diff --git a/.gitignore b/.gitignore index 8175a68..ceb4b13 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ test-*.log # AGENTS.md for codex) ARE tracked; only their generated dirs (.claude/, .agents/) and # local overrides (.codex/) are ignored. .agentic-qe/ +.ruvnet-brain/ .claude/ .agents/ .codex/ diff --git a/CLAUDE.md b/CLAUDE.md index e3b263a..3005cfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,8 @@ ruflo swarm init --topology hierarchical --max-agents 15 --strategy specialized ## Build & Test ```bash -node tests/statusline-segments.test.cjs +pnpm test # full unit suite (node --test tests/kit + the cjs suites) +pnpm run check # typecheck + lint + markdown lint + build + test ``` ## Agentic QE v3 diff --git a/MAINTAINER.md b/MAINTAINER.md index a5fd460..b633f06 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -76,7 +76,7 @@ ships — verify with `npm pack --dry-run` before a release if you touch `files` packages: detected via `installedVersion` (npm global root) and drift-checked with `npm view`. The RuvNet Brain is *not* — `npx ruvnet-brain@latest` (the **published** installer; never `github:`, which runs the unreleased default-branch HEAD) installs a -~512 MB offline KB to `~/.cache/ruvnet-brain/kb` (override `$RUVNET_BRAIN_KB`) and a +~2 GB offline KB to `~/.cache/ruvnet-brain/kb` (override `$RUVNET_BRAIN_KB`) and a user-scope Claude Code plugin (the `search_ruvnet` MCP + hooks + a skill). So it gets a *parallel* lifecycle in `src/lib/ruvnet-brain.mjs`: `present()` probes disk, `latestVersion()`/`drift()` hit the GitHub releases API (TTL-cached in kit.json like diff --git a/README.md b/README.md index 69b30f4..6d094a4 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ npm install -g @pacphi/agentic-kit@next # alpha channel until 4.0.0 GA ak setup # once per machine; run it inside a git repo to set that project up too ``` -That's the only package you install by hand — **you do not need to install ruflo or -agentic-qe yourself.** `ak setup` installs them globally for you (building natives past -npm ≥11.17's `allow-scripts` gate), then heals and proves them. +> [!IMPORTANT] +> That's the only package you install by hand — **you do not need to install ruflo or +> agentic-qe yourself.** `ak setup` installs them globally for you (building natives past +> npm ≥11.17's `allow-scripts` gate), then heals and proves them. ## Why this exists @@ -40,21 +41,22 @@ ak setup first-time setup — machine and/or the project you're standing ak status read-only dashboard: what's true, what's drifted [--json] [--deep] ak sync converge to good: upgrade + heal + verify [--dry-run] [--no-upgrade] ak dashboard open the local web dashboard (auto-opens your browser) [--port N] [--no-open] -ak uninstall leave cleanly [--dry-run] [--purge] +ak uninstall leave cleanly [--dry-run] [--this-project] [--remove-ruflo] [--remove-aqe] [--purge] [--yes] ``` -(`ak` is the daily-driver alias; the full `agentic-kit` command is identical. -Heads-up if you also use AutoKitteh: its CLI is also named `ak` — the full -command always works.) +> [!TIP] +> **When in doubt: `ak sync`.** Every mutating command takes `--dry-run` and +> prints its plan with reasons — you always see the impact before anything changes. -**When in doubt: `ak sync`.** Every mutating command takes `--dry-run` and -prints its plan with reasons — you always see the impact before anything changes. +`ak` is the daily-driver alias; the full `agentic-kit` command is identical. +(Heads-up if you also use AutoKitteh: its CLI is also named `ak` — the full +command always works.) What the verbs cover: | Verb | What it does | | ------ | -------------- | -| **setup** | Installs/updates ruflo + agentic-qe + the **agentdb** CLI globally (handling npm ≥11.17's `allow-scripts` so natives build; agentdb is pinned to ruflo's bundled version so the shared learning store stays coherent), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP — a ~512 MB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into `~/.claude/CLAUDE.md`, offers one-time MCP registration (user scope, with a tool-family picker), and — inside a repo — initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** store→disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` directory in the current folder; without one it's skipped with a note. `--project` forces it anyway (e.g. a not-yet-`git init`-ed folder), `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. | +| **setup** | Installs/updates ruflo + agentic-qe + the **agentdb** CLI globally (handling npm ≥11.17's `allow-scripts` so natives build; agentdb is pinned to ruflo's bundled version so the shared learning store stays coherent), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP — a ~2 GB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into `~/.claude/CLAUDE.md`, offers one-time MCP registration (user scope, with a tool-family picker), and — inside a repo — initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** store→disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` directory in the current folder; without one it's skipped with a note. `--project` forces it anyway (e.g. a not-yet-`git init`-ed folder), `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. | | **status** | Per-subsystem ✓/⚠/✗ (versions, the kit's own version, **ruvnet-brain** (present + release drift, or "not installed"), natives, security, learning, aqe/RVF, **agentdb** (CLI present + coherent with ruflo's bundled version, or a store-skew warning), MCP, **hosts** (claude/codex — version + install method, or "enabled but not installed"), **providers** (host wiring + aqe fallback chain, or "drifted"/claude-only default), daemons, CLAUDE.md blocks, statusline), each drift row naming what `sync` would do about it — plus a **health-history** line that flags regressions since the last sync (learning shrank, native slots dropped, drift/security backslid). | | **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex) that's entirely absent — never touching an external (mise/brew/native) install — and **re-applies provider wiring** (the `ENABLE_*` host env, the aqe fallback chain, and ruflo API providers) whenever it has drifted. It also **installs/repins the standalone `agentdb` CLI** to ruflo's bundled version (keeping the shared cognitive store coherent) and appends a **health-history snapshot** so `status` can flag regressions across syncs. It also **re-runs the RuvNet Brain installer** to pull the latest release when the on-disk KB has drifted (or installs it if absent, when enabled). It also **self-updates the kit**: when a newer `@pacphi/agentic-kit` exists it installs it as the *last* step (the new code applies from the next `ak` run, never mid-sync). Prerelease installs (`4.0.0-alpha.*`) track the `next` npm dist-tag as well as `latest`, so alphas see their successors; stable installs only ever follow `latest`. `--no-upgrade` skips the self-update along with the package upgrades. | | **dashboard** | Opens a read-only local web dashboard (`127.0.0.1:7431`, localhost-only, never detaches) that renders the same subsystem view as `ak status` — grouped one card per subsystem with severity triage and a learning-over-time strip. Fully self-contained and offline (no external fetches, nothing leaves your machine). **Auto-opens your browser** (`--no-open` to just print the URL for headless/SSH); `--port N` to change the port. Stop with Ctrl-C. (Also available as `ak x dashboard`.) | diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index ad33312..6a51b4e 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -55,7 +55,7 @@ Plumbing (power users) — each takes --help: ak x mcp [status|pick|off] MCP registration + tool-family deny rules ak x provider [status|pick|off] detect claude/codex CLIs; wire ruflo + aqe hosts/providers ak x reference [diff|sync] CLAUDE.md managed-block inspection/reconcile - ak x verify [learning|security|aqe|providers|all] deep proofs (slow, spawns real CLIs) + ak x verify [learning|security|aqe|providers|harvest|all] deep proofs (slow, spawns real CLIs) ak x improvement-eval [...] causal self-improvement eval (route Q-learner)`; /** True if the arg list is asking for help rather than an action. */ diff --git a/claude/aqe-reference.md b/claude/aqe-reference.md index a96fecf..06d4943 100644 --- a/claude/aqe-reference.md +++ b/claude/aqe-reference.md @@ -1,6 +1,6 @@ ## Agentic-QE — operating guidance diff --git a/claude/ruflo-reference-full.md b/claude/ruflo-reference-full.md index e09a421..100747c 100644 --- a/claude/ruflo-reference-full.md +++ b/claude/ruflo-reference-full.md @@ -16,7 +16,7 @@ security). It exposes the same functionality via two surfaces: - **MCP** — `mcp__claude-flow__*` tools, registered once at USER scope under the `claude-flow` key (`ak x mcp pick`). Claude Code defers MCP tool schemas and loads them on demand, so registration no longer costs the historical ~84k tokens of - always-loaded tool definitions per session. ruflo 3.28 exposes ~276 tools across ~35 + always-loaded tool definitions per session. ruflo (3.28+) exposes ~276 tools across ~35 families with no server-side gating; the kit's family picker turns exclusions into `permissions.deny` rules. Prefer MCP for tight, repeated, schema-typed integration; `ak x mcp off` opts back out entirely. @@ -328,7 +328,7 @@ When agentic-qe **is** installed, the kit also merges a conditional **`ruflo-aqe block (AQE operating guidance — policies + MCP tool usage + QE-agent patterns) into this same `~/.claude/CLAUDE.md`, just below this `ruflo-reference` block. It is added when `aqe` is on PATH and **stripped automatically when agentic-qe is absent**. Source: `claude/aqe-reference.md`; -applied by `install.sh` and re-asserted by `ak x reference sync` / `ak sync`. +applied by `ak setup` and re-asserted by `ak x reference sync` / `ak sync`. ### Security surface (verify + activate) diff --git a/claude/ruflo-reference.md b/claude/ruflo-reference.md index 9a9cc6a..34ec82a 100644 --- a/claude/ruflo-reference.md +++ b/claude/ruflo-reference.md @@ -1,5 +1,5 @@ - + diff --git a/claude/superpowers-reference.md b/claude/superpowers-reference.md index 09d4be7..7e78040 100644 --- a/claude/superpowers-reference.md +++ b/claude/superpowers-reference.md @@ -1,6 +1,6 @@ diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index c980401..f9f4dff 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -45,6 +45,11 @@ An interactive picker (or flags for scripts). Enable `codex` and `ak`: - runs `ruflo init --dual` (ruflo's "Claude Code + Codex hybrid" mode), - writes `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` into `.claude/settings.local.json`. +> [!NOTE] +> Project scope is resolved by walking up to the repo root (`.git`), so running +> from a subdirectory writes the same project file at the root — never the +> machine-wide user settings. Outside any repo, user scope is used and said so. + ```bash ak x provider pick --host claude,codex --yes # non-interactive ``` diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3669a5e..dffb710 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -9,32 +9,50 @@ ak sync --dry-run # see exactly what sync WOULD do, with reasons ak sync # apply it ``` -The rule: **`status` to look, `sync` to fix.** Every ⚠/✗ row in `status` names the -fix; `sync` applies them in the right order and re-checks afterward. +> [!TIP] +> The rule: **`status` to look, `sync` to fix.** Every ⚠/✗ row in `status` names the +> fix; `sync` applies them in the right order and re-checks afterward. ## Common situations | Symptom | What's happening | Fix | | --- | --- | --- | -| Just upgraded ruflo/agentic-qe (`npm i -g …`) and things feel off | Upgrades re-resolve dependencies: native SQLite bindings and the aidefence package get dropped, the statusline is regenerated without the footer | `ak sync` (this is its main job) | +| Just upgraded ruflo/agentic-qe (`npm i -g …`) and things feel off | Upgrades re-resolve dependencies: native SQLite bindings and the aidefence package get dropped, and ruflo's helper auto-refresh regenerates the statusline without the footer | `ak sync` (this is its main job) | | `status` shows `natives … WASM fallback` | agentdb resolved a non-native better-sqlite3 — on this path **memory writes can silently vanish**. Root cause is npm ≥11.17 blocking install scripts during upgrades | `ak sync` installs the native binding | | `status` shows `aidefence missing` | ruflo ≥3.28 stopped shipping `@claude-flow/aidefence` but `ruflo security defend` still imports it — injection defense is silently non-functional ([ruvnet/ruflo#2670](https://github.com/ruvnet/ruflo/issues/2670)) | `ak sync` reinstalls it; `ak x verify security` proves defend works (exit 1=threat / 0=clean) | -| `status` shows corrupt/oversized RVF | An interrupted write left store bytes in a `.rvf.lock` (FLVR signature) or a runaway `.rvf`; agentic-qe drops off ruvector with `FsyncFailed` and does **not** self-heal this | `ak sync` quarantines it — the store is a derived cache, rebuilt from `memory.db` | -| Statusline footer (🧠/🛡/🎓 lines) disappeared | `ruflo init`/upgrade regenerated `statusline.cjs` | `ak sync` re-injects it | -| Too many `⚙` daemons / stale daemons | One daemon per active project is normal (local-only workers, $0). Stale = workspace deleted or past the 12h TTL | `ak x daemon-gc --kill`; `sync` also reaps. AI workers only ever run with `RUFLO_DAEMON_AI_WORKERS=1`, capped by `ruflo daemon budget show` | +| `status` shows oversized RVF store(s) | A runaway append after a hard exit grew a `.rvf` past the 2 GB cap (seen at ~277 GB once) | `ak sync` quarantines the oversized store; agentic-qe rebuilds it | +| Statusline footer (🧠/🛡/🎓 lines) disappeared | `@claude-flow/cli`'s version-stamped helper auto-refresh pristine-copies `statusline.cjs` on the **first ruflo command after an upgrade** — including the statusline render itself | `ak sync` — it now triggers that refresh *first*, then re-injects, so the footer survives; `ak status` flags an armed wipe before it fires | +| Too many `⚙` daemons / stale daemons | One daemon per active project is normal (local-only workers, $0). Stale = workspace deleted or past the 12h TTL | `ak x daemon-gc --kill`; `sync` also reaps (and verifies the pid really is a ruflo daemon before killing) | | Want to change which MCP tool families are callable | Exclusions are `permissions.deny` rules, persisted in kit.json | `ak x mcp pick` (re-runnable); `x mcp status` shows the inventory; `x mcp off` unregisters | | `ruflo memory store` says OK but reads return nothing | Absolute-DB-path pin missing, or WAL not checkpointed, or the WASM fallback above | `ak setup` in the project re-pins + verifies a real write lands on disk | | Suspicious token burn | Background automation vs interactive usage | ask Claude to run the **ruflo-token-audit** skill (deployed by `setup`) | | `status` shows `ruvnet-brain … not installed` | The RuvNet Brain (offline KB + `search_ruvnet` MCP) isn't on disk | `ak sync` (or `ak setup`) runs the installer; `npx ruvnet-brain --doctor` health-checks it | -| Don't want the RuvNet Brain (the ~512 MB KB download) | It's on by default | `ak setup --no-ruvnet-brain`, or set `ruvnetBrain: false` in `~/.config/agentic-kit/kit.json` | +| Don't want the RuvNet Brain (the ~2 GB KB download) | It's on by default | `ak setup --no-ruvnet-brain`, or set `ruvnetBrain: false` in `~/.config/agentic-kit/kit.json` | +| Don't want the security surface managed | Also on by default | `ak setup --no-security` (persists `security:false`; status shows an info row and sync stops healing it) | | RuvNet Brain KB lives somewhere non-default | The installer + ak honor `$RUVNET_BRAIN_KB` (default `~/.cache/ruvnet-brain/kb`) | export `RUVNET_BRAIN_KB` so detection points at your KB | +> [!WARNING] +> The `natives … WASM fallback` row is the one that loses data: on the WASM path, +> memory writes print "OK" and silently vanish. Treat it as the highest-priority fix. + +One entry above deserves its history spelled out: + +> [!NOTE] +> **Historical:** earlier kit versions flagged any `.rvf.lock` starting with `FLVR` +> bytes as corruption and deleted the store beside it. That signal was measured +> unsound — `FLVR` is the *normal* lock magic (`SFVR` is the store's) — and +> agentic-qe ≥ 3.12.3 self-heals genuinely unusable stores non-destructively +> ([aqe #563](https://github.com/proffesor-for-testing/agentic-qe/issues/563)). +> The kit now guards only store *size*. If you see `brain.rvf.corrupt-` +> droppings from that era, they are safe to delete. + ## Deep proofs (slow, spawn real CLIs) ```bash ak x verify learning # trains a cycle in an isolated dir; asserts patterns persist to disk ak x verify security # packages load + defend flags a real injection sample ak x verify aqe # agentic-qe genuinely on ruvector (no FsyncFailed) +ak x verify harvest # end-to-end learning-write path against real CLIs ak x verify all ``` diff --git a/docs/archive/README.md b/docs/archive/README.md index 4f011b2..18c4109 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -4,8 +4,9 @@ Documents in this directory are **frozen**: they describe investigations, incide design work that shaped this kit but whose subject matter has since been resolved — mostly by upstream ruflo/agentic-qe releases (baseline: ruflo 3.28.0 / agentic-qe 3.12.2, 2026-07-14). They are kept verbatim as provenance for *why* the kit's surviving pieces -exist. Do not update them; the living docs are `../BACKGROUND.md`, -`../TROUBLESHOOTING.md`, and `../CONDITIONAL-BLOCKS.md`. +exist. Do not update them; the living docs are [../TROUBLESHOOTING.md](../TROUBLESHOOTING.md), +[../PROVIDERS.md](../PROVIDERS.md), and [../MANAGED-TOOLS.md](../MANAGED-TOOLS.md) +(the former `BACKGROUND.md` and `CONDITIONAL-BLOCKS.md` are themselves archived below). ## Index @@ -14,10 +15,10 @@ exist. Do not update them; the living docs are `../BACKGROUND.md`, | [2026-06-upstream-findings-f1-f6.md](2026-06-upstream-findings-f1-f6.md) | `docs/upstream/ruflo-self-improvement-findings.md` | The F1–F6 findings series: proofs/refutations of ruflo's self-improvement claims (Q-learning persistence, state-encoder collapse, SONA learn→inference wiring, native-training misreporting), with filed upstream issues. | Every finding is now fixed upstream: F2 in 3.10.6 ([#2222](https://github.com/ruvnet/ruflo/issues/2222)), F2b in 3.10.7, F3 in 3.10.11 ([#2239](https://github.com/ruvnet/ruflo/issues/2239)), F4 in `@ruvector/ruvllm` 2.5.6 ([RuVector#519](https://github.com/ruvnet/RuVector/issues/519)), F6 in 3.18.1/3.19.0 + ruvllm 2.5.7 ([#2549](https://github.com/ruvnet/ruflo/issues/2549), closed 2026-07-03). | | [2026-06-token-consumption-incident.md](2026-06-token-consumption-incident.md) | `docs/usage/token-consumption-findings-and-mitigation-2026-06.md` | Root-cause report for the June 2026 token-burn incident: six immortal auto-started daemons consumed ~8.1B tokens over 7 days via headless worker sessions. Produced the opt-in daemon policy, TTL reaper, ⚙ statusline alarm, and `ruflo-token-audit`. | The root cause was fixed upstream in ruflo 3.27/3.28 ([#2661](https://github.com/ruvnet/ruflo/issues/2661)): AI workers are opt-in, launches are governed by a machine-wide budget with telemetry, one supervisor daemon per repo, native daemon TTL. The kit's daemon policy flipped back to default-on (local-only workers) on that baseline; the reapers and token-audit remain as an independent check. | | [2026-06-11-token-consumption-recurrence.md](2026-06-11-token-consumption-recurrence.md) | `docs/usage/token-consumption-recurrence-and-cleanup-2026-06-11.md` | Follow-up audit 10 days later: 17 daemons had accumulated but the TTL auto-reaper had already contained them; cleanup of daemon-state files, logs, and two plugin MCP servers. | Same incident class as above — governed upstream since 3.27/3.28. Kept as evidence the TTL-reaper safety net worked. | -| [2026-05-28-superpowers-plan-ruvector-self-learning-aqe-security.md](2026-05-28-superpowers-plan-ruvector-self-learning-aqe-security.md) / [spec](2026-05-28-superpowers-spec-ruvector-self-learning-aqe-security.md) | `docs/superpowers/{plans,specs}/` | Plan + design for proving/repairing the ruvector stack (native better-sqlite3, RVF pattern store, solver) across ruflo and agentic-qe; produced `ruflo-enable-learning`, `ruflo-verify-aqe`, the RVF repair. | Implemented and shipped; the tools it produced live in `bin/` and `shell/`. Version targets in the text (ruflo 3.10.x) are frozen history. | -| [2026-05-28-superpowers-plan-self-improvement-eval.md](2026-05-28-superpowers-plan-self-improvement-eval.md) / [spec](2026-05-28-superpowers-spec-self-improvement-eval.md) | `docs/superpowers/{plans,specs}/` | Plan + design for `ruflo-improvement-eval`, the pre-registered causal test (permutation p, Cohen's d, ablation) that the route Q-learner actually self-improves. | Self-labeled HISTORICAL even before archiving: the F2/F3 bugs it was designed around were fixed upstream (3.10.6–3.10.11). The eval tool itself survives in `bin/ruflo-improvement-eval`. | +| [2026-05-28-superpowers-plan-ruvector-self-learning-aqe-security.md](2026-05-28-superpowers-plan-ruvector-self-learning-aqe-security.md) / [spec](2026-05-28-superpowers-spec-ruvector-self-learning-aqe-security.md) | `docs/superpowers/{plans,specs}/` | Plan + design for proving/repairing the ruvector stack (native better-sqlite3, RVF pattern store, solver) across ruflo and agentic-qe; produced `ruflo-enable-learning`, `ruflo-verify-aqe`, the RVF repair. | Implemented and shipped in the shell era; the surviving logic ported into `src/lib/` (natives, rvf, verify). Version targets in the text (ruflo 3.10.x) are frozen history. | +| [2026-05-28-superpowers-plan-self-improvement-eval.md](2026-05-28-superpowers-plan-self-improvement-eval.md) / [spec](2026-05-28-superpowers-spec-self-improvement-eval.md) | `docs/superpowers/{plans,specs}/` | Plan + design for `ruflo-improvement-eval`, the pre-registered causal test (permutation p, Cohen's d, ablation) that the route Q-learner actually self-improves. | Self-labeled HISTORICAL even before archiving: the F2/F3 bugs it was designed around were fixed upstream (3.10.6–3.10.11). The eval tool itself survives as `ak x improvement-eval` (`src/tools/improvement-eval.mjs`). | | [2026-05-29-superpowers-plan-daemon-statusline-resource-fix.md](2026-05-29-superpowers-plan-daemon-statusline-resource-fix.md) / [spec](2026-05-29-superpowers-spec-daemon-statusline-resource-fix.md) | `docs/superpowers/{plans,specs}/` | Plan + design for the daemon-hygiene suite (TTL reaper, auto-reap, ⚙ alarm) and statusline resource fixes. | Shipped; and the hazard it defended against is now bounded upstream (#2661). The surviving pieces were recalibrated for the daemon-default-on posture in July 2026. | -| [2026-05-29-superpowers-plan-install-onboarding-ux.md](2026-05-29-superpowers-plan-install-onboarding-ux.md) / [spec](2026-05-29-superpowers-spec-install-onboarding-ux.md) | `docs/superpowers/{plans,specs}/` | Plan + design for `install.sh` profiles, `ruflo-onboard`, `ruflo-resync`, and the conditional CLAUDE.md block system. | Implemented and shipped; `docs/CONDITIONAL-BLOCKS.md` is the living description of the block system. | +| [2026-05-29-superpowers-plan-install-onboarding-ux.md](2026-05-29-superpowers-plan-install-onboarding-ux.md) / [spec](2026-05-29-superpowers-spec-install-onboarding-ux.md) | `docs/superpowers/{plans,specs}/` | Plan + design for `install.sh` profiles, `ruflo-onboard`, `ruflo-resync`, and the conditional CLAUDE.md block system. | Implemented and shipped in the shell era; the block system now lives in `src/lib/blocks.mjs` (see the shell-kit-conditional-blocks entry below). | ## Naming convention diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 19a377e..987420f 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -18,7 +18,7 @@ import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; -import { scalar, checkpoint, withDb } from '../lib/sqlite.mjs'; +import { checkpoint, withDb } from '../lib/sqlite.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, heading, bold, dim } from '../lib/output.mjs'; @@ -45,7 +45,7 @@ Options: --project force project-scope setup even outside a git repo --minimal machine scope only; skip project setup --no-aqe skip agentic-qe install + configuration - --no-ruvnet-brain skip the RuvNet Brain (~512 MB offline KB) setup step + --no-ruvnet-brain skip the RuvNet Brain (~2 GB offline KB) setup step --no-security skip the security-surface verification --reconfigure re-run interactive choices, ignoring saved kit.json --yes accept all prompts (non-interactive) @@ -95,7 +95,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } if (cfg.ruvnetBrain) { if (!rb.present()) { - if (await ask('Install the RuvNet Brain (~512 MB offline KB, powers the search_ruvnet MCP)?', true, flags.yes)) { + if (await ask('Install the RuvNet Brain (~2 GB offline KB, powers the search_ruvnet MCP)?', true, flags.yes)) { info('installing ruvnet-brain via npx (downloads the KB — may take a while)…'); const r = await heal.installRuvnetBrain(); (r.ok ? ok : warn)(`ruvnet-brain: ${r.detail}`); @@ -103,9 +103,12 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } else ok('ruvnet-brain present (refresh to the latest release with `ak sync`)'); } - // 2. heal natives + the #2670 aidefence gap up front + // 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. ok(`natives: ${(await heal.healNatives()).detail}`); - ok(`aidefence: ${(await heal.healAidefence()).detail}`); + if (cfg.security !== false) ok(`aidefence: ${(await heal.healAidefence()).detail}`); + else info('security surface skipped (kit.json security:false — re-enable by removing the key)'); if (cfg.aqe) info(`aqe solver: ${(await heal.healAqeSolver()).detail}`); // 3. token-audit skill → ~/.claude/skills @@ -176,8 +179,11 @@ export async function run_project({ flags, cfg }) { const mcpCfg = readJson(mcpJson); if (mcpCfg?.mcpServers) { for (const k of ['ruflo', 'claude-flow', 'ruv-swarm', 'flow-nexus']) delete mcpCfg.mcpServers[k]; - if (Object.keys(mcpCfg.mcpServers).length === 0) fs.rmSync(mcpJson, { force: true }); - else fs.writeFileSync(mcpJson, JSON.stringify(mcpCfg, null, 2) + '\n'); + // Delete the FILE only when mcpServers was its only content — a repo may + // carry other top-level keys in .mcp.json that must survive the strip. + if (Object.keys(mcpCfg.mcpServers).length === 0 && Object.keys(mcpCfg).length === 1) { + fs.rmSync(mcpJson, { force: true }); + } else fs.writeFileSync(mcpJson, JSON.stringify(mcpCfg, null, 2) + '\n'); ok('.mcp.json sanitized (no committed ruflo/ruv-swarm/flow-nexus entries)'); } await runCmd('claude', ['mcp', 'remove', 'ruflo', '-s', 'local'], { cwd: root }); @@ -215,9 +221,15 @@ export async function run_project({ flags, cfg }) { checkpoint(dbPath); const probeKey = `_setup/verify-${process.pid}`; const stored = (await runCmd('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env })).code === 0; - const onDisk = stored && scalar(dbPath, `SELECT COUNT(*) FROM memory_entries WHERE key='${probeKey}'`) === 1; + // Bound parameters, not string interpolation — probeKey is pid-derived and + // safe today, but interpolated SQL is a habit this codebase doesn't keep. + const onDisk = stored && withDb(dbPath, + (db) => db.prepare('SELECT COUNT(*) AS n FROM memory_entries WHERE key = ?').get(probeKey)?.n) === 1; if (onDisk) { - withDb(dbPath, (db) => db.exec(`DELETE FROM memory_entries WHERE key='${probeKey}'; PRAGMA wal_checkpoint(TRUNCATE);`), null, { readonly: false }); + withDb(dbPath, (db) => { + db.prepare('DELETE FROM memory_entries WHERE key = ?').run(probeKey); + db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); + }, null, { readonly: false }); ok('memory write VERIFIED (store → on-disk row confirmed)'); } else { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 6a9b0af..223dba5 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -14,7 +14,7 @@ import { scanRvf } from '../lib/rvf.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; -import { upstreamCveCounterFabricated, fixStatusline } from '../lib/statusline.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'; @@ -146,8 +146,12 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('npx', 'warn', `npx cache check unavailable: ${e.message}`)); } - // security surface - if (securityPresent()) { + // 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 { @@ -175,12 +179,12 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { if (fs.existsSync(aqeDir)) { const findings = scanRvf(aqeDir); if (findings.length) { - // On aqe < 3.12.3 only: a stale-lock or oversized artifact aqe can't yet - // self-heal. aqe >= 3.12.3 makes scanRvf return nothing here (it repairs - // its own stores non-destructively), so this row goes quiet on upgrade. + // 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} stale/oversized RVF artifact(s) — aqe will drop OFF ruvector (FsyncFailed)`, - 'sync quarantines them (aqe 3.12.3 self-heals this)')); + `${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')); } @@ -322,11 +326,20 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { // 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 */ } - rows.push(row('statusline', wouldChange ? 'warn' : 'ok', + // 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') - : 'activation footer present and current', - wouldChange ? 'sync re-injects the footer' : null)); + : 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 diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index febe714..fbfa805 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { collect } from './status.mjs'; import * as heal from '../lib/heal.mjs'; -import { fixStatusline } from '../lib/statusline.mjs'; +import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; @@ -16,7 +16,7 @@ import { nativesStatus, securityPresent } from '../lib/natives.mjs'; import { readJson } from '../lib/settings.mjs'; import { appendToConfig } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; -import { ok, warn, fail, info, bold, dim } from '../lib/output.mjs'; +import { ok, warn, fail, info, bold, dim, withProgress } from '../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -58,18 +58,22 @@ export async function run({ flags, pkgRoot }) { const cfg = loadKitConfig(); const subsystems = new Set(plan.map((p) => p.subsystem)); const report = (name, r) => (r.ok ? ok(`${name}: ${r.detail}`) : fail(`${name}: ${r.detail}`)); + // 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('versions') && !flags['no-upgrade']) { report('daemons', await heal.stopAllDaemons()); for (const d of await driftReport({ force: true })) { - if (d.outdated || !d.installed) report(`upgrade ${d.pkg}`, await heal.upgradePackage(d.pkg)); + if (d.outdated || !d.installed) await 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']) { - report('ruvnet-brain', await heal.installRuvnetBrain({ force: true })); + await step('ruvnet-brain', () => heal.installRuvnetBrain({ force: true })); } // 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 @@ -77,9 +81,11 @@ export async function run({ flags, pkgRoot }) { if (subsystems.has('ruvnet-brain-nightly')) { report('ruvnet-brain nightly', await heal.disableRuvnetBrainNightly()); } - if (subsystems.has('security') || subsystems.has('versions')) { - report('aidefence', await heal.healAidefence()); - report('aqe solver', await heal.healAqeSolver()); + // cfg.security gate: on `versions` this branch 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()); } // 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 @@ -90,7 +96,7 @@ export async function run({ flags, pkgRoot }) { // 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')) { - report('natives', await heal.healNatives()); + await 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 @@ -106,10 +112,10 @@ export async function run({ flags, pkgRoot }) { // 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) { - report('agentdb', await heal.healAgentdb()); + await step('agentdb', () => heal.healAgentdb()); } if (subsystems.has('mcp') && cfg.mcp.register) { - const okReg = await mcpRegister(); + 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`); @@ -135,19 +141,36 @@ export async function run({ flags, pkgRoot }) { for (const h of HOSTS) { if (!cfg.providers.hosts[h.id]) continue; if ((await hostInstallState(h)).method !== 'absent') continue; - report(`install ${h.id}`, await installHost(h.id)); + await step(`install ${h.id}`, () => installHost(h.id)); } } if (subsystems.has('providers')) { report('providers', applyHosts(cfg, cwd)); const router = applyAqeRouter(cfg, cwd); if (router.changed || !router.ok) report('aqe router', router); - const prov = await applyProviders(cfg, cwd); + const prov = await withProgress('providers (api)', () => applyProviders(cfg, cwd)); if (prov.changed || !prov.ok) report('providers (api)', prov); } - if (subsystems.has('statusline') || subsystems.has('versions')) { - const r = fixStatusline(cwd); + // 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'); + } } // kit self-update — LAST, after every other heal: npm replaces the kit's @@ -155,7 +178,7 @@ export async function run({ flags, pkgRoot }) { // 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) report('self-update', await heal.selfUpdate(s.latest)); + if (s.outdated) await step('self-update', () => heal.selfUpdate(s.latest)); } // converge proof @@ -169,7 +192,15 @@ export async function run({ flags, pkgRoot }) { appendToConfig(cfg, { ts: Math.floor(Date.now() / 1000), learningRows: stats?.patternsLearned ?? 0, - nativeSlots: nativesStatus()?.locations?.length ?? 0, + // Count NATIVE bindings (incl. the aqe slot), not directories: a location + // flipping native→WASM must move this number or the regression detector + // named "native agentdb slots dropped" can never fire; and a benign tree + // reshape (a location legitimately vanishing) must not fake an alarm + // when its binding was WASM anyway. + nativeSlots: (() => { + const n = nativesStatus(); + return (n?.locations?.filter((l) => l.native).length ?? 0) + (n?.aqe?.native ? 1 : 0); + })(), driftOutdated: (await driftReport()).some((r) => !r.installed || r.outdated), securityPresent: securityPresent(), }); diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index 2d545a2..f1ee95a 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -72,7 +72,7 @@ async function verifySecurity() { async function verifyAqe() { heading('aqe — on ruvector: RVF store healthy, no FsyncFailed at startup'); const findings = scanRvf(projectAqeDir(process.cwd())); - if (findings.length) { fail(`${findings.length} corrupt/oversized RVF artifact(s) — run: ak sync`); return false; } + if (findings.length) { fail(`${findings.length} oversized RVF store(s) — run: ak sync`); return false; } ok('RVF store artifacts healthy'); const st = await runCmd('aqe', ['status'], { timeout: 120_000 }); if (/FsyncFailed|0x0303/.test(st.stdout + st.stderr)) { fail('aqe status reports FsyncFailed — off ruvector'); return false; } diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index e93f074..7995e06 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -117,9 +117,14 @@ export function upsertBlock(content, slug, blockText, position = 'append') { const e = END(slug); let out; const bi = text.indexOf(b); - if (bi !== -1) { - const afterEndLine = endOfSentinelLine(text, e, bi); + const afterEndLine = bi !== -1 ? endOfSentinelLine(text, e, bi) : null; + if (bi !== -1 && afterEndLine !== null) { out = text.slice(0, lineStart(text, bi)) + block + text.slice(afterEndLine); + } else if (bi !== -1) { + // Orphaned BEGIN (no END): append a fresh block instead of replacing "to + // end-of-file" — the orphan stays visible for the user to clean up, and + // nothing below it is destroyed. + out = text.replace(/\n*$/, '\n') + '\n' + block; } else if (position === 'prepend') { out = block + '\n' + text; } else { @@ -138,6 +143,7 @@ export function stripBlock(content, slug) { const bi = text.indexOf(b); if (bi === -1) return content; const afterEndLine = endOfSentinelLine(text, e, bi); + if (afterEndLine === null) return content; // orphaned BEGIN — never strip to EOF const tail = text.slice(afterEndLine).replace(/^\n/, ''); // Collapse the blank separator line upsert added before the block. const head = text.slice(0, lineStart(text, bi)).replace(/\n+$/, '\n'); @@ -149,12 +155,14 @@ function lineStart(text, index) { return nl === -1 ? 0 : nl + 1; } -/** Index just past the newline that terminates the sentinel `e`'s line - * (or end-of-string). Searches from `from`; if the END sentinel is missing - * the block is treated as running to end-of-file (defensive). */ +/** Index just past the newline that terminates the sentinel `e`'s line, or + * NULL when the END sentinel is missing. Callers must treat null as "no + * well-formed block here" — the old fallback (run to end-of-file) meant an + * orphaned BEGIN silently deleted everything below it on the next upsert: + * irreversible loss of the user's global CLAUDE.md content. */ function endOfSentinelLine(text, e, from) { const ei = text.indexOf(e, from); - if (ei === -1) return text.length; + if (ei === -1) return null; const nl = text.indexOf('\n', ei); return nl === -1 ? text.length : nl + 1; } @@ -200,6 +208,14 @@ export async function syncBlocks(file, rows, resolveTemplate, { dryRun = false } } if (changed && !dryRun) { fs.mkdirSync(path.dirname(file), { recursive: true }); + // One-time backup before the first rewrite of the user's CLAUDE.md — + // same contract as settings.mjs writeJsonWithBackup (never overwrite an + // existing .bak): this file is the user's global instructions, and every + // other writer in the kit is backup-first. + const bak = `${file}.bak`; + if (fs.existsSync(file) && !fs.existsSync(bak)) { + try { fs.copyFileSync(file, bak); } catch { /* best-effort */ } + } fs.writeFileSync(file, content); } return results; diff --git a/src/lib/daemons.mjs b/src/lib/daemons.mjs index 82fa48b..fe4840a 100644 --- a/src/lib/daemons.mjs +++ b/src/lib/daemons.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { execFileSync } from 'node:child_process'; import { run } from './exec.mjs'; import { isWindows, home } from './paths.mjs'; @@ -66,7 +67,7 @@ async function processSweep() { return found; } -function parseSweepLine(line, found) { +export function parseSweepLine(line, found) { const m = line.trim().match(/^(\d+)[\s\t]+(.*)$/); if (!m) return; const wsMatch = m[2].match(/--workspace[= ]([^\s"]+|"[^"]+")/); @@ -101,9 +102,31 @@ export function staleDaemons(daemons, ttlSecs = Number(process.env.RUFLO_DAEMON_ !d.workspaceExists || (ttlSecs > 0 && d.ageSecs !== null && d.ageSecs > ttlSecs)); } +/** True when `pid`'s command line looks like a ruflo daemon (`daemon start`). + * Guards reap() against pidfile pid-reuse: an orphaned daemon.pid whose pid + * the OS recycled onto an unrelated same-user process must not get our + * SIGTERM. The process-sweep path already matches cmdline; this brings the + * workspace/registry path up to the same standard, on every platform — + * POSIX via `ps`, Windows via the same CIM query the sweep uses (pid is + * numeric-coerced into the filter, so nothing user-shaped reaches the + * command). Unconfirmable (probe fails, no output) → false: never kill what + * we can't identify. */ +function isDaemonProcess(pid) { + if (!Number.isFinite(pid)) return false; + try { + const args = isWindows + ? execFileSync('powershell', ['-NoProfile', '-Command', + `(Get-CimInstance Win32_Process -Filter 'ProcessId=${Number(pid)}').CommandLine`], + { encoding: 'utf8', timeout: 10_000 }) + : execFileSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' }); + return /daemon start/.test(args); + } catch { return false; } +} + export function reap(daemons) { const results = []; for (const d of daemons) { + if (!isDaemonProcess(d.pid)) { results.push({ ...d, killed: false, pidReused: true }); continue; } try { process.kill(d.pid); results.push({ ...d, killed: true }); } catch { results.push({ ...d, killed: false }); } } diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index f2f7fa0..2b0a7e7 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -134,6 +134,16 @@ export function startDashboard({ port = 7431, cwd = process.cwd(), fetchStatus } const server = http.createServer(async (req, res) => { const url = (req.url || '/').split('?')[0]; if (req.method !== 'GET') { res.writeHead(405).end('method not allowed'); return; } + // DNS-rebinding guard: the socket binds loopback-only, but a hostile page + // can rebind its own hostname to 127.0.0.1 and read /api/status cross- + // origin (the browser's SOP keys on the NAME, not the address). Only + // loopback literals are legitimate Hosts for this panel. + const host = String(req.headers.host || '').toLowerCase(); + if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) { + res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('forbidden (unexpected Host)'); + return; + } if (url === '/' || url === '/index.html') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 65c9be0..8c15a8e 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -105,9 +105,9 @@ export async function healAqeSolver() { return { ok: true, detail: r.code === 0 ? 'installed' : 'unavailable (TS fallback is fine <50K nodes)' }; } -/** Quarantine stale-lock / oversized RVF artifacts in a project. No-op once the - * installed aqe self-heals its own stores (>= 3.12.3): scanRvf returns nothing, - * so this reports healthy without touching disk. See src/lib/rvf.mjs. */ +/** Quarantine oversized (runaway-append) RVF stores in a project — the one RVF + * failure mode left to the kit. Lock/corruption handling is agentic-qe's own + * job since 3.12.3; see src/lib/rvf.mjs for the history. */ export function healRvf(projectAqeDir) { const findings = scanRvf(projectAqeDir); const removed = findings.flatMap((f) => quarantine(f)); @@ -136,7 +136,7 @@ export async function selfUpdate(version) { } /** Install (or update to latest) the RuvNet Brain via its npx installer. - * The installer is idempotent and skips the ~512 MB download when the KB is + * The installer is idempotent and skips the ~2 GB download when the KB is * already present — pass force:true to bypass that skip (used when a drift * check saw a newer release). Runs `--no-stack --no-enhance`: ak already * manages ruflo/RuVector and owns the CLAUDE.md grounding block. */ diff --git a/src/lib/output.mjs b/src/lib/output.mjs index 301c320..1b2d43b 100644 --- a/src/lib/output.mjs +++ b/src/lib/output.mjs @@ -20,3 +20,42 @@ export const heading = (msg) => console.log(`\n${bold(msg)}`); /** Status glyph for dashboard rows. */ export const glyph = (level) => level === 'ok' ? green('✓') : level === 'warn' ? yellow('⚠') : level === 'fail' ? red('✗') : dim('·'); + +/** Run `thunk` while showing a live elapsed-time ticker on one rewritten line, so + * long heals (npm -g installs, the ~512 MB brain KB download, native rebuilds) + * visibly progress instead of leaving the prompt frozen — our `run()` buffers + * child output, so without this the terminal is silent until the process exits. + * TTY-only: piped/redirected output gets no ticker (the caller's result line is + * enough) so logs and `--json` stay clean. Always clears its line before + * returning, so the caller's ok/fail prints fresh. Rejects exactly as `thunk` + * does — never swallows errors. + * + * INVARIANT (load-bearing): the thunk must not write to stdout — the ticker + * owns the line via \r-rewrites with no trailing newline. Every current sync + * thunk routes child output through the buffered run() in exec.mjs, which is + * why this holds; a thunk that console.logs or spawns stdio:'inherit' will + * garble the line. */ +export async function withProgress(label, thunk, { + tty = process.stdout.isTTY, // injectable for hermetic tests + out = process.stdout, +} = {}) { + const start = Date.now(); + const fmt = (ms) => { + const s = Math.round(ms / 1000); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`; + }; + let timer; + if (tty) { + const render = () => + out.write(`\r${dim('⏳')} ${label} ${dim(`— ${fmt(Date.now() - start)}…`)}`); + render(); + timer = setInterval(render, 1000); + if (timer.unref) timer.unref(); + } + try { + return await thunk(); + } finally { + if (timer) clearInterval(timer); + if (tty) out.write('\r\x1b[K'); // erase the ticker line + } +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 810fe4b..60ca6c4 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -77,6 +77,23 @@ export const npxCacheDir = () => { return path.join(cache, '_npx'); }; +/** Nearest ancestor of `cwd` (inclusive) containing `.git`, or null. Bounded + * walk. The project-vs-user scope decision MUST use this, not a cwd-only + * probe: a cwd-only check run from a repo SUBDIR reports "not a project" and + * sends project-scoped env (ENABLE_* and AQE_LLM_PROVIDER) into the machine-wide + * user settings — while the sibling gates skip their project work — and the + * leak is then invisible/unreversible from the repo root. */ +export function repoRoot(cwd = process.cwd()) { + let dir = path.resolve(cwd); + for (let i = 0; i < 30; i++) { + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + export const rufloRoot = () => path.join(globalRoot(), 'ruflo'); export const rufloNodeModules = () => path.join(rufloRoot(), 'node_modules'); export const rufloCliDist = () => diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 78bfff2..8526735 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -27,7 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { run, have } from './exec.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; -import { installedVersion, cmpVersions } from './versions.mjs'; +import { installedVersion } from './versions.mjs'; import * as paths from './paths.mjs'; /** Frontier agent-CLI hosts. `pkg` is the npm global package; `enableEnv` is @@ -78,11 +78,6 @@ async function hostVersion(bin) { return m ? m[1] : null; } -async function npmLatest(pkg) { - const r = await run('npm', ['view', `${pkg}@latest`, 'version'], { timeout: 20_000 }); - return r.code === 0 ? r.stdout.trim() : null; -} - /** How a host is installed, so we never clobber a non-npm install: * 'npm' — an npm global copy exists (we may update it) * 'external' — on PATH but not the npm global copy (mise/native/brew — advise only) @@ -103,30 +98,10 @@ export async function installHost(id) { return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? `installed ${host.pkg}` : r.stderr.split('\n').slice(-2).join(' ').slice(0, 200) }; } -/** Update an npm-managed host to latest. No-op guidance for external installs. */ -export async function updateHost(id) { - const host = HOSTS.find((h) => h.id === id); - if (!host) return { ok: false, detail: `unknown host: ${id}` }; - const st = await hostInstallState(host); - if (st.method !== 'npm') return { ok: true, changed: false, detail: `${id} is ${st.method}-managed — update it with your own tool` }; - const r = await run('npm', ['install', '-g', `${host.pkg}@latest`], { timeout: 600_000 }); - return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? `updated ${host.pkg}` : r.stderr.split('\n').slice(-2).join(' ').slice(0, 200) }; -} - -/** Version drift per host. npm-managed hosts get a live `latest` lookup (network, - * cached by npm); external installs report installed-only (outdated=false, we - * don't own the update). Absent hosts report method 'absent'. */ -export async function hostDrift() { - const out = []; - for (const h of HOSTS) { - const st = await hostInstallState(h); - if (st.method === 'absent') { out.push({ id: h.id, method: 'absent', installed: null, latest: null, outdated: false }); continue; } - const latest = st.method === 'npm' ? await npmLatest(h.pkg) : null; - const outdated = !!(latest && st.version && cmpVersions(latest, st.version) > 0); - out.push({ id: h.id, method: st.method, installed: st.version, latest, outdated }); - } - return out; -} +// NOTE: host UPDATES ride versions.mjs `driftReport` (which lists the host +// packages) + heal.upgradePackage — there is deliberately no parallel +// updateHost/hostDrift pair here. Two earlier ones were dead code (zero +// production callers) and were removed; don't reintroduce a second drift path. /** Detect installed hosts + whether they are currently wired on in `cwd`. */ export async function detectHosts(cwd = process.cwd()) { @@ -153,11 +128,15 @@ export function detectProviders() { } /** Where host-enable env lands: project settings.local.json inside a repo (same - * seam as CLAUDE_FLOW_DB_PATH), else the user settings.json. */ + * seam as CLAUDE_FLOW_DB_PATH), else the user settings.json. Repo membership + * is resolved by WALKING UP to .git (paths.repoRoot) and the project file is + * anchored at that root — a cwd-only probe run from a repo subdir would + * silently retarget machine-wide user settings (and undo/status, run from + * the root, would never find the leaked keys). */ export function settingsTarget(cwd = process.cwd()) { - const inProject = fs.existsSync(path.join(cwd, '.git')); - return inProject - ? { file: paths.projectSettingsLocal(cwd), scope: 'project' } + const root = paths.repoRoot(cwd); + return root + ? { file: paths.projectSettingsLocal(root), scope: 'project' } : { file: paths.claudeSettingsPath(), scope: 'user' }; } @@ -214,10 +193,13 @@ function buildChain(entries) { export function applyAqeRouter(cfg, cwd = process.cwd()) { const chain = cfg.providers?.aqeFallback ?? []; if (chain.length === 0) return { ok: true, changed: false, detail: 'no aqe fallback chain configured' }; - if (!fs.existsSync(path.join(cwd, '.git'))) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; + // Same repo-root resolution as settingsTarget — the three scope gates must + // never disagree about what "in a project" means (see paths.repoRoot). + const root = paths.repoRoot(cwd); + if (!root) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; const valid = chain.filter((e) => e?.provider && AQE_PROVIDER_TYPES.includes(e.provider)); if (valid.length === 0) return { ok: false, detail: 'no valid providers in fallback chain' }; - const file = aqeRouterFile(cwd); + const file = aqeRouterFile(root); const existing = readJson(file, {}) ?? {}; const next = { ...existing }; next._managedBy = AQE_MANAGED_TAG; @@ -330,13 +312,16 @@ export async function ensureCodexAdapter(cfg, cwd = process.cwd()) { * Kept OUT of the sync hot path (it force-regenerates project files). */ export async function ensureDualAgents(cfg, cwd = process.cwd()) { if (!cfg.providers?.hosts?.codex) return { ok: true, changed: false, detail: 'codex disabled — no dual agents' }; - if (!fs.existsSync(path.join(cwd, '.git'))) return { ok: true, changed: false, detail: 'not a project — skipped `ruflo init --dual`' }; + // Repo-root walk, matching settingsTarget/applyAqeRouter — and init runs at + // the ROOT, so a subdir invocation can't scatter project files mid-tree. + const root = paths.repoRoot(cwd); + if (!root) return { ok: true, changed: false, detail: 'not a project — skipped `ruflo init --dual`' }; if (!(await have('ruflo'))) return { ok: false, detail: 'ruflo not on PATH' }; // Prerequisite: dual-init aborts unless @claude-flow/codex is present. Install // it first (guarded on opted-in codex + detected CLI) so a fresh machine just works. - const adapter = await ensureCodexAdapter(cfg, cwd); + const adapter = await ensureCodexAdapter(cfg, root); if (!adapter.ok) return { ok: false, changed: adapter.changed, detail: `adapter prerequisite failed: ${adapter.detail}` }; - const r = await run('ruflo', ['init', '--dual', '--force'], { cwd, timeout: 300_000 }); + const r = await run('ruflo', ['init', '--dual', '--force'], { cwd: root, timeout: 300_000 }); return { ok: r.code === 0, changed: r.code === 0, detail: r.code === 0 ? 'ruflo init --dual applied' : 'ruflo init --dual failed' }; } diff --git a/src/lib/ruvnet-brain.mjs b/src/lib/ruvnet-brain.mjs index 366395d..86d3d20 100644 --- a/src/lib/ruvnet-brain.mjs +++ b/src/lib/ruvnet-brain.mjs @@ -1,7 +1,7 @@ // RuvNet Brain — detection / install-locate / version primitives. // // Unlike ruflo/agentic-qe/the host CLIs, the brain is NOT a global npm package: -// `npx github:stuinfla/ruvnet-brain` is an installer that (a) downloads a ~512 MB +// `npx github:stuinfla/ruvnet-brain` is an installer that (a) downloads a ~2 GB // offline knowledge base to ~/.cache/ruvnet-brain/kb and (b) wires a user-scope // Claude Code plugin (the `search_ruvnet` MCP server + hooks + a skill). So the // npm primitives in versions.mjs (`installedVersion` → npm global root, @@ -71,7 +71,8 @@ export function installedVersion() { const vers = fs.readdirSync(inner, { withFileTypes: true }) .filter((e) => e.isDirectory() && /\d/.test(e.name)) .map((e) => e.name); - return vers.length ? vers.sort().at(-1).replace(/^v/, '') : null; + // Semver order, not lexical — .sort() alone ranks 0.9.0 above 0.10.0. + return vers.length ? vers.sort(cmpVersions).at(-1).replace(/^v/, '') : null; } catch { return null; } diff --git a/src/lib/rvf.mjs b/src/lib/rvf.mjs index 3952a6a..3879b20 100644 --- a/src/lib/rvf.mjs +++ b/src/lib/rvf.mjs @@ -1,100 +1,55 @@ -// RVF pattern-store health — the corruption modes the kit repaired before -// agentic-qe learned to heal itself. +// RVF pattern-store health — the oversized backstop (#495 runaway append after +// a hard exit; seen at ~277 GB). This is the ONLY filesystem-observable +// corruption mode left for the kit: every other RVF failure is agentic-qe's own +// job since 3.12.3 (issue #563 → aqe PR #564 — atomic tmp+rename exports plus +// non-destructive, pid-guarded self-healing). The legacy corrupt-lock branch +// was NOT dead code when removed — it stayed reachable on `ak status` (never +// upgrades), `ak sync --no-upgrade`, and offline syncs against a pre-3.12.3 +// aqe — it was removed because on exactly those paths its false-positive +// data-loss risk (deleting a healthy store beside a stale lock) outweighed the +// marginal protection it offered. // -// HISTORY (issue #563 → aqe PR #564, shipped in 3.12.3). The kit used to flag a -// `.rvf.lock` whose first four bytes are `FLVR` as a "corrupt lock" and delete -// the store next to it. Measured against @ruvector/rvf-node 0.1.8 — and -// confirmed across every RVF lock on the dev machine — that signal is UNSOUND: -// · `FLVR` is the NORMAL lock-record magic (the store's own magic is `SFVR`), -// so every lock on disk matches, healthy or not; -// · a 162-byte store is a VALID EMPTY store, not the "truncated header" the -// issue reported. -// The only sound signal for this corruption is "open fails AND create fails", -// which needs the native binding — the kit cannot observe it from the -// filesystem. So the old detector was really firing on "a lock exists", then -// deleting a store that may have been perfectly healthy (and, for `brain.rvf`, -// a dual-write target holding writes never flushed to memory.db — real data). -// -// aqe >= 3.12.3 makes exports atomic (tmp+rename) and self-heals genuinely -// unusable stores on the next run, non-destructively (quarantine-aside, not -// delete), gated on the lock's owner pid. Once that version is installed the -// kit stops second-guessing it: the corrupt-lock scan RETIRES itself and only -// the oversized backstop (a different mode — #495 runaway append, seen ~277 GB) -// remains. Until then the legacy scan stays active but is now guarded by the -// same pid-liveness check aqe added, so it can never quarantine a store a LIVE -// process is holding. Tracking the upgrade: pacphi/agentic-kit follow-up issue. +// HISTORY, kept because the mistake carried real data-loss risk: the kit once +// flagged any `.rvf.lock` whose first four bytes are `FLVR` as corruption and +// deleted the store beside it. Measured against @ruvector/rvf-node 0.1.8: +// `FLVR` is the NORMAL lock-record magic (the store's own magic is `SFVR`) and +// a 162-byte store is a valid EMPTY store — so the detector really fired on "a +// lock exists" and could delete a healthy `brain.rvf` (a dual-write target +// holding writes never flushed to memory.db, not a pure cache). The only sound +// corruption signal ("open fails AND create fails") needs the native binding, +// which is exactly why aqe owns it now. A version-gated, pid-guarded remnant of +// that detector lived here briefly (PR #31) and was stripped once the gate was +// permanently satisfied. Do NOT reintroduce a bytes-on-disk corruption +// heuristic here without proving it against rvf-node's actual format. import fs from 'node:fs'; import path from 'node:path'; -import { installedVersion, cmpVersions } from './versions.mjs'; - -// First agentic-qe release with atomic RVF export + self-healing stores (#564). -const AQE_SELFHEAL_VERSION = '3.12.3'; const defaultCapBytes = () => Number(process.env.RUFLO_AQE_RVF_MAX_BYTES ?? 2 * 1024 ** 3); -/** True once the installed agentic-qe atomically exports and self-heals RVF - * stores (>= 3.12.3), making the kit's corrupt-lock quarantine both redundant - * and unsound to keep. A prerelease of the target (3.12.3-rc.1) sorts below - * the release and so keeps the legacy scan active — the safe direction. */ -export function aqeSelfHealsRvf(installed = installedVersion('agentic-qe')) { - return !!installed && cmpVersions(installed, AQE_SELFHEAL_VERSION) >= 0; -} - -/** True when `pid` names a running process (signal 0 probes without delivering). - * EPERM = exists but owned by another user → still alive. */ -function isPidAlive(pid) { - try { process.kill(pid, 0); return true; } - catch (e) { return e.code === 'EPERM'; } -} - -/** The owner pid recorded in an rvf-node 0.1.8 lock record — `FLVR` magic then - * the pid as u32 LE at offset 4 — or null if the file is not a readable lock. */ -function readLockPid(file) { - let fd; - try { - fd = fs.openSync(file, 'r'); - const buf = Buffer.alloc(8); - if (fs.readSync(fd, buf, 0, 8, 0) < 8) return null; - if (buf.subarray(0, 4).toString('latin1') !== 'FLVR') return null; - const pid = buf.readUInt32LE(4); - return pid > 0 ? pid : null; - } catch { - return null; - } finally { - if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best-effort */ } } - } -} - -/** Scan a project's `.agentic-qe/` for RVF artifacts the kit should quarantine. - * `selfHeals`/`capBytes`/`isAlive` are injectable for hermetic tests. */ -export function scanRvf(dir, { - selfHeals = aqeSelfHealsRvf(), - capBytes = defaultCapBytes(), - isAlive = isPidAlive, -} = {}) { +/** Scan a project's `.agentic-qe/` for runaway-append RVF stores (> capBytes). + * `capBytes` is injectable for hermetic tests; <= 0 disables the backstop. + * Lock files (`.rvf.lock`) are never candidates — `.endsWith('.rvf')` excludes + * them by construction. */ +export function scanRvf(dir, { capBytes = defaultCapBytes() } = {}) { const findings = []; - if (!fs.existsSync(dir)) return findings; + if (!fs.existsSync(dir) || capBytes <= 0) return findings; for (const name of fs.readdirSync(dir)) { + if (!name.endsWith('.rvf')) continue; const file = path.join(dir, name); - if (!selfHeals && name.endsWith('.rvf.lock')) { - // Legacy pre-3.12.3 path (see file header). Only a STALE lock — one whose - // owner pid is gone — can trip this; a live owner means a store in use, - // never ours to quarantine. On 3.12.3+ this branch is skipped entirely. - const pid = readLockPid(file); - if (pid !== null && !isAlive(pid)) { - findings.push({ kind: 'corrupt-lock', file, sibling: file.replace(/\.lock$/, '') }); - } - } else if (name.endsWith('.rvf') && capBytes > 0) { - const size = fs.statSync(file).size; - if (size > capBytes) findings.push({ kind: 'oversized', file, size }); - } + // Per-entry guard: aqe daemon workers rename/quarantine stores in this dir + // while we scan (observed live: 35 renames in 7 minutes), so a file listed + // by readdir can be gone by stat time. A vanished entry is not a finding — + // and it must never crash `ak status`/`ak sync` (collect() calls us bare). + let size; + try { size = fs.statSync(file).size; } catch { continue; } + if (size > capBytes) findings.push({ kind: 'oversized', file, size }); } return findings; } -/** Delete a finding's store + sidecars. Returns the paths removed. */ +/** Delete an oversized store + its sidecars. Returns the paths removed. */ export function quarantine(finding) { - const base = finding.kind === 'corrupt-lock' ? finding.sibling : finding.file; + const base = finding.file; const targets = [base, `${base}.lock`, `${base}.idmap.json`, `${base}.manifest.json`]; const removed = []; for (const t of targets) { diff --git a/src/lib/statusline.mjs b/src/lib/statusline.mjs index fe74965..a304ae9 100644 --- a/src/lib/statusline.mjs +++ b/src/lib/statusline.mjs @@ -7,10 +7,11 @@ // CRLF-safe: operates on normalized text, re-emits the file's dominant ending. import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { execFileSync } from 'node:child_process'; -import { projectStatusline, projectSettings, rufloCliDist } from './paths.mjs'; -import { installedVersion } from './versions.mjs'; +import { createRequire } from 'node:module'; +import { projectStatusline, projectSettings, rufloCliDist, rufloNodeModules } from './paths.mjs'; +import { installedVersion, cmpVersions } from './versions.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; const FOOTER_TEMPLATE = path.join( @@ -113,10 +114,88 @@ export function upstreamCveCounterFabricated() { } catch { return false; } } +/** @claude-flow/cli's helper auto-refresh module (helper-refresh.js) — the + * writer that wiped the kit's footer between syncs. On EVERY ruflo CLI command + * it compares `.claude/helpers/.helpers-version` to the installed CLI version + * and, when the stamp lags, pristine-copies the CRITICAL_HELPERS (statusline.cjs + * among them) over ours. */ +const helperRefreshModule = () => path.join(rufloCliDist(), 'init', 'helper-refresh.js'); +const helperStampFile = (root) => path.join(root, '.claude', 'helpers', '.helpers-version'); + +/** Installed @claude-flow/cli version (the value ruflo stamps helpers with), + * or null. Versioned in lockstep with ruflo, but read from the cli package + * itself so a skewed tree can't fool the compare. */ +function rufloCliVersion() { + try { + const pkg = path.join(rufloCliDist(), '..', '..', 'package.json'); + return JSON.parse(fs.readFileSync(pkg, 'utf8')).version ?? null; + } catch { /* fall through to resolver */ } + // Resolver fallback: ruflo finds its own version via require.resolve, which + // can succeed where the fixed path.join fails (symlinked/relocated cli). The + // two staleness oracles — ours and ruflo's — must not disagree: if we + // under-report ("not stale") while ruflo would refresh, a providers-only + // sync runs a ruflo command that wipes the footer with no re-inject planned. + try { + const req = createRequire(pathToFileURL(path.join(rufloNodeModules(), 'noop.js'))); + return JSON.parse(fs.readFileSync(req.resolve('@claude-flow/cli/package.json'), 'utf8')).version ?? null; + } catch { return null; } +} + +/** True when ruflo's helper stamp lags the installed CLI — the armed state in + * which the NEXT ruflo command (in practice the daemon start) auto-refreshes + * the helpers and wipes the kit footer. Status uses this to flag the wipe + * BEFORE it happens; sync closes it via refreshRufloHelpers(). Missing stamp + * with a resolvable CLI counts as stale (first refresh hasn't run yet). */ +export function helperStampStale(root = process.cwd()) { + const installed = rufloCliVersion(); + if (!installed) return false; // no ruflo cli → nothing will refresh anything + try { + // Tolerate a `v` prefix: ruflo writes the stamp bare today, but a prefixed + // stamp fed raw into cmpVersions goes NaN and reads as PERMANENTLY stale — + // arming a pointless refresh on every status/sync forever. Genuine garbage + // still reads stale BY DESIGN: the refresh it arms rewrites a clean stamp, + // so the state self-corrects in one sync rather than sticking. + const stamp = fs.readFileSync(helperStampFile(root), 'utf8').trim().replace(/^v/i, ''); + return cmpVersions(installed, stamp) > 0; + } catch { return true; } // stamp unreadable/absent → first ruflo command will refresh +} + +/** Run ruflo's helper auto-refresh NOW, under the kit's control, so the + * pristine-copy happens BEFORE footer injection instead of on the first ruflo + * command after an upgrade. Root cause of the recurring footer wipe (observed + * 2026-07-18: daemon start at 12:20:35 rewrote statusline.cjs + .helpers-version + * the same second — sync had injected onto a stale-stamped helper, so the wipe + * was already armed). Subprocess, not in-process import: ruflo's ESM tree must + * never load into the kit's module graph. Best-effort — absent module or any + * failure returns false and injection proceeds on the file as-is (no worse + * than the pre-fix behavior). */ +export function refreshRufloHelpers(root = process.cwd(), { timeoutMs = 30_000 } = {}) { + const mod = helperRefreshModule(); + if (!fs.existsSync(mod)) return false; + try { + // A failed import / rejecting refresh exits 1; a BLOCKED refresh — upstream + // resolves {blocked:'…signature invalid'} rather than rejecting when the + // signed-manifest gate refuses to copy — exits 2. Both surface as false: + // "true = the refresh ran unblocked", never "a child spawned". (A resolved + // {refreshed:false} without blocked is a current-stamp no-op — success.) + execFileSync(process.execPath, ['-e', + 'import(process.argv[2]).then((m)=>m.autoRefreshHelpersIfStale(process.argv[1],{alsoRefreshGlobal:true})).then((r)=>{if(r&&r.blocked)process.exit(2)},()=>process.exit(1))', + root, pathToFileURL(mod).href, + ], { stdio: 'ignore', timeout: timeoutMs }); + return true; + } catch { return false; } +} + export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { const file = projectStatusline(root); if (!fs.existsSync(file)) return { file, applied: false, reason: 'no statusline.cjs (created by ruflo init)' }; + // Order matters: refresh ruflo's helpers BEFORE reading, so we inject onto the + // freshly-stamped copy and nothing rewrites it until the next ruflo upgrade + // (where sync repeats this, again under its own control). dryRun (status) must + // stay read-only — helperStampStale() reports the armed wipe there instead. + if (!dryRun) refreshRufloHelpers(root); + const raw = fs.readFileSync(file, 'utf8'); const ending = eol(raw); let s = raw.replace(/\r\n/g, '\n'); diff --git a/src/tools/improvement-eval.mjs b/src/tools/improvement-eval.mjs index 6f1db8b..a71df97 100755 --- a/src/tools/improvement-eval.mjs +++ b/src/tools/improvement-eval.mjs @@ -22,7 +22,12 @@ // // Exit: 0 self-improvement demonstrated / 1 not demonstrated / 2 environment error import { createRequire } from 'node:module'; -import { execSync } from 'node:child_process'; +// argv arrays only, per the exec.mjs binding rule — shell:true solely for the +// Windows .cmd shims (npm/ruflo), mirroring exec.mjs's CMD_SHIMS exception. +import { execFileSync } from 'node:child_process'; +const shim = { shell: process.platform === 'win32' }; +const execOut = (cmd, args) => + execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], ...shim }).toString(); import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -44,7 +49,7 @@ const fail = (s) => console.log(`${C.fail}✗${C.r} ${s}`); // ── Resolve ruflo's real Q-learning router from the global install ────────── function resolveRouterIdx() { let groot; - try { groot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } + try { groot = execOut('npm', ['root', '-g']).trim(); } catch { return null; } const cliPkg = join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli', 'package.json'); if (!existsSync(cliPkg)) return null; try { return createRequire(cliPkg).resolve('./dist/src/ruvector/index.js'); } catch { return null; } @@ -56,8 +61,8 @@ function resolveRouterIdx() { // regardless of autoSaveInterval. On <3.10.6 it holds only if the legacy stopgap // (autoSaveInterval:1, via ruflo-patch-route-learning) has been applied. if (has('--cli-check')) { - let groot; try { groot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { groot = ''; } - let ver = ''; try { ver = (execSync('ruflo --version', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().match(/\d+\.\d+\.\d+/) || [''])[0]; } catch { /* unknown */ } + let groot; try { groot = execOut('npm', ['root', '-g']).trim(); } catch { groot = ''; } + let ver = ''; try { ver = (execOut('ruflo', ['--version']).match(/\d+\.\d+\.\d+/) || [''])[0]; } catch { /* unknown */ } const ge = (v, a, b, c) => { const [x, y, z] = (v || '0.0.0').split('.').map(Number); return x > a || (x === a && (y > b || (y === b && z >= c))); }; const upstreamFixed = ver && ge(ver, 3, 10, 6); const route = join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli', 'dist', 'src', 'commands', 'route.js'); diff --git a/tests/kit/blocks.test.mjs b/tests/kit/blocks.test.mjs index 54765de..9fb00cf 100644 --- a/tests/kit/blocks.test.mjs +++ b/tests/kit/blocks.test.mjs @@ -126,3 +126,32 @@ test('syncBlocks reports missing-template without corrupting the file', async () assert.equal(fs.readFileSync(file, 'utf8'), 'keep\n'); fs.rmSync(tmp, { recursive: true, force: true }); }); + +// ── Orphaned-BEGIN safety (a corrupt sentinel must never eat the file) ────── + +test('an orphaned BEGIN (no END) never truncates content below it', () => { + // The old endOfSentinelLine fallback ran the "block" to end-of-file, so the + // next upsert silently deleted everything under the corrupt sentinel — + // irreversible loss of the user's global CLAUDE.md content. + const initial = 'above\n\nstale, END missing\n\n# User content that must survive\n'; + const out = upsertBlock(initial, 's1', block('s1', 'fresh')); + assert.ok(out.includes('# User content that must survive'), 'content below the orphan survives'); + assert.ok(out.includes('fresh'), 'a fresh block was appended'); + assert.ok(out.includes(''), 'the appended block is well-formed'); +}); + +test('stripBlock leaves content untouched when the END sentinel is missing', () => { + const initial = 'above\n\nno end here\nbelow\n'; + assert.equal(stripBlock(initial, 's1'), initial); +}); + +test('syncBlocks takes a one-time .bak before first rewriting the file', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-bak-')); + const file = path.join(tmp, 'CLAUDE.md'); + fs.writeFileSync(file, 'original\n'); + fs.writeFileSync(path.join(tmp, 't.md'), 'body\n'); + const rows = [{ slug: 'b1', template: 't.md', position: 'append', detector: { type: 'always' } }]; + await syncBlocks(file, rows, () => path.join(tmp, 't.md')); + assert.equal(fs.readFileSync(`${file}.bak`, 'utf8'), 'original\n', 'pre-rewrite content preserved'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/tests/kit/daemons.test.mjs b/tests/kit/daemons.test.mjs new file mode 100644 index 0000000..58d704d --- /dev/null +++ b/tests/kit/daemons.test.mjs @@ -0,0 +1,91 @@ +// daemons.mjs — the kit's only module whose job is TERMINATING processes, and +// (until this file) its only zero-coverage destructive module. Pins the three +// load-bearing behaviors: which daemons staleDaemons selects to die, that +// reap() degrades safely on dead/foreign pids (including the pid-reuse guard: +// never SIGTERM a live process whose cmdline isn't a ruflo daemon), and that +// parseSweepLine extracts pid + workspace from real ps shapes. reap's +// kill-true path runs against a REAL spawned child whose argv contains +// "daemon start", so the guard's match and the kill are both exercised. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { staleDaemons, reap, parseSweepLine } from '../../src/lib/daemons.mjs'; + +const DEAD_PID = 0x7ffffff0; // outside real pid ranges; kill(pid,0) → ESRCH + +// ── staleDaemons: the kill-selection rule ─────────────────────────────────── + +test('selects workspace-gone and TTL-exceeded daemons, spares the young', () => { + const daemons = [ + { pid: 1, workspaceExists: false, ageSecs: 10 }, // workspace gone → stale + { pid: 2, workspaceExists: true, ageSecs: 99_999 }, // over TTL → stale + { pid: 3, workspaceExists: true, ageSecs: 5 }, // healthy → spared + ]; + assert.deepEqual(staleDaemons(daemons, 43_200).map((d) => d.pid), [1, 2]); +}); + +test('ttlSecs=0 disables the age rule — only workspace-gone daemons die', () => { + const daemons = [ + { pid: 1, workspaceExists: false, ageSecs: 10 }, + { pid: 2, workspaceExists: true, ageSecs: 99_999_999 }, + ]; + assert.deepEqual(staleDaemons(daemons, 0).map((d) => d.pid), [1]); +}); + +test('unknown age (null) never counts as over-TTL', () => { + assert.deepEqual(staleDaemons([{ pid: 1, workspaceExists: true, ageSecs: null }], 1), []); +}); + +// ── reap: safety on every pid class ───────────────────────────────────────── + +test('a dead pid degrades to killed:false — no throw, per-entry result', () => { + const [r] = reap([{ pid: DEAD_PID, workspace: '/x' }]); + assert.equal(r.killed, false); +}); + +test('pid-reuse guard: a LIVE process that is not a ruflo daemon is never killed', () => { + // process.pid is this test runner — alive, but its cmdline has no + // "daemon start". The old reap would have SIGTERM'd it. + const [r] = reap([{ pid: process.pid, workspace: '/x' }]); + assert.equal(r.killed, false); + assert.equal(r.pidReused, true); +}); + +test('a real child whose argv matches "daemon start" IS reaped', async (t) => { + // The guard itself is cross-platform (ps on POSIX, CIM on Windows) and the + // never-kill side is covered above on every OS; this kill-true path stays + // POSIX-only because Windows CI's spawn + CIM probe latency makes the + // 200ms settle window flake-prone, not because the behavior differs. + if (process.platform === 'win32') { t.skip('kill-true path exercised on POSIX only (CI timing)'); return; } + // Spawn a keep-alive child whose ps args contain "daemon start" (extra argv + // after -e lands in the command line), so the identity guard matches it. + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'daemon', 'start'], { stdio: 'ignore' }); + t.after(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }); + await new Promise((r) => setTimeout(r, 200)); // let it register in the ps table + const [r] = reap([{ pid: child.pid, workspace: '/x' }]); + assert.equal(r.killed, true); +}); + +// ── parseSweepLine: pid + workspace extraction from ps output ─────────────── + +test('parses pid and --workspace from a standard ps line', () => { + const found = []; + parseSweepLine('12345 node /g/ruflo/node_modules/@claude-flow/cli/bin/cli.js daemon start --workspace /a/proj', found); + assert.equal(found.length, 1); + assert.equal(found[0].pid, 12345); + assert.equal(found[0].workspace, '/a/proj'); +}); + +test('parses the --workspace=/x/y variant and de-quotes a quoted path', () => { + const found = []; + parseSweepLine('7 node cli.js daemon start --workspace=/x/y', found); + parseSweepLine('8 node cli.js daemon start --workspace "/a b/proj"', found); + assert.equal(found[0].workspace, '/x/y'); + assert.equal(found[1].workspace, '/a b/proj'); +}); + +test('a garbage line yields nothing', () => { + const found = []; + parseSweepLine('not a ps line at all', found); + assert.deepEqual(found, []); +}); diff --git a/tests/kit/helper-stamp.test.mjs b/tests/kit/helper-stamp.test.mjs new file mode 100644 index 0000000..a681f38 --- /dev/null +++ b/tests/kit/helper-stamp.test.mjs @@ -0,0 +1,186 @@ +// helperStampStale / refreshRufloHelpers — closing the recurring statusline +// footer wipe. Root cause (identified 2026-07-18): @claude-flow/cli's +// version-stamped helper auto-refresh runs on EVERY ruflo CLI command and, +// when `.claude/helpers/.helpers-version` lags the installed CLI version, +// pristine-copies statusline.cjs over the kit's injected footer. Sync used to +// inject onto the stale-stamped file, so the first ruflo command afterwards +// (in practice the daemon start) wiped the footer — observed with statusline.cjs, +// .helpers-version, and daemon.pid mtimes matching to the second. +// +// The fix has two halves, both under test here: +// · helperStampStale — status flags the ARMED wipe before it fires; +// · refreshRufloHelpers + fixStatusline ordering — sync triggers ruflo's own +// refresh FIRST, then injects onto the freshly-stamped copy. +// Hermetic: a synthetic global root stands in for the installed CLI, and the +// fake helper-refresh.js MODELS THE REAL ONE FAITHFULLY — it pristine-copies +// statusline.cjs (wiping any injected footer) before recording its invocation. +// That wipe is load-bearing: it is what makes the ordering test able to FAIL +// on inject-then-refresh. A sentinel-only fake passes both orderings and +// guards nothing (found by review; do not simplify it back). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; +import { helperStampStale, refreshRufloHelpers, fixStatusline } from '../../src/lib/statusline.mjs'; + +// Minimal host: just the shapes fixStatusline keys off, runnable post-injection. +const HOST = `#!/usr/bin/env node +let ver = "3.0.0"; +function generateStatusline() { return 'ok'; } +console.log(generateStatusline()) +`; + +// Faithful stand-in for @claude-flow/cli's helper-refresh: pristine-copies +// statusline.cjs (the wipe), then records root + options for assertions. +const FAKE_REFRESH = `import fs from 'node:fs'; +import path from 'node:path'; +export async function autoRefreshHelpersIfStale(root, opts) { + const sl = path.join(root, '.claude', 'helpers', 'statusline.cjs'); + fs.writeFileSync(sl, ${JSON.stringify(HOST)}); + fs.writeFileSync(path.join(root, 'REFRESHED'), JSON.stringify({ root, opts })); + return { refreshed: true }; +} +`; + +// A refresh that rejects: the child must exit 1 → refreshRufloHelpers false. +const THROWING_REFRESH = `export async function autoRefreshHelpersIfStale() { + throw new Error('refresh exploded'); +} +`; + +// A refresh that hangs (keep-alive interval so the child can't drain its loop): +// the timeout must kill it → refreshRufloHelpers false. +const HANGING_REFRESH = `export function autoRefreshHelpersIfStale() { + setInterval(() => {}, 1000); + return new Promise(() => {}); +} +`; + +// A BLOCKED refresh: upstream's signed-manifest gate refuses to copy and +// RESOLVES {blocked} rather than rejecting — must still surface as false. +const BLOCKED_REFRESH = `export async function autoRefreshHelpersIfStale() { + return { refreshed: false, blocked: 'helpers manifest signature invalid' }; +} +`; + +const REFRESH_BODIES = { faithful: FAKE_REFRESH, throws: THROWING_REFRESH, hangs: HANGING_REFRESH, blocked: BLOCKED_REFRESH }; + +function fixture({ cliVersion = '3.32.7', stamp, refreshModule } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stamp-')); + const proj = path.join(dir, 'proj'); + const helpers = path.join(proj, '.claude', 'helpers'); + fs.mkdirSync(helpers, { recursive: true }); + fs.writeFileSync(path.join(helpers, 'statusline.cjs'), HOST); + if (stamp !== undefined) fs.writeFileSync(path.join(helpers, '.helpers-version'), stamp); + + const groot = path.join(dir, 'groot'); + if (cliVersion !== null) { + const cli = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(path.join(cli, 'dist', 'src', 'init'), { recursive: true }); + fs.writeFileSync(path.join(cli, 'package.json'), + JSON.stringify({ name: '@claude-flow/cli', version: cliVersion })); + if (refreshModule) { + fs.writeFileSync(path.join(cli, 'dist', 'src', 'init', 'helper-refresh.js'), + REFRESH_BODIES[refreshModule]); + } + } + _setGlobalRootForTest(groot); + return { proj, sl: path.join(helpers, 'statusline.cjs') }; +} + +// --- helperStampStale: the armed-wipe predicate -------------------------------- + +test('stale when the stamp lags the installed CLI', () => { + const { proj } = fixture({ cliVersion: '3.32.7', stamp: '3.32.2' }); + assert.equal(helperStampStale(proj), true); +}); + +test('current when the stamp matches the installed CLI', () => { + const { proj } = fixture({ cliVersion: '3.32.7', stamp: '3.32.7' }); + assert.equal(helperStampStale(proj), false); +}); + +test('a v-prefixed stamp matching the CLI is NOT stale (no permanent false-arm)', () => { + // Unstripped, 'v3.32.7' goes NaN in the compare and reads stale FOREVER — + // arming a pointless refresh on every status/sync. Pinned so it stays fixed. + const { proj } = fixture({ cliVersion: '3.32.7', stamp: 'v3.32.7' }); + assert.equal(helperStampStale(proj), false); +}); + +test('an empty stamp file is stale (self-correcting: the refresh rewrites it)', () => { + const { proj } = fixture({ cliVersion: '3.32.7', stamp: '' }); + assert.equal(helperStampStale(proj), true); +}); + +test('a garbage stamp is stale by design (one refresh converges it, not sticky)', () => { + const { proj } = fixture({ cliVersion: '3.32.7', stamp: 'not-a-version' }); + assert.equal(helperStampStale(proj), true); +}); + +test('a stamp AHEAD of the CLI is not stale (refresh is forward-only)', () => { + const { proj } = fixture({ cliVersion: '3.32.2', stamp: '3.32.7' }); + assert.equal(helperStampStale(proj), false); +}); + +test('missing stamp with a resolvable CLI counts as stale (first refresh pending)', () => { + const { proj } = fixture({ cliVersion: '3.32.7' }); // no stamp written + assert.equal(helperStampStale(proj), true); +}); + +test('no installed CLI → never stale (nothing exists to refresh anything)', () => { + const { proj } = fixture({ cliVersion: null, stamp: '3.32.2' }); + assert.equal(helperStampStale(proj), false); +}); + +// --- refreshRufloHelpers: the subprocess trigger ------------------------------- + +test('absent helper-refresh module → false, no throw', () => { + const { proj } = fixture({}); + assert.equal(refreshRufloHelpers(proj), false); +}); + +test('invokes ruflo refresh with the project root and alsoRefreshGlobal', () => { + const { proj } = fixture({ refreshModule: 'faithful' }); + assert.equal(refreshRufloHelpers(proj), true); + const rec = JSON.parse(fs.readFileSync(path.join(proj, 'REFRESHED'), 'utf8')); + assert.equal(rec.root, proj); + assert.equal(rec.opts.alsoRefreshGlobal, true); +}); + +test('a rejecting refresh returns false — true means it RAN, not that a child spawned', () => { + const { proj } = fixture({ refreshModule: 'throws' }); + assert.equal(refreshRufloHelpers(proj), false); +}); + +test('a hung refresh is killed by the timeout and returns false', () => { + const { proj } = fixture({ refreshModule: 'hangs' }); + assert.equal(refreshRufloHelpers(proj, { timeoutMs: 1000 }), false); +}); + +test('a BLOCKED refresh (signature gate resolved {blocked}) returns false', () => { + // Upstream resolves — not rejects — when the Ed25519 manifest gate refuses + // to copy. "true" must mean ran-unblocked, or a tampered install would read + // as a successful heal while nothing was written. + const { proj } = fixture({ refreshModule: 'blocked' }); + assert.equal(refreshRufloHelpers(proj), false); +}); + +// --- fixStatusline ordering: refresh BEFORE inject; dryRun stays read-only ----- + +test('refresh-then-inject: footer survives the wipe the refresh performs', () => { + // The faithful fake WIPES statusline.cjs when it runs. Only the correct + // ordering (refresh first, inject second) leaves the footer present; an + // inject-then-refresh regression ends with a pristine file and FAILS here. + const { proj, sl } = fixture({ refreshModule: 'faithful' }); + fixStatusline(proj); + assert.ok(fs.existsSync(path.join(proj, 'REFRESHED')), 'refresh must have run'); + assert.match(fs.readFileSync(sl, 'utf8'), /ruflo-seg:BEGIN/); +}); + +test('dryRun never triggers the refresh (status stays read-only)', () => { + const { proj } = fixture({ refreshModule: 'faithful' }); + fixStatusline(proj, { dryRun: true }); + assert.equal(fs.existsSync(path.join(proj, 'REFRESHED')), false); +}); diff --git a/tests/kit/output-progress.test.mjs b/tests/kit/output-progress.test.mjs new file mode 100644 index 0000000..604189e --- /dev/null +++ b/tests/kit/output-progress.test.mjs @@ -0,0 +1,86 @@ +// withProgress — the live elapsed-time ticker around slow sync heals (the +// ~512 MB brain KB download sat silent for 4m25s before this existed; run() +// buffers child output, so the parent must supply the liveness signal). +// Contract under test: transparent passthrough (value and rejection are the +// thunk's, always), ticker bytes only on a TTY, and the line is erased before +// the caller's ok/fail prints. Hermetic via the injectable {tty, out} seam — +// never touches the real stdout, so results don't depend on how tests are run. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { withProgress } from '../../src/lib/output.mjs'; + +const sink = () => { + const writes = []; + return { writes, write: (s) => { writes.push(String(s)); return true; } }; +}; + +test('resolves with the thunk value', async () => { + const out = sink(); + assert.equal(await withProgress('x', async () => 42, { tty: false, out }), 42); +}); + +test('propagates thunk rejection unchanged (never swallows errors)', async () => { + const out = sink(); + await assert.rejects( + () => withProgress('x', async () => { throw new Error('boom'); }, { tty: false, out }), + /boom/); +}); + +test('non-TTY: zero ticker bytes (piped/--json output stays clean)', async () => { + const out = sink(); + await withProgress('label', async () => 'v', { tty: false, out }); + assert.deepEqual(out.writes, []); +}); + +test('TTY: renders the label immediately, erases the line last', async () => { + const out = sink(); + await withProgress('upgrade ruflo', async () => 'v', { tty: true, out }); + assert.ok(out.writes.length >= 2, 'initial render + erase'); + assert.match(out.writes[0], /upgrade ruflo/); + assert.match(out.writes[0], /^\r/); // rewrites in place, never scrolls + assert.equal(out.writes.at(-1), '\r\x1b[K'); // caller's result line prints fresh +}); + +test('TTY: erases the line even when the thunk throws', async () => { + const out = sink(); + await assert.rejects( + () => withProgress('x', async () => { throw new Error('boom'); }, { tty: true, out }), + /boom/); + assert.equal(out.writes.at(-1), '\r\x1b[K'); +}); + +test('the interval actually ticks: elapsed time advances second by second', async (t) => { + // Mocked clock so the 1s interval and Date.now() are driven, not real-time — + // without this, every thunk resolves before the first tick and the entire + // ticker (the feature) could be deleted with the suite staying green. + t.mock.timers.enable({ apis: ['setInterval', 'Date'] }); + const out = sink(); + let release; + const p = withProgress('upgrade ruflo', () => new Promise((r) => { release = r; }), { tty: true, out }); + assert.match(out.writes[0], /upgrade ruflo/); + assert.match(out.writes[0], /0s/); // initial render at t=0 + t.mock.timers.tick(1000); + assert.match(out.writes.at(-1), /1s/); // interval fired, elapsed advanced + release('done'); + assert.equal(await p, 'done'); + assert.equal(out.writes.at(-1), '\r\x1b[K'); +}); + +test('minute rollover formats as XmYYs with zero-padded seconds', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'Date'] }); + const out = sink(); + let release; + const p = withProgress('brain KB', () => new Promise((r) => { release = r; }), { tty: true, out }); + t.mock.timers.tick(61_000); // 61 ticks; last render at t=61s + assert.match(out.writes.at(-1), /1m01s/); // not '1m1s', not '61s' + release('v'); + await p; +}); + +test('labels are interpolated verbatim — including real callers with metachars', async () => { + // Real sync labels include 'providers (api)' and 'upgrade @openai/codex'; + // the ticker must render them literally (plain interpolation, no formatting). + const out = sink(); + await withProgress('providers (api)', async () => 'v', { tty: true, out }); + assert.ok(out.writes[0].includes('providers (api)')); +}); diff --git a/tests/kit/providers.test.mjs b/tests/kit/providers.test.mjs index 5002792..70d29de 100644 --- a/tests/kit/providers.test.mjs +++ b/tests/kit/providers.test.mjs @@ -7,7 +7,7 @@ import { readJson } from '../../src/lib/settings.mjs'; import { loadKitConfig } from '../../src/lib/config.mjs'; import { isDefault, managedEnv, applyHosts, undoProviders, MANAGED_ENV_KEYS, - HOSTS, installHost, updateHost, applyAqeRouter, undoAqeRouter, aqeRouterFile, + HOSTS, installHost, applyAqeRouter, undoAqeRouter, aqeRouterFile, CODEX_ADAPTER_PKG, codexAdapterAction, ensureCodexAdapter, AQE_PROVIDER_TYPES, } from '../../src/lib/providers.mjs'; @@ -201,12 +201,6 @@ test('installHost rejects an unknown host id without shelling out', async () => assert.match(r.detail, /unknown host/); }); -test('updateHost rejects an unknown host id without shelling out', async () => { - const r = await updateHost('bogus'); - assert.equal(r.ok, false); - assert.match(r.detail, /unknown host/); -}); - test('config merge: providers partial merges over defaults (hosts deep-merged)', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-merge-')); const f = path.join(dir, 'kit.json'); @@ -268,3 +262,21 @@ test('ensureCodexAdapter is a no-op without shelling out when codex is disabled' assert.equal(res.changed, false); assert.match(res.detail, /codex disabled/); }); + +// ── settingsTarget repo-root walk (scope-leak regression pin) ─────────────── + +test('settingsTarget from a repo SUBDIR anchors project scope at the ROOT', async () => { + // A cwd-only .git probe here would fall through to USER scope — leaking + // ENABLE_*/AQE_LLM_PROVIDER machine-wide from any subdir invocation, with + // undo (run at the root) unable to find the keys. + const { settingsTarget } = await import('../../src/lib/providers.mjs'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scope-')); + fs.mkdirSync(path.join(dir, '.git')); + const sub = path.join(dir, 'src', 'deep'); + fs.mkdirSync(sub, { recursive: true }); + const t = settingsTarget(sub); + assert.equal(t.scope, 'project'); + assert.ok(t.file.startsWith(dir + path.sep), 'settings file anchored under the repo root'); + assert.ok(!t.file.includes('deep'), 'not anchored at the subdir'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/tests/kit/rvf.test.mjs b/tests/kit/rvf.test.mjs index 6dc5c3a..a9fdbe3 100644 --- a/tests/kit/rvf.test.mjs +++ b/tests/kit/rvf.test.mjs @@ -1,28 +1,29 @@ -// scanRvf / aqeSelfHealsRvf — the RVF corrupt-lock detector and its -// version-gated retirement (issue #563 → aqe PR #564). Hermetic: a synthetic -// .agentic-qe fixture with hand-built lock records + injected version/pid/cap, -// so no aqe install, no rvf-node binding, no network. +// scanRvf / quarantine — the oversized-store backstop (#495 runaway append), +// the ONLY RVF failure mode the kit still owns. Lock/corruption handling moved +// to agentic-qe itself (>= 3.12.3, issue #563 → PR #564); the kit's old +// corrupt-lock detector — and its version-gated remnant from PR #31 — are gone. +// These tests pin the reduced contract: size is the only signal, `.rvf.lock` +// files are never candidates, and quarantine takes the sidecars with the store. +// Hermetic: synthetic .agentic-qe fixtures, injected cap — no aqe install, no +// rvf-node binding, no network. // -// The load-bearing facts, measured against @ruvector/rvf-node 0.1.8 and -// confirmed on the dev machine: a lock record is `FLVR` magic + owner pid as -// u32 LE at offset 4; the store's own magic is `SFVR`; an empty store is 162 -// bytes. The old detector fired on the mere presence of an `FLVR` lock, which -// is EVERY lock — the retirement + pid-guard fix that. +// Load-bearing facts (measured against @ruvector/rvf-node 0.1.8): a lock record +// is 104 bytes starting `FLVR`; the store's own magic is `SFVR`; a 162-byte +// store is a valid EMPTY store. They appear here so any future "corruption" +// heuristic gets confronted with real shapes before it ships. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { scanRvf, aqeSelfHealsRvf } from '../../src/lib/rvf.mjs'; - -const DEAD_PID = 0x7fffffff; // no process; process.kill(_, 0) throws ESRCH → dead +import { scanRvf, quarantine } from '../../src/lib/rvf.mjs'; function aqeDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'ak-rvf-')); } /** Write an rvf-node-shaped lock: 4-byte 'FLVR' magic, pid u32 LE at offset 4. */ -function writeLock(dir, name, pid) { +function writeLock(dir, name, pid = process.pid) { const buf = Buffer.alloc(104); buf.write('FLVR', 0, 'latin1'); buf.writeUInt32LE(pid, 4); @@ -36,83 +37,101 @@ function writeStore(dir, name, size) { fs.writeFileSync(path.join(dir, name), buf); } -const kinds = (findings) => findings.map((f) => f.kind).sort(); - -// ── aqeSelfHealsRvf: the retirement gate ──────────────────────────────────── - -test('aqeSelfHealsRvf is false below 3.12.3 — the legacy scan stays active', () => { - assert.equal(aqeSelfHealsRvf('3.12.2'), false); -}); +// ── scanRvf: the oversized backstop ───────────────────────────────────────── -test('aqeSelfHealsRvf is true at exactly 3.12.3 — the scan retires', () => { - assert.equal(aqeSelfHealsRvf('3.12.3'), true); +test('an oversized store is flagged with its size', () => { + const dir = aqeDir(); + writeStore(dir, 'patterns.rvf', 4096); + const findings = scanRvf(dir, { capBytes: 1024 }); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'oversized'); + assert.equal(findings[0].size, 4096); + fs.rmSync(dir, { recursive: true, force: true }); }); -test('aqeSelfHealsRvf is true above 3.12.3', () => { - assert.equal(aqeSelfHealsRvf('3.13.0'), true); +test('a normal-sized store is not flagged (a 162-byte store is a valid EMPTY store)', () => { + const dir = aqeDir(); + writeStore(dir, 'brain.rvf', 162); + assert.deepEqual(scanRvf(dir, { capBytes: 2 * 1024 ** 3 }), []); + fs.rmSync(dir, { recursive: true, force: true }); }); -test('aqeSelfHealsRvf treats a 3.12.3 prerelease as not-yet-healed (safe direction)', () => { - assert.equal(aqeSelfHealsRvf('3.12.3-rc.1'), false); +test('a normal FLVR lock beside a healthy store is NOT a finding — ever', () => { + const dir = aqeDir(); + writeStore(dir, 'brain.rvf', 162); + writeLock(dir, 'brain.rvf.lock'); // FLVR magic = every real lock on disk + assert.deepEqual(scanRvf(dir, { capBytes: 2 * 1024 ** 3 }), []); + fs.rmSync(dir, { recursive: true, force: true }); }); -test('aqeSelfHealsRvf is false when no aqe version resolves', () => { - assert.equal(aqeSelfHealsRvf(null), false); +test('.rvf.lock files are never oversized candidates, even under a tiny cap', () => { + const dir = aqeDir(); + writeLock(dir, 'brain.rvf.lock'); // 104 bytes + assert.deepEqual(scanRvf(dir, { capBytes: 50 }), []); + fs.rmSync(dir, { recursive: true, force: true }); }); -// ── scanRvf: corrupt-lock detection, pre- and post-fix ────────────────────── - -test('a stale FLVR lock is flagged on aqe < 3.12.3 (legacy behavior preserved)', () => { +test('capBytes <= 0 disables the backstop entirely', () => { const dir = aqeDir(); - writeStore(dir, 'brain.rvf', 162); - writeLock(dir, 'brain.rvf.lock', DEAD_PID); - const findings = scanRvf(dir, { selfHeals: false }); - assert.deepEqual(kinds(findings), ['corrupt-lock']); - assert.equal(findings[0].sibling, path.join(dir, 'brain.rvf')); + writeStore(dir, 'patterns.rvf', 4096); + assert.deepEqual(scanRvf(dir, { capBytes: 0 }), []); fs.rmSync(dir, { recursive: true, force: true }); }); -test('the same stale FLVR lock is IGNORED once aqe self-heals (>= 3.12.3)', () => { +test('the cap is exclusive: size === capBytes is NOT oversized, cap+1 is', () => { const dir = aqeDir(); - writeStore(dir, 'brain.rvf', 162); - writeLock(dir, 'brain.rvf.lock', DEAD_PID); - assert.deepEqual(scanRvf(dir, { selfHeals: true }), []); + writeStore(dir, 'at-cap.rvf', 1024); + writeStore(dir, 'over-cap.rvf', 1025); + const findings = scanRvf(dir, { capBytes: 1024 }); + assert.equal(findings.length, 1); + assert.equal(path.basename(findings[0].file), 'over-cap.rvf'); fs.rmSync(dir, { recursive: true, force: true }); }); -test('a lock held by a LIVE process is never flagged — no quarantining a peer in use', () => { +test('only .rvf files are candidates — an oversized sidecar is never flagged', () => { const dir = aqeDir(); - writeStore(dir, 'patterns.rvf', 162); - writeLock(dir, 'patterns.rvf.lock', process.pid); // this test process is alive - assert.deepEqual(scanRvf(dir, { selfHeals: false }), []); + fs.writeFileSync(path.join(dir, 'patterns.rvf.idmap.json'), Buffer.alloc(4096)); + assert.deepEqual(scanRvf(dir, { capBytes: 1024 }), []); fs.rmSync(dir, { recursive: true, force: true }); }); -test('a non-FLVR .rvf.lock is not treated as a lock record', () => { +test('a store that vanishes between readdir and stat is skipped, not a crash', () => { + // The aqe daemon renames/quarantines stores while we scan (observed live: + // 35 renames in 7 min). A dangling symlink models the vanished entry + // deterministically: readdir lists it, stat throws ENOENT. const dir = aqeDir(); - fs.writeFileSync(path.join(dir, 'brain.rvf.lock'), Buffer.from('not-a-lock-record')); - assert.deepEqual(scanRvf(dir, { selfHeals: false }), []); + fs.symlinkSync(path.join(dir, 'gone-away'), path.join(dir, 'brain.rvf')); + writeStore(dir, 'patterns.rvf', 4096); + const findings = scanRvf(dir, { capBytes: 1024 }); // must not throw + assert.equal(findings.length, 1); + assert.equal(path.basename(findings[0].file), 'patterns.rvf'); fs.rmSync(dir, { recursive: true, force: true }); }); -// ── scanRvf: the oversized backstop (a different mode; survives retirement) ── +test('scanRvf on a missing .agentic-qe dir returns empty, never throws', () => { + assert.deepEqual(scanRvf(path.join(os.tmpdir(), 'ak-rvf-nope-123')), []); +}); -test('an oversized store is flagged even after the corrupt-lock scan retires', () => { +// ── quarantine: store + sidecars go together ──────────────────────────────── + +test('quarantine removes the store and every sidecar, and reports each path', () => { const dir = aqeDir(); writeStore(dir, 'patterns.rvf', 4096); - const findings = scanRvf(dir, { selfHeals: true, capBytes: 1024 }); - assert.deepEqual(kinds(findings), ['oversized']); - assert.equal(findings[0].size, 4096); + writeLock(dir, 'patterns.rvf.lock'); + fs.writeFileSync(path.join(dir, 'patterns.rvf.idmap.json'), '{}'); + fs.writeFileSync(path.join(dir, 'patterns.rvf.manifest.json'), '{}'); + const [finding] = scanRvf(dir, { capBytes: 1024 }); + const removed = quarantine(finding); + assert.equal(removed.length, 4); + assert.deepEqual(fs.readdirSync(dir), []); fs.rmSync(dir, { recursive: true, force: true }); }); -test('a normal-sized store is not flagged', () => { +test('quarantine skips absent sidecars without failing', () => { const dir = aqeDir(); - writeStore(dir, 'patterns.rvf', 162); - assert.deepEqual(scanRvf(dir, { selfHeals: true, capBytes: 2 * 1024 ** 3 }), []); + writeStore(dir, 'aqe.rvf', 4096); // no lock, no idmap, no manifest + const [finding] = scanRvf(dir, { capBytes: 1024 }); + const removed = quarantine(finding); + assert.deepEqual(removed, [path.join(dir, 'aqe.rvf')]); fs.rmSync(dir, { recursive: true, force: true }); }); - -test('scanRvf on a missing .agentic-qe dir returns empty, never throws', () => { - assert.deepEqual(scanRvf(path.join(os.tmpdir(), 'ak-rvf-nope-123'), { selfHeals: false }), []); -});