@sabaiway/agent-workflow-kit 1.36.0 → 1.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/SKILL.md +7 -3
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +22 -0
- package/references/modes/review-ledger.md +1 -1
- package/tools/commands.mjs +7 -0
- package/tools/fold-completeness-run.mjs +531 -0
- package/tools/fold-completeness.mjs +364 -0
- package/tools/procedures.mjs +12 -3
- package/tools/review-ledger-write.mjs +8 -4
- package/tools/review-ledger.mjs +34 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.37.1 — Fix: the fold-completeness probe on Node 18/20 counted pattern-filtered SKIP lines as executed tests
|
|
8
|
+
|
|
9
|
+
A **patch** release (one product fix + its pinned fixtures; no other change). On Node 18/20 —
|
|
10
|
+
versions the kit supports — `node --test --test-name-pattern` EMITS every non-matching test as
|
|
11
|
+
`ok N - <name> # SKIP test name does not match pattern` (newer Node omits them), so the 1.37.0
|
|
12
|
+
probe parser counted those lines as real matches: a `testId` whose pattern matches NOTHING was
|
|
13
|
+
reported **resolvable with a green baseline**, and the fold-completeness gate green-vouched a test
|
|
14
|
+
that never ran — defeating the gate's purpose on exactly those Node versions. Caught by the CI
|
|
15
|
+
matrix (node 18 + 20) on the 1.37.0 release commit; invisible on newer local Node.
|
|
16
|
+
|
|
17
|
+
- `parseProbeOutput` now ignores any TAP result line carrying a **SKIP/TODO directive** — a skipped
|
|
18
|
+
test was not executed, on any Node version; a real test name containing a literal `# skip` would
|
|
19
|
+
only fail CLOSED (unresolvable), never open.
|
|
20
|
+
- New pinned fixtures: the node-18/20 pattern-filter TAP shape (nomatch → unresolvable; a matched
|
|
21
|
+
test among skipped ones still resolves), a lowercase `# skip`, and a `# TODO` directive.
|
|
22
|
+
|
|
23
|
+
## 1.37.0 — Fold-safety completion: a fixable-bug requires its test, and a coverage gate attests the fold against the changed code (AD-046)
|
|
24
|
+
|
|
25
|
+
A **feature** release (deployment-lineage head stays `1.3.0` — no migration). AD-045's ledger computed
|
|
26
|
+
WHEN a review loop stops; this release mechanizes **"a fold carries no new bug"** — M2 + M3a of
|
|
27
|
+
DEBT-TEST-COMPLETENESS. **No mutation testing ships** — that half was researched and shelved (see the
|
|
28
|
+
honest-limits note below).
|
|
29
|
+
|
|
30
|
+
- **M2 — testId enforcement (ledger schema v2).** A `fixable-bug` triage classification now REQUIRES a
|
|
31
|
+
`testId` (`<test-file>#<test-name-pattern>` — a `#` separator with both halves non-empty; the writer
|
|
32
|
+
validates FORMAT only, staying hermetic). Schema 1→2 with per-version validation: historical v1
|
|
33
|
+
records stay valid on read, a mixed ledger reads back clean; the writer emits v2 only; `decideStop`
|
|
34
|
+
is untouched.
|
|
35
|
+
- **M3a — the fold-completeness tool pair (read/run split, mirroring the ledger's read/write split).**
|
|
36
|
+
`tools/fold-completeness-run.mjs` — the SOLE tree-toucher + result writer: ONE suite run under
|
|
37
|
+
`NODE_V8_COVERAGE` (the coverage dir lives OUTSIDE the work tree), the changed surface classified by
|
|
38
|
+
a CLOSED extension rule (assessable JS · unsupported TS/JSX fails the gate closed · out-of-domain
|
|
39
|
+
docs/config listed loudly, never blocking), every bound testId probed shell-free for resolvability +
|
|
40
|
+
a GREEN baseline, and ONE machine-only record bound to BOTH the tree fingerprint AND the sorted
|
|
41
|
+
fixable-bug testId set — either moving makes the record stale. `tools/fold-completeness.mjs` — the
|
|
42
|
+
read-only `--status` / `--check` gate (fail-closed; the normative exit contract lives in its header,
|
|
43
|
+
the single home) that never imports the runner (import-split test).
|
|
44
|
+
- **Command surface** — the `fold-completeness` catalog entry (a writer) + `### Mode: fold-completeness`
|
|
45
|
+
+ its mode reference. **Consumer seeding is deliberately ON HOLD**: the signal is JS/V8-only in v1,
|
|
46
|
+
so the consent-gated seeder does not offer this gate yet — wire it by hand (the mode-ref carries the
|
|
47
|
+
candidate `gates.json` line and the hold's reason).
|
|
48
|
+
- **Activity-aware canon pointer (with engine 1.14.0)** — the procedures advisor renders the ledger
|
|
49
|
+
pointer (record / `--status` / `--check`) for `plan-execution` ONLY, plus an unconditional
|
|
50
|
+
triage-classification bullet (`fixable-bug / inherent-layer-residual / escalate`) for every
|
|
51
|
+
review-backed activity.
|
|
52
|
+
- **Honest limits (stated in the tool headers, like `review-state`'s):** coverage proves EXECUTION, not
|
|
53
|
+
assertion — the per-fold proof remains the red→green test discipline (M2), the coverage run is the
|
|
54
|
+
whole-surface prefilter; records/testIds are forgeable (a self-discipline mechanism, not a security
|
|
55
|
+
boundary); line-entry granularity (same-line branch gaps are out of scope without an AST).
|
|
56
|
+
**Mutation (M3b) was researched and SHELVED** — bounded local-boundary mutation did not catch the
|
|
57
|
+
motivating interaction bug and is not language-independent; records carry a reserved EMPTY `mutation`
|
|
58
|
+
shape and the checker fails CLOSED on any record carrying mutation data.
|
|
59
|
+
|
|
7
60
|
## 1.36.0 — Review-round ledger: the prose crossover-stop becomes a computed signal (AD-045)
|
|
8
61
|
|
|
9
62
|
A **feature** release (deployment-lineage head stays `1.3.0` — no migration). The review-loop
|
package/README.md
CHANGED
|
@@ -236,6 +236,8 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
236
236
|
| `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<recipe>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend, never commits**; hand-editing stays fully supported. |
|
|
237
237
|
| `/agent-workflow-kit review-state` | any time | **read-only review-receipt checker** — makes "reviewed ≠ shipped" detectable: the bridge review wrappers append a receipt per successful review (into a file inside the git dir — never committable); this checks that every backend your configured `plan-execution.review` recipe names holds a **fresh, grounded receipt for the current uncommitted tree** (any later edit moves the fingerprint and stales the receipt; a review continuation never re-attests a folded tree). `--check` gives a gate exit code to declare in `docs/ai/gates.json` **by hand or via the consent-gated seeder** (preview → your explicit yes; never without consent). Never writes, never commits, never runs a subscription CLI; it spawns read-only `git` queries to fingerprint the tree — and `git commit --no-verify` stays possible (discipline, not a sandbox). |
|
|
238
238
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. Prints to stdout; `--out` writes **one scratch file only** (gitignored / outside the repo — a tracked or not-ignored in-repo path is refused). Never commits, never runs a subscription CLI. |
|
|
239
|
+
| `/agent-workflow-kit review-ledger` | any time | **review-round ledger** — turns the review-loop stop rule into a **computed** signal: record each plan-execution review round + its triage into a JSONL ledger inside the git dir (never committable), and read the stop decision (`converged > resolved-residual > triage-required > continue`) from the records — never from a remembered rule. `--status` replaces the hand-composed per-round tally; `--check` is a fail-closed gate exit code for `docs/ai/gates.json` (by hand or via the consent-gated seeder). A `fixable-bug` triage **requires its red→green `testId`** (schema v2; older records stay valid on read). The writer refuses a round past the hard-max, while a triage is pending, or without a grounded review receipt. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
240
|
+
| `/agent-workflow-kit fold-completeness` | any time | **fold-completeness gate** — attests the review loop's folded fixes against the changed code: the runner executes your suite ONCE under `NODE_V8_COVERAGE`, checks every changed executable line is **executed** and every bound `testId` resolves + starts GREEN, and appends one result record bound to BOTH the tree fingerprint AND the sorted testId set (either moving stales it — re-run after the last edit); the separate read-only checker (`--status` / `--check`) turns the record into a fail-closed gate. Honest residuals: coverage proves **execution, not assertion** (the per-fold proof stays the red→green `testId` discipline), and records are forgeable — self-discipline, not a security boundary. v1 scope is JS/V8: changed TS/JSX fails closed, docs/config changes are listed but never block, and **no mutation testing ships**. Wire the gate by hand — the consent-gated seeder deliberately does not offer it yet (JS-only hold). Never commits, never runs a subscription CLI. |
|
|
239
241
|
| `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
|
|
240
242
|
| `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
|
|
241
243
|
| `/agent-workflow-kit agents` | Claude Code · opt-in | **cheap-lane subagents** — places bundled subagent definitions (`.claude/agents/`) pinned to a **cheap model** (haiku, low effort, read-only tools) for mechanical work: extraction sweeps, changelog fact-skeletons, gate-failure triage. Judgment, review, and real code stay on your main lane — these vehicles only extract and draft, and their output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.37.1'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -81,7 +81,7 @@ Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide
|
|
|
81
81
|
1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`1.3.0`). This is the **only** axis this skill compares.
|
|
82
82
|
2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version is **not** the lineage head.
|
|
83
83
|
|
|
84
|
-
**Refreshed the kit but nothing changed?**
|
|
84
|
+
**Refreshed the kit but nothing changed?** After `npx @sabaiway/agent-workflow-kit@latest init` updates `~/.claude/skills/agent-workflow-kit/`, **restart the session** so the agent reloads the new skill files.
|
|
85
85
|
|
|
86
86
|
### Mode: help
|
|
87
87
|
|
|
@@ -159,11 +159,15 @@ writer — read `${CLAUDE_SKILL_DIR}/references/modes/grounding.md` before actin
|
|
|
159
159
|
|
|
160
160
|
writer — read `${CLAUDE_SKILL_DIR}/references/modes/review-ledger.md` before acting.
|
|
161
161
|
|
|
162
|
+
### Mode: fold-completeness
|
|
163
|
+
|
|
164
|
+
writer — read `${CLAUDE_SKILL_DIR}/references/modes/fold-completeness.md` before acting.
|
|
165
|
+
|
|
162
166
|
---
|
|
163
167
|
|
|
164
168
|
## References
|
|
165
169
|
|
|
166
|
-
- [`references/modes/`](${CLAUDE_SKILL_DIR}/references/modes/) — one file per mode
|
|
170
|
+
- [`references/modes/`](${CLAUDE_SKILL_DIR}/references/modes/) — one file per mode; [`references/shared/`](${CLAUDE_SKILL_DIR}/references/shared/) — the shared point-of-use contracts (report footer · composition hand-off · deploy tail).
|
|
167
171
|
- [`references/contracts.md`](${CLAUDE_SKILL_DIR}/references/contracts.md) — the three setup contracts.
|
|
168
172
|
- [`references/templates/`](${CLAUDE_SKILL_DIR}/references/templates/) — the `AGENTS.md` + `docs/ai/` templates + seeded configs; [`references/scripts/`](${CLAUDE_SKILL_DIR}/references/scripts/) — the Node enforcement scripts; [`migrations/`](${CLAUDE_SKILL_DIR}/migrations/) — per-version upgrade steps; [`launchers/`](${CLAUDE_SKILL_DIR}/launchers/) — non-Claude agent launchers.
|
|
169
173
|
- [`tools/`](${CLAUDE_SKILL_DIR}/tools/) — the family-wide tooling; each tool's contract lives with its mode file and its own header; bridge mirrors under [`bridges/`](${CLAUDE_SKILL_DIR}/bridges/).
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.1",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
### Mode: fold-completeness
|
|
2
|
+
|
|
3
|
+
The FOLD-COMPLETENESS gate (M3a / AD-046, DEBT-TEST-COMPLETENESS) — the sibling of the review-round ledger: the ledger computes WHEN the plan-execution review loop stops; this tool pair verifies that the tests the loop's folds bind to ACTUALLY pin the changed code. One recorded coverage run proves (a) every changed executable line is executed by the suite and (b) every `fixable-bug` bound testId resolves and starts GREEN; a read-only checker turns the recorded result into a gate exit code.
|
|
4
|
+
|
|
5
|
+
**Two tools — the read/run split:**
|
|
6
|
+
|
|
7
|
+
1. **Runner** — `node ${CLAUDE_SKILL_DIR}/tools/fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]` — the SOLE tree-toucher and the SOLE result writer. One run over the in-flight plan-execution loop's dirty tree: resolve the loop (the single in-flight plan stem — 0 or >1 plans is a typed refusal), classify the changed surface by a CLOSED extension rule (assessable JS / unsupported TS-JSX / out-of-domain), run the suite ONCE under `NODE_V8_COVERAGE` (the coverage dir lives OUTSIDE the work tree — no repo artifacts), map every changed executable line to covered/uncovered from the V8 report itself, probe each bound testId once (shell-free spawn) for resolvability + a green baseline, and append ONE machine-only record — bound to BOTH the tree fingerprint AND the sorted `fixable-bug` testId set — to `<git dir>/agent-workflow-fold-completeness.jsonl` (never committable by construction; `AW_FOLD_RESULTS` overrides).
|
|
8
|
+
2. **Read-only checker** — `node ${CLAUDE_SKILL_DIR}/tools/fold-completeness.mjs [--check | --status | --json]` — never runs tests, never touches the tree, never imports the runner (an import-split test pins the structural split). `--status` (default) is the human report; **`--check` is the gate exit code — the normative exit contract lives in the CHECKER's tool header (the single home — do not re-enumerate it elsewhere)**; `--json` is the structured state + decision.
|
|
9
|
+
|
|
10
|
+
**Wire the gate BY HAND, once, BEFORE the round's runner run.** The candidate line for your own `docs/ai/gates.json`: `{ "id": "fold-completeness", "title": "Fold-completeness — every changed executable line executed by tests (M3a)", "cmd": "node \"<path-to-this-skill>/tools/fold-completeness.mjs\" --check" }` — the path your project reaches the kit by, kept inside the escaped quotes so a path with spaces survives. Wiring comes FIRST because where `docs/ai` is tracked, the gate-file edit itself moves the tree fingerprint — wiring after a run stales the record you just wrote. The consent-gated seeder does NOT offer this entry yet — a deliberate v1 hold: the signal is JS/V8-only, and offering it to a project whose source the runner cannot assess would seed a gate that can only fail closed; seeder offering arrives with a language-aware verification profile.
|
|
11
|
+
|
|
12
|
+
**Run-then-check usage (each plan-execution round):** run the runner AFTER the round's LAST edit — any later tree edit (a source fix, a doc touch, a gate-file tweak) moves the fingerprint and requires a re-run — then the wired `--check` attests it. Staleness is by BOTH bindings: a tree edit moves the fingerprint, and a new `fixable-bug` triage moves the bound-testId set — either makes the previous record STALE, so a green check always attests the CURRENT tree against the CURRENT fold set.
|
|
13
|
+
|
|
14
|
+
**Suite + bound-test commands:** the suite command defaults to the `unit-tests` gate `cmd` in the project's `docs/ai/gates.json`; absent that, `--suite "<cmd>"` (or `AW_FOLD_SUITE_CMD`) is REQUIRED — refused loudly otherwise. Bound-test probes default to the `node --test --test-name-pattern` shape over the testId halves (`<test-file>#<test-name-pattern>`); a consumer on another runner sets `AW_FOLD_BOUND_CMD` — a JSON ARRAY of argv strings with `{file}` / `{pattern}` placeholder substitution, spawned WITHOUT a shell (testId content never reaches a shell). Note: the suite's own child processes must inherit `NODE_V8_COVERAGE` for their lines to count (node child spawns inherit it by default; a test runner that scrubs its child env needs consumer configuration).
|
|
15
|
+
|
|
16
|
+
**v1 scope (stated):** assessable = `.mjs` / `.cjs` / `.js`, excluding test files (`*.test.*` / `*.spec.*`); changed `.ts` / `.tsx` / `.jsx` / `.mts` / `.cts` files are recorded as unsupported and FAIL the gate CLOSED — the signal never vouches for JS-family source it cannot assess; everything else (docs/config the suite does not execute) is recorded out-of-domain and LOUDLY listed, never gate-blocking. Executability is V8-derived: a changed assessable file entirely absent from the coverage report is RED at file level ("never imported" must not read as "nothing executable").
|
|
17
|
+
|
|
18
|
+
**Mutation is NOT shipped (v1).** The result record carries a reserved, always-empty `mutation` shape; the shipped runner performs NO mutation and never modifies a source file, and the checker FAILS CLOSED on a record carrying ANY mutation data (such a record was not produced by this runner version). The researched mutation half was shelved: a bounded operator set tests local expression boundaries, not the cross-module interactions that produce real fold bugs, and it is not language-independent. The `AW_FOLD_MUTANTS_MAX` / `AW_FOLD_HUNK_MUTANTS_MAX` / `AW_FOLD_TIME_BUDGET_S` budgets are parsed and recorded but inert in v1 (reserved).
|
|
19
|
+
|
|
20
|
+
**Honest residuals (stated, accepted — exactly like review-state's / review-ledger's):** coverage proves EXECUTION, not assertion — an executed-but-unasserted line still reads covered; the per-fold proof remains the M2 discipline (a `fixable-bug` testId written red→green, probed green here), the coverage run is the whole-surface prefilter; the result records, testIds, and ledgers are forgeable (`git commit --no-verify`, file editing) — a self-discipline mechanism against silent process drift, NOT a security boundary.
|
|
21
|
+
|
|
22
|
+
**Invariants:** the checker is read-only · never commits · never runs a subscription CLI · spawns read-only `git` queries only · the SOLE tree-toucher + result writer is `fold-completeness-run.mjs`, which the checker NEVER imports (read/run split, import-split test) · the runner's only writes land OUTSIDE the work tree (the coverage temp dir + the git-dir result ledger) — the tree fingerprint is byte-identical before and after a run.
|
|
@@ -11,7 +11,7 @@ The review-round **LEDGER** (DEBT-REVIEW-CAP / AD-045) — it turns the prose re
|
|
|
11
11
|
|
|
12
12
|
2. **Writer** — `node ${CLAUDE_SKILL_DIR}/tools/review-ledger-write.mjs record|classify --json '<payload>'` (the SOLE writer, over the shared `atomic-write` core):
|
|
13
13
|
- `record` appends one round — `{ loop, round, origins, backends, findings }` (activity defaults to `plan-execution`). **The teeth (Decision 5):** it REFUSES (typed STOP) to append a round WHILE `decideStop` on the existing records is `triage-required` (an UNCLASSIFIED surviving blocking finding at/after the cap), and refuses ANY round beyond the **hard-max ceiling of 3** unconditionally. **Integrity binding (Decision 7):** each NON-degraded backend needs a grounded **code** receipt (from `codex-review code` / `agy-review code --facts @f`) for the current tree — a round cannot be recorded for a tree no bridge reviewed.
|
|
14
|
-
- `classify` appends one triage — each surviving blocking finding classified `fixable-bug` / `inherent-layer-residual` / `escalate`. **This is what BREAKS the deadlock:** once every surviving blocking finding is classified, `record` permits the next round (a `fixable-bug` classification lets the fix round run — no deadlock).
|
|
14
|
+
- `classify` appends one triage — each surviving blocking finding classified `fixable-bug` / `inherent-layer-residual` / `escalate`. **A `fixable-bug` REQUIRES a `testId`** (M2/AD-046, schema v2) — the red→green test that pins the fold, formatted `<test-file>#<test-name-pattern>` (a `#` separator, both halves non-empty; no file-suffix rule); `inherent-layer-residual` / `escalate` may omit it. The writer validates the FORMAT only. **This is what BREAKS the deadlock:** once every surviving blocking finding is classified, `record` permits the next round (a `fixable-bug` classification lets the fix round run — no deadlock).
|
|
15
15
|
|
|
16
16
|
**The computed stop (`decideStop`, precedence `converged > resolved-residual > triage-required > continue`).** Machine fields only (counts, `class`, `accepted`, `fingerprint`), never free-text:
|
|
17
17
|
- **converged** — every recipe-named backend present, **non-degraded, at 0 blockers + 0 majors** in the latest round, at the current tree fingerprint. **"Surviving blocking finding" = a blocker OR a major** (a minor never forces triage).
|
package/tools/commands.mjs
CHANGED
|
@@ -182,6 +182,13 @@ const CATALOG = [
|
|
|
182
182
|
kind: WRITER,
|
|
183
183
|
oneLine: 'Record each review round and read the computed crossover-stop for the plan-execution loop (converged / accepted-residual / triage-required); --check turns it into a gate exit code and forces a triage before over-running the review.',
|
|
184
184
|
},
|
|
185
|
+
{
|
|
186
|
+
key: 'fold-completeness',
|
|
187
|
+
invocation: invocationOf('fold-completeness'),
|
|
188
|
+
group: 'Orchestrate',
|
|
189
|
+
kind: WRITER,
|
|
190
|
+
oneLine: 'Verify the review loop’s folded fixes are pinned by tests — one recorded coverage run proves every changed executable line is executed and each bound test starts green; --check turns the result into a gate exit code.',
|
|
191
|
+
},
|
|
185
192
|
];
|
|
186
193
|
|
|
187
194
|
// Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fold-completeness-run.mjs — the M3 fold-completeness RUNNER (AD-046, DEBT-TEST-COMPLETENESS). It is
|
|
3
|
+
// the SOLE tree-toucher and the SOLE writer of the fold-completeness result ledger, the write half of
|
|
4
|
+
// the family read/write split (mirrors review-ledger-write.mjs): fold-completeness.mjs (the schema +
|
|
5
|
+
// result reader + the read-only `--check` gate) NEVER imports this module — an import-split test pins
|
|
6
|
+
// that. This module imports the read core the OTHER direction (the result schema + reader + the shared
|
|
7
|
+
// bound-testId collector) and appends records through the shared hardened atomic-write core.
|
|
8
|
+
//
|
|
9
|
+
// One run, over the in-flight plan-execution loop's dirty tree:
|
|
10
|
+
// 1. resolve the loop = the single in-flight plan stem (0 or >1 → typed refusal);
|
|
11
|
+
// 2. classify the changed surface (Decision 5, a CLOSED extension rule — assessable JS / unsupported
|
|
12
|
+
// TS-JSX / out-of-domain) and derive per-file changed line ranges;
|
|
13
|
+
// 3. M3a — run the suite ONCE under NODE_V8_COVERAGE (a dir OUTSIDE the work tree, Decision 8) and
|
|
14
|
+
// map every changed executable line to covered/uncovered via V8 innermost-range-wins (Decision 6);
|
|
15
|
+
// 4. probe each of the loop's fixable-bug bound testIds once (Decision 3 / 10, shell-free) for
|
|
16
|
+
// resolvability + a green baseline;
|
|
17
|
+
// 5. append ONE machine-only result record, bound to BOTH the tree fingerprint AND the sorted
|
|
18
|
+
// fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
|
|
19
|
+
// The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
|
|
20
|
+
// over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
|
|
21
|
+
//
|
|
22
|
+
// HONEST residuals (see fold-completeness.mjs header for the full list): coverage proves execution not
|
|
23
|
+
// assertion; testIds/records are forgeable (a self-discipline mechanism, not a security boundary);
|
|
24
|
+
// TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
|
|
27
|
+
import { join, dirname, basename } from 'node:path';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
32
|
+
import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
|
|
33
|
+
import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
|
|
34
|
+
import {
|
|
35
|
+
RESULT_SCHEMA_VERSION,
|
|
36
|
+
resolveResultsPath,
|
|
37
|
+
validateRunRecord,
|
|
38
|
+
collectBoundTestIds,
|
|
39
|
+
} from './fold-completeness.mjs';
|
|
40
|
+
|
|
41
|
+
const ACTIVITY = 'plan-execution';
|
|
42
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
|
|
43
|
+
|
|
44
|
+
// A typed STOP — a deliberate refusal we surface (loop derivation / a malformed override / a malformed
|
|
45
|
+
// record / an fs error), distinct from a native fs error. The codebase's typed-error idiom (no classes).
|
|
46
|
+
export const FOLD_RUN_STOP = 'FOLD_RUN_STOP';
|
|
47
|
+
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'FoldRunStop', code: FOLD_RUN_STOP });
|
|
48
|
+
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
49
|
+
|
|
50
|
+
const isoNow = () => new Date().toISOString();
|
|
51
|
+
|
|
52
|
+
// Node sets NODE_TEST_CONTEXT for any process running UNDER `node --test`; a fresh `node --test` that
|
|
53
|
+
// inherits it silently SKIPS running its files (the recursive-run guard). The runner spawns `node
|
|
54
|
+
// --test` for the suite + the bound-test probes, so it MUST strip that var — otherwise, whenever the
|
|
55
|
+
// runner is itself invoked from within a test context (e.g. this kit's own fold-completeness-run
|
|
56
|
+
// tests, or a consumer's), the child runs nothing and every file reads as uncovered. Unset in normal
|
|
57
|
+
// (non-test) invocation, so stripping is a no-op there.
|
|
58
|
+
const childTestEnv = (env, extra = {}) => {
|
|
59
|
+
const out = { ...env, ...extra };
|
|
60
|
+
delete out.NODE_TEST_CONTEXT;
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── Decision 5: the CLOSED changed-path classification rule (no heuristics) ───────────────────────
|
|
65
|
+
|
|
66
|
+
const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
|
|
67
|
+
const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
|
|
68
|
+
const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
|
|
69
|
+
|
|
70
|
+
// classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
|
|
71
|
+
export const classifyChangedPath = (rel) => {
|
|
72
|
+
const base = rel.split('/').pop();
|
|
73
|
+
if (TEST_FILE_RE.test(base)) return 'excluded-test';
|
|
74
|
+
const dot = base.lastIndexOf('.');
|
|
75
|
+
const ext = dot >= 0 ? base.slice(dot) : '';
|
|
76
|
+
if (ASSESSABLE_EXT.has(ext)) return 'assessable';
|
|
77
|
+
if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
|
|
78
|
+
return 'out-of-domain';
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ── unified-diff → new-side changed line numbers (line numbers only; content lines are ignored) ───
|
|
82
|
+
|
|
83
|
+
const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
84
|
+
|
|
85
|
+
// parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
|
|
86
|
+
// like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
|
|
87
|
+
// `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
|
|
88
|
+
// `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
|
|
89
|
+
export const parseUnifiedDiff = (diffText) => {
|
|
90
|
+
const map = new Map();
|
|
91
|
+
let current = null;
|
|
92
|
+
let inHeader = false;
|
|
93
|
+
for (const line of String(diffText).split('\n')) {
|
|
94
|
+
if (line.startsWith('diff --git ')) {
|
|
95
|
+
current = null;
|
|
96
|
+
inHeader = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (inHeader && line.startsWith('--- ')) continue;
|
|
100
|
+
if (inHeader && line.startsWith('+++ ')) {
|
|
101
|
+
const p = line.slice(4).trim();
|
|
102
|
+
current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
|
|
103
|
+
inHeader = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const m = DIFF_HUNK_RE.exec(line);
|
|
107
|
+
if (m) {
|
|
108
|
+
inHeader = false;
|
|
109
|
+
if (current == null) continue;
|
|
110
|
+
const start = Number(m[1]);
|
|
111
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
112
|
+
if (count > 0) {
|
|
113
|
+
const arr = map.get(current) ?? [];
|
|
114
|
+
for (let i = 0; i < count; i += 1) arr.push(start + i);
|
|
115
|
+
map.set(current, arr);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
|
|
120
|
+
return map;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
|
|
124
|
+
|
|
125
|
+
// lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
|
|
126
|
+
// '\n' keeps any trailing '\r' inside the line length, so CRLF offsets stay correct.
|
|
127
|
+
export const lineStartOffsets = (sourceText) => {
|
|
128
|
+
const offs = [0];
|
|
129
|
+
for (let i = 0; i < sourceText.length; i += 1) if (sourceText[i] === '\n') offs.push(i + 1);
|
|
130
|
+
return offs;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// effectiveCount(ranges, offset) → the execution count of the SMALLEST (innermost) range containing
|
|
134
|
+
// offset; 0 when no range contains it (absent from this process's report). This is the v8-to-istanbul
|
|
135
|
+
// rule reduced to Node built-ins: a nested count-0 block shadows its executed parent.
|
|
136
|
+
export const effectiveCount = (ranges, offset) => {
|
|
137
|
+
let best = null;
|
|
138
|
+
for (const r of ranges) {
|
|
139
|
+
if (r.startOffset <= offset && offset < r.endOffset) {
|
|
140
|
+
const width = r.endOffset - r.startOffset;
|
|
141
|
+
if (best === null || width < best.width) best = { width, count: r.count };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return best === null ? 0 : best.count;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// computeUncoveredLines({ perProcessRanges, sourceText, changedLines }) → the sorted changed lines that
|
|
148
|
+
// NO process executed. perProcessRanges is one flat range-list per process (process isolation); a line
|
|
149
|
+
// is covered iff ANY process gives its first non-whitespace char a positive effective count. Blank /
|
|
150
|
+
// whitespace-only lines are never executable and never flagged. Callers handle file-absent (an empty
|
|
151
|
+
// perProcessRanges means the file never loaded — a file-level RED decided by the caller, not here).
|
|
152
|
+
//
|
|
153
|
+
// GRANULARITY (stated residual, codex R1 → inherent-layer-residual): this is LINE-ENTRY coverage — the
|
|
154
|
+
// question "was this line entered by some test?", the same granularity c8 reports as line coverage. A
|
|
155
|
+
// same-line uncovered sub-branch (the false arm of `a ? b : c`, the RHS of `a && b()`) is NOT flagged
|
|
156
|
+
// when the line's leading statement executed — flagging it would need branch/AST analysis, and a naive
|
|
157
|
+
// "any count-0 char on the line" rule would FALSE-POSITIVE on an inline uncalled-function definition
|
|
158
|
+
// (`const f = () => never()`, whose body span is count-0 though the assignment ran), i.e. exactly the
|
|
159
|
+
// churn the plan's PRIMARY RISK warns against. Same-line branch gaps would need branch-level analysis
|
|
160
|
+
// (the shelved mutation half / a future parser-backed signal), not M3a line coverage. Chasing sub-line
|
|
161
|
+
// precision here without an AST is explicitly out of scope — a stated residual.
|
|
162
|
+
export const computeUncoveredLines = ({ perProcessRanges, sourceText, changedLines }) => {
|
|
163
|
+
const offs = lineStartOffsets(sourceText);
|
|
164
|
+
const total = sourceText.length;
|
|
165
|
+
const uncovered = [];
|
|
166
|
+
for (const n of changedLines) {
|
|
167
|
+
const start = offs[n - 1];
|
|
168
|
+
if (start === undefined) continue; // a changed line beyond EOF (defensive; should not happen)
|
|
169
|
+
const end = offs[n] ?? total;
|
|
170
|
+
const rel = sourceText.slice(start, end).search(/\S/);
|
|
171
|
+
if (rel < 0) continue; // blank / whitespace-only → not executable
|
|
172
|
+
const offset = start + rel;
|
|
173
|
+
const covered = perProcessRanges.some((ranges) => effectiveCount(ranges, offset) > 0);
|
|
174
|
+
if (!covered) uncovered.push(n);
|
|
175
|
+
}
|
|
176
|
+
return [...new Set(uncovered)].sort((a, b) => a - b);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ── Decision 3 / 10: the bound-test probe ─────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
const PROBE_RESULT_RE = /^(?:ok|not ok) \d+ - (.*)$/; // a column-0 TAP result line
|
|
182
|
+
const PROBE_FAIL_RE = /^# fail (\d+)$/;
|
|
183
|
+
const PROBE_DIRECTIVE_RE = /#\s*(?:skip|todo)\b/i; // a TAP SKIP/TODO directive — the test did NOT run
|
|
184
|
+
|
|
185
|
+
// parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. A node:test
|
|
186
|
+
// run with a pattern that matches NOTHING emits only a file-wrapper result whose description is the
|
|
187
|
+
// file path itself (`ok N - <file>`) on newer node — but node 18/20 ALSO emit every pattern-FILTERED
|
|
188
|
+
// test as `ok N - <name> # SKIP test name does not match pattern`, so a result line carrying a TAP
|
|
189
|
+
// SKIP/TODO directive must never count: the test was not executed, and counting it green-vouches a
|
|
190
|
+
// nonexistent testId on exactly the node versions the kit supports (caught by CI's 18/20 matrix).
|
|
191
|
+
// So `resolvable` = at least one column-0, directive-free result whose description is not the file we
|
|
192
|
+
// passed; `baselineGreen` = resolvable AND the run was green (exit 0 and `# fail 0`). The wrapper is
|
|
193
|
+
// matched by BASENAME, not literally: node normalizes the echoed path ('./x' → 'x', or an absolute
|
|
194
|
+
// path), so a literal desc===fileArg compare would count the wrapper as a real match and falsely
|
|
195
|
+
// report resolvable/green (codex R1). A basename compare is invariant to ./ / abs / rel; a real test
|
|
196
|
+
// name colliding with the file's basename — or containing a literal "# skip" — is absurd and would
|
|
197
|
+
// only fail CLOSED (mark unresolvable), never open.
|
|
198
|
+
export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
199
|
+
let matched = 0;
|
|
200
|
+
let failCount = null;
|
|
201
|
+
const wanted = basename(String(fileArg).trim());
|
|
202
|
+
for (const line of String(stdout).split('\n')) {
|
|
203
|
+
const m = PROBE_RESULT_RE.exec(line);
|
|
204
|
+
if (m && !PROBE_DIRECTIVE_RE.test(m[1]) && basename(m[1].trim()) !== wanted) matched += 1;
|
|
205
|
+
const f = PROBE_FAIL_RE.exec(line.trim());
|
|
206
|
+
if (f) failCount = Number(f[1]);
|
|
207
|
+
}
|
|
208
|
+
const resolvable = matched > 0;
|
|
209
|
+
const fails = failCount ?? (code === 0 ? 0 : 1);
|
|
210
|
+
return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
|
|
214
|
+
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', '--test-name-pattern', pattern, file];
|
|
215
|
+
|
|
216
|
+
// resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
|
|
217
|
+
// overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
|
|
218
|
+
// escape hatch — a consumer on another runner). A malformed override is a typed refusal, never a
|
|
219
|
+
// silent fallback to a shell. Substitution uses function replacers so a `$` in a testId is literal.
|
|
220
|
+
export const resolveBoundArgv = (env = process.env) => {
|
|
221
|
+
const raw = env.AW_FOLD_BOUND_CMD;
|
|
222
|
+
if (!raw) return (file, pattern) => defaultBoundArgv(file, pattern);
|
|
223
|
+
let tmpl;
|
|
224
|
+
try {
|
|
225
|
+
tmpl = JSON.parse(raw);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw stop(`AW_FOLD_BOUND_CMD is not valid JSON (${err.message}) — expected a JSON array of argv strings`);
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(tmpl) || tmpl.length === 0 || !tmpl.every((a) => typeof a === 'string')) {
|
|
230
|
+
throw stop('AW_FOLD_BOUND_CMD must be a non-empty JSON array of argv strings with {file}/{pattern} placeholders');
|
|
231
|
+
}
|
|
232
|
+
return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
|
|
238
|
+
const gitStdout = (args, cwd) => {
|
|
239
|
+
const r = runGit(args, cwd);
|
|
240
|
+
return r.error || r.status > 1 ? null : r.stdout;
|
|
241
|
+
};
|
|
242
|
+
const readFileSafe = (path) => {
|
|
243
|
+
try {
|
|
244
|
+
return readFileSync(path, 'utf8');
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const canon = (path) => {
|
|
250
|
+
try {
|
|
251
|
+
return realpathSync(path);
|
|
252
|
+
} catch {
|
|
253
|
+
return path;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
// A changed assessable LEAF is assessed only if it is a REGULAR file. lstat (no-follow): a symlinked
|
|
257
|
+
// or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
|
|
258
|
+
// tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
|
|
259
|
+
const isRegularLeaf = (abs) => {
|
|
260
|
+
try {
|
|
261
|
+
return lstatSync(abs).isFile();
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel], outOfDomain: [rel] }.
|
|
268
|
+
// Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
|
|
269
|
+
// classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
|
|
270
|
+
// is wholly new, so all its lines are "changed".
|
|
271
|
+
export const computeChangedSurface = (root) => {
|
|
272
|
+
const trackedDiff = gitStdout(['diff', 'HEAD', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root)
|
|
273
|
+
?? gitStdout(['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root) // no HEAD yet (unborn branch)
|
|
274
|
+
?? '';
|
|
275
|
+
const trackedLines = parseUnifiedDiff(trackedDiff);
|
|
276
|
+
const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
|
|
277
|
+
const untracked = untrackedZ.split('\0').filter(Boolean);
|
|
278
|
+
|
|
279
|
+
const assessable = new Map();
|
|
280
|
+
const unsupported = [];
|
|
281
|
+
const outOfDomain = [];
|
|
282
|
+
const place = (rel, cls, lines) => {
|
|
283
|
+
if (cls === 'excluded-test') return;
|
|
284
|
+
if (cls === 'assessable') {
|
|
285
|
+
if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
|
|
286
|
+
else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (cls === 'unsupported') unsupported.push(rel);
|
|
290
|
+
else outOfDomain.push(rel);
|
|
291
|
+
};
|
|
292
|
+
for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
|
|
293
|
+
for (const rel of untracked) {
|
|
294
|
+
const cls = classifyChangedPath(rel);
|
|
295
|
+
if (cls !== 'assessable') {
|
|
296
|
+
place(rel, cls, []);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
|
|
300
|
+
const abs = join(root, rel);
|
|
301
|
+
if (!isRegularLeaf(abs)) {
|
|
302
|
+
unsupported.push(rel);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const src = readFileSafe(abs);
|
|
306
|
+
const count = src == null || src.length === 0 ? 0 : src.split('\n').length;
|
|
307
|
+
assessable.set(rel, Array.from({ length: count }, (_, i) => i + 1));
|
|
308
|
+
}
|
|
309
|
+
return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
|
|
313
|
+
|
|
314
|
+
// readCoverage(covDir) → Map<canonicalAbsPath, Array<Array<range>>> — one flat range-list per process.
|
|
315
|
+
const readCoverage = (covDir) => {
|
|
316
|
+
const byPath = new Map();
|
|
317
|
+
let files;
|
|
318
|
+
try {
|
|
319
|
+
files = readdirSync(covDir);
|
|
320
|
+
} catch {
|
|
321
|
+
return byPath;
|
|
322
|
+
}
|
|
323
|
+
for (const f of files) {
|
|
324
|
+
if (!f.endsWith('.json')) continue;
|
|
325
|
+
let parsed;
|
|
326
|
+
try {
|
|
327
|
+
parsed = JSON.parse(readFileSync(join(covDir, f), 'utf8'));
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!parsed || !Array.isArray(parsed.result)) continue;
|
|
332
|
+
for (const entry of parsed.result) {
|
|
333
|
+
if (typeof entry.url !== 'string' || !entry.url.startsWith('file:')) continue;
|
|
334
|
+
let abs;
|
|
335
|
+
try {
|
|
336
|
+
abs = canon(fileURLToPath(entry.url));
|
|
337
|
+
} catch {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const ranges = [];
|
|
341
|
+
for (const fn of entry.functions ?? []) for (const r of fn.ranges ?? []) ranges.push({ startOffset: r.startOffset, endOffset: r.endOffset, count: r.count });
|
|
342
|
+
if (!byPath.has(abs)) byPath.set(abs, []);
|
|
343
|
+
byPath.get(abs).push(ranges);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return byPath;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// ── suite command discovery (Decision 10) ─────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
const GATES_REL = 'docs/ai/gates.json';
|
|
352
|
+
const resolveSuiteCmd = (root, env, explicit) => {
|
|
353
|
+
if (explicit) return explicit;
|
|
354
|
+
if (env.AW_FOLD_SUITE_CMD) return env.AW_FOLD_SUITE_CMD;
|
|
355
|
+
const raw = readFileSafe(join(root, GATES_REL));
|
|
356
|
+
if (raw) {
|
|
357
|
+
try {
|
|
358
|
+
const gate = (JSON.parse(raw).gates ?? []).find((g) => g && g.id === 'unit-tests');
|
|
359
|
+
if (gate && typeof gate.cmd === 'string') return gate.cmd;
|
|
360
|
+
} catch {
|
|
361
|
+
/* fall through to the loud refusal */
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw stop(`cannot resolve the suite command — no unit-tests gate in ${GATES_REL}; pass --suite "<cmd>" or set AW_FOLD_SUITE_CMD`);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// ── the append primitive (whole-file read → add one JSONL line → atomic rewrite) ─────────────────
|
|
368
|
+
|
|
369
|
+
const appendRecord = (resultsPath, record) => {
|
|
370
|
+
let existing = '';
|
|
371
|
+
try {
|
|
372
|
+
existing = readFileSync(resultsPath, 'utf8');
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (err && err.code === 'ENOENT') existing = '';
|
|
375
|
+
else throw stop(`cannot read the result ledger before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
|
|
376
|
+
}
|
|
377
|
+
const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
378
|
+
const body = `${prefix}${JSON.stringify(record)}\n`;
|
|
379
|
+
writeContainedFileAtomic(dirname(resultsPath), resultsPath, body, {}, { stop, label: resultsPath });
|
|
380
|
+
return { writtenPath: resultsPath, record };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ── the run ───────────────────────────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
const budgetsFromEnv = (env) => ({
|
|
386
|
+
mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
|
|
387
|
+
hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
|
|
388
|
+
timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
|
|
392
|
+
// derivation / suite discovery / a malformed record / an fs error) or a native fs error.
|
|
393
|
+
export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
|
|
394
|
+
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
395
|
+
if (root == null) throw stop('not a git work tree — nothing to assess');
|
|
396
|
+
const rootTop = root.replace(/\r?\n$/, '');
|
|
397
|
+
|
|
398
|
+
// Decision: the loop = the single in-flight plan stem; 0 or >1 → refuse (ambiguous).
|
|
399
|
+
const plans = plansInFlight(rootTop);
|
|
400
|
+
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to assess');
|
|
401
|
+
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
402
|
+
const loop = plans[0].replace(/\.md$/, '');
|
|
403
|
+
|
|
404
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
405
|
+
const cmd = resolveSuiteCmd(rootTop, env, suiteCmd);
|
|
406
|
+
const boundArgv = resolveBoundArgv(env); // resolves BEFORE any spawn (a malformed override refuses loudly)
|
|
407
|
+
const budgets = budgetsFromEnv(env);
|
|
408
|
+
|
|
409
|
+
const { assessable, unsupported, outOfDomain } = computeChangedSurface(rootTop);
|
|
410
|
+
|
|
411
|
+
// M3a: run the suite once under coverage in a dir OUTSIDE the work tree (Decision 8), then map.
|
|
412
|
+
const covDir = mkdtempSync(join(tmpdir(), 'agent-workflow-fold-cov-'));
|
|
413
|
+
let coverage;
|
|
414
|
+
try {
|
|
415
|
+
const suite = spawnSync('bash', ['-c', cmd], { cwd: rootTop, env: childTestEnv(env, { NODE_V8_COVERAGE: covDir }), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
|
|
416
|
+
if (suite.error && suite.error.code === 'ENOENT') throw stop('bash is unavailable — the suite command is a bash command line');
|
|
417
|
+
coverage = readCoverage(covDir);
|
|
418
|
+
} finally {
|
|
419
|
+
rmSync(covDir, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
const uncoveredChanged = [];
|
|
422
|
+
for (const [rel, lines] of assessable) {
|
|
423
|
+
const perProc = coverage.get(canon(join(rootTop, rel)));
|
|
424
|
+
if (!perProc || perProc.length === 0) {
|
|
425
|
+
uncoveredChanged.push({ file: rel, line: null }); // absent from coverage → file-level RED (Decision 6)
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const src = readFileSafe(join(rootTop, rel));
|
|
429
|
+
if (src == null) continue;
|
|
430
|
+
for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Decision 3 / 10: probe each fixable-bug bound testId once (shell-free).
|
|
434
|
+
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
435
|
+
const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
|
|
436
|
+
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
|
|
437
|
+
const testIds = boundTestIds.map((id) => {
|
|
438
|
+
const at = id.indexOf('#');
|
|
439
|
+
const file = id.slice(0, at);
|
|
440
|
+
const pattern = id.slice(at + 1);
|
|
441
|
+
const argv = boundArgv(file, pattern);
|
|
442
|
+
const res = spawnSync(argv[0], argv.slice(1), { cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
|
|
443
|
+
return { id, ...parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file }) };
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const record = {
|
|
447
|
+
schema: RESULT_SCHEMA_VERSION,
|
|
448
|
+
loop,
|
|
449
|
+
fingerprint,
|
|
450
|
+
boundTestIds,
|
|
451
|
+
testIds,
|
|
452
|
+
unsupported,
|
|
453
|
+
outOfDomain,
|
|
454
|
+
coverage: { uncoveredChanged },
|
|
455
|
+
mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
|
|
456
|
+
budgets,
|
|
457
|
+
timestamp: isoNow(),
|
|
458
|
+
};
|
|
459
|
+
const v = validateRunRecord(record);
|
|
460
|
+
if (!v.ok) throw stop(`refusing to write a malformed result record: ${v.reason}`);
|
|
461
|
+
|
|
462
|
+
const resultsPath = resolveResultsPath(cwd, env);
|
|
463
|
+
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
464
|
+
return appendRecord(resultsPath, record);
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
|
|
470
|
+
|
|
471
|
+
Usage:
|
|
472
|
+
node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
|
|
473
|
+
|
|
474
|
+
Runs the in-flight plan-execution loop's suite ONCE under coverage, maps every changed executable line
|
|
475
|
+
to covered/uncovered, probes each fixable-bug bound testId for resolvability + a green baseline, and
|
|
476
|
+
appends one result record to <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS
|
|
477
|
+
overrides). The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
|
|
478
|
+
|
|
479
|
+
Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
|
|
480
|
+
Bound-test runs default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
|
|
481
|
+
with a JSON argv array using {file}/{pattern}. Budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX /
|
|
482
|
+
AW_FOLD_TIME_BUDGET_S (recorded but inert — the mutation half is not shipped).
|
|
483
|
+
|
|
484
|
+
Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / malformed record / fs error);
|
|
485
|
+
2 usage.`;
|
|
486
|
+
|
|
487
|
+
const parseArgs = (argv) => {
|
|
488
|
+
const opts = { cwd: undefined, suite: undefined };
|
|
489
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
490
|
+
const a = argv[i];
|
|
491
|
+
if (a === '--cwd') {
|
|
492
|
+
opts.cwd = argv[i + 1];
|
|
493
|
+
if (opts.cwd === undefined) throw usageFail('--cwd needs a directory');
|
|
494
|
+
i += 1;
|
|
495
|
+
} else if (a === '--suite') {
|
|
496
|
+
opts.suite = argv[i + 1];
|
|
497
|
+
if (opts.suite === undefined) throw usageFail('--suite needs a command');
|
|
498
|
+
i += 1;
|
|
499
|
+
} else {
|
|
500
|
+
throw usageFail(`unknown argument: ${a}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return opts;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
export const main = (argv, ctx = {}) => {
|
|
507
|
+
const cwd0 = ctx.cwd ?? process.cwd();
|
|
508
|
+
const env = ctx.env ?? process.env;
|
|
509
|
+
try {
|
|
510
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
511
|
+
const opts = parseArgs(argv);
|
|
512
|
+
const { writtenPath, record } = runFoldCompleteness({ cwd: opts.cwd ?? cwd0, env, suiteCmd: opts.suite });
|
|
513
|
+
const uncovered = record.coverage.uncoveredChanged.length;
|
|
514
|
+
const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
|
|
515
|
+
return {
|
|
516
|
+
code: 0,
|
|
517
|
+
stdout: `fold-completeness-run: recorded a run for loop "${record.loop}" (${record.boundTestIds.length} bound testId(s), ${unresolved} unresolved/red, ${uncovered} uncovered changed line(s), ${record.unsupported.length} unsupported, ${record.outOfDomain.length} out-of-domain) → ${writtenPath}`,
|
|
518
|
+
stderr: '',
|
|
519
|
+
};
|
|
520
|
+
} catch (err) {
|
|
521
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `fold-completeness-run: ${err.message}` };
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
526
|
+
if (isDirectRun) {
|
|
527
|
+
const r = main(process.argv.slice(2));
|
|
528
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
529
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
530
|
+
process.exitCode = r.code;
|
|
531
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fold-completeness.mjs — the read-only FOLD-COMPLETENESS checker behind `/agent-workflow-kit
|
|
3
|
+
// fold-completeness` (M3, AD-046, DEBT-TEST-COMPLETENESS). It is the sibling gate of
|
|
4
|
+
// review-ledger.mjs: the ledger computes the review-loop crossover-stop; this tool computes whether
|
|
5
|
+
// the tests that the loop's folds bind to ACTUALLY pin the changed code. It reads the result ledger
|
|
6
|
+
// the runner writes (fold-completeness-run.mjs — the SOLE tree-toucher + result writer), recomputes
|
|
7
|
+
// the canonical uncommitted-state fingerprint, resolves the plan-execution.review recipe + the
|
|
8
|
+
// in-flight plan, and decides `--check` fail-closed. It NEVER runs tests, NEVER mutates, and NEVER
|
|
9
|
+
// imports the runner — an import-split test pins that structural invariant (the read half owns the
|
|
10
|
+
// result SCHEMA + reader; the runner imports them the other direction, mirroring review-ledger /
|
|
11
|
+
// review-ledger-write).
|
|
12
|
+
//
|
|
13
|
+
// Normative `--check` exit contract (the single home of this list — SKILL.md / the mode file point
|
|
14
|
+
// here). The gate is plan-EXECUTION-scoped, filtered to the in-flight plan's filename stem:
|
|
15
|
+
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there);
|
|
16
|
+
// when no plan is in flight; when the tree is clean; when the cwd is not a git work tree;
|
|
17
|
+
// and when the in-flight plan-execution loop has a CURRENT run record whose BOTH bindings
|
|
18
|
+
// match — the tree fingerprint AND the sorted fixable-bug testId set recorded in the run —
|
|
19
|
+
// with every bound testId resolvable + baseline-green, 0 uncovered changed lines, 0 changed
|
|
20
|
+
// unsupported-source files, and the reserved EMPTY mutation shape (v1 ships no mutation).
|
|
21
|
+
// exit 1 for any DIRTY in-flight plan-execution loop lacking such a current run record — including
|
|
22
|
+
// the stale-fingerprint case (a tree edit moves the fingerprint) and the same-fingerprint/
|
|
23
|
+
// new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9) —
|
|
24
|
+
// or a run naming an unresolvable/red-baseline bound test, an uncovered changed line, a
|
|
25
|
+
// changed unsupported-source file, or ANY mutation data (v1 ships no mutation — such a record
|
|
26
|
+
// was not produced by this runner version; fail closed); when MORE THAN ONE plan is in
|
|
27
|
+
// flight (ambiguous loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a
|
|
28
|
+
// detector failure, an unreadable/malformed result or review ledger, or a corrupt run set —
|
|
29
|
+
// the only detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN
|
|
30
|
+
// files (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
|
|
31
|
+
// (Decision 5): guarding what the tool cannot assess with a red gate is a pretend-mechanism.
|
|
32
|
+
//
|
|
33
|
+
// HONEST residuals (accepted, documented — exactly like review-state's / review-ledger's): coverage
|
|
34
|
+
// proves execution, not assertion; NO mutation signal ships in v1 — the researched mutation half was
|
|
35
|
+
// shelved, the record's `mutation` field is a reserved empty shape (a record carrying ANY mutation
|
|
36
|
+
// data fails the check closed); the result records, testIds, and the ledger are forgeable
|
|
37
|
+
// (`git commit --no-verify`, file editing) — a self-discipline mechanism against silent process
|
|
38
|
+
// drift, NOT a security boundary. TS/JSX source is out of scope v1 (a changed unsupported-source
|
|
39
|
+
// file fails the gate closed rather than be vouched for).
|
|
40
|
+
//
|
|
41
|
+
// Read-only: never writes, never commits, never runs a subscription CLI. It DOES spawn read-only
|
|
42
|
+
// `git` queries (via the reused review-state fingerprint reader + a git-dir resolver). Dependency-
|
|
43
|
+
// free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
44
|
+
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
import { pathToFileURL } from 'node:url';
|
|
48
|
+
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
50
|
+
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
51
|
+
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
52
|
+
import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
|
|
53
|
+
import { resolveLedgerPath, readLedger, filterLoopRecords } from './review-ledger.mjs';
|
|
54
|
+
|
|
55
|
+
export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
|
|
56
|
+
export const RESULT_SCHEMA_VERSION = 1;
|
|
57
|
+
const ACTIVITY = 'plan-execution';
|
|
58
|
+
const SLOT = 'review';
|
|
59
|
+
|
|
60
|
+
// ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
|
|
61
|
+
|
|
62
|
+
const gitLine = (args, cwd) => {
|
|
63
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true });
|
|
64
|
+
if (r.error || r.status !== 0) return null;
|
|
65
|
+
return r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
69
|
+
|
|
70
|
+
// The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
|
|
71
|
+
export const resolveResultsPath = (cwd, env = process.env) => {
|
|
72
|
+
if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
|
|
73
|
+
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
74
|
+
return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── the bound fixable-bug testId set (the SINGLE source of truth — runner + checker share it) ────
|
|
78
|
+
|
|
79
|
+
// collectBoundTestIds(reviewRecords, { activity, loop }) → the sorted, de-duplicated testIds of the
|
|
80
|
+
// loop's fixable-bug classifications. Pure over review-ledger records. Both the runner (which records
|
|
81
|
+
// the set as a binding) and the checker (which recomputes it for the staleness check) call THIS, so
|
|
82
|
+
// the two can never drift (Decision 9: same-fingerprint/new-testId is stale).
|
|
83
|
+
export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop } = {}) => {
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
for (const r of filterLoopRecords(reviewRecords, { activity, loop })) {
|
|
86
|
+
if (r.kind !== 'triage') continue;
|
|
87
|
+
for (const c of r.classifications) if (c.class === 'fixable-bug' && typeof c.testId === 'string' && c.testId.length > 0) ids.add(c.testId);
|
|
88
|
+
}
|
|
89
|
+
return [...ids].sort();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── result-record schema (machine fields only; the runner refuses to write a malformed record) ───
|
|
93
|
+
|
|
94
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
95
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
96
|
+
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
97
|
+
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
98
|
+
|
|
99
|
+
// validateRunRecord(obj) → { ok, reason }. The `reason` names the exact failed check so the
|
|
100
|
+
// malformed-line surface and the per-check named tests can assert it. Mutation is reserved and must
|
|
101
|
+
// stay the empty shape in v1 (the mutation half is shelved); the schema carries the fields so a
|
|
102
|
+
// record validates uniformly and decideCheck enforces the exact reserved shape.
|
|
103
|
+
export const validateRunRecord = (obj) => {
|
|
104
|
+
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
105
|
+
if (obj.schema !== RESULT_SCHEMA_VERSION) return { ok: false, reason: `schema must be ${RESULT_SCHEMA_VERSION}` };
|
|
106
|
+
if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
|
|
107
|
+
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
|
|
108
|
+
if (!isStringArray(obj.boundTestIds)) return { ok: false, reason: 'boundTestIds must be an array of strings' };
|
|
109
|
+
if (!Array.isArray(obj.testIds)) return { ok: false, reason: 'testIds must be an array' };
|
|
110
|
+
for (const t of obj.testIds) {
|
|
111
|
+
if (!isPlainObject(t) || !isNonEmptyString(t.id)) return { ok: false, reason: 'each testId entry needs an id' };
|
|
112
|
+
if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return { ok: false, reason: `testId ${t.id} needs boolean resolvable + baselineGreen` };
|
|
113
|
+
if (!isNonNegInt(t.executed)) return { ok: false, reason: `testId ${t.id} executed must be a non-negative integer` };
|
|
114
|
+
}
|
|
115
|
+
if (!isStringArray(obj.unsupported)) return { ok: false, reason: 'unsupported must be an array of strings' };
|
|
116
|
+
if (!isStringArray(obj.outOfDomain)) return { ok: false, reason: 'outOfDomain must be an array of strings' };
|
|
117
|
+
if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return { ok: false, reason: 'coverage.uncoveredChanged must be an array' };
|
|
118
|
+
for (const u of obj.coverage.uncoveredChanged) {
|
|
119
|
+
if (!isPlainObject(u) || !isNonEmptyString(u.file)) return { ok: false, reason: 'each uncoveredChanged entry needs a file' };
|
|
120
|
+
if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return { ok: false, reason: `uncoveredChanged ${u.file} line must be null or an integer >= 1` };
|
|
121
|
+
}
|
|
122
|
+
const m = obj.mutation;
|
|
123
|
+
if (!isPlainObject(m) || !isNonNegInt(m.total) || !isNonNegInt(m.killed) || !Array.isArray(m.survived) || !isNonNegInt(m.skipped)) {
|
|
124
|
+
return { ok: false, reason: 'mutation must be { total, killed, survived[], skipped, killSetBasis }' };
|
|
125
|
+
}
|
|
126
|
+
if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return { ok: false, reason: 'mutation.killSetBasis must be null or a non-empty string' };
|
|
127
|
+
if (!isPlainObject(obj.budgets)) return { ok: false, reason: 'budgets must be an object' };
|
|
128
|
+
if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
|
|
129
|
+
return { ok: true };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
|
|
133
|
+
// yet). A non-ENOENT read error surfaces as readError so callers fail CLOSED (mirrors readLedger).
|
|
134
|
+
export const readResults = (path, readFile = readFileSync) => {
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = readFile(path, 'utf8');
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
|
|
140
|
+
return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
141
|
+
}
|
|
142
|
+
const records = [];
|
|
143
|
+
const malformedReasons = [];
|
|
144
|
+
for (const line of raw.split('\n')) {
|
|
145
|
+
if (line.trim() === '') continue;
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(line);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
malformedReasons.push(`unparseable JSON (${err.message})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const v = validateRunRecord(parsed);
|
|
154
|
+
if (v.ok) records.push(parsed);
|
|
155
|
+
else malformedReasons.push(v.reason);
|
|
156
|
+
}
|
|
157
|
+
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// filterLoopResults(records, loop) → the run records of ONE loop, order preserved (latest is last).
|
|
161
|
+
export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
|
|
162
|
+
|
|
163
|
+
// ── the check + report core ─────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
// buildFoldState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges; every
|
|
166
|
+
// project-relative read anchors at the git work-tree ROOT when one exists (the fingerprint is
|
|
167
|
+
// root-anchored — the same discipline review-state / review-ledger use).
|
|
168
|
+
export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends } = {}) => {
|
|
169
|
+
const root = gitRoot(cwd) ?? cwd;
|
|
170
|
+
const { config, source: configSource } = loadConfig(root);
|
|
171
|
+
let detection = [];
|
|
172
|
+
let detectionWarning = null;
|
|
173
|
+
try {
|
|
174
|
+
detection = detect();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
detectionWarning = `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; the review recipe floors at solo.`;
|
|
177
|
+
}
|
|
178
|
+
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
|
|
179
|
+
const { dispatch } = planRecipe(resolved.recipe, detection);
|
|
180
|
+
const requiredBackends = dispatch.map((d) => DISPLAY_ALIASES[d.backend] ?? d.backend);
|
|
181
|
+
const plans = plansInFlight(root);
|
|
182
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
183
|
+
const clean = fingerprint == null ? null : isTreeClean(cwd);
|
|
184
|
+
const resultsPath = resolveResultsPath(cwd, env);
|
|
185
|
+
const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
186
|
+
const reviewPath = resolveLedgerPath(cwd, env);
|
|
187
|
+
const reviewRead = reviewPath ? readLedger(reviewPath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
188
|
+
return {
|
|
189
|
+
resolved,
|
|
190
|
+
configSource,
|
|
191
|
+
requiredBackends,
|
|
192
|
+
plans,
|
|
193
|
+
fingerprint,
|
|
194
|
+
clean,
|
|
195
|
+
resultsPath,
|
|
196
|
+
resultRecords: resultRead.records,
|
|
197
|
+
resultMalformed: resultRead.malformed,
|
|
198
|
+
resultReadError: resultRead.readError,
|
|
199
|
+
reviewPath,
|
|
200
|
+
reviewRecords: reviewRead.records,
|
|
201
|
+
reviewMalformed: reviewRead.malformed,
|
|
202
|
+
reviewReadError: reviewRead.readError,
|
|
203
|
+
detectionWarning,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const sameSet = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
208
|
+
// The reserved (and, in v1, only legal) mutation key set — sorted for the sameSet comparison.
|
|
209
|
+
const RESERVED_MUTATION_KEYS = ['killSetBasis', 'killed', 'skipped', 'survived', 'total'];
|
|
210
|
+
const renderUncovered = (u) => (u.line === null ? `${u.file} (absent from coverage)` : `${u.file}:${u.line}`);
|
|
211
|
+
|
|
212
|
+
// The normative --check decision (the header contract, in order) → { code, reason }. The checker is
|
|
213
|
+
// PURE over (record, current fingerprint, review ledger) — it recomputes NO coverage/mutation.
|
|
214
|
+
export const decideCheck = (state) => {
|
|
215
|
+
// A detector failure is UNKNOWN state, not "no reviewer ready" — fail closed (like review-ledger).
|
|
216
|
+
const explicitSolo = state.resolved.recipe === 'solo' && state.resolved.source === 'config' && !state.resolved.degradedFrom;
|
|
217
|
+
if (state.detectionWarning && !explicitSolo) return { code: 1, reason: `cannot verify fold-completeness — ${state.detectionWarning}` };
|
|
218
|
+
if (state.resolved.recipe === 'solo') {
|
|
219
|
+
const why = state.resolved.degradedFrom ? `resolved ${ACTIVITY}.${SLOT} recipe degrades to solo here (${state.resolved.reason})` : `resolved ${ACTIVITY}.${SLOT} recipe is solo`;
|
|
220
|
+
return { code: 0, reason: `${why} — no fold-completeness run required` };
|
|
221
|
+
}
|
|
222
|
+
if (state.plans.length === 0) return { code: 0, reason: 'no plan in flight (docs/plans/ holds no active plan) — no fold-completeness run required' };
|
|
223
|
+
if (state.plans.length > 1) return { code: 1, reason: `more than one plan in flight (${state.plans.join(', ')}) — ambiguous loop id; resolve to one active plan` };
|
|
224
|
+
if (state.fingerprint == null) return { code: 0, reason: 'not a git work tree — nothing to assess' };
|
|
225
|
+
if (state.clean === true) return { code: 0, reason: 'the working tree is clean — nothing to assess' };
|
|
226
|
+
// Fail CLOSED on any ledger the reader could not fully trust (a dropped line could hide a defect).
|
|
227
|
+
if (state.resultReadError) return { code: 1, reason: `cannot read the result ledger (${state.resultReadError}) — failing closed; inspect ${state.resultsPath}` };
|
|
228
|
+
if (state.resultMalformed > 0) return { code: 1, reason: `the result ledger has ${state.resultMalformed} malformed line(s) — failing closed; inspect ${state.resultsPath}` };
|
|
229
|
+
if (state.reviewReadError) return { code: 1, reason: `cannot read the review ledger (${state.reviewReadError}) — failing closed; inspect ${state.reviewPath}` };
|
|
230
|
+
if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
|
|
231
|
+
|
|
232
|
+
const loop = state.plans[0].replace(/\.md$/, '');
|
|
233
|
+
const runs = filterLoopResults(state.resultRecords, loop);
|
|
234
|
+
if (runs.length === 0) return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded — run fold-completeness-run.mjs` };
|
|
235
|
+
const latest = runs[runs.length - 1];
|
|
236
|
+
|
|
237
|
+
// Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
|
|
238
|
+
// the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
|
|
239
|
+
if (latest.fingerprint !== state.fingerprint) return { code: 1, reason: `no fold-completeness run for the current tree (the tree was edited after the run) — re-run fold-completeness-run.mjs after the last edit` };
|
|
240
|
+
const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop });
|
|
241
|
+
if (!sameSet(latest.boundTestIds, currentBound)) return { code: 1, reason: `a fixable-bug testId was triaged after the run (the bound-testId set changed) — re-run fold-completeness-run.mjs` };
|
|
242
|
+
// Fail CLOSED if the run's probe set does not cover its bound-testId set EXACTLY. A well-formed
|
|
243
|
+
// runner record always probes every bound testId (one testIds[] entry per boundTestId), but a record
|
|
244
|
+
// with missing/extra/duplicate probes is untrustworthy — proving "0 unresolvable, 0 red" over an
|
|
245
|
+
// EMPTY (or partial) probe set proves nothing about the bound tests (codex R1).
|
|
246
|
+
const probedIds = latest.testIds.map((t) => t.id).sort();
|
|
247
|
+
if (!sameSet(probedIds, latest.boundTestIds)) return { code: 1, reason: `the run's probe set does not match its bound-testId set (${probedIds.length} probe(s) for ${latest.boundTestIds.length} bound testId(s)) — failing closed; re-run fold-completeness-run.mjs` };
|
|
248
|
+
|
|
249
|
+
// A changed unsupported-source file → fail closed: the signal never vouches for JS-family source it
|
|
250
|
+
// cannot assess (TS/JSX out of scope v1, Decision 5).
|
|
251
|
+
if (latest.unsupported.length > 0) return { code: 1, reason: `changed unsupported-source file(s) the signal cannot assess (TS/JSX out of scope v1): ${latest.unsupported.join(', ')}` };
|
|
252
|
+
const unresolvable = latest.testIds.filter((t) => !t.resolvable).map((t) => t.id);
|
|
253
|
+
if (unresolvable.length > 0) return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${unresolvable.join(', ')}` };
|
|
254
|
+
const redBaseline = latest.testIds.filter((t) => t.resolvable && !t.baselineGreen).map((t) => t.id);
|
|
255
|
+
if (redBaseline.length > 0) return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${redBaseline.join(', ')}` };
|
|
256
|
+
if (latest.coverage.uncoveredChanged.length > 0) return { code: 1, reason: `uncovered changed line(s) — changed code no test executed: ${latest.coverage.uncoveredChanged.map(renderUncovered).join(', ')}` };
|
|
257
|
+
// v1 ships NO mutation (the mutation half was shelved): the shipped runner only ever writes the
|
|
258
|
+
// reserved empty shape, so a record carrying ANY mutation data was not produced by this runner
|
|
259
|
+
// version (forged, or a version-skewed ledger — e.g. an older placed checker reading a newer
|
|
260
|
+
// runner's ledger) — fail closed rather than vouch for a signal v1 cannot have computed. The rule
|
|
261
|
+
// is the EXACT reserved shape (key set + empty values): an extra key would smuggle mutation data
|
|
262
|
+
// past a known-fields-only check.
|
|
263
|
+
const m = latest.mutation;
|
|
264
|
+
const emptyReservedShape =
|
|
265
|
+
sameSet(Object.keys(m).sort(), RESERVED_MUTATION_KEYS) &&
|
|
266
|
+
m.total === 0 && m.killed === 0 && m.skipped === 0 && m.survived.length === 0 && m.killSetBasis === null;
|
|
267
|
+
if (!emptyReservedShape) {
|
|
268
|
+
return { code: 1, reason: `the run record's mutation field is not the reserved empty shape (${m.total} total / ${m.killed} killed / ${m.survived?.length ?? '?'} survived / ${m.skipped} skipped) but v1 ships no mutation — not a record this runner version produced; re-run fold-completeness-run.mjs` };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const notes = [];
|
|
272
|
+
if (latest.outOfDomain.length > 0) notes.push(`out-of-domain changes not assessed (non-blocking): ${latest.outOfDomain.join(', ')}`);
|
|
273
|
+
return { code: 0, reason: `fold-completeness verified for loop "${loop}" (fingerprint + bound-testId set current, coverage + baseline green)${notes.length ? ` — ${notes.join('; ')}` : ''}` };
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
const runLine = (r) => {
|
|
279
|
+
const tests = r.testIds.length ? r.testIds.map((t) => `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`).join(', ') : '(no bound tests)';
|
|
280
|
+
const uncov = r.coverage.uncoveredChanged.length ? r.coverage.uncoveredChanged.map(renderUncovered).join(', ') : 'none';
|
|
281
|
+
return [
|
|
282
|
+
` latest run — fingerprint ${r.fingerprint}`,
|
|
283
|
+
` bound testIds: ${tests}`,
|
|
284
|
+
` uncovered changed: ${uncov}`,
|
|
285
|
+
` unsupported: ${r.unsupported.length ? r.unsupported.join(', ') : 'none'} · out-of-domain: ${r.outOfDomain.length ? r.outOfDomain.join(', ') : 'none'}`,
|
|
286
|
+
` mutation: ${r.mutation.total} total / ${r.mutation.killed} killed / ${r.mutation.survived.length} survived / ${r.mutation.skipped} skipped`,
|
|
287
|
+
].join('\n');
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const formatHuman = (state, check) => {
|
|
291
|
+
const src = state.configSource === 'config' ? `from ${CONFIG_REL}` : 'computed default';
|
|
292
|
+
const lines = [
|
|
293
|
+
`fold-completeness — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
|
|
294
|
+
];
|
|
295
|
+
if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
|
|
296
|
+
lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
|
|
297
|
+
if (state.fingerprint == null) lines.push(' tree: not a git work tree');
|
|
298
|
+
else if (state.clean === true) lines.push(' tree: clean (nothing to assess)');
|
|
299
|
+
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
300
|
+
lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
|
|
301
|
+
if (state.plans.length === 1) {
|
|
302
|
+
const loop = state.plans[0].replace(/\.md$/, '');
|
|
303
|
+
const runs = filterLoopResults(state.resultRecords, loop);
|
|
304
|
+
if (runs.length > 0) lines.push(runLine(runs[runs.length - 1]));
|
|
305
|
+
}
|
|
306
|
+
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
307
|
+
return lines.join('\n');
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const HELP = `fold-completeness — read-only FOLD-COMPLETENESS checker (agent-workflow family, AD-046).
|
|
311
|
+
|
|
312
|
+
Usage:
|
|
313
|
+
node fold-completeness.mjs [--check | --status | --json]
|
|
314
|
+
|
|
315
|
+
Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
|
|
316
|
+
resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
|
|
317
|
+
fingerprint, and decides whether the in-flight plan-execution loop's changed code is pinned by tests.
|
|
318
|
+
|
|
319
|
+
--status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary, verdict.
|
|
320
|
+
--check → the gate exit code. The normative exit contract lives in the tool header (the single home):
|
|
321
|
+
exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT run whose fingerprint
|
|
322
|
+
AND bound-testId set both match, with resolvable+green bound tests, 0 uncovered changed lines, 0
|
|
323
|
+
changed unsupported source, and the reserved empty mutation shape; exit 1 otherwise (stale/missing
|
|
324
|
+
run, an unresolvable/red bound test, an uncovered line, changed TS/JSX, any mutation data — v1 ships
|
|
325
|
+
no mutation, >1 plan, an unreadable/malformed ledger, or a detector failure). Out-of-domain changes
|
|
326
|
+
are listed, never blocking.
|
|
327
|
+
--json → the structured state + decision.
|
|
328
|
+
|
|
329
|
+
The runner is a SEPARATE tool (fold-completeness-run.mjs) — this read-only checker never imports it.
|
|
330
|
+
Human residual: git commit --no-verify and ledger editing remain possible — a self-discipline mechanism.
|
|
331
|
+
|
|
332
|
+
Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
|
|
333
|
+
|
|
334
|
+
const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--status', '--json']);
|
|
335
|
+
|
|
336
|
+
export const main = (argv, ctx = {}) => {
|
|
337
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
338
|
+
const env = ctx.env ?? process.env;
|
|
339
|
+
const detect = ctx.detect ?? detectBackends;
|
|
340
|
+
try {
|
|
341
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
342
|
+
const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
|
|
343
|
+
if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
|
|
344
|
+
const state = buildFoldState({ cwd, env, detect });
|
|
345
|
+
const check = decideCheck(state);
|
|
346
|
+
if (argv.includes('--json')) {
|
|
347
|
+
return { code: argv.includes('--check') ? check.code : 0, stdout: JSON.stringify({ ...state, check }, null, 2), stderr: '' };
|
|
348
|
+
}
|
|
349
|
+
if (argv.includes('--check')) {
|
|
350
|
+
return { code: check.code, stdout: `fold-completeness check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`, stderr: '' };
|
|
351
|
+
}
|
|
352
|
+
return { code: 0, stdout: formatHuman(state, check), stderr: '' };
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `fold-completeness: ${err.message}` };
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
359
|
+
if (isDirectRun) {
|
|
360
|
+
const r = main(process.argv.slice(2));
|
|
361
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
362
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
363
|
+
process.exitCode = r.code;
|
|
364
|
+
}
|
package/tools/procedures.mjs
CHANGED
|
@@ -198,7 +198,10 @@ const backendSetLabel = (backends) =>
|
|
|
198
198
|
// REQUIRED per-round structured emission {round N · finding-origin tally · per-backend verdict}. Only a
|
|
199
199
|
// review slot can resolve reviewed|council (execute floors at solo|delegated), so gate on the recipe.
|
|
200
200
|
const REVIEW_RECIPES = new Set(['reviewed', 'council']);
|
|
201
|
-
|
|
201
|
+
// activity-aware (AD-046): the triage classification vocabulary rides EVERY review-backed activity;
|
|
202
|
+
// the LEDGER pointer renders ONLY for plan-execution — the ledger is plan-execution-scoped (AD-045),
|
|
203
|
+
// and pointing plan-authoring at it would send rounds of the wrong activity into the code loop's gate.
|
|
204
|
+
const reviewLoopAdvice = (slots, activity) =>
|
|
202
205
|
slots.some((s) => REVIEW_RECIPES.has(s.recipe))
|
|
203
206
|
? [
|
|
204
207
|
'Review-loop economics (planning.md §9 · orchestration.md §4) — the review this recipe runs:',
|
|
@@ -206,6 +209,12 @@ const reviewLoopAdvice = (slots) =>
|
|
|
206
209
|
' • Backend divergence (one backend grounded-ships while another keeps revising mechanics) IS the crossover stop.',
|
|
207
210
|
' • Route an all-mechanics/CI or prose-only artifact to a thin plan + diff-review; run a self-consistency read before every re-review.',
|
|
208
211
|
' • Each round MUST emit {round N · finding-origin tally (first-draft / fold-induced / mechanics) · per-backend verdict} so the crossover is a computed signal.',
|
|
212
|
+
' • At the cap, classify every surviving blocking finding: fixable-bug (fold ONCE as a red→green test, re-review) / inherent-layer-residual (document + raise to an acceptance criterion) / escalate (the maintainer decides); a minor never forces triage.',
|
|
213
|
+
...(activity === 'plan-execution'
|
|
214
|
+
? [
|
|
215
|
+
' • The computed instrument for THIS loop: record each round + triage via review-ledger (record / classify); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
|
|
216
|
+
]
|
|
217
|
+
: []),
|
|
209
218
|
]
|
|
210
219
|
: [];
|
|
211
220
|
|
|
@@ -302,7 +311,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans }) => {
|
|
|
302
311
|
}
|
|
303
312
|
const grounding = groundingPreStepAdvice(activity, slots, plans);
|
|
304
313
|
if (grounding.length) lines.push('', ...grounding);
|
|
305
|
-
const advice = reviewLoopAdvice(slots);
|
|
314
|
+
const advice = reviewLoopAdvice(slots, activity);
|
|
306
315
|
if (advice.length) lines.push('', ...advice);
|
|
307
316
|
lines.push('', ...costLanesAdvice());
|
|
308
317
|
if (warnings.length) {
|
|
@@ -320,7 +329,7 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans })
|
|
|
320
329
|
// `contracts` is the ADDITIVE per-dispatch driving-contract field (empty for solo).
|
|
321
330
|
slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
|
|
322
331
|
),
|
|
323
|
-
reviewLoop: reviewLoopAdvice(slots),
|
|
332
|
+
reviewLoop: reviewLoopAdvice(slots, activity),
|
|
324
333
|
// ADDITIVE (AD-038): the populated grounding pre-step, structured (empty when agy is not dispatched).
|
|
325
334
|
groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
|
|
326
335
|
// ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
|
|
@@ -155,9 +155,11 @@ export const recordTriage = (params, deps = {}) => {
|
|
|
155
155
|
if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
|
|
156
156
|
if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
|
|
157
157
|
const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
|
|
158
|
-
// Normalize each classification: testId
|
|
159
|
-
//
|
|
160
|
-
//
|
|
158
|
+
// Normalize each classification: an absent testId → null, an absent note → '' — an absent optional
|
|
159
|
+
// field is FILLED, never rejected as malformed (agy R3). Under schema v2 (M2/AD-046) a fixable-bug
|
|
160
|
+
// normalized to a null testId then FAILS validateRecord below (a typed STOP naming the rule + the
|
|
161
|
+
// red-test-first fix) — the test-per-fold binding rides the existing validate path. A non-array is
|
|
162
|
+
// left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
|
|
161
163
|
const normalized = Array.isArray(classifications) ? classifications.map((c) => ({ ...c, testId: c?.testId ?? null, note: c?.note ?? '' })) : classifications;
|
|
162
164
|
const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
|
|
163
165
|
const v = validateRecord(record);
|
|
@@ -195,7 +197,9 @@ record appends one review round. The JSON payload carries { loop, round, origi
|
|
|
195
197
|
triage is required, beyond the hard-max ceiling of ${HARD_MAX} rounds, or when a non-degraded
|
|
196
198
|
backend lacks a grounded code receipt for the current tree.
|
|
197
199
|
classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
|
|
198
|
-
{ findingKey, class, accepted, testId, note }).
|
|
200
|
+
{ findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
|
|
201
|
+
red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
|
|
202
|
+
first); inherent-layer-residual / escalate may omit it. This is what permits the next round.
|
|
199
203
|
|
|
200
204
|
The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
|
|
201
205
|
Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
|
package/tools/review-ledger.mjs
CHANGED
|
@@ -70,7 +70,14 @@ const SLOT = 'review';
|
|
|
70
70
|
// forces triage. Shared with the writer (which imports it) — the writer-only hard-max ceiling lives
|
|
71
71
|
// there, never here (it is not a decideStop input).
|
|
72
72
|
export const REVIEW_CAP = 2;
|
|
73
|
-
|
|
73
|
+
// SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
|
|
74
|
+
// well-formed testId — the red→green test that pins the fold). The READER tolerates every
|
|
75
|
+
// SUPPORTED_SCHEMAS version under its own per-version rules, so historical/live v1 ledgers never
|
|
76
|
+
// retroactively become malformed (Decision 2 — a malformed line cascades fail-closed refusals in the
|
|
77
|
+
// writer teeth AND the --check gate). v1 records keep the AD-045 rule (testId optional, unenforced);
|
|
78
|
+
// only v2 enforces the test-per-fold binding. decideStop never reads testId (not a decideStop input).
|
|
79
|
+
export const SCHEMA_VERSION = 2;
|
|
80
|
+
const SUPPORTED_SCHEMAS = new Set([1, 2]);
|
|
74
81
|
|
|
75
82
|
// The record vocabulary — the single home of every enum the schema validates.
|
|
76
83
|
const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
|
|
@@ -112,6 +119,17 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
|
|
|
112
119
|
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
113
120
|
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
114
121
|
|
|
122
|
+
// testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
|
|
123
|
+
// BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
|
|
124
|
+
// block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
|
|
125
|
+
// hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run.
|
|
126
|
+
const TESTID_SEPARATOR = '#';
|
|
127
|
+
const isWellFormedTestId = (v) => {
|
|
128
|
+
if (typeof v !== 'string') return false;
|
|
129
|
+
const at = v.indexOf(TESTID_SEPARATOR);
|
|
130
|
+
return at > 0 && at < v.length - 1; // separator present, both halves non-empty
|
|
131
|
+
};
|
|
132
|
+
|
|
115
133
|
// validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
|
|
116
134
|
// the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
|
|
117
135
|
// aggregation of findings[].origin.
|
|
@@ -161,16 +179,25 @@ const validateRound = (obj) => {
|
|
|
161
179
|
return { ok: true };
|
|
162
180
|
};
|
|
163
181
|
|
|
164
|
-
// validateTriage(obj) → { ok, reason }.
|
|
165
|
-
|
|
182
|
+
// validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version testId rule (v1
|
|
183
|
+
// tolerant / v2 the test-per-fold binding) — the shared structural checks run in both versions.
|
|
184
|
+
const validateTriage = (obj, schema = SCHEMA_VERSION) => {
|
|
166
185
|
if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
|
|
167
186
|
for (const c of obj.classifications) {
|
|
168
187
|
if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
|
|
169
188
|
if (!CLASSES.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
|
|
170
189
|
if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
|
|
171
|
-
// testId
|
|
172
|
-
//
|
|
190
|
+
// Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
|
|
191
|
+
// treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
|
|
173
192
|
if (!(c.testId === undefined || c.testId === null || isNonEmptyString(c.testId))) return { ok: false, reason: `triage: classification ${c.findingKey} testId must be null/absent or a non-empty string` };
|
|
193
|
+
// Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
|
|
194
|
+
// red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
|
|
195
|
+
// AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
|
|
196
|
+
if (schema >= 2) {
|
|
197
|
+
const present = isNonEmptyString(c.testId);
|
|
198
|
+
if (c.class === 'fixable-bug' && !present) return { ok: false, reason: `triage: classification ${c.findingKey} is a fixable-bug but carries no testId — record the red→green test that pins the fold (write it first)` };
|
|
199
|
+
if (present && !isWellFormedTestId(c.testId)) return { ok: false, reason: `triage: classification ${c.findingKey} testId "${c.testId}" is malformed — expected "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)` };
|
|
200
|
+
}
|
|
174
201
|
if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
|
|
175
202
|
}
|
|
176
203
|
return { ok: true };
|
|
@@ -181,14 +208,14 @@ const validateTriage = (obj) => {
|
|
|
181
208
|
// malformed-line surface and the per-check named tests can assert it.
|
|
182
209
|
export const validateRecord = (obj) => {
|
|
183
210
|
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
184
|
-
if (obj.schema
|
|
211
|
+
if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
|
|
185
212
|
if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
|
|
186
213
|
if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
|
|
187
214
|
if (!KINDS_SET.has(obj.kind)) return { ok: false, reason: `bad kind "${obj.kind}"` };
|
|
188
215
|
if (!(Number.isInteger(obj.round) && obj.round >= 1)) return { ok: false, reason: 'round must be an integer >= 1' };
|
|
189
216
|
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
|
|
190
217
|
if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
|
|
191
|
-
return obj.kind === 'round' ? validateRound(obj) : validateTriage(obj);
|
|
218
|
+
return obj.kind === 'round' ? validateRound(obj) : validateTriage(obj, obj.schema);
|
|
192
219
|
};
|
|
193
220
|
|
|
194
221
|
// readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
|