@sabaiway/agent-workflow-kit 10.3.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.
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
51
51
  import { deriveDoctorPlan } from './autonomy-doctor.mjs';
52
52
  import { detectBackends, findOnPath } from './detect-backends.mjs';
53
53
  import { isDirectRun } from './direct-run.mjs';
54
- import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
+ import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
55
55
  import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
56
56
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
57
57
  import { shellQuoteArg } from './review-state.mjs';
@@ -82,7 +82,9 @@ import { loadConfig } from './orchestration-config.mjs';
82
82
  import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
83
83
  import { assertContainedRealPath } from './fs-safe.mjs';
84
84
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
- import { preflightCheapAgents } from './cheap-agents.mjs';
85
+ import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
+ // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
87
+ import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
86
88
  // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
87
89
  // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
88
90
  import {
@@ -176,6 +178,10 @@ export const SEVERITIES = Object.freeze({
176
178
  'mcp-channel.masked': SEVERITY_OPTIONAL,
177
179
  'mcp-channel.differing': SEVERITY_ATTENTION,
178
180
  agents: SEVERITY_OPTIONAL,
181
+ // The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
182
+ // names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
183
+ // is broken, which is what attention means.
184
+ 'executor-vehicle': SEVERITY_ATTENTION,
179
185
  'family-freshness': SEVERITY_ATTENTION,
180
186
  'adr-store-migration': SEVERITY_ATTENTION,
181
187
  'sandbox-masks': SEVERITY_OPTIONAL,
@@ -250,7 +256,8 @@ export const WHATS = Object.freeze({
250
256
  'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
251
257
  'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
252
258
  'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
253
- agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
259
+ agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
260
+ 'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
254
261
  'family-freshness': '{parts}',
255
262
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
256
263
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
@@ -309,7 +316,8 @@ export const BENEFITS = Object.freeze({
309
316
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
310
317
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
311
318
  'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
312
- agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
319
+ agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
320
+ 'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
313
321
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
314
322
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
315
323
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
@@ -367,6 +375,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
367
375
  // which is why the review-recipe benefit is worded for either slot rather than for review alone.
368
376
  { id: 'delegated-execution', mode: 'set-recipe', advisorKey: 'review-recipe' },
369
377
  { id: 'agents', mode: 'agents', advisorKey: 'agents' },
378
+ // A DISTINCT capability from the offer above: the offer converges the moment nothing is left to
379
+ // PLACE, which a customized-but-unusable executor also satisfies — so it can never observe the
380
+ // state that makes a configured subagent carrier run solo.
381
+ { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
370
382
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
371
383
  // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
372
384
  // same doctrine that keeps sandbox network/filesystem allowances HAND-APPLY); velocity presents the
@@ -477,11 +489,12 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
477
489
  // The VALIDATED reader (Segment B): a schema-invalid config (unknown activity/slot,
478
490
  // bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
479
491
  const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
480
- const detection = detectBackends(deps);
492
+ const readiness = composeReadiness(root, { ...deps, detect: deps.detect ?? (() => detectBackends(deps)), onDetectError: (err) => { throw err; } });
481
493
  const degraded = [];
482
494
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
483
495
  for (const slot of Object.keys(def.slots)) {
484
- const r = resolveActivityRecipe({ config, readiness: detection, activity, slot });
496
+ if (config?.[activity]?.[slot] === 'subagent') continue; // the executor vehicle has its own probe
497
+ const r = resolveActivityRecipe({ config, readiness, activity, slot });
485
498
  if (r.degradedFrom) degraded.push(`${activity}.${slot}: configured ${r.degradedFrom} degrades to ${r.recipe} (${r.reason})`);
486
499
  }
487
500
  }
@@ -952,11 +965,9 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
952
965
  // skipped the per-vehicle plan the user is supposed to see before consenting.
953
966
  // The hidden-mode reconcile rides the detail, never the apply line: it is wrong to run on a
954
967
  // VISIBLE deployment, and the apply slot must stay one pure executable command.
955
- add(
956
- 'agents',
957
- fillTemplate(WHATS.agents, { n: toPlace.length }),
958
- `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`,
959
- 'agents',
968
+ const executors = toPlace.filter((item) => item.name === EXECUTOR_VEHICLE).length;
969
+ add('agents', fillTemplate(WHATS.agents, { n: toPlace.length, ex: executors, ro: toPlace.length - executors }),
970
+ `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`, 'agents',
960
971
  `hidden-mode deployments only: after the --apply the preview prints, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
961
972
  );
962
973
  } catch (err) {
@@ -964,6 +975,60 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
964
975
  }
965
976
  };
966
977
 
978
+ // The subagent carrier's ONE instrument (contract: kit/carriers). Neither neighbour can report this
979
+ // state: `probeReviewRecipe` skips a slot configured `subagent` by construction, and the offer above
980
+ // judges only what is left to PLACE — a customized-but-broken vehicle leaves nothing to place. So a
981
+ // project could declare the carrier and have every such slot run solo, unseen.
982
+ const VEHICLE_BROKEN_STATES = Object.freeze(['missing', 'unusable']);
983
+
984
+ const writerBlock = (root, deps) => {
985
+ try {
986
+ preflightCheapAgents({ cwd: root }, deps);
987
+ return null;
988
+ } catch (err) {
989
+ return safeLine(err?.message ?? String(err));
990
+ }
991
+ };
992
+
993
+ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
994
+ try {
995
+ // The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
996
+ // never an item computed over a shape nothing accepted.
997
+ const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
998
+ const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
999
+ Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
1000
+ if (configured.length === 0) return;
1001
+ const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
1002
+ if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
1003
+ const reason = safeLine(survey.reason ?? '');
1004
+ const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
1005
+ const preconditions = [
1006
+ ...(stamp === EXPECTED_WORKFLOW_VERSION ? [] : [`run /agent-workflow-kit upgrade first (deployment stamp ${stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`]),
1007
+ ...(survey.state === 'unusable' ? [`${reason || 'the vehicle file is unusable'} — fix that`] : []),
1008
+ ];
1009
+ // The writer refuses on ANY vehicle path it cannot touch (a symlinked read-only vehicle blocks a
1010
+ // missing executor's placement too), so its own preflight is the last precondition — named once.
1011
+ const blocked = writerBlock(root, deps);
1012
+ if (blocked && blocked !== reason) preconditions.push(`${blocked} — fix that`);
1013
+ const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
1014
+ add(
1015
+ 'executor-vehicle',
1016
+ fillTemplate(WHATS['executor-vehicle'], {
1017
+ n: configured.length,
1018
+ state: survey.state,
1019
+ reason: reason && room > 0 ? `: ${truncatedTo(reason, room)}` : '',
1020
+ }),
1021
+ // The writer places a MISSING vehicle; an unusable path (a symlink, a read-only customization)
1022
+ // is kept or refused, so that state's apply is a hand-apply precondition before the writer.
1023
+ `${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1024
+ 'executor-vehicle',
1025
+ `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1026
+ );
1027
+ } catch (err) {
1028
+ skip('executor-vehicle', err);
1029
+ }
1030
+ };
1031
+
967
1032
  const probeFamilyFreshness = ({ deps, add, skip }) => {
968
1033
  try {
969
1034
  const survey = deps.surveyFamily ?? surveyFamily;
@@ -1485,6 +1550,7 @@ const PROBES = Object.freeze([
1485
1550
  probeReadLane,
1486
1551
  probeStateBlockHook,
1487
1552
  probeCheapAgents,
1553
+ probeExecutorVehicle,
1488
1554
  probeFamilyFreshness,
1489
1555
  probeAdrStore,
1490
1556
  probeMasksItem,
@@ -20,6 +20,10 @@ const SETTINGS_COL = 14;
20
20
  // checks, so a future token joins the render by joining this line.
21
21
  const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
22
22
 
23
+ const DEGRADE_ARROW = '←';
24
+ const EMPTY_CELL = '—';
25
+ const UNKNOWN_EXECUTOR = 'unknown';
26
+
23
27
  const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
24
28
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
25
29
  export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
@@ -116,13 +120,22 @@ const renderSettings = (vm, { color, glyph }) => {
116
120
  const s = vm.project?.settings;
117
121
  if (!s) return [];
118
122
  const lines = ['', heading(BLOCK_TITLES.settings, color)];
119
- // recipes — the effective recipe per slot, or a loud error; a detector floor adds a sub-line.
123
+ // recipes — one line per slot: the effective recipe, where it came from, and the requested value
124
+ // a degrade replaced (a joined single line could not carry three activities and their slots), or a
125
+ // loud error; a detector floor adds a sub-line.
120
126
  if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
121
127
  else if (s.recipes) {
122
- const joined = s.recipes.pairs.map((p) => `${p.key}=${p.recipe}`).join(' · ') || '—';
123
- lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}${joined}`);
128
+ const rows = s.recipes.pairs.map((p) => {
129
+ const source = p.source ? ` (${p.source})` : '';
130
+ const recovery = p.degradedFrom === 'subagent' ? ' — runs solo until the executor vehicle is usable (/agent-workflow-kit agents)' : '';
131
+ const degraded = p.degradedFrom ? ` ${DEGRADE_ARROW} degraded from ${p.degradedFrom}${recovery}` : '';
132
+ return `${p.key}: ${p.recipe}${source}${degraded}`;
133
+ });
134
+ (rows.length ? rows : [EMPTY_CELL]).forEach((row, i) => {
135
+ lines.push(` ${pad(i === 0 ? SETTINGS_LABELS.recipes : '', SETTINGS_COL)}${row}`);
136
+ });
124
137
  if (s.recipes.detectError) {
125
- lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
138
+ lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); bridge-backed recipes floored at solo; the executor vehicle is unaffected`);
126
139
  }
127
140
  }
128
141
  // attribution — effective includeCoAuthoredBy; a real local override is called out.
@@ -136,10 +149,17 @@ const renderSettings = (vm, { color, glyph }) => {
136
149
  else if (s.velocity) {
137
150
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
138
151
  }
139
- // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
140
- if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
152
+ // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle, then the
153
+ // split the subagent carrier turns on — the read-only vehicles and the ONE executor, whose state
154
+ // decides whether a slot configured `subagent` can ride it (an unusable one carries its reason).
155
+ if (s.agents?.error) {
156
+ const partial = s.agents.executor ? ` — executor ${s.agents.executor}${s.agents.executorReason ? ` (${s.agents.executorReason})` : ''}` : '';
157
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}${partial}`);
158
+ }
141
159
  else if (s.agents) {
142
- lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
160
+ const reason = s.agents.executorReason ? ` (${s.agents.executorReason})` : '';
161
+ const executor = s.agents.executor == null ? `${UNKNOWN_EXECUTOR} (the installed kit predates the field)` : `${s.agents.executor}${reason}`;
162
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}${s.agents.placed}/${s.agents.bundled} placed — ${s.agents.readOnly} read-only, executor ${executor}`);
143
163
  }
144
164
  // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
145
165
  // declared gate count (null → '?' — unknown is shown as unknown, never as a number).
@@ -93,9 +93,9 @@ import { join, dirname } from 'node:path';
93
93
  import { fileURLToPath } from 'node:url';
94
94
  import { spawnSync } from 'node:child_process';
95
95
  import { createHash } from 'node:crypto';
96
- import { detectBackends, READY } from './detect-backends.mjs';
96
+ import { detectBackends } from './detect-backends.mjs';
97
97
  import { isDirectRun } from './direct-run.mjs';
98
- import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
98
+ import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
99
99
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
100
100
  import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
101
101
  import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
@@ -309,16 +309,16 @@ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
309
309
  // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
310
310
  // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
311
311
  // flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
312
- export const buildState = ({ cwd, env = process.env, detect = detectBackends, lstat = lstatSync, readFile = readFileSync } = {}) => {
312
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
313
313
  const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
314
314
  const { config, source: configSource } = loadConfig(root);
315
- let detection = [];
315
+ // A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
316
+ // executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
316
317
  let detectionWarning = null;
317
- try {
318
- detection = detect();
319
- } catch (err) {
320
- detectionWarning = `backend detection failed (${(err && err.message) || err}) — readiness unknown.`;
321
- }
318
+ const onDetectError = (err) => {
319
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
320
+ };
321
+ const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
322
322
  // The resolver stays for DISPLAY/diagnostics only; the OBLIGATIONS come from the configured
323
323
  // recipe (never the readiness-degraded effective one — no silent solo).
324
324
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
@@ -439,7 +439,6 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, ls
439
439
  degradedExempt,
440
440
  maskedUntracked: countNeverCommittableUntracked(cwd, { lstat }),
441
441
  detectionWarning,
442
- anyReviewerReady: detection.some((b) => b.readiness === READY),
443
442
  flowPresent,
444
443
  flowArmed,
445
444
  flowBrokenReason,
@@ -733,7 +732,7 @@ export const main = (argv, ctx = {}) => {
733
732
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
734
733
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
735
734
  if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
736
- const state = buildState({ cwd, env, detect, lstat: ctx.lstat, readFile: ctx.readFile });
735
+ const state = buildState({ cwd, env, detect, surveyVehicle: ctx.surveyVehicle, lstat: ctx.lstat, readFile: ctx.readFile });
737
736
  const check = decideCheck(state);
738
737
  // The mask advisory is NON-FAILING by contract: one notice line, never an exit-code arm.
739
738
  const advisory = maskAdvisoryLine(state);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // set-recipe.mjs — the WRITER for docs/ai/orchestration.json. The division of labor (AD-025): the AGENT
3
- // turns plain language into explicit `--set <activity>.<slot>=<recipe>` / `--unset <activity>.<slot>`
3
+ // turns plain language into explicit `--set <activity>.<slot>=<value>` / `--unset <activity>.<slot>`
4
4
  // ops; the KIT does the deterministic validate → merge → preview → write. The kit ships NO NL parser
5
5
  // (stays dependency-free + deterministic) and performs no `all`-magic — the agent expands "both review"
6
6
  // into explicit per-activity ops (asking if scope is unclear).
@@ -17,15 +17,25 @@
17
17
  // language when narrating. Exit codes: 0 success (an explicit recipe that gracefully degrades is still
18
18
  // 0); 2 usage (bad/duplicate op, --write with zero ops); 1 config error (malformed/unreadable config)
19
19
  // or a write STOP (no deployment / symlinked leaf). main(argv, ctx) → { code, stdout, stderr }; cwd /
20
- // env / home / detect / fs are injectable for host-independent tests.
20
+ // env / home / detect / surveyVehicle / fs are injectable for host-independent tests.
21
+ //
22
+ // It writes every slot of every activity in the registry (carriers.mjs, via recipes.mjs) — the
23
+ // carrier slots and the `routine.parallel` switch included — and resolves each preview against the
24
+ // SAME readiness the recipes advisor composes: detected backends plus the executor-vehicle survey.
21
25
  //
22
26
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
23
27
 
24
28
  import { readFileSync, lstatSync } from 'node:fs';
25
29
  import { homedir } from 'node:os';
26
- import { detectBackends } from './detect-backends.mjs';
27
30
  import { isDirectRun } from './direct-run.mjs';
28
- import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
31
+ import {
32
+ ACTIVITIES,
33
+ SLOT_RECIPES,
34
+ EXECUTOR_APPLY,
35
+ composeReadiness,
36
+ resolveActivityRecipe,
37
+ composeActiveRecipeLine,
38
+ } from './recipes.mjs';
29
39
  import { loadAutonomy, resolveAutonomy } from './autonomy-config.mjs';
30
40
  import {
31
41
  CONFIG_REL,
@@ -35,6 +45,7 @@ import {
35
45
  parseOp,
36
46
  applySetOps,
37
47
  serializeConfig,
48
+ refreshReadme,
38
49
  CANON_README,
39
50
  } from './orchestration-config.mjs';
40
51
  import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
@@ -50,7 +61,7 @@ const parseArgs = (argv) => {
50
61
  let write = false;
51
62
  let json = false;
52
63
  const takeOp = (kind, tok) => {
53
- if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<recipe>' : ''}`);
64
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<value>' : ''}`);
54
65
  const op = parseOp(kind, tok);
55
66
  const key = `${op.activity}.${op.slot}`;
56
67
  if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
@@ -74,12 +85,24 @@ const parseArgs = (argv) => {
74
85
 
75
86
  // ── effective-recipe resolution per op (degradation honesty) ────────────────────────
76
87
 
88
+ // The readiness EVERY resolution here runs against: the detected bridges plus the executor-vehicle
89
+ // survey, composed by the one helper the recipes CLI uses. Detection is a SECONDARY input — a bridge
90
+ // detector throw must NOT block the write (the config write is readiness-independent) and must not
91
+ // cost the CARRIER either: the hook warns, the bridge half floors at not-ready, the vehicle survives.
92
+ const composeReadinessOrWarn = (cwd, deps, warnings) =>
93
+ composeReadiness(cwd, {
94
+ ...deps,
95
+ onDetectError: (err) => warnings.push(
96
+ `backend detection failed (${(err && err.message) || err}) — treating every bridge as not ready; recipes needing a bridge degrade to solo (the executor vehicle is unaffected).`,
97
+ ),
98
+ });
99
+
77
100
  // A single op's before/after value + the effective recipe it resolves to here (vs live readiness).
78
101
  // `to` is null for an unset (falls to the computed default). degradedFrom/reason carry the honesty.
79
- const resolveOp = (op, current, after, detection) => {
102
+ const resolveOp = (op, current, after, readiness) => {
80
103
  const from = current?.[op.activity]?.[op.slot] ?? null;
81
104
  const to = after?.[op.activity]?.[op.slot] ?? null;
82
- const r = resolveActivityRecipe({ config: after ?? {}, readiness: detection, activity: op.activity, slot: op.slot });
105
+ const r = resolveActivityRecipe({ config: after ?? {}, readiness, activity: op.activity, slot: op.slot });
83
106
  return { activity: op.activity, slot: op.slot, from, to, effective: r.recipe, degradedFrom: r.degradedFrom, reason: r.reason };
84
107
  };
85
108
 
@@ -127,19 +150,40 @@ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine
127
150
  activeLine: activeLine ?? null,
128
151
  });
129
152
 
153
+ // The writable surface, rendered FROM the registry — never re-typed as literals, so an activity, a
154
+ // slot or an accepted value added to the table shows up in the help (and in the doc that quotes it).
155
+ const ACTIVITY_LINES = Object.entries(ACTIVITIES)
156
+ .map(([activity, def]) => ` ${activity} → ${Object.keys(def.slots).join(', ')}`)
157
+ .join('\n');
158
+
159
+ const VALUE_LINES = Object.entries(SLOT_RECIPES)
160
+ .map(([slotType, values]) => ` ${slotType} slots accept ${values.join(' | ')}`)
161
+ .join('\n');
162
+
163
+ const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
164
+ .flatMap(([activity, def]) => Object.keys(def.slots).map((slot) => `${activity}.${slot}`))
165
+ .join(', ');
166
+
130
167
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
131
168
 
132
169
  Usage:
133
- node set-recipe.mjs [--set <activity>.<slot>=<recipe>]... [--unset <activity>.<slot>]... [--write] [--json]
170
+ node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]... [--write] [--json]
134
171
 
135
- --set <activity>.<slot>=<recipe> pin a recipe (fully-qualified; e.g. plan-authoring.review=council)
172
+ --set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
136
173
  --unset <activity>.<slot> return a slot to its computed default
137
174
  --write apply the change (default: preview only — writes nothing)
138
175
  --json machine-readable output
139
176
  --help, -h this help
140
177
 
141
- Activities/slots: plan-authoring review; plan-execution → execute, review
142
- Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
178
+ Activities and their slots:
179
+ ${ACTIVITY_LINES}
180
+
181
+ Accepted values per slot type:
182
+ ${VALUE_LINES}
183
+
184
+ A carrier slot set to subagent needs the executor vehicle placed in this project — ${EXECUTOR_APPLY};
185
+ without it the slot resolves to solo with the reason stated. routine.parallel is a flag, not a
186
+ recipe: it never degrades.
143
187
 
144
188
  Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
145
189
  Config writer only: it NEVER runs a backend and NEVER commits. Hand-editing the file stays fully supported.
@@ -152,7 +196,7 @@ Exit codes: 0 success (an explicit recipe that gracefully degrades is still 0);
152
196
 
153
197
  export const main = (argv, ctx = {}) => {
154
198
  const cwd = ctx.cwd ?? process.cwd();
155
- const detect = ctx.detect ?? detectBackends;
199
+ const readinessDeps = { detect: ctx.detect, surveyVehicle: ctx.surveyVehicle };
156
200
  const readFile = ctx.readFileSync ?? readFileSync;
157
201
  const lstat = ctx.lstatSync ?? lstatSync;
158
202
  const writeConfig = ctx.writeConfig ?? writeConfigFs;
@@ -170,23 +214,18 @@ export const main = (argv, ctx = {}) => {
170
214
  return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], warnings: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
171
215
  }
172
216
  const shown = current == null ? `(no ${CONFIG_REL} yet — computed defaults apply)` : serializeConfig(current).replace(/\n$/, '');
173
- const hint = `\nPass --set <activity>.<slot>=<recipe> (preview) then --write to apply. Activities/slots: plan-authoring.review, plan-execution.execute, plan-execution.review.`;
217
+ const hint = `\nPass --set <activity>.<slot>=<value> (preview) then --write to apply. Activities/slots: ${QUALIFIED_SLOTS}.`;
174
218
  return { code: 0, stdout: `${source === 'none' ? '' : `${CONFIG_REL}:\n`}${shown}${hint}`, stderr: '' };
175
219
  }
176
220
 
177
- const after = applySetOps(current, ops, { seedReadme: CANON_README });
221
+ // The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
222
+ // is replaced by the current one on a touched write, while a customized note stays untouched.
223
+ const after = refreshReadme(applySetOps(current, ops, { seedReadme: CANON_README })).config;
178
224
 
179
- // Detection is a SECONDARY input — it only refines the EFFECTIVE recipe note. A throw must NOT block
180
- // the write (the config write is readiness-independent): treat all backends as not-ready, warn, exit 0.
181
225
  const warnings = [];
182
- let detection = [];
183
- try {
184
- detection = detect();
185
- } catch (err) {
186
- warnings.push(`backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`);
187
- }
226
+ const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
188
227
 
189
- const resolved = ops.map((op) => resolveOp(op, current, after, detection));
228
+ const resolved = ops.map((op) => resolveOp(op, current, after, readiness));
190
229
  const changed = resolved.filter((e) => e.from !== e.to);
191
230
  const unchanged = resolved.filter((e) => e.from === e.to);
192
231
  const noop = changed.length === 0;
@@ -220,7 +259,7 @@ export const main = (argv, ctx = {}) => {
220
259
  return { error: (err && err.message) || String(err) };
221
260
  }
222
261
  })();
223
- const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection, autonomyFacts);
262
+ const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, readiness, autonomyFacts);
224
263
  const stdout = json
225
264
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
226
265
  : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
@@ -54,7 +54,11 @@ const recipesVm = (r) => {
54
54
  if (r.error) return { error: r.error };
55
55
  const pairs = [];
56
56
  for (const [activity, slots] of Object.entries(r.activities ?? {})) {
57
- for (const [slot, v] of Object.entries(slots)) pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe });
57
+ // source + degradedFrom ride along: an effective recipe alone cannot tell a chosen value from a
58
+ // computed default, nor a degrade from a configuration that really names the resolved recipe.
59
+ for (const [slot, v] of Object.entries(slots)) {
60
+ pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe, source: v.source ?? null, degradedFrom: v.degradedFrom ?? null });
61
+ }
58
62
  }
59
63
  return { pairs, detectError: r.detectError ?? null };
60
64
  };
@@ -76,8 +80,18 @@ const velocityVm = (v) => {
76
80
 
77
81
  const agentsVm = (a) => {
78
82
  if (!a) return null;
79
- if (a.error) return { error: a.error };
80
- return { bundled: a.bundled ?? 0, placed: a.placed ?? 0 };
83
+ if (a.error) return { error: a.error, executor: a.executor ?? null, executorReason: a.executorReason ?? null };
84
+ const bundled = a.bundled ?? 0;
85
+ return {
86
+ bundled,
87
+ placed: a.placed ?? 0,
88
+ // The envelope counts the read-only vehicles; an envelope predating the executor field bundled
89
+ // only read-only ones.
90
+ readOnly: a.readOnly ?? (a.executor == null ? bundled : Math.max(bundled - 1, 0)),
91
+ // null = an envelope predating the field (unknown), never a state.
92
+ executor: a.executor ?? null,
93
+ executorReason: a.executorReason ?? null,
94
+ };
81
95
  };
82
96
 
83
97
  const hookVm = (h) => {