diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0fd8b23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Source files are read as TEXT at runtime in places (the dashboard client +# collector concatenates ./client/*.mjs; admin-server inlines admin-view.mjs; +# Function.prototype.toString sources are interpolated into served bundles), so +# a CRLF checkout must never diverge from what CI's LF platforms exercise. +*.mjs text eol=lf +*.cjs text eol=lf +*.js text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14c258..0d96a39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,25 @@ jobs: - name: Dependency audit run: pnpm run audit + # Rendering verification: the route suites prove the server returns correct + # JSON; only this proves the page RENDERS it (no `undefined`/`NaN`/ + # `[object Object]` in visible text). Drives the SYSTEM Chrome — ubuntu + # runners ship it, nothing is downloaded. + ui: + name: ui (dashboard rendering, system Chrome) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install devDependencies + run: pnpm install --frozen-lockfile + - name: Dashboard UI (Playwright, fixture corpus) + run: pnpm run test:ui + # Internal/relative links only (fast, deterministic). External links run in # nightly — network + rate-limits make them flaky per-PR. links: diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 697176c..67e8b61 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -516,7 +516,9 @@ The current [OpenAI API model catalog](https://developers.openai.com/api/docs/mo GPT-5.4 and GPT-5.4 mini, and no first-party withdrawal notice supports the former automatic replacement claims. `ak` only adds a retirement rule when it can cite the host's direct notice; a newer default remains a recommendation, not a route rewrite (see -[ADR-0003](adr/0003-auto-seed-dual-host-provenance.md)). +[ADR-0003](adr/0003-auto-seed-dual-host-provenance.md)). Once a citation-backed rule exists, +`ak host pick`, `ak setup`, and `ak sync` all rewrite a seeded route naming the withdrawn model — +a user-pinned route is reported, never rewritten (still routed to the replacement at run time). `claude-opus-4-8` is **not** retired — it carries no deprecation notice and stays pinnable. It is merely no longer the default, which `ak status` reports as routing *divergence*: a trade for you to diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 48eef00..d761127 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -37,7 +37,7 @@ rewritten; rule 3 of the module header, `usage-index.mjs:22-29`): | Host | Store | Discovered by | |---|---|---| | Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:916-926`) — exactly one level of project directories | -| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:891-904`) — the `yyyy/mm/dd` tree walk | +| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:878-891`) — the `yyyy/mm/dd` tree walk | Roots come from `defaultRoots()` (`usage-index.mjs:908-912`) and are injectable for tests. A malformed line is skipped, never fatal (`jsonLines`, @@ -65,7 +65,7 @@ An assistant entry with `isApiErrorMessage: true` is a **local placeholder** Claude Code writes when a request dies before a real completion (connection drop, rate limit, auth failure — `model: ""`, all-zero usage). It is real engaged time but not a model attempt: counted as an *exception*, never -pushed into `models` or priced (`usage-index.mjs:551-570`; the full story is +pushed into `models` or priced (`usage-index.mjs:595-606`; the full story is [`USAGE-SCORECARD-METRICS.md`](USAGE-SCORECARD-METRICS.md) §10). ### 1.2 Codex entry vocabulary @@ -200,7 +200,7 @@ Two deliberate subtleties: * **`tool-result` outranks `context`**: a `tool_result` block on an `isMeta` entry is still tool feedback. -Codex user turns are `kind: 'prompt'` by construction (`usage-index.mjs:767-775`) +Codex user turns are `kind: 'prompt'` by construction (`usage-index.mjs:716-725`) — rollouts only record real prompts as `user_message` events (§1.2). Coverage: `tests/kit/usage-index.test.mjs` — "user-role turns carry a kind" @@ -211,7 +211,7 @@ and image-only pastes get the right kind" (the two edges). ## 4. The `readSession` pipeline — how one session becomes a payload -`readSession(id, opts)` (`usage-index.mjs:1566-1621`) is the only way +`readSession(id, opts)` (`usage-index.mjs:1573-1629`) is the only way transcript content leaves the module, and every step is a gate: ### 4.1 Locate, contain, bound @@ -256,7 +256,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:208` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, `usage-index.mjs:89`) with the marker appended -(`usage-index.mjs:1721-1729`). Two invariants: +(`usage-index.mjs:1724-1734`). Two invariants: * **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. @@ -412,7 +412,7 @@ was wrong before, for the curious. assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole subject is cost. `meta.cost` is now priced via `sessionCost()` from the - same per-model usage rows `aggregate()` uses (`usage-index.mjs:1691`). + same per-model usage rows `aggregate()` uses (`usage-index.mjs:1696`). * **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index 4ddc021..698aee8 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -154,12 +154,12 @@ responses = Σ over included sessions of session.responses **Source:** - Filter: a parsed record with zero assistant turns is dropped entirely — "no - assistant turn → not a session" (`usage-index.mjs:1123`) — and a record whose + assistant turn → not a session" (`usage-index.mjs:1070`) — and a record whose last activity falls outside the requested window is dropped too (`usage-index.mjs:1124`). - `responses` accumulation: Claude increments per assistant message -(`usage-index.mjs:568-571`); Codex increments per `agent_message` event -(`usage-index.mjs:653-662`). +(`usage-index.mjs:559-563`); Codex increments per `agent_message` event +(`usage-index.mjs:727-731`). - Totals: `totals.responses += s.responses` per included session (`usage-index.mjs:1175`). - Render: `kpi("sessions", fmtNum(t.sessions), fmtNum(t.responses)+" assistant @@ -335,9 +335,9 @@ numbers as percentages of `t.tokens` (`dashboard/client.mjs`, **What "input" excludes.** For both providers, the `input` counter recorded per row is **gross input minus cached input** — Claude's parser reads `cache_read_input_tokens` and `cache_creation_input_tokens` as separate fields -the provider already reports separately (`usage-index.mjs:598-599`); Codex's +the provider already reports separately (`telemetry-records.mjs:209-212`); Codex's parser subtracts `cached_input_tokens` from `input_tokens` explicitly -(`usage-index.mjs:780-789`, `input: Math.max(0, gross - cacheRead)`) because +(`usage-index.mjs:741-750`, `input: Math.max(0, gross - cacheRead)`) because Codex's own `input_tokens` field **includes** cached tokens and would double-count them against the separately-reported `cacheRead` figure if left as-is. This is asserted by test: @@ -434,7 +434,7 @@ session data, and each needs its own fix: - `activeIntervals()` (`usage-index.mjs:427-438`) — splits one session's sorted timestamp list into sub-intervals wherever a gap exceeds `IDLE_GAP_MS`; "a run of one timestamp yields a zero-length interval and so - contributes nothing" (comment, `usage-index.mjs:399-403`). + contributes nothing" (comment, `usage-index.mjs:427-431`). - Aggregation: `totals.engagedSeconds = mergeIntervals(sessions.flatMap(s => s._active))` (`usage-index.mjs:1219`); `totals.spanUnionSeconds = mergeIntervals(sessions.map(s => s._span))` (`usage-index.mjs:1218`); @@ -490,7 +490,7 @@ byDay[day].sessionsActive = count of distinct sessions with any usage row that d **Source:** the day key is the row's own `row.day`, computed once at parse time as **local calendar day**, not UTC -(`usage-index.mjs:602`/`usage-index.mjs:798` call `localDay(at)`) — so a +(`usage-index.mjs:589`/`usage-index.mjs:745` call `localDay(at)`) — so a session that runs from 23:58 local to 00:05 local is billed to the day its *first* row landed on (test: `tests/kit/usage-index.test.mjs:634`, "a session that opens before midnight @@ -522,8 +522,8 @@ renders "no sessions in window" instead of zeroed figures (`dashboard/client.mjs`). **Formula:** identical aggregation to every other bucket -(`byProvider[s.provider]`, populated via `addTo()`, `usage-index.mjs:942-951`, - called once per session at `usage-index.mjs:1084`), keyed by the literal string +(`byProvider[s.provider]`, populated via `addTo()`, `usage-index.mjs:1036-1045`, + called once per session at `usage-index.mjs:1097`), keyed by the literal string `"claude"` or `"codex"` assigned at parse time (`blankSession(id, 'claude')` / `blankSession(id, 'codex')`, `usage-index.mjs:398-408`, `parseClaude`/`parseCodex` entry points). @@ -562,9 +562,9 @@ punchcard[dow + "-" + hour] += 1 per assistant/agent_message response, at its ``` **Source:** incremented once per Claude assistant turn -(`usage-index.mjs:568-571`, keyed by `punchKey(at)`) and once per Codex -`agent_message` (`usage-index.mjs:653-662`), merged into the window-level -`punchcard` object per session (`usage-index.mjs:1197`). Cell intensity is +(`usage-index.mjs:559-563`, keyed by `punchKey(at)`) and once per Codex +`agent_message` (`usage-index.mjs:727-731`), merged into the window-level +`punchcard` object per session (`usage-index.mjs:1184`). Cell intensity is linear against the single busiest cell in the window: `v = pcMax ? n/pcMax : 0` (`dashboard/client.mjs`) — this is a **relative**, not absolute, scale, so the heatmap's brightest cell is always @@ -613,7 +613,7 @@ rather than vanishing. passed into `addUsage()` at the call site — `1` per Claude assistant turn (`usage-index.mjs:596-608`), or `rec.responses` (the session's whole response count) once per Codex session, passed at the single point Codex calls -`addUsage` (`usage-index.mjs:798-804`). +`addUsage` (`usage-index.mjs:745-804`). **Render:** `bar(name, fmtUsd(cost), fmtTok(tokens)+" · "+fmtNum(responses)+" resp", pct(cost, topModelCost), false)` (`dashboard/client.mjs`), @@ -630,7 +630,7 @@ distinct underlying causes, one placeholder shape). The parser branches on `isApiErrorMessage === true` (`usage-index.mjs:522-531`): the turn still increments `rec.responses` -and the punchcard (`usage-index.mjs:510-513`) — it *is* real engaged +and the punchcard (`usage-index.mjs:559-563`) — it *is* real engaged time, someone was genuinely waiting on it — but it is never pushed into `rec.models` and `addUsage()` is never called for it, so it can no longer create a `byModel` row of any kind. It increments a separate @@ -920,7 +920,7 @@ both credential-free for ak: `windowDurationMins: 10080` (the weekly). Windows are therefore keyed and labelled by duration (`windowLabel`, `quota.mjs:44`), never by slot name. The same rule applies to the historical snapshots parsed out of rollouts: the -normalizer at `usage-index.mjs:729-746` keeps a flat `windows` list keyed by +normalizer at `usage-index.mjs:686-703` keeps a flat `windows` list keyed by `window_minutes`. **Freshness is part of the number.** Both sides carry `fetchedAt`; the view @@ -945,13 +945,13 @@ Codex ≥0.140 maintains its own SQLite thread ledger (`~/.codex/state_N.sqlite` — the `N` is a migration generation, so `codexStateDb` (`codex-state.mjs:30`) globs and takes the newest). `readCodexState` (`:49`) reads per-thread `thread_source` (`user` vs `subagent`) plus `thread_spawn_edges`, and -`applyCodexLedger` (`usage-index.mjs:1529-1539`) overlays that onto parsed +`applyCodexLedger` (`usage-index.mjs:1532-1543`) overlays that onto parsed sessions: a ledger-identified subagent has its token usage stripped — its rollout replays the parent's entire token history (ccusage/ccusage#950 measured up to 91× inflation) — while the session record stays visible. The rollout's own `session_meta.thread_source` sniff remains as the fallback when the ledger is absent or migrated beyond recognition. Codex sessions also carry -`reasoningOutput` (`usage-index.mjs:808`) — reasoning tokens are a **subset** +`reasoningOutput` (`usage-index.mjs:755`) — reasoning tokens are a **subset** of output tokens and are annotation only, never added to any sum. ## 14. Known limitations, restated as a single checklist @@ -1023,9 +1023,9 @@ at face value (correctly avoiding the separate naive-summing bug **[C5]** documents, since it already used last-event-only logic — see §4's worked example) but performed **no de-duplication** against a parent session a subagent file might be replaying. **Fix:** the parser now reads -`session_meta.thread_source` (`usage-index.mjs:697-704`, confirmed as a real +`session_meta.thread_source` (`telemetry-records.mjs:117-122`, confirmed as a real Codex rollout field by **[C7]**) and skips the `addUsage()` call entirely -when its value is `'subagent'` (`usage-index.mjs:780-790`, guard condition +when its value is `'subagent'` (`usage-index.mjs:741`, guard condition `rec.threadSource !== 'subagent'`). The session record itself is **not** dropped — it remains visible in the Sessions tab with `threadSource` surfaced (mirroring the existing `sidechain` flag Claude sessions already diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 9af87e7..953d80b 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -212,5 +212,6 @@ the experimental flag. This table is the source of truth for what is real. [ruvnet/ruflo#3046](https://github.com/ruvnet/ruflo/issues/3046), each inviting the maintainer to close-as-satisfied if the current source already provides the surface. - Companion explainer for consumers and implementers: - [`docs/HOST-EXTENSIBILITY-EXPLAINER.html`](../HOST-EXTENSIBILITY-EXPLAINER.html); design dossier: - [`docs/ADAPTER-CONTRACT-DOSSIER.html`](../ADAPTER-CONTRACT-DOSSIER.html). + [`docs/archive/2026-08-16-artifact-host-extensibility-explainer.html`](../archive/2026-08-16-artifact-host-extensibility-explainer.html); + design dossier: + [`docs/archive/2026-08-14-artifact-adapter-contract-dossier.html`](../archive/2026-08-14-artifact-adapter-contract-dossier.html). diff --git a/docs/adr/0036-dashboard-client-modularization-and-shared-loopback-server.md b/docs/adr/0036-dashboard-client-modularization-and-shared-loopback-server.md new file mode 100644 index 0000000..cfcf8f0 --- /dev/null +++ b/docs/adr/0036-dashboard-client-modularization-and-shared-loopback-server.md @@ -0,0 +1,180 @@ +# ADR-0036 — Dashboard client modularization and shared loopback server + +- **Status:** Implemented +- **Date:** 2026-08-26 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0005](0005-dashboard-in-page-routing-reveal.md), + [ADR-0007](0007-maintainer-admin-local-telemetry.md), + [ADR-0014](0014-dashboard-auth-and-remediation.md) + +## Context + +A 2026-08 complexity audit of `src/lib/dashboard-server.mjs`, `src/lib/dashboard/client.mjs`, +`src/lib/admin-server.mjs`, and `src/lib/dashboard/styles.mjs` found three unrelated problems that +shared one root cause — large amounts of served-but-never-parsed content: + +1. `dashboard-server.mjs`'s `http.createServer` callback was one `async (req, res) => {...}` + closure spanning ~680 lines (cyclomatic complexity 194): an if-chain of 15 routes, including + two independent SSE state machines whose reserve-slot/early-close/channel-open lifecycle was + copy-pasted verbatim three times (`/api/live/events`, `/api/live/intelligence`, + `/api/live/transcripts/:host/:id/events`). +2. `dashboard/client.mjs` — the dashboard's entire browser-side SPA (5 tabs, a hash router, SSE + clients, inline SVG charting, ~184 function declarations) — was one exported template-literal + string (`export const JS` assigned one 4,044-line template literal). `node --check`, ESLint, + and `tsc` all see a single string; none of them ever looked inside it. The same was true, at + smaller scale, of + `dashboard/styles.mjs`'s 1,309-line inline stylesheet. +3. `dashboard-server.mjs` and `admin-server.mjs` each independently defined `readJsonSafe`, minted + their session token the same way, wrote the same 401/404 response headers, and repeated the + same `server.listen(...).then(resolve {url, urlWithToken, port, token, close})` boilerplate. + `dashboard-server.mjs` additionally imported `tokenMatches` — a security primitive — **from** + `admin-server.mjs`, coupling two independent loopback servers for no architectural reason. + +The "served JS/CSS must be one inline `` and one `` in one HTML response, under +the same CSP, with no new HTTP routes. + +## Consequences + +### Positive + +- `dashboard-server.mjs`'s request handler drops from one CC-194 closure to a trivial dispatcher + plus named per-route handlers, each independently under CC 25. +- The SSE reserve-slot/early-close/channel lifecycle exists in exactly one place; a fourth SSE + route only has to supply `setup`/`afterOpen`, not re-derive the TOCTOU-safe scaffolding. +- `client.mjs` and `styles.mjs` are real, lintable, typecheckable source for the first time — + ESLint's very first pass over this code found (and this refactor fixed) a handful of pre-existing + dead locals and unused catch bindings that had been invisible inside the template literal. +- `tokenMatches` and the loopback listen/response boilerplate have one home, independent of either + server, closing the backwards dependency of dashboard-server on admin-server. +- The readFileSync-concat pattern is now documented as the sanctioned mechanism for future + dashboard-area growth, rather than something only admin's page happened to do. + +### Negative + +- The dashboard bundle is now 11 files instead of 1 for the browser client (plus 4 for styles); + understanding the whole bundle requires opening more files, though each one is now small enough + to actually read. +- The split files' real `import`/`export` graph is a lint/documentation aid only — it is never + actually resolved at runtime, which is a source of confusion if not called out (hence the header + comment convention followed in every split file and in `client.mjs`/`styles.mjs` themselves). +- The shared-mutable-global list in `eslint.config.mjs` is a manually maintained contract: adding a + new cross-file-reassigned name requires adding it there too, or ESLint will (correctly) flag the + reassignment as `no-import-assign` once the name is imported instead. + +### Verification + +Both collectors were checked against a captured snapshot of their pre-refactor resolved output +(`JS`/`CSS`'s actual string value, not the template-literal source): the only differences are +harmless inter-file blank lines and two deliberate `_`-prefixed renames of pre-existing dead +locals that ESLint's first-ever pass surfaced. `dashboard.test.cjs`, `admin.test.cjs`, and the +full Playwright `dashboard-ui` suite (331 cases) pass unmodified. + +## References + +- `src/lib/dashboard/sse.mjs` (`sseRoute`, `sseChannel`, `reserveClientSlot`, `clientGone`) +- `src/lib/dashboard-server.mjs` (the route table, `deliverLiveInit`) +- `src/lib/loopback-server.mjs` +- `src/lib/admin-server.mjs`, `src/lib/admin-view.mjs`, `src/lib/admin-model.mjs` (the original + readFileSync-concat precedent) +- `src/lib/dashboard/client.mjs` and `src/lib/dashboard/client/` (the collector and its 11 modules) +- `src/lib/dashboard/styles.mjs` and `src/lib/dashboard/styles/` (the collector and its 4 modules) +- `eslint.config.mjs` (the `dashboard/client/**` override) +- [Dashboard user guide](../DASHBOARD.md) diff --git a/docs/adr/0037-complexity-program-structural-patterns.md b/docs/adr/0037-complexity-program-structural-patterns.md new file mode 100644 index 0000000..89c2117 --- /dev/null +++ b/docs/adr/0037-complexity-program-structural-patterns.md @@ -0,0 +1,81 @@ +# ADR-0037 — Complexity program: structural patterns and gates + +- **Status:** Implemented +- **Date:** 2026-08-26 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0036](0036-dashboard-client-modularization-and-shared-loopback-server.md) + +## Context + +A 2026-08-26 full-codebase complexity audit (ESLint `complexity` over `src/` + `bin/`, +plus four parallel review agents) found 399 functions over cyclomatic complexity 10, +six over CC 100 (worst: `status.mjs collect()` at CC 250), eleven source files over +1,000 lines, and no lint rule enforcing any of it. More importantly, the duplication +behind those numbers had produced five real behavioral divergences: + +1. The live Codex adapters (status-plane and content-plane) never learned the newer + `item_completed` message generation the batch parser handled — newer Codex sessions + appeared dead in the live view. +2. The provider-convergence pipeline was pasted three times (`pick`/`sync`/`setup`); + only `sync`'s copy healed retired routes, so `ak host pick` persisted config its + sibling command had to repair — the issue #129 failure shape. +3. Signal-kind inference existed twice with disagreeing fallbacks. +4. `ak status` re-implemented, read-side, drift comparisons whose write-side twins + lived in `providers.mjs` — issue #129's class, re-created. + +## Decision + +One refactor program (branch `refactor/complexity-program`), executed as five +file-disjoint parallel tracks with serial gated integration, established these as the +repository's sanctioned structures: + +- **Section registry for `ak status`** — `src/commands/status/sections/*.mjs`, each + exporting `{id, collect(ctx)}`, iterated by a small `collect()` orchestrator with a + uniform per-section error contract (generalizing the pre-existing + `HOST_DETAIL_RENDERERS` pattern). Row order, subsystem strings, messages, and fix + strings are load-bearing (`sync` plans from them); the golden snapshot + (`tests/kit/status-golden.test.mjs` + fixture) pins them byte-for-byte and is + regenerated only deliberately (`STATUS_GOLDEN_UPDATE=1`). +- **One provider pipeline** — `convergeProviderStack()` in `src/lib/providers.mjs` is + the only definition of the hosts→routes→router→codex-mcp→providers sequence; + commands supply reporting/persistence policy. `applyAqeRouter` folds ordered + surface reconcilers, each `(draft, ctx) → {detail, error, changed}`. +- **Ordered step registries for convergence commands** — `sync`'s and `setup`'s run + flows are `[{id, when, run}]` arrays; array order carries the ordering invariants + that previously lived in comments. +- **Writer-owned drift comparators** — `providerEnvDrift()` / `aqeRouterDrift()` are + exported from `providers.mjs`, computed from the writer's own predicates and + dry-run fold; status consumes them. A parity test + (`tests/kit/providers-drift-parity.test.mjs`) asserts status's reported drift + equals the writer's own computation, killing the #129 class structurally. +- **Single decode layer for vendor telemetry** — `src/lib/telemetry-records.mjs` + (`decodeCodexRecord` / `decodeClaudeRecord`) is the one home of wire-format + knowledge; batch usage scanning and all live adapters consume it. Only decode is + shared — aggregation and event emission remain separate by design. +- **Statusline segment providers** — the emitted `statusline-footer.cjs` template + renders through an ordered array of per-segment functions inside its single-file + marked region (no build step; template constraints in the file header). +- **Dashboard modularization** — per [ADR-0036](0036-dashboard-client-modularization-and-shared-loopback-server.md): + route-table dispatcher, `sseRoute()` lifecycle helper, real browser modules + concatenated by collectors, `loopback-server.mjs` for loopback security primitives. +- **Gates** — the dashboard Playwright suite (`pnpm run test:ui`) runs in CI; + `complexity: 25` / `max-depth: 5` / `max-lines: 1000` ESLint **warnings** apply to + `src/` + `bin/`. Warnings are visibility, not a wall: new code should land under + the thresholds, and they ratchet to errors per-directory as areas come clean. + +## Consequences + +- All six CC>100 functions are gone (worst named targets: `collect()` 250→4, + `dashboard-server` handler 194→13, `rufloActivationSegments` 193→9, `pick()` + 144→11, `applyAqeRouter` 111→21, `sync run()` 110→20; also `scan` 73→13, + `detectInsights` 71→7, `reduceLiveEvent` 81→12). All five behavioral divergences + are fixed with regression tests. +- The measured CC>10 count rose (399→~507) because the dashboard client's ~184 + functions became visible to ESLint for the first time — the denominator became + honest; per-function severity dropped sharply. +- Known residual backlog (deliberately not part of this program): `uninstall run()` + (CC 100), `opencode.mjs` receipt-reconciliation family (`applyOpencode` 71 — the + audit's `reconcileOwnedMap` recommendation), `model-inventory` discovery and + `footprint`/`adapters` functions in the 50–65 band, and the newly visible client + functions (`system-projects` 88, `overview` 63). `providers.mjs` sits just over + the 1,000-line warning; a future `aqe-router.mjs` split would clear it. diff --git a/docs/adr/README.md b/docs/adr/README.md index c113924..cb11ec2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -43,6 +43,8 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) | Retire Codex MCP; bound reciprocal QE-Court participant transport | Implemented; handoff transport amended by 0034 | | [0034](0034-schema-native-handoffs-and-hermetic-seats.md) | Schema-native worker handoffs and hermetic qe-court seats | Implemented | | [0035](0035-managed-deja-vu-companion.md) | Manage deja-vu as an opt-in session-history companion | Accepted; implementation tracked by issue #114 | +| [0036](0036-dashboard-client-modularization-and-shared-loopback-server.md) | Dashboard client modularization and shared loopback server | Implemented | +| [0037](0037-complexity-program-structural-patterns.md) | Complexity program: structural patterns and gates | Implemented | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -252,3 +254,24 @@ routing target, or observability authority. Package, target, plugin, and data ow separate; normal diagnosis parses offline doctor schema version 2; indexing uses bounded `deja index` rather than guidance-writing `deja warmup`; teardown preserves external installs and user data unless a separately previewed purge is confirmed. + +**0036** answers a 2026-08 complexity audit of the dashboard/admin implementation, not a +user-facing change. `dashboard-server.mjs`'s 15-route request handler becomes a route table plus +one `sseRoute()` lifecycle contract in `dashboard/sse.mjs` for the three SSE routes' shared +reserve-slot/early-close/channel-open scaffolding. New `loopback-server.mjs` gives token +mint/compare, `readJsonSafe`, and the listen/response boilerplate one home shared by both +`dashboard-server.mjs` and `admin-server.mjs`, ending the latter importing a security primitive +from the former. `dashboard/client.mjs`'s 4,044-line inline-script string and +`dashboard/styles.mjs`'s 1,309-line inline stylesheet are rebuilt as small collectors over real, +individually lintable modules, generalizing the readFileSync-concat pattern ADR-0007's admin page +already used. The served page, its routes, and its CSP are unchanged. + +**0037** is the program-level record of the same 2026-08 complexity audit: five file-disjoint +refactor tracks that removed every function over CC 100 and fixed the five behavioral +divergences duplication had caused. Its durable content is the set of sanctioned structures — +the `ak status` section registry pinned by a golden snapshot, `convergeProviderStack()` as the +one provider pipeline with ordered surface reconcilers, writer-owned drift comparators with a +status/writer parity test (closing the issue #129 class), the `telemetry-records.mjs` decode +layer shared by batch and live telemetry, statusline segment providers, and the complexity +lint warnings (25/5/1000 over `src`+`bin`) that ratchet to errors as areas come clean — plus +the honestly-stated residual backlog it did not take on. diff --git a/docs/HOST-PROVIDER-CONSISTENCY.html b/docs/archive/2026-08-13-artifact-host-provider-consistency.html similarity index 100% rename from docs/HOST-PROVIDER-CONSISTENCY.html rename to docs/archive/2026-08-13-artifact-host-provider-consistency.html diff --git a/docs/ADAPTER-CONTRACT-DOSSIER.html b/docs/archive/2026-08-14-artifact-adapter-contract-dossier.html similarity index 100% rename from docs/ADAPTER-CONTRACT-DOSSIER.html rename to docs/archive/2026-08-14-artifact-adapter-contract-dossier.html diff --git a/docs/HOST-EXTENSIBILITY-EXPLAINER.html b/docs/archive/2026-08-16-artifact-host-extensibility-explainer.html similarity index 100% rename from docs/HOST-EXTENSIBILITY-EXPLAINER.html rename to docs/archive/2026-08-16-artifact-host-extensibility-explainer.html diff --git a/docs/archive/2026-08-25-swarm-prompt-issue-110-model-lifecycle.md b/docs/archive/2026-08-25-swarm-prompt-issue-110-model-lifecycle.md new file mode 100644 index 0000000..bf8088e --- /dev/null +++ b/docs/archive/2026-08-25-swarm-prompt-issue-110-model-lifecycle.md @@ -0,0 +1,477 @@ +# Issue #110 Model Lifecycle Swarm Execution Prompt + +Paste the following prompt into a new session: + +```text +Continue the full implementation of GitHub issue #110 in: + +/Users/cphillipson/Development/active/ai/agentic-kit + +Take ownership of the work and execute it end-to-end with a dependency-aware Ruflo swarm or coordinated team of specialized subagents. Do not stop after analysis, brainstorming, or planning. + +CURRENT STATE + +- Continue on the branch that is checked out when the session begins. +- Expected branch: feat/110-model-lifecycle-intelligence +- Existing PR: #179 +- Do not create another branch or duplicate PR. +- Do not rebase, reset, force-push, or rewrite existing history. +- Preserve all user and unrelated changes. +- Inspect the branch, worktree, issue, PR discussion, commits, CI, and existing implementation before making changes. +- Treat any previously reported HEAD as informational; determine the exact current HEAD yourself. +- Commit logical units of work continuously to the current branch. +- Push the completed branch and update PR #179. +- Do not merge the PR unless explicitly instructed. + +MANDATORY PROJECT PROCESS + +Read the repository AGENTS.md and all applicable nested instructions first. + +Use the real installed RuvNet tools for capabilities they own: + +- Ruflo for swarm orchestration. +- AgentDB/Ruflo memory for decisions and continuation state. +- SPARC for specification, pseudocode, architecture, refinement, and completion. +- Agentic-QE for test generation, coverage, quality scoring, and validation. +- MetaHarness/red-blue tooling for adversarial or security validation when available. +- frontend-design and accessibility disciplines for the Models UI. + +Do not silently substitute generic agents or hand-written imitations for installed RuvNet capabilities. Call search_ruvnet before asserting what a RuvNet tool supports. If a required real tool is unavailable, state that explicitly and use the safest fallback without stalling the project. + +Use a hierarchical-mesh, anti-drift topology. Parallelize independent work aggressively, but respect the dependency graph and exclusive file ownership. + +Before coding: + +1. Inspect GitHub issue #110 in full, including comments and acceptance criteria. +2. Inspect PR #179, its discussion, review findings, commits, and checks. +3. Inspect the current Models page and reproduce the reported usability problems. +4. Read the relevant code, tests, fixtures, documentation, DDD model, and ADRs. +5. Read at minimum: + - docs/adr/0032-model-lifecycle-intelligence.md + - ADRs covering the dashboard, usage evidence, host adapters, OpenCode, and provider provenance + - relevant DDD, API, user-facing, operational, and supporting documentation +6. Search project memory for earlier decisions and persist new decisions throughout the work. +7. Establish a clean baseline with the relevant test and quality commands. + +LIVING ADR RULE + +ADRs are living implementation plans, not immutable historical artifacts. + +For every relevant ADR: + +- Record its current Status and Updated date before implementation. +- Compare its concrete claims with the current code. +- Report any precise claim/code mismatch. +- Reconcile the ADR and implementation. +- Update Status, Updated date, and implementation notes when this work changes the ADR’s implementation state. +- Add a new ADR only when the decision is genuinely new and architectural. +- Keep DDD, API, developer, operational, and user-facing documentation synchronized with delivered behavior. + +PRODUCT OUTCOME + +The existing Models page exposes storage and evidence vocabulary instead of answering operator questions. Redesign it around these questions: + +1. Which models am I actually using? +2. Which activities and routes use each model? +3. What can each model do? +4. What are its limits and costs? +5. Which models or routes require action? +6. How fresh and trustworthy is the evidence? + +The page must no longer make internal identifiers, hashes, binding IDs, evidence IDs, or scope fingerprints the primary user experience. + +REQUIRED INFORMATION ARCHITECTURE + +Design and implement a useful operator-focused page containing: + +1. Summary/KPI area + - Routes needing attention + - Models in use + - Source health and freshness + - Refresh status, partial failures, and stale sources + +2. Needs attention + - Human-readable model and route names + - Affected activities + - Current and recommended replacement + - Reason the action is required + - Appropriate user action or documentation link + +3. Your routes + - This is the primary operational table. + - Recommended default columns: + Model | Access path | Used for | Last used | Capabilities | Cost | Lifecycle + - Show primary/fallback position in human terms. + - Show lifecycle and migration impact clearly. + - Do not show internal IDs in ordinary rows. + +4. Catalog explorer + - Separate from operational models. + - Collapsed or lazy-loaded by default. + - Clearly distinguish catalog availability from configuration, entitlement, routability, and observed use. + - Do not mix hundreds of catalog-only entries into the primary operational table. + +5. Source coverage + - Explain which facts each source exposes. + - Distinguish unknown, not exposed, not checked, stale, failed, and unavailable. + - Explain partial refresh failures in plain language. + +6. Model detail drawer or equivalent progressive disclosure + - Human name, selector, family, maker, and access path + - Primary and fallback activities + - Configured versus observed use + - First/last use, session count, responses, tokens, cache tokens, and spend where evidenced + - Context and output limits + - Input/output modalities + - Tool calling and structured-output support + - Reasoning support, choices, default, and selected level + - Temperature, variants, service tier, and relevant capabilities + - Lifecycle, replacement, and migration impact + - Evidence source, freshness, provenance, and limitations + - The detailed evidence-state matrix may live here, not in the primary table + +IDENTITY AND PRIVACY POLICY + +Remove internal hashes and opaque references from normal user-facing presentation. + +Use these patterns: + +- Public model: human name, copyable selector, verified source links. +- Private or unverified identity: “Private Codex model,” “Custom OpenCode deployment,” or another honest semantic label. +- Consumer: “Implementation · primary” or “Testing · fallback 1.” +- Evidence: “Codex catalogue · refreshed 12 minutes ago.” + +Internal hashes may remain as hidden API keys or DOM identifiers but must not leak through visible labels, links, accessible names, tooltips, copy actions, logs, exports, or errors. + +Do not infer a public model identity from an opaque ID. + +Investigate whether an exact, authoritative host catalogue match is enough to retain a human name even when a model is hidden. Handle injected/custom catalogue entries defensively. Document the resulting proof rule. + +Consider an optional session-only “Reveal private names” control: + +- Off by default +- Never persisted +- Never logged +- Clear privacy warning +- Implement only if architecture, security, and UX review determine it is safe and valuable + +FILTERING, SORTING, LAZY LOADING, AND SCROLLING + +The host inventory/catalog must: + +- Lazy-load data that is not needed for the initial operational view. +- Have a restricted height with an internal vertical scroll region. +- Preserve usable sticky headers where appropriate. +- Avoid forcing the entire page to grow with the inventory. +- Support ascending and descending sorting by every meaningful column. +- Be fully keyboard operable. +- Preserve focus and announce sort/filter changes accessibly. + +Use meaningful, facet-counted filters: + +- View: In use, Configured, Recently observed, Needs attention, Available, Local +- Activity +- Access path +- Model family +- Primary/fallback role +- Capability +- Context-window band +- Price band +- Lifecycle +- Last-used range +- Source freshness + +Search should match human name, selector, family, and activity. Private matching may happen internally, but the response must remain privacy-preserving. + +Rename misleading concepts: + +- “Serving provider” should normally become “Access path.” +- “Publisher” should become “Model maker” only when independently proven. + +Hide or disable facets with fewer than two meaningful values. Do not render empty or misleading filter controls. + +SOURCE DISCOVERY AND ENRICHMENT + +Preserve independent evidence facts. Do not collapse configuration, discovery, entitlement, policy, routability, and observed use into a single inferred truth. + +Codex: + +- Prefer the stable app-server model/list source, with an appropriate cache fallback. +- Surface supported fields such as human description, visibility/default state, reasoning choices and descriptions, input modalities, personality support, multi-agent version, service tiers/default tier, upgrade guidance, context limits, and supported capabilities when actually present. +- Do not equate a Codex subscription catalogue with OpenAI API availability, entitlement, pricing, or routability. + +Primary references: +- https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/src/protocol/v2/model.rs +- https://github.com/openai/codex/blob/main/codex-rs/core/models.json + +Claude: + +- Use status-line JSON where appropriate for actual model ID/name, context usage, effort, thinking, cost, and rate limits. +- Use settings/configuration for alias resolution, defaults, custom names/descriptions/capabilities, managed allowlists, and gateway discovery. +- Use transcripts for observed use. +- Treat Anthropic /v1/models as API visibility, not Claude Code subscription entitlement. + +Primary references: +- https://code.claude.com/docs/en/statusline +- https://code.claude.com/docs/en/model-config +- https://platform.claude.com/docs/en/api/models/list + +OpenCode: + +- Preserve available human name, family, description, capabilities, limits, costs, status, variants, knowledge cutoff, update date, open-weights status, reasoning options, structured output, cache pricing, provider display name, and documentation link. +- Credential presence may be shown safely but does not prove model entitlement. +- A configured model list does not prove a successful inference request. + +Primary references: +- https://opencode.ai/docs/cli/ +- https://opencode.ai/docs/providers + +Models.dev: + +Support and preserve the current schema where relevant, including provider identity/documentation/environment hints and model attachment, cost, description, family, knowledge, update date, limits, modalities, open-weights, reasoning, reasoning options, release date, lifecycle status, structured output, temperature, and tool-calling fields. + +Treat this as public metadata, not entitlement or routability evidence. + +Primary reference: +- https://github.com/anomalyco/models.dev/blob/dev/README.md + +Ollama: + +- Prefer /api/tags for installed model name, size, modified date, digest, format, family, parameter size, and quantization. +- Use /api/show for bounded/safe model metadata such as license summary, capabilities, model information, context, and parameters. +- Use /api/ps for loaded state, memory/VRAM, active context, and expiry. +- Do not retain raw templates, unbounded model cards, or full license bodies when a safe summary/link is sufficient. +- Installed or loaded does not prove successful inference. + +Primary references: +- https://docs.ollama.com/api/tags +- https://docs.ollama.com/api-reference/show-model-details +- https://docs.ollama.com/api/ps + +Hugging Face: + +- Enrich only when there is exact repository proof. +- Online lookup must be explicit. +- Useful fields include license, base model, task, library, languages, model card, and newer version. +- Never manufacture a Hugging Face link from a similar-looking model name. + +Primary reference: +- https://huggingface.co/docs/hub/main/en/model-cards + +Direct provider/gateway APIs: + +- Optional and explicit online /v1/models lookups may prove credential-visible API models. +- They do not prove host subscription entitlement or a successful request. + +ORDINARY READ AND REFRESH SEMANTICS + +- Normal dashboard reads must remain cache-only and offline. +- Normal page rendering must not create network egress or consume inference tokens. +- Refresh must be explicit and say which sources it contacts. +- Refresh failures must retain valid cached facts and report partial coverage honestly. +- Never perform inference probes merely to populate the dashboard. +- Never scrape interactive model pickers. +- Public catalogue presence is not entitlement or routability. +- Local installation is not successful inference. +- Unknown must remain unknown when the source does not provide proof. + +WEB RESEARCH AND VERSION VALIDATION + +Research all provider/model/source contracts against primary official documentation as of August 2026. + +For newly introduced dependencies: + +- Prefer no new runtime dependencies; this project is intentionally zero-runtime-dependency. +- If a dependency is genuinely necessary, verify the latest compatible release as of August 2026 using official registries, release notes, and primary documentation. +- Record the compatibility reasoning and rejected alternatives. +- Apply the same freshness requirement to model names, aliases, lifecycle information, source schemas, and model-lookup validation. +- Do not rely on remembered model lists or stale secondary articles. +- Cite the exact authoritative sources in ADRs and supporting documentation. + +SWARM AND DEPENDENCY PLAN + +Have the coordinator create a dependency graph and exclusive ownership matrix before allowing edits. + +Suggested execution waves: + +Wave 0 — coordinator baseline + +- Inspect branch, issue, PR, code, tests, ADRs, DDD, documentation, and project memory. +- Reproduce the problems. +- Define acceptance criteria, domain language, privacy rules, source-proof rules, and API contracts. +- Identify changed files and assign exclusive ownership. +- Persist the plan and decisions. + +Wave 1 — parallel read-only architecture and design + +- Domain/architecture agent: + DDD, source boundaries, evidence semantics, projections, refresh behavior, ADR drift. + +- Product/UX/frontend/a11y agent: + Information architecture, filters, table/drawer interaction, responsive behavior, keyboard model, scroll behavior, and accessible states. + +- Source/provenance/privacy agent: + Provider contracts, proof strength, identity protection, online/offline boundaries, source freshness, and August 2026 research. + +- Test/QE agent: + Risk model, strict-TDD sequence, fixtures, contract tests, privacy tests, browser flows, accessibility, compatibility, and quality gates. + +Gate 1: + +- Coordinator synthesizes findings. +- Resolve architectural conflicts. +- Freeze domain and API contracts. +- Update the ownership matrix before implementation. + +Wave 2 — parallel implementation + +Use exclusive, non-overlapping file sets: + +- Codex and Claude collectors/normalizers +- OpenCode, Models.dev, Ollama, Hugging Face, and generic provider collectors/normalizers +- Domain model, evidence projection, query/filter/facet logic, and API +- UI components, page behavior, styling, lazy loading, scrolling, sorting, filters, and accessibility +- Tests may run concurrently only in separately assigned test files +- Documentation may begin with non-conflicting drafts, but final behavior documentation waits for stable contracts + +Dependency rules: + +- Domain and API contracts precede collector and projection integration. +- Projection/API behavior precedes final UI integration. +- UI scaffolding may proceed against agreed fixtures. +- Documentation finalization follows verified behavior. +- Integrators must not overwrite another agent’s work in the shared filesystem. +- Agents must communicate findings when blocked instead of editing outside their assigned scope. + +COLLISION PREVENTION + +The coordinator must maintain a live table containing: + +Agent | Responsibility | Allowed file globs | Dependencies | Status | Commit + +Rules: + +- No two agents edit the same file concurrently. +- Every agent checks git status before editing and committing. +- Agents stage only their explicitly owned paths. +- Agents never commit another agent’s files. +- Avoid repository-wide formatting or mechanical rewrites during parallel work. +- Shared files are changed only by the coordinator/integrator after contributing agents finish. +- Keep existing files under approximately 500 lines by extracting cohesive modules where appropriate. +- Validate input at boundaries. +- Never commit secrets or .env files. +- Do not add Co-Authored-By trailers unless repository settings explicitly require them. + +STRICT TDD AND QUALITY + +For each behavioral unit: + +1. Write or identify a failing test. +2. Implement the minimum behavior. +3. Refactor while green. +4. Commit the test and implementation together where practical. + +Required coverage includes: + +- Parser and normalization unit tests for every source +- Production-shaped contract fixtures +- Boundary and malformed-input tests +- Adversarial privacy and opaque-identity tests +- Evidence-strength and non-inference tests +- Snapshot coherency across inventory, filters, facets, and detail views +- Sorting in both directions for every meaningful column +- Facet counts, empty facets, reset behavior, and combinations +- Lazy-loading and partial-failure behavior +- Cache-only/no-egress/no-token ordinary reads +- Refresh source selection, timeout, stale-cache, and partial success +- Keyboard navigation, focus management, announcements, labels, contrast, reduced motion, and screen-reader behavior +- Restricted-height inventory and internal scrolling +- Responsive layouts and browser compatibility +- macOS, Linux, and Windows path/source variations where applicable +- Realistic large catalog fixtures and performance behavior + +Wave 3 — integration and documentation + +- Integrate shared files sequentially. +- Reconcile all ADR/DDD/API/user/supporting documentation with actual behavior. +- Add migration or operational guidance where appropriate. +- Document evidence meanings and source limitations in plain language. +- Remove stale claims and internal vocabulary from user-facing material. +- Commit cohesive logical units. + +Wave 4 — independent review swarm + +At the same exact HEAD, run independent reviews for: + +- Architecture and DDD consistency +- Source/provenance correctness and August 2026 freshness +- Privacy/security and data leakage +- API and snapshot correctness +- UI/UX/accessibility +- Test adequacy +- ADR and documentation truthfulness + +Fix all blocker and major findings. Repeat review against the new exact HEAD after fixes. + +FINAL VALIDATION + +Run all applicable gates, including at minimum: + +- pnpm test +- pnpm run check +- pnpm run build +- pnpm run test:ui, if present +- pnpm audit --prod +- Agentic-QE coverage and quality analysis +- Accessibility validation +- Security/privacy checks +- Documentation lint and link validation +- Packaging dry run +- Targeted browser verification of the Models workflow + +Use Agentic-QE to score the verified repository result. Iterate toward at least 98/100 unless a concrete environmental limitation prevents it. Never fabricate the score or claim a test passed without evidence. + +COMMIT AND PR DISCIPLINE + +Commit logical units continuously, for example: + +1. Domain/evidence contracts and ADR alignment +2. Source collectors and normalization +3. Projection/API/filter/facet behavior +4. Operator-focused Models UI +5. Privacy and accessibility hardening +6. Documentation and final regression coverage + +Use the repository’s commit convention. Each commit must be independently understandable and green for its owned scope. + +After final validation: + +- Confirm the worktree contains no accidental files. +- Push the current branch normally. +- Update PR #179 rather than opening a duplicate. +- Update its description with architecture, UX, source/proof semantics, privacy, tests, ADRs, screenshots, and validation evidence. +- Monitor every required CI check to completion. +- Fix failures and repeat the exact-head review. +- Report the final commit SHA, PR URL, mergeability, checks, quality score, known limitations, and any intentionally deferred work. +- Do not merge without explicit approval. +- Persist final decisions and outcome to project memory. +- Complete tasks and gracefully terminate the swarm. + +DEFINITION OF DONE + +The work is complete only when: + +- Operators can identify their models and routes without interpreting hashes. +- Operational models and the public catalog are clearly separated. +- Filters contain real, meaningful values and reflect facet counts. +- Sorting, lazy loading, bounded scrolling, and accessibility work. +- Available source metadata is preserved and surfaced appropriately. +- Unknown states are explained rather than repeated as noise. +- Identity, entitlement, availability, policy, routability, and observed use remain independent evidence facts. +- Privacy and no-egress requirements are proven by tests. +- ADRs, DDD, API, user-facing, and supporting documentation match reality. +- All local and CI gates pass on the final exact HEAD. +- Logical commits have been pushed to the existing branch and PR #179 is fully updated. + +Begin immediately. Keep me oriented with concise progress updates, but do not pause for routine decisions. Escalate only a genuinely ambiguous product choice, missing authority, or an irreversible action. +``` diff --git a/docs/archive/README.md b/docs/archive/README.md index 4fdecd5..79c5650 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -39,3 +39,12 @@ incident reports intentionally keep their original, now-dangling paths. | [2026-07-14-shell-kit-background.md](2026-07-14-shell-kit-background.md) | `docs/BACKGROUND.md` | Root-cause investigation behind every guard: Node-ABI/WASM memory loss, dormant self-learning, aqe's variant, the security surface, the Δ‖W‖ tracker design. | The guards live on inside `agentic-kit` (`src/lib/`); the investigation is finished history. Still the best deep-dive on *why*. | | [2026-07-14-shell-kit-troubleshooting.md](2026-07-14-shell-kit-troubleshooting.md) | `docs/TROUBLESHOOTING.md` | Symptom→fix runbook keyed to the shell commands (`ruflo-resync`, `ruflo-patch-native`, …). | Superseded by the much shorter npm-command runbook at `../TROUBLESHOOTING.md` (`status` to look, `sync` to fix). | | [2026-07-14-shell-kit-conditional-blocks.md](2026-07-14-shell-kit-conditional-blocks.md) | `docs/CONDITIONAL-BLOCKS.md` | Design doc for the sentinel-block registry in shell (`_ruflo_cond_blocks`). | The mechanism ported to `src/lib/blocks.mjs` with a user-extensible registry (custom rows + declarative detectors in `kit.json`); sentinel format unchanged. | + +## Added 2026-08-26 — implemented plans and superseded audits + +| File | Original location | What it was | Why it's historical | +|---|---|---|---| +| [2026-08-13-artifact-host-provider-consistency.html](2026-08-13-artifact-host-provider-consistency.html) | `docs/HOST-PROVIDER-CONSISTENCY.html` | Local snapshot of the "Host & Provider Consistency" master review artifact (findings F-00–F-09, decisions D-n), triggered by PR #131: the full host/provider-axis audit of ak against Hermes v0.20.0. | Point-in-time dossier pinned to `main @ 8aad47b`. Its decisions live on in ADRs 0028–0031, and its structural citations (e.g. F-05 on `status.mjs` internals) describe the pre-refactor layout replaced by the 2026-08 complexity program's section registry. | +| [2026-08-25-swarm-prompt-issue-110-model-lifecycle.md](2026-08-25-swarm-prompt-issue-110-model-lifecycle.md) | `docs/plan/issue-110-model-lifecycle-swarm-prompt.md` | Ready-to-paste session prompt for driving the issue #110 Model Lifecycle Intelligence build end-to-end on branch `feat/110-model-lifecycle-intelligence` / PR #179. | The work it drives shipped: PR #179 merged and the durable record is ADR-0032 (`docs/adr/0032-model-lifecycle-intelligence.md`). Kept as provenance for how the build was orchestrated. | +| [2026-08-14-artifact-adapter-contract-dossier.html](2026-08-14-artifact-adapter-contract-dossier.html) | `docs/ADAPTER-CONTRACT-DOSSIER.html` | Local snapshot of the "Adapter Contract Dossier" artifact: four source-grounded research sweeps (ruflo plugin history, the three hosts' extension models, Hermes at HEAD, agentic-qe parity constraints) that fed the phase-2 adapter-contract design. | Build-time design input, implemented via ADRs 0028–0031; the shipped contract lives in `src/lib/adapters/` and the ADRs. Frozen as research provenance. | +| [2026-08-16-artifact-host-extensibility-explainer.html](2026-08-16-artifact-host-extensibility-explainer.html) | `docs/HOST-EXTENSIBILITY-EXPLAINER.html` | Local snapshot of the "Room for More Hosts" explainer artifact: consumer-level walkthrough of how hosts work and how a fourth host joins via the adapter door. | Companion piece to the row above and to ADR-0031, which still links both; the living operator docs are `docs/HOST-SUPPORT.md` and `docs/PROVIDERS.md`. | diff --git a/eslint.config.mjs b/eslint.config.mjs index 6224dda..0bf8865 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,6 +45,19 @@ export default [ files: ['**/*.cjs'], languageOptions: { sourceType: 'commonjs' }, }, + { + // Complexity visibility for shipped code (src/bin only — tests are long by + // nature). Warnings, not errors: the 2026-08 complexity audit found 399 + // functions over CC 10 (worst CC 250), so an error gate would be + // unpayable today. Thresholds ratchet down per-directory as the refactor + // tracks land; new code should stay under them from the start. + files: ['src/**/*.{mjs,js,cjs}', 'bin/**/*.mjs'], + rules: { + complexity: ['warn', 25], + 'max-depth': ['warn', 5], + 'max-lines': ['warn', { max: 1000, skipBlankLines: true, skipComments: true }], + }, + }, { // admin-view.mjs is BROWSER code — never node-imported, only read as text and // embedded into the served admin page (ADR-0007 §5). It legitimately uses DOM @@ -70,4 +83,63 @@ export default [ 'prefer-const': 'off', }, }, + { + // src/lib/dashboard/client/**: the dashboard's browser bundle, split out of + // the former single 4,066-line client.mjs template literal (2026-08 + // complexity audit, Finding 2) into real, individually lintable browser + // modules. Never node-imported — client.mjs (the collector) reads each + // file as TEXT, strips its cross-file `import`/`export` lines (concatenation + // collapses the module graph into one flat classic-script scope, exactly + // as the pre-split bundle already was), and serves the result inline + // (ADR-0036). Real `import`/`export` between these files exists purely + // so node --check/eslint can verify the actual cross-file dependency graph + // — see each file's own header comment. + // + // `var` throughout (not `let`/`const`) is DELIBERATE, not legacy debt: every + // file becomes one flat scope once concatenated, so two files each using + // `let`/`const` for a same-named local (e.g. a loop index) would collide + // with a hard SyntaxError at the CONCATENATED scope — `var`'s redeclare + // tolerance is exactly what makes that safe. Converting away from it is a + // cross-file, whole-bundle change, not a per-file cleanup. + files: ['src/lib/dashboard/client/**/*.mjs'], + languageOptions: { + globals: { + ...globals.browser, + // Cross-file MUTABLE state: each name below is declared+exported by + // exactly one file but REASSIGNED (not just read) from others. Real + // ES import bindings are read-only from the importing side (no-import- + // assign) — declaring these as globals instead of importing them + // documents the same "owned by one file" contract (see that file's + // own `export var` declaration) without fighting the language's own + // live-binding rules. Every name here is read-only FROM THIS LIST's + // point of view only in the sense that eslint won't flag reassignment + // — the actual single-owner discipline is enforced by code review, + // same as any other shared-mutable-global codebase. + aboutScrollPending: 'writable', consMode: 'writable', inflight: 'writable', + intelProjects: 'writable', intelRequestSeq: 'writable', LAST: 'writable', + lastAttempt: 'writable', lastUpdated: 'writable', LIMITS: 'writable', + modelDirection: 'writable', modelRouteDirection: 'writable', modelRouteSort: 'writable', + MODELS: 'writable', modelSearchTimer: 'writable', modelSnapshotId: 'writable', + modelSort: 'writable', projSort: 'writable', selectedProjectKey: 'writable', + selectedProjectLabel: 'writable', SYSTEM: 'writable', systemBusy: 'writable', + systemPollTimer: 'writable', usageDays: 'writable', usageLoaded: 'writable', + usageSession: 'writable', usageView: 'writable', + }, + }, + rules: { + 'no-var': 'off', + 'no-redeclare': 'off', + // Old-school defensive style throughout this bundle: `try{...}catch(e){}` + // swallows a localStorage/URL/DOM quirk without needing the error value. + // Never linted before this split (the audit's own Finding 2) — the + // pattern itself isn't new, only its visibility to ESLint is. + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }], + // Same rationale as the statusline-footer override below: pre-existing, + // harmless "assigned, then unconditionally reassigned before use" spots + // (e.g. a switch-like if/else-if/else chain that always overwrites its + // seed value) that a first-time lint pass surfaces but changing would be + // a behavior-adjacent edit this split does not make. + 'no-useless-assignment': 'off', + }, + }, ]; diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 900f607..b90e8d7 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -20,7 +20,7 @@ import { managedCompanionFor } from '../lib/adapters/companion-registry.mjs'; import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { DEJA_VU_TARGETS } from '../lib/deja-vu.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, retireCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; +import { HOSTS, hostInstallState, installHost, migrateRetiredRoutesInConfig, printActivityRoutingTable, aqeSupportsAgentOverrides, convergeProviderStack, applySetupHostFlags, guidanceContext, reportRetiredRouteChanges } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; @@ -284,11 +284,9 @@ export function removeUndisclosedPermissions(file, before, authorized) { return unexpected; } -export async function run_machine({ flags, pkgRoot, cfg }) { - heading('machine setup'); - if (flags['dry-run']) { info('dry-run: would ensure packages (incl. ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } - - // 1. global packages +/** Step 1: global packages (ruflo/agentic-qe/agentdb/ruvnet-brain). Returns + * false only when the mandatory ruflo install itself fails. */ +async function installMachinePackages(cfg, flags) { if (!installedVersion('ruflo')) { info('installing ruflo globally (native build scripts allowed)…'); const r = await heal.upgradePackage('ruflo'); @@ -322,16 +320,22 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } else warn('ruvnet-brain skipped — install later with `ak sync` (or `ak setup --no-ruvnet-brain` to stop asking)'); } else ok('ruvnet-brain present (refresh to the latest release with `ak sync`)'); } + return true; +} - // 2. heal natives + the #2670 aidefence gap up front. The aidefence heal is - // the security surface — it honors `--no-security` (cfg.security=false), - // which was previously write-only: documented, persisted, read by nothing. +/** Step 2: heal natives + the #2670 aidefence gap up front. The aidefence + * heal is the security surface — it honors `--no-security` + * (cfg.security=false), which was previously write-only: documented, + * persisted, read by nothing. */ +async function healMachineSecuritySurface(cfg) { reportOutcome('natives', await heal.healNatives()); if (cfg.security !== false) reportOutcome('aidefence', await heal.healAidefence()); else info('security surface skipped (kit.json security:false — re-enable by removing the key)'); if (cfg.aqe) reportOutcome('aqe solver', await heal.healAqeSolver()); +} - // 3. token-audit skill → ~/.claude/skills +/** Step 3: token-audit skill → ~/.claude/skills. */ +function deployTokenAuditSkill(pkgRoot) { const skillSrc = path.join(pkgRoot, 'claude', 'skills', 'ruflo-token-audit'); if (fs.existsSync(skillSrc)) { const dst = path.join(paths.claudeSkillsDir(), 'ruflo-token-audit'); @@ -339,15 +343,14 @@ export async function run_machine({ flags, pkgRoot, cfg }) { fs.cpSync(skillSrc, dst, { recursive: true }); ok('skill deployed: ruflo-token-audit'); } +} - // 4+5. CLAUDE.md guidance blocks + user-scope MCP registration moved to the - // FINAL pass in run(): both depend on host CLIs that step 6 below is - // about to install (mcp needs `claude` on disk; several block detectors - // key on `codex` being on PATH / dual-mode enablement). Running them - // here warned + drifted on genuinely bare machines. - - // 6. frontier hosts — install any ENABLED host that is entirely absent (default - // enables claude only). External installs (mise/native/brew) are left alone. +/** Step 6: frontier hosts — install any ENABLED host that is entirely absent + * (default enables claude only). External installs (mise/native/brew) are + * left alone. Shares HOSTS/hostInstallState/installHost with `ak sync`'s + * and `ak host pick`'s own host-install loops; the interactive confirmation + * here (vs. their unconditional install) is this command's own UX. */ +async function installEnabledAbsentHosts(cfg, flags) { for (const h of HOSTS) { if (!cfg.integrations?.hosts?.[h.id]) continue; const st = await hostInstallState(h); @@ -360,24 +363,27 @@ export async function run_machine({ flags, pkgRoot, cfg }) { ok(`${h.id} ${st.version ?? ''} present (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`); } } +} - // 6b. host lifecycle wiring — connected MCPs, compact lazy gateway, - // lifecycle plugin, converted agents, specialist dispatcher, and - // platform skill (each adapter owns its own surfaces — opencode.mjs for - // opencode; a subprocess hook for an admitted external, see - // lifecycle-registry.mjs's buildAdmittedLifecycleAdapter). - // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, - // ADR-0031 P3) rather than naming opencode, so a second lifecycle host - // — built-in or admitted — needs no new branch here. lifecycleExecutionEnabled - // gates each host: a built-in only needs cfg enablement (unchanged); an - // admitted external ALSO needs the experimental flag — an admitted host - // is opt-in exactly like opencode, and this never auto-enables anything. - // Only when the CLI is actually present: a declined/failed install must - // not leave a freshly-created config home behind (codex-review #4). - // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle - // result's own shape (opencode's rich per-surface shape — including the - // compact gateway — vs. an admitted host's generic lifecycleResult), so - // this loop body never destructures a host-specific result directly. +/** Step 6b: host lifecycle wiring — connected MCPs, compact lazy gateway, + * lifecycle plugin, converted agents, specialist dispatcher, and platform + * skill (each adapter owns its own surfaces — opencode.mjs for opencode; a + * subprocess hook for an admitted external, see lifecycle-registry.mjs's + * buildAdmittedLifecycleAdapter). Registry-driven: loops + * hostsWithLifecycle() (built-ins + admitted, ADR-0031 P3) rather than + * naming opencode, so a second lifecycle host — built-in or admitted — + * needs no new branch here. lifecycleExecutionEnabled gates each host: a + * built-in only needs cfg enablement (unchanged); an admitted external + * ALSO needs the experimental flag — an admitted host is opt-in exactly + * like opencode, and this never auto-enables anything. Only when the CLI + * is actually present: a declined/failed install must not leave a + * freshly-created config home behind (codex-review #4). + * lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle + * result's own shape (opencode's rich per-surface shape — including the + * compact gateway — vs. an admitted host's generic lifecycleResult), so + * this loop body never destructures a host-specific result directly. + * Returns false only when a report demands run_machine abort. */ +async function applyMachineHostLifecycles(cfg, pkgRoot) { for (const hostId of hostsWithLifecycle()) { if (!lifecycleExecutionEnabled(hostId, cfg)) continue; if (!(await have(detectionBinFor(hostId)))) { @@ -405,44 +411,62 @@ export async function run_machine({ flags, pkgRoot, cfg }) { info('restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections (loaded once at startup)'); } } + return true; +} - // 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`) +/** Step 7: frontier host hints — detected but not enabled (opt-in via + * `ak host pick`). */ +async function printUndetectedHostHints(cfg) { if (!cfg.integrations?.hosts?.codex && await have('codex')) { info('codex CLI detected — run `ak host pick` to let ruflo use both claude and codex'); } - // opencode hint — detected but not enabled (post-install opt-in via provider pick) if (!cfg.integrations?.hosts?.opencode && await have('opencode')) { info('opencode CLI detected — wire ruflo + ruvnet-brain into it with: ak host pick --host claude,opencode'); } - return true; } -export async function run_project({ flags, cfg, trustDisclosed = false }) { - const root = process.cwd(); - heading(`project setup — ${root}`); - if (!trustDisclosed) discloseSetupTrust(cfg, { project: true }); - if (flags['dry-run']) { info('dry-run: would init, sanitize, pin DB path, activate memory/swarm/daemon, verify'); return true; } +export async function run_machine({ flags, pkgRoot, cfg }) { + heading('machine setup'); + if (flags['dry-run']) { info('dry-run: would ensure packages (incl. ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } - const permissionsFile = paths.projectSettings(root); - const permissionsBefore = new Set(allowRules(permissionsFile)); - const authorizedPermissions = new Set(projectPermissionManifest(cfg).map((entry) => entry.rule)); + if (!(await installMachinePackages(cfg, flags))) return false; + await healMachineSecuritySurface(cfg); + // Steps 4+5 (CLAUDE.md guidance blocks + user-scope MCP registration) moved + // to the FINAL pass in run(): both depend on host CLIs step 6 below is + // about to install (mcp needs `claude` on disk; several block detectors + // key on `codex` being on PATH / dual-mode enablement). Running them here + // warned + drifted on genuinely bare machines. + deployTokenAuditSkill(pkgRoot); + await installEnabledAbsentHosts(cfg, flags); + if (!(await applyMachineHostLifecycles(cfg, pkgRoot))) return false; + await printUndetectedHostHints(cfg); + return true; +} - // 1. ruflo init (--force regenerates; CLAUDE.md backed up upstream, #2208) +/** Step 1: `ruflo init --full --force` (--force regenerates; CLAUDE.md + * backed up upstream, #2208), then verify it introduced no undisclosed + * auto-approve rules. Returns false when run_project must abort. */ +async function rufloProjectInit(root, permCtx) { const init = await runCmd('ruflo', ['init', '--full', '--force'], { cwd: root, timeout: 300_000 }); (init.code === 0 ? ok : fail)('ruflo init --full'); if (init.code !== 0) return false; - const rufloUnexpected = removeUndisclosedPermissions(permissionsFile, permissionsBefore, authorizedPermissions); + const rufloUnexpected = removeUndisclosedPermissions( + permCtx.permissionsFile, permCtx.permissionsBefore, permCtx.authorizedPermissions, + ); if (rufloUnexpected.length) { fail(`ruflo init introduced undisclosed auto-approve rules; removed: ${rufloUnexpected.join(', ')}`); return false; } + return true; +} - // 2. statusline heal is DEFERRED to the end of project setup (see step 10): - // fixStatusline is a no-op until ruflo/aqe have finished writing - // .claude/helpers/statusline.cjs, so injecting the footer here can silently - // miss (helper not settled) — running it last guarantees convergence. - - // 3. strip committed MCP cruft (keep any agentic-qe entry) +/** Step 3: strip committed MCP cruft (keep any agentic-qe entry), and remove + * any local-scope `ruflo` MCP server `ruflo init` may have registered. + * Step 2 (statusline heal) is DEFERRED to the end of project setup (see + * healProjectStatusline): fixStatusline is a no-op until ruflo/aqe have + * finished writing .claude/helpers/statusline.cjs, so injecting the footer + * before that can silently miss — running it last guarantees convergence. */ +async function sanitizeProjectMcpConfig(root) { const mcpJson = path.join(root, '.mcp.json'); const mcpCfg = readJson(mcpJson); if (mcpCfg?.mcpServers) { @@ -455,28 +479,34 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { ok('.mcp.json sanitized (no committed ruflo/ruv-swarm/flow-nexus entries)'); } await runCmd('claude', ['mcp', 'remove', 'ruflo', '-s', 'local'], { cwd: root }); +} - // 4. pin ABSOLUTE CLAUDE_FLOW_DB_PATH (Claude Code doesn't expand ${CLAUDE_PROJECT_DIR}) +/** Step 4: pin ABSOLUTE CLAUDE_FLOW_DB_PATH (Claude Code doesn't expand + * ${CLAUDE_PROJECT_DIR}). */ +function pinProjectMemoryDbPath(root) { const dbPath = paths.projectMemoryDb(fs.realpathSync(root)); const localFile = paths.projectSettingsLocal(root); const local = readJson(localFile, {}) ?? {}; local.env = { ...local.env, CLAUDE_FLOW_DB_PATH: dbPath }; writeJsonWithBackup(localFile, local); ok(`CLAUDE_FLOW_DB_PATH pinned → ${dbPath}`); +} - // 5. activate memory + swarm with the pin exported - const env = projectMemoryEnv(root); +/** Step 5: activate memory + swarm with the pin exported. */ +async function activateProjectMemoryAndSwarm(root, env) { (await runCmd('ruflo', ['memory', 'init'], { cwd: root, env })).code === 0 ? ok('memory initialized') : warn('ruflo memory init failed'); (await runCmd('ruflo', ['swarm', 'init', '--v3-mode'], { cwd: root, env })).code === 0 ? ok('swarm initialized (v3-mode)') : warn('ruflo swarm init failed'); +} - // 6. daemon: default-on, local-only workers (AI workers stay opt-in upstream) +/** Step 6: daemon — default-on, local-only workers (AI workers stay opt-in + * upstream); defensive: never let Claude Code auto-restart it (issue #3 RC3). */ +async function startProjectDaemon(root) { const d = await runCmd('ruflo', ['daemon', 'start'], { cwd: root, timeout: 60_000 }); if (d.code === 0) { ok('daemon started (local-only workers; 12h TTL; AI workers opt-in: RUFLO_DAEMON_AI_WORKERS=1)'); } else warn('daemon failed to start — try: ruflo daemon start'); - // defensive: never let Claude Code auto-restart it (issue #3 RC3) const projSettingsFile = paths.projectSettings(root); const ps = readJson(projSettingsFile); if (ps?.claudeFlow?.daemon?.autoStart === true) { @@ -484,9 +514,12 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { writeJsonWithBackup(projSettingsFile, ps); ok('claudeFlow.daemon.autoStart → false (explicit start only)'); } +} - // 7. Write-verification (store → actual on-disk row, then clean up). Native - // Native memory integrations may select agentdb-memory.db beside the pinned compatibility DB. +/** Step 7: write-verification (store → actual on-disk row, then clean up). + * Native memory integrations may select agentdb-memory.db beside the pinned + * compatibility DB. */ +async function verifyProjectMemoryWrite(root, env) { const probeKey = `_setup/verify-${process.pid}-${Date.now()}`; const stored = (await runCmd('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env })).code === 0; const landed = stored ? findMemoryEntry(root, '_setup', probeKey) : null; @@ -503,67 +536,145 @@ export async function run_project({ flags, cfg, trustDisclosed = false }) { } else { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); } +} - // 8. lean project CLAUDE.md (generic guidance lives machine-wide) +/** Step 8: lean project CLAUDE.md (generic guidance lives machine-wide). */ +function writeLeanProjectClaudeMd(root, flags) { const projectMd = path.join(root, 'CLAUDE.md'); if (fs.existsSync(projectMd) && !flags.minimal) { fs.writeFileSync(projectMd, leanStub(path.basename(root))); ok('project CLAUDE.md → lean stub (machine-wide reference carries the rest)'); } +} - // 9. agentic-qe in this repo (sentinel first so aqe init skips duplicate guidance) - if (cfg.aqe && !flags['no-aqe'] && await have('aqe')) { - const md = fs.existsSync(projectMd) ? fs.readFileSync(projectMd, 'utf8') : ''; - if (!md.includes('## Agentic QE v3')) { - fs.appendFileSync(projectMd, '\n## Agentic QE v3\n\n'); - } - heal.healRvf(paths.projectAqeDir(root)); - // aqe ≥ 3.13.1 with codex enabled → install the Codex-native QE skills too. - const withCodex = !!cfg.integrations?.hosts?.codex && aqeSupportsAgentOverrides(); - const aqe = await runCmd('aqe', ['init', '--auto', ...(withCodex ? ['--with-codex'] : [])], { cwd: root, timeout: 300_000 }); - (aqe.code === 0 ? ok : warn)(`agentic-qe initialized${withCodex ? ' (+ codex skills)' : ''}`); - const aqeUnexpected = removeUndisclosedPermissions(permissionsFile, permissionsBefore, authorizedPermissions); - if (aqeUnexpected.length) { - fail(`agentic-qe init introduced undisclosed auto-approve rules; removed: ${aqeUnexpected.join(', ')}`); - return false; - } +/** Step 9: agentic-qe in this repo (sentinel first so aqe init skips + * duplicate guidance). Returns false when run_project must abort. */ +async function initProjectAgenticQe(root, cfg, flags, permCtx) { + if (!(cfg.aqe && !flags['no-aqe'] && await have('aqe'))) return true; + const projectMd = path.join(root, 'CLAUDE.md'); + const md = fs.existsSync(projectMd) ? fs.readFileSync(projectMd, 'utf8') : ''; + if (!md.includes('## Agentic QE v3')) { + fs.appendFileSync(projectMd, '\n## Agentic QE v3\n\n'); + } + heal.healRvf(paths.projectAqeDir(root)); + // aqe ≥ 3.13.1 with codex enabled → install the Codex-native QE skills too. + const withCodex = !!cfg.integrations?.hosts?.codex && aqeSupportsAgentOverrides(); + const aqe = await runCmd('aqe', ['init', '--auto', ...(withCodex ? ['--with-codex'] : [])], { cwd: root, timeout: 300_000 }); + (aqe.code === 0 ? ok : warn)(`agentic-qe initialized${withCodex ? ' (+ codex skills)' : ''}`); + const aqeUnexpected = removeUndisclosedPermissions( + permCtx.permissionsFile, permCtx.permissionsBefore, permCtx.authorizedPermissions, + ); + if (aqeUnexpected.length) { + fail(`agentic-qe init introduced undisclosed auto-approve rules; removed: ${aqeUnexpected.join(', ')}`); + return false; } + return true; +} - // 9.5 frontier host/provider wiring — reapply kit.json prefs (no-op at the - // claude-only default, so existing repos see zero change). When codex is - // enabled: write ENABLE_* env and register providers. - const ph = applyHosts(cfg, root); - if (ph.changed) ok(`providers: ${ph.detail}`); - // dual-host: seed the per-activity routing policy from defaults (persist first - // so the router materialization below writes agentOverrides). No-op single-host. - const seed = seedActivityRoutesIfMultiHost(cfg); - if (seed.seeded) { saveKitConfig(cfg); ok(`per-activity routing seeded — ${seed.count} activities (dual-host defaults)`); } - const rt = applyAqeRouter(cfg, root); - if (rt.changed) (rt.ok ? ok : warn)(`aqe router: ${rt.detail}`); +/** The 'aqe-router' step's own report, plus the activity-routing table print + * that always follows it here (regardless of whether the router itself + * changed) — split out purely to keep applyProjectProviderStack's + * reporter's own branch count legible. */ +function reportProjectAqeRouterStep(cfg, result) { + if (result.changed) (result.ok ? ok : warn)(`aqe router: ${result.detail}`); if (Object.keys(cfg.routing?.routes ?? {}).length) printActivityRoutingTable(cfg); - if (cfg.integrations?.hosts?.codex) { - const mcp = await retireCodexMcp(cfg, root); - if (mcp.changed) saveKitConfig(cfg); - if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`legacy codex MCP: ${mcp.detail}`); - // Independently register Ruflo in Codex for shared routing/swarm/memory tools. - const rmcp = await ensureRufloMcpInCodex(cfg, root); - if (rmcp.changed) saveKitConfig(cfg); // persist reverse MCP ownership - if (rmcp.changed || !rmcp.ok) (rmcp.ok ? ok : warn)(`ruflo→codex MCP: ${rmcp.detail}`); - } else if (await have('codex')) { +} + +/** The 'ruflo-codex-mcp' step's own report — when codexMcp:false skipped it + * (`result` is null), print the "codex CLI detected" hint instead, in the + * same position the whole codex block used to occupy. */ +async function reportProjectRufloCodexMcpStep(result) { + if (result) { + if (result.changed || !result.ok) (result.ok ? ok : warn)(`ruflo→codex MCP: ${result.detail}`); + return; + } + if (await have('codex')) { info('codex CLI detected — enable dual-host with: ak host pick'); } - // Provider routing is independent of the enabled execution-host set. Apply - // persisted Ruflo providers for Claude-only setups too (#128 / ruflo#2962). - const prov = await applyProviders(cfg, root); - if (prov.changed || !prov.ok || prov.status === 'degraded') reportOutcome('providers', prov); +} - // 10. statusline footer — LAST, after ruflo + aqe have settled the helper. - // A still-missing footer is a WARN (not silent info): it means the AQE / - // SONA segments won't render and `ak sync` is needed to heal it. +/** Step 9.5: frontier host/provider wiring — reapply kit.json prefs (no-op + * at the claude-only default, so existing repos see zero change). When + * codex is enabled: write ENABLE_* env and register providers. The shared + * pipeline (providers.mjs's convergeProviderStack) computes and persists + * every step; this reporter only decides what to print and how, preserving + * setup's exact wording/gating/ordering per step. codexMcp gates the + * legacy/reverse Codex MCP steps to run only while codex is enabled — + * matching this command's pre-existing behavior (sync and pick always run + * them; the deprecated-backend cleanup is independent of enablement there — + * see retireCodexMcp). */ +async function applyProjectProviderStack(cfg, root, migrateRoutes) { + const codexEnabled = !!cfg.integrations?.hosts?.codex; + const projectReporter = async (step, result) => { + if (step === 'hosts') { if (result.changed) ok(`providers: ${result.detail}`); return; } + // dual-host: seed the per-activity routing policy from defaults (persist + // first so the router materialization below writes agentOverrides). + // No-op single-host. + if (step === 'routing-seed') { + if (result.seeded) ok(`per-activity routing seeded — ${result.count} activities (dual-host defaults)`); + return; + } + // Retire withdrawn models from the persisted policy — the same heal `ak + // sync` already runs (sync.mjs). Without this, project setup could + // persist a route naming a model the host has withdrawn, left for the + // next sync to repair. Only seeded entries are rewritten; a user pin is + // reported and kept. + if (step === 'routing-retired') { reportRetiredRouteChanges(result.changes); return; } + if (step === 'aqe-router') { reportProjectAqeRouterStep(cfg, result); return; } + if (step === 'legacy-codex-mcp') { + if (result && (result.changed || !result.ok)) (result.ok ? ok : warn)(`legacy codex MCP: ${result.detail}`); + return; + } + // Independently register Ruflo in Codex for shared routing/swarm/memory tools. + if (step === 'ruflo-codex-mcp') { await reportProjectRufloCodexMcpStep(result); return; } + // Provider routing is independent of the enabled execution-host set. + // Apply persisted Ruflo providers for Claude-only setups too (#128 / + // ruflo#2962). + if (step === 'providers-api' && (result.changed || !result.ok || result.status === 'degraded')) { + reportOutcome('providers', result); + } + }; + await convergeProviderStack(cfg, root, { + reporter: projectReporter, migrateRoutes, codexMcp: codexEnabled, + }); +} + +/** Step 10: statusline footer — LAST, after ruflo + aqe have settled the + * helper. A still-missing footer is a WARN (not silent info): it means the + * AQE/SONA segments won't render and `ak sync` is needed to heal it. */ +function healProjectStatusline(root) { const sl = fixStatusline(root); if (sl.applied) ok(`statusline: footer injected (v${sl.version})`); else if (sl.reason) warn(`statusline: ${sl.reason} — run \`ak sync\` to re-inject`); else ok('statusline: footer in sync'); +} + +export async function run_project({ + flags, cfg, trustDisclosed = false, migrateRoutes = migrateRetiredRoutesInConfig, +}) { + const root = process.cwd(); + heading(`project setup — ${root}`); + if (!trustDisclosed) discloseSetupTrust(cfg, { project: true }); + if (flags['dry-run']) { info('dry-run: would init, sanitize, pin DB path, activate memory/swarm/daemon, verify'); return true; } + + const permissionsFile = paths.projectSettings(root); + const permCtx = { + permissionsFile, + permissionsBefore: new Set(allowRules(permissionsFile)), + authorizedPermissions: new Set(projectPermissionManifest(cfg).map((entry) => entry.rule)), + }; + + if (!(await rufloProjectInit(root, permCtx))) return false; + await sanitizeProjectMcpConfig(root); + pinProjectMemoryDbPath(root); + const env = projectMemoryEnv(root); + await activateProjectMemoryAndSwarm(root, env); + await startProjectDaemon(root); + await verifyProjectMemoryWrite(root, env); + writeLeanProjectClaudeMd(root, flags); + if (!(await initProjectAgenticQe(root, cfg, flags, permCtx))) return false; + await applyProjectProviderStack(cfg, root, migrateRoutes); + healProjectStatusline(root); return true; } @@ -582,26 +693,11 @@ ruflo swarm init --topology hierarchical --max-agents 15 --strategy specialized \`\`\` `; -export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEFAULT_DEJA_VU_LIFECYCLE }) { - const dejaVuFlags = validateDejaVuSetupFlags(flags); - if (!dejaVuFlags.ok) { - fail(dejaVuFlags.error); - return 2; - } - const cfg = loadKitConfig(); - if (flags['no-aqe']) cfg.aqe = false; - if (flags['no-ruvnet-brain']) cfg.ruvnetBrain = false; - if (flags['no-security']) cfg.security = false; - - const inProject = flags.project - || (fs.existsSync(path.join(process.cwd(), '.git')) && process.cwd() !== paths.home); - const willConfigureProject = inProject && !flags.minimal; - - // Apply host flags to the in-memory config before preflight so the manifest - // describes this invocation, including a newly requested host. Dry-run never - // persists this object; a declined confirmation returns before saveKitConfig. - const hostFlags = applySetupHostFlags(cfg, flags); - const dejaVuFlagsResult = applySetupDejaVuFlags(cfg, flags); +/** Preflight the deja-vu companion and disclose the full setup trust + * manifest (host + project + companion changes), honoring a declined + * confirmation. Returns `{code}` when `run()` must return immediately, or + * `{companionPreflight}` to continue. */ +async function resolveSetupTrust(cfg, flags, dejaVuLifecycle, confirm, willConfigureProject) { let companionPreflight; try { companionPreflight = await preflightSetupDejaVu(cfg, dejaVuLifecycle, { @@ -609,28 +705,32 @@ export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEF }); } catch { fail('deja-vu preflight failed before any setup mutation'); - return 1; + return { code: 1 }; } const trustManifest = discloseSetupTrust(cfg, { project: willConfigureProject, companionPreflight, }); if (companionPreflight?.error) { fail(`deja-vu preflight refused the plan (${safeCompanionCode(companionPreflight.error)})`); - return 1; + return { code: 1 }; } - if (trustManifest.length) { - if (!flags['dry-run'] && !(await confirm( - 'Proceed with setup and these trust changes?', false, flags.yes))) { - info('setup cancelled before machine, user, or project changes'); - return 0; - } + if (trustManifest.length && !flags['dry-run'] + && !(await confirm('Proceed with setup and these trust changes?', false, flags.yes))) { + info('setup cancelled before machine, user, or project changes'); + return { code: 0 }; } + return { companionPreflight }; +} - // --codex / --primary-host: opt codex in BEFORE run_machine's host-install loop - // and run_project's dual wiring, so the existing gated/prompted/external-safe - // paths install + wire codex. No-op (claude-only) when neither flag is passed. - // Dry-run applies these choices in memory for an accurate plan, but never - // persists the resulting config. +/** --codex / --primary-host / --opencode / deja-vu: preview (dry-run) or + * announce (real run) what the host/companion flags will do. Opting codex + * in happens BEFORE run_machine's host-install loop and run_project's dual + * wiring (applySetupHostFlags already ran, in `run()`, so the existing + * gated/prompted/external-safe paths install + wire codex); dry-run applies + * these choices in memory for an accurate preview but never persists them. */ +function printSetupHostFlagPreview({ + flags, cfg, hostFlags, companionPreflight, dejaVuFlagsResult, +}) { if (flags['dry-run']) { if (flags.codex || flags['primary-host']) info('dry-run: --codex/--primary-host would enable + install the codex host and wire dual-mode (no changes made)'); if (flags.opencode) info('dry-run: --opencode would enable the opencode host and wire it (no changes made)'); @@ -640,17 +740,66 @@ export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEF } else if (dejaVuFlagsResult.changed) { info('dry-run: deja-vu would remain disabled (no probes or changes)'); } - } else { - for (const w of hostFlags.warnings) warn(w); - if (hostFlags.changed) { - if (flags.codex || flags['primary-host'] === 'codex') { - const primary = cfg.routing?.primaryHost && cfg.routing.primaryHost !== 'claude' - ? ` (primary: ${cfg.routing.primaryHost})` : ''; - info(`codex host enabled${primary} — will install + wire dual-mode`); - } - if (flags.opencode) info('opencode host enabled — will wire opencode.json and deploy plugin/agents/skills'); + return; + } + for (const w of hostFlags.warnings) warn(w); + if (hostFlags.changed) { + if (flags.codex || flags['primary-host'] === 'codex') { + const primary = cfg.routing?.primaryHost && cfg.routing.primaryHost !== 'claude' + ? ` (primary: ${cfg.routing.primaryHost})` : ''; + info(`codex host enabled${primary} — will install + wire dual-mode`); } + if (flags.opencode) info('opencode host enabled — will wire opencode.json and deploy plugin/agents/skills'); + } +} + +/** Final reconcile pass — deliberately AFTER the hosts branch (which installs + * the claude/codex/opencode CLIs) and the project phase (whose Codex + * integration creates ~/.codex): the user-scope MCP registration needs the + * claude CLI on disk, and several guidance blocks gate on freshly-installed + * hosts (command:codex, flag:dualMode). Shares blocks.mjs reconcileGuidance + * (via providers.mjs's guidanceContext) with `ak sync` so setup and sync + * converge guidance identically. */ +async function finalizeSetupGuidanceAndMcp(cfg, pkgRoot, flags) { + for (const t of await reconcileGuidance({ cwd: process.cwd(), cfg, pkgRoot, context: guidanceContext(cfg) })) { + if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); + } + const wantMcp = cfg.mcp.register && (flags.reconfigure || !(readJson(paths.claudeUserMcpPath(), {})?.mcpServers?.['claude-flow'])); + if (wantMcp && await ask('Register the ruflo MCP server at user scope (schemas load on demand)?', true, flags.yes)) { + if (await mcpRegister()) { + const { denied } = applyExclusions(cfg.mcp.excludeFamilies ?? []); + ok(`MCP registered${denied ? ` (${denied} tool(s) denied per kit.json)` : ''} — exclude families anytime: ak x mcp pick`); + } else warn('claude mcp add failed — run: ak x mcp pick'); } +} + +export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEFAULT_DEJA_VU_LIFECYCLE }) { + const dejaVuFlags = validateDejaVuSetupFlags(flags); + if (!dejaVuFlags.ok) { + fail(dejaVuFlags.error); + return 2; + } + const cfg = loadKitConfig(); + if (flags['no-aqe']) cfg.aqe = false; + if (flags['no-ruvnet-brain']) cfg.ruvnetBrain = false; + if (flags['no-security']) cfg.security = false; + + const inProject = flags.project + || (fs.existsSync(path.join(process.cwd(), '.git')) && process.cwd() !== paths.home); + const willConfigureProject = inProject && !flags.minimal; + + // Apply host flags to the in-memory config before preflight so the manifest + // describes this invocation, including a newly requested host. Dry-run never + // persists this object; a declined confirmation returns before saveKitConfig. + const hostFlags = applySetupHostFlags(cfg, flags); + const dejaVuFlagsResult = applySetupDejaVuFlags(cfg, flags); + const trust = await resolveSetupTrust(cfg, flags, dejaVuLifecycle, confirm, willConfigureProject); + if (trust.code !== undefined) return trust.code; + const { companionPreflight } = trust; + + printSetupHostFlagPreview({ + flags, cfg, hostFlags, companionPreflight, dejaVuFlagsResult, + }); if (!(await run_machine({ flags, pkgRoot, cfg }))) return 1; // Companion execution is deliberately sequenced after every enabled host is @@ -673,25 +822,7 @@ export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEF } else if (!flags.minimal) { info('not inside a project (no .git here) — run `ak setup` from a repo to set one up'); } - // Final reconcile pass — deliberately AFTER the hosts branch (which installs - // the claude/codex/opencode CLIs) and the project phase (whose Codex integration - // creates ~/.codex): the user-scope MCP registration needs the claude CLI on - // disk, and several guidance blocks gate on freshly-installed hosts - // (command:codex, flag:dualMode). Shares blocks.mjs reconcileGuidance with - // `ak sync` so setup and sync converge guidance identically. - if (!flags['dry-run']) { - const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; - for (const t of await reconcileGuidance({ cwd: process.cwd(), cfg, pkgRoot, context: ctx })) { - if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); - } - const wantMcp = cfg.mcp.register && (flags.reconfigure || !(readJson(paths.claudeUserMcpPath(), {})?.mcpServers?.['claude-flow'])); - if (wantMcp && await ask('Register the ruflo MCP server at user scope (schemas load on demand)?', true, flags.yes)) { - if (await mcpRegister()) { - const { denied } = applyExclusions(cfg.mcp.excludeFamilies ?? []); - ok(`MCP registered${denied ? ` (${denied} tool(s) denied per kit.json)` : ''} — exclude families anytime: ak x mcp pick`); - } else warn('claude mcp add failed — run: ak x mcp pick'); - } - } + if (!flags['dry-run']) await finalizeSetupGuidanceAndMcp(cfg, pkgRoot, flags); console.log(''); ok(bold('setup complete — `agentic-kit` anytime for status, `ak sync` after upgrades')); diff --git a/src/commands/status.mjs b/src/commands/status.mjs index e83f698..b5ab039 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -1,40 +1,17 @@ // ak status — read-only dashboard. Each row: subsystem, level, message, // and (for drift) what `sync` would do. --json emits the raw rows; --hint // (set by bare invocation) appends exactly one suggested next action. -import fs from 'node:fs'; -import path from 'node:path'; import { glyph, dim, bold, warn } from '../lib/output.mjs'; import { loadRing, detectRegression } from '../lib/health-history.mjs'; -import * as paths from '../lib/paths.mjs'; -import { nativesStatus, rufloRuntimeNatives, dbPathPinStatus, aidefencePresent, securityPresent } from '../lib/natives.mjs'; -import { scanNpxStale } from '../lib/npx.mjs'; -import { registrationStatus, codexMcpStatus, codexMcpTopology, rufloCodexMcpStatus, ruvectorRegistered } from '../lib/mcp.mjs'; -import { - opencodeMcpStatus, catalogSource, createOpencodeLifecycleAdapter, - opencodeArtifactReceiptState, -} from '../lib/opencode.mjs'; -import { listDaemons, staleDaemons } from '../lib/daemons.mjs'; -import { scanRvf } from '../lib/rvf.mjs'; -import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; -import { driftReport, selfDrift, installedVersion, cmpVersions } from '../lib/versions.mjs'; -import { upstreamCveCounterFabricated, fixStatusline, helperStampStale } from '../lib/statusline.mjs'; -import { drift as ruvnetBrainDrift, nightlyAgentPresent as rbNightlyPresent, NIGHTLY_LABEL as RB_NIGHTLY_LABEL } from '../lib/ruvnet-brain.mjs'; -import { coherence as adbCoherence } from '../lib/agentdb.mjs'; -import { readJson } from '../lib/settings.mjs'; -import { have } from '../lib/exec.mjs'; -import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, aqeExternalProviderState, credentialGaps, collectIntegrationFacts, MIN_RUFLO_PERSISTED_PROVIDER_VERSION, EXTERNAL_PROVIDERS_MIN_AQE } from '../lib/providers.mjs'; -import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../lib/adapters/lifecycle-registry.mjs'; +import { collectIntegrationFacts } from '../lib/providers.mjs'; import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-registry.mjs'; -import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; -import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; -import { qeCourtShipped, readQeCourtConfig, validateCourtConfig, qeCourtReadiness } from '../lib/qeCourt.mjs'; -import { drift as ruvectorDrift } from '../lib/ruvector.mjs'; -import { statuslineDrift } from '../lib/codex-statusline.mjs'; -import { inspectCodexPlugins } from '../lib/codex-plugins.mjs'; -import { projectMemoryStatus } from '../lib/project-memory.mjs'; -import { removedAgentGaps, upstreamFixAvailable } from '../lib/scaffold.mjs'; -import { latestSnapshot, readModelStore, summarizeModelHealth } from '../lib/model-inventory/index.mjs'; +import { row } from './status/row.mjs'; +import { renderHostDetailRows, admittedLifecycleFallbackRows } from './status/host-detail.mjs'; +import { collectDejaVuRows } from './status/deja-vu.mjs'; +import { SECTIONS_BEFORE_HOST_DETAIL, SECTIONS_AFTER_HOST_DETAIL } from './status/sections/index.mjs'; + +export { renderHostDetailRows, collectDejaVuRows }; export const options = { json: { type: 'boolean', default: false }, @@ -59,348 +36,23 @@ Examples: ak status --deep thorough check ak status --json machine-readable rows`; -const row = (subsystem, level, message, fix = null) => ({ subsystem, level, message, fix }); -const DEJA_HOSTS = Object.freeze(['claude', 'codex', 'opencode']); -const SAFE_VERSION = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; - -function hasDejaVuOwnership(cfg) { - const ownership = cfg?.integrations?.ownership?.dejaVu; - return !!ownership?.install - || (!!ownership?.targets && typeof ownership.targets === 'object' - && Object.keys(ownership.targets).length > 0); -} - -function safeDejaCode(value) { - return typeof value === 'string' && /^[a-z0-9-]{1,80}$/.test(value) - ? value : 'unavailable'; -} - -function safeDejaVersion(value) { - return typeof value === 'string' && SAFE_VERSION.test(value) ? value : 'unknown'; -} - -/** - * Render the managed companion from its bounded lifecycle facts. No upstream - * path, command output, signature, transcript metadata, or plugin payload is - * copied into a row. A fix is attached only when the same adapter plan contains - * an operation that can perform it. - * @param {{cfg?:any,adapter?:any,planOptions?:Record}} [options] - */ -export async function collectDejaVuRows(options = {}) { - const { - cfg, - adapter = companionLifecycleFor('deja-vu'), - planOptions = {}, - } = options; - const desired = cfg?.integrations?.tools?.dejaVu; - const enabled = desired?.enabled === true; - const owned = hasDejaVuOwnership(cfg); - if (!enabled && !owned) { - return [row('deja-vu', 'info', - 'deja-vu disabled — package, host wiring, and history remain unprobed')]; - } - if (!adapter) { - return [row('deja-vu', 'warn', 'deja-vu lifecycle adapter unavailable')]; - } - - try { - const facts = await adapter.detect({ cfg }); - const plan = await adapter.plan({ cfg, facts, options: planOptions }); - const operations = Array.isArray(plan?.operations) ? plan.operations : []; - const actionable = !facts?.error && !plan?.error; - const hasOperation = (kind, host = null) => actionable && operations.some((operation) => - operation?.kind === kind && (host === null || operation.host === host)); - const rows = []; - - if (facts?.error || plan?.error) { - const code = safeDejaCode(facts?.error ?? plan?.error); - const external = code === 'deja-external-version-unsupported' - || code === 'deja-external-install-unusable'; - rows.push(row('deja-vu', external ? 'warn' : 'fail', external - ? `external deja-vu installation is not safely manageable (${code}); preserved` - : `deja-vu health contract failed closed (${code})`)); - } - - const install = facts?.install ?? {}; - if (install.binaryPresent === false) { - if (enabled && hasOperation('package-install')) { - rows.push(row('deja-vu', 'warn', 'deja-vu package missing', - 'sync installs the managed npm companion')); - } else if (install.ownership === 'external') { - rows.push(row('deja-vu', 'warn', 'external deja-vu package is unavailable; preserved')); - } else { - rows.push(row('deja-vu', 'info', 'deja-vu package absent')); - } - } else if (install.binaryPresent === true) { - const version = safeDejaVersion(install.version); - if (hasOperation('package-upgrade')) { - rows.push(row('deja-vu', 'warn', - `managed deja-vu ${version} has an available package upgrade`, - 'sync upgrades the owned npm companion')); - } else if (install.receiptState === 'drifted' || install.receiptState === 'malformed') { - rows.push(row('deja-vu', 'warn', - `deja-vu ${version} package ownership receipt drifted; current installation preserved`)); - } else if (install.ownership === 'agentic-kit') { - rows.push(row('deja-vu', install.supported === false ? 'warn' : 'ok', - `managed deja-vu ${version} package${install.supported === false ? ' is below v0.19.0' : ' is present'}`)); - } else { - rows.push(row('deja-vu', 'info', - `external deja-vu ${version} package detected; installation remains user-owned`)); - } - } - - if (facts?.doctor?.state === 'ok') { - if (facts.doctor.health?.state === 'degraded') { - const issueCount = Number.isSafeInteger(facts.doctor.health.storeIssues) - ? Math.min(facts.doctor.health.storeIssues, 999) : 0; - rows.push(row('deja-vu', 'warn', - `deja-vu doctor schema v2 accepted but component health is degraded${issueCount > 0 - ? ` (${issueCount} bounded store issue${issueCount === 1 ? '' : 's'})` : ''}`)); - } else { - rows.push(row('deja-vu', 'ok', 'deja-vu doctor schema v2 accepted')); - } - } - - for (const host of DEJA_HOSTS) { - const target = facts?.targets?.[host]; - const receipt = cfg?.integrations?.ownership?.dejaVu?.targets?.[host]; - const receiptPresent = !!receipt; - if (!target || (!target.selected && !receiptPresent)) continue; - const hasTargetOperation = hasOperation('target-remove', host) - || hasOperation('target-install', host); - const managedTransition = hasTargetOperation && receipt?.mode - && receipt.mode !== facts?.desired?.mode; - if (target.receiptState === 'drifted') { - rows.push(row('deja-vu', 'warn', - `${host}: managed target ownership drifted; current wiring preserved`)); - } else if (target.conflict === 'external-auto-active' && !managedTransition) { - rows.push(row('deja-vu', 'warn', - `${host}: external automatic recall conflicts with MCP-only intent; external state preserved`)); - } else if (target.satisfied) { - rows.push(row('deja-vu', target.ownership === 'agentic-kit' ? 'ok' : 'info', - `${host}: ${target.desiredTarget ?? 'deja-vu'} target active` - + (target.ownership === 'agentic-kit' ? ' and receipt-owned' : ' via external wiring'))); - } else if (hasTargetOperation) { - rows.push(row('deja-vu', 'warn', - `${host}: managed deja-vu target requires convergence`, - `sync converges the exact ${host} target`)); - } else if (target.hostPresent === false) { - rows.push(row('deja-vu', 'warn', - `${host}: selected host is unavailable; companion wiring skipped`)); - } else { - rows.push(row('deja-vu', 'warn', - `${host}: desired deja-vu target is not proven; external state preserved`)); - } - } - - if (enabled && facts?.desired?.indexOnSetup) { - if (facts?.index?.state === 'ok') { - rows.push(row('deja-vu', 'ok', 'deja-vu derived index is healthy')); - } else if (hasOperation('index')) { - rows.push(row('deja-vu', 'warn', - `deja-vu derived index is ${['missing', 'stale'].includes(facts?.index?.state) - ? facts.index.state : 'not ready'}`, - 'sync runs one bounded deja index after target convergence')); - } else if (facts?.index?.state === 'stale-readonly') { - rows.push(row('deja-vu', 'warn', - 'deja-vu derived index is stale-readonly; automatic repair is unsafe')); - } else if (facts?.index?.state !== undefined) { - rows.push(row('deja-vu', 'info', 'deja-vu derived index health is unknown')); - } - } - - if (plan?.warnings?.includes('deja-package-latest-unavailable')) { - rows.push(row('deja-vu', 'warn', - 'managed deja-vu package is usable, but npm latest-version drift could not be verified')); - } - - return rows.length ? rows : [row('deja-vu', 'info', 'deja-vu state is unobserved')]; - } catch { - return [row('deja-vu', 'warn', 'deja-vu status unavailable')]; - } -} - -// Per-host status DETAIL rows — beyond the generic install/auth rows the -// `hosts` loop in collect() already renders from facts for every host alike. -// A detail renderer owns everything specific to how ONE host proves itself -// wired (config files, lifecycle bridges, converted artifacts, …); the -// opencode-specific PROBES/MESSAGES live in the renderer below and in -// lib/opencode.mjs, never in the dispatch loop itself. Adding a fourth host -// means adding (or not adding) a table entry here — the loop that walks this -// table never changes. -// The loop also passes `hostId`; this renderer doesn't need it (it IS the -// opencode renderer) but the signature admits it so the dispatch call site -// typechecks for every renderer uniformly. -async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'opencode' } = /** @type {any} */ ({})) { - const rows = []; - try { - if (!facts.hosts?.opencode?.present) { - rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)')); - } else { - const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir }); - const st = opencodeMcpStatus(cfg); - const lifecycle = await createOpencodeLifecycleAdapter({ pkgRoot }).detect({ cfg }); - const conv = st.parseError ? null : lifecycle.convergence; - if (st.parseError) { - rows.push(row('opencode', 'warn', - 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', - 'merge the ak wiring manually')); - } else if (!st.exists || !st.claudeFlow) { - rows.push(row('opencode', 'warn', - `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`, - 'sync writes the opencode wiring')); - } else if (!conv?.converged) { - rows.push(row('opencode', 'warn', - `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? '…' : ''})`, - 'sync re-applies the opencode wiring')); - } else { - rows.push(row('opencode', 'ok', - `opencode.json converged (claude-flow${st.aqe ? ' + agentic-qe' : ''}${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`)); - } - const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed); - if (receiptState.adoptionBlocked) { - rows.push(row('opencode', 'warn', - 'artifact receipt ledger is malformed — ownership adoption blocked; artifacts left untouched', - 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup')); - } - const plug = lifecycle.plugin; - if (!receiptState.adoptionBlocked && plug.adoptable) { - rows.push(row('opencode', 'warn', - 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt', - 'sync adopts it into the receipt ledger without rewriting it')); - } else if (!receiptState.adoptionBlocked && plug.foreign) { - rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js — ak leaves it alone')); - } else if (!receiptState.adoptionBlocked && !plug.present) { - rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it')); - } else if (!receiptState.adoptionBlocked && !plug.current) { - rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it')); - } - const gateway = lifecycle.gateway; - if (!receiptState.adoptionBlocked && gateway.adoptable) { - rows.push(row('opencode', 'warn', - 'lazy rUv gateway is exact and marker-bearing but lacks an ownership receipt', - 'sync adopts it into the receipt ledger without rewriting it')); - } else if (!receiptState.adoptionBlocked && gateway.foreign) { - rows.push(row('opencode', 'info', - 'lazy rUv gateway slot is user-owned — direct MCP exposure is preserved')); - } else if (!receiptState.adoptionBlocked && gateway.required && !gateway.present) { - rows.push(row('opencode', 'warn', 'lazy rUv gateway not deployed', 'sync deploys it')); - } else if (!receiptState.adoptionBlocked && gateway.required && !gateway.current) { - rows.push(row('opencode', 'warn', 'lazy rUv gateway out of date', 'sync rewrites it')); - } else if (!receiptState.adoptionBlocked && !gateway.required && gateway.present) { - rows.push(row('opencode', 'warn', 'lazy rUv gateway is no longer required', 'sync retires it')); - } else if (!receiptState.adoptionBlocked && gateway.required && gateway.current) { - rows.push(row('opencode', 'ok', - 'Ruflo and Agentic QE connected; compact ak_* gateway projection active')); - } - const ag = lifecycle.agents; - const lazyAgents = gateway.required && gateway.current && ag.count === 1; - if (!receiptState.adoptionBlocked && ag.adoptable) { - rows.push(row('opencode', 'warn', - `${ag.count} exact marker-bearing agent projection or stamp lacks ownership receipts`, - 'sync adopts them into the receipt ledger without rewriting them')); - } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) { - rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog')); - } else if (!receiptState.adoptionBlocked && ag.count === 0) { - rows.push(row('opencode', 'warn', 'no Agentic Kit specialist projection', 'sync deploys the specialist dispatcher')); - } else if (!receiptState.adoptionBlocked && ag.modified) { - rows.push(row('opencode', 'info', - `${ag.count} agent projection files include user edits — ak leaves those files alone`)); - } else if (!receiptState.adoptionBlocked && ag.stale) { - rows.push(row('opencode', 'warn', - `${ag.count} agent projection files from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, - 'sync refreshes the agent projection')); - } else if (!receiptState.adoptionBlocked) { - rows.push(row('opencode', 'ok', lazyAgents - ? `lazy specialist dispatcher current (${ag.currentId})` - : `${ag.count} converted agents (${ag.currentId})`)); - } - const sk = lifecycle.skill; - if (!receiptState.adoptionBlocked && sk.adoptable) { - rows.push(row('opencode', 'warn', - 'platform skill is exact and marker-bearing but lacks an ownership receipt', - 'sync adopts it into the receipt ledger without rewriting it')); - } else if (!receiptState.adoptionBlocked && sk.foreign) { - rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned — ak leaves it alone')); - } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.present) { - rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it')); - } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.current) { - rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it')); - } - } - } catch (e) { - rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`)); - } - return rows; -} - -// Dispatch table: host id → detail renderer. CONTRACT: a renderer must catch -// its own errors and degrade to a warn row — the dispatch loop deliberately -// has no catch, so an uncaught throw would take down ALL of collect(), not -// just this host. A host absent from this table -// gets no detail rows here (its install/auth state still comes from the -// `hosts` loop, which is already host-neutral). This is the ONLY place a new -// host's status detail wiring gets registered. -const HOST_DETAIL_RENDERERS = { opencode: opencodeDetailRows }; - -/** Host-neutral dispatch loop: walks `renderers` (defaults to the table - * above) and, for each host enabled in cfg, calls its renderer with the - * shared facts snapshot. Exported (not just used internally) so a test can - * prove a synthetic host renders through this exact loop — with no host-id - * branching anywhere in the loop body — by injecting its own renderers map - * instead of reaching into module internals. */ -export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HOST_DETAIL_RENDERERS }) { - const rows = []; - for (const [hostId, renderer] of Object.entries(renderers)) { - if (!cfg.integrations?.hosts?.[hostId]) continue; - rows.push(...(await renderer({ cfg, pkgRoot, facts, hostId }))); - } - return rows; +// Generalizes the HOST_DETAIL_RENDERERS contract (status/host-detail.mjs) to +// every section: a section owns its own error handling when it needs an +// exact message (most already carry their original try/catch verbatim), and +// this is the backstop for the rest — a thrown probe degrades to one warn +// row instead of taking down every row collect() hasn't pushed yet. +function defaultOnError(id, e) { + return row(id, 'warn', `${id} check unavailable: ${e.message}`); } -/** Sync reachability gap (ADR-0031 P3 known limitation): setup.mjs and - * uninstall.mjs's admitted-host lifecycle loops (ADR-0031 P3) already iterate - * hostsWithLifecycle() and run for real; sync.mjs's twin loop is gated on - * BOTH lifecycleExecutionEnabled(hostId, cfg) AND `subsystems.has(hostId)`, - * where subsystems is `new Set(plan.map(p => p.subsystem))` derived straight - * from THIS collector's rows (sync.mjs). An admitted host with no - * HOST_DETAIL_RENDERERS entry (only opencode has one) produced no row at - * all, so its subsystem could never appear in the plan and sync's branch was - * unreachable for a real admitted host — even fully enabled, flag on, CLI - * present. This closes that gap: any admitted (never built-in) lifecycle - * host that lifecycleExecutionEnabled() actually gates IN for this run gets - * exactly one subsystem-tagged row, `subsystem === hostId` — deliberately - * the same identity opencode's own renderer uses (subsystem 'opencode' === - * HOST_DETAIL_RENDERERS key 'opencode'), so sync's `subsystems.has(hostId)` - * finds it. - * - * Deliberately lean, not a per-surface renderer like opencodeDetailRows: an - * arbitrary admitted host's only introspection surface is its own declared - * detect/verify hooks (a subprocess spawn), which this read-only, cheap - * collector does not invoke — so the row cannot report real drift and always - * carries a `fix` while the gate holds. Convergence is left to the adapter's - * own apply, which lifecycle.mjs's contract requires to be idempotent. - * Excludes built-in hosts (opencode already has a bespoke renderer above; - * a future built-in lifecycle host with no renderer is a gap for its own - * renderer to close, not this fallback) and any host already present in - * `renderers` (never double-reports one host under two mechanisms). Isolated - * per host, mirroring the per-renderer try/catch contract above — one - * admitted host's failure must not take down collect() or any other host's - * row. */ -function admittedLifecycleFallbackRows(cfg, renderers = HOST_DETAIL_RENDERERS) { - const rows = []; - for (const hostId of hostsWithLifecycle()) { - if (isBuiltinHost(hostId) || hostId in renderers) continue; +async function runSections(sections, ctx, rows) { + for (const section of sections) { try { - if (!lifecycleExecutionEnabled(hostId, cfg)) continue; - rows.push(row(hostId, 'warn', - `${hostId}: external lifecycle host, enabled — sync will converge its hooks`, - `sync applies the ${hostId} lifecycle adapter`)); + rows.push(...(await section.collect(ctx))); } catch (e) { - rows.push(row(hostId, 'warn', `${hostId} lifecycle status unavailable: ${e.message}`)); + rows.push(defaultOnError(section.id, e)); } } - return rows; } export async function collect({ @@ -412,385 +64,9 @@ export async function collect({ const rows = []; const cfg = loadKitConfig(); const integrationFacts = await collectIntegrationFacts({ cwd, cfg }); + const ctx = { cfg, cwd, pkgRoot, integrationFacts }; - // Cache-only model lifecycle summary. Discovery and network access belong - // exclusively to `ak models refresh`. - try { - const snapshot = latestSnapshot(readModelStore()); - if (!snapshot) rows.push(row('models', 'info', 'no local model inventory yet; run `ak models refresh` explicitly')); - else { - const health = summarizeModelHealth(snapshot); - rows.push(row('models', health.level, health.message, health.fix)); - } - } catch (error) { - rows.push(row('models', 'warn', `model inventory unavailable: ${error.message}; run \`ak models refresh\` explicitly`)); - } - - // versions - try { - for (const r of await driftReport()) { - if (!r.installed) { - rows.push(row('versions', r.pkg === 'ruflo' ? 'fail' : 'warn', - `${r.pkg} not installed globally`, 'setup installs it')); - } else if (r.outdated) { - rows.push(row('versions', 'warn', - `${r.pkg} ${r.installed} installed, ${r.latest} available`, 'sync upgrades + re-heals')); - } else { - rows.push(row('versions', 'ok', `${r.pkg} ${r.installed}${r.latest ? ' (latest)' : ''}`)); - } - } - } catch (e) { - rows.push(row('versions', 'warn', `version check unavailable: ${e.message}`)); - } - - // ruvnet-brain (offline KB + search_ruvnet MCP; not an npm package — detected - // on disk, drift via GitHub releases, TTL-cached like `self`) - if (cfg.ruvnetBrain) { - try { - const b = await ruvnetBrainDrift(); - if (!b.present) { - rows.push(row('ruvnet-brain', 'warn', 'RuvNet Brain not installed', 'setup installs it (or `ak sync`)')); - } else if (b.outdated) { - const have = b.installedRelease ? `release v${b.installedRelease}` : 'present (unversioned install)'; - rows.push(row('ruvnet-brain', 'warn', - `ruvnet-brain ${have}, release v${b.latest} available`, 'sync refreshes the KB')); - } else { - const shown = b.installedRelease ? `release v${b.installedRelease}${b.latest ? ' (latest)' : ''}` : 'present'; - rows.push(row('ruvnet-brain', 'ok', `ruvnet-brain ${shown}`)); - } - } catch (e) { - rows.push(row('ruvnet-brain', 'warn', `ruvnet-brain check unavailable: ${e.message}`)); - } - // The installer's own nightly self-updater (macOS LaunchAgent, 03:47) bypasses - // ak-managed updates: it rewrites the KB outside ak's release stamp, so status - // and the statusline drift from disk. Own subsystem so sync's fix is "disable - // the agent", never a needless force-reinstall of the brain itself. - if (rbNightlyPresent()) { - rows.push(row('ruvnet-brain-nightly', 'warn', - `ruvnet-brain nightly self-updater active (${RB_NIGHTLY_LABEL}) — bypasses ak-managed updates`, - 'sync disables it (re-enable deliberately: `npx ruvnet-brain --enable-nightly`)')); - } - } - - // ruvector — a global CLI users register as an MCP server BY HAND. ak manages - // its drift, never its presence or its registration. Unregistered → no row at - // all (same silence as codex-not-enabled): nudging a tool nobody opted into - // would be management by ambush. Registered but kit.json ruvector:false → an - // info row with NO fix, so sync never plans an upgrade the user turned off. - // - // Wording is deliberately "CLI": the registered command is typically - // `npx -y ruvector mcp start`, so upgrading the global package does not - // necessarily change what the MCP server executes. Claim only what is true. - if (ruvectorRegistered()) { - if (cfg.ruvector === false) { - rows.push(row('ruvector', 'info', 'ruvector MCP registered — CLI updates disabled (kit.json ruvector:false)')); - } else { - try { - const rv = await ruvectorDrift(); - if (rv.present && rv.outdated) { - rows.push(row('ruvector', 'warn', - `ruvector CLI ${rv.installed} installed, ${rv.latest} available`, 'sync upgrades the ruvector CLI')); - } else if (rv.present) { - rows.push(row('ruvector', 'ok', `ruvector CLI ${rv.installed}${rv.latest ? ' (latest)' : ''} (MCP registered, user scope)`)); - } else { - rows.push(row('ruvector', 'info', 'ruvector MCP registered but no global CLI installed (server runs via npx)')); - } - } catch (e) { - rows.push(row('ruvector', 'warn', `ruvector check unavailable: ${e.message}`)); - } - } - } - - // self (the kit's own version — prerelease installs track the `next` tag) - try { - const s = await selfDrift({ pkgRoot }); - if (s.outdated) { - rows.push(row('self', 'warn', - `kit ${s.installed} installed, ${s.latest} available (${s.tag} tag)`, - 'sync self-updates the kit (runs last)')); - } else if (s.installed) { - rows.push(row('self', 'ok', `kit ${s.installed}${s.latest ? ' (latest)' : ''}`)); - } - } catch (e) { - rows.push(row('self', 'warn', `kit version check unavailable: ${e.message}`)); - } - - // natives (better-sqlite3 in agentdb locations + aqe) - try { - const n = nativesStatus(); - const bad = n.locations.filter((l) => !l.native); - if (n.locations.length === 0) { - rows.push(row('natives', 'warn', 'no agentdb locations found under global ruflo', 'setup/sync installs ruflo')); - } else if (bad.length) { - rows.push(row('natives', 'fail', - `${bad.length}/${n.locations.length} agentdb location(s) on WASM fallback (data-loss writes)`, - 'sync installs native better-sqlite3')); - } else { - rows.push(row('natives', 'ok', `native better-sqlite3 in ${n.locations.length} agentdb location(s)`)); - } - if (n.aqe && !n.aqe.native) { - rows.push(row('natives', 'fail', 'agentic-qe better-sqlite3 not native', 'sync repairs it')); - } - // #45: the agentdb copies above are NOT what `npx ruflo memory` loads — probe - // the binding as resolved from ruflo's own memory runtime (@claude-flow/memory - // + /cli), or the row reads ✓ while memory store runs on the WASM fallback. - const rt = await rufloRuntimeNatives(); - if (rt.installed && rt.contexts.length) { - const wasm = rt.contexts.filter((c) => !c.ok); - if (wasm.length) { - rows.push(row('natives', 'fail', - `ruflo memory runtime on WASM fallback (${wasm.map((c) => `@claude-flow/${c.context}`).join(', ')}) — memory and orchestration may degrade`, - 'sync builds the native binding')); - } else { - rows.push(row('natives', 'ok', `ruflo memory runtime native (${rt.contexts.map((c) => c.context).join(', ')})`)); - } - } - } catch (e) { - rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`)); - } - - // #45 aftermath: a CLAUDE_FLOW_DB_PATH pin aimed at a dead or foreign path makes - // every memory op target the wrong DB ("Database not initialized" with a healthy - // DB in-repo). Warn-only — the pin may be deliberate; sync never touches it. - try { - const pin = dbPathPinStatus({ - settingsLocalFile: path.join(cwd, '.claude', 'settings.local.json'), - projectRoot: cwd, - }); - if (pin?.warn) { - rows.push(row('memory-pin', 'warn', - `CLAUDE_FLOW_DB_PATH pins ${pin.pinned} (${pin.reason})`, - 'repoint it in .claude/settings.local.json env, or remove the pin')); - } - } catch { /* pin check is best-effort — never blocks status */ } - - // Project memory may legitimately have two stores: the compatibility/sql.js - // memory.db and the native bridge's plaintext agentdb-memory.db sibling. - // Presence is a quick signal only; `ak x verify memory` performs the write - // round-trip proof. - try { - const memory = projectMemoryStatus(cwd); - if (!memory.active) { - rows.push(row('memory', 'info', 'no project memory store yet (run setup here to initialize)')); - } else if (!memory.active.readable) { - rows.push(row('memory', 'warn', - `active ${memory.active.kind} store is unreadable (${memory.active.file}) — run: ak x verify memory`)); - } else { - const sibling = memory.secondary - ? `; ${memory.secondary.kind} compatibility store also present` - : ''; - rows.push(row('memory', 'ok', - `${memory.active.kind} active writer: ${memory.active.entries} active entr${memory.active.entries === 1 ? 'y' : 'ies'}${sibling}`)); - } - } catch (e) { - rows.push(row('memory', 'warn', `project memory check unavailable: ${e.message}`)); - } - - // Scaffold agents (ADR-128 Phase 2 removals — ruflo#2985). Upstream never - // revisits an existing scaffold, so projects inited before ruflo 3.38.x are - // missing up to 9 plugin-canonical agents (coder, researcher, reviewer, …). - // The fix is upstream's `ruflo migrate fix --agents` (PR #2986): when the - // installed CLI ships it, the row carries a fix and sync delegates; until - // then it is advisory-only — a kit-side restore would fork plugin-canonical - // content. Spawn-free (dist probe + file walk), project-scoped: silent when - // the cwd has no .claude/agents tree. - try { - const { relevant, gaps } = removedAgentGaps(cwd); - if (relevant && gaps.length > 0) { - const named = gaps.slice(0, 3).map((g) => g.basename.replace(/\.md$/, '')).join(', '); - const suffix = gaps.length > 3 ? ', …' : ''; - if (upstreamFixAvailable()) { - rows.push(row('scaffold-agents', 'warn', - `${gaps.length} ADR-128-removed agent(s) missing from .claude/agents (${named}${suffix})`, - 'sync delegates to `ruflo migrate fix --agents`')); - } else { - rows.push(row('scaffold-agents', 'info', - `${gaps.length} ADR-128-removed agent(s) missing (${named}${suffix}) — installed ruflo lacks \`migrate fix --agents\` (ruflo#2986 pending); upgrade ruflo or install the owning plugins`)); - } - } else if (relevant) { - rows.push(row('scaffold-agents', 'ok', 'ADR-128-removed agents present or plugin-covered')); - } - } catch (e) { - rows.push(row('scaffold-agents', 'warn', `scaffold agent check unavailable: ${e.message}`)); - } - - // npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the - // statusline/hooks execute these verbatim, keeping retired defects alive) - try { - const stale = scanNpxStale(); - if (stale.length) { - const what = stale.flatMap((e) => e.stale.map((s) => `${s.pkg}@${s.cached}`)).join(', '); - rows.push(row('npx', 'warn', - `${stale.length} stale npx env(s) serve outdated code (${what})`, - 'sync prunes them (npx re-fetches on demand)')); - } else { - rows.push(row('npx', 'ok', 'npx cache holds no stale ruflo-family envs')); - } - } catch (e) { - rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`)); - } - - // security surface — honors kit.json security:false (`ak setup - // --no-security`): an info row with NO fix, so sync never plans (or heals) - // the surface a user turned off. Previously the flag was write-only. - if (cfg.security === false) { - rows.push(row('security', 'info', 'security checks disabled (kit.json security:false)')); - } else if (securityPresent()) { - if (aidefencePresent()) { - rows.push(row('security', 'ok', '@claude-flow/security + aidefence present (defend functional)')); - } else { - rows.push(row('security', 'fail', - 'aidefence missing — `security defend` silently non-functional (ruvnet/ruflo#2670)', - 'sync reinstalls @claude-flow/aidefence')); - } - } else { - rows.push(row('security', 'warn', '@claude-flow/security not found under global ruflo')); - } - - // learning (project-scope quick signals) - const stats = readJson(path.join(paths.projectClaudeFlowDir(cwd), 'neural', 'stats.json')); - if (stats) { - const pn = stats.patternsLearned ?? 0; - rows.push(row('learning', pn > 0 ? 'ok' : 'warn', - pn > 0 ? `${pn} patterns learned, ${stats.trajectoriesRecorded ?? 0} trajectories (this project)` - : 'learning initialized but no patterns yet (this project)')); - } else { - rows.push(row('learning', 'info', 'no learning state in this project (run setup here to activate)')); - } - - // aqe / RVF (project scope) - const aqeDir = paths.projectAqeDir(cwd); - if (fs.existsSync(aqeDir)) { - const findings = scanRvf(aqeDir); - if (findings.length) { - // Oversized = the #495 runaway-append mode, the one RVF failure aqe's own - // self-healing (>= 3.12.3) doesn't cover and the kit can see from the - // filesystem. Everything lock-shaped is aqe's job now — see src/lib/rvf.mjs. - rows.push(row('aqe', 'fail', - `${findings.length} oversized RVF store(s) (runaway append) — quarantine before they eat the disk`, - 'sync quarantines them (aqe rebuilds the store)')); - } else { - rows.push(row('aqe', 'ok', 'agentic-qe initialized here; RVF store healthy')); - } - } else { - rows.push(row('aqe', 'info', 'agentic-qe not initialized in this project')); - } - - // agentdb (data-plane CLI `ak x harvest` drives). Pinned to ruflo's BUNDLED - // agentdb so the shared cognitive store never skews on the core version. - if (cfg.agentdb === false) { - rows.push(row('agentdb', 'info', 'agentdb management disabled in kit.json')); - } else { - const c = adbCoherence(); - if (!c.present) { - rows.push(row('agentdb', 'warn', 'agentdb CLI not installed (harvest write path unavailable)', - "setup/sync installs it (pinned to ruflo's bundled agentdb)")); - } else if (c.skew === 'core') { - rows.push(row('agentdb', 'warn', - `agentdb ${c.global} skewed from ruflo-bundled ${c.bundled} — shared-store corruption risk`, - "sync repins agentdb to ruflo's bundled version")); - } else { - rows.push(row('agentdb', 'ok', - `agentdb ${c.global}${c.bundled ? ` (coherent with ruflo${c.skew === 'prerelease' ? ' — prerelease diff' : ''})` : ''}`)); - } - } - - // MCP - const mcp = registrationStatus(); - if (mcp.claudeFlow) { - rows.push(row('mcp', 'ok', - `claude-flow registered (user scope)${mcp.denyCount ? `, ${mcp.denyCount} tool(s) denied by family exclusions` : ', all families allowed'}`)); - } else if (cfg.mcp.register) { - rows.push(row('mcp', 'warn', 'ruflo MCP not registered', 'setup/sync registers claude-flow at user scope')); - } else { - rows.push(row('mcp', 'info', 'MCP registration disabled in kit.json')); - } - if (mcp.legacyRuflo) { - rows.push(row('mcp', 'warn', "legacy 'ruflo'-keyed MCP registration present", 'sync migrates it to claude-flow')); - } - - // Retired Claude→Codex `codex mcp-server` projection (ADR-0033). Its absence - // is healthy; setup/sync remove only the prior agentic-kit-owned entry. - // User-owned entries are preserved and receive an explicit manual remedy. - if (cfg.integrations?.hosts?.codex) { - try { - const { registered, owned } = codexMcpStatus(cfg, cwd); - if (registered) { - rows.push(row('codex-mcp', 'warn', - `deprecated codex mcp-server registered${owned ? ' — agentic-kit-owned' : ' — user-owned; preserved'}`, - owned ? 'sync retires the legacy MCP entry' : 'remove manually: claude mcp remove codex -s project')); - } else { - rows.push(row('codex-mcp', 'ok', 'legacy codex mcp-server absent; supervised cross-host execution uses ak run')); - } - } catch (e) { - rows.push(row('codex-mcp', 'warn', `codex MCP check unavailable: ${e.message}`)); - } - // Independent Ruflo MCP integration lets a Codex-driven session reach the - // same routing, swarm, and memory tools as Claude. - try { - const { registered, owned, command, args } = rufloCodexMcpStatus(cfg); - const workspacePinned = command === 'ak' - && JSON.stringify(args) === JSON.stringify(['x', 'ruflo-mcp']); - if (registered && owned && !workspacePinned) { - rows.push(row('codex-mcp', 'warn', - 'ak-owned ruflo MCP in codex uses the legacy cwd-only launcher', - 'sync migrates it to workspace-pinned project memory')); - } else if (registered) { - rows.push(row('codex-mcp', 'ok', - `ruflo MCP registered in codex ([mcp_servers.ruflo])${owned ? ' — workspace memory pinned' : ' — pre-existing (not ak-managed)'}`)); - } else if (await have('codex')) { - rows.push(row('codex-mcp', 'warn', 'codex enabled but ruflo MCP not registered in codex', - 'sync registers the ruflo MCP into codex')); - } - } catch (e) { - rows.push(row('codex-mcp', 'warn', `ruflo→codex MCP check unavailable: ${e.message}`)); - } - - // Effective project+user topology. These checks are independent of the - // agentic-kit ownership receipt because recursive/duplicate transports can - // stall a Codex-driven worker even when another tool created them. - try { - const topology = codexMcpTopology({ cwd }); - if (topology.selfRegistrations.length) { - const scopes = topology.selfRegistrations.map((entry) => entry.scope).join(', '); - rows.push(row('codex-mcp', 'fail', - `recursive codex → codex mcp-server registration detected (${scopes})`, - 'remove the [mcp_servers.codex] table from the reported Codex config before live multi-host runs')); - } - if (!topology.agenticQeRegistrations.length) { - rows.push(row('codex-mcp', 'warn', 'agentic-qe MCP is not concretely registered in Codex', - 'run: aqe platform setup codex --overwrite --with-ruflo')); - } else { - rows.push(row('codex-mcp', 'ok', 'agentic-qe MCP concretely registered in Codex')); - } - if (topology.duplicateRuflo) { - rows.push(row('codex-mcp', 'warn', - `duplicate Ruflo MCP registrations in Codex: ${topology.rufloRegistrations.map((entry) => entry.name).join(', ')}`, - 'keep the workspace-aware [mcp_servers.ruflo] entry and remove legacy duplicates after reviewing ownership')); - } - } catch (e) { - rows.push(row('codex-mcp', 'warn', `Codex MCP topology check unavailable: ${e.message}`)); - } - } - - // Codex owns plugin installation, enablement, and refresh. Inspect every - // explicitly enabled cached plugin's hooks and skills, but never attach a - // sync fix: the supported repair surface is Codex's /plugins UI followed by - // a fresh session. - try { - const plugins = inspectCodexPlugins(); - if (plugins.enabled.length && plugins.issues.length) { - rows.push(row('codex-plugins', 'warn', - `${plugins.issues.length} Codex plugin compatibility issue(s): ${plugins.issues[0]}; ` - + 'open Codex /plugins to refresh or disable it, then start a new session')); - } else if (plugins.enabled.length) { - const versions = plugins.plugins.map((plugin) => `${plugin.ref} (${plugin.version})`).join(', '); - rows.push(row('codex-plugins', 'ok', - `${plugins.enabled.length} enabled Codex plugin(s); newest cached hooks and skills pass known compatibility checks (${versions})`)); - } - } catch (e) { - rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`)); - } + await runSections(SECTIONS_BEFORE_HOST_DETAIL, ctx, rows); rows.push(...(await collectDejaVuRows({ cfg, adapter: dejaVuAdapter, planOptions: dejaVuPlanOptions, @@ -805,381 +81,7 @@ export async function collect({ rows.push(...(await renderHostDetailRows({ cfg, pkgRoot, facts: integrationFacts }))); rows.push(...admittedLifecycleFallbackRows(cfg)); - // hosts (install-if-missing) — cheap: file read + `which`, no network. - // An enabled host that is entirely absent is installable by sync; an external - // install (mise/native/brew) is reported but never touched. - try { - // primary host absent = fail (nothing can drive); alternate absent = warn. - const primaryHost = cfg.routing?.primaryHost ?? 'claude'; - for (const h of HOSTS) { - if (!cfg.integrations.hosts[h.id]) continue; - const detected = integrationFacts.hosts[h.id]; - if (detected?.present === false) { - rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', - `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); - continue; - } - const st = await hostInstallState(h); - if (st.method === 'absent') { - rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', - `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); - } else { - rows.push(row('hosts', 'ok', `${h.id} ${st.version ?? ''} (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`)); - // auth mode (billing axis): oauth/subscription ($0) vs metered api-key. - // A distinct row so `ak status --json` (and the dashboard) can badge it. - const auth = hostAuthState(h.id, { present: true }); - const billing = auth.billing === 'subscription' ? 'subscription, $0' - : auth.billing === 'metered' ? 'metered' : auth.billing; - rows.push(row('hosts', auth.mode === 'none' ? 'warn' : 'ok', - `${h.id} auth: ${auth.mode} (${billing})${auth.source ? ` · ${auth.source}` : ''}${auth.note ? ` — ${auth.note}` : ''}`, - auth.mode === 'none' ? `${h.id} login` : null)); - } - } - } catch (e) { - rows.push(row('hosts', 'warn', `host check unavailable: ${e.message}`)); - } - - // providers (frontier host wiring) — light: `have` probe + env read, no --version - try { - const { file, scope } = settingsTarget(cwd); - const env = readJson(file, {})?.env ?? {}; - const externalRoot = paths.repoRoot(cwd); - const externalDisk = externalRoot ? (readJson(aqeRouterFile(externalRoot), {}) ?? {}) : {}; - const external = externalRoot - ? aqeExternalProviderState(externalDisk, { projectRoot: externalRoot }) - : null; - const configuredAdapterIds = new Set((cfg.hostAdapters ?? []) - .map((entry) => entry?.name).filter((name) => typeof name === 'string' && name)); - const builtinHostIds = new Set(HOSTS.map((host) => host.id)); - for (const id of Object.keys(cfg.integrations?.hosts ?? {})) { - if (!builtinHostIds.has(id)) configuredAdapterIds.add(id); - } - const externalIntent = new Set(); - if (configuredAdapterIds.has(cfg.providers?.aqeProvider)) externalIntent.add(cfg.providers.aqeProvider); - for (const entry of cfg.providers?.aqeFallback ?? []) { - if (configuredAdapterIds.has(entry?.provider)) externalIntent.add(entry.provider); - } - for (const route of Object.values(cfg.routing?.routes ?? {})) { - if (configuredAdapterIds.has(route?.host)) externalIntent.add(route.host); - for (const rung of route?.escalation ?? []) { - if (configuredAdapterIds.has(rung?.host)) externalIntent.add(rung.host); - } - } - const liveExternal = new Set(external?.desired ?? []); - const unavailableExternalIntent = [...externalIntent].filter((id) => !liveExternal.has(id)); - const unavailableExternalSet = new Set(unavailableExternalIntent); - if (isDefault(cfg)) { - // advisory only (no fix): opting codex in is a deliberate `ak host pick` - if (await have('codex')) { - rows.push(row('providers', 'info', 'codex CLI installed but not enabled (claude-only default)')); - } else { - rows.push(row('providers', 'info', 'claude-only (default host)')); - } - if (!cfg.integrations?.hosts?.opencode && await have('opencode')) { - rows.push(row('providers', 'info', 'opencode CLI installed but not enabled (`ak host pick --host claude,opencode` wires it)')); - } - } else { - const desired = managedEnv(cfg); - const envDrift = MANAGED_ENV_KEYS.some((k) => (k in desired ? env[k] !== desired[k] : k in env)); - // aqe fallback chain: on-disk llm-config.json must match kit.json order. - // Same scope gate as the writer (#129): applyAqeRouter anchors the file at - // repoRoot and declines outside a project, so the check must read the root - // and stay silent where sync would decline — a warn here would recommend a - // sync that cannot repair it. - const chain = cfg.providers.aqeFallback ?? []; - const chainRoot = paths.repoRoot(cwd); - let routerDrift = false; - if (chain.length && chainRoot) { - const disk = readJson(aqeRouterFile(chainRoot)); - const diskOrder = (disk?.fallbackChain?.entries ?? []).map((e) => e.provider).join('→'); - const liveOrder = chain.filter((entry) => !unavailableExternalSet.has(entry?.provider)) - .map((entry) => entry.provider).join('→'); - routerDrift = liveOrder - ? disk?._managedBy !== 'agentic-kit' || diskOrder !== liveOrder - : diskOrder !== ''; - } - const on = HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id).join('+') || 'none'; - const chainStr = chain.length ? `; aqe chain ${chain.map((e) => e.provider).join('→')}` : ''; - if (envDrift || routerDrift) { - rows.push(row('providers', 'warn', `provider config drifted (want ${on}${chainStr}, ${scope})`, 'sync re-applies provider env + aqe router')); - } else { - rows.push(row('providers', 'ok', `wired: ${on}${chainStr} (${scope})`)); - } - // Chain VIABILITY, separate from chain ORDER above: a chain in the right - // order whose rungs have no credential fails over into nothing (#54). Warn, - // not fail — the primary rung still works — and no `fix`, since only the - // user can supply a key. - const credentialChain = chain.filter((entry) => !unavailableExternalSet.has(entry?.provider)); - if (credentialChain.length) { - const gaps = credentialGaps(credentialChain); - if (gaps.length) { - rows.push(row('providers', 'warn', - `aqe chain: ${credentialChain.length - gaps.length}/${credentialChain.length} rungs have credentials ` - + `(${gaps.map((g) => `${g.provider}: needs ${g.missing.join(', ')}`).join('; ')})`)); - } else { - rows.push(row('providers', 'ok', `aqe chain: ${credentialChain.length}/${credentialChain.length} rungs have credentials`)); - } - } - } - if (unavailableExternalIntent.length) { - rows.push(row('providers', 'warn', - `external AQE intent is unavailable (${unavailableExternalIntent.join(', ')}) — restore its admission/host/grant, or retire only its dependent intent with ` - + `\`ak host adapters revoke-grant ${unavailableExternalIntent[0]} aqeProvider\``)); - } - if (externalRoot && external) { - if (external.desired.length || external.stale.length) { - const defaultDrift = external.desired.includes(cfg.providers?.aqeProvider) - && externalDisk.defaultProvider !== cfg.providers.aqeProvider; - if (!external.supported) { - rows.push(row('providers', 'warn', - `external AQE providers admitted but installed agentic-qe needs >=${EXTERNAL_PROVIDERS_MIN_AQE}`)); - } else if (!external.ok || defaultDrift) { - const facts = [ - external.missing.length ? `missing ${external.missing.join(', ')}` : '', - external.drifted.length ? `drifted/conflicting ${external.drifted.join(', ')}` : '', - external.stale.length ? `stale owned ${external.stale.join(', ')}` : '', - defaultDrift ? `default is not ${cfg.providers.aqeProvider}` : '', - ].filter(Boolean).join('; '); - rows.push(row('providers', 'warn', `external AQE projection out of sync (${facts})`, 'sync reconciles only ak-owned entries')); - } else { - rows.push(row('providers', 'ok', - `external AQE providers projected: ${external.desired.join(', ')} (declared/admitted; served inference not yet proven)`)); - } - } - } - // A kit.json provider/model entry is registration intent. Ruflo >=3.38.8 - // can honor explicit OpenRouter/Ollama provider+model selection, but the - // registry does not retarget every agent and it is not execution evidence. - // Keep that distinction in the status rows the dashboard consumes. - const rufloModels = cfg.providers?.models ?? []; - if (rufloModels.length) { - const intent = rufloModels - .filter((entry) => entry?.id) - .map((entry) => `${entry.id}${entry.model ? `:${entry.model}` : ''}`) - .join(', '); - const rufloVersion = installedVersion('ruflo'); - const affected = !!rufloVersion - && cmpVersions(rufloVersion, MIN_RUFLO_PERSISTED_PROVIDER_VERSION) < 0; - const missingOpenRouterKey = rufloModels.some((entry) => entry?.id === 'openrouter') - && !integrationFacts.providers?.openrouter?.credentialPresent; - const directIds = new Set(['ollama', 'openrouter']); - const registryOnly = [...new Set(rufloModels - .map((entry) => entry?.id) - .filter((id) => id && !directIds.has(id)))]; - if (affected) { - rows.push(row('providers', 'warn', - `ruflo provider intent: ${intent} — ruflo ${rufloVersion} cannot honor persisted provider/model execution; needs >=${MIN_RUFLO_PERSISTED_PROVIDER_VERSION}`)); - } else if (missingOpenRouterKey) { - rows.push(row('providers', 'warn', - `ruflo provider intent: ${intent} — direct agents must select provider + model; openrouter needs OPENROUTER_API_KEY in the Ruflo/MCP process`)); - } else { - const unsupported = registryOnly.length - ? `; no direct-agent execution branch for ${registryOnly.join(', ')}` - : ''; - rows.push(row('providers', 'info', - `ruflo provider intent: ${intent} — direct agents must select provider + model; Usage proves served execution${unsupported}`)); - } - } - // ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT - // projected to 'aqe' (unlike ollama, which is) — surface that asymmetry - // plainly so it reads as a fact, not a bug. Registry-driven (billing + - // projections), not an id check, so any future provider of the same - // shape gets the same treatment for free. - const providerById = Object.fromEntries(PROVIDER_REGISTRY.map((p) => [p.id, p])); - for (const binding of cfg.integrations?.bindings ?? []) { - const provider = providerById[binding.provider]; - if (!provider || provider.billing !== 'local' || provider.projections.includes('aqe')) continue; - const endpoint = binding.endpoint ? ` @ ${binding.endpoint}` : ''; - rows.push(row('providers', 'info', - `local binding: ${binding.provider} via ${binding.host}${endpoint} (${provider.billing} $0; not an AQE provider type)`)); - } - } catch (e) { - rows.push(row('providers', 'warn', `provider check unavailable: ${e.message}`)); - } - - // Per-activity routing (canonical routes → agentOverrides projection). Only surfaces - // once a policy is set; the dashboard renders this row like any other subsystem. - try { - const policy = cfg.routing?.routes ?? {}; - if (Object.keys(policy).length) { - const s = routingSummary(policy); - // The WRITER's projection (#129): applyAqeRouter materializes only - // explicitly persisted routes, so status must count and compare the same - // set — the resolved projection would demand entries sync never writes. - const want = configuredPolicyToAgentOverrides(policy); - const base = `dual-host · ${s.total} activities (${s.custom} custom) → ${Object.keys(want).length} agent overrides`; - if (!aqeSupportsAgentOverrides()) { - rows.push(row('routing', 'info', `${base} · needs agentic-qe ≥ 3.13.1 to materialize`)); - } else { - // Same scope gate as the writer: applyAqeRouter anchors at repoRoot(cwd) - // and declines outside a project — a raw-cwd read from a subdir would - // false-warn "out of sync" (M2), and outside a project a warn would - // recommend a sync that cannot repair it (#129). - const root = paths.repoRoot(cwd); - if (!root) { - rows.push(row('routing', 'info', `${base} · not in a project — aqe router unmanaged here`)); - } else { - const overrides = readJson(aqeRouterFile(root))?.agentOverrides; - const drift = overrides == null || agentOverridesDrift(overrides, policy); - if (drift) rows.push(row('routing', 'warn', `${base} — llm-config.json out of sync`, 'sync re-applies agentOverrides')); - else rows.push(row('routing', 'ok', base)); - } - } - // Seeded pins vs today's defaults. Deliberately `info` and deliberately - // "diverges from": which side wins is activity-dependent (a newer default - // can cost 2-3× the agentic turns on routine work), so a `warn` would push - // users to spend turns clearing a lint. No `fix` — sync must never - // auto-refresh a pin; `ak host refresh` is the opt-in path (#55). - const diverged = divergedRoutes(policy); - if (diverged.length) { - const pairs = [...new Set(diverged.flatMap((d) => [ - ...(d.modelDiverged ? [`${d.model} vs ${d.defaultModel}`] : []), - ...d.escalation.map((e) => `${e.model} vs ${e.defaultModel} (escalation)`), - ]))].join(', '); - rows.push(row('routing', 'info', - `${diverged.length} seeded route(s) diverge from current defaults (${pairs}) — ak host refresh`)); - } - } - } catch (e) { - rows.push(row('routing', 'warn', `routing check unavailable: ${e.message}`)); - } - - // daemons - try { - const daemons = await listDaemons({ cwd }); - const stale = staleDaemons(daemons); - if (stale.length) { - rows.push(row('daemons', 'warn', - `${daemons.length} running, ${stale.length} stale (orphaned or past TTL)`, 'sync reaps stale daemons')); - } else { - rows.push(row('daemons', 'ok', - daemons.length ? `${daemons.length} running (one per active project is expected)` : 'none running')); - } - } catch (e) { - rows.push(row('daemons', 'warn', `daemon check unavailable: ${e.message}`)); - } - - // guidance-file blocks (dry-run reconcile = drift report). Three targets - // (guidanceTargets): machine-wide ~/.claude/CLAUDE.md (claude), the project - // /AGENTS.md (agents), and — only when ~/.codex exists — machine-wide - // ~/.codex/AGENTS.md (agents-user). The dual-mode block is gated on both hosts - // being enabled (flag detector), so the agents targets stay unmanaged/quiet - // until dual mode is on. retiredForTarget force-strips re-scoped blocks (the - // migration path that clears the dual block from any project AGENTS.md). - try { - const rowsReg = registry(cfg.customBlocks); - const resolve = (r) => (r.custom - ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) - : path.join(pkgRoot, 'claude', r.template)); - const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; - for (const t of guidanceTargets({ cwd, cfg })) { - const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; - const res = await syncBlocks(t.file, treg, resolve, { dryRun: true, context: ctx }); - const drift = res.filter((r) => r.action === 'upserted' || r.action === 'stripped'); - const missing = res.filter((r) => r.action === 'missing-template'); - // The agents targets are unmanaged on single-host setups — stay quiet - // unless there's actual drift (e.g. a block to strip after disabling dual - // mode) or a missing template. Only the claude target always reports. - if (t.name !== 'claude' && drift.length === 0 && missing.length === 0) continue; - if (drift.length) { - rows.push(row('blocks', 'warn', - `${drift.length} ${t.label} block(s) drifted: ${drift.map((d) => `${d.slug}→${d.action.replace('ped', 'p')}`).join(', ')}`, - 'sync reconciles blocks')); - } else { - rows.push(row('blocks', 'ok', `${t.label} managed blocks in sync (${res.length} in registry)`)); - } - for (const m of missing) rows.push(row('blocks', 'warn', `template missing for block '${m.slug}'`)); - } - } catch (e) { - rows.push(row('blocks', 'warn', `block check unavailable: ${e.message}`)); - } - - // statusline footer (project scope) - const sl = paths.projectStatusline(cwd); - if (fs.existsSync(sl)) { - const slSrc = fs.readFileSync(sl, 'utf8'); - const hasFooter = slSrc.includes('ruflo-seg:BEGIN'); - // Drift is "would a sync CHANGE this file?", which fixStatusline's dry run answers - // exactly. A marker-presence test alone cannot see CONTENT drift: after a kit upgrade - // revises the footer or the security overlay, the marker is still there, this row - // reports 'ok', and — because sync builds its plan from rows carrying a `fix` — the - // re-injection never runs and the stale block survives indefinitely. Observed live: - // an updated overlay silently failed to land for exactly this reason. - let wouldChange = !hasFooter; - try { wouldChange = fixStatusline(cwd, { dryRun: true }).applied; } catch { /* keep marker fallback */ } - // Armed wipe: the footer can be present AND current while ruflo's helper - // stamp lags the installed CLI — the next ruflo command (in practice the - // daemon start) then pristine-copies statusline.cjs over ours. That is how - // the footer kept vanishing BETWEEN syncs. Surface it as the same drift - // story; sync closes it by refreshing the helpers before re-injecting. - let stampStale = false; - try { stampStale = helperStampStale(cwd); } catch { /* best-effort */ } - rows.push(row('statusline', (wouldChange || stampStale) ? 'warn' : 'ok', - wouldChange - ? (hasFooter ? 'injected blocks are out of date' : 'statusline present but footer missing') - : stampStale - ? 'footer present but ruflo helper stamp is stale — next ruflo command wipes it' - : 'activation footer present and current', - (wouldChange || stampStale) ? 'sync refreshes helpers, then re-injects the footer' : null)); - // The CVE-counter overlay is tracked SEPARATELY from the footer: a footer-only - // check reports 'ok' while the statusline still renders ruflo's fabricated - // "⚠ 3 CVEs" (hardcoded totalCves, cvesFixed from a file count). Only warn while - // the upstream defect is actually present — once ruflo fixes getSecurityStatus - // the overlay is intentionally absent, and this row must go quiet on its own - // rather than nag for a patch that is no longer wanted. - if (upstreamCveCounterFabricated()) { - const patched = slSrc.includes('ruflo-sec:BEGIN'); - rows.push(row('statusline/cve', patched ? 'ok' : 'warn', - patched - ? 'CVE counter overlaid with real scan results' - : 'statusline shows ruflo\'s fabricated CVE count (upstream defect)', - patched ? null : 'sync injects the security overlay')); - } - } else { - rows.push(row('statusline', 'info', 'no project statusline here (created by setup)')); - } - // Codex has a native user-scoped line, but no command-backed rich renderer. - if (cfg.integrations?.hosts?.codex || cfg.statusline?.codex) { - const codexLine = statuslineDrift(cfg); - if (!codexLine.owned) { - rows.push(row('codex-statusline', 'info', - 'Codex native status line is unmanaged — opt in with `ak x statusline codex native`')); - } else if (codexLine.drifted) { - rows.push(row('codex-statusline', 'warn', - `managed Codex ${codexLine.preset} status line has drifted`, - 'sync restores the selected native preset')); - } else { - rows.push(row('codex-statusline', 'ok', - `managed Codex ${codexLine.preset} native status line is current (rich ruflo/SONA/AQE segments remain Claude-only)`)); - } - } - if (cfg.integrations?.hosts?.opencode) { - rows.push(row('statusline', 'info', - 'opencode has no statusline surface; its ruflo lifecycle ships via the plugins/ bridge + AGENTS.md')); - } - - // qe-court (ADR-124): read-only awareness. agentic-qe >=3.13.3 owns config - // validation and ships a valid default; ak reports existing project state - // but never rewrites the skill's config. - if (qeCourtShipped()) { - const qcRoot = paths.repoRoot(cwd); - const qc = qcRoot ? readQeCourtConfig(qcRoot) : null; - if (qc) { - const violations = validateCourtConfig(qc); - if (violations.length) { - rows.push(row('qe-court', 'warn', - `qe-court panel invalid: ${violations.join(', ')} — regenerate with agentic-qe >=3.13.3 or choose different defense/jury vendors`)); - } else { - const readiness = qeCourtReadiness(qcRoot); - if (readiness.ready) { - rows.push(row('qe-court', 'ok', 'qe-court routing and consumer artifacts are ready; provider-seat readiness still requires a live proof')); - } else { - rows.push(row('qe-court', 'warn', - `qe-court routing config passes the local anti-collusion check, but executability is not proven (${readiness.artifactIssues.join('; ')})`)); - } - } - } - } + await runSections(SECTIONS_AFTER_HOST_DETAIL, ctx, rows); return rows; } diff --git a/src/commands/status/deja-vu.mjs b/src/commands/status/deja-vu.mjs new file mode 100644 index 0000000..afb57ea --- /dev/null +++ b/src/commands/status/deja-vu.mjs @@ -0,0 +1,193 @@ +// The deja-vu companion status collector. Five concerns feed one orchestrator: +// error mapping, the install ladder, doctor health, the per-host target +// ladder, and the derived-index ladder. Each is its own small function so a +// change to one ladder can't accidentally reach into another; the +// orchestrator just assembles their rows in the same order the monolithic +// version used to push them. +import { companionLifecycleFor } from '../../lib/adapters/companion-lifecycle-registry.mjs'; +import { row } from './row.mjs'; + +const DEJA_HOSTS = Object.freeze(['claude', 'codex', 'opencode']); +const SAFE_VERSION = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function hasDejaVuOwnership(cfg) { + const ownership = cfg?.integrations?.ownership?.dejaVu; + return !!ownership?.install + || (!!ownership?.targets && typeof ownership.targets === 'object' + && Object.keys(ownership.targets).length > 0); +} + +function safeDejaCode(value) { + return typeof value === 'string' && /^[a-z0-9-]{1,80}$/.test(value) + ? value : 'unavailable'; +} + +function safeDejaVersion(value) { + return typeof value === 'string' && SAFE_VERSION.test(value) ? value : 'unknown'; +} + +function dejaErrorRow(facts, plan) { + if (!facts?.error && !plan?.error) return null; + const code = safeDejaCode(facts?.error ?? plan?.error); + const external = code === 'deja-external-version-unsupported' + || code === 'deja-external-install-unusable'; + return row('deja-vu', external ? 'warn' : 'fail', external + ? `external deja-vu installation is not safely manageable (${code}); preserved` + : `deja-vu health contract failed closed (${code})`); +} + +function dejaInstallRows(facts, { enabled, hasOperation }) { + const install = facts?.install ?? {}; + if (install.binaryPresent === false) { + if (enabled && hasOperation('package-install')) { + return [row('deja-vu', 'warn', 'deja-vu package missing', + 'sync installs the managed npm companion')]; + } + if (install.ownership === 'external') { + return [row('deja-vu', 'warn', 'external deja-vu package is unavailable; preserved')]; + } + return [row('deja-vu', 'info', 'deja-vu package absent')]; + } + if (install.binaryPresent === true) { + const version = safeDejaVersion(install.version); + if (hasOperation('package-upgrade')) { + return [row('deja-vu', 'warn', + `managed deja-vu ${version} has an available package upgrade`, + 'sync upgrades the owned npm companion')]; + } + if (install.receiptState === 'drifted' || install.receiptState === 'malformed') { + return [row('deja-vu', 'warn', + `deja-vu ${version} package ownership receipt drifted; current installation preserved`)]; + } + if (install.ownership === 'agentic-kit') { + return [row('deja-vu', install.supported === false ? 'warn' : 'ok', + `managed deja-vu ${version} package${install.supported === false ? ' is below v0.19.0' : ' is present'}`)]; + } + return [row('deja-vu', 'info', + `external deja-vu ${version} package detected; installation remains user-owned`)]; + } + return []; +} + +function dejaDoctorRows(facts) { + if (facts?.doctor?.state !== 'ok') return []; + if (facts.doctor.health?.state === 'degraded') { + const issueCount = Number.isSafeInteger(facts.doctor.health.storeIssues) + ? Math.min(facts.doctor.health.storeIssues, 999) : 0; + return [row('deja-vu', 'warn', + `deja-vu doctor schema v2 accepted but component health is degraded${issueCount > 0 + ? ` (${issueCount} bounded store issue${issueCount === 1 ? '' : 's'})` : ''}`)]; + } + return [row('deja-vu', 'ok', 'deja-vu doctor schema v2 accepted')]; +} + +// Split from the ladder below purely to keep each function's cyclomatic +// complexity in range — the guard clause and its two derived booleans pull a +// lot of optional-chaining branches on their own. +function dejaTargetContext(host, { facts, cfg, hasOperation }) { + const target = facts?.targets?.[host]; + const receipt = cfg?.integrations?.ownership?.dejaVu?.targets?.[host]; + const receiptPresent = !!receipt; + if (!target || (!target.selected && !receiptPresent)) return null; + const hasTargetOperation = hasOperation('target-remove', host) + || hasOperation('target-install', host); + const managedTransition = hasTargetOperation && receipt?.mode + && receipt.mode !== facts?.desired?.mode; + return { target, hasTargetOperation, managedTransition }; +} + +function dejaTargetRows(host, ctx) { + const found = dejaTargetContext(host, ctx); + if (!found) return []; + const { target, hasTargetOperation, managedTransition } = found; + if (target.receiptState === 'drifted') { + return [row('deja-vu', 'warn', `${host}: managed target ownership drifted; current wiring preserved`)]; + } + if (target.conflict === 'external-auto-active' && !managedTransition) { + return [row('deja-vu', 'warn', + `${host}: external automatic recall conflicts with MCP-only intent; external state preserved`)]; + } + if (target.satisfied) { + return [row('deja-vu', target.ownership === 'agentic-kit' ? 'ok' : 'info', + `${host}: ${target.desiredTarget ?? 'deja-vu'} target active` + + (target.ownership === 'agentic-kit' ? ' and receipt-owned' : ' via external wiring'))]; + } + if (hasTargetOperation) { + return [row('deja-vu', 'warn', + `${host}: managed deja-vu target requires convergence`, + `sync converges the exact ${host} target`)]; + } + if (target.hostPresent === false) { + return [row('deja-vu', 'warn', `${host}: selected host is unavailable; companion wiring skipped`)]; + } + return [row('deja-vu', 'warn', `${host}: desired deja-vu target is not proven; external state preserved`)]; +} + +function dejaIndexRows(facts, { enabled, hasOperation }) { + if (!(enabled && facts?.desired?.indexOnSetup)) return []; + if (facts?.index?.state === 'ok') return [row('deja-vu', 'ok', 'deja-vu derived index is healthy')]; + if (hasOperation('index')) { + return [row('deja-vu', 'warn', + `deja-vu derived index is ${['missing', 'stale'].includes(facts?.index?.state) + ? facts.index.state : 'not ready'}`, + 'sync runs one bounded deja index after target convergence')]; + } + if (facts?.index?.state === 'stale-readonly') { + return [row('deja-vu', 'warn', 'deja-vu derived index is stale-readonly; automatic repair is unsafe')]; + } + if (facts?.index?.state !== undefined) { + return [row('deja-vu', 'info', 'deja-vu derived index health is unknown')]; + } + return []; +} + +/** + * Render the managed companion from its bounded lifecycle facts. No upstream + * path, command output, signature, transcript metadata, or plugin payload is + * copied into a row. A fix is attached only when the same adapter plan contains + * an operation that can perform it. + * @param {{cfg?:any,adapter?:any,planOptions?:Record}} [options] + */ +export async function collectDejaVuRows(options = {}) { + const { + cfg, + adapter = companionLifecycleFor('deja-vu'), + planOptions = {}, + } = options; + const desired = cfg?.integrations?.tools?.dejaVu; + const enabled = desired?.enabled === true; + const owned = hasDejaVuOwnership(cfg); + if (!enabled && !owned) { + return [row('deja-vu', 'info', + 'deja-vu disabled — package, host wiring, and history remain unprobed')]; + } + if (!adapter) { + return [row('deja-vu', 'warn', 'deja-vu lifecycle adapter unavailable')]; + } + + try { + const facts = await adapter.detect({ cfg }); + const plan = await adapter.plan({ cfg, facts, options: planOptions }); + const operations = Array.isArray(plan?.operations) ? plan.operations : []; + const actionable = !facts?.error && !plan?.error; + const hasOperation = (kind, host = null) => actionable && operations.some((operation) => + operation?.kind === kind && (host === null || operation.host === host)); + + const rows = [ + dejaErrorRow(facts, plan), + ...dejaInstallRows(facts, { enabled, hasOperation }), + ...dejaDoctorRows(facts), + ...DEJA_HOSTS.flatMap((host) => dejaTargetRows(host, { facts, cfg, hasOperation })), + ...dejaIndexRows(facts, { enabled, hasOperation }), + ].filter(Boolean); + + if (plan?.warnings?.includes('deja-package-latest-unavailable')) { + rows.push(row('deja-vu', 'warn', + 'managed deja-vu package is usable, but npm latest-version drift could not be verified')); + } + + return rows.length ? rows : [row('deja-vu', 'info', 'deja-vu state is unobserved')]; + } catch { + return [row('deja-vu', 'warn', 'deja-vu status unavailable')]; + } +} diff --git a/src/commands/status/host-detail.mjs b/src/commands/status/host-detail.mjs new file mode 100644 index 0000000..189f9c5 --- /dev/null +++ b/src/commands/status/host-detail.mjs @@ -0,0 +1,225 @@ +// Per-host status DETAIL rows — beyond the generic install/auth rows the +// `hosts` loop in collect() already renders from facts for every host alike. +// A detail renderer owns everything specific to how ONE host proves itself +// wired (config files, lifecycle bridges, converted artifacts, …); the +// opencode-specific PROBES/MESSAGES live in the renderer below and in +// lib/opencode.mjs, never in the dispatch loop itself. Adding a fourth host +// means adding (or not adding) a table entry here — the loop that walks this +// table never changes. +import { + opencodeMcpStatus, catalogSource, createOpencodeLifecycleAdapter, + opencodeArtifactReceiptState, +} from '../../lib/opencode.mjs'; +import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../../lib/adapters/lifecycle-registry.mjs'; +import { row } from './row.mjs'; + +function opencodeWiringRow(st, conv) { + if (st.parseError) { + return row('opencode', 'warn', + 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', + 'merge the ak wiring manually'); + } + if (!st.exists || !st.claudeFlow) { + return row('opencode', 'warn', + `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`, + 'sync writes the opencode wiring'); + } + if (!conv?.converged) { + return row('opencode', 'warn', + `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? '…' : ''})`, + 'sync re-applies the opencode wiring'); + } + return row('opencode', 'ok', + `opencode.json converged (claude-flow${st.aqe ? ' + agentic-qe' : ''}${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`); +} + +// Shared adoptable → foreign → absent → stale [→ not-required] [→ ok] ladder +// behind the plugin/gateway/skill artifacts (ADR-complexity-program #3): each +// used to repeat this exact decision tree with its own `!receiptState. +// adoptionBlocked &&` guard on every branch. The caller supplies only what +// differs per artifact — its label and its exact message/fix text — so the +// control flow lives in exactly one place. Returns `null` when the artifact +// is fine and has no `okMessage` configured (silence, matching plug/skill). +function artifactRow(subsystem, label, state, opts) { + const { + foreignMessage, + absentMessage, + absentFix = 'sync deploys it', + staleMessage, + staleFix = 'sync rewrites it', + required = true, + notRequiredMessage, + notRequiredFix, + okMessage, + } = opts; + if (state.adoptable) { + return row(subsystem, 'warn', `${label} is exact and marker-bearing but lacks an ownership receipt`, + 'sync adopts it into the receipt ledger without rewriting it'); + } + if (state.foreign) return row(subsystem, 'info', foreignMessage); + if (!required) { + return (state.present && notRequiredMessage) ? row(subsystem, 'warn', notRequiredMessage, notRequiredFix) : null; + } + if (!state.present) return row(subsystem, 'warn', absentMessage, absentFix); + if (!state.current) return row(subsystem, 'warn', staleMessage, staleFix); + return okMessage ? row(subsystem, 'ok', okMessage) : null; +} + +// The agents artifact doesn't fit the adoptable/foreign/absent/stale shape +// above — its branches are counted-projection facts, not a single present/ +// current pair — so it keeps its own ladder, just lifted out of the mega +// function. +function opencodeAgentsRow({ ag, gateway, source }) { + const lazyAgents = gateway.required && gateway.current && ag.count === 1; + if (ag.adoptable) { + return row('opencode', 'warn', + `${ag.count} exact marker-bearing agent projection or stamp lacks ownership receipts`, + 'sync adopts them into the receipt ledger without rewriting them'); + } + if (ag.count === 0 && !source) { + return row('opencode', 'warn', + 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', + 'install ruflo (or claude marketplace) for the agent catalog'); + } + if (ag.count === 0) { + return row('opencode', 'warn', 'no Agentic Kit specialist projection', 'sync deploys the specialist dispatcher'); + } + if (ag.modified) { + return row('opencode', 'info', `${ag.count} agent projection files include user edits — ak leaves those files alone`); + } + if (ag.stale) { + return row('opencode', 'warn', + `${ag.count} agent projection files from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, + 'sync refreshes the agent projection'); + } + return row('opencode', 'ok', lazyAgents + ? `lazy specialist dispatcher current (${ag.currentId})` + : `${ag.count} converted agents (${ag.currentId})`); +} + +// The loop also passes `hostId`; this renderer doesn't need it (it IS the +// opencode renderer) but the signature admits it so the dispatch call site +// typechecks for every renderer uniformly. +export async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'opencode' } = /** @type {any} */ ({})) { + const rows = []; + try { + if (!facts.hosts?.opencode?.present) { + rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)')); + return rows; + } + const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir }); + const st = opencodeMcpStatus(cfg); + const lifecycle = await createOpencodeLifecycleAdapter({ pkgRoot }).detect({ cfg }); + const conv = st.parseError ? null : lifecycle.convergence; + rows.push(opencodeWiringRow(st, conv)); + + const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed); + if (receiptState.adoptionBlocked) { + rows.push(row('opencode', 'warn', + 'artifact receipt ledger is malformed — ownership adoption blocked; artifacts left untouched', + 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup')); + return rows; + } + + const plugRow = artifactRow('opencode', 'lifecycle plugin', lifecycle.plugin, { + foreignMessage: 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js — ak leaves it alone', + absentMessage: 'lifecycle plugin (ruflo-hooks.js) not deployed', + staleMessage: 'lifecycle plugin out of date', + }); + if (plugRow) rows.push(plugRow); + + const gatewayRow = artifactRow('opencode', 'lazy rUv gateway', lifecycle.gateway, { + foreignMessage: 'lazy rUv gateway slot is user-owned — direct MCP exposure is preserved', + absentMessage: 'lazy rUv gateway not deployed', + staleMessage: 'lazy rUv gateway out of date', + required: lifecycle.gateway.required, + notRequiredMessage: 'lazy rUv gateway is no longer required', + notRequiredFix: 'sync retires it', + okMessage: 'Ruflo and Agentic QE connected; compact ak_* gateway projection active', + }); + if (gatewayRow) rows.push(gatewayRow); + + rows.push(opencodeAgentsRow({ ag: lifecycle.agents, gateway: lifecycle.gateway, source })); + + const skillRow = artifactRow('opencode', 'platform skill', lifecycle.skill, { + foreignMessage: 'skills/ruflo/SKILL.md is user-owned — ak leaves it alone', + absentMessage: 'platform skill (skills/ruflo/SKILL.md) not deployed', + staleMessage: 'platform skill out of date', + staleFix: 'sync re-deploys it', + required: !!source?.hasPlatformSkill, + }); + if (skillRow) rows.push(skillRow); + } catch (e) { + rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`)); + } + return rows; +} + +// Dispatch table: host id → detail renderer. CONTRACT: a renderer must catch +// its own errors and degrade to a warn row — the dispatch loop deliberately +// has no catch, so an uncaught throw would take down ALL of collect(), not +// just this host. A host absent from this table +// gets no detail rows here (its install/auth state still comes from the +// `hosts` loop, which is already host-neutral). This is the ONLY place a new +// host's status detail wiring gets registered. +const HOST_DETAIL_RENDERERS = { opencode: opencodeDetailRows }; + +/** Host-neutral dispatch loop: walks `renderers` (defaults to the table + * above) and, for each host enabled in cfg, calls its renderer with the + * shared facts snapshot. Exported (not just used internally) so a test can + * prove a synthetic host renders through this exact loop — with no host-id + * branching anywhere in the loop body — by injecting its own renderers map + * instead of reaching into module internals. */ +export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HOST_DETAIL_RENDERERS }) { + const rows = []; + for (const [hostId, renderer] of Object.entries(renderers)) { + if (!cfg.integrations?.hosts?.[hostId]) continue; + rows.push(...(await renderer({ cfg, pkgRoot, facts, hostId }))); + } + return rows; +} + +/** Sync reachability gap (ADR-0031 P3 known limitation): setup.mjs and + * uninstall.mjs's admitted-host lifecycle loops (ADR-0031 P3) already iterate + * hostsWithLifecycle() and run for real; sync.mjs's twin loop is gated on + * BOTH lifecycleExecutionEnabled(hostId, cfg) AND `subsystems.has(hostId)`, + * where subsystems is `new Set(plan.map(p => p.subsystem))` derived straight + * from THIS collector's rows (sync.mjs). An admitted host with no + * HOST_DETAIL_RENDERERS entry (only opencode has one) produced no row at + * all, so its subsystem could never appear in the plan and sync's branch was + * unreachable for a real admitted host — even fully enabled, flag on, CLI + * present. This closes that gap: any admitted (never built-in) lifecycle + * host that lifecycleExecutionEnabled() actually gates IN for this run gets + * exactly one subsystem-tagged row, `subsystem === hostId` — deliberately + * the same identity opencode's own renderer uses (subsystem 'opencode' === + * HOST_DETAIL_RENDERERS key 'opencode'), so sync's `subsystems.has(hostId)` + * finds it. + * + * Deliberately lean, not a per-surface renderer like opencodeDetailRows: an + * arbitrary admitted host's only introspection surface is its own declared + * detect/verify hooks (a subprocess spawn), which this read-only, cheap + * collector does not invoke — so the row cannot report real drift and always + * carries a `fix` while the gate holds. Convergence is left to the adapter's + * own apply, which lifecycle.mjs's contract requires to be idempotent. + * Excludes built-in hosts (opencode already has a bespoke renderer above; + * a future built-in lifecycle host with no renderer is a gap for its own + * renderer to close, not this fallback) and any host already present in + * `renderers` (never double-reports one host under two mechanisms). Isolated + * per host, mirroring the per-renderer try/catch contract above — one + * admitted host's failure must not take down collect() or any other host's + * row. */ +export function admittedLifecycleFallbackRows(cfg, renderers = HOST_DETAIL_RENDERERS) { + const rows = []; + for (const hostId of hostsWithLifecycle()) { + if (isBuiltinHost(hostId) || hostId in renderers) continue; + try { + if (!lifecycleExecutionEnabled(hostId, cfg)) continue; + rows.push(row(hostId, 'warn', + `${hostId}: external lifecycle host, enabled — sync will converge its hooks`, + `sync applies the ${hostId} lifecycle adapter`)); + } catch (e) { + rows.push(row(hostId, 'warn', `${hostId} lifecycle status unavailable: ${e.message}`)); + } + } + return rows; +} diff --git a/src/commands/status/row.mjs b/src/commands/status/row.mjs new file mode 100644 index 0000000..d730868 --- /dev/null +++ b/src/commands/status/row.mjs @@ -0,0 +1,6 @@ +// Shared row constructor for every `ak status` section. One row = one +// subsystem fact: a level (ok/info/warn/fail), a human message, and an +// optional `fix` string that `ak sync` uses to build its plan. Load-bearing: +// sync derives its plan from these exact fields, so no section may reshape +// this contract. +export const row = (subsystem, level, message, fix = null) => ({ subsystem, level, message, fix }); diff --git a/src/commands/status/sections/agentdb.mjs b/src/commands/status/sections/agentdb.mjs new file mode 100644 index 0000000..e4aec9e --- /dev/null +++ b/src/commands/status/sections/agentdb.mjs @@ -0,0 +1,25 @@ +// agentdb (data-plane CLI `ak x harvest` drives). Pinned to ruflo's BUNDLED +// agentdb so the shared cognitive store never skews on the core version. +import { coherence as adbCoherence } from '../../../lib/agentdb.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'agentdb', + async collect({ cfg }) { + if (cfg.agentdb === false) { + return [row('agentdb', 'info', 'agentdb management disabled in kit.json')]; + } + const c = adbCoherence(); + if (!c.present) { + return [row('agentdb', 'warn', 'agentdb CLI not installed (harvest write path unavailable)', + "setup/sync installs it (pinned to ruflo's bundled agentdb)")]; + } + if (c.skew === 'core') { + return [row('agentdb', 'warn', + `agentdb ${c.global} skewed from ruflo-bundled ${c.bundled} — shared-store corruption risk`, + "sync repins agentdb to ruflo's bundled version")]; + } + return [row('agentdb', 'ok', + `agentdb ${c.global}${c.bundled ? ` (coherent with ruflo${c.skew === 'prerelease' ? ' — prerelease diff' : ''})` : ''}`)]; + }, +}; diff --git a/src/commands/status/sections/aqe.mjs b/src/commands/status/sections/aqe.mjs new file mode 100644 index 0000000..4523a2c --- /dev/null +++ b/src/commands/status/sections/aqe.mjs @@ -0,0 +1,25 @@ +// aqe / RVF (project scope) +import fs from 'node:fs'; +import * as paths from '../../../lib/paths.mjs'; +import { scanRvf } from '../../../lib/rvf.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'aqe', + async collect({ cwd }) { + const aqeDir = paths.projectAqeDir(cwd); + if (!fs.existsSync(aqeDir)) { + return [row('aqe', 'info', 'agentic-qe not initialized in this project')]; + } + const findings = scanRvf(aqeDir); + if (findings.length) { + // Oversized = the #495 runaway-append mode, the one RVF failure aqe's own + // self-healing (>= 3.12.3) doesn't cover and the kit can see from the + // filesystem. Everything lock-shaped is aqe's job now — see src/lib/rvf.mjs. + return [row('aqe', 'fail', + `${findings.length} oversized RVF store(s) (runaway append) — quarantine before they eat the disk`, + 'sync quarantines them (aqe rebuilds the store)')]; + } + return [row('aqe', 'ok', 'agentic-qe initialized here; RVF store healthy')]; + }, +}; diff --git a/src/commands/status/sections/blocks.mjs b/src/commands/status/sections/blocks.mjs new file mode 100644 index 0000000..bd1df08 --- /dev/null +++ b/src/commands/status/sections/blocks.mjs @@ -0,0 +1,47 @@ +// guidance-file blocks (dry-run reconcile = drift report). Three targets +// (guidanceTargets): machine-wide ~/.claude/CLAUDE.md (claude), the project +// /AGENTS.md (agents), and — only when ~/.codex exists — machine-wide +// ~/.codex/AGENTS.md (agents-user). The dual-mode block is gated on both hosts +// being enabled (flag detector), so the agents targets stay unmanaged/quiet +// until dual mode is on. retiredForTarget force-strips re-scoped blocks (the +// migration path that clears the dual block from any project AGENTS.md). +import path from 'node:path'; +import * as paths from '../../../lib/paths.mjs'; +import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../../../lib/blocks.mjs'; +import { bothHostsEnabled } from '../../../lib/providers.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'blocks', + async collect({ cfg, cwd, pkgRoot }) { + const rows = []; + try { + const rowsReg = registry(cfg.customBlocks); + const resolve = (r) => (r.custom + ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) + : path.join(pkgRoot, 'claude', r.template)); + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; + for (const t of guidanceTargets({ cwd, cfg })) { + const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; + const res = await syncBlocks(t.file, treg, resolve, { dryRun: true, context: ctx }); + const drift = res.filter((r) => r.action === 'upserted' || r.action === 'stripped'); + const missing = res.filter((r) => r.action === 'missing-template'); + // The agents targets are unmanaged on single-host setups — stay quiet + // unless there's actual drift (e.g. a block to strip after disabling dual + // mode) or a missing template. Only the claude target always reports. + if (t.name !== 'claude' && drift.length === 0 && missing.length === 0) continue; + if (drift.length) { + rows.push(row('blocks', 'warn', + `${drift.length} ${t.label} block(s) drifted: ${drift.map((d) => `${d.slug}→${d.action.replace('ped', 'p')}`).join(', ')}`, + 'sync reconciles blocks')); + } else { + rows.push(row('blocks', 'ok', `${t.label} managed blocks in sync (${res.length} in registry)`)); + } + for (const m of missing) rows.push(row('blocks', 'warn', `template missing for block '${m.slug}'`)); + } + } catch (e) { + rows.push(row('blocks', 'warn', `block check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/codex-mcp.mjs b/src/commands/status/sections/codex-mcp.mjs new file mode 100644 index 0000000..caeb0f0 --- /dev/null +++ b/src/commands/status/sections/codex-mcp.mjs @@ -0,0 +1,91 @@ +// Retired Claude→Codex `codex mcp-server` projection (ADR-0033). Its absence +// is healthy; setup/sync remove only the prior agentic-kit-owned entry. +// User-owned entries are preserved and receive an explicit manual remedy. +// +// Three independently-probed concerns share the codex-mcp subsystem tag, each +// with its own try/catch: one probe throwing must not silence the other two. +import { codexMcpStatus, codexMcpTopology, rufloCodexMcpStatus } from '../../../lib/mcp.mjs'; +import { have } from '../../../lib/exec.mjs'; +import { row } from '../row.mjs'; + +function legacyProjectionRows(cfg, cwd) { + try { + const { registered, owned } = codexMcpStatus(cfg, cwd); + if (registered) { + return [row('codex-mcp', 'warn', + `deprecated codex mcp-server registered${owned ? ' — agentic-kit-owned' : ' — user-owned; preserved'}`, + owned ? 'sync retires the legacy MCP entry' : 'remove manually: claude mcp remove codex -s project')]; + } + return [row('codex-mcp', 'ok', 'legacy codex mcp-server absent; supervised cross-host execution uses ak run')]; + } catch (e) { + return [row('codex-mcp', 'warn', `codex MCP check unavailable: ${e.message}`)]; + } +} + +// Independent Ruflo MCP integration lets a Codex-driven session reach the +// same routing, swarm, and memory tools as Claude. +async function rufloIntegrationRows(cfg) { + try { + const { registered, owned, command, args } = rufloCodexMcpStatus(cfg); + const workspacePinned = command === 'ak' + && JSON.stringify(args) === JSON.stringify(['x', 'ruflo-mcp']); + if (registered && owned && !workspacePinned) { + return [row('codex-mcp', 'warn', + 'ak-owned ruflo MCP in codex uses the legacy cwd-only launcher', + 'sync migrates it to workspace-pinned project memory')]; + } + if (registered) { + return [row('codex-mcp', 'ok', + `ruflo MCP registered in codex ([mcp_servers.ruflo])${owned ? ' — workspace memory pinned' : ' — pre-existing (not ak-managed)'}`)]; + } + if (await have('codex')) { + return [row('codex-mcp', 'warn', 'codex enabled but ruflo MCP not registered in codex', + 'sync registers the ruflo MCP into codex')]; + } + return []; + } catch (e) { + return [row('codex-mcp', 'warn', `ruflo→codex MCP check unavailable: ${e.message}`)]; + } +} + +// Effective project+user topology. These checks are independent of the +// agentic-kit ownership receipt because recursive/duplicate transports can +// stall a Codex-driven worker even when another tool created them. +function topologyRows(cwd) { + const rows = []; + try { + const topology = codexMcpTopology({ cwd }); + if (topology.selfRegistrations.length) { + const scopes = topology.selfRegistrations.map((entry) => entry.scope).join(', '); + rows.push(row('codex-mcp', 'fail', + `recursive codex → codex mcp-server registration detected (${scopes})`, + 'remove the [mcp_servers.codex] table from the reported Codex config before live multi-host runs')); + } + if (!topology.agenticQeRegistrations.length) { + rows.push(row('codex-mcp', 'warn', 'agentic-qe MCP is not concretely registered in Codex', + 'run: aqe platform setup codex --overwrite --with-ruflo')); + } else { + rows.push(row('codex-mcp', 'ok', 'agentic-qe MCP concretely registered in Codex')); + } + if (topology.duplicateRuflo) { + rows.push(row('codex-mcp', 'warn', + `duplicate Ruflo MCP registrations in Codex: ${topology.rufloRegistrations.map((entry) => entry.name).join(', ')}`, + 'keep the workspace-aware [mcp_servers.ruflo] entry and remove legacy duplicates after reviewing ownership')); + } + } catch (e) { + rows.push(row('codex-mcp', 'warn', `Codex MCP topology check unavailable: ${e.message}`)); + } + return rows; +} + +export default { + id: 'codex-mcp', + async collect({ cfg, cwd }) { + if (!cfg.integrations?.hosts?.codex) return []; + return [ + ...legacyProjectionRows(cfg, cwd), + ...(await rufloIntegrationRows(cfg)), + ...topologyRows(cwd), + ]; + }, +}; diff --git a/src/commands/status/sections/codex-plugins.mjs b/src/commands/status/sections/codex-plugins.mjs new file mode 100644 index 0000000..3d84d56 --- /dev/null +++ b/src/commands/status/sections/codex-plugins.mjs @@ -0,0 +1,28 @@ +// Codex owns plugin installation, enablement, and refresh. Inspect every +// explicitly enabled cached plugin's hooks and skills, but never attach a +// sync fix: the supported repair surface is Codex's /plugins UI followed by +// a fresh session. +import { inspectCodexPlugins } from '../../../lib/codex-plugins.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'codex-plugins', + async collect() { + const rows = []; + try { + const plugins = inspectCodexPlugins(); + if (plugins.enabled.length && plugins.issues.length) { + rows.push(row('codex-plugins', 'warn', + `${plugins.issues.length} Codex plugin compatibility issue(s): ${plugins.issues[0]}; ` + + 'open Codex /plugins to refresh or disable it, then start a new session')); + } else if (plugins.enabled.length) { + const versions = plugins.plugins.map((plugin) => `${plugin.ref} (${plugin.version})`).join(', '); + rows.push(row('codex-plugins', 'ok', + `${plugins.enabled.length} enabled Codex plugin(s); newest cached hooks and skills pass known compatibility checks (${versions})`)); + } + } catch (e) { + rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/daemons.mjs b/src/commands/status/sections/daemons.mjs new file mode 100644 index 0000000..318b23d --- /dev/null +++ b/src/commands/status/sections/daemons.mjs @@ -0,0 +1,24 @@ +// daemons +import { listDaemons, staleDaemons } from '../../../lib/daemons.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'daemons', + async collect({ cwd }) { + const rows = []; + try { + const daemons = await listDaemons({ cwd }); + const stale = staleDaemons(daemons); + if (stale.length) { + rows.push(row('daemons', 'warn', + `${daemons.length} running, ${stale.length} stale (orphaned or past TTL)`, 'sync reaps stale daemons')); + } else { + rows.push(row('daemons', 'ok', + daemons.length ? `${daemons.length} running (one per active project is expected)` : 'none running')); + } + } catch (e) { + rows.push(row('daemons', 'warn', `daemon check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/hosts.mjs b/src/commands/status/sections/hosts.mjs new file mode 100644 index 0000000..4f1a286 --- /dev/null +++ b/src/commands/status/sections/hosts.mjs @@ -0,0 +1,43 @@ +// hosts (install-if-missing) — cheap: file read + `which`, no network. +// An enabled host that is entirely absent is installable by sync; an external +// install (mise/native/brew) is reported but never touched. +import { HOSTS, hostInstallState, hostAuthState } from '../../../lib/providers.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'hosts', + async collect({ cfg, integrationFacts }) { + const rows = []; + try { + // primary host absent = fail (nothing can drive); alternate absent = warn. + const primaryHost = cfg.routing?.primaryHost ?? 'claude'; + for (const h of HOSTS) { + if (!cfg.integrations.hosts[h.id]) continue; + const detected = integrationFacts.hosts[h.id]; + if (detected?.present === false) { + rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', + `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); + continue; + } + const st = await hostInstallState(h); + if (st.method === 'absent') { + rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', + `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); + } else { + rows.push(row('hosts', 'ok', `${h.id} ${st.version ?? ''} (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`)); + // auth mode (billing axis): oauth/subscription ($0) vs metered api-key. + // A distinct row so `ak status --json` (and the dashboard) can badge it. + const auth = hostAuthState(h.id, { present: true }); + const billing = auth.billing === 'subscription' ? 'subscription, $0' + : auth.billing === 'metered' ? 'metered' : auth.billing; + rows.push(row('hosts', auth.mode === 'none' ? 'warn' : 'ok', + `${h.id} auth: ${auth.mode} (${billing})${auth.source ? ` · ${auth.source}` : ''}${auth.note ? ` — ${auth.note}` : ''}`, + auth.mode === 'none' ? `${h.id} login` : null)); + } + } + } catch (e) { + rows.push(row('hosts', 'warn', `host check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/index.mjs b/src/commands/status/sections/index.mjs new file mode 100644 index 0000000..4085852 --- /dev/null +++ b/src/commands/status/sections/index.mjs @@ -0,0 +1,53 @@ +// The ordered registries `collect()` walks. Each section exports +// `{ id, collect: async (ctx) => Row[] }` — ctx is `{ cfg, cwd, pkgRoot, +// integrationFacts }`. Splitting collect() into two registries (rather than +// one) only reflects that three calls in between (collectDejaVuRows, +// renderHostDetailRows, admittedLifecycleFallbackRows) already have their +// own bespoke signatures and error contracts and are called directly by +// collect() instead of going through this generic dispatch — the row ORDER +// across both registries plus those three calls is unchanged from the +// original monolithic collect(). +import models from './models.mjs'; +import versions from './versions.mjs'; +import ruvnetBrain from './ruvnet-brain.mjs'; +import ruvector from './ruvector.mjs'; +import self from './self.mjs'; +import natives from './natives.mjs'; +import memoryPin from './memory-pin.mjs'; +import projectMemory from './project-memory.mjs'; +import scaffoldAgents from './scaffold-agents.mjs'; +import npx from './npx.mjs'; +import security from './security.mjs'; +import learning from './learning.mjs'; +import aqe from './aqe.mjs'; +import agentdb from './agentdb.mjs'; +import mcp from './mcp.mjs'; +import codexMcp from './codex-mcp.mjs'; +import codexPlugins from './codex-plugins.mjs'; + +import hosts from './hosts.mjs'; +import providersStatus from './providers-status.mjs'; +import providersExternalIntent from './providers-external-intent.mjs'; +import providersExternalProjection from './providers-external-projection.mjs'; +import providersRufloModels from './providers-ruflo-models.mjs'; +import providersLocalBindings from './providers-local-bindings.mjs'; +import routing from './routing.mjs'; +import daemons from './daemons.mjs'; +import blocks from './blocks.mjs'; +import statusline from './statusline.mjs'; +import qeCourt from './qe-court.mjs'; + +// Everything up to and including codex-plugins — before the deja-vu / +// host-detail / admitted-lifecycle calls that collect() makes directly. +export const SECTIONS_BEFORE_HOST_DETAIL = [ + models, versions, ruvnetBrain, ruvector, self, natives, memoryPin, + projectMemory, scaffoldAgents, npx, security, learning, aqe, agentdb, mcp, + codexMcp, codexPlugins, +]; + +// Everything from `hosts` onward — after those direct calls. +export const SECTIONS_AFTER_HOST_DETAIL = [ + hosts, providersStatus, providersExternalIntent, providersExternalProjection, + providersRufloModels, providersLocalBindings, routing, daemons, blocks, + statusline, qeCourt, +]; diff --git a/src/commands/status/sections/learning.mjs b/src/commands/status/sections/learning.mjs new file mode 100644 index 0000000..cc9c8cc --- /dev/null +++ b/src/commands/status/sections/learning.mjs @@ -0,0 +1,19 @@ +// learning (project-scope quick signals) +import path from 'node:path'; +import * as paths from '../../../lib/paths.mjs'; +import { readJson } from '../../../lib/settings.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'learning', + async collect({ cwd }) { + const stats = readJson(path.join(paths.projectClaudeFlowDir(cwd), 'neural', 'stats.json')); + if (stats) { + const pn = stats.patternsLearned ?? 0; + return [row('learning', pn > 0 ? 'ok' : 'warn', + pn > 0 ? `${pn} patterns learned, ${stats.trajectoriesRecorded ?? 0} trajectories (this project)` + : 'learning initialized but no patterns yet (this project)')]; + } + return [row('learning', 'info', 'no learning state in this project (run setup here to activate)')]; + }, +}; diff --git a/src/commands/status/sections/mcp.mjs b/src/commands/status/sections/mcp.mjs new file mode 100644 index 0000000..36d8d44 --- /dev/null +++ b/src/commands/status/sections/mcp.mjs @@ -0,0 +1,23 @@ +// MCP +import { registrationStatus } from '../../../lib/mcp.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'mcp', + async collect({ cfg }) { + const rows = []; + const mcp = registrationStatus(); + if (mcp.claudeFlow) { + rows.push(row('mcp', 'ok', + `claude-flow registered (user scope)${mcp.denyCount ? `, ${mcp.denyCount} tool(s) denied by family exclusions` : ', all families allowed'}`)); + } else if (cfg.mcp.register) { + rows.push(row('mcp', 'warn', 'ruflo MCP not registered', 'setup/sync registers claude-flow at user scope')); + } else { + rows.push(row('mcp', 'info', 'MCP registration disabled in kit.json')); + } + if (mcp.legacyRuflo) { + rows.push(row('mcp', 'warn', "legacy 'ruflo'-keyed MCP registration present", 'sync migrates it to claude-flow')); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/memory-pin.mjs b/src/commands/status/sections/memory-pin.mjs new file mode 100644 index 0000000..d0b99b8 --- /dev/null +++ b/src/commands/status/sections/memory-pin.mjs @@ -0,0 +1,25 @@ +// #45 aftermath: a CLAUDE_FLOW_DB_PATH pin aimed at a dead or foreign path makes +// every memory op target the wrong DB ("Database not initialized" with a healthy +// DB in-repo). Warn-only — the pin may be deliberate; sync never touches it. +import path from 'node:path'; +import { dbPathPinStatus } from '../../../lib/natives.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'memory-pin', + async collect({ cwd }) { + const rows = []; + try { + const pin = dbPathPinStatus({ + settingsLocalFile: path.join(cwd, '.claude', 'settings.local.json'), + projectRoot: cwd, + }); + if (pin?.warn) { + rows.push(row('memory-pin', 'warn', + `CLAUDE_FLOW_DB_PATH pins ${pin.pinned} (${pin.reason})`, + 'repoint it in .claude/settings.local.json env, or remove the pin')); + } + } catch { /* pin check is best-effort — never blocks status */ } + return rows; + }, +}; diff --git a/src/commands/status/sections/models.mjs b/src/commands/status/sections/models.mjs new file mode 100644 index 0000000..19459f4 --- /dev/null +++ b/src/commands/status/sections/models.mjs @@ -0,0 +1,22 @@ +// Cache-only model lifecycle summary. Discovery and network access belong +// exclusively to `ak models refresh`. +import { latestSnapshot, readModelStore, summarizeModelHealth } from '../../../lib/model-inventory/index.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'models', + async collect() { + const rows = []; + try { + const snapshot = latestSnapshot(readModelStore()); + if (!snapshot) rows.push(row('models', 'info', 'no local model inventory yet; run `ak models refresh` explicitly')); + else { + const health = summarizeModelHealth(snapshot); + rows.push(row('models', health.level, health.message, health.fix)); + } + } catch (error) { + rows.push(row('models', 'warn', `model inventory unavailable: ${error.message}; run \`ak models refresh\` explicitly`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/natives.mjs b/src/commands/status/sections/natives.mjs new file mode 100644 index 0000000..01d75b7 --- /dev/null +++ b/src/commands/status/sections/natives.mjs @@ -0,0 +1,43 @@ +// natives (better-sqlite3 in agentdb locations + aqe) +import { nativesStatus, rufloRuntimeNatives } from '../../../lib/natives.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'natives', + async collect() { + const rows = []; + try { + const n = nativesStatus(); + const bad = n.locations.filter((l) => !l.native); + if (n.locations.length === 0) { + rows.push(row('natives', 'warn', 'no agentdb locations found under global ruflo', 'setup/sync installs ruflo')); + } else if (bad.length) { + rows.push(row('natives', 'fail', + `${bad.length}/${n.locations.length} agentdb location(s) on WASM fallback (data-loss writes)`, + 'sync installs native better-sqlite3')); + } else { + rows.push(row('natives', 'ok', `native better-sqlite3 in ${n.locations.length} agentdb location(s)`)); + } + if (n.aqe && !n.aqe.native) { + rows.push(row('natives', 'fail', 'agentic-qe better-sqlite3 not native', 'sync repairs it')); + } + // #45: the agentdb copies above are NOT what `npx ruflo memory` loads — probe + // the binding as resolved from ruflo's own memory runtime (@claude-flow/memory + // + /cli), or the row reads ✓ while memory store runs on the WASM fallback. + const rt = await rufloRuntimeNatives(); + if (rt.installed && rt.contexts.length) { + const wasm = rt.contexts.filter((c) => !c.ok); + if (wasm.length) { + rows.push(row('natives', 'fail', + `ruflo memory runtime on WASM fallback (${wasm.map((c) => `@claude-flow/${c.context}`).join(', ')}) — memory and orchestration may degrade`, + 'sync builds the native binding')); + } else { + rows.push(row('natives', 'ok', `ruflo memory runtime native (${rt.contexts.map((c) => c.context).join(', ')})`)); + } + } + } catch (e) { + rows.push(row('natives', 'warn', `native check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/npx.mjs b/src/commands/status/sections/npx.mjs new file mode 100644 index 0000000..6dc5522 --- /dev/null +++ b/src/commands/status/sections/npx.mjs @@ -0,0 +1,25 @@ +// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the +// statusline/hooks execute these verbatim, keeping retired defects alive) +import { scanNpxStale } from '../../../lib/npx.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'npx', + async collect() { + const rows = []; + try { + const stale = scanNpxStale(); + if (stale.length) { + const what = stale.flatMap((e) => e.stale.map((s) => `${s.pkg}@${s.cached}`)).join(', '); + rows.push(row('npx', 'warn', + `${stale.length} stale npx env(s) serve outdated code (${what})`, + 'sync prunes them (npx re-fetches on demand)')); + } else { + rows.push(row('npx', 'ok', 'npx cache holds no stale ruflo-family envs')); + } + } catch (e) { + rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/project-memory.mjs b/src/commands/status/sections/project-memory.mjs new file mode 100644 index 0000000..9cc1335 --- /dev/null +++ b/src/commands/status/sections/project-memory.mjs @@ -0,0 +1,31 @@ +// Project memory may legitimately have two stores: the compatibility/sql.js +// memory.db and the native bridge's plaintext agentdb-memory.db sibling. +// Presence is a quick signal only; `ak x verify memory` performs the write +// round-trip proof. +import { projectMemoryStatus } from '../../../lib/project-memory.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'memory', + async collect({ cwd }) { + const rows = []; + try { + const memory = projectMemoryStatus(cwd); + if (!memory.active) { + rows.push(row('memory', 'info', 'no project memory store yet (run setup here to initialize)')); + } else if (!memory.active.readable) { + rows.push(row('memory', 'warn', + `active ${memory.active.kind} store is unreadable (${memory.active.file}) — run: ak x verify memory`)); + } else { + const sibling = memory.secondary + ? `; ${memory.secondary.kind} compatibility store also present` + : ''; + rows.push(row('memory', 'ok', + `${memory.active.kind} active writer: ${memory.active.entries} active entr${memory.active.entries === 1 ? 'y' : 'ies'}${sibling}`)); + } + } catch (e) { + rows.push(row('memory', 'warn', `project memory check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/providers-external-intent.mjs b/src/commands/status/sections/providers-external-intent.mjs new file mode 100644 index 0000000..1a8bfd9 --- /dev/null +++ b/src/commands/status/sections/providers-external-intent.mjs @@ -0,0 +1,22 @@ +// Warn when an admitted external AQE provider is referenced by kit.json +// intent (aqeProvider / aqeFallback / routing) but isn't actually live — +// isolated from the sibling providers-* sections (ADR-complexity-program #4). +// The intent/live comparison itself lives in src/lib/providers.mjs +// (providerExternalState) so this section never re-derives its own view (#129). +import { row } from '../row.mjs'; +import { providerExternalState } from '../../../lib/providers.mjs'; + +export default { + id: 'providers', + async collect({ cfg, cwd }) { + try { + const { unavailableIntent } = providerExternalState(cfg, cwd); + if (!unavailableIntent.length) return []; + return [row('providers', 'warn', + `external AQE intent is unavailable (${unavailableIntent.join(', ')}) — restore its admission/host/grant, or retire only its dependent intent with ` + + `\`ak host adapters revoke-grant ${unavailableIntent[0]} aqeProvider\``)]; + } catch (e) { + return [row('providers', 'warn', `provider check unavailable: ${e.message}`)]; + } + }, +}; diff --git a/src/commands/status/sections/providers-external-projection.mjs b/src/commands/status/sections/providers-external-projection.mjs new file mode 100644 index 0000000..188bac9 --- /dev/null +++ b/src/commands/status/sections/providers-external-projection.mjs @@ -0,0 +1,37 @@ +// The external AQE providers projection (admitted providers reflected into +// agentic-qe's own config) — isolated from the sibling providers-* +// sections (ADR-complexity-program #4). The projection state itself lives in +// src/lib/providers.mjs (providerExternalState) so this section never +// re-derives its own view (#129). +import { EXTERNAL_PROVIDERS_MIN_AQE, providerExternalState } from '../../../lib/providers.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'providers', + async collect({ cfg, cwd }) { + try { + const { root, disk, state } = providerExternalState(cfg, cwd); + if (!(root && state)) return []; + if (!(state.desired.length || state.stale.length)) return []; + const defaultDrift = state.desired.includes(cfg.providers?.aqeProvider) + && disk.defaultProvider !== cfg.providers.aqeProvider; + if (!state.supported) { + return [row('providers', 'warn', + `external AQE providers admitted but installed agentic-qe needs >=${EXTERNAL_PROVIDERS_MIN_AQE}`)]; + } + if (!state.ok || defaultDrift) { + const facts = [ + state.missing.length ? `missing ${state.missing.join(', ')}` : '', + state.drifted.length ? `drifted/conflicting ${state.drifted.join(', ')}` : '', + state.stale.length ? `stale owned ${state.stale.join(', ')}` : '', + defaultDrift ? `default is not ${cfg.providers.aqeProvider}` : '', + ].filter(Boolean).join('; '); + return [row('providers', 'warn', `external AQE projection out of sync (${facts})`, 'sync reconciles only ak-owned entries')]; + } + return [row('providers', 'ok', + `external AQE providers projected: ${state.desired.join(', ')} (declared/admitted; served inference not yet proven)`)]; + } catch (e) { + return [row('providers', 'warn', `provider check unavailable: ${e.message}`)]; + } + }, +}; diff --git a/src/commands/status/sections/providers-local-bindings.mjs b/src/commands/status/sections/providers-local-bindings.mjs new file mode 100644 index 0000000..f3daa2b --- /dev/null +++ b/src/commands/status/sections/providers-local-bindings.mjs @@ -0,0 +1,28 @@ +// ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT +// projected to 'aqe' (unlike ollama, which is) — surface that asymmetry +// plainly so it reads as a fact, not a bug. Registry-driven (billing + +// projections), not an id check, so any future provider of the same shape +// gets the same treatment for free. Isolated from the sibling providers-* +// sections (ADR-complexity-program #4). +import { PROVIDER_REGISTRY } from '../../../lib/adapters/index.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'providers', + async collect({ cfg }) { + try { + const rows = []; + const providerById = Object.fromEntries(PROVIDER_REGISTRY.map((p) => [p.id, p])); + for (const binding of cfg.integrations?.bindings ?? []) { + const provider = providerById[binding.provider]; + if (!provider || provider.billing !== 'local' || provider.projections.includes('aqe')) continue; + const endpoint = binding.endpoint ? ` @ ${binding.endpoint}` : ''; + rows.push(row('providers', 'info', + `local binding: ${binding.provider} via ${binding.host}${endpoint} (${provider.billing} $0; not an AQE provider type)`)); + } + return rows; + } catch (e) { + return [row('providers', 'warn', `provider check unavailable: ${e.message}`)]; + } + }, +}; diff --git a/src/commands/status/sections/providers-ruflo-models.mjs b/src/commands/status/sections/providers-ruflo-models.mjs new file mode 100644 index 0000000..90c94ac --- /dev/null +++ b/src/commands/status/sections/providers-ruflo-models.mjs @@ -0,0 +1,46 @@ +// A kit.json provider/model entry is registration intent. Ruflo >=3.38.8 +// can honor explicit OpenRouter/Ollama provider+model selection, but the +// registry does not retarget every agent and it is not execution evidence. +// Keep that distinction in the status rows the dashboard consumes. Isolated +// from the sibling providers-* sections (ADR-complexity-program #4). +import { installedVersion, cmpVersions } from '../../../lib/versions.mjs'; +import { MIN_RUFLO_PERSISTED_PROVIDER_VERSION } from '../../../lib/providers.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'providers', + async collect({ cfg, integrationFacts }) { + try { + const rufloModels = cfg.providers?.models ?? []; + if (!rufloModels.length) return []; + const intent = rufloModels + .filter((entry) => entry?.id) + .map((entry) => `${entry.id}${entry.model ? `:${entry.model}` : ''}`) + .join(', '); + const rufloVersion = installedVersion('ruflo'); + const affected = !!rufloVersion + && cmpVersions(rufloVersion, MIN_RUFLO_PERSISTED_PROVIDER_VERSION) < 0; + const missingOpenRouterKey = rufloModels.some((entry) => entry?.id === 'openrouter') + && !integrationFacts.providers?.openrouter?.credentialPresent; + const directIds = new Set(['ollama', 'openrouter']); + const registryOnly = [...new Set(rufloModels + .map((entry) => entry?.id) + .filter((id) => id && !directIds.has(id)))]; + if (affected) { + return [row('providers', 'warn', + `ruflo provider intent: ${intent} — ruflo ${rufloVersion} cannot honor persisted provider/model execution; needs >=${MIN_RUFLO_PERSISTED_PROVIDER_VERSION}`)]; + } + if (missingOpenRouterKey) { + return [row('providers', 'warn', + `ruflo provider intent: ${intent} — direct agents must select provider + model; openrouter needs OPENROUTER_API_KEY in the Ruflo/MCP process`)]; + } + const unsupported = registryOnly.length + ? `; no direct-agent execution branch for ${registryOnly.join(', ')}` + : ''; + return [row('providers', 'info', + `ruflo provider intent: ${intent} — direct agents must select provider + model; Usage proves served execution${unsupported}`)]; + } catch (e) { + return [row('providers', 'warn', `provider check unavailable: ${e.message}`)]; + } + }, +}; diff --git a/src/commands/status/sections/providers-status.mjs b/src/commands/status/sections/providers-status.mjs new file mode 100644 index 0000000..c2d2f26 --- /dev/null +++ b/src/commands/status/sections/providers-status.mjs @@ -0,0 +1,82 @@ +// providers (frontier host wiring) — light: `have` probe + env read, no --version +// +// The CORE "is provider config synced" signal: env drift, aqe fallback-chain +// order drift, and chain credential viability. Split out from the ~8-concern +// monolith (ADR-complexity-program #4) so a probe failure here doesn't also +// swallow the external-intent, external-projection, ruflo-models, and +// local-bindings rows in the sibling sections below. +// +// Drift itself is judged by src/lib/providers.mjs's own comparators +// (providerEnvDrift, aqeRouterDrift) — the same dry-run computation the +// writer (applyHosts/applyAqeRouter) executes, so this section can never +// silently diverge from what `ak sync` would actually do (#129). +import { have } from '../../../lib/exec.mjs'; +import { + HOSTS, settingsTarget, isDefault, providerEnvDrift, aqeRouterDrift, credentialGaps, providerExternalState, +} from '../../../lib/providers.mjs'; +import { readJson } from '../../../lib/settings.mjs'; +import { row } from '../row.mjs'; + +async function defaultHostRows(cfg) { + const rows = []; + // advisory only (no fix): opting codex in is a deliberate `ak host pick` + if (await have('codex')) { + rows.push(row('providers', 'info', 'codex CLI installed but not enabled (claude-only default)')); + } else { + rows.push(row('providers', 'info', 'claude-only (default host)')); + } + if (!cfg.integrations?.hosts?.opencode && await have('opencode')) { + rows.push(row('providers', 'info', 'opencode CLI installed but not enabled (`ak host pick --host claude,opencode` wires it)')); + } + return rows; +} + +function driftRow(cfg, cwd, env, scope) { + const envDrift = providerEnvDrift(cfg, env); + const { drift: routerDrift } = aqeRouterDrift(cfg, cwd); + const chain = cfg.providers.aqeFallback ?? []; + const on = HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id).join('+') || 'none'; + const chainStr = chain.length ? `; aqe chain ${chain.map((e) => e.provider).join('→')}` : ''; + return (envDrift || routerDrift) + ? row('providers', 'warn', `provider config drifted (want ${on}${chainStr}, ${scope})`, 'sync re-applies provider env + aqe router') + : row('providers', 'ok', `wired: ${on}${chainStr} (${scope})`); +} + +// Chain VIABILITY, separate from chain ORDER above: a chain in the right +// order whose rungs have no credential fails over into nothing (#54). Warn, +// not fail — the primary rung still works — and no `fix`, since only the +// user can supply a key. +function credentialChainRow(cfg, unavailableExternalSet) { + const chain = cfg.providers.aqeFallback ?? []; + const credentialChain = chain.filter((entry) => !unavailableExternalSet.has(entry?.provider)); + if (!credentialChain.length) return null; + const gaps = credentialGaps(credentialChain); + if (gaps.length) { + return row('providers', 'warn', + `aqe chain: ${credentialChain.length - gaps.length}/${credentialChain.length} rungs have credentials ` + + `(${gaps.map((g) => `${g.provider}: needs ${g.missing.join(', ')}`).join('; ')})`); + } + return row('providers', 'ok', `aqe chain: ${credentialChain.length}/${credentialChain.length} rungs have credentials`); +} + +export default { + id: 'providers', + async collect({ cfg, cwd }) { + const rows = []; + try { + const { file, scope } = settingsTarget(cwd); + const env = readJson(file, {})?.env ?? {}; + const { unavailableIntentSet } = providerExternalState(cfg, cwd); + if (isDefault(cfg)) { + rows.push(...(await defaultHostRows(cfg))); + } else { + rows.push(driftRow(cfg, cwd, env, scope)); + const credRow = credentialChainRow(cfg, unavailableIntentSet); + if (credRow) rows.push(credRow); + } + } catch (e) { + rows.push(row('providers', 'warn', `provider check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/qe-court.mjs b/src/commands/status/sections/qe-court.mjs new file mode 100644 index 0000000..3bd9774 --- /dev/null +++ b/src/commands/status/sections/qe-court.mjs @@ -0,0 +1,27 @@ +// qe-court (ADR-124): read-only awareness. agentic-qe >=3.13.3 owns config +// validation and ships a valid default; ak reports existing project state +// but never rewrites the skill's config. +import * as paths from '../../../lib/paths.mjs'; +import { qeCourtShipped, readQeCourtConfig, validateCourtConfig, qeCourtReadiness } from '../../../lib/qeCourt.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'qe-court', + async collect({ cwd }) { + if (!qeCourtShipped()) return []; + const qcRoot = paths.repoRoot(cwd); + const qc = qcRoot ? readQeCourtConfig(qcRoot) : null; + if (!qc) return []; + const violations = validateCourtConfig(qc); + if (violations.length) { + return [row('qe-court', 'warn', + `qe-court panel invalid: ${violations.join(', ')} — regenerate with agentic-qe >=3.13.3 or choose different defense/jury vendors`)]; + } + const readiness = qeCourtReadiness(qcRoot); + if (readiness.ready) { + return [row('qe-court', 'ok', 'qe-court routing and consumer artifacts are ready; provider-seat readiness still requires a live proof')]; + } + return [row('qe-court', 'warn', + `qe-court routing config passes the local anti-collusion check, but executability is not proven (${readiness.artifactIssues.join('; ')})`)]; + }, +}; diff --git a/src/commands/status/sections/routing.mjs b/src/commands/status/sections/routing.mjs new file mode 100644 index 0000000..3a05746 --- /dev/null +++ b/src/commands/status/sections/routing.mjs @@ -0,0 +1,65 @@ +// Per-activity routing (canonical routes → agentOverrides projection). Only surfaces +// once a policy is set; the dashboard renders this row like any other subsystem. +import * as paths from '../../../lib/paths.mjs'; +import { readJson } from '../../../lib/settings.mjs'; +import { aqeRouterFile, aqeSupportsAgentOverrides } from '../../../lib/providers.mjs'; +import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../../../lib/routing.mjs'; +import { row } from '../row.mjs'; + +function overridesSyncRow(base, cwd, policy) { + // Same scope gate as the writer: applyAqeRouter anchors at repoRoot(cwd) + // and declines outside a project — a raw-cwd read from a subdir would + // false-warn "out of sync" (M2), and outside a project a warn would + // recommend a sync that cannot repair it (#129). + const root = paths.repoRoot(cwd); + if (!root) { + return row('routing', 'info', `${base} · not in a project — aqe router unmanaged here`); + } + const overrides = readJson(aqeRouterFile(root))?.agentOverrides; + const drift = overrides == null || agentOverridesDrift(overrides, policy); + return drift + ? row('routing', 'warn', `${base} — llm-config.json out of sync`, 'sync re-applies agentOverrides') + : row('routing', 'ok', base); +} + +function divergedRow(policy) { + // Seeded pins vs today's defaults. Deliberately `info` and deliberately + // "diverges from": which side wins is activity-dependent (a newer default + // can cost 2-3× the agentic turns on routine work), so a `warn` would push + // users to spend turns clearing a lint. No `fix` — sync must never + // auto-refresh a pin; `ak host refresh` is the opt-in path (#55). + const diverged = divergedRoutes(policy); + if (!diverged.length) return null; + const pairs = [...new Set(diverged.flatMap((d) => [ + ...(d.modelDiverged ? [`${d.model} vs ${d.defaultModel}`] : []), + ...d.escalation.map((e) => `${e.model} vs ${e.defaultModel} (escalation)`), + ]))].join(', '); + return row('routing', 'info', + `${diverged.length} seeded route(s) diverge from current defaults (${pairs}) — ak host refresh`); +} + +export default { + id: 'routing', + async collect({ cfg, cwd }) { + const rows = []; + try { + const policy = cfg.routing?.routes ?? {}; + if (Object.keys(policy).length) { + const s = routingSummary(policy); + // The WRITER's projection (#129): applyAqeRouter materializes only + // explicitly persisted routes, so status must count and compare the same + // set — the resolved projection would demand entries sync never writes. + const want = configuredPolicyToAgentOverrides(policy); + const base = `dual-host · ${s.total} activities (${s.custom} custom) → ${Object.keys(want).length} agent overrides`; + rows.push(aqeSupportsAgentOverrides() + ? overridesSyncRow(base, cwd, policy) + : row('routing', 'info', `${base} · needs agentic-qe ≥ 3.13.1 to materialize`)); + const diverged = divergedRow(policy); + if (diverged) rows.push(diverged); + } + } catch (e) { + rows.push(row('routing', 'warn', `routing check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/ruvector.mjs b/src/commands/status/sections/ruvector.mjs new file mode 100644 index 0000000..f163eeb --- /dev/null +++ b/src/commands/status/sections/ruvector.mjs @@ -0,0 +1,38 @@ +// ruvector — a global CLI users register as an MCP server BY HAND. ak manages +// its drift, never its presence or its registration. Unregistered → no row at +// all (same silence as codex-not-enabled): nudging a tool nobody opted into +// would be management by ambush. Registered but kit.json ruvector:false → an +// info row with NO fix, so sync never plans an upgrade the user turned off. +// +// Wording is deliberately "CLI": the registered command is typically +// `npx -y ruvector mcp start`, so upgrading the global package does not +// necessarily change what the MCP server executes. Claim only what is true. +import { ruvectorRegistered } from '../../../lib/mcp.mjs'; +import { drift as ruvectorDrift } from '../../../lib/ruvector.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'ruvector', + async collect({ cfg }) { + const rows = []; + if (!ruvectorRegistered()) return rows; + if (cfg.ruvector === false) { + rows.push(row('ruvector', 'info', 'ruvector MCP registered — CLI updates disabled (kit.json ruvector:false)')); + return rows; + } + try { + const rv = await ruvectorDrift(); + if (rv.present && rv.outdated) { + rows.push(row('ruvector', 'warn', + `ruvector CLI ${rv.installed} installed, ${rv.latest} available`, 'sync upgrades the ruvector CLI')); + } else if (rv.present) { + rows.push(row('ruvector', 'ok', `ruvector CLI ${rv.installed}${rv.latest ? ' (latest)' : ''} (MCP registered, user scope)`)); + } else { + rows.push(row('ruvector', 'info', 'ruvector MCP registered but no global CLI installed (server runs via npx)')); + } + } catch (e) { + rows.push(row('ruvector', 'warn', `ruvector check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/ruvnet-brain.mjs b/src/commands/status/sections/ruvnet-brain.mjs new file mode 100644 index 0000000..5b26434 --- /dev/null +++ b/src/commands/status/sections/ruvnet-brain.mjs @@ -0,0 +1,37 @@ +// ruvnet-brain (offline KB + search_ruvnet MCP; not an npm package — detected +// on disk, drift via GitHub releases, TTL-cached like `self`) +import { drift as ruvnetBrainDrift, nightlyAgentPresent as rbNightlyPresent, NIGHTLY_LABEL as RB_NIGHTLY_LABEL } from '../../../lib/ruvnet-brain.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'ruvnet-brain', + async collect({ cfg }) { + const rows = []; + if (!cfg.ruvnetBrain) return rows; + try { + const b = await ruvnetBrainDrift(); + if (!b.present) { + rows.push(row('ruvnet-brain', 'warn', 'RuvNet Brain not installed', 'setup installs it (or `ak sync`)')); + } else if (b.outdated) { + const have = b.installedRelease ? `release v${b.installedRelease}` : 'present (unversioned install)'; + rows.push(row('ruvnet-brain', 'warn', + `ruvnet-brain ${have}, release v${b.latest} available`, 'sync refreshes the KB')); + } else { + const shown = b.installedRelease ? `release v${b.installedRelease}${b.latest ? ' (latest)' : ''}` : 'present'; + rows.push(row('ruvnet-brain', 'ok', `ruvnet-brain ${shown}`)); + } + } catch (e) { + rows.push(row('ruvnet-brain', 'warn', `ruvnet-brain check unavailable: ${e.message}`)); + } + // The installer's own nightly self-updater (macOS LaunchAgent, 03:47) bypasses + // ak-managed updates: it rewrites the KB outside ak's release stamp, so status + // and the statusline drift from disk. Own subsystem so sync's fix is "disable + // the agent", never a needless force-reinstall of the brain itself. + if (rbNightlyPresent()) { + rows.push(row('ruvnet-brain-nightly', 'warn', + `ruvnet-brain nightly self-updater active (${RB_NIGHTLY_LABEL}) — bypasses ak-managed updates`, + 'sync disables it (re-enable deliberately: `npx ruvnet-brain --enable-nightly`)')); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/scaffold-agents.mjs b/src/commands/status/sections/scaffold-agents.mjs new file mode 100644 index 0000000..402ef59 --- /dev/null +++ b/src/commands/status/sections/scaffold-agents.mjs @@ -0,0 +1,37 @@ +// Scaffold agents (ADR-128 Phase 2 removals — ruflo#2985). Upstream never +// revisits an existing scaffold, so projects inited before ruflo 3.38.x are +// missing up to 9 plugin-canonical agents (coder, researcher, reviewer, …). +// The fix is upstream's `ruflo migrate fix --agents` (PR #2986): when the +// installed CLI ships it, the row carries a fix and sync delegates; until +// then it is advisory-only — a kit-side restore would fork plugin-canonical +// content. Spawn-free (dist probe + file walk), project-scoped: silent when +// the cwd has no .claude/agents tree. +import { removedAgentGaps, upstreamFixAvailable } from '../../../lib/scaffold.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'scaffold-agents', + async collect({ cwd }) { + const rows = []; + try { + const { relevant, gaps } = removedAgentGaps(cwd); + if (relevant && gaps.length > 0) { + const named = gaps.slice(0, 3).map((g) => g.basename.replace(/\.md$/, '')).join(', '); + const suffix = gaps.length > 3 ? ', …' : ''; + if (upstreamFixAvailable()) { + rows.push(row('scaffold-agents', 'warn', + `${gaps.length} ADR-128-removed agent(s) missing from .claude/agents (${named}${suffix})`, + 'sync delegates to `ruflo migrate fix --agents`')); + } else { + rows.push(row('scaffold-agents', 'info', + `${gaps.length} ADR-128-removed agent(s) missing (${named}${suffix}) — installed ruflo lacks \`migrate fix --agents\` (ruflo#2986 pending); upgrade ruflo or install the owning plugins`)); + } + } else if (relevant) { + rows.push(row('scaffold-agents', 'ok', 'ADR-128-removed agents present or plugin-covered')); + } + } catch (e) { + rows.push(row('scaffold-agents', 'warn', `scaffold agent check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/security.mjs b/src/commands/status/sections/security.mjs new file mode 100644 index 0000000..b616555 --- /dev/null +++ b/src/commands/status/sections/security.mjs @@ -0,0 +1,22 @@ +// security surface — honors kit.json security:false (`ak setup +// --no-security`): an info row with NO fix, so sync never plans (or heals) +// the surface a user turned off. Previously the flag was write-only. +import { aidefencePresent, securityPresent } from '../../../lib/natives.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'security', + async collect({ cfg }) { + if (cfg.security === false) { + return [row('security', 'info', 'security checks disabled (kit.json security:false)')]; + } + if (securityPresent()) { + return [aidefencePresent() + ? row('security', 'ok', '@claude-flow/security + aidefence present (defend functional)') + : row('security', 'fail', + 'aidefence missing — `security defend` silently non-functional (ruvnet/ruflo#2670)', + 'sync reinstalls @claude-flow/aidefence')]; + } + return [row('security', 'warn', '@claude-flow/security not found under global ruflo')]; + }, +}; diff --git a/src/commands/status/sections/self.mjs b/src/commands/status/sections/self.mjs new file mode 100644 index 0000000..2bc8ae5 --- /dev/null +++ b/src/commands/status/sections/self.mjs @@ -0,0 +1,23 @@ +// self (the kit's own version — prerelease installs track the `next` tag) +import { selfDrift } from '../../../lib/versions.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'self', + async collect({ pkgRoot }) { + const rows = []; + try { + const s = await selfDrift({ pkgRoot }); + if (s.outdated) { + rows.push(row('self', 'warn', + `kit ${s.installed} installed, ${s.latest} available (${s.tag} tag)`, + 'sync self-updates the kit (runs last)')); + } else if (s.installed) { + rows.push(row('self', 'ok', `kit ${s.installed}${s.latest ? ' (latest)' : ''}`)); + } + } catch (e) { + rows.push(row('self', 'warn', `kit version check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/statusline.mjs b/src/commands/status/sections/statusline.mjs new file mode 100644 index 0000000..2cb7f3f --- /dev/null +++ b/src/commands/status/sections/statusline.mjs @@ -0,0 +1,85 @@ +// Four related statusline surfaces, none of which had their own try/catch in +// the original monolith: the project footer + its CVE-counter overlay, the +// Codex native line, and an opencode informational note. Grouped in one +// section (they share the "statusline" family of subsystem tags) but split +// into small functions so each stays readable and under the CC budget. +import fs from 'node:fs'; +import * as paths from '../../../lib/paths.mjs'; +import { upstreamCveCounterFabricated, fixStatusline, helperStampStale } from '../../../lib/statusline.mjs'; +import { statuslineDrift } from '../../../lib/codex-statusline.mjs'; +import { row } from '../row.mjs'; + +function footerRows(cwd) { + const sl = paths.projectStatusline(cwd); + if (!fs.existsSync(sl)) { + return [row('statusline', 'info', 'no project statusline here (created by setup)')]; + } + const slSrc = fs.readFileSync(sl, 'utf8'); + const hasFooter = slSrc.includes('ruflo-seg:BEGIN'); + // Drift is "would a sync CHANGE this file?", which fixStatusline's dry run answers + // exactly. A marker-presence test alone cannot see CONTENT drift: after a kit upgrade + // revises the footer or the security overlay, the marker is still there, this row + // reports 'ok', and — because sync builds its plan from rows carrying a `fix` — the + // re-injection never runs and the stale block survives indefinitely. Observed live: + // an updated overlay silently failed to land for exactly this reason. + let wouldChange = !hasFooter; + try { wouldChange = fixStatusline(cwd, { dryRun: true }).applied; } catch { /* keep marker fallback */ } + // Armed wipe: the footer can be present AND current while ruflo's helper + // stamp lags the installed CLI — the next ruflo command (in practice the + // daemon start) then pristine-copies statusline.cjs over ours. That is how + // the footer kept vanishing BETWEEN syncs. Surface it as the same drift + // story; sync closes it by refreshing the helpers before re-injecting. + let stampStale = false; + try { stampStale = helperStampStale(cwd); } catch { /* best-effort */ } + const rows = [row('statusline', (wouldChange || stampStale) ? 'warn' : 'ok', + wouldChange + ? (hasFooter ? 'injected blocks are out of date' : 'statusline present but footer missing') + : stampStale + ? 'footer present but ruflo helper stamp is stale — next ruflo command wipes it' + : 'activation footer present and current', + (wouldChange || stampStale) ? 'sync refreshes helpers, then re-injects the footer' : null)]; + // The CVE-counter overlay is tracked SEPARATELY from the footer: a footer-only + // check reports 'ok' while the statusline still renders ruflo's fabricated + // "⚠ 3 CVEs" (hardcoded totalCves, cvesFixed from a file count). Only warn while + // the upstream defect is actually present — once ruflo fixes getSecurityStatus + // the overlay is intentionally absent, and this row must go quiet on its own + // rather than nag for a patch that is no longer wanted. + if (upstreamCveCounterFabricated()) { + const patched = slSrc.includes('ruflo-sec:BEGIN'); + rows.push(row('statusline/cve', patched ? 'ok' : 'warn', + patched + ? 'CVE counter overlaid with real scan results' + : 'statusline shows ruflo\'s fabricated CVE count (upstream defect)', + patched ? null : 'sync injects the security overlay')); + } + return rows; +} + +// Codex has a native user-scoped line, but no command-backed rich renderer. +function codexStatuslineRows(cfg) { + if (!(cfg.integrations?.hosts?.codex || cfg.statusline?.codex)) return []; + const codexLine = statuslineDrift(cfg); + if (!codexLine.owned) { + return [row('codex-statusline', 'info', + 'Codex native status line is unmanaged — opt in with `ak x statusline codex native`')]; + } + if (codexLine.drifted) { + return [row('codex-statusline', 'warn', + `managed Codex ${codexLine.preset} status line has drifted`, + 'sync restores the selected native preset')]; + } + return [row('codex-statusline', 'ok', + `managed Codex ${codexLine.preset} native status line is current (rich ruflo/SONA/AQE segments remain Claude-only)`)]; +} + +export default { + id: 'statusline', + async collect({ cfg, cwd }) { + const rows = [...footerRows(cwd), ...codexStatuslineRows(cfg)]; + if (cfg.integrations?.hosts?.opencode) { + rows.push(row('statusline', 'info', + 'opencode has no statusline surface; its ruflo lifecycle ships via the plugins/ bridge + AGENTS.md')); + } + return rows; + }, +}; diff --git a/src/commands/status/sections/versions.mjs b/src/commands/status/sections/versions.mjs new file mode 100644 index 0000000..6b26b9e --- /dev/null +++ b/src/commands/status/sections/versions.mjs @@ -0,0 +1,25 @@ +import { driftReport } from '../../../lib/versions.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'versions', + async collect() { + const rows = []; + try { + for (const r of await driftReport()) { + if (!r.installed) { + rows.push(row('versions', r.pkg === 'ruflo' ? 'fail' : 'warn', + `${r.pkg} not installed globally`, 'setup installs it')); + } else if (r.outdated) { + rows.push(row('versions', 'warn', + `${r.pkg} ${r.installed} installed, ${r.latest} available`, 'sync upgrades + re-heals')); + } else { + rows.push(row('versions', 'ok', `${r.pkg} ${r.installed}${r.latest ? ' (latest)' : ''}`)); + } + } + } catch (e) { + rows.push(row('versions', 'warn', `version check unavailable: ${e.message}`)); + } + return rows; + }, +}; diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 7b8fe20..9de9751 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -14,7 +14,7 @@ import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-regis import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, retireCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; +import { commandHosts, hostInstallState, installHost, convergeProviderStack, guidanceContext, reportRetiredRouteChanges } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs'; import { pruneNpxStale } from '../lib/npx.mjs'; @@ -61,92 +61,82 @@ Examples: ak sync --dry-run preview the plan ak sync --no-upgrade re-heal without touching versions`; -export async function run({ - flags, - pkgRoot, - fetchLatest, - dejaVuAdapter = companionLifecycleFor('deja-vu'), - collectFn = collect, -}) { - const cwd = process.cwd(); - const dejaVuPlanOptions = { allowUpgrade: !flags['no-upgrade'] }; - // #134: draw the plan from CURRENT drift, not the TTL cache — a cache - // stamped before an upstream release claims "all current" and the upgrade - // never reaches the plan (the old force at apply time sat behind the very - // versions gate it needed to open). Dry-runs skip the refresh: it writes - // kit.json, and --dry-run is pinned to touch nothing — so a dry-run - // preview may be cache-stale by up to one TTL window. - if (!flags['dry-run'] && !flags['no-upgrade']) { - await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) }); - } - const rows = await collectFn({ pkgRoot, cwd, dejaVuAdapter, dejaVuPlanOptions }); - const plan = rows.filter((r) => r.fix) - // Model lifecycle actions are explicit advisory commands. `ak status` must - // name them, but sync neither refreshes catalogs nor applies model plans. - .filter((r) => r.subsystem !== 'models') - .filter((r) => !(flags['no-upgrade'] && ['versions', 'self', 'ruvnet-brain', 'ruvector'].includes(r.subsystem))); - - if (plan.length === 0) { ok('nothing to do — all subsystems healthy'); return 0; } - - console.log(bold(`sync plan (${plan.length} action(s)):`)); - for (const p of plan) console.log(` • [${p.subsystem}] ${p.fix} ${dim(`— because: ${p.message}`)}`); - if (flags['dry-run']) return 0; - console.log(''); - - const cfg = loadKitConfig(); - const subsystems = new Set(plan.map((p) => p.subsystem)); - const report = reportOutcome; - let dejaVuApplyFailed = false; - let aqeRouterApplyFailure = null; - // Run a managed heal under a live elapsed-time ticker, then print its result. - // Keeps every slow tool (npm upgrades, brain KB download, native rebuild) - // visibly alive instead of freezing the prompt; fast/local steps clear in <1s. - const step = async (name, thunk) => { const r = await withProgress(name, thunk); report(name, r); return r; }; - - if (subsystems.has('codex-statusline') && cfg.statusline?.codex?.preset) { - const preset = cfg.statusline.codex.preset; - const r = applyCodexStatusline(preset); - cfg.statusline.codex.lastProjection = projectionFor(preset); - saveKitConfig(cfg); - ok(`codex statusline: ${r.changed ? `restored ${preset} preset` : 'in sync'}`); - } - - if (subsystems.has('versions') && !flags['no-upgrade']) { - report('daemons', await heal.stopAllDaemons()); - // No force here: the pre-plan refresh above already ran for every - // non-dry-run, non-no-upgrade sync, so this read hits that fresh cache. - for (const d of await driftReport()) { - if (d.outdated || !d.installed) await step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg)); - } - } +// ── the sync step registry ─────────────────────────────────────────────────── +// Every heal used to be a `if (subsystems.has(X)) { ... }` block inlined into +// `run()`, with real ordering invariants (natives LAST among npm-tree +// mutations, statusline AFTER providers, kit self-update LAST of all) proven +// only by source-order and explained only in comments — nothing stopped a +// future edit from reordering them apart. Each step below is +// `{id, when(subsystems, flags, cfg), run(ctx)}`; SYNC_STEPS's array order +// *is* the ordering invariant, and `when` is a pure, explicitly-parameterized +// predicate (no closures) so it stays easy to reason about independent of +// `run`'s side effects. `run(ctx)` receives the shared per-invocation context +// (see `run()` below): {cfg, cwd, pkgRoot, flags, dejaVuAdapter, subsystems, +// report, step, state}. `state` carries the two cross-step signals +// (`dejaVuApplyFailed`, `aqeRouterApplyFailure`) the final convergence check +// needs — the only state that survives past its own step. +export const SYNC_STEPS = [ + { + id: 'codex-statusline', + when: (subs, flags, cfg) => subs.has('codex-statusline') && !!cfg.statusline?.codex?.preset, + run: (ctx) => { + const preset = ctx.cfg.statusline.codex.preset; + const r = applyCodexStatusline(preset); + ctx.cfg.statusline.codex.lastProjection = projectionFor(preset); + saveKitConfig(ctx.cfg); + ok(`codex statusline: ${r.changed ? `restored ${preset} preset` : 'in sync'}`); + }, + }, + { + id: 'versions', + when: (subs, flags) => subs.has('versions') && !flags['no-upgrade'], + run: async (ctx) => { + ctx.report('daemons', await heal.stopAllDaemons()); + // No force here: the pre-plan refresh in run() already ran for every + // non-dry-run, non-no-upgrade sync, so this read hits that fresh cache. + for (const d of await driftReport()) { + if (d.outdated || !d.installed) await ctx.step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg)); + } + }, + }, // ruvnet-brain: install if absent / re-run installer to pull latest when // drifted (force bypasses the installer's skip-if-present). Not an npm pkg, so - // it rides its own branch rather than the driftReport loop above. - if (subsystems.has('ruvnet-brain') && !flags['no-upgrade']) { - await step('ruvnet-brain', () => heal.installRuvnetBrain({ force: true })); - } + // it rides its own step rather than the driftReport loop above. + { + id: 'ruvnet-brain', + when: (subs, flags) => subs.has('ruvnet-brain') && !flags['no-upgrade'], + run: (ctx) => ctx.step('ruvnet-brain', () => heal.installRuvnetBrain({ force: true })), + }, // ruvector: an unmanaged global users wire up as an MCP server by hand. Only // ever UPGRADED — status emits no row (and so no plan entry) when it is absent, - // so this branch can never install it for someone who didn't opt in. - // The status row already gates on registration + opt-in (an unregistered or - // opted-out ruvector emits no `fix`, so it cannot reach this plan) — but this - // branch installs software globally, so it re-checks rather than trusting the - // plan to be the only guard. - if (subsystems.has('ruvector') && !flags['no-upgrade'] && ruvectorManaged(cfg)) { - await step('ruvector', () => heal.upgradePackage(RUVECTOR_PKG)); - } + // so this step can never install it for someone who didn't opt in. The status + // row already gates on registration + opt-in (an unregistered or opted-out + // ruvector emits no `fix`, so it cannot reach this plan) — but this step + // installs software globally, so it re-checks rather than trusting the plan + // to be the only guard. + { + id: 'ruvector', + when: (subs, flags, cfg) => subs.has('ruvector') && !flags['no-upgrade'] && ruvectorManaged(cfg), + run: (ctx) => ctx.step('ruvector', () => heal.upgradePackage(RUVECTOR_PKG)), + }, // The brain installer's own nightly self-updater (macOS LaunchAgent) bypasses // ak-managed updates — disabling it is a heal, not an upgrade, so it runs even // under --no-upgrade. Reversible: `npx ruvnet-brain --enable-nightly`. - if (subsystems.has('ruvnet-brain-nightly')) { - report('ruvnet-brain nightly', await heal.disableRuvnetBrainNightly()); - } - // cfg.security gate: on `versions` this branch would otherwise heal the + { + id: 'ruvnet-brain-nightly', + when: (subs) => subs.has('ruvnet-brain-nightly'), + run: async (ctx) => ctx.report('ruvnet-brain nightly', await heal.disableRuvnetBrainNightly()), + }, + // cfg.security gate: on `versions` this step would otherwise heal the // security surface even when the user disabled it (`ak setup --no-security`). - if ((subsystems.has('security') || subsystems.has('versions')) && cfg.security !== false) { - await step('aidefence', () => heal.healAidefence()); - await step('aqe solver', () => heal.healAqeSolver()); - } + { + id: 'security', + when: (subs, flags, cfg) => (subs.has('security') || subs.has('versions')) && cfg.security !== false, + run: async (ctx) => { + await ctx.step('aidefence', () => heal.healAidefence()); + await ctx.step('aqe solver', () => heal.healAqeSolver()); + }, + }, // natives LAST among the npm-tree mutations. Every agentdb location resolves up // to the single shared ruflo/node_modules/better-sqlite3, so any later `npm // install` into the ruflo/aqe root re-resolves that copy and drops the freshly @@ -154,185 +144,294 @@ export async function run({ // build script never re-runs and a half-built build/ dir (obj/, sqlite3.a, no // .node) is left behind. Healing here means nothing reshapes the tree after us. // Runs on `security` too: an aidefence install wipes the binding even when the - // plan never flagged natives. - if (subsystems.has('natives') || subsystems.has('versions') || subsystems.has('security')) { - await step('natives', () => heal.healNatives()); - } + // plan never flagged natives. (Array position, not this comment, is what keeps + // it last among those three sibling gates — see the section note above.) + { + id: 'natives', + when: (subs) => subs.has('natives') || subs.has('versions') || subs.has('security'), + run: (ctx) => ctx.step('natives', () => heal.healNatives()), + }, // npx: prune cached envs serving outdated ruflo-family code — the statusline/ // hook `npx --prefer-offline` fallbacks execute these verbatim, so a stale env // keeps retired defects (the fabricated CVE counter) alive on an upgraded // machine. Runs on `versions` too: an upgrade is precisely what turns a // previously-current cache stale. - if (subsystems.has('npx') || subsystems.has('versions')) { - report('npx', pruneNpxStale()); - } + { + id: 'npx', + when: (subs) => subs.has('npx') || subs.has('versions'), + run: (ctx) => ctx.report('npx', pruneNpxStale()), + }, // Scaffold agents: the row only carries a fix (and so only enters the plan) // when the installed CLI already ships `migrate fix --agents` (ruflo#2986) — // delegation, never a kit-side restore. If THIS sync's upgrade step is what // delivered the capability, the pre-upgrade plan won't include it; the next // `ak status`/`ak sync` picks it up (same one-pass-behind rule as any // upgrade-delivered fix). - if (subsystems.has('scaffold-agents')) { - await step('scaffold agents', () => runScaffoldAgentsFix(cwd)); - } - if (subsystems.has('aqe')) { - report('rvf', heal.healRvf(paths.projectAqeDir(cwd))); - } + { + id: 'scaffold-agents', + when: (subs) => subs.has('scaffold-agents'), + run: (ctx) => ctx.step('scaffold agents', () => runScaffoldAgentsFix(ctx.cwd)), + }, + { + id: 'aqe-rvf', + when: (subs) => subs.has('aqe'), + run: (ctx) => ctx.report('rvf', heal.healRvf(paths.projectAqeDir(ctx.cwd))), + }, // agentdb: install/repin the standalone CLI to ruflo's bundled version so the // shared cognitive store stays coherent (harvest's write path depends on it). - if (subsystems.has('agentdb') && cfg.agentdb !== false) { - await step('agentdb', () => heal.healAgentdb()); - } - if (subsystems.has('mcp') && cfg.mcp.register) { - const okReg = await withProgress('mcp', () => mcpRegister()); - if (okReg) { - const { denied } = applyExclusions(cfg.mcp.excludeFamilies ?? []); - ok(`mcp: claude-flow registered (user scope), ${denied} tool(s) denied per kit.json`); - } else warn('mcp: claude mcp add failed — run: ak x mcp pick'); - } - if (subsystems.has('daemons')) { - const stale = staleDaemons(await listDaemons({ cwd })); - for (const r of reap(stale)) { - (r.killed ? ok : warn)(`daemon pid=${r.pid}: ${r.killed ? 'reaped' : 'could not stop'}`); - } - } + { + id: 'agentdb', + when: (subs, flags, cfg) => subs.has('agentdb') && cfg.agentdb !== false, + run: (ctx) => ctx.step('agentdb', () => heal.healAgentdb()), + }, + { + id: 'mcp', + when: (subs, flags, cfg) => subs.has('mcp') && cfg.mcp.register, + run: async (ctx) => { + const okReg = await withProgress('mcp', () => mcpRegister()); + if (okReg) { + const { denied } = applyExclusions(ctx.cfg.mcp.excludeFamilies ?? []); + ok(`mcp: claude-flow registered (user scope), ${denied} tool(s) denied per kit.json`); + } else warn('mcp: claude mcp add failed — run: ak x mcp pick'); + }, + }, + { + id: 'daemons', + when: (subs) => subs.has('daemons'), + run: async (ctx) => { + const stale = staleDaemons(await listDaemons({ cwd: ctx.cwd })); + for (const r of reap(stale)) { + (r.killed ? ok : warn)(`daemon pid=${r.pid}: ${r.killed ? 'reaped' : 'could not stop'}`); + } + }, + }, // hosts: install any ENABLED host that is entirely absent (updates to - // npm-managed hosts ride the versions branch above via driftReport). - if (subsystems.has('hosts')) { - for (const h of commandHosts()) { - if (!cfg.integrations.hosts[h.id]) continue; - if ((await hostInstallState(h)).method !== 'absent') continue; - await step(`install ${h.id}`, () => installHost(h.id)); - } - } + // npm-managed hosts ride the `versions` step above via driftReport). + { + id: 'hosts', + when: (subs) => subs.has('hosts'), + run: async (ctx) => { + for (const h of commandHosts()) { + if (!ctx.cfg.integrations.hosts[h.id]) continue; + if ((await hostInstallState(h)).method !== 'absent') continue; + await ctx.step(`install ${h.id}`, () => installHost(h.id)); + } + }, + }, // Managed companion convergence is independent from host lifecycle // adapters. The adapter owns exact package/target/index ordering and mutates // only its in-memory ownership ledger; this command owns persistence. Save a // changed ledger even after a partial failure so a later sync or uninstall // retains the proof for every operation that did verify successfully. - if (subsystems.has('deja-vu') && dejaVuAdapter) { - const lifecycle = await withProgress('deja-vu', () => runLifecycle({ - adapter: dejaVuAdapter, - action: 'apply', - cfg, - options: { pkgRoot, allowUpgrade: !flags['no-upgrade'] }, - })); - if (lifecycle.configChanged) saveKitConfig(cfg); - dejaVuApplyFailed = lifecycle.ok === false; - const applyReport = renderApplyReport('deja-vu', lifecycle); - for (const line of applyReport.lines) printReportLine(line); - } + { + id: 'deja-vu', + when: (subs) => subs.has('deja-vu'), + run: async (ctx) => { + if (!ctx.dejaVuAdapter) return; + const lifecycle = await withProgress('deja-vu', () => runLifecycle({ + adapter: ctx.dejaVuAdapter, + action: 'apply', + cfg: ctx.cfg, + options: { pkgRoot: ctx.pkgRoot, allowUpgrade: !ctx.flags['no-upgrade'] }, + })); + if (lifecycle.configChanged) saveKitConfig(ctx.cfg); + ctx.state.dejaVuApplyFailed = lifecycle.ok === false; + const applyReport = renderApplyReport('deja-vu', lifecycle); + for (const line of applyReport.lines) printReportLine(line); + }, + }, // opencode host wiring: connected MCPs, compact lazy gateway, lifecycle - // bridge, specialist dispatcher, and platform skill. Runs AFTER - // the hosts install branch so an enable+install converges in one sync, and - // only when the CLI is actually present — otherwise the writers would create - // the host's config home for a host that isn't there (codex-review #4). - // Runs BEFORE the blocks branch: the agents-opencode guidance target is gated - // on the config home this branch creates — this order lets a fresh enable - // converge guidance in the SAME sync (a second sync is then a true no-op). - // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, - // ADR-0031 P3) rather than naming opencode, so a second lifecycle host — - // built-in or admitted — needs no new branch here. lifecycleExecutionEnabled - // gates each host exactly as setup.mjs does (built-in: cfg enablement only; - // admitted: cfg enablement AND the experimental flag — never auto-enabled). + // bridge, specialist dispatcher, and platform skill. Runs AFTER the `hosts` + // step so an enable+install converges in one sync, and only when the CLI is + // actually present — otherwise the writers would create the host's config + // home for a host that isn't there (codex-review #4). Runs BEFORE `blocks`: + // the agents-opencode guidance target is gated on the config home this step + // creates — this order lets a fresh enable converge guidance in the SAME + // sync (a second sync is then a true no-op). Registry-driven: loops + // hostsWithLifecycle() (built-ins + admitted, ADR-0031 P3) rather than + // naming opencode, so a second lifecycle host — built-in or admitted — + // needs no new step here. lifecycleExecutionEnabled gates each host exactly + // as setup.mjs does (built-in: cfg enablement only; admitted: cfg + // enablement AND the experimental flag — never auto-enabled). // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle // result's own shape, so this loop body never destructures a host-specific // result directly; opencode's per-surface lines render exactly as before. - for (const hostId of hostsWithLifecycle()) { - if (!subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, cfg)) continue; - if (!(await have(detectionBinFor(hostId)))) { - info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`); - continue; - } - const lifecycle = await runLifecycle({ - adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot }, - }); - const applyReport = renderApplyReport(hostId, lifecycle); - // persist the markers on ANY refresh (a converged file whose kit.json - // markers are stale/missing still needs the save, or the next teardown - // cannot prove ownership — codex-review r3), not only on file changes. - if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(cfg); - for (const line of applyReport.lines) printReportLine(line); - } - // The 'opencode' guard: the opencode branch above can CREATE the config home - // that activates the agents-opencode guidance target — a machine whose other - // guidance is already converged (no blocks drift rows) would otherwise skip - // this branch on a fresh enable and land the guidance one sync late + { + id: 'host-lifecycles', + when: () => true, + run: async (ctx) => { + for (const hostId of hostsWithLifecycle()) { + if (!ctx.subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, ctx.cfg)) continue; + if (!(await have(detectionBinFor(hostId)))) { + info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`); + continue; + } + const lifecycle = await runLifecycle({ + adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg: ctx.cfg, options: { pkgRoot: ctx.pkgRoot }, + }); + const applyReport = renderApplyReport(hostId, lifecycle); + // persist the markers on ANY refresh (a converged file whose kit.json + // markers are stale/missing still needs the save, or the next teardown + // cannot prove ownership — codex-review r3), not only on file changes. + if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(ctx.cfg); + for (const line of applyReport.lines) printReportLine(line); + } + }, + }, + // The 'opencode' guard: the host-lifecycles step above can CREATE the config + // home that activates the agents-opencode guidance target — a machine whose + // other guidance is already converged (no blocks drift rows) would otherwise + // skip this step on a fresh enable and land the guidance one sync late // (codex-review r3). When the CLI is absent the target's own config-home // gate still refuses to fabricate anything. - if (subsystems.has('blocks') || subsystems.has('versions') || subsystems.has('opencode')) { - // The reconcile loop itself (targets, retired-row strips, dual-mode/ - // opencode flag gating) lives in blocks.mjs reconcileGuidance — shared - // with setup's final pass so the two commands cannot drift (ADR-0008 on - // target scoping). - const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; - for (const t of await reconcileGuidance({ cwd, cfg, pkgRoot, context: ctx })) { - // stay quiet on the agents targets unless they actually changed (single-host - // leaves them unmanaged); always report the claude target. - if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); - } - } - if (subsystems.has('providers') || subsystems.has('routing') || subsystems.has('codex-mcp')) { - report('providers', applyHosts(cfg, cwd)); - // heal per-activity routing: seed from defaults if dual-host only just became - // eligible (e.g. aqe upgraded ≥3.13.1 since enablement), before materializing. - const seed = seedActivityRoutesIfMultiHost(cfg); - if (seed.seeded) { saveKitConfig(cfg); report('routing', { ok: true, changed: true, detail: `seeded ${seed.count} activities` }); } - // Retire withdrawn models from the persisted policy. Distinct from divergence - // (which stays an explicit `ak x host refresh` decision): a retired model - // stops answering, so leaving it named on disk is a scheduled failure. Only - // seeded entries are rewritten; a user pin is reported and left alone. - const retired = migrateRetiredRoutesInConfig(cfg); - if (retired.changes.length > 0) { - if (retired.changed) saveKitConfig(cfg); - for (const c of retired.changes) { - const when = c.retiresOn ? `retires ${c.retiresOn}` : 'already withdrawn'; - report('routing', c.rewritten - ? { ok: true, changed: true, detail: `${c.activity} ${c.field}: ${c.from} → ${c.to} (${when})` } - : { ok: true, changed: false, detail: `${c.activity} ${c.field} pins ${c.from} (${when}) — user pin kept; ak runs ${c.to}` }); + { + id: 'blocks', + when: (subs) => subs.has('blocks') || subs.has('versions') || subs.has('opencode'), + run: async (ctx) => { + // The reconcile loop itself (targets, retired-row strips, dual-mode/ + // opencode flag gating) lives in blocks.mjs reconcileGuidance — shared + // with setup's final pass so the two commands cannot drift (ADR-0008 on + // target scoping; providers.mjs's guidanceContext is the shared ctx + // shape both commands build). + for (const t of await reconcileGuidance({ + cwd: ctx.cwd, cfg: ctx.cfg, pkgRoot: ctx.pkgRoot, context: guidanceContext(ctx.cfg), + })) { + // stay quiet on the agents targets unless they actually changed + // (single-host leaves them unmanaged); always report the claude target. + if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); } - } - const router = applyAqeRouter(cfg, cwd); - if (router.changed || !router.ok) report('aqe router', router); - if (!router.ok) aqeRouterApplyFailure = router.detail || 'AQE router apply failed'; - const mcp = await retireCodexMcp(cfg, cwd); - if (mcp.changed) saveKitConfig(cfg); - if (mcp.changed || !mcp.ok) report('legacy codex MCP', mcp); - // Independent Ruflo integration for Codex-driven sessions. - const rmcp = await ensureRufloMcpInCodex(cfg, cwd); - if (rmcp.changed) saveKitConfig(cfg); - if (rmcp.changed || !rmcp.ok) report('ruflo→codex MCP', rmcp); - const prov = await withProgress('providers (api)', () => applyProviders(cfg, cwd)); - if (prov.changed || !prov.ok) report('providers (api)', prov); - } + }, + }, + { + id: 'providers', + when: (subs) => subs.has('providers') || subs.has('routing') || subs.has('codex-mcp'), + run: async (ctx) => { + // The shared pipeline (providers.mjs's convergeProviderStack) computes + // and persists every step; this reporter only decides what to print and + // how, preserving sync's exact wording/gating per step. + const reporter = (step, result) => { + if (step === 'hosts') { ctx.report('providers', result); return; } + // heal per-activity routing: seed from defaults if dual-host only just + // became eligible (e.g. aqe upgraded ≥3.13.1 since enablement), before + // materializing. + if (step === 'routing-seed') { + if (result.seeded) ctx.report('routing', { ok: true, changed: true, detail: `seeded ${result.count} activities` }); + return; + } + // Retire withdrawn models from the persisted policy. Distinct from + // divergence (which stays an explicit `ak x host refresh` decision): a + // retired model stops answering, so leaving it named on disk is a + // scheduled failure. Only seeded entries are rewritten; a user pin is + // reported and left alone. + if (step === 'routing-retired') { reportRetiredRouteChanges(result.changes); return; } + if (step === 'aqe-router') { + if (result.changed || !result.ok) ctx.report('aqe router', result); + if (!result.ok) ctx.state.aqeRouterApplyFailure = result.detail || 'AQE router apply failed'; + return; + } + if (step === 'legacy-codex-mcp') { + if (result.changed || !result.ok) ctx.report('legacy codex MCP', result); + return; + } + // Independent Ruflo integration for Codex-driven sessions. + if (step === 'ruflo-codex-mcp') { + if (result.changed || !result.ok) ctx.report('ruflo→codex MCP', result); + return; + } + if (step === 'providers-api' && (result.changed || !result.ok)) ctx.report('providers (api)', result); + }; + await convergeProviderStack(ctx.cfg, ctx.cwd, { + reporter, + runProviders: (fn) => withProgress('providers (api)', fn), + }); + }, + }, // Gate includes 'providers': applyProviders runs ruflo CLI commands, and any // ruflo command is a potential helper-refresh wiper — so a providers-only // sync must re-heal the statusline afterwards (this step runs after the - // providers step by design). Without this, a stale-oracle miss could let a - // providers sync wipe the footer with no re-inject planned. - if (subsystems.has('statusline') || subsystems.has('versions') || subsystems.has('providers')) { - // withProgress: fixStatusline blocks on a node subprocess (ruflo's helper - // refresh, up to 30s). The interval can't animate through a synchronous - // execFileSync, but the initial "⏳ statusline" render lands before the - // block — a visible label beats a frozen prompt. - const r = await withProgress('statusline', async () => fixStatusline(cwd)); - (r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`); - // Honest success: fixStatusline invokes ruflo's PRIVATE helper-refresh - // internal, best-effort. If the stamp is STILL stale after the heal, that - // refresh silently no-oped (e.g. upstream moved the dist module) and the - // next ruflo command will wipe the footer we just injected — say so - // instead of letting "footer injected" read as converged. - if (helperStampStale(cwd)) { - warn('statusline: helper stamp still stale after heal — ruflo\'s refresh did not run; the footer may not survive the next ruflo command'); - } - } - + // `providers` step by design — array position, not comments, keeps it so). + // Without this, a stale-oracle miss could let a providers sync wipe the + // footer with no re-inject planned. + { + id: 'statusline', + when: (subs) => subs.has('statusline') || subs.has('versions') || subs.has('providers'), + run: async (ctx) => { + // withProgress: fixStatusline blocks on a node subprocess (ruflo's helper + // refresh, up to 30s). The interval can't animate through a synchronous + // execFileSync, but the initial "⏳ statusline" render lands before the + // block — a visible label beats a frozen prompt. + const r = await withProgress('statusline', async () => fixStatusline(ctx.cwd)); + (r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`); + // Honest success: fixStatusline invokes ruflo's PRIVATE helper-refresh + // internal, best-effort. If the stamp is STILL stale after the heal, that + // refresh silently no-oped (e.g. upstream moved the dist module) and the + // next ruflo command will wipe the footer we just injected — say so + // instead of letting "footer injected" read as converged. + if (helperStampStale(ctx.cwd)) { + warn('statusline: helper stamp still stale after heal — ruflo\'s refresh did not run; the footer may not survive the next ruflo command'); + } + }, + }, // kit self-update — LAST, after every other heal: npm replaces the kit's // files on disk, and the new code applies from the next ak run, so nothing // after this point should depend on the kit's own modules being current. - if (subsystems.has('self') && !flags['no-upgrade']) { - const s = await selfDrift({ pkgRoot, force: true }); - if (s.outdated) await step('self-update', () => heal.selfUpdate(s.latest)); + // (Array position — the final entry in SYNC_STEPS — is the invariant.) + { + id: 'self', + when: (subs, flags) => subs.has('self') && !flags['no-upgrade'], + run: async (ctx) => { + const s = await selfDrift({ pkgRoot: ctx.pkgRoot, force: true }); + if (s.outdated) await ctx.step('self-update', () => heal.selfUpdate(s.latest)); + }, + }, +]; + +export async function run({ + flags, + pkgRoot, + fetchLatest, + dejaVuAdapter = companionLifecycleFor('deja-vu'), + collectFn = collect, +}) { + const cwd = process.cwd(); + const dejaVuPlanOptions = { allowUpgrade: !flags['no-upgrade'] }; + // #134: draw the plan from CURRENT drift, not the TTL cache — a cache + // stamped before an upstream release claims "all current" and the upgrade + // never reaches the plan (the old force at apply time sat behind the very + // versions gate it needed to open). Dry-runs skip the refresh: it writes + // kit.json, and --dry-run is pinned to touch nothing — so a dry-run + // preview may be cache-stale by up to one TTL window. + if (!flags['dry-run'] && !flags['no-upgrade']) { + await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) }); + } + const rows = await collectFn({ pkgRoot, cwd, dejaVuAdapter, dejaVuPlanOptions }); + const plan = rows.filter((r) => r.fix) + // Model lifecycle actions are explicit advisory commands. `ak status` must + // name them, but sync neither refreshes catalogs nor applies model plans. + .filter((r) => r.subsystem !== 'models') + .filter((r) => !(flags['no-upgrade'] && ['versions', 'self', 'ruvnet-brain', 'ruvector'].includes(r.subsystem))); + + if (plan.length === 0) { ok('nothing to do — all subsystems healthy'); return 0; } + + console.log(bold(`sync plan (${plan.length} action(s)):`)); + for (const p of plan) console.log(` • [${p.subsystem}] ${p.fix} ${dim(`— because: ${p.message}`)}`); + if (flags['dry-run']) return 0; + console.log(''); + + const cfg = loadKitConfig(); + const subsystems = new Set(plan.map((p) => p.subsystem)); + const report = reportOutcome; + // Run a managed heal under a live elapsed-time ticker, then print its result. + // Keeps every slow tool (npm upgrades, brain KB download, native rebuild) + // visibly alive instead of freezing the prompt; fast/local steps clear in <1s. + const step = async (name, thunk) => { const r = await withProgress(name, thunk); report(name, r); return r; }; + const state = { dejaVuApplyFailed: false, aqeRouterApplyFailure: null }; + const ctx = { + cfg, cwd, pkgRoot, flags, dejaVuAdapter, subsystems, report, step, state, + }; + + for (const s of SYNC_STEPS) { + if (s.when(subsystems, flags, cfg)) await s.run(ctx); } // converge proof @@ -372,10 +471,10 @@ export async function run({ // erase an apply failure from this run. In particular, an unavailable // external fallback can leave only a warning row; claiming convergence // after applyAqeRouter returned !ok is a false success and retry loop. - if (aqeRouterApplyFailure && !remaining.some((r) => r.subsystem === 'providers')) { - remaining.push({ subsystem: 'providers', message: `AQE router apply failed: ${aqeRouterApplyFailure}` }); + if (state.aqeRouterApplyFailure && !remaining.some((r) => r.subsystem === 'providers')) { + remaining.push({ subsystem: 'providers', message: `AQE router apply failed: ${state.aqeRouterApplyFailure}` }); } - if (dejaVuApplyFailed && !remaining.some((r) => r.subsystem === 'deja-vu')) { + if (state.dejaVuApplyFailed && !remaining.some((r) => r.subsystem === 'deja-vu')) { remaining.push({ subsystem: 'deja-vu', message: 'companion lifecycle apply failed' }); } if (remaining.length === 0) { diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index fa91fc4..152f0dd 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -7,11 +7,11 @@ import readline from 'node:readline/promises'; import { HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, detectHosts, - settingsTarget, isDefault, applyHosts, applyProviders, + settingsTarget, isDefault, undoProviders, hostInstallState, hostAuthState, installHost, applyAqeRouter, undoAqeRouter, bothHostsEnabled, DUAL_ROLE_TIP, JUDGE_BIAS_TIP, QE_COURT_TIP, suggestedFallbackFor, - seedActivityRoutesIfMultiHost, printActivityRoutingTable, retireCodexMcp, undoCodexMcp, - ensureRufloMcpInCodex, undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource, + seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, printActivityRoutingTable, convergeProviderStack, reportRetiredRouteChanges, undoCodexMcp, + undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource, collectIntegrationFacts, aqeSelectableProviderTypes, aqeSelectableChainProviderTypes, } from '../../lib/providers.mjs'; import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, pruneRoutesForHosts, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; @@ -28,7 +28,9 @@ import { newlyEnabledHostTrustManifest, trustManifestLines, } from '../../lib/trust-manifest.mjs'; import { have } from '../../lib/exec.mjs'; -import { ok, warn, fail, info, dim, bold, yellow } from '../../lib/output.mjs'; +import { + ok, warn, fail, info, dim, bold, yellow, +} from '../../lib/output.mjs'; import { repoRoot } from '../../lib/paths.mjs'; import { writeJsonWithBackup } from '../../lib/settings.mjs'; import { panelFromRouting, validateCourtConfig, readQeCourtConfig, qeCourtConfigPath, vendorOf, qeCourtShipped } from '../../lib/qeCourt.mjs'; @@ -449,88 +451,153 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv ok(`qe-court routing updated: ${changes.map(([role, p]) => `${role}→${p}`).join(', ')}`); } -async function pick({ flags, cwd, pkgRoot }) { - let aqeProviderTypes = aqeSelectableProviderTypes(); - let aqeChainProviderTypes = aqeSelectableChainProviderTypes(); - const cfg = loadKitConfig(); - const trustBaseline = structuredClone(cfg); - const hosts = await detectHosts(cwd); - // Routing eligibility is capability-derived. OpenCode retains its independent - // lifecycle wiring even though it is now an execution host; it is never a - // primary/AQE host because those are separate registry capabilities. - // --host is the complete desired enabled-host set on BOTH tiers; excluding an - // enabled host disables it (ak-managed wiring stripped, user config kept). - // Keep primary-host selection on the built-in routing set, but admit an - // explicitly named external host when the live adapter overlay proves it is - // routable. Provider-only retunes also carry already-enabled external ids - // through unchanged instead of mistaking them for unknown host tokens. - const ROUTING = new Set(routableHostIds()); - const EFFECTIVE_ROUTING = new Set(effectiveRoutableHostIds()); - const MANAGED_HOSTS = new Set(HOSTS.map((host) => host.id)); - const prevOpencode = !!cfg.integrations?.hosts?.opencode - || cfg.integrations?.ownership?.opencode?.mcp === 'ak'; - let enabled; +// ── pick(): three stages ───────────────────────────────────────────────────── +// 1. parsePickInput — raw {enabled, aqeProvider, aqeFallback, models} +// intent, from flags or an interactive prompt. +// 2. resolvePickDecision — validate + resolve that intent into the actual +// cfg.integrations.hosts/providers/routing writes +// (host validation, primary-host resolution, admission +// refresh, aqe selection validation, route policy). +// 3. the apply step — install/wire/converge (uses convergeProviderStack). +// pick() itself is the sequencing of these three stages plus the handful of +// pick-specific side effects (trust confirmation, codex-disable teardown, +// opencode lifecycle) that sit between them. + +/** The non-interactive half of stage 1: flags fully determine the intent. */ +function parsePickInputFromFlags(flags, cfg) { + const enabled = flags.host !== undefined + ? flags.host.split(',').map((s) => s.trim()).filter(Boolean) + : Object.entries(cfg.integrations.hosts).filter(([, v]) => v).map(([k]) => k); let aqeProvider = cfg.providers.aqeProvider ?? null; + if (flags['aqe-provider'] !== undefined) { + const v = flags['aqe-provider'].trim().toLowerCase(); + aqeProvider = (v === 'none' || v === '') ? null : v; + } // A legacy chain written before provenance existed reads as 'user': we cannot // tell whether it was typed or accepted, so it must never be auto-touched. let aqeFallback = (cfg.providers.aqeFallback ?? []).map((e) => ({ ...e, source: fallbackSource(e) })); - let models = cfg.providers.models ?? []; - const prevPrimary = cfg.routing?.primaryHost ?? DEFAULT_PRIMARY_HOST; - const oldPolicy = cfg.routing?.routes ?? {}; - const prevCodex = !!cfg.integrations?.hosts?.codex; - const codexMcpManaged = cfg.integrations?.ownership?.codex?.mcp === 'ak'; - const rufloCodexManaged = cfg.integrations?.ownership?.codex?.reverseMcp === 'ak'; + if (flags['aqe-fallback'] !== undefined) { + const v = flags['aqe-fallback'].trim().toLowerCase(); + aqeFallback = (v === 'none' || v === '') ? [] : stamp('user')(parseFallback(v)); + } + const models = flags.provider !== undefined ? parseModels(flags.provider) : (cfg.providers.models ?? []); + return { + enabled, aqeProvider, aqeFallback, models, + }; +} +/** The interactive half of stage 1: prompt for enable/provider/fallback/ + * models via readline. Returns `{code}` when no frontier CLI is detected + * at all. */ +async function promptPickInputInteractively(cfg, hosts, registries, aqeProviderTypes) { + const installedRouting = HOSTS.filter((h) => hosts[h.id].present && registries.ROUTING.has(h.id) + && (h.id !== 'opencode' || cfg.integrations.hosts.opencode)).map((h) => h.id); + const installedOpenCode = hosts.opencode?.present && !cfg.integrations.hosts.opencode; + if (installedRouting.length === 0 && !installedOpenCode) { fail('no frontier CLI (claude/codex/opencode) found on PATH'); return { code: 1 }; } + console.log(`Installed hosts: ${installedRouting.join(', ') || 'none'}${installedOpenCode ? dim(' (opencode is available; type it to opt in)') : ''}`); + // Default: every currently ENABLED host (even one temporarily absent from + // PATH — a bare enter must never tear down an enabled host it simply can't + // see right now) ∪ newly detected routing hosts. An installed-but-disabled + // OpenCode host remains opt-in by typing it — a bare enter must not opt a + // third host's config home in sight unseen either. + const enabledHosts = HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id); + const dflt = [...new Set([...enabledHosts, ...installedRouting])]; + const absentEnabled = enabledHosts.filter((h) => !hosts[h].present); + if (absentEnabled.length) { + console.log(dim(` enabled but not detected right now: ${absentEnabled.join(', ')} (kept enabled on Enter)`)); + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${dflt.join(',')}]: `)).trim(); + const enabled = (hAns || dflt.join(',')).split(',').map((s) => s.trim()).filter(Boolean); + console.log(dim(` ${AQE_BILLING_HINT}`)); + const aAns = (await rl.question(`agentic-qe primary LLM provider — ${aqeProviderTypes.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); + const aqeProvider = aAns ? aAns : null; + const suggestion = suggestedFallbackFor(enabled); + const fAns = (await rl.question( + `aqe fallback chain, ordered (e.g. "claude-code:claude-opus-5; openai:gpt-5.6"${suggestion ? `, blank = use suggested [${suggestion}]` : ', blank = none'}): `, + )).trim().toLowerCase(); + const aqeFallback = fAns + ? stamp('user')(parseFallback(fAns)) + : (suggestion ? stamp('suggested')(parseFallback(suggestion.toLowerCase())) : []); + const provAns = (await rl.question('ruflo providers to register (e.g. ollama:qwen3.6:27b, blank to skip): ')).trim(); + const models = provAns ? parseModels(provAns) : (cfg.providers.models ?? []); + rl.close(); + return { + enabled, aqeProvider, aqeFallback, models, + }; +} + +/** Stage 1/3: resolve the raw enable/provider intent — from flags + * (non-interactive) or a readline prompt sequence. Returns `{code}` when + * pick() must return immediately (no frontier CLI detected on an + * interactive run), else the parsed intent plus `nonInteractive` (used + * later to gate the qe-court defaults prompt). */ +async function parsePickInput({ + flags, cfg, hosts, registries, aqeProviderTypes, +}) { const nonInteractive = flags.host !== undefined || flags['aqe-provider'] !== undefined || flags['aqe-fallback'] !== undefined || flags.provider !== undefined || flags['primary-host'] !== undefined; - if (nonInteractive) { - enabled = flags.host !== undefined - ? flags.host.split(',').map((s) => s.trim()).filter(Boolean) - : Object.entries(cfg.integrations.hosts).filter(([, v]) => v).map(([k]) => k); - if (flags['aqe-provider'] !== undefined) { - const v = flags['aqe-provider'].trim().toLowerCase(); - aqeProvider = (v === 'none' || v === '') ? null : v; - } - if (flags['aqe-fallback'] !== undefined) { - const v = flags['aqe-fallback'].trim().toLowerCase(); - aqeFallback = (v === 'none' || v === '') ? [] : stamp('user')(parseFallback(v)); - } - if (flags.provider !== undefined) models = parseModels(flags.provider); - } else { - const installedRouting = HOSTS.filter((h) => hosts[h.id].present && ROUTING.has(h.id) - && (h.id !== 'opencode' || cfg.integrations.hosts.opencode)).map((h) => h.id); - const installedOpenCode = hosts.opencode?.present && !cfg.integrations.hosts.opencode; - if (installedRouting.length === 0 && !installedOpenCode) { fail('no frontier CLI (claude/codex/opencode) found on PATH'); return 1; } - console.log(`Installed hosts: ${installedRouting.join(', ') || 'none'}${installedOpenCode ? dim(' (opencode is available; type it to opt in)') : ''}`); - // Default: every currently ENABLED host (even one temporarily absent from - // PATH — a bare enter must never tear down an enabled host it simply can't - // see right now) ∪ newly detected routing hosts. An installed-but-disabled - // OpenCode host remains opt-in by typing it — a bare enter must not opt a - // third host's config home in sight unseen either. - const enabledHosts = HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id); - const dflt = [...new Set([...enabledHosts, ...installedRouting])]; - const absentEnabled = enabledHosts.filter((h) => !hosts[h].present); - if (absentEnabled.length) { - console.log(dim(` enabled but not detected right now: ${absentEnabled.join(', ')} (kept enabled on Enter)`)); - } - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${dflt.join(',')}]: `)).trim(); - enabled = (hAns || dflt.join(',')).split(',').map((s) => s.trim()).filter(Boolean); - console.log(dim(` ${AQE_BILLING_HINT}`)); - const aAns = (await rl.question(`agentic-qe primary LLM provider — ${aqeProviderTypes.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); - aqeProvider = aAns ? aAns : null; - const suggestion = suggestedFallbackFor(enabled); - const fAns = (await rl.question( - `aqe fallback chain, ordered (e.g. "claude-code:claude-opus-5; openai:gpt-5.6"${suggestion ? `, blank = use suggested [${suggestion}]` : ', blank = none'}): `, - )).trim().toLowerCase(); - aqeFallback = fAns - ? stamp('user')(parseFallback(fAns)) - : (suggestion ? stamp('suggested')(parseFallback(suggestion.toLowerCase())) : []); - const provAns = (await rl.question('ruflo providers to register (e.g. ollama:qwen3.6:27b, blank to skip): ')).trim(); - if (provAns) models = parseModels(provAns); - rl.close(); + const parsed = nonInteractive + ? parsePickInputFromFlags(flags, cfg) + : await promptPickInputInteractively(cfg, hosts, registries, aqeProviderTypes); + if (parsed.code !== undefined) return parsed; + return { ...parsed, nonInteractive }; +} + +/** Validate the aqe primary-provider and fallback-chain selections against + * the (possibly admission-refreshed) selectable sets, warning about — + * rather than silently keeping — anything unusable. Returns the validated + * {aqeProvider, aqeFallback}. */ +function validatePickAqeSelections({ + aqeProvider, aqeFallback, aqeProviderTypes, aqeChainProviderTypes, +}) { + // validate aqe primary provider + let provider = aqeProvider; + if (provider && !aqeProviderTypes.includes(provider)) { + const norm = provider === 'anthropic' ? 'claude' : provider; + if (aqeProviderTypes.includes(norm)) provider = norm; + else { warn(`unknown aqe provider '${provider}' — leaving aqe on its default (valid: ${aqeProviderTypes.join(', ')})`); provider = null; } + } + // validate fallback chain providers (chain gate admits codex — #108 phase 3) + const fallback = aqeFallback + .map((e) => ({ ...e, provider: e.provider === 'anthropic' ? 'claude' : e.provider })) + .filter((e) => { + const okp = aqeChainProviderTypes.includes(e.provider); + if (!okp) warn(`dropping unknown fallback provider '${e.provider}'`); + else if (!e.models.length) warn(`fallback entry '${e.provider}' has no models — aqe may skip it; add e.g. ${e.provider}:`); + return okp; + }); + // Tell the user AT ENTRY TIME that a rung is inert, and name what it needs — + // the failure otherwise surfaces at QE-run time, far from the config (#54). + const gaps = credentialGaps(fallback); + for (const g of gaps) { + warn(`${g.provider}: no ${g.missing.join(' / ')} in env — this rung will fail over into nothing`); } + if (gaps.length) { + const live = AQE_PROVIDER_TYPES.filter((p) => aqeProviderCredential(p).present + && !fallback.some((e) => e.provider === p)); + if (live.length) info(`credentialed alternatives available now: ${live.join(', ')}`); + } + return { aqeProvider: provider, aqeFallback: fallback }; +} + +/** Stage 2/3: validate the enabled-host set, resolve primary-host + routing + * intent, refresh host-adapter admission against that final intent, + * validate the aqe provider/fallback selections against the (possibly + * refreshed) selectable sets, and build the resulting providers/routing + * policy. Mutates `cfg` in place (integrations.hosts, providers, routing) — + * this is the decision, not yet the apply step (see applyPickProviderStack). + * Returns `{code}` when pick() must return immediately (unknown host + * token), else the resolved decision. */ +async function resolvePickDecision(cfg, { + enabled: rawEnabled, aqeProvider: rawAqeProvider, aqeFallback: rawAqeFallback, models, + flags, registries, prevPrimary, oldPolicy, aqeProviderTypes: initialAqeProviderTypes, aqeChainProviderTypes: initialAqeChainProviderTypes, +}) { + const { ROUTING, EFFECTIVE_ROUTING, MANAGED_HOSTS } = registries; + let enabled = rawEnabled; + let aqeProvider = rawAqeProvider; + let aqeFallback = rawAqeFallback; // validate hosts against the two tiers. An unknown token is a hard error, // never a silent drop: `--host claude,opencdoe` must not "succeed" as @@ -539,7 +606,7 @@ async function pick({ flags, cwd, pkgRoot }) { const unknown = enabled.filter((h) => !known.has(h)); if (unknown.length) { fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`); - return 2; + return { code: 2 }; } // The routing set needs at least one primary-capable member; OpenCode remains // routable but cannot satisfy that primary-host invariant on its own. @@ -582,6 +649,8 @@ async function pick({ flags, cwd, pkgRoot }) { // validation/projection: an admitted+granted provider can then be enabled // and selected atomically, while a provider disabled by this command is // removed from the AQE bridge before applyAqeRouter computes its projection. + let aqeProviderTypes = initialAqeProviderTypes; + let aqeChainProviderTypes = initialAqeChainProviderTypes; if (process.env.AK_EXPERIMENTAL_HOST_ADAPTERS === '1') { const refreshed = await bootstrapHostAdapters({ cfg, env: process.env }); for (const entry of refreshed.warnings) { @@ -591,32 +660,9 @@ async function pick({ flags, cwd, pkgRoot }) { aqeChainProviderTypes = aqeSelectableChainProviderTypes(); } - // validate aqe primary provider - if (aqeProvider && !aqeProviderTypes.includes(aqeProvider)) { - const norm = aqeProvider === 'anthropic' ? 'claude' : aqeProvider; - if (aqeProviderTypes.includes(norm)) aqeProvider = norm; - else { warn(`unknown aqe provider '${aqeProvider}' — leaving aqe on its default (valid: ${aqeProviderTypes.join(', ')})`); aqeProvider = null; } - } - // validate fallback chain providers (chain gate admits codex — #108 phase 3) - aqeFallback = aqeFallback - .map((e) => ({ ...e, provider: e.provider === 'anthropic' ? 'claude' : e.provider })) - .filter((e) => { - const okp = aqeChainProviderTypes.includes(e.provider); - if (!okp) warn(`dropping unknown fallback provider '${e.provider}'`); - else if (!e.models.length) warn(`fallback entry '${e.provider}' has no models — aqe may skip it; add e.g. ${e.provider}:`); - return okp; - }); - // Tell the user AT ENTRY TIME that a rung is inert, and name what it needs — - // the failure otherwise surfaces at QE-run time, far from the config (#54). - const gaps = credentialGaps(aqeFallback); - for (const g of gaps) { - warn(`${g.provider}: no ${g.missing.join(' / ')} in env — this rung will fail over into nothing`); - } - if (gaps.length) { - const live = AQE_PROVIDER_TYPES.filter((p) => aqeProviderCredential(p).present - && !aqeFallback.some((e) => e.provider === p)); - if (live.length) info(`credentialed alternatives available now: ${live.join(', ')}`); - } + ({ aqeProvider, aqeFallback } = validatePickAqeSelections({ + aqeProvider, aqeFallback, aqeProviderTypes, aqeChainProviderTypes, + })); cfg.providers = { aqeProvider, @@ -639,44 +685,54 @@ async function pick({ flags, cwd, pkgRoot }) { cfg.routing.routes = prunedRoutes.policy; for (const message of prunedRoutes.warnings) warn(message); - const trustManifest = newlyEnabledHostTrustManifest(trustBaseline, enabled); - if (trustManifest.length) { - info('host trust manifest (evaluated before user or project changes):'); - for (const line of trustManifestLines(trustManifest)) console.log(` ${line}`); - if (!flags.yes) { - if (!process.stdin.isTTY) { - fail('host enablement needs trust confirmation; re-run with --yes after reviewing the manifest'); - return 2; - } - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = (await rl.question('Enable these hosts and apply these trust changes? [y/N] ')) - .trim().toLowerCase(); - rl.close(); - if (!answer.startsWith('y')) { - info('host selection cancelled before user or project changes'); - return 0; - } - } - } + return { + enabled, routing, primaryHost, aqeProvider, seed, + }; +} - // Disable only marker-owned integrations, matching OpenCode's receipt-based - // teardown semantics. The legacy Claude→Codex MCP receipt may still exist on - // machines upgrading across ADR-0033. - let codexRetired = null; - if (prevCodex && !cfg.integrations.hosts.codex) { - const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); - const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); - cfg.integrations.ownership ??= {}; - cfg.integrations.ownership.codex = { - ...(cfg.integrations.ownership.codex ?? {}), - ...(mcp.ok ? { mcp: null } : {}), - ...(rmcp.ok ? { reverseMcp: null } : {}), - }; - codexRetired = { mcp, rmcp }; +/** Print the host trust manifest (if any newly-enabled host carries one) and + * confirm before any user/project mutation. Returns a numeric exit code + * when pick() must return immediately, else undefined. */ +async function confirmPickTrustManifest(trustManifest, flags) { + if (!trustManifest.length) return undefined; + info('host trust manifest (evaluated before user or project changes):'); + for (const line of trustManifestLines(trustManifest)) console.log(` ${line}`); + if (flags.yes) return undefined; + if (!process.stdin.isTTY) { + fail('host enablement needs trust confirmation; re-run with --yes after reviewing the manifest'); + return 2; } - saveKitConfig(cfg); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = (await rl.question('Enable these hosts and apply these trust changes? [y/N] ')) + .trim().toLowerCase(); + rl.close(); + if (!answer.startsWith('y')) { + info('host selection cancelled before user or project changes'); + return 0; + } + return undefined; +} - // install any enabled host that is entirely absent (external installs untouched) +/** Disable only marker-owned codex integrations, matching OpenCode's + * receipt-based teardown semantics. The legacy Claude→Codex MCP receipt may + * still exist on machines upgrading across ADR-0033. Mutates + * cfg.integrations.ownership.codex. */ +async function retireCodexOnDisable(cfg, cwd, { codexMcpManaged, rufloCodexManaged }) { + const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); + const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); + cfg.integrations.ownership ??= {}; + cfg.integrations.ownership.codex = { + ...(cfg.integrations.ownership.codex ?? {}), + ...(mcp.ok ? { mcp: null } : {}), + ...(rmcp.ok ? { reverseMcp: null } : {}), + }; + return { mcp, rmcp }; +} + +/** Install any enabled host that is entirely absent (external installs + * untouched). Unlike setup's install loop, pick never prompts first — the + * user already confirmed the trust manifest for this exact enable. */ +async function installPickAbsentHosts(cfg) { for (const h of HOSTS) { if (!cfg.integrations.hosts[h.id]) continue; if ((await hostInstallState(h)).method !== 'absent') continue; @@ -684,59 +740,83 @@ async function pick({ flags, cwd, pkgRoot }) { const r = await installHost(h.id); (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); } +} + +/** opencode enable half: apply the same owner-module stack setup/sync use — + * connected MCPs, compact lazy gateway, lifecycle plugin, specialist + * dispatcher, and platform skill — then converge guidance the same way + * ("wired + guided" is one contract, not two). CLI-gated: an + * enabled-but-absent CLI never fabricates the config home. */ +async function enablePickOpencodeLifecycle(cfg, { pkgRoot, cwd }) { + if (!(await have('opencode'))) { + warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); + return; + } + const lifecycle = await runLifecycle({ + adapter: lifecycleAdapterFor('opencode'), action: 'apply', cfg, options: { pkgRoot }, + }); + const stack = lifecycle.result; + // persist the markers on ANY refresh (converged file + stale markers is + // exactly the stranded-teardown case), not only on file changes. + if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); + if (stack.oc.changed || !stack.oc.ok) (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); + if (stack.plugin.changed || !stack.plugin.ok) (stack.plugin.ok ? ok : warn)(`opencode plugin: ${stack.plugin.detail}`); + if (stack.gateway.changed || !stack.gateway.ok) (stack.gateway.ok ? ok : warn)(`opencode gateway: ${stack.gateway.detail}`); + if (stack.agents.changed || !stack.agents.ok) (stack.agents.ok ? ok : warn)(`opencode agent projection: ${stack.agents.detail}`); + if (stack.skill.changed || !stack.skill.ok) (stack.skill.ok ? ok : warn)(`opencode skill: ${stack.skill.detail}`); + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: true }); + if (guidance.changed) ok(`opencode ${guidance.detail}`); + // opencode loads config/plugins/MCP/agents once at startup — say so now, + // or the user files "hooks don't work" issues (observed live). + if (stack.oc.changed || stack.plugin.changed || stack.gateway.changed + || stack.agents.changed || stack.skill.changed) { + info('restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections (loaded once at startup)'); + } +} - // opencode (integration host): apply the same owner-module stack setup/sync - // use — connected MCPs, compact lazy gateway, lifecycle plugin, specialist - // dispatcher, and platform skill — then converge guidance the same way ("wired + - // guided" is one contract, not two). - // CLI-gated: an enabled-but-absent CLI never fabricates the config home. +/** opencode disable half: excluded from the desired set while previously + * enabled/managed → strip ONLY ak-managed wiring/artifacts (priors + * restored, marker-gated), never the user's own opencode config. A + * teardown that cannot complete (e.g. a JSONC config) is reported honestly + * — markers stay for the retry and "disabled" is never claimed over + * still-active wiring. Returns {incompleteTeardown}. */ +async function disablePickOpencodeLifecycle(cfg, { pkgRoot, cwd }) { + const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg }); + const ret = retired.result; + saveKitConfig(cfg); // persist markers (nulled on success, retained on failure) let incompleteTeardown = false; + if (ret.ok) ok(`opencode disabled: ${ret.undo.detail}; ${ret.artifacts.detail}`); + else { + incompleteTeardown = true; + warn(`opencode disable incomplete — ${ret.undo.detail} (artifacts: ${ret.artifacts.detail})`); + } + // enablement-gated guidance strips regardless (user content preserved). + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: false }); + if (guidance.changed) ok(`opencode ${guidance.detail}`); + return { incompleteTeardown }; +} + +async function applyPickOpencodeLifecycle(cfg, { pkgRoot, cwd, prevOpencode }) { if (cfg.integrations.hosts.opencode) { - if (!(await have('opencode'))) { - warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); - } else { - const lifecycle = await runLifecycle({ - adapter: lifecycleAdapterFor('opencode'), action: 'apply', cfg, options: { pkgRoot }, - }); - const stack = lifecycle.result; - // persist the markers on ANY refresh (converged file + stale markers is - // exactly the stranded-teardown case), not only on file changes. - if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); - if (stack.oc.changed || !stack.oc.ok) (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); - if (stack.plugin.changed || !stack.plugin.ok) (stack.plugin.ok ? ok : warn)(`opencode plugin: ${stack.plugin.detail}`); - if (stack.gateway.changed || !stack.gateway.ok) (stack.gateway.ok ? ok : warn)(`opencode gateway: ${stack.gateway.detail}`); - if (stack.agents.changed || !stack.agents.ok) (stack.agents.ok ? ok : warn)(`opencode agent projection: ${stack.agents.detail}`); - if (stack.skill.changed || !stack.skill.ok) (stack.skill.ok ? ok : warn)(`opencode skill: ${stack.skill.detail}`); - const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: true }); - if (guidance.changed) ok(`opencode ${guidance.detail}`); - // opencode loads config/plugins/MCP/agents once at startup — say so now, - // or the user files "hooks don't work" issues (observed live). - if (stack.oc.changed || stack.plugin.changed || stack.gateway.changed - || stack.agents.changed || stack.skill.changed) { - info('restart opencode to load the Agentic Kit hooks, compact gateway, and MCP connections (loaded once at startup)'); - } - } - } else if (prevOpencode) { - // Excluded from the desired set while previously enabled/managed → disable: - // strip ONLY ak-managed wiring/artifacts (priors restored, marker-gated), - // never the user's own opencode config. A teardown that cannot complete - // (e.g. a JSONC config) is reported honestly — markers stay for the retry - // and "disabled" is never claimed over still-active wiring. - const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg }); - const ret = retired.result; - saveKitConfig(cfg); // persist markers (nulled on success, retained on failure) - if (ret.ok) ok(`opencode disabled: ${ret.undo.detail}; ${ret.artifacts.detail}`); - else { - incompleteTeardown = true; - warn(`opencode disable incomplete — ${ret.undo.detail} (artifacts: ${ret.artifacts.detail})`); - } - // enablement-gated guidance strips regardless (user content preserved). - const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd, enabled: false }); - if (guidance.changed) ok(`opencode ${guidance.detail}`); + await enablePickOpencodeLifecycle(cfg, { pkgRoot, cwd }); + return { incompleteTeardown: false }; } + if (prevOpencode) return disablePickOpencodeLifecycle(cfg, { pkgRoot, cwd }); + return { incompleteTeardown: false }; +} - const h = applyHosts(cfg, cwd); - (h.ok ? ok : fail)(`hosts: ${h.detail}`); +/** Stage 3/3 (apply): the shared pipeline (providers.mjs's + * convergeProviderStack) computes and persists every step; this reporter + * only decides what to print and how, preserving pick's exact + * wording/gating/ordering per step. seedRoutes:false — routes were already + * seeded during resolvePickDecision (before pruning); re-seeding here would + * be a no-op anyway, but the reporter stays silent for it since the + * "seeded" message prints later, from that earlier `seed`. */ +/** The 'hosts' step's own report: the hosts-apply result, plus (only on this + * step) the codex-disabled and primary-host messages — split out purely to + * keep applyPickProviderStack's reporter's own branch count legible. */ +function reportPickHostsStep(result, { codexRetired, primaryHost, routing }) { + (result.ok ? ok : fail)(`hosts: ${result.detail}`); if (codexRetired) { const complete = codexRetired.mcp.ok && codexRetired.rmcp.ok; (complete ? ok : warn)(`codex disabled${complete ? '' : ' with teardown receipts retained'}: ${codexRetired.mcp.detail}; ${codexRetired.rmcp.detail}`); @@ -745,28 +825,130 @@ async function pick({ flags, cwd, pkgRoot }) { const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none'; ok(`primary host: ${primaryHost} (alternate: ${alt})`); } - const router = applyAqeRouter(cfg, cwd); - if (router.changed || !router.ok) (router.ok ? ok : warn)(`aqe router: ${router.detail}`); +} + +/** The 'aqe-router' step's own report: the router-apply result, plus (only + * when a primary provider is selected) whether it actually took effect. */ +function reportPickAqeRouterStep(result, aqeProvider) { + if (result.changed || !result.ok) (result.ok ? ok : warn)(`aqe router: ${result.detail}`); if (aqeProvider) { - (router.ok ? ok : warn)(router.ok + (result.ok ? ok : warn)(result.ok ? `aqe provider: AQE_LLM_PROVIDER=${aqeProvider}` : `aqe provider intent not active: ${aqeProvider} (router projection incomplete)`); } - const mcp = await retireCodexMcp(cfg, cwd); - if (mcp.changed) saveKitConfig(cfg); - if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`legacy codex MCP: ${mcp.detail}`); - // Register Ruflo independently in Codex. Agentic-QE's Codex integration is - // handled by `aqe init --with-codex` during setup. - const rmcp = await ensureRufloMcpInCodex(cfg, cwd); - if (rmcp.changed) saveKitConfig(cfg); // persist reverse MCP ownership - if (rmcp.changed || !rmcp.ok) (rmcp.ok ? ok : warn)(`ruflo→codex MCP: ${rmcp.detail}`); - const prov = await applyProviders(cfg, cwd); - (prov.status === 'degraded' ? warn : prov.ok ? (prov.changed ? ok : info) : warn)(`ruflo providers: ${prov.detail}`); +} + +async function applyPickProviderStack(cfg, cwd, { + codexRetired, primaryHost, routing, aqeProvider, migrateRoutes, +}) { + const pickReporter = (step, result) => { + if (step === 'hosts') { reportPickHostsStep(result, { codexRetired, primaryHost, routing }); return; } + // Retire withdrawn models from the persisted policy — the same heal `ak + // sync` already runs (sync.mjs). Without this, `ak host pick` could + // persist a route naming a model the host has withdrawn, left for the + // next sync to repair. Only seeded entries are rewritten; a user pin is + // reported and kept. + if (step === 'routing-retired') { reportRetiredRouteChanges(result.changes); return; } + if (step === 'aqe-router') { reportPickAqeRouterStep(result, aqeProvider); return; } + if (step === 'legacy-codex-mcp') { + if (result.changed || !result.ok) (result.ok ? ok : warn)(`legacy codex MCP: ${result.detail}`); + return; + } + // Register Ruflo independently in Codex. Agentic-QE's Codex integration is + // handled by `aqe init --with-codex` during setup. + if (step === 'ruflo-codex-mcp') { + if (result.changed || !result.ok) (result.ok ? ok : warn)(`ruflo→codex MCP: ${result.detail}`); + return; + } + if (step === 'providers-api') { + (result.status === 'degraded' ? warn : result.ok ? (result.changed ? ok : info) : warn)(`ruflo providers: ${result.detail}`); + } + }; + return convergeProviderStack(cfg, cwd, { + reporter: pickReporter, seedRoutes: false, migrateRoutes, + }); +} + +/** The pre-pick facts pick() needs to detect a transition (host being newly + * disabled, primary changing, etc.) — read once, before any mutation. */ +function readPickPriorState(cfg) { + return { + prevOpencode: !!cfg.integrations?.hosts?.opencode || cfg.integrations?.ownership?.opencode?.mcp === 'ak', + prevPrimary: cfg.routing?.primaryHost ?? DEFAULT_PRIMARY_HOST, + oldPolicy: cfg.routing?.routes ?? {}, + prevCodex: !!cfg.integrations?.hosts?.codex, + codexMcpManaged: cfg.integrations?.ownership?.codex?.mcp === 'ak', + rufloCodexManaged: cfg.integrations?.ownership?.codex?.reverseMcp === 'ak', + }; +} + +export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetiredRoutesInConfig }) { + const aqeProviderTypes = aqeSelectableProviderTypes(); + const aqeChainProviderTypes = aqeSelectableChainProviderTypes(); + const cfg = loadKitConfig(); + const trustBaseline = structuredClone(cfg); + const hosts = await detectHosts(cwd); + // Routing eligibility is capability-derived. OpenCode retains its independent + // lifecycle wiring even though it is now an execution host; it is never a + // primary/AQE host because those are separate registry capabilities. + // --host is the complete desired enabled-host set on BOTH tiers; excluding an + // enabled host disables it (ak-managed wiring stripped, user config kept). + // Keep primary-host selection on the built-in routing set, but admit an + // explicitly named external host when the live adapter overlay proves it is + // routable. Provider-only retunes also carry already-enabled external ids + // through unchanged instead of mistaking them for unknown host tokens. + const registries = { + ROUTING: new Set(routableHostIds()), + EFFECTIVE_ROUTING: new Set(effectiveRoutableHostIds()), + MANAGED_HOSTS: new Set(HOSTS.map((host) => host.id)), + }; + const { + prevOpencode, prevPrimary, oldPolicy, prevCodex, codexMcpManaged, rufloCodexManaged, + } = readPickPriorState(cfg); + + const input = await parsePickInput({ + flags, cfg, hosts, registries, aqeProviderTypes, + }); + if (input.code !== undefined) return input.code; + + const decision = await resolvePickDecision(cfg, { + enabled: input.enabled, + aqeProvider: input.aqeProvider, + aqeFallback: input.aqeFallback, + models: input.models, + flags, + registries, + prevPrimary, + oldPolicy, + aqeProviderTypes, + aqeChainProviderTypes, + }); + if (decision.code !== undefined) return decision.code; + const { + enabled, routing, primaryHost, aqeProvider, seed, + } = decision; + + const trustManifest = newlyEnabledHostTrustManifest(trustBaseline, enabled); + const trustCode = await confirmPickTrustManifest(trustManifest, flags); + if (trustCode !== undefined) return trustCode; + + let codexRetired = null; + if (prevCodex && !cfg.integrations.hosts.codex) { + codexRetired = await retireCodexOnDisable(cfg, cwd, { codexMcpManaged, rufloCodexManaged }); + } + saveKitConfig(cfg); + + await installPickAbsentHosts(cfg); + const { incompleteTeardown } = await applyPickOpencodeLifecycle(cfg, { pkgRoot, cwd, prevOpencode }); + + const { router } = await applyPickProviderStack(cfg, cwd, { + codexRetired, primaryHost, routing, aqeProvider, migrateRoutes, + }); if (router.ok) ok('saved to kit.json — reapplied on every `ak sync`; undo with `ak host off`'); else warn('saved intent to kit.json, but AQE routing is incomplete — fix the warning above and re-run `ak sync`'); if (seed.seeded) ok(`per-activity routing seeded — ${seed.count} activities (dual-host defaults; tune with --route or edit kit.json)`); printActivityRoutingTable(cfg); - await maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProvider }); + await maybeWriteQeCourtDefaults({ nonInteractive: input.nonInteractive, cwd, enabled, aqeProvider }); printDualHostTips(cfg); return incompleteTeardown || !router.ok ? 1 : 0; } diff --git a/src/lib/admin-server.mjs b/src/lib/admin-server.mjs index 8f57b52..20b7944 100644 --- a/src/lib/admin-server.mjs +++ b/src/lib/admin-server.mjs @@ -15,7 +15,6 @@ // (constant-time, length-guarded). 401 JSON on mismatch, // carrying no data fields. import http from 'node:http'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,24 +22,20 @@ import { parseRepoSlug, defaultCollect } from './admin-collect.mjs'; import { ADMIN_CSS } from './admin-styles.mjs'; import { ADMIN_THEME_JS } from './admin-theme.mjs'; import { requestRejection } from './dashboard/request-security.mjs'; +import { + readJsonSafe, mintToken, tokenMatches, sendJson, sendUnauthorized, sendNotFound, listenLoopback, +} from './loopback-server.mjs'; + +// tokenMatches is now homed in loopback-server.mjs (a security primitive +// belongs to neither server specifically — dashboard-server.mjs used to +// import it from here); re-exported here so this file's own public surface +// (and tests/admin.test.cjs, which imports admin-server.mjs directly) keep +// working unchanged. +export { tokenMatches }; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = path.resolve(HERE, '..', '..'); -function readJsonSafe(file) { - try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } -} - -/** Constant-time token compare with a length guard. timingSafeEqual THROWS on - * unequal length, which is itself a length/timing oracle — the guard turns - * unequal length into a plain `false`. No secret ⇒ never open (fail-closed). */ -export function tokenMatches(given, expected) { - if (!expected) return false; - const a = Buffer.from(String(given ?? '')); - const b = Buffer.from(String(expected)); - return a.length === b.length && crypto.timingSafeEqual(a, b); -} - // The page makes ZERO external fetches; the browser enforces it via this header. const CSP = [ "default-src 'none'", // deny everything not explicitly allowed @@ -79,7 +74,7 @@ export function startAdmin({ port = 7432, collect, resolveToken, pkg: injectedPk // Per-session auth secret (FR-3): 256-bit, fresh each start, URL-safe so it // rides cleanly in the launch URL's # fragment. There is no unauth mode. - const token = crypto.randomBytes(32).toString('base64url'); + const token = mintToken(); // Assemble the ONE self-contained document once (NFR-2, AC-5). Only first-party // source (theme + model + view + CSS) is interpolated — no third-party data reaches the @@ -127,35 +122,21 @@ export function startAdmin({ port = 7432, collect, resolveToken, pkg: injectedPk if (!tokenMatches(req.headers['x-admin-token'], token)) { // 401 body carries NO data fields (AC-1). nosniff so a browser cannot be // coaxed into re-interpreting the JSON body as another content type. - res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); - res.end(JSON.stringify({ error: 'Wrong or missing admin token.' })); + sendUnauthorized(res, 'Wrong or missing admin token.'); return; } let payload; try { payload = await provide(); } catch (e) { payload = { generatedAt: new Date().toISOString(), error: String((e && e.message) || e) }; } - res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); - res.end(JSON.stringify(payload)); + sendJson(res, 200, payload); return; } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); - res.end('not found'); + sendNotFound(res); }); - return new Promise((resolve, reject) => { - server.on('error', reject); // EADDRINUSE bubbles to the caller (EC-5) - server.listen(port, '127.0.0.1', () => { // loopback literal ONLY (NFR-2) - const addr = server.address(); - const actual = addr && typeof addr === 'object' ? addr.port : port; - resolve({ - url: `http://127.0.0.1:${actual}/`, - urlWithToken: `http://127.0.0.1:${actual}/#token=${token}`, // FR-3 fragment bootstrap - port: actual, - token, - close: () => new Promise((r) => server.close(() => r())), - }); - }); + return listenLoopback(server, { + port, token, close: () => new Promise((r) => server.close(() => r())), }); } diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index 34deb17..db52008 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -47,7 +47,6 @@ // startDashboard() NEVER detaches — the caller runs it foreground and calls // close() on SIGINT. import http from 'node:http'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -61,8 +60,10 @@ import { loadKitConfig } from './config.mjs'; import { resolveRoutes, routingSummary, divergedRoutes, retirementOf, ACTIVITIES } from './routing.mjs'; import { renderPage } from './dashboard/page.mjs'; import { requestRejection } from './dashboard/request-security.mjs'; -import { tokenMatches } from './admin-server.mjs'; -import { sseChannel, reserveClientSlot, clientGone } from './dashboard/sse.mjs'; +import { + readJsonSafe, mintToken, tokenMatches, sendJson, sendUnauthorized, sendNotFound, listenLoopback, +} from './loopback-server.mjs'; +import { sseRoute } from './dashboard/sse.mjs'; // readHealthRing itself was moved (not duplicated) into intel-history.mjs — // dashboard-server.mjs no longer defines it locally. It isn't called directly // here because readIntelHistory() already composes it (as `.healthRing`, @@ -102,10 +103,6 @@ const DASH_CSP = [ "frame-ancestors 'none'", ].join('; '); -function readJsonSafe(file) { - try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } -} - /** Default status provider: shell out to the installed CLI and parse its JSON. * Resilient — a spawn/parse failure resolves to an honest empty payload rather * than rejecting, so /api/status always answers with valid JSON. */ @@ -557,11 +554,6 @@ function windowToSinceMs(raw, now = Date.now()) { return now - days * 86_400_000; } -function sendJson(res, status, payload) { - res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); - res.end(JSON.stringify(payload)); -} - const PRIVATE_LIVE_FIELDS = new Set([ 'prompt', 'response', 'arguments', 'args', 'result', 'toolarguments', 'toolresult', 'content', 'body', 'text', 'transcript', @@ -621,6 +613,51 @@ function transcriptSseFrame(name, data, id) { return `${lines.join('\n')}\n\n`; } +/** The dedup/delivery pass for /api/live/events' init: reconcile events that + * arrived via replay(snapshot.cursor) ("after") against events buffered + * while snapshot()/replay() were in flight ("pending") so nothing is + * delivered twice and nothing from the gap between them is dropped. Moved + * out of handleLiveEvents verbatim (2026-08 complexity audit, Finding 1) — + * including the ORDERING, which is load-bearing: reading `postSnapshot`'s + * `events` (below) can itself synchronously re-enter the subscription + * callback (a service may publish from a getter, as the race regression + * test does), so `pending` must not be buffer-snapshotted — and `initAt` + * must not flip to "live" — until AFTER that read, with no `await` between + * the snapshot and the flip (nothing can dispatch a callback in that gap). */ +function deliverLiveInit({ write, replay, snapshot, postSnapshot, pending, snapshotPendingCount, markInitialized }) { + // Inspect replay while callbacks still buffer. A custom/async service may + // publish while materializing this result even though replay() itself has + // resolved. + const after = !postSnapshot?.reset && Array.isArray(postSnapshot?.events) + ? postSnapshot.events : []; + if (!replay?.reset) { + for (const event of Array.isArray(replay?.events) ? replay.events : []) { + write(sseFrame('delta', event, event?.eventId)); + } + } + write(sseFrame('init', { reset: !!replay?.reset, snapshot })); + const afterIds = new Set(after.map((event) => event?.eventId).filter(Boolean)); + const emitted = new Set(); + const buffered = [...pending]; + // Take the final buffer snapshot and flip modes without an await between + // them. JavaScript cannot dispatch a subscription callback in that gap: + // every later event therefore goes directly to the response. + markInitialized(); + for (const [index, event] of [...after, ...buffered].entries()) { + const id = event?.eventId; + const pendingIndex = index - after.length; + // Events buffered after snapshot() resolved cannot be represented by + // that immutable snapshot. Deliver them even if replay() captured its + // result before they arrived. Earlier identified events absent from + // replay(snapshot.cursor) are already represented by the snapshot. + if (id && !afterIds.has(id) + && (pendingIndex < 0 || pendingIndex < snapshotPendingCount)) continue; + if (id && emitted.has(id)) continue; + if (id) emitted.add(id); + write(sseFrame('delta', event, id)); + } +} + function sendTranscriptJson(res, status, payload) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', @@ -896,7 +933,7 @@ export function startDashboard({ // a strictly more sensitive payload than admin's GitHub/npm stats, which // already required one. EventSource cannot send headers, so its two routes // also accept the token as a query param (see client.mjs's dashSseUrl). - const token = crypto.randomBytes(32).toString('base64url'); + const token = mintToken(); const checkToken = (req, query) => tokenMatches(req.headers['x-dash-token'] || query.get('token'), token); const server = http.createServer(async (req, res) => { @@ -927,12 +964,11 @@ export function startDashboard({ // Every route below serves data — none of it is safe to hand to any // process that can merely reach this loopback port (Security Finding 1). if (url.startsWith('/api/') && !checkToken(req, query)) { - res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); - res.end(JSON.stringify({ error: 'Wrong or missing dashboard token.' })); + sendUnauthorized(res, 'Wrong or missing dashboard token.'); return; } - if (url === '/api/status') { + async function handleStatus(req, res, query) { let payload; try { payload = await collectData({ @@ -960,7 +996,7 @@ export function startDashboard({ return; } - if (url === '/api/live') { + async function handleLiveSnapshot(req, res, _query) { try { const service = await getLive(); sendJson(res, 200, publicLivePayload(await service.snapshot())); @@ -972,7 +1008,7 @@ export function startDashboard({ return; } - if (url === '/api/live/history') { + async function handleLiveHistory(req, res, query) { // On-demand, not part of the SSE stream: a fresh scan per request, same // privacy scrubbing (publicLivePayload) as every other live surface. try { @@ -1002,245 +1038,151 @@ export function startDashboard({ return; } - if (url === '/api/live/events') { - // Reserve the client-cap slot BEFORE the first await (getLive() may do a - // dynamic import() + service.start()). Concurrent requests arriving - // during that gap must not all observe the same pre-reservation size and - // all pass the cap — that TOCTOU is what made the cap decorative - // (code-quality Finding 1). - const maxClients = Math.max(1, Math.min(256, Number(liveMaxClients) || 32)); - const slot = reserveClientSlot(liveClients, maxClients); - if (!slot) { - sendJson(res, 503, { error: 'too many live telemetry clients' }); - return; - } - // Forwards to the real cleanup once it exists below; if the client goes - // away DURING the awaits between here and that point (before - // req.once('close', …) can attach to catch it), `earlyClosed` remembers - // it so the real cleanup runs immediately once constructed instead of - // leaking the reservation and the subscription wired up after it. - let earlyClosed = false; - let realCleanup = null; - const cleanup = (terminate) => { - if (realCleanup) { realCleanup(terminate); return; } - earlyClosed = true; - }; - req.once('close', cleanup); - res.once('close', cleanup); - + async function handleLiveEvents(req, res, _query) { let service; - try { service = await getLive(); } catch { - liveClients.delete(slot); - sendJson(res, 503, { error: 'live telemetry unavailable' }); - return; - } - if (earlyClosed || clientGone(req, res)) { liveClients.delete(slot); return; } - - res.writeHead(200, { - 'content-type': 'text/event-stream; charset=utf-8', - 'cache-control': 'no-store', - connection: 'keep-alive', - 'x-accel-buffering': 'no', - }); - res.flushHeaders?.(); - - const limit = Math.max(1, Math.min(4096, Number(liveClientBuffer) || 256)); - const channel = sseChannel(res, { - limit, heartbeatMs: liveHeartbeatMs, - onOverflow: async () => sseFrame('init', { reset: true, snapshot: await service.snapshot() }), + await sseRoute({ + req, res, clients: liveClients, + maxClients: Math.max(1, Math.min(256, Number(liveMaxClients) || 32)), + tooManyPayload: { error: 'too many live telemetry clients' }, + limit: Math.max(1, Math.min(4096, Number(liveClientBuffer) || 256)), + heartbeatMs: liveHeartbeatMs, + // getLive() may do a dynamic import() + service.start(); sseRoute + // reserves the cap slot before this runs (TOCTOU fix, Finding 1). + setup: async () => { + try { service = await getLive(); } catch { + sendJson(res, 503, { error: 'live telemetry unavailable' }); + return null; + } + return { onOverflow: async () => sseFrame('init', { reset: true, snapshot: await service.snapshot() }) }; + }, + afterOpen: async ({ channel, write, cleanup, isGone, activate, setOnClose }) => { + const cursorHeader = req.headers['last-event-id']; + const cursor = typeof cursorHeader === 'string' && cursorHeader.length <= 256 + ? cursorHeader : null; + let replay = { reset: false, events: [] }; + if (cursorHeader != null && cursor == null) replay = { reset: true, events: [] }; + else if (cursor != null && cursor !== '') { + try { replay = await service.replay(String(cursor)); } catch { replay = { reset: true, events: [] }; } + } + if (isGone()) { cleanup(false); return; } + // Subscribe before taking the snapshot. Events published while the + // initial state is assembled are buffered and reconciled below, + // closing the snapshot→subscribe loss window. + let initializing = true; + const pending = []; + const onEvent = (event) => { + if (initializing) pending.push(event); + else { + try { write(sseFrame('delta', event, event?.eventId)); } catch { cleanup(true); } + } + }; + let unsubscribe; + try { unsubscribe = service.subscribe(onEvent); } catch { + cleanup(false); + res.end(); + scheduleLiveIdle(); + return; + } + setOnClose(() => { + if (typeof unsubscribe === 'function') unsubscribe(); + else if (unsubscribe && typeof unsubscribe.unsubscribe === 'function') unsubscribe.unsubscribe(); + scheduleLiveIdle(); + }); + activate(); + if (isGone()) { cleanup(true); return; } + let snapshot; + let postSnapshot = { reset: false, events: [] }; + let snapshotPendingCount; + try { + snapshot = await service.snapshot(); + snapshotPendingCount = pending.length; + if (snapshot?.cursor) postSnapshot = await service.replay(String(snapshot.cursor)); + } catch { + cleanup(true); + return; + } + if (channel.isClosed()) return; + deliverLiveInit({ + write, replay, snapshot, postSnapshot, pending, snapshotPendingCount, + markInitialized: () => { initializing = false; }, + }); + channel.startHeartbeat(); + }, }); - const write = channel.write; - - const cursorHeader = req.headers['last-event-id']; - const cursor = typeof cursorHeader === 'string' && cursorHeader.length <= 256 - ? cursorHeader : null; - let replay = { reset: false, events: [] }; - if (cursorHeader != null && cursor == null) replay = { reset: true, events: [] }; - else if (cursor != null && cursor !== '') { - try { replay = await service.replay(String(cursor)); } catch { replay = { reset: true, events: [] }; } - } - if (earlyClosed || clientGone(req, res)) { liveClients.delete(slot); channel.cleanup(); return; } - // Subscribe before taking the snapshot. Events published while the - // initial state is assembled are buffered and reconciled below, closing - // the snapshot→subscribe loss window. - let initializing = true; - const pending = []; - const onEvent = (event) => { - if (initializing) pending.push(event); - else { - try { write(sseFrame('delta', event, event?.eventId)); } catch { cleanup(true); } - } - }; - let unsubscribe; - try { unsubscribe = service.subscribe(onEvent); } catch { - liveClients.delete(slot); - channel.cleanup(); - res.end(); - scheduleLiveIdle(); - return; - } - realCleanup = (terminate = false) => { - if (channel.isClosed()) return; - channel.cleanup(terminate); - if (typeof unsubscribe === 'function') unsubscribe(); - else if (unsubscribe && typeof unsubscribe.unsubscribe === 'function') unsubscribe.unsubscribe(); - liveClients.delete(slot); - liveClients.delete(cleanup); - scheduleLiveIdle(); - }; - liveClients.delete(slot); - liveClients.add(cleanup); - if (earlyClosed) { cleanup(true); return; } - let snapshot; - let postSnapshot = { reset: false, events: [] }; - let snapshotPendingCount; - try { - snapshot = await service.snapshot(); - snapshotPendingCount = pending.length; - if (snapshot?.cursor) postSnapshot = await service.replay(String(snapshot.cursor)); - } catch { - cleanup(true); - return; - } - if (channel.isClosed()) return; - // Inspect replay while callbacks still buffer. A custom/async service may - // publish while materializing this result even though replay() itself has - // resolved. - const after = !postSnapshot?.reset && Array.isArray(postSnapshot?.events) - ? postSnapshot.events : []; - if (!replay?.reset) { - for (const event of Array.isArray(replay?.events) ? replay.events : []) { - write(sseFrame('delta', event, event?.eventId)); - } - } - write(sseFrame('init', { reset: !!replay?.reset, snapshot })); - const afterIds = new Set(after.map((event) => event?.eventId).filter(Boolean)); - const emitted = new Set(); - const buffered = [...pending]; - // Take the final buffer snapshot and flip modes without an await between - // them. JavaScript cannot dispatch a subscription callback in that gap: - // every later event therefore goes directly to the response. - initializing = false; - for (const [index, event] of [...after, ...buffered].entries()) { - const id = event?.eventId; - const pendingIndex = index - after.length; - // Events buffered after snapshot() resolved cannot be represented by - // that immutable snapshot. Deliver them even if replay() captured its - // result before they arrived. Earlier identified events absent from - // replay(snapshot.cursor) are already represented by the snapshot. - if (id && !afterIds.has(id) - && (pendingIndex < 0 || pendingIndex < snapshotPendingCount)) continue; - if (id && emitted.has(id)) continue; - if (id) emitted.add(id); - write(sseFrame('delta', event, id)); - } - channel.startHeartbeat(); - return; } - if (url === '/api/live/intelligence') { - // Same reservation-before-await discipline as /api/live/events above - // (sse.mjs's reserveClientSlot doc comment): resolving + starting a - // project's pool entry may await a dynamic import() + watch.start() on - // that project's very first connection, and concurrent requests - // arriving during that gap must not all observe the same - // pre-reservation size and all pass the cap. - const maxClients = Math.max(1, Math.min(256, Number(intelMaxClients) || 32)); - const slot = reserveClientSlot(intelClients, maxClients); - if (!slot) { - sendJson(res, 503, { error: 'too many intelligence clients' }); - return; - } - // Same forwarding-cleanup pattern as /api/live/events and the transcript - // route: catches a close that fires during the awaits below, before the - // real cleanup (which needs the channel/write) can be constructed. - let earlyClosed = false; - let realCleanup = null; - const cleanup = (terminate) => { - if (realCleanup) { realCleanup(terminate); return; } - earlyClosed = true; - }; - req.once('close', cleanup); - res.once('close', cleanup); - - // Same ?project= resolution as /api/status, off the SAME cached - // discovery snapshot — the two endpoints can never disagree about - // which project an absent/unresolvable key defaults to. - const { projects } = getProjectSnapshot(); - const selected = resolveSelectedProject(projects, query.get('project')); - if (!selected) { - intelClients.delete(slot); - sendJson(res, 503, { error: 'no ruflo-initialized project found on this machine' }); - return; - } - const poolEntry = getOrCreateIntelPoolEntry(selected.path); - - try { await poolEntry.getWatch(); } catch { - intelClients.delete(slot); - // Nobody else is (yet) watching this path — don't leave a dead entry - // behind for the next request to trip over. - if (poolEntry.writers.size === 0) intelPool.delete(selected.path); - sendJson(res, 503, { error: 'intelligence telemetry unavailable' }); - return; - } - if (earlyClosed || clientGone(req, res)) { - intelClients.delete(slot); - if (poolEntry.writers.size === 0) intelPool.delete(selected.path); - return; - } - - res.writeHead(200, { - 'content-type': 'text/event-stream; charset=utf-8', - 'cache-control': 'no-store', - connection: 'keep-alive', - 'x-accel-buffering': 'no', - }); - res.flushHeaders?.(); - - const limit = Math.max(1, Math.min(4096, Number(intelClientBuffer) || 256)); - // Reuses transcriptSseFrame (plain id/event/data lines, no publicLivePayload - // redaction) rather than sseFrame — this payload is aggregate learning - // metrics, not live session/transcript content, so the session-privacy - // scrubbing sseFrame applies is not the right tool here. - const channel = sseChannel(res, { - limit, heartbeatMs: liveHeartbeatMs, - onOverflow: () => transcriptSseFrame('init', readIntelHistory(selected.path)), + async function handleLiveIntelligence(req, res, query) { + let selected; + let poolEntry; + await sseRoute({ + req, res, clients: intelClients, + maxClients: Math.max(1, Math.min(256, Number(intelMaxClients) || 32)), + tooManyPayload: { error: 'too many intelligence clients' }, + limit: Math.max(1, Math.min(4096, Number(intelClientBuffer) || 256)), + heartbeatMs: liveHeartbeatMs, + // Same reservation-before-await discipline sseRoute applies to every + // caller: resolving + starting a project's pool entry may await a + // dynamic import() + watch.start() on that project's very first + // connection. + setup: async () => { + // Same ?project= resolution as /api/status, off the SAME + // cached discovery snapshot — the two endpoints can never disagree + // about which project an absent/unresolvable key defaults to. + const { projects } = getProjectSnapshot(); + selected = resolveSelectedProject(projects, query.get('project')); + if (!selected) { + sendJson(res, 503, { error: 'no ruflo-initialized project found on this machine' }); + return null; + } + poolEntry = getOrCreateIntelPoolEntry(selected.path); + try { await poolEntry.getWatch(); } catch { + // Nobody else is (yet) watching this path — don't leave a dead + // entry behind for the next request to trip over. + if (poolEntry.writers.size === 0) intelPool.delete(selected.path); + sendJson(res, 503, { error: 'intelligence telemetry unavailable' }); + return null; + } + return { + // Reuses transcriptSseFrame (plain id/event/data lines, no + // publicLivePayload redaction) rather than sseFrame — this + // payload is aggregate learning metrics, not live + // session/transcript content, so the session-privacy scrubbing + // sseFrame applies is not the right tool here. + onOverflow: () => transcriptSseFrame('init', readIntelHistory(selected.path)), + onClose: () => { if (poolEntry.writers.size === 0) intelPool.delete(selected.path); }, + }; + }, + afterOpen: async ({ channel, write, cleanup, isGone, activate, setOnClose }) => { + setOnClose(() => { + poolEntry.writers.delete(write); + // Last writer for this project gone — stop its watcher and forget + // the pool entry entirely, so an unwatched project's watcher does + // not run forever (the exact leak this pool replaces the old + // singleton to avoid). + if (poolEntry.writers.size === 0) { + intelPool.delete(selected.path); + void poolEntry.stop(); + } + }); + activate(); + poolEntry.writers.add(write); + if (isGone()) { cleanup(true); return; } + + // One initial frame with the current combined read for the + // SELECTED project so a fresh page load doesn't have to wait out + // the watcher's own debounce window; every frame after this is + // pushed by that project's IntelligenceWatch onUpdate, fanned out + // to every writer currently watching this same path (and only this + // path). + write(transcriptSseFrame('init', readIntelHistory(selected.path))); + channel.startHeartbeat(); + }, }); - const write = channel.write; - - realCleanup = (terminate = false) => { - if (channel.isClosed()) return; - channel.cleanup(terminate); - poolEntry.writers.delete(write); - intelClients.delete(cleanup); - // Last writer for this project gone — stop its watcher and forget - // the pool entry entirely, so an unwatched project's watcher does - // not run forever (the exact leak this pool replaces the old - // singleton to avoid). - if (poolEntry.writers.size === 0) { - intelPool.delete(selected.path); - void poolEntry.stop(); - } - }; - intelClients.delete(slot); - intelClients.add(cleanup); - poolEntry.writers.add(write); - if (earlyClosed) { cleanup(true); return; } - - // One initial frame with the current combined read for the SELECTED - // project so a fresh page load doesn't have to wait out the watcher's - // own debounce window; every frame after this is pushed by that - // project's IntelligenceWatch onUpdate, fanned out to every writer - // currently watching this same path (and only this path). - write(transcriptSseFrame('init', readIntelHistory(selected.path))); - channel.startHeartbeat(); - return; } - const playbackMatch = /^\/api\/live\/playback\/([^/]+)\/([^/]+)$/.exec(url); - if (playbackMatch) { - const host = playbackMatch[1]; - const id = parseSessionId(playbackMatch[2]); + async function handlePlayback(req, res, query, match) { + const host = match[1]; + const id = parseSessionId(match[2]); const rawAt = query.get('at'); const atMs = rawAt == null || rawAt === '' ? null : Number(rawAt); if (!['claude', 'codex'].includes(host) || !id @@ -1274,124 +1216,92 @@ export function startDashboard({ return; } - const transcriptMatch = /^\/api\/live\/transcripts\/([^/]+)\/([^/]+)\/events$/.exec(url); - if (transcriptMatch) { - const host = transcriptMatch[1]; - const id = parseSessionId(transcriptMatch[2]); + async function handleTranscriptEvents(req, res, query, match) { + const host = match[1]; + const id = parseSessionId(match[2]); if (!['claude', 'codex'].includes(host) || !id) { sendJson(res, 400, { error: 'invalid transcript target' }); return; } - const maxClients = Math.max(1, Math.min(64, Number(transcriptMaxClients) || 16)); - const slot = reserveClientSlot(transcriptClients, maxClients); - if (!slot) { - sendJson(res, 503, { error: 'too many transcript clients' }); - return; - } - // Same forwarding-cleanup pattern as /api/live/events: catches a close - // that fires during the awaits below, before the real cleanup (which - // needs transcriptService/host/id) can be constructed. - let earlyClosed = false; - let realCleanup = null; - const cleanup = (terminate) => { - if (realCleanup) { realCleanup(terminate); return; } - earlyClosed = true; - }; - req.once('close', cleanup); - res.once('close', cleanup); - let stream; let transcriptService; - try { - transcriptService = await getTranscripts(); - stream = transcriptService.open(host, id); - } catch (error) { - transcriptClients.delete(slot); - const message = String(error?.message ?? ''); - const status = /invalid/.test(message) ? 400 : (/not found|outside/.test(message) ? 404 : 503); - sendJson(res, status, { error: status === 404 ? 'transcript not found' : 'transcript unavailable' }); - return; - } - if (earlyClosed || clientGone(req, res)) { - transcriptClients.delete(slot); - transcriptService?.release?.(host, id); - return; - } - - res.writeHead(200, { - 'content-type': 'text/event-stream; charset=utf-8', - 'cache-control': 'no-store', - connection: 'keep-alive', - 'x-accel-buffering': 'no', - 'x-content-type-options': 'nosniff', - 'cross-origin-resource-policy': 'same-origin', - 'referrer-policy': 'no-referrer', - }); - res.flushHeaders?.(); - - const limit = Math.max(1, Math.min(512, Number(transcriptClientBuffer) || 64)); - const channel = sseChannel(res, { - limit, heartbeatMs: Math.max(1_000, liveHeartbeatMs), - onOverflow: () => transcriptSseFrame('gap', { sessionKey: `${host}:${id}`, reason: 'client-overflow' }), - }); - const write = channel.write; - - let initializing = true; - const pending = []; - const onEvent = (event) => { - if (initializing) pending.push(event); - else write(transcriptSseFrame('delta', event, event?.eventId)); - }; - let unsubscribe; - try { unsubscribe = stream.subscribe(onEvent); } catch { - transcriptClients.delete(slot); - channel.cleanup(); - transcriptService?.release?.(host, id); - res.end(); - return; - } - realCleanup = (terminate = false) => { - if (channel.isClosed()) return; - channel.cleanup(terminate); - if (typeof unsubscribe === 'function') unsubscribe(); - transcriptClients.delete(slot); - transcriptClients.delete(cleanup); - transcriptService?.release?.(host, id); - }; - transcriptClients.delete(slot); - transcriptClients.add(cleanup); - if (earlyClosed) { cleanup(true); return; } - - const header = req.headers['last-event-id']; - const cursor = typeof header === 'string' && header.length <= 256 ? header : null; - let replay = { reset: header != null && cursor == null, events: [] }; - try { - if (cursor) replay = stream.replay(cursor); - const snapshot = stream.snapshot(); - if (!replay.reset) { - for (const event of replay.events ?? []) { - write(transcriptSseFrame('delta', event, event?.eventId)); + await sseRoute({ + req, res, clients: transcriptClients, + maxClients: Math.max(1, Math.min(64, Number(transcriptMaxClients) || 16)), + tooManyPayload: { error: 'too many transcript clients' }, + limit: Math.max(1, Math.min(512, Number(transcriptClientBuffer) || 64)), + heartbeatMs: Math.max(1_000, liveHeartbeatMs), + setup: async () => { + try { + transcriptService = await getTranscripts(); + stream = transcriptService.open(host, id); + } catch (error) { + const message = String(error?.message ?? ''); + const status = /invalid/.test(message) ? 400 : (/not found|outside/.test(message) ? 404 : 503); + sendJson(res, status, { error: status === 404 ? 'transcript not found' : 'transcript unavailable' }); + return null; } - } - write(transcriptSseFrame('init', { reset: !!replay.reset, snapshot })); - } catch { - cleanup(true); - return; - } - const emitted = new Set((replay.events ?? []).map((event) => event?.eventId)); - for (const event of pending) { - if (!event?.eventId || !emitted.has(event.eventId)) { - write(transcriptSseFrame('delta', event, event?.eventId)); - } - } - initializing = false; - channel.startHeartbeat(); - return; + return { + headers: { + 'x-content-type-options': 'nosniff', + 'cross-origin-resource-policy': 'same-origin', + 'referrer-policy': 'no-referrer', + }, + onOverflow: () => transcriptSseFrame('gap', { sessionKey: `${host}:${id}`, reason: 'client-overflow' }), + onClose: () => { transcriptService?.release?.(host, id); }, + }; + }, + afterOpen: async ({ channel, write, cleanup, isGone, activate, setOnClose }) => { + let initializing = true; + const pending = []; + const onEvent = (event) => { + if (initializing) pending.push(event); + else write(transcriptSseFrame('delta', event, event?.eventId)); + }; + let unsubscribe; + try { unsubscribe = stream.subscribe(onEvent); } catch { + cleanup(false); + res.end(); + return; + } + setOnClose(() => { + if (typeof unsubscribe === 'function') unsubscribe(); + transcriptService?.release?.(host, id); + }); + activate(); + if (isGone()) { cleanup(true); return; } + + const header = req.headers['last-event-id']; + const cursor = typeof header === 'string' && header.length <= 256 ? header : null; + let replay = { reset: header != null && cursor == null, events: [] }; + try { + if (cursor) replay = stream.replay(cursor); + const snapshot = stream.snapshot(); + if (!replay.reset) { + for (const event of replay.events ?? []) { + write(transcriptSseFrame('delta', event, event?.eventId)); + } + } + write(transcriptSseFrame('init', { reset: !!replay.reset, snapshot })); + } catch { + cleanup(true); + return; + } + const emitted = new Set((replay.events ?? []).map((event) => event?.eventId)); + for (const event of pending) { + if (!event?.eventId || !emitted.has(event.eventId)) { + write(transcriptSseFrame('delta', event, event?.eventId)); + } + } + initializing = false; + channel.startHeartbeat(); + }, + }); } // ── Usage (ADR-0009). Lazy: nothing below runs until the tab is opened. ── - if (url === '/api/models') { + async function handleModels(req, res, query) { try { const payload = await provideModels(); if (!payload || payload.status === 'empty' || !payload.snapshot) { @@ -1433,7 +1343,7 @@ export function startDashboard({ // The tree preview is therefore trimmed to what the client actually renders // (USAGE_TREE_PREVIEW rows per project), with rowsTotal kept so the "load // all" control still knows the true count and calls /api/sessions for the rest. - if (url === '/api/usage') { + async function handleUsage(req, res, query) { try { const [agg, providerAnalytics] = await Promise.all([ usageApi.readIndex({ days: clampDays(query.get('days')) }), @@ -1464,7 +1374,7 @@ export function startDashboard({ // statusline tee); Codex side may spawn ONE vendor subprocess (`codex // app-server`), TTL-cached — the same shell-out trust model as // `ak status --json` above. No vendor credential is ever read here. - if (url === '/api/limits') { + async function handleLimits(req, res, query) { try { const agg = await usageApi.readIndex({ days: clampDays(query.get('days')) }).catch(() => null); const payload = await provideLimits(); @@ -1489,7 +1399,7 @@ export function startDashboard({ // only), so no transcript, prompt, or tool payload can reach this route to // leak. Delivery protections are otherwise identical to every route above // — loopback bind, per-session token auth, no-store, nosniff, zero egress. - if (url === '/api/system') { + async function handleSystem(req, res, query) { try { const collector = await getSystem(); // ORDER IS LOAD-BEARING: assemble the payload BEFORE starting a scan. @@ -1525,7 +1435,7 @@ export function startDashboard({ return; } - if (url === '/api/sessions') { + async function handleSessions(req, res, query) { try { const agg = await usageApi.readIndex({ days: clampDays(query.get('days')) }); const project = query.get('project') || ''; @@ -1542,10 +1452,10 @@ export function startDashboard({ return; } - if (url.startsWith('/api/session/')) { + async function handleSession(req, res, query, match) { // Validate BEFORE touching the index — a rejected id must never reach a // filesystem call, so the 400 happens here and nowhere deeper. - const id = parseSessionId(url.slice('/api/session/'.length)); + const id = parseSessionId(match[1]); if (!id || !TRANSCRIPT_ROOTS.some((r) => resolvesInsideRoot(r, id))) { sendJson(res, 400, { error: 'invalid session id' }); return; @@ -1575,40 +1485,65 @@ export function startDashboard({ return; } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); - res.end('not found'); + // Exact-path routes (O(1) lookup) win first, then the parametrized ones — + // mirrors the original if-chain's ordering, though none of these patterns + // can collide with an exact path above. Splitting the 15-route if-chain + // (formerly one closure, CC=194) into a handler per route plus this table + // is the mechanical half of the dashboard refactor (ADR-0036); the SSE + // routes' shared reserve-slot/early-close/channel lifecycle is factored + // out separately into sse.mjs's sseRoute(). + const ROUTES = { + '/api/status': handleStatus, + '/api/live': handleLiveSnapshot, + '/api/live/history': handleLiveHistory, + '/api/live/events': handleLiveEvents, + '/api/live/intelligence': handleLiveIntelligence, + '/api/models': handleModels, + '/api/usage': handleUsage, + '/api/limits': handleLimits, + '/api/system': handleSystem, + '/api/sessions': handleSessions, + }; + /** @type {Array<[RegExp, (req: any, res: any, query: any, match: RegExpExecArray) => Promise]>} */ + const PARAM_ROUTES = [ + [/^\/api\/live\/playback\/([^/]+)\/([^/]+)$/, handlePlayback], + [/^\/api\/live\/transcripts\/([^/]+)\/([^/]+)\/events$/, handleTranscriptEvents], + // (.*) not (.+): the original startsWith('/api/session/') matched a + // bare trailing slash too (empty id), relying on parseSessionId/ + // handleSession's own validation to fail it closed with 400 — a + // one-or-more-chars pattern here would 404 that case instead. + [/^\/api\/session\/(.*)$/, handleSession], + ]; + const exactHandler = ROUTES[url]; + if (exactHandler) { await exactHandler(req, res, query); return; } + for (const [pattern, handler] of PARAM_ROUTES) { + const match = pattern.exec(url); + if (match) { await handler(req, res, query, match); return; } + } + + sendNotFound(res); }); - return new Promise((resolve, reject) => { - server.on('error', reject); - // Loopback ONLY — never expose the panel beyond this machine. - server.listen(port, '127.0.0.1', () => { - const addr = server.address(); - const actual = addr && typeof addr === 'object' ? addr.port : port; - resolve({ - url: `http://127.0.0.1:${actual}/`, - urlWithToken: `http://127.0.0.1:${actual}/#token=${token}`, - port: actual, - token, - close: async () => { - shuttingDown = true; - cancelLiveIdle(); - for (const cleanup of [...liveClients]) cleanup(true); - for (const cleanup of [...transcriptClients]) cleanup(true); - for (const cleanup of [...intelClients]) cleanup(true); - await new Promise((res) => server.close(() => res(undefined))); - await stopLive({ force: true }); - try { await (await transcriptServicePromise)?.close?.(); } catch {} - // Backstop: each intel client's own cleanup above already stops + - // deletes its pool entry the instant its last writer disconnects, - // so this is normally a no-op — but stop every REMAINING entry - // (e.g. one still mid-start with zero writers) rather than assume - // that cascade always wins the race, so every pool watcher is - // stopped on shutdown, not just one. - for (const entry of [...intelPool.values()]) { try { await entry.stop(); } catch {} } - intelPool.clear(); - }, - }); - }); + // Loopback ONLY — never expose the panel beyond this machine. + return listenLoopback(server, { + port, token, + close: async () => { + shuttingDown = true; + cancelLiveIdle(); + for (const cleanup of [...liveClients]) cleanup(true); + for (const cleanup of [...transcriptClients]) cleanup(true); + for (const cleanup of [...intelClients]) cleanup(true); + await new Promise((res) => server.close(() => res(undefined))); + await stopLive({ force: true }); + try { await (await transcriptServicePromise)?.close?.(); } catch {} + // Backstop: each intel client's own cleanup above already stops + + // deletes its pool entry the instant its last writer disconnects, + // so this is normally a no-op — but stop every REMAINING entry + // (e.g. one still mid-start with zero writers) rather than assume + // that cascade always wins the race, so every pool watcher is + // stopped on shutdown, not just one. + for (const entry of [...intelPool.values()]) { try { await entry.stop(); } catch {} } + intelPool.clear(); + }, }); } diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index a1c1449..575004e 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -1,9 +1,24 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { CAT, RANK, PREF, esc, catOf, groupRows, rowLine, groupCard, gridHtml, noticeHtml } from './groups.mjs'; import { directoryEntries } from './about-directory.mjs'; +// client.mjs is the COLLECTOR for the dashboard's browser bundle: it reads the +// real, individually lintable modules under ./client/ (split out of what used +// to be this file's own 4,066-line template literal — 2026-08 complexity +// audit, Finding 2), strips their cross-file `import`/`export` lines (real +// only for node --check/eslint's benefit — concatenation collapses the module +// graph into one flat classic-script scope, same as the pre-split bundle +// already was), splices in the handful of Node-computed values each carries +// (see `inject` below), and concatenates them back into the exact same single +// IIFE this export has always produced. The SERVING CONTRACT is unchanged: +// page.mjs still does `import { JS } from './client.mjs'` and embeds it as +// `` — one HTML response, no new routes. +// // The classification/grouping/card/notice logic lives in ./groups.mjs (pure — -// unit-testable in node without a DOM). Here those exact function sources and -// JSON-serialized tables are interpolated into the served