Argus — Whole-System Inventory & Consolidation Design¶
- Date: 2026-07-07
- Author: design lead synthesis (for Aaron)
- Status: proposal, drives real work
- Scope: the Argus hub (
internal/hub,cmd/hub) HTTP surface + config stores, and the Argus SwiftUI app (argus-app) screens + config scatter. This is the authoritative inventory and the consolidation plan. - Trigger: wanting a single unified agent-config surface (one place to pick model + backend + dispatch + notify per role/project) instead of separate model and backend pickers spread across screens. Formalized as ARGUS-409 (unified agent config), which this doc scopes as the first concrete slice.
1. Executive summary¶
Argus works, but the control surface has fragmented faster than the system. The same three operational concepts — how a role/project runs (model + backend), when/whether a project builds (dispatch cadence), and when to notify — are each configured in 4–6 disconnected stores (per-run columns, DB config tables, SKILL.md frontmatter, config.ini, and a scatter of env vars), and no single endpoint reports the effective value after precedence resolution. In the app the same scatter reappears as UI: model config is split across ConfigView and ProjectDetailView, owner options are triplicated in code across three sheets, notify/owner/dispatch each span 2–3 screens, and there are two separate "settings" homes (SettingsView for connection, ConfigView for global models) plus a de-facto third (ProjectDetailView carries the densest config in the app yet is reached only by drilling Projects → a project). The result violates Argus's founding promise — you can always tell whether it's working — because you often can't even tell where a thing is configured or what value is actually in effect.
Headline consolidation moves:
1. One "Agent Config" surface owning model + backend + dispatch + notify, resolved per (role, project) with the effective value shown — kills the Config/Projects model split and the invisible per-run tier. (This is ARGUS-409.)
2. One "Project" hub as the single home for everything project-scoped (repos, deploy hook, dispatch, notify default, per-project overrides, skill bindings) — promote it out of Projects-drilldown into a first-class surface.
3. One effective-config read endpoint per concept (GET /effective-config?project=) so the backend resolves precedence and the app renders truth, never re-implements resolution.
4. Collapse the two settings homes (Settings + Config) into one Settings surface with connection, tokens, and global agent defaults under one roof.
5. Retire the duplicate write paths — the legacy env-pinned board watcher, ARGUS_VERIFY_REPOS, the /ui/* HTML write mirrors, and unsettable-via-API fields like deploy_repos — so each concept has exactly one authoritative writer.
Proposed target IA: collapse from today's ~10 app sections + 2.5 settings homes into 6 top-level surfaces — Command, Chat, Inbox, Work (Tasks+Activity+Landing+Flow), Memory, and Settings — with Project and Agent Config as two focused config hubs, and a single backend precedence resolver feeding effective-config reads.
2. Endpoint inventory (hub API, by domain)¶
Auth tiers (by chi group in internal/hub/server.go):
- public — no middleware; LAN/Tailscale ingress is the only gate.
- capture-bearer — requireBearer(reportToken); the shared token agents hold. Empty token disables (dev).
- approve-token — requireBearerStrict(approveToken); fails closed (503) if unset; human-only, agents structurally cannot call.
Envelopes: success via writeJSON, errors {"error":"…"}. Task writes are strict-decoded (unknown fields, notably verified, 400).
2.1 Tasks / board¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /tasks.json |
Whole board tree (?project=) |
public |
| GET | /tasks/{id}.json |
Full item + evidence + events + links | public |
| GET | /projects/{slug}/next-task |
Single ready task for runner (?owner=) |
public (doc says bearer — discrepancy) |
| POST | /tasks |
File a task (verify=approval seeds approval + push) |
capture-bearer |
| POST | /tasks/{id}/evidence |
Attach evidence row (kind=approval pushes) |
capture-bearer |
| POST | /tasks/{id}/lifecycle |
Set lifecycle (backlog/ready/in_progress/blocked/cancelled) | capture-bearer |
| PATCH | /tasks/{id} |
Re-title/re-parent/re-own (no path to verified) |
capture-bearer |
| POST | /tasks/{id}/links, DELETE /tasks/{id}/links/{to}/{type} |
Typed links | capture-bearer |
| POST | /tasks/links/backfill |
Re-derive marker links corpus-wide | capture-bearer |
| POST | /tasks/{id}/tags, DELETE /tasks/{id}/tags/{tag} |
Tags | capture-bearer |
| POST | /tasks/verify |
Run deterministic verifier sweep | capture-bearer |
| GET | /ui/tasks, /ui/tasks/graph, /ui/tasks/graph.json, /ui/tasks/{id} |
HTML board/graph/detail | public |
| POST | /ui/tasks/{id}/approve · /edit · /links · /links/remove · /tags · /tags/{tag}/remove |
HTML write mirrors of the JSON endpoints | public |
2.2 Runs / activity¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /runs |
Two-phase run report ingest (fires fail/resolved alerts) | capture-bearer |
| GET | /runs.json |
Recent ledger (?recent/?limit≤1000, ?routine), incl. cost+token usage |
public |
| GET | /runs/{id}.json |
One run + log tail (200 lines) | public |
| GET | /noshows.json |
Watchdog signal + liveness | public |
| GET | /digest.json |
Rollup digest (?hours≤720) |
public |
| GET | /scheduled.json |
Scheduled job registry + next fire | public |
| GET | /jobs.json |
Job registry joined to last run | public |
| GET | /artifacts.json, /artifacts/{id} |
Agent artifact list + download | public |
| GET | /metrics |
Prometheus exporter | public |
| GET | /ui/runs, /ui/runs/table, /ui/jobs |
HTML runs/jobs pages | public |
| POST | /jobs/{name}/run-now |
Run a scheduled job now | capture-bearer |
| POST | /rtk/gain |
Ingest rtk savings per host (X-Host) |
capture-bearer |
2.3 Agents / sessions (agent-run queue + presence)¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /runs/agent |
Queue an agent run ({chat_id,skill,prompt,project,output_kind,model,backend}) |
capture-bearer |
| POST | /runs/agent/claim |
Runner claims oldest queued (respects ClaudeCapped) |
capture-bearer |
| POST | /runs/agent/{id}/complete |
Terminal result; archive/route/push/codex-fallback | capture-bearer |
| POST | /runs/agent/{id}/progress · /session · /release |
Heartbeat / session id / requeue | capture-bearer |
| GET | /runs/agent/forced |
Count queued forced runs | capture-bearer |
| POST | /agents/heartbeat |
Upsert agent presence | capture-bearer |
| GET | /agents.json |
Live agent inventory (?project=) |
capture-bearer |
| GET | /ui/agent-runs, /ui/agent-runs/table |
HTML agent-runs | public |
| POST | /ui/agent-runs/{id}/force, /ui/agent-runs/{id}/model |
Force / set-model (UI) | public |
2.4 Landing¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /landing/{owner}/{name}/ready |
Enqueue a PR to land | capture-bearer |
| GET | /landing/{owner}/{name}/status |
Per-repo landing state | capture-bearer |
| DELETE | /landing/{owner}/{name}/ready/{pr} |
Withdraw a queued entry | capture-bearer |
| GET | /landing/stats.json |
Landing counters | capture-bearer |
2.5 Deploys¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /deploys/claim |
Host runner claims queued deploy | capture-bearer |
| POST | /deploys/{id}/complete |
Record outcome; satisfy held evidence + verify | capture-bearer |
| GET | /deploys.json |
Recent deploys | capture-bearer |
| POST | /projects/{slug}/redeploy |
Enqueue a project's deploy hook | capture-bearer |
2.6 Chat¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /chat/send |
Enqueue one chat turn | capture-bearer |
| GET | /chat/turns |
Poll/history of app-channel turns | capture-bearer |
| GET | /chat/actions.json |
Pending Tier-2 proposals | capture-bearer |
| POST | /chat/actions/{id}/confirm · /cancel |
Execute / discard a staged proposal | approve-token |
| POST | /plans/propose |
Stage a whole task tree as one create_tree proposal | capture-bearer |
2.7 Inbox / approvals¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /inbox.json |
Merged attention feed + badge | capture-bearer |
| POST | /inbox/{id}/resolve |
Resolve review/lint/parked/flag item | capture-bearer |
| POST | /approvals/{id} |
Human Approve/Reject of a task's pending approval (id=task id) | approve-token |
2.8 Command / flow¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /command.json |
Fleet-wide live rollup (server-composed) | capture-bearer |
| GET | /projects/{slug}/flow.json |
Per-project pipeline view | capture-bearer |
2.9 Memory (hub is a read-only client of the memory service)¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /memory/overview.json |
Corpus stats + recent notes | public |
| GET | /memory/graph.json |
Notes graph relay (?note&hops&cap&project) |
public |
| GET | /ui/episodes[/{id}], /ui/notes[/list], /ui/injections, /ui/feed[/cards], /ui/review, /ui/parked, /ui/lint, /ui/review-hub, /ui/artifacts[/{id}] |
HTML memory surfaces | public |
| POST | /ui/review/{id}/approve\|reject, /ui/review/grades/{id}/agree\|disagree, /ui/review/notes/{id}/{verdict}, /ui/parked/{kind}/{id}/retry, /ui/lint/{id}/{status} |
Review/grade/spot-check/retry/lint dispositions | public |
2.10 Models / config¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /models.json |
Allowlist + roles + effective config (excludes env fallbacks) | public |
| PATCH | /models |
Set a role's model ({role,project,model}, global or per-project) |
capture-bearer |
| GET | /config.json |
Rendered tasks config + linter states | public |
2.11 Skills¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /skills.json |
Skill registry catalog (?project=) |
public |
| GET | /ci/pr.json |
Merge+CI snapshot (gated tool) | public |
| PATCH | /skills/{name} |
Bind skill project + notify policy (pins) | capture-bearer |
| POST | /skills/{name}/run |
Trigger a skill via agent-run queue | capture-bearer |
| GET | /ui/skills |
HTML skills catalog | public |
2.12 Projects (two distinct "project" surfaces)¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /projects.json |
Registry read (ETag/304) | public |
| POST | /projects |
Create project (kicks onboarding) | capture-bearer |
| PATCH | /projects/{slug} |
Patch registry (deploy_repos has NO write path) |
capture-bearer |
| POST | /projects/{slug}/dispatch |
Force-dispatch builder ({backend}; ignores cap signal) |
capture-bearer |
| POST | /pulls/open |
Open a PR from a pushed branch | capture-bearer |
| GET | /ui/projects, /ui/projects/{slug}/edit |
Memory-service projects (different data, similar name) | public |
| POST | /ui/projects/{slug}/edit |
Upsert memory project (NOT registry) | public |
2.13 Devices / OTA / misc¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /devices, GET /devices.json, POST /devices/live-activity |
APNs token + Live Activity registration | capture-bearer |
| POST | /app/upload, GET /app/latest.json |
OTA build upload + newest build | capture-bearer |
| GET | /app, /app/manifest.plist, /app/argus.ipa |
Safari install page (capability-token ?k=) |
public (?k=) |
| GET | /healthz, /, /manifest.webmanifest, /sw.js, /static/* |
Health, root redirect, PWA assets | public |
| POST | /alert |
Relay external alert → Telegram + APNs (?source=) |
capture-bearer |
| GET | /telegram/channels, POST /telegram/channels/{chat_id} |
Channel registry + routing | capture-bearer |
| GET | /ui/telegram, POST /ui/telegram/{chatID}/skills/{skill} · /role |
HTML telegram admin | public |
| POST | /ui/lint/skill/{name}/prune |
Prune orphan skill | public |
| ANY | /mcp |
Coding-agent argus toolset (JSON-RPC over HTTP) | capture-bearer |
Structural notes for consolidation:
- Auth split across the same noun. /projects/{slug}/next-task (public) vs /projects/{slug}/dispatch and /flow.json (bearer); /projects.json (public) vs /flow.json (bearer). Same resource, different gate.
- Name collision. /projects.json (orchestration registry, Postgres) vs /ui/projects (memory-service projects) — different data behind similar names.
- Duplicate write paths. Every /ui/* POST is a public, browser-shaped mirror of a bearer JSON endpoint (UI task edit vs PATCH /tasks/{id}; UI review vs /inbox/{id}/resolve; UI agent-run model vs /runs/agent model). Prime consolidation target.
- Effective-value blindness. /models.json omits the env fallbacks that actually resolve, so no endpoint reports the effective model.
3. App surface map¶
Shell: Argus/ArgusApp.swift. iPhone = 5-tab TabView (phoneShell); iPad/macOS = NavigationSplitView over 9 MacSections (splitShell). iPhone buries Activity/Memory/Skills/Projects/Config under a More menu; iPad/Mac promote them to first-class sidebar sections. A gear (Settings) sits on every top bar. An UPDATE pill (OTA) appears on iPhone+iPad, never macOS.
| Surface | Reached via | Data / endpoints | Actions | Config exposed |
|---|---|---|---|---|
| CommandCenterView | Command tab / command |
GET /command.json (+/projects.json for repo→slug), 15s poll |
Approve/Reject; re-run failed; run-now cron/no-show; redeploy; re-signal/withdraw landing; dispatch-now; set-blocked; row→task | none (pure action surface) |
| ChatView | Chat tab / chat |
GET /chat/turns, chat proposals; POST /chat/send |
Send; Confirm/Cancel Tier-2 proposal (approve-token); Edit proposal→TaskComposeSheet; plan-node Edit/Remove | none |
| InboxView | Inbox tab / inbox (badged) |
GET /inbox.json |
Approve/Reject (approve-token); resolve review/triage; row→InboxDetailView | none |
| InboxDetailView | Inbox row | approval→TaskDetailSheet; else title/subtitle + memlint notes | same resolves | none |
| TasksView | Tasks tab / tasks |
GET /tasks.json?project=; BOARD/BACKLOG/GRAPH |
NEW→TaskComposeSheet; lifecycle move; search; owner filter; show-done toggle; row→TaskDetailSheet | owner filter, show-done (view state) |
| TaskGraphView | Tasks→GRAPH (split-only) | board items + edges | scope-to-epic; zoom; node→task | epic scope, zoom (view state) |
| TaskDetailSheet | task tap | GET /tasks/{id}.json |
EDIT→PATCH /tasks/{id} (title/why/owner/parent) |
per-task owner picker, parent menu |
| TaskComposeSheet | Tasks NEW; Chat proposal EDIT | POST /tasks |
file: title/type/parent/why/acceptance/owner/verify | per-task owner, verify contract (approval vs pr) |
| ActivityView | More→Activity / activity |
RUNS /runs.json, SCHEDULED /scheduled.json, ARTIFACTS /artifacts.json, LANDING→LandingView |
filter runs (hide-routine); filter artifacts; run→RunDetailSheet; artifact→ArtifactSheet | hide-routine, searches, artifact project filter (view state) |
| RunDetailSheet | run tap | GET /runs/{id}.json |
read-only | none |
| ArtifactSheet | artifact tap | GET /artifacts/{id} |
read-only | none |
| LandingView | Activity→LANDING | /projects.json + per-repo /landing/{repo}/status + /landing/stats.json, 15s |
repo→FlowView; entry→task | none |
| FlowView | Landing repo | GET /projects/{slug}/flow.json, 5s |
node→task | none |
| MemoryView | More→Memory / memory |
memory overview; GRAPH→NotesGraphView | segment toggle | none |
| NotesGraphView | Memory→GRAPH (split-only) | GET /memory/graph.json |
scope; linked-only; zoom; node→reseed | scope, linked-only, zoom (view state) |
| SkillsView | More→Skills / skills |
GET /skills.json |
RUN→RunSkillSheet | none (skill config lives on ProjectDetailView) |
| RunSkillSheet | Skills RUN | POST /skills/{name}/run |
run with optional prompt | prompt only — model/project/output supported by client but not exposed |
| ProjectsView | More→Projects / projects |
GET /projects.json (ETag/304) |
+ADD→AddProjectSheet; row→ProjectDetailView | none |
| AddProjectSheet | Projects +ADD | POST /projects |
create: slug + description | none |
| ProjectDetailView | project tap | /projects.json, /models.json, /skills.json?project=, /flow.json; PATCHes /projects/{slug}, /skills/{name}, /models |
edit desc; attach repo; dispatch-now; bind/unbind skill; open onboarding PRs | THE config hub: default runner/owner, deploy hook, notify default, dispatch mode, per-project model overrides, per-skill notify override, skill bindings |
| ConfigView | More→Config / config |
GET /config.json + /models.json |
set global model per role (PATCH /models) |
global model defaults per role; read-only jobs/lint/raw tasks config |
| SettingsView | gear (Cmd+,) | Keychain only | save | hub URL, bearer token, approve token; app version |
| PlanNodeEditSheet | Chat plan node | staged plan | edit node title/owner/type/ready | per-node owner/type/ready (proposal-local) |
| Text zoom | macOS menu (Cmd +/−/0) | TypeScale, persisted |
larger/smaller/actual | app-wide text scale — macOS only |
4. CONFIG FRAGMENTATION MATRIX (the centerpiece)¶
Each row is one configurable concept. Columns: everywhere it lives in the hub today, where it surfaces in the app, and a severity note. This is the scatter, made undeniable.
| # | Concept | Hub stores / endpoints | App screen(s) | Fragmentation / severity |
|---|---|---|---|---|
| 1 | Which model a run uses | agent_runs.model (per-run) · model_config table (project + global) · hub-side env fallback ARGUS_BOARD_WATCH_MODEL · ARGUS_CHAT_MODEL (chat worker). (ARGUS_RUNNER_MODEL is runner-side, in cmd/agent-runner/main.go, not the hub — don't attribute it to resolveModel.) Read: GET /models.json (omits env fallbacks). Precedence resolveModel: per-run > per-project > global > env |
ConfigView (global) + ProjectDetailView (per-project). Per-run tier not surfaced anywhere | CRITICAL. 5 sources, 4-level precedence, no endpoint reports the effective value. Directly the ARGUS-409 trigger. |
| 2 | Which backend (claude/codex) | agent_runs.backend (per-run only; no durable per-project config) · Dispatch.hubDispatchBackend (cap signal) · POST /projects/{slug}/dispatch body (ignores cap) · RequeueCodexFallbackOnce on complete · runner Claim(ClaudeCapped) |
Nowhere as config; only implicit via dispatch-now backend arg (not exposed in UI picker) | CRITICAL. Cap-aware decision lives in 3 places that can disagree; builderDispatchModel couples backend↔model (codex ⇒ empty model), silently bypassing the model store. No UI. |
| 3 | When/whether a project builds (dispatch cadence) | project_registry.dispatch (off/night/always) · legacy env board watcher ARGUS_BOARD_WATCH_PROJECT/_WINDOW/_INTERVAL/_MODEL · hardcoded night window 23:00-07:00 · ARGUS_RUNNER_NIGHT_WINDOW (a second night window) |
ProjectDetailView "AUTONOMOUS DISPATCH" (mode); dispatch-now trigger on both ProjectDetailView and CommandCenter | HIGH. Registry vs env board watcher do the same job for one project; main.go warns you must keep them from overlapping (unenforced foot-gun). Two night windows default to the same string in two places. |
| 4 | When to notify (success) | skills.notify (frontmatter OR app-pinned) · project_registry.notify_default (appears twice in precedence) · env ARGUS_SELF_ALERTS · APNs-vs-Telegram wiring. Precedence: skill frontmatter > run-project default > skill-bound-project default > "all" |
ProjectDetailView "SUCCESS NOTIFICATIONS" (project) + per-skill "NOTIFY" row. Skills tab exposes no notify control | HIGH. 4 sources across 2 tables + frontmatter, project noun appears twice; no endpoint shows effective (skill,project) policy. |
| 5 | What/where a project deploys | project_registry.deploy_hook + deploy_repos[] (no API write path) · runner script ~/.argus/deploys/<hook> · Komodo/compose target the script invokes |
ProjectDetailView "DEPLOY HOOK" (hook only); Redeploy action from CommandCenter | HIGH. 4 layers, only 2 in DB, deploy_repos settable only by direct DB/migration. |
| 6 | Owner / runner | project_registry.default_owner (barely consumed — dispatch routes on per-task owner, not this) · per-task items.owner column |
ProjectDetailView "DEFAULT RUNNER" + TaskComposeSheet OWNER + TaskDetailSheet OWNER. Options ["aaron","mac-agent","hub-agent"] hardcoded 3× independently |
MEDIUM. Registry default set but barely used; per-task owner is the real router; options triplicated in Swift. |
| 7 | "project" identity | registry slug · task_items.project · skills.project binding · telegram_channels.project routing tag |
Threaded through Projects, Tasks, Skills(via project), Telegram admin | MEDIUM. Same noun configured/tagged in 4 stores; /projects.json (registry) vs /ui/projects (memory projects) name collision compounds it. |
| 8 | Repo scan list (verify) | project_registry.repos union (AllRepos) · env ARGUS_VERIFY_REPOS (default runonyourown/argus). verifyRepoProvider prefers registry, falls back to env |
Not surfaced | MEDIUM. Two sources; code comment says env should retire but both stay wired. |
| 9 | Auth tokens | env ARGUS_APPROVE_TOKEN (human, fail-closed) · cfg.CaptureToken (shared agent bearer) · OTA per-upload capability ?k= |
SettingsView sets hub URL + bearer + approve token; approve token consumed by Command, Inbox, Chat (3 surfaces) | MEDIUM. Set in one place (good), but 3 code paths consume it and there's no in-app validation/health check. |
| 10 | Watchdog expectations / schedules | tasks/config.ini (ARGUS_TASKS_CONFIG) parsed twice (ParseTasksConfig strict, ParseJobs lenient); grace constants hardcoded |
ConfigView shows raw tasks config read-only | MEDIUM. Coupled to Ofelia only by convention (same file); UnknownReporters exists precisely because they diverge. |
| 11 | Staleness clocks | landing 45m/10m/2m/3-attempt + 30s tick · deploy reaper 30m · agent-run reaper 5m · watchdog grace 5m · retention 7d — all independent magic constants | Not surfaced | LOW-MEDIUM. Four+ independent clocks, none aligned or configurable. |
| 12 | Skill execution eligibility | skills.executable + exec_tier (0/1/2) + orphan + telegram_enabled, each independently settable; source-of-truth bodies in Forgejo claude-code-skills |
ProjectDetailView skill bindings; RunSkillSheet gates on it implicitly | LOW-MEDIUM. Eligibility spread across 4 flags + 2 write sources (frontmatter vs app-pin). |
| 13 | Telegram channel routing | telegram_channels (status/mode/project/label/role) + channel_skills palette; global skills.telegram_enabled overlaps |
Telegram admin (/ui/telegram, web only — no native app screen) |
LOW. Fourth place "project" is configured; per-channel palette overlaps global telegram flag; no native surface. |
| 14 | Chat worker model / MCP | env ARGUS_CHAT_MODEL + ARGUS_CHAT_MCP_CONFIG/_BIN · also role chat in model_config |
Via ConfigView global role=chat | LOW. Same double-source as row 1, chat-specific. |
| 15 | Runner budget / usage-cap gate | env block ARGUS_RUNNER_SESSION_PCT/_NIGHT_SESSION_PCT/_WEEKLY_PCT/_NIGHT_WINDOW/_BUDGET_RETRY — one UsageGate reused for admission timing AND backend routing (row 2) |
Not surfaced | LOW. One env block, two subsystems; invisible to the app. |
| 16 | Text scale | app-local TypeScale |
macOS menu only; no iOS/iPadOS control | LOW. Platform-inconsistent; iOS relies on Dynamic Type (which macOS ignores — separate known issue). |
| 17 | Digest cadence (when to notify — scheduled) | env ARGUS_DIGEST_SCHEDULE (when the rollup digest fires) · GET /digest.json renders the payload on demand · Ofelia/scheduler triggers the send |
Not surfaced (digest arrives; cadence unconfigurable in-app) | LOW-MEDIUM. A "when to notify" sibling to row 4 but on a schedule axis; lives only in env, invisible to the app, and not reconciled with the notify gates in row 4. |
| 18 | Subsystem enable toggles (Telegram) | env ARGUS_TELEGRAM_COMMANDS (accept slash-commands) · env ARGUS_TELEGRAM_INGEST (accept inbound capture) — each independently enables a Telegram subsystem |
Not surfaced (row 13 exposes routing, not the master enables) | LOW-MEDIUM. Two boolean master-switches gating whole subsystems, set only via env; overlaps row 13's per-channel telegram_enabled with no single view of "is Telegram commands/ingest even on." |
| 19 | Memory recall threshold | env ARGUS_RECALL_FLOOR (min score for a note to be injected at session start; cmd/recall-hook) — the only knob tuning what memory actually surfaces |
Not surfaced (MemoryView is read-only; recall floor invisible) | MEDIUM. Memory is a top-level surface yet its single most behavior-shaping config (what gets recalled) lives only in env and appears nowhere in the app — the matrix's first memory-config row, and a blind spot for "can you tell whether it's working." |
The through-line: the same three concepts — how a role/project runs (rows 1+2), when a project builds (row 3), when to notify (row 4) — are each configured in 4–6 disconnected stores, and not one of them has a single endpoint that reports the effective resolved value. That is the fragmentation, precisely.
5. Workflow map (and where each surfaces in the app)¶
5.1 Task lifecycle: file → ready → dispatch → build → PR → land → deploy → verify¶
- File —
POST /tasks(or MCPcreate_task).lifecycle=backlog,verified=false. App: TaskComposeSheet. - Ready —
POST /tasks/{id}/lifecycle.PromoteReadyDependentsauto-promotes backlog dependents whose deps are all verified (ARGUS-393). App: TasksView swipe/context-menu. - Dispatch —
Dispatch.Run(10m ticker) →pollOnce: per armed project, counts readyhub-agenttasks, live-session defer (30m), one-in-flight guard, enqueues overnight-build (backend=hubDispatchBackend,model=builderDispatchModel).mac-agentroutes viarouteMac. Manual:POST /projects/{slug}/dispatch. App: dispatch-now on CommandCenter + ProjectDetailView; cadence on ProjectDetailView. Effective backend/model choice is invisible in the app. - Build — runner
POST /runs/agent/claim(FOR UPDATE SKIP LOCKED, forced-first, respects cap), heartbeats, records session;ReapStaleat 5m. App: ActivityView RUNS, CommandCenter agents, FlowView live agents. - PR — runner opens
Closes ARGUS-N;POST /runs/agent/{id}/completearchives artifact, pushes (policy-gated), codex-fallback on cap, escalates on streak. App: push notification; ActivityView/RunDetailSheet. - Land —
POST /landing/{owner}/{name}/ready;LandingQueueserializes per repo: queued→rebasing→ci→merging (verifiesmerged=true, never trusts response)→landed; post-merge CI net. App: LandingView + CommandCenter landing section. - Deploy —
onLanded:AttachDeployEvidence(unsatisfieddeploy_logholds the task) +Deploys.Enqueue; runnerclaim→runs~/.argus/deploys/<hook>→complete; success satisfies evidence + immediate verify. App: Redeploy on CommandCenter; deploy hook config on ProjectDetailView. No app view of deploy queue/outcome beyond command rollup. - Verify —
AutoLinkPRs+VerifySweep/verifyOne: verified iff ≥1 evidence, all satisfied (AND), ≥1 item-bound anchor (pr/approval/deploy_log/artifact).verifiedset only here or by human approval. App: TaskDetailSheet shows verified + evidence.
5.2 Capture → approve (two mechanisms sharing the word)¶
- (a) Task approval (human verify gate) — pending
approvalevidence →PushApproval(APNs_category:APPROVAL, inline Approve/Reject) → surfaced in/command.jsoninbox and/inbox.json→ humanPOST /approvals/{id}(approve-token, fail-closed) →items.Approvesatisfies evidence → verify. App: CommandCenter, InboxView, InboxDetailView (3 surfaces, one token). - (b) Chat tier-2 confirmation — chat agent proposes →
ChatActions.Stage(pending_actions, expiry) → inline[Confirm]/[Cancel]→Claim(id,chatID)atomic exactly-once re-auth (approve-token) → deterministic mutation. App: ChatView.
5.3 Notify / alert pipeline (four triggers, three gates, one shared transport)¶
Four triggers originate a notification: watchdog self-alerts ([noshow]/[fail]/[resolved], gated ARGUS_SELF_ALERTS) · agent-run completion push (NotifyPolicy; failure always loud; streak escalation) · external /alert relay (APNs + Telegram, never silent) · scheduled digest. All four converge on one shared transport — APNs fan-out (per-device, drops ErrDeviceGone, ledgers every push:*), which is delivery plumbing, not a trigger. A single failed overnight run can fire completion push + watchdog [fail] + streak escalation + Grafana→/alert relay — four triggers down one transport. App: notifications arrive; no app surface configures the notify gates except project/skill notify on ProjectDetailView.
5.4 Memory distill / recall (separate service; hub read-only)¶
Capture (cmd/capture-hook, Telegram ingest, Karakeep) → distill inside memory service → recall injection (cmd/recall-hook) at session start. Hub observes via MemEpisode.DistilledAt/NoteCount, parked units, grades. App: MemoryView, NotesGraphView (read); review/lint/parked dispositions are web-only (/ui/review, /ui/lint, /ui/parked) — no native app surface.
Surfacing gaps worth naming: deploy queue/outcomes (only in command rollup), the effective model/backend for a dispatch, memory review/lint/parked (web-only), telegram admin (web-only), per-run model override (client supports, UI never exposes).
6. CONSOLIDATION RECOMMENDATIONS¶
Target information architecture¶
Collapse today's ~10 app sections + 2.5 settings homes into 6 top-level surfaces, with 2 focused config hubs and 1 backend resolver:
Command — fleet rollup + fast actions (unchanged home)
Chat — conversation + tier-2 confirms (unchanged)
Inbox — approvals + review + triage (unchanged; absorbs memory review/lint)
Work — Tasks + Activity + Landing + Flow + Skills(catalog/run) under one section w/ segments
Memory — overview + graph + (NEW) native review/parked
Settings — connection + tokens + GLOBAL agent defaults (absorbs ConfigView)
┌─ Project hub — the single home for everything project-scoped
└─ Agent Config — model + backend + dispatch + notify, per (role, project),
showing the EFFECTIVE resolved value ← ARGUS-409
Where Skills land. Two distinct skill concerns split by nature, not merged: - Skill bindings (per-project bind/unbind, per-skill notify policy, exec eligibility) → the Project hub, where they are project-scoped config alongside repos/deploy/dispatch. - Skill catalog / browse (SkillsView) and run-a-skill (RunSkillSheet) → a Skills segment under Work. Running a skill enqueues an agent-run that already lands in Activity/Runs, so the catalog and the run action belong beside the work they produce, not in a settings-shaped hub. This keeps the catalog one tap from its output and avoids a standalone top-level section for what is fundamentally an action surface. (RunSkillSheet also gains the model/project/output controls the client already supports — see R1.)
Two backend changes underpin it:
- One effective-config resolver. GET /effective-config?project=&role= returns the resolved model, backend, dispatch cadence, and notify policy with provenance (which source won). The app renders truth; it never re-implements resolveModel/NotifyPolicy/resolveBackend precedence.
- One authoritative writer per concept. Retire the duplicate/legacy writers so every concept has exactly one write path (details below).
Recommendations, prioritized¶
R1 — Unified Agent Config surface (ARGUS-409) — the first slice¶
What moves where: merge the model picker from ConfigView + ProjectDetailView, the (currently UI-less) backend selection, dispatch cadence, and notify default into one "Agent Config" screen with a scope switch (Global ▸ Project ▸ per-Role). Each field shows the effective value + its source (global/project/env/per-run) via a new GET /effective-config. Add the missing backend control (claude/codex/auto) and expose the per-run model tier on RunSkillSheet.
Why: rows 1, 2, 3, 4 of the matrix are the owner's stated pain; today none reports the effective value.
Backend: add GET /effective-config (resolver over resolveModel + backend + dispatchArmed + NotifyPolicy, returning provenance); add a durable per-project backend column so backend has a real config home (row 2) instead of per-run-only.
Pinned response schema. For a (project, role) query the resolver returns each of the four concepts as a {value, source} pair, where source is drawn from a fixed provenance enum:
provenance = "global" | "project" | "per-run" | "env" | "backend-coupled" | "frontmatter"
// GET /effective-config?project=argus&role=builder
{
"project": "argus",
"role": "builder",
"model": { "value": "claude-opus-4-8", "source": "project" },
"backend": { "value": "codex", "source": "project" },
"dispatch": { "value": "night", "source": "project" },
"notify": { "value": "all", "source": "frontmatter" }
}
Each of model/backend/dispatch/notify is exactly {value, source}; source MUST be one of the enum members. backend-coupled is the source reported for model when backend=codex forces an empty/ignored model (row 2's builderDispatchModel coupling), so the app can render why the model store was bypassed rather than showing a blank. Provenance depth (winner-only vs. full candidate chain behind ?explain=1) is Open Question #5.
Blast radius: ConfigView + ProjectDetailView MODELS/NOTIFY/DISPATCH sections; /models.json consumers; new endpoint. Read-mostly; low risk if the new endpoint is additive and old ones stay until cutover.
Effort: M (endpoint + one new screen + wiring). This is the vertical slice to build first.
R2 — Promote "Project" to a first-class hub¶
What moves where: ProjectDetailView (today the densest config in the app, buried under Projects→drilldown) becomes a top-level Project surface. It owns repos, deploy hook + deploy_repos (add the missing RegistryPatch field — row 5), dispatch cadence link (into Agent Config scoped to that project), skill bindings, onboarding PRs.
Why: it already carries 7 distinct controls; burying it contradicts its role. deploy_repos currently needs a DB migration to set — unacceptable.
Backend: add deploy_repos to RegistryPatch; keep registry as the single project writer; resolve the /projects.json (registry) vs /ui/projects (memory) name collision by renaming the memory one (e.g. /ui/memory-projects).
Blast radius: ProjectDetailView, ProjectsView nav, one endpoint field, one route rename. Medium.
Effort: M.
Evidence gate: PATCH /projects/{slug} with a deploy_repos body persists and reads back via GET /projects.json (no DB migration needed); grep finds zero /ui/projects route references to the registry collision (memory projects served only under the renamed /ui/memory-projects); Project is reachable as a top-level surface (not only via Projects→drilldown) in the app nav.
R3 — Collapse the two settings homes¶
What moves where: fold ConfigView (global models + read-only jobs/lint/raw tasks config) into SettingsView. Settings then owns connection (hub URL), tokens (bearer + approve, with a live validation ping against /healthz + a bearer-scoped probe), global agent defaults (link to Agent Config Global scope), and read-only system config (jobs/lint/tasks) under a "System" disclosure.
Why: two settings homes with no cross-link is the clearest UX smell; the approve token is consumed by 3 surfaces but only set here — make that legible.
Blast radius: SettingsView, ConfigView (removed as a section), nav. Low-medium.
Effort: S-M. Quick-ish win.
Evidence gate: ConfigView no longer exists as a routable section (grep shows zero nav registrations for config/ConfigView); its former contents (global models, read-only jobs/lint/tasks) all reachable from within Settings; the token fields show a live validation result from a /healthz ping + a bearer-scoped probe.
R4 — Single owner picker component + registry cleanup¶
What moves where: replace the 3 hardcoded ["aaron","mac-agent","hub-agent"] arrays (ProjectDetailView, TaskComposeSheet, TaskDetailSheet) with one shared OwnerPicker fed by a small GET /owners.json (or a static shared constant). Decide default_owner's fate (row 6): either wire it into dispatch routing or drop it from the UI as dead config.
Why: triplicated options drift; default_owner is set-but-barely-consumed.
Blast radius: 3 Swift sheets, maybe one endpoint. Low.
Effort: S. Quick win.
Evidence gate: grep shows zero remaining ["aaron","mac-agent","hub-agent"] literals in the app; all three sheets (ProjectDetailView, TaskComposeSheet, TaskDetailSheet) consume one shared OwnerPicker component fed from one source; default_owner's fate is decided in code (either it seeds a new task's owner on POST /tasks and a test proves it, or the field is removed from the UI).
R5 — Retire duplicate/legacy write paths (backend hygiene)¶
Why: each concept should have exactly one authoritative writer; these four are the ones with two. They are heterogeneous — different blast radii and different risk — so they are split into independently shippable sub-tasks rather than one bundle. Do after R1–R3 prove the pattern.
R5(a) — Delete the legacy env board watcher (ARGUS_BOARD_WATCH_*, board_watch.go) now that project_registry.dispatch covers it — kills the unenforced overlap foot-gun (row 3).
- Blast radius: live dispatch behavior — board_watch.go, its wiring in main.go, the overlap warning. Highest risk of the four (touches what actually enqueues builds). Needs the smoke test + an overnight dry-run.
- Effort: M.
- Evidence gate: board_watch.go and every ARGUS_BOARD_WATCH_* reference are gone (grep clean); a project set to dispatch=night still enqueues its overnight build via Dispatch.Run (verified by a dispatch round-trip), and no second dispatch path exists.
R5(b) — Retire ARGUS_VERIFY_REPOS, standardize on registry AllRepos (row 8).
- Blast radius: verifyRepoProvider and its env fallback only; read-path, low risk.
- Effort: S.
- Evidence gate: grep shows zero ARGUS_VERIFY_REPOS references; the verify sweep's repo set is sourced solely from registry AllRepos and a test confirms the same corpus as before for the default project.
R5(c) — Unify the two 23:00-07:00 night windows (row 3): the hardcoded dispatch night window and ARGUS_RUNNER_NIGHT_WINDOW (usage gate) collapse to one config value.
- Blast radius: dispatch cadence + usage-gate admission both read the unified value; medium — two subsystems now share one source.
- Effort: S-M.
- Evidence gate: grep shows exactly one definition of the night-window string (no hardcoded 23:00-07:00 literal remaining alongside the env); both dispatch and the usage gate resolve their window from that single value (a test changing it moves both).
R5(d) — Decide the fate of the public /ui/* write mirrors — docs-only decision, no code change: they are duplicate write paths to bearer JSON endpoints. This overlaps Open Question #3; reconcile by keeping-and-documenting — per OQ#3's recommendation the /ui/* writes stay as the one sanctioned PWA browser path, and this sub-task's deliverable is the written statement (in this doc / an ADR) that they are intentional and that no new /ui/* write mirrors may be added.
- Blast radius: documentation only; zero runtime change.
- Effort: S.
- Evidence gate: a committed doc/ADR paragraph records the keep-and-document decision and the "no new mirrors" rule; Open Question #3 is marked resolved pointing at it.
R6 — Native surfaces for the web-only gaps¶
What moves where: add native app screens (or promote into Inbox/Memory) for memory review/lint/parked (today only /ui/review, /ui/lint, /ui/parked) and Telegram admin (today only /ui/telegram). These are real operations with no native reach.
Why: "you can always tell whether it's working" fails when a whole class of dispositions is web-only.
Blast radius: new app views + reuse existing JSON where it exists (some /ui/* are HTML-only today — may need JSON endpoints). Medium-large.
Effort: L. Later milestone.
Evidence gate: memory review/lint/parked dispositions and Telegram admin routing are each performable from a native app screen (no browser) — a disposition submitted from the app round-trips through a JSON endpoint (any HTML-only /ui/* op used gets a JSON sibling); grep shows the app no longer depends on opening a /ui/* HTML page for these operations.
Quick wins vs larger refactors¶
- Quick wins: R4 (shared owner picker, S), R3 (merge settings homes, S-M).
- First real slice: R1 (ARGUS-409 unified Agent Config, M) — highest pain, additive endpoint, clear done-condition.
- Then: R2 (Project hub, M).
- Larger refactors (later milestones): R5 (retire legacy writers, M — behavior risk), R6 (native web-only surfaces, L).
ARGUS-409 as the concrete first slice¶
Vertical slice: "one Agent Config screen where I pick model + backend for a role/project and see the effective value and where it came from."
Evidence gate: GET /effective-config?project=X&role=builder returns a body matching R1's pinned schema — model/backend/dispatch/notify each present as {value, source} with source in the provenance enum (global|project|per-run|env|backend-coupled|frontmatter); the app screen renders it and a PATCH round-trips; setting a per-project model then reading effective returns model.source == "project"; setting backend=codex returns model.source == "backend-coupled" (model store correctly bypassed). One PR, green CI, smoke test.
Why first: it is the trigger, it is read-mostly (additive endpoint, old endpoints stay), and it forces the resolver (GET /effective-config) that R2–R5 all reuse. Build the resolver once; every later consolidation leans on it.
7. Open questions for Aaron¶
- Backend as durable config? Today
agent_runs.backendis per-run only; there is no per-project backend home. Add aproject_registry.backend(auto/claude/codex) column so R1 has something to write, or keep backend purely cap-signal-driven and only surface the effective choice read-only? (Recommendation: add the column; "auto" default preserves current behavior.) default_owner— wire or drop? It's set but dispatch routes on per-taskowner. Makedefault_owneractually seed new tasks' owner, or remove it from the UI as dead config? (Recommendation: seed new tasks, then it earns its place.)/ui/*public write mirrors — keep, gate, or drop? They're a full duplicate write surface (public, browser-shaped) of the bearer JSON API. Intentional PWA path, or attack surface to close? (Recommendation: keep for the PWA but document them as the one sanctioned duplicate; do not add new ones.) Reconciled with R5(d): this question is the decision R5(d) executes — keep-and-document, no new mirrors; R5(d) carries the docs-only deliverable and evidence gate.- Night window — one or two? Dispatch night window (hardcoded
23:00-07:00) vsARGUS_RUNNER_NIGHT_WINDOW(usage gate). Unify to one config value, and should it be per-project? (Recommendation: one hub-level config value, not per-project, unless you actually want different build windows per project.) - Effective-config provenance depth. Should
GET /effective-configexpose the full precedence chain (all candidate values + which won) or just the winner + its source? Full chain is more debuggable, more payload. (Recommendation: winner + source by default, full chain behind?explain=1.) - Six-surface IA — does "Work" (Tasks+Activity+Landing+Flow) merge feel right, or do you want Tasks and Activity to stay separate top-level sections? This is the one IA call that changes muscle memory most.
- Memory review/parked — native or leave web-only? R6 is real work; if you only ever triage memory from a laptop, it may not be worth native screens. Your call on whether the app must be complete or Command-focused.
- Text scale parity. Add an in-app text-scale control for iOS/iPadOS (macOS has one; iOS relies on Dynamic Type which macOS ignores), or accept the platform split?