@sabaiway/agent-workflow-kit 1.11.0 → 1.13.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.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -17,8 +17,17 @@ import {
17
17
  AGENTS_MD_CAP,
18
18
  START_MARKER,
19
19
  END_MARKER,
20
+ ORCH_START_MARKER,
21
+ ORCH_END_MARKER,
22
+ ORCHESTRATION_DESCRIPTOR,
23
+ findMarkerSlot,
24
+ extractMarkerSlot,
20
25
  } from './inject-methodology.mjs';
21
26
 
27
+ // Read the orchestration slot's content from a reconciled entry point.
28
+ const extractOrch = (text) => extractMarkerSlot(text, ORCHESTRATION_DESCRIPTOR);
29
+ const hasOrchSlot = (text) => findMarkerSlot(text, ORCHESTRATION_DESCRIPTOR).state === 'ok';
30
+
22
31
  const HERE = dirname(fileURLToPath(import.meta.url));
23
32
  const SCRIPT = join(HERE, 'inject-methodology.mjs');
24
33
 
@@ -29,6 +38,9 @@ const SCRIPT = join(HERE, 'inject-methodology.mjs');
29
38
  // Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
30
39
  const FRAGMENT =
31
40
  '> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
41
+ // The SECOND bounded fragment (Plan 4) — distinct content so a test can tell which slot got which.
42
+ const ORCH_FRAGMENT =
43
+ '> **Orchestration recipes (test fixture)** — Solo / Reviewed / Council / Delegated; pick one with `/agent-workflow-kit recipes`.\n';
32
44
 
33
45
  // Temp dirs created by the fixtures below — cleaned up once after the whole file.
34
46
  const tmpDirs = [];
@@ -36,8 +48,9 @@ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })))
36
48
 
37
49
  // A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
38
50
  // whose metadata.version matches it (the validator's authoritative version source when there is no
39
- // package.json) + the live fragment at references/methodology-slot.md. detectEngine accepts it.
40
- const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
51
+ // package.json) + BOTH live fragments (methodology + orchestration). detectEngine accepts it; pass
52
+ // `orchFragment = null` to model an OLDER engine (<1.2.0) that ships no orchestration fragment.
53
+ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0', orchFragment = ORCH_FRAGMENT) => {
41
54
  const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
42
55
  tmpDirs.push(dir);
43
56
  const manifest = {
@@ -54,6 +67,7 @@ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
54
67
  writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
55
68
  mkdirSync(join(dir, 'references'), { recursive: true });
56
69
  writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
70
+ if (orchFragment != null) writeFileSync(join(dir, 'references', 'orchestration-slot.md'), orchFragment);
57
71
  return dir;
58
72
  };
59
73
 
@@ -66,6 +80,11 @@ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR:
66
80
  const wrap = (inner) =>
67
81
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
68
82
 
83
+ // An entry point carrying BOTH reconciled slots (the orchestration pair sits right under the
84
+ // methodology pair, as the descriptor anchors it) — the deployed shape after a dual-slot reconcile.
85
+ const wrapDual = (methInner, orchInner) =>
86
+ `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${methInner}${END_MARKER}\n${ORCH_START_MARKER}${orchInner}${ORCH_END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
87
+
69
88
  // The exact Session-Protocols line both deployed templates carry — the slot anchor.
70
89
  const ANCHOR_LINE =
71
90
  'Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**';
@@ -401,26 +420,32 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
401
420
  }
402
421
  };
403
422
 
404
- it('markerless legacy (with anchor) → slot inserted + filled from the live engine fragment (exit 0)', () => {
423
+ it('markerless legacy (with anchor) → BOTH slots inserted + filled from their own live engine fragments (exit 0)', () => {
405
424
  withTempAgents(legacyWithAnchor(), (agents) => {
406
425
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
407
426
  const out = readFileSync(agents, 'utf8');
408
427
  assert.equal(findSlot(out).state, 'ok');
409
- assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
428
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'methodology slot filled from the methodology fragment');
429
+ assert.ok(hasOrchSlot(out), 'the orchestration slot was inserted below the methodology pair');
430
+ assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim(), 'orchestration slot filled from the orchestration fragment');
410
431
  });
411
432
  });
412
433
 
413
- it('present empty slot → filled from the live engine fragment (exit 0)', () => {
434
+ it('present empty methodology slot → BOTH slots filled; each from its OWN engine fragment (exit 0)', () => {
414
435
  withTempAgents(wrap('\n'), (agents) => {
415
436
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
416
- assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
437
+ const out = readFileSync(agents, 'utf8');
438
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
439
+ assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim());
440
+ // Per-slot fragment sourcing: the two slots carry DIFFERENT content (not both the methodology one).
441
+ assert.notEqual(extractSlot(out).trim(), extractOrch(out).trim());
417
442
  });
418
443
  });
419
444
 
420
- it('filled/customized slot → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
421
- const custom = wrap('\nuser notes\n');
445
+ it('BOTH slots already filled → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
446
+ const custom = wrapDual('\nuser meth notes\n', '\nuser orch notes\n');
422
447
  withTempAgents(custom, (agents) => {
423
- // Engine pointed at a path that does not exist — a filled slot must NOT require it.
448
+ // Engine pointed at a path that does not exist — a fully-filled entry point must NOT require it.
424
449
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
425
450
  assert.equal(readFileSync(agents, 'utf8'), custom);
426
451
  });
@@ -444,7 +469,7 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
444
469
  });
445
470
  });
446
471
 
447
- it('explicit [fragment.md] override fills from that file and skips engine resolution (engine absent)', () => {
472
+ it('explicit [fragment.md] override fills methodology ONLY and skips both engine + the orchestration slot', () => {
448
473
  const override = '> custom override fragment line\n';
449
474
  const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
450
475
  tmpDirs.push(fdir);
@@ -452,7 +477,98 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
452
477
  writeFileSync(fpath, override);
453
478
  withTempAgents(wrap('\n'), (agents) => {
454
479
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
455
- assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), override.trim());
480
+ const out = readFileSync(agents, 'utf8');
481
+ assert.equal(extractSlot(out).trim(), override.trim(), 'methodology filled from the explicit fragment');
482
+ assert.ok(!hasOrchSlot(out), 'an explicit single-file override binds methodology only — no orchestration slot added');
483
+ });
484
+ });
485
+
486
+ it('dual-block cap → orchestration pointer is SOFT-skipped (reported), the file is byte-unchanged (exit 0)', () => {
487
+ // A 100-line entry point with a FILLED methodology slot + an EMPTY orchestration slot: filling the
488
+ // orchestration slot would push it to 101 > 100, so it is skipped loudly while methodology stays.
489
+ const head = [
490
+ '# AGENTS.md',
491
+ '',
492
+ '## Session Protocols',
493
+ '',
494
+ 'Read it before any code change.',
495
+ '',
496
+ START_MARKER,
497
+ FRAGMENT.trim(),
498
+ END_MARKER,
499
+ ORCH_START_MARKER,
500
+ ORCH_END_MARKER,
501
+ ];
502
+ const pad = Array.from({ length: AGENTS_MD_CAP - head.length }, (_, i) => `pad line ${i}`);
503
+ const atCap = [...head, ...pad].join('\n') + '\n';
504
+ withTempAgents(atCap, (agents) => {
505
+ const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(ENGINE) });
506
+ assert.match(out, /skipped/i, 'the orchestration cap-skip is reported, not silent');
507
+ assert.equal(readFileSync(agents, 'utf8'), atCap, 'file byte-unchanged — the orchestration pointer was withheld, methodology already present');
508
+ });
509
+ });
510
+
511
+ it('malformed orchestration pair → hard STOP (nonzero), file byte-unchanged (methodology fine)', () => {
512
+ const malformedOrch =
513
+ `# AGENTS.md\n\nintro line.\n\n${START_MARKER}\n${FRAGMENT.trim()}\n${END_MARKER}\n` +
514
+ `${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n`;
515
+ withTempAgents(malformedOrch, (agents) => {
516
+ assert.throws(() =>
517
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) }),
518
+ );
519
+ assert.equal(readFileSync(agents, 'utf8'), malformedOrch, 'no partial write on an orchestration STOP');
520
+ });
521
+ });
522
+
523
+ it('engine too old (no orchestration fragment) → methodology filled, orchestration SOFT-skipped (exit 0, not a regression)', () => {
524
+ // A VALID engine that ships methodology-slot.md but NOT orchestration-slot.md (i.e. <1.2.0). The
525
+ // methodology fill must NOT be discarded — only the recipes pointer is withheld, reported, exit 0.
526
+ const oldEngine = makeEngineFixture(FRAGMENT, '1.0.0', null);
527
+ withTempAgents(wrap('\n'), (agents) => {
528
+ const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(oldEngine) });
529
+ const text = readFileSync(agents, 'utf8');
530
+ assert.equal(extractSlot(text).trim(), FRAGMENT.trim(), 'methodology pointer filled (not discarded by the too-old engine)');
531
+ assert.match(out, /skipped/i, 'the orchestration skip is reported (not silent)');
532
+ assert.match(out, /too old/i, 'the skip names the too-old-engine reason');
533
+ assert.ok(!hasOrchSlot(text), 'a too-old engine adds no orchestration pointer');
534
+ });
535
+ });
536
+
537
+ it('orchestration fragment PRESENT but unreadable → hard STOP (a corrupt engine is NOT mislabeled "too old")', () => {
538
+ if (process.getuid && process.getuid() === 0) return; // root bypasses 0o000 perms — can't restrict
539
+ const corruptEngine = makeEngineFixture(FRAGMENT, '1.2.0', '> orchestration line\n'); // both fragments present
540
+ const orchPath = join(corruptEngine, 'references', 'orchestration-slot.md');
541
+ chmodSync(orchPath, 0o000);
542
+ let restricted = false;
543
+ try {
544
+ readFileSync(orchPath, 'utf8');
545
+ } catch {
546
+ restricted = true;
547
+ }
548
+ if (!restricted) {
549
+ chmodSync(orchPath, 0o644); // exotic FS / perms ignored → can't exercise this path here
550
+ return;
551
+ }
552
+ try {
553
+ withTempAgents(wrap('\n'), (agents) => {
554
+ assert.throws(() =>
555
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(corruptEngine) }),
556
+ );
557
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), '', 'no partial write on a corrupt-fragment STOP');
558
+ });
559
+ } finally {
560
+ chmodSync(orchPath, 0o644); // restore so the after() cleanup can remove the fixture
561
+ }
562
+ });
563
+
564
+ it('fully absent engine + a fill needed → hard STOP (methodology fragment also unavailable)', () => {
565
+ // Distinct from the too-old case: a fully absent engine cannot supply EITHER fragment, so the
566
+ // methodology slot fill itself STOPs first — the dual-slot reconcile never silently no-ops.
567
+ withTempAgents(wrap('\n'), (agents) => {
568
+ assert.throws(() =>
569
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
570
+ );
571
+ assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write when the engine is fully absent');
456
572
  });
457
573
  });
458
574
 
@@ -77,7 +77,9 @@ const readSkillVersion = (text) => {
77
77
 
78
78
  // Authoritative version source: package.json where one exists, else SKILL.md
79
79
  // frontmatter metadata.version. So a bridge (no package.json) can't drift from its SKILL.md.
80
- const readAuthoritativeVersion = (skillDir) => {
80
+ // Exported so the family registry (tools/family-registry.mjs) reports an INSTALLED member's
81
+ // version from the SAME authoritative source the validator checks — no second, drifting reader.
82
+ export const readAuthoritativeVersion = (skillDir) => {
81
83
  const pkgPath = join(skillDir, 'package.json');
82
84
  if (existsSync(pkgPath)) {
83
85
  try {
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // Recipe planner — the pure brain behind the read-only `/agent-workflow-kit recipes` advisor.
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 >= 18.
16
+
17
+ import { pathToFileURL } from 'node:url';
18
+ import {
19
+ detectBackends,
20
+ READY,
21
+ NEEDS_SKILL,
22
+ NEEDS_CLI,
23
+ NEEDS_CREDENTIALS,
24
+ DEGRADED,
25
+ } from './detect-backends.mjs';
26
+
27
+ const CODEX = 'codex-cli-bridge';
28
+ const AGY = 'antigravity-cli-bridge';
29
+
30
+ // The manifest-name → human-alias map (the detector emits manifest names; humans say codex/agy).
31
+ export const DISPLAY_ALIASES = { [CODEX]: 'codex', [AGY]: 'agy' };
32
+
33
+ // The backend → role table, keyed by the manifest name the detector emits (status.name) — NOT the
34
+ // display alias. Drift-guarded against each bridge capability.json `provides[]` (recipes.test.mjs).
35
+ export const BACKEND_ROLES = {
36
+ [CODEX]: ['execute', 'review'],
37
+ [AGY]: ['review', 'probe'],
38
+ };
39
+
40
+ // Advisory metadata the DETECTION object does not carry (it has no cost/quota — those live only in
41
+ // capability.json). cost/quota are drift-guarded against the manifests; the agy `health` advisory is
42
+ // static project knowledge (Issue-001: the Antigravity service can stall on substantive prompts —
43
+ // invisible to file-presence detection, so it is NOT a readiness signal, only a standing caveat).
44
+ export const BACKEND_META = {
45
+ [CODEX]: { cost: 'subscription', quota: { kind: 'subscription', finite: true } },
46
+ [AGY]: {
47
+ cost: 'subscription',
48
+ quota: { kind: 'subscription', finite: true },
49
+ health: 'Note: the Antigravity service may stall on substantive prompts (Issue-001) — prefer codex while it lasts.',
50
+ },
51
+ };
52
+
53
+ // Deterministic tie-break order: codex before agy (agy carries the standing health caveat above).
54
+ const BACKEND_PRIORITY = [CODEX, AGY];
55
+ const priorityIndex = (name) => {
56
+ const i = BACKEND_PRIORITY.indexOf(name);
57
+ return i === -1 ? BACKEND_PRIORITY.length : i;
58
+ };
59
+
60
+ // The four recipes, in lattice order. `role` is the backend role a recipe needs (null = Solo, no
61
+ // backend); `minBackends` is how many READY providers it needs; `degradesTo` is the next-weaker
62
+ // recipe when it can't be satisfied (the chain terminates at Solo, which is always satisfiable).
63
+ export const RECIPES = [
64
+ {
65
+ id: 'solo',
66
+ title: 'Solo',
67
+ role: null,
68
+ minBackends: 0,
69
+ degradesTo: null,
70
+ summary: 'the orchestrator plans, executes, and self-reviews — no backend (always available; the floor).',
71
+ },
72
+ {
73
+ id: 'reviewed',
74
+ title: 'Reviewed',
75
+ role: 'review',
76
+ minBackends: 1,
77
+ degradesTo: 'solo',
78
+ summary: 'the orchestrator executes; one backend reviews the result (advisory). Prefers codex when both are ready.',
79
+ },
80
+ {
81
+ id: 'council',
82
+ title: 'Council',
83
+ role: 'review',
84
+ minBackends: 2,
85
+ degradesTo: 'reviewed',
86
+ summary: 'both backends review independently; the orchestrator synthesizes the two opinions.',
87
+ },
88
+ {
89
+ id: 'delegated',
90
+ title: 'Delegated',
91
+ role: 'execute',
92
+ minBackends: 1,
93
+ degradesTo: 'solo',
94
+ summary: 'the orchestrator hands a bounded execution sub-task to a backend (codex exec), then reviews the diff and commits.',
95
+ },
96
+ ];
97
+
98
+ const recipeById = (id) => RECIPES.find((r) => r.id === id);
99
+
100
+ // The human reason a non-ready readiness yields (read-only file-presence remedies — never a claim
101
+ // about whether the backend's service is responsive).
102
+ const READINESS_REASON = {
103
+ [NEEDS_SKILL]: 'bridge skill not installed — run /agent-workflow-kit setup',
104
+ [NEEDS_CLI]: 'the CLI is not installed',
105
+ [NEEDS_CREDENTIALS]: 'not signed in (credentials missing)',
106
+ [DEGRADED]: 'wrapper not on PATH — run /agent-workflow-kit setup',
107
+ };
108
+
109
+ // ── pure planner ───────────────────────────────────────────────────────────────
110
+
111
+ // Backends (ready or not) whose role table includes `role`.
112
+ const providersOf = (role, detection) => detection.filter((b) => (BACKEND_ROLES[b.name] ?? []).includes(role));
113
+
114
+ // READY providers of `role`, in deterministic priority order (codex before agy) → an array of names.
115
+ const readyProvidersOf = (role, detection) =>
116
+ providersOf(role, detection)
117
+ .filter((b) => b.readiness === READY)
118
+ .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
119
+ .map((b) => b.name);
120
+
121
+ // Availability = readiness === READY, full stop. A recipe is satisfiable iff it needs no backend OR
122
+ // enough READY providers of its role exist.
123
+ const isSatisfiable = (recipe, detection) =>
124
+ recipe.role === null || readyProvidersOf(recipe.role, detection).length >= recipe.minBackends;
125
+
126
+ // Why a recipe can't run as-is — the specific not-ready providers and their readiness-derived reasons.
127
+ const degradeReason = (recipe, detection) => {
128
+ const providers = providersOf(recipe.role, detection);
129
+ if (providers.length === 0) {
130
+ return `${recipe.title} needs a backend providing ${recipe.role}, but no backend provides it`;
131
+ }
132
+ const ready = providers.filter((b) => b.readiness === READY);
133
+ const detail = providers
134
+ .filter((b) => b.readiness !== READY)
135
+ .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`)
136
+ .join('; ');
137
+ return `${recipe.title} needs ${recipe.minBackends} backend(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
138
+ };
139
+
140
+ // Per-stage dispatch for an EFFECTIVE (already-satisfiable) recipe: the first `minBackends` READY
141
+ // providers, in priority order. Solo dispatches nothing (the orchestrator does it all).
142
+ const dispatchFor = (recipe, detection) => {
143
+ if (recipe.role === null) return [];
144
+ return readyProvidersOf(recipe.role, detection)
145
+ .slice(0, recipe.minBackends)
146
+ .map((name) => ({ role: recipe.role, backend: name, display: DISPLAY_ALIASES[name] }));
147
+ };
148
+
149
+ const QUOTA_NOTE = "Prefer the cheapest model that fits the task; don't reach for a top-tier model by reflex.";
150
+ const COUNCIL_QUOTA_NOTE = "Council spends two backends' quota for one decision — reserve it for changes that justify the cost.";
151
+
152
+ // Advisory notes for an effective recipe: a quota reminder when any backend is dispatched, the
153
+ // two-quota caveat for Council, and the agy health advisory whenever the dispatch actually uses agy.
154
+ const notesFor = (recipe, dispatch) => {
155
+ const notes = [];
156
+ if (dispatch.length > 0) notes.push(QUOTA_NOTE);
157
+ if (recipe.id === 'council') notes.push(COUNCIL_QUOTA_NOTE);
158
+ if (dispatch.some((d) => d.backend === AGY) && BACKEND_META[AGY].health) notes.push(BACKEND_META[AGY].health);
159
+ return notes;
160
+ };
161
+
162
+ // planRecipe(recipe, detection) → pure plan. `recipe` is a recipe id or descriptor. Walks the
163
+ // degradation chain (with a stated reason per step) until a satisfiable recipe is reached, then emits
164
+ // the per-stage dispatch + advisory notes. Deterministic; never mutates the detection input.
165
+ export const planRecipe = (recipe, detection) => {
166
+ const requested = typeof recipe === 'string' ? recipeById(recipe) : recipe;
167
+ if (!requested) throw new Error(`unknown recipe: ${recipe}`);
168
+ let current = requested;
169
+ const degradation = [];
170
+ while (!isSatisfiable(current, detection)) {
171
+ const next = recipeById(current.degradesTo);
172
+ degradation.push({ from: current.id, to: next.id, reason: degradeReason(current, detection) });
173
+ current = next;
174
+ }
175
+ const dispatch = dispatchFor(current, detection);
176
+ return {
177
+ requested: requested.id,
178
+ effective: current.id,
179
+ degraded: current.id !== requested.id,
180
+ degradation,
181
+ dispatch,
182
+ notes: notesFor(current, dispatch),
183
+ };
184
+ };
185
+
186
+ // How close to ready a non-ready backend is — used to surface the most-actionable remedy first.
187
+ const READINESS_RANK = { [DEGRADED]: 3, [NEEDS_CREDENTIALS]: 2, [NEEDS_CLI]: 1, [NEEDS_SKILL]: 0 };
188
+ const READINESS_REMEDY = {
189
+ [NEEDS_SKILL]: 'run /agent-workflow-kit setup',
190
+ [NEEDS_CLI]: 'install its CLI',
191
+ [NEEDS_CREDENTIALS]: 'sign in',
192
+ [DEGRADED]: 'run /agent-workflow-kit setup (wrapper not on PATH)',
193
+ };
194
+
195
+ // recommendRecipe(detection) → { recipe, clause }. Never blank: both ready → Council (Reviewed the
196
+ // everyday default); one ready → Reviewed; none installed → Solo + a setup pointer; a backend
197
+ // present-but-not-ready → Solo with that backend's specific remedy. Pure.
198
+ export const recommendRecipe = (detection) => {
199
+ const readyReview = readyProvidersOf('review', detection);
200
+ if (readyReview.length >= 2) {
201
+ return { recipe: 'council', clause: 'Council available, Reviewed the everyday default' };
202
+ }
203
+ if (readyReview.length === 1) {
204
+ return { recipe: 'reviewed', clause: `Reviewed available (via ${DISPLAY_ALIASES[readyReview[0]]})` };
205
+ }
206
+ // No ready reviewer → Solo. Say how to unlock more: a present-but-not-ready backend names its
207
+ // remedy; nothing installed names the setup pointer.
208
+ const present = detection.filter((b) => b.readiness !== NEEDS_SKILL && b.readiness !== READY);
209
+ if (present.length === 0) {
210
+ return { recipe: 'solo', clause: 'Solo — run /agent-workflow-kit setup to add a backend' };
211
+ }
212
+ // Rank by how close to ready; break ties with the SAME codex-before-agy priority the dispatch path
213
+ // uses (priorityIndex) so the recommendation is deterministic regardless of detection emission order.
214
+ const best = [...present].sort(
215
+ (a, b) => (READINESS_RANK[b.readiness] ?? -1) - (READINESS_RANK[a.readiness] ?? -1) || priorityIndex(a.name) - priorityIndex(b.name),
216
+ )[0];
217
+ const remedy = READINESS_REMEDY[best.readiness] ?? best.readiness;
218
+ return { recipe: 'solo', clause: `Solo — ${DISPLAY_ALIASES[best.name] ?? best.name}: ${remedy} to unlock Reviewed` };
219
+ };
220
+
221
+ // ── report + CLI ─────────────────────────────────────────────────────────────────
222
+
223
+ // The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
224
+ export const buildReport = (detection) => ({
225
+ recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
226
+ id,
227
+ title,
228
+ role,
229
+ minBackends,
230
+ degradesTo,
231
+ summary,
232
+ })),
233
+ recommendation: recommendRecipe(detection),
234
+ plans: RECIPES.map((r) => planRecipe(r.id, detection)),
235
+ });
236
+
237
+ // formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
238
+ // and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
239
+ export const formatRecipes = (detection) => {
240
+ const lines = [
241
+ 'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills and always commits)',
242
+ '',
243
+ ];
244
+ for (const r of RECIPES) lines.push(` ${r.title} (${r.id}) — ${r.summary}`);
245
+ const rec = recommendRecipe(detection);
246
+ lines.push('', `recommended here: ${rec.recipe} — ${rec.clause}`, '', 'plan for the current environment:');
247
+ for (const r of RECIPES) {
248
+ const p = planRecipe(r.id, detection);
249
+ const arrow = p.degraded ? ` → ${p.effective}` : '';
250
+ const who = p.dispatch.length ? p.dispatch.map((d) => `${d.display} ${d.role}`).join(', ') : 'orchestrator only';
251
+ lines.push(` ${r.title}${arrow}: ${who}`);
252
+ for (const step of p.degradation) lines.push(` ↳ ${step.reason}`);
253
+ for (const note of p.notes) lines.push(` • ${note}`);
254
+ }
255
+ return lines.join('\n');
256
+ };
257
+
258
+ const main = (argv) => {
259
+ if (argv.includes('--help') || argv.includes('-h')) {
260
+ console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
261
+
262
+ Usage:
263
+ node recipes.mjs [--json]
264
+
265
+ Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
266
+ detector, plans + recommends one for the current environment. Detection only — never writes, never
267
+ commits, never runs a subscription CLI.`);
268
+ return;
269
+ }
270
+ const detection = detectBackends();
271
+ if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
272
+ else console.log(formatRecipes(detection));
273
+ };
274
+
275
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
276
+ if (isDirectRun) main(process.argv.slice(2));