@sabaiway/agent-workflow-kit 1.26.0 → 1.27.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 +40 -0
- package/README.md +2 -0
- package/SKILL.md +39 -7
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +23 -0
- package/references/agents/gate-triage.md +23 -0
- package/references/agents/mechanical-sweep.md +20 -0
- package/references/contracts.md +1 -0
- package/references/scripts/archive-decisions.mjs +424 -0
- package/references/scripts/archive-decisions.test.mjs +365 -0
- package/references/scripts/install-git-hooks.mjs +1 -0
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/gates.json +4 -0
- package/tools/cheap-agents.mjs +239 -0
- package/tools/commands.mjs +29 -6
- package/tools/known-footprint.mjs +3 -0
- package/tools/procedures.mjs +19 -0
- package/tools/run-gates.mjs +299 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, it, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
HOT_REL,
|
|
8
|
+
WARM_REL,
|
|
9
|
+
COLD_REL,
|
|
10
|
+
parseDecisionsText,
|
|
11
|
+
loadTiers,
|
|
12
|
+
renderTier,
|
|
13
|
+
lineCountOf,
|
|
14
|
+
planRotation,
|
|
15
|
+
updateRangeTokens,
|
|
16
|
+
runCli,
|
|
17
|
+
} from './archive-decisions.mjs';
|
|
18
|
+
|
|
19
|
+
// Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
|
|
20
|
+
// pre-commit `node --test scripts/*.test.mjs` — it must never read the host repo's docs/ai.
|
|
21
|
+
|
|
22
|
+
const tempDirs = [];
|
|
23
|
+
const makeRoot = () => {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-decisions-'));
|
|
25
|
+
tempDirs.push(dir);
|
|
26
|
+
return dir;
|
|
27
|
+
};
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
while (tempDirs.length) rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const fm = (cap) =>
|
|
33
|
+
`---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
|
|
34
|
+
|
|
35
|
+
// One canonical entry block: 3 fixed lines + extraLines body lines.
|
|
36
|
+
const entryBlock = (id, extraLines = 2) =>
|
|
37
|
+
[`## AD-${id} — Decision ${id}`, '', '**Date:** 2026-01-01', ...Array.from({ length: extraLines }, (_, i) => `body ${i + 1} of AD-${id}`)].join('\n');
|
|
38
|
+
|
|
39
|
+
const tierText = (cap, preamble, blocks) => `${fm(cap)}\n${preamble}\n\n${blocks.join('\n\n')}\n`;
|
|
40
|
+
|
|
41
|
+
const HOT_PREAMBLE = [
|
|
42
|
+
'# Architecture Decision Records (ADRs)',
|
|
43
|
+
'',
|
|
44
|
+
'> Newest at the bottom.',
|
|
45
|
+
'>',
|
|
46
|
+
'> **Archive:** the stable ADRs **AD-003 … AD-004** now live in [`history/decisions-archive.md`](./history/decisions-archive.md) (the earliest **AD-001 … AD-002** rolled further to the COLD [`history/decisions-archive-early.md`](./history/decisions-archive-early.md)); this file carries the active set (AD-005 onward).',
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier. The earliest (AD-001 … AD-002) are COLD.';
|
|
50
|
+
const COLD_PREAMBLE = '# ADR Early Archive (AD-001 … AD-002)\n\n> COLD tier.';
|
|
51
|
+
|
|
52
|
+
// Seed a project: HOT with `hotIds`, WARM with `warmIds`, COLD with `coldIds`; caps computed
|
|
53
|
+
// from the measured rendered size + a delta (so fixtures stay robust to formatting arithmetic).
|
|
54
|
+
const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCapDelta = 100, coldCapDelta = 100 }) => {
|
|
55
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
56
|
+
const write = (rel, preamble, ids, capDelta) => {
|
|
57
|
+
const blocks = ids.map((id) => entryBlock(id));
|
|
58
|
+
const probe = tierText(999, preamble, blocks);
|
|
59
|
+
const cap = lineCountOf(probe) + capDelta;
|
|
60
|
+
writeFileSync(join(root, rel), tierText(cap, preamble, blocks));
|
|
61
|
+
return cap;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
hotCap: write(HOT_REL, HOT_PREAMBLE, hotIds, hotCapDelta),
|
|
65
|
+
warmCap: write(WARM_REL, WARM_PREAMBLE, warmIds, warmCapDelta),
|
|
66
|
+
coldCap: write(COLD_REL, COLD_PREAMBLE, coldIds, coldCapDelta),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const run = (argv, root) => {
|
|
71
|
+
const out = [];
|
|
72
|
+
const err = [];
|
|
73
|
+
const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
|
|
74
|
+
return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
|
|
78
|
+
|
|
79
|
+
// ── parsing: strict canonical headings (the Issue-009 lesson) ─────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('parseDecisionsText — fail-loud on non-canonical headings', () => {
|
|
82
|
+
it('parses canonical entries with ids, titles, and per-entry line counts', () => {
|
|
83
|
+
const parsed = parseDecisionsText(tierText(500, HOT_PREAMBLE, [entryBlock('005'), entryBlock('006', 4)]), 'x');
|
|
84
|
+
assert.deepEqual(parsed.entries.map((e) => e.id), ['005', '006']);
|
|
85
|
+
assert.equal(parsed.entries[0].lineCount, 5);
|
|
86
|
+
assert.equal(parsed.entries[1].lineCount, 7);
|
|
87
|
+
assert.equal(parsed.cap, 500);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const badHeadings = [
|
|
91
|
+
['a hyphen instead of the em-dash', '## AD-024 - Title'],
|
|
92
|
+
['a 2-digit id', '## AD-24 — Title'],
|
|
93
|
+
['a missing title', '## AD-024 — '],
|
|
94
|
+
['an unrelated H2', '## Notes'],
|
|
95
|
+
];
|
|
96
|
+
for (const [name, heading] of badHeadings) {
|
|
97
|
+
it(`rejects ${name} naming file:line — never a silent merge`, () => {
|
|
98
|
+
const text = `${fm(500)}\n# T\n\n${entryBlock('001')}\n\n${heading}\nbody\n`;
|
|
99
|
+
assert.throws(() => parseDecisionsText(text, 'docs/ai/decisions.md'), (e) => {
|
|
100
|
+
assert.equal(e.exitCode, 1);
|
|
101
|
+
assert.match(e.message, /docs\/ai\/decisions\.md:\d+/);
|
|
102
|
+
assert.match(e.message, /non-canonical H2/);
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('rejects disordered ids (oldest must be at the top)', () => {
|
|
109
|
+
const text = tierText(500, '# T', [entryBlock('007'), entryBlock('005')]);
|
|
110
|
+
assert.throws(() => parseDecisionsText(text, 'x'), /strictly ascending/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('loadTiers — cross-tier integrity', () => {
|
|
115
|
+
it('rejects a duplicate id across tiers', () => {
|
|
116
|
+
const root = makeRoot();
|
|
117
|
+
seedProject(root, { hotIds: ['005'], warmIds: ['005'], coldIds: [] });
|
|
118
|
+
assert.throws(() => loadTiers(root, '2026-01-02'), /duplicate id across tiers/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── the cascade ───────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('rotation — simple HOT→WARM roll', () => {
|
|
125
|
+
it('rolls the OLDEST whole entries until HOT fits; ids + line counts conserved', () => {
|
|
126
|
+
const root = makeRoot();
|
|
127
|
+
seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
|
|
128
|
+
const before = loadTiers(root, '2026-01-02');
|
|
129
|
+
const allBefore = [before.hot, before.warm, before.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
|
|
130
|
+
|
|
131
|
+
const { code, text } = run(['--today=2026-01-02'], root);
|
|
132
|
+
assert.equal(code, 0, text);
|
|
133
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'the oldest HOT entry rolled');
|
|
134
|
+
assert.deepEqual(idsIn(root, WARM_REL), ['003', '004', '005'], 'appended to the WARM end');
|
|
135
|
+
assert.deepEqual(idsIn(root, COLD_REL), ['001', '002'], 'COLD untouched');
|
|
136
|
+
|
|
137
|
+
const after = loadTiers(root, '2026-01-02');
|
|
138
|
+
const allAfter = [after.hot, after.warm, after.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
|
|
139
|
+
assert.deepEqual(allAfter, allBefore, 'id multiset + per-entry line counts conserved');
|
|
140
|
+
assert.ok(lineCountOf(renderTier(after.hot, after.hot.entries)) <= after.hot.cap, 'HOT now fits its cap');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('a tree already under cap is a no-op (nothing written)', () => {
|
|
144
|
+
const root = makeRoot();
|
|
145
|
+
seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
|
|
146
|
+
const bytesBefore = readFileSync(join(root, HOT_REL), 'utf8');
|
|
147
|
+
const { code, text } = run([], root);
|
|
148
|
+
assert.equal(code, 0);
|
|
149
|
+
assert.match(text, /nothing to rotate/);
|
|
150
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), bytesBefore);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('rotation — the CHAINED roll (WARM near cap rolls WARM→COLD first)', () => {
|
|
155
|
+
it('an incoming HOT entry that would overflow WARM pushes the oldest WARM entry to COLD', () => {
|
|
156
|
+
const root = makeRoot();
|
|
157
|
+
seedProject(root, {
|
|
158
|
+
hotIds: ['005', '006', '007'],
|
|
159
|
+
warmIds: ['003', '004'],
|
|
160
|
+
coldIds: ['001', '002'],
|
|
161
|
+
hotCapDelta: -1, // force one HOT roll
|
|
162
|
+
warmCapDelta: 3, // the incoming 6-line append does NOT fit → chain
|
|
163
|
+
});
|
|
164
|
+
const { code } = run(['--today=2026-01-02'], root);
|
|
165
|
+
assert.equal(code, 0);
|
|
166
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007']);
|
|
167
|
+
assert.deepEqual(idsIn(root, WARM_REL), ['004', '005'], 'AD-003 chained out, AD-005 appended');
|
|
168
|
+
assert.deepEqual(idsIn(root, COLD_REL), ['001', '002', '003'], 'the chained entry landed in COLD');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('rotation — COLD exhaustion fails LOUD before ANY write', () => {
|
|
173
|
+
it('a roll that does not fit COLD headroom leaves all three files byte-identical', () => {
|
|
174
|
+
const root = makeRoot();
|
|
175
|
+
seedProject(root, {
|
|
176
|
+
hotIds: ['005', '006', '007'],
|
|
177
|
+
warmIds: ['003', '004'],
|
|
178
|
+
coldIds: ['001', '002'],
|
|
179
|
+
hotCapDelta: -1, // force a HOT roll
|
|
180
|
+
warmCapDelta: 3, // force the chain into COLD
|
|
181
|
+
coldCapDelta: 3, // the chained 6-line entry does NOT fit COLD
|
|
182
|
+
});
|
|
183
|
+
const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
|
|
184
|
+
const before = snapshot();
|
|
185
|
+
const { code, errText } = run(['--today=2026-01-02'], root);
|
|
186
|
+
assert.equal(code, 1);
|
|
187
|
+
assert.match(errText, /refusing BEFORE any write/);
|
|
188
|
+
assert.match(errText, /maintainer\/agent decision/);
|
|
189
|
+
assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('rotation — determinism + dry-run', () => {
|
|
194
|
+
it('the same input yields the same move-set (planRotation is pure)', () => {
|
|
195
|
+
const root = makeRoot();
|
|
196
|
+
seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003'], coldIds: ['001'], hotCapDelta: -1 });
|
|
197
|
+
const tiers = loadTiers(root, '2026-01-02');
|
|
198
|
+
const planA = planRotation(tiers);
|
|
199
|
+
const planB = planRotation(loadTiers(root, '2026-01-02'));
|
|
200
|
+
assert.deepEqual(planA.moves, planB.moves);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('--dry-run prints the move-set and writes nothing', () => {
|
|
204
|
+
const root = makeRoot();
|
|
205
|
+
seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
|
|
206
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
207
|
+
const { code, text } = run(['--dry-run'], root);
|
|
208
|
+
assert.equal(code, 0);
|
|
209
|
+
assert.match(text, /DRY-RUN/);
|
|
210
|
+
assert.match(text, /AD-005/);
|
|
211
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('preamble range tokens', () => {
|
|
216
|
+
it('updates the recognizable range/onward tokens after a rotation', () => {
|
|
217
|
+
const root = makeRoot();
|
|
218
|
+
seedProject(root, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
|
|
219
|
+
const { code } = run(['--today=2026-01-02'], root);
|
|
220
|
+
assert.equal(code, 0);
|
|
221
|
+
const hotText = readFileSync(join(root, HOT_REL), 'utf8');
|
|
222
|
+
assert.match(hotText, /\*\*AD-003 … AD-005\*\*/, 'WARM range token updated in HOT');
|
|
223
|
+
assert.match(hotText, /\(AD-006 onward\)/, 'active-set token updated');
|
|
224
|
+
const warmText = readFileSync(join(root, WARM_REL), 'utf8');
|
|
225
|
+
assert.match(warmText, /AD-003 … AD-005/, 'WARM file H1 range updated');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('a preamble without tokens is left untouched (consumer wording preserved)', () => {
|
|
229
|
+
const updated = updateRangeTokens('# My own ADR file\n\n> no tokens here', 'hot', {
|
|
230
|
+
hotEntries: [{ id: '010' }],
|
|
231
|
+
warmEntries: [{ id: '001' }, { id: '002' }],
|
|
232
|
+
coldEntries: [],
|
|
233
|
+
});
|
|
234
|
+
assert.equal(updated, '# My own ADR file\n\n> no tokens here');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── --check + the absent-substrate divergence ─────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
describe('--check', () => {
|
|
241
|
+
it('all tiers within caps → exit 0 with a per-tier usage report', () => {
|
|
242
|
+
const root = makeRoot();
|
|
243
|
+
seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
|
|
244
|
+
const { code, text } = run(['--check'], root);
|
|
245
|
+
assert.equal(code, 0);
|
|
246
|
+
assert.match(text, /decisions\.md: \d+\/\d+/);
|
|
247
|
+
assert.match(text, /OK — every tier is within its cap/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('an over-cap HOT → exit 1 naming the rotation recovery', () => {
|
|
251
|
+
const root = makeRoot();
|
|
252
|
+
seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
|
|
253
|
+
const { code, errText } = run(['--check'], root);
|
|
254
|
+
assert.equal(code, 1);
|
|
255
|
+
assert.match(errText, /decisions\.md is over its cap/);
|
|
256
|
+
assert.match(errText, /archive-decisions\.mjs` to rotate/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('an over-cap COLD → exit 1 naming the maintainer decision (rotation cannot fix it)', () => {
|
|
260
|
+
const root = makeRoot();
|
|
261
|
+
seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -1 });
|
|
262
|
+
const { code, errText } = run(['--check'], root);
|
|
263
|
+
assert.equal(code, 1);
|
|
264
|
+
assert.match(errText, /maintainer\/agent decision/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('ABSENT decisions.md → --check exits 0 with a STATED skip (deliberate divergence from archive-changelog)', () => {
|
|
268
|
+
const root = makeRoot();
|
|
269
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
270
|
+
const { code, text } = run(['--check'], root);
|
|
271
|
+
assert.equal(code, 0);
|
|
272
|
+
assert.match(text, /SKIP — docs\/ai\/decisions\.md not found/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('an absent decisions.md WITHOUT --check is still a loud non-zero (rotation has nothing to do)', () => {
|
|
276
|
+
const root = makeRoot();
|
|
277
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
278
|
+
const { code, errText } = run([], root);
|
|
279
|
+
assert.equal(code, 1);
|
|
280
|
+
assert.match(errText, /not found/);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('cap accounting is on RAW file lines (the template-shaped `---` separator case)', () => {
|
|
285
|
+
// A consumer file born from the shipped template joins entries with `\n\n---\n\n`. Normalized
|
|
286
|
+
// rendering drops those separator lines, so a render-based count would false-green a file the
|
|
287
|
+
// docs cap-validator (raw lines) fails. --check and the write trigger must count RAW lines.
|
|
288
|
+
const seedSeparatorShaped = (root, { capDelta }) => {
|
|
289
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
290
|
+
const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
|
|
291
|
+
const body = blocks.join('\n\n---\n\n'); // the template separator shape
|
|
292
|
+
const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
|
|
293
|
+
const cap = lineCountOf(probe) + capDelta;
|
|
294
|
+
writeFileSync(join(root, HOT_REL), `${fm(cap)}\n# ADRs\n\n${body}\n`);
|
|
295
|
+
return { cap, rawLines: lineCountOf(probe) };
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
it('--check fails on raw-over-cap even when the normalized render would fit', () => {
|
|
299
|
+
const root = makeRoot();
|
|
300
|
+
// 3 separators × 2 lines = 6 lines the render drops; cap sits 2 under raw → render fits, raw does not.
|
|
301
|
+
seedSeparatorShaped(root, { capDelta: -2 });
|
|
302
|
+
const { code, errText } = run(['--check'], root);
|
|
303
|
+
assert.equal(code, 1, 'raw lines are what the docs cap-validator counts — never false-green');
|
|
304
|
+
assert.match(errText, /decisions\.md is over its cap/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('rotation performs a NORMALIZE-ONLY rewrite when raw is over cap but the render fits (no moves)', () => {
|
|
308
|
+
const root = makeRoot();
|
|
309
|
+
seedSeparatorShaped(root, { capDelta: -2 });
|
|
310
|
+
const { code, text } = run(['--today=2026-01-02'], root);
|
|
311
|
+
assert.equal(code, 0, text);
|
|
312
|
+
assert.match(text, /normalize-only rewrite/);
|
|
313
|
+
assert.match(text, /HOT→WARM: \(none\)/);
|
|
314
|
+
const after = parseDecisionsText(readFileSync(join(root, HOT_REL), 'utf8'), HOT_REL);
|
|
315
|
+
assert.deepEqual(after.entries.map((e) => e.id), ['001', '002', '003'], 'ids conserved, nothing moved');
|
|
316
|
+
const { code: recheck } = run(['--check'], root);
|
|
317
|
+
assert.equal(recheck, 0, 'the normalized file now fits its cap on raw lines');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('a tier STILL over cap after the planned rewrite refuses BEFORE any write (a normalized rewrite is not a licence)', () => {
|
|
321
|
+
const root = makeRoot();
|
|
322
|
+
// COLD legitimately exceeds its cap (entries, not formatting): no move can fix it and a
|
|
323
|
+
// normalize-only rewrite would still be over budget — the run must refuse with files untouched.
|
|
324
|
+
seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -3 });
|
|
325
|
+
const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
|
|
326
|
+
const before = snapshot();
|
|
327
|
+
const { code, errText } = run(['--today=2026-01-02'], root);
|
|
328
|
+
assert.equal(code, 1);
|
|
329
|
+
assert.match(errText, /would still be over its cap after rotation/);
|
|
330
|
+
assert.match(errText, /maintainer\/agent decision/);
|
|
331
|
+
assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('a normalize-only rewrite NEVER materializes absent WARM/COLD files (no premature archives)', () => {
|
|
335
|
+
const root = makeRoot();
|
|
336
|
+
seedSeparatorShaped(root, { capDelta: -2 }); // HOT only — no history files exist
|
|
337
|
+
const { code } = run(['--today=2026-01-02'], root);
|
|
338
|
+
assert.equal(code, 0);
|
|
339
|
+
assert.ok(!existsSync(join(root, WARM_REL)), 'an absent WARM with zero entries is not created');
|
|
340
|
+
assert.ok(!existsSync(join(root, COLD_REL)), 'an absent COLD with zero entries is not created');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('created tiers (a consumer with no history files yet)', () => {
|
|
345
|
+
it('rolling into an absent WARM creates it with frontmatter + preamble', () => {
|
|
346
|
+
const root = makeRoot();
|
|
347
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
348
|
+
const blocks = [entryBlock('001'), entryBlock('002')];
|
|
349
|
+
const probe = tierText(999, '# ADRs', blocks);
|
|
350
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
|
|
351
|
+
const { code } = run(['--today=2026-01-02'], root);
|
|
352
|
+
assert.equal(code, 0);
|
|
353
|
+
assert.ok(existsSync(join(root, WARM_REL)), 'WARM created');
|
|
354
|
+
const warm = parseDecisionsText(readFileSync(join(root, WARM_REL), 'utf8'), WARM_REL);
|
|
355
|
+
assert.deepEqual(warm.entries.map((e) => e.id), ['001']);
|
|
356
|
+
assert.equal(warm.cap, 500, 'created WARM carries the default cap');
|
|
357
|
+
assert.match(warm.preamble, /AD-001 … AD-001/, 'created preamble range filled');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('usage errors are loud (exit 2)', () => {
|
|
361
|
+
const { code, errText } = run(['--frobnicate'], makeRoot());
|
|
362
|
+
assert.equal(code, 2);
|
|
363
|
+
assert.match(errText, /Unknown argument/);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -74,6 +74,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
|
|
|
74
74
|
- **Per-round emission.** Every review round emits **{round N · finding-origin tally · per-backend verdict}** so the crossover is a computed, visible signal, not a remembered rule.
|
|
75
75
|
- **Recipe fidelity.** Council runs every backend the recipe names, **every round**; silently dropping a ready backend for quota/convenience is a forbidden downgrade — an unavailable backend is a LOUD, stated degrade, never a quiet drop.
|
|
76
76
|
- **ExitPlanMode ≠ execute.** A harness "approved — start coding" prompt authorizes the PLAN only; this methodology overrides it. Continue into execution only as a DELIBERATE transition after the plan + cold-start prompt exist, never an implicit slide.
|
|
77
|
+
- **Cost lanes.** Route every step to the **cheapest adequate executor** — L0 deterministic script (the batched gate matrix over `gates.json`, the rotation `--check`s) · L1 cheap subagent (extraction/drafting only; the orchestrator verifies) · L2 subscription bridge · L3 frontier judgment. A step with **no named guardrail does not move down** a lane, and the **red lines never move down** (council review models · real code · ADR/handover/changelog-entry wording · persuasive copy · go/no-go · the approval asks). Own-error repair: salvage recorded state first (L0/L1, batched), never frontier re-derivation.
|
|
77
78
|
|
|
78
79
|
---
|
|
79
80
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd }: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell). This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
|
|
3
|
+
"gates": []
|
|
4
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cheap-agents.mjs — the onboarding writer behind `/agent-workflow-kit agents`: places the
|
|
3
|
+
// bundled CHEAP-LANE subagent definitions (references/agents/*.md — haiku/low-effort, bounded
|
|
4
|
+
// read-only tools) into a project's .claude/agents/ so mechanical work (sweeps, changelog
|
|
5
|
+
// skeletons, gate triage) stops running on a frontier model by default.
|
|
6
|
+
//
|
|
7
|
+
// The family's second `.claude/` writer, the velocity-profile.mjs writer discipline verbatim:
|
|
8
|
+
// • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
|
|
9
|
+
// • deployment-gated — `--apply` STOPs unless docs/ai/.workflow-version equals the lineage
|
|
10
|
+
// head (a dry-run stays usable on any project);
|
|
11
|
+
// • symlink-safe — a symlinked `.claude` / `.claude/agents` / target file is a STOP, never a
|
|
12
|
+
// write-through;
|
|
13
|
+
// • NEVER overwrites an existing .claude/agents/ file whose content differs from the bundled
|
|
14
|
+
// template — a customization is REPORTED (`customized — preserved`), never clobbered;
|
|
15
|
+
// an identical file is `already current` (idempotent re-run);
|
|
16
|
+
// • writes ONLY under .claude/agents/ — never settings.json / settings.local.json, never
|
|
17
|
+
// commits.
|
|
18
|
+
//
|
|
19
|
+
// Claude-Code-specific (like velocity): .claude/agents/ is a Claude Code surface; other agent
|
|
20
|
+
// hosts ignore it. In a HIDDEN-mode deployment, run the hide-footprint reconcile after apply so
|
|
21
|
+
// the placed files stay out of `git status` (the registry already carries /.claude/agents/); the
|
|
22
|
+
// apply report reminds you.
|
|
23
|
+
//
|
|
24
|
+
// Exit codes: 0 done / dry-run (incl. preserved customizations — a user's file is a legitimate
|
|
25
|
+
// state, not an error); 1 precondition STOP (stamp, symlink, missing bundle); 2 usage.
|
|
26
|
+
// Dependency-free, Node >= 18. No side effects on import.
|
|
27
|
+
|
|
28
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
29
|
+
import { join, resolve, dirname } from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
export const AGENTS_DIR = '.claude/agents';
|
|
35
|
+
export const CLAUDE_DIR = '.claude';
|
|
36
|
+
export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
|
|
37
|
+
export const EXPECTED_WORKFLOW_VERSION = '1.3.0';
|
|
38
|
+
export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
|
|
39
|
+
|
|
40
|
+
const EXIT_OK = 0;
|
|
41
|
+
const EXIT_PRECONDITION = 1;
|
|
42
|
+
const EXIT_USAGE = 2;
|
|
43
|
+
const UTF8 = 'utf8';
|
|
44
|
+
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
45
|
+
|
|
46
|
+
export const CHEAP_AGENTS_STAMP = 'CHEAP_AGENTS_STAMP';
|
|
47
|
+
export const CHEAP_AGENTS_SYMLINK = 'CHEAP_AGENTS_SYMLINK';
|
|
48
|
+
export const CHEAP_AGENTS_BUNDLE = 'CHEAP_AGENTS_BUNDLE';
|
|
49
|
+
|
|
50
|
+
const USAGE = `usage: cheap-agents [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
51
|
+
|
|
52
|
+
Places the bundled cheap-lane subagent definitions (haiku/low, read-only tools) into the
|
|
53
|
+
project's ${AGENTS_DIR}/. Default is --dry-run (a preview; writes nothing). --apply writes.
|
|
54
|
+
An existing file with DIFFERENT content is preserved and reported, never overwritten.`;
|
|
55
|
+
|
|
56
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
57
|
+
|
|
58
|
+
export const makeCheapAgentsError = (code, message) =>
|
|
59
|
+
Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'CheapAgentsError', code, exitCode: EXIT_PRECONDITION });
|
|
60
|
+
|
|
61
|
+
const fsDeps = (deps = {}) => ({
|
|
62
|
+
exists: deps.exists ?? existsSync,
|
|
63
|
+
lstat: deps.lstat ?? lstatSync,
|
|
64
|
+
mkdir: deps.mkdir ?? mkdirSync,
|
|
65
|
+
readFile: deps.readFile ?? readFileSync,
|
|
66
|
+
writeFile: deps.writeFile ?? writeFileSync,
|
|
67
|
+
readdir: deps.readdir ?? readdirSync,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const lstatNoFollow = (absPath, fs) => {
|
|
71
|
+
try {
|
|
72
|
+
return fs.lstat(absPath);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err && err.code === 'ENOENT') return null;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ── the bundle (the kit's own references/agents/) ─────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export const readBundledAgents = (deps = {}) => {
|
|
82
|
+
const fs = fsDeps(deps);
|
|
83
|
+
const bundleDir = deps.bundleDir ?? BUNDLED_AGENTS_DIR;
|
|
84
|
+
let names;
|
|
85
|
+
try {
|
|
86
|
+
names = fs.readdir(bundleDir);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `bundled agents dir unreadable (${err.code ?? err.message}): ${bundleDir}`);
|
|
89
|
+
}
|
|
90
|
+
const templates = names
|
|
91
|
+
.filter((name) => name.endsWith('.md'))
|
|
92
|
+
.sort()
|
|
93
|
+
.map((name) => ({ name, content: fs.readFile(join(bundleDir, name), UTF8) }));
|
|
94
|
+
if (templates.length === 0) {
|
|
95
|
+
throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `no bundled agent templates found in ${bundleDir} — the kit install is incomplete`);
|
|
96
|
+
}
|
|
97
|
+
return templates;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── preflight (velocity discipline: symlink-safe, stamp read, no writes) ──────────────
|
|
101
|
+
|
|
102
|
+
const readStamp = (absPath, fs) => {
|
|
103
|
+
try {
|
|
104
|
+
if (!fs.exists(absPath)) return null;
|
|
105
|
+
const stamp = String(fs.readFile(absPath, UTF8)).trim();
|
|
106
|
+
return stamp.length ? stamp : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const assertDirSafe = (absPath, relPath, fs) => {
|
|
113
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
114
|
+
if (stat === null) return { absent: true };
|
|
115
|
+
if (stat.isSymbolicLink()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
|
|
116
|
+
if (!stat.isDirectory()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
|
|
117
|
+
return { absent: false };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Per-template placement plan: place | already-current | customized-preserved (never clobbered).
|
|
121
|
+
export const planPlacement = (templates, projectDir, deps = {}) => {
|
|
122
|
+
const fs = fsDeps(deps);
|
|
123
|
+
return templates.map((template) => {
|
|
124
|
+
const rel = `${AGENTS_DIR}/${template.name}`;
|
|
125
|
+
const abs = join(projectDir, AGENTS_DIR, template.name);
|
|
126
|
+
const stat = lstatNoFollow(abs, fs);
|
|
127
|
+
if (stat === null) return { ...template, rel, abs, action: 'place' };
|
|
128
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
129
|
+
throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${rel} exists but is not a regular file — refusing to touch it`);
|
|
130
|
+
}
|
|
131
|
+
const existing = fs.readFile(abs, UTF8);
|
|
132
|
+
if (existing === template.content) return { ...template, rel, abs, action: 'already-current' };
|
|
133
|
+
return { ...template, rel, abs, action: 'customized-preserved' };
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const preflightCheapAgents = ({ cwd }, deps = {}) => {
|
|
138
|
+
const fs = fsDeps(deps);
|
|
139
|
+
const projectDir = cwd ?? process.cwd();
|
|
140
|
+
const templates = readBundledAgents(deps);
|
|
141
|
+
const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
|
|
142
|
+
const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
|
|
143
|
+
assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
|
|
144
|
+
assertDirSafe(join(projectDir, AGENTS_DIR), AGENTS_DIR, fs);
|
|
145
|
+
const plan = planPlacement(templates, projectDir, deps);
|
|
146
|
+
return { projectDir, stamp, stampOk, plan };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── the writer ────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export const writeCheapAgents = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
152
|
+
const fs = fsDeps(deps);
|
|
153
|
+
const preflight = preflightCheapAgents({ cwd }, deps);
|
|
154
|
+
if (dryRun) return { wrote: false, dryRun: true, ...preflight };
|
|
155
|
+
|
|
156
|
+
if (!preflight.stampOk) {
|
|
157
|
+
throw makeCheapAgentsError(
|
|
158
|
+
CHEAP_AGENTS_STAMP,
|
|
159
|
+
`not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) — run init/upgrade first`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const toPlace = preflight.plan.filter((item) => item.action === 'place');
|
|
163
|
+
if (toPlace.length > 0) fs.mkdir(join(preflight.projectDir, AGENTS_DIR), { recursive: true });
|
|
164
|
+
for (const item of toPlace) fs.writeFile(item.abs, item.content, UTF8);
|
|
165
|
+
return { wrote: toPlace.length > 0, dryRun: false, ...preflight };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ── report ────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const ACTION_LABEL = {
|
|
171
|
+
place: 'place',
|
|
172
|
+
'already-current': 'already current',
|
|
173
|
+
'customized-preserved': 'customized — preserved (delete the file to reseed from the bundle)',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const formatResult = (result) => {
|
|
177
|
+
const lines = [
|
|
178
|
+
result.dryRun
|
|
179
|
+
? 'agent-workflow cheap-lane agents — DRY RUN (no changes; re-run with --apply)'
|
|
180
|
+
: 'agent-workflow cheap-lane agents — APPLY',
|
|
181
|
+
];
|
|
182
|
+
for (const item of result.plan) {
|
|
183
|
+
const verb = result.dryRun && item.action === 'place' ? 'would place' : ACTION_LABEL[item.action];
|
|
184
|
+
lines.push(` - ${item.rel}: ${verb}`);
|
|
185
|
+
}
|
|
186
|
+
if (!result.stampOk) {
|
|
187
|
+
lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
|
|
188
|
+
}
|
|
189
|
+
lines.push(
|
|
190
|
+
'the vehicles are Claude Code subagents (model: haiku, effort: low, read-only tools) for mechanical work only — judgment, review, and real code stay on your main lane.',
|
|
191
|
+
);
|
|
192
|
+
if (!result.dryRun && result.wrote) {
|
|
193
|
+
lines.push('hidden-mode note: if this deployment is hidden, run the hide-footprint reconcile so the placed files stay out of `git status`.');
|
|
194
|
+
}
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export const parseArgs = (argv) => {
|
|
201
|
+
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
202
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
203
|
+
const arg = argv[i];
|
|
204
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
205
|
+
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
206
|
+
else if (arg === '--apply') opts.apply = true;
|
|
207
|
+
else if (arg === '--cwd') {
|
|
208
|
+
i += 1;
|
|
209
|
+
if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
|
|
210
|
+
opts.cwd = argv[i];
|
|
211
|
+
} else {
|
|
212
|
+
throw fail(EXIT_USAGE, `unknown argument: ${arg}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
216
|
+
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
220
|
+
const log = deps.log ?? console.log;
|
|
221
|
+
const errlog = deps.errlog ?? console.error;
|
|
222
|
+
try {
|
|
223
|
+
const args = parseArgs(argv);
|
|
224
|
+
if (args.help) {
|
|
225
|
+
log(USAGE);
|
|
226
|
+
return EXIT_OK;
|
|
227
|
+
}
|
|
228
|
+
const result = writeCheapAgents({ cwd: args.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
|
|
229
|
+
log(formatResult(result));
|
|
230
|
+
return EXIT_OK;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
errlog(err?.message ?? String(err));
|
|
233
|
+
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
234
|
+
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
239
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|