mason-context 0.20.2 → 0.22.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.
@@ -1,40 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  if (!import.meta.url.endsWith("/mason-runtime.js")) await import("./mason-runtime.js").then(m => m.registerRuntime());
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __esm = (fn, res) => function __init() {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ };
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
3
12
 
4
- // src/hook/hook.ts
5
- import fs6 from "fs/promises";
6
- import path10 from "path";
7
- import os2 from "os";
8
-
9
- // src/decisions/decisions.ts
10
- import fs4 from "fs/promises";
11
- import path7 from "path";
12
- import { createHash } from "crypto";
13
- import { z as z3 } from "zod";
13
+ // src/audit/types.ts
14
+ var types_exports = {};
15
+ __export(types_exports, {
16
+ ALL_CHECKS: () => ALL_CHECKS
17
+ });
18
+ var ALL_CHECKS;
19
+ var init_types = __esm({
20
+ "src/audit/types.ts"() {
21
+ "use strict";
22
+ ALL_CHECKS = [
23
+ "deleted-reference",
24
+ "new-module",
25
+ "stale-count",
26
+ "dead-command",
27
+ "deps-changed",
28
+ "decision-anchor-drift"
29
+ ];
30
+ }
31
+ });
14
32
 
15
- // src/utils/storage.ts
16
- import fs2 from "fs/promises";
17
- import path3 from "path";
18
- import { randomUUID } from "crypto";
33
+ // src/hook/hook.ts
34
+ import { z as z6 } from "zod";
19
35
 
20
- // src/utils/paths.ts
21
- import path from "path";
22
- function normalizeRepoPath(value) {
23
- const slash = value.replace(/\\/g, "/");
24
- if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
25
- if (slash.split("/").includes("..")) return null;
26
- const normalized = path.posix.normalize(slash).replace(/\/$/, "");
27
- return normalized === "." ? null : normalized;
28
- }
29
- function anchorMatches(anchor, file) {
30
- const a = normalizeRepoPath(anchor);
31
- const f = normalizeRepoPath(file);
32
- return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
36
+ // src/utils/validation.ts
37
+ function isRecord(value) {
38
+ return value !== null && typeof value === "object" && !Array.isArray(value);
33
39
  }
34
- function matchingPaths(anchors, files) {
35
- return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
40
+ function isStringArray(value) {
41
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
36
42
  }
37
43
 
44
+ // src/hook/hook.ts
45
+ import fs7 from "fs/promises";
46
+ import path9 from "path";
47
+ import os2 from "os";
48
+
49
+ // src/audit/inspection.ts
50
+ import fg2 from "fast-glob";
51
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
52
+ import path3 from "path";
53
+
38
54
  // src/utils/git-read.ts
39
55
  import { execFile } from "child_process";
40
56
  import { promisify } from "util";
@@ -57,7 +73,19 @@ async function profilePhase(label, run) {
57
73
 
58
74
  // src/utils/git-read.ts
59
75
  var execute = promisify(execFile);
60
- var commands = /* @__PURE__ */ new Set(["rev-parse", "symbolic-ref", "for-each-ref", "ls-files", "check-ignore", "status", "log", "diff", "ls-tree", "show", "merge-base"]);
76
+ var commands = /* @__PURE__ */ new Set([
77
+ "rev-parse",
78
+ "symbolic-ref",
79
+ "for-each-ref",
80
+ "ls-files",
81
+ "check-ignore",
82
+ "status",
83
+ "log",
84
+ "diff",
85
+ "ls-tree",
86
+ "show",
87
+ "merge-base"
88
+ ]);
61
89
  function execGit(args, options) {
62
90
  const command = args.find((arg) => !arg.startsWith("-")) ?? "other";
63
91
  return profilePhase("git." + (commands.has(command) ? command : "other"), async () => {
@@ -77,11 +105,127 @@ import fs from "fs/promises";
77
105
  import { constants } from "fs";
78
106
  import path2 from "path";
79
107
  import fg from "fast-glob";
80
- var SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs", "vue", "svelte", "kt", "kts", "java", "py", "go", "rs", "swift", "rb", "cs", "cpp", "c", "h", "hpp", "dart", "php"];
108
+
109
+ // src/utils/paths.ts
110
+ import path from "path";
111
+ function normalizeRepoPath(value) {
112
+ const slash = value.replace(/\\/g, "/");
113
+ if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash))
114
+ return null;
115
+ if (slash.split("/").includes("..")) return null;
116
+ const normalized = path.posix.normalize(slash).replace(/\/$/, "");
117
+ return normalized === "." ? null : normalized;
118
+ }
119
+ function isWithinRoot(root, candidate) {
120
+ const relative = path.relative(root, candidate);
121
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
122
+ }
123
+ function anchorMatches(anchor, file) {
124
+ const a = normalizeRepoPath(anchor);
125
+ const f = normalizeRepoPath(file);
126
+ return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
127
+ }
128
+ function matchingPaths(anchors, files) {
129
+ return [...new Set(files)].filter(
130
+ (file) => anchors.some((anchor) => anchorMatches(anchor, file))
131
+ );
132
+ }
133
+ function indexMatchingPaths(scopes, files) {
134
+ const node = () => ({ children: /* @__PURE__ */ new Map(), scopes: /* @__PURE__ */ new Set() });
135
+ const root = node();
136
+ const matches = scopes.map(() => []);
137
+ scopes.forEach((anchors, index) => {
138
+ for (const anchor of anchors) {
139
+ const normalized = normalizeRepoPath(anchor);
140
+ if (!normalized) continue;
141
+ let current = root;
142
+ for (const part of normalized.split("/")) {
143
+ if (!current.children.has(part)) current.children.set(part, node());
144
+ current = current.children.get(part);
145
+ }
146
+ current.scopes.add(index);
147
+ }
148
+ });
149
+ for (const file of new Set(files)) {
150
+ const normalized = normalizeRepoPath(file);
151
+ if (!normalized) continue;
152
+ const selected = /* @__PURE__ */ new Set();
153
+ let current = root;
154
+ for (const part of normalized.split("/")) {
155
+ current = current.children.get(part);
156
+ if (!current) break;
157
+ for (const index of current.scopes) selected.add(index);
158
+ }
159
+ for (const index of selected) matches[index].push(file);
160
+ }
161
+ return matches;
162
+ }
163
+
164
+ // src/utils/files.ts
165
+ var SOURCE_EXTENSIONS = [
166
+ "ts",
167
+ "tsx",
168
+ "js",
169
+ "jsx",
170
+ "mts",
171
+ "cts",
172
+ "mjs",
173
+ "cjs",
174
+ "vue",
175
+ "svelte",
176
+ "kt",
177
+ "kts",
178
+ "java",
179
+ "py",
180
+ "go",
181
+ "rs",
182
+ "swift",
183
+ "rb",
184
+ "cs",
185
+ "cpp",
186
+ "c",
187
+ "h",
188
+ "hpp",
189
+ "dart",
190
+ "php"
191
+ ];
81
192
  var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
193
+ var SOURCE_IGNORE = [
194
+ "**/node_modules/**",
195
+ "**/dist/**",
196
+ "**/build/**",
197
+ "**/.gradle/**",
198
+ "**/target/**",
199
+ "**/.git/**",
200
+ "**/.mason/**",
201
+ "**/vendor/**",
202
+ "**/__pycache__/**",
203
+ "**/venv/**",
204
+ "**/.venv/**",
205
+ "**/*.min.*",
206
+ "**/*.map",
207
+ "**/*.lock",
208
+ "**/generated/**",
209
+ "**/*.generated.*",
210
+ "**/R.java",
211
+ "**/BuildConfig.java",
212
+ "**/package-lock.json",
213
+ "**/yarn.lock",
214
+ "**/pnpm-lock.yaml"
215
+ ];
82
216
  var MAX_SOURCE_BYTES = 1024 * 1024;
217
+ function isSensitiveFile(file) {
218
+ return file.split(/[\\/]/).some(
219
+ (part) => /^(?:\.env(?:\..*)?|id_rsa.*|id_ed25519.*)$|\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\.|secret|^local\.properties$/i.test(
220
+ part
221
+ )
222
+ );
223
+ }
83
224
  async function readBoundedFile(file, maxBytes) {
84
- const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
225
+ const handle = await fs.open(
226
+ file,
227
+ constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW
228
+ );
85
229
  try {
86
230
  const stat = await handle.stat();
87
231
  if (!stat.isFile() || stat.size > maxBytes) return null;
@@ -97,15 +241,168 @@ async function readBoundedFile(file, maxBytes) {
97
241
  await handle.close();
98
242
  }
99
243
  }
244
+ async function loadProjectConfig(root) {
245
+ try {
246
+ const canonicalRoot = await fs.realpath(root);
247
+ const configPath = await fs.realpath(path2.join(root, ".mason/config.json"));
248
+ if (!isWithinRoot(canonicalRoot, configPath))
249
+ throw new Error("Project configuration resolves outside the repository");
250
+ const raw = await readBoundedFile(configPath, 64 * 1024);
251
+ if (raw === null)
252
+ throw new Error("Project configuration is not a regular file or exceeds 64 KiB");
253
+ const value = JSON.parse(raw);
254
+ if (!isRecord(value)) throw new Error("Expected a configuration object");
255
+ const config = {};
256
+ for (const key of ["patterns", "alwaysInclude", "ignore"]) {
257
+ if (value[key] === void 0) continue;
258
+ if (!isStringArray(value[key])) {
259
+ throw new Error(`Configuration ${key} must be an array of strings`);
260
+ }
261
+ config[key] = value[key];
262
+ }
263
+ if (value.audit !== void 0) {
264
+ const audit = value.audit;
265
+ if (!isRecord(audit)) throw new Error("Configuration audit must be an object");
266
+ const patterns = (raw2) => {
267
+ if (!isStringArray(raw2) || raw2.length > 100 || !raw2.every(
268
+ (s) => typeof s === "string" && normalizeRepoPath(s) === s && !s.startsWith("!") && !/[\x00-\x1f]/.test(s)
269
+ )) {
270
+ throw new Error(
271
+ "Audit patterns must be at most 100 repository-relative globs without traversal or negation"
272
+ );
273
+ }
274
+ return raw2;
275
+ };
276
+ config.audit = {};
277
+ if (audit.include !== void 0) config.audit.include = patterns(audit.include);
278
+ if (audit.exclude !== void 0) {
279
+ if (!isRecord(audit.exclude))
280
+ throw new Error("audit.exclude must map check names to patterns");
281
+ const { ALL_CHECKS: ALL_CHECKS2 } = await Promise.resolve().then(() => (init_types(), types_exports));
282
+ config.audit.exclude = {};
283
+ for (const [check, globs] of Object.entries(audit.exclude)) {
284
+ if (!ALL_CHECKS2.includes(check))
285
+ throw new Error("Unknown audit check: " + check);
286
+ config.audit.exclude[check] = patterns(globs);
287
+ }
288
+ }
289
+ }
290
+ return config;
291
+ } catch (error) {
292
+ if (error.code === "ENOENT") return {};
293
+ throw new Error(
294
+ `Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`,
295
+ { cause: error }
296
+ );
297
+ }
298
+ }
299
+ async function createFileAccess(rootDir) {
300
+ const root = path2.resolve(rootDir);
301
+ const canonicalRoot = await fs.realpath(root).catch(() => root);
302
+ const config = await loadProjectConfig(root);
303
+ const ignore = [...SOURCE_IGNORE, ...config.ignore ?? []];
304
+ let gitFiles = null;
305
+ try {
306
+ const { stdout } = await execGit(
307
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
308
+ { cwd: root, maxBuffer: 50 * 1024 * 1024 }
309
+ );
310
+ gitFiles = new Set(stdout.split("\0").filter(Boolean));
311
+ } catch {
312
+ let inGit = false;
313
+ try {
314
+ await execGit(["rev-parse", "--git-dir"], { cwd: root });
315
+ inGit = true;
316
+ } catch {
317
+ }
318
+ if (inGit) throw new Error("Cannot enumerate Git files safely");
319
+ }
320
+ async function resolve(file) {
321
+ const relative = normalizeRepoPath(file);
322
+ if (!relative || isSensitiveFile(relative) || gitFiles && !gitFiles.has(relative))
323
+ return null;
324
+ const candidate = path2.join(root, relative);
325
+ try {
326
+ const real = await fs.realpath(candidate);
327
+ if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path2.relative(canonicalRoot, real)))
328
+ return null;
329
+ const stat = await fs.stat(real);
330
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
331
+ if (gitFiles && !gitFiles.has(path2.relative(canonicalRoot, real).split(path2.sep).join("/")))
332
+ return null;
333
+ return real;
334
+ } catch {
335
+ return null;
336
+ }
337
+ }
338
+ async function list(patterns = SOURCE_GLOB, options = {}) {
339
+ const found = await fg(patterns, { cwd: root, ignore, followSymbolicLinks: false, ...options });
340
+ const safe = await Promise.all(found.map(async (f) => await resolve(f) ? f : null));
341
+ return safe.filter((f) => f !== null).sort();
342
+ }
343
+ async function read(file) {
344
+ const relative = normalizeRepoPath(file);
345
+ if (!relative) return null;
346
+ const real = await resolve(relative);
347
+ if (!real) return null;
348
+ for (const rel of /* @__PURE__ */ new Set([
349
+ relative,
350
+ path2.relative(canonicalRoot, real).split(path2.sep).join("/")
351
+ ])) {
352
+ if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;
353
+ }
354
+ try {
355
+ const content = await readBoundedFile(real, MAX_SOURCE_BYTES);
356
+ return content === null ? null : { path: relative, content, totalLines: content.split("\n").length };
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+ return { root, config, list, read, inventory: () => gitFiles ? [...gitFiles].sort() : null };
362
+ }
363
+
364
+ // src/audit/inspection.ts
365
+ var inspections = new AsyncLocalStorage2();
366
+ function inspectionRead(root, key, read) {
367
+ const inspection = inspections.getStore();
368
+ if (!inspection || inspection.root !== path3.resolve(root)) return read();
369
+ if (!inspection.reads.has(key)) {
370
+ const pending = read();
371
+ inspection.reads.set(key, pending);
372
+ void pending.catch(() => {
373
+ if (inspection.reads.get(key) === pending) inspection.reads.delete(key);
374
+ });
375
+ }
376
+ return inspection.reads.get(key);
377
+ }
378
+ function inspectionGit(args, options) {
379
+ return inspectionRead(
380
+ options.cwd,
381
+ "git:" + JSON.stringify([args, options]),
382
+ () => execGit(args, options)
383
+ );
384
+ }
385
+ var sourceSpecs = SOURCE_EXTENSIONS.map((extension) => `:(glob)**/*.${extension}`);
386
+ var sourceSuffixes = new Set(SOURCE_EXTENSIONS.map((extension) => "." + extension));
387
+
388
+ // src/decisions/anchors.ts
389
+ import { createHash } from "crypto";
390
+ import fs3 from "fs/promises";
391
+ import { constants as constants2 } from "fs";
392
+ import { z } from "zod";
393
+ import fg3 from "fast-glob";
100
394
 
101
395
  // src/utils/storage.ts
396
+ import fs2 from "fs/promises";
397
+ import path4 from "path";
398
+ import { randomUUID } from "crypto";
102
399
  async function storePath(root, relative, createParents = false) {
103
400
  const normalized = normalizeRepoPath(relative);
104
401
  if (!normalized) throw new Error(`Invalid store path: ${relative}`);
105
402
  let current = await fs2.realpath(root);
106
403
  const parts = normalized.split("/");
107
404
  for (let i = 0; i < parts.length; i++) {
108
- current = path3.join(current, parts[i]);
405
+ current = path4.join(current, parts[i]);
109
406
  let stat;
110
407
  try {
111
408
  stat = await fs2.lstat(current);
@@ -124,56 +421,166 @@ async function storePath(root, relative, createParents = false) {
124
421
  }
125
422
  return current;
126
423
  }
127
- async function readStoreJson(root, relative) {
128
- try {
129
- const file = await storePath(root, relative);
130
- const raw = await readBoundedFile(file, 10 * 1024 * 1024);
131
- if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
132
- const parsed = JSON.parse(raw);
133
- if (parsed === null) throw new Error("expected a JSON object, received null");
134
- return parsed;
135
- } catch (error) {
136
- if (error.code === "ENOENT") return null;
137
- throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
138
- }
424
+
425
+ // src/decisions/anchors.ts
426
+ var repoPath = z.string().refine((p) => normalizeRepoPath(p) === p);
427
+ var anchorCaptureSchema = z.object({
428
+ anchors: z.array(repoPath),
429
+ files: z.record(repoPath, z.string().regex(/^[a-f0-9]{64}$/)),
430
+ complete: z.boolean()
431
+ }).refine(
432
+ (capture) => JSON.stringify(capture.anchors) === JSON.stringify([...new Set(capture.anchors)].sort()) && matchingPaths(capture.anchors, Object.keys(capture.files)).length === Object.keys(capture.files).length,
433
+ "Capture paths must belong to its normalized, sorted anchor scope"
434
+ );
435
+ async function captureAnchorScopes(root, scopes) {
436
+ const union = [...new Set(scopes.flat())].sort();
437
+ let context;
438
+ const prepareContext = async () => {
439
+ const access = await inspectionRead(
440
+ root,
441
+ "decision-anchor-access",
442
+ () => createFileAccess(root)
443
+ );
444
+ const inventory = access.inventory();
445
+ if (!inventory) throw new Error("Git anchor inventory unavailable");
446
+ const ignored = await inspectionGit(
447
+ [
448
+ "ls-files",
449
+ "-z",
450
+ "--others",
451
+ "--ignored",
452
+ "--exclude-standard",
453
+ "--",
454
+ ...union.map((anchor) => `:(literal)${anchor}`)
455
+ ],
456
+ { cwd: root, maxBuffer: 8 * 1024 * 1024 }
457
+ );
458
+ const files = [
459
+ .../* @__PURE__ */ new Set([...inventory, ...ignored.stdout.split("\0").filter(Boolean)])
460
+ ].sort();
461
+ const patterns = await Promise.all(
462
+ union.map(async (anchor) => {
463
+ const literal = fg3.escapePath(anchor);
464
+ try {
465
+ return (await fs3.lstat(await storePath(root, anchor))).isDirectory() ? `${literal}/**/*` : literal;
466
+ } catch {
467
+ return literal;
468
+ }
469
+ })
470
+ );
471
+ const eligible = new Set(await access.list(patterns, { dot: true }));
472
+ const indexed = indexMatchingPaths(scopes, files);
473
+ const filesByScope = new Map(
474
+ scopes.map((scope, index) => [JSON.stringify([...new Set(scope)].sort()), indexed[index]])
475
+ );
476
+ return { filesByScope, eligible };
477
+ };
478
+ return Promise.all(
479
+ scopes.map((scope) => {
480
+ const anchors = [...new Set(scope)].sort();
481
+ return inspectionRead(
482
+ root,
483
+ "decision-anchor-capture:" + JSON.stringify(anchors),
484
+ async () => {
485
+ const capture = { anchors, files: {}, complete: true };
486
+ if (!anchors.length) return capture;
487
+ try {
488
+ const { eligible, filesByScope } = await (context ??= prepareContext());
489
+ const files = filesByScope.get(JSON.stringify(anchors));
490
+ if (files.length > 2e3) return { ...capture, complete: false };
491
+ for (const file of files) {
492
+ const evidence = await inspectionRead(
493
+ root,
494
+ "decision-anchor-file:" + file,
495
+ async () => {
496
+ try {
497
+ const absolute = await storePath(root, file);
498
+ await fs3.lstat(absolute);
499
+ if (!eligible.has(file)) return { complete: false };
500
+ const handle = await fs3.open(
501
+ absolute,
502
+ constants2.O_RDONLY | constants2.O_NONBLOCK | constants2.O_NOFOLLOW
503
+ );
504
+ try {
505
+ const stat = await handle.stat();
506
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES)
507
+ return { complete: false };
508
+ const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
509
+ let size = 0;
510
+ while (size < bytes.length) {
511
+ const read = await handle.read(bytes, size, bytes.length - size, null);
512
+ if (!read.bytesRead) break;
513
+ size += read.bytesRead;
514
+ }
515
+ if (size === bytes.length) return { complete: false };
516
+ return {
517
+ complete: true,
518
+ hash: createHash("sha256").update(bytes.subarray(0, size)).digest("hex")
519
+ };
520
+ } finally {
521
+ await handle.close();
522
+ }
523
+ } catch (error) {
524
+ return { complete: error.code === "ENOENT" };
525
+ }
526
+ }
527
+ );
528
+ if (!evidence.complete) capture.complete = false;
529
+ if (evidence.hash) capture.files[file] = evidence.hash;
530
+ }
531
+ } catch {
532
+ capture.complete = false;
533
+ }
534
+ return capture;
535
+ }
536
+ );
537
+ })
538
+ );
539
+ }
540
+ function changedCaptureFiles(before, after) {
541
+ return [.../* @__PURE__ */ new Set([...Object.keys(before.files), ...Object.keys(after.files)])].filter((file) => before.files[file] !== after.files[file]).sort();
139
542
  }
140
543
 
544
+ // src/decisions/decisions.ts
545
+ import fs5 from "fs/promises";
546
+ import path6 from "path";
547
+ import { createHash as createHash3 } from "crypto";
548
+ import { z as z5 } from "zod";
549
+
141
550
  // src/snapshot/snapshot.ts
142
- import path5 from "path";
143
- import { z } from "zod";
551
+ import { z as z3 } from "zod";
144
552
 
145
553
  // src/utils/store-lock.ts
146
- import fs3 from "fs/promises";
554
+ import { z as z2 } from "zod";
555
+ import fs4 from "fs/promises";
147
556
  import os from "os";
148
-
149
- // src/test-map.ts
150
- import path4 from "path";
557
+ var lockOwnerSchema = z2.object({ host: z2.string(), pid: z2.number().int().positive() });
151
558
 
152
559
  // src/snapshot/snapshot.ts
153
- var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
560
+ var repoPath2 = z3.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
154
561
  var verificationFields = {
155
- refreshedHash: z.string().optional(),
156
- verifiedAt: z.string().optional(),
157
- verifiedHash: z.string().optional(),
158
- verificationToken: z.string().optional(),
159
- verificationFailed: z.boolean().optional(),
160
- verificationNote: z.string().optional()
562
+ refreshedHash: z3.string().optional(),
563
+ verifiedAt: z3.string().optional(),
564
+ verifiedHash: z3.string().optional(),
565
+ verificationToken: z3.string().optional(),
566
+ verificationFailed: z3.boolean().optional(),
567
+ verificationNote: z3.string().optional()
161
568
  };
162
- var featureSchema = z.object({
163
- description: z.string(),
164
- files: z.array(repoPath),
165
- tests: z.array(repoPath).optional(),
166
- type: z.enum(["capability", "infrastructure"]).optional(),
569
+ var featureSchema = z3.object({
570
+ description: z3.string(),
571
+ files: z3.array(repoPath2),
572
+ tests: z3.array(repoPath2).optional(),
573
+ type: z3.enum(["capability", "infrastructure"]).optional(),
167
574
  ...verificationFields
168
575
  }).passthrough();
169
- var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
170
- var snapshotSchema = z.object({
171
- version: z.literal(2),
172
- createdAt: z.string(),
173
- updatedAt: z.string(),
174
- gitHash: z.string(),
175
- features: z.record(featureSchema),
176
- flows: z.record(flowSchema)
576
+ var flowSchema = z3.object({ description: z3.string(), chain: z3.array(repoPath2), ...verificationFields }).passthrough();
577
+ var snapshotSchema = z3.object({
578
+ version: z3.literal(2),
579
+ createdAt: z3.string(),
580
+ updatedAt: z3.string(),
581
+ gitHash: z3.string(),
582
+ features: z3.record(featureSchema),
583
+ flows: z3.record(flowSchema)
177
584
  }).passthrough();
178
585
  async function getCurrentGitHash(rootDir) {
179
586
  try {
@@ -187,114 +594,168 @@ async function getCurrentGitHash(rootDir) {
187
594
  }
188
595
 
189
596
  // src/context/lexical.ts
190
- import path6 from "path";
597
+ import path5 from "path";
191
598
 
192
599
  // src/decisions/provenance.ts
193
- import { z as z2 } from "zod";
600
+ import { z as z4 } from "zod";
601
+ import { createHash as createHash2 } from "crypto";
194
602
 
195
603
  // src/context/trust.ts
196
604
  function assessTrust(entry, freshness) {
197
605
  const verification = entry.verificationFailed ? "failed" : entry.verifiedAt ? "passed" : "unverified";
198
606
  const reasons = [];
199
- if (freshness === "unknown") reasons.push("Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry.");
200
- if (freshness === "changed") reasons.push("Anchored files changed; verify against current code before relying on this entry.");
201
- if (verification === "failed") reasons.push(`Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`);
607
+ if (freshness === "unknown")
608
+ reasons.push(
609
+ "Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry."
610
+ );
611
+ if (freshness === "changed")
612
+ reasons.push(
613
+ "Anchored files changed; verify against current code before relying on this entry."
614
+ );
615
+ if (verification === "failed")
616
+ reasons.push(
617
+ `Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`
618
+ );
202
619
  if (verification === "unverified") reasons.push("No correctness verification has been recorded.");
203
- return { freshness, verification, verifiedAt: entry.verifiedAt, verifiedHash: entry.verifiedHash, reasons };
620
+ return {
621
+ freshness,
622
+ verification,
623
+ verifiedAt: entry.verifiedAt,
624
+ verifiedHash: entry.verifiedHash,
625
+ reasons
626
+ };
204
627
  }
205
628
 
206
629
  // src/decisions/provenance.ts
207
- var text = (max) => z2.string().trim().min(1).max(max);
208
- var decisionSourceSchema = z2.object({
209
- kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
630
+ var text = (max) => z4.string().trim().min(1).max(max);
631
+ var decisionSourceSchema = z4.object({
632
+ kind: z4.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
210
633
  reference: text(1e3),
211
634
  note: text(500).optional()
212
635
  }).strict();
213
- var attributionSchema = z2.object({
636
+ var attributionSchema = z4.object({
214
637
  owner: text(200).nullable().optional(),
215
- sources: z2.array(decisionSourceSchema).max(20).optional(),
638
+ sources: z4.array(decisionSourceSchema).max(20).optional(),
216
639
  actor: text(200).optional()
217
640
  });
218
- var contentSchema = z2.object({
219
- title: z2.string().min(1),
220
- body: z2.string().min(1),
221
- category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
222
- files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
641
+ var contentSchema = z4.object({
642
+ title: z4.string().min(1),
643
+ body: z4.string().min(1),
644
+ category: z4.enum(["decision", "gotcha", "deprecation", "convention"]),
645
+ files: z4.array(z4.string().refine((f) => normalizeRepoPath(f) !== null)),
223
646
  owner: text(200).optional(),
224
- sources: z2.array(decisionSourceSchema).max(20)
647
+ sources: z4.array(decisionSourceSchema).max(20)
225
648
  });
226
- var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
227
- var statusSchema = z2.enum(["active", "superseded", "retired"]);
228
- var reviewEvidenceSchema = z2.object({
229
- baseHash: z2.string(),
230
- headHash: z2.string(),
231
- historyAvailable: z2.boolean(),
232
- changedFiles: z2.array(z2.string()),
233
- localChanges: z2.array(z2.string())
649
+ var approvalSchema = z4.enum(["unreviewed", "proposed", "accepted"]);
650
+ var statusSchema = z4.enum(["active", "superseded", "retired"]);
651
+ var reviewEvidenceSchema = z4.object({
652
+ baseHash: z4.string(),
653
+ headHash: z4.string(),
654
+ historyAvailable: z4.boolean(),
655
+ changedFiles: z4.array(z4.string()),
656
+ localChanges: z4.array(z4.string())
234
657
  });
235
- var eventSchema = z2.object({
236
- kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
237
- at: z2.string().datetime(),
658
+ var eventSchema = z4.object({
659
+ kind: z4.enum([
660
+ "imported",
661
+ "created",
662
+ "revised",
663
+ "accepted",
664
+ "reaffirmed",
665
+ "retired",
666
+ "superseded"
667
+ ]),
668
+ at: z4.string().datetime(),
238
669
  actor: text(200).optional(),
239
670
  note: text(1500).optional(),
240
- revision: z2.number().int().positive(),
671
+ revision: z4.number().int().positive(),
241
672
  content: contentSchema,
242
673
  approval: approvalSchema,
243
674
  status: statusSchema,
244
- refreshedHash: z2.string(),
245
- evidence: reviewEvidenceSchema.optional()
675
+ refreshedHash: z4.string(),
676
+ evidence: reviewEvidenceSchema.optional(),
677
+ capture: anchorCaptureSchema.optional()
246
678
  });
247
- var legacySchema = z2.object({
248
- version: z2.literal(1),
249
- id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
250
- title: z2.string().min(1),
251
- body: z2.string().min(1),
679
+ var legacySchema = z4.object({
680
+ version: z4.literal(1),
681
+ id: z4.string().regex(/^[a-zA-Z0-9_-]+$/),
682
+ title: z4.string().min(1),
683
+ body: z4.string().min(1),
252
684
  category: contentSchema.shape.category,
253
685
  files: contentSchema.shape.files,
254
- createdAt: z2.string(),
255
- updatedAt: z2.string(),
256
- refreshedHash: z2.string(),
257
- status: z2.enum(["active", "superseded"]),
258
- supersededBy: z2.string().optional()
686
+ createdAt: z4.string(),
687
+ updatedAt: z4.string(),
688
+ refreshedHash: z4.string(),
689
+ status: z4.enum(["active", "superseded"]),
690
+ supersededBy: z4.string().optional()
259
691
  }).passthrough();
260
692
  var currentSchema = legacySchema.extend({
261
- version: z2.literal(2),
693
+ version: z4.literal(2),
262
694
  status: statusSchema,
263
695
  approval: approvalSchema,
264
- revision: z2.number().int().positive(),
696
+ revision: z4.number().int().positive(),
265
697
  owner: text(200).optional(),
266
- sources: z2.array(decisionSourceSchema).max(20),
267
- history: z2.array(eventSchema).min(1)
698
+ sources: z4.array(decisionSourceSchema).max(20),
699
+ history: z4.array(eventSchema).min(1),
700
+ capture: anchorCaptureSchema.optional(),
701
+ inspections: z4.array(
702
+ z4.object({
703
+ at: z4.string().datetime(),
704
+ inspector: text(200),
705
+ note: text(1500),
706
+ outcome: z4.literal("no_contradiction_found"),
707
+ scope: z4.literal("operative").optional(),
708
+ decisionDigest: z4.string().regex(/^[a-f0-9]{64}$/),
709
+ headHash: z4.string(),
710
+ capture: anchorCaptureSchema
711
+ })
712
+ ).max(200).optional()
268
713
  }).superRefine((record, ctx) => {
269
714
  const invalid = (message) => ctx.addIssue({ code: "custom", message });
270
715
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
271
716
  let previous;
272
717
  for (const event of record.history) {
273
718
  if (!previous) {
274
- if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
275
- if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
719
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1)
720
+ invalid("History must begin with creation or legacy import at revision 1");
721
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed"))
722
+ invalid("Initial records cannot claim acceptance");
276
723
  } else {
277
724
  if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
278
725
  if (previous.status !== "active") invalid("Archived decisions cannot be changed");
279
- if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
280
- if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
281
- if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
282
- if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
726
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0))
727
+ invalid("Invalid revision sequence");
728
+ if (event.kind !== "revised" && !same(event.content, previous.content))
729
+ invalid("A review cannot silently revise decision content");
730
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted")
731
+ invalid("Only accepted decisions can be reaffirmed");
732
+ if (event.kind === "accepted" && previous.approval === "accepted")
733
+ invalid("Use reaffirmation for an accepted decision");
283
734
  const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
284
735
  if (event.approval !== approval) invalid("Approval disagrees with review history");
285
- if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
736
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash)
737
+ invalid("Only a review can refresh the evidence baseline");
286
738
  }
287
- if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
288
- if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
739
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active"))
740
+ invalid("Lifecycle disagrees with history");
741
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence))
742
+ invalid("Reviews require a named reviewer, reason, and code evidence");
289
743
  if (["accepted", "reaffirmed"].includes(event.kind)) {
290
- if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
291
- if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length) invalid("Acceptance requires a committed evidence baseline");
744
+ if (!event.content.owner || !event.content.sources.length)
745
+ invalid("Accepted decisions require an owner and source");
746
+ if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length)
747
+ invalid("Acceptance requires a committed evidence baseline");
292
748
  }
749
+ if (event.capture && !same([...event.content.files].sort(), event.capture.anchors))
750
+ invalid("Capture scope disagrees with decision anchors");
293
751
  previous = event;
294
752
  }
295
- if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash) invalid("Decision does not match the final history event");
753
+ if (previous && !same(previous.capture, record.capture))
754
+ invalid("Capture does not match the final history event");
755
+ if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash)
756
+ invalid("Decision does not match the final history event");
296
757
  });
297
- var decisionSchema = z2.union([legacySchema, currentSchema]);
758
+ var decisionSchema = z4.union([legacySchema, currentSchema]);
298
759
  function decisionContent(record) {
299
760
  return {
300
761
  title: record.title,
@@ -309,7 +770,8 @@ function decisionApproval(record) {
309
770
  return record.version === 1 ? "unreviewed" : record.approval;
310
771
  }
311
772
  function effectiveDecision(record) {
312
- if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
773
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed")
774
+ return record;
313
775
  let index = record.history.length - 1;
314
776
  while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
315
777
  if (index < 0) return record;
@@ -321,6 +783,7 @@ function effectiveDecision(record) {
321
783
  approval: "accepted",
322
784
  revision: event.revision,
323
785
  refreshedHash: event.refreshedHash,
786
+ capture: event.capture,
324
787
  updatedAt: event.at,
325
788
  history: record.history.slice(0, index + 1)
326
789
  };
@@ -330,7 +793,9 @@ function decisionAnchors(record) {
330
793
  }
331
794
  function decisionProvenance(record, freshness = "unknown") {
332
795
  const approval = decisionApproval(record);
333
- const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
796
+ const review = record.version === 2 ? [...record.history].reverse().find(
797
+ (e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision
798
+ ) : void 0;
334
799
  return {
335
800
  approval,
336
801
  revision: record.version === 2 ? record.revision : 0,
@@ -338,71 +803,171 @@ function decisionProvenance(record, freshness = "unknown") {
338
803
  sources: record.version === 2 ? record.sources : [],
339
804
  guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
340
805
  reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
341
- lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
806
+ lastReview: review ? {
807
+ reviewer: review.actor,
808
+ at: review.at,
809
+ note: review.note,
810
+ gitHash: review.refreshedHash
811
+ } : null
342
812
  };
343
813
  }
344
814
  function decisionTrust(record, freshness) {
345
815
  const review = decisionProvenance(record, freshness).lastReview;
346
- return assessTrust(review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {}, freshness);
816
+ return assessTrust(
817
+ review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {},
818
+ freshness
819
+ );
820
+ }
821
+ function decisionInspectionDigest(record, scope = "all") {
822
+ const effective = effectiveDecision(record);
823
+ if (scope === "operative")
824
+ return createHash2("sha256").update(
825
+ JSON.stringify([
826
+ "operative",
827
+ record.status,
828
+ decisionApproval(effective),
829
+ effective.version === 2 ? effective.revision : 0,
830
+ decisionContent(effective),
831
+ effective.refreshedHash
832
+ ])
833
+ ).digest("hex");
834
+ return createHash2("sha256").update(
835
+ JSON.stringify([
836
+ record.status,
837
+ decisionApproval(record),
838
+ record.version === 2 ? record.revision : 0,
839
+ decisionContent(record),
840
+ decisionContent(effective),
841
+ effective.refreshedHash
842
+ ])
843
+ ).digest("hex");
844
+ }
845
+ function latestDecisionInspection(record, scope = "all") {
846
+ return record.version === 2 ? record.inspections?.filter(
847
+ (i) => (scope === "operative" || i.scope !== "operative") && i.decisionDigest === decisionInspectionDigest(record, i.scope ?? "all")
848
+ ).at(-1) : void 0;
347
849
  }
348
850
  function revisionKnowledge(record, freshness) {
349
- return { ...decisionContent(record), ...decisionProvenance(record, freshness), trust: decisionTrust(record, freshness) };
851
+ return {
852
+ ...decisionContent(record),
853
+ ...decisionProvenance(record, freshness),
854
+ trust: decisionTrust(record, freshness)
855
+ };
350
856
  }
351
857
  function decisionKnowledge(record, freshness = "unknown", proposalFreshness = "unknown") {
352
858
  const effective = effectiveDecision(record);
859
+ const inspection = latestDecisionInspection(
860
+ record,
861
+ decisionApproval(effective) === "accepted" ? "operative" : "all"
862
+ );
353
863
  return {
354
864
  ...revisionKnowledge(effective, freshness),
355
- ...effective !== record ? { pendingProposal: revisionKnowledge(record, proposalFreshness) } : {}
865
+ ...inspection ? {
866
+ inspection: {
867
+ freshness,
868
+ at: inspection.at,
869
+ inspector: inspection.inspector,
870
+ note: inspection.note,
871
+ outcome: inspection.outcome,
872
+ scope: inspection.scope ?? "all",
873
+ headHash: inspection.headHash
874
+ }
875
+ } : {},
876
+ ...effective !== record ? {
877
+ pendingProposal: {
878
+ ...revisionKnowledge(record, proposalFreshness),
879
+ assessment: latestDecisionInspection(record) ? "no_contradiction_found" : "not_assessed"
880
+ }
881
+ } : {}
356
882
  };
357
883
  }
358
- var DECISION_GUIDANCE = "Accepted decisions are recorded team constraints, subject to freshness checks. A pendingProposal is an unaccepted replacement; the accepted revision remains operative until explicit acceptance or retirement. Proposals are suggestions; legacy unreviewed records need confirmation. Use review_decision to inspect provenance and record an authorized review; identities and sources are recorded assertions, not authenticated proof.";
884
+ var DECISION_GUIDANCE = "Accepted decisions are recorded team constraints, subject to freshness checks. A pendingProposal is an unaccepted replacement; the accepted revision remains operative until explicit acceptance or retirement. Proposals are suggestions; legacy unreviewed records need confirmation. Use review_decision to prepare evidence and record a separate source inspection without human approval, or an authorized human review; identities and sources are recorded assertions, not authenticated proof.";
359
885
 
360
886
  // src/decisions/decisions.ts
361
887
  var TITLE_MAX_CHARS = 80;
362
888
  var BODY_RECOMMENDED_CHARS = 1500;
363
889
  var BODY_MAX_CHARS = 2500;
364
- var boundedText = (field, max, hint) => z3.string({
365
- errorMap: (issue, ctx) => ({ message: issue.code === "too_big" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError })
890
+ var boundedText = (field, max, hint) => z5.string({
891
+ errorMap: (issue, ctx) => ({
892
+ message: issue.code === "too_big" && typeof ctx.data === "string" ? `${field} has ${ctx.data.length} characters; maximum is ${max} (${ctx.data.length - max} over). ${hint}` : ctx.defaultError
893
+ })
366
894
  }).trim().min(1, `${field} must be non-empty`).max(max);
367
- var decisionTitleSchema = boundedText("title", TITLE_MAX_CHARS, "Tighten it to a specific headline.");
895
+ var decisionTitleSchema = boundedText(
896
+ "title",
897
+ TITLE_MAX_CHARS,
898
+ "Tighten it to a specific headline."
899
+ );
368
900
  var decisionBodySchema = boundedText(
369
901
  "body",
370
902
  BODY_MAX_CHARS,
371
903
  `Aim for ${BODY_RECOMMENDED_CHARS} characters or fewer. Preserve the rule, reason, and exceptions; put supporting references in sources.`
372
904
  );
905
+ async function readDecisionInputs(rootDir) {
906
+ return structuredClone(
907
+ await inspectionRead(rootDir, "decision-store:inputs", async () => {
908
+ const inputs = [];
909
+ const diagnostics = [];
910
+ let entries;
911
+ try {
912
+ entries = await fs5.readdir(await storePath(rootDir, ".mason/decisions"));
913
+ } catch (error) {
914
+ if (error.code !== "ENOENT")
915
+ diagnostics.push({ path: ".mason/decisions", message: String(error) });
916
+ return { inputs, diagnostics };
917
+ }
918
+ for (const entry of entries.sort()) {
919
+ if (!entry.endsWith(".json")) continue;
920
+ const relative = `.mason/decisions/${entry}`;
921
+ try {
922
+ const raw = await readBoundedFile(await storePath(rootDir, relative), 10 * 1024 * 1024);
923
+ if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
924
+ inputs.push([relative, raw]);
925
+ } catch (error) {
926
+ if (error.code === "ENOENT") inputs.push([relative, null]);
927
+ else
928
+ diagnostics.push({
929
+ path: relative,
930
+ message: error instanceof Error ? error.message : String(error)
931
+ });
932
+ }
933
+ }
934
+ return { inputs, diagnostics };
935
+ })
936
+ );
937
+ }
373
938
  async function loadDecisionStore(rootDir) {
374
- const records = [];
375
- const diagnostics = [];
376
- let entries;
377
- try {
378
- entries = await fs4.readdir(await storePath(rootDir, ".mason/decisions"));
379
- } catch (error) {
380
- if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
381
- return { records, diagnostics };
382
- }
383
- for (const entry of entries.sort()) {
384
- if (!entry.endsWith(".json")) continue;
385
- const relative = `.mason/decisions/${entry}`;
386
- try {
387
- const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
388
- if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
389
- records.push(record);
390
- } catch (error) {
391
- diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
392
- }
393
- }
394
- return { records, diagnostics };
939
+ return structuredClone(
940
+ await inspectionRead(rootDir, "decision-store:parsed", async () => {
941
+ const raw = await readDecisionInputs(rootDir);
942
+ const records = [];
943
+ const diagnostics = [...raw.diagnostics];
944
+ for (const [relative, content] of raw.inputs) {
945
+ try {
946
+ const record = decisionSchema.parse(content === null ? null : JSON.parse(content));
947
+ if (relative !== `.mason/decisions/${record.id}.json`)
948
+ throw new Error("Record id does not match its filename");
949
+ records.push(record);
950
+ } catch (error) {
951
+ diagnostics.push({
952
+ path: relative,
953
+ message: error instanceof Error ? error.message : String(error)
954
+ });
955
+ }
956
+ }
957
+ return { records, diagnostics };
958
+ })
959
+ );
395
960
  }
396
961
 
397
962
  // src/hook/hook.ts
398
- import { createHash as createHash2 } from "crypto";
963
+ import { createHash as createHash4 } from "crypto";
399
964
 
400
965
  // src/decisions/drift.ts
401
- import path9 from "path";
966
+ import path8 from "path";
402
967
 
403
968
  // src/drift/drift.ts
404
- import fs5 from "fs/promises";
405
- import path8 from "path";
969
+ import fs6 from "fs/promises";
970
+ import path7 from "path";
406
971
  function parseChanges(output) {
407
972
  const fields = output.split("\0");
408
973
  const changes = [];
@@ -412,18 +977,25 @@ function parseChanges(output) {
412
977
  if (!first) break;
413
978
  const second = /^[RC]/.test(code) ? fields[i++] : void 0;
414
979
  const change = second ? code.startsWith("R") ? { status: "renamed", path: second, previousPath: first } : { status: "added", path: second } : { status: code === "A" ? "added" : code === "D" ? "deleted" : "modified", path: first };
415
- if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
980
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/")))
981
+ continue;
416
982
  changes.push(change);
417
983
  }
418
984
  return changes;
419
985
  }
420
986
  function touchedPaths(changes) {
421
- return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
987
+ return [
988
+ ...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))
989
+ ].sort();
422
990
  }
423
991
  async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
424
- if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
992
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-"))
993
+ return null;
425
994
  try {
426
- const { stdout } = await execGit(["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
995
+ const { stdout } = await execGit(
996
+ ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"],
997
+ { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
998
+ );
427
999
  return parseChanges(stdout);
428
1000
  } catch {
429
1001
  return null;
@@ -432,11 +1004,23 @@ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
432
1004
  async function getWorkingTree(resolvedRoot) {
433
1005
  try {
434
1006
  const [diff, untracked] = await Promise.all([
435
- execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
436
- execGit(["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
1007
+ execGit(["diff", "--name-status", "-z", "-M", "HEAD", "--"], {
1008
+ cwd: resolvedRoot,
1009
+ maxBuffer: 10 * 1024 * 1024
1010
+ }),
1011
+ execGit(["ls-files", "-z", "--others", "--exclude-standard"], {
1012
+ cwd: resolvedRoot,
1013
+ maxBuffer: 10 * 1024 * 1024
1014
+ })
437
1015
  ]);
438
1016
  const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
439
- return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
1017
+ return {
1018
+ available: true,
1019
+ changedFiles: [
1020
+ .../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])
1021
+ ].sort(),
1022
+ untrackedFiles
1023
+ };
440
1024
  } catch {
441
1025
  return { available: false, changedFiles: [], untrackedFiles: [] };
442
1026
  }
@@ -444,13 +1028,51 @@ async function getWorkingTree(resolvedRoot) {
444
1028
 
445
1029
  // src/decisions/drift.ts
446
1030
  async function computeDecisionDrift(rootDir, decisions) {
447
- const resolvedRoot = path9.resolve(rootDir);
1031
+ const resolvedRoot = path8.resolve(rootDir);
448
1032
  const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
449
- const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
450
- const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
1033
+ const report = {
1034
+ historyAvailable: true,
1035
+ totalDecisions: store.records.length,
1036
+ staleDecisions: {},
1037
+ freshness: {},
1038
+ diagnostics: store.diagnostics
1039
+ };
1040
+ const [head, workingTree] = await Promise.all([
1041
+ getCurrentGitHash(resolvedRoot),
1042
+ getWorkingTree(resolvedRoot)
1043
+ ]);
451
1044
  const changesByHash = /* @__PURE__ */ new Map();
452
- const inspect = async (record) => {
1045
+ const baselines = store.records.filter((record) => record.status === "active").flatMap((record) => {
1046
+ const effective = effectiveDecision(record);
1047
+ return [.../* @__PURE__ */ new Set([effective, record])].flatMap((candidate) => {
1048
+ const inspection = latestDecisionInspection(
1049
+ record,
1050
+ candidate === effective && decisionApproval(effective) === "accepted" ? "operative" : "all"
1051
+ );
1052
+ const capture = inspection?.capture ?? (candidate.version === 2 ? candidate.capture : void 0);
1053
+ return capture ? [capture] : [];
1054
+ });
1055
+ });
1056
+ const captures = await captureAnchorScopes(
1057
+ resolvedRoot,
1058
+ baselines.map((baseline) => baseline.anchors)
1059
+ );
1060
+ const currentCaptures = new Map(
1061
+ baselines.map((baseline, index) => [JSON.stringify(baseline.anchors), captures[index]])
1062
+ );
1063
+ const inspect = async (record, inspectionCapture) => {
453
1064
  if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
1065
+ const baseline = inspectionCapture ?? (record.version === 2 ? record.capture : void 0);
1066
+ if (baseline) {
1067
+ const current = currentCaptures.get(JSON.stringify(baseline.anchors));
1068
+ const hits2 = changedCaptureFiles(baseline, current).filter(
1069
+ (file) => matchingPaths(record.files, [file]).length
1070
+ );
1071
+ return {
1072
+ freshness: !baseline.complete || !current.complete ? "unknown" : hits2.length ? "changed" : "current",
1073
+ changedFiles: hits2
1074
+ };
1075
+ }
454
1076
  let touched = changesByHash.get(record.refreshedHash);
455
1077
  if (touched === void 0) {
456
1078
  const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
@@ -460,15 +1082,26 @@ async function computeDecisionDrift(rootDir, decisions) {
460
1082
  if (touched === null) report.historyAvailable = false;
461
1083
  const hits = touched ? matchingPaths(record.files, touched) : [];
462
1084
  const localHits = matchingPaths(record.files, workingTree.changedFiles);
463
- return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
1085
+ return {
1086
+ freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current",
1087
+ changedFiles: [.../* @__PURE__ */ new Set([...hits, ...localHits])].sort()
1088
+ };
464
1089
  };
465
1090
  for (const record of store.records) {
466
1091
  if (record.status !== "active") continue;
467
1092
  const effective = effectiveDecision(record);
468
- const state = await inspect(effective);
1093
+ const inspection = latestDecisionInspection(
1094
+ record,
1095
+ decisionApproval(effective) === "accepted" ? "operative" : "all"
1096
+ );
1097
+ const state = await inspect(effective, inspection?.capture);
469
1098
  report.freshness[record.id] = state.freshness;
470
1099
  if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
471
- if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
1100
+ if (effective !== record)
1101
+ (report.pendingProposals ??= {})[record.id] = await inspect(
1102
+ record,
1103
+ latestDecisionInspection(record)?.capture
1104
+ );
472
1105
  }
473
1106
  return report;
474
1107
  }
@@ -477,9 +1110,17 @@ async function computeDecisionDrift(rootDir, decisions) {
477
1110
  var MAX_INJECTED_DECISIONS = 3;
478
1111
  var MAX_WALK_UP = 30;
479
1112
  var SUPPORTED_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write"]);
1113
+ var hookStdinSchema = z6.object({
1114
+ session_id: z6.string().optional(),
1115
+ agent_id: z6.string().optional(),
1116
+ cwd: z6.string().optional(),
1117
+ hook_event_name: z6.string().optional(),
1118
+ tool_name: z6.string().optional(),
1119
+ tool_input: z6.object({ file_path: z6.string().optional() }).optional()
1120
+ });
480
1121
  async function exists(p) {
481
1122
  try {
482
- await fs6.access(p);
1123
+ await fs7.access(p);
483
1124
  return true;
484
1125
  } catch {
485
1126
  return false;
@@ -488,9 +1129,9 @@ async function exists(p) {
488
1129
  async function findMasonRoot(startDir) {
489
1130
  let dir = startDir;
490
1131
  for (let i = 0; i < MAX_WALK_UP; i++) {
491
- if (await exists(path10.join(dir, ".mason", "decisions"))) return dir;
492
- if (await exists(path10.join(dir, ".git"))) return null;
493
- const parent = path10.dirname(dir);
1132
+ if (await exists(path9.join(dir, ".mason", "decisions"))) return dir;
1133
+ if (await exists(path9.join(dir, ".git"))) return null;
1134
+ const parent = path9.dirname(dir);
494
1135
  if (parent === dir) return null;
495
1136
  dir = parent;
496
1137
  }
@@ -508,8 +1149,8 @@ function stateKey(input) {
508
1149
  }
509
1150
  async function loadInjected(stateFile) {
510
1151
  try {
511
- const parsed = JSON.parse(await fs6.readFile(stateFile, "utf-8"));
512
- return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []);
1152
+ const parsed = JSON.parse(await fs7.readFile(stateFile, "utf-8"));
1153
+ return new Set(isStringArray(parsed) ? parsed : []);
513
1154
  } catch {
514
1155
  return /* @__PURE__ */ new Set();
515
1156
  }
@@ -517,7 +1158,7 @@ async function loadInjected(stateFile) {
517
1158
  function formatContext(relPath, records, drift) {
518
1159
  const lines = [];
519
1160
  lines.push(
520
- `Mason: decision knowledge relevant to ${relPath} or updated since this session saw it. This replaces earlier guidance for the same decision id. ${DECISION_GUIDANCE} Retired or superseded records are no longer active. Do not modify decision records in .mason/decisions/.`
1161
+ `Mason: decision knowledge relevant to ${relPath} or updated since this session saw it. This replaces earlier guidance for the same decision id. ${DECISION_GUIDANCE} Retired or superseded records are no longer active. Use Mason tools to record decision changes and source inspections; do not edit the JSON in .mason/decisions/ directly.`
521
1162
  );
522
1163
  const append = (id, knowledge, label, freshness) => {
523
1164
  const stale = freshness === "current" ? "" : freshness === "changed" ? " [recorded against changed files \u2013 verify against current code before relying on it]" : " [freshness unknown \u2013 verify against current code before relying on it]";
@@ -526,16 +1167,31 @@ function formatContext(relPath, records, drift) {
526
1167
  );
527
1168
  };
528
1169
  for (const record of records) {
529
- const knowledge = decisionKnowledge(record, drift.freshness?.[record.id] ?? "unknown", drift.pendingProposals?.[record.id]?.freshness ?? "unknown");
530
- append(record.id, knowledge, record.status === "active" ? knowledge.approval : record.status, knowledge.trust.freshness);
531
- if (knowledge.pendingProposal) append(record.id, knowledge.pendingProposal, "proposed", knowledge.pendingProposal.trust.freshness);
1170
+ const knowledge = decisionKnowledge(
1171
+ record,
1172
+ drift.freshness?.[record.id] ?? "unknown",
1173
+ drift.pendingProposals?.[record.id]?.freshness ?? "unknown"
1174
+ );
1175
+ append(
1176
+ record.id,
1177
+ knowledge,
1178
+ record.status === "active" ? knowledge.approval : record.status,
1179
+ knowledge.trust.freshness
1180
+ );
1181
+ if (knowledge.pendingProposal)
1182
+ append(
1183
+ record.id,
1184
+ knowledge.pendingProposal,
1185
+ "proposed",
1186
+ knowledge.pendingProposal.trust.freshness
1187
+ );
532
1188
  }
533
1189
  return lines.join("\n");
534
1190
  }
535
1191
  async function runHook(stdinText, env = {}) {
536
1192
  let input;
537
1193
  try {
538
- input = JSON.parse(stdinText);
1194
+ input = hookStdinSchema.parse(JSON.parse(stdinText));
539
1195
  } catch {
540
1196
  return null;
541
1197
  }
@@ -543,18 +1199,24 @@ async function runHook(stdinText, env = {}) {
543
1199
  if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;
544
1200
  const filePath = input.tool_input?.file_path;
545
1201
  if (!filePath || typeof filePath !== "string") return null;
546
- const absPath = path10.isAbsolute(filePath) ? filePath : path10.resolve(input.cwd ?? process.cwd(), filePath);
547
- const root = await findMasonRoot(path10.dirname(absPath));
1202
+ const absPath = path9.isAbsolute(filePath) ? filePath : path9.resolve(input.cwd ?? process.cwd(), filePath);
1203
+ const root = await findMasonRoot(path9.dirname(absPath));
548
1204
  if (!root) return null;
549
- const relPath = path10.relative(root, absPath).split(path10.sep).join("/");
1205
+ if (env.rootDir && await fs7.realpath(root) !== await fs7.realpath(env.rootDir)) return null;
1206
+ const relPath = path9.relative(root, absPath).split(path9.sep).join("/");
550
1207
  if (relPath.startsWith("..")) return null;
551
1208
  const { records } = await loadDecisionStore(root);
552
1209
  const stateDir = env.stateDir ?? os2.tmpdir();
553
- const stateFile = path10.join(stateDir, `mason-hook-${createHash2("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`);
1210
+ const stateFile = path9.join(
1211
+ stateDir,
1212
+ `mason-hook-${createHash4("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`
1213
+ );
554
1214
  const injected = await loadInjected(stateFile);
555
- const recordKey = (record) => `${record.id}:${createHash2("sha256").update(JSON.stringify(record)).digest("hex")}`;
1215
+ const recordKey = (record) => `${record.id}:${createHash4("sha256").update(JSON.stringify(record)).digest("hex")}`;
556
1216
  const previouslyInjected = (record) => [...injected].some((key) => key === record.id || key.startsWith(`${record.id}:`));
557
- const fresh = records.filter((r) => !injected.has(recordKey(r)) && (previouslyInjected(r) || r.status === "active" && anchorsCover(r, relPath)));
1217
+ const fresh = records.filter(
1218
+ (r) => !injected.has(recordKey(r)) && (previouslyInjected(r) || r.status === "active" && anchorsCover(r, relPath))
1219
+ );
558
1220
  if (fresh.length === 0) return null;
559
1221
  fresh.sort((a, b) => {
560
1222
  const withdrawn = Number(b.status !== "active") - Number(a.status !== "active");
@@ -567,12 +1229,12 @@ async function runHook(stdinText, env = {}) {
567
1229
  const drift = await computeDecisionDrift(root, selected);
568
1230
  for (const record of selected) injected.add(recordKey(record));
569
1231
  try {
570
- await fs6.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
1232
+ await fs7.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
571
1233
  } catch {
572
1234
  }
573
1235
  return JSON.stringify({
574
1236
  hookSpecificOutput: {
575
- hookEventName: "PostToolUse",
1237
+ hookEventName: env.hookEventName ?? "PostToolUse",
576
1238
  additionalContext: formatContext(relPath, selected, drift)
577
1239
  }
578
1240
  });
@@ -635,7 +1297,9 @@ async function readStdin() {
635
1297
  if (process.stdin.isTTY) return "";
636
1298
  const chunks = [];
637
1299
  for await (const chunk of process.stdin) {
638
- chunks.push(Buffer.from(chunk));
1300
+ const value = chunk;
1301
+ if (typeof value === "string" || value instanceof Uint8Array) chunks.push(Buffer.from(value));
1302
+ else throw new Error("Unsupported stdin chunk.");
639
1303
  }
640
1304
  return Buffer.concat(chunks).toString("utf-8");
641
1305
  }