@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.
- package/CHANGELOG.md +61 -0
- package/README.md +10 -7
- package/SKILL.md +46 -18
- package/bin/install.mjs +117 -14
- package/bin/install.test.mjs +128 -5
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +21 -1
- package/tools/engine-source.mjs +115 -0
- package/tools/engine-source.test.mjs +182 -0
- package/tools/fs-safe.mjs +4 -1
- package/tools/fs-safe.test.mjs +6 -0
- package/tools/hide-footprint.integration.test.mjs +168 -0
- package/tools/hide-footprint.mjs +570 -0
- package/tools/hide-footprint.test.mjs +463 -0
- package/tools/inject-methodology.mjs +51 -7
- package/tools/inject-methodology.test.mjs +157 -12
- package/tools/known-footprint.mjs +161 -0
- package/tools/known-footprint.test.mjs +271 -0
- package/references/planning.md +0 -105
- package/tools/methodology-slot.md +0 -1
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
159
|
-
const
|
|
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
|
|
186
|
+
const explicitFragmentArg = rest[1];
|
|
171
187
|
const text = await readFile(resolve(agentsPath), 'utf8');
|
|
172
|
-
|
|
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}`);
|