@sabaiway/agent-workflow-kit 1.33.0 → 1.34.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,67 @@ 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.34.0 — Onboarding UX: one batched setup prompt, honest installer messaging, the visible accelerator funnel, and the consent-gated gates seeder (AD-042)
8
+
9
+ A **feature** release (first-contact flow + a new consent-gated writer; deployment-lineage head
10
+ stays `1.3.0` — no migration). First contact now interrupts once instead of three times, the
11
+ opt-in accelerators are discoverable from every happy path, and a project's own verification
12
+ commands can be seeded into `docs/ai/gates.json` behind an explicit per-entry yes:
13
+
14
+ - **F11 — ONE batched setup prompt.** Bootstrap asks the three setup questions (visibility /
15
+ conversational language / attribution) as one structured multi-question prompt where supported
16
+ (`AskUserQuestion`, up to 4 questions per call), records each answer individually, and writes
17
+ nothing until all are answered (`references/modes/bootstrap.md` preamble + steps 2–4;
18
+ `references/contracts.md` · `references/shared/deploy-tail.md` · `launchers/windsurf-workflow.md`
19
+ reworded to match). Upgrade batches its two migration asks the same way ONLY when BOTH
20
+ `AGENTS.md` blocks are missing (a pre-1.1.0 deployment), collects them in step 6 BEFORE the
21
+ migrations apply, and never re-asks a collected answer (`references/modes/upgrade.md`); the four
22
+ migration files are untouched — their own ask stays the standalone fallback. New
23
+ `test/ask-contract.test.mjs` pins the wording across all 7 files and holds the kit↔memory
24
+ `references/contracts.md` ask paragraph byte-identical (a hand-lockstep pair, deliberately not a
25
+ sync-mirrors family).
26
+ - **F12 — the installer says what a returning user needs.** `bin/install.mjs` prints a restart
27
+ hint on every run over a PRE-existing install ("restart the session so the agent reloads the
28
+ refreshed kit files") — on the verb path, at most once per run, surviving even the fatal
29
+ engine-install abort — and replaces the false "Claude Code / Codex / Devin Desktop all use the
30
+ same /agent-workflow-kit" claim with the real per-agent matrix (Codex invokes via its `/skills`
31
+ menu and may auto-trigger) in `--help` AND the final next-steps block. Pinned in
32
+ `bin/install.test.mjs`.
33
+ - **F10a — the opt-in funnel is visible.** The welcome-mat ladder
34
+ (`references/shared/report-footer.md`) gains caveat-aware rungs: velocity when the allowlist is
35
+ unseeded, `agents` when no cheap-lane vehicle is placed, `hook` when gates are DECLARED
36
+ (non-empty — file presence alone would misfire on the empty seed) but the hook is unwired; the
37
+ two new signals ride the existing status envelope (`tools/family-registry.mjs`
38
+ `surveyCheapAgents` + `surveyGateHook.declaredGates` → `tools/view-model.mjs` →
39
+ `tools/renderers.mjs` — no new helper call). Bootstrap step 11 ends on a compact
40
+ optional-accelerators block (velocity · agents · gates seeding + hook · set-recipe;
41
+ preview-first, nothing runs without a yes), and `help` output gains a matching "Tune" tail
42
+ (`tools/commands.mjs` — no new mode, no new kind; the router SKILL.md is untouched). New
43
+ `test/report-footer-rotation.test.mjs` pins one shorthand ladder literal across
44
+ upgrade + bootstrap; the `gate-approve-hook` fixture is refreshed to the live 10-gate shape.
45
+ - **F10b — the consent-gated `gates.json` seeder (the seeding↔hook trust chain).** New
46
+ `tools/seed-gates.mjs`: dry-run by default (prints the derived `{ id, title, cmd }` entries,
47
+ writes NOTHING; declining leaves the file byte-identical); `--apply [--only <id>]…` appends
48
+ exactly the consented entries — append-only (never modifies or removes an existing entry),
49
+ id-collision refusal, validator-checked (it imports the runner's `validateDeclaration`; the
50
+ runner never imports it), stamp-gated apply, and OUTSIDE every velocity tier (a consent-per-run
51
+ writer is never pre-approved). Offered candidates are terminating verification classes only
52
+ (test / lint / type-check / build) — release/publish/deploy scripts, watch/serve modes, and
53
+ MUTATING variants (`lint:fix`, `test:update`, bodies carrying `--fix`/`--write`/`-w`/`-u`)
54
+ never enter the offer; commands are package-manager-aware (npm/pnpm/yarn). The review-state
55
+ candidate is included ONLY when `docs/ai/orchestration.json` declares reviewed/council on
56
+ `plan-execution.review` (the slot the checker enforces), with the resolved QUOTED path. Every
57
+ preview prints the trust-chain disclosure: the hook auto-approves byte-exact declared commands —
58
+ seeding and hook wiring are two separate consents. The hardened atomic write core is extracted
59
+ into `tools/atomic-write.mjs` (exclusive-create tmp+rename, TOCTOU re-check, symlink STOPs) and
60
+ shared with `tools/orchestration-write.mjs` (public API unchanged). The consent-seed protocol
61
+ lives in `references/modes/gates.md`; `references/modes/review-state.md` step 3 now names the
62
+ seeder path ("by hand OR the explicit-consent seeder — never without consent").
63
+ - **AD-039 amendment (documented, AD-042):** `test/router-contract.test.mjs` `routerPlusMode`
64
+ 28672 → 29696 — the F11 upgrade batching caveat is +422 B of new contract text against 154 B of
65
+ headroom; the router itself is byte-identical. Tarball 116 → 118 files
66
+ (`test/package-content.test.mjs` count + payload pins for the seeder pair).
67
+
7
68
  ## 1.33.0 — The agent-rules lens region: render + reconcile from the engine canon (AD-041)
8
69
 
9
70
  A **feature** release (new shipped tool + wiring; deployment-lineage head stays `1.3.0` — the
package/README.md CHANGED
@@ -234,7 +234,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
234
234
  | `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents four named recipes for composing the bridges into plan → execute → review — **Solo / Reviewed / Council / Delegated** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready), and offers the choice. The orchestrator runs it via the bridge skills and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
235
235
  | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution`) 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) + backend readiness (default Reviewed when a backend is ready, Council on request, slot-aware incl. Delegated) — 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>=<recipe>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
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
- | `/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** (never auto-seeded). 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). |
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
239
  | `/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
240
  | `/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. |
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.33.0'
6
+ version: '1.34.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/bin/install.mjs CHANGED
@@ -303,7 +303,8 @@ Installs/refreshes the kit at ~/.claude/skills/agent-workflow-kit
303
303
  After install, invoke the skill in your agent, inside a project:
304
304
  first time in the project -> /agent-workflow-kit
305
305
  project already has it -> /agent-workflow-kit upgrade
306
- (Claude Code / Codex / Devin Desktop all use the same /agent-workflow-kit.)
306
+ Per agent: Claude Code -> /agent-workflow-kit; Devin Local -> /agent-workflow-kit;
307
+ Codex -> its /skills menu -> agent-workflow-kit (Codex may also auto-trigger it).
307
308
 
308
309
  Re-running this npx command updates the kit's own files; /agent-workflow-kit
309
310
  upgrade then migrates a project's deployment to that version.`);
@@ -388,6 +389,23 @@ const main = async () => {
388
389
  : 'installed';
389
390
  console.log(`[agent-workflow-kit] ${verb} v${version} -> ${tildify(target)}`);
390
391
 
392
+ // F12 restart hint — any run over a PRE-EXISTING install (every cmp outcome, incl. the legacy
393
+ // unstamped one) leaves an already-open agent session still holding the PREVIOUS kit files in
394
+ // memory (mirrors the SKILL.md "restart the session" note the memory-miss paths already carry).
395
+ // Printed AT MOST ONCE per run: here on the verb path — so it survives the fatal engine abort
396
+ // below — with the final next-steps block as the carrier only if this line did not print.
397
+ const restartHintState = { pending: wasPresent };
398
+ const printRestartHint = () => {
399
+ if (!restartHintState.pending) return;
400
+ restartHintState.pending = false;
401
+ console.log(
402
+ `[agent-workflow-kit] the kit was already installed here — an agent session that was ` +
403
+ `already open is still running the previous kit files; restart the session so the agent ` +
404
+ `reloads the refreshed kit files.`,
405
+ );
406
+ };
407
+ printRestartHint();
408
+
391
409
  // Same-version re-run: state observable facts only. The copy DID run (repair-on-rerun is a feature —
392
410
  // it restores locally modified/deleted files), and whether npx served a cached build is NOT
393
411
  // observable here (no network), so the note never claims it; the @latest hint is conditional.
@@ -506,10 +524,13 @@ const main = async () => {
506
524
  // This command (de)installed the *kit* globally. Deploying it into a project is a
507
525
  // separate, in-agent step — and which sub-command depends on whether that project
508
526
  // already has the kit. Spell both out so it's unambiguous (see README "Use").
527
+ printRestartHint(); // carrier fallback — a no-op when the verb path already printed it
509
528
  console.log(`
510
529
  Next — open your agent inside a project and run the skill:
511
530
  • first time in this project -> /agent-workflow-kit
512
531
  • project already has the kit -> /agent-workflow-kit upgrade
532
+ • per agent: Claude Code -> /agent-workflow-kit · Devin Local -> /agent-workflow-kit ·
533
+ Codex -> its /skills menu -> agent-workflow-kit (Codex may also auto-trigger it)
513
534
 
514
535
  This command only installs/updates the kit itself (in ${tildify(target)}).
515
536
  To update the kit later, re-run: npx @sabaiway/agent-workflow-kit@latest init`);
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": "1.33.0",
6
+ "version": "1.34.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
@@ -22,7 +22,9 @@ reinvent any of it here; the kit's `SKILL.md` is the single source of truth.
22
22
  - No `docs/ai/` in this project → **bootstrap**.
23
23
  - `docs/ai/` already exists → ask the user whether to run **upgrade** instead.
24
24
  3. Execute every step from `SKILL.md` for THIS repository:
25
- - Recon (read-only) → **ask the user: visible or hidden** (wait for the answer) →
25
+ - Recon (read-only) → **ask the three setup questions (visible-or-hidden / language /
26
+ attribution) as ONE structured multi-question prompt; record each answer individually,
27
+ write nothing until ALL are answered** →
26
28
  create `AGENTS.md` (+ `CLAUDE.md` symlink) from `<KIT_DIR>/references/templates/` →
27
29
  deploy `docs/ai/` → copy `<KIT_DIR>/references/scripts/*.mjs` (Node projects) →
28
30
  wire/hide per visibility → install the pre-commit hook → stamp `docs/ai/.workflow-version`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.33.0",
3
+ "version": "1.34.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",
@@ -5,8 +5,9 @@ language**, and **agent attribution** — each have a contract below. `SKILL.md`
5
5
  main procedure stays lean; load this file when you need the full rule for a contract (e.g. while
6
6
  filling the matching `AGENTS.md` block, or when an `upgrade` migration touches it).
7
7
 
8
- Ask each as a **structured multiple-choice prompt where your agent supports it** (`AskUserQuestion`
9
- in Claude Code), otherwise in prose and always wait for the answer before writing.
8
+ Ask the three as **ONE structured multi-question prompt where your agent supports it**
9
+ (`AskUserQuestion` in Claude Code, up to 4 questions per call), otherwise in prose; record each
10
+ answer individually — and write nothing until ALL are answered.
10
11
 
11
12
  ---
12
13
 
@@ -4,7 +4,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
4
4
 
5
5
  > Bundled sources below (templates, scripts) live in **this skill's own directory** — `${CLAUDE_SKILL_DIR}/` in Claude Code, or the folder containing this `SKILL.md` in Codex / other agents. Use that as the copy/read source; the working directory is the **target project**, not the skill.
6
6
 
7
- > The three setup questions (steps 2–4) are decisions only the user can make and are hard to reverse after a commit. Ask each as a **structured multiple-choice prompt where your agent supports it** (`AskUserQuestion` in Claude Code — one option per choice, recommended one first), otherwise in prose — and **wait for the answer before writing anything**.
7
+ > The three setup questions (steps 2–4) are decisions only the user can make and are hard to reverse after a commit. Ask them as **ONE structured multi-question prompt where your agent supports it** (`AskUserQuestion` in Claude Code — up to 4 questions per call, one option per choice, recommended one first), otherwise in prose; **record each answer individually** — and **write nothing until ALL are answered**.
8
8
 
9
9
  1. **Recon (read-only).** Before writing anything:
10
10
  - `package.json` / `pyproject.toml` / `go.mod` / `Cargo.toml` → stack, package manager, scripts.
@@ -13,10 +13,10 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
13
13
  - `src/` (or equivalent) 2–3 levels deep → modules, routes/pages, components, services, types.
14
14
  - Tests (framework, location, E2E?) and linter rules.
15
15
  - Record: stack, package manager, daily commands (`dev`/`test`/`lint`/`type-check`), routes/pages, architecture layers.
16
- - **First-contact orientation — say this before the first question (read-only).** In 1–2 plain lines, tell the user what this is — a portable memory & workflow system for this repo, so future sessions boot from a small structured memory instead of re-reading everything — and that they can run **`/agent-workflow-kit help`** any time to see every command. This is the "start here"; keep it to a sentence or two, then ask the visibility question.
17
- 2. **Choose visibility — ASK the user explicitly and wait for the answer, before writing anything.** This decides what gets tracked and is hard to reverse after a commit, so never assume the default silently: `visible` (committed — canonical, recommended) or `hidden` (in-tree, git-ignored via the **project-local** `.git/info/exclude` — one managed block covering the full AI/agent footprint, never the machine-global excludes). See [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract).
18
- 3. **Choose conversational language — ASK the user explicitly and wait for the answer.** Which language should the agent *talk to them* in — questions, explanations, summaries, status updates? Offer the language they're already writing in as the default. Carry the answer into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block when `AGENTS.md` is created (step 5). See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract). This sets the **dialogue** language only — never the files.
19
- 4. **Choose agent attribution — ASK the user explicitly and wait for the answer.** May the agent attribute work to itself / to AI — `Co-Authored-By` trailers, "Generated with …" footers, "AI"/agent/model mentions in code, comments, commit messages, PR titles/bodies, or docs? **Default to `off`** (no agent/AI mention anywhere) unless they opt in — people are routinely surprised to find an AI listed as a repo contributor. Carry the answer into the `{{AGENT_ATTRIBUTION}}` slot of the *Attribution* block when `AGENTS.md` is created (step 5). **If `off` and the project uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent) — the trailer is added by the harness, so a doc directive alone won't stop it. See [Attribution contract](${CLAUDE_SKILL_DIR}/references/contracts.md#attribution-contract).
16
+ - **First-contact orientation — say this before the first question (read-only).** In 1–2 plain lines, tell the user what this is — a portable memory & workflow system for this repo, so future sessions boot from a small structured memory instead of re-reading everything — and that they can run **`/agent-workflow-kit help`** any time to see every command. This is the "start here"; keep it to a sentence or two, then ask the three setup questions (the step-2 batched prompt).
17
+ 2. **Choose visibility — ask the batched prompt NOW (all three questions, per the preamble above) and wait until every answer is in.** This decides what gets tracked and is hard to reverse after a commit, so never assume the default silently: `visible` (committed — canonical, recommended) or `hidden` (in-tree, git-ignored via the **project-local** `.git/info/exclude` — one managed block covering the full AI/agent footprint, never the machine-global excludes). See [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract).
18
+ 3. **Choose conversational language — answered in the step-2 batch.** Which language should the agent *talk to them* in — questions, explanations, summaries, status updates? Offer the language they're already writing in as the default. Carry the answer into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block when `AGENTS.md` is created (step 5). See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract). This sets the **dialogue** language only — never the files.
19
+ 4. **Choose agent attribution — answered in the step-2 batch.** May the agent attribute work to itself / to AI — `Co-Authored-By` trailers, "Generated with …" footers, "AI"/agent/model mentions in code, comments, commit messages, PR titles/bodies, or docs? **Default to `off`** (no agent/AI mention anywhere) unless they opt in — people are routinely surprised to find an AI listed as a repo contributor. Carry the answer into the `{{AGENT_ATTRIBUTION}}` slot of the *Attribution* block when `AGENTS.md` is created (step 5). **If `off` and the project uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent) — the trailer is added by the harness, so a doc directive alone won't stop it. See [Attribution contract](${CLAUDE_SKILL_DIR}/references/contracts.md#attribution-contract).
20
20
  5. **Entry-point doc.** If `AGENTS.md` / `CLAUDE.md` already exist (step-1 recon), do **not** overwrite — show the user and ask whether to merge or replace. Otherwise create `AGENTS.md` (the cross-agent standard — Codex / Cursor / Devin Desktop / Copilot read it natively) from `${CLAUDE_SKILL_DIR}/references/templates/AGENTS.md`, and symlink `CLAUDE.md -> AGENTS.md` (`ln -s AGENTS.md CLAUDE.md`) for Claude Code — single source, no duplication. For nested context, add a subdir `AGENTS.md` (+ a `CLAUDE.md` symlink beside it for Claude Code).
21
21
  6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys each non-`AGENTS.md` template — the `.md` docs **and** the two seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads) and **`docs/ai/gates.json`** (the project's gate declaration — an empty list to fill with its own verification commands, consumed by `${CLAUDE_SKILL_DIR}/references/modes/gates.md`)). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
22
22
  7. **Fill templates** per the table below.
@@ -36,7 +36,12 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
36
36
  independent axes: a packaging-only release bumps the package but leaves the lineage head until a
37
37
  migration actually changes the deployed `docs/ai` structure. A stamp greater than the head →
38
38
  STOP (never downgrade).
39
- 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat ends on **one** recommended next step including the optional, opt-in `/agent-workflow-kit velocity` (a read-only allowlist so routine read-only commands stop prompting; `${CLAUDE_SKILL_DIR}/references/modes/velocity.md`) or, once gates are declared, `/agent-workflow-kit hook` (auto-approve your own declared gate commands; `${CLAUDE_SKILL_DIR}/references/modes/hook.md`) when nothing more pressing applies, never run without a yes. Then **ask before committing** — never auto-commit.
39
+ 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`). **After the footer, present ONE compact optional-accelerators block** every entry preview-first, a one-line why each, nothing runs without a yes:
40
+ - `/agent-workflow-kit velocity` — routine read-only commands stop prompting (incl. the `--kit-tools` tier for the kit's own read-only tools; `${CLAUDE_SKILL_DIR}/references/modes/velocity.md`);
41
+ - `/agent-workflow-kit agents` — cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage; `${CLAUDE_SKILL_DIR}/references/modes/agents.md`);
42
+ - **gates seeding + hook** — offer to seed `docs/ai/gates.json` from the commands recon already recorded (step 1), via the consent-gated preview in `${CLAUDE_SKILL_DIR}/references/modes/gates.md` — this block is where that offer fires; then, once gates are declared, `/agent-workflow-kit hook` auto-approves exactly those declared commands (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`);
43
+ - `/agent-workflow-kit set-recipe` — put a ready review backend to work on plans and diffs (`${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md`).
44
+ Then **ask before committing** — never auto-commit.
40
45
 
41
46
  Fill strategy:
42
47
 
@@ -12,6 +12,8 @@ The declaration is **seeded at bootstrap** (the template loop, `${CLAUDE_SKILL_D
12
12
 
13
13
  Declared gates can also be **auto-approved** (no permission prompt on a byte-exact invocation from the project root) via the opt-in PreToolUse hook — `${CLAUDE_SKILL_DIR}/references/modes/hook.md`: the SAME declaration, a second consumer; editing gates.json needs no re-wiring.
14
14
 
15
- **Candidate line — the review-receipt gate (opt-in, never auto-seeded; AD-021).** Projects that configure a reviewed/council `plan-execution.review` recipe can declare the AD-038 review-state check as one more gate — the exact candidate `{ id, title, cmd }` line and its contract live under `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` (step 3). The template `gates.json` stays EMPTY; adding the line is the maintainer's explicit consent, by hand.
15
+ **Candidate line — the review-receipt gate (opt-in, never auto-seeded; AD-021).** Projects that configure a reviewed/council `plan-execution.review` recipe can declare the AD-038 review-state check as one more gate — the exact candidate `{ id, title, cmd }` line and its contract live under `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` (step 3). The template `gates.json` stays EMPTY; adding the line is the maintainer's explicit consent by hand, or through the consent-gated seeder below (AD-042).
16
16
 
17
- **Invariants:** the runner writes nothing · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets.
17
+ **Consent-gated seeding — a separate WRITER CLI, not part of the runner (AD-042).** Bootstrap recon already records the project's own daily commands; the seeder turns them into a preview of `{ id, title, cmd }` entries. Protocol: run `node ${CLAUDE_SKILL_DIR}/tools/seed-gates.mjs --cwd <project>` (dry-run by default — prints the derived entries and **writes NOTHING**; declining leaves the file byte-identical), show the user the EXACT entries (`AskUserQuestion` preview where supported, prose otherwise), and only on an explicit yes run it again with `--apply [--only <id>]…` — it **appends exactly the consented entries** (append-only: existing entries are never modified or removed; an id collision is refused loudly; a malformed declaration is never written over). Only terminating verification commands are offered (test / lint / type-check / build — never dev/watch/serve, never a formatter write-mode, never a release/publish/deploy script), commands are package-manager-aware, and the review-state candidate above is included automatically when the project's orchestration config declares reviewed/council on `plan-execution.review`. **Disclose before the yes** (the preview prints it): once the approval hook is wired, it auto-approves byte-exact declared gate commands — seeding and hook wiring are two separate consents.
18
+
19
+ **Invariants:** the runner writes nothing · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets · the seeder is a separate consent-per-run CLI — preview-first, append-only, never pre-approved by any velocity tier.
@@ -6,7 +6,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
6
6
 
7
7
  1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / missing) with verdict + grounding + timestamp.
8
8
  2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend receipted **current + grounded**; exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green.
9
- 3. **Wire it as a gate by hand — never auto-seeded (AD-021).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
9
+ 3. **Wire it as a gate by hand OR via the explicit-consent seeder — never without consent (AD-021/AD-042).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated seeder (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-seed section) offers exactly this entry — path resolved and quoted — ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
10
10
 
11
11
  **Human residual (stated, accepted):** `git commit --no-verify` and receipt-file deletion/forgery remain possible — this is a self-discipline mechanism against silent process drift, not a security boundary.
12
12
 
@@ -14,6 +14,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json [--dir <project>]
14
14
  - **recipes** — the effective recipe per slot (detail → `/agent-workflow-kit procedures` / `recipes`); a `recipes.detectError` → say the backends couldn't be checked, so recipes floored at solo.
15
15
  - **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).
16
16
  - **velocity** — the effective `permissions.defaultMode` + whether an allowlist is seeded (detail → `/agent-workflow-kit velocity`).
17
+ - **cheap agents** — how many of the kit's cheap-lane subagent vehicles are placed (`agents.placed` of `agents.bundled`; zero placed → the optional `/agent-workflow-kit agents` opt-in).
18
+ - **gate hook** — wired / hook file placed / declaration present, plus **`hook.declaredGates`** (0 = absent or an empty list; `null` = present but unreadable → say *couldn't be counted*, never a number; detail → `/agent-workflow-kit hook` and the gates guide).
17
19
  - Any area's **`error`** field → surface it **loudly** in plain language; the rest of `status` still renders (never a crash).
18
20
  4. **Bridges (host, one line)** (from `bridges[]`): per bridge — readiness + wrapper PATH-presence; render each wrapper's `state` as *on PATH* (`present`) / *not on PATH* (`missing`) / *couldn't check* (`unknown`) (detail → `/agent-workflow-kit backends` / `setup`). **No default-model claim.** "credentials present" means a marker file exists, not a live login.
19
21
 
@@ -48,9 +48,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
48
48
  4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
49
49
  - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
50
50
  - **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
51
- - **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity`).
51
+ - **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`).
52
52
  - **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
53
53
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
54
- 6. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
55
- 7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`.
54
+ 6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
55
+ 7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.)
56
56
  8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) and the step-3 **agent-rules lens** outcome (same outcome set as step 4) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
@@ -19,7 +19,7 @@ The non-obvious traps — scan these before bootstrapping or upgrading. Each is
19
19
 
20
20
  ## Setup contracts
21
21
 
22
- The three setup choices — **visibility** (step 2), **conversational language** (step 3), and **agent attribution** (step 4) — each have a full contract in [`references/contracts.md`](${CLAUDE_SKILL_DIR}/references/contracts.md). Load it when you need the complete rule (e.g. while filling the matching `AGENTS.md` block, or when an `upgrade` migration touches one). Defaults, in brief: visibility = `visible` (committed); language = whatever the user is already writing in; attribution = `off`. Ask each as a structured multiple-choice prompt where supported (`AskUserQuestion` in Claude Code), otherwise in prose.
22
+ The three setup choices — **visibility** (step 2), **conversational language** (step 3), and **agent attribution** (step 4) — each have a full contract in [`references/contracts.md`](${CLAUDE_SKILL_DIR}/references/contracts.md). Load it when you need the complete rule (e.g. while filling the matching `AGENTS.md` block, or when an `upgrade` migration touches one). Defaults, in brief: visibility = `visible` (committed); language = whatever the user is already writing in; attribution = `off`. Ask the three as ONE structured multi-question prompt where supported (`AskUserQuestion` in Claude Code, up to 4 questions per call), otherwise in prose; record each answer individually — and write nothing until ALL are answered.
23
23
 
24
24
  ---
25
25
 
@@ -74,8 +74,9 @@ the rest of the report — and the commit gate — proceeds.
74
74
 
75
75
  **Welcome mat — the last line(s) of the footer.** After the version block and the backend-status
76
76
  line, print *"Run `/agent-workflow-kit help` to see every command."* then **one** recommended next
77
- step, chosen **caveat-aware** from signals already in hand (the version block's notes + the
78
- backend-status line — no new helper call) in this priority order:
77
+ step, chosen **caveat-aware** from signals already in hand (the version block's notes, the settings
78
+ areas of the same `--json` envelope, and the backend-status line — no new helper call) in this
79
+ priority order:
79
80
  1. a member is **behind** (a behind-class `installed[].notes` caveat fired — any member, the bridges
80
81
  included) → *refresh the behind member first*, quoting **that note's own recovery command
81
82
  verbatim** (a memory/engine note carries its `npx …@latest init` + restart the session; a bridge
@@ -87,8 +88,14 @@ backend-status line — no new helper call) in this priority order:
87
88
  3. else **a backend is ready but the orchestration config is still all-Solo** (no `reviewed` /
88
89
  `council` / `delegated` slot anywhere — inspect `docs/ai/orchestration.json`, or read the
89
90
  procedures advisor's resolved recipes) → *put it to work with `/agent-workflow-kit recipes`*;
90
- 4. else (a backend is ready **and** a backend-backed recipe is already selected) → the optional
91
- *`/agent-workflow-kit velocity`* opt-in (never run it without a yes).
91
+ 4. else **the velocity allowlist is not yet seeded** (the envelope's velocity settings show zero
92
+ allow entries) → the optional *`/agent-workflow-kit velocity`* opt-in (never run it without a
93
+ yes);
94
+ 5. else **the cheap-lane agent vehicles are not placed** (the envelope's agents settings show zero
95
+ placed) → the optional *`/agent-workflow-kit agents`* opt-in (never run it without a yes);
96
+ 6. else **gates are declared but the approval hook is not wired** (the envelope's hook settings:
97
+ at least one declared gate, wired = no) → the optional *`/agent-workflow-kit hook`* opt-in
98
+ (never run it without a yes). If no rung applies, the help line above stands alone.
92
99
 
93
100
  Keep it compact — a few short lines, plain language, no kit-internal terms.
94
101
 
@@ -0,0 +1,106 @@
1
+ // atomic-write.mjs — the family's ONE hardened atomic-write core for kit writers that target a
2
+ // file under a project's docs/ai/. Extracted from orchestration-write.mjs (the only full
3
+ // implementation of the discipline, AD-042) and parameterized by (rel, body, stop identity) so
4
+ // BOTH consumers run the same guarded flow with zero drift:
5
+ // • orchestration-write.mjs — docs/ai/orchestration.json (public API unchanged);
6
+ // • seed-gates.mjs — docs/ai/gates.json (the consent-gated seeder).
7
+ //
8
+ // The discipline (verbatim from the source implementation):
9
+ // - DEPLOYMENT GATE first: refuse to scatter a file into a repo with no docs/ai/ — STOP loud.
10
+ // - refuse a SYMLINKED leaf — a rename would silently replace the link target.
11
+ // - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
12
+ // docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
13
+ // - atomic: write a UNIQUE *.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
14
+ // - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
15
+ // - tmp cleaned up on any failure after its creation.
16
+ // - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
17
+ //
18
+ // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are
19
+ // unit-testable. NEVER imported by a read-only module (procedures.mjs — pinned by an import guard).
20
+
21
+ import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
22
+ import { randomBytes } from 'node:crypto';
23
+ import { join } from 'node:path';
24
+ import { assertContainedRealPath } from './fs-safe.mjs';
25
+
26
+ export const ATOMIC_WRITE_STOP = 'ATOMIC_WRITE_STOP';
27
+ const defaultStop = (message) =>
28
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'AtomicWriteStop', code: ATOMIC_WRITE_STOP });
29
+
30
+ // lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
31
+ export const lstatNoFollow = (path, lstat = lstatSync) => {
32
+ try {
33
+ return lstat(path);
34
+ } catch (err) {
35
+ if (err && err.code === 'ENOENT') return null;
36
+ throw err;
37
+ }
38
+ };
39
+
40
+ // Deployment gate — never scatter a file into a non-deployed repo. lstat, NOT existsSync:
41
+ // existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent" and
42
+ // mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true
43
+ // ENOENT → no deployment (STOP, run init); a symlink or non-directory → STOP loud.
44
+ // `noun` names what the caller writes ("a config" / "a gate declaration") so each consumer's STOP
45
+ // message stays exactly as its own tests pinned it.
46
+ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
47
+ const lstat = deps.lstat ?? lstatSync;
48
+ const stop = opts.stop ?? defaultStop;
49
+ const noun = opts.noun ?? 'a file';
50
+ const rel = opts.rel ?? 'under docs/ai';
51
+ const docsAi = join(cwd, 'docs', 'ai');
52
+ const docsAiStat = lstatNoFollow(docsAi, lstat);
53
+ if (docsAiStat === null) {
54
+ throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${rel}`);
55
+ }
56
+ if (docsAiStat.isSymbolicLink()) {
57
+ throw stop(`docs/ai is a symlink — refusing to write ${noun} through it (run init/bootstrap in a real deployment)`);
58
+ }
59
+ if (!docsAiStat.isDirectory()) {
60
+ throw stop(`docs/ai exists but is not a directory — refusing to write ${rel}`);
61
+ }
62
+ };
63
+
64
+ // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel } on success; THROWS the
65
+ // caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives pre-serialized
66
+ // (each consumer owns its canonical serialization).
67
+ export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
68
+ const lstat = deps.lstat ?? lstatSync;
69
+ const writeFile = deps.writeFile ?? writeFileSync;
70
+ const rename = deps.rename ?? renameSync;
71
+ const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
72
+ const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
73
+ const stop = opts.stop ?? defaultStop;
74
+ const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
75
+
76
+ assertDocsAiDeployment(cwd, deps, { ...opts, rel });
77
+
78
+ const dst = join(cwd, rel);
79
+ // Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
80
+ // would silently replace the link rather than the file the user thinks they are editing).
81
+ const leaf = lstatNoFollow(dst, lstat);
82
+ if (leaf && leaf.isSymbolicLink()) {
83
+ throw stop(`${rel} is a symlink — refusing to replace it (a write would clobber the link target)`);
84
+ }
85
+ // Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
86
+ guard(dst);
87
+ const tmp = `${dst}.${rand()}.tmp`;
88
+ guard(tmp);
89
+
90
+ // Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
91
+ // overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
92
+ writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
93
+ try {
94
+ // TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
95
+ guard(dst);
96
+ const leafAgain = lstatNoFollow(dst, lstat);
97
+ if (leafAgain && leafAgain.isSymbolicLink()) {
98
+ throw stop(`${rel} became a symlink — refusing to replace it`);
99
+ }
100
+ rename(tmp, dst);
101
+ } catch (err) {
102
+ rm(tmp); // never leave a temp file behind on failure
103
+ throw err;
104
+ }
105
+ return { writtenPath: rel };
106
+ };
@@ -96,7 +96,7 @@ const CATALOG = [
96
96
  invocation: invocationOf('gates'),
97
97
  group: 'Inspect',
98
98
  kind: PROJECT_EXEC,
99
- oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
99
+ oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
100
100
  },
101
101
  {
102
102
  key: 'setup',
@@ -212,6 +212,19 @@ const KIND_TAG = {
212
212
  };
213
213
  const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
214
214
 
215
+ // The "Tune" tail — the opt-in accelerator funnel, rendered AFTER the catalog groups. NOT a new
216
+ // catalog key or mode (the frozen CATALOG + GROUP_ORDER stay; the router SKILL.md is untouched):
217
+ // the same four entries the bootstrap accelerators block presents, one line of why each.
218
+ const TUNE_TAIL = Object.freeze([
219
+ '',
220
+ 'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
221
+ ` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
222
+ ` ${BARE_INVOCATION} agents cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage)`,
223
+ ` ${BARE_INVOCATION} gates run your declared gates (docs/ai/gates.json) as one batch; its guide also offers the consent-gated seeding preview — writes only on your yes`,
224
+ ` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
225
+ ` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
226
+ ]);
227
+
215
228
  // formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
216
229
  // catalog order within each group. The header states the index itself is read-only.
217
230
  export const formatHelp = () => {
@@ -229,6 +242,7 @@ export const formatHelp = () => {
229
242
  lines.push(` ${pad(c.invocation, invocWidth)} ${KIND_TAG[c.kind] ?? c.kind} ${c.oneLine}`);
230
243
  }
231
244
  }
245
+ lines.push(...TUNE_TAIL);
232
246
  return lines.join('\n');
233
247
  };
234
248
 
@@ -47,7 +47,10 @@ import {
47
47
  // The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
48
48
  // implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
49
49
  import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
50
- import { GATES_REL } from './run-gates.mjs';
50
+ import { GATES_REL, loadDeclaration } from './run-gates.mjs';
51
+ // The cheap-agents writer's own bundle reader + placement planner — reused by the settings survey
52
+ // (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
53
+ import { readBundledAgents, planPlacement } from './cheap-agents.mjs';
51
54
  // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
52
55
  // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
53
56
  // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
@@ -456,21 +459,50 @@ export const surveyGateHook = (dir, deps = {}) => {
456
459
  const exists = deps.exists ?? existsSync;
457
460
  const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
458
461
  const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
462
+ // How many gates are actually DECLARED — the welcome-mat hook rung keys on a non-empty
463
+ // declaration (a fresh bootstrap seeds gates.json EMPTY, so file presence alone would misfire).
464
+ // 0 = absent or empty list; null = present but unreadable/malformed (unknown — never a false
465
+ // count, and never an area-wide error: the wired/placed probes still report; the validation
466
+ // REASON is preserved beside the null so status never renders a bare question mark).
467
+ const declaration = (() => {
468
+ try {
469
+ const res = loadDeclaration(d, deps);
470
+ return { declaredGates: res.outcome === 'loaded' ? res.gates.length : 0 };
471
+ } catch (err) {
472
+ return { declaredGates: null, declarationError: localizeError(err) };
473
+ }
474
+ })();
459
475
  return {
460
476
  wired: isHookWired(project.data) || isHookWired(local.data),
461
477
  filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
462
478
  declarationPresent: Boolean(exists(join(d, GATES_REL))),
479
+ ...declaration,
463
480
  };
464
481
  } catch (err) {
465
482
  return { error: localizeError(err) };
466
483
  }
467
484
  };
468
485
 
486
+ // cheap agents: the kit-placed .claude/agents/ vehicles (Mode: agents) — how many of the bundled
487
+ // cheap-lane definitions are present in the project. A customized copy counts as PLACED (it exists;
488
+ // the writer preserves it) — the welcome-mat agents rung keys on zero placed. Read-only; REUSES the
489
+ // writer's own bundle reader + placement planner (cheap-agents.mjs — one implementation).
490
+ export const surveyCheapAgents = (dir, deps = {}) => {
491
+ try {
492
+ const templates = readBundledAgents(deps);
493
+ const plan = planPlacement(templates, resolve(dir), deps);
494
+ return { bundled: templates.length, placed: plan.filter((p) => p.action !== 'place').length };
495
+ } catch (err) {
496
+ return { error: localizeError(err) };
497
+ }
498
+ };
499
+
469
500
  // the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
470
501
  export const surveySettings = (dir, deps = {}) => ({
471
502
  recipes: surveyRecipes(dir, deps),
472
503
  attribution: surveyAttribution(dir, deps),
473
504
  velocity: surveyVelocity(dir, deps),
505
+ agents: surveyCheapAgents(dir, deps),
474
506
  hook: surveyGateHook(dir, deps),
475
507
  });
476
508
 
@@ -4,94 +4,24 @@
4
4
  // can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
5
5
  // assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
6
6
  //
7
- // writeConfig is hardened (mirrors the setup-backends posture):
8
- // - DEPLOYMENT GATE first: refuse to scatter a config into a repo with no docs/ai/ STOP loud,
9
- // pointing at init/bootstrap (a config without a deployment is meaningless + surprising).
10
- // - refuse a SYMLINKED leaf (orchestration.json itself a symlink) a rename would silently replace
11
- // the link target.
12
- // - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
13
- // docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
14
- // - atomic: write a UNIQUE *.json.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
15
- // - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
16
- // - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
7
+ // The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create
8
+ // tmp + rename with a TOCTOU re-check tmp cleanup; LAST-WRITER-WINS, documented) lives in the
9
+ // shared atomic-write core (tools/atomic-write.mjs, extracted from this module AD-042) and is
10
+ // consumed here with this module's own STOP identity, so the public API + error contract are
11
+ // unchanged (this file's tests pin them, characterize-first).
17
12
  //
18
13
  // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
19
14
 
20
- import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
21
- import { randomBytes } from 'node:crypto';
22
- import { join } from 'node:path';
23
- import { assertContainedRealPath } from './fs-safe.mjs';
24
15
  import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
16
+ import { writeDocsAiFileAtomic } from './atomic-write.mjs';
25
17
 
26
18
  // A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
27
19
  // native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
28
20
  export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
29
21
  const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
30
22
 
31
- // lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
32
- const lstatNoFollow = (path, lstat) => {
33
- try {
34
- return lstat(path);
35
- } catch (err) {
36
- if (err && err.code === 'ENOENT') return null;
37
- throw err;
38
- }
39
- };
40
-
41
23
  // writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
42
24
  // symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
43
25
  // creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
44
- export const writeConfig = (cwd, config, deps = {}) => {
45
- const lstat = deps.lstat ?? lstatSync;
46
- const writeFile = deps.writeFile ?? writeFileSync;
47
- const rename = deps.rename ?? renameSync;
48
- const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
49
- const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
50
- const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
51
-
52
- // Deployment gate — never scatter a config into a non-deployed repo (mirror velocity / setup). lstat,
53
- // NOT existsSync: existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent"
54
- // and mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true ENOENT
55
- // → no deployment (STOP, run init); a symlink or non-directory → STOP loud (never write through it).
56
- const docsAi = join(cwd, 'docs', 'ai');
57
- const docsAiStat = lstatNoFollow(docsAi, lstat);
58
- if (docsAiStat === null) {
59
- throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${CONFIG_REL}`);
60
- }
61
- if (docsAiStat.isSymbolicLink()) {
62
- throw stop(`docs/ai is a symlink — refusing to write a config through it (run init/bootstrap in a real deployment)`);
63
- }
64
- if (!docsAiStat.isDirectory()) {
65
- throw stop(`docs/ai exists but is not a directory — refusing to write ${CONFIG_REL}`);
66
- }
67
-
68
- const dst = join(cwd, CONFIG_REL);
69
- // Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
70
- // would silently replace the link rather than the file the user thinks they are editing).
71
- const leaf = lstatNoFollow(dst, lstat);
72
- if (leaf && leaf.isSymbolicLink()) {
73
- throw stop(`${CONFIG_REL} is a symlink — refusing to replace it (a write would clobber the link target)`);
74
- }
75
- // Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
76
- guard(dst);
77
- const tmp = `${dst}.${rand()}.tmp`;
78
- guard(tmp);
79
-
80
- const body = serializeConfig(config);
81
- // Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
82
- // overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
83
- writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
84
- try {
85
- // TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
86
- guard(dst);
87
- const leafAgain = lstatNoFollow(dst, lstat);
88
- if (leafAgain && leafAgain.isSymbolicLink()) {
89
- throw stop(`${CONFIG_REL} became a symlink — refusing to replace it`);
90
- }
91
- rename(tmp, dst);
92
- } catch (err) {
93
- rm(tmp); // never leave a temp file behind on failure
94
- throw err;
95
- }
96
- return { writtenPath: CONFIG_REL };
97
- };
26
+ export const writeConfig = (cwd, config, deps = {}) =>
27
+ writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
@@ -41,6 +41,7 @@ export const SETTINGS_LABELS = Object.freeze({
41
41
  recipes: 'recipes',
42
42
  attribution: 'attribution',
43
43
  velocity: 'velocity',
44
+ agents: 'cheap agents',
44
45
  hook: 'gate hook',
45
46
  });
46
47
 
@@ -101,11 +101,19 @@ const renderSettings = (vm, { color, glyph }) => {
101
101
  else if (s.velocity) {
102
102
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
103
103
  }
104
- // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present.
104
+ // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
105
+ if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
106
+ else if (s.agents) {
107
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
108
+ }
109
+ // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
110
+ // declared gate count (null → '?' — unknown is shown as unknown, never as a number).
105
111
  if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
106
112
  else if (s.hook) {
107
113
  const yn = (b) => (b ? 'yes' : 'no');
108
- lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)}`);
114
+ // A null count carries its preserved validation reason — never a silent bare "?".
115
+ const declared = s.hook.declaredGates ?? (s.hook.declarationError ? `? (${s.hook.declarationError})` : '?');
116
+ lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)} · declared=${declared}`);
109
117
  }
110
118
  return lines;
111
119
  };
@@ -5,7 +5,8 @@
5
5
  // resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
6
6
  // recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
7
7
  // backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
8
- // report into a gate exit code (declare it in docs/ai/gates.json — by hand; never auto-seeded).
8
+ // report into a gate exit code (declare it in docs/ai/gates.json — by hand OR via the
9
+ // explicit-consent seeder, tools/seed-gates.mjs — never without consent, AD-021/AD-042).
9
10
  //
10
11
  // Normative `--check` exit contract (the single home of this list — SKILL.md points here):
11
12
  // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
@@ -353,7 +354,8 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
353
354
  --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
354
355
  a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
355
356
  recipe-named backend is missing, stale (edited after review), or grounded:false under
356
- reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) never auto-seeded.
357
+ reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
358
+ explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
357
359
 
358
360
  Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
359
361
  Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ // seed-gates.mjs — the consent-gated docs/ai/gates.json seeder (AD-042). Reached ONLY through
3
+ // explicitly-consenting prose (the bootstrap accelerators block and the gates.md consent-seed
4
+ // section) — it is NOT a routable mode token, it sits OUTSIDE every velocity allowlist tier
5
+ // (a consent-per-run writer is never pre-approved), and the shipped gates.json TEMPLATE stays
6
+ // EMPTY (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through
7
+ // this preview, never auto-seeding.
8
+ //
9
+ // What it offers (the derivation invariants, test-pinned):
10
+ // • sources: discoverGateCandidates over package.json scripts (velocity-profile.mjs stays
11
+ // read-only; THIS module owns the offer mapping) + the conditional review-state candidate;
12
+ // • warn-flagged candidates (release/publish/deploy/push/version/commit/tag, pre*/post*) NEVER
13
+ // enter the offer — excluded, not offered-with-a-warning;
14
+ // • only TERMINATING verification classes are offered (test / lint / type-check / build) —
15
+ // never dev/start/watch/serve/preview, never a formatter write-mode;
16
+ // • commands are package-manager-aware (packageManager field, else lockfile probe, else npm);
17
+ // • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
18
+ // passes the runner's validateDeclaration (the seeder imports the validator — NEVER the
19
+ // reverse: run-gates.mjs stays a runner that writes nothing);
20
+ // • the review-state candidate appears ONLY when docs/ai/orchestration.json DECLARES
21
+ // reviewed/council on plan-execution.review — the slot the checker enforces — with the
22
+ // resolved, QUOTED tool path (spaces survive; executes from the project root).
23
+ //
24
+ // Write discipline: preview (dry-run) is the DEFAULT and writes NOTHING — a declined offer leaves
25
+ // the file byte-identical. `--apply` appends EXACTLY the consented entries (`--only <id>`
26
+ // repeatable) through the shared atomic-write core (tools/atomic-write.mjs — exclusive-create
27
+ // tmp+rename, TOCTOU re-check, symlink STOPs): append-only, never modifies or removes an existing
28
+ // entry, refuses id collisions, refuses a malformed declaration (never writes over what it cannot
29
+ // parse). Deployment-gated: docs/ai presence (lstat, no-follow) on EVERY run; the
30
+ // .workflow-version == lineage-head stamp gate on --apply only (the velocity/gate-hook precedent).
31
+ //
32
+ // Exit codes: 0 done / dry-run; 1 precondition STOP (no deployment, stamp, symlink, malformed
33
+ // declaration, id collision); 2 usage. Dependency-free, Node >= 18. No side effects on import.
34
+
35
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
36
+ import { join, resolve, dirname } from 'node:path';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url';
38
+ import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
39
+ import { GATES_REL, validateDeclaration } from './run-gates.mjs';
40
+ import { loadConfig } from './orchestration-config.mjs';
41
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const KIT_ROOT = resolve(HERE, '..');
45
+ const TEMPLATE_PATH = join(KIT_ROOT, 'references', 'templates', 'gates.json');
46
+ const REVIEW_STATE_TOOL = join(KIT_ROOT, 'tools', 'review-state.mjs');
47
+ const STAMP_REL = join('docs', 'ai', '.workflow-version');
48
+
49
+ const EXIT_OK = 0;
50
+ const EXIT_PRECONDITION = 1;
51
+ const EXIT_USAGE = 2;
52
+
53
+ export const SEED_GATES_STOP = 'SEED_GATES_STOP';
54
+ const stop = (message) =>
55
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'SeedGatesStop', code: SEED_GATES_STOP, exitCode: EXIT_PRECONDITION });
56
+ const usageFail = (message) =>
57
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: EXIT_USAGE });
58
+
59
+ // The trust-chain disclosure (AD-042) — printed with EVERY preview, token-pinned by the tests: a
60
+ // consent-seeded command becomes auto-approvable only after TWO explicit consents.
61
+ export const TRUST_CHAIN_DISCLOSURE =
62
+ 'Disclosure: once the optional approval hook is wired (/agent-workflow-kit hook), it ' +
63
+ 'auto-approves byte-exact declared gate commands from the project root — seeding (this consent) ' +
64
+ 'and hook wiring (its own consent) are two separate yeses.';
65
+
66
+ const USAGE = `usage: seed-gates [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
67
+
68
+ Consent-gated seeder for the project's own docs/ai/gates.json. Default is --dry-run: prints the
69
+ derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the consented
70
+ entries (--only <id> selects a subset; append-only — existing entries are never modified or
71
+ removed, an id collision is refused). Only terminating verification commands are offered
72
+ (test / lint / type-check / build); release/publish/deploy scripts and watch/serve modes never
73
+ enter the offer. ${TRUST_CHAIN_DISCLOSURE}`;
74
+
75
+ // ── candidate classification (the LOCKED derivation invariants) ────────────────────────
76
+ // The derived cmd (`<pm> run <name>`) is bash-interpolated by the gate runner and can become
77
+ // hook-auto-approvable, so only shell-SAFE script names ever enter the offer: a name carrying
78
+ // whitespace or any shell metacharacter (`test:ci && echo pwn`) is screened out entirely.
79
+ const SAFE_SCRIPT_NAME_PATTERN = /^[A-Za-z0-9:_.-]+$/;
80
+ const TERMINATING_CLASS_PATTERN = /^(test|lint|type-?check|types|tsc|build)([:._-]|$)/i;
81
+ // Non-terminating screening is TOKEN-set based, position-independent (never an anchored regex —
82
+ // the anchored form missed `build:preview` and a bare `vite preview` body): a non-terminating
83
+ // token in ANY name segment, or as ANY bare/dashed body word, disqualifies; `watch` disqualifies
84
+ // as a substring anywhere (watchAll, --watchAll). Conservative on purpose — a screened-out script
85
+ // can still be declared by hand; a wrongly-included one would become hook-auto-approvable.
86
+ const NON_TERMINATING_TOKENS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
87
+ // A terminating-looking NAME can still hide release work in its BODY (`"test": "npm publish"`):
88
+ // the same token classes the warn-name screen rejects are rejected as bare body words too — an
89
+ // offered cmd is hook-auto-approvable, so a dangerous body must never ride a clean name.
90
+ const DANGEROUS_BODY_TOKENS = new Set(['release', 'publish', 'deploy', 'push', 'version', 'commit', 'tag']);
91
+ const WATCH_ANYWHERE_PATTERN = /watch/i;
92
+ const wordOf = (raw) => raw.toLowerCase().replace(/^-+/, '').split('=')[0];
93
+ const hasTokenIn = (text, splitter, tokens) =>
94
+ String(text)
95
+ .split(splitter)
96
+ .some((part) => tokens.has(wordOf(part)));
97
+ const isNonTerminatingName = (name) =>
98
+ hasTokenIn(name, /[:._-]/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(name);
99
+ const isNonTerminatingBody = (body) =>
100
+ hasTokenIn(body, /\s+/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(body);
101
+ const isDangerousBody = (body) => hasTokenIn(body, /\s+/, DANGEROUS_BODY_TOKENS);
102
+ // A MUTATING VARIANT of a terminating class never enters the offer — a hook-auto-approvable gate
103
+ // must never be a writer. Screened on BOTH axes: the script NAME (lint:fix, test:update,
104
+ // build:write, test:snapshot) and the script BODY's write-mode flags (eslint --fix,
105
+ // prettier --write / -w, jest -u / --updateSnapshot, tsc -w). Conservative by design: an excluded
106
+ // candidate can still be declared by hand — a wrongly-included one would be silently auto-approved.
107
+ const MUTATING_VARIANT_NAME_PATTERN = /(^|[:._-])(fix|write|update|snapshot)([:._-]|$)/i;
108
+ const MUTATING_BODY_FLAG_PATTERN = /(^|\s)(--fix|--write|--update(?:-snapshot)?|--updateSnapshot|-w|-u)(=|\s|$)/;
109
+
110
+ export const kebabIdOf = (name) =>
111
+ String(name)
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9]+/g, '-')
114
+ .replace(/^-+|-+$/g, '');
115
+
116
+ // package manager: the package.json `packageManager` field wins, else the lockfile probe, else npm.
117
+ export const detectPackageManager = (cwd, deps = {}) => {
118
+ const read = deps.readFile ?? readFileSync;
119
+ const exists = deps.exists ?? existsSync;
120
+ const fromField = (() => {
121
+ try {
122
+ const pkg = JSON.parse(String(read(join(cwd, 'package.json'), 'utf8')));
123
+ const pm = typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : null;
124
+ return pm === 'pnpm' || pm === 'yarn' || pm === 'npm' ? pm : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ })();
129
+ if (fromField) return fromField;
130
+ if (exists(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
131
+ if (exists(join(cwd, 'yarn.lock'))) return 'yarn';
132
+ return 'npm';
133
+ };
134
+
135
+ // The script-derived offer entries. Order = package.json scripts order (the offer the user reads).
136
+ export const deriveScriptEntries = (cwd, deps = {}) => {
137
+ const read = deps.readFile ?? readFileSync;
138
+ const pkg = (() => {
139
+ try {
140
+ return JSON.parse(String(read(join(cwd, 'package.json'), 'utf8')));
141
+ } catch {
142
+ return null; // no/unreadable package.json → no script candidates (an honest empty offer)
143
+ }
144
+ })();
145
+ const pm = detectPackageManager(cwd, deps);
146
+ const seen = new Set();
147
+ const bodyOf = (name) => String(pkg?.scripts?.[name] ?? '');
148
+ return discoverGateCandidates(pkg ?? {})
149
+ .filter((c) => !c.warn) // warn-flagged NEVER enter the offer
150
+ .filter((c) => SAFE_SCRIPT_NAME_PATTERN.test(c.scriptName)) // shell-safe names only, FIRST
151
+ .filter((c) => TERMINATING_CLASS_PATTERN.test(c.scriptName))
152
+ .filter((c) => !isNonTerminatingName(c.scriptName))
153
+ .filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName) && !MUTATING_BODY_FLAG_PATTERN.test(bodyOf(c.scriptName)))
154
+ .filter((c) => !isNonTerminatingBody(bodyOf(c.scriptName)))
155
+ .filter((c) => !isDangerousBody(bodyOf(c.scriptName)))
156
+ .map((c) => ({
157
+ id: kebabIdOf(c.scriptName),
158
+ title: `Project script: ${pm} run ${c.scriptName}`,
159
+ cmd: `${pm} run ${c.scriptName}`,
160
+ }))
161
+ .filter((e) => {
162
+ if (!e.id || seen.has(e.id)) return false; // an empty or duplicate derived id never enters the offer
163
+ seen.add(e.id);
164
+ return true;
165
+ });
166
+ };
167
+
168
+ // The conditional review-state candidate — keyed on the SLOT the checker enforces
169
+ // (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
170
+ // when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
171
+ // config never see it. The cmd carries the resolved, QUOTED tool path and passes the validator.
172
+ // Double-quote-unsafe shell metacharacters: inside `"…"` bash still expands `$`, backticks and
173
+ // backslashes, and a `"` breaks the quoting entirely. A candidate cmd is hook-auto-approvable, so a
174
+ // path that cannot be safely double-quoted is WITHHELD with a loud note — never offered wrongly.
175
+ const DQ_UNSAFE_PATH_PATTERN = /["$`\\\r\n]/;
176
+
177
+ export const reviewStateCandidate = (cwd, deps = {}) => {
178
+ const toolPath = deps.reviewStateTool ?? REVIEW_STATE_TOOL;
179
+ try {
180
+ const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
181
+ const declared = config?.['plan-execution']?.review;
182
+ if (declared !== 'reviewed' && declared !== 'council') return { candidate: null, note: null };
183
+ if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
184
+ return {
185
+ candidate: null,
186
+ note:
187
+ `the review-state candidate was withheld: the resolved kit path contains shell ` +
188
+ `metacharacters that do not survive double-quoting (${toolPath}) — declare the gate ` +
189
+ `by hand per references/modes/review-state.md step 3`,
190
+ };
191
+ }
192
+ return {
193
+ candidate: {
194
+ id: 'review-state',
195
+ title: 'Review receipts current for the uncommitted tree',
196
+ cmd: `node "${toolPath}" --check`,
197
+ },
198
+ note: null,
199
+ };
200
+ } catch (err) {
201
+ return {
202
+ candidate: null,
203
+ note: `orchestration config unreadable (${err.message}) — the review-state candidate was not evaluated`,
204
+ };
205
+ }
206
+ };
207
+
208
+ // Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
209
+ // any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
210
+ // "nothing to offer" success.
211
+ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
212
+ const offered = new Set(offer.entries.map((e) => e.id));
213
+ const unknown = onlyIds.filter((id) => !offered.has(id));
214
+ if (unknown.length) {
215
+ throw usageFail(`--only names ids not in the offer: ${unknown.join(', ')} (offered: ${[...offered].join(', ') || 'none'})`);
216
+ }
217
+ };
218
+
219
+ // The full offer: script entries + the conditional review-state candidate (last), plus loud notes.
220
+ export const buildOffer = (cwd, deps = {}) => {
221
+ const entries = deriveScriptEntries(cwd, deps);
222
+ const { candidate, note } = reviewStateCandidate(cwd, deps);
223
+ return {
224
+ entries: candidate ? [...entries, candidate] : entries,
225
+ notes: note ? [note] : [],
226
+ };
227
+ };
228
+
229
+ // The RUNNABLE apply invocation for a given project — this tool has no bin and no mode token, so
230
+ // the consent step must print the real command, never a bare `seed-gates`. Consent integrity: a
231
+ // previewed --only subset is carried into the hint VERBATIM (ids are shell-safe by construction —
232
+ // kebabIdOf output / the fixed `review-state`), so following the hint can never widen the consent.
233
+ // Paths that do not survive double-quoting (the reviewStateCandidate screen, same pattern) make
234
+ // this return null — the preview then falls back to a generic, unquoted instruction.
235
+ export const applyInvocationFor = (cwd, onlyIds = []) => {
236
+ const ownPath = fileURLToPath(import.meta.url);
237
+ if (DQ_UNSAFE_PATH_PATTERN.test(ownPath) || DQ_UNSAFE_PATH_PATTERN.test(cwd)) return null;
238
+ const only = onlyIds.map((id) => ` --only ${id}`).join('');
239
+ return `node "${ownPath}" --cwd "${cwd}" --apply${only}`;
240
+ };
241
+
242
+ const GENERIC_APPLY_HINT = 're-run this same command with --apply [--only <id>]...';
243
+
244
+ export const formatPreview = (offer, applyInvocation = null, { explicitOnly = false } = {}) => {
245
+ const lines = ['[agent-workflow-kit] gates seeding preview (dry-run — nothing was written):'];
246
+ if (!offer.entries.length) {
247
+ lines.push(' nothing to offer — no seedable terminating verification scripts were found.');
248
+ }
249
+ for (const e of offer.entries) {
250
+ lines.push(` ${e.id}: ${e.cmd} (${e.title})`);
251
+ }
252
+ for (const note of offer.notes) lines.push(` note: ${note}`);
253
+ if (offer.entries.length) {
254
+ const suffix = explicitOnly ? '' : ' [--only <id>]...';
255
+ lines.push(` apply exactly the entries you consent to: ${applyInvocation ?? GENERIC_APPLY_HINT}${applyInvocation ? suffix : ''}`);
256
+ }
257
+ lines.push(` ${TRUST_CHAIN_DISCLOSURE}`);
258
+ return lines.join('\n');
259
+ };
260
+
261
+ // ── the existing declaration (append-only source) ──────────────────────────────────────
262
+ const loadExistingDeclaration = (cwd, deps = {}) => {
263
+ const read = deps.readFile ?? readFileSync;
264
+ const lstat = deps.lstat ?? lstatSync;
265
+ const full = join(cwd, GATES_REL);
266
+ const leaf = lstatNoFollow(full, lstat);
267
+ if (leaf === null) return { outcome: 'missing' };
268
+ // Refuse a symlinked leaf HERE, before any read/parse — the atomic core would refuse it at write
269
+ // time anyway, but the honest STOP names the symlink, not a misleading parse error on its target.
270
+ if (leaf.isSymbolicLink()) {
271
+ throw stop(`${GATES_REL} is a symlink — refusing to touch it (a write would clobber the link target)`);
272
+ }
273
+ const parsed = (() => {
274
+ try {
275
+ return JSON.parse(String(read(full, 'utf8')));
276
+ } catch (err) {
277
+ throw stop(`${GATES_REL}: malformed JSON (${err.message}) — fix it by hand; the seeder never writes over a declaration it cannot parse`);
278
+ }
279
+ })();
280
+ const gates = (() => {
281
+ try {
282
+ return validateDeclaration(parsed);
283
+ } catch (err) {
284
+ throw stop(`${err.message} — fix it by hand; the seeder never writes over an invalid declaration`);
285
+ }
286
+ })();
287
+ return { outcome: 'loaded', readme: typeof parsed._README === 'string' ? parsed._README : undefined, gates };
288
+ };
289
+
290
+ const templateReadme = (deps = {}) => {
291
+ const read = deps.readTemplate ?? readFileSync;
292
+ try {
293
+ const parsed = JSON.parse(String(read(TEMPLATE_PATH, 'utf8')));
294
+ if (typeof parsed._README !== 'string') throw new Error('template has no _README');
295
+ return parsed._README;
296
+ } catch (err) {
297
+ throw stop(`the bundled gates.json template is unreadable (${err.message}) — the kit install is incomplete`);
298
+ }
299
+ };
300
+
301
+ const readStampValue = (cwd, deps = {}) => {
302
+ const read = deps.readFile ?? readFileSync;
303
+ try {
304
+ const v = String(read(join(cwd, STAMP_REL), 'utf8')).trim();
305
+ return v.length ? v : null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ };
310
+
311
+ // ── apply (append exactly the consented entries) ───────────────────────────────────────
312
+ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
313
+ assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
314
+ const stampValue = readStampValue(cwd, deps);
315
+ if (stampValue !== EXPECTED_WORKFLOW_VERSION) {
316
+ throw stop(
317
+ `--apply is deployment-gated: ${STAMP_REL} is ${stampValue ?? 'absent'} but this kit expects ` +
318
+ `${EXPECTED_WORKFLOW_VERSION} (the preview works on any deployment; run upgrade first)`,
319
+ );
320
+ }
321
+ const offer = buildOffer(cwd, deps);
322
+ assertOnlyIdsOffered(offer, onlyIds); // BEFORE the empty-offer return — a typo is never masked
323
+ if (!offer.entries.length) return { outcome: 'nothing' };
324
+ const selected = onlyIds.length ? offer.entries.filter((e) => onlyIds.includes(e.id)) : offer.entries;
325
+
326
+ const existing = loadExistingDeclaration(cwd, deps);
327
+ const existingGates = existing.outcome === 'loaded' ? existing.gates : [];
328
+ const existingIds = new Set(existingGates.map((g) => g.id));
329
+ const collisions = selected.filter((e) => existingIds.has(e.id)).map((e) => e.id);
330
+ if (collisions.length) {
331
+ throw stop(
332
+ `id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (append-only: the ` +
333
+ `seeder never modifies or removes an existing entry; pick the others with --only, or edit by hand)`,
334
+ );
335
+ }
336
+
337
+ const merged = {
338
+ _README: existing.outcome === 'loaded' && existing.readme !== undefined ? existing.readme : templateReadme(deps),
339
+ gates: [...existingGates, ...selected],
340
+ };
341
+ validateDeclaration(merged); // every written declaration passes the runner's validator, always
342
+ const body = `${JSON.stringify(merged, null, 2)}\n`;
343
+ const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
344
+ return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id) };
345
+ };
346
+
347
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────
348
+ export const parseArgs = (argv) => {
349
+ const parsed = argv.reduce(
350
+ (acc, a, i) => {
351
+ if (acc.skip) return { ...acc, skip: false };
352
+ if (a === '--help' || a === '-h') return { ...acc, help: true };
353
+ // A consent-gated writer never lets a later flag silently decide whether it mutates:
354
+ // mixed --dry-run/--apply is a usage error, whichever order they arrive in.
355
+ if (a === '--dry-run') {
356
+ if (acc.apply === true) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
357
+ return { ...acc, apply: false, dryRunExplicit: true };
358
+ }
359
+ if (a === '--apply') {
360
+ if (acc.dryRunExplicit) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
361
+ return { ...acc, apply: true };
362
+ }
363
+ if (a === '--cwd') {
364
+ const value = argv[i + 1];
365
+ if (value === undefined || value.startsWith('-')) throw usageFail('--cwd needs a value: --cwd <dir>');
366
+ return { ...acc, cwd: value, skip: true };
367
+ }
368
+ if (a === '--only') {
369
+ const value = argv[i + 1];
370
+ if (value === undefined || value.startsWith('-')) throw usageFail('--only needs a gate id');
371
+ return { ...acc, only: [...acc.only, value], skip: true };
372
+ }
373
+ throw usageFail(`unknown argument: ${a}`);
374
+ },
375
+ { help: false, apply: false, dryRunExplicit: false, cwd: undefined, only: [], skip: false },
376
+ );
377
+ return parsed;
378
+ };
379
+
380
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
381
+ const log = deps.log ?? console.log;
382
+ const error = deps.error ?? console.error;
383
+ try {
384
+ const args = parseArgs(argv);
385
+ if (args.help) {
386
+ log(USAGE);
387
+ return EXIT_OK;
388
+ }
389
+ const cwd = resolve(args.cwd ?? process.cwd());
390
+ assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
391
+ if (!args.apply) {
392
+ const offer = buildOffer(cwd, deps);
393
+ assertOnlyIdsOffered(offer, args.only); // a dry-run --only typo is loud too
394
+ const filtered = args.only.length ? { ...offer, entries: offer.entries.filter((e) => args.only.includes(e.id)) } : offer;
395
+ log(formatPreview(filtered, applyInvocationFor(cwd, args.only), { explicitOnly: args.only.length > 0 }));
396
+ return EXIT_OK;
397
+ }
398
+ const result = applySeed({ cwd, onlyIds: args.only }, deps);
399
+ if (result.outcome === 'nothing') {
400
+ log('[agent-workflow-kit] nothing to offer — no seedable terminating verification scripts were found; wrote nothing.');
401
+ return EXIT_OK;
402
+ }
403
+ log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
404
+ log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
405
+ return EXIT_OK;
406
+ } catch (err) {
407
+ error(err?.message ?? String(err));
408
+ return err?.exitCode ?? EXIT_PRECONDITION;
409
+ }
410
+ };
411
+
412
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
413
+ if (isDirectRun) process.exit(main(process.argv.slice(2)));
@@ -364,8 +364,12 @@ const isScriptMap = (scripts) => Boolean(scripts) && typeof scripts === 'object'
364
364
  const isMutatingScriptName = (name) =>
365
365
  MUTATING_SCRIPT_NAME_PATTERN.test(name) || MUTATING_SCRIPT_HOOK_PATTERN.test(name);
366
366
 
367
+ // `scriptName` is ADDITIVE (AD-042): the seed-gates offer layer maps a candidate to a
368
+ // package-manager-aware `{ id, title, cmd }` and needs the raw script name for that derivation;
369
+ // `command` stays the advisory's own npm-run spelling (this fn is otherwise unchanged).
367
370
  const makeGateCandidate = (name) => ({
368
371
  command: `${NPM_RUN_COMMAND} ${name}`,
372
+ scriptName: name,
369
373
  addByHand: ADD_BY_HAND,
370
374
  ...(isMutatingScriptName(name) ? { warn: DO_NOT_ADD_WARNING } : {}),
371
375
  });
@@ -65,6 +65,12 @@ const velocityVm = (v) => {
65
65
  return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
66
66
  };
67
67
 
68
+ const agentsVm = (a) => {
69
+ if (!a) return null;
70
+ if (a.error) return { error: a.error };
71
+ return { bundled: a.bundled ?? 0, placed: a.placed ?? 0 };
72
+ };
73
+
68
74
  const hookVm = (h) => {
69
75
  if (!h) return null;
70
76
  if (h.error) return { error: h.error };
@@ -72,6 +78,12 @@ const hookVm = (h) => {
72
78
  wired: Boolean(h.wired),
73
79
  filePlaced: Boolean(h.filePlaced),
74
80
  declarationPresent: Boolean(h.declarationPresent),
81
+ // 0 = absent/empty declaration; null = unreadable/malformed OR an envelope predating the
82
+ // field — unknown reads as unknown, never as a count (the welcome-mat hook rung keys on > 0).
83
+ declaredGates: h.declaredGates ?? null,
84
+ // The preserved validation reason for a null count (null when absent) — rendered beside the
85
+ // unknown marker so a malformed declaration is never a silent bare "?".
86
+ declarationError: h.declarationError ?? null,
75
87
  };
76
88
  };
77
89
 
@@ -81,6 +93,7 @@ const settingsVm = (s) =>
81
93
  recipes: recipesVm(s.recipes),
82
94
  attribution: attributionVm(s.attribution),
83
95
  velocity: velocityVm(s.velocity),
96
+ agents: agentsVm(s.agents),
84
97
  hook: hookVm(s.hook),
85
98
  }
86
99
  : null;