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,492 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runChangeNew = runChangeNew;
7
+ exports.runChangeValidate = runChangeValidate;
8
+ exports.registerChangeCommand = registerChangeCommand;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const command_1 = require("../../core/changes/command");
12
+ const store_1 = require("../../core/journal/store");
13
+ const store_2 = require("../../core/ledger/store");
14
+ const parse_1 = require("../../core/changes/parse");
15
+ const git_1 = require("../../core/changes/git");
16
+ const range_1 = require("../../core/changes/range");
17
+ const derive_1 = require("../../core/changes/derive");
18
+ const trailers_1 = require("../../core/changes/trailers");
19
+ const receipt_1 = require("../../core/changes/receipt");
20
+ const render_1 = require("../../core/changes/render");
21
+ const fs_safe_1 = require("../../core/changes/fs-safe");
22
+ const record_io_1 = require("../../core/changes/record-io");
23
+ const text_safety_1 = require("../../core/changes/text-safety");
24
+ const types_1 = require("../../core/changes/types");
25
+ const validate_shape_1 = require("../../core/changes/validate-shape");
26
+ const REMEDY_RECEIPT = 'commit your work, then run awm sensors run';
27
+ const REMEDY_SPLIT = 'split the change into smaller ones';
28
+ const REMEDY_SENSORS = 'reduce the sensors listed in .awm/sensors.json (the sensor receipt makes the record too large)';
29
+ /** R3.4: the remedy that names the cause of an oversized record: the sensor receipt when it outweighs the module list, else the change itself. */
30
+ function tooLargeRemedy(fields, verification) {
31
+ const bytes = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
32
+ return bytes(verification.sensors) > bytes(fields.modules) ? REMEDY_SENSORS : REMEDY_SPLIT;
33
+ }
34
+ const MAX_PROVENANCE_VALUE = 200;
35
+ const MAX_ISSUE_URL = 2048;
36
+ const CREATION_ONLY_FLAGS = ['plan', 'issue', 'model', 'session'];
37
+ // ---- Flags (R3.11b, R3.12) ----
38
+ /** Provenance text: one line, not blank, at most 200 code points, writable as UTF-8. */
39
+ function isProvenanceValue(v) {
40
+ return (0, text_safety_1.isSingleLine)(v) && v.trim() !== '' && [...v].length <= MAX_PROVENANCE_VALUE && (0, text_safety_1.isWellFormed)(v);
41
+ }
42
+ /** A static description of the first bad flag (never the value), or null. */
43
+ function flagProblem(opts, root) {
44
+ for (const key of ['base', 'plan', 'issue', 'model', 'session']) {
45
+ if (opts[key] !== undefined && typeof opts[key] !== 'string')
46
+ return `--${key} must be a string`;
47
+ }
48
+ if (opts.refresh !== undefined && typeof opts.refresh !== 'boolean')
49
+ return '--refresh must be a boolean flag';
50
+ if (opts.refresh === true) {
51
+ const clash = CREATION_ONLY_FLAGS.find((key) => opts[key] !== undefined);
52
+ if (clash)
53
+ return `--refresh cannot be combined with --${clash}: it only applies when a record is created`;
54
+ }
55
+ for (const key of ['model', 'session']) {
56
+ const v = opts[key];
57
+ if (v !== undefined && !isProvenanceValue(v))
58
+ return `--${key} must be one non-blank line of at most ${MAX_PROVENANCE_VALUE} characters`;
59
+ }
60
+ if (opts.issue !== undefined && !((0, text_safety_1.isWellFormed)(opts.issue) && (0, text_safety_1.isHttpsUrl)(opts.issue, { allowCredentials: false, maxLength: MAX_ISSUE_URL })))
61
+ return '--issue must be an https URL without credentials';
62
+ if (opts.plan !== undefined && !(0, fs_safe_1.isRegularFileInside)(root, opts.plan))
63
+ return '--plan must be an existing regular file inside the repository';
64
+ return null;
65
+ }
66
+ // ---- Provenance (R3.8) ----
67
+ /** Only keys with a source: the journal (when present and readable) and the flags. Never throws; a bad journal contributes nothing. */
68
+ function provenance(root, opts) {
69
+ const out = {};
70
+ try {
71
+ const state = (0, store_1.readJournal)(root, (0, store_2.detectBranch)(root)).state;
72
+ const agent = state?.admissionContext?.provider;
73
+ if (typeof agent === 'string' && isProvenanceValue(agent))
74
+ out.agent = agent;
75
+ if ((0, text_safety_1.isCanonicalIso)(state?.cycle?.startedAt))
76
+ out.started_at = state.cycle.startedAt;
77
+ if ((0, text_safety_1.isCanonicalIso)(state?.cycle?.completedAt))
78
+ out.finished_at = state.cycle.completedAt;
79
+ }
80
+ catch {
81
+ // A damaged or unreadable journal is not an error of this command.
82
+ }
83
+ if (opts.model !== undefined)
84
+ out.model = opts.model;
85
+ if (opts.session !== undefined)
86
+ out.session = opts.session;
87
+ return out;
88
+ }
89
+ // ---- awm change new ----
90
+ function utcDate(deps) {
91
+ try {
92
+ const moment = (deps.now ?? (() => new Date()))();
93
+ if (!(moment instanceof Date) || Number.isNaN(moment.getTime()))
94
+ return null;
95
+ const iso = moment.toISOString();
96
+ return /^\d{4}-\d{2}-\d{2}T/.test(iso) ? iso.slice(0, 10) : null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ function changeNew(cwd, slug, rawOpts, deps) {
103
+ if (typeof cwd !== 'string' || cwd.length === 0)
104
+ return (0, command_1.fail)('invalid-input', 'the working directory must be a non-empty string');
105
+ if (!(0, parse_1.isValidSlug)(slug))
106
+ return (0, command_1.fail)('slug-invalid', `slug "${(0, text_safety_1.esc)(slug)}" must match [a-z0-9]+(-[a-z0-9]+)* and be at most 64 characters`);
107
+ if (rawOpts !== undefined && !(0, text_safety_1.isObj)(rawOpts))
108
+ return (0, command_1.fail)('flag-invalid', 'options must be an object');
109
+ const opts = (rawOpts ?? {});
110
+ const root = (0, git_1.repoRoot)(cwd);
111
+ if (root === null)
112
+ return (0, command_1.fail)('not-a-git-repo', 'awm change must run inside a git repository');
113
+ const flag = flagProblem(opts, root);
114
+ if (flag)
115
+ return (0, command_1.fail)('flag-invalid', flag);
116
+ // Pin HEAD before anything is derived: the fields, the dirty check and the receipt all describe this one commit.
117
+ const range = (0, range_1.resolveRange)(root, opts.base);
118
+ if (!range.ok)
119
+ return (0, command_1.failed)(range.failure);
120
+ const { head } = range;
121
+ const derived = (0, derive_1.deriveFields)(root, range.mergeBase);
122
+ if (!derived.ok)
123
+ return (0, command_1.fail)(derived.diagnostic.code, derived.diagnostic.message);
124
+ const movedAfterDerive = (0, range_1.headMovedFailure)(root, head);
125
+ if (movedAfterDerive)
126
+ return (0, command_1.failed)(movedAfterDerive);
127
+ // R3.6: the tree equals HEAD (apart from docs/changes and the runtime state) and a receipt vouches for HEAD.
128
+ if ((0, git_1.isDirtyOutside)(root, [types_1.RECORD_DIR]))
129
+ return (0, command_1.fail)('tree-dirty', `working tree has uncommitted changes outside ${types_1.RECORD_DIR}/`, REMEDY_RECEIPT);
130
+ const receipt = (0, receipt_1.readReceipt)(root, head);
131
+ if (!receipt.ok)
132
+ return (0, command_1.fail)(receipt.code, `no valid sensor receipt for HEAD ${head.slice(0, 12)}`, REMEDY_RECEIPT);
133
+ const verification = { commit: receipt.receipt.commit, overall: receipt.receipt.overall, sensors: receipt.receipt.sensors, receipt_digest: receipt.receipt.digest };
134
+ let dir = (0, record_io_1.inspectRecordDir)(root);
135
+ const dirProblem = (0, range_1.recordDirProblem)(dir, 'write');
136
+ if (dirProblem)
137
+ return (0, command_1.failed)(dirProblem);
138
+ const existing = dir.kind === 'ok' ? (0, record_io_1.recordsWithSlug)(dir.dir, slug) : [];
139
+ if (existing === null)
140
+ return (0, command_1.fail)('io-failed', `could not list ${types_1.RECORD_DIR}`);
141
+ if (opts.refresh === true)
142
+ return refresh({ dir, existing, slug, fields: derived.fields, verification, head, root });
143
+ if (existing.length > 0)
144
+ return (0, command_1.fail)('record-exists', `a record for slug "${slug}" already exists (${existing[0]})`, 'use --refresh to update derived fields');
145
+ const date = utcDate(deps);
146
+ const id = date === null ? null : `${date}-${slug}`;
147
+ if (id === null || !(0, parse_1.isValidRecordFileName)(`${id}.md`))
148
+ return (0, command_1.fail)('date-invalid', 'the clock did not yield a valid calendar date');
149
+ const links = {};
150
+ if (opts.plan !== undefined)
151
+ links.plan = opts.plan;
152
+ if (opts.issue !== undefined)
153
+ links.issue = opts.issue;
154
+ const content = (0, render_1.renderSkeleton)({ id, fields: derived.fields, verification, links, provenance: provenance(root, opts) });
155
+ if (content === null)
156
+ return (0, command_1.fail)('skeleton-invalid', 'the record skeleton could not be rendered');
157
+ // R3.4: a change touching thousands of directories (or a huge receipt) outgrows the record bound: say so, instead of a validation code.
158
+ if (Buffer.byteLength(content, 'utf8') > types_1.MAX_RECORD_BYTES)
159
+ return (0, command_1.fail)('record-too-large', `the generated record would exceed ${types_1.MAX_RECORD_BYTES} bytes`, tooLargeRemedy(derived.fields, verification));
160
+ // Backstop: the only thing wrong with a skeleton may be its pending markers.
161
+ const problem = (0, validate_shape_1.validateRecordShape)(`${id}.md`, content, { root }).diagnostics.find((d) => d.code !== 'pending-content');
162
+ if (problem)
163
+ return (0, command_1.fail)('skeleton-invalid', `the generated record failed validation: ${(0, text_safety_1.esc)(problem.code)}${problem.field ? ` (${(0, text_safety_1.esc)(problem.field)})` : ''}`);
164
+ const movedBeforeWrite = (0, range_1.headMovedFailure)(root, head);
165
+ if (movedBeforeWrite)
166
+ return (0, command_1.failed)(movedBeforeWrite);
167
+ if (dir.kind === 'absent')
168
+ dir = (0, record_io_1.createRecordDir)(root);
169
+ if (dir.kind !== 'ok')
170
+ return (0, command_1.failed)((0, range_1.recordDirProblem)(dir, 'create'));
171
+ const written = (0, record_io_1.createExclusive)(path_1.default.join(dir.dir, `${id}.md`), content);
172
+ if (written === 'exists')
173
+ return (0, command_1.fail)('record-exists', `${id}.md already exists`, 'use --refresh to update derived fields');
174
+ if (written === 'error')
175
+ return (0, command_1.fail)('io-failed', `could not write ${types_1.RECORD_DIR}/${id}.md`);
176
+ return { code: 0, stdout: `Created ${types_1.RECORD_DIR}/${id}.md\n`, stderr: '' };
177
+ }
178
+ /** R3.11: rewrites only the derived fields and verification of the one existing record. */
179
+ function refresh({ dir, existing, slug, fields, verification, head, root }) {
180
+ if (dir.kind !== 'ok' || existing.length === 0)
181
+ return (0, command_1.fail)('record-missing', `no record found for slug "${slug}"`, `run awm change new ${slug} to create it`);
182
+ if (existing.length > 1)
183
+ return (0, command_1.fail)('record-ambiguous', `${existing.length} records exist for slug "${slug}"; keep one`);
184
+ const name = existing[0];
185
+ const target = path_1.default.join(dir.dir, name);
186
+ const current = (0, record_io_1.readRecord)(target, name);
187
+ if (!current.ok)
188
+ return (0, command_1.fail)(current.code, current.message);
189
+ const next = (0, render_1.refreshDerived)(current.text, fields, verification);
190
+ if (next !== null && Buffer.byteLength(next, 'utf8') > types_1.MAX_RECORD_BYTES)
191
+ return (0, command_1.fail)('record-too-large', `${(0, text_safety_1.esc)(name)} would exceed ${types_1.MAX_RECORD_BYTES} bytes once refreshed`, tooLargeRemedy(fields, verification));
192
+ if (next === null)
193
+ return (0, command_1.fail)('record-invalid', `${(0, text_safety_1.esc)(name)} has unparseable frontmatter, misses a derived key, or cannot be refreshed safely`);
194
+ const moved = (0, range_1.headMovedFailure)(root, head);
195
+ if (moved)
196
+ return (0, command_1.failed)(moved);
197
+ // Someone else may have edited (or replaced) the record since it was read: their version wins, nothing is written.
198
+ if (!(0, record_io_1.unchangedSince)(target, current.snapshot))
199
+ return (0, command_1.fail)('record-changed', `${(0, text_safety_1.esc)(name)} changed while it was being refreshed; run the command again`);
200
+ const written = (0, record_io_1.replaceRecord)(target, next);
201
+ if (!written.ok)
202
+ return (0, command_1.fail)('io-failed', `could not write ${(0, text_safety_1.esc)(name)} (${(0, text_safety_1.esc)(written.code)})`);
203
+ return { code: 0, stdout: `Refreshed ${types_1.RECORD_DIR}/${name}\n`, stderr: '' };
204
+ }
205
+ /** Never throws: any unexpected failure becomes a diagnostic. */
206
+ function runChangeNew(cwd, slug, opts, deps = {}) {
207
+ try {
208
+ return changeNew(cwd, slug, opts, (0, text_safety_1.isObj)(deps) ? deps : {});
209
+ }
210
+ catch (error) {
211
+ return (0, command_1.fail)('internal-error', `unexpected failure (${(0, text_safety_1.esc)(error instanceof Error ? error.name : 'unknown')})`);
212
+ }
213
+ }
214
+ /** R4.5: diagnostics per record. When more were found the last slot becomes a `diagnostics-truncated` marker. */
215
+ const MAX_RECORD_DIAGNOSTICS = 20;
216
+ /** Records validated per run; further ones are not read and make the run invalid (`too-many-records`). */
217
+ const MAX_RECORDS = 50;
218
+ /** Paths listed in a `code-changed-after-verification` message (the message itself is clipped to one line of 300 units). */
219
+ const MAX_LATE_PATHS = 20;
220
+ /** Each half of a `derived-mismatch` message gets this many UTF-16 units, so the recorded and the derived value are both always visible. */
221
+ const MAX_SHOWN_VALUE = 120;
222
+ const safeDiagnostic = (d) => {
223
+ const base = { code: (0, command_1.line)(d.code), message: (0, command_1.line)(d.message) };
224
+ return d.field === undefined ? base : { ...base, field: (0, command_1.line)(d.field) };
225
+ };
226
+ /**
227
+ * One record's report: every text field made single-line and bounded, at most 20 diagnostics. `dropped` counts the diagnostics
228
+ * the shape validator's own cap already removed, so the truncation marker counts every omitted diagnostic.
229
+ */
230
+ function finish(recordPath, list, dropped = 0) {
231
+ const all = list.map(safeDiagnostic);
232
+ const total = all.length + dropped;
233
+ const kept = total > MAX_RECORD_DIAGNOSTICS
234
+ ? [...all.slice(0, MAX_RECORD_DIAGNOSTICS - 1), { code: 'diagnostics-truncated', message: `${total - (MAX_RECORD_DIAGNOSTICS - 1)} more diagnostics omitted` }]
235
+ : all;
236
+ return { path: (0, command_1.line)(recordPath), state: kept.length === 0 ? 'valid' : 'invalid', diagnostics: kept };
237
+ }
238
+ function formatText(state, records) {
239
+ const lines = [`Change Record validation: ${state}${state === 'exempt' ? ' (Change-Record: none)' : ''}`];
240
+ for (const r of records) {
241
+ lines.push((0, command_1.line)(`- ${r.path}: ${r.state}`));
242
+ for (const d of r.diagnostics)
243
+ lines.push((0, command_1.line)(` - ${d.code}${d.field === undefined ? '' : ` (${d.field})`}: ${d.message}`));
244
+ }
245
+ return `${lines.join('\n')}\n`;
246
+ }
247
+ /** R4.5: the one place a verdict becomes output: a text report or one JSON line, exit 0 unless invalid. */
248
+ function render(json, state, records) {
249
+ return { code: state === 'invalid' ? 2 : 0, stdout: json ? `${JSON.stringify({ state, records })}\n` : formatText(state, records), stderr: '' };
250
+ }
251
+ /** R4.5: an operational failure. With --json it is the one-line report shape (state invalid, record `docs/changes`), else the stderr form of `change new`. */
252
+ function failure(json, code, message, remedy) {
253
+ if (!json)
254
+ return (0, command_1.fail)(code, message, remedy);
255
+ return render(true, 'invalid', [finish(types_1.RECORD_DIR, [{ code, message: remedy ? `${message}; ${remedy}` : message }])]);
256
+ }
257
+ const deriveOnce = (run) => (run.derived ??= (0, derive_1.deriveFields)(run.root, run.mergeBaseSha));
258
+ function lateFiles(run, commit) {
259
+ if (!run.late.has(commit)) {
260
+ const files = (0, git_1.changedFiles)(run.root, commit, run.head)?.filter((f) => !f.startsWith(`${types_1.RECORD_DIR}/`));
261
+ run.late.set(commit, files === undefined ? null : { count: files.length, first: files.slice(0, MAX_LATE_PATHS) });
262
+ }
263
+ return run.late.get(commit) ?? null;
264
+ }
265
+ /** A git failure counts as "not an ancestor": the caller reports `verification-commit-foreign` (fail closed). */
266
+ function isAncestorOfHead(run, commit) {
267
+ if (!run.ancestors.has(commit))
268
+ run.ancestors.set(commit, (0, git_1.isAncestor)(run.root, commit, run.head));
269
+ return run.ancestors.get(commit) === true;
270
+ }
271
+ /** The same sensors with the same statuses, compared as name-sorted pairs (never by JavaScript object key order). */
272
+ const sameSensors = (a, b) => JSON.stringify((0, receipt_1.sensorPairs)(a)) === JSON.stringify((0, receipt_1.sensorPairs)(b));
273
+ /** A derived value for a message: strings escaped inside quotes (esc), lists bracketed, everything else as JSON; at most MAX_SHOWN_VALUE units. */
274
+ function show(value) {
275
+ const text = showAll(value);
276
+ return text.length > MAX_SHOWN_VALUE ? `${(0, text_safety_1.sliceNoSplit)(text, MAX_SHOWN_VALUE - 1)}…` : text;
277
+ }
278
+ function showAll(value) {
279
+ if (typeof value === 'string')
280
+ return `"${(0, text_safety_1.esc)(value)}"`;
281
+ if (Array.isArray(value))
282
+ return `[${value.map(showAll).join(', ')}]`;
283
+ return JSON.stringify(value) ?? 'undefined';
284
+ }
285
+ /** R4.3: the recomputed derived fields equal the record's, one diagnostic per differing field. */
286
+ function checkDerived(run, record, out) {
287
+ const derived = deriveOnce(run);
288
+ if (!derived.ok) {
289
+ out.push(derived.diagnostic);
290
+ return;
291
+ }
292
+ for (const key of types_1.DERIVED_KEYS) {
293
+ if (JSON.stringify(record[key]) !== JSON.stringify(derived.fields[key])) {
294
+ out.push({ code: 'derived-mismatch', field: key, message: `${key} is ${show(record[key])} but git derives ${show(derived.fields[key])}` });
295
+ }
296
+ }
297
+ }
298
+ /** R4.3: the verified commit is HEAD or an ancestor of it, and nothing outside docs/changes/ changed since. */
299
+ function checkVerifiedCommit(run, commit, out) {
300
+ if (!isAncestorOfHead(run, commit)) {
301
+ out.push({ code: 'verification-commit-foreign', field: 'verification.commit', message: `${commit.slice(0, 12)} is not HEAD or an ancestor of HEAD` });
302
+ return;
303
+ }
304
+ const late = lateFiles(run, commit);
305
+ if (late === null) {
306
+ out.push({ code: 'git-failed', message: `git could not list the files changed since ${commit.slice(0, 12)}` });
307
+ return;
308
+ }
309
+ if (late.count === 0)
310
+ return;
311
+ out.push({ code: 'code-changed-after-verification', message: `${late.count} file${late.count === 1 ? '' : 's'} changed after ${commit.slice(0, 12)}: ${late.first.map(text_safety_1.esc).join(', ')}${late.count > MAX_LATE_PATHS ? ', …' : ''}` });
312
+ }
313
+ /** R4.4: the receipt of the verified commit exists, matches the record's verification block, and says pass. */
314
+ function checkReceipt(run, record, out) {
315
+ const { verification } = record;
316
+ const receipt = (0, receipt_1.readReceipt)(run.root, verification.commit);
317
+ if (!receipt.ok) {
318
+ out.push({ code: receipt.code, field: 'verification.commit', message: `no valid sensor receipt for ${verification.commit.slice(0, 12)}` });
319
+ return;
320
+ }
321
+ const r = receipt.receipt;
322
+ if (r.digest !== verification.receipt_digest)
323
+ out.push({ code: 'verification-mismatch', field: 'verification.receipt_digest', message: 'receipt_digest differs from the sensor receipt' });
324
+ if (r.overall !== verification.overall)
325
+ out.push({ code: 'verification-mismatch', field: 'verification.overall', message: `the record says ${(0, text_safety_1.esc)(verification.overall)} but the sensor receipt says ${(0, text_safety_1.esc)(r.overall)}` });
326
+ if (!sameSensors(r.sensors, verification.sensors))
327
+ out.push({ code: 'verification-mismatch', field: 'verification.sensors', message: 'the sensors differ from the sensor receipt' });
328
+ if (r.overall !== 'pass')
329
+ out.push({ code: 'verification-not-passing', field: 'verification.overall', message: `the sensor verdict is ${(0, text_safety_1.esc)(r.overall)}, not pass` });
330
+ }
331
+ /** R4.3: without a path the verdict describes the committed record. Git unable to tell fails closed. */
332
+ function checkCommitted(root, rel, out) {
333
+ const clean = (0, git_1.isCommittedClean)(root, rel);
334
+ if (clean === true)
335
+ return;
336
+ out.push({ code: 'record-uncommitted', message: clean === false ? `${(0, text_safety_1.esc)(rel)} has uncommitted changes; commit it and validate again` : `could not tell whether ${(0, text_safety_1.esc)(rel)} has uncommitted changes` });
337
+ }
338
+ /** The records are read from the working tree, through the symlink-safe, size-bounded readers. Never throws. */
339
+ function validateOne(run, rel, requireCommitted) {
340
+ const name = path_1.default.posix.basename(rel);
341
+ /** A record that cannot even be read: one diagnostic. */
342
+ const single = (code, message) => finish(rel, [{ code, message }]);
343
+ const { dir } = run;
344
+ if (dir.kind !== 'ok') {
345
+ const dirProblem = (0, range_1.recordDirProblem)(dir, 'read');
346
+ return dirProblem ? single(dirProblem.code, dirProblem.message) : single('record-missing', `${types_1.RECORD_DIR}/ does not exist`);
347
+ }
348
+ if (!(0, fs_safe_1.isRegularFileInside)(run.root, rel))
349
+ return single('record-missing', `${(0, text_safety_1.esc)(rel)} is not a regular file in the working tree`);
350
+ const read = (0, record_io_1.readRecord)(path_1.default.join(dir.dir, name), name);
351
+ if (!read.ok)
352
+ return single(read.tooLarge === true ? 'record-too-large' : read.code, read.message);
353
+ const diagnostics = [];
354
+ if (requireCommitted)
355
+ checkCommitted(run.root, rel, diagnostics);
356
+ const shape = (0, validate_shape_1.validateRecordShape)(name, read.text, { root: run.root });
357
+ // The shape validator's own cap ends its list with a marker; finish() writes one marker for the whole record instead.
358
+ const dropped = shape.omitted ?? 0;
359
+ diagnostics.push(...(dropped > 0 ? shape.diagnostics.slice(0, -1) : shape.diagnostics));
360
+ const record = shape.record;
361
+ if (record) {
362
+ checkDerived(run, record, diagnostics);
363
+ checkVerifiedCommit(run, record.verification.commit, diagnostics);
364
+ if (run.strict)
365
+ checkReceipt(run, record, diagnostics);
366
+ }
367
+ return finish(rel, diagnostics, dropped);
368
+ }
369
+ /** The repo-relative, `/`-separated path `target` (read from `cwd`) names, or null when it lies outside the repository. */
370
+ function repoRelative(root, cwd, target) {
371
+ if (target === '' || target.includes('\0'))
372
+ return null;
373
+ const real = (p) => { try {
374
+ return fs_1.default.realpathSync.native(p);
375
+ }
376
+ catch {
377
+ return p;
378
+ } };
379
+ for (const from of new Set([cwd, real(cwd)])) {
380
+ const resolved = path_1.default.resolve(from, target);
381
+ for (const full of new Set([resolved, real(resolved)])) {
382
+ for (const base of new Set([root, real(root)])) {
383
+ const rel = path_1.default.relative(base, full);
384
+ if (rel !== '' && rel !== '..' && !rel.startsWith(`..${path_1.default.sep}`) && !path_1.default.isAbsolute(rel))
385
+ return rel.split(path_1.default.sep).join('/');
386
+ }
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+ /** True only for a real `--json` flag; anything else (including a malformed options value) keeps the stderr form. */
392
+ function wantsJson(rawOpts) {
393
+ try {
394
+ return (0, text_safety_1.isObj)(rawOpts) && rawOpts.json === true;
395
+ }
396
+ catch {
397
+ return false;
398
+ }
399
+ }
400
+ function validateOpts(rawOpts) {
401
+ if (rawOpts !== undefined && !(0, text_safety_1.isObj)(rawOpts))
402
+ return 'options must be an object';
403
+ const opts = (rawOpts ?? {});
404
+ if (opts.base !== undefined && typeof opts.base !== 'string')
405
+ return '--base must be a string';
406
+ for (const key of ['strict', 'json'])
407
+ if (opts[key] !== undefined && typeof opts[key] !== 'boolean')
408
+ return `--${key} must be a boolean flag`;
409
+ return opts;
410
+ }
411
+ const verdict = (records) => ({ state: records.every((r) => r.state === 'valid') ? 'valid' : 'invalid', records });
412
+ /** The records added in the range, validated; the opt-out trailer exempts a branch that adds none. Null when git cannot list them. */
413
+ function branchVerdict(run) {
414
+ const added = (0, git_1.addedFilesUnder)(run.root, run.mergeBaseSha, run.head, types_1.RECORD_DIR);
415
+ if (added === null)
416
+ return null;
417
+ const names = added.filter(parse_1.isRecordPath);
418
+ if (names.length === 0) {
419
+ if ((0, trailers_1.hasOptOut)(run.root, [`${run.mergeBaseSha}..${run.head}`]))
420
+ return { state: 'exempt', records: [] };
421
+ return verdict([finish(types_1.RECORD_DIR, [{ code: 'record-missing', message: 'this branch adds no Change Record and no commit carries Change-Record: none' }])]);
422
+ }
423
+ const records = names.slice(0, MAX_RECORDS).map((name) => validateOne(run, name, true));
424
+ if (names.length > MAX_RECORDS)
425
+ records.push(finish(types_1.RECORD_DIR, [{ code: 'too-many-records', message: `${names.length} records were added; only the first ${MAX_RECORDS} were validated` }]));
426
+ return verdict(records);
427
+ }
428
+ function changeValidate(cwd, target, rawOpts) {
429
+ const json = wantsJson(rawOpts);
430
+ const bail = (code, message, remedy) => failure(json, code, message, remedy);
431
+ if (typeof cwd !== 'string' || cwd.length === 0)
432
+ return bail('invalid-input', 'the working directory must be a non-empty string');
433
+ if (target !== undefined && typeof target !== 'string')
434
+ return bail('path-invalid', 'the path must be a string');
435
+ const opts = validateOpts(rawOpts);
436
+ if (typeof opts === 'string')
437
+ return bail('flag-invalid', opts);
438
+ const root = (0, git_1.repoRoot)(cwd);
439
+ if (root === null)
440
+ return bail('not-a-git-repo', 'awm change must run inside a git repository');
441
+ let rel = null;
442
+ if (target !== undefined) {
443
+ rel = repoRelative(root, cwd, target);
444
+ if (rel === null || !(0, parse_1.isRecordPath)(rel))
445
+ return bail('path-invalid', `"${(0, text_safety_1.esc)(target)}" must name a file ${types_1.RECORD_DIR}/YYYY-MM-DD-<slug>.md of this repository`);
446
+ }
447
+ // Pin HEAD: the range, the ancestry and the late-file checks all describe this one commit.
448
+ const range = (0, range_1.resolveRange)(root, opts.base);
449
+ if (!range.ok)
450
+ return bail(range.failure.code, range.failure.message, range.failure.remedy);
451
+ const run = { root, head: range.head, mergeBaseSha: range.mergeBase, strict: opts.strict === true, dir: (0, record_io_1.inspectRecordDir)(root), ancestors: new Map(), late: new Map() };
452
+ const outcome = rel !== null ? verdict([validateOne(run, rel, false)]) : branchVerdict(run);
453
+ if (outcome === null)
454
+ return bail('git-failed', `git could not list the records added since ${range.mergeBase.slice(0, 12)}`);
455
+ const moved = (0, range_1.headMovedFailure)(root, range.head);
456
+ if (moved)
457
+ return bail(moved.code, moved.message);
458
+ return render(json, outcome.state, outcome.records);
459
+ }
460
+ /** Never throws: any unexpected failure becomes a diagnostic. Read-only (R4.6): nothing here writes a file. */
461
+ function runChangeValidate(cwd, target, opts) {
462
+ try {
463
+ return changeValidate(cwd, target, opts);
464
+ }
465
+ catch (error) {
466
+ return failure(wantsJson(opts), 'internal-error', `unexpected failure (${(0, text_safety_1.esc)(error instanceof Error ? error.name : 'unknown')})`);
467
+ }
468
+ }
469
+ /** The action wrapper: the working directory may be gone, and nothing may throw out of the action. */
470
+ function inCwd(command, json = false) {
471
+ (0, command_1.inCwd)(command, () => failure(json, 'not-a-git-repo', 'the working directory is not accessible'));
472
+ }
473
+ function registerChangeCommand(program, deps = {}) {
474
+ const change = program.command('change').description('create and validate the Change Record of the current branch');
475
+ change
476
+ .command('new <slug>')
477
+ .description('create docs/changes/YYYY-MM-DD-<slug>.md with derived fields and pending narrative')
478
+ .option('--base <ref>', 'base branch (default: origin/HEAD, main, master)')
479
+ .option('--plan <path>', 'repo-relative plan file to link')
480
+ .option('--issue <url>', 'https issue URL to link')
481
+ .option('--model <model>', 'provenance: model identifier')
482
+ .option('--session <session>', 'provenance: session id or URL (avoid internal URLs in public repos)')
483
+ .option('--refresh', 'recompute only derived fields and verification of the existing record')
484
+ .action((slug, opts) => inCwd((cwd) => runChangeNew(cwd, slug, opts, deps)));
485
+ change
486
+ .command('validate [path]')
487
+ .description('validate the Change Record(s) added by this branch, or one record (--strict also checks the sensor receipt)')
488
+ .option('--base <ref>', 'base branch (default: origin/HEAD, main, master)')
489
+ .option('--strict', 'require a matching, passing sensor receipt for verification.commit')
490
+ .option('--json', 'print a single JSON line')
491
+ .action((target, opts) => inCwd((cwd) => runChangeValidate(cwd, target, opts), wantsJson(opts)));
492
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runChangesList = runChangesList;
7
+ exports.registerChangesCommand = registerChangesCommand;
8
+ const path_1 = __importDefault(require("path"));
9
+ const command_1 = require("../../core/changes/command");
10
+ const git_1 = require("../../core/changes/git");
11
+ const list_1 = require("../../core/changes/list");
12
+ const list_parse_1 = require("../../core/changes/list-parse");
13
+ const render_list_1 = require("../../core/changes/render-list");
14
+ const text_safety_1 = require("../../core/changes/text-safety");
15
+ const FORMATS = ['text', 'json', 'voice'];
16
+ const DEFAULT_SINCE = '24h';
17
+ /** A static description of the first flag of the wrong type, or null. */
18
+ function flagProblem(opts) {
19
+ for (const key of ['since', 'base', 'format'])
20
+ if (opts[key] !== undefined && typeof opts[key] !== 'string')
21
+ return `--${key} must be a string`;
22
+ return null;
23
+ }
24
+ /** The clock's instant, or null when the clock throws or does not yield a valid date. */
25
+ function clock(deps) {
26
+ try {
27
+ const moment = (deps.now ?? (() => new Date()))();
28
+ return moment instanceof Date && !Number.isNaN(moment.getTime()) ? moment : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function changesList(cwd, rawOpts, rawDeps) {
35
+ if (typeof cwd !== 'string' || cwd.length === 0)
36
+ return (0, command_1.fail)('invalid-input', 'the working directory must be a non-empty string');
37
+ if (rawOpts !== undefined && !(0, text_safety_1.isObj)(rawOpts))
38
+ return (0, command_1.fail)('flag-invalid', 'options must be an object');
39
+ if (rawDeps !== undefined && !(0, text_safety_1.isObj)(rawDeps))
40
+ return (0, command_1.fail)('invalid-input', 'dependencies must be an object');
41
+ const opts = (rawOpts ?? {});
42
+ const deps = (rawDeps ?? {});
43
+ const flag = flagProblem(opts);
44
+ if (flag)
45
+ return (0, command_1.fail)('flag-invalid', flag);
46
+ const format = opts.format ?? 'text';
47
+ if (!FORMATS.includes(format))
48
+ return (0, command_1.fail)('format-invalid', `--format "${(0, text_safety_1.esc)(format)}" must be text, json or voice`);
49
+ const moment = clock(deps);
50
+ if (moment === null)
51
+ return (0, command_1.fail)('invalid-input', 'the clock did not yield a valid date');
52
+ const window = opts.since ?? DEFAULT_SINCE;
53
+ const since = (0, list_parse_1.parseSince)(window, moment);
54
+ if (since === null)
55
+ return (0, command_1.fail)('since-invalid', `--since "${(0, text_safety_1.esc)(window)}" must be <n>h, <n>d or YYYY-MM-DD`);
56
+ const root = (0, git_1.repoRoot)(cwd);
57
+ if (root === null)
58
+ return (0, command_1.fail)('not-a-git-repo', 'awm changes must run inside a git repository');
59
+ const outcome = (0, list_1.listChanges)(root, { base: opts.base, since, limit: deps.limit });
60
+ if (!outcome.ok)
61
+ return (0, command_1.failed)(outcome.failure);
62
+ const fallback = path_1.default.basename(root);
63
+ const stdout = format === 'json' ? (0, render_list_1.renderJson)(outcome.result) : format === 'voice' ? (0, render_list_1.renderVoice)(outcome.result, fallback) : (0, render_list_1.renderText)(outcome.result, fallback);
64
+ return { code: 0, stdout, stderr: '' };
65
+ }
66
+ /** `awm changes list`: read-only; never throws (any unexpected failure becomes an `internal-error` on stderr). */
67
+ function runChangesList(cwd, opts, deps = {}) {
68
+ try {
69
+ return changesList(cwd, opts, deps);
70
+ }
71
+ catch (error) {
72
+ return (0, command_1.fail)('internal-error', `unexpected failure (${(0, text_safety_1.esc)(error instanceof Error ? error.name : 'unknown')})`);
73
+ }
74
+ }
75
+ function registerChangesCommand(program, deps = {}) {
76
+ const changes = program.command('changes').description('query the Change Records merged into the base branch');
77
+ changes
78
+ .command('list')
79
+ .description('list the changes of the base branch with their Change Record and derived merge/release state')
80
+ .option('--since <window>', '<n>h, <n>d or YYYY-MM-DD (default 24h)')
81
+ .option('--base <ref>', 'base branch (default: origin/HEAD, main, master)')
82
+ .option('--format <format>', 'text | json | voice (default text)')
83
+ .action((opts) => (0, command_1.inCwd)((cwd) => runChangesList(cwd, opts, deps), () => (0, command_1.fail)('not-a-git-repo', 'the working directory is not accessible')));
84
+ }
@@ -283,7 +283,7 @@ function registerPlanCommand(program, deps = { validatePlanFile: validate_1.vali
283
283
  .requiredOption('--cwd <path>', 'repository root for plan containment and source resolution')
284
284
  .option('--execution-mode <mode>', 'explicit override: interactivo or desatendido')
285
285
  .option('--require-current', 'require authoritative consumed-contract currentness')
286
- .option('--verify-sensors', 'require an empirical sensor pass')
286
+ .option('--verify-sensors', 'require an empirical sensor pass; read-only observation skips git-ignored build output (see .awm/admission.json)')
287
287
  .option('--runtime-kind <kind>', 'routing runtime kind for compact v2')
288
288
  .option('--runtime-version <version>', 'routing runtime version for compact v2')
289
289
  .option('--account-scope-digest <sha>', 'routing account scope digest for compact v2')
@@ -17,6 +17,7 @@ const registries_1 = require("../../core/registries");
17
17
  const verdict_1 = require("./verdict");
18
18
  const bootstrap_1 = require("./bootstrap");
19
19
  const unresolved_1 = require("./unresolved");
20
+ const receipt_1 = require("../../core/changes/receipt");
20
21
  /** Commander coercion for coverage recurrence emphasis. It deliberately runs
21
22
  * before the action, so an invalid value cannot trigger ledger I/O. */
22
23
  function parsePositiveSafeInteger(value) {
@@ -53,6 +54,8 @@ function registerSensorsCommand(program) {
53
54
  .option('--changed', 'scope sensors that support it to the files changed vs --base')
54
55
  .option('--base <ref>', 'comparison point for --changed (default: HEAD, i.e. uncommitted work)')
55
56
  .action(async (opts) => {
57
+ // R2.2b: the receipt belongs to the manifest that runs (the one `runSensors` resolves from this cwd), not to the cwd.
58
+ const pre = (0, receipt_1.isFullSelection)(opts) ? (0, receipt_1.captureReceiptPrecondition)((0, run_1.findManifestDir)(process.cwd()) ?? process.cwd()) : null;
56
59
  const output = await (0, run_1.runSensors)({
57
60
  fast: opts.fast, slow: opts.slow, all: opts.all,
58
61
  changed: opts.changed, base: opts.base,
@@ -60,6 +63,12 @@ function registerSensorsCommand(program) {
60
63
  // Emit the verdict ALWAYS — an empty `sensors` with overall:'not_certified'
61
64
  // must be visible, never a silent exit-0 that reads as "clean".
62
65
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
66
+ // R2: the receipt is a side effect; it never changes stdout or the exit code.
67
+ if (pre) {
68
+ const note = (0, receipt_1.finalizeRunReceipt)(pre, output);
69
+ if (note)
70
+ process.stderr.write(note);
71
+ }
63
72
  process.exitCode = (0, verdict_1.exitCodeForVerdict)(output.overall);
64
73
  });
65
74
  sensors