agentic-workflow-manager 9.18.0 → 9.20.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.
Files changed (69) hide show
  1. package/dist/src/commands/change/index.js +492 -0
  2. package/dist/src/commands/changes/index.js +84 -0
  3. package/dist/src/commands/plan/index.js +1 -1
  4. package/dist/src/commands/sensors/index.js +9 -0
  5. package/dist/src/commands/sensors/read-only-observation.js +305 -0
  6. package/dist/src/commands/sensors/run.js +24 -60
  7. package/dist/src/core/admission/index.js +11 -1
  8. package/dist/src/core/changes/command.js +38 -0
  9. package/dist/src/core/changes/derive.js +120 -0
  10. package/dist/src/core/changes/fs-safe.js +47 -0
  11. package/dist/src/core/changes/git-env.js +68 -0
  12. package/dist/src/core/changes/git.js +216 -0
  13. package/dist/src/core/changes/list-parse.js +67 -0
  14. package/dist/src/core/changes/list.js +408 -0
  15. package/dist/src/core/changes/parse.js +72 -0
  16. package/dist/src/core/changes/range.js +53 -0
  17. package/dist/src/core/changes/receipt.js +274 -0
  18. package/dist/src/core/changes/record-io.js +149 -0
  19. package/dist/src/core/changes/render-list.js +109 -0
  20. package/dist/src/core/changes/render.js +178 -0
  21. package/dist/src/core/changes/text-safety.js +116 -0
  22. package/dist/src/core/changes/trailers.js +76 -0
  23. package/dist/src/core/changes/types.js +26 -0
  24. package/dist/src/core/changes/validate-shape.js +219 -0
  25. package/dist/src/core/changes/worktree.js +53 -0
  26. package/dist/src/core/currentness/check.js +5 -2
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/release/orchestrator.js +3 -1
  29. package/dist/tests/commands/change/new.test.js +1126 -0
  30. package/dist/tests/commands/change/validate.test.js +1145 -0
  31. package/dist/tests/commands/changes/list.test.js +423 -0
  32. package/dist/tests/commands/sensors/index.test.js +1 -1
  33. package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
  34. package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
  35. package/dist/tests/core/admission/index.test.js +60 -0
  36. package/dist/tests/core/changes/derive.test.js +455 -0
  37. package/dist/tests/core/changes/fs-safe.test.js +82 -0
  38. package/dist/tests/core/changes/git-env.test.js +143 -0
  39. package/dist/tests/core/changes/git.test.js +442 -0
  40. package/dist/tests/core/changes/helpers.js +269 -0
  41. package/dist/tests/core/changes/list-fixtures.js +122 -0
  42. package/dist/tests/core/changes/list-long-history.test.js +52 -0
  43. package/dist/tests/core/changes/list-paging.test.js +511 -0
  44. package/dist/tests/core/changes/list-parse.test.js +120 -0
  45. package/dist/tests/core/changes/list-tree.test.js +44 -0
  46. package/dist/tests/core/changes/list.test.js +821 -0
  47. package/dist/tests/core/changes/parse.test.js +142 -0
  48. package/dist/tests/core/changes/predicates.test.js +83 -0
  49. package/dist/tests/core/changes/range.test.js +92 -0
  50. package/dist/tests/core/changes/receipt.test.js +947 -0
  51. package/dist/tests/core/changes/record-io.test.js +307 -0
  52. package/dist/tests/core/changes/record.test.js +549 -0
  53. package/dist/tests/core/changes/render-list.test.js +212 -0
  54. package/dist/tests/core/changes/render.test.js +202 -0
  55. package/dist/tests/core/changes/text-safety.test.js +226 -0
  56. package/dist/tests/core/changes/trailers.test.js +181 -0
  57. package/dist/tests/core/changes/worktree.test.js +259 -0
  58. package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
  59. package/dist/tests/core/currentness/check.test.js +75 -14
  60. package/dist/tests/release/orchestrator-trailer.test.js +72 -0
  61. package/dist/tests/release/orchestrator.test.js +1 -0
  62. package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
  63. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
  64. package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
  65. package/package.json +1 -1
  66. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  67. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  68. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  69. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,1126 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const fs_1 = __importDefault(require("fs"));
40
+ const os_1 = __importDefault(require("os"));
41
+ const path_1 = __importDefault(require("path"));
42
+ const commander_1 = require("commander");
43
+ const child_process_1 = __importDefault(require("child_process"));
44
+ const helpers_1 = require("../../core/changes/helpers");
45
+ const change_1 = require("../../../src/commands/change");
46
+ const store_1 = require("../../../src/core/journal/store");
47
+ const ledgerStore = __importStar(require("../../../src/core/ledger/store"));
48
+ const receipt_1 = require("../../../src/core/changes/receipt");
49
+ const parse_1 = require("../../../src/core/changes/parse");
50
+ const renderModule = __importStar(require("../../../src/core/changes/render"));
51
+ const validate_shape_1 = require("../../../src/core/changes/validate-shape");
52
+ const types_1 = require("../../../src/core/changes/types");
53
+ const REAL_CHANGES_DIR = path_1.default.resolve(__dirname, '..', '..', '..', '..', 'docs', 'changes');
54
+ const listReal = () => { try {
55
+ return fs_1.default.readdirSync(REAL_CHANGES_DIR).sort();
56
+ }
57
+ catch {
58
+ return null;
59
+ } };
60
+ const realBefore = listReal();
61
+ let repo;
62
+ const now = () => new Date('2026-10-17T12:00:00.000Z');
63
+ const pass = { overall: 'pass', sensors: [{ name: 'lint', status: 'pass', errors: [] }] };
64
+ const RECEIPT_TIME = '2026-10-17T11:00:00.000Z';
65
+ const REMEDY = 'commit your work, then run awm sensors run';
66
+ const REMEDY_SPLIT = 'split the change into smaller ones';
67
+ const REMEDY_SENSORS = 'reduce the sensors listed in .awm/sensors.json (the sensor receipt makes the record too large)';
68
+ function receiptForHead() {
69
+ (0, receipt_1.writeReceipt)(repo.root, (0, receipt_1.buildReceipt)({ commit: repo.git('rev-parse', 'HEAD'), ranAt: RECEIPT_TIME, cliVersion: '9.20.0', output: pass }));
70
+ }
71
+ // One template repository for the whole file (it also holds HOME and AWM_HOME on a throwaway home); every test gets a copy.
72
+ let template;
73
+ beforeAll(() => {
74
+ template = (0, helpers_1.makeRepo)();
75
+ template.git('checkout', '-q', '-b', 'feat/x');
76
+ template.commit({ 'cli/src/a.ts': '1' }, 'feat(changes): a');
77
+ });
78
+ afterAll(() => template.cleanup());
79
+ function newRepo() {
80
+ repo = (0, helpers_1.cloneRepo)(template);
81
+ }
82
+ /** A fresh repository for every test: for tests that write a record, move HEAD or change the tree. */
83
+ function freshRepo() {
84
+ beforeEach(newRepo);
85
+ afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
86
+ }
87
+ /**
88
+ * One repository (with a receipt for HEAD) for a whole group. Only for tests that are refused before anything is written
89
+ * (they never change HEAD, the tree that matters, or docs/changes): they would otherwise pay for a repository each.
90
+ */
91
+ function sharedRepo() {
92
+ beforeAll(() => { newRepo(); receiptForHead(); });
93
+ afterAll(() => repo.cleanup());
94
+ }
95
+ // The command writes only into the throwaway repository: the real checkout is never touched.
96
+ afterEach(() => expect(listReal()).toEqual(realBefore));
97
+ afterAll(() => expect(listReal()).toEqual(realBefore));
98
+ /** Replaces one fs function for the calls `match` selects (by their arguments); every other call reaches the real one. */
99
+ function interceptFs(name, match, replace) {
100
+ const real = fs_1.default[name];
101
+ return jest.spyOn(fs_1.default, name).mockImplementation(((...args) => (match(args) ? replace(real, args) : real(...args))));
102
+ }
103
+ const baseIs = (arg, name) => typeof arg === 'string' && path_1.default.basename(arg) === name;
104
+ const isRecordsDirArg = (arg) => baseIs(arg, 'changes') && baseIs(path_1.default.dirname(String(arg)), 'docs');
105
+ const recordsDir = () => path_1.default.join(repo.root, 'docs', 'changes');
106
+ const file = () => path_1.default.join(recordsDir(), '2026-10-17-change-record.md');
107
+ const run = (slug, opts = {}, deps = { now }) => (0, change_1.runChangeNew)(repo.root, slug, opts, deps);
108
+ const read = () => fs_1.default.readFileSync(file(), 'utf8');
109
+ const frontmatter = (text) => {
110
+ const split = (0, parse_1.splitRecord)(text);
111
+ if (!split.ok)
112
+ throw new Error('not a record');
113
+ const loaded = (0, parse_1.loadFrontmatter)(split.frontmatterText);
114
+ if (!loaded.ok)
115
+ throw new Error('bad yaml');
116
+ return loaded.value;
117
+ };
118
+ /** The failure contract: exit 2, nothing on stdout, `code: <one line of at most 300 units>` (+ an optional remedy line). */
119
+ function expectFailure(r, code, remedy) {
120
+ expect(r.code).toBe(2);
121
+ expect(r.stdout).toBe('');
122
+ const lines = r.stderr.split('\n');
123
+ expect(lines[lines.length - 1]).toBe('');
124
+ expect(lines[0].startsWith(`${code}: `)).toBe(true);
125
+ expect(lines[0].length).toBeLessThanOrEqual(300);
126
+ expect(r.stderr).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/);
127
+ if (remedy !== undefined)
128
+ expect(lines[1]).toBe(` remedy: ${remedy}`);
129
+ expect(lines.length).toBe(remedy === undefined ? 2 : 3);
130
+ }
131
+ const recordFiles = () => { try {
132
+ return fs_1.default.readdirSync(recordsDir()).sort();
133
+ }
134
+ catch {
135
+ return [];
136
+ } };
137
+ describe('test isolation', () => {
138
+ freshRepo();
139
+ test('HOME and AWM_HOME point into the throwaway home', () => {
140
+ expect(process.env.HOME).toBe(repo.home);
141
+ expect(process.env.AWM_HOME).toBe(path_1.default.join(repo.home, '.awm'));
142
+ expect(fs_1.default.realpathSync.native(repo.root).startsWith(fs_1.default.realpathSync.native(os_1.default.tmpdir()))).toBe(true);
143
+ });
144
+ });
145
+ describe('awm change new: receipt and clean tree (R3.6)', () => {
146
+ freshRepo();
147
+ test('a missing receipt fails with receipt-missing and the remedy, writing nothing', () => {
148
+ expectFailure(run('change-record'), 'receipt-missing', REMEDY);
149
+ expect(recordFiles()).toEqual([]);
150
+ });
151
+ test('an untracked file outside docs/changes fails with tree-dirty and the remedy', () => {
152
+ receiptForHead();
153
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'dirty.ts'), 'x');
154
+ expectFailure(run('change-record'), 'tree-dirty', REMEDY);
155
+ expect(recordFiles()).toEqual([]);
156
+ });
157
+ test('a modified tracked file fails with tree-dirty', () => {
158
+ receiptForHead();
159
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'cli', 'src', 'a.ts'), '2');
160
+ expectFailure(run('change-record'), 'tree-dirty', REMEDY);
161
+ });
162
+ test('another uncommitted record in docs/changes does not make the tree dirty', () => {
163
+ receiptForHead();
164
+ fs_1.default.mkdirSync(recordsDir(), { recursive: true });
165
+ fs_1.default.writeFileSync(path_1.default.join(recordsDir(), '2026-10-01-other.md'), 'draft');
166
+ expect(run('change-record').code).toBe(0);
167
+ });
168
+ test('a receipt for another commit is receipt-missing', () => {
169
+ receiptForHead();
170
+ repo.commit({ 'cli/src/b.ts': '1' }, 'fix(changes): b');
171
+ expectFailure(run('change-record'), 'receipt-missing', REMEDY);
172
+ });
173
+ test('a tampered receipt is receipt-invalid', () => {
174
+ receiptForHead();
175
+ const receiptFile = path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${repo.git('rev-parse', 'HEAD')}.json`);
176
+ fs_1.default.writeFileSync(receiptFile, fs_1.default.readFileSync(receiptFile, 'utf8').replace('"pass"', '"fail"'));
177
+ expectFailure(run('change-record'), 'receipt-invalid', REMEDY);
178
+ expect(recordFiles()).toEqual([]);
179
+ });
180
+ });
181
+ describe('awm change new: skeleton (R3.7, R3.9)', () => {
182
+ freshRepo();
183
+ test('prints the created path and writes exactly that file', () => {
184
+ receiptForHead();
185
+ const r = run('change-record');
186
+ expect(r).toEqual({ code: 0, stdout: 'Created docs/changes/2026-10-17-change-record.md\n', stderr: '' });
187
+ expect(recordFiles()).toEqual(['2026-10-17-change-record.md']);
188
+ });
189
+ test('derives type, scope, semver, breaking and modules from the branch commits', () => {
190
+ receiptForHead();
191
+ run('change-record');
192
+ const fm = frontmatter(read());
193
+ expect(fm).toMatchObject({ schema: 'awm.change-record/v1', id: '2026-10-17-change-record', type: 'feat', scope: 'changes', semver: 'minor', breaking: false, modules: ['cli/src'] });
194
+ });
195
+ test('copies commit, overall, sensors and the digest of the receipt into verification', () => {
196
+ receiptForHead();
197
+ run('change-record');
198
+ const expected = {
199
+ commit: repo.git('rev-parse', 'HEAD'),
200
+ overall: 'pass',
201
+ sensors: { lint: 'pass' },
202
+ receipt_digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/),
203
+ };
204
+ expect(frontmatter(read()).verification).toEqual(expected);
205
+ });
206
+ test('the digest is the receipt digest, not a recomputation of the record', () => {
207
+ receiptForHead();
208
+ const receiptFile = path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${repo.git('rev-parse', 'HEAD')}.json`);
209
+ const digest = JSON.parse(fs_1.default.readFileSync(receiptFile, 'utf8')).digest;
210
+ run('change-record');
211
+ expect(frontmatter(read()).verification.receipt_digest).toBe(digest);
212
+ });
213
+ test('every narrative field and every body section carries the pending marker', () => {
214
+ receiptForHead();
215
+ run('change-record');
216
+ const text = read();
217
+ const fm = frontmatter(text);
218
+ expect(fm.title).toBe(types_1.PENDING_MARKER);
219
+ expect(fm.summary_short).toBe(types_1.PENDING_MARKER);
220
+ expect(fm.manual_validation).toEqual([{ step: types_1.PENDING_MARKER, expected: types_1.PENDING_MARKER }]);
221
+ expect(fm.risks).toEqual([types_1.PENDING_MARKER]);
222
+ expect(fm.rollback).toBe(types_1.PENDING_MARKER);
223
+ for (const heading of ['## Qué se hizo', '## Qué se probó', '## Qué falta validar', '## Riesgos y rollback']) {
224
+ expect(text).toContain(`${heading}\n\n${types_1.PENDING_MARKER}\n`);
225
+ }
226
+ });
227
+ test('the skeleton fails validation only because of its pending markers', () => {
228
+ receiptForHead();
229
+ run('change-record');
230
+ const { diagnostics } = (0, validate_shape_1.validateRecordShape)('2026-10-17-change-record.md', read(), { root: repo.root });
231
+ expect(diagnostics.map((d) => d.code)).toEqual(['pending-content']);
232
+ });
233
+ test('once the markers are replaced the record passes validateRecordShape', () => {
234
+ repo.commit({ 'docs/plans/p.md': 'plan' }, 'docs: plan');
235
+ receiptForHead();
236
+ run('change-record', { plan: 'docs/plans/p.md', issue: 'https://github.com/o/r/issues/217', model: 'm1', session: 's1' });
237
+ const filled = read().split(types_1.PENDING_MARKER).join('written by the agent');
238
+ const r = (0, validate_shape_1.validateRecordShape)('2026-10-17-change-record.md', filled, { root: repo.root });
239
+ expect(r.diagnostics).toEqual([]);
240
+ expect(r.record?.verification.commit).toBe(repo.git('rev-parse', 'HEAD'));
241
+ });
242
+ test('links carry --plan and --issue exactly as given', () => {
243
+ repo.commit({ 'docs/plans/p.md': 'plan' }, 'docs: plan');
244
+ receiptForHead();
245
+ run('change-record', { plan: 'docs/plans/p.md', issue: 'https://github.com/o/r/issues/217' });
246
+ expect(frontmatter(read()).links).toEqual({ plan: 'docs/plans/p.md', issue: 'https://github.com/o/r/issues/217' });
247
+ });
248
+ test('without --plan and --issue there is no links key', () => {
249
+ receiptForHead();
250
+ run('change-record');
251
+ expect(frontmatter(read())).not.toHaveProperty('links');
252
+ });
253
+ test('the record ends with a newline and uses LF only', () => {
254
+ receiptForHead();
255
+ run('change-record');
256
+ const text = read();
257
+ expect(text.endsWith('\n')).toBe(true);
258
+ expect(text).not.toContain('\r');
259
+ });
260
+ test('a record is written outside the cwd-relative path: running from a subdirectory writes at the repo root', () => {
261
+ receiptForHead();
262
+ const sub = path_1.default.join(repo.root, 'cli', 'src');
263
+ expect((0, change_1.runChangeNew)(sub, 'change-record', {}, { now }).code).toBe(0);
264
+ expect(recordFiles()).toEqual(['2026-10-17-change-record.md']);
265
+ expect(fs_1.default.existsSync(path_1.default.join(sub, 'docs'))).toBe(false);
266
+ });
267
+ test('the date comes from the injected clock, in UTC', () => {
268
+ receiptForHead();
269
+ run('change-record', {}, { now: () => new Date('2027-01-02T23:59:59.000Z') });
270
+ expect(recordFiles()).toEqual(['2027-01-02-change-record.md']);
271
+ });
272
+ test.each([
273
+ ['an invalid date', () => new Date(NaN)],
274
+ ['a year beyond 9999', () => new Date('+010000-01-01T00:00:00.000Z')],
275
+ ['a clock that throws', () => { throw new Error('boom'); }],
276
+ ['a clock that returns a string', (() => 'x')],
277
+ ])('an unusable clock (%s) fails with date-invalid and writes nothing', (_name, bad) => {
278
+ receiptForHead();
279
+ expectFailure(run('change-record', {}, { now: bad }), 'date-invalid');
280
+ expect(recordFiles()).toEqual([]);
281
+ });
282
+ });
283
+ describe('awm change new: generated record is checked before it is written (R3.4 size bound)', () => {
284
+ freshRepo();
285
+ test('R3.4 a receipt that makes the record exceed 64 KiB fails with record-too-large and a remedy naming the sensors, and writes nothing', () => {
286
+ const sensors = Array.from({ length: 700 }, (_, i) => ({ name: `sensor-${String(i).padStart(4, '0')}-${'x'.repeat(100)}`, status: 'pass', errors: [] }));
287
+ (0, receipt_1.writeReceipt)(repo.root, (0, receipt_1.buildReceipt)({ commit: repo.git('rev-parse', 'HEAD'), ranAt: RECEIPT_TIME, cliVersion: '9.20.0', output: { overall: 'pass', sensors } }));
288
+ const r = run('change-record');
289
+ // The receipt, not the directories, is the cause: the remedy says so (R3.4).
290
+ expectFailure(r, 'record-too-large', REMEDY_SENSORS);
291
+ expect(recordFiles()).toEqual([]);
292
+ });
293
+ test('R3.4 a change touching thousands of directories fails with record-too-large (not a cryptic skeleton-invalid) and writes nothing', () => {
294
+ // One fast-import commit adds a file in each of 4000 vendor directories: `modules` alone is far over 64 KiB.
295
+ const files = Object.fromEntries(Array.from({ length: 4000 }, (_, i) => [`vendor/package-${String(i).padStart(5, '0')}/index.js`, '1']));
296
+ (0, helpers_1.importCommits)(repo, [{ message: 'feat(vendor): import vendored packages', files, date: '2026-10-17T10:00:00Z' }]);
297
+ receiptForHead();
298
+ const r = run('change-record');
299
+ expectFailure(r, 'record-too-large', REMEDY_SPLIT);
300
+ expect(r.stderr).not.toContain('skeleton-invalid');
301
+ expect(recordFiles()).toEqual([]);
302
+ });
303
+ });
304
+ describe('awm change new: the 65536-byte boundary of the record (R3.4, R1.8)', () => {
305
+ freshRepo();
306
+ // The record grows by exactly one byte per character of a sensor name (ASCII), while the number of sensors is fixed. Filler
307
+ // sensors of 100 characters move the size in steps of about 111 bytes, less than the 127 bytes one variable name can add, so
308
+ // some filler count has the limit inside the variable name's range.
309
+ const sensorList = (fillers, variable) => [
310
+ ...Array.from({ length: fillers }, (_, i) => ({ name: `f${String(i).padStart(4, '0')}${'x'.repeat(95)}`, status: 'pass', errors: [] })),
311
+ { name: `v${'x'.repeat(variable - 1)}`, status: 'pass', errors: [] },
312
+ ];
313
+ const setReceipt = (fillers, variable) => {
314
+ (0, receipt_1.writeReceipt)(repo.root, (0, receipt_1.buildReceipt)({ commit: repo.git('rev-parse', 'HEAD'), ranAt: RECEIPT_TIME, cliVersion: '9.20.0', output: { overall: 'pass', sensors: sensorList(fillers, variable) } }));
315
+ };
316
+ /** Largest n in [lo, hi] for which `fits(n)` holds, `fits` being true up to a point and false after it. */
317
+ const largest = (lo, hi, fits) => {
318
+ let a = lo;
319
+ let b = hi;
320
+ while (a < b) {
321
+ const mid = Math.ceil((a + b) / 2);
322
+ if (fits(mid))
323
+ a = mid;
324
+ else
325
+ b = mid - 1;
326
+ }
327
+ return a;
328
+ };
329
+ /** Runs `attempt` for a receipt of (fillers, variable); `bytes` is the size of the file it wrote, null when it failed as too large. */
330
+ function findBoundary(attempt) {
331
+ const fits = (fillers, variable) => { setReceipt(fillers, variable); return attempt().bytes; };
332
+ const fillers = largest(0, 700, (k) => fits(k, 1) !== null);
333
+ const variable = largest(1, 128, (l) => fits(fillers, l) !== null);
334
+ return { fillers, variable, atLimit: fits(fillers, variable), oneMore: variable < 128 ? fits(fillers, variable + 1) : -1 };
335
+ }
336
+ test('change new: a record of exactly 65536 bytes is written, one of 65537 is record-too-large', () => {
337
+ const attempt = () => {
338
+ const r = run('change-record');
339
+ if (r.code === 0) {
340
+ const bytes = fs_1.default.statSync(file()).size;
341
+ fs_1.default.rmSync(file());
342
+ return { bytes };
343
+ }
344
+ expect(r.stderr).toContain('record-too-large');
345
+ return { bytes: null };
346
+ };
347
+ const found = findBoundary(attempt);
348
+ expect(found.variable).toBeLessThan(128);
349
+ expect(found.atLimit).toBe(65536);
350
+ expect(found.oneMore).toBeNull();
351
+ });
352
+ test('change new --refresh: a refreshed record of exactly 65536 bytes is written, one of 65537 is record-too-large', () => {
353
+ setReceipt(0, 1);
354
+ expect(run('change-record').code).toBe(0);
355
+ repo.git('add', '-A');
356
+ repo.git('commit', '-q', '-m', 'docs(changes): record');
357
+ const original = read();
358
+ const attempt = () => {
359
+ const r = run('change-record', { refresh: true });
360
+ if (r.code === 0) {
361
+ const bytes = fs_1.default.statSync(file()).size;
362
+ fs_1.default.writeFileSync(file(), original);
363
+ return { bytes };
364
+ }
365
+ expect(r.stderr).toContain('record-too-large');
366
+ expect(read()).toBe(original);
367
+ return { bytes: null };
368
+ };
369
+ const found = findBoundary(attempt);
370
+ expect(found.variable).toBeLessThan(128);
371
+ expect(found.atLimit).toBe(65536);
372
+ expect(found.oneMore).toBeNull();
373
+ });
374
+ test('the remedy names the cause: a receipt that is too large asks to reduce the sensors, in new and in refresh alike', () => {
375
+ setReceipt(0, 1);
376
+ run('change-record');
377
+ repo.git('add', '-A');
378
+ repo.git('commit', '-q', '-m', 'docs(changes): record');
379
+ setReceipt(700, 100);
380
+ expectFailure(run('change-record', { refresh: true }), 'record-too-large', REMEDY_SENSORS);
381
+ });
382
+ });
383
+ describe('awm change new: HEAD moving while the command runs', () => {
384
+ freshRepo();
385
+ test('a HEAD that moves before the record is written is head-moved and nothing is written', () => {
386
+ receiptForHead();
387
+ const moving = () => { repo.commit({ 'cli/src/late.ts': '1' }, 'fix(core): late'); return now(); };
388
+ expectFailure(run('change-record', {}, { now: moving }), 'head-moved');
389
+ expect(recordFiles()).toEqual([]);
390
+ });
391
+ test('a HEAD that moves before a refresh is written is head-moved and the record is untouched', () => {
392
+ receiptForHead();
393
+ run('change-record');
394
+ repo.git('add', '-A');
395
+ repo.git('commit', '-q', '-m', 'docs(changes): record');
396
+ receiptForHead();
397
+ const before = read();
398
+ const spy = interceptFs('readFileSync', (a) => baseIs(a[0], path_1.default.basename(file())), (real, a) => {
399
+ const out = real(...a);
400
+ spy.mockRestore();
401
+ repo.commit({ 'cli/src/late.ts': '1' }, 'fix(core): late');
402
+ return out;
403
+ });
404
+ expectFailure(run('change-record', { refresh: true }), 'head-moved');
405
+ expect(read()).toBe(before);
406
+ });
407
+ });
408
+ describe('awm change new: HEAD is pinned before the derivation', () => {
409
+ freshRepo();
410
+ /** Runs `act` once, right before (or after) the first git call whose first argument is `sub`. */
411
+ function onGit(sub, when, act) {
412
+ const real = child_process_1.default.execFileSync;
413
+ let fired = false;
414
+ return jest.spyOn(child_process_1.default, 'execFileSync').mockImplementation(((...args) => {
415
+ const git = Array.isArray(args[1]) ? args[1] : [];
416
+ const hit = !fired && args[0] === 'git' && git.includes(sub);
417
+ if (hit)
418
+ fired = true;
419
+ if (hit && when === 'before')
420
+ act();
421
+ const out = real(...args);
422
+ if (hit && when === 'after')
423
+ act();
424
+ return out;
425
+ }));
426
+ }
427
+ const land = () => {
428
+ // A commit lands and leaves an untracked file: with no early check the dirty-tree diagnostic would come first.
429
+ repo.commit({ 'cli/src/late.ts': '1' }, 'fix(core): late');
430
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'late-untracked.txt'), 'x');
431
+ };
432
+ test('a commit landing between the pin and the derivation is head-moved, not a record of mixed commits', () => {
433
+ receiptForHead();
434
+ onGit('merge-base', 'after', land);
435
+ expectFailure(run('change-record'), 'head-moved');
436
+ expect(recordFiles()).toEqual([]);
437
+ });
438
+ test('the message says HEAD moved during derivation', () => {
439
+ receiptForHead();
440
+ onGit('merge-base', 'after', land);
441
+ expect(run('change-record').stderr).toContain('HEAD moved during derivation');
442
+ });
443
+ test('a commit landing after the derivation but before the write is head-moved', () => {
444
+ receiptForHead();
445
+ onGit('status', 'before', () => repo.commit({ 'cli/src/late.ts': '1' }, 'fix(core): late'));
446
+ expectFailure(run('change-record'), 'head-moved');
447
+ expect(recordFiles()).toEqual([]);
448
+ });
449
+ });
450
+ describe('awm change new: provenance (R3.8)', () => {
451
+ freshRepo();
452
+ const writeJournalWith = (patch) => {
453
+ (0, store_1.initJournal)(repo.root, 'feat/x');
454
+ const state = readStateOrThrow();
455
+ patch(state);
456
+ (0, store_1.writeJournal)(repo.root, 'feat/x', state);
457
+ };
458
+ function readStateOrThrow() {
459
+ const r = (0, store_1.readJournal)(repo.root, 'feat/x');
460
+ if (!r.state)
461
+ throw new Error('no journal');
462
+ return r.state;
463
+ }
464
+ test('omits the provenance key entirely when no source exists', () => {
465
+ receiptForHead();
466
+ run('change-record');
467
+ expect(frontmatter(read())).not.toHaveProperty('provenance');
468
+ });
469
+ test('model and session come only from flags', () => {
470
+ receiptForHead();
471
+ run('change-record', { model: 'm1', session: 'https://claude.ai/code/session_x' });
472
+ expect(frontmatter(read()).provenance).toEqual({ model: 'm1', session: 'https://claude.ai/code/session_x' });
473
+ });
474
+ test('agent and timestamps come from the branch journal', () => {
475
+ writeJournalWith((s) => { s.admissionContext = { provider: 'claude-code' }; s.cycle.completedAt = '2026-10-17T10:30:00.000Z'; s.cycle.startedAt = '2026-10-17T09:00:00.000Z'; });
476
+ receiptForHead();
477
+ run('change-record', { model: 'm1' });
478
+ expect(frontmatter(read()).provenance).toEqual({ agent: 'claude-code', started_at: '2026-10-17T09:00:00.000Z', finished_at: '2026-10-17T10:30:00.000Z', model: 'm1' });
479
+ });
480
+ test('a journal without admissionContext and completedAt yields only started_at', () => {
481
+ writeJournalWith((s) => { s.cycle.startedAt = '2026-10-17T09:00:00.000Z'; });
482
+ receiptForHead();
483
+ run('change-record');
484
+ expect(frontmatter(read()).provenance).toEqual({ started_at: '2026-10-17T09:00:00.000Z' });
485
+ });
486
+ test('a journal timestamp that is not canonical ISO is omitted, never copied', () => {
487
+ writeJournalWith((s) => { s.cycle.startedAt = 'yesterday'; s.admissionContext = { provider: 'codex' }; });
488
+ receiptForHead();
489
+ expect(run('change-record').code).toBe(0);
490
+ expect(frontmatter(read()).provenance).toEqual({ agent: 'codex' });
491
+ });
492
+ test('a corrupt journal is ignored: no journal keys and no error', () => {
493
+ (0, store_1.initJournal)(repo.root, 'feat/x');
494
+ const journalFile = path_1.default.join(repo.root, '.awm', 'journal', 'feat_2Fx', 'state.json');
495
+ fs_1.default.writeFileSync(journalFile, '{ not json');
496
+ receiptForHead();
497
+ expect(run('change-record', { model: 'm1' }).code).toBe(0);
498
+ expect(frontmatter(read()).provenance).toEqual({ model: 'm1' });
499
+ });
500
+ test('a journal of another branch is not used', () => {
501
+ (0, store_1.initJournal)(repo.root, 'other');
502
+ const other = (0, store_1.readJournal)(repo.root, 'other').state;
503
+ if (!other)
504
+ throw new Error('no journal');
505
+ (0, store_1.writeJournal)(repo.root, 'other', { ...other, admissionContext: { provider: 'codex' } });
506
+ receiptForHead();
507
+ run('change-record');
508
+ expect(frontmatter(read())).not.toHaveProperty('provenance');
509
+ });
510
+ test('the branch whose journal is read is named by the ledger helper the journal writers use (one helper, not a copy)', () => {
511
+ writeJournalWith((s) => { s.admissionContext = { provider: 'claude-code' }; });
512
+ repo.git('checkout', '-q', '-b', 'somewhere-else');
513
+ const named = jest.spyOn(ledgerStore, 'detectBranch').mockReturnValue('feat/x');
514
+ receiptForHead();
515
+ run('change-record');
516
+ expect(named).toHaveBeenCalledWith(repo.root);
517
+ expect(frontmatter(read()).provenance).toMatchObject({ agent: 'claude-code' });
518
+ });
519
+ test('a detached HEAD has no journal and is not an error', () => {
520
+ repo.git('checkout', '-q', '--detach');
521
+ receiptForHead();
522
+ expect(run('change-record').code).toBe(0);
523
+ expect(frontmatter(read())).not.toHaveProperty('provenance');
524
+ });
525
+ });
526
+ describe('awm change new: no overwrite (R3.10)', () => {
527
+ freshRepo();
528
+ test('refuses to overwrite on the same date and leaves the file untouched', () => {
529
+ receiptForHead();
530
+ run('change-record');
531
+ fs_1.default.writeFileSync(file(), 'edited by hand');
532
+ expectFailure(run('change-record'), 'record-exists', 'use --refresh to update derived fields');
533
+ expect(read()).toBe('edited by hand');
534
+ });
535
+ test('refuses the same slug on another date', () => {
536
+ receiptForHead();
537
+ run('change-record');
538
+ const before = read();
539
+ expectFailure(run('change-record', {}, { now: () => new Date('2026-10-20T12:00:00.000Z') }), 'record-exists', 'use --refresh to update derived fields');
540
+ expect(recordFiles()).toEqual(['2026-10-17-change-record.md']);
541
+ expect(read()).toBe(before);
542
+ });
543
+ test('a different slug is not blocked by an existing record', () => {
544
+ receiptForHead();
545
+ run('change-record');
546
+ expect(run('change-record-2').code).toBe(0);
547
+ expect(recordFiles()).toEqual(['2026-10-17-change-record-2.md', '2026-10-17-change-record.md']);
548
+ });
549
+ test('a slug that is only a prefix of an existing slug is not blocked', () => {
550
+ receiptForHead();
551
+ run('change-record-long');
552
+ expect(run('change-record').code).toBe(0);
553
+ });
554
+ (0, helpers_1.posixOnly)('a symlink named like the record counts as existing and its target is not written', () => {
555
+ receiptForHead();
556
+ fs_1.default.mkdirSync(recordsDir(), { recursive: true });
557
+ const outside = path_1.default.join(repo.home, 'outside.md');
558
+ fs_1.default.writeFileSync(outside, 'precious');
559
+ fs_1.default.symlinkSync(outside, file());
560
+ expectFailure(run('change-record'), 'record-exists', 'use --refresh to update derived fields');
561
+ expect(fs_1.default.readFileSync(outside, 'utf8')).toBe('precious');
562
+ });
563
+ test('a listing that misses an existing file still never overwrites it (exclusive create)', () => {
564
+ receiptForHead();
565
+ run('change-record');
566
+ fs_1.default.writeFileSync(file(), 'edited by hand');
567
+ interceptFs('readdirSync', (a) => isRecordsDirArg(a[0]), () => []);
568
+ expectFailure(run('change-record'), 'record-exists', 'use --refresh to update derived fields');
569
+ expect(read()).toBe('edited by hand');
570
+ });
571
+ test('an unreadable docs/changes listing fails closed instead of creating', () => {
572
+ receiptForHead();
573
+ fs_1.default.mkdirSync(recordsDir(), { recursive: true });
574
+ interceptFs('readdirSync', (a) => isRecordsDirArg(a[0]), () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); });
575
+ expectFailure(run('change-record'), 'io-failed');
576
+ jest.restoreAllMocks();
577
+ expect(fs_1.default.readdirSync(recordsDir())).toEqual([]);
578
+ });
579
+ test('a failed write leaves no partial file behind', () => {
580
+ receiptForHead();
581
+ // Only the write through the descriptor of the new record fails (the create itself succeeded).
582
+ interceptFs('writeFileSync', (a) => typeof a[0] === 'number', () => { throw Object.assign(new Error('full'), { code: 'ENOSPC' }); });
583
+ expectFailure(run('change-record'), 'io-failed');
584
+ expect(recordFiles()).toEqual([]);
585
+ });
586
+ });
587
+ describe('awm change new: slug validation (R3.12)', () => {
588
+ sharedRepo();
589
+ const BAD_SLUGS = ['../evil', 'a/b', 'a\\b', 'Upper', 'a_b', '', '-a', 'a-', 'a--b', 'a b', 'a\nb', 'a'.repeat(65), 'é', '.hidden'];
590
+ test.each(BAD_SLUGS)('rejects the slug %j before building any path', (slug) => {
591
+ expectFailure(run(slug), 'slug-invalid');
592
+ expect(fs_1.default.existsSync(recordsDir())).toBe(false);
593
+ });
594
+ test.each([['undefined', undefined], ['an object with toString', { toString: () => 'ok' }]])('a non-string slug (%s) is slug-invalid, not a throw', (_name, slug) => {
595
+ expectFailure((0, change_1.runChangeNew)(repo.root, slug, {}, { now }), 'slug-invalid');
596
+ });
597
+ test('a hostile slug never reaches the diagnostic raw', () => {
598
+ const r = run('a\u2028b\u001b[31m'.repeat(40));
599
+ expectFailure(r, 'slug-invalid');
600
+ expect(r.stderr).not.toContain('\u001b');
601
+ });
602
+ test('the whole first line, code included, is clipped to 300 units for a hostile slug', () => {
603
+ const r = run('\0'.repeat(80));
604
+ expectFailure(r, 'slug-invalid');
605
+ expect(r.stderr.split('\n')[0].length).toBe(300);
606
+ expect(r.stderr.split('\n')[0].endsWith('…')).toBe(true);
607
+ });
608
+ });
609
+ describe('awm change new: path safety (R3.12)', () => {
610
+ freshRepo();
611
+ test('accepts a slug of exactly 64 characters', () => {
612
+ receiptForHead();
613
+ expect(run('a'.repeat(64)).code).toBe(0);
614
+ });
615
+ (0, helpers_1.posixOnly)('refuses a symlinked docs/changes and writes nothing through it', () => {
616
+ receiptForHead();
617
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
618
+ fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs'), { recursive: true });
619
+ fs_1.default.symlinkSync(outside, recordsDir());
620
+ expectFailure(run('ok'), 'unsafe-path');
621
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
622
+ });
623
+ (0, helpers_1.posixOnly)('refuses a symlinked docs and creates nothing through it', () => {
624
+ receiptForHead();
625
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
626
+ fs_1.default.symlinkSync(outside, path_1.default.join(repo.root, 'docs'));
627
+ repo.git('add', '-A');
628
+ repo.git('commit', '-q', '-m', 'chore: link docs');
629
+ receiptForHead();
630
+ expectFailure(run('ok'), 'unsafe-path');
631
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
632
+ });
633
+ test('refuses when docs/changes is a regular file', () => {
634
+ receiptForHead();
635
+ fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs'), { recursive: true });
636
+ fs_1.default.writeFileSync(recordsDir(), 'x');
637
+ expectFailure(run('ok'), 'unsafe-path');
638
+ expect(fs_1.default.readFileSync(recordsDir(), 'utf8')).toBe('x');
639
+ });
640
+ (0, helpers_1.posixOnly)('refuses to refresh through a symlinked docs/changes', () => {
641
+ receiptForHead();
642
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
643
+ fs_1.default.writeFileSync(path_1.default.join(outside, '2026-10-17-ok.md'), 'precious');
644
+ fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs'), { recursive: true });
645
+ fs_1.default.symlinkSync(outside, recordsDir());
646
+ expectFailure(run('ok', { refresh: true }), 'unsafe-path');
647
+ expect(fs_1.default.readFileSync(path_1.default.join(outside, '2026-10-17-ok.md'), 'utf8')).toBe('precious');
648
+ });
649
+ (0, helpers_1.posixOnly)('a link planted in place of docs while it is being created is refused, and nothing is created through it', () => {
650
+ receiptForHead();
651
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
652
+ interceptFs('mkdirSync', (a) => baseIs(a[0], 'docs'), (_real, a) => { fs_1.default.symlinkSync(outside, String(a[0])); });
653
+ expectFailure(run('ok'), 'unsafe-path');
654
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
655
+ });
656
+ (0, helpers_1.posixOnly)('a link planted in place of changes while it is being created is refused, and nothing is created through it', () => {
657
+ receiptForHead();
658
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
659
+ interceptFs('mkdirSync', (a) => isRecordsDirArg(a[0]), (_real, a) => { fs_1.default.symlinkSync(outside, String(a[0])); });
660
+ expectFailure(run('ok'), 'unsafe-path');
661
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
662
+ });
663
+ test('creates docs/changes when it does not exist yet', () => {
664
+ receiptForHead();
665
+ expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
666
+ expect(run('ok').code).toBe(0);
667
+ expect(fs_1.default.statSync(recordsDir()).isDirectory()).toBe(true);
668
+ });
669
+ test('a failure before the write never creates docs/changes', () => {
670
+ expectFailure(run('ok'), 'receipt-missing', REMEDY);
671
+ expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
672
+ });
673
+ });
674
+ /** The command refuses `opts` as `flag-invalid`, names `what`, and writes nothing. */
675
+ const badFlag = (opts, what) => {
676
+ const r = run('ok', opts);
677
+ expectFailure(r, 'flag-invalid');
678
+ expect(r.stderr).toContain(what);
679
+ expect(recordFiles()).toEqual([]);
680
+ };
681
+ describe('awm change new: refused flags (R3.12, R3.11b)', () => {
682
+ sharedRepo();
683
+ const bad = badFlag;
684
+ test.each([
685
+ ['http is refused', 'http://example.com/1'],
686
+ ['a non-URL is refused', 'not a url'],
687
+ ['javascript: is refused', 'javascript:alert(1)'],
688
+ ['embedded credentials are refused', 'https://user@example.com/1'],
689
+ ['whitespace is refused', 'https://example.com/a b'],
690
+ ['a line break is refused', 'https://example.com/a\nb: c'],
691
+ ['a host-less https is refused', 'https://'],
692
+ ['a very long URL is refused', `https://example.com/${'a'.repeat(3000)}`],
693
+ ['a lone surrogate is refused', 'https://example.com/\ud800'],
694
+ ])('--issue: %s', (_name, issue) => bad({ issue }, '--issue'));
695
+ test.each([
696
+ ['a line break', 'a\nb'],
697
+ ['a control character', 'a\u0000b'],
698
+ ['U+2028', 'a\u2028b'],
699
+ ['an empty value', ''],
700
+ ['a blank value', ' '],
701
+ ['201 characters', 'm'.repeat(201)],
702
+ ['201 code points', '\u{1F600}'.repeat(201)],
703
+ ['a lone surrogate', 'a\ud800b'],
704
+ ])('--model: %s is refused', (_name, model) => bad({ model }, '--model'));
705
+ test.each([['a line break', 'a\r\nb'], ['201 characters', 's'.repeat(201)], ['a blank value', ' ']])('--session: %s is refused', (_name, session) => bad({ session }, '--session'));
706
+ test.each([
707
+ ['--model as a number', { model: 5 }, '--model'],
708
+ ['--plan as an array', { plan: ['a'] }, '--plan'],
709
+ ['--refresh as a string', { refresh: 'yes' }, '--refresh'],
710
+ ])('a flag that is not the right type is refused (%s)', (_name, opts, what) => bad(opts, what));
711
+ test.each([
712
+ ['a missing file', 'docs/plans/missing.md'],
713
+ ['.. segments', '../outside.md'],
714
+ ['an absolute path', path_1.default.join(path_1.default.sep, 'etc', 'passwd')],
715
+ ['a backslash path', 'docs\\plans\\p.md'],
716
+ ['a drive-letter path', 'C:/plan.md'],
717
+ ['a directory', 'cli'],
718
+ ])('--plan: %s is refused', (_name, plan) => bad({ plan }, '--plan'));
719
+ test('a hostile --plan value never reaches the diagnostic raw', () => {
720
+ const r = run('ok', { plan: 'a\u001b[31m\nb' });
721
+ expectFailure(r, 'flag-invalid');
722
+ expect(r.stderr).not.toContain('\u001b');
723
+ });
724
+ test('a non-object opts is flag-invalid, not a throw', () => {
725
+ expectFailure((0, change_1.runChangeNew)(repo.root, 'ok', 'x', { now }), 'flag-invalid');
726
+ });
727
+ test.each([
728
+ ['--plan', { plan: 'README.md' }],
729
+ ['--issue', { issue: 'https://example.com/1' }],
730
+ ['--model', { model: 'm1' }],
731
+ ['--session', { session: 's1' }],
732
+ ])('--refresh together with %s is flag-invalid and writes nothing', (flag, opts) => {
733
+ const r = run('ok', { ...opts, refresh: true });
734
+ expectFailure(r, 'flag-invalid');
735
+ expect(r.stderr).toContain('--refresh');
736
+ expect(r.stderr).toContain(flag);
737
+ expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
738
+ });
739
+ });
740
+ describe('awm change new: --plan through links', () => {
741
+ freshRepo();
742
+ beforeEach(() => receiptForHead());
743
+ (0, helpers_1.posixOnly)('--plan: a symlink is refused', () => {
744
+ fs_1.default.symlinkSync(path_1.default.join(repo.root, 'README.md'), path_1.default.join(repo.root, 'link.md'));
745
+ badFlag({ plan: 'link.md' }, '--plan');
746
+ });
747
+ (0, helpers_1.posixOnly)('--plan: a file reached through a symlinked directory outside the repository is refused', () => {
748
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'out-'));
749
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'p.md'), 'x');
750
+ fs_1.default.symlinkSync(outside, path_1.default.join(repo.root, 'plans'));
751
+ badFlag({ plan: 'plans/p.md' }, '--plan');
752
+ });
753
+ });
754
+ describe('awm change new: accepted flags', () => {
755
+ freshRepo();
756
+ beforeEach(() => receiptForHead());
757
+ test.each([
758
+ ['--issue https', { issue: 'https://example.com/1' }],
759
+ ['--model of 200 characters', { model: 'm'.repeat(200) }],
760
+ ['--model of 200 code points', { model: '\u{1F600}'.repeat(200) }],
761
+ ['--session', { session: 'abc' }],
762
+ ['--plan an existing repo file', { plan: 'README.md' }],
763
+ ])('%s is accepted', (_name, opts) => {
764
+ expect(run('ok', opts).code).toBe(0);
765
+ });
766
+ });
767
+ describe('awm change new: unsafe --base (R3.5)', () => {
768
+ sharedRepo();
769
+ test.each(['-x', '--upload-pack=evil', 'a..b', 'a b', 'a\nb', 'HEAD~1', 'a^', 'a:b', 'a@{1}', ''])('an unsafe --base %j is invalid-input', (base) => {
770
+ expectFailure(run('ok', { base }), 'invalid-input');
771
+ });
772
+ test('a hostile --base is echoed escaped, on one line of at most 300 units', () => {
773
+ const r = run('ok', { base: '\0\u001b[31m'.repeat(80) });
774
+ expectFailure(r, 'invalid-input');
775
+ expect(r.stderr).toContain('\\u0000');
776
+ });
777
+ });
778
+ describe('awm change new: base and derivation diagnostics (R3.1, R3.2, R3.5)', () => {
779
+ freshRepo();
780
+ test('an unknown --base is base-unresolved with its remedy', () => {
781
+ receiptForHead();
782
+ expectFailure(run('ok', { base: 'nope' }), 'base-unresolved', 'pass --base <ref>');
783
+ });
784
+ test('no main, master or origin/HEAD is base-unresolved', () => {
785
+ repo.git('branch', '-m', 'main', 'trunk');
786
+ receiptForHead();
787
+ expectFailure(run('ok'), 'base-unresolved', 'pass --base <ref>');
788
+ });
789
+ test('an explicit --base is used instead of main', () => {
790
+ repo.git('branch', 'trunk', 'main');
791
+ repo.commit({ 'cli/src/b.ts': '1' }, 'fix(core): b');
792
+ receiptForHead();
793
+ repo.git('branch', 'mid', 'HEAD~1');
794
+ run('change-record', { base: 'mid' });
795
+ expect(frontmatter(read())).toMatchObject({ type: 'fix', scope: 'core', semver: 'patch', modules: ['cli/src'] });
796
+ });
797
+ test('a branch with no commits beyond the base is no-commits', () => {
798
+ repo.git('checkout', '-q', 'main');
799
+ expectFailure(run('ok'), 'no-commits');
800
+ });
801
+ test('only non-conventional commits is no-conventional-commits', () => {
802
+ repo.git('checkout', '-q', '-b', 'feat/z', 'main');
803
+ repo.commit({ 'cli/src/z.ts': '1' }, 'update some things');
804
+ expectFailure(run('ok'), 'no-conventional-commits');
805
+ });
806
+ test('base and HEAD without a common ancestor is base-unresolved', () => {
807
+ repo.git('checkout', '-q', '--orphan', 'orphan');
808
+ repo.git('rm', '-rf', '-q', '.');
809
+ repo.commit({ 'o.ts': '1' }, 'feat: orphan');
810
+ receiptForHead();
811
+ expectFailure(run('ok'), 'base-unresolved', 'pass --base <ref>');
812
+ });
813
+ test('outside a git repository it fails with not-a-git-repo', () => {
814
+ expectFailure((0, change_1.runChangeNew)((0, helpers_1.nonRepoDir)(repo), 'ok', {}, { now }), 'not-a-git-repo');
815
+ });
816
+ test('a non-string cwd fails without throwing', () => {
817
+ expectFailure((0, change_1.runChangeNew)(undefined, 'ok', {}, { now }), 'invalid-input');
818
+ });
819
+ });
820
+ describe('awm change new --refresh (R3.11)', () => {
821
+ freshRepo();
822
+ function recordCommitted(mutate = (t) => t) {
823
+ receiptForHead();
824
+ run('change-record');
825
+ const edited = mutate(read().split(types_1.PENDING_MARKER).join('written by agent'));
826
+ fs_1.default.writeFileSync(file(), edited);
827
+ repo.git('add', '-A');
828
+ repo.git('commit', '-q', '-m', 'docs(changes): record');
829
+ repo.commit({ 'cli/src/core/b.ts': '1' }, 'fix(core): b');
830
+ receiptForHead();
831
+ return edited;
832
+ }
833
+ const strip = (s) => s.replace(/^(type|scope|semver|breaking|modules|verification):[\s\S]*?(?=^[a-z_]+:|^---$)/gm, '');
834
+ test('recomputes the derived fields and verification', () => {
835
+ recordCommitted();
836
+ const r = run('change-record', { refresh: true });
837
+ expect(r).toEqual({ code: 0, stdout: 'Refreshed docs/changes/2026-10-17-change-record.md\n', stderr: '' });
838
+ const fm = frontmatter(read());
839
+ expect(fm.verification.commit).toBe(repo.git('rev-parse', 'HEAD'));
840
+ expect(fm.modules).toEqual(['cli/src', 'cli/src/core']);
841
+ expect(fm).toMatchObject({ type: 'feat', scope: 'changes', semver: 'minor' });
842
+ });
843
+ test('every byte outside the derived keys and verification is unchanged', () => {
844
+ const edited = recordCommitted();
845
+ run('change-record', { refresh: true });
846
+ expect(strip(read())).toBe(strip(edited));
847
+ expect(read()).not.toBe(edited);
848
+ });
849
+ test('the refreshed record still validates', () => {
850
+ recordCommitted();
851
+ run('change-record', { refresh: true });
852
+ expect((0, validate_shape_1.validateRecordShape)('2026-10-17-change-record.md', read(), { root: repo.root }).diagnostics).toEqual([]);
853
+ });
854
+ test('refresh works on the record of another date', () => {
855
+ recordCommitted();
856
+ run('change-record', { refresh: true }, { now: () => new Date('2030-01-01T00:00:00.000Z') });
857
+ expect(frontmatter(read()).id).toBe('2026-10-17-change-record');
858
+ });
859
+ test('a CRLF record keeps CRLF everywhere and its other bytes', () => {
860
+ const edited = recordCommitted((t) => t.replace(/\n/g, '\r\n'));
861
+ run('change-record', { refresh: true });
862
+ const after = read();
863
+ expect(after).not.toMatch(/[^\r]\n/);
864
+ expect(strip(after.replace(/\r\n/g, '\n'))).toBe(strip(edited.replace(/\r\n/g, '\n')));
865
+ expect(frontmatter(after).verification.commit).toBe(repo.git('rev-parse', 'HEAD'));
866
+ });
867
+ test('refresh does not require a clean tree for docs/changes itself but still refuses other changes', () => {
868
+ recordCommitted();
869
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'dirty.ts'), 'x');
870
+ expectFailure(run('change-record', { refresh: true }), 'tree-dirty', REMEDY);
871
+ });
872
+ test('refresh needs a receipt for HEAD', () => {
873
+ recordCommitted();
874
+ fs_1.default.rmSync(path_1.default.join(repo.root, '.awm', 'sensors'), { recursive: true });
875
+ expectFailure(run('change-record', { refresh: true }), 'receipt-missing', REMEDY);
876
+ });
877
+ test('a missing record is record-missing and creates nothing', () => {
878
+ receiptForHead();
879
+ expectFailure(run('change-record', { refresh: true }), 'record-missing', 'run awm change new change-record to create it');
880
+ expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
881
+ });
882
+ test('an existing docs/changes without that slug is record-missing', () => {
883
+ receiptForHead();
884
+ run('other');
885
+ expectFailure(run('change-record', { refresh: true }), 'record-missing', 'run awm change new change-record to create it');
886
+ });
887
+ test('two records with the same slug are record-ambiguous and neither is modified', () => {
888
+ receiptForHead();
889
+ run('change-record');
890
+ run('change-record', {}, { now });
891
+ fs_1.default.copyFileSync(file(), path_1.default.join(recordsDir(), '2026-11-01-change-record.md'));
892
+ const before = recordFiles().map((f) => fs_1.default.readFileSync(path_1.default.join(recordsDir(), f), 'utf8'));
893
+ expectFailure(run('change-record', { refresh: true }), 'record-ambiguous');
894
+ expect(recordFiles().map((f) => fs_1.default.readFileSync(path_1.default.join(recordsDir(), f), 'utf8'))).toEqual(before);
895
+ });
896
+ test('a broken record is record-invalid and untouched', () => {
897
+ receiptForHead();
898
+ fs_1.default.mkdirSync(recordsDir(), { recursive: true });
899
+ fs_1.default.writeFileSync(file(), '---\n: : :\n---\n');
900
+ repo.git('add', '-A');
901
+ repo.git('commit', '-q', '-m', 'docs(changes): broken');
902
+ receiptForHead();
903
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
904
+ expect(read()).toBe('---\n: : :\n---\n');
905
+ });
906
+ test('a record missing a derived key is record-invalid and untouched', () => {
907
+ receiptForHead();
908
+ run('change-record');
909
+ const broken = read().replace(/^semver: .*\n/m, '');
910
+ fs_1.default.writeFileSync(file(), broken);
911
+ repo.git('add', '-A');
912
+ repo.git('commit', '-q', '-m', 'docs(changes): broken');
913
+ receiptForHead();
914
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
915
+ expect(read()).toBe(broken);
916
+ });
917
+ test('R3.4 a refresh that would push the record over 64 KiB is record-too-large with a remedy, and the record is untouched', () => {
918
+ receiptForHead();
919
+ run('change-record');
920
+ repo.git('add', '-A');
921
+ repo.git('commit', '-q', '-m', 'docs(changes): record');
922
+ const files = Object.fromEntries(Array.from({ length: 4000 }, (_, i) => [`vendor/package-${String(i).padStart(5, '0')}/index.js`, '1']));
923
+ (0, helpers_1.importCommits)(repo, [{ message: 'feat(vendor): import vendored packages', files, date: '2026-10-17T10:30:00Z' }]);
924
+ receiptForHead();
925
+ const before = read();
926
+ expectFailure(run('change-record', { refresh: true }), 'record-too-large', REMEDY_SPLIT);
927
+ expect(read()).toBe(before);
928
+ });
929
+ test('a record already over 64 KiB on disk is record-invalid and untouched (it cannot be read for a rewrite)', () => {
930
+ receiptForHead();
931
+ run('change-record');
932
+ fs_1.default.writeFileSync(file(), `${read()}\n${'x'.repeat(70 * 1024)}\n`);
933
+ repo.git('add', '-A');
934
+ repo.git('commit', '-q', '-m', 'docs(changes): huge');
935
+ receiptForHead();
936
+ const before = read();
937
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
938
+ expect(read()).toBe(before);
939
+ });
940
+ test('a record that is not valid UTF-8 is record-invalid and untouched', () => {
941
+ receiptForHead();
942
+ run('change-record');
943
+ const bytes = Buffer.concat([fs_1.default.readFileSync(file()), Buffer.from([0xff, 0xfe, 0x0a])]);
944
+ fs_1.default.writeFileSync(file(), bytes);
945
+ repo.git('add', '-A');
946
+ repo.git('commit', '-q', '-m', 'docs(changes): bytes');
947
+ receiptForHead();
948
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
949
+ expect(fs_1.default.readFileSync(file()).equals(bytes)).toBe(true);
950
+ });
951
+ test('a record with a UTF-8 BOM is record-invalid and its bytes are untouched', () => {
952
+ receiptForHead();
953
+ run('change-record');
954
+ const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), fs_1.default.readFileSync(file())]);
955
+ fs_1.default.writeFileSync(file(), bytes);
956
+ repo.git('add', '-A');
957
+ repo.git('commit', '-q', '-m', 'docs(changes): bom');
958
+ receiptForHead();
959
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
960
+ expect(fs_1.default.readFileSync(file()).equals(bytes)).toBe(true);
961
+ });
962
+ test('a record without a BOM but with non-ASCII text still refreshes and keeps those bytes', () => {
963
+ receiptForHead();
964
+ run('change-record');
965
+ fs_1.default.writeFileSync(file(), fs_1.default.readFileSync(file(), 'utf8').split(types_1.PENDING_MARKER).join('Qué se probó ñandú \u{1F600}'));
966
+ repo.git('add', '-A');
967
+ repo.git('commit', '-q', '-m', 'docs(changes): text');
968
+ repo.commit({ 'cli/src/core/b.ts': '1' }, 'fix(core): b');
969
+ receiptForHead();
970
+ expect(run('change-record', { refresh: true }).code).toBe(0);
971
+ expect(read()).toContain('Qué se probó ñandú \u{1F600}');
972
+ expect(read().charCodeAt(0)).toBe('-'.charCodeAt(0));
973
+ });
974
+ test('a record larger than the record limit is record-invalid', () => {
975
+ receiptForHead();
976
+ run('change-record');
977
+ fs_1.default.appendFileSync(file(), 'x'.repeat(70 * 1024));
978
+ repo.git('add', '-A');
979
+ repo.git('commit', '-q', '-m', 'docs(changes): big');
980
+ receiptForHead();
981
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
982
+ });
983
+ test('a record whose derived keys are quoted is refreshed and still validates', () => {
984
+ receiptForHead();
985
+ run('change-record');
986
+ const quoted = read().replace(/^(type|semver) *:/gm, '"$1" :');
987
+ fs_1.default.writeFileSync(file(), quoted.split(types_1.PENDING_MARKER).join('written by agent'));
988
+ repo.git('add', '-A');
989
+ repo.git('commit', '-q', '-m', 'docs(changes): quoted');
990
+ repo.commit({ 'cli/src/core/b.ts': '1' }, 'fix(core): b');
991
+ receiptForHead();
992
+ expect(run('change-record', { refresh: true }).code).toBe(0);
993
+ expect(read()).not.toContain('"semver"');
994
+ expect((0, validate_shape_1.validateRecordShape)('2026-10-17-change-record.md', read(), { root: repo.root }).diagnostics).toEqual([]);
995
+ });
996
+ (0, helpers_1.posixOnly)('a record that is a symlink is refused and its target untouched', () => {
997
+ receiptForHead();
998
+ fs_1.default.mkdirSync(recordsDir(), { recursive: true });
999
+ const outside = path_1.default.join(repo.home, 'real.md');
1000
+ fs_1.default.writeFileSync(outside, 'precious');
1001
+ fs_1.default.symlinkSync(outside, file());
1002
+ expectFailure(run('change-record', { refresh: true }), 'unsafe-path');
1003
+ expect(fs_1.default.readFileSync(outside, 'utf8')).toBe('precious');
1004
+ });
1005
+ test('a record that is a directory is record-invalid', () => {
1006
+ receiptForHead();
1007
+ fs_1.default.mkdirSync(file(), { recursive: true });
1008
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
1009
+ });
1010
+ test('a failed write keeps the old content and leaves no temporary file', () => {
1011
+ const edited = recordCommitted();
1012
+ interceptFs('renameSync', (a) => baseIs(a[1], path_1.default.basename(file())), () => { throw Object.assign(new Error('nope'), { code: 'EPERM' }); });
1013
+ expectFailure(run('change-record', { refresh: true }), 'io-failed');
1014
+ jest.restoreAllMocks();
1015
+ expect(read()).toBe(edited);
1016
+ expect(recordFiles()).toEqual(['2026-10-17-change-record.md']);
1017
+ });
1018
+ test('refresh accepts --base', () => {
1019
+ recordCommitted();
1020
+ expect(run('change-record', { refresh: true, base: 'main' }).code).toBe(0);
1021
+ });
1022
+ test('a derived key spelled with a YAML escape is record-invalid and the bytes are untouched', () => {
1023
+ receiptForHead();
1024
+ run('change-record');
1025
+ const bytes = Buffer.from(read().replace(/^type: .*$/m, '"t\\x79pe": fix'));
1026
+ expect(bytes.toString('utf8')).toContain('"t\\x79pe": fix');
1027
+ fs_1.default.writeFileSync(file(), bytes);
1028
+ repo.git('add', '-A');
1029
+ repo.git('commit', '-q', '-m', 'docs(changes): escaped key');
1030
+ receiptForHead();
1031
+ expectFailure(run('change-record', { refresh: true }), 'record-invalid');
1032
+ expect(fs_1.default.readFileSync(file()).equals(bytes)).toBe(true);
1033
+ });
1034
+ describe('a record that changes between being read and being written', () => {
1035
+ /** Lets the real splice run, then edits the record as a concurrent editor would. */
1036
+ function editDuringRefresh(edit) {
1037
+ const real = renderModule.refreshDerived;
1038
+ jest.spyOn(renderModule, 'refreshDerived').mockImplementation((...args) => { const out = real(...args); edit(); return out; });
1039
+ }
1040
+ const later = new Date(Date.now() + 60_000);
1041
+ test.each([
1042
+ ['it grows', () => fs_1.default.appendFileSync(file(), 'edited meanwhile\n')],
1043
+ ['it keeps its size but is rewritten later', () => { const t = fs_1.default.readFileSync(file(), 'utf8'); fs_1.default.writeFileSync(file(), t.replace('written by agent', 'written by human')); fs_1.default.utimesSync(file(), later, later); }],
1044
+ ['only its modification time moves', () => fs_1.default.utimesSync(file(), later, later)],
1045
+ ])('%s: record-changed, and the editor\'s version is kept', (_name, edit) => {
1046
+ recordCommitted();
1047
+ editDuringRefresh(edit);
1048
+ expectFailure(run('change-record', { refresh: true }), 'record-changed');
1049
+ jest.restoreAllMocks();
1050
+ // The editor's version stands: the record still has the derived values of the commit it was written for.
1051
+ expect(frontmatter(read()).modules).toEqual(['cli/src']);
1052
+ });
1053
+ (0, helpers_1.posixOnly)('it is replaced by a symlink: record-changed, and the link target is untouched', () => {
1054
+ recordCommitted();
1055
+ const outside = path_1.default.join(repo.home, 'real.md');
1056
+ fs_1.default.writeFileSync(outside, 'precious');
1057
+ editDuringRefresh(() => { fs_1.default.rmSync(file()); fs_1.default.symlinkSync(outside, file()); });
1058
+ expectFailure(run('change-record', { refresh: true }), 'record-changed');
1059
+ jest.restoreAllMocks();
1060
+ expect(fs_1.default.readFileSync(outside, 'utf8')).toBe('precious');
1061
+ expect(fs_1.default.lstatSync(file()).isSymbolicLink()).toBe(true);
1062
+ });
1063
+ test('an untouched record is refreshed (the check is not always failing)', () => {
1064
+ recordCommitted();
1065
+ editDuringRefresh(() => undefined);
1066
+ expect(run('change-record', { refresh: true }).code).toBe(0);
1067
+ });
1068
+ });
1069
+ (0, helpers_1.posixOnly)('refresh keeps the file mode', () => {
1070
+ recordCommitted();
1071
+ fs_1.default.chmodSync(file(), 0o640);
1072
+ run('change-record', { refresh: true });
1073
+ expect(fs_1.default.statSync(file()).mode & 0o777).toBe(0o640);
1074
+ });
1075
+ });
1076
+ describe('registerChangeCommand', () => {
1077
+ freshRepo();
1078
+ function program() {
1079
+ const p = new commander_1.Command();
1080
+ p.exitOverride();
1081
+ (0, change_1.registerChangeCommand)(p, { now });
1082
+ return p;
1083
+ }
1084
+ let savedExit;
1085
+ let out;
1086
+ let err;
1087
+ beforeEach(() => {
1088
+ savedExit = process.exitCode;
1089
+ out = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
1090
+ err = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
1091
+ // The action reads process.cwd(); pointing it at the repository needs no chdir (a process cannot delete its own cwd on Windows).
1092
+ jest.spyOn(process, 'cwd').mockImplementation(() => repo.root);
1093
+ });
1094
+ afterEach(() => { process.exitCode = savedExit; });
1095
+ test('registers change new with its documented flags', () => {
1096
+ const change = program().commands.find((c) => c.name() === 'change');
1097
+ const sub = change?.commands.find((c) => c.name() === 'new');
1098
+ expect(sub?.options.map((o) => o.long).sort()).toEqual(['--base', '--issue', '--model', '--plan', '--refresh', '--session']);
1099
+ });
1100
+ test('the change command describes both of its subcommands', () => {
1101
+ expect(program().commands.find((c) => c.name() === 'change')?.description()).toBe('create and validate the Change Record of the current branch');
1102
+ });
1103
+ test('runs from the cwd, prints the created path and leaves the exit code alone', async () => {
1104
+ receiptForHead();
1105
+ await program().parseAsync(['node', 'awm', 'change', 'new', 'change-record', '--model', 'm1']);
1106
+ expect(out).toHaveBeenCalledWith('Created docs/changes/2026-10-17-change-record.md\n');
1107
+ expect(process.exitCode).toBe(savedExit);
1108
+ expect(frontmatter(read()).provenance.model).toBe('m1');
1109
+ });
1110
+ test('a failure writes the diagnostic to stderr and sets exit code 2', async () => {
1111
+ await program().parseAsync(['node', 'awm', 'change', 'new', 'change-record']);
1112
+ expect(err).toHaveBeenCalledWith(expect.stringContaining('receipt-missing: '));
1113
+ expect(process.exitCode).toBe(2);
1114
+ });
1115
+ test('--refresh reaches the command', async () => {
1116
+ receiptForHead();
1117
+ await program().parseAsync(['node', 'awm', 'change', 'new', 'change-record', '--refresh']);
1118
+ expect(err).toHaveBeenCalledWith(expect.stringContaining('record-missing: '));
1119
+ });
1120
+ test('a cwd that is not accessible is reported, not thrown', async () => {
1121
+ jest.spyOn(process, 'cwd').mockImplementation(() => { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); });
1122
+ await program().parseAsync(['node', 'awm', 'change', 'new', 'x']);
1123
+ expect(process.exitCode).toBe(2);
1124
+ expect(err).toHaveBeenCalledWith(expect.stringMatching(/^[a-z-]+: /));
1125
+ });
1126
+ });