@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
@@ -0,0 +1,77 @@
1
+ // node-evidence.mjs — does Node PROVABLY run in this project tree? Contract: docs/ai/specs/kit/node-evidence.md.
2
+ // Pure over an injectable lstat; no writes, no side effects on import. Dependency-free, Node >= 22.
3
+
4
+ import { lstatSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export const NODE_EVIDENCE = Object.freeze({
8
+ PACKAGE_JSON: 'package-json',
9
+ DEPLOYED_SCRIPTS: 'deployed-node-scripts',
10
+ NONE: 'none',
11
+ UNREADABLE: 'unreadable',
12
+ });
13
+
14
+ export const PACKAGE_JSON_REL = 'package.json';
15
+ export const SCRIPTS_DIR = 'scripts';
16
+
17
+ // The runnable scripts the bootstrap copies from references/scripts/ — pinned against the bundle by the suite.
18
+ export const NODE_EVIDENCE_SCRIPTS = Object.freeze([
19
+ 'archive-caps.mjs',
20
+ 'archive-changelog.mjs',
21
+ 'archive-decisions.mjs',
22
+ 'archive-issues.mjs',
23
+ 'check-docs-size.mjs',
24
+ 'install-git-hooks.mjs',
25
+ 'markdown-blocks.mjs',
26
+ 'migrate-gates.mjs',
27
+ 'spec-schema.mjs',
28
+ ]);
29
+
30
+ export const NODE_EVIDENCE_PROBES = Object.freeze([PACKAGE_JSON_REL, ...NODE_EVIDENCE_SCRIPTS.map((name) => `${SCRIPTS_DIR}/${name}`)]);
31
+
32
+ const ENOENT = 'ENOENT';
33
+ const kindOf = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : st.isFile() ? 'a regular file' : 'not a regular file');
34
+
35
+ const answer = (state, evidence, wrongKind, extra = {}) =>
36
+ Object.freeze({ state, evidence, probed: NODE_EVIDENCE_PROBES, wrongKind: Object.freeze(wrongKind), ...extra });
37
+
38
+ // probeNodeEvidence(cwd, lstat) -> { state, evidence, probed, wrongKind, error? }: the first regular file
39
+ // among the probes answers; a probe failing with anything but ENOENT answers unreadable at once; a path of
40
+ // the wrong node kind is not evidence — it is recorded in `wrongKind` and the walk continues. lstat does
41
+ // not follow the LEAF but walks THROUGH a symlinked scripts/, whose files are not this tree's — so the
42
+ // directory is proven plain before any script inside it counts.
43
+ export const probeNodeEvidence = (cwd, lstat = lstatSync) => {
44
+ const wrongKind = [];
45
+ const probeKind = (rel, wanted) => {
46
+ let st;
47
+ try {
48
+ st = lstat(join(cwd, rel));
49
+ } catch (err) {
50
+ if (err && err.code === ENOENT) return 'absent';
51
+ throw Object.assign(err, { probedRel: rel });
52
+ }
53
+ if (wanted === 'dir' ? st.isDirectory() && !st.isSymbolicLink() : st.isFile()) return wanted;
54
+ wrongKind.push(`${rel} is ${kindOf(st)}`);
55
+ return 'wrong-kind';
56
+ };
57
+ try {
58
+ if (probeKind(PACKAGE_JSON_REL, 'file') === 'file') return answer(NODE_EVIDENCE.PACKAGE_JSON, PACKAGE_JSON_REL, wrongKind);
59
+ if (probeKind(SCRIPTS_DIR, 'dir') === 'dir') {
60
+ for (const rel of NODE_EVIDENCE_PROBES.slice(1)) {
61
+ if (probeKind(rel, 'file') === 'file') return answer(NODE_EVIDENCE.DEPLOYED_SCRIPTS, rel, wrongKind);
62
+ }
63
+ }
64
+ } catch (err) {
65
+ return answer(NODE_EVIDENCE.UNREADABLE, null, wrongKind, { error: `${err.code || err.message || 'lstat failed'} on ${err.probedRel}` });
66
+ }
67
+ return answer(NODE_EVIDENCE.NONE, null, wrongKind);
68
+ };
69
+
70
+ export const hasNodeEvidence = (probe) => probe.state === NODE_EVIDENCE.PACKAGE_JSON || probe.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS;
71
+
72
+ // The sentence a skip line carries: every probe checked, and what of the wrong kind sat at any of them.
73
+ export const describeNodeProbes = (probe = null) => {
74
+ const probes = `${PACKAGE_JSON_REL} and the kit-seeded ${SCRIPTS_DIR}/ files (${NODE_EVIDENCE_SCRIPTS.join(', ')})`;
75
+ const wrong = probe?.wrongKind?.length ? ` — not evidence: ${probe.wrongKind.join('; ')}` : '';
76
+ return `${probes}${wrong}`;
77
+ };
@@ -68,7 +68,7 @@ export const assertSlotRecipe = (activity, slot, recipe, exitCode = 2) => {
68
68
  if (!(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
69
69
  throw fail(
70
70
  exitCode,
71
- `invalid recipe "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
71
+ `invalid value "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
72
72
  );
73
73
  }
74
74
  return slotType;
@@ -98,14 +98,14 @@ const parseQualified = (lhs, flag) => {
98
98
  export const parseOp = (kind, token) => {
99
99
  if (kind === 'set') {
100
100
  const eq = token.indexOf('=');
101
- if (eq <= 0) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
101
+ if (eq <= 0) throw fail(2, `--set must be <activity>.<slot>=<value> (got "${token}")`);
102
102
  const recipe = token.slice(eq + 1);
103
- if (!recipe) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
103
+ if (!recipe) throw fail(2, `--set must be <activity>.<slot>=<value> (got "${token}")`);
104
104
  const { activity, slot } = parseQualified(token.slice(0, eq), '--set');
105
105
  assertSlotRecipe(activity, slot, recipe);
106
106
  return { kind: 'set', activity, slot, recipe };
107
107
  }
108
- if (token.includes('=')) throw fail(2, `--unset takes <activity>.<slot> without a recipe (got "${token}")`);
108
+ if (token.includes('=')) throw fail(2, `--unset takes <activity>.<slot> without a value (got "${token}")`);
109
109
  const { activity, slot } = parseQualified(token, '--unset');
110
110
  assertSlot(activity, slot);
111
111
  return { kind: 'unset', activity, slot };
@@ -245,7 +245,7 @@ export const validateConfig = (config) => {
245
245
  if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
246
246
  throw fail(
247
247
  1,
248
- `${CONFIG_REL}: invalid recipe "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
248
+ `${CONFIG_REL}: invalid value "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
249
249
  );
250
250
  }
251
251
  }
@@ -361,19 +361,40 @@ export const CANON_README =
361
361
  "Per-project orchestration config: the recipe used at each step (slot) of each named activity. " +
362
362
  "Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, " +
363
363
  "previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you " +
364
- "prefer; that option never goes away. Each activity is configured independently (e.g. plan-authoring, " +
365
- "plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts " +
364
+ "prefer; that option never goes away. Three activities are configured independently, and so is each slot " +
365
+ "within them: 'plan-authoring' (slots author, review), 'plan-execution' (slots execute, review) and " +
366
+ "'routine' (slots carrier, parallel). A slot's value is a recipe: a 'review' slot accepts " +
366
367
  "solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an " +
367
- "'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default " +
368
- "below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second " +
369
- "opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's " +
370
- "line (or run `set-recipe --unset <activity>.<slot>`) to fall back to the computed default (reviewed when a " +
371
- "review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps " +
372
- "plus the recipe resolved for your environment. Strict JSON no comments.";
368
+ "'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a " +
369
+ "full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author' " +
370
+ "and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts " +
371
+ "on | off and decides whether file-disjoint subagent slices dispatch concurrently. The default below is " +
372
+ "'solo' for every recipe and carrier slot, and 'on' for the parallel switch no execution backend required. " +
373
+ "Raise a slot to reviewed or council for a second " +
374
+ "opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' " +
375
+ "needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without " +
376
+ "it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or " +
377
+ "run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review " +
378
+ "backend is ready and otherwise solo for a review slot, solo for author, execute and carrier, on for " +
379
+ "parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for " +
380
+ "your environment. Strict JSON — no comments.";
373
381
 
374
382
  export const KNOWN_PRIOR_README = [
375
383
  // v1 (pre-set-recipe) — the "Hand-edit this file — it is never written for you" note. APPEND-ONLY.
376
384
  "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Hand-edit this file — it is never written for you. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's line to fall back to the computed default (reviewed when a review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment, and pass a per-run override to change one slot just once. Strict JSON — no comments.",
385
+ // v2 (two activities, an `execute` slot without a carrier) — the note that shipped before AD-124.
386
+ "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell " +
387
+ "the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, " +
388
+ "and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option " +
389
+ "never goes away. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is " +
390
+ "each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you " +
391
+ "self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | " +
392
+ "delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no " +
393
+ "execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand " +
394
+ "off execution; those need an execution backend set up first. Remove a slot's line (or run `set-recipe --unset " +
395
+ "<activity>.<slot>`) to fall back to the computed default (reviewed when a review backend is ready, otherwise " +
396
+ "solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your " +
397
+ "environment. Strict JSON — no comments.",
377
398
  ];
378
399
 
379
400
  // refreshReadme(config) → { config, changed }: refresh ONLY the `_README` value when it normalize-
@@ -3,10 +3,10 @@
3
3
  //
4
4
  // It composes the AD-018 orchestration recipes into NAMED activities: it reads the canonical procedure
5
5
  // steps LIVE from the installed agent-workflow-engine (references/procedures.md — AD-016 live read, no
6
- // bundled mirror), reads the per-project, hand-edited config (docs/ai/orchestration.json), runs the
7
- // read-only backend detector, and prints the activity's steps VERBATIM + the resolved effective recipe
8
- // per slot (default = Reviewed-when-a-backend-is-ready, Council on request, slot-aware incl. Delegated),
9
- // plus the project's DECLARED source-size practice when it declares one (D-17 U1).
6
+ // bundled mirror), reads the per-project, hand-edited config (docs/ai/orchestration.json), composes the
7
+ // readiness every caller composes (detected backends + the executor vehicle), and prints the activity's
8
+ // steps VERBATIM + the resolved effective recipe per slot, plus the project's DECLARED source-size
9
+ // practice when it declares one (D-17 U1).
10
10
  //
11
11
  // Invariants (mirror recipes.mjs): pure-where-possible, READ-ONLY (never writes, never commits, never
12
12
  // runs a subscription CLI). The deterministic resolution lives in the kit (resolveActivityRecipe), not
@@ -26,7 +26,9 @@ import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-back
26
26
  // core only — never the writer — so this read-only advisor never imports the atomic-write core.
27
27
  import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
28
28
  import { isDirectRun } from './direct-run.mjs';
29
- import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
29
+ import { ACTIVITIES, SLOT_RECIPES, isSwitchSlot, resolveActivityRecipe, planRecipe, composeReadiness } from './recipes.mjs';
30
+ // The dispatch-form wording, from the pure (fs-free) leaf that owns it: composed here, never re-worded.
31
+ import { dispatchForm, parallelLine } from './carriers.mjs';
30
32
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
31
33
  // The plan-in-flight detector (AD-038) — imported from the plan-files.mjs LEAF (read-only fs by
32
34
  // construction); the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported.
@@ -34,19 +36,15 @@ import { plansInFlight, PLANS_REL } from './plan-files.mjs';
34
36
  // The family's ONE shell quoter for a RENDERED command operand (bare when the value is already safe,
35
37
  // single-quoted otherwise) — the same leaf eight other command renderers here read through.
36
38
  import { shellQuoteArg } from './repo-lex.mjs';
37
- // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
38
- // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
39
- // (orchestration-write.mjs) DIRECTLY — the import-split test pins the direct-import rule.
40
- // CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs, historically) keep their
41
- // import site working.
39
+ // The config schema/read core (orchestration-config.mjs, the single config contract): the reader +
40
+ // the SHARED slot/recipe validity, never the fs-writer (orchestration-write.mjs) DIRECTLY — the
41
+ // import-split test pins the direct-import rule.
42
42
  import { CONFIG_REL, fail, loadConfig, assertSlotRecipe } from './orchestration-config.mjs';
43
43
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
44
- // The flow armed-halves probe (P8): read-only store presence/adoption facts for the session-start
45
- // surface, imported from the read module that OWNS no write API — this advisor never imports the
46
- // mixed flow-store module (append API) DIRECTLY, like it never imports orchestration-write (the
47
- // import-split test pins both direct rules). The TRANSITIVE claim is now structural, not
48
- // narrated: this advisor's import closure reaches NO write-API module and the tools graph is
49
- // acyclic — pinned by test/read-graph-purity.test.mjs (FLOW-READ-GRAPH-PURITY).
44
+ // The flow armed-halves probe (P8): read-only store presence/adoption facts, from the read module
45
+ // that OWNS no write API — never the mixed flow-store module (append API) DIRECTLY, like never
46
+ // orchestration-write (the import-split test pins both direct rules; the TRANSITIVE claim is
47
+ // structural test/read-graph-purity.test.mjs pins it).
50
48
  import { resolveFlowStorePath, readFlowStore } from './flow-store-read.mjs';
51
49
  import { CHAIN_KIND } from './flow-record.mjs';
52
50
  // The declared source-size practice (D-17 U1), read through the practice's PURE READ core — never
@@ -57,18 +55,17 @@ export { CONFIG_REL };
57
55
 
58
56
  // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
59
57
 
60
- // Parse the activity's --override <slot>=<recipe> tokens into a { slot: recipe } map, validating each
58
+ // Parse the activity's --override <slot>=<value> tokens into a { slot: recipe } map, validating each
61
59
  // against the SHARED slot/recipe validity table (assertSlotRecipe — the SAME accept/reject the set-recipe
62
- // op parser uses, drift-guarded). Every malformed token is a USAGE error (exit 2): a bare `<recipe>` (no
63
- // slot), an unknown slot for the activity, an invalid recipe-for-slot, or a duplicate slot. (An override
64
- // naming a recipe whose backend merely is not `ready` is NOT a usage error — it degrades loudly at
65
- // resolution time, exit 0.) The `--override` grammar stays activity-SCOPED (the activity comes from the
66
- // CLI arg), unlike the fully-qualified `--set <activity>.<slot>=<recipe>` the writer takes.
60
+ // op parser uses, drift-guarded). Every malformed token is a USAGE error (exit 2): a bare `<recipe>`, an
61
+ // unknown slot for the activity, an invalid recipe-for-slot, or a duplicate slot. (An override naming a
62
+ // recipe whose backend merely is not `ready` is NOT a usage error — it degrades loudly at resolution
63
+ // time, exit 0.) The grammar stays activity-SCOPED, unlike the writer's `--set <activity>.<slot>=<x>`.
67
64
  const parseOverrides = (tokens, activity) => {
68
65
  const overrides = {};
69
66
  for (const tok of tokens) {
70
67
  const eq = tok.indexOf('=');
71
- if (eq <= 0) throw fail(2, `--override must be <slot>=<recipe> (got "${tok}")`);
68
+ if (eq <= 0) throw fail(2, `--override must be <slot>=<value> (got "${tok}")`);
72
69
  const slot = tok.slice(0, eq);
73
70
  const recipe = tok.slice(eq + 1);
74
71
  assertSlotRecipe(activity, slot, recipe); // shared validity (unknown slot / invalid recipe → exit 2)
@@ -91,7 +88,7 @@ const parseArgs = (argv) => {
91
88
  json = true;
92
89
  } else if (a === '--override') {
93
90
  const tok = argv[i + 1];
94
- if (tok === undefined || tok.startsWith('--')) throw fail(2, '--override requires <slot>=<recipe>');
91
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, '--override requires <slot>=<value>');
95
92
  overrideTokens.push(tok);
96
93
  i += 1;
97
94
  } else if (a.startsWith('--override=')) {
@@ -162,28 +159,36 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
162
159
  }
163
160
  })();
164
161
  const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
165
- return Object.keys(ACTIVITIES[activity].slots).map((slot) => {
162
+ return Object.entries(ACTIVITIES[activity].slots).map(([slot, slotType]) => {
166
163
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
164
+ if (isSwitchSlot(slotType)) return { slot, slotType, ...resolved, backends: [], contracts: [], vehicles: [] };
167
165
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
168
166
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
169
- // manifest wrapper cmd via the bridge registry — no wrapper name is hand-composed here.
167
+ // manifest wrapper cmd via the bridge registry — no wrapper name is hand-composed here. A vehicle
168
+ // step is NOT a bridge: it carries its own state and is never looked up in a manifest.
170
169
  const { dispatch } = planRecipe(resolved.recipe, detection);
171
- const backends = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
170
+ const vehicles = dispatch.filter((d) => d.vehicle != null).map((d) => ({ backend: d.backend, state: d.vehicle }));
171
+ const bridged = dispatch.filter((d) => d.vehicle == null);
172
+ const backends = bridged.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
172
173
  // The full DRIVING CONTRACT per dispatched (backend, role) — resolved HERE, on the raw dispatch
173
174
  // pairs, BEFORE they are flattened to wrapper names (the name array cannot reconstruct the role).
174
175
  // Every slot with a non-empty dispatch gets contracts — including execute=delegated; the contract
175
176
  // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
176
- const contracts = dispatch
177
+ const contracts = bridged
177
178
  .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
178
179
  .filter((c) => c.cmd && c.contract)
179
180
  // `retired` rides along: without it this surface advertised a RETIRED key as an ordinary
180
181
  // settable knob, while the writer refuses to set it — a driving contract that contradicts the
181
182
  // tool it points at.
182
183
  .map((c) => ({ ...c, settings: knobsFor(c.cmd).map((k) => ({ key: k.key, allowed: allowedLabel(k), retired: k.retired ?? null })) }));
183
- return { slot, ...resolved, backends, contracts };
184
+ return { slot, slotType, ...resolved, backends, contracts, vehicles };
184
185
  });
185
186
  };
186
187
 
188
+ // The routine switch reads against the EFFECTIVE carrier: the resolved recipe of the activity's
189
+ // carrier-typed slot (registry-driven — no slot name is spelled here), solo when it has none.
190
+ const effectiveCarrier = (slots) => slots.find((s) => s.slotType === 'carrier')?.recipe ?? 'solo';
191
+
187
192
  // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
188
193
  // graceful config/default degradation is reported as a per-slot reason, not a warning.
189
194
  const collectWarnings = (slots) =>
@@ -410,12 +415,11 @@ const flowHalvesAdvice = (flow, probe) => {
410
415
  // plan is being written. Composed from the project's live declaration, never from constants here.
411
416
  // Each config state speaks as itself: ABSENT renders NOTHING (a project that declares no practice must
412
417
  // not be handed invented limits); AUTHORED and INCOMPLETE render the declared caps plus the honest
413
- // "nothing is recorded yet" line both are pre-mint states, and treating INCOMPLETE as MINTED would
414
- // report a half record as the whole tree's debt; MINTED renders the recorded counts too.
418
+ // "nothing is recorded yet" line (treating INCOMPLETE as MINTED would report a half record as the
419
+ // whole tree's debt); MINTED renders the recorded counts too.
415
420
  // A config that cannot be read renders ONE loud line carrying the reader's own message and the render
416
- // still completes: the exit code for a broken source-size config belongs to the practice's own
417
- // checker (exit 2 there, and its declared gate reds the matrix on it), while THIS tool's exit
418
- // contract is about its own config and the engine.
421
+ // still completes: the exit code for a broken source-size config belongs to the practice's own checker,
422
+ // while THIS tool's exit contract is about its own config and the engine.
419
423
 
420
424
  export const DECLARED_PRACTICE_HEADER = `Declared source-size practice (${SOURCE_SIZE_CONFIG_REL}) — known BEFORE the code is written:`;
421
425
 
@@ -486,12 +490,21 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
486
490
  const lines = [
487
491
  section,
488
492
  '',
489
- `resolved recipes for "${activity}" (read-only — the orchestrator runs the recipe via the bridge skills and owns any commit; a backend never commits):`,
493
+ `resolved recipes for "${activity}" (read-only — the orchestrator runs the recipe via the bridge skills or the executor vehicle and owns any commit; every other carrier never commits):`,
490
494
  ];
495
+ const carrier = effectiveCarrier(slots);
491
496
  for (const s of slots) {
492
497
  const arrow = s.degradedFrom ? ` (requested ${s.degradedFrom} → degraded)` : '';
493
- lines.push(` ${s.slot}: ${s.recipe} ${SOURCE_LABEL[s.source]}${arrow}${backendSetLabel(s.backends)}`);
498
+ // A switch slot states what the flag DOES under the effective carrier; both keep the source suffix.
499
+ lines.push(isSwitchSlot(s.slotType)
500
+ ? ` ${parallelLine({ value: s.recipe, carrier })} — ${SOURCE_LABEL[s.source]}`
501
+ : ` ${s.slot}: ${s.recipe} — ${SOURCE_LABEL[s.source]}${arrow}${backendSetLabel(s.backends)}`);
494
502
  if (s.reason) lines.push(` ↳ ${s.reason}`);
503
+ // The form replaces the one-line vehicle mention: a carrier never told how to carry is a name,
504
+ // not an instruction. Indented like the driving contracts beside it.
505
+ for (const v of s.vehicles ?? []) {
506
+ for (const line of dispatchForm({ activity, slot: s.slot, state: v.state })) lines.push(` ${line}`);
507
+ }
495
508
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
496
509
  }
497
510
  if ((flowHalves ?? []).length) lines.push('', ...flowHalves);
@@ -544,16 +557,17 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
544
557
  const HELP = `procedures — read-only activity-procedures advisor for the agent-workflow family.
545
558
 
546
559
  Usage:
547
- node procedures.mjs <activity> [--override <slot>=<recipe>]... [--json]
560
+ node procedures.mjs <activity> [--override <slot>=<value>]... [--json]
548
561
 
549
562
  Activities: ${Object.keys(ACTIVITIES).join(', ')}
550
- Slots: plan-authoring review; plan-executionexecute, review
551
- Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
563
+ Slots: ${Object.entries(ACTIVITIES).map(([a, d]) => `${a} ${Object.keys(d.slots).join(', ')}`).join('; ')}
564
+ Accepted values: ${Object.entries(SLOT_RECIPES).map(([type, values]) => `${type} accepts ${values.join('|')}`).join('; ')}
552
565
 
553
566
  Reads the activity's procedure steps LIVE from the installed agent-workflow-engine
554
567
  (references/procedures.md), resolves the effective recipe per slot from
555
- ${CONFIG_REL} + the read-only backend detector, and prints both. A per-run
556
- --override <slot>=<recipe> (repeatable) overrides the configured/default recipe for that slot.
568
+ ${CONFIG_REL} + the read-only backend detector plus the executor-vehicle survey, and prints
569
+ both. A per-run
570
+ --override <slot>=<value> (repeatable) overrides the configured/default recipe for that slot.
557
571
  Read-only: never writes, never commits, never runs a subscription CLI.
558
572
 
559
573
  Also prints the project's DECLARED source-size practice (${SOURCE_SIZE_CONFIG_REL}) when it declares
@@ -582,18 +596,17 @@ export const main = (argv, ctx = {}) => {
582
596
  const { activity, overrides, json } = parseArgs(argv);
583
597
  const { config, source: configSource } = loadConfig(cwd, readFile, lstat);
584
598
  const section = extractSection(readProceduresCanon(env, home), activity);
585
- // Backend detection is a SECONDARY input — it only refines the recipe. A corrupt / unreadable backend
586
- // must NOT fail activity resolution as a config/engine error (exit 1, outside the contract): treat all
587
- // backends as not-ready (resolution floors at Solo) and surface the failure as a loud warning, exit 0.
599
+ // Readiness is a SECONDARY input — it only refines the recipe. A corrupt bridge must NOT fail
600
+ // activity resolution as a config/engine error: the detector-failure hook floors the bridge half
601
+ // at not-ready and warns (exit 0), while the surveyed executor vehicle survives untouched.
588
602
  const detectWarnings = [];
589
- let detection = [];
590
- try {
591
- detection = detect();
592
- } catch (err) {
593
- detectWarnings.push(
594
- `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`,
595
- );
596
- }
603
+ const detection = composeReadiness(cwd, {
604
+ detect,
605
+ surveyVehicle: ctx.surveyVehicle,
606
+ onDetectError: (err) => detectWarnings.push(
607
+ `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).`,
608
+ ),
609
+ });
597
610
  const slots = resolveAllSlots({ activity, config, detection, overrides });
598
611
  const warnings = [...detectWarnings, ...collectWarnings(slots)];
599
612
  const plans = plansInFlight(cwd);