@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.
package/tools/recipes.mjs CHANGED
@@ -1,25 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  // Recipe planner — the pure brain behind the read-only `/agent-workflow-kit recipes` advisor.
3
3
  //
4
- // A "recipe" is a NAMED orchestration pattern over the family's optional execution-backends (the
5
- // subscription-CLI bridges: codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`), composed
6
- // into the plan → execute → review flow. The ENGINE owns the canonical narrative
7
- // (agent-workflow-engine/references/orchestration.md — the when/why, kept in lockstep by the
8
- // recipe-name parity guard in recipes.test.mjs); this module owns the EXECUTABLE dispatch: given a
9
- // recipe + the read-only detector's view of the environment, which backend does which role, how it
10
- // degrades when a backend isn't ready, and the advisory quota/health notes.
11
- //
12
- // Invariants (the backends/status posture): pure (no fs/network/CLI in planRecipe/recommendRecipe),
13
- // read-only, NEVER runs a subscription CLI. The kit only surfaces/selects/plans a recipe — the
14
- // orchestrator (the main agent) executes it via the bridge skills and always makes the single commit;
15
- // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 22.
4
+ // A "recipe" is a NAMED orchestration pattern over the family's optional carriers: the
5
+ // subscription-CLI bridges (codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`) and the
6
+ // executor subagent vehicle. The ENGINE owns the canonical narrative
7
+ // (agent-workflow-engine/references/orchestration.md, kept in lockstep by the recipe-name parity
8
+ // guard in recipes.test.mjs); this module owns the EXECUTABLE dispatch. The activity/slot registry
9
+ // lives in carriers.mjs, re-exported here so every importer keeps its './recipes.mjs' path.
10
+ // Invariants: pure (no fs/network/CLI in planRecipe/recommendRecipe), read-only, NEVER runs a
11
+ // subscription CLI. The orchestrator executes a recipe and always makes the single commit; a
12
+ // backend or a subagent is advisory or delegated, never autonomous. Dependency-free, Node >= 22.
16
13
 
17
14
  import { existsSync, readFileSync } from 'node:fs';
18
15
  import { dirname, join, resolve } from 'node:path';
19
- // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only never the
20
- // writer — so this read-only advisor never pulls in the atomic-write core.
16
+ // The READ-ONLY settings core, never the writer: this advisor never pulls in the atomic-write core.
21
17
  import { settingsSnapshot, DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
22
18
  import { isDirectRun } from './direct-run.mjs';
19
+ import {
20
+ ACTIVITIES,
21
+ POLICY_ACTIVITIES,
22
+ SLOT_RECIPES,
23
+ SUBAGENT_RECIPE,
24
+ CARRY_ROLE,
25
+ EXECUTOR_PROVIDER,
26
+ SWITCH_DEFAULT,
27
+ isSwitchSlot,
28
+ withVehicle,
29
+ vehicleDegradeReason,
30
+ EXECUTOR_APPLY,
31
+ safeLine,
32
+ } from './carriers.mjs';
33
+ // The READ core of the vehicle surface, never the writer: this advisor can never reach a module
34
+ // that creates `.claude/agents/`.
35
+ import { surveyExecutorVehicle } from './cheap-agents-read.mjs';
23
36
  import {
24
37
  detectBackends,
25
38
  wrapperCmdFor,
@@ -30,25 +43,25 @@ import {
30
43
  DEGRADED,
31
44
  } from './detect-backends.mjs';
32
45
 
46
+ export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APPLY, safeLine };
47
+
33
48
  const CODEX = 'codex-cli-bridge';
34
49
  const AGY = 'antigravity-cli-bridge';
35
50
 
36
51
  // The manifest-name → human-alias map (the detector emits manifest names; humans say codex/agy).
37
52
  export const DISPLAY_ALIASES = { [CODEX]: 'codex', [AGY]: 'agy' };
38
53
 
39
- // The backend → role table, keyed by the manifest name the detector emits (status.name) NOT the
40
- // display alias. Drift-guarded against each bridge capability.json `provides[]` (recipes.test.mjs).
54
+ // The provider → role table, keyed by the name the readiness array carries, NOT the display alias.
55
+ // The bridge rows are drift-guarded against each capability.json `provides[]`; the executor row is
56
+ // the vehicle withVehicle appends, and the ONLY provider of `carry`.
41
57
  export const BACKEND_ROLES = {
42
58
  [CODEX]: ['execute', 'review'],
43
59
  [AGY]: ['review', 'probe'],
60
+ [EXECUTOR_PROVIDER]: [CARRY_ROLE],
44
61
  };
45
62
 
46
- // Review obligations from the CONFIGURED plan-execution.review recipe — the RAW config value,
47
- // never the readiness-degraded effective recipe (a computed readiness-degrade NEVER silently
48
- // becomes solo). Homed HERE (cycle-free) so review-state AND flow-check consume the SAME consumed
49
- // backend set (#42 exact coverage must never fall open on the computed-default class).
50
- // solo → no obligation; reviewed → ONE ship-class from ANY backend (minShip 1);
51
- // council → EVERY backend ship-class or degrade-recorded, and never all (minShip 1).
63
+ // Review obligations from the CONFIGURED plan-execution.review recipe — the RAW config value, never
64
+ // the readiness-degraded one. Homed HERE so review-state and flow-check share one backend set (#42).
52
65
  export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false } = {}) => {
53
66
  const configured = config?.['plan-execution']?.review;
54
67
  const providers = Object.values(DISPLAY_ALIASES); // every review-capable backend, codex first
@@ -56,7 +69,9 @@ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], de
56
69
  // No config + no readiness signal: the computed default is UNKNOWABLE — fail closed upstream.
57
70
  return { recipe: null, source: 'default', backends: [], minShip: 0, perBackend: false, unknowable: true };
58
71
  }
59
- const anyReady = readiness.some((b) => b.readiness === READY);
72
+ // Role-filtered: a ready EXECUTOR vehicle is a carry provider, never a reviewer, so it must not
73
+ // turn a silent review config into `reviewed`.
74
+ const anyReady = readyProvidersOf('review', readiness).length >= 1;
60
75
  const recipe = configured ?? (anyReady ? 'reviewed' : 'solo');
61
76
  const source = configured != null ? 'config' : 'default';
62
77
  if (recipe === 'solo') return { recipe, source, backends: [], minShip: 0, perBackend: false, unknowable: false };
@@ -64,10 +79,8 @@ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], de
64
79
  return { recipe, source, backends: providers, minShip: 1, perBackend: false, unknowable: false };
65
80
  };
66
81
 
67
- // Advisory metadata the DETECTION object does not carry (it has no cost/quota those live only in
68
- // capability.json). cost/quota are drift-guarded against the manifests; the agy `health` advisory is
69
- // static project knowledge (Issue-001: the Antigravity service can stall on substantive prompts —
70
- // invisible to file-presence detection, so it is NOT a readiness signal, only a standing caveat).
82
+ // Advisory metadata the detection object does not carry. cost/quota are drift-guarded against the
83
+ // manifests; agy's `health` (Issue-001) is invisible to detection a caveat, not a readiness signal.
71
84
  export const BACKEND_META = {
72
85
  [CODEX]: { cost: 'subscription', quota: { kind: 'subscription', finite: true } },
73
86
  [AGY]: {
@@ -77,17 +90,15 @@ export const BACKEND_META = {
77
90
  },
78
91
  };
79
92
 
80
- // Deterministic tie-break order: codex before agy. agy is a sound grounded reviewer now, but codex is
81
- // the more reliable default for substantive reviews (agy carries the standing service-stall caveat above).
93
+ // Deterministic tie-break: codex before agy the more reliable default for substantive reviews.
82
94
  const BACKEND_PRIORITY = [CODEX, AGY];
83
95
  const priorityIndex = (name) => {
84
96
  const i = BACKEND_PRIORITY.indexOf(name);
85
97
  return i === -1 ? BACKEND_PRIORITY.length : i;
86
98
  };
87
99
 
88
- // The four recipes, in lattice order. `role` is the backend role a recipe needs (null = Solo, no
89
- // backend); `minBackends` is how many READY providers it needs; `degradesTo` is the next-weaker
90
- // recipe when it can't be satisfied (the chain terminates at Solo, which is always satisfiable).
100
+ // The five recipes, in lattice order. `degradesTo` is the next-weaker recipe when a recipe can't be
101
+ // satisfied; every chain terminates at Solo, which always is.
91
102
  export const RECIPES = [
92
103
  {
93
104
  id: 'solo',
@@ -121,12 +132,12 @@ export const RECIPES = [
121
132
  degradesTo: 'solo',
122
133
  summary: 'the orchestrator hands a bounded execution sub-task to a backend (codex exec), then reviews the diff and commits.',
123
134
  },
135
+ SUBAGENT_RECIPE,
124
136
  ];
125
137
 
126
138
  const recipeById = (id) => RECIPES.find((r) => r.id === id);
127
139
 
128
- // The human reason a non-ready readiness yields (read-only file-presence remedies — never a claim
129
- // about whether the backend's service is responsive).
140
+ // Read-only file-presence remedies — never a claim about whether a backend's service is responsive.
130
141
  const READINESS_REASON = {
131
142
  [NEEDS_SKILL]: 'bridge skill not installed — run /agent-workflow-kit setup',
132
143
  [NEEDS_CLI]: 'the CLI is not installed',
@@ -136,18 +147,16 @@ const READINESS_REASON = {
136
147
 
137
148
  // ── pure planner ───────────────────────────────────────────────────────────────
138
149
 
139
- // Backends (ready or not) whose role table includes `role`.
140
150
  const providersOf = (role, detection) => detection.filter((b) => (BACKEND_ROLES[b.name] ?? []).includes(role));
141
151
 
142
- // READY providers of `role`, in deterministic priority order (codex before agy) an array of names.
143
- const readyProvidersOf = (role, detection) =>
152
+ // In deterministic priority order, so a dispatch never depends on detection emission order.
153
+ const readyProviderEntriesOf = (role, detection) =>
144
154
  providersOf(role, detection)
145
155
  .filter((b) => b.readiness === READY)
146
- .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
147
- .map((b) => b.name);
156
+ .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name));
157
+
158
+ const readyProvidersOf = (role, detection) => readyProviderEntriesOf(role, detection).map((b) => b.name);
148
159
 
149
- // Availability = readiness === READY, full stop. A recipe is satisfiable iff it needs no backend OR
150
- // enough READY providers of its role exist.
151
160
  const isSatisfiable = (recipe, detection) =>
152
161
  recipe.role === null || readyProvidersOf(recipe.role, detection).length >= recipe.minBackends;
153
162
 
@@ -155,41 +164,44 @@ const isSatisfiable = (recipe, detection) =>
155
164
  const degradeReason = (recipe, detection) => {
156
165
  const providers = providersOf(recipe.role, detection);
157
166
  if (providers.length === 0) {
158
- return `${recipe.title} needs a backend providing ${recipe.role}, but no backend provides it`;
167
+ return `${recipe.title} needs a provider providing ${recipe.role}, but no provider provides it`;
159
168
  }
160
169
  const ready = providers.filter((b) => b.readiness === READY);
161
170
  const detail = providers
162
171
  .filter((b) => b.readiness !== READY)
163
- .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`)
172
+ .map((b) => (b.name === EXECUTOR_PROVIDER
173
+ ? vehicleDegradeReason(b.vehicle, EXECUTOR_APPLY)
174
+ : `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`))
164
175
  .join('; ');
165
- return `${recipe.title} needs ${recipe.minBackends} backend(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
176
+ return `${recipe.title} needs ${recipe.minBackends} provider(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
166
177
  };
167
178
 
168
- // Per-stage dispatch for an EFFECTIVE (already-satisfiable) recipe: the first `minBackends` READY
169
- // providers, in priority order. Solo dispatches nothing (the orchestrator does it all).
179
+ // Per-stage dispatch for an EFFECTIVE (already-satisfiable) recipe. The executor's step carries its
180
+ // vehicle state, so both renders can name it.
170
181
  const dispatchFor = (recipe, detection) => {
171
182
  if (recipe.role === null) return [];
172
- return readyProvidersOf(recipe.role, detection)
183
+ return readyProviderEntriesOf(recipe.role, detection)
173
184
  .slice(0, recipe.minBackends)
174
- .map((name) => ({ role: recipe.role, backend: name, display: DISPLAY_ALIASES[name] }));
185
+ .map((b) => {
186
+ const step = { role: recipe.role, backend: b.name, display: DISPLAY_ALIASES[b.name] ?? b.name };
187
+ return b.vehicle ? { ...step, vehicle: b.vehicle.state } : step;
188
+ });
175
189
  };
176
190
 
177
191
  const QUOTA_NOTE = "Prefer the cheapest model that fits the task; don't reach for a top-tier model by reflex.";
178
192
  const COUNCIL_QUOTA_NOTE = "Council spends two backends' quota for one decision — reserve it for changes that justify the cost.";
179
193
 
180
- // Advisory notes for an effective recipe: a quota reminder when any backend is dispatched, the
181
- // two-quota caveat for Council, and the agy health advisory whenever the dispatch actually uses agy.
194
+ // The quota reminder rides a subscription-BACKEND dispatch only: the executor vehicle spends no
195
+ // bridge quota, and its recipe is a frontier one, so the cheapest-model reminder would contradict it.
182
196
  const notesFor = (recipe, dispatch) => {
183
197
  const notes = [];
184
- if (dispatch.length > 0) notes.push(QUOTA_NOTE);
198
+ if (dispatch.some((d) => d.role !== CARRY_ROLE)) notes.push(QUOTA_NOTE);
185
199
  if (recipe.id === 'council') notes.push(COUNCIL_QUOTA_NOTE);
186
200
  if (dispatch.some((d) => d.backend === AGY) && BACKEND_META[AGY].health) notes.push(BACKEND_META[AGY].health);
187
201
  return notes;
188
202
  };
189
203
 
190
- // planRecipe(recipe, detection) → pure plan. `recipe` is a recipe id or descriptor. Walks the
191
- // degradation chain (with a stated reason per step) until a satisfiable recipe is reached, then emits
192
- // the per-stage dispatch + advisory notes. Deterministic; never mutates the detection input.
204
+ // planRecipe → pure plan: walks the degradation chain, a stated reason per step, then dispatches.
193
205
  export const planRecipe = (recipe, detection) => {
194
206
  const requested = typeof recipe === 'string' ? recipeById(recipe) : recipe;
195
207
  if (!requested) throw new Error(`unknown recipe: ${recipe}`);
@@ -211,7 +223,7 @@ export const planRecipe = (recipe, detection) => {
211
223
  };
212
224
  };
213
225
 
214
- // How close to ready a non-ready backend is — used to surface the most-actionable remedy first.
226
+ // Rank = how close to ready, so the most actionable remedy surfaces first.
215
227
  const READINESS_RANK = { [DEGRADED]: 3, [NEEDS_CREDENTIALS]: 2, [NEEDS_CLI]: 1, [NEEDS_SKILL]: 0 };
216
228
  const READINESS_REMEDY = {
217
229
  [NEEDS_SKILL]: 'run /agent-workflow-kit setup',
@@ -220,9 +232,8 @@ const READINESS_REMEDY = {
220
232
  [DEGRADED]: 'run /agent-workflow-kit setup (wrapper not on PATH)',
221
233
  };
222
234
 
223
- // recommendRecipe(detection) → { recipe, clause }. Never blank: both ready → Council (Reviewed the
224
- // everyday default); one ready → Reviewed; none installed → Solo + a setup pointer; a backend
225
- // present-but-not-ready → Solo with that backend's specific remedy. Pure.
235
+ // recommendRecipe(detection) → { recipe, clause }, never blank: both ready → Council (Reviewed the
236
+ // everyday default); one → Reviewed; otherwise Solo plus the most actionable remedy. Pure.
226
237
  export const recommendRecipe = (detection) => {
227
238
  const readyReview = readyProvidersOf('review', detection);
228
239
  if (readyReview.length >= 2) {
@@ -231,14 +242,11 @@ export const recommendRecipe = (detection) => {
231
242
  if (readyReview.length === 1) {
232
243
  return { recipe: 'reviewed', clause: `Reviewed available (via ${DISPLAY_ALIASES[readyReview[0]]})` };
233
244
  }
234
- // No ready reviewer Solo. Say how to unlock more: a present-but-not-ready backend names its
235
- // remedy; nothing installed names the setup pointer.
236
- const present = detection.filter((b) => b.readiness !== NEEDS_SKILL && b.readiness !== READY);
245
+ // Role-filtered: the executor vehicle unlocks no review recipe, so it never supplies the remedy.
246
+ const present = providersOf('review', detection).filter((b) => b.readiness !== NEEDS_SKILL && b.readiness !== READY);
237
247
  if (present.length === 0) {
238
248
  return { recipe: 'solo', clause: 'Solo — run /agent-workflow-kit setup to add a backend' };
239
249
  }
240
- // Rank by how close to ready; break ties with the SAME codex-before-agy priority the dispatch path
241
- // uses (priorityIndex) so the recommendation is deterministic regardless of detection emission order.
242
250
  const best = [...present].sort(
243
251
  (a, b) => (READINESS_RANK[b.readiness] ?? -1) - (READINESS_RANK[a.readiness] ?? -1) || priorityIndex(a.name) - priorityIndex(b.name),
244
252
  )[0];
@@ -247,42 +255,19 @@ export const recommendRecipe = (detection) => {
247
255
  };
248
256
 
249
257
  // ── activity procedures: per-slot recipe resolution ────────────────────────────────
250
-
251
- // The named activities and their typed recipe slots the EXECUTABLE mirror of the engine canon
252
- // (agent-workflow-engine/references/procedures.md). Drift-guarded against that canon's `Slots:` lines
253
- // (recipes.test.mjs): the activity ids and each section's slot set must match this table. The slot
254
- // VALUE is the slot's recipe-TYPE (used to look up which recipes are valid for it, SLOT_RECIPES); in
255
- // v1 each slot's key equals its type, but the indirection keeps a future renamed slot expressible.
256
- export const ACTIVITIES = {
257
- 'plan-authoring': { slots: { review: 'review' } },
258
- 'plan-execution': { slots: { execute: 'execute', review: 'review' } },
259
- };
260
-
261
- // Which recipes are valid in each slot type. `review` composes a review DEPTH (Solo / Reviewed /
262
- // Council); `execute` composes Solo / Delegated (delegation is opt-in). A recipe outside its slot's
263
- // list is a config error (the IO shell) or a usage error (an --override) — never silently coerced.
264
- export const SLOT_RECIPES = {
265
- review: ['solo', 'reviewed', 'council'],
266
- execute: ['solo', 'delegated'],
267
- };
268
-
269
- // The computed default for a slot when the config is silent (no file, or no entry for this slot).
270
- // review → Reviewed when ANY review-capable backend is `ready`, else Solo (NEVER Council — Council is
271
- // opt-in; it spends two backends' quota). execute → Solo (Delegated is opt-in only). Readiness-aware,
272
- // so a computed default is always satisfiable and never itself degrades. Deliberately NOT
273
- // recommendRecipe (which returns Council when both are ready — that drives the status line, not a
274
- // per-slot default).
258
+ // The computed default for a silent slot. NEVER Council (opt-in: it spends two backends' quota) and
259
+ // deliberately not recommendRecipe, which drives the status line rather than a per-slot default.
260
+ // Every non-review, non-switch slot floors at Solo, so placing the executor vehicle never flips a
261
+ // default. Readiness-aware, so a computed default is always satisfiable and never itself degrades.
275
262
  const computedDefaultForSlot = (slotType, detection) => {
276
263
  if (slotType === 'review') return readyProvidersOf('review', detection).length >= 1 ? 'reviewed' : 'solo';
277
- return 'solo'; // execute (and any future opt-in slot) floors at Solo
264
+ if (isSwitchSlot(slotType)) return SWITCH_DEFAULT;
265
+ return 'solo';
278
266
  };
279
267
 
280
- // resolveActivityRecipe({ config, readiness, activity, slot, override }) the effective recipe for ONE
281
- // slot of an activity, with graceful-vs-loud degradation. Precedence: an explicit `override` (degrades
282
- // LOUDLY — overrideUnsatisfied, so the agent tells the user) > the `config` entry (degrades gracefully)
283
- // > the computed default (graceful; readiness-aware so it never degrades). Satisfiability + the
284
- // degradation lattice REUSE planRecipe (Council → Reviewed → Solo; Delegated → Solo) — the single source
285
- // of the recipe lattice. `readiness` is the detector array ([{ name, readiness }]). Pure; never mutates.
268
+ // The effective recipe for ONE slot. Precedence: an explicit `override` (degrades LOUDLY, so the
269
+ // agent tells the user) > the `config` entry (graceful) > the computed default. Satisfiability and
270
+ // the lattice REUSE planRecipe one source. Pure; never mutates.
286
271
  export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override } = {}) => {
287
272
  const activityDef = ACTIVITIES[activity];
288
273
  if (!activityDef) throw new Error(`unknown activity: ${activity}`);
@@ -293,12 +278,17 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
293
278
  const requested = override ?? configured ?? computedDefaultForSlot(slotType, readiness);
294
279
  const source = override != null ? 'override' : configured != null ? 'config' : 'default';
295
280
 
296
- // Defensive: the IO shell (config) and CLI (override) validate recipe-for-slot first; a stray value
297
- // here is a programmer error, surfaced loudly rather than silently coerced into a neighbour recipe.
281
+ // Defensive: the IO shell and the CLI validate first, so a stray value here is a programmer error
282
+ // surfaced loudly rather than silently coerced into a neighbour recipe.
298
283
  if (!(SLOT_RECIPES[slotType] ?? []).includes(requested)) {
299
284
  throw new Error(`invalid recipe "${requested}" for ${slotType} slot of "${activity}"`);
300
285
  }
301
286
 
287
+ // A switch slot is a flag, not a recipe: it resolves outside the lattice and can never degrade.
288
+ if (isSwitchSlot(slotType)) {
289
+ return { recipe: requested, source, degradedFrom: null, reason: null, overrideUnsatisfied: false };
290
+ }
291
+
302
292
  const plan = planRecipe(requested, readiness);
303
293
  const degraded = plan.degraded;
304
294
  return {
@@ -310,21 +300,12 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
310
300
  };
311
301
  };
312
302
 
313
- // ── the one-line backend status (deterministic-first: the tool speaks, the agent pastes) ───────────
314
-
315
- // composeStatusLine(detection, recommendation) the ENTIRE one-line backend-status summary the
316
- // bootstrap/upgrade report footers print. Machine-composed so the agent pastes it verbatim and
317
- // composes NOTHING factual (this closes the realistic-example contamination class: a session once
318
- // echoed SKILL.md's canonical example while the detector said otherwise). Display names come from
319
- // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
320
- // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
321
- // Always exactly one line: no part may carry a newline (pinned by tests).
322
- // composeConfiguredPosture(ctx) → the CONFIGURED dispatch posture (strip-the-kit D5), rendered
323
- // from the bundled manifests' VALIDATED posture pins overlaid with the ACTIVE bridge-settings —
324
- // today only the codex tier knob (bridge-settings stays model/effort-free by design, so this
325
- // surface can never carry a settings-file model claim). Returns null when NO bundled bridge
326
- // declares a posture block (a pre-D5 bundle keeps every surface byte-identical). Best-effort:
327
- // a corrupt bundle degrades to null — the posture segment is a footer fact, never a gate.
303
+ // ── the one-line backend status (the tool speaks, the agent pastes) ───────────────────────────────
304
+ // Machine-composed so the agent composes NOTHING factual (a session once echoed SKILL.md's canonical
305
+ // example while the detector said otherwise). Always exactly one line.
306
+
307
+ // The CONFIGURED dispatch posture: the bundled manifests' pins overlaid with the active
308
+ // bridge-settings. No pins or a corrupt bundle → null; the posture segment is a fact, never a gate.
328
309
  export const composeConfiguredPosture = (ctx = {}) => {
329
310
  try {
330
311
  const bundleRoot = ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
@@ -339,8 +320,7 @@ export const composeConfiguredPosture = (ctx = {}) => {
339
320
  }
340
321
  if (!Object.hasOwn(manifest, 'posture')) continue; // a pre-D5 bridge — legitimate absence
341
322
  const posture = manifest.posture;
342
- // The FULL closed shape (the validator/receipt-predicate twin): a present-but-invalid
343
- // block nulls the WHOLE render — fail closed to silence, never a partial/mangled line.
323
+ // A present-but-invalid block nulls the WHOLE render — never a partial/mangled line.
344
324
  const invalid =
345
325
  posture === null || typeof posture !== 'object' || Array.isArray(posture) ||
346
326
  typeof posture.model !== 'string' || posture.model.length === 0 ||
@@ -362,33 +342,29 @@ export const composeConfiguredPosture = (ctx = {}) => {
362
342
  }
363
343
  };
364
344
 
345
+ // The bridge half of a readiness array: every backend-status render lists BRIDGES (the things
346
+ // `/agent-workflow-kit backends` sets up); the executor vehicle is a carrier, judged by the agents
347
+ // mode, and only the recipe lattice sees it.
348
+ export const bridgeEntries = (readiness) => readiness.filter((b) => b.name !== EXECUTOR_PROVIDER);
349
+
365
350
  export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null, posture = null) => {
366
- const backends = [...detection]
351
+ const backends = bridgeEntries(detection)
367
352
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
368
353
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
369
354
  .join(' · ');
370
355
  const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
371
- // Fact-only suffix, ONLY when a bridge knob is actively set (env/file, non-default). Omitted otherwise,
372
- // so the default line is byte-identical to before. A raw env value may (D3) carry newlines/control
373
- // chars — collapse them to a single space so the "exactly one line" backend-status contract holds.
356
+ // A raw env value may carry newlines/control chars collapse them so the one-line contract holds.
374
357
  const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
375
358
  const active = settings?.active ?? [];
376
- // A RETIRED knob is rendered as retired, never as an armed capability (D3 Invariant E): the line
377
- // exists in the user's file, so hiding it would be a silent deletion but reading it as active
378
- // would claim a capability the wrapper no longer has.
359
+ // A RETIRED knob renders as retired: hiding the user's line would be a silent deletion, reading it
360
+ // as active would claim a capability the wrapper no longer has.
379
361
  const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}${s.retired ? ' (RETIRED — arms nothing)' : ''}`).join(' · ')}` : '';
380
- // The autonomy segment (AD-044 Plan 4): rendered ONLY when the caller supplies the computed
381
- // facts (composeAutonomyFacts) — an omitted param keeps the line byte-identical (the settings-
382
- // suffix precedent). Fact-only: effective per-activity levels + the render-sync state; an absent
383
- // policy says "computed defaults" honestly; a malformed policy surfaces LOUDLY, never omitted.
362
+ // Each optional segment renders ONLY when its facts are supplied; an omitted param changes nothing.
384
363
  const autonomySegment = autonomy == null ? '' : ` · autonomy: ${oneLine(formatAutonomySegment(autonomy))}`;
385
- // The D5 posture segment: rendered ONLY when the caller supplies the composed pins-derived
386
- // posture (composeConfiguredPosture) — an omitted/null param keeps the line byte-identical.
387
364
  const postureSegment = posture == null ? '' : ` · posture: ${oneLine(posture)}`;
388
365
  return base + suffix + autonomySegment + postureSegment;
389
366
  };
390
367
 
391
- // The one-segment autonomy renderer behind composeStatusLine's 4th param.
392
368
  const formatAutonomySegment = (a) => {
393
369
  if (a.error) return `MALFORMED policy — ${a.error}`;
394
370
  const levels = Object.entries(a.activities ?? {}).map(([k, v]) => `${k}=${v.autonomy}`).join(', ');
@@ -400,14 +376,8 @@ const formatAutonomySegment = (a) => {
400
376
  return `${levels} (${state})`;
401
377
  };
402
378
 
403
- // composeAutonomyFacts(cwd) the fact object the status/active lines render (AD-044 Plan 4):
404
- // { source, redlines, activities, renderState } or { error }. Lazy imports — autonomy-config
405
- // statically imports THIS module (ACTIVITIES) and velocity-profile is heavy, so both load at call
406
- // time only (the loadConfig precedent). Never throws: a malformed policy becomes { error } so the
407
- // one-line surfaces render it loudly instead of dying.
408
- // The policy lives at the PROJECT root; the report-footer invokes the paste surfaces without
409
- // --cwd, so a subdirectory shell must still find it. Nearest-.git walk-up (dir or worktree file),
410
- // fs-only — this advisor stays spawn-free; no repo found → the cwd itself (fixture behavior).
379
+ // The policy lives at the PROJECT root and the paste surfaces run without --cwd, so a subdirectory
380
+ // shell must still find it. Fs-only: this advisor stays spawn-free.
411
381
  const projectTopOf = (cwd) => {
412
382
  let dir = resolve(cwd);
413
383
  for (;;) {
@@ -418,9 +388,8 @@ const projectTopOf = (cwd) => {
418
388
  }
419
389
  };
420
390
 
421
- // `deps` is threaded to the render check because that check now PROBES the installed harness: without
422
- // a seam the render state would vary with the machine composing the facts, and a caller could not
423
- // pin it. Production passes nothing and probes for real.
391
+ // The facts the status/active lines render. Lazy imports: autonomy-config statically imports THIS
392
+ // module. Never throws a malformed policy becomes { error }, rendered loudly. `deps` is the probe seam.
424
393
  export const composeAutonomyFacts = async (cwd, deps = {}) => {
425
394
  try {
426
395
  const root = projectTopOf(cwd);
@@ -428,10 +397,8 @@ export const composeAutonomyFacts = async (cwd, deps = {}) => {
428
397
  const { config, source } = loadAutonomy(root);
429
398
  const resolved = resolveAutonomy(config);
430
399
  if (source === 'none') return { source, redlines: resolved.redlines, activities: resolved.activities, renderState: null };
431
- // The STRUCTURAL seed (_README only) is a fresh-deployment NORMAL treating it as "declared"
432
- // would report "render DRIFT" on every fresh upgrade (codex, Segment B). An EXPLICIT policy
433
- // declaring the default values is a real declaration — its render state IS computed
434
- // (structural detection, codex Segment B closing).
400
+ // The structural seed is a fresh-deployment NORMAL: reading it as "declared" would report
401
+ // "render DRIFT" on every fresh upgrade.
435
402
  if (isSparseSeedConfig(config)) {
436
403
  return { source, defaultsEquivalent: true, redlines: resolved.redlines, activities: resolved.activities, renderState: null };
437
404
  }
@@ -452,29 +419,22 @@ export const composeAutonomyFacts = async (cwd, deps = {}) => {
452
419
 
453
420
  // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
454
421
 
455
- // composeActiveRecipeLine({ config, source }, detection) → ONE line rendering the CONFIGURED recipe of
456
- // every activity/slot (resolved via resolveActivityRecipe: config entry, else computed default), each
457
- // with its source label, its degradation stated, and its dispatched wrapper set — explicitly labeled
458
- // "configured" and contrasted with the readiness-RECOMMENDED recipe (which composeStatusLine shows and
459
- // which is NOT what runs). This is the machine-composed sibling of composeStatusLine (AD-034): the
460
- // session-start checklist + the handover "Active recipes:" slot paste it verbatim, so no agent composes
461
- // the configured-recipe facts by hand. `{ config, source }` is exactly what loadConfig returns (source
462
- // 'none' when no config file exists). Always exactly one line: no part may carry a newline (pinned).
422
+ // ONE line rendering the CONFIGURED recipe of every activity/slot with its source, degradation and
423
+ // dispatched wrappers contrasted with the readiness-RECOMMENDED recipe, which is NOT what runs.
424
+ // The session-start checklist and the handover "Active recipes:" slot paste it verbatim.
463
425
  export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null) => {
464
426
  const cells = [];
465
427
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
466
- // The per-activity autonomy level rides each cell when the caller supplies the facts (AD-044
467
- // Plan 4) — an omitted param keeps the line byte-identical (the composeStatusLine precedent).
468
428
  const level = autonomy?.activities?.[activity]?.autonomy;
469
429
  const auto = level ? `; autonomy ${level}` : '';
470
- for (const slot of Object.keys(def.slots)) {
430
+ for (const [slot, slotType] of Object.entries(def.slots)) {
471
431
  const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
472
- const { dispatch } = planRecipe(r.recipe, detection);
432
+ const dispatch = isSwitchSlot(slotType) ? [] : planRecipe(r.recipe, detection).dispatch;
473
433
  const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
474
434
  const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
475
435
  const head = r.degradedFrom
476
436
  ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason}${auto})`
477
- : `${activity}.${slot} = ${r.recipe} (${srcLabel}${auto})`;
437
+ : `${activity}.${slot} = ${r.recipe} (${srcLabel}${isSwitchSlot(slotType) ? '; switch' : ''}${auto})`;
478
438
  const suffix =
479
439
  wrappers.length >= 2
480
440
  ? ` → every backend every round: ${wrappers.join(' + ')}`
@@ -486,18 +446,16 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection, auto
486
446
  }
487
447
  const rec = recommendRecipe(detection);
488
448
  const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
489
- // A MALFORMED policy must surface LOUDLY on this paste surface too silently rendering cells
490
- // without levels would hide the required STOP signal (Segment B). One line always.
449
+ // A MALFORMED policy surfaces LOUDLY here too: rendering cells without levels would hide the STOP.
491
450
  const malformed = autonomy?.error
492
451
  ? ` · autonomy: MALFORMED policy — ${String(autonomy.error).replace(/[\s]+/g, ' ').trim()}`
493
452
  : '';
494
- return `active recipes (${origin}): ${cells.join(' · ')} — the configured recipes above are what runs; readiness-recommended here: ${rec.recipe} (informational)${malformed}`;
453
+ return `active recipes (${origin}): ${cells.join(' · ')} — the configured orchestration values above are what runs; readiness-recommended here: ${rec.recipe} (informational)${malformed}`;
495
454
  };
496
455
 
497
456
  // ── report + CLI ─────────────────────────────────────────────────────────────────
498
457
 
499
- // The structured report behind `--json` the recipes, the recommendation, a plan per recipe, and
500
- // (additive) the pasteable one-line backend status composed from the same detection.
458
+ // The structured report behind `--json`, incl. the same one-line status the --status-line mode emits.
501
459
  export const buildReport = (detection, settings = null, autonomy = null, posture = null) => {
502
460
  const recommendation = recommendRecipe(detection);
503
461
  return {
@@ -511,17 +469,15 @@ export const buildReport = (detection, settings = null, autonomy = null, posture
511
469
  })),
512
470
  recommendation,
513
471
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
514
- // The SAME autonomy facts the --status-line surface renders the --json envelope must never
515
- // expose a stale machine-composed status line (Segment B).
472
+ // The --json envelope must never expose a status line staler than the --status-line surface.
516
473
  statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture),
517
474
  };
518
475
  };
519
476
 
520
- // formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
521
- // and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
477
+ // Deterministic human advisor text: the recipes, the recommendation, and the per-recipe plan here.
522
478
  export const formatRecipes = (detection) => {
523
479
  const lines = [
524
- 'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills and always commits)',
480
+ 'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills or the executor vehicle and always commits)',
525
481
  '',
526
482
  ];
527
483
  for (const r of RECIPES) lines.push(` ${r.title} (${r.id}) — ${r.summary}`);
@@ -530,7 +486,9 @@ export const formatRecipes = (detection) => {
530
486
  for (const r of RECIPES) {
531
487
  const p = planRecipe(r.id, detection);
532
488
  const arrow = p.degraded ? ` → ${p.effective}` : '';
533
- const who = p.dispatch.length ? p.dispatch.map((d) => `${d.display} ${d.role}`).join(', ') : 'orchestrator only';
489
+ const who = p.dispatch.length
490
+ ? p.dispatch.map((d) => `${d.display} ${d.role}${d.vehicle ? ` (vehicle ${d.vehicle})` : ''}`).join(', ')
491
+ : 'orchestrator only';
534
492
  lines.push(` ${r.title}${arrow}: ${who}`);
535
493
  for (const step of p.degradation) lines.push(` ↳ ${step.reason}`);
536
494
  for (const note of p.notes) lines.push(` • ${note}`);
@@ -538,22 +496,37 @@ export const formatRecipes = (detection) => {
538
496
  return lines.join('\n');
539
497
  };
540
498
 
541
- // The full argv vocabulary anything else rejects LOUDLY. The old parse silently routed unknown
542
- // args into the multi-line human render; with `--status-line` / `--active-line` (whose output is
543
- // pasted as fact) a mistyped flag masquerading as a mode would be a silent failure, so the parse is
544
- // strict now.
499
+ // Closed argv vocabulary: the mode outputs are pasted as fact, so a mistyped flag masquerading as a
500
+ // mode would be a silent failure.
545
501
  const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line', '--active-line']);
546
502
  const EXCLUSIVE_ARGS = ['--json', '--status-line', '--active-line']; // each owns stdout whole
547
503
 
548
- const main = async (argv) => {
504
+ // The readiness array EVERY mode composes: the detected backends plus the executor vehicle as the
505
+ // one carry provider. Two independent axes: the vehicle is surveyed first, and a bridge detector
506
+ // failure reaches `deps.onDetectError` exactly once (default: a loud stderr line) while the
507
+ // vehicle's readiness survives it — so a caller's fail-closed state for bridges never masks the
508
+ // carrier, and the reverse. `deps` is also the seam a test injects a fake survey or detector through.
509
+ export const composeReadiness = (cwd, deps = {}) => {
510
+ const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(cwd, deps);
511
+ let detection = [];
512
+ try {
513
+ detection = (deps.detect ?? detectBackends)();
514
+ } catch (err) {
515
+ (deps.onDetectError ?? ((e) => console.error(`[agent-workflow-kit] backend detection failed: ${e.message}`)))(err);
516
+ }
517
+ return withVehicle(detection, survey);
518
+ };
519
+
520
+ const main = async (argv, deps = {}) => {
549
521
  if (argv.includes('--help') || argv.includes('-h')) {
550
522
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
551
523
 
552
524
  Usage:
553
525
  node recipes.mjs [--json | --status-line | --active-line]
554
526
 
555
- Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
556
- detector, plans + recommends one for the current environment. --status-line prints exactly ONE
527
+ Lists the five recipes (Solo / Reviewed / Council / Delegated / Subagent) and, from the read-only
528
+ backend detector plus the executor-vehicle survey, plans + recommends one for the current
529
+ environment. --status-line prints exactly ONE
557
530
  line — the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim
558
531
  (incl. the per-activity autonomy segment: effective levels + render-sync state, honest
559
532
  computed-defaults wording when no policy file exists). --active-line prints exactly ONE line — the
@@ -574,31 +547,30 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
574
547
  console.error(`[agent-workflow-kit] ${exclusive.join(' and ')} are mutually exclusive — pick one output`);
575
548
  return 1;
576
549
  }
577
- const detection = detectBackends();
550
+ const cwd = process.cwd();
551
+ const readiness = composeReadiness(cwd, deps);
578
552
  if (argv.includes('--active-line')) {
579
- // Lazy import: orchestration-config.mjs statically imports this module (ACTIVITIES/SLOT_RECIPES),
580
- // so the config reader is pulled in at run time only — no static import cycle.
553
+ // Lazy: orchestration-config.mjs statically imports this module — no static cycle.
581
554
  const { loadConfig } = await import('./orchestration-config.mjs');
582
555
  try {
583
- console.log(composeActiveRecipeLine(loadConfig(process.cwd()), detection, await composeAutonomyFacts(process.cwd())));
556
+ console.log(composeActiveRecipeLine(loadConfig(cwd), readiness, await composeAutonomyFacts(cwd)));
584
557
  } catch (err) {
585
558
  console.error(`[agent-workflow-kit] ${err.message}`);
586
559
  return err.exitCode ?? 1;
587
560
  }
588
561
  } else if (argv.includes('--status-line')) {
589
562
  const snapshot = settingsSnapshot();
590
- console.log(composeStatusLine(detection, recommendRecipe(detection), snapshot, await composeAutonomyFacts(process.cwd()), composeConfiguredPosture({ settings: snapshot })));
563
+ console.log(composeStatusLine(readiness, recommendRecipe(readiness), snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot })));
591
564
  } else if (argv.includes('--json')) {
592
565
  const snapshot = settingsSnapshot();
593
- console.log(JSON.stringify(buildReport(detection, snapshot, await composeAutonomyFacts(process.cwd()), composeConfiguredPosture({ settings: snapshot })), null, 2));
566
+ console.log(JSON.stringify(buildReport(readiness, snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot })), null, 2));
594
567
  }
595
- else console.log(formatRecipes(detection));
568
+ else console.log(formatRecipes(readiness));
596
569
  return 0;
597
570
  };
598
571
 
599
- // Natural exit via process.exitCode — never process.exit inside the async main (it would drop buffered
600
- // stdio writes on piped stderr), and never a TOP-LEVEL await here: orchestration-config.mjs statically
601
- // imports this module, so awaiting the dynamic import during our own evaluation would deadlock the cycle.
572
+ // Natural exit via process.exitCode — never process.exit inside the async main (it would drop
573
+ // buffered stdio on piped stderr), and never a TOP-LEVEL await (that would deadlock the import cycle).
602
574
  if (isDirectRun(import.meta.url)) {
603
575
  main(process.argv.slice(2)).then(
604
576
  (code) => {