@sabaiway/agent-workflow-kit 1.12.0 → 1.14.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.
@@ -0,0 +1,303 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { main, extractSection, CONFIG_REL } from './procedures.mjs';
8
+ import { READY, NEEDS_SKILL } from './detect-backends.mjs';
9
+
10
+ // Host-independent fixtures: a temp cwd for the config + the REPO's OWN engine via
11
+ // AGENT_WORKFLOW_ENGINE_DIR (it ships references/procedures.md, so the live read is deterministic and
12
+ // needs no separate engine fixture) + an INJECTED synthetic detection (ctx.detect) so the resolved
13
+ // recipe never depends on which backends the test host happens to have installed.
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const ENGINE_DIR = join(HERE, '..', '..', 'agent-workflow-engine');
16
+ const CODEX = 'codex-cli-bridge';
17
+ const AGY = 'antigravity-cli-bridge';
18
+ const detect = (codex, agy) => () => [
19
+ { name: CODEX, readiness: codex },
20
+ { name: AGY, readiness: agy },
21
+ ];
22
+
23
+ let cwd;
24
+ beforeEach(() => {
25
+ cwd = mkdtempSync(join(tmpdir(), 'procedures-cwd-'));
26
+ mkdirSync(join(cwd, 'docs', 'ai'), { recursive: true });
27
+ });
28
+ afterEach(() => {
29
+ rmSync(cwd, { recursive: true, force: true });
30
+ });
31
+
32
+ const writeConfig = (json) => writeFileSync(join(cwd, CONFIG_REL), json);
33
+ // Run main() with the repo engine + an injected detection; config comes from the temp cwd.
34
+ const run = (argv, { codex = READY, agy = READY } = {}) =>
35
+ main(argv, { cwd, env: { AGENT_WORKFLOW_ENGINE_DIR: ENGINE_DIR }, detect: detect(codex, agy) });
36
+
37
+ describe('procedures CLI — happy path (section verbatim + resolved recipe)', () => {
38
+ it('plan-authoring prints the canon section + the resolved review recipe, exit 0', () => {
39
+ const r = run(['plan-authoring'], { codex: READY, agy: NEEDS_SKILL });
40
+ assert.equal(r.code, 0, r.stderr);
41
+ assert.match(r.stdout, /## plan-authoring/);
42
+ assert.match(r.stdout, /Slots: review/);
43
+ assert.match(r.stdout, /resolved recipes for "plan-authoring"/);
44
+ assert.match(r.stdout, /review: reviewed — computed default/);
45
+ });
46
+
47
+ it('plan-execution resolves BOTH slots (execute then review)', () => {
48
+ const r = run(['plan-execution'], { codex: READY, agy: NEEDS_SKILL });
49
+ assert.equal(r.code, 0, r.stderr);
50
+ assert.match(r.stdout, /## plan-execution/);
51
+ assert.match(r.stdout, /Slots: execute, review/);
52
+ assert.match(r.stdout, /execute: solo — computed default/);
53
+ assert.match(r.stdout, /review: reviewed — computed default/);
54
+ });
55
+
56
+ it('section extraction is scoped to the requested activity (no sibling section bleeds in)', () => {
57
+ const r = run(['plan-execution'], { codex: READY, agy: READY });
58
+ assert.ok(r.stdout.includes('## plan-execution'));
59
+ assert.ok(!r.stdout.includes('## plan-authoring'), 'only the requested activity section is printed');
60
+ });
61
+ });
62
+
63
+ describe('procedures CLI — config IO (§2.2)', () => {
64
+ it('absent config → computed defaults, stated as configSource:none', () => {
65
+ const r = run(['plan-authoring', '--json'], { codex: NEEDS_SKILL, agy: NEEDS_SKILL });
66
+ assert.equal(r.code, 0, r.stderr);
67
+ const j = JSON.parse(r.stdout);
68
+ assert.equal(j.configSource, 'none');
69
+ assert.equal(j.slots.review.source, 'default');
70
+ assert.equal(j.slots.review.recipe, 'solo', 'no ready backend → review defaults to solo');
71
+ });
72
+
73
+ it('a valid config drives the slot (execute=delegated honoured when codex is ready)', () => {
74
+ writeConfig(JSON.stringify({ _README: 'composition-root config', 'plan-execution': { execute: 'delegated' } }));
75
+ const r = run(['plan-execution', '--json'], { codex: READY, agy: NEEDS_SKILL });
76
+ assert.equal(r.code, 0, r.stderr);
77
+ const j = JSON.parse(r.stdout);
78
+ assert.equal(j.configSource, CONFIG_REL);
79
+ assert.equal(j.slots.execute.recipe, 'delegated');
80
+ assert.equal(j.slots.execute.source, 'config');
81
+ });
82
+
83
+ it('malformed JSON → loud `path: malformed JSON …`, exit 1', () => {
84
+ writeConfig('{ not valid json');
85
+ const r = run(['plan-authoring']);
86
+ assert.equal(r.code, 1);
87
+ assert.match(r.stderr, new RegExp(`${CONFIG_REL}: malformed JSON`));
88
+ });
89
+
90
+ it('schema-invalid (recipe not allowed for the slot) → loud `path: invalid recipe …`, exit 1', () => {
91
+ writeConfig(JSON.stringify({ 'plan-authoring': { review: 'delegated' } }));
92
+ const r = run(['plan-authoring']);
93
+ assert.equal(r.code, 1);
94
+ assert.match(r.stderr, /invalid recipe "delegated" for review slot of "plan-authoring"/);
95
+ });
96
+
97
+ it('schema-invalid (unknown activity) → exit 1', () => {
98
+ writeConfig(JSON.stringify({ 'plan-foo': { review: 'reviewed' } }));
99
+ const r = run(['plan-authoring']);
100
+ assert.equal(r.code, 1);
101
+ assert.match(r.stderr, /unknown activity "plan-foo"/);
102
+ });
103
+
104
+ it('schema-invalid (unknown slot) → exit 1', () => {
105
+ writeConfig(JSON.stringify({ 'plan-authoring': { execute: 'solo' } }));
106
+ const r = run(['plan-authoring']);
107
+ assert.equal(r.code, 1);
108
+ assert.match(r.stderr, /unknown slot "execute" for activity "plan-authoring"/);
109
+ });
110
+
111
+ it('unreadable config (a directory in its place → EISDIR) → loud `path: unreadable …`, exit 1', () => {
112
+ mkdirSync(join(cwd, CONFIG_REL)); // orchestration.json IS a dir → readFileSync throws
113
+ const r = run(['plan-authoring']);
114
+ assert.equal(r.code, 1);
115
+ assert.match(r.stderr, new RegExp(`${CONFIG_REL}: unreadable`));
116
+ });
117
+
118
+ it('a DANGLING symlink at the config path is unreadable (exit 1), NOT silently treated as absent', () => {
119
+ // A broken config symlink is a present-but-broken config — surface it loudly, never fall through to
120
+ // defaults (no-silent-failures). lstat sees the link; readFileSync follows it to a missing target.
121
+ symlinkSync(join(cwd, 'nowhere.json'), join(cwd, CONFIG_REL));
122
+ const r = run(['plan-authoring']);
123
+ assert.equal(r.code, 1);
124
+ assert.match(r.stderr, new RegExp(`${CONFIG_REL}: unreadable`));
125
+ });
126
+ });
127
+
128
+ describe('procedures CLI — usage errors → exit 2', () => {
129
+ it('unknown <activity> → exit 2', () => {
130
+ const r = run(['plan-foo']);
131
+ assert.equal(r.code, 2);
132
+ assert.match(r.stderr, /unknown activity "plan-foo"/);
133
+ });
134
+
135
+ it('missing <activity> → exit 2', () => {
136
+ const r = run([]);
137
+ assert.equal(r.code, 2);
138
+ assert.match(r.stderr, /missing <activity>/);
139
+ });
140
+
141
+ it('a bare --override <recipe> (no slot) → exit 2', () => {
142
+ const r = run(['plan-authoring', '--override', 'council']);
143
+ assert.equal(r.code, 2);
144
+ assert.match(r.stderr, /--override must be <slot>=<recipe>/);
145
+ });
146
+
147
+ it('--override with an unknown slot for the activity → exit 2', () => {
148
+ const r = run(['plan-authoring', '--override', 'execute=delegated']);
149
+ assert.equal(r.code, 2);
150
+ assert.match(r.stderr, /unknown slot "execute" for activity "plan-authoring"/);
151
+ });
152
+
153
+ it('--override with a recipe invalid for the slot → exit 2', () => {
154
+ const r = run(['plan-authoring', '--override', 'review=delegated']);
155
+ assert.equal(r.code, 2);
156
+ assert.match(r.stderr, /invalid recipe "delegated" for review slot/);
157
+ });
158
+
159
+ it('a duplicate --override for the same slot → exit 2', () => {
160
+ const r = run(['plan-execution', '--override', 'review=council', '--override', 'review=solo']);
161
+ assert.equal(r.code, 2);
162
+ assert.match(r.stderr, /duplicate override for slot "review"/);
163
+ });
164
+ });
165
+
166
+ describe('procedures CLI — override resolution (degrades loudly, still exit 0)', () => {
167
+ it('an UNSATISFIABLE explicit override degrades loudly and exits 0 with a warning', () => {
168
+ // council needs two ready reviewers; only codex is ready → degrade to reviewed, flagged loud.
169
+ const r = run(['plan-authoring', '--override', 'review=council', '--json'], { codex: READY, agy: NEEDS_SKILL });
170
+ assert.equal(r.code, 0, r.stderr);
171
+ const j = JSON.parse(r.stdout);
172
+ assert.equal(j.slots.review.recipe, 'reviewed');
173
+ assert.equal(j.slots.review.degradedFrom, 'council');
174
+ assert.equal(j.slots.review.source, 'override');
175
+ assert.equal(j.warnings.length, 1, 'an unsatisfiable override is surfaced as a loud warning');
176
+ assert.match(j.warnings[0], /could not be satisfied/);
177
+ });
178
+
179
+ it('the same override in human mode prints a ⚠ warning line', () => {
180
+ const r = run(['plan-authoring', '--override', 'review=council'], { codex: READY, agy: NEEDS_SKILL });
181
+ assert.equal(r.code, 0);
182
+ assert.match(r.stdout, /warnings:/);
183
+ assert.match(r.stdout, /⚠/);
184
+ });
185
+
186
+ it('a satisfiable override holds with no warning (exit 0)', () => {
187
+ const r = run(['plan-authoring', '--override', 'review=council', '--json'], { codex: READY, agy: READY });
188
+ assert.equal(r.code, 0);
189
+ const j = JSON.parse(r.stdout);
190
+ assert.equal(j.slots.review.recipe, 'council');
191
+ assert.equal(j.warnings.length, 0);
192
+ });
193
+ });
194
+
195
+ describe('procedures CLI — a backend-detection failure does NOT break activity resolution', () => {
196
+ // A corrupt / unreadable bridge can make the detector throw. Detection is a SECONDARY input (it only
197
+ // refines the recipe), so a throw must NOT surface as a config/engine error (exit 1) — resolution
198
+ // floors at Solo and the failure is a loud warning, exit 0.
199
+ const throwingDetect = () => {
200
+ throw Object.assign(new Error('corrupt bridge manifest (EISDIR)'), { code: 'EISDIR' });
201
+ };
202
+
203
+ it('detect() throwing → exit 0, a warning, and every slot floors at solo', () => {
204
+ const r = main(['plan-execution', '--json'], { cwd, env: { AGENT_WORKFLOW_ENGINE_DIR: ENGINE_DIR }, detect: throwingDetect });
205
+ assert.equal(r.code, 0, r.stderr);
206
+ const j = JSON.parse(r.stdout);
207
+ assert.equal(j.slots.execute.recipe, 'solo');
208
+ assert.equal(j.slots.review.recipe, 'solo');
209
+ assert.ok(j.warnings.some((w) => /backend detection failed/.test(w)), 'the detection failure is surfaced as a warning');
210
+ });
211
+
212
+ it('the same failure in human mode prints a ⚠ warning, still exit 0', () => {
213
+ const r = main(['plan-authoring'], { cwd, env: { AGENT_WORKFLOW_ENGINE_DIR: ENGINE_DIR }, detect: throwingDetect });
214
+ assert.equal(r.code, 0);
215
+ assert.match(r.stdout, /backend detection failed/);
216
+ assert.match(r.stdout, /review: solo/);
217
+ });
218
+ });
219
+
220
+ describe('procedures CLI — --json schema (§2.0)', () => {
221
+ it('emits activity, section, per-slot resolution, configSource, warnings', () => {
222
+ const r = run(['plan-execution', '--json'], { codex: READY, agy: NEEDS_SKILL });
223
+ assert.equal(r.code, 0, r.stderr);
224
+ const j = JSON.parse(r.stdout);
225
+ assert.deepEqual(Object.keys(j).sort(), ['activity', 'configSource', 'section', 'slots', 'warnings'].sort());
226
+ assert.equal(j.activity, 'plan-execution');
227
+ assert.match(j.section, /## plan-execution/);
228
+ for (const slot of ['execute', 'review']) {
229
+ assert.ok(j.slots[slot], `slot ${slot} present`);
230
+ assert.deepEqual(Object.keys(j.slots[slot]).sort(), ['degradedFrom', 'reason', 'recipe', 'source'].sort());
231
+ }
232
+ assert.ok(Array.isArray(j.warnings));
233
+ });
234
+ });
235
+
236
+ describe('procedures CLI — --help is read-only and exits 0', () => {
237
+ it('prints usage naming both activities and exits 0', () => {
238
+ const r = run(['--help']);
239
+ assert.equal(r.code, 0);
240
+ assert.match(r.stdout, /plan-authoring/);
241
+ assert.match(r.stdout, /plan-execution/);
242
+ assert.match(r.stdout, /never commits/);
243
+ });
244
+ });
245
+
246
+ // §4.0 — an installed engine too old to ship references/procedures.md must FAIL LOUDLY (exit 1 with a
247
+ // clear "upgrade the engine" message), never a cryptic read error. A temp fixture models a VALID
248
+ // methodology-engine that ships every fragment EXCEPT procedures.md (i.e. engine < 1.3.0).
249
+ describe('procedures CLI — engine too old (no procedures.md) → loud exit 1', () => {
250
+ const makeOldEngine = () => {
251
+ const dir = mkdtempSync(join(tmpdir(), 'old-engine-'));
252
+ const manifest = {
253
+ family: 'agent-workflow',
254
+ schema: 1,
255
+ name: 'agent-workflow-engine',
256
+ kind: 'methodology-engine',
257
+ version: '1.2.0',
258
+ available: true,
259
+ provides: ['plan'],
260
+ roles: {},
261
+ };
262
+ writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifest, null, 2));
263
+ writeFileSync(join(dir, 'SKILL.md'), "---\nname: agent-workflow-engine\nmetadata:\n version: '1.2.0'\n---\n# engine\n");
264
+ mkdirSync(join(dir, 'references'), { recursive: true });
265
+ writeFileSync(join(dir, 'references', 'methodology-slot.md'), '> methodology fragment\n');
266
+ // deliberately NO references/procedures.md
267
+ return dir;
268
+ };
269
+
270
+ it('exits 1 with an upgrade-the-engine message (not a cryptic fs error)', () => {
271
+ const oldEngine = makeOldEngine();
272
+ try {
273
+ const r = main(['plan-authoring'], { cwd, env: { AGENT_WORKFLOW_ENGINE_DIR: oldEngine }, detect: detect(READY, READY) });
274
+ assert.equal(r.code, 1);
275
+ assert.match(r.stderr, /procedures\.md/, 'the error names the missing fragment');
276
+ assert.match(r.stderr, /upgrade the engine|@latest init/i, 'the error tells the user to upgrade the engine');
277
+ } finally {
278
+ rmSync(oldEngine, { recursive: true, force: true });
279
+ }
280
+ });
281
+ });
282
+
283
+ describe('extractSection (unit) — boundary + verbatim', () => {
284
+ const FIXTURE = ['# Title', '', '## plan-authoring', '', 'Slots: review', '', 'step one', '', '## plan-execution', '', 'Slots: execute, review', '', 'step two', ''].join('\n');
285
+
286
+ it('returns the requested section, heading-to-next-heading', () => {
287
+ const sec = extractSection(FIXTURE, 'plan-authoring');
288
+ assert.match(sec, /## plan-authoring/);
289
+ assert.match(sec, /Slots: review/);
290
+ assert.match(sec, /step one/);
291
+ assert.ok(!sec.includes('plan-execution'), 'stops before the next ## heading');
292
+ });
293
+
294
+ it('extracts the LAST section to EOF', () => {
295
+ const sec = extractSection(FIXTURE, 'plan-execution');
296
+ assert.match(sec, /step two/);
297
+ assert.ok(!sec.includes('plan-authoring'));
298
+ });
299
+
300
+ it('throws (engine-too-old) when the activity section is absent', () => {
301
+ assert.throws(() => extractSection(FIXTURE, 'plan-nope'), /has no "## plan-nope" section/);
302
+ });
303
+ });
@@ -0,0 +1,340 @@
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
+ // ── activity procedures: per-slot recipe resolution ────────────────────────────────
222
+
223
+ // The named activities and their typed recipe slots — the EXECUTABLE mirror of the engine canon
224
+ // (agent-workflow-engine/references/procedures.md). Drift-guarded against that canon's `Slots:` lines
225
+ // (recipes.test.mjs): the activity ids and each section's slot set must match this table. The slot
226
+ // VALUE is the slot's recipe-TYPE (used to look up which recipes are valid for it, SLOT_RECIPES); in
227
+ // v1 each slot's key equals its type, but the indirection keeps a future renamed slot expressible.
228
+ export const ACTIVITIES = {
229
+ 'plan-authoring': { slots: { review: 'review' } },
230
+ 'plan-execution': { slots: { execute: 'execute', review: 'review' } },
231
+ };
232
+
233
+ // Which recipes are valid in each slot type. `review` composes a review DEPTH (Solo / Reviewed /
234
+ // Council); `execute` composes Solo / Delegated (delegation is opt-in). A recipe outside its slot's
235
+ // list is a config error (the IO shell) or a usage error (an --override) — never silently coerced.
236
+ export const SLOT_RECIPES = {
237
+ review: ['solo', 'reviewed', 'council'],
238
+ execute: ['solo', 'delegated'],
239
+ };
240
+
241
+ // The computed default for a slot when the config is silent (no file, or no entry for this slot).
242
+ // review → Reviewed when ANY review-capable backend is `ready`, else Solo (NEVER Council — Council is
243
+ // opt-in; it spends two backends' quota). execute → Solo (Delegated is opt-in only). Readiness-aware,
244
+ // so a computed default is always satisfiable and never itself degrades. Deliberately NOT
245
+ // recommendRecipe (which returns Council when both are ready — that drives the status line, not a
246
+ // per-slot default).
247
+ const computedDefaultForSlot = (slotType, detection) => {
248
+ if (slotType === 'review') return readyProvidersOf('review', detection).length >= 1 ? 'reviewed' : 'solo';
249
+ return 'solo'; // execute (and any future opt-in slot) floors at Solo
250
+ };
251
+
252
+ // resolveActivityRecipe({ config, readiness, activity, slot, override }) → the effective recipe for ONE
253
+ // slot of an activity, with graceful-vs-loud degradation. Precedence: an explicit `override` (degrades
254
+ // LOUDLY — overrideUnsatisfied, so the agent tells the user) > the `config` entry (degrades gracefully)
255
+ // > the computed default (graceful; readiness-aware so it never degrades). Satisfiability + the
256
+ // degradation lattice REUSE planRecipe (Council → Reviewed → Solo; Delegated → Solo) — the single source
257
+ // of the recipe lattice. `readiness` is the detector array ([{ name, readiness }]). Pure; never mutates.
258
+ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override } = {}) => {
259
+ const activityDef = ACTIVITIES[activity];
260
+ if (!activityDef) throw new Error(`unknown activity: ${activity}`);
261
+ const slotType = activityDef.slots[slot];
262
+ if (!slotType) throw new Error(`unknown slot "${slot}" for activity "${activity}"`);
263
+
264
+ const configured = config?.[activity]?.[slot];
265
+ const requested = override ?? configured ?? computedDefaultForSlot(slotType, readiness);
266
+ const source = override != null ? 'override' : configured != null ? 'config' : 'default';
267
+
268
+ // Defensive: the IO shell (config) and CLI (override) validate recipe-for-slot first; a stray value
269
+ // here is a programmer error, surfaced loudly rather than silently coerced into a neighbour recipe.
270
+ if (!(SLOT_RECIPES[slotType] ?? []).includes(requested)) {
271
+ throw new Error(`invalid recipe "${requested}" for ${slotType} slot of "${activity}"`);
272
+ }
273
+
274
+ const plan = planRecipe(requested, readiness);
275
+ const degraded = plan.degraded;
276
+ return {
277
+ recipe: plan.effective,
278
+ source,
279
+ degradedFrom: degraded ? requested : null,
280
+ reason: degraded ? plan.degradation.map((d) => d.reason).join('; ') : null,
281
+ overrideUnsatisfied: source === 'override' && degraded,
282
+ };
283
+ };
284
+
285
+ // ── report + CLI ─────────────────────────────────────────────────────────────────
286
+
287
+ // The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
288
+ export const buildReport = (detection) => ({
289
+ recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
290
+ id,
291
+ title,
292
+ role,
293
+ minBackends,
294
+ degradesTo,
295
+ summary,
296
+ })),
297
+ recommendation: recommendRecipe(detection),
298
+ plans: RECIPES.map((r) => planRecipe(r.id, detection)),
299
+ });
300
+
301
+ // formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
302
+ // and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
303
+ export const formatRecipes = (detection) => {
304
+ const lines = [
305
+ 'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills and always commits)',
306
+ '',
307
+ ];
308
+ for (const r of RECIPES) lines.push(` ${r.title} (${r.id}) — ${r.summary}`);
309
+ const rec = recommendRecipe(detection);
310
+ lines.push('', `recommended here: ${rec.recipe} — ${rec.clause}`, '', 'plan for the current environment:');
311
+ for (const r of RECIPES) {
312
+ const p = planRecipe(r.id, detection);
313
+ const arrow = p.degraded ? ` → ${p.effective}` : '';
314
+ const who = p.dispatch.length ? p.dispatch.map((d) => `${d.display} ${d.role}`).join(', ') : 'orchestrator only';
315
+ lines.push(` ${r.title}${arrow}: ${who}`);
316
+ for (const step of p.degradation) lines.push(` ↳ ${step.reason}`);
317
+ for (const note of p.notes) lines.push(` • ${note}`);
318
+ }
319
+ return lines.join('\n');
320
+ };
321
+
322
+ const main = (argv) => {
323
+ if (argv.includes('--help') || argv.includes('-h')) {
324
+ console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
325
+
326
+ Usage:
327
+ node recipes.mjs [--json]
328
+
329
+ Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
330
+ detector, plans + recommends one for the current environment. Detection only — never writes, never
331
+ commits, never runs a subscription CLI.`);
332
+ return;
333
+ }
334
+ const detection = detectBackends();
335
+ if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
336
+ else console.log(formatRecipes(detection));
337
+ };
338
+
339
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
340
+ if (isDirectRun) main(process.argv.slice(2));