From eabab35ceeed0a9a21f56e2dbaec0c4a5bf37f19 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:40:13 -0700 Subject: [PATCH 01/45] docs(plan): Branch 3 Ruflo support window code-level plan --- ...026-09-27-branch-3-ruflo-support-window.md | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-branch-3-ruflo-support-window.md diff --git a/docs/superpowers/plans/2026-09-27-branch-3-ruflo-support-window.md b/docs/superpowers/plans/2026-09-27-branch-3-ruflo-support-window.md new file mode 100644 index 00000000..d8606c6a --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-branch-3-ruflo-support-window.md @@ -0,0 +1,478 @@ +# Branch 3 (`feat/ruflo-support-window`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Adopt a rolling Ruflo support window, make Ruflo's backup and distillation actually run and be +reported, stop the false "defend is non-functional" alarm, adopt the Ruflo 3.46.x fixes, route Claude +Code's Ruflo MCP through ak's launcher, and clean ak's old memory probe rows once. + +**Architecture:** Four sequential slices, each run by a fresh implementer in the worktree +`/Users/cphillipson/Development/active/ai/agentic-kit-b3`. Every behavior change is a unit commit, +test first. Ruflo facts come from the installed 3.46.1 source; older-version behavior comes from +fixtures or `npm pack` tarballs in the scratch area, never from a real install. + +**Tech Stack:** Node 22+/26 ESM CLI (`bin/agentic-kit.mjs`), `node:test`, Ruflo / `@claude-flow/cli` +3.46.1 (`$(npm root -g)/ruflo/node_modules/@claude-flow/cli/dist/src`, written below as `CLI/`), +SQLite through `src/lib/sqlite.mjs`. + +**Spec:** [program plan, Branch 3](2026-09-26-remediation-program.md#branch-3-featruflo-support-window), +[audit record](../../audits/2026-09-26-issues-237-238-239-verification-and-decisions.md) (Addendum 2 +Problem 2, Addendum 3 Items 1 and 6, "Ruflo support window", "Ruflo 3.46.0", "Retroactive upstream +sweep"), the SDD ledger's maintainer decisions B3-D1 to B3-D4, and the binding brief +`.superpowers/sdd/2026-09-26-remediation-program/briefs/common.md` in the main checkout. + +## Global Constraints + +- Ruflo support window: the newest six minors, never fewer than the minors released in the last 30 days (n-5, at least 30 days, rolling). +- Never run `pnpm` in the worktree; use `node --test`, `npx tsc -p tsconfig.json`, `npx eslint`, `npx markdownlint-cli2`, `node scripts/build-check.mjs`. +- Commits: conventional subject, one unit each, failing run shown before the passing run, no `Co-Authored-By` or any trailer-like last line; stage files by name; never commit `.harness/`, `.swarm/`, `.claude-flow/`, `.agentic-qe/`. +- Tests never write `~/.config/agentic-kit`, `~/.local/state/agentic-kit`, a repository's `.claude`/`.swarm`/`.claude-flow`/`.harness`, or a real memory store. Disposable runs use `env -u XDG_CONFIG_HOME -u XDG_DATA_HOME -u XDG_CACHE_HOME -u XDG_STATE_HOME HOME=$T` with `T=$(mktemp -d "$SCRATCH/ak.XXXXXX")` and assert every created path lies in `$T`. +- Never write Ruflo daemon settings through `ruflo config set` (ruvnet/ruflo#3449); flat keys in `.claude-flow/config.json` only. +- Runtime assets live under `src/` and are proven shipped with `npm pack --dry-run`. +- An ADR a task changes gets its Status, an `Updated` date and a one-line note in the same task's docs commit; user-facing docs describe the current state only. +- Never push, open or comment on pull requests or issues, or post upstream. Upstream comment drafts go to the report only. +- Every registry edit bumps `lastVerifiedAt` to the edit date and keeps each constraint's `nextRetestAt` on or after it (`src/lib/hook-audit/upstream.mjs:93-96`); run `node --test tests/kit/upstream-watch-registry.test.mjs` before committing. +- Branch 5 later edits `src/lib/heal.mjs` and `src/commands/x/verify.mjs`: keep hunks there small and local to the security functions. + +## Review Focus + +- **A user who set `autoStart: false` on purpose.** `ruflo init` writes the same value, so ak cannot tell them apart; expect ak to turn it on only under its own recorded intent, keep a receipt of the old value, show the setting in `ak status`, and restore it on `ak uninstall`. Pinned in Task 2.2. +- **A `.claude-flow/config.json` that already holds other keys or is malformed.** Expect ak to add or remove only its own flat keys and to leave a malformed file untouched and reported. Pinned in Task 2.2. +- **A Claude Code session started in a project subfolder once Ruflo enforces MCP policy on stdio (3.46.0+).** Ruflo reads `/.harness/mcp-policy.json` (`CLI/mcp-tools/policy-enforcer.js:66`) and refuses every call when it is missing; expect tool calls to keep working. Opened by Task 3.3, closed and pinned by Task 4.1. +- **A machine with no remembered Ruflo release dates (fresh install, offline).** Expect `ak status` to say the window is not yet known and name `ak sync`, never to call the network and never to call the version unsupported. Pinned in Task 1.2. +- **`security defend` crashing after it detects a threat (ruvnet/ruflo#3473, still in 3.46.1).** Expect `ak x verify security` to report a detection from the JSON verdict and a crash as a crash, never a pass. Pinned in Task 3.1. + +## Verification of the scope against the code (2026-09-27, Ruflo 3.46.1) + +Each scope item was checked against the worktree at `be1c1d47` and the installed Ruflo 3.46.1. + +| # | Item | Verdict | Evidence | +|---|---|---|---| +| 1 | Rolling support window | **Confirmed.** ak declares no floor; `src/lib/versions.mjs` only compares against npm latest. | Minors by first publish (npm `time`): 3.46 2026-09-26, 3.45 09-24, 3.44 09-23, 3.43 09-23, 3.42 09-15, 3.41 09-10, 3.40 09-09, 3.39 09-08, 3.38 08-11. Today's window: n-5 gives 3.41, the 30-day rule gives 3.39, so the floor is **3.39.0**. | +| 2 | Status row for backup and distillation | **Mostly already done.** `src/commands/status/sections/project-memory.mjs:112-151` (`backupRow`, `distillRow`) reads `memoryMaintenanceStatus` (`src/lib/memory-maintenance.mjs`), which reads `.claude-flow/metrics/backup.json` and `consolidation.json` exactly as 3.46.1 writes them (`CLI/services/worker-daemon.js:1529,1619`). The daemons row (`src/commands/status/sections/daemons.mjs`) names the autostart setting. | Remaining gap: nobody reports *why* workers do not run while a daemon is alive (the macOS low-memory deferral, `worker-daemon.js:594`, logged to `.claude-flow/logs/daemon.log`), and the rows do not say that start-on-use will start the daemon. Task 2.1 is scoped to that delta. | +| 3 | Daemon auto-start with managed flat keys | **Confirmed, and the task wording is incomplete.** `src/commands/setup.mjs:569-573` (not 575-585) flips `claudeFlow.daemon.autoStart` true to false. Stopping that is not enough: `ruflo init` writes `false` (`CLI/init/settings-generator.js:123`) and Ruflo's start-on-use refuses when it reads `false` (`CLI/services/daemon-autostart.js:56-76,84-88,184`). ak must set it to `true` under a receipt. | #3194 idle fix is in 3.46.1 (`worker-daemon.js:1019-1024` counts idle from process start). #2935 is not fixed (`worker-daemon.js:590` still `os.freemem()`, `:165` darwin default 5). Daemon reads flat keys from `.claude-flow/config.json`, JSON before YAML (`worker-daemon.js:354-416`). Memory root reads `claude-flow.config.json` then `.claude-flow/config.json`, falling through when a file has no `memory` key (`CLI/memory/memory-initializer.js:128-150`), so a daemon-only `.claude-flow/config.json` coexists with the memory pin. | +| 4 | Security defend reported non-functional | **Confirmed wrong reporting.** 3.46.1 `security defend` falls back to a built-in engine when `@claude-flow/aidefence` is missing (`CLI/commands/security.js:954-965`, `CLI/security/builtin-aidefence.js`). The `aidefence_*` MCP tools still need the package (`CLI/mcp-tools/security-tools.js:42-110`), so the heal stays. | ak's wording: `status/sections/security.mjs:15-17`, `x/verify.mjs:214`, `about.mjs:169-175`, `heal.mjs:177`, footer `statusline-footer.cjs:377-403,698`. #3473 reproduced on 3.46.1 in a disposable folder: text mode prints `2 threat(s) detected` then `[ERROR] Cannot read properties of undefined (reading 'color')`, exit 1; `-o json` prints the full verdict with no crash (exit 1 on threat, 0 on clean). | +| 5 | Remove the CVE-counter overlay | **Confirmed, and already inert.** `upstreamCveCounterFabricated()` (`src/lib/statusline.mjs:111-118`) is false on 3.46.1 (`CLI/funnel/local-signals.js:15,41` has `totalCves: 0`), so `SEC_WRAP` is no longer injected. Removal is safe because the floor 3.39.0 is above the fix (3.32.2). | Code: `statusline.mjs:40-59,101-118,307,315`; `status/sections/statusline.mjs:44-57`; `sync.mjs:563-565,646,708`; `statusline-footer.cjs:706-780`. | +| 6 | Stale "#2986 pending" note | **Confirmed.** `status/sections/scaffold-agents.mjs:27`. Every supported version (3.39.0+) ships `migrate fix --agents` (3.38.2+). | Registry adjustment at `agentic-dependency-constraints.json:1193`. | +| 7a | #3415 governance on stdio | **Confirmed shipped in 3.46.0, and ak's boundary is wrong.** 3.46.1 wires `evaluateToolCall` into both stdio entry points (`CLI/../bin/cli.js:166-168`, `bin/mcp-server.js:32-35,181-182`). The 3.45.0 and 3.44.0 tarballs have none. ak says "3.44.0 and earlier" (`ruflo-components/snapshot.mjs:48-52`, `catalogue.mjs:19`, `policy.mjs:1-5`); the correct boundary is "below 3.46.0". | `npm pack @claude-flow/cli@3.45.0` in scratch: `grep -c evaluateToolCall bin/cli.js bin/mcp-server.js` gives 0 and 0. | +| 7b | #3167 init opt-out flags | **Confirmed fixed in 3.46.1** (`CLI/commands/init.js:116-118,300-305`). **Cannot be removed unconditionally:** 3.39 to 3.45 are inside the window and still defective. Planned as a version gate (decision flagged). Nothing parses init's output (`setup.mjs:508`), so dropping `--format json` is safe on 3.46.0+. | Constraint `ruflo-3.38.21-init-suppression-flags`. | +| 7c | #3166 agent-browser doctor row | **Confirmed shipped.** `CLI/commands/doctor.js:66-100` reports agent-browser against a 0.27.0 minimum. ak has no workaround, so adoption is registry-only. The doctor's fix text `npm install -g agent-browser@latest` conflicts with ak's `>=0.27.0 <0.28.0` range (`src/lib/agent-browser.mjs:17`): recorded as an investigation candidate, not filed. | Installed agent-browser 0.27.3. | +| 7d | #3193 YAML config | **Half shipped.** The daemon now parses `.claude-flow/config.yaml` (`worker-daemon.js:30-32,370-387`; `yaml` resolves from the CLI package). The memory root still reads JSON only (`memory-initializer.js:128-150`) and `ConfigFileManager` still creates `claude-flow.config.json` from defaults with `persistPath: ./data/memory` (`CLI/services/config-file-manager.js:8-11,31,104-110`). ak's memory pin stays; registry note only. | Issue still open. | +| 8 | Nightly note for #2885 | **Confirmed stale.** `.github/workflows/nightly.yml:80-85` says "open as of 2026-08-13"; the thread has an Aug 31 triage (better-sqlite3 plus onnxruntime on macOS arm64) and our Sep 26 inconclusive local test. | `gh issue view 2885` (read-only). | +| 8b | `.harness/` in git | **Evidence favors excluding only ak's own file.** Ruflo and Agentic QE commit their own `.harness/mcp-policy.json` (search_ruvnet: `ruflo/.harness/manifest.json`, `agentic-qe/.harness/mcp-policy.json`), so a blanket `.harness/` ignore would hide other tools' intended files. A committed ak-written file has no receipt on a teammate's machine, so `reconcilePolicy` returns `user-managed` and quietly stops managing it there (`ruflo-components/policy.mjs:57-62`). | Plan: add `.harness/mcp-policy.json` to `.git/info/exclude` only when ak writes the file (decision flagged). | +| D1 | Claude through `ak x ruflo-mcp` | **Confirmed needed, and it has a second benefit.** Claude's registration is `ruflo mcp start` (`src/lib/mcp.mjs:539-567`). In a subfolder, Claude's `agentdb-memory.db` derives from `/.swarm` (see `ruflo-memory.mjs:1-8`), so it strays today, and the policy file lookup is cwd-relative. `rufloMcpLaunch` merges `componentEnv` over the inherited env (`ruflo-memory.mjs:103-138`), which would override a Claude user's settings env (ADR-0058 §3): the launcher needs a Claude mode that sets only the memory location. | `memoryProjectRoot` (`ruflo-memory.mjs:92-95`) is where harvest and setup pick their root. | +| D2 | One-time probe-row cleanup | **Confirmed #3450 still reproduces on 3.46.1.** In a disposable project, `memory store` wrote the row to both `memory.db` and `agentdb-memory.db`; `memory delete` only marked the `memory.db` row deleted; `memory purge` removed it from `memory.db` and left both mirror rows `active`. This repository's stores hold 0 probe rows today, so the real-data pass may find nothing, and must say so. | Probe key format `_setup/verify-${pid}-${Date.now()}`, namespace `_setup`, content `setup-verify` (`setup.mjs:583-585`). | + +**Carried re-checks (RM5, RM6, RM8), all on 3.46.1, no comment drafted:** + +- RM5 / ruvnet/ruflo#3450: reproduces unchanged (above). The only change is that `memory search` now warns that it did not search `agentdb-memory.db`; not material. +- RM6 / ruvnet/ruflo#3449: reproduces unchanged. `config set daemon.idleSecs 0` gives `Required option missing: --key/--value`; `-k daemon.idleSecs -v 0` gives `Both key and value are required`; `-k daemon.idleSecs -v 5` creates `claude-flow.config.json` with `memory.persistPath: "./data/memory"` and nested `daemon.idleSecs`. All already in the issue. +- RM8 / ruvnet/ruflo#2935: `worker-daemon.js:590` still reads `os.freemem()`, `:165` still defaults darwin to 5%. A reading today on the same 128 GB machine gave 5.96% free, straddling the default. Not material. + +## Maintainer decisions this plan cannot make (recommended option planned) + +1. **#3167 suppression:** gate ak's `--format json` and `RUFLO_NO_SKILLS_SH=1` to Ruflo below 3.46.0 (recommended; matches "version-gated" in the Ruflo dependency policy) or keep them until the window floor reaches 3.46.0. +2. **macOS memory threshold:** `daemon.resourceThresholds.minFreeMemoryPercent: 0` (recommended; the workaround Ruflo's own code comment names at `worker-daemon.js:648`) or `1`. +3. **`.harness/mcp-policy.json` in git:** `.git/info/exclude` entry written when ak writes the file (recommended; no tracked-file change), a managed `.gitignore` line, or nothing. +4. **Registry `supportWindow` field:** an optional field on the Ruflo dependency policy under schema 5 (recommended) or a schema bump to 6. +5. **#2885 macOS job (B3-D3):** GitHub runs `workflow_dispatch` only for a workflow file on the default branch, so the throwaway job uses `on: push` limited to its own short-lived branch. The controller pushes it under B3-D3; implementers never push. + +## File structure + +| File | Responsibility | Slice | +|---|---|---| +| `src/lib/ruflo-support-window.mjs` (new) | Minor first-publish dates from `npm view ruflo time --json`, window computation, remembered evidence in `kit.json` | 1 | +| `src/lib/hook-audit/agentic-dependency-constraints.json` | `supportWindow` on the Ruflo policy; watch entries moved to adopted/retired | 1, 3 | +| `src/lib/hook-audit/upstream.mjs` | Validate the optional `supportWindow` | 1 | +| `src/commands/status/sections/versions.mjs` | Window row | 1 | +| `src/commands/sync.mjs` | Record release dates on the forced lookup; probe-row cleanup step | 1, 4 | +| `scripts/upstream-watch/classify.mjs` | "Waiting for the window" before proposing removal | 1 | +| `src/lib/statusline.mjs`, `src/templates/statusline-footer.cjs`, `src/commands/status/sections/statusline.mjs` | Overlay removal; aidefence footer wording | 1, 3 | +| `src/lib/ruflo-daemon-config.mjs` (new) | Managed flat keys in `.claude-flow/config.json` and the autostart setting, with receipts | 2 | +| `src/commands/status/sections/daemons.mjs`, `src/lib/memory-maintenance.mjs` | Deferral reason and start-on-use state | 2 | +| `src/commands/setup.mjs` | Stop flipping autostart; call the daemon config reconcile; version-gated init suppression | 2, 3 | +| `src/commands/status/sections/security.mjs`, `src/commands/x/verify.mjs`, `src/commands/about.mjs`, `src/lib/heal.mjs`, `src/lib/natives.mjs` | Built-in engine awareness | 3 | +| `src/lib/ruflo-components/{snapshot,catalogue,policy,apply}.mjs` | Governance boundary 3.46.0; `.git/info/exclude` | 3 | +| `src/lib/ruflo-memory.mjs`, `src/commands/x/ruflo-mcp.mjs`, `src/lib/ruflo-mcp-transport.mjs`, `src/lib/mcp.mjs`, `src/lib/adapters/registries.mjs` | Claude through the launcher | 4 | +| `src/lib/memory-probe-cleanup.mjs` (new) | Find, back up, delete and receipt old probe rows | 4 | + +--- + +## Slice 1: support window and retirements + +### Task 1.1: Support-window policy in the registry + +**Files:** + +- Modify: `src/lib/hook-audit/agentic-dependency-constraints.json` (Ruflo entry of `dependencyPolicies`, line ~27) +- Modify: `src/lib/hook-audit/upstream.mjs:60-74` +- Test: `tests/kit/upstream-watch-registry.test.mjs` + +**Interfaces:** + +- Produces: `dependencyPolicies[ruflo].supportWindow = { newestMinors: 6, minDays: 30, basis: "first npm publish of each minor", unsupported: "ak status reports the installed Ruflo as unsupported and points to ak sync" }`. Validation: optional; when present `newestMinors` and `minDays` are positive integers and `basis` a string. + +- [ ] **Step 1: Write the failing tests** (append to `tests/kit/upstream-watch-registry.test.mjs`, using its existing `withRegistry` and `errorsOf` helpers) + +```js +test('the Ruflo dependency policy carries the rolling support window', () => { + const doc = JSON.parse(fs.readFileSync(REGISTRY, 'utf8')); + const ruflo = doc.dependencyPolicies.find((p) => p.dependency === 'ruflo'); + assert.deepEqual({ n: ruflo.supportWindow.newestMinors, d: ruflo.supportWindow.minDays }, { n: 6, d: 30 }); +}); + +test('an invalid support window invalidates the registry', () => { + const errors = errorsOf((doc) => { + doc.dependencyPolicies.find((p) => p.dependency === 'ruflo').supportWindow = { newestMinors: 0, minDays: 'x' }; + }); + assert.match(errors, /dependency policy 0 is invalid/); +}); +``` + +- [ ] **Step 2: Run and see both fail** + +Run: `node --test tests/kit/upstream-watch-registry.test.mjs` +Expected: the first fails on `ruflo.supportWindow` undefined; the second passes vacuously or fails because the invalid window is accepted. Record the output. + +- [ ] **Step 3: Implement.** Add the object above to the Ruflo policy. In `upstream.mjs` extend the policy `valid` predicate: + +```js +&& (entry.supportWindow === undefined || ( + Number.isInteger(entry.supportWindow?.newestMinors) && entry.supportWindow.newestMinors > 0 + && Number.isInteger(entry.supportWindow?.minDays) && entry.supportWindow.minDays > 0 + && typeof entry.supportWindow?.basis === 'string')) +``` + +- [ ] **Step 4: Run to pass.** `node --test tests/kit/upstream-watch-registry.test.mjs tests/kit/hook-upstream.test.mjs` passes. +- [ ] **Step 5: Commit** `feat(upstream): record the Ruflo support window on its dependency policy` (stage the three files by name). + +### Task 1.2: Compute and report the window from remembered evidence + +**Files:** + +- Create: `src/lib/ruflo-support-window.mjs` +- Modify: `src/commands/status/sections/versions.mjs`, `src/commands/sync.mjs:107` (the forced `driftReport` call site) +- Test: `tests/kit/ruflo-support-window.test.mjs` (new), `tests/kit/versions.test.mjs` + +**Interfaces:** + +- Produces: + - `minorFirstPublished(timeJson: Record): Record` maps `"3.46"` to the earliest ISO time of any stable `3.46.x` (prereleases and `created`/`modified` ignored). + - `computeSupportWindow({ firstPublished, now, newestMinors, minDays }): { floor: string, minors: string[] } | null` where `floor` is `"..0"`: the older of the `newestMinors`-th newest minor and the oldest minor first published within `minDays` of `now`. `null` when `firstPublished` is empty. + - `rememberedSupportWindow(cfg, { now, policy }): { floor, minors, observedAt } | null` reads `cfg.versionCheck.rufloMinors = { observedAt: number, firstPublished: {...} }`. No I/O besides the argument. + - `recordRufloReleaseDates({ runner, cfg }): Promise` runs `npm view ruflo time --json` (20 s timeout) and stores `cfg.versionCheck.rufloMinors`; returns false and leaves the old value on failure. + - `supportWindowPolicy(): { newestMinors, minDays }` reads the registry via the existing loader, defaulting to 6 and 30 if the registry is unreadable. + +- [ ] **Step 1: Write the failing unit tests** (`tests/kit/ruflo-support-window.test.mjs`) + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { minorFirstPublished, computeSupportWindow, rememberedSupportWindow } from '../../src/lib/ruflo-support-window.mjs'; + +const TIME = { created: '2020-01-01T00:00:00Z', modified: '2026-09-27T00:00:00Z', + '3.38.0': '2026-08-11T22:43:21Z', '3.39.0': '2026-09-08T16:52:33Z', '3.40.0': '2026-09-09T23:10:35Z', + '3.41.0': '2026-09-10T11:59:59Z', '3.42.0': '2026-09-15T00:50:39Z', '3.43.0': '2026-09-23T15:05:58Z', + '3.44.0': '2026-09-23T18:47:33Z', '3.45.0': '2026-09-24T22:54:53Z', '3.46.0-alpha.1': '2026-09-20T00:00:00Z', + '3.46.0': '2026-09-26T22:34:55Z', '3.46.1': '2026-09-27T01:00:00Z' }; +const NOW = Date.parse('2026-09-27T12:00:00Z'); + +test('first publish per minor ignores prereleases and metadata keys', () => { + const m = minorFirstPublished(TIME); + assert.equal(m['3.46'], '2026-09-26T22:34:55Z'); + assert.equal(m.created, undefined); +}); + +test('30-day rule widens n-5: today the floor is 3.39.0, not 3.41.0', () => { + const w = computeSupportWindow({ firstPublished: minorFirstPublished(TIME), now: NOW, newestMinors: 6, minDays: 30 }); + assert.equal(w.floor, '3.39.0'); +}); + +test('after a quiet month n-5 alone decides', () => { + const w = computeSupportWindow({ firstPublished: minorFirstPublished(TIME), now: Date.parse('2026-12-01T00:00:00Z'), newestMinors: 6, minDays: 30 }); + assert.equal(w.floor, '3.41.0'); +}); + +test('no remembered evidence gives null, never a guess', () => { + assert.equal(rememberedSupportWindow({}, { now: NOW, policy: { newestMinors: 6, minDays: 30 } }), null); +}); +``` + +- [ ] **Step 2: Write the failing section tests** (`tests/kit/versions.test.mjs`, reuse its sandbox and injected `driftReport`/`fetchLatest` seams). Cases, each asserting the Ruflo window row: + - remembered floor 3.39.0, installed 3.46.1: `info`, message `Ruflo 3.46.1 is inside the support window (3.39.0 and newer; release dates observed )`. + - installed 3.38.2: `fail`, message contains `unsupported`, `below the support window (3.39.0 and newer)`, fix `sync upgrades Ruflo into the support window`, `repair: 'sync'`. + - no `rufloMinors`: `info`, message "Ruflo support window not yet known: run ak sync to record Ruflo's release dates", and a spy runner proves no `npm` call was made by the section (the runner must record zero calls; `driftReport` is injected as a stub). +- [ ] **Step 3: Run and see them fail** (`node --test tests/kit/ruflo-support-window.test.mjs tests/kit/versions.test.mjs`: module not found / row missing). +- [ ] **Step 4: Implement** the module; add the row after the existing Ruflo row in `versions.mjs` (reads `loadKitConfig()` only); in `sync.mjs` call `recordRufloReleaseDates` right after the forced `driftReport` and save the config through the same path `driftReport` uses (`saveKitConfig`). Wire the sync step's `when` so a `versions` fail row plans it (it already does for `versions`). +- [ ] **Step 5: Run to pass**, plus `node --test tests/kit/sync-command.test.mjs tests/kit/status-repair-contract.test.mjs`. +- [ ] **Step 6: Commit** `feat(versions): support a rolling window of Ruflo minors (n-5, at least 30 days)`. + +### Task 1.3: The watcher waits for the window before proposing removal + +**Files:** Modify `scripts/upstream-watch/classify.mjs:157-190`, `scripts/upstream-watch/fetch.mjs` (reuse its npm read to get `time`); Test `tests/kit/upstream-watch-script.test.mjs`. + +**Interfaces:** Consumes `computeSupportWindow` from Task 1.2. Produces a group `waiting-for-window` on a released Ruflo entry whose `doneWhen.release.minVersion` is above the floor; `dispatch` stays null for it. + +- [ ] **Step 1: Failing test:** with floor 3.39.0, `entry('ruvnet/ruflo#3167', { status: 'released', doneWhen: { state: 'closed-completed', release: { channel: 'npm', name: 'ruflo', minVersion: '3.46.0' } } })` classifies with `groups` containing `waiting-for-window` and `dispatch === null`; the same entry with `minVersion: '3.32.2'` is dispatchable. +- [ ] **Step 2:** run, expect FAIL. **Step 3:** implement (floor passed in `context.supportFloor`; the CLI computes it from the npm `time` it already fetches; fixtures add a `time` object). **Step 4:** run to pass. +- [ ] **Step 5: Commit** `feat(upstream): hold workaround removals until the oldest supported Ruflo has the fix`. + +### Task 1.4: Remove the retired CVE-counter overlay + +**Files:** Modify `src/lib/statusline.mjs` (delete `SEC_WRAP`, `upstreamCveCounterFabricated`, the `securityOverlay` gate; keep `SEC_WRAP_STRIP` and its strip line with a comment "remove after one release"), `src/templates/statusline-footer.cjs` (delete `rufloLocalSecurity`, `rufloHonestInsight` and their comment block at 706-780), `src/commands/status/sections/statusline.mjs:44-57`, `src/commands/sync.mjs:563-565,646,708` (drop `statusline/cve`); Tests `tests/kit/statusline.test.mjs`, `tests/statusline-segments.test.cjs`, `tests/kit/sync-command.test.mjs:659`, `tests/kit/status-repair-contract.test.mjs:194-196`. + +- [ ] **Step 1: Failing tests:** (a) a statusline source containing an old `/* ruflo-sec:BEGIN */ … /* ruflo-sec:END */` block is patched to a file with no `ruflo-sec` text and no `rufloLocalSecurity`; (b) the footer template does not define `rufloLocalSecurity` or `rufloHonestInsight`; (c) status never emits a `statusline/cve` row, even with a fixture `local-signals.js` containing `const totalCves = 3` and `scans.length`. +- [ ] **Step 2:** run, expect FAIL on (a) second clause and (b), (c). +- [ ] **Step 3:** delete the code; update the two repair-contract tests that asserted `statusline/cve` behavior to assert the subsystem no longer exists. +- [ ] **Step 4:** run `node --test tests/kit/statusline*.test.mjs tests/kit/sync-command.test.mjs tests/kit/status-repair-contract.test.mjs` and `node tests/statusline-segments.test.cjs`. +- [ ] **Step 5: Commit** `refactor(statusline): remove the retired CVE-counter overlay`. Registry: set `ruvnet/ruflo#2694` to `status: "adopted"` with a history line `{"date":"","event":"adopted","note":"overlay removed; SEC_WRAP_STRIP kept one release; floor 3.39.0 >= 3.32.2"}` in the same commit (removal proof: the statusline suite passes against a 3.46.1 statusline fixture). + +### Task 1.5: Drop the stale "#2986 pending" note + +**Files:** Modify `src/commands/status/sections/scaffold-agents.mjs:4-7,24-28`; Test `tests/kit/scaffold.test.mjs`. + +- [ ] **Step 1: Failing test:** with `upstreamFixAvailable()` false (fixture dist without `migrate-agent-restore.js`), the info row says ``installed Ruflo predates `migrate fix --agents` (added in 3.38.2, below the support window): run `ak sync` `` and does not match `/#2986 pending/`. +- [ ] **Step 2–4:** run (FAIL), change the message and the header comment, run (PASS). +- [ ] **Step 5: Commit** `docs(status): drop the stale "#2986 pending" note`. Registry `ruvnet/ruflo#2986` to `adopted` with history in the same commit. + +### Task 1.6: Slice 1 docs + +- [ ] Amend ADR-0041 §7 with one paragraph (and check `docs/ddd/` for a context that names Ruflo versions; update it or record that none does): the support window rule, where it lives (`supportWindow` on the Ruflo policy), remembered evidence in `kit.json` (`versionCheck.rufloMinors`, written only by `ak sync`), and "the watcher proposes removal only once the floor contains the fix". Add an `Updated: 2026-09-27` line. +- [ ] Update `docs/UPGRADING.md` (support window, what "unsupported" means), `docs/UPSTREAM-WATCH.md` (`waiting-for-window`), `docs/TROUBLESHOOTING.md` (statusline no longer overlays a CVE count). +- [ ] Run `npx markdownlint-cli2 docs/adr/0041-host-neutral-hook-configuration-assurance.md docs/UPGRADING.md docs/UPSTREAM-WATCH.md docs/TROUBLESHOOTING.md` and `node --test tests/kit/doc-citations.test.mjs`. +- [ ] Commit `docs(adr): record the Ruflo support window (ADR-0041 §7)`. + +--- + +## Slice 2: daemon + +### Task 2.1: Say why backup and distillation are not running + +**Files:** Modify `src/lib/memory-maintenance.mjs` (add a bounded log reader), `src/commands/status/sections/daemons.mjs`, `src/commands/status/sections/project-memory.mjs:112-134`; Tests `tests/kit/memory-maintenance.test.mjs`, `tests/kit/daemons-status.test.mjs`. + +**Interfaces:** + +- Produces: `lastWorkerDeferral(root, { now }): { worker: 'backup'|'consolidate', reason: string, ageMs: number } | null`. Reads at most the last 64 KiB of `/.claude-flow/logs/daemon.log`, matching lines `^\[(ISO)\] \[INFO\] Worker (backup|consolidate) deferred: (.+)$` (3.46.1 format, `worker-daemon.js:1156-1161`). + +- [ ] **Step 1: Failing tests:** + - A log whose last matching line is `[2026-09-27T10:00:00.000Z] [INFO] Worker consolidate deferred: Memory too low: 3.9% free` gives `{ worker: 'consolidate', reason: 'Memory too low: 3.9% free' }`; a 5 MB log is read only at its tail (assert via a file with the match in the last 1 KiB and 5 MB of padding, and time under 50 ms is not asserted, only correctness). + - With a live daemon (inject `projectDaemonAlive` through the section's options) and that deferral, the memory section adds `warn` `Ruflo's daemon is running but deferred distillation 2 h ago: Memory too low: 3.9% free (macOS free-memory gate, ruvnet/ruflo#2935)` with fix `sync sets Ruflo's macOS memory threshold` and `repair: 'sync'` on darwin (the fix exists after Task 2.2), and `repair: 'manual'` elsewhere. + - With no daemon and start-on-use on, the daemons row says ``none running for this project yet; Ruflo starts it on the next `ruflo` command here`` (level `info`); with start-on-use off it keeps today's message. +- [ ] **Step 2:** run, expect FAIL. **Step 3:** implement (extend `projectDaemonAlive` injection in `project-memory.mjs` the same way `daemons.mjs` injects `listDaemons`). **Step 4:** run `node --test tests/kit/memory-maintenance.test.mjs tests/kit/daemons-status.test.mjs tests/kit/project-memory-status.test.mjs`. +- [ ] **Step 5: Commit** `feat(status): show whether Ruflo's backup and distillation are running`. + +### Task 2.2: Managed daemon settings and start-on-use + +**Files:** + +- Create: `src/lib/ruflo-daemon-config.mjs` +- Modify: `src/commands/setup.mjs:560-574` (replace the `autoStart → false` flip with a call to `reconcileRufloDaemon`), `src/commands/sync.mjs` (a `ruflo-daemon` step fired by a `daemons` row with `repair: 'sync'`), `src/commands/status/sections/daemons.mjs` (row for drift), `src/lib/trust-manifest.mjs` (disclose both writes), `src/commands/uninstall.mjs` (restore from receipts), `src/lib/daemons.mjs:210-235` (comment: ak no longer keeps it false) +- Test: `tests/kit/ruflo-daemon-config.test.mjs` (new), `tests/kit/setup-command.test.mjs`, `tests/kit/uninstall-command.test.mjs` + +**Interfaces:** + +- Produces: + - `desiredDaemonKeys({ rufloVersion: string, platform: string }): Record`: `{'daemon.idleSecs': 0}` when `cmpVersions(rufloVersion, '3.46.0') < 0`; `{'daemon.resourceThresholds.minFreeMemoryPercent': 0}` when `platform === 'darwin'` (until ruvnet/ruflo#2935 closes). Empty object otherwise. + - `reconcileRufloDaemon(root, { rufloVersion, platform, receipts, dryRun }): { config: 'written'|'converged'|'removed'|'user-managed'|'absent', autostart: 'enabled'|'converged'|'user-managed', changed: boolean }`. + - Receipts live in `kit.json` under `rufloDaemon.receipts[] = { configKeys: Record, autostartBefore: false|null }`. +- Rules: `.claude-flow/config.json` is created only when `desiredDaemonKeys` is non-empty; existing foreign keys are preserved byte-for-byte in value (re-serialized with two-space JSON); a malformed or non-object file is `user-managed` and never written; a key ak wrote that is no longer desired (Ruflo upgraded to 3.46.0) is removed, and an ak-created file left empty is deleted. `.claude/settings.json` `claudeFlow.daemon.autoStart` is set to `true` only when it is exactly `false` and `kit.json` `rufloDaemon.autoStart !== false`; the old value goes in the receipt. `claude-flow.config.json` `daemon.autostart` and the `RUFLO_DAEMON_AUTOSTART` variable are never edited; status names them. Never runs `ruflo config set`. + +- [ ] **Step 1: Failing unit tests** (temp project folders only): + +```js +test('below 3.46.0 on macOS both keys are flat in .claude-flow/config.json', (t) => { + const root = tmpProject(t); + const receipts = {}; + const r = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.equal(r.config, 'written'); + const cfg = JSON.parse(fs.readFileSync(path.join(root, '.claude-flow', 'config.json'), 'utf8')); + assert.deepEqual(cfg, { 'daemon.idleSecs': 0, 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.equal(cfg.daemon, undefined, 'never nested keys (ruvnet/ruflo#3449)'); +}); + +test('on 3.46.1 Linux nothing is written', (t) => { + const root = tmpProject(t); + assert.equal(reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'linux', receipts: {} }).config, 'absent'); + assert.equal(fs.existsSync(path.join(root, '.claude-flow', 'config.json')), false); +}); + +test('an upgrade to 3.46.0 removes only the idle key ak wrote', (t) => { /* write with 3.45.0, add a foreign key "providers": [], reconcile with 3.46.1 darwin: idleSecs gone, providers and the memory threshold kept */ }); +test('a malformed config.json is user-managed and untouched', (t) => { /* write "{", reconcile, bytes unchanged, status user-managed */ }); +test('the memory pin still wins and .swarm stays the memory root', (t) => { + /* write claude-flow.config.json {"memory":{"persistPath":".swarm"}} and reconcile; then rufloConfigMemoryRoot(root) resolves to /.swarm; + remove claude-flow.config.json: rufloConfigMemoryRoot(root) is null (Ruflo falls back to /.swarm) */ +}); +test('init\'s autoStart:false becomes true with a receipt, and uninstall restores it', (t) => { /* … */ }); +test('kit.json rufloDaemon.autoStart:false leaves the setting alone', (t) => { /* … */ }); +test('reconcile never spawns ruflo', (t) => { /* inject a runner that throws; reconcile completes */ }); +``` + +- [ ] **Step 2:** run `node --test tests/kit/ruflo-daemon-config.test.mjs`, expect module-not-found FAIL. Add a failing `setup-command.test.mjs` case: after `run_project` with an injected runner in a sandbox project whose settings say `autoStart: true`, the value stays `true` (today it becomes `false`). +- [ ] **Step 3:** implement; `setup.mjs` calls `reconcileRufloDaemon` **before** `ruflo daemon start`, because the daemon reads `.claude-flow/config.json` once, in its constructor (`worker-daemon.js:139-144`), so a daemon started first never sees the managed keys. Add a setup test asserting the reconcile write happens before the runner's `daemon start` call; the daemons row reports drift (`ak-managed daemon settings differ from what this Ruflo version needs`) with `repair: 'sync'`. +- [ ] **Step 4:** run the three test files plus `node --test tests/kit/trust-manifest*.test.mjs tests/kit/status-repair-contract.test.mjs`. +- [ ] **Step 5: Commit** `feat(ruflo-daemon): enable auto-start with Ruflo's supported daemon settings`. + +### Task 2.3: Prove on 3.46.1 that backup and distillation run + +No code; the evidence goes into the slice report and the audit record. + +- [ ] **Step 1:** build a disposable home and project: `SCR=; T=$(mktemp -d "$SCR/ak.XXXXXX")`; `git init $T/proj`; inside `$T/proj` run `env -u XDG_CONFIG_HOME -u XDG_DATA_HOME -u XDG_CACHE_HOME -u XDG_STATE_HOME HOME=$T node /bin/agentic-kit.mjs setup --project --yes --minimal --no-aqe --no-agent-browser --no-ruvnet-brain --no-deja-vu` (flags from `setup.mjs:66-84`). Assert every new path is under `$T`. +- [ ] **Step 2:** setup already started a daemon, which proves nothing about start-on-use: run `ruflo daemon stop` in `$T/proj` and confirm no live PID in `.claude-flow/daemon.pid`. Then `ruflo memory store -k proof --value one -n proof` (Ruflo's start-on-use, `CLI/index.js:189-204`) and confirm a new live PID and the log line `Daemon config loaded from …/.claude-flow/config.json`. +- [ ] **Step 3:** wait at least 11 minutes (consolidate first runs 6 min after start, backup 10 min; `worker-daemon.js:38,40`). `ruflo daemon trigger` exists but proves only a manual run, so do not substitute it. Use Monitor with an until-loop on `$T/proj/.claude-flow/metrics/backup.json`, not `sleep`. +- [ ] **Step 4:** record `backup.json` (`backedUp: true`), `consolidation.json` (`distillationEnabled: true`, no `error`), the snapshot file under `.swarm/backups/`, the daemon log lines, and `node bin/agentic-kit.mjs status --json` rows for `memory` and `daemons` from that project. +- [ ] **Step 5:** `ruflo daemon stop` in `$T/proj`; confirm no daemon for `$T` remains (`ruflo daemon status --all`); remove `$T`. +- [ ] **Step 6:** repeat Steps 1–5 with the 3.45.0 behavior simulated only at unit level (Task 2.2 tests); do not install 3.45.0 globally. + +### Task 2.4: Slice 2 docs + +- [ ] Check `docs/ddd/` for the memory and machine-footprint contexts' daemon wording. +- [ ] Addendum 3 Item 1 gets an implementation note (3.46.1 facts above, the autostart correction, the proof results). ADR-0058 is not touched here. +- [ ] `docs/SETUP.md` and `docs/TROUBLESHOOTING.md`: what ak writes in `.claude-flow/config.json` and `.claude/settings.json`, how to turn it off (`kit.json` `rufloDaemon.autoStart: false`, then `ak sync`). +- [ ] `ak setup --help` and `ak sync --help` mention the daemon step. +- [ ] Commit `docs(ruflo-daemon): document managed daemon settings and start-on-use`. + +--- + +## Slice 3: security and 3.46.x adoptions + +### Task 3.1: Defend is functional on the built-in engine; verify reads the verdict + +**Files:** Modify `src/lib/natives.mjs` (add `rufloBuiltinDefence(): boolean`, true when `CLI/security/builtin-aidefence.js` exists under the global Ruflo), `src/commands/status/sections/security.mjs`, `src/commands/x/verify.mjs:210-222` (security function only), `src/commands/about.mjs:166-176`, `src/lib/heal.mjs:173-178` (detail text only), `src/templates/statusline-footer.cjs:377-403,698-705`; Tests `tests/kit/verify-command.test.mjs:60-80`, a new `tests/kit/security-status.test.mjs`, `tests/statusline-segments.test.cjs:455-470`, `tests/kit/about-*.test.mjs` as affected. + +**Interfaces:** Produces `parseDefendVerdict(stdout: string): { safe: boolean, threats: number } | null` in `verify.mjs` (exported for tests): the last complete top-level JSON object in the output with a boolean `safe`, else `null`. + +- [ ] **Step 1: Failing tests:** + - Status with security present, aidefence absent, built-in engine present: `warn`, message `security defend uses Ruflo's built-in engine; @claude-flow/aidefence (adaptive learning and the aidefence_* MCP tools) is missing`, fix `sync reinstalls @claude-flow/aidefence`, `repair: 'sync'`. Aidefence absent and no built-in engine: today's `fail` row (kept for completeness, below the window). + - `parseDefendVerdict` on the recorded 3.46.1 JSON output (store it as `tests/fixtures/ruflo-defend/threat.json.txt` and `clean.json.txt`, captured with `-o json` in a disposable folder) gives `{ safe: false, threats: 2 }` and `{ safe: true, threats: 0 }`; on the text-mode crash output (`tests/fixtures/ruflo-defend/threat-crash.txt`, containing `[ERROR] Cannot read properties of undefined (reading 'color')`) gives `null`. + - `verifySecurity` with an injected runner: threat JSON exit 1 plus clean JSON exit 0 prints `defend: flags injection (2 threats), passes clean`; crash output exit 1 prints `defend crashed before reporting a verdict (ruvnet/ruflo#3473)` and fails; aidefence absent no longer returns early. + - The footer renders no alarm when the built-in engine file exists and aidefence is absent. +- [ ] **Step 2:** run, expect FAIL. **Step 3:** implement: verify calls `ruflo security defend -i -o json`; the footer's `rufloAidefenceState` returns `"builtin"` (no alarm) when `security/builtin-aidefence.js` exists; `healAidefence` detail becomes `installed (adaptive learning and aidefence_* MCP tools)`; About reports `attention('aidefence missing: defend uses the built-in engine; aidefence_* MCP tools unavailable')`. +- [ ] **Step 4:** run `node --test tests/kit/verify-command.test.mjs tests/kit/security-status.test.mjs tests/kit/about-*.test.mjs` and `node tests/statusline-segments.test.cjs`. +- [ ] **Step 5: Commit** `fix(security): stop reporting defend as non-functional when Ruflo ships the built-in engine`. Registry: `ruvnet/ruflo#2670` to `adopted`; `ruvnet/ruflo#3473` gets `kitImpact.files` = the verify and status files and an adjustment note "verify reads `-o json`; text-mode crash is reported as a crash". + +### Task 3.2: #3167 init suppression gated below 3.46.0 + +**Files:** Modify `src/commands/setup.mjs:490-512`; Test `tests/kit/setup-command.test.mjs` (or the test that already imports `RUFLO_PROJECT_INIT_ARGS`). + +**Interfaces:** `tests/kit/setup-command.test.mjs` imports `RUFLO_PROJECT_INIT_ARGS`/`_ENV`; update it in the same commit. Replace the two constants with `rufloProjectInitInvocation(rufloVersion: string): { args: string[], env: Record }`. On 3.46.0 and newer: `['init','--full','--force','--no-global','--no-codex-detect','--no-skills-sh']`, `{}`. Below: today's args with `--format json` and `{ RUFLO_NO_SKILLS_SH: '1' }`. Unknown version: the older, safer form. + +- [ ] **Step 1: Failing test** for the three cases. **Step 2:** FAIL. **Step 3:** implement; `rufloProjectInit` passes `installedVersion('ruflo')`. +- [ ] **Step 4: Removal proof** (the constraint's `sunsetWhen`): in a disposable home and empty folder, run `ruflo init --full --force --no-global --no-codex-detect` and, separately, `--no-skills-sh`, and both together, each through the public `ruflo` wrapper on 3.46.1; record that no `.codex/` projection and no skills.sh registration appear (compare `find . -newer marker` lists). Put the lists in the report. +- [ ] **Step 5: Commit** `feat(setup): drop the init opt-out suppression on Ruflo 3.46.0 and newer`. Registry in the same commit: `ruvnet/ruflo#3167` to `adopted` (history with the proof date), constraint `versionGate` to `apply to Ruflo below 3.46.0 (flags honoured from 3.46.0, PR #3434)`, `affected` widened to `["3.38.21", "3.39.x", "3.40.x", "3.41.x", "3.42.x", "3.43.x", "3.44.x", "3.45.x"]` in the form `affectedBy` accepts (check `src/lib/hook-audit/upstream.mjs` first), `lastVerifiedAt` bumped to today, `issueState` stays, `sunsetWhen` unchanged, `nextRetestAt` 14 days out per `recheckPolicy` (the date the floor reaches 3.46.0 cannot be known in advance). + +### Task 3.3: #3415 governance enforced on stdio from 3.46.0 + +**Files:** Modify `src/lib/ruflo-components/snapshot.mjs:47-52`, `catalogue.mjs:19-22`, `policy.mjs:1-5`; Tests `tests/kit/ruflo-components-snapshot.test.mjs`, `ruflo-components-catalogue.test.mjs`. + +- [ ] **Step 1: Failing tests** (through the snapshot builder the existing snapshot tests already call, since `governanceUnobservedReason` is module-private): with no audited calls, the `mcpGovernance` reason for Ruflo 3.45.0 includes the not-wired sentence (today it does not, because the boundary is 3.44.0), and for 3.46.0 it does not; the catalogue text for `mcpGovernance` says `Ruflo 3.46.0 and newer apply the policy on the stdio MCP launches; older versions do not`. +- [ ] **Step 2–4:** FAIL, change the boundary to `cmpVersions(rufloVersion, '3.46.0') < 0` and the texts, PASS. +- [ ] **Step 5: Live proof** in a disposable project on 3.46.1: write an ak policy with `maxToolCallsPerTurn: 2`, start `ruflo mcp start` with `RUFLO_MCP_ENFORCE_POLICY=1` and cwd = project root, send `initialize` and three `tools/call` for `memory_stats` over stdio (reuse `src/lib/mcp-tool-call.mjs`), and record that the third is refused and the audit log (`src/lib/ruflo-components/evidence.mjs` `AUDIT_LOG`, which is in `os.tmpdir()`: set `TMPDIR` to `$T/tmp`) records the calls. Then repeat from a subfolder with no `.harness/`: record that every call is refused. That is the Claude subfolder exposure; it stays open until Task 4.1 and is listed in the slice report. +- [ ] **Step 6: Commit** `fix(ruflo-components): governance is enforced on stdio from Ruflo 3.46.0`. Registry `ruvnet/ruflo#3415` to `adopted` in the same commit. + +### Task 3.4: Keep ak's policy file out of git + +**Files:** Modify `src/lib/ruflo-components/policy.mjs` (after a `written` result), `src/lib/ruflo-components/apply.mjs` (report it), `src/lib/trust-manifest.mjs:165-181` (disclose); Test `tests/kit/ruflo-components-convergence.test.mjs`. + +**Interfaces:** `excludeFromGit(root, relative = '.harness/mcp-policy.json'): 'added'|'present'|'no-git'` appends one line under a `# agentic-kit` comment to the file `git rev-parse --git-path info/exclude` names. Git reads `info/exclude` from the common dir, so for a linked worktree follow the `.git` file's `gitdir:` and then that folder's `commondir` (gitrepository-layout); `
/.git/worktrees//info/exclude` is never read. Resolve without spawning git (read the two files); removal on `reconcilePolicy` `removed` deletes the same line. + +- [ ] **Step 1: Failing tests:** in a temp repository (`git init`), and in a linked worktree of it (`git worktree add`, where the line must land in the main repository's `.git/info/exclude` and `git check-ignore .harness/mcp-policy.json` must succeed from the worktree), a `written` policy leaves `info/exclude` containing `.harness/mcp-policy.json` once after two reconciles; a `foreign` policy adds nothing; a non-repository returns `no-git`; `removed` deletes the line; `.gitignore` is never touched. +- [ ] **Step 2–4:** FAIL, implement, PASS. +- [ ] **Step 5: Commit** `feat(ruflo-components): keep ak's MCP policy file out of git`. + +### Task 3.5: #3166 and #3193 registry adoption + +- [ ] Registry only, one commit each with the evidence line in `history`: + - `ruvnet/ruflo#3166`: `mapping: "mapped"`, `kitImpact: { refs: ["no ak workaround; doctor row since 3.46.0 (PR #3441)"], files: [] }`, `adjustment: "none"`, `status: "retired"`. Note in the report: doctor's fix text `npm install -g agent-browser@latest` conflicts with ak's `>=0.27.0 <0.28.0` pin (investigation candidate). Commit `chore(upstream): retire ruvnet/ruflo#3166 (doctor reports agent-browser since 3.46.0)`. + - `ruvnet/ruflo#3193`: history `{"event":"released","note":"PR #3420 in 3.46.0: the daemon parses config.yaml; memory settings in config.yaml are still not read (memory-initializer.js getMemoryRoot), so ak keeps the memory pin"}`, status stays `watching`. Commit `chore(upstream): record the partial #3193 fix shipped in Ruflo 3.46.0`. +- [ ] Run `node --test tests/kit/upstream-watch-registry.test.mjs tests/kit/upstream-watch-script.test.mjs` before each commit. + +### Task 3.6: #2885 nightly note and the throwaway macOS job (B3-D3) + +- [ ] Edit `.github/workflows/nightly.yml:80-85`: "tracked upstream at ruvnet/ruflo#2885 (open; Aug 31 triage points at better-sqlite3 with onnxruntime-node on macOS arm64; a local single-thread test on Sep 26 was inconclusive)". Keep `continue-on-error`. Commit `docs(ci): correct the nightly note for ruvnet/ruflo#2885`. +- [ ] Write, but do not commit or push, the probe workflow to `/Users/cphillipson/Development/active/ai/agentic-kit/.superpowers/sdd/2026-09-26-remediation-program/reports/b3-ruflo-2885-probe.yml` (the implementer works only on this branch; the controller creates `probe/ruflo-2885`, adds the file as `.github/workflows/ruflo-2885-probe.yml` and pushes under B3-D3): `on: push: branches: [probe/ruflo-2885]`, `runs-on: macos-26-arm64` (confirm the label in GitHub's runner-images list before use), a matrix `mitigation: [off, on]` times `run: [1..10]`, each installing the released Ruflo, running `ruflo memory search` against a seeded disposable store, recording the exit code (134 = abort), with the single-thread ONNX setting from our Sep 26 comment when `mitigation: on`. The controller pushes it under B3-D3, collects the 20 results, deletes the remote branch, and drafts any #2885 comment into the report only. + +### Task 3.7: Slice 3 docs + +- [ ] Check `docs/ddd/` for governance and security wording. +- [ ] ADR-0058: `Updated: ` with "Ruflo 3.46.0 enforces the policy on stdio (#3415); ak's boundary corrected from 3.44.0 to below 3.46.0; ak excludes its policy file from git". Update the §table line 101 wording. +- [ ] `docs/TROUBLESHOOTING.md` security section; `docs/HOST-SUPPORT.md` and `docs/SETUP.md` init flags. +- [ ] Commit `docs(adr): record governance on stdio and the security wording (ADR-0058)`. + +--- + +## Slice 4: maintainer decisions B3-D1 and B3-D2 + +### Task 4.1: The launcher's Claude mode + +**Files:** Modify `src/lib/ruflo-memory.mjs:103-138`, `src/commands/x/ruflo-mcp.mjs`, `src/lib/ruflo-mcp-transport.mjs:6`; Tests `tests/kit/ruflo-memory.test.mjs`, `tests/kit/ruflo-memory-location.test.mjs`, `tests/kit/codex-mcp.test.mjs` (transport recognition). + +**Interfaces:** + +- `rufloMcpLaunch(cwd, env, { cfg, rufloVersion, home, host = 'codex' })`. With `host: 'claude'` it returns `env: { ...env, ...managedAgentBrowserEnv({ enabled: cfg.agentBrowser !== false }), ...memoryEnv }`: ak's own agent-browser config stays (Claude's registration carries it today and `agentBrowserMcpConfigured`, `mcp.mjs:120-124`, checks it), but no `componentEnv` and no governance deletion (ADR-0058 §3: Claude receives component keys from its settings env). `cwd` is `location.root` in both modes. +- `ak x ruflo-mcp --host claude` parses the flag (`options = { host: { type: 'string' } }`), accepts only `claude` or `codex`, and exits 2 on anything else. +- `isRufloMcpTransport` accepts `['x','ruflo-mcp']` and `['x','ruflo-mcp','--host','claude']`. + +- [ ] **Step 1: Failing tests:** (a) Claude mode keeps an inherited `RUFLO_INTELLIGENCE_MODE=fast` that `componentEnv` would override, still sets `AGENT_BROWSER_CONFIG` to ak's managed value, and sets `CLAUDE_FLOW_DB_PATH` to `/.swarm/memory.db` from a subfolder with `cwd` = repo root; (b) from `$HOME` it sets both memory variables to the user store; (c) Codex mode is unchanged; (d) transport recognition for the new args; (e) an unknown `--host` exits 2. +- [ ] **Step 2–4:** FAIL, implement, PASS. +- [ ] **Step 5: Subfolder governance regression** (closes the Task 3.3 exposure): with a fake `ruflo` on `PATH` in a sandbox (the existing `fakeGlobalRoot` helpers), assert `ak x ruflo-mcp --host claude` started from `/sub/dir` spawns with `cwd === ` so `/.harness/mcp-policy.json` is the file Ruflo reads. +- [ ] **Step 6: Commit** `feat(ruflo-mcp): a Claude mode that pins only the memory location`. + +### Task 4.2: Register Claude's Ruflo MCP through the launcher + +**Files:** Modify `src/lib/mcp.mjs:126-137,539-567` (desired entry `{ command: 'ak', args: ['x','ruflo-mcp','--host','claude'], env: managedAgentBrowserEnv(...) }`, keeping the `-e AGENT_BROWSER_CONFIG=…` registration so `agentBrowserMcpConfigured` stays true; `src/lib/execution/claude.mjs:19` gets the same `--host claude` args; ak's old `ruflo mcp start` user entry with only `AGENT_BROWSER_CONFIG` becomes `replaceable`; a user-written entry stays `preserved`), `src/lib/adapters/registries.mjs:258` (value), `src/commands/status/sections/mcp.mjs` (row names the store the launcher picks from the current folder, using `rufloMemoryLocation`), `src/commands/status/sections/project-memory.mjs:157-159` and `user-memory.mjs` (drop "Claude's own Ruflo registration is unchanged"; say "Claude Code's and Codex's Ruflo launcher"); Tests `tests/kit/mcp-scopes.test.mjs`, `tests/kit/adapter-registries.test.mjs`, `tests/kit/project-memory-status.test.mjs`. + +- [ ] **Step 1: Failing tests:** `register()` with a topology holding ak's old entry removes it and adds `claude mcp add claude-flow -s user -- ak x ruflo-mcp --host claude` (assert the runner calls); an already-desired entry makes no calls; a user entry with an extra env key is preserved and `ok: false`; a failed add restores the removed entries (existing pattern); the status row reads ``Claude Code's Ruflo MCP starts through `ak x ruflo-mcp`; from here it uses ``. +- [ ] **Step 2–4:** FAIL, implement, PASS. Also check `ak` resolves on `PATH`: when `which ak` fails, `register()` returns `{ ok: false, reason: 'ak-not-on-path' }` and status says so (Codex already depends on the same). +- [ ] **Step 5: Disposable proof, recorded not asserted in unit tests:** in a sandbox home, register with the real `claude mcp add` if Claude Code is installed, start `claude` headless in a subfolder of a disposable repository with `--mcp-config` pointing at the same entry (the pattern in `src/lib/execution/claude.mjs:19`), call `memory_store`, and record which `agentdb-memory.db` received the row and the MCP server's cwd (`ps -o command,cwd`). This settles the assumption that Claude Code starts user-scope stdio servers in the session folder; if it does not, report it before continuing. +- [ ] **Step 6: Commit** `feat(mcp): route Claude Code's Ruflo MCP through ak x ruflo-mcp`. + +### Task 4.3: Claude-side harvest and setup follow the same store rule + +**Files:** Modify `src/lib/harvest.mjs` (take `rufloMemoryLocation(cwd)` and use its `root`, `dir` and `db` directly; for `kind === 'user'` pin `CLAUDE_FLOW_MEMORY_PATH=dir` and `CLAUDE_FLOW_DB_PATH=db`, and pass `--db ` to distill; do not call `paths.projectMemoryDb(root)`, which would give `~/.claude-flow/memory/.swarm/memory.db` while the user store is flat, `ruflo-memory.mjs:86`), `src/lib/ruflo-memory.mjs:88-100` (leave `memoryProjectRoot` unchanged: `daemons.mjs:20` and `projectMemoryEnv` rely on it; update its comment to say harvest no longer uses it), `src/commands/setup.mjs` (project setup from an unsuitable folder refuses with `this folder is ; run ak setup from a project folder`); Tests `tests/kit/ruflo-memory.test.mjs`, `tests/kit/harvest*.test.mjs` (the file that covers `planHarvest`), `tests/kit/setup-command.test.mjs`. + +- [ ] **Step 1: Failing tests:** `runHarvest` from `$HOME` with a spy runner uses `cwd` = `paths.userMemoryDir(home)`, env `CLAUDE_FLOW_DB_PATH` = `/memory.db` (flat, no `.swarm`) and `CLAUDE_FLOW_MEMORY_PATH` = ``; from a repository subfolder it still uses `/.swarm/memory.db`; the daemons row for `$HOME` is unchanged; project setup from `$HOME` refuses without spawning anything. +- [ ] **Step 2–4:** FAIL, implement, PASS; run `node --test tests/kit/ruflo-memory*.test.mjs tests/kit/setup-*.test.mjs`. +- [ ] **Step 5: Commit** `fix(memory): Claude-side harvest and setup use the user-level store outside projects`. + +### Task 4.4: One-time cleanup of old setup probe rows (B3-D2) + +**Files:** Create `src/lib/memory-probe-cleanup.mjs`; Modify `src/commands/sync.mjs` (a `memory-probe-cleanup` step, planned when a `memory` row with `repair: 'sync'` names probe rows), `src/commands/status/sections/project-memory.mjs` and `user-memory.mjs` (row), `src/lib/project-memory.mjs` (reuse `withDb`, `lookupEntry`); Test `tests/kit/memory-probe-cleanup.test.mjs` (new). + +**Interfaces:** + +- `PROBE = { namespace: '_setup', key: /^_setup\/verify-\d+-\d+$/, content: 'setup-verify' }`. +- `findProbeRows(dir): Array<{ file, rows: Array<{ id, key, status }> }>` over `memory.db` and `agentdb-memory.db` in `dir`, read-only, any `status`. +- `cleanupProbeRows(dirs, { dryRun, backupRoot, now }): { receipt: { at, stores: Array<{ file, backup, deleted: string[] }> } }`: for each store with rows, first `VACUUM INTO '//-'`, then `DELETE FROM memory_entries WHERE id IN (...)` for exactly the matched ids, then `PRAGMA wal_checkpoint(TRUNCATE)`; receipt written to `/agentic-kit/memory-probe-cleanup/.json`; `kit.json` `cleanups.setupProbeRows[] = ` so a store is cleaned at most once. +- Scope: the current project's canonical `.swarm` (from `rufloMemoryLocation`) and the user-level store. Other projects are named in the report only. + +- [ ] **Step 1: Failing tests** (build both stores with the schema columns seen on 3.46.1: `id key namespace content type … status`): + - Rows `_setup/verify-12-1700000000000` / `setup-verify` in both files, plus decoys `_setup/verify-x` (wrong key), `_setup/verify-1-2` with content `other`, and a user row in namespace `_setup` with key `_setup/verify-3-4` but namespace `mine`: only the two exact rows are deleted. + - `dryRun` deletes nothing and returns the same plan. + - The backup file exists and opens with the rows still in it; the receipt lists them. + - A second run finds nothing and writes no second receipt; the status row disappears. + - A store Ruflo holds open with WAL: the delete still succeeds (use a second connection kept open in the test). + - After the delete, `PRAGMA foreign_key_check` and `PRAGMA quick_check` are clean on a fixture that also has rows in the AgentDB tables (the 3.46.1 disposable store gained rows only in `memory_entries`, but a populated store may link other tables). +- [ ] **Step 2–4:** FAIL, implement, PASS. Status row: `info` when none, `warn` `N old ak setup probe rows in (and its AgentDB mirror, ruvnet/ruflo#3450)` with fix `sync backs up the store and removes exactly those rows` and `repair: 'sync'`. +- [ ] **Step 5: Disposable proof on 3.46.1:** create the rows with Ruflo's own `memory store` as in the verification table, run `node bin/agentic-kit.mjs sync --dry-run` then `sync` in the sandbox, and record before and after counts in both files. +- [ ] **Step 6: Real-data preview (read-only):** `node bin/agentic-kit.mjs sync --dry-run` from this repository. Today's read found 0 rows in `.swarm/memory.db` and `.swarm/agentdb-memory.db`; report the preview's count for the user-level store too. Do not run the real cleanup; that is the controller's real-data pass. +- [ ] **Step 7: Commit** `feat(sync): remove ak's old setup probe rows once, with a backup and receipt`. + +### Task 4.5: Decisions B3-D1 to B3-D4 in the audit record, and slice docs + +- [ ] Append to the audit record a section `### Branch 3 decisions (2026-09-27)` with four entries in the record's decision format (**The situation.** / **The problem.** / **What the user sees.** / **What should be the case.** / **The choices.** / **Recommendation … Choice: …**), taken from the ledger lines for B3-D1, B3-D2, B3-D3 and B3-D4 (D4 recorded as superseded: the machine already runs 3.46.1; no upgrade step). Add the implementation commits under each. +- [ ] Check `docs/ddd/` for the MCP registration and memory-location wording. +- [ ] ADR-0058 §3 `Updated`: Claude Code now reaches Ruflo through `ak x ruflo-mcp --host claude`, which sets only the memory location; component keys still come from Claude's settings env. ADR-0016 one-line note on the registration change. +- [ ] `docs/HOST-SUPPORT.md`, `docs/SETUP.md`, `docs/UPGRADING.md` (the registration migrates on the next `ak sync`), `docs/TROUBLESHOOTING.md` (probe-row cleanup), `ak x ruflo-mcp --help`. +- [ ] Commit `docs(audit): record Branch 3 decisions and the Claude launcher route`. + +--- + +## Gate (after every slice, run by the gate agent) + +Run the full gate set from the common brief (`briefs/common.md` "Gate set"), including the fingerprint before and after, `npm pack --dry-run | grep -E 'ruflo-support-window|ruflo-daemon-config|memory-probe-cleanup'` once those files exist, and `node --test tests/kit/doc-citations.test.mjs tests/kit/ga-surface-guard.test.mjs`. Record pass/fail counts, coverage and the fingerprint diff verbatim in the slice report. + +## Self-review notes + +- Every scope item has a task: 1 → 1.1–1.3, 1.6; 2 → 2.1; 3 → 2.2–2.4; 4 → 3.1; 5 → 1.4; 6 → 1.5; 7 → 3.2, 3.3, 3.5; 8 → 3.4, 3.6; D1 → 4.1–4.3; D2 → 4.4; decisions → 4.5. +- Names used across tasks: `computeSupportWindow`, `rememberedSupportWindow`, `recordRufloReleaseDates` (1.2, 1.3); `desiredDaemonKeys`, `reconcileRufloDaemon` (2.2, 2.1's fix text); `rufloMcpLaunch(..., { host })` (4.1, 4.2); `findProbeRows`, `cleanupProbeRows` (4.4). +- Review Focus lines map to tests in 2.2 (two lines), 3.3/4.1, 1.2 and 3.1. From b976c044b56bfc4a5d17c2ae333a7e9a092d8f53 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:45:07 -0700 Subject: [PATCH 02/45] feat(upstream): record the Ruflo support window on its dependency policy The Ruflo dependency policy now carries supportWindow (newest 6 minors, never fewer than those first published in the last 30 days). The loader validates the optional field: positive integer newestMinors and minDays and a string basis; anything else invalidates the policy. --- .../hook-audit/agentic-dependency-constraints.json | 8 +++++++- src/lib/hook-audit/upstream.mjs | 13 ++++++++++++- tests/kit/upstream-watch-registry.test.mjs | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 7cbf057e..fb7ae2ac 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -33,7 +33,13 @@ "evidenceRequired": ["installed-version", "affected-range", "minimal-reproduction", "expected-versus-observed", "artifact-digest"], "workaroundPolicy": "bounded, receipt-owned, version-gated, and never applied to generated runtime caches", "retestPolicy": "weekly, on observed release, and before managed upgrade", - "removalProof": "released artifact passes the host-neutral hook and lifecycle conformance suite" + "removalProof": "released artifact passes the host-neutral hook and lifecycle conformance suite", + "supportWindow": { + "newestMinors": 6, + "minDays": 30, + "basis": "first npm publish of each minor", + "unsupported": "ak status reports the installed Ruflo as unsupported and points to ak sync" + } }, { "dependency": "agentic-qe", diff --git a/src/lib/hook-audit/upstream.mjs b/src/lib/hook-audit/upstream.mjs index d7b43a84..4b714aac 100644 --- a/src/lib/hook-audit/upstream.mjs +++ b/src/lib/hook-audit/upstream.mjs @@ -27,6 +27,16 @@ function validDate(value) { && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value; } +// Optional rolling support window on a dependency policy (ADR-0041 §7): +// the newest N minors, never fewer than those first published within minDays. +function validSupportWindow(window) { + if (window === undefined) return true; + const positive = (value) => Number.isInteger(value) && value > 0; + return Boolean(window) && typeof window === 'object' + && positive(window.newestMinors) && positive(window.minDays) + && typeof window.basis === 'string'; +} + function affectedBy(version, ranges) { if (typeof version !== 'string' || !version || version === 'unknown') return null; return ranges.some((range) => range === version @@ -75,7 +85,8 @@ function loadRegistry({ && Array.isArray(entry.evidenceRequired) && typeof entry.workaroundPolicy === 'string' && typeof entry.retestPolicy === 'string' - && typeof entry.removalProof === 'string'; + && typeof entry.removalProof === 'string' + && validSupportWindow(entry.supportWindow); if (valid && policyNames.has(entry.dependency)) errors.push(`dependency policy ${index} duplicates ${entry.dependency}`); if (valid) policyNames.add(entry.dependency); if (!valid) errors.push(`dependency policy ${index} is invalid`); diff --git a/tests/kit/upstream-watch-registry.test.mjs b/tests/kit/upstream-watch-registry.test.mjs index 70f98a46..61ebcabe 100644 --- a/tests/kit/upstream-watch-registry.test.mjs +++ b/tests/kit/upstream-watch-registry.test.mjs @@ -307,3 +307,17 @@ test('the audit record carries the Branch 4 decisions in decision format', () => assert.ok(section.split(part).length - 1 >= 5, part); } }); + +// ADR-0041 §7: the rolling Ruflo support window lives on the Ruflo dependency policy. +test('the Ruflo dependency policy carries the rolling support window', () => { + const ruflo = document().dependencyPolicies.find((policy) => policy.dependency === 'ruflo'); + assert.deepEqual({ n: ruflo.supportWindow?.newestMinors, d: ruflo.supportWindow?.minDays }, { n: 6, d: 30 }); + assert.equal(typeof ruflo.supportWindow.basis, 'string'); +}); + +test('an invalid support window invalidates the registry', () => { + const errors = errorsOf((doc) => { + doc.dependencyPolicies.find((policy) => policy.dependency === 'ruflo').supportWindow = { newestMinors: 0, minDays: 'x' }; + }); + assert.match(errors, /dependency policy 0 is invalid/); +}); From 70790c39831893a36d102dbde3c3d5d7bbdd5642 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:50:35 -0700 Subject: [PATCH 03/45] feat(versions): support a rolling window of Ruflo minors (n-5, at least 30 days) ak status adds a Ruflo support-window row computed from release dates remembered in kit.json (versionCheck.rufloMinors): inside the window is info, below it is a fail row that sync repairs by upgrading, and with no remembered dates the row says the window is not yet known and names ak sync. A plain read never calls the network. A non-dry, upgrading sync records each minor's first stable npm publish right after its forced drift lookup; a failed lookup keeps the old dates. The versions section gains drift/loadConfig/now seams and sync.run a releaseDatesRunner seam. --- src/commands/status/sections/versions.mjs | 24 ++++- src/commands/sync.mjs | 13 ++- src/lib/ruflo-support-window.mjs | 110 ++++++++++++++++++++++ tests/kit/drift-freshness.test.mjs | 25 +++++ tests/kit/fixtures/status-golden.json | 7 ++ tests/kit/ruflo-support-window.test.mjs | 93 ++++++++++++++++++ tests/kit/versions.test.mjs | 77 +++++++++++++++ 7 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 src/lib/ruflo-support-window.mjs create mode 100644 tests/kit/ruflo-support-window.test.mjs diff --git a/src/commands/status/sections/versions.mjs b/src/commands/status/sections/versions.mjs index 07f22585..9d9e3789 100644 --- a/src/commands/status/sections/versions.mjs +++ b/src/commands/status/sections/versions.mjs @@ -1,12 +1,29 @@ -import { driftReport, releaseObservationLabel } from '../../../lib/versions.mjs'; +import { loadKitConfig } from '../../../lib/config.mjs'; +import { rememberedSupportWindow, supportWindowPolicy } from '../../../lib/ruflo-support-window.mjs'; +import { cmpVersions, driftReport, releaseObservationLabel } from '../../../lib/versions.mjs'; import { row } from '../row.mjs'; +/** The Ruflo support-window row (ADR-0041 §7). Reads remembered release + * dates only: an unknown window names `ak sync` and is never "unsupported". */ +function supportWindowRow(installed, cfg, now) { + const window = rememberedSupportWindow(cfg, { now, policy: supportWindowPolicy() }); + if (!window) { + return row('versions', 'info', "Ruflo support window not yet known: run ak sync to record Ruflo's release dates"); + } + const range = `${window.floor} and newer; release dates observed ${new Date(window.observedAt).toISOString()}`; + if (cmpVersions(installed, window.floor) < 0) { + return row('versions', 'fail', `Ruflo ${installed} is unsupported: below the support window (${range})`, + 'sync upgrades Ruflo into the support window'); + } + return row('versions', 'info', `Ruflo ${installed} is inside the support window (${range})`); +} + export default { id: 'versions', - async collect() { + async collect({ drift = driftReport, loadConfig = loadKitConfig, now = Date.now } = {}) { const rows = []; try { - for (const r of await driftReport()) { + for (const r of await drift()) { if (!r.installed) { rows.push(row('versions', r.pkg === 'ruflo' ? 'fail' : 'warn', `${r.pkg} not installed globally`, 'setup installs it')); @@ -17,6 +34,7 @@ export default { rows.push(row('versions', r.latest ? 'ok' : 'info', `${r.pkg} ${r.installed}${r.latest ? ` (latest known; ${releaseObservationLabel(r)})` : ' (release metadata unavailable)'}`)); } + if (r.pkg === 'ruflo' && r.installed) rows.push(supportWindowRow(r.installed, loadConfig(), now())); } } catch (e) { rows.push(row('versions', 'warn', `version check unavailable: ${e.message}`)); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 6cf93f59..5b2bf8d6 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -26,6 +26,7 @@ import { reconcileRufloComponents } from '../lib/ruflo-components/apply.mjs'; import { RESTART_REMINDER } from './status/sections/ruflo-components.mjs'; import { HOSTS, commandHosts, hostInstallState, hostExecutable, installHost, convergeProviderStack, guidanceContext, reportRetiredRouteChanges } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; +import { recordRufloReleaseDates } from '../lib/ruflo-support-window.mjs'; import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs'; import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs'; import { pruneNpxStale } from '../lib/npx.mjs'; @@ -102,9 +103,16 @@ export function recordApplyFailure(state, name, result) { /** Refresh every network-backed fact that can open an upgrade gate. Kept out * of run() so adding one release boundary does not grow the command's already * broad orchestration complexity. Sequential: these probes persist kit.json. */ -async function refreshPlanDrift(flags, fetchLatest, pkgRoot) { +async function refreshPlanDrift(flags, fetchLatest, pkgRoot, releaseDatesRunner) { if (flags['dry-run'] || flags['no-upgrade']) return; await driftReport({ force: true, ...(fetchLatest ? { fetchLatest } : {}) }); + // ADR-0041 §7: remember each Ruflo minor's first publish so `ak status` can + // compute the support window without a network call. A failed lookup keeps + // the old dates. + const cfg = loadKitConfig(); + if (await recordRufloReleaseDates({ cfg, ...(releaseDatesRunner ? { runner: releaseDatesRunner } : {}) })) { + try { saveKitConfig(cfg); } catch { /* read-only envs: the next sync records them */ } + } // Self-update has its own TTL cache; refresh it before the collector decides // whether a self action exists. An apply-time refresh cannot open that gate. await selfDrift({ pkgRoot, force: true, ...(fetchLatest ? { fetchLatest } : {}) }); @@ -941,6 +949,7 @@ async function converge({ flags, pkgRoot, fetchLatest, + releaseDatesRunner, dejaVuAdapter = companionLifecycleFor('deja-vu'), collectFn = collect, confirmCodexRepair = askCodexRepair, @@ -961,7 +970,7 @@ async function converge({ // versions gate it needed to open). Dry-runs skip the refresh: it writes // kit.json, and --dry-run is pinned to touch nothing — so a dry-run // preview may be cache-stale by up to one TTL window. - await refreshPlanDrift(flags, fetchLatest, pkgRoot); + await refreshPlanDrift(flags, fetchLatest, pkgRoot, releaseDatesRunner); const rows = await collectFn({ pkgRoot, cwd, dejaVuAdapter, dejaVuPlanOptions }); result.needsYourAction = needsYourAction(rows); // Only fixes a sync step performs enter the plan (status/row.mjs repair diff --git a/src/lib/ruflo-support-window.mjs b/src/lib/ruflo-support-window.mjs new file mode 100644 index 00000000..72962b97 --- /dev/null +++ b/src/lib/ruflo-support-window.mjs @@ -0,0 +1,110 @@ +// The rolling Ruflo support window (ADR-0041 §7): the newest `newestMinors` +// minors, never fewer than the minors first published within `minDays`. The +// rule lives on the Ruflo dependency policy in the constraint registry +// (`supportWindow`); the evidence is each minor's first stable npm publish, +// remembered in kit.json as `versionCheck.rufloMinors` and written only by +// `ak sync` (recordRufloReleaseDates). A plain read (`ak status`) computes the +// window from that memory and never calls the network. +// +// Stale memory never over-reports "unsupported": minors published after the +// observation are newer than every remembered one, so a window computed from +// old dates can only sit at or below the true floor. +import { run } from './exec.mjs'; +import { loadUpstreamConstraints } from './hook-audit/upstream.mjs'; + +const DEFAULT_POLICY = Object.freeze({ newestMinors: 6, minDays: 30 }); +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; + +const minorKey = (major, minor) => `${Number(major)}.${Number(minor)}`; +const minorParts = (key) => key.split('.').map(Number); +const newerMinorFirst = (a, b) => { + const [aMajor, aMinor] = minorParts(a); + const [bMajor, bMinor] = minorParts(b); + return bMajor - aMajor || bMinor - aMinor; +}; + +/** + * Map each minor ("3.46") to the earliest publish time of any stable patch of + * it. Prereleases and npm's `created`/`modified` keys are ignored. + * @param {Record | null | undefined} timeJson `npm view time --json` + * @returns {Record} + */ +export function minorFirstPublished(timeJson) { + const out = {}; + if (!timeJson || typeof timeJson !== 'object') return out; + for (const [version, iso] of Object.entries(timeJson)) { + const match = STABLE.exec(version); + if (!match || typeof iso !== 'string' || !Number.isFinite(Date.parse(iso))) continue; + const key = minorKey(match[1], match[2]); + if (!out[key] || Date.parse(iso) < Date.parse(out[key])) out[key] = iso; + } + return out; +} + +/** + * The window's floor is the older of (a) the `newestMinors`-th newest minor + * and (b) the oldest minor first published within `minDays` of `now`. + * @param {{ firstPublished: Record | null | undefined, now: number, newestMinors: number, minDays: number }} input + * @returns {{ floor: string, minors: string[] } | null} + */ +export function computeSupportWindow({ firstPublished, now, newestMinors, minDays }) { + const known = Object.keys(firstPublished ?? {}) + .filter((key) => /^\d+\.\d+$/.test(key) && Number.isFinite(Date.parse(firstPublished[key]))) + .sort(newerMinorFirst); + if (!known.length) return null; + let oldestIndex = Math.min(newestMinors, known.length) - 1; + const since = now - minDays * 86_400_000; + known.forEach((key, index) => { + if (Date.parse(firstPublished[key]) >= since && index > oldestIndex) oldestIndex = index; + }); + const minors = known.slice(0, oldestIndex + 1); + return { floor: `${minors[minors.length - 1]}.0`, minors }; +} + +/** + * The window from the release dates `ak sync` remembered. No I/O. + * @param {any} cfg kit.json + * @param {{ now: number, policy: { newestMinors: number, minDays: number } }} options + * @returns {{ floor: string, minors: string[], observedAt: number } | null} + */ +export function rememberedSupportWindow(cfg, { now, policy }) { + const remembered = cfg?.versionCheck?.rufloMinors; + if (!remembered || !Number.isFinite(remembered.observedAt)) return null; + const window = computeSupportWindow({ firstPublished: remembered.firstPublished, now, ...policy }); + return window && { ...window, observedAt: remembered.observedAt }; +} + +/** + * Record each Ruflo minor's first publish in `cfg.versionCheck.rufloMinors`. + * Only `ak sync` calls this (one `npm view`, 20 s). On any failure the old + * value stays and the result is false; the caller saves kit.json. + * @param {{ cfg: any, runner?: typeof run, now?: () => number }} options + * @returns {Promise} + */ +export async function recordRufloReleaseDates({ cfg, runner = run, now = Date.now }) { + let firstPublished; + try { + const result = await runner('npm', ['view', 'ruflo', 'time', '--json'], { timeout: 20_000 }); + if (result.code !== 0) return false; + firstPublished = minorFirstPublished(JSON.parse(result.stdout)); + } catch { + return false; + } + if (!Object.keys(firstPublished).length) return false; + cfg.versionCheck = { ...cfg.versionCheck, rufloMinors: { observedAt: now(), firstPublished } }; + return true; +} + +/** + * The window rule from the registry's Ruflo dependency policy; the documented + * defaults when the registry cannot be read. + * @returns {{ newestMinors: number, minDays: number }} + */ +export function supportWindowPolicy() { + try { + const window = loadUpstreamConstraints().dependencyPolicies + .find((policy) => policy.dependency === 'ruflo')?.supportWindow; + if (window) return { newestMinors: window.newestMinors, minDays: window.minDays }; + } catch { /* unreadable registry: fall through to the defaults */ } + return { ...DEFAULT_POLICY }; +} diff --git a/tests/kit/drift-freshness.test.mjs b/tests/kit/drift-freshness.test.mjs index 4fb86807..5973fb7e 100644 --- a/tests/kit/drift-freshness.test.mjs +++ b/tests/kit/drift-freshness.test.mjs @@ -109,3 +109,28 @@ test('sync (non-dry) force-refreshes drift BEFORE building the plan, so a fresh- assert.match(out, /\[versions\].*ruflo 9\.9\.9 installed, 9\.9\.12 available/, 'plan must be built from a forced refresh, not the stale-fresh cache'); }); + +// ADR-0041 §7: the forced lookup is where Ruflo's release dates are +// remembered; a dry run touches nothing, and `ak status` never looks them up. +test('a non-dry sync remembers Ruflo release dates for the support window; a dry run does not', async () => { + const time = { created: '2020-01-01T00:00:00Z', '3.45.0': '2026-09-24T22:54:53Z', '3.46.0': '2026-09-26T22:34:55Z' }; + const calls = []; + const releaseDatesRunner = async (command, args) => { + calls.push([command, ...args].join(' ')); + return { code: 0, stdout: JSON.stringify(time), stderr: '' }; + }; + const syncWith = (flags) => inSandboxProject(() => captureLog(() => sync.run({ + flags, pkgRoot: PKG_ROOT, fetchLatest: async () => null, releaseDatesRunner, collectFn: async () => [], + }))); + + seedHome({ last: 1, seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' } }); + await syncWith(FLAGS({ 'dry-run': true })); + assert.deepEqual(calls, []); + assert.equal(loadKitConfig().versionCheck.rufloMinors, undefined); + + await syncWith(FLAGS()); + assert.deepEqual(calls, ['npm view ruflo time --json']); + const remembered = loadKitConfig().versionCheck.rufloMinors; + assert.deepEqual(remembered.firstPublished, { '3.45': '2026-09-24T22:54:53Z', '3.46': '2026-09-26T22:34:55Z' }); + assert.ok(remembered.observedAt > 0); +}); diff --git a/tests/kit/fixtures/status-golden.json b/tests/kit/fixtures/status-golden.json index f39b5013..28c8c453 100644 --- a/tests/kit/fixtures/status-golden.json +++ b/tests/kit/fixtures/status-golden.json @@ -13,6 +13,13 @@ "fix": null, "repair": null }, + { + "subsystem": "versions", + "level": "info", + "message": "Ruflo support window not yet known: run ak sync to record Ruflo's release dates", + "fix": null, + "repair": null + }, { "subsystem": "versions", "level": "ok", diff --git a/tests/kit/ruflo-support-window.test.mjs b/tests/kit/ruflo-support-window.test.mjs new file mode 100644 index 00000000..584ea693 --- /dev/null +++ b/tests/kit/ruflo-support-window.test.mjs @@ -0,0 +1,93 @@ +// The rolling Ruflo support window (ADR-0041 §7): the newest six minors, +// never fewer than the minors first published in the last 30 days. Computed +// from release dates `ak sync` remembers in kit.json; a plain read never +// calls the network. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + computeSupportWindow, minorFirstPublished, recordRufloReleaseDates, rememberedSupportWindow, supportWindowPolicy, +} from '../../src/lib/ruflo-support-window.mjs'; + +const TIME = { + created: '2020-01-01T00:00:00Z', modified: '2026-09-27T00:00:00Z', + '3.38.0': '2026-08-11T22:43:21Z', '3.38.2': '2026-08-13T00:00:00Z', '3.39.0': '2026-09-08T16:52:33Z', + '3.40.0': '2026-09-09T23:10:35Z', '3.41.0': '2026-09-10T11:59:59Z', '3.42.0': '2026-09-15T00:50:39Z', + '3.43.0': '2026-09-23T15:05:58Z', '3.44.0': '2026-09-23T18:47:33Z', '3.45.0': '2026-09-24T22:54:53Z', + '3.46.0-alpha.1': '2026-09-20T00:00:00Z', '3.46.0': '2026-09-26T22:34:55Z', '3.46.1': '2026-09-27T01:00:00Z', +}; +const NOW = Date.parse('2026-09-27T12:00:00Z'); +const POLICY = { newestMinors: 6, minDays: 30 }; + +test('first publish per minor ignores prereleases and metadata keys', () => { + const minors = minorFirstPublished(TIME); + assert.equal(minors['3.46'], '2026-09-26T22:34:55Z', 'the 3.46.0-alpha.1 prerelease does not open 3.46'); + assert.equal(minors['3.38'], '2026-08-11T22:43:21Z', 'the earliest patch of a minor wins'); + assert.equal(minors.created, undefined); + assert.equal(minors.modified, undefined); + assert.deepEqual(minorFirstPublished(null), {}); +}); + +test('30-day rule widens n-5: today the floor is 3.39.0, not 3.41.0', () => { + const window = computeSupportWindow({ firstPublished: minorFirstPublished(TIME), now: NOW, ...POLICY }); + assert.equal(window.floor, '3.39.0'); + assert.deepEqual(window.minors, ['3.46', '3.45', '3.44', '3.43', '3.42', '3.41', '3.40', '3.39']); +}); + +test('after a quiet month n-5 alone decides', () => { + const window = computeSupportWindow({ + firstPublished: minorFirstPublished(TIME), now: Date.parse('2026-12-01T00:00:00Z'), ...POLICY, + }); + assert.equal(window.floor, '3.41.0'); +}); + +test('fewer minors than the window keeps every known minor', () => { + const window = computeSupportWindow({ + firstPublished: { '3.45': '2026-09-24T22:54:53Z', '3.46': '2026-09-26T22:34:55Z' }, now: NOW, ...POLICY, + }); + assert.equal(window.floor, '3.45.0'); +}); + +test('no release dates give null, never a guess', () => { + assert.equal(computeSupportWindow({ firstPublished: {}, now: NOW, ...POLICY }), null); + assert.equal(rememberedSupportWindow({}, { now: NOW, policy: POLICY }), null); + assert.equal(rememberedSupportWindow({ versionCheck: { rufloMinors: { observedAt: 1 } } }, { now: NOW, policy: POLICY }), null); +}); + +test('the remembered window carries when the dates were observed', () => { + const cfg = { versionCheck: { rufloMinors: { observedAt: NOW - 3_600_000, firstPublished: minorFirstPublished(TIME) } } }; + const window = rememberedSupportWindow(cfg, { now: NOW, policy: POLICY }); + assert.equal(window.floor, '3.39.0'); + assert.equal(window.observedAt, NOW - 3_600_000); +}); + +test('recording release dates runs one npm view and keeps the old value on failure', async () => { + const calls = []; + const cfg = { versionCheck: { seen: { ruflo: '3.46.1' } } }; + const ok = await recordRufloReleaseDates({ + cfg, now: () => NOW, + runner: async (command, args, options) => { + calls.push({ command, args, options }); + return { code: 0, stdout: JSON.stringify(TIME), stderr: '' }; + }, + }); + assert.equal(ok, true); + assert.deepEqual(calls, [{ command: 'npm', args: ['view', 'ruflo', 'time', '--json'], options: { timeout: 20_000 } }]); + assert.equal(cfg.versionCheck.rufloMinors.observedAt, NOW); + assert.equal(cfg.versionCheck.rufloMinors.firstPublished['3.39'], '2026-09-08T16:52:33Z'); + assert.deepEqual(cfg.versionCheck.seen, { ruflo: '3.46.1' }, 'other version-check facts are kept'); + + const before = structuredClone(cfg.versionCheck.rufloMinors); + for (const result of [ + { code: 1, stdout: '', stderr: 'E404' }, + { code: 0, stdout: 'not json', stderr: '' }, + { code: 0, stdout: '{"created":"2020-01-01T00:00:00Z"}', stderr: '' }, + ]) { + assert.equal(await recordRufloReleaseDates({ cfg, now: () => NOW + 1, runner: async () => result }), false); + assert.deepEqual(cfg.versionCheck.rufloMinors, before); + } +}); + +test('the policy comes from the registry', () => { + assert.deepEqual(supportWindowPolicy(), POLICY); +}); diff --git a/tests/kit/versions.test.mjs b/tests/kit/versions.test.mjs index 514197e9..d558ea26 100644 --- a/tests/kit/versions.test.mjs +++ b/tests/kit/versions.test.mjs @@ -83,3 +83,80 @@ test('latestVersion uses literal npm argv, honors its deadline, and rejects unsa options: { timeout: 5_000 }, }]); }); + +// ── The Ruflo support-window row (ADR-0041 §7) ───────────────────────────── +// The row reads remembered release dates only; the drift report is stubbed so +// the only process a plain read could start would be the window's own lookup. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import versionsSection from '../../src/commands/status/sections/versions.mjs'; + +const WINDOW_NOW = Date.parse('2026-09-27T12:00:00Z'); +const OBSERVED = Date.parse('2026-09-27T09:00:00Z'); +const REMEMBERED = { + versionCheck: { + rufloMinors: { + observedAt: OBSERVED, + firstPublished: { + 3.38: '2026-08-11T22:43:21Z', 3.39: '2026-09-08T16:52:33Z', '3.40': '2026-09-09T23:10:35Z', + 3.41: '2026-09-10T11:59:59Z', 3.42: '2026-09-15T00:50:39Z', 3.43: '2026-09-23T15:05:58Z', + 3.44: '2026-09-23T18:47:33Z', 3.45: '2026-09-24T22:54:53Z', 3.46: '2026-09-26T22:34:55Z', + }, + }, + }, +}; + +/** Collect the versions section with a fake `npm` first on PATH that records any call. */ +async function windowRows(installed, cfg) { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-window-bin-')); + const marker = path.join(bin, 'npm-called'); + fs.writeFileSync(path.join(bin, 'npm'), `#!/bin/sh\necho "$@" >> "${marker}"\nexit 1\n`, { mode: 0o755 }); + const oldPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${oldPath}`; + try { + const rows = await versionsSection.collect({ + drift: async () => [{ pkg: 'ruflo', installed, latest: '3.46.1', latestSource: 'cache', latestObservedAt: OBSERVED, outdated: installed !== '3.46.1' }], + loadConfig: () => cfg, + now: () => WINDOW_NOW, + }); + return { rows, npmCalled: fs.existsSync(marker) }; + } finally { + process.env.PATH = oldPath; + fs.rmSync(bin, { recursive: true, force: true }); + } +} +const windowRow = (rows) => rows.find((r) => r.subsystem === 'versions' && /support window/.test(r.message)); + +test('a Ruflo inside the remembered window is reported as supported', async () => { + const { rows } = await windowRows('3.46.1', REMEMBERED); + const r = windowRow(rows); + assert.equal(r.level, 'info'); + assert.equal(r.message, 'Ruflo 3.46.1 is inside the support window (3.39.0 and newer; release dates observed 2026-09-27T09:00:00.000Z)'); + assert.equal(r.fix, null); +}); + +test('a Ruflo below the window is unsupported and sync repairs it', async () => { + const { rows } = await windowRows('3.38.2', REMEMBERED); + const r = windowRow(rows); + assert.equal(r.level, 'fail'); + assert.match(r.message, /unsupported/); + assert.match(r.message, /below the support window \(3\.39\.0 and newer/); + assert.equal(r.fix, 'sync upgrades Ruflo into the support window'); + assert.equal(r.repair, 'sync'); +}); + +test('with no remembered release dates the window is unknown and nothing calls npm', async () => { + const { rows, npmCalled } = await windowRows('3.46.1', {}); + const r = windowRow(rows); + assert.equal(r.level, 'info'); + assert.equal(r.message, "Ruflo support window not yet known: run ak sync to record Ruflo's release dates"); + assert.equal(r.fix, null, 'an info pointer, never a sync step that would stop daemons'); + assert.equal(npmCalled, false); + assert.doesNotMatch(r.message, /unsupported/); +}); + +test('no window row when Ruflo is not installed', async () => { + const { rows } = await windowRows(null, REMEMBERED); + assert.equal(windowRow(rows), undefined); +}); From afc42eb26d579e232b02bd61a4965ccd230a66b2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:53:21 -0700 Subject: [PATCH 04/45] feat(upstream): hold workaround removals until the oldest supported Ruflo has the fix The watch computes the Ruflo support-window floor from the npm release dates it reads (reusing a gate's fetch) and the registry's supportWindow. A released Ruflo entry whose fix version is above the floor moves from the act-now groups to 'Released, waiting for the support window' with no dispatch; its 'released' ledger line carries no branch. With no floor (offline, npm unreadable, no window) nothing is held. The upstream-status skill names the new group. --- .agents/skills/upstream-status/SKILL.md | 4 +- .claude/skills/upstream-status/SKILL.md | 4 +- scripts/upstream-watch.mjs | 32 ++++++++++- scripts/upstream-watch/classify.mjs | 48 ++++++++++++---- scripts/upstream-watch/render.mjs | 5 ++ tests/kit/upstream-watch-script.test.mjs | 73 ++++++++++++++++++++++-- 6 files changed, 147 insertions(+), 19 deletions(-) diff --git a/.agents/skills/upstream-status/SKILL.md b/.agents/skills/upstream-status/SKILL.md index 6370f491..46d3245a 100644 --- a/.agents/skills/upstream-status/SKILL.md +++ b/.agents/skills/upstream-status/SKILL.md @@ -41,7 +41,9 @@ dependency policies, constraints, and the `watch` list of upstream threads. For each item give the id, title, URL and a one-line reason: who replied and when, which release, or which ak change is pending. Give "Fixed upstream, not yet released", - "Waiting on upstream" and "Unmapped (no ak change recorded)" as counts only, unless asked. + "Released, waiting for the support window", "Waiting on upstream" and + "Unmapped (no ak change recorded)" as counts only, unless asked. A held item is not + dispatched: the oldest Ruflo in the support window (`supportWindow.floor`) predates its fix. 4. Offer the next actions that fit: - Draft a reply to an upstream thread. Show the draft; do not post it. - Dispatch a released item: branch `upstream/` from `main`, make the entry's `adjustment` diff --git a/.claude/skills/upstream-status/SKILL.md b/.claude/skills/upstream-status/SKILL.md index 6370f491..46d3245a 100644 --- a/.claude/skills/upstream-status/SKILL.md +++ b/.claude/skills/upstream-status/SKILL.md @@ -41,7 +41,9 @@ dependency policies, constraints, and the `watch` list of upstream threads. For each item give the id, title, URL and a one-line reason: who replied and when, which release, or which ak change is pending. Give "Fixed upstream, not yet released", - "Waiting on upstream" and "Unmapped (no ak change recorded)" as counts only, unless asked. + "Released, waiting for the support window", "Waiting on upstream" and + "Unmapped (no ak change recorded)" as counts only, unless asked. A held item is not + dispatched: the oldest Ruflo in the support window (`supportWindow.floor`) predates its fix. 4. Offer the next actions that fit: - Draft a reply to an upstream thread. Show the draft; do not post it. - Dispatch a released item: branch `upstream/` from `main`, make the entry's `adjustment` diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index a30fab37..b9279061 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -11,6 +11,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { loadUpstreamRegistry } from '../src/lib/hook-audit/upstream.mjs'; +import { computeSupportWindow, minorFirstPublished } from '../src/lib/ruflo-support-window.mjs'; import { buildReport, candidateVersions, confirmationStart, ledgerEvents, nextRelease, tagRefs, upstreamOf, withoutRecorded, } from './upstream-watch/classify.mjs'; @@ -133,7 +134,30 @@ async function collect(registry, fetcher, concurrency) { await confirmReleases(gated.filter((entry) => !entry.doneWhen.release.minVersion && live.get(entry.id).release), live, fetcher, concurrency, fetchErrors); await resolveBundles(gated.filter((entry) => entry.doneWhen.release.bundledBy), live, fetcher, concurrency, fetchErrors); fetchErrors.sort((a, b) => a.id.localeCompare(b.id)); - return { live, fetchErrors }; + return { live, fetchErrors, facts }; +} + +/** + * ADR-0041 §7: the oldest supported Ruflo, from the npm release dates the + * watch reads anyway (reused when a gate already fetched them). Null when the + * registry carries no window or npm could not be read; nothing is held then. + */ +async function supportFloor(registry, fetcher, facts, fetchErrors, now) { + const window = registry.dependencyPolicies.find((policy) => policy.dependency === 'ruflo')?.supportWindow; + if (!window) return null; + let ruflo = facts.get('npm:ruflo'); + if (!ruflo) { + try { + ruflo = await fetcher.release({ channel: 'npm', name: 'ruflo' }); + } catch (error) { + fetchErrors.push({ id: 'ruflo support window', error: error.message }); + return null; + } + } + const time = Object.fromEntries(ruflo.versions.map((item) => [item.version, item.publishedAt])); + return computeSupportWindow({ + firstPublished: minorFirstPublished(time), now: now.getTime(), newestMinors: window.newestMinors, minDays: window.minDays, + })?.floor ?? null; } export async function main(argv, { @@ -160,8 +184,10 @@ export async function main(argv, { const auth = await fetcher.auth(); const offline = auth.ok ? null : auth.message; if (offline) stderr.write(`${offline}\n`); - const { live, fetchErrors } = offline ? { live: new Map(), fetchErrors: [] } : await collect(registry, fetcher, options.concurrency); - const report = buildReport(registry, live, { now, offline, fetchErrors }); + const { live, fetchErrors, facts } = offline + ? { live: new Map(), fetchErrors: [], facts: new Map() } : await collect(registry, fetcher, options.concurrency); + const floor = offline ? null : await supportFloor(registry, fetcher, facts, fetchErrors, now); + const report = buildReport(registry, live, { now, offline, fetchErrors, supportFloor: floor }); if (options.command === 'report') { stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderReport(report)); return 0; diff --git a/scripts/upstream-watch/classify.mjs b/scripts/upstream-watch/classify.mjs index f5c06c82..7c22fd2f 100644 --- a/scripts/upstream-watch/classify.mjs +++ b/scripts/upstream-watch/classify.mjs @@ -14,6 +14,7 @@ export const GROUPS = [ ['released-actionable', 'Released and actionable'], ['release-unconfirmed', 'Released, fix not confirmed'], ['workaround-carried', 'Fixed upstream, ak still carries the workaround'], + ['waiting-for-window', 'Released, waiting for the support window'], ['fixed-unreleased', 'Fixed upstream, not yet released'], ['reopened', 'Reopened upstream after ak recorded a fix'], ['waiting', 'Waiting on upstream'], @@ -284,36 +285,60 @@ function dispatchFor(entry, policy, dependencyPolicies) { return { branch: `${policy.dispatch.branchPrefix}${slug}`, pullRequest: policy.dispatch.pullRequest, merge: policy.dispatch.merge, removalProof, adjustment: entry.adjustment }; } +const ACT_NOW = new Set(['released-actionable', 'workaround-carried']); + +/** + * ADR-0041 §7: a Ruflo workaround comes out only once the oldest supported + * Ruflo (the support-window floor) contains the fix. Returns the hold, or + * null when the floor is unknown, the fix version is unknown, or it is in. + */ +function windowHold(entry, release, supportFloor) { + const gate = entry.doneWhen?.release; + if (!supportFloor || entry.dependency !== 'ruflo' || gate?.name !== 'ruflo') return null; + const needs = gate.minVersion ?? release?.version ?? null; + return needs && compareVersions(needs, supportFloor) > 0 ? { floor: supportFloor, needs } : null; +} + +/** Swap the act-now groups for `waiting-for-window` on a held entry. */ +function applyHold(groups, hold) { + if (!hold || !groups.some((group) => ACT_NOW.has(group))) return { groups, hold: null }; + return { groups: [...groups.filter((group) => !ACT_NOW.has(group)), 'waiting-for-window'], hold }; +} + /** Classify one non-retired entry; `live` is null when offline or the fetch failed. */ -export function classifyEntry(entry, live, { policy, dependencyPolicies, now }) { +export function classifyEntry(entry, live, { policy, dependencyPolicies, now, supportFloor = null }) { const base = { id: entry.id, url: entry.url, title: entry.title, relation: entry.relation, status: entry.status, mapping: entry.mapping, adjustment: entry.adjustment, tracks: entry.tracks ?? null, }; const fromRegistry = registryGroups(entry); if (!live || live.error) { - const groups = live?.error ? ['unchecked', ...fromRegistry] : fromRegistry; - return { ...base, groups: [...new Set(groups)], error: live?.error ?? null, upstream: null, dispatch: groups.includes('workaround-carried') ? dispatchFor(entry, policy, dependencyPolicies) : null }; + const held = applyHold([...new Set(live?.error ? ['unchecked', ...fromRegistry] : fromRegistry)], windowHold(entry, null, supportFloor)); + return { + ...base, groups: held.groups, error: live?.error ?? null, upstream: null, ...(held.hold ? { window: held.hold } : {}), + dispatch: held.groups.includes('workaround-carried') ? dispatchFor(entry, policy, dependencyPolicies) : null, + }; } const up = upstreamOf(live.thread); const facts = commentFacts(entry, live.thread, policy); const release = up.fixed && PENDING.has(entry.status) ? releaseState(entry, up.fixedAt, live.release, live.confirmation ?? null, live.bundle ?? null) : null; const stale = up.state === 'open' && now.getTime() - Date.parse(facts.lastUpstreamActivityAt) >= policy.staleAfterDays * DAY; - const groups = [...new Set([...liveGroups(entry, up, facts, release, stale), ...fromRegistry])]; + const found = [...new Set([...liveGroups(entry, up, facts, release, stale), ...fromRegistry])]; if (entry.relation === 'tracking') { - for (const drop of ['waiting', 'stale']) if (groups.includes(drop)) groups.splice(groups.indexOf(drop), 1); + for (const drop of ['waiting', 'stale']) if (found.includes(drop)) found.splice(found.indexOf(drop), 1); } + const { groups, hold } = applyHold(found, windowHold(entry, release, supportFloor)); const actionable = groups.includes('released-actionable') || groups.includes('workaround-carried'); return { - ...base, groups, upstream: up, release, stale, ...facts, + ...base, groups, upstream: up, release, stale, ...facts, ...(hold ? { window: hold } : {}), lastHistoryDate: lastHistoryDate(entry), dispatch: actionable ? dispatchFor(entry, policy, dependencyPolicies) : null, }; } /** Assemble the report from the registry and whatever live facts were collected. */ -export function buildReport(registry, liveById, { now, offline = null, fetchErrors = [] }) { - const context = { policy: registry.watchPolicy, dependencyPolicies: registry.dependencyPolicies, now }; +export function buildReport(registry, liveById, { now, offline = null, fetchErrors = [], supportFloor = null }) { + const context = { policy: registry.watchPolicy, dependencyPolicies: registry.dependencyPolicies, now, supportFloor }; const active = registry.watch.filter((entry) => entry.status !== 'retired'); const entries = active.map((entry) => classifyEntry(entry, offline ? null : liveById.get(entry.id) ?? null, context)); const today = now.toISOString().slice(0, 10); @@ -330,6 +355,7 @@ export function buildReport(registry, liveById, { now, offline = null, fetchErro generatedAt: now.toISOString(), mode: offline ? 'offline' : 'live', offlineReason: offline, + supportWindow: { floor: supportFloor }, registry: { status: registry.registryStatus, errors: registry.errors ?? [], lastVerifiedAt: registry.lastVerifiedAt, lastCheckedAt: registry.lastCheckedAt, statuses }, counts: Object.fromEntries(groups.map((group) => [group.key, group.items.length])), groups, @@ -364,12 +390,14 @@ export function ledgerEvents(report, registry, { since }) { const up = entry.upstream; if (up?.isPr && up.mergedAt && up.mergedAt > since) events.push(eventLine(sentinel, entry.id, 'merged', day(up.mergedAt))); else if (up?.state === 'closed' && up.closedAt > since) events.push(eventLine(sentinel, entry.id, 'closed', day(up.closedAt), { reason: up.reason })); - if (entry.groups.includes('released-actionable')) { + // A release held for the support window is still a release: the line + // carries no dispatch branch until the floor contains the fix. + if (entry.groups.includes('released-actionable') || (entry.window && entry.release?.released === true)) { events.push(eventLine(sentinel, entry.id, 'released', entry.release.date, { version: entry.release.version, pr: entry.release.change?.pr ?? null, commit: entry.release.change && !entry.release.change.pr ? entry.release.change.sha.slice(0, 7) : null, - branch: entry.dispatch.branch, + branch: entry.dispatch?.branch, })); } if (entry.groups.includes('reopened')) events.push(eventLine(sentinel, entry.id, 'reopened', entry.lastHistoryDate, { status: entry.status })); diff --git a/scripts/upstream-watch/render.mjs b/scripts/upstream-watch/render.mjs index 02476877..9caa8a3f 100644 --- a/scripts/upstream-watch/render.mjs +++ b/scripts/upstream-watch/render.mjs @@ -21,6 +21,11 @@ function detail(key, item) { `${item.release.version} (${item.release.date}) is the first release after the fix not ruled out; ${item.release.basis}`, `change once confirmed: ${item.adjustment}`, ]; + case 'waiting-for-window': + return [ + `fixed in ${item.window.needs}; the oldest supported Ruflo is ${item.window.floor}, so ak keeps the workaround until the support window's floor reaches ${item.window.needs}`, + `change: ${item.adjustment}`, + ]; case 'workaround-carried': return [`status ${item.status}; branch ${item.dispatch?.branch ?? 'n/a'}`, `change: ${item.adjustment}`]; case 'fixed-unreleased': diff --git a/tests/kit/upstream-watch-script.test.mjs b/tests/kit/upstream-watch-script.test.mjs index 3ec1cd40..02655d5b 100644 --- a/tests/kit/upstream-watch-script.test.mjs +++ b/tests/kit/upstream-watch-script.test.mjs @@ -197,7 +197,7 @@ test('collect walks the releases after the fixing merge, even when the issue clo await main(['report', '--json', '--registry', file], { fetcher, stdout: out.stream, stderr: capture().stream, now: new Date('2026-09-28T00:00:00Z') }); const report = JSON.parse(out.text()); assert.deepEqual(report.groups.find((group) => group.key === 'released-actionable').items.map((item) => item.version ?? item.release.version), ['3.46.0']); - }); + }, { supportWindow: false }); assert.deepEqual(calls, ['v3.46.0']); }); @@ -220,7 +220,7 @@ async function walkReleases(facts, containing) { const out = capture(); await main(['report', '--json', '--registry', file], { fetcher, stdout: out.stream, stderr: capture().stream, now: NOW }); report = JSON.parse(out.text()); - }); + }, { supportWindow: false }); return { calls, result: report.entries.find((item) => item.id === 'ruvnet/ruflo#3194') }; } const releasesUpTo = (count, extra = []) => { @@ -296,7 +296,7 @@ test('collect confirms through the fetcher with bounded, read-only calls', async await main(['report', '--json', '--registry', file], { fetcher, stdout: out.stream, stderr: capture().stream, now: NOW }); const report = JSON.parse(out.text()); assert.deepEqual(report.groups.find((group) => group.key === 'released-actionable').items.map((item) => item.id), ['ruvnet/ruflo#3194']); - }); + }, { supportWindow: false }); assert.deepEqual(calls, ['changes ruvnet/ruflo#3194', 'contains v3.46.0'], 'stops at the first containing version'); }); @@ -384,6 +384,68 @@ test('a merged pull request is fixed; reopened and not-planned threads are calle assert.ok(notPlanned.groups.includes('ready-to-retire')); }); +// ADR-0041 §7: a workaround comes out only once the oldest supported Ruflo has the fix. +test('a released Ruflo fix above the support-window floor waits for the window', () => { + const gated = (minVersion) => entry('ruvnet/ruflo#3167', { + status: 'released', history: [{ date: '2026-09-26', event: 'registered' }, { date: '2026-09-26', event: 'released' }], + doneWhen: { state: 'closed-completed', release: { channel: 'npm', name: 'ruflo', minVersion } }, + }); + const windowed = { ...context, supportFloor: '3.39.0' }; + const held = classifyEntry(gated('3.46.0'), null, windowed); + assert.ok(held.groups.includes('waiting-for-window'), held.groups.join(',')); + assert.ok(!held.groups.includes('workaround-carried')); + assert.equal(held.dispatch, null); + assert.deepEqual(held.window, { floor: '3.39.0', needs: '3.46.0' }); + const ready = classifyEntry(gated('3.32.2'), null, windowed); + assert.ok(!ready.groups.includes('waiting-for-window')); + assert.match(ready.dispatch.branch, /^upstream\/ruvnet-ruflo-3167$/); + const unknownFloor = classifyEntry(gated('3.46.0'), null, context); + assert.ok(!unknownFloor.groups.includes('waiting-for-window'), 'no remembered floor: nothing is held'); +}); + +test('a release confirmed from the fixing change is held for the window too', () => { + const confirmation = { changes: [change], checks: [{ version: '3.46.0', ref: 'v3.46.0', contained: true }] }; + const registry = registryWith([entry('ruvnet/ruflo#3194')]); + const report = buildReport(registry, new Map([['ruvnet/ruflo#3194', { thread: closedThread('ruvnet/ruflo#3194', '2026-09-26T22:31:23Z'), release: rufloFacts, confirmation }]]), + { now: NOW, supportFloor: '3.41.0' }); + const held = report.entries[0]; + assert.ok(held.groups.includes('waiting-for-window'), held.groups.join(',')); + assert.ok(!held.groups.includes('released-actionable')); + assert.deepEqual(held.window, { floor: '3.41.0', needs: '3.46.0' }); + assert.equal(held.dispatch, null); + const line = ledgerEvents(report, registry, { since: '2026-09-26T00:00:00Z' }).find((event) => event.event === 'released').line; + assert.equal(line, 'UPSTREAM-WATCH ruvnet/ruflo#3194 released 2026-09-26 version=3.46.0 pr=3421'); +}); + +test('a newly released Ruflo fix above the floor records the release without a dispatch branch', () => { + const pending = entry('ruvnet/ruflo#2986', { + kind: 'pr', doneWhen: { state: 'merged', release: { channel: 'npm', name: 'ruflo', minVersion: '3.38.2' } }, + }); + const registry = registryWith([pending]); + const report = buildReport(registry, new Map([['ruvnet/ruflo#2986', live('ruvnet/ruflo#2986', releaseFacts('npm', npm.ruflo))]]), + { now: NOW, supportFloor: '3.30.0' }); + const held = report.entries[0]; + assert.deepEqual(held.groups.filter((group) => ['released-actionable', 'waiting-for-window'].includes(group)), ['waiting-for-window']); + assert.equal(report.counts['waiting-for-window'], 1); + const released = ledgerEvents(report, registry, { since: '2026-09-25T00:00:00Z' }).find((event) => event.event === 'released'); + assert.match(released.line, /released \S+ version=3\.38\.2$/); + assert.match(renderReport(report), /Released, waiting for the support window[\s\S]*oldest supported Ruflo is 3\.30\.0/); +}); + +test('the watch computes the support-window floor from the npm release dates it fetches', async () => { + const fixed = entry('ruvnet/ruflo#3167', { + status: 'released', doneWhen: { state: 'closed-completed', release: { channel: 'npm', name: 'ruflo', minVersion: '9.0.0' } }, + }); + await withRegistryFile([fixed], async (file) => { + const out = capture(); + const fetcher = { ...fixtureFetcher(), thread: async () => { throw new Error('offline thread'); } }; + assert.equal(await main(['report', '--json', '--registry', file], { fetcher, stdout: out.stream, stderr: capture().stream, now: NOW }), 0); + const report = JSON.parse(out.text()); + assert.match(report.supportWindow.floor, /^3\.\d+\.0$/); + assert.ok(report.entries.find((item) => item.id === 'ruvnet/ruflo#3167').groups.includes('waiting-for-window')); + }); +}); + test('the report counts groups, lists constraints past retest and never watches retired entries', () => { const registry = registryWith([ entry('ruvnet/ruflo#3153', { relation: 'commented' }), @@ -695,11 +757,14 @@ function fixtureFetcher({ authenticated = true } = {}) { }; } -async function withRegistryFile(watch, run) { +// `supportWindow: false` drops the Ruflo support window, so a test about +// release confirmation is not also held for the window (ADR-0041 §7). +async function withRegistryFile(watch, run, { supportWindow = true } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-watch-cli-')); try { const document = JSON.parse(fs.readFileSync(UPSTREAM_REGISTRY_FILE, 'utf8')); document.watch = watch; + if (!supportWindow) for (const policy of document.dependencyPolicies) delete policy.supportWindow; // Pin the verification window around NOW instead of inheriting the live registry's dates. document.lastVerifiedAt = '2026-09-26'; document.lastCheckedAt = '2026-09-26'; From 73f3010a4e19c5d0adf66c7361af226273f3b670 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:56:56 -0700 Subject: [PATCH 05/45] refactor(statusline): remove the retired CVE-counter overlay Ruflo fixed its fabricated CVE count (ruvnet/ruflo#2694) in 3.32.2, below the support window's floor. ak no longer injects the getStatuslineData wrapper, the footer drops rufloLocalSecurity and rufloHonestInsight, and status and sync drop the statusline/cve subsystem. SEC_WRAP_STRIP stays for one release so a statusline an older ak patched is cleaned on the next sync. The registry marks ruvnet/ruflo#2694 adopted with its removal proof. --- src/commands/status/sections/statusline.mjs | 24 +-- src/commands/sync.mjs | 11 +- .../agentic-dependency-constraints.json | 7 +- src/lib/statusline.mjs | 78 ++------- src/templates/statusline-footer.cjs | 78 --------- tests/kit/status-repair-contract.test.mjs | 6 +- tests/kit/statusline.test.mjs | 95 +++++------ tests/kit/sync-command.test.mjs | 6 +- tests/statusline-segments.test.cjs | 152 ++---------------- 9 files changed, 95 insertions(+), 362 deletions(-) diff --git a/src/commands/status/sections/statusline.mjs b/src/commands/status/sections/statusline.mjs index 61dde34e..89a8e69d 100644 --- a/src/commands/status/sections/statusline.mjs +++ b/src/commands/status/sections/statusline.mjs @@ -1,12 +1,12 @@ -// Four related statusline surfaces, none of which had their own try/catch in -// the original monolith: the project footer + its CVE-counter overlay, the -// Codex native line, and an opencode informational note. Grouped in one +// Three related statusline surfaces, none of which had their own try/catch in +// the original monolith: the project footer, the Codex native line, and an +// opencode informational note. Grouped in one // section (they share the "statusline" family of subsystem tags) but split // into small functions so each stays readable and under the CC budget. import fs from 'node:fs'; import * as paths from '../../../lib/paths.mjs'; import { - upstreamCveCounterFabricated, fixStatusline, helperStampStale, statuslineVersionAhead, + fixStatusline, helperStampStale, statuslineVersionAhead, helperRefreshBlocker, bakedVersionManualFix, } from '../../../lib/statusline.mjs'; import { statuslineDrift } from '../../../lib/codex-statusline.mjs'; @@ -21,7 +21,7 @@ function footerRows(cwd) { const hasFooter = slSrc.includes('ruflo-seg:BEGIN'); // Drift is "would a sync CHANGE this file?", which fixStatusline's dry run answers // exactly. A marker-presence test alone cannot see CONTENT drift: after a kit upgrade - // revises the footer or the security overlay, the marker is still there, this row + // revises the footer or another injected block, the marker is still there, this row // reports 'ok', and — because sync builds its plan from rows carrying a `fix` — the // re-injection never runs and the stale block survives indefinitely. Observed live: // an updated overlay silently failed to land for exactly this reason. @@ -41,20 +41,6 @@ function footerRows(cwd) { ? 'footer present but ruflo helper stamp is stale — next ruflo command wipes it' : 'activation footer present and current', (wouldChange || stampStale) ? 'sync refreshes helpers, then re-injects the footer' : null)]; - // The CVE-counter overlay is tracked SEPARATELY from the footer: a footer-only - // check reports 'ok' while the statusline still renders ruflo's fabricated - // "⚠ 3 CVEs" (hardcoded totalCves, cvesFixed from a file count). Only warn while - // the upstream defect is actually present — once ruflo fixes getSecurityStatus - // the overlay is intentionally absent, and this row must go quiet on its own - // rather than nag for a patch that is no longer wanted. - if (upstreamCveCounterFabricated()) { - const patched = slSrc.includes('ruflo-sec:BEGIN'); - rows.push(row('statusline/cve', patched ? 'ok' : 'warn', - patched - ? 'CVE counter overlaid with real scan results' - : 'statusline shows ruflo\'s fabricated CVE count (upstream defect)', - patched ? null : 'sync injects the security overlay')); - } rows.push(...versionRows(cwd)); return rows; } diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 5b2bf8d6..57c0c7d8 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -568,9 +568,7 @@ export const SYNC_STEPS = [ // footer with no re-inject planned. { id: 'statusline', - // 'statusline/cve': fixStatusline also injects the CVE-counter overlay - // that row promises (a planned overlay fix used to run no step at all). - when: (subs) => subs.has('statusline') || subs.has('statusline/cve') || subs.has('versions') || subs.has('providers'), + when: (subs) => subs.has('statusline') || subs.has('versions') || subs.has('providers'), run: async (ctx) => { // withProgress: fixStatusline blocks on a node subprocess (ruflo's helper // refresh, up to 30s). The interval can't animate through a synchronous @@ -651,7 +649,7 @@ const SYNC_SUBSYSTEMS = [ 'agent-browser', 'aqe', 'aqe-embedding', 'blocks', 'codex-context', 'codex-mcp', 'codex-statusline', 'daemons', 'deja-vu', 'host-alignment', 'hosts', 'mcp', 'natives', 'npx', 'providers', 'routing', 'ruflo-components', 'ruvector', 'ruvnet-brain', 'ruvnet-brain-nightly', 'scaffold-agents', 'security', - 'self', 'statusline', 'statusline/cve', 'versions', + 'self', 'statusline', 'versions', ]; /** The names `ak sync --skip` accepts: SYNC_SUBSYSTEMS plus every lifecycle host. */ @@ -713,9 +711,8 @@ export function performingStepsFor(item, flags, cfg, skip = new Set()) { } /** Take --skip's items out of the plan: those of a skipped subsystem, and - * those only a skipped step performs (statusline/cve when statusline is - * skipped; a Codex MCP registration when providers is skipped) — running the - * rest could never repair them. */ + * those only a skipped step performs (a Codex MCP registration when + * providers is skipped) — running the rest could never repair them. */ export function splitSkipped(candidates, skip, flags, cfg) { if (!skip.size) return { plan: candidates, skipped: [] }; const plan = []; const skipped = []; diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index fb7ae2ac..9948006c 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -1033,16 +1033,17 @@ "mapping": "mapped", "kitImpact": { "refs": ["closed-upstream review 2026-09-26"], - "files": ["src/lib/statusline.mjs", "src/templates/statusline-footer.cjs"] + "files": ["src/lib/statusline.mjs"] }, "adjustment": "refactor(statusline): remove the retired CVE-counter overlay (SEC_WRAP, its gate, the status row, rufloLocalSecurity and rufloHonestInsight); keep SEC_WRAP_STRIP for one release. Conditional on declaring a Ruflo floor >= 3.32.2.", - "status": "released", + "status": "adopted", "constraintIds": [], "history": [ { "date": "2026-07-16", "event": "filed" }, { "date": "2026-07-17", "event": "closed", "note": "completed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.32.2; the ak change is pending" } + { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.32.2; the ak change is pending" }, + { "date": "2026-09-27", "event": "adopted", "note": "overlay removed; SEC_WRAP_STRIP kept one release; floor 3.39.0 >= 3.32.2; removal proof: statusline suites pass and a 3.46.1 statusline helper carrying an old block is stripped, passes node --check and converges on the second run" } ] }, { diff --git a/src/lib/statusline.mjs b/src/lib/statusline.mjs index 561eeb53..270e439b 100644 --- a/src/lib/statusline.mjs +++ b/src/lib/statusline.mjs @@ -23,39 +23,12 @@ const FOOTER_TEMPLATE = path.join( const eol = (s) => (s.includes('\r\n') ? '\r\n' : '\n'); -// Security overlay wrapper. Wraps getStatuslineData() rather than patching -// applyLocalOverlays(), because applyLocalOverlays is NOT on every path: the -// fresh-cache early return (`if (cache.fresh && cache.promoFresh) return -// overlayMemoPromo(cache.data)`) bypasses it, so for the 60s TTL a patched -// applyLocalOverlays is simply never called and the fabricated count renders -// anyway (verified empirically — the overlay had no effect until this wrapper). -// Wrapping the single entry point covers all four return paths (CLI delegation, -// fresh cache, stale-while-revalidate, local fallback) with one injection. -// -// Relies on function-declaration hoisting: `function getStatuslineData()` is -// initialized before any top-level code runs, so this block — injected near the -// top of the file — can reassign the binding, and the later declaration does not -// re-execute and clobber it. The typeof guard keeps it inert on any template that -// lacks the function (e.g. the minimal statusline-v3.cjs). -const SEC_WRAP = [ - '/* ruflo-sec:BEGIN */', - 'try {', - ' if (typeof getStatuslineData === "function") {', - ' var _rufloOrigGetStatuslineData = getStatuslineData;', - ' getStatuslineData = function(){', - ' var d = _rufloOrigGetStatuslineData.apply(this, arguments);', - ' try {', - ' if (d) {', - ' d.security = rufloLocalSecurity(process.cwd(), d.security);', - ' d.promo = rufloHonestInsight(d.promo, d.security);', - ' }', - ' } catch(e){}', - ' return d;', - ' };', - ' }', - '} catch(e){}', - '/* ruflo-sec:END */', -].join('\n'); +// Retired security overlay (ruvnet/ruflo#2694). An earlier ak wrapped +// getStatuslineData() between these markers to replace Ruflo's fabricated CVE +// count; Ruflo fixed getSecurityStatus in 3.32.2, below the support window's +// floor (ADR-0041 §7), so ak no longer injects it. The strip stays for one +// release so a statusline patched by an older ak is cleaned on the next sync: +// remove SEC_WRAP_STRIP and its use in fixStatusline after one release. const SEC_WRAP_STRIP = /\/\* ruflo-sec:BEGIN \*\/[\s\S]*?\/\* ruflo-sec:END \*\/\n?/g; // (e) Bin-resolution wrapper. Upstream's resolveCliBinCandidates probes filenames @@ -67,13 +40,14 @@ const SEC_WRAP_STRIP = /\/\* ruflo-sec:BEGIN \*\/[\s\S]*?\/\* ruflo-sec:END \*\/ // installed 3.32.2 carried the CVE-counter fix still rendered the fabricated // "⚠ 1 CVE" / perpetual "scanning…" from a cached 3.28.0. // -// Unlike the security overlay there is deliberately NO retirement gate: the wrapper +// Unlike the retired security overlay there is deliberately NO retirement gate: the wrapper // only PREPENDS bins verified to exist on disk (rufloRealCliBins, injected with the // footer) and keeps upstream's own candidates as the tail, so on a fixed upstream it // converges to the same delegation instead of fighting it. A gate would be one more // proxy-probe that can misfire — the CVE gate watched the global install while the -// render path executed a stale npx copy. Same function-declaration-hoisting -// mechanism as the security wrapper; typeof-guarded so it is inert on templates +// render path executed a stale npx copy. Relies on function-declaration hoisting: +// the resolver is initialized before any top-level code runs, so this block can +// reassign the binding. Typeof-guarded so it is inert on templates // without the function (e.g. the minimal statusline-v3.cjs). The inner try around // the CWD read absorbs the TDZ ReferenceError if a future template declares CWD // with let/const after this block yet calls the resolver during top-level eval. @@ -98,26 +72,6 @@ const BIN_WRAP = [ ].join('\n'); const BIN_WRAP_STRIP = /\/\* ruflo-bin:BEGIN \*\/[\s\S]*?\/\* ruflo-bin:END \*\/\n?/g; -/** Upstream defect: ruvnet/ruflo#2694. - * True while ruflo's getSecurityStatus() still FABRICATES the CVE count — i.e. the - * installed CLI still has `const totalCves = 3` (a hardcoded constant naming ruflo's - * own v3 roadmap items, not the rendered project's risk) with cvesFixed derived from - * scans.length (a FILE count, not findings). Read-only probe of the installed CLI. - * - * This is the stopgap's self-retirement gate, mirroring improvement-eval's --cli-check - * (#2222): detect the defect in shipped code rather than pinning a version number, so - * the kit stops patching the moment upstream fixes it — no release-tracking required. - * Unreadable/absent/changed => false (fail safe: never patch what we cannot verify is - * broken; the worst case is ruflo's own unmodified behavior). */ -export function upstreamCveCounterFabricated() { - try { - const f = path.join(rufloCliDist(), 'funnel', 'local-signals.js'); - if (!fs.existsSync(f)) return false; - const src = fs.readFileSync(f, 'utf8'); - return /const totalCves = 3\b/.test(src) && /scans\.length/.test(src); - } 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 @@ -302,19 +256,15 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { const footer = fs.readFileSync(FOOTER_TEMPLATE, 'utf8').replace(/\r\n/g, '\n').trim(); s = s.replace(/\/\* ruflo-seg:BEGIN \*\/[\s\S]*?\/\* ruflo-seg:END \*\/\n?/, ''); s = s.replace(/ \+ rufloActivationSegments\(process\.cwd\(\)\)/g, ''); - // (d) security overlay: stripped unconditionally BEFORE the gate is consulted, so the - // stopgap retires itself on the first sync after upstream fixes getSecurityStatus. + // (d) retired security overlay (ruvnet/ruflo#2694): strip a block an older ak + // injected; never re-injected. Remove after one release. s = s.replace(SEC_WRAP_STRIP, ''); // (e) bin wrapper: stripped unconditionally like the others, re-injected always — // no gate (see BIN_WRAP), it self-neutralizes on a template it doesn't fit. s = s.replace(BIN_WRAP_STRIP, ''); - const securityOverlay = upstreamCveCounterFabricated(); const lines = s.split('\n'); const at = lines[0]?.startsWith('#!') ? 1 : 0; - const blocks = [footer]; - if (securityOverlay) blocks.push(SEC_WRAP); - blocks.push(BIN_WRAP); - lines.splice(at, 0, blocks.join('\n')); + lines.splice(at, 0, [footer, BIN_WRAP].join('\n')); s = lines.join('\n'); s = s.replace(/console\.log\(generateStatusline\(\)\)/, 'console.log(generateStatusline() + rufloActivationSegments(process.cwd()))'); @@ -348,5 +298,5 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { repointed = true; } - return { file, applied: out !== raw, repointed, securityOverlay, ...version }; + return { file, applied: out !== raw, repointed, ...version }; } diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 1339c2b2..3eb219f3 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -703,82 +703,4 @@ function rufloAidefenceState(rufloRoot){ return fs.existsSync(ad) ? "on" : "off"; } catch(e){ rufloStatuslineDebug("aidefence-probe", e); return "unknown"; } } -// ── security overlay: replaces ruflo's FABRICATED CVE counter with the real scan ── -// Upstream (@claude-flow/cli dist/src/funnel/local-signals.js, getSecurityStatus) does: -// let cvesFixed = 0; const totalCves = 3; -// cvesFixed = Math.min(totalCves, scans.length); // counts FILES, not findings -// Two independent defects. (1) `totalCves = 3` is a hardcoded constant referring to -// ruflo's OWN v3 remediation roadmap — CVE-1/2/3 in .claude/agents/v3/v3-security-architect.md -// are an outdated @anthropic-ai/claude-code dep + SHA-256 hashing + hardcoded creds in -// THEIR api/auth-service.ts. They are not public CVE IDs and have nothing to do with the -// project being rendered, so every clean repo is told it has 3 CVEs. (2) `cvesFixed` -// counts .json files in .claude/security-scans/, so running the very scan the warning -// tells you to run "fixes" a CVE by writing a file. The counter converges to CLEAN -// without anything being scanned, let alone fixed. Upstream: ruvnet/ruflo#2694. -// -// This overlay reports what the newest scan ACTUALLY found, and never invents a CVE: -// totalCves/cvesFixed are pinned to 0 so the "⚠ N CVEs" branch can never fire again; -// real state is carried in `status`, which ruflo's own renderer prints verbatim. -// no scan yet → PENDING → "🛡 scan pending" (honest unknown, not green) -// findings > 0 → "N ISSUES" → red "🛡 n issues" (real count from the scan) -// clean + fresh → CLEAN → "🛡 ✓" -// clean + stale >7d → STALE → "🛡 scan stale" -// Returns `upstream` untouched on any unexpected error — a wrong overlay would be worse -// than the bug, so the failure mode is "no worse than ruflo". -function rufloLocalSecurity(cwd, upstream){ - try { - var fs = require("fs"), path = require("path"); - var dir = path.join(cwd, ".claude", "security-scans"); - var newest = null; - try { - fs.readdirSync(dir).forEach(function(f){ - if (f.slice(-5) !== ".json") return; - try { - var j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); - // Prefer the scan's own timestamp; fall back to mtime so a hand-written or - // older-format scan file still orders correctly instead of sorting to epoch 0. - var t = Date.parse(j && j.timestamp); - if (!t) { try { t = fs.statSync(path.join(dir, f)).mtimeMs; } catch(e){ rufloStatuslineDebug("security-scan-stat", e); t = 0; } } - if (!newest || t > newest.t) newest = { t: t, j: j }; - } catch(e){ rufloStatuslineDebug("security-scan-file", e); } // unreadable/!JSON scan file: ignore, never let it break the render - }); - } catch(e){ rufloStatuslineDebug("security-scan-directory", e); } // no directory => never scanned - if (!newest) return { status: "PENDING", cvesFixed: 0, totalCves: 0 }; - var s = newest.j.summary || {}; - var n = typeof s.total === "number" ? s.total - : (Array.isArray(newest.j.findings) ? newest.j.findings.length : 0); - if (n > 0) return { status: n + " ISSUE" + (n === 1 ? "" : "S"), cvesFixed: 0, totalCves: 0 }; - var staleMs = Number(process.env.RUFLO_SCAN_STALE_MS || 7 * 24 * 3600 * 1000); - if (staleMs > 0 && newest.t && (Date.now() - newest.t) > staleMs) { - return { status: "STALE", cvesFixed: 0, totalCves: 0 }; - } - return { status: "CLEAN", cvesFixed: 0, totalCves: 0 }; - } catch(e){ rufloStatuslineDebug("security-overlay", e); return upstream; } -} -// ── insight-row companion to rufloLocalSecurity ────────────────────────────── -// The fabricated count reaches the render through a SECOND, independent path: the -// CLI builds the line-3 insight itself (funnel/insights.js securityInsight → -// `pending = s.totalCves - s.cvesFixed`) and ships it as pre-rendered promo TEXT. -// Overlaying data.security cannot fix that — the sentence is already baked, so a -// repo with a clean scan still gets "⚠ 1 CVE pending". This rebuilds that one -// sentence from the real scan, or drops it when there is nothing to say. -// Matched on TEXT, not id: promo.js reduces the insight to {text, kind} and throws -// the id away, so `insight-cves-pending` is not observable by the time we see it. -// Only ever touches a CVE-worded insight — every other insight/tip/promo passes -// through untouched, so the funnel rotation is preserved. -function rufloHonestInsight(promo, sec){ - try { - if (!promo || promo.kind !== "insight" || typeof promo.text !== "string") return promo; - if (!/\bCVEs?\b/.test(promo.text)) return promo; // a different insight — not ours to touch - if (!sec) return null; - if (sec.status === "PENDING") return { text: "🛡 Security scan pending — Run ruflo security scan --depth full", kind: "insight" }; - if (sec.status === "STALE") return { text: "🛡 Security scan stale — Run ruflo security scan --depth full", kind: "insight" }; - var m = /^(\d+) ISSUE/.exec(sec.status || ""); - if (m) { - var n = Number(m[1]); - return { text: "⚠ " + n + " security issue" + (n === 1 ? "" : "s") + " found — see .claude/security-scans", kind: "insight" }; - } - return null; // CLEAN: say nothing. The slot falls blank rather than nagging about a lie. - } catch(e){ rufloStatuslineDebug("security-insight", e); return promo; } -} /* ruflo-seg:END */ diff --git a/tests/kit/status-repair-contract.test.mjs b/tests/kit/status-repair-contract.test.mjs index 8d8c2097..e974fe1a 100644 --- a/tests/kit/status-repair-contract.test.mjs +++ b/tests/kit/status-repair-contract.test.mjs @@ -191,11 +191,13 @@ test('with mcp.register false the mcp rows promise no sync repair, and sync does assert.doesNotMatch(out, /unresolved/); }); -test('the CVE overlay fix is performed by the statusline step', () => { +// ruvnet/ruflo#2694: the CVE-counter overlay is retired, and its subsystem with it. +test('the retired statusline/cve subsystem has no sync step and cannot be skipped', () => { const cfg = loadKitConfig(); const fired = sync.SYNC_STEPS.filter((s) => s.when(new Set(['statusline/cve']), { 'no-upgrade': false }, cfg)) .map((s) => s.id); - assert.ok(fired.includes('statusline'), `statusline/cve must fire the statusline step; fired: ${fired}`); + assert.equal(fired.includes('statusline'), false, `no step repairs statusline/cve; fired: ${fired}`); + assert.equal(sync.skippableSubsystems().includes('statusline/cve'), false); }); // ── census: every 'sync' fix has a sync step that runs for it ──────────────── diff --git a/tests/kit/statusline.test.mjs b/tests/kit/statusline.test.mjs index 5fce8a59..a9a5e8a8 100644 --- a/tests/kit/statusline.test.mjs +++ b/tests/kit/statusline.test.mjs @@ -1,19 +1,18 @@ -// fixStatusline's security-overlay injection — the stopgap for ruflo's fabricated -// CVE counter (@claude-flow/cli funnel/local-signals.js getSecurityStatus: a hardcoded -// `totalCves = 3` naming ruflo's OWN v3 roadmap items, with cvesFixed derived from -// scans.length — a FILE count). Hermetic: a synthetic global-root fixture stands in for -// the installed CLI, so the upstream-defect gate can be driven both ways without npm, -// network, or a real ruflo install. +// fixStatusline's injected blocks. Hermetic: a synthetic global-root fixture stands +// in for the installed CLI, so no npm, network or real ruflo install is involved. // -// The retirement test is the important one: the kit must STOP patching the moment -// upstream ships a fix, without anyone editing a pinned version number here. +// The CVE-counter overlay (the stopgap for ruvnet/ruflo#2694: a hardcoded +// `totalCves = 3` with cvesFixed from scans.length) is retired: the fix shipped in +// Ruflo 3.32.2, below the support window's floor. fixStatusline still strips an old +// block for one release; it never injects one, even on a CLI that has the defect. import { test, after } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; -import { fixStatusline, upstreamCveCounterFabricated } from '../../src/lib/statusline.mjs'; +import { fixStatusline } from '../../src/lib/statusline.mjs'; +import statuslineSection from '../../src/commands/status/sections/statusline.mjs'; import { tempDir } from './helpers/temp-dir.mjs'; import { redirectToolState, spawnEnv } from './helpers/home-sandbox.mjs'; @@ -36,7 +35,7 @@ function generateStatusline() { return 'BINS:' + resolveCliBinCandidates().join( console.log(generateStatusline()) `; -// The buggy shape fixStatusline probes for; `fixed` models an upstream repair. +// The ruvnet/ruflo#2694 defect shape (buggy) and its repair; fixStatusline ignores both now. const signalsSrc = (buggy) => (buggy ? 'export function getSecurityStatus(cwd) {\n let cvesFixed = 0;\n const totalCves = 3;\n cvesFixed = Math.min(totalCves, scans.length);\n}\n' : 'export function getSecurityStatus(cwd) {\n const findings = readScan(cwd);\n return { status: findings.length ? "ISSUES" : "CLEAN" };\n}\n'); @@ -60,25 +59,45 @@ function fixture({ buggyUpstream, rufloVersion }) { const count = (s, re) => (s.match(re) || []).length; -test('gate detects the fabricated CVE counter in a buggy CLI', () => { - fixture({ buggyUpstream: true }); - assert.equal(upstreamCveCounterFabricated(), true); +// An old security block as an earlier ak injected it (ruvnet/ruflo#2694 stopgap). +const OLD_SEC_BLOCK = [ + '/* ruflo-sec:BEGIN */', + 'try {', + ' if (typeof getStatuslineData === "function") {', + ' var _rufloOrigGetStatuslineData = getStatuslineData;', + ' getStatuslineData = function(){', + ' var d = _rufloOrigGetStatuslineData.apply(this, arguments);', + ' try { if (d) { d.security = rufloLocalSecurity(process.cwd(), d.security); d.promo = rufloHonestInsight(d.promo, d.security); } } catch(e){}', + ' return d;', + ' };', + ' }', + '} catch(e){}', + '/* ruflo-sec:END */', +].join('\n'); + +test('an old CVE overlay block is stripped and never re-injected, even on a CLI with the defect', () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + fs.writeFileSync(sl, HOST.replace('let ver', `${OLD_SEC_BLOCK}\nlet ver`)); + const r = fixStatusline(proj); + assert.equal(r.applied, true); + assert.equal('securityOverlay' in r, false, 'the result no longer reports an overlay'); + const out = fs.readFileSync(sl, 'utf8'); + assert.doesNotMatch(out, /ruflo-sec/); + assert.doesNotMatch(out, /rufloLocalSecurity|rufloHonestInsight/); + assert.match(out, /ruflo-seg:BEGIN/, 'the activation footer is injected'); }); -test('gate goes quiet once upstream repairs getSecurityStatus', () => { - fixture({ buggyUpstream: false }); - assert.equal(upstreamCveCounterFabricated(), false); +test('status never reports a statusline/cve row, even on a CLI with the defect', async () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + fs.writeFileSync(sl, HOST.replace('let ver', `${OLD_SEC_BLOCK}\nlet ver`)); + const rows = await statuslineSection.collect({ cfg: {}, cwd: proj }); + assert.deepEqual(rows.filter((r) => r.subsystem === 'statusline/cve'), []); + assert.equal(rows.find((r) => r.subsystem === 'statusline').level, 'warn', 'the old block is drift sync removes'); }); -test('overlay is injected while the upstream defect is present', () => { - const { proj, sl } = fixture({ buggyUpstream: true }); - const r = fixStatusline(proj); - assert.equal(r.securityOverlay, true); - const out = fs.readFileSync(sl, 'utf8'); - assert.match(out, /ruflo-sec:BEGIN/); - assert.match(out, /function rufloLocalSecurity/); - assert.match(out, /d\.security = rufloLocalSecurity/); - assert.match(out, /d\.promo = rufloHonestInsight/); +test('the footer template no longer carries the CVE overlay functions', () => { + const footer = fs.readFileSync(new URL('../../src/templates/statusline-footer.cjs', import.meta.url), 'utf8'); + assert.doesNotMatch(footer, /function rufloLocalSecurity|function rufloHonestInsight/); }); test('injected statusline is syntactically valid', () => { @@ -92,7 +111,7 @@ test('injection is idempotent — repeated syncs never stack blocks', () => { fixStatusline(proj); fixStatusline(proj); const r3 = fixStatusline(proj); const out = fs.readFileSync(sl, 'utf8'); - assert.equal(count(out, /ruflo-sec:BEGIN/g), 1); + assert.equal(count(out, /ruflo-sec:BEGIN/g), 0); assert.equal(count(out, /ruflo-seg:BEGIN/g), 1); assert.equal(count(out, /ruflo-bin:BEGIN/g), 1); assert.equal(r3.applied, false, 'a converged file must report no change'); @@ -106,11 +125,10 @@ test('injection is idempotent — repeated syncs never stack blocks', () => { // security overlay, precisely because the render path executed a stale npx copy // the gate never probed. -test('bin wrapper is injected even when the security overlay is retired', () => { - // buggyUpstream:false = the exact state that bit us: CVE gate retired, bin path broken. +test('bin wrapper is injected on a fixed CLI', () => { + // buggyUpstream:false = the exact state that bit us: CVE counter fixed, bin path broken. const { proj, sl } = fixture({ buggyUpstream: false }); - const r = fixStatusline(proj); - assert.equal(r.securityOverlay, false, 'precondition: the gated overlay must be off'); + fixStatusline(proj); const out = fs.readFileSync(sl, 'utf8'); assert.match(out, /ruflo-bin:BEGIN/); assert.match(out, /function rufloRealCliBins/, 'footer helper the wrapper depends on'); @@ -139,23 +157,6 @@ test('bin wrapper is inert on a template without resolveCliBinCandidates', () => assert.match(stdout, /^x/, 'typeof guard: the wrapper must not break a template it does not fit'); }); -// The self-retirement contract: no version pin, no manual cleanup step. -test('overlay retires itself once upstream is fixed', () => { - const { proj, sl } = fixture({ buggyUpstream: true }); - fixStatusline(proj); - assert.match(fs.readFileSync(sl, 'utf8'), /ruflo-sec:BEGIN/); - - // Upstream ships the fix underneath us; the next sync must strip the stopgap. - const funnel = path.join(_globalRootOf(sl), 'ruflo', 'node_modules', '@claude-flow', 'cli', 'dist', 'src', 'funnel'); - fs.writeFileSync(path.join(funnel, 'local-signals.js'), signalsSrc(false)); - - const r = fixStatusline(proj); - assert.equal(r.securityOverlay, false); - const out = fs.readFileSync(sl, 'utf8'); - assert.equal(count(out, /ruflo-sec:BEGIN/g), 0, 'stopgap must be gone'); - assert.match(out, /ruflo-seg:BEGIN/, 'the activation footer must survive'); -}); - // ── Ruflo owns the version its helper shows ────────────────────────────────── // Ruflo bakes `let ver` into statusline.cjs as a FLOOR and, at render time, shows // the HIGHEST version among that floor and every install it can find. A value ak diff --git a/tests/kit/sync-command.test.mjs b/tests/kit/sync-command.test.mjs index 4b37fd51..b4061437 100644 --- a/tests/kit/sync-command.test.mjs +++ b/tests/kit/sync-command.test.mjs @@ -656,10 +656,10 @@ test('--skip stops a step on its derived triggers too', () => { test('a fix performed only by a skipped step is skipped with it, never unresolved', async () => { seedHome(); - const cve = { subsystem: 'statusline/cve', level: 'warn', message: 'fabricated CVE counter', fix: 'sync injects the security overlay', repair: 'sync' }; - const { out } = await syncWith(async () => [cve], { 'dry-run': true, skip: ['statusline'] }); + const codex = { subsystem: 'codex-mcp', level: 'warn', message: 'no ruflo MCP in codex', fix: 'sync registers the ruflo MCP into codex', repair: 'sync' }; + const { out } = await syncWith(async () => [codex], { 'dry-run': true, skip: ['providers'] }); assert.doesNotMatch(out, /sync plan/, out); - assert.match(out, /skipped by request: \[statusline\/cve\]/); + assert.match(out, /skipped by request: \[codex-mcp\]/); }); // correctness-skip-providers-false-unresolved: the codex-mcp subsystem has diff --git a/tests/statusline-segments.test.cjs b/tests/statusline-segments.test.cjs index 931c2f5c..3788ff8a 100644 --- a/tests/statusline-segments.test.cjs +++ b/tests/statusline-segments.test.cjs @@ -40,14 +40,14 @@ try { process.exit(2); } -// The security overlay ships in the same block (it must: the strip regex in +// The aidefence and debug helpers ship in the same block (they must: the strip regex in // statusline.mjs is non-global, so a second ruflo-seg block would leak on re-injection). -let rufloLocalSecurity, rufloHonestInsight, rufloAidefenceState, rufloStatuslineDebug; +// The CVE-counter overlay functions (ruvnet/ruflo#2694 stopgap) are retired: the block +// must no longer define them. +let rufloAidefenceState, rufloStatuslineDebug, retiredOverlay; try { // eslint-disable-next-line no-eval - rufloLocalSecurity = eval('(function(){' + block + '\nreturn rufloLocalSecurity;})()'); - // eslint-disable-next-line no-eval - rufloHonestInsight = eval('(function(){' + block + '\nreturn rufloHonestInsight;})()'); + retiredOverlay = eval('(function(){' + block + '\nreturn [typeof rufloLocalSecurity, typeof rufloHonestInsight];})()'); // eslint-disable-next-line no-eval rufloAidefenceState = eval('(function(){' + block + '\nreturn rufloAidefenceState;})()'); // eslint-disable-next-line no-eval @@ -321,140 +321,14 @@ test('Δ LoRA field is never rendered (F4 gate honored)', () => { absent(out, 'Δ LoRA'); }); -// ── security overlay: ruflo's fabricated CVE counter ──────────────────────── -// Upstream getSecurityStatus() (@claude-flow/cli funnel/local-signals.js) hardcodes -// `const totalCves = 3` — ruflo's OWN v3 roadmap items, not the rendered project's risk — -// and derives cvesFixed from scans.length, a FILE count. So a pristine repo is told it -// has 3 CVEs, and running the suggested scan "fixes" one by writing a file. The overlay -// reports what the newest scan actually found and never invents a CVE. -console.log('\nsecurity overlay (fabricated-CVE fix)'); - -const scanFixture = (files) => mkFixture(Object.fromEntries( - Object.entries(files).map(([f, o]) => ['.claude/security-scans/' + f, o]))); -const iso = (ms) => new Date(Date.now() + ms).toISOString(); - -test('never scanned → PENDING (honest unknown, not a false green)', () => { - const r = rufloLocalSecurity(mkFixture({}), { status: 'UPSTREAM' }); - assert(r.status === 'PENDING', 'expected PENDING, got ' + r.status); -}); - -test('clean fresh scan → CLEAN', () => { - const r = rufloLocalSecurity(scanFixture({ - 'scan-all-full.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, - }), null); - assert(r.status === 'CLEAN', 'expected CLEAN, got ' + r.status); -}); - -test('real findings → "N ISSUES" with the true count', () => { - const r = rufloLocalSecurity(scanFixture({ - 'scan.json': { timestamp: iso(0), summary: { critical: 1, high: 2, total: 3 }, findings: [1, 2, 3] }, - }), null); - assert(r.status === '3 ISSUES', 'expected "3 ISSUES", got ' + r.status); -}); - -test('single finding is singular ("1 ISSUE")', () => { - const r = rufloLocalSecurity(scanFixture({ - 'scan.json': { timestamp: iso(0), summary: { total: 1 }, findings: [1] }, - }), null); - assert(r.status === '1 ISSUE', 'expected "1 ISSUE", got ' + r.status); -}); - -test('clean but stale scan → STALE (not a stale green tick)', () => { - const r = rufloLocalSecurity(scanFixture({ - 'scan.json': { timestamp: iso(-30 * 864e5), summary: { total: 0 }, findings: [] }, - }), null); - assert(r.status === 'STALE', 'expected STALE, got ' + r.status); -}); - -// THE regression this whole patch exists for. -test('N clean scan FILES never fabricate CVEs (the upstream file-count bug)', () => { - const r = rufloLocalSecurity(scanFixture({ - 'a.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, - 'b.json': { timestamp: iso(1), summary: { total: 0 }, findings: [] }, - 'c.json': { timestamp: iso(2), summary: { total: 0 }, findings: [] }, - }), null); - assert(r.status === 'CLEAN', 'three clean scans must be CLEAN, got ' + r.status); - assert(r.totalCves === 0 && r.cvesFixed === 0, 'file count must never become a CVE count'); -}); - -test('totalCves/cvesFixed are pinned to 0 in every state (⚠ N CVEs can never fire)', () => { - const states = [ - mkFixture({}), - scanFixture({ 's.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] } }), - scanFixture({ 's.json': { timestamp: iso(0), summary: { total: 9 }, findings: [1] } }), - scanFixture({ 's.json': { timestamp: iso(-30 * 864e5), summary: { total: 0 }, findings: [] } }), - ]; - for (const dir of states) { - const r = rufloLocalSecurity(dir, null); - assert(r.totalCves === 0 && r.cvesFixed === 0, - 'CVE counters must stay 0, got ' + JSON.stringify(r)); - } -}); - -test('newest scan wins over older ones', () => { - const r = rufloLocalSecurity(scanFixture({ - 'old.json': { timestamp: iso(-864e5), summary: { total: 7 }, findings: [1] }, - 'new.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, - }), null); - assert(r.status === 'CLEAN', 'newest (clean) scan must win, got ' + r.status); -}); - -test('malformed scan JSON is ignored, never throws', () => { - const dir = mkFixture({ - '.claude/security-scans/broken.json': 'not json at all{{', - '.claude/security-scans/good.json': JSON.stringify({ timestamp: iso(0), summary: { total: 2 }, findings: [1, 2] }), - }); - const r = rufloLocalSecurity(dir, null); - assert(r.status === '2 ISSUES', 'expected "2 ISSUES" from the readable scan, got ' + r.status); -}); - -test('findings[] length is used when summary.total is absent', () => { - const r = rufloLocalSecurity(scanFixture({ - 'scan.json': { timestamp: iso(0), findings: [1, 2, 3, 4] }, - }), null); - assert(r.status === '4 ISSUES', 'expected "4 ISSUES", got ' + r.status); -}); - -// ── insight row: the CLI bakes the fabricated count into promo TEXT ────────── -// funnel/insights.js computes `pending = totalCves - cvesFixed` CLI-side and ships a -// finished sentence, so overlaying data.security alone still leaves "⚠ 1 CVE pending" -// on line 3. promo.js drops the insight id, so this must match on text. -const cveInsight = (n) => ({ text: `⚠ ${n} CVE${n === 1 ? '' : 's'} pending — Run ruflo security scan --depth full`, kind: 'insight' }); - -test('fabricated CVE insight is dropped when the real scan is CLEAN', () => { - const r = rufloHonestInsight(cveInsight(1), { status: 'CLEAN', cvesFixed: 0, totalCves: 0 }); - assert(r === null, 'clean scan must not nag about CVEs, got ' + JSON.stringify(r)); -}); - -test('CVE insight becomes an honest scan-pending prompt when never scanned', () => { - const r = rufloHonestInsight(cveInsight(3), { status: 'PENDING', cvesFixed: 0, totalCves: 0 }); - absent(r.text, 'CVE'); - contains(r.text, 'scan pending'); -}); - -test('CVE insight becomes a real issue count when the scan found things', () => { - const r = rufloHonestInsight(cveInsight(1), { status: '4 ISSUES', cvesFixed: 0, totalCves: 0 }); - contains(r.text, '4 security issues'); - absent(r.text, 'CVE'); -}); - -test('CVE insight reports a stale scan honestly', () => { - const r = rufloHonestInsight(cveInsight(2), { status: 'STALE', cvesFixed: 0, totalCves: 0 }); - contains(r.text, 'scan stale'); - absent(r.text, 'CVE'); -}); - -test('non-CVE insights pass through untouched (funnel rotation preserved)', () => { - const tip = { text: '💾 ruflo session restore --latest brings back your last session', kind: 'educational' }; - assert(rufloHonestInsight(tip, { status: 'CLEAN' }) === tip, 'educational tip must pass through by identity'); - const other = { text: '🧬 flywheel headline', kind: 'insight' }; - assert(rufloHonestInsight(other, { status: 'CLEAN' }) === other, 'non-CVE insight must pass through by identity'); -}); +// ── retired CVE-counter overlay (ruvnet/ruflo#2694) ───────────────────────── +// Ruflo fixed getSecurityStatus in 3.32.2, below the support window's floor, so the +// footer no longer overlays a scan-derived security status or rewrites the CVE insight. +console.log('\nretired security overlay'); -test('null/!text promo is safe', () => { - assert(rufloHonestInsight(null, { status: 'CLEAN' }) === null, 'null promo stays null'); - const weird = { kind: 'insight' }; - assert(rufloHonestInsight(weird, { status: 'CLEAN' }) === weird, 'promo without text passes through'); +test('the footer no longer defines the CVE overlay functions', () => { + assert(retiredOverlay[0] === 'undefined' && retiredOverlay[1] === 'undefined', + 'expected rufloLocalSecurity and rufloHonestInsight to be gone, got ' + retiredOverlay.join(', ')); }); // ── AI defense (AIMDS): ALARM-ONLY, three-state, fail-safe ────────────────── @@ -534,7 +408,7 @@ test('unresolvable ruflo → silent (a probe miss must never fail loud and wrong // Test-quality Finding 5: bump deliberately when adding/removing a test — // see admin-model.test.cjs's identical guard for the full rationale. -const EXPECTED = 46; +const EXPECTED = 31; if (passed + failed !== EXPECTED) { console.error(`\nPLAN MISMATCH: expected ${EXPECTED} tests, ran ${passed + failed}`); process.exit(1); From b6d3740232b0400c8f43eb24c9e06674973e4efd Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:57:51 -0700 Subject: [PATCH 06/45] docs(status): drop the stale "#2986 pending" note Ruflo shipped `migrate fix --agents` in 3.38.2, below the support window. The scaffold-agents info row now says the installed Ruflo predates it and points to ak sync. The section gains gaps/fixAvailable seams for a hermetic test. The registry marks ruvnet/ruflo#2986 and ruvnet/ruflo#2985 (the same ak change) adopted. --- .../status/sections/scaffold-agents.mjs | 20 ++++++++-------- .../agentic-dependency-constraints.json | 10 ++++---- tests/kit/scaffold.test.mjs | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/commands/status/sections/scaffold-agents.mjs b/src/commands/status/sections/scaffold-agents.mjs index 402ef59d..b8f3b560 100644 --- a/src/commands/status/sections/scaffold-agents.mjs +++ b/src/commands/status/sections/scaffold-agents.mjs @@ -1,30 +1,32 @@ // Scaffold agents (ADR-128 Phase 2 removals — ruflo#2985). Upstream never // revisits an existing scaffold, so projects inited before ruflo 3.38.x are // missing up to 9 plugin-canonical agents (coder, researcher, reviewer, …). -// The fix is upstream's `ruflo migrate fix --agents` (PR #2986): when the -// installed CLI ships it, the row carries a fix and sync delegates; until -// then it is advisory-only — a kit-side restore would fork plugin-canonical -// content. Spawn-free (dist probe + file walk), project-scoped: silent when -// the cwd has no .claude/agents tree. +// The fix is upstream's `ruflo migrate fix --agents` (ruvnet/ruflo#2986, +// released in 3.38.2, below the support window): when the installed CLI ships +// it, the row carries a fix and sync delegates. An older CLI is outside the +// window, so the row points to `ak sync` (which upgrades Ruflo) and stays +// advisory — a kit-side restore would fork plugin-canonical content. +// Spawn-free (dist probe + file walk), project-scoped: silent when the cwd +// has no .claude/agents tree. import { removedAgentGaps, upstreamFixAvailable } from '../../../lib/scaffold.mjs'; import { row } from '../row.mjs'; export default { id: 'scaffold-agents', - async collect({ cwd }) { + async collect({ cwd, gaps: findGaps = removedAgentGaps, fixAvailable = upstreamFixAvailable }) { const rows = []; try { - const { relevant, gaps } = removedAgentGaps(cwd); + const { relevant, gaps } = findGaps(cwd); if (relevant && gaps.length > 0) { const named = gaps.slice(0, 3).map((g) => g.basename.replace(/\.md$/, '')).join(', '); const suffix = gaps.length > 3 ? ', …' : ''; - if (upstreamFixAvailable()) { + if (fixAvailable()) { rows.push(row('scaffold-agents', 'warn', `${gaps.length} ADR-128-removed agent(s) missing from .claude/agents (${named}${suffix})`, 'sync delegates to `ruflo migrate fix --agents`')); } else { rows.push(row('scaffold-agents', 'info', - `${gaps.length} ADR-128-removed agent(s) missing (${named}${suffix}) — installed ruflo lacks \`migrate fix --agents\` (ruflo#2986 pending); upgrade ruflo or install the owning plugins`)); + `${gaps.length} ADR-128-removed agent(s) missing (${named}${suffix}) — installed Ruflo predates \`migrate fix --agents\` (added in 3.38.2, below the support window): run \`ak sync\` to upgrade Ruflo, or install the owning plugins`)); } } else if (relevant) { rows.push(row('scaffold-agents', 'ok', 'ADR-128-removed agents present or plugin-covered')); diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 9948006c..5e908380 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -1197,13 +1197,14 @@ "files": ["src/commands/status/sections/scaffold-agents.mjs", "src/lib/scaffold.mjs"] }, "adjustment": "fix(status): scaffold-agents advice names ruflo >= 3.38.2 instead of \"ruflo#2986 pending\"; keep the file probe and the delegation.", - "status": "released", + "status": "adopted", "constraintIds": [], "history": [ { "date": "2026-08-12", "event": "filed" }, { "date": "2026-08-12", "event": "closed", "note": "completed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.38.2; the ak change is pending" } + { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.38.2; the ak change is pending" }, + { "date": "2026-09-27", "event": "adopted", "note": "same ak change as ruvnet/ruflo#2986: the status note names 3.38.2, below the support window; removal proof: tests/kit/scaffold.test.mjs" } ] }, { @@ -1220,13 +1221,14 @@ "files": ["src/commands/status/sections/scaffold-agents.mjs", "src/commands/sync.mjs", "src/lib/scaffold.mjs"] }, "adjustment": "fix(status): scaffold-agents advice names ruflo >= 3.38.2 instead of \"ruflo#2986 pending\"; keep the file probe and the delegation.", - "status": "released", + "status": "adopted", "constraintIds": [], "history": [ { "date": "2026-08-12", "event": "filed" }, { "date": "2026-08-12", "event": "closed", "note": "merged" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.38.2; the ak change is pending" } + { "date": "2026-09-26", "event": "released", "note": "fix first released in 3.38.2; the ak change is pending" }, + { "date": "2026-09-27", "event": "adopted", "note": "status note says the fix is in 3.38.2, below the support window (floor 3.39.0), and points to ak sync; file probe and delegation kept; removal proof: tests/kit/scaffold.test.mjs" } ] }, { diff --git a/tests/kit/scaffold.test.mjs b/tests/kit/scaffold.test.mjs index c5cd9d3b..9ee1126e 100644 --- a/tests/kit/scaffold.test.mjs +++ b/tests/kit/scaffold.test.mjs @@ -134,3 +134,26 @@ test('runScaffoldAgentsFix flags non-convergence when gaps remain after a zero e assert.match(r.detail, /gap\(s\) remain/); } finally { rm(cwd); rm(homeDir); } }); + +// The status row below the fix: `migrate fix --agents` shipped in Ruflo 3.38.2, +// which is below the support window, so the old "#2986 pending" note is stale. +test('without the upstream restore the row points to ak sync, not a pending upstream fix', async () => { + const { default: section } = await import('../../src/commands/status/sections/scaffold-agents.mjs'); + const cwd = tmpProject(); + const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-dist-')); + const homeDir = tmpHome(); + try { + const rows = await section.collect({ + cwd, + gaps: (dir) => removedAgentGaps(dir, { homeDir }), + fixAvailable: () => upstreamFixAvailable({ cliDist: dist }), + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].level, 'info'); + assert.match(rows[0].message, + /installed Ruflo predates `migrate fix --agents` \(added in 3\.38\.2, below the support window\): run `ak sync`/); + assert.doesNotMatch(rows[0].message, /#2986 pending/); + } finally { + rm(cwd); rm(dist); rm(homeDir); + } +}); From 4b2f6989c63195f834c41542473c8d6d35ee93c9 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 09:59:28 -0700 Subject: [PATCH 07/45] =?UTF-8?q?docs(adr):=20record=20the=20Ruflo=20suppo?= =?UTF-8?q?rt=20window=20(ADR-0041=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0041 §7 records the rule, where it lives (supportWindow on the Ruflo dependency policy), the remembered evidence (kit.json versionCheck.rufloMinors, written only by an upgrading ak sync) and the watch's hold. The hook-assurance DDD context gains SupportWindow. UPGRADING, UPSTREAM-WATCH and TROUBLESHOOTING describe the window, the waiting-for-window group and the retired statusline overlay; ak sync --help says when release dates are read. --- docs/TROUBLESHOOTING.md | 3 +++ docs/UPGRADING.md | 15 +++++++++++++ docs/UPSTREAM-WATCH.md | 11 ++++++++-- ...st-neutral-hook-configuration-assurance.md | 22 +++++++++++++++++-- docs/ddd/hook-configuration-assurance.md | 3 +++ src/commands/sync.mjs | 3 +++ 6 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index b7fd01cb..d2dcdc3b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -37,6 +37,8 @@ ak sync # apply it | Codex receives automatic deja-vu recall while Agentic Kit says MCP mode | A user-owned Codex deja-vu plugin can contribute session/per-prompt/precompaction hooks independently of Agentic Kit's mode | disable/remove that plugin through Codex if MCP-only behavior is required. `ak sync` preserves external plugins and reports the effective auto surface without claiming a fix | | `--purge-deja-vu-data` refuses the index path | The observed path is broad, relative, outside an approved data root, overlaps config/transcript sources, or crosses a symlink | move/reconfigure the derived index safely, run `deja doctor --offline`, then retry. Never bypass the guard by deleting a host transcript root | | 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` says `Ruflo … is unsupported: below the support window` | ak supports the newest six Ruflo minors, never fewer than those released in the last 30 days. Workarounds for Ruflo defects fixed before the window's floor have been removed, so an older Ruflo may misbehave | `ak sync` upgrades Ruflo. See [Ruflo support window](UPGRADING.md#2026-09-27-ruflo-support-window) | +| `status` says `Ruflo support window not yet known` | ak has not read Ruflo's release dates yet; `ak status` never looks them up itself | Run `ak sync` (not `--dry-run` or `--no-upgrade`): it reads and remembers them | | `status` shows a host `installed but not executable` | npm exits 0 even when an optional dependency fails, so a package can be recorded without its platform binary. Codex ships its binary as per-platform versions (for example `@openai/codex-darwin-arm64`) published minutes after the main version, so an upgrade in that window can leave `codex` unable to start | `ak sync` reinstalls an npm-owned host and verifies it starts; upgrades and installs already retry once with `--prefer-online`. An external (mise/native/brew) install is reinstalled with its own tool | | `status` shows `natives … WASM fallback` | agentdb resolved a non-native better-sqlite3 — on this path **memory writes can silently vanish**. Common causes are npm ≥11.17 blocking install scripts during upgrades, or a stale better-sqlite3 ≤12.9 pin on Node 26 | `ak sync` selects a Node-compatible release and installs the native binding | | `status` says `ak applied Ruflo's native SQLite pin (ruvnet/ruflo#2219)` | To install a native better-sqlite3 where a bundled package could not find one, `ak sync` changed that package's own better-sqlite3 line (npm refuses the install otherwise). Ruflo pins better-sqlite3 to 12.8.0 or later for the same reason, but `npm install -g` does not apply Ruflo's pin. The row names each file, field, original value and ak's value | Nothing to do. `ak uninstall` puts each original value back where the file still holds ak's value. A Ruflo upgrade or reinstall replaces the file; `status` then says the edit is no longer there. Edits made before ak kept receipts are not listed and cannot be restored by ak; reinstall Ruflo if you want its shipped files back | @@ -47,6 +49,7 @@ ak sync # apply it | `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 | | Statusline footer is blank or stale with no visible error | Footer probes are intentionally silent during normal rendering | Set `AK_STATUSLINE_DEBUG=1` for one reproduction. Redacted stage/error metadata goes to `$XDG_STATE_HOME/agentic-kit/statusline-debug.log` (default `~/.local/state/agentic-kit/statusline-debug.log`, mode 0600, bounded at 64 KiB); set `AK_STATUSLINE_DEBUG_FILE` to redirect it, then unset debug | +| Statusline security line shows Ruflo's own scan status | ak no longer overlays Ruflo's security count: Ruflo fixed its fabricated CVE count in 3.32.2, below the support window. `ak sync` removes the overlay an older ak injected | Nothing to do. For a current result run `ruflo security scan`; use `npm audit` for dependency CVEs | | Statusline shows a Ruflo version you do not have installed (for example `RuFlo V9.9.9`) | Ruflo's helper bakes a version into `.claude/helpers/statusline.cjs` as a floor and shows the highest version it finds. A baked value above every install never corrects itself. `ak status` flags it on the `statusline` row | `ak sync`: it clears the helper stamp so Ruflo's own refresh regenerates the helper, then re-injects the footer. `ak` never writes the version. If Ruflo's refresh cannot run (`.claude/helpers/.LOCKED` or `RUFLO_HELPERS_LOCKED`), edit `let ver` in that file to the installed version or lower. A version newer than `ak status` can also come from a newer Ruflo copy the helper finds, such as the Claude plugin marketplace checkout. That is Ruflo's own choice and `ak` leaves it alone | | Codex's native status line did not change | Codex reads the user-wide setting when a session starts; an existing TUI may not hot-reload it | Exit and start a new Codex session; inspect ownership with `ak x statusline status` and drift with `ak status` | | The right side of Codex's status line is missing | Codex has one width-constrained native line | Widen the terminal or choose the compact preset with `ak x statusline codex native` | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 56bc7c77..218f27c7 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -48,6 +48,21 @@ old hosts and origins, so the Footprint snapshot schema advances to v8. This bui snapshot as unreadable until you run **Full scan** in System or `ak system --deep`. It is never shown under the new rule. See [ADR-0060](adr/0060-session-surface-initiator-and-product-names.md) §3. +## 2026-09-27: Ruflo support window + +ak supports the newest six Ruflo minor versions, and never fewer than the minors released in the +last 30 days. The oldest supported minor is the window's floor (for example `3.39.0`). `ak status` +shows a `versions` row for it: + +- **inside the support window**: your Ruflo is supported; the row names the floor and when ak last + read Ruflo's release dates. +- **unsupported**: your Ruflo is below the floor. ak's workarounds for Ruflo defects fixed before + the floor are gone, so an older Ruflo may misbehave. Run `ak sync` to upgrade it. +- **not yet known**: ak has not read Ruflo's release dates yet. Run `ak sync`. + +`ak status` never looks the dates up itself. A plain `ak sync` reads them from npm and remembers them +in `kit.json` (`versionCheck.rufloMinors`); `ak sync --dry-run` and `ak sync --no-upgrade` do not. + ## 2026-09-26: `ak sync`'s exit code ignores fixes you do by hand `ak sync` now exits 0 when everything it can repair has converged, even if a row whose fix you diff --git a/docs/UPSTREAM-WATCH.md b/docs/UPSTREAM-WATCH.md index 6b6f59a4..8a73ca9b 100644 --- a/docs/UPSTREAM-WATCH.md +++ b/docs/UPSTREAM-WATCH.md @@ -85,6 +85,11 @@ node scripts/upstream-watch.mjs report [--json] node scripts/upstream-watch.mjs check --since [--ledger ] [--json] ``` +The Ruflo support window (the newest six minors, never fewer than those released in the last +30 days; `supportWindow` on the Ruflo dependency policy, ADR-0041 §7) comes from the npm release +dates the check reads. When they cannot be read, or `gh` is signed out, nothing is held for the +window. + `report` gives counts, then these groups (a thread can be in more than one): | Group | Rule | @@ -93,6 +98,7 @@ node scripts/upstream-watch.mjs check --since [--ledger ] [--js | Released and actionable | Upstream fixed, and a published release contains the merged fixing pull request (or closing commit), checked against the repository's tag for that version, or the registry records the first fixed version (`minVersion`). The entry is `watching` or `fixed-unreleased` and ak has an adjustment. Carries the dispatch branch and removal proof. | | Released, fix not confirmed | A release came out after the fix, but ak could not prove it contains the fixing change (no merged pull request closed the thread, or no tag for that version). Confirm by hand and record `minVersion`. Never dispatched. | | Fixed upstream, ak still carries the workaround | The entry is `released` or `dispatched` and ak has an adjustment. | +| Released, waiting for the support window | A Ruflo entry that would be in one of the two groups above, but its first fixed version is above the Ruflo support window's floor (`supportWindow.floor` in the JSON report). No dispatch: the workaround stays until the oldest supported Ruflo has the fix. | | Fixed upstream, not yet released | Upstream fixed, no release contains it, the entry is `watching` or `fixed-unreleased`, and ak has an adjustment. | | Reopened upstream | Open upstream while the entry says fixed, released, dispatched or adopted. | | Waiting on upstream | Open, not stale, and nobody is waiting on us. | @@ -122,7 +128,8 @@ fixing change when the release was confirmed from it), `reopened`, `stale`, `retire-proposed`, `retest-due` (constraint id) and `idle` (id `registry`, nothing left to watch). `check --since` limits replies, acknowledgements, closures and merges to activity after `--since`. The other events repeat while their condition holds, dated by the upstream fact, so -the same fact always gives the same line. `--ledger ` drops any line already in that file, +the same fact always gives the same line. A `released` line for a fix held for the support +window has no `branch=` field; the line with one appears once the window's floor contains the fix. `--ledger ` drops any line already in that file, so an exact line the routine recorded is never acted on twice. The file holds only the routine's own comments: a line someone else posted would suppress a real event. Each posted comment ends with `checked-at