@sabaiway/agent-workflow-kit 1.13.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.
- package/CHANGELOG.md +27 -0
- package/README.md +2 -0
- package/SKILL.md +28 -6
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size.mjs +4 -1
- package/references/scripts/check-docs-size.test.mjs +24 -0
- package/references/templates/orchestration.json +5 -0
- package/tools/engine-source.mjs +5 -0
- package/tools/engine-source.test.mjs +48 -0
- package/tools/family-registry.mjs +22 -16
- package/tools/family-registry.test.mjs +55 -23
- package/tools/inject-methodology.mjs +25 -0
- package/tools/inject-methodology.test.mjs +73 -0
- package/tools/procedures.mjs +324 -0
- package/tools/procedures.test.mjs +303 -0
- package/tools/recipes.mjs +64 -0
- package/tools/recipes.test.mjs +175 -0
|
@@ -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
|
+
});
|
package/tools/recipes.mjs
CHANGED
|
@@ -218,6 +218,70 @@ export const recommendRecipe = (detection) => {
|
|
|
218
218
|
return { recipe: 'solo', clause: `Solo — ${DISPLAY_ALIASES[best.name] ?? best.name}: ${remedy} to unlock Reviewed` };
|
|
219
219
|
};
|
|
220
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
|
+
|
|
221
285
|
// ── report + CLI ─────────────────────────────────────────────────────────────────
|
|
222
286
|
|
|
223
287
|
// The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
|
package/tools/recipes.test.mjs
CHANGED
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
BACKEND_ROLES,
|
|
10
10
|
BACKEND_META,
|
|
11
11
|
DISPLAY_ALIASES,
|
|
12
|
+
ACTIVITIES,
|
|
13
|
+
SLOT_RECIPES,
|
|
12
14
|
planRecipe,
|
|
13
15
|
recommendRecipe,
|
|
16
|
+
resolveActivityRecipe,
|
|
14
17
|
formatRecipes,
|
|
15
18
|
} from './recipes.mjs';
|
|
16
19
|
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
@@ -349,6 +352,178 @@ describe('formatRecipes — deterministic advisor text', () => {
|
|
|
349
352
|
});
|
|
350
353
|
});
|
|
351
354
|
|
|
355
|
+
// ── ACTIVITIES + resolveActivityRecipe (the activity-procedures resolver) ───────────
|
|
356
|
+
|
|
357
|
+
describe('ACTIVITIES — the v1 activity/slot table', () => {
|
|
358
|
+
it('declares exactly plan-authoring (review) and plan-execution (execute, review)', () => {
|
|
359
|
+
assert.deepEqual(Object.keys(ACTIVITIES), ['plan-authoring', 'plan-execution']);
|
|
360
|
+
assert.deepEqual(Object.keys(ACTIVITIES['plan-authoring'].slots), ['review']);
|
|
361
|
+
assert.deepEqual(Object.keys(ACTIVITIES['plan-execution'].slots), ['execute', 'review']);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('every slot type maps to a SLOT_RECIPES list, and every listed recipe is a real RECIPE id', () => {
|
|
365
|
+
const recipeIds = new Set(RECIPES.map((r) => r.id));
|
|
366
|
+
for (const def of Object.values(ACTIVITIES)) {
|
|
367
|
+
for (const slotType of Object.values(def.slots)) {
|
|
368
|
+
assert.ok(Array.isArray(SLOT_RECIPES[slotType]), `slot type "${slotType}" has a recipe list`);
|
|
369
|
+
for (const id of SLOT_RECIPES[slotType]) assert.ok(recipeIds.has(id), `"${id}" is a real recipe`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('review composes solo|reviewed|council; execute composes solo|delegated', () => {
|
|
375
|
+
assert.deepEqual(SLOT_RECIPES.review, ['solo', 'reviewed', 'council']);
|
|
376
|
+
assert.deepEqual(SLOT_RECIPES.execute, ['solo', 'delegated']);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// The activity/slot drift guard — the JS ACTIVITIES table must match the engine canon's parseable
|
|
381
|
+
// `Slots:` lines (the kit parses ONLY that line; the steps are rendered verbatim). Clones the
|
|
382
|
+
// engine↔kit recipe-name parity pattern above.
|
|
383
|
+
describe('engine↔kit activity/slot parity — ACTIVITIES matches procedures.md `Slots:` lines', () => {
|
|
384
|
+
const PROCEDURES = readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', 'procedures.md'), 'utf8');
|
|
385
|
+
const sectionOf = (activity) => {
|
|
386
|
+
const lines = PROCEDURES.split('\n');
|
|
387
|
+
const start = lines.findIndex((l) => l.trim() === `## ${activity}`);
|
|
388
|
+
if (start === -1) return null;
|
|
389
|
+
let end = lines.length;
|
|
390
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
391
|
+
if (/^## /.test(lines[i])) {
|
|
392
|
+
end = i;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return lines.slice(start, end);
|
|
397
|
+
};
|
|
398
|
+
const slotsOf = (activity) => {
|
|
399
|
+
const sec = sectionOf(activity);
|
|
400
|
+
if (!sec) return null;
|
|
401
|
+
const slotsLine = sec.slice(1).map((l) => l.trim()).find((l) => l.startsWith('Slots:'));
|
|
402
|
+
if (!slotsLine) return null;
|
|
403
|
+
return slotsLine
|
|
404
|
+
.replace(/^Slots:\s*/, '')
|
|
405
|
+
.split(',')
|
|
406
|
+
.map((s) => s.trim())
|
|
407
|
+
.filter(Boolean);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
for (const [activity, def] of Object.entries(ACTIVITIES)) {
|
|
411
|
+
it(`${activity}: the canon's Slots line equals the JS slot set`, () => {
|
|
412
|
+
assert.deepEqual(slotsOf(activity), Object.keys(def.slots), `procedures.md "## ${activity}" Slots: drifted from ACTIVITIES`);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
it('procedures.md declares no activity section absent from the ACTIVITIES table', () => {
|
|
417
|
+
const headingIds = PROCEDURES.split('\n')
|
|
418
|
+
.filter((l) => /^## /.test(l))
|
|
419
|
+
.map((l) => l.replace(/^##\s+/, '').trim());
|
|
420
|
+
for (const id of headingIds) assert.ok(ACTIVITIES[id], `procedures.md "## ${id}" has no ACTIVITIES entry`);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('resolveActivityRecipe — defaults (config silent), readiness-aware', () => {
|
|
425
|
+
it('review default is Reviewed when a backend is ready — NOT Council (recommendRecipe is not reused)', () => {
|
|
426
|
+
const r = resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'review' });
|
|
427
|
+
assert.equal(r.recipe, 'reviewed');
|
|
428
|
+
assert.equal(r.source, 'default');
|
|
429
|
+
assert.equal(r.degradedFrom, null);
|
|
430
|
+
// sanity: recommendRecipe DOES return council for the same detection — the default must not.
|
|
431
|
+
assert.equal(recommendRecipe(detect(READY, READY)).recipe, 'council');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('review default is Solo when no backend is ready', () => {
|
|
435
|
+
const r = resolveActivityRecipe({ readiness: detect(NEEDS_SKILL, NEEDS_SKILL), activity: 'plan-authoring', slot: 'review' });
|
|
436
|
+
assert.equal(r.recipe, 'solo');
|
|
437
|
+
assert.equal(r.source, 'default');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('execute default is Solo even when codex is ready (Delegated is opt-in)', () => {
|
|
441
|
+
const r = resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'plan-execution', slot: 'execute' });
|
|
442
|
+
assert.equal(r.recipe, 'solo');
|
|
443
|
+
assert.equal(r.source, 'default');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('resolveActivityRecipe — config-driven, graceful degradation', () => {
|
|
448
|
+
const config = { 'plan-authoring': { review: 'council' }, 'plan-execution': { execute: 'delegated', review: 'reviewed' } };
|
|
449
|
+
|
|
450
|
+
it('config review=council holds when both backends are ready', () => {
|
|
451
|
+
const r = resolveActivityRecipe({ config, readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'review' });
|
|
452
|
+
assert.equal(r.recipe, 'council');
|
|
453
|
+
assert.equal(r.source, 'config');
|
|
454
|
+
assert.equal(r.degradedFrom, null);
|
|
455
|
+
assert.equal(r.overrideUnsatisfied, false);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('config review=council degrades GRACEFULLY to Reviewed with one backend (not a loud override)', () => {
|
|
459
|
+
const r = resolveActivityRecipe({ config, readiness: detect(READY, NEEDS_SKILL), activity: 'plan-authoring', slot: 'review' });
|
|
460
|
+
assert.equal(r.recipe, 'reviewed');
|
|
461
|
+
assert.equal(r.degradedFrom, 'council');
|
|
462
|
+
assert.equal(r.overrideUnsatisfied, false);
|
|
463
|
+
assert.match(r.reason, /not installed|council/i);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('config execute=delegated holds when codex is ready', () => {
|
|
467
|
+
const r = resolveActivityRecipe({ config, readiness: detect(READY, NEEDS_SKILL), activity: 'plan-execution', slot: 'execute' });
|
|
468
|
+
assert.equal(r.recipe, 'delegated');
|
|
469
|
+
assert.equal(r.degradedFrom, null);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('config execute=delegated degrades to Solo when codex is not ready (agy cannot execute)', () => {
|
|
473
|
+
const r = resolveActivityRecipe({ config, readiness: detect(NEEDS_SKILL, READY), activity: 'plan-execution', slot: 'execute' });
|
|
474
|
+
assert.equal(r.recipe, 'solo');
|
|
475
|
+
assert.equal(r.degradedFrom, 'delegated');
|
|
476
|
+
assert.equal(r.overrideUnsatisfied, false);
|
|
477
|
+
assert.match(r.reason, /execute/i);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('resolveActivityRecipe — override precedence + LOUD degradation', () => {
|
|
482
|
+
it('an override beats the config entry', () => {
|
|
483
|
+
const config = { 'plan-authoring': { review: 'solo' } };
|
|
484
|
+
const r = resolveActivityRecipe({ config, readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'review', override: 'council' });
|
|
485
|
+
assert.equal(r.recipe, 'council');
|
|
486
|
+
assert.equal(r.source, 'override');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('an unsatisfiable override degrades LOUDLY (overrideUnsatisfied = true)', () => {
|
|
490
|
+
const r = resolveActivityRecipe({ readiness: detect(READY, NEEDS_SKILL), activity: 'plan-authoring', slot: 'review', override: 'council' });
|
|
491
|
+
assert.equal(r.recipe, 'reviewed');
|
|
492
|
+
assert.equal(r.degradedFrom, 'council');
|
|
493
|
+
assert.equal(r.overrideUnsatisfied, true, 'an explicit override that cannot be satisfied is flagged loud');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('a satisfiable override is not flagged', () => {
|
|
497
|
+
const r = resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'review', override: 'council' });
|
|
498
|
+
assert.equal(r.recipe, 'council');
|
|
499
|
+
assert.equal(r.overrideUnsatisfied, false);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe('resolveActivityRecipe — defensive validity + purity', () => {
|
|
504
|
+
it('throws on an unknown activity', () => {
|
|
505
|
+
assert.throws(() => resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'nope', slot: 'review' }), /unknown activity/);
|
|
506
|
+
});
|
|
507
|
+
it('throws on an unknown slot for the activity', () => {
|
|
508
|
+
assert.throws(
|
|
509
|
+
() => resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'execute' }),
|
|
510
|
+
/unknown slot/,
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
it('throws on a recipe invalid for the slot (e.g. delegated in a review slot)', () => {
|
|
514
|
+
assert.throws(
|
|
515
|
+
() => resolveActivityRecipe({ readiness: detect(READY, READY), activity: 'plan-authoring', slot: 'review', override: 'delegated' }),
|
|
516
|
+
/invalid recipe/,
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
it('does not mutate the detection input', () => {
|
|
520
|
+
const det = detect(READY, READY);
|
|
521
|
+
const snapshot = JSON.parse(JSON.stringify(det));
|
|
522
|
+
resolveActivityRecipe({ readiness: det, activity: 'plan-execution', slot: 'review' });
|
|
523
|
+
assert.deepEqual(det, snapshot);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
352
527
|
describe('recipes.mjs CLI — read-only, exit 0', () => {
|
|
353
528
|
it('prints the recipes and exits 0', () => {
|
|
354
529
|
const out = execFileSync(process.execPath, [SCRIPT], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|