@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.
- package/CHANGELOG.md +50 -0
- package/README.md +9 -3
- package/SKILL.md +95 -40
- 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/AGENTS.md +2 -3
- package/references/templates/orchestration.json +5 -0
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +15 -5
- package/tools/engine-source.test.mjs +98 -0
- package/tools/family-registry.mjs +33 -1
- package/tools/family-registry.test.mjs +90 -0
- package/tools/inject-methodology.mjs +262 -110
- package/tools/inject-methodology.test.mjs +201 -12
- package/tools/procedures.mjs +324 -0
- package/tools/procedures.test.mjs +303 -0
- package/tools/recipes.mjs +340 -0
- package/tools/recipes.test.mjs +538 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
RECIPES,
|
|
9
|
+
BACKEND_ROLES,
|
|
10
|
+
BACKEND_META,
|
|
11
|
+
DISPLAY_ALIASES,
|
|
12
|
+
ACTIVITIES,
|
|
13
|
+
SLOT_RECIPES,
|
|
14
|
+
planRecipe,
|
|
15
|
+
recommendRecipe,
|
|
16
|
+
resolveActivityRecipe,
|
|
17
|
+
formatRecipes,
|
|
18
|
+
} from './recipes.mjs';
|
|
19
|
+
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const SCRIPT = join(HERE, 'recipes.mjs');
|
|
23
|
+
const REPO_ROOT = join(HERE, '..', '..');
|
|
24
|
+
|
|
25
|
+
const CODEX = 'codex-cli-bridge';
|
|
26
|
+
const AGY = 'antigravity-cli-bridge';
|
|
27
|
+
const RECIPE_IDS = ['solo', 'reviewed', 'council', 'delegated'];
|
|
28
|
+
|
|
29
|
+
// A synthetic detector fixture — the planner only consumes { name, readiness } off each entry, built
|
|
30
|
+
// from the REAL readiness vocabulary (no `missing` — that is a probe-axis state, not a readiness).
|
|
31
|
+
const detect = (codexReadiness, agyReadiness) => [
|
|
32
|
+
{ name: CODEX, readiness: codexReadiness },
|
|
33
|
+
{ name: AGY, readiness: agyReadiness },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const readManifest = (name) => JSON.parse(readFileSync(join(REPO_ROOT, name, 'capability.json'), 'utf8'));
|
|
37
|
+
|
|
38
|
+
// ── RECIPES shape ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('RECIPES — the four named patterns', () => {
|
|
41
|
+
it('is exactly the four recipes, in lattice order', () => {
|
|
42
|
+
assert.deepEqual(RECIPES.map((r) => r.id), RECIPE_IDS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('each recipe declares a role (or null for Solo), a minBackends count, and a degradation target', () => {
|
|
46
|
+
for (const r of RECIPES) {
|
|
47
|
+
assert.ok(typeof r.id === 'string' && r.id.length > 0);
|
|
48
|
+
assert.ok('role' in r, `${r.id} declares a role key`);
|
|
49
|
+
assert.ok(Number.isInteger(r.minBackends), `${r.id} declares minBackends`);
|
|
50
|
+
assert.ok('degradesTo' in r, `${r.id} declares a degradation target`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('Solo is the floor (no role, no backend, no degradation target)', () => {
|
|
55
|
+
const solo = RECIPES.find((r) => r.id === 'solo');
|
|
56
|
+
assert.equal(solo.role, null);
|
|
57
|
+
assert.equal(solo.minBackends, 0);
|
|
58
|
+
assert.equal(solo.degradesTo, null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('Reviewed/Council need review; Delegated needs execute; degradation chains terminate at Solo', () => {
|
|
62
|
+
const by = Object.fromEntries(RECIPES.map((r) => [r.id, r]));
|
|
63
|
+
assert.equal(by.reviewed.role, 'review');
|
|
64
|
+
assert.equal(by.reviewed.minBackends, 1);
|
|
65
|
+
assert.equal(by.reviewed.degradesTo, 'solo');
|
|
66
|
+
assert.equal(by.council.role, 'review');
|
|
67
|
+
assert.equal(by.council.minBackends, 2);
|
|
68
|
+
assert.equal(by.council.degradesTo, 'reviewed');
|
|
69
|
+
assert.equal(by.delegated.role, 'execute');
|
|
70
|
+
assert.equal(by.delegated.minBackends, 1);
|
|
71
|
+
assert.equal(by.delegated.degradesTo, 'solo');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── drift guards ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('role-coverage drift-guard — every recipe role ∈ union of the bridges provides[]', () => {
|
|
78
|
+
it('no recipe demands a role no backend provides', () => {
|
|
79
|
+
const union = new Set([...readManifest(CODEX).provides, ...readManifest(AGY).provides]);
|
|
80
|
+
for (const r of RECIPES) {
|
|
81
|
+
if (r.role !== null) assert.ok(union.has(r.role), `recipe ${r.id} role "${r.role}" is provided by some backend`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('BACKEND_ROLES drift-guard — keyed by status.name, equals each bridge provides[]', () => {
|
|
87
|
+
it('is keyed by the manifest names the detector emits (not the display aliases)', () => {
|
|
88
|
+
assert.deepEqual(Object.keys(BACKEND_ROLES).sort(), [AGY, CODEX].sort());
|
|
89
|
+
});
|
|
90
|
+
it('matches each bridge capability.json provides[]', () => {
|
|
91
|
+
assert.deepEqual(BACKEND_ROLES[CODEX], readManifest(CODEX).provides);
|
|
92
|
+
assert.deepEqual(BACKEND_ROLES[AGY], readManifest(AGY).provides);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('BACKEND_META drift-guard — cost/quota mirror the manifests; agy carries a health advisory', () => {
|
|
97
|
+
it('cost + quota equal each bridge capability.json', () => {
|
|
98
|
+
for (const name of [CODEX, AGY]) {
|
|
99
|
+
const m = readManifest(name);
|
|
100
|
+
assert.equal(BACKEND_META[name].cost, m.cost);
|
|
101
|
+
assert.deepEqual(BACKEND_META[name].quota, m.quota);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
it('the agy health advisory (Issue-001) is present as static project knowledge', () => {
|
|
105
|
+
assert.equal(typeof BACKEND_META[AGY].health, 'string');
|
|
106
|
+
assert.ok(BACKEND_META[AGY].health.length > 0);
|
|
107
|
+
// codex carries no standing health caveat
|
|
108
|
+
assert.ok(!BACKEND_META[CODEX].health);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('DISPLAY_ALIASES — the manifest-name → human-alias map', () => {
|
|
113
|
+
it('maps both bridges to their short aliases', () => {
|
|
114
|
+
assert.equal(DISPLAY_ALIASES[CODEX], 'codex');
|
|
115
|
+
assert.equal(DISPLAY_ALIASES[AGY], 'agy');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── engine ⟷ kit recipe-name parity (cross-package read in the monorepo) ───────────
|
|
120
|
+
|
|
121
|
+
describe('engine↔kit recipe-name parity — the four ids appear in the engine canon', () => {
|
|
122
|
+
const engineRefs = ['orchestration.md', 'orchestration-slot.md'].map((f) =>
|
|
123
|
+
readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', f), 'utf8').toLowerCase(),
|
|
124
|
+
);
|
|
125
|
+
for (const id of RECIPE_IDS) {
|
|
126
|
+
it(`"${id}" appears in both engine orchestration files`, () => {
|
|
127
|
+
for (const text of engineRefs) assert.ok(text.includes(id), `engine canon names "${id}"`);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// The engine narrative (orchestration.md) hardcodes the bridges' role vocabulary as prose; keep it in
|
|
133
|
+
// lockstep with the manifests so a future `provides[]` change forces the narrative to be updated too.
|
|
134
|
+
describe('engine narrative ⟷ manifest role-vocabulary parity', () => {
|
|
135
|
+
const orchestration = readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', 'orchestration.md'), 'utf8');
|
|
136
|
+
const norm = (s) => s.replace(/\s+/g, ''); // whitespace-insensitive: prose has `["a", "b"]`, JSON has `["a","b"]`
|
|
137
|
+
|
|
138
|
+
for (const name of [CODEX, AGY]) {
|
|
139
|
+
it(`orchestration.md renders ${name}'s provides[] from the manifest`, () => {
|
|
140
|
+
const provides = readManifest(name).provides;
|
|
141
|
+
assert.ok(
|
|
142
|
+
norm(orchestration).includes(norm(`provides: ${JSON.stringify(provides)}`)),
|
|
143
|
+
`orchestration.md §1 must render ${name} provides ${JSON.stringify(provides)} (drifted from the manifest)`,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('the agy health advisory (Issue-001) is consistent between BACKEND_META and the engine narrative', () => {
|
|
149
|
+
// Flatten whitespace so a prose line-wrap (e.g. "substantive\nprompts") doesn't hide the substring.
|
|
150
|
+
const flat = orchestration.replace(/\s+/g, ' ').toLowerCase();
|
|
151
|
+
assert.match(flat, /stall on substantive prompts/, 'the engine narrates the stall advisory');
|
|
152
|
+
assert.match(flat, /issue-001/, 'the engine narrative ties it to Issue-001');
|
|
153
|
+
assert.match(flat, /prefer .?codex/, 'the engine narrates the prefer-codex remedy');
|
|
154
|
+
// BACKEND_META carries the same advisory facts (kit-side), tying the two representations together.
|
|
155
|
+
const health = BACKEND_META[AGY].health.toLowerCase();
|
|
156
|
+
assert.ok(health.includes('stall on substantive prompts') && health.includes('issue-001') && health.includes('codex'));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── planRecipe ─────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
const dispatchBackends = (plan) => plan.dispatch.map((d) => d.backend);
|
|
163
|
+
const notesText = (plan) => plan.notes.join(' :: ');
|
|
164
|
+
|
|
165
|
+
describe('planRecipe — all backends ready', () => {
|
|
166
|
+
const det = detect(READY, READY);
|
|
167
|
+
|
|
168
|
+
it('Solo: no degradation, no dispatch', () => {
|
|
169
|
+
const p = planRecipe('solo', det);
|
|
170
|
+
assert.equal(p.effective, 'solo');
|
|
171
|
+
assert.equal(p.degraded, false);
|
|
172
|
+
assert.deepEqual(p.dispatch, []);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('Reviewed: picks codex over agy (deterministic tie-break), not degraded', () => {
|
|
176
|
+
const p = planRecipe('reviewed', det);
|
|
177
|
+
assert.equal(p.effective, 'reviewed');
|
|
178
|
+
assert.equal(p.degraded, false);
|
|
179
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
180
|
+
// codex chosen → the agy health caveat is NOT attached
|
|
181
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('Council: both backends review; the agy health caveat + the two-quota note are attached', () => {
|
|
185
|
+
const p = planRecipe('council', det);
|
|
186
|
+
assert.equal(p.effective, 'council');
|
|
187
|
+
assert.equal(p.degraded, false);
|
|
188
|
+
assert.deepEqual(dispatchBackends(p), [CODEX, AGY]);
|
|
189
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is used');
|
|
190
|
+
assert.match(notesText(p), /two backends|both backends|two .*quota/i);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('Delegated: codex executes the bounded sub-task', () => {
|
|
194
|
+
const p = planRecipe('delegated', det);
|
|
195
|
+
assert.equal(p.effective, 'delegated');
|
|
196
|
+
assert.equal(p.degraded, false);
|
|
197
|
+
assert.deepEqual(p.dispatch, [{ role: 'execute', backend: CODEX, display: 'codex' }]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('planRecipe — codex only (agy needs-skill)', () => {
|
|
202
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
203
|
+
|
|
204
|
+
it('Council → Reviewed(codex) with a stated reason', () => {
|
|
205
|
+
const p = planRecipe('council', det);
|
|
206
|
+
assert.equal(p.effective, 'reviewed');
|
|
207
|
+
assert.equal(p.degraded, true);
|
|
208
|
+
assert.equal(p.degradation[0].from, 'council');
|
|
209
|
+
assert.equal(p.degradation[0].to, 'reviewed');
|
|
210
|
+
assert.match(p.degradation[0].reason, /not installed/i);
|
|
211
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('Delegated stays Delegated (codex provides execute and is ready)', () => {
|
|
215
|
+
const p = planRecipe('delegated', det);
|
|
216
|
+
assert.equal(p.effective, 'delegated');
|
|
217
|
+
assert.equal(p.degraded, false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('Reviewed → codex', () => {
|
|
221
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [CODEX]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('planRecipe — agy only (codex needs-skill)', () => {
|
|
226
|
+
const det = detect(NEEDS_SKILL, READY);
|
|
227
|
+
|
|
228
|
+
it('Council → Reviewed(agy)', () => {
|
|
229
|
+
const p = planRecipe('council', det);
|
|
230
|
+
assert.equal(p.effective, 'reviewed');
|
|
231
|
+
assert.equal(p.degraded, true);
|
|
232
|
+
assert.deepEqual(dispatchBackends(p), [AGY]);
|
|
233
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is the reviewer');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('Delegated → Solo (no backend provides execute) with the reason stated', () => {
|
|
237
|
+
const p = planRecipe('delegated', det);
|
|
238
|
+
assert.equal(p.effective, 'solo');
|
|
239
|
+
assert.equal(p.degraded, true);
|
|
240
|
+
assert.match(p.degradation[0].reason, /execute/i);
|
|
241
|
+
assert.deepEqual(p.dispatch, []);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('Reviewed → agy', () => {
|
|
245
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [AGY]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('planRecipe — none installed (both needs-skill)', () => {
|
|
250
|
+
const det = detect(NEEDS_SKILL, NEEDS_SKILL);
|
|
251
|
+
for (const id of ['reviewed', 'council', 'delegated']) {
|
|
252
|
+
it(`${id} → Solo, reason names the not-installed bridge skill`, () => {
|
|
253
|
+
const p = planRecipe(id, det);
|
|
254
|
+
assert.equal(p.effective, 'solo');
|
|
255
|
+
assert.equal(p.degraded, true);
|
|
256
|
+
assert.match(p.degradation.map((d) => d.reason).join(' '), /not installed/i);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('planRecipe — agy degraded (wrapper not on PATH)', () => {
|
|
262
|
+
const det = detect(READY, DEGRADED);
|
|
263
|
+
|
|
264
|
+
it('Reviewed → codex (agy not dispatchable); no agy health note (agy unused)', () => {
|
|
265
|
+
const p = planRecipe('reviewed', det);
|
|
266
|
+
assert.equal(p.effective, 'reviewed');
|
|
267
|
+
assert.equal(p.degraded, false);
|
|
268
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
269
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('Council → Reviewed(codex); the degradation reason is the wrapper one (distinct from the health note)', () => {
|
|
273
|
+
const p = planRecipe('council', det);
|
|
274
|
+
assert.equal(p.effective, 'reviewed');
|
|
275
|
+
assert.equal(p.degraded, true);
|
|
276
|
+
assert.match(p.degradation[0].reason, /PATH|wrapper/i);
|
|
277
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('planRecipe — purity + determinism', () => {
|
|
282
|
+
it('is deterministic: same detection → deeply-equal plan', () => {
|
|
283
|
+
const det = detect(READY, NEEDS_CREDENTIALS);
|
|
284
|
+
assert.deepEqual(planRecipe('council', det), planRecipe('council', det));
|
|
285
|
+
});
|
|
286
|
+
it('does not mutate the detection input', () => {
|
|
287
|
+
const det = detect(READY, READY);
|
|
288
|
+
const snapshot = JSON.parse(JSON.stringify(det));
|
|
289
|
+
planRecipe('council', det);
|
|
290
|
+
assert.deepEqual(det, snapshot);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── recommendRecipe ──────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe('recommendRecipe — never blank; the everyday default', () => {
|
|
297
|
+
it('both ready → Council available, Reviewed the everyday default', () => {
|
|
298
|
+
const r = recommendRecipe(detect(READY, READY));
|
|
299
|
+
assert.equal(r.recipe, 'council');
|
|
300
|
+
assert.match(r.clause, /council/i);
|
|
301
|
+
assert.match(r.clause, /reviewed/i);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('one ready → Reviewed', () => {
|
|
305
|
+
const r = recommendRecipe(detect(READY, NEEDS_SKILL));
|
|
306
|
+
assert.equal(r.recipe, 'reviewed');
|
|
307
|
+
assert.match(r.clause, /reviewed/i);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('none installed → Solo + a setup pointer', () => {
|
|
311
|
+
const r = recommendRecipe(detect(NEEDS_SKILL, NEEDS_SKILL));
|
|
312
|
+
assert.equal(r.recipe, 'solo');
|
|
313
|
+
assert.match(r.clause, /solo/i);
|
|
314
|
+
assert.match(r.clause, /\/agent-workflow-kit setup/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('present-but-not-ready → Solo with the specific remedy', () => {
|
|
318
|
+
const r = recommendRecipe(detect(NEEDS_CLI, NEEDS_SKILL));
|
|
319
|
+
assert.equal(r.recipe, 'solo');
|
|
320
|
+
assert.match(r.clause, /CLI|cli/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('the clause is never empty', () => {
|
|
324
|
+
for (const det of [
|
|
325
|
+
detect(READY, READY),
|
|
326
|
+
detect(READY, NEEDS_SKILL),
|
|
327
|
+
detect(NEEDS_SKILL, NEEDS_SKILL),
|
|
328
|
+
detect(NEEDS_CREDENTIALS, DEGRADED),
|
|
329
|
+
]) {
|
|
330
|
+
assert.ok(recommendRecipe(det).clause.trim().length > 0);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('present-but-not-ready tie-break is deterministic (codex before agy) regardless of detection order', () => {
|
|
335
|
+
// Both present-but-not-ready at the SAME readiness rank → the remedy must name codex, not whichever
|
|
336
|
+
// the detector happened to emit first (mirrors the dispatch-path priority).
|
|
337
|
+
const forward = recommendRecipe([{ name: CODEX, readiness: DEGRADED }, { name: AGY, readiness: DEGRADED }]);
|
|
338
|
+
const reversed = recommendRecipe([{ name: AGY, readiness: DEGRADED }, { name: CODEX, readiness: DEGRADED }]);
|
|
339
|
+
assert.equal(forward.clause, reversed.clause, 'order-independent');
|
|
340
|
+
assert.match(forward.clause, /codex/, 'ties break to codex');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ── CLI / formatRecipes ──────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
describe('formatRecipes — deterministic advisor text', () => {
|
|
347
|
+
it('renders the four recipes + a recommendation deterministically', () => {
|
|
348
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
349
|
+
const once = formatRecipes(det);
|
|
350
|
+
assert.equal(once, formatRecipes(det), 'same detection → identical text');
|
|
351
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(once, new RegExp(title));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
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
|
+
|
|
527
|
+
describe('recipes.mjs CLI — read-only, exit 0', () => {
|
|
528
|
+
it('prints the recipes and exits 0', () => {
|
|
529
|
+
const out = execFileSync(process.execPath, [SCRIPT], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
530
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(out, new RegExp(title));
|
|
531
|
+
});
|
|
532
|
+
it('--json emits parseable JSON with the recommendation', () => {
|
|
533
|
+
const out = execFileSync(process.execPath, [SCRIPT, '--json'], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
534
|
+
const parsed = JSON.parse(out);
|
|
535
|
+
assert.ok(Array.isArray(parsed.recipes));
|
|
536
|
+
assert.ok(parsed.recommendation && typeof parsed.recommendation.recipe === 'string');
|
|
537
|
+
});
|
|
538
|
+
});
|