@sabaiway/agent-workflow-kit 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/README.md +12 -4
- package/SKILL.md +103 -37
- package/capability.json +5 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +276 -0
- package/tools/family-registry.test.mjs +247 -0
- package/tools/fs-safe.mjs +50 -1
- package/tools/fs-safe.test.mjs +140 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/manifest/validate.mjs +3 -1
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
- package/tools/uninstall.integration.test.mjs +144 -0
- package/tools/uninstall.mjs +420 -0
- package/tools/uninstall.test.mjs +372 -0
|
@@ -0,0 +1,363 @@
|
|
|
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
|
+
planRecipe,
|
|
13
|
+
recommendRecipe,
|
|
14
|
+
formatRecipes,
|
|
15
|
+
} from './recipes.mjs';
|
|
16
|
+
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SCRIPT = join(HERE, 'recipes.mjs');
|
|
20
|
+
const REPO_ROOT = join(HERE, '..', '..');
|
|
21
|
+
|
|
22
|
+
const CODEX = 'codex-cli-bridge';
|
|
23
|
+
const AGY = 'antigravity-cli-bridge';
|
|
24
|
+
const RECIPE_IDS = ['solo', 'reviewed', 'council', 'delegated'];
|
|
25
|
+
|
|
26
|
+
// A synthetic detector fixture — the planner only consumes { name, readiness } off each entry, built
|
|
27
|
+
// from the REAL readiness vocabulary (no `missing` — that is a probe-axis state, not a readiness).
|
|
28
|
+
const detect = (codexReadiness, agyReadiness) => [
|
|
29
|
+
{ name: CODEX, readiness: codexReadiness },
|
|
30
|
+
{ name: AGY, readiness: agyReadiness },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const readManifest = (name) => JSON.parse(readFileSync(join(REPO_ROOT, name, 'capability.json'), 'utf8'));
|
|
34
|
+
|
|
35
|
+
// ── RECIPES shape ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('RECIPES — the four named patterns', () => {
|
|
38
|
+
it('is exactly the four recipes, in lattice order', () => {
|
|
39
|
+
assert.deepEqual(RECIPES.map((r) => r.id), RECIPE_IDS);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('each recipe declares a role (or null for Solo), a minBackends count, and a degradation target', () => {
|
|
43
|
+
for (const r of RECIPES) {
|
|
44
|
+
assert.ok(typeof r.id === 'string' && r.id.length > 0);
|
|
45
|
+
assert.ok('role' in r, `${r.id} declares a role key`);
|
|
46
|
+
assert.ok(Number.isInteger(r.minBackends), `${r.id} declares minBackends`);
|
|
47
|
+
assert.ok('degradesTo' in r, `${r.id} declares a degradation target`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('Solo is the floor (no role, no backend, no degradation target)', () => {
|
|
52
|
+
const solo = RECIPES.find((r) => r.id === 'solo');
|
|
53
|
+
assert.equal(solo.role, null);
|
|
54
|
+
assert.equal(solo.minBackends, 0);
|
|
55
|
+
assert.equal(solo.degradesTo, null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('Reviewed/Council need review; Delegated needs execute; degradation chains terminate at Solo', () => {
|
|
59
|
+
const by = Object.fromEntries(RECIPES.map((r) => [r.id, r]));
|
|
60
|
+
assert.equal(by.reviewed.role, 'review');
|
|
61
|
+
assert.equal(by.reviewed.minBackends, 1);
|
|
62
|
+
assert.equal(by.reviewed.degradesTo, 'solo');
|
|
63
|
+
assert.equal(by.council.role, 'review');
|
|
64
|
+
assert.equal(by.council.minBackends, 2);
|
|
65
|
+
assert.equal(by.council.degradesTo, 'reviewed');
|
|
66
|
+
assert.equal(by.delegated.role, 'execute');
|
|
67
|
+
assert.equal(by.delegated.minBackends, 1);
|
|
68
|
+
assert.equal(by.delegated.degradesTo, 'solo');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── drift guards ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('role-coverage drift-guard — every recipe role ∈ union of the bridges provides[]', () => {
|
|
75
|
+
it('no recipe demands a role no backend provides', () => {
|
|
76
|
+
const union = new Set([...readManifest(CODEX).provides, ...readManifest(AGY).provides]);
|
|
77
|
+
for (const r of RECIPES) {
|
|
78
|
+
if (r.role !== null) assert.ok(union.has(r.role), `recipe ${r.id} role "${r.role}" is provided by some backend`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('BACKEND_ROLES drift-guard — keyed by status.name, equals each bridge provides[]', () => {
|
|
84
|
+
it('is keyed by the manifest names the detector emits (not the display aliases)', () => {
|
|
85
|
+
assert.deepEqual(Object.keys(BACKEND_ROLES).sort(), [AGY, CODEX].sort());
|
|
86
|
+
});
|
|
87
|
+
it('matches each bridge capability.json provides[]', () => {
|
|
88
|
+
assert.deepEqual(BACKEND_ROLES[CODEX], readManifest(CODEX).provides);
|
|
89
|
+
assert.deepEqual(BACKEND_ROLES[AGY], readManifest(AGY).provides);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('BACKEND_META drift-guard — cost/quota mirror the manifests; agy carries a health advisory', () => {
|
|
94
|
+
it('cost + quota equal each bridge capability.json', () => {
|
|
95
|
+
for (const name of [CODEX, AGY]) {
|
|
96
|
+
const m = readManifest(name);
|
|
97
|
+
assert.equal(BACKEND_META[name].cost, m.cost);
|
|
98
|
+
assert.deepEqual(BACKEND_META[name].quota, m.quota);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it('the agy health advisory (Issue-001) is present as static project knowledge', () => {
|
|
102
|
+
assert.equal(typeof BACKEND_META[AGY].health, 'string');
|
|
103
|
+
assert.ok(BACKEND_META[AGY].health.length > 0);
|
|
104
|
+
// codex carries no standing health caveat
|
|
105
|
+
assert.ok(!BACKEND_META[CODEX].health);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('DISPLAY_ALIASES — the manifest-name → human-alias map', () => {
|
|
110
|
+
it('maps both bridges to their short aliases', () => {
|
|
111
|
+
assert.equal(DISPLAY_ALIASES[CODEX], 'codex');
|
|
112
|
+
assert.equal(DISPLAY_ALIASES[AGY], 'agy');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── engine ⟷ kit recipe-name parity (cross-package read in the monorepo) ───────────
|
|
117
|
+
|
|
118
|
+
describe('engine↔kit recipe-name parity — the four ids appear in the engine canon', () => {
|
|
119
|
+
const engineRefs = ['orchestration.md', 'orchestration-slot.md'].map((f) =>
|
|
120
|
+
readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', f), 'utf8').toLowerCase(),
|
|
121
|
+
);
|
|
122
|
+
for (const id of RECIPE_IDS) {
|
|
123
|
+
it(`"${id}" appears in both engine orchestration files`, () => {
|
|
124
|
+
for (const text of engineRefs) assert.ok(text.includes(id), `engine canon names "${id}"`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// The engine narrative (orchestration.md) hardcodes the bridges' role vocabulary as prose; keep it in
|
|
130
|
+
// lockstep with the manifests so a future `provides[]` change forces the narrative to be updated too.
|
|
131
|
+
describe('engine narrative ⟷ manifest role-vocabulary parity', () => {
|
|
132
|
+
const orchestration = readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', 'orchestration.md'), 'utf8');
|
|
133
|
+
const norm = (s) => s.replace(/\s+/g, ''); // whitespace-insensitive: prose has `["a", "b"]`, JSON has `["a","b"]`
|
|
134
|
+
|
|
135
|
+
for (const name of [CODEX, AGY]) {
|
|
136
|
+
it(`orchestration.md renders ${name}'s provides[] from the manifest`, () => {
|
|
137
|
+
const provides = readManifest(name).provides;
|
|
138
|
+
assert.ok(
|
|
139
|
+
norm(orchestration).includes(norm(`provides: ${JSON.stringify(provides)}`)),
|
|
140
|
+
`orchestration.md §1 must render ${name} provides ${JSON.stringify(provides)} (drifted from the manifest)`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('the agy health advisory (Issue-001) is consistent between BACKEND_META and the engine narrative', () => {
|
|
146
|
+
// Flatten whitespace so a prose line-wrap (e.g. "substantive\nprompts") doesn't hide the substring.
|
|
147
|
+
const flat = orchestration.replace(/\s+/g, ' ').toLowerCase();
|
|
148
|
+
assert.match(flat, /stall on substantive prompts/, 'the engine narrates the stall advisory');
|
|
149
|
+
assert.match(flat, /issue-001/, 'the engine narrative ties it to Issue-001');
|
|
150
|
+
assert.match(flat, /prefer .?codex/, 'the engine narrates the prefer-codex remedy');
|
|
151
|
+
// BACKEND_META carries the same advisory facts (kit-side), tying the two representations together.
|
|
152
|
+
const health = BACKEND_META[AGY].health.toLowerCase();
|
|
153
|
+
assert.ok(health.includes('stall on substantive prompts') && health.includes('issue-001') && health.includes('codex'));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── planRecipe ─────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const dispatchBackends = (plan) => plan.dispatch.map((d) => d.backend);
|
|
160
|
+
const notesText = (plan) => plan.notes.join(' :: ');
|
|
161
|
+
|
|
162
|
+
describe('planRecipe — all backends ready', () => {
|
|
163
|
+
const det = detect(READY, READY);
|
|
164
|
+
|
|
165
|
+
it('Solo: no degradation, no dispatch', () => {
|
|
166
|
+
const p = planRecipe('solo', det);
|
|
167
|
+
assert.equal(p.effective, 'solo');
|
|
168
|
+
assert.equal(p.degraded, false);
|
|
169
|
+
assert.deepEqual(p.dispatch, []);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('Reviewed: picks codex over agy (deterministic tie-break), not degraded', () => {
|
|
173
|
+
const p = planRecipe('reviewed', det);
|
|
174
|
+
assert.equal(p.effective, 'reviewed');
|
|
175
|
+
assert.equal(p.degraded, false);
|
|
176
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
177
|
+
// codex chosen → the agy health caveat is NOT attached
|
|
178
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('Council: both backends review; the agy health caveat + the two-quota note are attached', () => {
|
|
182
|
+
const p = planRecipe('council', det);
|
|
183
|
+
assert.equal(p.effective, 'council');
|
|
184
|
+
assert.equal(p.degraded, false);
|
|
185
|
+
assert.deepEqual(dispatchBackends(p), [CODEX, AGY]);
|
|
186
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is used');
|
|
187
|
+
assert.match(notesText(p), /two backends|both backends|two .*quota/i);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('Delegated: codex executes the bounded sub-task', () => {
|
|
191
|
+
const p = planRecipe('delegated', det);
|
|
192
|
+
assert.equal(p.effective, 'delegated');
|
|
193
|
+
assert.equal(p.degraded, false);
|
|
194
|
+
assert.deepEqual(p.dispatch, [{ role: 'execute', backend: CODEX, display: 'codex' }]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('planRecipe — codex only (agy needs-skill)', () => {
|
|
199
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
200
|
+
|
|
201
|
+
it('Council → Reviewed(codex) with a stated reason', () => {
|
|
202
|
+
const p = planRecipe('council', det);
|
|
203
|
+
assert.equal(p.effective, 'reviewed');
|
|
204
|
+
assert.equal(p.degraded, true);
|
|
205
|
+
assert.equal(p.degradation[0].from, 'council');
|
|
206
|
+
assert.equal(p.degradation[0].to, 'reviewed');
|
|
207
|
+
assert.match(p.degradation[0].reason, /not installed/i);
|
|
208
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('Delegated stays Delegated (codex provides execute and is ready)', () => {
|
|
212
|
+
const p = planRecipe('delegated', det);
|
|
213
|
+
assert.equal(p.effective, 'delegated');
|
|
214
|
+
assert.equal(p.degraded, false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('Reviewed → codex', () => {
|
|
218
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [CODEX]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('planRecipe — agy only (codex needs-skill)', () => {
|
|
223
|
+
const det = detect(NEEDS_SKILL, READY);
|
|
224
|
+
|
|
225
|
+
it('Council → Reviewed(agy)', () => {
|
|
226
|
+
const p = planRecipe('council', det);
|
|
227
|
+
assert.equal(p.effective, 'reviewed');
|
|
228
|
+
assert.equal(p.degraded, true);
|
|
229
|
+
assert.deepEqual(dispatchBackends(p), [AGY]);
|
|
230
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is the reviewer');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('Delegated → Solo (no backend provides execute) with the reason stated', () => {
|
|
234
|
+
const p = planRecipe('delegated', det);
|
|
235
|
+
assert.equal(p.effective, 'solo');
|
|
236
|
+
assert.equal(p.degraded, true);
|
|
237
|
+
assert.match(p.degradation[0].reason, /execute/i);
|
|
238
|
+
assert.deepEqual(p.dispatch, []);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('Reviewed → agy', () => {
|
|
242
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [AGY]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('planRecipe — none installed (both needs-skill)', () => {
|
|
247
|
+
const det = detect(NEEDS_SKILL, NEEDS_SKILL);
|
|
248
|
+
for (const id of ['reviewed', 'council', 'delegated']) {
|
|
249
|
+
it(`${id} → Solo, reason names the not-installed bridge skill`, () => {
|
|
250
|
+
const p = planRecipe(id, det);
|
|
251
|
+
assert.equal(p.effective, 'solo');
|
|
252
|
+
assert.equal(p.degraded, true);
|
|
253
|
+
assert.match(p.degradation.map((d) => d.reason).join(' '), /not installed/i);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('planRecipe — agy degraded (wrapper not on PATH)', () => {
|
|
259
|
+
const det = detect(READY, DEGRADED);
|
|
260
|
+
|
|
261
|
+
it('Reviewed → codex (agy not dispatchable); no agy health note (agy unused)', () => {
|
|
262
|
+
const p = planRecipe('reviewed', det);
|
|
263
|
+
assert.equal(p.effective, 'reviewed');
|
|
264
|
+
assert.equal(p.degraded, false);
|
|
265
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
266
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('Council → Reviewed(codex); the degradation reason is the wrapper one (distinct from the health note)', () => {
|
|
270
|
+
const p = planRecipe('council', det);
|
|
271
|
+
assert.equal(p.effective, 'reviewed');
|
|
272
|
+
assert.equal(p.degraded, true);
|
|
273
|
+
assert.match(p.degradation[0].reason, /PATH|wrapper/i);
|
|
274
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('planRecipe — purity + determinism', () => {
|
|
279
|
+
it('is deterministic: same detection → deeply-equal plan', () => {
|
|
280
|
+
const det = detect(READY, NEEDS_CREDENTIALS);
|
|
281
|
+
assert.deepEqual(planRecipe('council', det), planRecipe('council', det));
|
|
282
|
+
});
|
|
283
|
+
it('does not mutate the detection input', () => {
|
|
284
|
+
const det = detect(READY, READY);
|
|
285
|
+
const snapshot = JSON.parse(JSON.stringify(det));
|
|
286
|
+
planRecipe('council', det);
|
|
287
|
+
assert.deepEqual(det, snapshot);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── recommendRecipe ──────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe('recommendRecipe — never blank; the everyday default', () => {
|
|
294
|
+
it('both ready → Council available, Reviewed the everyday default', () => {
|
|
295
|
+
const r = recommendRecipe(detect(READY, READY));
|
|
296
|
+
assert.equal(r.recipe, 'council');
|
|
297
|
+
assert.match(r.clause, /council/i);
|
|
298
|
+
assert.match(r.clause, /reviewed/i);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('one ready → Reviewed', () => {
|
|
302
|
+
const r = recommendRecipe(detect(READY, NEEDS_SKILL));
|
|
303
|
+
assert.equal(r.recipe, 'reviewed');
|
|
304
|
+
assert.match(r.clause, /reviewed/i);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('none installed → Solo + a setup pointer', () => {
|
|
308
|
+
const r = recommendRecipe(detect(NEEDS_SKILL, NEEDS_SKILL));
|
|
309
|
+
assert.equal(r.recipe, 'solo');
|
|
310
|
+
assert.match(r.clause, /solo/i);
|
|
311
|
+
assert.match(r.clause, /\/agent-workflow-kit setup/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('present-but-not-ready → Solo with the specific remedy', () => {
|
|
315
|
+
const r = recommendRecipe(detect(NEEDS_CLI, NEEDS_SKILL));
|
|
316
|
+
assert.equal(r.recipe, 'solo');
|
|
317
|
+
assert.match(r.clause, /CLI|cli/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('the clause is never empty', () => {
|
|
321
|
+
for (const det of [
|
|
322
|
+
detect(READY, READY),
|
|
323
|
+
detect(READY, NEEDS_SKILL),
|
|
324
|
+
detect(NEEDS_SKILL, NEEDS_SKILL),
|
|
325
|
+
detect(NEEDS_CREDENTIALS, DEGRADED),
|
|
326
|
+
]) {
|
|
327
|
+
assert.ok(recommendRecipe(det).clause.trim().length > 0);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('present-but-not-ready tie-break is deterministic (codex before agy) regardless of detection order', () => {
|
|
332
|
+
// Both present-but-not-ready at the SAME readiness rank → the remedy must name codex, not whichever
|
|
333
|
+
// the detector happened to emit first (mirrors the dispatch-path priority).
|
|
334
|
+
const forward = recommendRecipe([{ name: CODEX, readiness: DEGRADED }, { name: AGY, readiness: DEGRADED }]);
|
|
335
|
+
const reversed = recommendRecipe([{ name: AGY, readiness: DEGRADED }, { name: CODEX, readiness: DEGRADED }]);
|
|
336
|
+
assert.equal(forward.clause, reversed.clause, 'order-independent');
|
|
337
|
+
assert.match(forward.clause, /codex/, 'ties break to codex');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── CLI / formatRecipes ──────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe('formatRecipes — deterministic advisor text', () => {
|
|
344
|
+
it('renders the four recipes + a recommendation deterministically', () => {
|
|
345
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
346
|
+
const once = formatRecipes(det);
|
|
347
|
+
assert.equal(once, formatRecipes(det), 'same detection → identical text');
|
|
348
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(once, new RegExp(title));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('recipes.mjs CLI — read-only, exit 0', () => {
|
|
353
|
+
it('prints the recipes and exits 0', () => {
|
|
354
|
+
const out = execFileSync(process.execPath, [SCRIPT], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
355
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(out, new RegExp(title));
|
|
356
|
+
});
|
|
357
|
+
it('--json emits parseable JSON with the recommendation', () => {
|
|
358
|
+
const out = execFileSync(process.execPath, [SCRIPT, '--json'], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
359
|
+
const parsed = JSON.parse(out);
|
|
360
|
+
assert.ok(Array.isArray(parsed.recipes));
|
|
361
|
+
assert.ok(parsed.recommendation && typeof parsed.recommendation.recipe === 'string');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Integration acceptance for the guarded uninstaller against the REAL filesystem — what the mocked
|
|
2
|
+
// unit test cannot prove: real validateManifest over real skill dirs, real removeTreeManaged deleting
|
|
3
|
+
// a real tree, real unlinkManaged removing OUR symlink while leaving a FOREIGN one, a real marker
|
|
4
|
+
// pre-commit hook removed, and user-authored docs/ai LEFT INTACT after a full --yes teardown. The
|
|
5
|
+
// git-backed fence unhide is delegated to hideFootprint (already covered by its own integration test),
|
|
6
|
+
// so it is injected here as a recording stub — this test owns the uninstaller's own fs mutations.
|
|
7
|
+
|
|
8
|
+
import { describe, it, afterEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, existsSync, lstatSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { buildPlan, executePlan, SAFE_REMOVE, MANAGED_MARKER, REPORT_ONLY, STOP } from './uninstall.mjs';
|
|
14
|
+
import { surveyFamily, surveyProject } from './family-registry.mjs';
|
|
15
|
+
import { START_MARKER } from './hide-footprint.mjs';
|
|
16
|
+
|
|
17
|
+
const made = [];
|
|
18
|
+
const mkdtemp = (tag) => { const d = mkdtempSync(join(tmpdir(), tag)); made.push(d); return d; };
|
|
19
|
+
afterEach(() => { while (made.length) { try { rmSync(made.pop(), { recursive: true, force: true }); } catch { /* best effort */ } } });
|
|
20
|
+
|
|
21
|
+
const writeFile = (p, s) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, s); };
|
|
22
|
+
|
|
23
|
+
// A minimal but VALID family skill dir (passes the real validateManifest: family/schema/kind/version
|
|
24
|
+
// match + SKILL.md metadata.version == capability.json version + role sources exist).
|
|
25
|
+
const makeMemorySkill = (skillsRoot) => {
|
|
26
|
+
const dir = join(skillsRoot, 'agent-workflow-memory');
|
|
27
|
+
writeFile(join(dir, 'SKILL.md'), "---\nname: agent-workflow-memory\nmetadata:\n version: '1.1.1'\n---\n# memory\n");
|
|
28
|
+
writeFile(join(dir, 'capability.json'), JSON.stringify({
|
|
29
|
+
family: 'agent-workflow', schema: 1, name: 'agent-workflow-memory', kind: 'memory-substrate',
|
|
30
|
+
version: '1.1.1', provides: ['context'], roles: {},
|
|
31
|
+
detect: { installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' } },
|
|
32
|
+
}));
|
|
33
|
+
return dir;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const makeCodexBridge = (skillsRoot) => {
|
|
37
|
+
const dir = join(skillsRoot, 'codex-cli-bridge');
|
|
38
|
+
writeFile(join(dir, 'SKILL.md'), "---\nname: codex-cli-bridge\nmetadata:\n version: '1.0.0'\n---\n# codex\n");
|
|
39
|
+
writeFile(join(dir, 'bin', 'codex-exec.sh'), '#!/bin/sh\n');
|
|
40
|
+
writeFile(join(dir, 'bin', 'codex-review.sh'), '#!/bin/sh\n');
|
|
41
|
+
writeFile(join(dir, 'capability.json'), JSON.stringify({
|
|
42
|
+
family: 'agent-workflow', schema: 1, name: 'codex-cli-bridge', kind: 'execution-backend',
|
|
43
|
+
version: '1.0.0', provides: ['execute', 'review'],
|
|
44
|
+
roles: {
|
|
45
|
+
execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' },
|
|
46
|
+
review: { cmd: 'codex-review', source: 'bin/codex-review.sh' },
|
|
47
|
+
},
|
|
48
|
+
detect: { installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' } },
|
|
49
|
+
}));
|
|
50
|
+
return dir;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('uninstall integration (real fs)', () => {
|
|
54
|
+
it('plans + applies a full guarded teardown: removes ours, keeps foreign + user-authored', () => {
|
|
55
|
+
const home = mkdtemp('aw-unh-home-');
|
|
56
|
+
const skills = mkdtemp('aw-unh-skills-');
|
|
57
|
+
const bindir = mkdtemp('aw-unh-bin-');
|
|
58
|
+
const proj = mkdtemp('aw-unh-proj-');
|
|
59
|
+
const foreignTarget = mkdtemp('aw-unh-foreign-');
|
|
60
|
+
|
|
61
|
+
const memorySkill = makeMemorySkill(skills);
|
|
62
|
+
const codexSkill = makeCodexBridge(skills);
|
|
63
|
+
|
|
64
|
+
// ~/.local/bin wrappers: codex-exec is OURS; codex-review is a FOREIGN symlink (must be kept).
|
|
65
|
+
symlinkSync(join(codexSkill, 'bin/codex-exec.sh'), join(bindir, 'codex-exec'));
|
|
66
|
+
writeFileSync(join(foreignTarget, 'codex-review'), '#!/bin/sh\n');
|
|
67
|
+
symlinkSync(join(foreignTarget, 'codex-review'), join(bindir, 'codex-review'));
|
|
68
|
+
|
|
69
|
+
// Project surfaces: a hidden fence, OUR marker pre-commit hook, and user-authored docs/ai.
|
|
70
|
+
writeFile(join(proj, '.git/info/exclude'), `# user rule\n${START_MARKER}\n/AGENTS.md\n# <<< agent-workflow-kit hidden mode <<<\n`);
|
|
71
|
+
writeFile(join(proj, '.git/hooks/pre-commit'), '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\nnode scripts/check-docs-size.mjs\n');
|
|
72
|
+
writeFile(join(proj, 'docs/ai/handover.md'), '# handover (USER-AUTHORED)\n');
|
|
73
|
+
writeFile(join(proj, 'docs/ai/.workflow-version'), '1.3.0\n');
|
|
74
|
+
|
|
75
|
+
// Resolve only memory + codex as installed (env-pointed); other members fall to <home>/.claude → absent.
|
|
76
|
+
const deps = {
|
|
77
|
+
getenv: { AGENT_WORKFLOW_MEMORY_DIR: memorySkill, CODEX_CLI_BRIDGE_DIR: codexSkill },
|
|
78
|
+
home,
|
|
79
|
+
};
|
|
80
|
+
const family = surveyFamily(deps);
|
|
81
|
+
const project = surveyProject(proj, deps);
|
|
82
|
+
assert.equal(project.hiddenFence, true);
|
|
83
|
+
assert.equal(family.find((m) => m.name === 'agent-workflow-memory').manifestState, 'ok');
|
|
84
|
+
assert.equal(family.find((m) => m.name === 'codex-cli-bridge').manifestState, 'ok');
|
|
85
|
+
|
|
86
|
+
const plan = buildPlan({ family, project, projectDir: proj, bindir }, deps);
|
|
87
|
+
const cls = (surface, pred) => plan.items.find((i) => i.surface === surface && pred(i));
|
|
88
|
+
assert.equal(cls('skill', (i) => i.member === 'agent-workflow-memory').class, SAFE_REMOVE);
|
|
89
|
+
assert.equal(cls('wrapper', (i) => i.path.endsWith('codex-exec')).class, MANAGED_MARKER);
|
|
90
|
+
assert.equal(cls('wrapper', (i) => i.path.endsWith('codex-review')).class, STOP); // foreign
|
|
91
|
+
assert.equal(cls('fence', () => true).class, MANAGED_MARKER);
|
|
92
|
+
assert.equal(cls('hook', () => true).class, MANAGED_MARKER);
|
|
93
|
+
assert.equal(cls('docs', (i) => i.path.endsWith('docs/ai')).class, REPORT_ONLY);
|
|
94
|
+
|
|
95
|
+
// Apply. Inject a recording fence-unhide (its real git path is covered by hide-footprint's own test).
|
|
96
|
+
const unhideCalls = [];
|
|
97
|
+
const r = executePlan(plan, { yes: true }, { ...deps, hideFootprint: (opts) => { unhideCalls.push(opts); return { action: 'unhidden' }; } });
|
|
98
|
+
|
|
99
|
+
// Ours is gone:
|
|
100
|
+
assert.equal(existsSync(memorySkill), false, 'memory skill dir removed');
|
|
101
|
+
assert.equal(existsSync(codexSkill), false, 'codex skill dir removed');
|
|
102
|
+
assert.equal(existsSync(join(bindir, 'codex-exec')), false, 'our wrapper symlink removed');
|
|
103
|
+
assert.equal(existsSync(join(proj, '.git/hooks/pre-commit')), false, 'marker hook removed');
|
|
104
|
+
assert.equal(r.unhidden, true);
|
|
105
|
+
// The fence is validated by a dry-run unhide in preflight, then unhidden for real in the mutate phase.
|
|
106
|
+
assert.deepEqual(unhideCalls, [{ dir: proj, unhide: true, dryRun: true }, { dir: proj, unhide: true }]);
|
|
107
|
+
|
|
108
|
+
// Foreign + user-authored is KEPT:
|
|
109
|
+
assert.equal(lstatSync(join(bindir, 'codex-review')).isSymbolicLink(), true, 'foreign wrapper kept');
|
|
110
|
+
assert.equal(existsSync(join(foreignTarget, 'codex-review')), true, 'foreign target untouched');
|
|
111
|
+
assert.equal(existsSync(join(proj, 'docs/ai/handover.md')), true, 'user-authored docs/ai NEVER deleted');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('preflight refuses (zero mutation) when a skill dir turns foreign between plan and apply', () => {
|
|
115
|
+
const home = mkdtemp('aw-unh2-home-');
|
|
116
|
+
const skills = mkdtemp('aw-unh2-skills-');
|
|
117
|
+
const memorySkill = makeMemorySkill(skills);
|
|
118
|
+
const deps = { getenv: { AGENT_WORKFLOW_MEMORY_DIR: memorySkill }, home };
|
|
119
|
+
|
|
120
|
+
const family = surveyFamily(deps);
|
|
121
|
+
const plan = buildPlan({ family }, deps);
|
|
122
|
+
assert.equal(plan.items.find((i) => i.surface === 'skill').class, SAFE_REMOVE);
|
|
123
|
+
|
|
124
|
+
// Corrupt the manifest after planning → the preflight re-check must STOP, leaving the dir intact.
|
|
125
|
+
writeFileSync(join(memorySkill, 'capability.json'), '{ not json');
|
|
126
|
+
assert.throws(() => executePlan(plan, { yes: true }, deps), (err) => err.code === 'UNINSTALL_STOP');
|
|
127
|
+
assert.equal(existsSync(memorySkill), true, 'skill dir untouched after a refused preflight');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps a pre-commit hook whose marker was removed between plan and apply', () => {
|
|
131
|
+
const home = mkdtemp('aw-unh3-home-');
|
|
132
|
+
const proj = mkdtemp('aw-unh3-proj-');
|
|
133
|
+
writeFile(join(proj, '.git/hooks/pre-commit'), '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\n');
|
|
134
|
+
const deps = { getenv: {}, home };
|
|
135
|
+
|
|
136
|
+
const plan = buildPlan({ family: [], project: surveyProject(proj, deps), projectDir: proj }, deps);
|
|
137
|
+
assert.equal(plan.items.find((i) => i.surface === 'hook').class, MANAGED_MARKER);
|
|
138
|
+
|
|
139
|
+
// The user rewrites the hook (dropping our marker) before they apply the teardown.
|
|
140
|
+
writeFileSync(join(proj, '.git/hooks/pre-commit'), '#!/bin/sh\n# my own hook now\n');
|
|
141
|
+
assert.throws(() => executePlan(plan, { yes: true }, deps), (err) => err.code === 'UNINSTALL_STOP');
|
|
142
|
+
assert.equal(existsSync(join(proj, '.git/hooks/pre-commit')), true, 'a now-unmarked (user) hook is NEVER deleted');
|
|
143
|
+
});
|
|
144
|
+
});
|