@sabaiway/agent-workflow-kit 1.10.0 → 1.12.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.
@@ -9,6 +9,8 @@ import {
9
9
  assertContainedRealPath,
10
10
  copyTreeRefresh,
11
11
  linkManaged,
12
+ removeTreeManaged,
13
+ unlinkManaged,
12
14
  MANAGED_LINK_CONFLICT,
13
15
  } from './fs-safe.mjs';
14
16
 
@@ -30,6 +32,12 @@ describe('assertContainedRealPath', () => {
30
32
  assert.throws(() => assertContainedRealPath('/root', '/etc/passwd'), /outside/);
31
33
  });
32
34
 
35
+ it('Issue-004: accepts a contained child literally named "..foo", still rejects a true ".." segment', () => {
36
+ // rel "..foo" is a real child, NOT a "../" escape — the old `rel.startsWith('..')` wrongly rejected it.
37
+ assert.doesNotThrow(() => assertContainedRealPath(dir, join(dir, '..foo')));
38
+ assert.throws(() => assertContainedRealPath(dir, join(dir, '..', 'escape')), /outside/);
39
+ });
40
+
33
41
  it('rejects writing INTO a symlinked root', () => {
34
42
  const real = join(dir, 'real');
35
43
  const root = join(dir, 'root');
@@ -198,3 +206,141 @@ describe('linkManaged', () => {
198
206
  assert.throws(() => linkManaged(srcDir, join(root, 'cmd'), root), /regular file/i);
199
207
  });
200
208
  });
209
+
210
+ // ── removeTreeManaged ───────────────────────────────────────────────────────────
211
+
212
+ describe('removeTreeManaged', () => {
213
+ it('recursively removes a managed dir tree', () => {
214
+ const root = join(dir, 'skills');
215
+ const skill = join(root, 'agent-workflow-kit');
216
+ mkdirSync(join(skill, 'tools'), { recursive: true });
217
+ writeFileSync(join(skill, 'SKILL.md'), 'x');
218
+ writeFileSync(join(skill, 'tools', 'a.mjs'), 'y');
219
+ const result = removeTreeManaged(skill, root);
220
+ assert.equal(result, 'removed');
221
+ assert.equal(existsSync(skill), false);
222
+ assert.equal(existsSync(root), true); // only the target went, not the parent
223
+ });
224
+
225
+ it('is a no-op when the target is already absent', () => {
226
+ const root = join(dir, 'skills');
227
+ mkdirSync(root);
228
+ assert.equal(removeTreeManaged(join(root, 'gone'), root), 'noop');
229
+ });
230
+
231
+ it('STOPs on a symlinked target (never follows + deletes through it)', () => {
232
+ const root = join(dir, 'skills');
233
+ const real = join(dir, 'real-skill');
234
+ mkdirSync(root);
235
+ mkdirSync(real);
236
+ writeFileSync(join(real, 'keep.txt'), 'keep');
237
+ symlinkSync(real, join(root, 'agent-workflow-kit')); // the skill dir is a symlink
238
+ assert.throws(() => removeTreeManaged(join(root, 'agent-workflow-kit'), root), /symlink/i);
239
+ assert.equal(existsSync(join(real, 'keep.txt')), true); // the target it pointed at is untouched
240
+ });
241
+
242
+ it('removes a symlink ENTRY inside the tree without touching what it points at', () => {
243
+ const root = join(dir, 'skills');
244
+ const skill = join(root, 'agent-workflow-kit');
245
+ const outside = join(dir, 'outside');
246
+ mkdirSync(skill, { recursive: true });
247
+ mkdirSync(outside);
248
+ writeFileSync(join(outside, 'precious.txt'), 'precious');
249
+ symlinkSync(outside, join(skill, 'link-to-outside')); // an internal symlink
250
+ removeTreeManaged(skill, root);
251
+ assert.equal(existsSync(skill), false);
252
+ assert.equal(existsSync(join(outside, 'precious.txt')), true); // never recursed through the link
253
+ });
254
+
255
+ it('refuses a target outside the root', () => {
256
+ const root = join(dir, 'skills');
257
+ mkdirSync(root);
258
+ assert.throws(() => removeTreeManaged(join(dir, 'elsewhere'), root), /outside/);
259
+ });
260
+
261
+ it('rm is injectable (no real deletion when injected)', () => {
262
+ const root = join(dir, 'skills');
263
+ const skill = join(root, 'agent-workflow-kit');
264
+ mkdirSync(skill, { recursive: true });
265
+ let removed = null;
266
+ const result = removeTreeManaged(skill, root, { rm: (p) => { removed = p; } });
267
+ assert.equal(result, 'removed');
268
+ assert.equal(removed, skill);
269
+ assert.equal(existsSync(skill), true); // the injected rm did nothing
270
+ });
271
+ });
272
+
273
+ // ── unlinkManaged ───────────────────────────────────────────────────────────────
274
+
275
+ describe('unlinkManaged', () => {
276
+ const makeSrc = () => {
277
+ const src = join(dir, 'src.sh');
278
+ writeFileSync(src, '#!/bin/sh\n');
279
+ return src;
280
+ };
281
+
282
+ it('unlinks a symlink that points at our source', () => {
283
+ const src = makeSrc();
284
+ const root = join(dir, 'bin');
285
+ mkdirSync(root);
286
+ const dest = join(root, 'cmd');
287
+ symlinkSync(src, dest);
288
+ const result = unlinkManaged(dest, src, root);
289
+ assert.equal(result, 'unlinked');
290
+ assert.equal(existsSync(dest), false);
291
+ assert.equal(existsSync(src), true); // the source it pointed at is untouched
292
+ });
293
+
294
+ it('is a no-op when the dest is absent', () => {
295
+ const src = makeSrc();
296
+ const root = join(dir, 'bin');
297
+ mkdirSync(root);
298
+ assert.equal(unlinkManaged(join(root, 'cmd'), src, root), 'noop');
299
+ });
300
+
301
+ it('STOPs on a non-symlink dest (typed ManagedLinkConflict)', () => {
302
+ const src = makeSrc();
303
+ const root = join(dir, 'bin');
304
+ mkdirSync(root);
305
+ const dest = join(root, 'cmd');
306
+ writeFileSync(dest, 'someone-elses-file');
307
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
308
+ assert.equal(readFileSync(dest, 'utf8'), 'someone-elses-file'); // untouched
309
+ });
310
+
311
+ it('STOPs on a foreign symlink (points elsewhere)', () => {
312
+ const src = makeSrc();
313
+ const root = join(dir, 'bin');
314
+ mkdirSync(root);
315
+ const dest = join(root, 'cmd');
316
+ const foreign = join(dir, 'foreign.sh');
317
+ writeFileSync(foreign, '#!/bin/sh\n');
318
+ symlinkSync(foreign, dest);
319
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
320
+ assert.equal(readlinkSync(dest), foreign); // untouched
321
+ });
322
+
323
+ it('removes a dangling symlink that still textually points at our source', () => {
324
+ const src = join(dir, 'src.sh'); // never created → the link is dangling
325
+ const root = join(dir, 'bin');
326
+ mkdirSync(root);
327
+ const dest = join(root, 'cmd');
328
+ symlinkSync(src, dest);
329
+ assert.equal(unlinkManaged(dest, src, root), 'unlinked');
330
+ assert.equal(lstatSync(root).isDirectory(), true);
331
+ assert.equal(existsSync(dest), false);
332
+ });
333
+
334
+ it('unlink is injectable', () => {
335
+ const src = makeSrc();
336
+ const root = join(dir, 'bin');
337
+ mkdirSync(root);
338
+ const dest = join(root, 'cmd');
339
+ symlinkSync(src, dest);
340
+ let unlinked = null;
341
+ const result = unlinkManaged(dest, src, root, { unlink: (p) => { unlinked = p; } });
342
+ assert.equal(result, 'unlinked');
343
+ assert.equal(unlinked, dest);
344
+ assert.equal(existsSync(dest), true); // the injected unlink did nothing
345
+ });
346
+ });
@@ -3,9 +3,12 @@
3
3
  // deployed AGENTS.md.
4
4
  //
5
5
  // Both templates (memory's + the kit fallback) ship an EMPTY delimited slot; the kit (which knows
6
- // the whole family) fills it. The bounded fragment (tools/methodology-slot.md) is a BOUNDED summary
7
- // + pointer, NOT the full references/planning.md, so AGENTS.md stays under its line cap; it is a
8
- // byte-identical MIRROR of the canonical text in agent-workflow-engine (drift-guarded).
6
+ // the whole family) fills it. The bounded fragment is a BOUNDED summary + pointer (NOT the full
7
+ // references/planning.md), so AGENTS.md stays under its line cap. It is read LIVE from the installed
8
+ // agent-workflow-engine (references/methodology-slot.md) via engine-source.mjs — the family's one
9
+ // source of truth; there is no bundled mirror (retired in Plan 3D, AD-016). The live read is lazy +
10
+ // fail-loud: resolve+read the engine ONLY when a fill is actually needed, and STOP loudly (never a
11
+ // silent fallback) when the engine is needed but absent/invalid.
9
12
  //
10
13
  // Two layers over one marker parser:
11
14
  // - injectMethodology — fill an EXISTING slot. Marker contract, strictly enforced:
@@ -152,11 +155,24 @@ export const reconcileSlot = (text, fragment, { maxLines } = {}) => {
152
155
  return { status, text: injected.text };
153
156
  };
154
157
 
158
+ // Pure predicate (no fs): does this AGENTS.md actually need the methodology fragment? True only when
159
+ // the slot can be ensured (present or insertable) AND is empty — i.e. when reconcileSlot would
160
+ // inject. False when the slot is already filled (preserve-verbatim, no fragment read) OR when the
161
+ // slot/anchor is malformed (so reconcileSlot's own precise error path still fires). It reuses the
162
+ // SAME primitives as reconcileSlot (ensureSlot + extractSlot), so the lazy "read the engine only
163
+ // when needed" guard in main() cannot diverge from the actual fill decision.
164
+ export const slotNeedsFill = (text) => {
165
+ const ensured = ensureSlot(text);
166
+ if (ensured.status === 'error') return false;
167
+ const current = extractSlot(ensured.text);
168
+ return current == null || current.trim() === '';
169
+ };
170
+
155
171
  const main = async (argv) => {
156
172
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
157
173
  const { dirname, basename, join, resolve } = await import('node:path');
158
- const { fileURLToPath } = await import('node:url');
159
- const here = dirname(fileURLToPath(import.meta.url));
174
+ const { homedir } = await import('node:os');
175
+ const { resolveEngineDir, readEngineFragment } = await import('./engine-source.mjs');
160
176
 
161
177
  // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade);
162
178
  // `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-slot mode.
@@ -167,9 +183,29 @@ const main = async (argv) => {
167
183
  console.error('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
168
184
  process.exit(2);
169
185
  }
170
- const fragmentPath = rest[1] ? resolve(rest[1]) : resolve(here, 'methodology-slot.md');
186
+ const explicitFragmentArg = rest[1];
171
187
  const text = await readFile(resolve(agentsPath), 'utf8');
172
- const fragment = await readFile(fragmentPath, 'utf8');
188
+
189
+ // Source the bounded fragment LAZILY. An explicit [fragment.md] arg (tests + manual) wins and skips
190
+ // engine resolution entirely. Otherwise read it LIVE from the installed engine — there is no
191
+ // bundled mirror. readEngineFragment THROWS (never falls back) when the engine is needed but
192
+ // absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying the install
193
+ // command. The caller only invokes this when a fill is actually needed (the laziness).
194
+ const sourceFragment = async () => {
195
+ if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
196
+ const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
197
+ return readEngineFragment(dir, { source }); // sync; throws loudly when the engine is absent/invalid
198
+ };
199
+ const sourceFragmentOrStop = async (label) => {
200
+ try {
201
+ return await sourceFragment();
202
+ } catch (err) {
203
+ // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The
204
+ // "methodology engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
205
+ console.error(`[inject-methodology] ${label} — ${err.message}`);
206
+ process.exit(1);
207
+ }
208
+ };
173
209
 
174
210
  const writeAtomic = async (out) => {
175
211
  const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
@@ -183,6 +219,10 @@ const main = async (argv) => {
183
219
  };
184
220
 
185
221
  if (mode === 'reconcile') {
222
+ // Read the engine only when the slot actually needs filling (lazy). slotNeedsFill reuses the same
223
+ // primitives reconcileSlot does, so it cannot disagree with the fill decision below — a filled
224
+ // slot reconciles to a zero-diff no-op WITHOUT consulting the engine.
225
+ const fragment = slotNeedsFill(text) ? await sourceFragmentOrStop('reconcile STOP') : '';
186
226
  const result = reconcileSlot(text, fragment, { maxLines: AGENTS_MD_CAP });
187
227
  if (result.status === 'error') {
188
228
  console.error(`[inject-methodology] reconcile refused — ${result.error}`);
@@ -201,6 +241,10 @@ const main = async (argv) => {
201
241
  return;
202
242
  }
203
243
 
244
+ // Legacy inject-into-existing-slot mode. injectMethodology no-ops on absent markers and errors on a
245
+ // malformed slot WITHOUT reading the fragment, so resolve+read the engine only when there is a
246
+ // present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
247
+ const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP') : '';
204
248
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
205
249
  if (result.status === 'error') {
206
250
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
@@ -1,6 +1,6 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -11,6 +11,7 @@ import {
11
11
  extractSlot,
12
12
  ensureSlot,
13
13
  reconcileSlot,
14
+ slotNeedsFill,
14
15
  METHODOLOGY_ANCHOR,
15
16
  EMPTY_SLOT,
16
17
  AGENTS_MD_CAP,
@@ -20,7 +21,47 @@ import {
20
21
 
21
22
  const HERE = dirname(fileURLToPath(import.meta.url));
22
23
  const SCRIPT = join(HERE, 'inject-methodology.mjs');
23
- const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
24
+
25
+ // The bounded methodology fragment is read LIVE from the installed engine now (the kit mirror is
26
+ // retired in Plan 3D), so this suite no longer reads a bundled methodology-slot.md. Tests use an
27
+ // inline fragment and, for the live-read CLI cases, an on-the-fly engine fixture that ships exactly
28
+ // this fragment — keeping the kit suite decoupled from the sibling engine's on-disk presence.
29
+ // Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
30
+ const FRAGMENT =
31
+ '> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
32
+
33
+ // Temp dirs created by the fixtures below — cleaned up once after the whole file.
34
+ const tmpDirs = [];
35
+ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
36
+
37
+ // A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
38
+ // whose metadata.version matches it (the validator's authoritative version source when there is no
39
+ // package.json) + the live fragment at references/methodology-slot.md. detectEngine accepts it.
40
+ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
41
+ const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
42
+ tmpDirs.push(dir);
43
+ const manifest = {
44
+ family: 'agent-workflow',
45
+ schema: 1,
46
+ name: 'agent-workflow-engine',
47
+ kind: 'methodology-engine',
48
+ version,
49
+ available: true,
50
+ provides: ['plan'],
51
+ roles: {},
52
+ };
53
+ writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifest, null, 2));
54
+ writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
55
+ mkdirSync(join(dir, 'references'), { recursive: true });
56
+ writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
57
+ return dir;
58
+ };
59
+
60
+ const ENGINE = makeEngineFixture();
61
+ // A path that is guaranteed NOT to be a valid engine — proves the no-op / explicit-override paths
62
+ // never consult the engine, and drives the fail-loud STOP.
63
+ const NO_ENGINE = join(tmpdir(), `definitely-no-engine-${process.pid}`);
64
+ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR: engineDir });
24
65
 
25
66
  const wrap = (inner) =>
26
67
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
@@ -309,7 +350,46 @@ describe('reconcileSlot — ensure + inject-if-empty + cap, as one atomic policy
309
350
  });
310
351
  });
311
352
 
312
- describe('reconcile CLI atomic ensure+inject-if-empty+cap on the real filesystem', () => {
353
+ describe('slotNeedsFilllazy-read predicate (matches reconcileSlot fill decision)', () => {
354
+ it('present empty slot → true', () => {
355
+ assert.equal(slotNeedsFill(wrap('\n')), true);
356
+ });
357
+ it('markerless legacy with one anchor (insertable empty slot) → true', () => {
358
+ assert.equal(slotNeedsFill(legacyWithAnchor()), true);
359
+ });
360
+ it('present filled/customized slot → false (fragment not needed)', () => {
361
+ assert.equal(slotNeedsFill(wrap('\nuser notes\n')), false);
362
+ });
363
+ it('malformed slot → false (reconcileSlot error path fires, not a fill)', () => {
364
+ assert.equal(slotNeedsFill(`${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), false);
365
+ });
366
+ it('markerless with no anchor → false (cannot insert; reconcile errors without the engine)', () => {
367
+ assert.equal(slotNeedsFill('# AGENTS.md\n\nno anchor here\n'), false);
368
+ });
369
+
370
+ // slotNeedsFill and reconcileSlot share the SAME emptiness primitives (ensureSlot + extractSlot), so
371
+ // the lazy "read the engine only when needed" guard cannot disagree with reconcileSlot's actual fill
372
+ // decision. Pin that equivalence across representative inputs: needsFill === true IFF reconcile fills
373
+ // (reconciled-filled / reconciled-inserted); needsFill === false IFF reconcile does NOT fill
374
+ // (present-filled / error). This forecloses any future divergence that could silently drop a slot.
375
+ it('agrees with reconcileSlot across every slot state (no divergence → no silent drop)', () => {
376
+ const cases = [
377
+ wrap('\n'), // present empty slot → fill
378
+ legacyWithAnchor(), // markerless + anchor → insert + fill
379
+ wrap('\nuser notes\n'), // filled slot → no fill
380
+ `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`, // malformed → no fill (error)
381
+ '# AGENTS.md\n\nno anchor here\n', // no anchor → no fill (error)
382
+ ];
383
+ for (const text of cases) {
384
+ const needs = slotNeedsFill(text);
385
+ const result = reconcileSlot(text, FRAGMENT, { maxLines: AGENTS_MD_CAP });
386
+ const filled = result.status === 'reconciled-filled' || result.status === 'reconciled-inserted';
387
+ assert.equal(needs, filled, `slotNeedsFill (${needs}) must match whether reconcile fills (${result.status})`);
388
+ }
389
+ });
390
+ });
391
+
392
+ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragment LIVE from the engine', () => {
313
393
  const withTempAgents = (contents, run) => {
314
394
  const dir = mkdtempSync(join(tmpdir(), 'reconcile-cli-'));
315
395
  const agents = join(dir, 'AGENTS.md');
@@ -321,35 +401,100 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap on the real filesy
321
401
  }
322
402
  };
323
403
 
324
- it('markerless legacy (with anchor) → slot inserted and filled (exit 0)', () => {
404
+ it('markerless legacy (with anchor) → slot inserted + filled from the live engine fragment (exit 0)', () => {
325
405
  withTempAgents(legacyWithAnchor(), (agents) => {
326
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
406
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
327
407
  const out = readFileSync(agents, 'utf8');
328
408
  assert.equal(findSlot(out).state, 'ok');
329
409
  assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
330
410
  });
331
411
  });
332
412
 
333
- it('present empty slot → slot filled (exit 0)', () => {
413
+ it('present empty slot → filled from the live engine fragment (exit 0)', () => {
334
414
  withTempAgents(wrap('\n'), (agents) => {
335
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
415
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
336
416
  assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
337
417
  });
338
418
  });
339
419
 
340
- it('filled/customized slot → file left byte-for-byte untouched', () => {
420
+ it('filled/customized slot → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
341
421
  const custom = wrap('\nuser notes\n');
342
422
  withTempAgents(custom, (agents) => {
343
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
423
+ // Engine pointed at a path that does not exist — a filled slot must NOT require it.
424
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
344
425
  assert.equal(readFileSync(agents, 'utf8'), custom);
345
426
  });
346
427
  });
347
428
 
348
- it('malformed slot → STOP with non-zero exit, file byte-unchanged', () => {
429
+ it('present empty slot + engine ABSENT hard STOP (nonzero) printing the install command, file unchanged', () => {
430
+ withTempAgents(wrap('\n'), (agents) => {
431
+ const err = (() => {
432
+ try {
433
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
434
+ return null;
435
+ } catch (e) {
436
+ return e;
437
+ }
438
+ })();
439
+ assert.ok(err, 'expected a non-zero exit when a fill is needed but the engine is absent');
440
+ const stderr = String(err.stderr);
441
+ assert.match(stderr, /methodology engine not found\/invalid/);
442
+ assert.match(stderr, /npx @sabaiway\/agent-workflow-engine@latest init/);
443
+ assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write on STOP');
444
+ });
445
+ });
446
+
447
+ it('explicit [fragment.md] override fills from that file and skips engine resolution (engine absent)', () => {
448
+ const override = '> custom override fragment line\n';
449
+ const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
450
+ tmpDirs.push(fdir);
451
+ const fpath = join(fdir, 'frag.md');
452
+ writeFileSync(fpath, override);
453
+ withTempAgents(wrap('\n'), (agents) => {
454
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
455
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), override.trim());
456
+ });
457
+ });
458
+
459
+ it('malformed slot → STOP with non-zero exit, file byte-unchanged (engine never consulted)', () => {
349
460
  const malformed = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
350
461
  withTempAgents(malformed, (agents) => {
351
- assert.throws(() => execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' }));
462
+ assert.throws(() =>
463
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
464
+ );
352
465
  assert.equal(readFileSync(agents, 'utf8'), malformed);
353
466
  });
354
467
  });
468
+
469
+ it('legacy inject mode: markerless AGENTS.md → no-op WITHOUT the engine (exit 0)', () => {
470
+ const markerless = '# AGENTS.md\n\nlegacy, no slot\n';
471
+ withTempAgents(markerless, (agents) => {
472
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
473
+ assert.equal(readFileSync(agents, 'utf8'), markerless);
474
+ });
475
+ });
476
+
477
+ // Legacy `inject` mode FORCE-OVERWRITES any present (ok) slot — filled or empty — unlike `reconcile`,
478
+ // which preserves a filled slot. So for an ok slot it genuinely NEEDS the fragment and reads the
479
+ // engine; the read-on-`state==='ok'` guard is correct (reading only an EMPTY ok slot would inject ''
480
+ // and WIPE a filled slot). These two tests pin that contract.
481
+ it('legacy inject mode: a FILLED slot is OVERWRITTEN from the live engine (engine present, exit 0)', () => {
482
+ const filled = wrap('\nstale user content\n');
483
+ withTempAgents(filled, (agents) => {
484
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(ENGINE) });
485
+ const out = readFileSync(agents, 'utf8');
486
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'slot overwritten with the live fragment');
487
+ assert.ok(!out.includes('stale user content'), 'prior slot content replaced');
488
+ });
489
+ });
490
+
491
+ it('legacy inject mode: a present (ok) slot + engine ABSENT → hard STOP, file unchanged', () => {
492
+ const filled = wrap('\nstale user content\n');
493
+ withTempAgents(filled, (agents) => {
494
+ assert.throws(() =>
495
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
496
+ );
497
+ assert.equal(readFileSync(agents, 'utf8'), filled, 'no partial write on STOP');
498
+ });
499
+ });
355
500
  });
@@ -77,7 +77,9 @@ const readSkillVersion = (text) => {
77
77
 
78
78
  // Authoritative version source: package.json where one exists, else SKILL.md
79
79
  // frontmatter metadata.version. So a bridge (no package.json) can't drift from its SKILL.md.
80
- const readAuthoritativeVersion = (skillDir) => {
80
+ // Exported so the family registry (tools/family-registry.mjs) reports an INSTALLED member's
81
+ // version from the SAME authoritative source the validator checks — no second, drifting reader.
82
+ export const readAuthoritativeVersion = (skillDir) => {
81
83
  const pkgPath = join(skillDir, 'package.json');
82
84
  if (existsSync(pkgPath)) {
83
85
  try {
@@ -0,0 +1,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
+ });