@sabaiway/agent-workflow-kit 11.1.0 → 11.2.0

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 CHANGED
@@ -4,6 +4,72 @@ 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
+ ## 11.2.0 — a field report is triaged through a rendered procedure over a checked record (AD-132)
8
+
9
+ Three consumer field reports were triaged ad hoc — verify each claim on HEAD, record the verdict,
10
+ queue with a hand ratchet bump — with no record shape, no anchor check and nothing rendering the
11
+ steps. The answer is not a skill: a fourth activity, a record grammar a checker can refuse, and a
12
+ procedure the advisor renders. Contract: `docs/ai/specs/kit/feedback-triage.md` (live, S1–S15 bound).
13
+
14
+ **The fourth activity.** `ACTIVITIES` in `tools/carriers.mjs` gains `feedback-triage` with ONE slot,
15
+ `review`, and `policy: false` — a chore inside a session like `routine`; no `carrier` slot, no
16
+ autonomy block. `procedures feedback-triage` renders the engine's canon section verbatim (Record ·
17
+ Verify · Check · review {recipe} · Rows · Fold; Definition of Done: a checked record, its rows in the
18
+ queue, the ratchet moved by exactly the rows rendered). Every activity list names it (four mode docs name
19
+ it and `recommendations.md` carries the count, both READMEs, the engine SKILL and README, the methodology slot — the outgoing three-activity
20
+ fragment joins `KNOWN_PRIOR_METHODOLOGY_SLOT`); `set-recipe`, `recipes`, `status` and the registry
21
+ survey take the slot; the golden active-recipes line carries `feedback-triage.review = reviewed`
22
+ (computed default); the tarball pin counts 287.
23
+
24
+ **The record and its checker.** `docs/plans/FEEDBACK-<date>-<slug>.md` — scratch by the in-flight-plan
25
+ prefix — is one grammar, version 1: `# Feedback:` title, `Source:`, `Head: <object id>`, `## Claims`
26
+ and ONE pipe table (`#` · claim · backticked `path:line[-line]` anchors · `confirmed` / `corrected` /
27
+ `refuted` / `works-as-designed` · `queue <ROW-ID>` / `already-queued <ROW-ID>` / `declined: <reason>` /
28
+ `folded: <reason>`), optional `## Notes`. `tools/feedback-record.mjs` (the pure half: parse, judge,
29
+ render rows, render excerpts, the ratchet line — imports nothing) and `tools/feedback-record-cli.mjs`
30
+ (`--check <record>`, `--check <record> --excerpts <out>`, `--rows <record>`; `main(argv, deps)` never
31
+ exits). `--check` reads the record and every anchor no-follow and capped, realpath-contained under
32
+ the cwd, decodes fatally, compares `Head:` to `git rev-parse HEAD` under the git-location leaf with
33
+ `GIT_OPTIONAL_LOCKS=0`, then proves tracking POSITIVELY: each anchor's LEXICAL git-top-relative path
34
+ rides ONE `git status --porcelain -z` and ONE `git ls-files -v -z` as the pathspec `:(literal)<rel>`
35
+ — a rel the index does not list is `anchor-dirty` "not tracked at the stamped HEAD" (`.git/config`,
36
+ an untracked file, a path through a symlinked parent), a tag other than `H` is `anchor-dirty` behind
37
+ a concealing index bit, `H` with a status entry "differs", `H` and clean is proof. Every defect has
38
+ one name from a closed set (`title` · `source` · `head` · `table` · `row-cells` · `claim-id` ·
39
+ `anchor-grammar` · `anchor-path` · `verdict` · `disposition` · `anchor-absent` · `anchor-unreadable`
40
+ · `anchor-line` · `anchor-dirty` · `git-location` · `head-mismatch` · `excerpts-destination` ·
41
+ `excerpts-budget` · `excerpts-write` · `EEXIST`): a structural defect (title, source, head, table)
42
+ ends the read with its name, a row defect is listed per row and an anchor finding per anchor —
43
+ never the first only. Exit 0 accept · 1 findings · 2 usage. `--excerpts` writes the verbatim anchor lines — the `--facts` payload of
44
+ the record's agy review — under `grounding.mjs`'s scratch-destination guard with an exclusive create,
45
+ budgeted at `DEFAULT_MAX_PROMPT_BYTES` minus the record minus `EXCERPTS_FRAMING_RESERVE_BYTES` (4096),
46
+ each text split once, the walk stopped at the budget, tail-trimmed through `trimToBudget`; a text
47
+ that would not begin with an excerpt line is `excerpts-budget`, nothing written. `--rows` prints one
48
+ skeleton queue row per distinct `queue <ROW-ID>` and the `--max-rows` ratchet line read from the git
49
+ top's `docs/ai/gates.json`. Suites: `feedback-record.test.mjs`, `feedback-record-cli.test.mjs`,
50
+ `feedback-record-cli-anchors.test.mjs` (the S9 state table) over the shared
51
+ `feedback-record-cli-harness.test.mjs`.
52
+
53
+ **The advisor renders the record as the review artifact.** For `feedback-triage` the procedures
54
+ advisor suppresses the driving contracts and the grounding pre-step and renders, after the slot's
55
+ reason, the check line with `--excerpts`, then one line per review member — `codex-review plan
56
+ <record>`, `agy-review plan <record> --facts @<facts>`, a ready lens's brief over the record plus the
57
+ excerpts, a skipped line for a not-ready member — and `review-rounds-cli --artifact <record>
58
+ --activity feedback-triage` followed by the persisted-obligation fact whenever the slot carries a
59
+ bridge obligation (a lens-only roster gets the no-receipt fact); `<record>` and `<facts>` stay
60
+ literal. The JSON key is `feedbackTriage`; the lens survey seam is `ctx.surveyLens ?? ((spec) => …)`.
61
+
62
+ **The seed note.** The canonical `_README` names four activities and states the defaults honestly —
63
+ the seed keeps its two-activity shape, so `feedback-triage.review` is silent and "reviewed as soon as
64
+ a review backend is ready (solo until then)"; the outgoing four-activity note joins
65
+ `KNOWN_PRIOR_README` and both seed twins carry the canonical byte for byte.
66
+
67
+ **Recorded tech debt (queue).** The note's "every slot seeded below" clause is a claim about the file;
68
+ the plain-`solo` render of the activity; the status flags decide nothing after the positive proof;
69
+ the registry suite walks one direction only; the import-boundary pin's grammar; S1's wording; a
70
+ submodule anchor reads "not tracked"; `status` / Recommendations drop `r.roster`; the `--kit-tools`
71
+ tier cannot take the checker.
72
+
7
73
  ## 11.1.0 — the bootstrap offers enforcement on every project, and the advisor closes the loop (AD-131)
8
74
 
9
75
  Bootstrap steps 8–9 (the enforcement scripts + the pre-commit hook) used to decide by the step-1
package/README.md CHANGED
@@ -232,8 +232,8 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
232
232
  | `/agent-workflow-kit gates` | any time | **project gate runner** — runs the verification commands **your project itself declares** in `docs/ai/gates.json` (seeded at deploy; hand-editable `{ id, title, cmd }` entries plus the optional boolean `lcovProducer`, each `cmd` one bash line) as one batch: a per-gate **PASS/FAIL table** + one machine-readable summary line, exit 0 iff all green; a failing gate's own output is shown verbatim; `--only <id>` re-runs one. Honest distinct outcomes for a missing / empty / malformed declaration — never a silent green. The runner writes nothing **by default** and never commits — opt-in `--final` runs the FULL declared matrix and mints ONE final-run receipt into the git-dir core-evidence store (status green/red, pre/post tree fingerprints, the declaration, the consumed lcov's sha; the receipt the commit-guard binds); it executes only your own declared commands (a batching convenience, not a sandbox). The velocity tier auto-approves only the exact no-`--final` form — the recording run stays explicit. |
233
233
  | `/agent-workflow-kit setup [backend]` | opt-in, any time | **link-only** auto-setup of a bridge: places the bundled bridge skill (only into an absent / empty / managed dir — never overwrites an unmanaged one) + links its wrappers onto `PATH` via managed symlinks (idempotent; refuses to clobber a non-symlink; try `--dry-run` to preview). The binary install + the one-time subscription login stay **manual**: it prints the exact **login** command and points the binary install at each bridge's `setup/README.md`. POSIX wrappers — on Windows use WSL. Never commits, never runs a subscription CLI. |
234
234
  | `/agent-workflow-kit status` | any time | **read-only** single view of **versions + deployment + settings + bridges**: which members (kit / memory / engine / the two bridges) are installed and at what version (with an honest "installed on this machine" note when one is behind) and — in a project — what's deployed (`docs/ai`, the version stamps, and the **visibility**: visible / hidden / unclear), and the **feature-spec adoption state** (`not adopted` / `adopting (N draft)` / `adopted (N live, M draft)` / `could not be read`, plus `declined` when recorded), plus your settings (orchestration recipes, attribution, velocity) and the bridges' readiness. The two version axes (package number vs deployment-structure head) stay decoupled. Never writes, never commits, never runs a subscription CLI. |
235
- | `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents five named recipes for composing the carriers of a step — the bridges and the full-tool executor subagent — into plan → execute → review — **Solo / Reviewed / Council / Delegated / Subagent** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready, or when the executor vehicle is missing/unusable), and offers the choice. The activity/slot registry (three activities) is rendered by `procedures` and by `set-recipe --help`. The orchestrator runs it via the bridge skills or the executor vehicle and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
236
- | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution` / `routine`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + carrier readiness (`plan-authoring`: author, fold, review; default Reviewed when a backend is ready, Council on request; slot-aware incl. Delegated and Subagent) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same). `--override <slot>=<value>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
235
+ | `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents five named recipes for composing the carriers of a step — the bridges and the full-tool executor subagent — into plan → execute → review — **Solo / Reviewed / Council / Delegated / Subagent** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready, or when the executor vehicle is missing/unusable), and offers the choice. The activity/slot registry (four activities) is rendered by `procedures` and by `set-recipe --help`. The orchestrator runs it via the bridge skills or the executor vehicle and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
236
+ | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution` / `routine` / `feedback-triage`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + carrier readiness (`plan-authoring`: author, fold, review; default Reviewed when a backend is ready, Council on request; slot-aware incl. Delegated and Subagent) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same) — except `feedback-triage`, whose record-bound block carries the check with `--excerpts`, the resolved member lines (ready bridge commands, the ready lens brief, or explicit skipped members), and either the record round-table command for a bridge obligation or the no-receipt fact otherwise: the record is the artifact. `--override <slot>=<value>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
237
237
  | `/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>=<value>` / `--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 or a subagent, never commits**; hand-editing stays fully supported. |
238
238
  | `/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). `--await [--timeout <s>]` (AD-049) BLOCKS until every recipe-named backend has receipted the current tree — the durable completion signal is the receipt, never a process event — so you wait for the bridges instead of hand-polling. 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). |
239
239
  | `/agent-workflow-kit sandbox-masks` | any time | **cosmetic exclude lane for sandbox device masks** — an OS sandbox (Claude Code) injects character-device masks into the work tree as untracked `git status` noise; the review domain already ignores them **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat — never a frozen list — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
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: '11.1.0'
6
+ version: '11.2.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "11.1.0",
6
+ "version": "11.2.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "11.1.0",
3
+ "version": "11.2.0",
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",
@@ -4,11 +4,12 @@
4
4
 
5
5
  Read-only **activity-procedures advisor**. Answers *"what are the steps of this named activity, and which recipe applies at each slot here?"* It composes the orchestration recipes (`${CLAUDE_SKILL_DIR}/references/modes/recipes.md`) into **named activities** with **typed recipe slots**. It **never writes, never commits, never runs a subscription CLI** — the deterministic resolution lives in the kit; the orchestrator runs the resolved recipe via the bridge skills or the executor vehicle and **owns any commit when the activity has a commit boundary** (every other carrier never commits). Not every activity commits: `plan-authoring` ends at approval and produces no commit (plans are ephemeral, never committed); `plan-execution` commits per ledger row.
6
6
 
7
- The three activities (canon in the **installed engine**, `references/procedures.md`; the registry is `tools/carriers.mjs`):
7
+ The four activities (canon in the **installed engine**, `references/procedures.md`; the registry is `tools/carriers.mjs`):
8
8
 
9
9
  - **`plan-authoring`** (slots: `author`, `fold`, `review`) — research → draft (the resolved `author` carrier drafts: Solo writes it, Subagent drafts from the orchestrator's brief) → self-review with the readers sweep first → **review {recipe}** → consult, then fold/loop through the resolved `fold` carrier → present for approval; enforce the mandatory Cleanup.
10
10
  - **`plan-execution`** (slots: `execute`, `review`) — per ledger row: resolve the recipe → if Delegated, dispatch execution first; if Subagent, dispatch file-disjoint slices to the executor vehicle and verify each returned slice → implement → self-review → **review {recipe}** → gates → commit boundary; after the last row, the project-declared stages and Phase: Cleanup run as rows of their own.
11
11
  - **`routine`** (slots: `carrier`, `parallel`) — name the chore and its file-disjoint slices → resolve → carry it (Solo, or Subagent: a read-only slice rides a placed read-only vehicle, a write-capable one rides the executor; `parallel` on → concurrently) → verify every returned slice; the commit boundary is unchanged.
12
+ - **`feedback-triage`** (slot: `review`) — record → verify every claim by code on the stamped HEAD → `feedback-record-cli --check` → **review {recipe}** over the record, the excerpts as agy's `--facts` payload → `--rows` renders the queue rows and the ratchet line → fold only a false statement in shipped copy.
12
13
 
13
14
  Value sets per slot type: `review` accepts `solo | reviewed | council`; `execute` accepts `solo | delegated | subagent`; a carrier slot (`author`, `fold`, `carrier`) accepts `solo | subagent`; `parallel` is a **switch**, `on | off`, resolved outside the recipe lattice. Defaults when the config is silent: `review` → Reviewed if a review-capable backend is ready, else Solo; `author`, `fold`, `execute`, `carrier` → Solo (a placed executor never flips a default); `parallel` → on. The orchestrator owns every commit; every other carrier — a bridge or a subagent — never commits.
14
15
 
@@ -38,6 +39,8 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
38
39
 
39
40
  **Robustness-brief block (`plan-execution` only).** The advisor renders `node ${CLAUDE_SKILL_DIR}/tools/robustness-brief.mjs --plan <plan>` populated when exactly one plan is in flight and names the zero/several-plan placeholder reason otherwise. `--json` carries the identical lines under **`robustnessBrief`**, an empty array outside `plan-execution`; human and structured renders share one block.
40
41
 
42
+ **Record-bound review block (`feedback-triage` only).** In place of the driving contracts and the grounding pre-step the advisor renders the check-with-excerpts line, then one line per review member — a ready bridge's plan-mode command, a ready lens's brief (the record plus the excerpts), a skipped line for a not-ready member — and the round-table line while the slot carries a bridge obligation (a roster naming no bridge: the no-receipt fact in its place; a fully degraded reviewed or council slot keeps the line). The round-table line is followed by the standing fact that the table judges the obligation `review-rounds-cli` resolves from `docs/ai/orchestration.json`, never this run's `--override`. `--json` carries the identical lines under **`feedbackTriage`** (an empty array for the other activities).
43
+
41
44
  **Cap-soft-skip degradation (the feature's only AUTO route).** The activity procedures are auto-discoverable only through the one-line **`workflow:methodology`** pointer (this kit + the engine carry `disable-model-invocation:true`, so NL like "write a plan" does **not** auto-load this skill). On a deployment whose methodology pointer was cap-soft-skipped — or whose pre-existing customized pointer lacks the procedures clause — the procedures are still reachable by **explicitly** invoking `/agent-workflow-kit procedures`; surface that plainly rather than treating it as a gap.
42
45
 
43
46
  **Invariants:** read-only · never writes · never commits · never runs a subscription CLI · the deterministic resolution is the kit's, the recipe execution is the orchestrator's.
@@ -16,7 +16,7 @@ The five recipes (defined over each bridge's `provides` roles — `codex`: execu
16
16
  2. **Offer the choice** via **`AskUserQuestion` where your agent supports it** (`AskUserQuestion` in Claude Code) — one option per recipe, the `recommendRecipe` choice listed **first** — otherwise in prose. Then print `planRecipe(chosen, detection)` (the per-stage dispatch + degradation reasons + quota/health notes) so the user sees exactly what running it entails.
17
17
  3. **Availability = `readiness === ready`, full stop.** Every other readiness supplies the human reason (needs-skill → "not installed — `/agent-workflow-kit setup`"; needs-cli → "install the CLI"; needs-credentials → "log in"; degraded → "wrapper not on PATH — `/agent-workflow-kit setup`"). This is set-up state only — **never** a claim that a backend's service is responsive (the detector cannot observe a runtime stall; `agy`'s Issue-001 is a *standing advisory*, not a readiness signal).
18
18
 
19
- **The activities and their slots.** One registry (`tools/carriers.mjs`, re-exported by `recipes.mjs`) names **three** activities and their typed slots: `plan-authoring` → `author`, `fold`, `review`; `plan-execution` → `execute`, `review`; `routine` → `carrier`, `parallel`. Each slot's accepted values follow its TYPE — `review` accepts `solo|reviewed|council`; `execute` accepts `solo|delegated|subagent`; `carrier` accepts `solo|subagent`. **`parallel` is a flag (`on|off`), not a recipe:** it resolves OUTSIDE the recipe lattice, never degrades, and never reaches the planner. A silent slot takes its **computed default** — `solo` for `author`, `fold`, `execute` and `carrier` (placing the executor vehicle never flips a default; the subagent carrier is opt-in exactly as `delegated` is), `reviewed`/`solo` by readiness for `review`, and `on` for `parallel`.
19
+ **The activities and their slots.** One registry (`tools/carriers.mjs`, re-exported by `recipes.mjs`) names **four** activities and their typed slots: `plan-authoring` → `author`, `fold`, `review`; `plan-execution` → `execute`, `review`; `routine` → `carrier`, `parallel`; `feedback-triage` → `review`. Each slot's accepted values follow its TYPE — `review` accepts `solo|reviewed|council`; `execute` accepts `solo|delegated|subagent`; `carrier` accepts `solo|subagent`. **`parallel` is a flag (`on|off`), not a recipe:** it resolves OUTSIDE the recipe lattice, never degrades, and never reaches the planner. A silent slot takes its **computed default** — `solo` for `author`, `fold`, `execute` and `carrier` (placing the executor vehicle never flips a default; the subagent carrier is opt-in exactly as `delegated` is), `reviewed`/`solo` by readiness for `review`, and `on` for `parallel`.
20
20
 
21
21
  **The configured-recipe line (`--active-line`, read-only).** `node ${CLAUDE_SKILL_DIR}/tools/recipes.mjs --active-line` prints exactly **one** machine-composed line: the **CONFIGURED** recipe of every activity/slot, resolved from the target project's `docs/ai/orchestration.json` (read from the current directory) + live readiness — each slot with its source (configured vs computed default), its degradation stated, and its dispatched wrapper set — explicitly contrasted with the readiness **recommendation** (which is informational; the configured recipes are what runs). Paste it verbatim: it fills the session-start discovery step (the deployed `agent_rules.md` §1.1) and the handover "Active recipes:" slot; `set-recipe` echoes the same line after every successful write. A malformed config fails loud (exit 1), never a silent fallback.
22
22
 
@@ -20,7 +20,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
20
20
  3. **The apply-through-agent lane — an explicit informed-consent checkpoint:** after presenting, OFFER to apply; the user selects items in plain language; for each selected item you **surface its posture note inline** where one exists (the per-item notes below cover exactly the risk-marked keys; for `sandbox-lane` the note INCLUDES the sandbox-lanes ladder — present the whole ladder inline at the consent moment, never as a bare pointer). The user **explicitly confirms**, and **only then** — **no command runs before confirmation** — do you run the rendered command. Do NOT infer safety from the presence or absence of an `--apply` flag: most items' rendered command IS the mutation and completes on that one run, **including a no-`--apply` mutation** such as `family-freshness`'s `npx … init`. Some items instead render a **dry-run preview** that changes nothing and prints an explicit follow-up `--apply`/mutating command to run NEXT (e.g. `sandbox-lane`'s ack-write — a NEUTRAL recipe fingerprint into the family-owned `docs/ai/acks.json`, never a security key — and the `gates-declaration` seeder): after the SAME confirmation you run that printed follow-up command — no second ask, no improvisation (each writer keeps its own consent semantics: previews, `--apply` flags, refusals). An item marked **HAND-APPLY** (e.g. the `read-lane` stale/missing arms) is **never run by you and never written by the kit** — hand the user its rendered line together with its posture note; HAND-APPLY is **maintainer territory wherever it renders** — the apply slot or the `recipe:` line. For `worktrees-dir` the two classes COMPOSE in one item and the ORDER is part of the contract: its `recipe:` line is the HAND-APPLY grant advice, its apply one-liner is the dry-run ack preview, and you **wait for the maintainer to confirm the grant is applied (or the terminal fallback chosen)** before running that preview and the exact `--apply` command it prints — the ack RECORDS a choice that must already have been made, and against a trusted host NO the item renders no ack at all.
21
21
  4. Registry strings (benefits + item texts) are frozen tool data, fact-true, ONE line under the pinned shape cap — posture/risk detail lives in the notes below at the consent moment, never inline in the overview. The dual velocity+security wording (`safer — blast radius bounded by the OS sandbox, not human attention`) rides ONLY the items with a real security delta (the autonomy render, the sandbox provisioning); the bridge-wrappers item claims **velocity only**.
22
22
 
23
- **The `executor-vehicle` item (the carrier the project already CONFIGURED — beside the `agents` offer, never the same item).** It fires on ONE pair of facts: a slot in `docs/ai/orchestration.json` names `subagent` while the executor vehicle (`.claude/agents/executor.md`) is `missing` or `unusable` — so every such slot silently degrades to solo. That is a configured declaration that is broken, hence **`attention`**, not an offer; its text names the state, its reason and how many configured slots are affected across the three activities. **`placed` and `customized` render NOTHING** (a working vehicle is not a recommendation), a config naming no `subagent` slot renders nothing, and a config the validated reader refuses is a stated **skip**, never an item computed over a shape nothing accepted. It is deliberately DISTINCT from its two neighbours, neither of which can observe this state: the `agents` offer converges the moment nothing is left to PLACE — which a present-but-unusable executor also satisfies — and the review-recipe probe skips a slot configured `subagent` by construction. Its apply is the agents writer's own `cheap-agents.mjs --apply` (the same writer the `agents` item previews; here the rendered line IS the mutation, so read the item's state before confirming), and its `recipe:` line is the hidden-mode reconcile follow-up (`hide-footprint --reconcile`), for a hidden deployment only. Honest limit on convergence: the apply places an ABSENT vehicle, so it clears `missing`; it never overwrites a diverged file and STOPs on a symlink, so on `unusable` the item's apply line carries a `HAND-APPLY:` precondition (fix or remove the file, then run the writer) and an `unusable` vehicle converges only after the maintainer fixes or deletes the file the reason names (the delete-to-reseed the agents mode documents) and applies then. Risk profile: create-only writes under `.claude/agents/` — never `settings.json`, never a commit.
23
+ **The `executor-vehicle` item (the carrier the project already CONFIGURED — beside the `agents` offer, never the same item).** It fires on ONE pair of facts: a slot in `docs/ai/orchestration.json` names `subagent` while the executor vehicle (`.claude/agents/executor.md`) is `missing` or `unusable` — so every such slot silently degrades to solo. That is a configured declaration that is broken, hence **`attention`**, not an offer; its text names the state, its reason and how many configured slots are affected across the four activities. **`placed` and `customized` render NOTHING** (a working vehicle is not a recommendation), a config naming no `subagent` slot renders nothing, and a config the validated reader refuses is a stated **skip**, never an item computed over a shape nothing accepted. It is deliberately DISTINCT from its two neighbours, neither of which can observe this state: the `agents` offer converges the moment nothing is left to PLACE — which a present-but-unusable executor also satisfies — and the review-recipe probe skips a slot configured `subagent` by construction. Its apply is the agents writer's own `cheap-agents.mjs --apply` (the same writer the `agents` item previews; here the rendered line IS the mutation, so read the item's state before confirming), and its `recipe:` line is the hidden-mode reconcile follow-up (`hide-footprint --reconcile`), for a hidden deployment only. Honest limit on convergence: the apply places an ABSENT vehicle, so it clears `missing`; it never overwrites a diverged file and STOPs on a symlink, so on `unusable` the item's apply line carries a `HAND-APPLY:` precondition (fix or remove the file, then run the writer) and an `unusable` vehicle converges only after the maintainer fixes or deletes the file the reason names (the delete-to-reseed the agents mode documents) and applies then. Risk profile: create-only writes under `.claude/agents/` — never `settings.json`, never a commit.
24
24
 
25
25
  **Per-item posture notes (the consent moment — surface BEFORE running or handing over the apply):**
26
26
 
@@ -21,13 +21,14 @@ mapping their wording onto them is your job, not a shipped phrase list.
21
21
 
22
22
  Run **`node ${CLAUDE_SKILL_DIR}/tools/set-recipe.mjs [--set <activity>.<slot>=<value>]… [--unset <activity>.<slot>]… [--write] [--json]`**:
23
23
 
24
- 1. **Grammar — always fully-qualified `<activity>.<slot>`** (the kit never guesses the activity; a bare `review=council` is rejected). The writer takes **every slot of the three activities**, and `--help` prints the registry (`tools/carriers.mjs`) as two blocks:
24
+ 1. **Grammar — always fully-qualified `<activity>.<slot>`** (the kit never guesses the activity; a bare `review=council` is rejected). The writer takes **every slot of the four activities**, and `--help` prints the registry (`tools/carriers.mjs`) as two blocks:
25
25
 
26
26
  ```
27
27
  Activities and their slots:
28
28
  plan-authoring → author, fold, review
29
29
  plan-execution → execute, review
30
30
  routine → carrier, parallel
31
+ feedback-triage → review
31
32
 
32
33
  Accepted values per slot type:
33
34
  review slots accept solo | reviewed | council
@@ -13,7 +13,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json [--dir <project>]
13
13
  > **Status reads `refresh`; the shared version block + the bootstrap/upgrade footers stay `notes`-based (unchanged this release).** `${CLAUDE_SKILL_DIR}/references/modes/status.md` has its OWN status-only render (above), keyed on `installed[].refresh.behind` / `refresh.recommend`. The shared **version block** (under *The version block + welcome mat* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and the bootstrap (step 11) + every upgrade (steps 4 / 8) report footer still consume `installed[].notes` verbatim — that wiring is deliberately **untouched** here (their migration onto `refresh` is deferred). Do not rewrite those footers onto `refresh`.
14
14
  2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly. When `project.adrLayout` is **`old`** or **`old-unrotated`**, add a plain-language note that the project still uses an **older ADR layout** and should run the opt-in **`/agent-workflow-kit migrate-adr-store`** to move to the one-file-per-ADR store (preview first; it never commits) — the note is the SAME for both, they differ only in how the older layout was detected (`old` = a retired archive file is still on disk; `old-unrotated` = the project's deployed rotation script predates the store); `migrated` / `none` need no note. Then **one `specs` line, always** (from `project.specs` — the feature-spec layer's adoption state, judged from the store alone): `not adopted` (no `docs/ai/specs/` — the layer was never seeded), `adopting (N draft)` (a store with no live contract yet), `adopted (N live, M draft)`, or `could not be read — <reason>` (the store or a document in it could not be observed; never counted as any of the other three). A recorded decline (`project.specs.declined`) appends ` — declined` to the first two. The internal tokens are `not-adopted` / `adopting` / `adopted` / `unreadable`; render the plain phrases above, never the tokens. An envelope without the field says the installed kit predates it — say so rather than inventing a state.
15
15
  3. **Settings (`--dir`, one line each)** (from `project.settings`):
16
- - **recipes** — the effective recipe per slot, **one line per slot** over the three activities (`plan-authoring.author` / `.fold` / `.review`, `plan-execution.execute` / `.review`, `routine.carrier` / `.parallel`); each line also carries **where the value came from** (`source` — the project's config, an explicit override, or a computed default) and, when the resolved recipe is not the one that was asked for, **what it degraded from** (`degradedFrom`). Say both in plain words (never the field names): an effective recipe alone cannot tell a deliberate choice from a default, nor a degrade from a config that really names the resolved recipe — and a slot showing `solo` after a degrade from `subagent` is exactly the case the **subagents** line below explains. Detail → `/agent-workflow-kit procedures` / `recipes`; a `recipes.detectError` → say the backends couldn't be checked, so bridge-backed recipes floored at solo; the executor vehicle is unaffected.
16
+ - **recipes** — the effective recipe per slot, **one line per slot** over the four activities (`plan-authoring.author` / `.fold` / `.review`, `plan-execution.execute` / `.review`, `routine.carrier` / `.parallel`, `feedback-triage.review`); each line also carries **where the value came from** (`source` — the project's config, an explicit override, or a computed default) and, when the resolved recipe is not the one that was asked for, **what it degraded from** (`degradedFrom`). Say both in plain words (never the field names): an effective recipe alone cannot tell a deliberate choice from a default, nor a degrade from a config that really names the resolved recipe — and a slot showing `solo` after a degrade from `subagent` is exactly the case the **subagents** line below explains. Detail → `/agent-workflow-kit procedures` / `recipes`; a `recipes.detectError` → say the backends couldn't be checked, so bridge-backed recipes floored at solo; the executor vehicle is unaffected.
17
17
  - **attribution** — `includeCoAuthoredBy` effective; call out a **local override** only when `local` is non-null **and** differs from `project` (a `null` `local` means the key is absent there, so the project value stands — that is not an override).
18
18
  - **velocity** — the effective `permissions.defaultMode` + whether an allowlist is seeded (detail → `/agent-workflow-kit velocity`).
19
19
  - **subagents** — how many of the kit's subagent vehicles are placed (`agents.placed` of `agents.bundled`; zero placed → the optional `/agent-workflow-kit agents` opt-in), then the **split the line always states separately**: the **read-only vehicles** (`agents.readOnly` — four; none of them grants a shell, three ride a cheap model for mechanical work and one is a read-only review lens) and the **ONE full-tool `executor`**, whose own state is `agents.executor`: *placed* / *customized* (both dispatchable) / *unusable* (`agents.executorReason` carries the reason — say it) / *missing*. That state is what decides whether a slot configured `subagent` can ride it: on **unusable** or **missing** every such slot runs solo instead, so surface it plainly beside the recipes lines and point at `/agent-workflow-kit agents` to place it. It is a claim about the FILE, never about the host. An envelope with no executor state says the installed kit predates the field — say so rather than inventing a state.
@@ -1,5 +1,5 @@
1
1
  {
2
- "_README": "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option never goes away. Three activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review) and 'routine' (slots carrier, parallel). A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form; an 'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts on | off and decides whether file-disjoint subagent slices dispatch concurrently. The default below is 'solo' for every recipe and carrier slot, and 'on' for the parallel switch — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review backend is ready and otherwise solo for a review slot, solo for author, fold, execute and carrier, on for parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment. Strict JSON — no comments.",
2
+ "_README": "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option never goes away. Four activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review), 'routine' (slots carrier, parallel) and 'feedback-triage' (slot review). A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form; an 'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts on | off and decides whether file-disjoint subagent slices dispatch concurrently. Every slot seeded below is 'solo' — no execution backend required. A slot the seed leaves silent takes the computed default stated further down: 'feedback-triage.review' is reviewed as soon as a review backend is ready (solo until then); 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' stay 'solo', and 'routine.parallel' stays 'on'. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review backend is ready and otherwise solo for a review slot, solo for author, fold, execute and carrier, on for parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment. Strict JSON — no comments.",
3
3
  "plan-authoring": {
4
4
  "review": "solo"
5
5
  },
@@ -15,6 +15,7 @@ export const ACTIVITIES = {
15
15
  'plan-authoring': { slots: { author: 'carrier', fold: 'carrier', review: 'review' }, policy: true },
16
16
  'plan-execution': { slots: { execute: 'execute', review: 'review' }, policy: true },
17
17
  routine: { slots: { carrier: 'carrier', parallel: 'switch' }, policy: false },
18
+ 'feedback-triage': { slots: { review: 'review' }, policy: false },
18
19
  };
19
20
 
20
21
  export const POLICY_ACTIVITIES = Object.fromEntries(
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ // Feedback-record filesystem and argv shell. Governing contract:
3
+ // docs/ai/specs/kit/feedback-triage.md.
4
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
8
+ import { isDirectRun } from './direct-run.mjs';
9
+ import { readFileBytesNoFollowCapped } from './fs-read-nofollow.mjs';
10
+ import { GIT_MAX_BUFFER, resolveGitLocation, stripGitLocationEnv, withGitPath } from './git-env.mjs';
11
+ import { assertScratchDestination, DEFAULT_MAX_PROMPT_BYTES, trimToBudget } from './grounding.mjs';
12
+ import { judgeAnchors, parseRecord, ratchetLine, REFUSALS, renderExcerpts, renderRows } from './feedback-record.mjs';
13
+
14
+ const RECORD_MAX_BYTES = 1024 * 1024;
15
+ export const EXCERPTS_FRAMING_RESERVE_BYTES = 4096;
16
+ const GATES_REL = 'docs/ai/gates.json';
17
+ const QUEUE_GATE_TOKEN = 'queue-audit-cli.mjs';
18
+ const MODE_FLAGS = Object.freeze(['--check', '--rows']);
19
+ const FATAL_PARSE_REFUSALS = Object.freeze(REFUSALS.slice(0, 4));
20
+ const FINDING_NAMES = Object.freeze({
21
+ anchorPath: 'anchor-path', anchorDirty: 'anchor-dirty', gitLocation: 'git-location',
22
+ headMismatch: 'head-mismatch', excerptsDestination: 'excerpts-destination',
23
+ excerptsBudget: 'excerpts-budget', excerptsWrite: 'excerpts-write', exists: 'EEXIST',
24
+ });
25
+ const GIT_ENV_LOCALE = 'C';
26
+ const GIT_OPTIONAL_LOCKS_DISABLED = '0';
27
+ const EXCLUSIVE_CREATE_FLAG = 'wx';
28
+ const EMPTY_ANSWER_OK = new Set(['status', 'ls-files']);
29
+ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
30
+
31
+ const readValue = (argv, index, flag) => {
32
+ const value = argv[index + 1];
33
+ if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
34
+ return value;
35
+ };
36
+
37
+ const parseArgv = (argv) => {
38
+ const options = { mode: null, record: null, excerpts: null };
39
+ const seen = new Set();
40
+ for (const [index, arg] of argv.entries()) {
41
+ if (seen.has(index)) continue;
42
+ if (MODE_FLAGS.includes(arg)) {
43
+ if (options.mode !== null) throw fail(2, 'name exactly one of --check or --rows');
44
+ options.mode = arg.slice(2);
45
+ options.record = readValue(argv, index, arg);
46
+ seen.add(index + 1);
47
+ } else if (arg === '--excerpts') {
48
+ if (options.excerpts !== null) throw fail(2, '--excerpts was given twice');
49
+ options.excerpts = readValue(argv, index, arg);
50
+ seen.add(index + 1);
51
+ } else throw fail(2, `unknown argument: ${arg}`);
52
+ }
53
+ if (options.mode === null) throw fail(2, 'one of --check or --rows is required');
54
+ if (options.excerpts !== null && options.mode !== 'check') throw fail(2, '--excerpts is available only with --check');
55
+ return options;
56
+ };
57
+
58
+ const decodeBytes = (bytes) => {
59
+ try { return FATAL_UTF8.decode(bytes); }
60
+ catch { return null; }
61
+ };
62
+ const countLines = (text) => text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
63
+ const getHeadLine = (text) => Math.max(1, text.split('\n').findIndex((line) => line.replace(/\r$/u, '').startsWith('Head: ')) + 1);
64
+ const formatFinding = (record, finding) =>
65
+ `${String(record).replace(/[\r\n]+/gu, ' ')}:${finding.line}: ${finding.name}: ${String(finding.message).replace(/[\r\n]+/gu, ' ')}`;
66
+ const reportFindings = (record, findings, error) => {
67
+ for (const finding of findings) error(formatFinding(record, finding));
68
+ return findings.length === 0 ? 0 : 1;
69
+ };
70
+
71
+ const readRecord = (recordPath, io) => {
72
+ const result = readFileBytesNoFollowCapped(recordPath, RECORD_MAX_BYTES, io);
73
+ if (result.outcome !== 'ok') {
74
+ const reason = result.outcome === 'over-cap' ? `over the ${RECORD_MAX_BYTES}-byte cap` : result.code ?? result.className ?? result.outcome;
75
+ throw fail(2, `cannot read regular feedback record ${recordPath}: ${reason}`);
76
+ }
77
+ const text = decodeBytes(result.bytes);
78
+ if (text === null) throw fail(2, `cannot read regular feedback record ${recordPath}: invalid UTF-8`);
79
+ return { bytes: result.bytes, text };
80
+ };
81
+
82
+ const isContained = (root, target) => {
83
+ const rel = relative(root, target);
84
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
85
+ };
86
+ const inspectAnchors = (claims, cwd, io) => {
87
+ const anchors = claims.flatMap((claim) => claim.anchors);
88
+ const paths = [...new Set(anchors.map((anchor) => anchor.path))];
89
+ const facts = Object.create(null);
90
+ const texts = Object.create(null);
91
+ const targets = Object.create(null);
92
+ const findings = [];
93
+ const resolvedRoot = (() => {
94
+ try { return { root: realpathSync(cwd), reason: null }; }
95
+ catch (err) { return { root: null, reason: err?.code ?? err?.message ?? String(err) }; }
96
+ })();
97
+ if (paths.length === 0) return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
98
+ if (resolvedRoot.root === null) {
99
+ for (const path of paths) {
100
+ facts[path] = { kind: 'regular', lines: Number.MAX_SAFE_INTEGER };
101
+ for (const anchor of anchors.filter((candidate) => candidate.path === path)) {
102
+ findings.push({ name: FINDING_NAMES.anchorPath, line: anchor.line, path, message: `the working directory cannot be resolved (${resolvedRoot.reason})` });
103
+ }
104
+ }
105
+ return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
106
+ }
107
+ for (const path of paths) {
108
+ const occurrences = anchors.filter((anchor) => anchor.path === path);
109
+ const full = resolve(cwd, path);
110
+ const parent = (() => {
111
+ try { return realpathSync(dirname(full)); }
112
+ catch { return null; }
113
+ })();
114
+ if (parent === null) {
115
+ facts[path] = { kind: 'absent' };
116
+ continue;
117
+ }
118
+ const target = join(parent, basename(full));
119
+ if (!isContained(resolvedRoot.root, target)) {
120
+ facts[path] = { kind: 'regular', lines: Number.MAX_SAFE_INTEGER };
121
+ for (const anchor of occurrences) findings.push({ name: FINDING_NAMES.anchorPath, line: anchor.line, path, message: `anchor resolves outside the working tree: ${path}` });
122
+ continue;
123
+ }
124
+ const result = readFileBytesNoFollowCapped(target, RECORD_MAX_BYTES, io);
125
+ if (result.outcome === 'absent' || result.outcome === 'foreign') {
126
+ facts[path] = { kind: result.outcome === 'absent' ? 'absent' : 'other' };
127
+ } else if (result.outcome === 'over-cap' || result.outcome === 'error') {
128
+ const reason = result.outcome === 'over-cap' ? `over the ${RECORD_MAX_BYTES}-byte cap` : result.code ?? 'read failed';
129
+ facts[path] = { kind: 'unreadable', reason };
130
+ } else {
131
+ const text = decodeBytes(result.bytes);
132
+ if (text === null) facts[path] = { kind: 'unreadable', reason: 'invalid UTF-8' };
133
+ else {
134
+ facts[path] = { kind: 'regular', lines: countLines(text) };
135
+ texts[path] = text;
136
+ targets[path] = target;
137
+ }
138
+ }
139
+ }
140
+ return { anchors, facts, texts, targets, paths, findings, root: resolvedRoot.root };
141
+ };
142
+
143
+ const runGit = (spawn, cwd, env, args) => {
144
+ const options = {
145
+ cwd, env: withGitPath({
146
+ ...stripGitLocationEnv(env), LC_ALL: GIT_ENV_LOCALE, GIT_OPTIONAL_LOCKS: GIT_OPTIONAL_LOCKS_DISABLED,
147
+ }),
148
+ encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
149
+ };
150
+ const attempted = (() => {
151
+ try { return { result: spawn('git', args, options), error: null }; }
152
+ catch (err) { return { result: null, error: `git threw synchronously (${err?.message ?? err})` }; }
153
+ })();
154
+ if (attempted.error !== null) return { error: attempted.error };
155
+ const result = attempted.result;
156
+ if (result == null || typeof result !== 'object') return { error: 'the git runner returned no result' };
157
+ if (result.error) return { error: `git could not run (${result.error.code ?? result.error.message ?? result.error})` };
158
+ if (result.signal) return { error: `git ${args[0]} was killed by ${result.signal}` };
159
+ const stderr = String(result.stderr ?? '').trim();
160
+ if (result.status !== 0) return { error: `git ${args[0]} exited ${result.status}${stderr ? ` (${stderr})` : ''}` };
161
+ const stdout = String(result.stdout ?? '');
162
+ if (stdout.length === 0 && !EMPTY_ANSWER_OK.has(args[0])) return { error: `git ${args[0]} answered nothing` };
163
+ return { stdout };
164
+ };
165
+
166
+ const parseDirtyPaths = (stdout) => stdout.split('\0').filter(Boolean).map((entry) => entry.slice(3));
167
+
168
+ const inspectGit = ({ cwd, env, spawn, head, headLine, anchors }) => {
169
+ const findings = [];
170
+ const location = resolveGitLocation(cwd, { spawn, env });
171
+ if (location.state !== 'work-tree') {
172
+ return { findings: [{ name: FINDING_NAMES.gitLocation, line: headLine, message: `${location.state}: "${location.cause ?? 'unknown cause'}"` }], top: null };
173
+ }
174
+ const checkout = runGit(spawn, cwd, env, ['rev-parse', 'HEAD']);
175
+ if (checkout.error) return { findings: [{ name: FINDING_NAMES.gitLocation, line: headLine, message: checkout.error }], top: null };
176
+ const actualHead = checkout.stdout.trim();
177
+ if (actualHead === '') findings.push({ name: FINDING_NAMES.gitLocation, line: headLine, message: 'git rev-parse HEAD answered nothing' });
178
+ else if (actualHead.toLowerCase() !== head.toLowerCase()) findings.push({ name: FINDING_NAMES.headMismatch, line: headLine, message: `record head ${head} does not match checkout head ${actualHead}` });
179
+ const resolvedTop = (() => {
180
+ try { return { value: realpathSync(location.top), error: null }; }
181
+ catch (err) { return { value: null, error: err?.code ?? err?.message ?? String(err) }; }
182
+ })();
183
+ if (resolvedTop.error !== null) {
184
+ return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `the work-tree top cannot be resolved (${resolvedTop.error})` }], top: location.top };
185
+ }
186
+ const top = resolvedTop.value;
187
+ const rels = Object.fromEntries(Object.keys(anchors.targets).map((path) => [
188
+ path, relative(top, resolve(anchors.root, path)).split(sep).join('/'),
189
+ ]));
190
+ if (Object.keys(rels).length === 0) return { findings, top: location.top };
191
+ const pathspecs = Object.values(rels).map((rel) => `:(literal)${rel}`);
192
+ const status = runGit(spawn, location.top, env, ['status', '--porcelain', '-z', '--untracked-files=all', '--ignored=traditional', '--', ...pathspecs]);
193
+ if (status.error) return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `${FINDING_NAMES.anchorDirty} cannot be judged: ${status.error}` }], top: location.top };
194
+ const dirty = new Set(parseDirtyPaths(status.stdout));
195
+ const listing = runGit(spawn, location.top, env, ['ls-files', '-v', '-z', '--', ...pathspecs]);
196
+ if (listing.error) return { findings: [...findings, { name: FINDING_NAMES.gitLocation, line: headLine, message: `${FINDING_NAMES.anchorDirty} cannot be judged: ${listing.error}` }], top: location.top };
197
+ const listed = new Map();
198
+ for (const entry of listing.stdout.split('\0').filter(Boolean)) {
199
+ listed.set(entry.slice(2), entry[0]);
200
+ }
201
+ for (const path of Object.keys(rels)) {
202
+ const rel = rels[path];
203
+ const tag = listed.get(rel);
204
+ const message = tag === undefined
205
+ ? `anchor is not tracked at the stamped HEAD: ${path}`
206
+ : tag !== 'H'
207
+ ? `the index conceals the working tree for ${path} (ls-files tag ${tag})`
208
+ : dirty.has(rel)
209
+ ? `anchor differs from the stamped HEAD: ${path}`
210
+ : null;
211
+ if (message === null) continue;
212
+ for (const anchor of anchors.anchors.filter((candidate) => candidate.path === path)) {
213
+ findings.push({ name: FINDING_NAMES.anchorDirty, line: anchor.line, path, message });
214
+ }
215
+ }
216
+ return { findings, top: location.top };
217
+ };
218
+
219
+ const readRatchet = (cwd, rows) => {
220
+ const path = join(cwd, GATES_REL);
221
+ const parsed = (() => {
222
+ try { return { value: JSON.parse(readFileSync(path, 'utf8')), error: null }; }
223
+ catch (err) { return { value: null, error: err }; }
224
+ })();
225
+ if (parsed.error?.code === 'ENOENT') return `ratchet: ${GATES_REL} is absent`;
226
+ if (parsed.error) return `ratchet: ${GATES_REL} cannot be read (${parsed.error.message})`;
227
+ const gate = Array.isArray(parsed.value?.gates)
228
+ ? parsed.value.gates.find((entry) => typeof entry?.cmd === 'string' && entry.cmd.includes(QUEUE_GATE_TOKEN))
229
+ : null;
230
+ return gate ? ratchetLine(gate.cmd, rows) : `ratchet: gate carrying ${QUEUE_GATE_TOKEN} is absent`;
231
+ };
232
+
233
+ const writeExcerpts = ({ options, cwd, recordBytes, anchors, error }) => {
234
+ const destination = (() => {
235
+ try { return { value: assertScratchDestination(options.excerpts, cwd), error: null }; }
236
+ catch (err) { return { value: null, error: err }; }
237
+ })();
238
+ if (destination.error) {
239
+ error(`${FINDING_NAMES.excerptsDestination}: ${String(destination.error.message ?? destination.error).replace(/^--out/u, '--excerpts')}`);
240
+ return 2;
241
+ }
242
+ const budget = DEFAULT_MAX_PROMPT_BYTES - recordBytes - EXCERPTS_FRAMING_RESERVE_BYTES;
243
+ if (budget <= 0) return reportFindings(options.record, [{ name: FINDING_NAMES.excerptsBudget, line: 1, message: 'the record leaves no byte budget for excerpts' }], error);
244
+ const payload = renderExcerpts(anchors.anchors, anchors.texts, budget);
245
+ const trimmed = trimToBudget(payload, budget);
246
+ if (trimmed.trimmedBytes > 0 && !/^[^\n]/u.test(trimmed.text)) {
247
+ return reportFindings(options.record, [{ name: FINDING_NAMES.excerptsBudget, line: 1, message: `the ${budget}-byte budget leaves no excerpt line beside the trim marker` }], error);
248
+ }
249
+ const written = (() => {
250
+ try { writeFileSync(destination.value.path, trimmed.text, { flag: EXCLUSIVE_CREATE_FLAG }); return null; }
251
+ catch (err) { return err; }
252
+ })();
253
+ if (written === null) return 0;
254
+ const name = written.code === FINDING_NAMES.exists ? FINDING_NAMES.exists : FINDING_NAMES.excerptsWrite;
255
+ return reportFindings(options.record, [{ name, line: 1, message: written.message ?? String(written) }], error);
256
+ };
257
+
258
+ export const main = (argv, deps = {}) => {
259
+ const cwd = deps.cwd ?? process.cwd();
260
+ const env = deps.env ?? process.env;
261
+ const spawn = deps.spawn ?? spawnSync;
262
+ const log = deps.log ?? console.log;
263
+ const error = deps.error ?? console.error;
264
+ const io = deps.io ?? {};
265
+ const now = deps.now ?? (() => new Date());
266
+ const options = (() => {
267
+ try { return { value: parseArgv(argv), error: null }; }
268
+ catch (err) { return { value: null, error: err }; }
269
+ })();
270
+ if (options.error) { error(options.error.message); return options.error.exitCode ?? 2; }
271
+ const loaded = (() => {
272
+ try { return { value: readRecord(resolve(cwd, options.value.record), io), error: null }; }
273
+ catch (err) { return { value: null, error: err }; }
274
+ })();
275
+ if (loaded.error) { error(loaded.error.message); return loaded.error.exitCode ?? 2; }
276
+ const parsed = parseRecord(loaded.value.text);
277
+ if (parsed.refusals.some((finding) => FATAL_PARSE_REFUSALS.includes(finding.name))) {
278
+ return reportFindings(options.value.record, parsed.refusals, error);
279
+ }
280
+ const anchors = inspectAnchors(parsed.claims, cwd, io);
281
+ const git = inspectGit({ cwd, env, spawn, head: parsed.head, headLine: getHeadLine(loaded.value.text), anchors });
282
+ const findings = [
283
+ ...parsed.refusals,
284
+ ...anchors.findings,
285
+ ...judgeAnchors(parsed.claims, anchors.facts),
286
+ ...git.findings,
287
+ ];
288
+ if (reportFindings(options.value.record, findings, error) !== 0) return 1;
289
+ if (options.value.mode === 'rows') {
290
+ const rows = renderRows(parsed, { date: now().toISOString().slice(0, 10), recordPath: options.value.record });
291
+ for (const row of rows) log(row);
292
+ log(readRatchet(git.top, rows));
293
+ }
294
+ if (options.value.excerpts !== null) {
295
+ return writeExcerpts({ options: options.value, cwd, recordBytes: loaded.value.bytes.length, anchors, error });
296
+ }
297
+ return 0;
298
+ };
299
+
300
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,243 @@
1
+ // Feedback-record grammar and pure judgments. Governing contract:
2
+ // docs/ai/specs/kit/feedback-triage.md.
3
+
4
+ const REFUSAL_NAMES = Object.freeze({
5
+ title: 'title', source: 'source', head: 'head', table: 'table', rowCells: 'row-cells',
6
+ claimId: 'claim-id', anchorGrammar: 'anchor-grammar', anchorPath: 'anchor-path',
7
+ verdict: 'verdict', disposition: 'disposition', anchorAbsent: 'anchor-absent',
8
+ anchorUnreadable: 'anchor-unreadable', anchorLine: 'anchor-line',
9
+ });
10
+ export const VERDICTS = Object.freeze(['confirmed', 'corrected', 'refuted', 'works-as-designed']);
11
+ export const REFUSALS = Object.freeze(Object.values(REFUSAL_NAMES));
12
+
13
+ const TITLE_PREFIX = '# Feedback: ';
14
+ const SOURCE_PREFIX = 'Source: ';
15
+ const HEAD_PREFIX = 'Head: ';
16
+ const CLAIMS_HEADING = '## Claims';
17
+ const NOTES_HEADING = '## Notes';
18
+ const TABLE_HEADER = '| # | Claim | Evidence | Verdict | Disposition |';
19
+ const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
20
+ const ANCHOR = /^`([^`,\s]+):([1-9][0-9]*)(?:-([1-9][0-9]*))?`$/u;
21
+ const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
22
+ const QUEUE = new RegExp(`^queue (${ROW_ID})$`, 'u');
23
+ const ALREADY_QUEUED = new RegExp(`^already-queued (${ROW_ID})$`, 'u');
24
+ const REASONED_DISPOSITION = /^(?:declined|folded): (.+)$/u;
25
+ const LINE_JOINER = '\n';
26
+
27
+ const makeRefusal = (name, line, message) => ({ name, line, message });
28
+ const getLineNumber = (lines, index) => Math.min(index + 1, Math.max(lines.length, 1));
29
+ const findNextContent = (lines, start) => {
30
+ const index = lines.findIndex((line, offset) => offset >= start && line.trim() !== '');
31
+ return index === -1 ? lines.length : index;
32
+ };
33
+ const makeFatalResult = (record, name, line, message) => ({
34
+ ...record, claims: [], refusals: [makeRefusal(name, line, message)],
35
+ });
36
+
37
+ const splitCells = (line) => {
38
+ if (!line.startsWith('|') || !line.endsWith('|')) return null;
39
+ const body = line.slice(1, -1);
40
+ const parsed = Array.from(body).reduce((state, byte, index) => {
41
+ if (state.skip) state.skip = false;
42
+ else if (byte === '\\' && body[index + 1] === '|') { state.cell += '|'; state.skip = true; }
43
+ else if (byte === '|') { state.cells.push(state.cell.trim()); state.cell = ''; }
44
+ else state.cell += byte;
45
+ return state;
46
+ }, { cells: [], cell: '', skip: false });
47
+ return [...parsed.cells, parsed.cell.trim()];
48
+ };
49
+
50
+ const parsesDelimiter = (line) => {
51
+ const cells = splitCells(line);
52
+ return cells?.length === 5 && cells.every((cell) => /^-{3,}$/u.test(cell));
53
+ };
54
+
55
+ const RECORD_STATES = Object.freeze([
56
+ { name: REFUSAL_NAMES.title, fixed: true, accepts: (line) => line.startsWith(TITLE_PREFIX) && line.slice(TITLE_PREFIX.length).trim() !== '', assigns: (record, line) => { record.title = line.slice(TITLE_PREFIX.length); }, message: `line 1 must be ${TITLE_PREFIX}<title>` },
57
+ { name: REFUSAL_NAMES.source, accepts: (line) => line.startsWith(SOURCE_PREFIX) && line.slice(SOURCE_PREFIX.length).trim() !== '', assigns: (record, line) => { record.source = line.slice(SOURCE_PREFIX.length); }, message: `expected ${SOURCE_PREFIX}<one line>` },
58
+ { name: REFUSAL_NAMES.head, accepts: (line) => line.startsWith(HEAD_PREFIX) && OBJECT_ID.test(line.slice(HEAD_PREFIX.length)), assigns: (record, line) => { record.head = line.slice(HEAD_PREFIX.length); }, message: `expected ${HEAD_PREFIX}<object id>` },
59
+ { name: REFUSAL_NAMES.table, accepts: (line) => line === CLAIMS_HEADING, assigns: () => {}, message: `expected ${CLAIMS_HEADING}` },
60
+ { name: REFUSAL_NAMES.table, accepts: (line) => line === TABLE_HEADER, assigns: () => {}, message: `expected ${TABLE_HEADER}` },
61
+ { name: REFUSAL_NAMES.table, accepts: parsesDelimiter, assigns: () => {}, message: 'expected a five-cell table delimiter' },
62
+ ]);
63
+
64
+ const hasUnsafePath = (path) =>
65
+ path.startsWith('/') || path.includes('\\') || path.split('/').includes('..');
66
+
67
+ const parseAnchors = (value, line) => {
68
+ const tokens = value.split(/,\s*/u);
69
+ if (tokens.length === 0 || tokens.some((token) => token === '')) {
70
+ return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, 'Evidence must name one or more backticked anchors') };
71
+ }
72
+ const anchors = [];
73
+ for (const token of tokens) {
74
+ const match = ANCHOR.exec(token);
75
+ if (!match || match[1].includes('|')) {
76
+ return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, `invalid anchor grammar: ${token}`) };
77
+ }
78
+ const [, path, startText, endText] = match;
79
+ if (hasUnsafePath(path)) {
80
+ return { refusal: makeRefusal(REFUSAL_NAMES.anchorPath, line, `anchor path is not repository-relative: ${path}`) };
81
+ }
82
+ const start = Number(startText);
83
+ const end = endText === undefined ? start : Number(endText);
84
+ if (start > end) {
85
+ return { refusal: makeRefusal(REFUSAL_NAMES.anchorGrammar, line, `anchor range starts after it ends: ${token}`) };
86
+ }
87
+ anchors.push({ path, start, end, line });
88
+ }
89
+ return { anchors };
90
+ };
91
+
92
+ const parsesDisposition = (value) =>
93
+ QUEUE.test(value) || ALREADY_QUEUED.test(value) || REASONED_DISPOSITION.test(value);
94
+
95
+ const parseClaim = (lineText, line, expectedId) => {
96
+ const cells = splitCells(lineText);
97
+ if (cells?.length !== 5) {
98
+ return { refusal: makeRefusal(REFUSAL_NAMES.rowCells, line, 'claim row must contain exactly five cells') };
99
+ }
100
+ const [idText, claim, evidence, verdict, disposition] = cells;
101
+ if (idText !== String(expectedId)) {
102
+ return { refusal: makeRefusal(REFUSAL_NAMES.claimId, line, `claim id must be ${expectedId}, got ${idText || '(empty)'}`) };
103
+ }
104
+ const parsedAnchors = parseAnchors(evidence, line);
105
+ if (parsedAnchors.refusal) return parsedAnchors;
106
+ if (!VERDICTS.includes(verdict)) {
107
+ return { refusal: makeRefusal(REFUSAL_NAMES.verdict, line, `unknown verdict: ${verdict || '(empty)'}`) };
108
+ }
109
+ if (!parsesDisposition(disposition)) {
110
+ return { refusal: makeRefusal(REFUSAL_NAMES.disposition, line, `invalid disposition: ${disposition || '(empty)'}`) };
111
+ }
112
+ return {
113
+ claim: { id: expectedId, claim, anchors: parsedAnchors.anchors, verdict, disposition, line },
114
+ };
115
+ };
116
+
117
+ export const parseRecord = (text) => {
118
+ const lines = String(text).split('\n').map((line) => line.replace(/\r$/u, ''));
119
+ const record = { head: null, title: null, source: null, claims: [], refusals: [] };
120
+ const structure = RECORD_STATES.reduce((state, spec) => {
121
+ if (state.refusal !== null) return state;
122
+ const index = spec.fixed ? 0 : findNextContent(lines, state.index + 1);
123
+ const line = lines[index] ?? '';
124
+ if (!spec.accepts(line)) return { index, refusal: makeRefusal(spec.name, getLineNumber(lines, index), spec.message) };
125
+ spec.assigns(record, line);
126
+ return { index, refusal: null };
127
+ }, { index: -1, refusal: null });
128
+ if (structure.refusal !== null) {
129
+ return makeFatalResult(record, structure.refusal.name, structure.refusal.line, structure.refusal.message);
130
+ }
131
+ const delimiterIndex = structure.index;
132
+ const table = lines.slice(delimiterIndex + 1).reduce((state, lineText, offset) => {
133
+ const line = delimiterIndex + offset + 2;
134
+ if (state.notes || state.fatal !== null) return state;
135
+ if (state.done) {
136
+ if (lineText.trim() === '') return state;
137
+ if (lineText === NOTES_HEADING) return { ...state, notes: true };
138
+ return {
139
+ ...state,
140
+ fatal: makeRefusal(
141
+ REFUSAL_NAMES.table,
142
+ line,
143
+ `the claims table ended at line ${state.endedLine}; only ${NOTES_HEADING} or the end of the record may follow`,
144
+ ),
145
+ };
146
+ }
147
+ if (lineText === NOTES_HEADING) return { ...state, done: true, notes: true };
148
+ if (lineText.trim() === '') return { ...state, done: true, endedLine: line };
149
+ const parsed = parseClaim(lineText, delimiterIndex + offset + 2, state.expectedId);
150
+ return {
151
+ expectedId: state.expectedId + 1,
152
+ done: false,
153
+ notes: false,
154
+ endedLine: null,
155
+ fatal: null,
156
+ claims: parsed.claim ? [...state.claims, parsed.claim] : state.claims,
157
+ refusals: parsed.refusal ? [...state.refusals, parsed.refusal] : state.refusals,
158
+ };
159
+ }, { expectedId: 1, done: false, notes: false, endedLine: null, fatal: null, claims: [], refusals: [] });
160
+ if (table.fatal !== null) {
161
+ return makeFatalResult(record, table.fatal.name, table.fatal.line, table.fatal.message);
162
+ }
163
+ if (table.claims.length === 0 && table.refusals.length === 0) {
164
+ return makeFatalResult(record, REFUSAL_NAMES.table, delimiterIndex + 1, 'the claims table carries no row');
165
+ }
166
+ record.claims = table.claims;
167
+ record.refusals = table.refusals;
168
+ return record;
169
+ };
170
+
171
+ export const judgeAnchors = (claims, facts) => {
172
+ const findings = [];
173
+ for (const claim of claims) {
174
+ for (const anchor of claim.anchors) {
175
+ const fact = Object.hasOwn(facts, anchor.path) ? facts[anchor.path] : { kind: 'absent' };
176
+ if (fact.kind === 'absent' || fact.kind === 'other') {
177
+ findings.push({ name: REFUSAL_NAMES.anchorAbsent, line: anchor.line, path: anchor.path, message: `anchor is absent or not a regular file: ${anchor.path}` });
178
+ } else if (fact.kind === 'unreadable') {
179
+ findings.push({ name: REFUSAL_NAMES.anchorUnreadable, line: anchor.line, path: anchor.path, message: `anchor cannot be read: ${anchor.path} (${fact.reason ?? 'unknown reason'})` });
180
+ } else if (anchor.end > fact.lines) {
181
+ findings.push({ name: REFUSAL_NAMES.anchorLine, line: anchor.line, path: anchor.path, message: `anchor line ${anchor.end} exceeds ${fact.lines} lines in ${anchor.path}` });
182
+ }
183
+ }
184
+ }
185
+ return findings;
186
+ };
187
+
188
+ const rendersAnchor = (anchor) => `${anchor.path}:${anchor.start}${anchor.end === anchor.start ? '' : `-${anchor.end}`}`;
189
+
190
+ const makeRange = (start, end) => ({
191
+ [Symbol.iterator]: () => {
192
+ const state = { next: start };
193
+ return {
194
+ next: () => state.next > end
195
+ ? { done: true }
196
+ : { done: false, value: state.next++ },
197
+ };
198
+ },
199
+ });
200
+
201
+ export const renderExcerpts = (anchors, texts, budget) => {
202
+ const rendered = [];
203
+ const state = { bytes: 0 };
204
+ const sources = new Map();
205
+ for (const anchor of anchors) {
206
+ if (!sources.has(anchor.path)) {
207
+ const text = Object.hasOwn(texts, anchor.path) ? texts[anchor.path] : '';
208
+ sources.set(anchor.path, String(text ?? '').split(LINE_JOINER));
209
+ }
210
+ const sourceLines = sources.get(anchor.path);
211
+ for (const number of makeRange(anchor.start, anchor.end)) {
212
+ const line = `${anchor.path}:${number}: ${sourceLines[number - 1] ?? ''}`;
213
+ state.bytes += Buffer.byteLength(line, 'utf8') + (rendered.length === 0 ? 0 : Buffer.byteLength(LINE_JOINER));
214
+ rendered.push(line);
215
+ if (state.bytes > budget) return rendered.join(LINE_JOINER);
216
+ }
217
+ }
218
+ return rendered.join(LINE_JOINER);
219
+ };
220
+
221
+ export const renderRows = (record, { date, recordPath }) => {
222
+ const ids = [];
223
+ for (const claim of record.claims) {
224
+ const id = QUEUE.exec(claim.disposition)?.[1];
225
+ if (id && !ids.includes(id)) ids.push(id);
226
+ }
227
+ return ids.map((id) => {
228
+ const claims = record.claims.filter((claim) => QUEUE.exec(claim.disposition)?.[1] === id);
229
+ return [
230
+ `- [ ] ${id}: ${claims[0].claim} (${date})`,
231
+ ...claims.map((claim) => ` - ${claim.claim}; verdict ${claim.verdict}; evidence ${claim.anchors.map(rendersAnchor).join(', ')}`),
232
+ ` - record: ${recordPath}`,
233
+ ` - head: ${record.head}`,
234
+ ].join('\n');
235
+ });
236
+ };
237
+
238
+ export const ratchetLine = (gateCmd, newRows) => {
239
+ const match = /(?:^|\s)--max-rows\s+([0-9]+)(?=\s|$)/u.exec(String(gateCmd));
240
+ if (!match) return 'ratchet: queue-audit --max-rows is absent from the gate command';
241
+ const current = Number(match[1]);
242
+ return `ratchet: queue-audit --max-rows ${current} \u2192 ${current + newRows.length}`;
243
+ };
@@ -88,6 +88,8 @@ export const KNOWN_PRIOR_METHODOLOGY_SLOT = [
88
88
  '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
89
89
  // engine 4.2.0 — the two-activity pointer, before `routine` joined the named activities.
90
90
  '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. The plan shape, its caps and lifecycle live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
91
+ // engine 4.6.0 — the three-activity pointer, before feedback-triage joined the named activities.
92
+ '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. The plan shape, its caps and lifecycle live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution, routine) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
91
93
  ];
92
94
  export const KNOWN_PRIOR_ORCH_SLOT = [
93
95
  // v1.3.0 — pre-read-at-start orchestration pointer (recipes vocabulary, no orchestration.json clause).
@@ -50,12 +50,22 @@ const ROSTER_README = V3_README.replace(
50
50
  "a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form",
51
51
  );
52
52
 
53
- export const CANON_README = ROSTER_README
53
+ const FOLD_README = ROSTER_README
54
54
  .replace("'plan-authoring' (slots author, review)", "'plan-authoring' (slots author, fold, review)")
55
55
  .replace("the carrier slots 'plan-authoring.author' and 'routine.carrier'", "the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier'")
56
56
  .replace('solo for author, execute and carrier', 'solo for author, fold, execute and carrier');
57
57
 
58
- export const KNOWN_PRIOR_README = Object.freeze([V1_README, V2_README, V3_README, ROSTER_README]);
58
+ const FOUR_README = FOLD_README.replace(
59
+ "Three activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review) and 'routine' (slots carrier, parallel).",
60
+ "Four activities are configured independently, and so is each slot within them: 'plan-authoring' (slots author, fold, review), 'plan-execution' (slots execute, review), 'routine' (slots carrier, parallel) and 'feedback-triage' (slot review).",
61
+ );
62
+
63
+ export const CANON_README = FOUR_README.replace(
64
+ "The default below is 'solo' for every recipe and carrier slot, and 'on' for the parallel switch — no execution backend required.",
65
+ "Every slot seeded below is 'solo' — no execution backend required. A slot the seed leaves silent takes the computed default stated further down: 'feedback-triage.review' is reviewed as soon as a review backend is ready (solo until then); 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier' stay 'solo', and 'routine.parallel' stays 'on'.",
66
+ );
67
+
68
+ export const KNOWN_PRIOR_README = Object.freeze([V1_README, V2_README, V3_README, ROSTER_README, FOLD_README, FOUR_README]);
59
69
 
60
70
  export const refreshReadme = (config) => {
61
71
  if (config == null || typeof config !== 'object' || Array.isArray(config)) return { config, changed: false };
@@ -22,6 +22,8 @@ import { homedir } from 'node:os';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
25
+ import { surveyVehicle as surveyReviewVehicle } from './cheap-agents-read.mjs';
26
+ import { isReadyMember, skippedLine } from './review-roster-resolve.mjs';
25
27
  // The host-level bridge-settings registry (manifest-as-source) + its allowed-value labels. READ-ONLY
26
28
  // core only — never the writer — so this read-only advisor never imports the atomic-write core.
27
29
  import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
@@ -150,7 +152,7 @@ export const extractSection = (text, activity) => {
150
152
 
151
153
  // ── resolution + rendering ─────────────────────────────────────────────────────────
152
154
 
153
- const resolveAllSlots = ({ activity, config, detection, overrides }) => {
155
+ const resolveAllSlots = ({ activity, config, detection, overrides, surveyLens }) => {
154
156
  // The host-level settings knobs (manifest-as-source), best-effort: a corrupt bundle degrades to none
155
157
  // and the advisor never crashes. Attached per wrapper cmd via each knob's `appliesTo`. Fact-only —
156
158
  // model/effort are not knobs, so no model claim can ride here.
@@ -163,7 +165,7 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
163
165
  })();
164
166
  const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
165
167
  return Object.entries(ACTIVITIES[activity].slots).map(([slot, slotType]) => {
166
- const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
168
+ const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot], surveyLens });
167
169
  if (isSwitchSlot(slotType)) return { slot, slotType, ...resolved, backends: [], contracts: [], vehicles: [] };
168
170
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
169
171
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
@@ -177,7 +179,7 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
177
179
  // pairs, BEFORE they are flattened to wrapper names (the name array cannot reconstruct the role).
178
180
  // Every slot with a non-empty dispatch gets contracts — including execute=delegated; the contract
179
181
  // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
180
- const contracts = bridged
182
+ const contracts = activity === 'feedback-triage' ? [] : bridged
181
183
  .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
182
184
  .filter((c) => c.cmd && c.contract)
183
185
  // `retired` rides along: without it this surface advertised a RETIRED key as an ordinary
@@ -271,8 +273,40 @@ const reviewLoopAdvice = (slots, activity, flowArmed = false, plans = []) =>
271
273
  export const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
272
274
  export const REVIEW_ROUNDS_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'review-rounds-cli.mjs');
273
275
  export const REPO_SEARCH_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'repo-search.mjs');
276
+ export const FEEDBACK_RECORD_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'feedback-record-cli.mjs');
277
+ const FEEDBACK_TRIAGE = 'feedback-triage';
274
278
  const GROUNDING_FACTS_OUT = '/tmp/review-facts.md';
275
279
  const MINTS_RECEIPT = Object.freeze({ mintsReceipt: true });
280
+ const FEEDBACK_BRIDGE_LINES = Object.freeze({
281
+ 'codex-review': 'codex-review plan <record>',
282
+ 'agy-review': 'agy-review plan <record> --facts @<facts>',
283
+ });
284
+ const FEEDBACK_LENS_LINE = 'the Agent tool, read-only, in the background — the brief carries <record> plus <facts>';
285
+ const mintsReceipts = (review) => review.roster == null
286
+ ? (review.backends ?? []).length > 0 || REVIEW_RECIPES.has(review.degradedFrom)
287
+ : review.roster.some((row) => row.kind === 'bridge');
288
+
289
+ const renderFeedbackTriageAdvice = (activity, slots) => {
290
+ if (activity !== FEEDBACK_TRIAGE) return [];
291
+ const review = slots.find((slot) => slot.slot === 'review');
292
+ const members = review.roster == null
293
+ ? review.backends.map((name) => ` then: ${FEEDBACK_BRIDGE_LINES[name]}`)
294
+ : review.roster.map((row) => !isReadyMember(row)
295
+ ? ` then: ${row.member} — ${skippedLine(row)}`
296
+ : row.kind === 'bridge'
297
+ ? ` then: ${FEEDBACK_BRIDGE_LINES[row.member]}`
298
+ : ` then: ${row.member} — ${FEEDBACK_LENS_LINE}`);
299
+ return [
300
+ ` run: node ${renderToolPath(FEEDBACK_RECORD_TOOL)} --check <record> --excerpts <facts>`,
301
+ ...members,
302
+ ...(mintsReceipts(review)
303
+ ? [
304
+ ` then: node ${renderToolPath(REVIEW_ROUNDS_TOOL)} --artifact <record> --activity feedback-triage`,
305
+ ROUND_RENDER_FACT_LINE,
306
+ ]
307
+ : [ROUND_RENDER_NO_BRIDGE_LINE]),
308
+ ];
309
+ };
276
310
 
277
311
  // The round table judges what review-rounds-cli resolves from the config (S27), never this render's
278
312
  // override or degradation, so the advisor states that fact beneath the command instead of predicting
@@ -285,10 +319,7 @@ const roundRenderAdvice = (slots, plans) => {
285
319
  const review = slots.find((s) => s.slot === 'review');
286
320
  if (review === undefined) return [];
287
321
  const roster = review.roster ?? null;
288
- const shouldRenderCommand = roster === null
289
- ? (review.backends ?? []).length > 0 || REVIEW_RECIPES.has(review.degradedFrom)
290
- : roster.some((row) => row.kind === 'bridge');
291
- if (!shouldRenderCommand) return [ROUND_RENDER_NO_BRIDGE_LINE];
322
+ if (!mintsReceipts(review)) return [ROUND_RENDER_NO_BRIDGE_LINE];
292
323
  const lensMembers = (roster ?? []).filter((row) => row.kind === 'lens').map((row) => row.member);
293
324
  const operand = populatedPlan(plans, MINTS_RECEIPT);
294
325
  return [
@@ -345,6 +376,7 @@ const slotSkewWarning = (section, activity) => {
345
376
  return [`the installed engine's canon lists slots (${listed.join(', ')}) for ${activity} while this kit's registry names (${registry.join(', ')}) — the resolved lines follow the registry; the engine and this kit are out of step — /agent-workflow-kit status names which member is behind: upgrade that one. Tell the user.`];
346
377
  };
347
378
  const groundingPreStepAdvice = (activity, slots, plans) => {
379
+ if (activity === FEEDBACK_TRIAGE) return [];
348
380
  if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
349
381
  const operand = populatedPlan(plans);
350
382
  const planArg = operand === null ? '--plan <path>' : `--plan ${operand}`;
@@ -621,6 +653,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
621
653
  ? ` ${parallelLine({ value: s.recipe, carrier })} — ${SOURCE_LABEL[s.source]}`
622
654
  : ` ${s.slot}: ${s.recipe} — ${SOURCE_LABEL[s.source]}${arrow}${backendSetLabel(s.backends)}`);
623
655
  if (s.reason) lines.push(` ↳ ${s.reason}`);
656
+ if (s.slot === 'review') lines.push(...renderFeedbackTriageAdvice(activity, slots));
624
657
  // The form replaces the one-line vehicle mention: a carrier never told how to carry is a name,
625
658
  // not an instruction. Indented like the driving contracts beside it.
626
659
  for (const v of s.vehicles ?? []) {
@@ -664,6 +697,7 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
664
697
  readersSweep,
665
698
  // ADDITIVE (AD-038): the populated grounding pre-step, structured (empty when agy is not dispatched).
666
699
  groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
700
+ feedbackTriage: renderFeedbackTriageAdvice(activity, slots),
667
701
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
668
702
  costLanes: costLanesAdvice(),
669
703
  // ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
@@ -737,7 +771,8 @@ export const main = (argv, ctx = {}) => {
737
771
  `backend detection failed (${(err && err.message) || err}) — treating every bridge as not ready; recipes needing a bridge degrade to solo (the executor vehicle is unaffected).`,
738
772
  ),
739
773
  });
740
- const slots = resolveAllSlots({ activity, config, detection, overrides });
774
+ const surveyLens = ctx.surveyLens ?? ((spec) => surveyReviewVehicle(cwd, spec, ctx));
775
+ const slots = resolveAllSlots({ activity, config, detection, overrides, surveyLens });
741
776
  const warnings = [...detectWarnings, ...collectWarnings(slots), ...slotSkewWarning(section, activity)];
742
777
  const plans = plansInFlight(cwd);
743
778
  // The autonomy facts (AD-044 Plan 4): resolved levels + red-lines from the policy file. A