@sabaiway/agent-workflow-kit 10.2.0 → 10.4.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +7 -7
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/agents/executor.md +40 -0
  7. package/references/modes/agents.md +9 -4
  8. package/references/modes/procedures.md +17 -8
  9. package/references/modes/recipes.md +7 -4
  10. package/references/modes/recommendations.md +4 -1
  11. package/references/modes/set-recipe.md +22 -5
  12. package/references/modes/status.md +3 -3
  13. package/references/modes/upgrade.md +7 -5
  14. package/references/shared/composition-handoff.md +1 -1
  15. package/references/shared/deploy-tail.md +2 -2
  16. package/references/templates/agent_rules.md +3 -2
  17. package/references/templates/orchestration.json +1 -1
  18. package/tools/ack-store.mjs +57 -0
  19. package/tools/ack-write.mjs +1 -1
  20. package/tools/autonomy-config.mjs +1 -1
  21. package/tools/carriers.mjs +140 -0
  22. package/tools/cheap-agents-read.mjs +172 -0
  23. package/tools/cheap-agents.mjs +57 -105
  24. package/tools/commands.mjs +3 -3
  25. package/tools/direct-run.mjs +6 -0
  26. package/tools/doc-parity.mjs +8 -0
  27. package/tools/ensure-ops.mjs +18 -9
  28. package/tools/ensure-specs.mjs +3 -4
  29. package/tools/ensure-vocabulary.mjs +5 -2
  30. package/tools/family-registry.mjs +70 -21
  31. package/tools/flow-check.mjs +2 -7
  32. package/tools/inject-methodology.mjs +4 -0
  33. package/tools/lens-region.mjs +4 -1
  34. package/tools/node-evidence.mjs +77 -0
  35. package/tools/orchestration-config.mjs +34 -13
  36. package/tools/procedures.mjs +65 -52
  37. package/tools/recipes.mjs +156 -184
  38. package/tools/recommendations.mjs +145 -78
  39. package/tools/renderers.mjs +36 -7
  40. package/tools/review-state.mjs +10 -11
  41. package/tools/set-recipe.mjs +63 -24
  42. package/tools/spec-adoption.mjs +71 -0
  43. package/tools/spec-check.mjs +2 -2
  44. package/tools/upgrade-runlist.mjs +1 -1
  45. package/tools/view-model.mjs +19 -3
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // cheap-agents.mjs — the onboarding writer behind `/agent-workflow-kit agents`: places the
3
- // bundled CHEAP-LANE subagent definitions (references/agents/*.md haiku/low-effort, bounded
4
- // read-only tools) into a project's .claude/agents/ so mechanical work (sweeps, changelog
5
- // skeletons, gate triage) stops running on a frontier model by default.
2
+ // cheap-agents.mjs — the onboarding writer behind `/agent-workflow-kit agents`: places the bundled
3
+ // subagent definitions (references/agents/*.md) into a project's .claude/agents/. FOUR vehicles
4
+ // grant NO shell three ride the cheap lane (haiku/low-effort) so mechanical work (sweeps,
5
+ // changelog skeletons, gate triage) stops running on a frontier model by default, and review-lens
6
+ // is the read-only review opinion. The fifth, `executor`, is the ONE full-tool vehicle: dispatched
7
+ // only for a bounded execution, authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and it
8
+ // never commits. `surveyExecutorVehicle` is that vehicle's readiness, for the subagent carrier.
6
9
  //
7
10
  // The family's second `.claude/` writer, the velocity-profile.mjs writer discipline verbatim:
8
11
  // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
@@ -25,29 +28,48 @@
25
28
  // state, not an error); 1 precondition STOP (stamp, symlink, missing bundle); 2 usage.
26
29
  // Dependency-free, Node >= 22. No side effects on import.
27
30
 
28
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
29
- import { join, resolve, dirname } from 'node:path';
31
+ import { mkdirSync, writeFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
30
33
  import { fileURLToPath } from 'node:url';
31
34
  import { isDirectRun } from './direct-run.mjs';
32
35
  import { shellQuoteArg } from './repo-lex.mjs';
33
-
34
- const HERE = dirname(fileURLToPath(import.meta.url));
35
-
36
- export const AGENTS_DIR = '.claude/agents';
37
- export const CLAUDE_DIR = '.claude';
38
- export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
39
- export const EXPECTED_WORKFLOW_VERSION = '3.0.0';
40
- export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
36
+ // The READ core, never a second copy: the bundle, the placement plan and the executor survey live
37
+ // there so the read-only advisor graph can reach them without reaching this writer.
38
+ import {
39
+ AGENTS_DIR,
40
+ CLAUDE_DIR,
41
+ WORKFLOW_STAMP,
42
+ EXPECTED_WORKFLOW_VERSION,
43
+ UTF8,
44
+ CHEAP_AGENTS_STAMP,
45
+ makeCheapAgentsError,
46
+ readFsDeps,
47
+ readBundledAgents,
48
+ readStamp,
49
+ assertDirSafe,
50
+ planPlacement,
51
+ } from './cheap-agents-read.mjs';
52
+
53
+ export {
54
+ AGENTS_DIR,
55
+ CLAUDE_DIR,
56
+ WORKFLOW_STAMP,
57
+ EXPECTED_WORKFLOW_VERSION,
58
+ BUNDLED_AGENTS_DIR,
59
+ CHEAP_AGENTS_STAMP,
60
+ CHEAP_AGENTS_SYMLINK,
61
+ CHEAP_AGENTS_BUNDLE,
62
+ makeCheapAgentsError,
63
+ readBundledAgents,
64
+ planPlacement,
65
+ surveyExecutorVehicle,
66
+ EXECUTOR_VEHICLE,
67
+ EXECUTOR_VEHICLE_REL,
68
+ } from './cheap-agents-read.mjs';
41
69
 
42
70
  const EXIT_OK = 0;
43
71
  const EXIT_PRECONDITION = 1;
44
72
  const EXIT_USAGE = 2;
45
- const UTF8 = 'utf8';
46
- const ERROR_PREFIX = '[agent-workflow-kit]';
47
-
48
- export const CHEAP_AGENTS_STAMP = 'CHEAP_AGENTS_STAMP';
49
- export const CHEAP_AGENTS_SYMLINK = 'CHEAP_AGENTS_SYMLINK';
50
- export const CHEAP_AGENTS_BUNDLE = 'CHEAP_AGENTS_BUNDLE';
51
73
 
52
74
  // The fallback-lens contract, formalized where it lives (flow-orchestration #15/#3, Phase 4.3):
53
75
  // the internal-attestation evaluation consumes this sentence — a lens set claiming a configured
@@ -57,95 +79,24 @@ export const FALLBACK_LENS_ADDITIONAL_ONLY = 'review-lens is an ADDITIONAL read-
57
79
 
58
80
  const USAGE = `usage: cheap-agents [--dry-run | --apply] [--cwd <dir>] [--help]
59
81
 
60
- Places the bundled READ-ONLY subagent definitions into the project's ${AGENTS_DIR}/. No vehicle
61
- grants a shell: three ride a cheap model (haiku/low) for mechanical work — extraction sweeps,
62
- changelog fact-skeletons, gate triage — and review-lens is a read-only REVIEW vehicle on a
63
- review-capable model. Default is --dry-run (a preview; writes nothing). --apply writes.
82
+ Places the bundled subagent definitions into the project's ${AGENTS_DIR}/. Four vehicles grant NO
83
+ shell: three ride a cheap model (haiku/low) for mechanical work — extraction sweeps, changelog
84
+ fact-skeletons, gate triage — and review-lens is a read-only REVIEW vehicle on a review-capable
85
+ model. The fifth, executor, is the ONE full-tool vehicle: dispatched only for a bounded execution,
86
+ authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and
87
+ it never commits.
88
+ Default is --dry-run (a preview; writes nothing). --apply writes.
64
89
  An existing file with DIFFERENT content is preserved and reported, never overwritten.`;
65
90
 
66
91
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
67
92
 
68
- export const makeCheapAgentsError = (code, message) =>
69
- Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'CheapAgentsError', code, exitCode: EXIT_PRECONDITION });
70
-
71
- const fsDeps = (deps = {}) => ({
72
- exists: deps.exists ?? existsSync,
73
- lstat: deps.lstat ?? lstatSync,
93
+ const writeFsDeps = (deps = {}) => ({
74
94
  mkdir: deps.mkdir ?? mkdirSync,
75
- readFile: deps.readFile ?? readFileSync,
76
95
  writeFile: deps.writeFile ?? writeFileSync,
77
- readdir: deps.readdir ?? readdirSync,
78
96
  });
79
97
 
80
- const lstatNoFollow = (absPath, fs) => {
81
- try {
82
- return fs.lstat(absPath);
83
- } catch (err) {
84
- if (err && err.code === 'ENOENT') return null;
85
- throw err;
86
- }
87
- };
88
-
89
- // ── the bundle (the kit's own references/agents/) ─────────────────────────────────────
90
-
91
- export const readBundledAgents = (deps = {}) => {
92
- const fs = fsDeps(deps);
93
- const bundleDir = deps.bundleDir ?? BUNDLED_AGENTS_DIR;
94
- let names;
95
- try {
96
- names = fs.readdir(bundleDir);
97
- } catch (err) {
98
- throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `bundled agents dir unreadable (${err.code ?? err.message}): ${bundleDir}`);
99
- }
100
- const templates = names
101
- .filter((name) => name.endsWith('.md'))
102
- .sort()
103
- .map((name) => ({ name, content: fs.readFile(join(bundleDir, name), UTF8) }));
104
- if (templates.length === 0) {
105
- throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `no bundled agent templates found in ${bundleDir} — the kit install is incomplete`);
106
- }
107
- return templates;
108
- };
109
-
110
- // ── preflight (velocity discipline: symlink-safe, stamp read, no writes) ──────────────
111
-
112
- const readStamp = (absPath, fs) => {
113
- try {
114
- if (!fs.exists(absPath)) return null;
115
- const stamp = String(fs.readFile(absPath, UTF8)).trim();
116
- return stamp.length ? stamp : null;
117
- } catch {
118
- return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
119
- }
120
- };
121
-
122
- const assertDirSafe = (absPath, relPath, fs) => {
123
- const stat = lstatNoFollow(absPath, fs);
124
- if (stat === null) return { absent: true };
125
- if (stat.isSymbolicLink()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
126
- if (!stat.isDirectory()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
127
- return { absent: false };
128
- };
129
-
130
- // Per-template placement plan: place | already-current | customized-preserved (never clobbered).
131
- export const planPlacement = (templates, projectDir, deps = {}) => {
132
- const fs = fsDeps(deps);
133
- return templates.map((template) => {
134
- const rel = `${AGENTS_DIR}/${template.name}`;
135
- const abs = join(projectDir, AGENTS_DIR, template.name);
136
- const stat = lstatNoFollow(abs, fs);
137
- if (stat === null) return { ...template, rel, abs, action: 'place' };
138
- if (stat.isSymbolicLink() || !stat.isFile()) {
139
- throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${rel} exists but is not a regular file — refusing to touch it`);
140
- }
141
- const existing = fs.readFile(abs, UTF8);
142
- if (existing === template.content) return { ...template, rel, abs, action: 'already-current' };
143
- return { ...template, rel, abs, action: 'customized-preserved' };
144
- });
145
- };
146
-
147
98
  export const preflightCheapAgents = ({ cwd }, deps = {}) => {
148
- const fs = fsDeps(deps);
99
+ const fs = readFsDeps(deps);
149
100
  const projectDir = cwd ?? process.cwd();
150
101
  const templates = readBundledAgents(deps);
151
102
  const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
@@ -159,7 +110,7 @@ export const preflightCheapAgents = ({ cwd }, deps = {}) => {
159
110
  // ── the writer ────────────────────────────────────────────────────────────────────────
160
111
 
161
112
  export const writeCheapAgents = ({ cwd, dryRun = true } = {}, deps = {}) => {
162
- const fs = fsDeps(deps);
113
+ const fs = writeFsDeps(deps);
163
114
  const preflight = preflightCheapAgents({ cwd }, deps);
164
115
  if (dryRun) return { wrote: false, dryRun: true, ...preflight };
165
116
 
@@ -186,8 +137,8 @@ const ACTION_LABEL = {
186
137
  export const formatResult = (result) => {
187
138
  const lines = [
188
139
  result.dryRun
189
- ? 'agent-workflow read-only subagents — DRY RUN (no changes)'
190
- : 'agent-workflow read-only subagents — APPLY',
140
+ ? 'agent-workflow subagent vehicles — DRY RUN (no changes)'
141
+ : 'agent-workflow subagent vehicles — APPLY',
191
142
  ];
192
143
  for (const item of result.plan) {
193
144
  const verb = result.dryRun && item.action === 'place' ? 'would place' : ACTION_LABEL[item.action];
@@ -197,8 +148,9 @@ export const formatResult = (result) => {
197
148
  lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
198
149
  }
199
150
  lines.push(
200
- 'the vehicles are Claude Code subagents with READ-ONLY tools and NO shell — so a fan-out can never turn into a wave of approval prompts.',
201
- `three ride the cheap lane (model: haiku, effort: low) for mechanical work; ${FALLBACK_LENS_ADDITIONAL_ONLY} Writing code and running gates stay on your main lane.`,
151
+ 'four vehicles are Claude Code subagents with READ-ONLY tools and NO shell — so a fan-out can never turn into a wave of approval prompts.',
152
+ `three of those ride the cheap lane (model: haiku, effort: low) for mechanical work; ${FALLBACK_LENS_ADDITIONAL_ONLY}`,
153
+ 'executor is the one FULL-TOOL vehicle: dispatched only for a bounded execution, authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and it never commits.',
202
154
  );
203
155
  // A preview must print the EXACT command that applies it. The advisor renders this dry-run as an
204
156
  // item's one-liner, and that flow's contract is "run the printed command, no improvisation" — a
@@ -131,7 +131,7 @@ const CATALOG = [
131
131
  invocation: invocationOf('agents'),
132
132
  group: 'Configure',
133
133
  kind: WRITER,
134
- oneLine: 'Place bundled READ-ONLY subagent definitions, none of them granted a shell — three cheap-model ones for mechanical work (sweeps, changelog skeletons, gate triage) and a review lens (Claude Code; opt-in; preview first).',
134
+ oneLine: 'Place the bundled subagent vehicles: four read-only ones, none granted a shell — three cheap-model ones for mechanical work (sweeps, changelog skeletons, gate triage) and a review lens — plus the one full-tool executor that carries bounded slices you verify (Claude Code; opt-in; preview first).',
135
135
  },
136
136
  {
137
137
  key: 'hook',
@@ -180,7 +180,7 @@ const CATALOG = [
180
180
  invocation: invocationOf('recipes'),
181
181
  group: 'Orchestrate',
182
182
  kind: READ_ONLY,
183
- oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated), which one fits this environment, and the configured per-activity line to paste at session start.',
183
+ oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated / Subagent), which one fits this environment, and the configured per-activity line to paste at session start.',
184
184
  },
185
185
  {
186
186
  key: 'procedures',
@@ -341,7 +341,7 @@ const TUNE_TAIL = Object.freeze([
341
341
  '',
342
342
  'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
343
343
  ` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
344
- ` ${BARE_INVOCATION} agents shell-free subagents: cheap ones take the mechanical work, a review lens gives an extra read-only opinion`,
344
+ ` ${BARE_INVOCATION} agents subagent vehicles: four read-only (cheap ones take the mechanical work, a review lens gives another opinion) + the one full-tool executor that carries bounded slices you verify`,
345
345
  ` ${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`,
346
346
  ` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
347
347
  ` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
@@ -56,6 +56,12 @@ export const LIBRARY_ONLY_MODULES = Object.freeze({
56
56
  // Named by references/modes/mcp.md as the read half the advisor and uninstall ask. It only ever
57
57
  // REPORTS; the command that acts on what it reports is the mode itself.
58
58
  'mcp-registration.mjs': '/agent-workflow-kit mcp',
59
+ // Named by references/modes/set-recipe.md as the activity/slot registry; the command that shows
60
+ // the recipes it defines, resolved for this environment, is the recipes advisor.
61
+ 'carriers.mjs': '/agent-workflow-kit recipes',
62
+ // The READ core of the subagent-vehicle surface: it sits one name away from the writer
63
+ // references/modes/agents.md DOES name, and reaching for it is reaching for the agents mode.
64
+ 'cheap-agents-read.mjs': '/agent-workflow-kit agents',
59
65
  });
60
66
 
61
67
  // The frozen refusal line. One line, names the module, names the command.
@@ -72,6 +72,8 @@ import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabula
72
72
  // The MCP registration's four public strings. Imported from the READ-ONLY leaf, never from the
73
73
  // writer: a read-only lint must not pull the atomic-write core into its import graph.
74
74
  import { ENABLED_KEY as MCP_ENABLED_KEY, MCP_JSON_REL, SERVER_NAME as MCP_SERVER_NAME, allowRulesFor } from './mcp-registration.mjs';
75
+ // The spec-adoption state tokens the status mode doc must name (contract: kit/spec-adoption).
76
+ import { ADOPTION_STATES, SPEC_ADOPTION_LANE } from './spec-adoption.mjs';
75
77
 
76
78
  const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
77
79
  const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
@@ -86,6 +88,7 @@ const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
86
88
  const GATES_DOC = 'references/modes/gates.md';
87
89
  const MCP_DOC = 'references/modes/mcp.md';
88
90
  const UNINSTALL_DOC = 'references/modes/uninstall.md';
91
+ const STATUS_DOC = 'references/modes/status.md';
89
92
  // One literal for the dispatch mode doc: the structure leaf already names it as the file it anchors
90
93
  // its table in, and a second copy here is exactly the drift this lint exists to catch.
91
94
  const DISPATCH_DOC = ADVISOR_MATRIX_DOC;
@@ -236,6 +239,11 @@ export const BINDINGS = Object.freeze([
236
239
  valueBinding('mcp-enabled-key', MCP_ENABLED_KEY, `\`${MCP_ENABLED_KEY}\``, [MCP_DOC, UNINSTALL_DOC]),
237
240
  valueBinding('mcp-server-name', MCP_SERVER_NAME, `\`"${MCP_SERVER_NAME}"\``, [MCP_DOC, UNINSTALL_DOC]),
238
241
  ...allowRulesFor().map((rule) => valueBinding(`mcp-allow-rule:${rule}`, rule, `\`${rule}\``, [MCP_DOC, UNINSTALL_DOC])),
242
+ // The spec-adoption state tokens: status.md renders a plain phrase per token, so the doc must name
243
+ // every token the survey can answer — a fifth state added to the leaf with no phrase fails here.
244
+ // The decline lane rides both the status line and the advisor item, so both docs name it.
245
+ ...ADOPTION_STATES.map((state) => valueBinding(`spec-adoption:${state}`, state, `\`${state}\``, [STATUS_DOC])),
246
+ valueBinding('spec-adoption-lane', SPEC_ADOPTION_LANE, `--lane ${SPEC_ADOPTION_LANE}`, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
239
247
  ].map((b) => Object.freeze(b)));
240
248
 
241
249
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -16,8 +16,8 @@
16
16
  // • The DECISION lives where it already lived. The orchestration `_README` refresh asks
17
17
  // orchestration-config.mjs (refreshReadme / the known-prior canonical set) and writes through
18
18
  // orchestration-write.mjs — the file's one writer. Nothing here re-derives either.
19
- // • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node`
20
- // names the missing package.json; an ADR-layout read that fails is `adr-layout-unverifiable` and
19
+ // • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node-evidence`
20
+ // names every Node probe that answered absent; an ADR-layout read that fails is `adr-layout-unverifiable` and
21
21
  // writes NOTHING (the STRICT survey, fail-closed — the lenient status wrapper reads an unreadable
22
22
  // tree as `none`, which here would mean seeding a rotator beside a store nobody could inspect).
23
23
  // • A failed op is a non-zero signal, never a line that reads like success.
@@ -34,6 +34,7 @@ import { GATES_REL } from './gates-declaration.mjs';
34
34
  import { AUTONOMY_REL } from './autonomy-config.mjs';
35
35
  import { surveyAdrLayoutStrict } from './family-registry.mjs';
36
36
  import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
37
+ import { NODE_EVIDENCE, describeNodeProbes, probeNodeEvidence } from './node-evidence.mjs';
37
38
 
38
39
  // The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
39
40
  // relayed token set without importing this module's writer graph. Re-exported here because every
@@ -49,7 +50,6 @@ export {
49
50
  WRITE_TOKENS,
50
51
  } from './ensure-vocabulary.mjs';
51
52
 
52
- const PACKAGE_JSON = 'package.json';
53
53
  const SCRIPTS_DIR = 'scripts';
54
54
 
55
55
  const outcome = (op, token, lines, failed = false) => {
@@ -204,9 +204,19 @@ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
204
204
 
205
205
  // ── 4. scripts/ — the ADR-cascade enforcement pairs, detect-first ──────────────────────────────────
206
206
 
207
- // A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
208
- // never silent: the token names the evidence, and the three config ensures still run.
209
- export const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
207
+ // The Node-evidence refusal every ensure that places Node scripts shares (contract: kit/node-evidence):
208
+ // null when Node provably runs here, else the ONE outcome the caller returns a stated skip naming the
209
+ // probes that answered absent, or a fail-closed failure when a probe could not be read.
210
+ export const nodeEvidenceRefusal = (op, cwd, lstat) => {
211
+ const evidence = probeNodeEvidence(cwd, lstat);
212
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) {
213
+ return loud(op, 'node-evidence-unverifiable', `${SCRIPTS_DIR}/: whether Node runs here could not be read (${evidence.error}), so nothing was written — resolve it by hand, then re-run`);
214
+ }
215
+ if (evidence.state === NODE_EVIDENCE.NONE) {
216
+ return ok(op, 'skipped-no-node-evidence', `${SCRIPTS_DIR}/: no Node evidence in this tree — probed ${describeNodeProbes(evidence)}, no regular file present; the seeded pairs are Node enforcement, so nothing written`);
217
+ }
218
+ return null;
219
+ };
210
220
 
211
221
  const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
212
222
 
@@ -221,9 +231,8 @@ const partialNote = (lines) => {
221
231
  export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
222
232
  const lstat = deps.lstat ?? lstatSync;
223
233
  const read = deps.readFile ?? readFileSync;
224
- if (!isNodeProject(cwd, lstat)) {
225
- return ok('scripts', 'skipped-no-node', `${SCRIPTS_DIR}/: no ${PACKAGE_JSON} at the project root — the seeded pairs are Node enforcement; nothing written`);
226
- }
234
+ const refusal = nodeEvidenceRefusal('scripts', cwd, lstat);
235
+ if (refusal) return refusal;
227
236
  let layout;
228
237
  try {
229
238
  layout = surveyAdrLayoutStrict(cwd, deps);
@@ -26,7 +26,7 @@ import { readFileSync, lstatSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
28
28
  import { classifyDeployedScript } from './script-priors.mjs';
29
- import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
29
+ import { composeFailure, composeOutcome, nodeEvidenceRefusal, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
30
30
 
31
31
  const OP = 'specs';
32
32
  const SCRIPTS_DIR = 'scripts';
@@ -159,9 +159,8 @@ const renderStoreRoot = (kitRoot, read, today) => {
159
159
  export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
160
160
  const lstat = deps.lstat ?? lstatSync;
161
161
  const read = deps.readFile ?? readFileSync;
162
- if (!isNodeProject(cwd, lstat)) {
163
- return ok('skipped-no-node', [`${SCRIPTS_DIR}/: no package.json at the project root — the spec reader and checker are Node scripts; nothing written`]);
164
- }
162
+ const refusal = nodeEvidenceRefusal(OP, cwd, lstat);
163
+ if (refusal) return refusal;
165
164
  const survey = (name) => surveyScript({ cwd, kitRoot, name, read, lstat });
166
165
  const reader = READER_PAIR.map(survey);
167
166
  const checker = CHECKER_PAIR.map(survey);
@@ -31,7 +31,7 @@ export const ENSURE_TOKENS = Object.freeze([
31
31
  'already-present',
32
32
  'customized-preserved',
33
33
  'malformed-preserved',
34
- 'skipped-no-node',
34
+ 'skipped-no-node-evidence',
35
35
  'old-adr-layout-migration-instructed',
36
36
  'failed',
37
37
  ]);
@@ -44,6 +44,9 @@ export const FAILURE_CAUSES = Object.freeze([
44
44
  'template-unreadable',
45
45
  'bundle-unreadable',
46
46
  'adr-layout-unverifiable',
47
+ // A Node probe (package.json, a kit-seeded script) failed with anything but ENOENT: whether Node runs
48
+ // here is unproven, so the ensures that place Node scripts write nothing (contract: kit/node-evidence).
49
+ 'node-evidence-unverifiable',
47
50
  'wrong-node-kind',
48
51
  'write-refused',
49
52
  'unexpected-error',
@@ -75,7 +78,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
75
78
  'customized-preserved',
76
79
  'malformed-preserved',
77
80
  'already-present',
78
- 'skipped-no-node',
81
+ 'skipped-no-node-evidence',
79
82
  'old-adr-layout-migration-instructed',
80
83
  'failed',
81
84
  ]);
@@ -28,7 +28,7 @@ import { parseSemver, compareSemver } from './semver-lite.mjs';
28
28
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
29
29
  import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
30
30
  import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
31
- import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
31
+ import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
32
32
  // The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
33
33
  // settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
34
34
  import { loadConfig } from './orchestration-config.mjs';
@@ -52,9 +52,10 @@ import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mj
52
52
  // writer, which pulls in the atomic-write core) so the status survey stays a pure reader.
53
53
  import { settingsSnapshot } from './bridge-settings-read.mjs';
54
54
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
55
- // The cheap-agents writer's own bundle reader + placement planner reused by the settings survey
56
- // (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
57
- import { readBundledAgents, planPlacement } from './cheap-agents.mjs';
55
+ // The cheap-agents READ core's bundle reader, placement planner and executor-vehicle survey
56
+ // reused by the settings survey (one implementation, never a drifting copy; the read core imports
57
+ // only node builtins, no cycle).
58
+ import { readBundledAgents, planPlacement, surveyExecutorVehicle, EXECUTOR_VEHICLE, assertDirSafe, readFsDeps, CLAUDE_DIR, AGENTS_DIR } from './cheap-agents-read.mjs';
58
59
  // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
59
60
  // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
60
61
  // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
@@ -82,6 +83,8 @@ import {
82
83
  import { detectSurface } from './surface.mjs';
83
84
  import { toViewModel } from './view-model.mjs';
84
85
  import { render } from './renderers.mjs';
86
+ // The feature-spec adoption state (contract: kit/spec-adoption) — read-only leaves, no cycle.
87
+ import { ADOPTION, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
85
88
 
86
89
  // ── manifestState values — re-export the EXACT public subset family-registry exported before B1 ─────
87
90
  // (the 7 state constants + DISPLAY_NAMES) so every existing importer (uninstall.mjs, the test suites)
@@ -428,9 +431,33 @@ const surveyAdrLayout = (dir, deps) => {
428
431
  }
429
432
  };
430
433
 
434
+ // The spec-adoption survey, LENIENT for the read-only view — two facts, each failing on its own: a
435
+ // survey that throws becomes the `unreadable` state with its reason, and an ack read that throws
436
+ // keeps the store state and carries `declineError` beside `declined: false` — the status line still
437
+ // renders, never a crash, never a silent "not adopted" and never a decline it could not read.
438
+ const surveySpecs = (dir, deps) => {
439
+ const survey = (() => {
440
+ try {
441
+ return surveySpecAdoption(dir, deps);
442
+ } catch (err) {
443
+ return { state: ADOPTION.UNREADABLE, live: 0, draft: 0, reason: localizeError(err) };
444
+ }
445
+ })();
446
+ const decline = (() => {
447
+ if (survey.state === ADOPTION.ADOPTED) return { declined: false, declineError: null };
448
+ try {
449
+ return { declined: readDeclineAck(dir, deps), declineError: null };
450
+ } catch (err) {
451
+ return { declined: false, declineError: localizeError(err) };
452
+ }
453
+ })();
454
+ return { state: survey.state, live: survey.live, draft: survey.draft, reason: survey.reason, ...decline };
455
+ };
456
+
431
457
  // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
432
- // docs/ai/ exists, the ADR-store layout, and whether the hidden-mode fence is present. Pure (fs reads
433
- // only, all injectable), no git subprocess — the read-only `status` view must never mutate or spawn.
458
+ // docs/ai/ exists, the ADR-store layout, the spec-adoption state, and whether the hidden-mode fence is
459
+ // present. Pure (fs reads only, all injectable), no git subprocess — the read-only `status` view must
460
+ // never mutate or spawn.
434
461
  export const surveyProject = (projectDir, deps = {}) => {
435
462
  const exists = deps.exists ?? existsSync;
436
463
  const dir = resolve(projectDir);
@@ -445,7 +472,7 @@ export const surveyProject = (projectDir, deps = {}) => {
445
472
  }
446
473
  })();
447
474
  const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
448
- return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
475
+ return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), specs: surveySpecs(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
449
476
  };
450
477
 
451
478
  // ── report ───────────────────────────────────────────────────────────────────────
@@ -460,9 +487,6 @@ export const surveyProject = (projectDir, deps = {}) => {
460
487
  // consumes THIS, never the human table verbatim. An envelope-shape test pins its shape so later phases
461
488
  // (the settings/visibility block) can't silently break the Phase-2 version consumer.
462
489
 
463
- // STATE_PUBLIC (internal→public token map) + DISPLAY_NAMES + displayOf now live in labels.mjs (B1) —
464
- // imported at the top of this file. They are used below exactly as before.
465
-
466
490
  // ── the settings survey (Phase 3) — read-only, honest, localized-on-error ──────────
467
491
  // Each sub-survey returns a small user-safe object OR a single `{ error }` field (a localized message,
468
492
  // never a crash): a malformed/unreadable file in ONE area must not break the rest of `status`. The
@@ -501,19 +525,21 @@ export const surveyVisibility = (dir, deps = {}) => {
501
525
  };
502
526
 
503
527
  // orchestration recipes: the EFFECTIVE recipe per slot (config · default · effective), engine-free —
504
- // shared loadConfig + resolveActivityRecipe + the read-only backend detector. A malformed config → a
505
- // localized error field; a detection failure floors at solo (a corrupt bridge must not break the view).
528
+ // shared loadConfig + resolveActivityRecipe + the ONE readiness composition (detected backends +
529
+ // the executor vehicle). A malformed config a localized error field; a detection failure floors
530
+ // at solo (a corrupt bridge must not break the view) but is surfaced as `detectError`, so the
531
+ // render says "couldn't check backends" instead of letting a real solo-default look identical.
506
532
  export const surveyRecipes = (dir, deps = {}) => {
507
- // A detector failure floors recipes at solo (mirrors procedures) but is surfaced as `detectError`, so
508
- // the render says "couldn't check backends" instead of letting a real solo-default look identical.
509
533
  const { detection, error: detectError } = detectSafe(deps);
534
+ const projectDir = resolve(dir);
535
+ const readiness = composeReadiness(projectDir, { ...deps, detect: () => detection });
510
536
  try {
511
- const { config, source } = loadConfig(resolve(dir), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
537
+ const { config, source } = loadConfig(projectDir, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
512
538
  const activities = {};
513
539
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
514
540
  activities[activity] = {};
515
541
  for (const slot of Object.keys(def.slots)) {
516
- const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
542
+ const r = resolveActivityRecipe({ config: config ?? {}, readiness, activity, slot });
517
543
  activities[activity][slot] = { recipe: r.recipe, source: r.source, degradedFrom: r.degradedFrom ?? null };
518
544
  }
519
545
  }
@@ -590,14 +616,34 @@ export const surveyGateHook = (dir, deps = {}) => {
590
616
  };
591
617
 
592
618
  // cheap agents: the kit-placed .claude/agents/ vehicles (Mode: agents) — how many of the bundled
593
- // cheap-lane definitions are present in the project. A customized copy counts as PLACED (it exists;
594
- // the writer preserves it) — the welcome-mat agents rung keys on zero placed. Read-only; REUSES the
595
- // writer's own bundle reader + placement planner (cheap-agents.mjs one implementation).
619
+ // definitions are present in the project, plus the ONE full-tool vehicle's own state. A customized
620
+ // copy counts as PLACED (it exists; the writer preserves it) — the welcome-mat agents rung keys on
621
+ // zero placed, so the counts span every vehicle, the executor included. The executor state comes
622
+ // from the read core's survey (the subagent carrier's one instrument — one implementation), which
623
+ // answers a state instead of throwing; its reason rides along when it carries one.
596
624
  export const surveyCheapAgents = (dir, deps = {}) => {
597
625
  try {
626
+ const projectDir = resolve(dir);
598
627
  const templates = readBundledAgents(deps);
599
- const plan = planPlacement(templates, resolve(dir), deps);
600
- return { bundled: templates.length, placed: plan.filter((p) => p.action !== 'place').length };
628
+ const vehicle = (deps.surveyVehicle ?? surveyExecutorVehicle)(projectDir, deps);
629
+ // The executor is judged by its own survey (which answers a state where the placement plan
630
+ // would throw); the plan covers the read-only vehicles, so a broken executor never hides them,
631
+ // and it never follows a symlinked ancestor — the writer's own guard refuses first.
632
+ const executor = { executor: vehicle.state, ...(vehicle.reason ? { executorReason: safeLine(vehicle.reason) } : {}) };
633
+ try {
634
+ const fs = readFsDeps(deps);
635
+ assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
636
+ assertDirSafe(join(projectDir, AGENTS_DIR), AGENTS_DIR, fs);
637
+ const plan = planPlacement(templates.filter((t) => t.name !== EXECUTOR_VEHICLE), projectDir, deps);
638
+ return {
639
+ bundled: templates.length,
640
+ readOnly: templates.filter((t) => t.name !== EXECUTOR_VEHICLE).length,
641
+ placed: plan.filter((p) => p.action !== 'place').length + (['placed', 'customized'].includes(vehicle.state) ? 1 : 0),
642
+ ...executor,
643
+ };
644
+ } catch (err) {
645
+ return { error: localizeError(err), ...executor };
646
+ }
601
647
  } catch (err) {
602
648
  return { error: localizeError(err) };
603
649
  }
@@ -683,6 +729,9 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
683
729
  deployed: project.deployed,
684
730
  docsAi: project.docsAiPresent,
685
731
  adrLayout: project.adrLayout, // 'old' | 'old-unrotated' | 'migrated' | 'none' — a user-safe token, never a raw path
732
+ // { state: not-adopted | adopting | adopted | unreadable, live, draft, reason, declined, declineError }
733
+ // — an envelope predating the field omits it (the view-model reads that as unknown, never as a state).
734
+ ...(project.specs ? { specs: project.specs } : {}),
686
735
  // member + display + version only — never the internal stamp FILENAME (s.file).
687
736
  deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
688
737
  };
@@ -29,8 +29,7 @@ import {
29
29
  resolveReceiptsPath, readReceipts, computeTreeFingerprint,
30
30
  } from './core-evidence.mjs';
31
31
  import { loadConfig } from './orchestration-config.mjs';
32
- import { requiredBackendsForConfiguredRecipe, DISPLAY_ALIASES } from './recipes.mjs';
33
- import { detectBackends } from './detect-backends.mjs';
32
+ import { requiredBackendsForConfiguredRecipe, DISPLAY_ALIASES, composeReadiness } from './recipes.mjs';
34
33
  import { decideFlowCheck } from './flow-check-cores.mjs';
35
34
  import { short } from './flow-check-rungs.mjs';
36
35
  import {
@@ -116,11 +115,7 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
116
115
  let readiness = [];
117
116
  let detectionFailed = false;
118
117
  if (configFailure == null && config?.['plan-execution']?.review == null) {
119
- try {
120
- readiness = detectBackends();
121
- } catch {
122
- detectionFailed = true;
123
- }
118
+ readiness = composeReadiness(top, { ...probes, onDetectError: () => { detectionFailed = true; } });
124
119
  }
125
120
  const obligations = configFailure == null
126
121
  ? requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed })
@@ -86,10 +86,14 @@ export const KNOWN_PRIOR_METHODOLOGY_SLOT = [
86
86
  '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe.',
87
87
  // engine 2.1.0 — the pre-canon-rewrite pointer (vocabulary + plan-then-execute wording, with the communication contract).
88
88
  '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
89
+ // engine 4.2.0 — the two-activity pointer, before `routine` joined the named activities.
90
+ '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. The plan shape, its caps and lifecycle live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe. **Communication:** user-facing messages deliver the artifact inline (paste the prompt / diff / command — never "see §X" as a substitute), lead with the result, show exactly what was asked, and never read as mockery (a large artifact: a real summary inline + a link).',
89
91
  ];
90
92
  export const KNOWN_PRIOR_ORCH_SLOT = [
91
93
  // v1.3.0 — pre-read-at-start orchestration pointer (recipes vocabulary, no orchestration.json clause).
92
94
  '> **Orchestration recipes** — compose plan → execute → review with a named recipe: **Solo** (no backend), **Reviewed** (one backend reviews), **Council** (both review, you synthesize), **Delegated** (a backend executes a bounded sub-task); the orchestrator always commits, a backend is never autonomous. Pick + plan one for this environment with `/agent-workflow-kit recipes` (read-only); the deployed how/why lives in your `docs/ai/` workflow docs.',
95
+ // engine 4.2.0 — the four-recipe pointer with the read-at-start clause, before the Subagent carrier joined the list.
96
+ '> **Orchestration recipes** — compose plan → execute → review with a named recipe: **Solo** (no backend), **Reviewed** (one backend reviews), **Council** (both review, you synthesize), **Delegated** (a backend executes a bounded sub-task); the orchestrator always commits, a backend is never autonomous. Pick + plan one for this environment with `/agent-workflow-kit recipes` (read-only); the deployed how/why lives in your `docs/ai/` workflow docs. At the start of a planning/execution session, read your standing recipe preference in `docs/ai/orchestration.json` — set it in plain language with `/agent-workflow-kit set-recipe` (previews first; hand-edit stays supported).',
93
97
  ];
94
98
 
95
99
  // A slot descriptor bundles everything the generic engine needs to operate on ONE marker pair.
@@ -62,7 +62,10 @@ Apply this as part of §2 before any user-facing summary:
62
62
  - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
63
63
  - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
64
64
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
65
- export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE];
65
+ // The canon that shipped between the closing-state-block contract and the contradicted-skip bullet.
66
+ const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
67
+ - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
68
+ export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
66
69
 
67
70
  const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
68
71
  const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);