@sabaiway/agent-workflow-kit 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,463 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join, dirname, basename, resolve } from 'node:path';
4
+ import {
5
+ hideFootprint,
6
+ inferVisibility,
7
+ migrateFromGlobal,
8
+ buildBlock,
9
+ START_MARKER,
10
+ END_MARKER,
11
+ } from './hide-footprint.mjs';
12
+ import { FOOTPRINT_STOP } from './known-footprint.mjs';
13
+
14
+ const DIR = '/repo';
15
+ const EXCLUDE = join(DIR, '.git/info/exclude');
16
+ const ENOENT = (p) => Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' });
17
+
18
+ // ── in-memory world (absolute path → node) ───────────────────────────────────────
19
+
20
+ const makeWorld = (seed = {}) => {
21
+ const nodes = new Map(); // abspath → { kind:'file'|'dir', content? }
22
+ const world = {
23
+ file: (rel, content = '') => { nodes.set(join(DIR, rel), { kind: 'file', content }); return world; },
24
+ dir: (rel) => { nodes.set(join(DIR, rel.replace(/\/$/, '')), { kind: 'dir' }); return world; },
25
+ abs: (p, content = '') => { nodes.set(p, { kind: 'file', content }); return world; },
26
+ read: (rel) => nodes.get(join(DIR, rel))?.content,
27
+ nodes,
28
+ };
29
+ if (seed.exclude !== undefined) world.file('.git/info/exclude', seed.exclude);
30
+ return world;
31
+ };
32
+
33
+ const fsDeps = (world) => ({
34
+ readFile: (p) => { const n = world.nodes.get(p); if (!n || n.kind !== 'file') throw ENOENT(p); return n.content; },
35
+ writeFile: (p, c) => { world.nodes.set(p, { kind: 'file', content: c }); },
36
+ stat: (p) => { const n = world.nodes.get(p); if (!n) throw ENOENT(p); return { isFile: () => n.kind === 'file' }; },
37
+ readdir: (p) => {
38
+ const n = world.nodes.get(p);
39
+ if (!n || n.kind !== 'dir') throw ENOENT(p);
40
+ const names = [];
41
+ for (const k of world.nodes.keys()) if (dirname(k) === p) names.push(basename(k));
42
+ return names;
43
+ },
44
+ });
45
+
46
+ // ── mock git (asserts cwd + repo-relative probes) ────────────────────────────────
47
+
48
+ const gitDeps = (cfg, world) => (args, opts) => {
49
+ assert.equal(opts.cwd, DIR, 'every git call runs with cwd = the project dir');
50
+ const [cmd, ...rest] = args;
51
+ if (cmd === 'rev-parse') return { status: 0, stdout: '.git/info/exclude\n', stderr: '' };
52
+ if (cmd === 'config') {
53
+ if (cfg.global == null) return { status: 1, stdout: '', stderr: '' }; // unset
54
+ return { status: 0, stdout: `${cfg.global}\n`, stderr: '' };
55
+ }
56
+ const probe = rest[rest.length - 1];
57
+ assert.ok(!probe.startsWith('/'), `probe is repo-relative, never the anchored "/" form: ${probe}`);
58
+ if (cmd === 'ls-files') {
59
+ if (cfg.lsFilesFails) return { status: 128, stdout: '', stderr: 'fatal: boom' };
60
+ const hits = (cfg.tracked ?? []).filter((t) => (probe.endsWith('/') ? t.startsWith(probe) : t === probe));
61
+ return { status: 0, stdout: hits.map((h) => `${h}\0`).join(''), stderr: '' };
62
+ }
63
+ if (cmd === 'check-ignore') {
64
+ if (cfg.checkIgnoreFails) return { status: 128, stdout: '', stderr: 'fatal: bad config' };
65
+ // Real precedence: a tracked .gitignore > .git/info/exclude > global core.excludesFile.
66
+ const stat = (cfg.ignored ?? {})[probe];
67
+ const fmt = (s) => ({ status: 0, stdout: `${s.source}:${s.line ?? 1}:${s.pattern ?? probe}\t${probe}\n`, stderr: '' });
68
+ if (stat && basename(stat.source) === '.gitignore') return fmt(stat); // gitignore wins
69
+ const content = world.nodes.get(EXCLUDE)?.content ?? '';
70
+ const lines = content.split('\n').map((l) => l.replace(/\r$/, '')); // git is EOL-agnostic
71
+ if (lines.includes(`/${probe}`)) return { status: 0, stdout: `.git/info/exclude:1:/${probe}\t${probe}\n`, stderr: '' };
72
+ if (stat) return fmt(stat); // global (lower than the local exclude)
73
+ return { status: 1, stdout: '', stderr: '' };
74
+ }
75
+ return { status: 128, stdout: '', stderr: 'unknown' };
76
+ };
77
+
78
+ const mk = (cfg = {}, seed = {}) => {
79
+ const world = makeWorld(seed);
80
+ if (seed.world) seed.world(world);
81
+ return { world, deps: { git: gitDeps(cfg, world), ...fsDeps(world), home: '/home/u', log: () => {}, errlog: () => {} } };
82
+ };
83
+
84
+ // ── buildBlock ────────────────────────────────────────────────────────────────────
85
+
86
+ describe('buildBlock', () => {
87
+ it('sorts + dedupes', () => {
88
+ assert.deepEqual(buildBlock(['/b', '/a', '/b', '/c']), ['/a', '/b', '/c']);
89
+ });
90
+ });
91
+
92
+ // ── idempotency + EOL + splice ────────────────────────────────────────────────────
93
+
94
+ describe('hide flow — idempotency & splice', () => {
95
+ it('apply twice → byte-identical (zero diff)', () => {
96
+ const { world, deps } = mk();
97
+ const r1 = hideFootprint({ dir: DIR }, deps);
98
+ assert.equal(r1.action, 'created');
99
+ const after1 = world.read('.git/info/exclude');
100
+ const r2 = hideFootprint({ dir: DIR }, deps);
101
+ assert.equal(r2.action, 'noop');
102
+ assert.equal(world.read('.git/info/exclude'), after1, 'second run leaves the file byte-for-byte');
103
+ });
104
+
105
+ it('one managed block, markers present, KIT_OWN hidden', () => {
106
+ const { world, deps } = mk();
107
+ const r = hideFootprint({ dir: DIR }, deps);
108
+ const content = world.read('.git/info/exclude');
109
+ assert.equal(content.split(START_MARKER).length - 1, 1, 'exactly one start marker');
110
+ assert.equal(content.split(END_MARKER).length - 1, 1, 'exactly one end marker');
111
+ assert.ok(r.wrote.includes('/AGENTS.md') && r.wrote.includes('/docs/ai/'));
112
+ assert.ok(content.endsWith('\n'));
113
+ });
114
+
115
+ it('preserves outside boilerplate; appends the fence', () => {
116
+ const boiler = '# git ls-files --others --exclude-from=.git/info/exclude\n# *~\n';
117
+ const { world, deps } = mk({}, { exclude: boiler });
118
+ hideFootprint({ dir: DIR }, deps);
119
+ const content = world.read('.git/info/exclude');
120
+ assert.ok(content.startsWith('# git ls-files --others'), 'boilerplate kept at the top');
121
+ assert.ok(content.includes('# *~'), 'comment kept');
122
+ assert.ok(content.indexOf(START_MARKER) > content.indexOf('# *~'), 'fence appended after boilerplate');
123
+ });
124
+
125
+ it('CRLF preserved when the file uses CRLF', () => {
126
+ const { world, deps } = mk({}, { exclude: '# boiler\r\n' });
127
+ hideFootprint({ dir: DIR }, deps);
128
+ const content = world.read('.git/info/exclude');
129
+ assert.ok(content.includes('\r\n'), 'CRLF EOL preserved');
130
+ assert.ok(!/[^\r]\n/.test(content), 'no lone LF introduced');
131
+ });
132
+
133
+ it('empty/missing exclude → LF default, still idempotent', () => {
134
+ const { world, deps } = mk(); // no exclude file at all
135
+ hideFootprint({ dir: DIR }, deps);
136
+ const content = world.read('.git/info/exclude');
137
+ assert.ok(content.includes('\n') && !content.includes('\r'));
138
+ const before = content;
139
+ hideFootprint({ dir: DIR }, deps);
140
+ assert.equal(world.read('.git/info/exclude'), before);
141
+ });
142
+
143
+ it('malformed markers (single / reversed / duplicate) → STOP, file byte-for-byte unchanged', () => {
144
+ const cases = [
145
+ `${START_MARKER}\n/AGENTS.md\n`, // single: start, no end
146
+ `${END_MARKER}\n/x\n${START_MARKER}\n`, // reversed: end before start
147
+ `${START_MARKER}\n/x\n${START_MARKER}\n/y\n${END_MARKER}\n`, // duplicate start
148
+ ];
149
+ for (const bad of cases) {
150
+ const { world, deps } = mk({}, { exclude: bad });
151
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP, `STOP on: ${JSON.stringify(bad)}`);
152
+ assert.equal(world.read('.git/info/exclude'), bad, 'left byte-for-byte');
153
+ }
154
+ });
155
+ });
156
+
157
+ // ── classify: tracked → ASK; order; drop; risk ───────────────────────────────────
158
+
159
+ describe('classify', () => {
160
+ it('a tracked KIT_OWN path → ASK (excluded by default), with rm guidance', () => {
161
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
162
+ const r = hideFootprint({ dir: DIR }, deps);
163
+ assert.ok(!r.wrote.includes('/.claude/settings.json'), 'tracked path not written');
164
+ assert.ok(r.asks.some((a) => a.path === '/.claude/settings.json'), 'surfaced as ASK');
165
+ });
166
+
167
+ it('--include opts a tracked ASK path into needsUntrack (written line + rm guidance, NOT hidden)', () => {
168
+ const { world, deps } = mk({ tracked: ['.claude/settings.json'] });
169
+ const r = hideFootprint({ dir: DIR, include: ['/.claude/settings.json'] }, deps);
170
+ assert.ok(r.wrote.includes('/.claude/settings.json'), 'now written into the block');
171
+ assert.ok(r.needsUntrack.some((n) => n.path === '/.claude/settings.json' && n.command.includes('git rm --cached')));
172
+ assert.ok(!r.verify.some((v) => v.path === '/.claude/settings.json' && v.hidden), 'tracked → never counted hidden');
173
+ });
174
+
175
+ it('--include rejects a path that is not one of this run’s asks', () => {
176
+ const { deps } = mk();
177
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['/src/'] }, deps), (e) => e.code === FOOTPRINT_STOP);
178
+ });
179
+
180
+ it('--include rejects traversal / globs', () => {
181
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
182
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['../etc'] }, deps), (e) => e.code === FOOTPRINT_STOP);
183
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['/.github/copilot-*'] }, deps), (e) => e.code === FOOTPRINT_STOP);
184
+ });
185
+
186
+ it('untracked + covered by a tracked .gitignore → DROPPED; tracked + covered → ASK (tracked checked first)', () => {
187
+ const { deps } = mk({
188
+ tracked: ['.gitignore', 'AGENTS.md'],
189
+ ignored: { 'AGENTS.md': { source: '.gitignore' }, 'docs/ai/': { source: '.gitignore' } },
190
+ });
191
+ const r = hideFootprint({ dir: DIR }, deps);
192
+ assert.ok(r.dropped.includes('/docs/ai/'), 'untracked + gitignore-covered → dropped as redundant');
193
+ assert.ok(r.asks.some((a) => a.path === '/AGENTS.md'), 'tracked wins over the gitignore-drop → ASK');
194
+ assert.ok(!r.dropped.includes('/AGENTS.md'));
195
+ });
196
+
197
+ it('ls-files failure → fail-closed STOP (UNKNOWN never counts as safe-to-hide)', () => {
198
+ const { deps } = mk({ lsFilesFails: true });
199
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP);
200
+ });
201
+
202
+ it('check-ignore exit 128 → fail-closed STOP (the symmetric guard to ls-files)', () => {
203
+ const { deps } = mk({ checkIgnoreFails: true });
204
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP);
205
+ });
206
+ });
207
+
208
+ // ── present external footprint + glob ────────────────────────────────────────────
209
+
210
+ describe('external footprint', () => {
211
+ it('a present low-risk external dir → HIDE; an absent external → not emitted', () => {
212
+ const { deps } = mk({}, { world: (w) => w.dir('.continue') });
213
+ const r = hideFootprint({ dir: DIR }, deps);
214
+ assert.ok(r.wrote.includes('/.continue/'), 'present external dir hidden');
215
+ assert.ok(!r.wrote.includes('/.cursorrules'), 'absent external not emitted');
216
+ });
217
+
218
+ it('a present high-risk external file (generic name) → ASK, not hidden by default', () => {
219
+ const { deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
220
+ const r = hideFootprint({ dir: DIR }, deps);
221
+ assert.ok(!r.wrote.includes('/GEMINI.md'), 'present-high-risk excluded by default');
222
+ assert.ok(r.asks.some((a) => a.path === '/GEMINI.md'));
223
+ });
224
+
225
+ it('glob /.github/copilot-* expands to each present file; a tracked sibling does not pull them into ASK', () => {
226
+ const { deps } = mk(
227
+ { tracked: ['.github/workflows/ci.yml'] },
228
+ { world: (w) => w.dir('.github').file('.github/copilot-instructions.md').file('.github/copilot-setup.yml').dir('.github/workflows') },
229
+ );
230
+ const r = hideFootprint({ dir: DIR }, deps);
231
+ // copilot-* is falsePositiveRisk:true → present → ASK each (not hidden by default). The ASK reason
232
+ // must be the RISK reason, NOT the tracked reason — proving each file was probed on its OWN path
233
+ // and the tracked sibling did not leak into their classification.
234
+ const a1 = r.asks.find((a) => a.path === '/.github/copilot-instructions.md');
235
+ const a2 = r.asks.find((a) => a.path === '/.github/copilot-setup.yml');
236
+ assert.ok(a1 && /generic|confirm before hiding/.test(a1.reason) && !/tracked/.test(a1.reason));
237
+ assert.ok(a2 && /generic|confirm before hiding/.test(a2.reason));
238
+ assert.ok(!r.wrote.some((p) => p.startsWith('/.github/copilot')));
239
+ });
240
+
241
+ it('a glob-expanded file consented via --include is RETAINED on re-run (zero diff, idempotent)', () => {
242
+ const seed = { world: (w) => w.dir('.github').file('.github/copilot-instructions.md') };
243
+ const { world, deps } = mk({}, seed);
244
+ const r1 = hideFootprint({ dir: DIR, include: ['/.github/copilot-instructions.md'] }, deps);
245
+ assert.ok(r1.wrote.includes('/.github/copilot-instructions.md'), 'consented glob child written');
246
+ const c1 = world.read('.git/info/exclude');
247
+ const r2 = hideFootprint({ dir: DIR }, deps); // no --include — consent must survive
248
+ assert.ok(r2.wrote.includes('/.github/copilot-instructions.md'), 'glob-child consent survives re-run');
249
+ assert.ok(!r2.asks.some((a) => a.path === '/.github/copilot-instructions.md'), 'not re-asked');
250
+ assert.equal(r2.action, 'noop');
251
+ assert.equal(world.read('.git/info/exclude'), c1, 'zero diff (no silent un-hide of the copilot file)');
252
+ });
253
+ });
254
+
255
+ // ── re-run consent (D4) ──────────────────────────────────────────────────────────
256
+
257
+ describe('re-run preserves prior consent', () => {
258
+ it('a consented present-high-risk path stays on re-run without --include (zero diff, not re-asked)', () => {
259
+ const { world, deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
260
+ const r1 = hideFootprint({ dir: DIR, include: ['/GEMINI.md'] }, deps); // opt in once
261
+ assert.ok(r1.wrote.includes('/GEMINI.md'));
262
+ const c1 = world.read('.git/info/exclude');
263
+ const r2 = hideFootprint({ dir: DIR }, deps); // no --include this time
264
+ assert.ok(r2.wrote.includes('/GEMINI.md'), 'prior in-block consent retained');
265
+ assert.ok(!r2.asks.some((a) => a.path === '/GEMINI.md'), 'not re-asked');
266
+ assert.equal(r2.action, 'noop');
267
+ assert.equal(world.read('.git/info/exclude'), c1, 'zero diff');
268
+ });
269
+
270
+ it('a previously-consented high-risk path that DISAPPEARED is dropped (consent does not survive)', () => {
271
+ const { world, deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
272
+ hideFootprint({ dir: DIR, include: ['/GEMINI.md'] }, deps); // in the block now
273
+ world.nodes.delete(join(DIR, 'GEMINI.md')); // GEMINI.md disappears from disk
274
+ const r = hideFootprint({ dir: DIR }, deps);
275
+ assert.ok(!r.wrote.includes('/GEMINI.md'), 'disappeared high-risk path dropped');
276
+ assert.ok(!world.read('.git/info/exclude').includes('/GEMINI.md'));
277
+ });
278
+ });
279
+
280
+ // ── absorb (D8) — verbatim present-machine exclude ───────────────────────────────
281
+
282
+ describe('absorb pre-existing recognized local lines', () => {
283
+ const presentMachine = [
284
+ '# git ls-files --others --exclude-from=.git/info/exclude',
285
+ "# Lines that start with '#' are comments.",
286
+ '',
287
+ '# standalone local-dev agent skills — kept out of THIS repo (project-local; visibility is a project setting)',
288
+ '.claude/skills/',
289
+ '',
290
+ ].join('\n');
291
+
292
+ it('folds the AD-013 comment + bare .claude/skills/ (dir ABSENT) into the fence, no orphan comment, apply-twice zero diff', () => {
293
+ const { world, deps } = mk({}, { exclude: presentMachine });
294
+ const r1 = hideFootprint({ dir: DIR }, deps);
295
+ const c1 = world.read('.git/info/exclude');
296
+ assert.ok(r1.wrote.includes('/.claude/skills/'), 'folded into the fence (preserved despite absent dir)');
297
+ assert.ok(!c1.includes('# standalone local-dev agent skills'), 'orphan comment removed');
298
+ assert.equal((c1.match(/\.claude\/skills\//g) ?? []).length, 1, 'exactly one .claude/skills/ rule, inside the fence');
299
+ assert.ok(c1.startsWith('# git ls-files --others'), 'git boilerplate preserved');
300
+ const r2 = hideFootprint({ dir: DIR }, deps);
301
+ assert.equal(r2.action, 'noop');
302
+ assert.equal(world.read('.git/info/exclude'), c1, 'apply-twice = zero diff');
303
+ });
304
+
305
+ it('a loose pre-existing HIGH-RISK line whose file is PRESENT is folded as consent (no report mismatch, one block)', () => {
306
+ const { world, deps } = mk({}, { exclude: '# boiler\n/.cursorrules\n', world: (w) => w.file('.cursorrules') });
307
+ const r = hideFootprint({ dir: DIR }, deps);
308
+ const content = world.read('.git/info/exclude');
309
+ assert.ok(r.wrote.includes('/.cursorrules'), 'folded into the managed block as prior consent');
310
+ assert.ok(!r.asks.some((a) => a.path === '/.cursorrules'), 'not reported as un-consented ASK while it is in fact hidden');
311
+ assert.equal((content.match(/\.cursorrules/g) ?? []).length, 1, 'exactly one .cursorrules rule, inside the fence');
312
+ assert.ok(content.indexOf('/.cursorrules') > content.indexOf(START_MARKER), 'inside the fence, not loose');
313
+ const v = r.verify.find((x) => x.path === '/.cursorrules');
314
+ assert.ok(v && v.hidden === true, 'verified hidden');
315
+ });
316
+
317
+ it('a loose HIGH-RISK line whose file is ABSENT is left untouched (a pre-emptive hide is never silently removed)', () => {
318
+ const { world, deps } = mk({}, { exclude: '# boiler\n/.cursorrules\n' }); // .cursorrules absent
319
+ const r = hideFootprint({ dir: DIR }, deps);
320
+ assert.ok(!r.wrote.includes('/.cursorrules'), 'absent high-risk path not folded');
321
+ assert.ok(world.read('.git/info/exclude').includes('/.cursorrules'), 'the user’s loose line is preserved as-is');
322
+ });
323
+ });
324
+
325
+ // ── verify (D5) ──────────────────────────────────────────────────────────────────
326
+
327
+ describe('verify', () => {
328
+ it('a tracked path written via --include reports NOT hidden', () => {
329
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
330
+ const r = hideFootprint({ dir: DIR, include: ['/.claude/settings.json'] }, deps);
331
+ const v = r.verify.find((x) => x.path === '/.claude/settings.json');
332
+ assert.ok(v && v.tracked === true && v.hidden === false);
333
+ });
334
+
335
+ it('an untracked auto-HIDE path verifies hidden by our exclude source', () => {
336
+ const { deps } = mk();
337
+ const r = hideFootprint({ dir: DIR }, deps);
338
+ const v = r.verify.find((x) => x.path === '/AGENTS.md');
339
+ assert.ok(v && v.hidden === true && /info\/exclude/.test(v.source));
340
+ });
341
+ });
342
+
343
+ // ── migrateFromGlobal (D6/D7) ────────────────────────────────────────────────────
344
+
345
+ describe('migrateFromGlobal', () => {
346
+ const GLOBAL = '/home/u/.gitignore_global';
347
+ const kitOld = [
348
+ '',
349
+ '# agent-workflow-kit hidden mode (machine-local; remove these lines to un-hide)',
350
+ '/AGENTS.md', '/CLAUDE.md', '/docs/ai/',
351
+ '/scripts/_expect-shim.mjs', '/scripts/install-git-hooks.mjs',
352
+ '',
353
+ ].join('\n');
354
+
355
+ it('default KEEPS + reports the legacy global block; --remove-global removes it + returns a backup', () => {
356
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, kitOld) });
357
+ const kept = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: false, dryRun: false });
358
+ assert.equal(kept.action, 'kept');
359
+ assert.ok(kept.removedLines.includes('/AGENTS.md'));
360
+ assert.equal(world.nodes.get(GLOBAL).content, kitOld, 'kept = file untouched');
361
+
362
+ const { world: w2, deps: d2 } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, kitOld) });
363
+ const removed = migrateFromGlobal(d2, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
364
+ assert.equal(removed.action, 'removed');
365
+ assert.ok(removed.backup.includes('/AGENTS.md'));
366
+ assert.ok(!w2.nodes.get(GLOBAL).content.includes('/AGENTS.md'), 'global block removed');
367
+ });
368
+
369
+ it('preserves a user line interleaved with the legacy block', () => {
370
+ const withUser = `/AGENTS.md\n/CLAUDE.md\n\n/my/own/rule\n`;
371
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, withUser) });
372
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
373
+ assert.equal(r.action, 'removed');
374
+ assert.ok(world.nodes.get(GLOBAL).content.includes('/my/own/rule'), 'user line preserved');
375
+ assert.ok(!world.nodes.get(GLOBAL).content.includes('/AGENTS.md'));
376
+ });
377
+
378
+ it('core.excludesFile unset (exit 1) → no-op, never a STOP', () => {
379
+ const { deps } = mk({ global: null });
380
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
381
+ assert.equal(r.found, false);
382
+ assert.equal(r.action, 'none');
383
+ });
384
+
385
+ it('a memory-old global path-set (no header) is recognized + removed', () => {
386
+ const memOld = `/AGENTS.md\n/docs/ai/\n/docs/ai/.memory-version\n/docs/plans/\n`;
387
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, memOld) });
388
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
389
+ assert.equal(r.action, 'removed');
390
+ assert.equal(world.nodes.get(GLOBAL).content, '', 'whole memory-old block recognized + removed');
391
+ });
392
+
393
+ it('the AD-013 standalone form as a GLOBAL block is recognized + removed (the third D7 fixture)', () => {
394
+ const ad013 = '# standalone local-dev agent skills — kept out of THIS repo (project-local; visibility is a project setting)\n.claude/skills/\n';
395
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, ad013) });
396
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
397
+ assert.equal(r.action, 'removed');
398
+ assert.ok(r.backup.includes('.claude/skills/'));
399
+ assert.equal(world.nodes.get(GLOBAL).content, '', 'AD-013 comment + bare line removed');
400
+ });
401
+ });
402
+
403
+ // ── --unhide (D12) ───────────────────────────────────────────────────────────────
404
+
405
+ describe('--unhide', () => {
406
+ const GLOBAL = '/home/u/.gitignore_global';
407
+ it('deletes the fence; default reports the residual global block; --remove-global removes it', () => {
408
+ const exclude = `# boiler\n${START_MARKER}\n/AGENTS.md\n/docs/ai/\n${END_MARKER}\n`;
409
+ const kitOld = '# agent-workflow-kit hidden mode (machine-local; remove these lines to un-hide)\n/AGENTS.md\n/docs/ai/\n';
410
+ const { world, deps } = mk({ global: GLOBAL }, { exclude, world: (w) => w.abs(GLOBAL, kitOld) });
411
+ const r = hideFootprint({ dir: DIR, unhide: true }, deps);
412
+ assert.equal(r.action, 'unhidden');
413
+ const content = world.read('.git/info/exclude');
414
+ assert.ok(!content.includes(START_MARKER), 'fence removed');
415
+ assert.ok(content.includes('# boiler'), 'user boilerplate kept');
416
+ assert.equal(r.global.action, 'kept', 'residual global reported, not removed by default');
417
+ assert.equal(world.nodes.get(GLOBAL).content, kitOld);
418
+
419
+ const { world: w2, deps: d2 } = mk({ global: GLOBAL }, { exclude, world: (w) => w.abs(GLOBAL, kitOld) });
420
+ const r2 = hideFootprint({ dir: DIR, unhide: true, removeGlobal: true }, d2);
421
+ assert.equal(r2.global.action, 'removed');
422
+ assert.ok(!w2.nodes.get(GLOBAL).content.includes('/AGENTS.md'));
423
+ });
424
+ });
425
+
426
+ // ── visibility inference (D16) ───────────────────────────────────────────────────
427
+
428
+ describe('inferVisibility + --reconcile', () => {
429
+ it('tracked anchor → visible; --reconcile writes zero bytes', () => {
430
+ const { world, deps } = mk({ tracked: ['AGENTS.md'] }, { world: (w) => w.file('AGENTS.md') });
431
+ assert.equal(inferVisibility(deps, DIR).visibility, 'visible');
432
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
433
+ assert.equal(r.action, 'noop');
434
+ assert.equal(r.visibility, 'visible');
435
+ assert.equal(world.read('.git/info/exclude'), undefined, 'no exclude file written');
436
+ });
437
+
438
+ it('untracked + ignored anchor → hidden; --reconcile runs the hide', () => {
439
+ const { deps } = mk(
440
+ { ignored: { 'AGENTS.md': { source: '/home/u/.gitignore_global' } } },
441
+ { world: (w) => w.file('AGENTS.md') },
442
+ );
443
+ assert.equal(inferVisibility(deps, DIR).visibility, 'hidden');
444
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
445
+ assert.equal(r.visibility, 'hidden');
446
+ assert.ok(r.wrote.includes('/AGENTS.md'));
447
+ });
448
+
449
+ it('untracked + not ignored anchor → ambiguous; --reconcile surfaces it and writes nothing', () => {
450
+ const { world, deps } = mk({}, { world: (w) => w.file('AGENTS.md') });
451
+ assert.equal(inferVisibility(deps, DIR).visibility, 'ambiguous');
452
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
453
+ assert.equal(r.ambiguous, true);
454
+ assert.equal(r.action, 'noop');
455
+ assert.equal(world.read('.git/info/exclude'), undefined);
456
+ });
457
+
458
+ it('a committed-but-DELETED AGENTS.md still infers VISIBLE (tracked-ness, not disk presence)', () => {
459
+ // AGENTS.md tracked but absent from the worktree; docs/ai/ present, untracked, not ignored.
460
+ const { deps } = mk({ tracked: ['AGENTS.md'] }, { world: (w) => w.dir('docs/ai') });
461
+ assert.equal(inferVisibility(deps, DIR).visibility, 'visible');
462
+ });
463
+ });
@@ -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}`);