mason-context 0.9.0 → 0.10.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.
@@ -2,35 +2,141 @@
2
2
 
3
3
  // src/hook/hook.ts
4
4
  import fs5 from "fs/promises";
5
- import path8 from "path";
5
+ import path10 from "path";
6
6
  import os from "os";
7
7
 
8
8
  // src/decisions/decisions.ts
9
9
  import fs3 from "fs/promises";
10
- import path5 from "path";
10
+ import path7 from "path";
11
11
  import { createHash } from "crypto";
12
12
 
13
- // src/snapshot/snapshot.ts
13
+ // src/utils/storage.ts
14
14
  import fs2 from "fs/promises";
15
15
  import path3 from "path";
16
- import { execFile as execFile2 } from "child_process";
17
- import { promisify as promisify2 } from "util";
18
- import fg3 from "fast-glob";
16
+ import { randomUUID } from "crypto";
19
17
 
20
- // src/mcp/sampler.ts
21
- import fs from "fs/promises";
18
+ // src/utils/paths.ts
22
19
  import path from "path";
20
+ function normalizeRepoPath(value) {
21
+ const slash = value.replace(/\\/g, "/");
22
+ if (!slash || slash.includes("\0") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;
23
+ if (slash.split("/").includes("..")) return null;
24
+ const normalized = path.posix.normalize(slash).replace(/\/$/, "");
25
+ return normalized === "." ? null : normalized;
26
+ }
27
+ function anchorMatches(anchor, file) {
28
+ const a = normalizeRepoPath(anchor);
29
+ const f = normalizeRepoPath(file);
30
+ return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));
31
+ }
32
+ function matchingPaths(anchors, files) {
33
+ return [...new Set(files)].filter((file) => anchors.some((anchor) => anchorMatches(anchor, file)));
34
+ }
35
+
36
+ // src/utils/files.ts
37
+ import fs from "fs/promises";
38
+ import { constants } from "fs";
39
+ import path2 from "path";
23
40
  import { execFile } from "child_process";
24
41
  import { promisify } from "util";
25
42
  import fg from "fast-glob";
26
43
  var exec = promisify(execFile);
44
+ 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"];
45
+ var SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`;
46
+ var MAX_SOURCE_BYTES = 1024 * 1024;
47
+ async function readBoundedFile(file, maxBytes) {
48
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
49
+ try {
50
+ const stat = await handle.stat();
51
+ if (!stat.isFile() || stat.size > maxBytes) return null;
52
+ const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));
53
+ let bytes = 0;
54
+ while (bytes < buffer.length) {
55
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, null);
56
+ if (result.bytesRead === 0) break;
57
+ bytes += result.bytesRead;
58
+ }
59
+ return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString("utf8");
60
+ } finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+
65
+ // src/utils/storage.ts
66
+ async function storePath(root, relative, createParents = false) {
67
+ const normalized = normalizeRepoPath(relative);
68
+ if (!normalized) throw new Error(`Invalid store path: ${relative}`);
69
+ let current = await fs2.realpath(root);
70
+ const parts = normalized.split("/");
71
+ for (let i = 0; i < parts.length; i++) {
72
+ current = path3.join(current, parts[i]);
73
+ let stat;
74
+ try {
75
+ stat = await fs2.lstat(current);
76
+ } catch (error) {
77
+ if (error.code !== "ENOENT") throw error;
78
+ if (createParents && i < parts.length - 1) {
79
+ try {
80
+ await fs2.mkdir(current);
81
+ } catch (mkdirError) {
82
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
83
+ }
84
+ stat = await fs2.lstat(current);
85
+ }
86
+ }
87
+ if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);
88
+ }
89
+ return current;
90
+ }
91
+ async function readStoreJson(root, relative) {
92
+ try {
93
+ const file = await storePath(root, relative);
94
+ const raw = await readBoundedFile(file, 10 * 1024 * 1024);
95
+ if (raw === null) throw new Error("file is not regular or exceeds 10 MiB");
96
+ const parsed = JSON.parse(raw);
97
+ if (parsed === null) throw new Error("expected a JSON object, received null");
98
+ return parsed;
99
+ } catch (error) {
100
+ if (error.code === "ENOENT") return null;
101
+ throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
102
+ }
103
+ }
104
+
105
+ // src/snapshot/snapshot.ts
106
+ import path5 from "path";
107
+ import { execFile as execFile2 } from "child_process";
108
+ import { promisify as promisify2 } from "util";
109
+ import { z } from "zod";
27
110
 
28
111
  // src/test-map.ts
29
- import path2 from "path";
30
- import fg2 from "fast-glob";
112
+ import path4 from "path";
31
113
 
32
114
  // src/snapshot/snapshot.ts
33
115
  var exec2 = promisify2(execFile2);
116
+ var repoPath = z.string().refine((value) => normalizeRepoPath(value) !== null, "Expected a relative repository path");
117
+ var verificationFields = {
118
+ refreshedHash: z.string().optional(),
119
+ verifiedAt: z.string().optional(),
120
+ verifiedHash: z.string().optional(),
121
+ verificationFailed: z.boolean().optional(),
122
+ verificationNote: z.string().optional()
123
+ };
124
+ var featureSchema = z.object({
125
+ description: z.string(),
126
+ files: z.array(repoPath),
127
+ tests: z.array(repoPath).optional(),
128
+ type: z.enum(["capability", "infrastructure"]).optional(),
129
+ ...verificationFields
130
+ }).passthrough();
131
+ var flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();
132
+ var snapshotSchema = z.object({
133
+ version: z.literal(2),
134
+ createdAt: z.string(),
135
+ updatedAt: z.string(),
136
+ gitHash: z.string(),
137
+ features: z.record(featureSchema),
138
+ flows: z.record(flowSchema)
139
+ }).passthrough();
34
140
  async function getCurrentGitHash(rootDir) {
35
141
  try {
36
142
  const { stdout } = await exec2("git", ["rev-parse", "HEAD"], {
@@ -43,123 +149,228 @@ async function getCurrentGitHash(rootDir) {
43
149
  }
44
150
 
45
151
  // src/context/lexical.ts
46
- import path4 from "path";
152
+ import path6 from "path";
47
153
 
48
- // src/decisions/decisions.ts
49
- function decisionsDir(rootDir) {
50
- return path5.join(rootDir, ".mason", "decisions");
154
+ // src/decisions/provenance.ts
155
+ import { z as z2 } from "zod";
156
+ var text = (max) => z2.string().trim().min(1).max(max);
157
+ var decisionSourceSchema = z2.object({
158
+ kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
159
+ reference: text(1e3),
160
+ note: text(500).optional()
161
+ }).strict();
162
+ var attributionSchema = z2.object({
163
+ owner: text(200).nullable().optional(),
164
+ sources: z2.array(decisionSourceSchema).max(20).optional(),
165
+ actor: text(200).optional()
166
+ });
167
+ var contentSchema = z2.object({
168
+ title: z2.string().min(1),
169
+ body: z2.string().min(1),
170
+ category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
171
+ files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
172
+ owner: text(200).optional(),
173
+ sources: z2.array(decisionSourceSchema).max(20)
174
+ });
175
+ var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
176
+ var statusSchema = z2.enum(["active", "superseded", "retired"]);
177
+ var reviewEvidenceSchema = z2.object({
178
+ baseHash: z2.string(),
179
+ headHash: z2.string(),
180
+ historyAvailable: z2.boolean(),
181
+ changedFiles: z2.array(z2.string()),
182
+ localChanges: z2.array(z2.string())
183
+ });
184
+ var eventSchema = z2.object({
185
+ kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
186
+ at: z2.string().datetime(),
187
+ actor: text(200).optional(),
188
+ note: text(1500).optional(),
189
+ revision: z2.number().int().positive(),
190
+ content: contentSchema,
191
+ approval: approvalSchema,
192
+ status: statusSchema,
193
+ refreshedHash: z2.string(),
194
+ evidence: reviewEvidenceSchema.optional()
195
+ });
196
+ var legacySchema = z2.object({
197
+ version: z2.literal(1),
198
+ id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
199
+ title: z2.string().min(1),
200
+ body: z2.string().min(1),
201
+ category: contentSchema.shape.category,
202
+ files: contentSchema.shape.files,
203
+ createdAt: z2.string(),
204
+ updatedAt: z2.string(),
205
+ refreshedHash: z2.string(),
206
+ status: z2.enum(["active", "superseded"]),
207
+ supersededBy: z2.string().optional()
208
+ }).passthrough();
209
+ var currentSchema = legacySchema.extend({
210
+ version: z2.literal(2),
211
+ status: statusSchema,
212
+ approval: approvalSchema,
213
+ revision: z2.number().int().positive(),
214
+ owner: text(200).optional(),
215
+ sources: z2.array(decisionSourceSchema).max(20),
216
+ history: z2.array(eventSchema).min(1)
217
+ }).superRefine((record, ctx) => {
218
+ const invalid = (message) => ctx.addIssue({ code: "custom", message });
219
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
220
+ let previous;
221
+ for (const event of record.history) {
222
+ if (!previous) {
223
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
224
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
225
+ } else {
226
+ if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
227
+ if (previous.status !== "active") invalid("Archived decisions cannot be changed");
228
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
229
+ if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
230
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
231
+ if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
232
+ const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
233
+ if (event.approval !== approval) invalid("Approval disagrees with review history");
234
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
235
+ }
236
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
237
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
238
+ if (["accepted", "reaffirmed"].includes(event.kind)) {
239
+ if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
240
+ 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");
241
+ }
242
+ previous = event;
243
+ }
244
+ 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");
245
+ });
246
+ var decisionSchema = z2.union([legacySchema, currentSchema]);
247
+ function decisionContent(record) {
248
+ return {
249
+ title: record.title,
250
+ body: record.body,
251
+ category: record.category,
252
+ files: record.files,
253
+ ...typeof record.owner === "string" ? { owner: record.owner } : {},
254
+ sources: Array.isArray(record.sources) ? record.sources : []
255
+ };
256
+ }
257
+ function decisionApproval(record) {
258
+ return record.version === 1 ? "unreviewed" : record.approval;
259
+ }
260
+ function decisionProvenance(record, freshness = "unknown") {
261
+ const approval = decisionApproval(record);
262
+ const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
263
+ return {
264
+ approval,
265
+ revision: record.version === 2 ? record.revision : 0,
266
+ owner: record.version === 2 ? record.owner ?? null : null,
267
+ sources: record.version === 2 ? record.sources : [],
268
+ guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
269
+ reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
270
+ lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
271
+ };
51
272
  }
52
- async function loadDecisions(rootDir) {
273
+
274
+ // src/decisions/decisions.ts
275
+ async function loadDecisionStore(rootDir) {
276
+ const records = [];
277
+ const diagnostics = [];
53
278
  let entries;
54
279
  try {
55
- entries = await fs3.readdir(decisionsDir(rootDir));
56
- } catch {
57
- return [];
280
+ entries = await fs3.readdir(await storePath(rootDir, ".mason/decisions"));
281
+ } catch (error) {
282
+ if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
283
+ return { records, diagnostics };
58
284
  }
59
- const records = [];
60
- for (const entry of entries) {
285
+ for (const entry of entries.sort()) {
61
286
  if (!entry.endsWith(".json")) continue;
287
+ const relative = `.mason/decisions/${entry}`;
62
288
  try {
63
- const raw = await fs3.readFile(
64
- path5.join(decisionsDir(rootDir), entry),
65
- "utf-8"
66
- );
67
- const parsed = JSON.parse(raw);
68
- if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
69
- continue;
70
- }
71
- records.push(parsed);
72
- } catch {
73
- continue;
289
+ const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
290
+ if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
291
+ records.push(record);
292
+ } catch (error) {
293
+ diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
74
294
  }
75
295
  }
76
- return records.sort((a, b) => a.id.localeCompare(b.id));
296
+ return { records, diagnostics };
77
297
  }
78
298
 
299
+ // src/hook/hook.ts
300
+ import { createHash as createHash2 } from "crypto";
301
+
79
302
  // src/decisions/drift.ts
80
- import path7 from "path";
303
+ import path9 from "path";
81
304
 
82
305
  // src/drift/drift.ts
83
306
  import fs4 from "fs/promises";
84
- import path6 from "path";
307
+ import path8 from "path";
85
308
  import { execFile as execFile3 } from "child_process";
86
309
  import { promisify as promisify3 } from "util";
87
310
  var exec3 = promisify3(execFile3);
88
- async function getChangesWithStatus(resolvedRoot, fromHash) {
89
- if (!fromHash || fromHash === "unknown") return null;
311
+ function parseChanges(output) {
312
+ const fields = output.split("\0");
313
+ const changes = [];
314
+ for (let i = 0; i < fields.length && fields[i]; ) {
315
+ const code = fields[i++];
316
+ const first = fields[i++];
317
+ if (!first) break;
318
+ const second = /^[RC]/.test(code) ? fields[i++] : void 0;
319
+ 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 };
320
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
321
+ changes.push(change);
322
+ }
323
+ return changes;
324
+ }
325
+ function touchedPaths(changes) {
326
+ return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
327
+ }
328
+ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
329
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
90
330
  try {
91
- const { stdout } = await exec3(
92
- "git",
93
- ["diff", "--name-status", "-M", fromHash, "HEAD"],
94
- { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
95
- );
96
- const changes = [];
97
- for (const line of stdout.split("\n")) {
98
- if (!line.trim()) continue;
99
- const parts = line.split(" ");
100
- if (parts.some((p) => p.startsWith(".mason/"))) continue;
101
- const code = parts[0];
102
- if (code.startsWith("R") && parts.length >= 3) {
103
- changes.push({
104
- status: "renamed",
105
- path: parts[2],
106
- previousPath: parts[1]
107
- });
108
- } else if (code.startsWith("C") && parts.length >= 3) {
109
- changes.push({ status: "added", path: parts[2] });
110
- } else if (code === "A" && parts.length >= 2) {
111
- changes.push({ status: "added", path: parts[1] });
112
- } else if (code === "D" && parts.length >= 2) {
113
- changes.push({ status: "deleted", path: parts[1] });
114
- } else if (parts.length >= 2) {
115
- changes.push({ status: "modified", path: parts[1] });
116
- }
117
- }
118
- return changes;
331
+ const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
332
+ return parseChanges(stdout);
119
333
  } catch {
120
334
  return null;
121
335
  }
122
336
  }
337
+ async function getWorkingTree(resolvedRoot) {
338
+ try {
339
+ const [diff, untracked] = await Promise.all([
340
+ exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
341
+ exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
342
+ ]);
343
+ const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
344
+ return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
345
+ } catch {
346
+ return { available: false, changedFiles: [], untrackedFiles: [] };
347
+ }
348
+ }
123
349
 
124
350
  // src/decisions/drift.ts
125
351
  async function computeDecisionDrift(rootDir, decisions) {
126
- const resolvedRoot = path7.resolve(rootDir);
127
- const records = decisions ?? await loadDecisions(resolvedRoot);
128
- const report = {
129
- historyAvailable: true,
130
- totalDecisions: records.length,
131
- staleDecisions: {}
132
- };
133
- const head = await getCurrentGitHash(resolvedRoot);
352
+ const resolvedRoot = path9.resolve(rootDir);
353
+ const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
354
+ const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
355
+ const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
134
356
  const changesByHash = /* @__PURE__ */ new Map();
135
- for (const record of records) {
136
- if (record.status !== "active" || record.files.length === 0) continue;
137
- if (record.refreshedHash === head) continue;
357
+ for (const record of store.records) {
358
+ if (record.status !== "active") continue;
359
+ if (record.files.length === 0) {
360
+ report.freshness[record.id] = "unknown";
361
+ continue;
362
+ }
138
363
  let touched = changesByHash.get(record.refreshedHash);
139
364
  if (touched === void 0) {
140
- const changes = await getChangesWithStatus(
141
- resolvedRoot,
142
- record.refreshedHash
143
- );
144
- if (changes === null) {
145
- touched = null;
146
- } else {
147
- touched = /* @__PURE__ */ new Set();
148
- for (const change of changes) {
149
- touched.add(change.path);
150
- if (change.previousPath) touched.add(change.previousPath);
151
- }
152
- }
365
+ const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
366
+ touched = changes === null ? null : touchedPaths(changes);
153
367
  changesByHash.set(record.refreshedHash, touched);
154
368
  }
155
- if (touched === null) {
156
- report.historyAvailable = false;
157
- continue;
158
- }
159
- const hits = record.files.filter((f) => touched.has(f));
160
- if (hits.length > 0) {
161
- report.staleDecisions[record.id] = hits;
162
- }
369
+ if (touched === null) report.historyAvailable = false;
370
+ const hits = touched ? matchingPaths(record.files, touched) : [];
371
+ if (hits.length) report.staleDecisions[record.id] = hits;
372
+ const localHits = matchingPaths(record.files, workingTree.changedFiles);
373
+ report.freshness[record.id] = touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current";
163
374
  }
164
375
  return report;
165
376
  }
@@ -179,19 +390,16 @@ async function exists(p) {
179
390
  async function findMasonRoot(startDir) {
180
391
  let dir = startDir;
181
392
  for (let i = 0; i < MAX_WALK_UP; i++) {
182
- if (await exists(path8.join(dir, ".mason", "decisions"))) return dir;
183
- if (await exists(path8.join(dir, ".git"))) return null;
184
- const parent = path8.dirname(dir);
393
+ if (await exists(path10.join(dir, ".mason", "decisions"))) return dir;
394
+ if (await exists(path10.join(dir, ".git"))) return null;
395
+ const parent = path10.dirname(dir);
185
396
  if (parent === dir) return null;
186
397
  dir = parent;
187
398
  }
188
399
  return null;
189
400
  }
190
401
  function anchorsCover(record, relPath) {
191
- return record.files.some((anchor) => {
192
- const a = anchor.replace(/\/+$/, "");
193
- return a === relPath || relPath.startsWith(`${a}/`);
194
- });
402
+ return record.files.some((anchor) => anchorMatches(anchor, relPath));
195
403
  }
196
404
  function exactAnchor(record, relPath) {
197
405
  return record.files.some((a) => a.replace(/\/+$/, "") === relPath);
@@ -208,15 +416,17 @@ async function loadInjected(stateFile) {
208
416
  return /* @__PURE__ */ new Set();
209
417
  }
210
418
  }
211
- function formatContext(relPath, records, staleIds) {
419
+ function formatContext(relPath, records, freshness) {
212
420
  const lines = [];
213
421
  lines.push(
214
- `Mason: recorded team knowledge anchored to ${relPath} \u2014 treat as constraints. Do not modify decision records in .mason/decisions/.`
422
+ `Mason: recorded knowledge anchored to ${relPath}. Accepted records are constraints subject to freshness; proposals are suggestions and legacy unreviewed records need confirmation. Retired or superseded records are no longer active. Do not modify decision records in .mason/decisions/.`
215
423
  );
216
424
  for (const record of records) {
217
- const stale = staleIds.has(record.id) ? " [recorded against an older commit \u2013 verify against current code before relying on it]" : "";
425
+ const provenance = decisionProvenance(record, freshness[record.id] ?? "unknown");
426
+ const label = record.status === "active" ? provenance.approval : record.status;
427
+ const stale = freshness[record.id] === "current" ? "" : freshness[record.id] === "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]";
218
428
  lines.push(
219
- `- [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")})${stale}`
429
+ `- [${label}] [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")}; owner: ${provenance.owner ?? "unknown"}; sources: ${provenance.sources.slice(0, 2).map((s) => s.reference).join(", ") || "unrecorded"})${stale}`
220
430
  );
221
431
  }
222
432
  return lines.join("\n");
@@ -228,33 +438,35 @@ async function runHook(stdinText, env = {}) {
228
438
  } catch {
229
439
  return null;
230
440
  }
441
+ if (!input || typeof input !== "object") return null;
231
442
  if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;
232
443
  const filePath = input.tool_input?.file_path;
233
444
  if (!filePath || typeof filePath !== "string") return null;
234
- const absPath = path8.isAbsolute(filePath) ? filePath : path8.resolve(input.cwd ?? process.cwd(), filePath);
235
- const root = await findMasonRoot(path8.dirname(absPath));
445
+ const absPath = path10.isAbsolute(filePath) ? filePath : path10.resolve(input.cwd ?? process.cwd(), filePath);
446
+ const root = await findMasonRoot(path10.dirname(absPath));
236
447
  if (!root) return null;
237
- const relPath = path8.relative(root, absPath).split(path8.sep).join("/");
448
+ const relPath = path10.relative(root, absPath).split(path10.sep).join("/");
238
449
  if (relPath.startsWith("..")) return null;
239
- const records = await loadDecisions(root);
240
- const matched = records.filter(
241
- (r) => r.status === "active" && anchorsCover(r, relPath)
242
- );
450
+ const { records } = await loadDecisionStore(root);
451
+ const matched = records.filter((r) => anchorsCover(r, relPath));
243
452
  if (matched.length === 0) return null;
244
453
  const stateDir = env.stateDir ?? os.tmpdir();
245
- const stateFile = path8.join(stateDir, `mason-hook-${stateKey(input)}.json`);
454
+ const stateFile = path10.join(stateDir, `mason-hook-${createHash2("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`);
246
455
  const injected = await loadInjected(stateFile);
247
- const fresh = matched.filter((r) => !injected.has(r.id));
456
+ const recordKey = (record) => `${record.id}:${createHash2("sha256").update(JSON.stringify(record)).digest("hex")}`;
457
+ const fresh = matched.filter((r) => !injected.has(recordKey(r)) && (r.status === "active" || [...injected].some((key) => key === r.id || key.startsWith(`${r.id}:`))));
248
458
  if (fresh.length === 0) return null;
249
459
  fresh.sort((a, b) => {
460
+ const withdrawn = Number(b.status !== "active") - Number(a.status !== "active");
461
+ if (withdrawn) return withdrawn;
250
462
  const exactDiff = Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath));
251
463
  if (exactDiff !== 0) return exactDiff;
252
464
  return b.updatedAt.localeCompare(a.updatedAt);
253
465
  });
254
466
  const selected = fresh.slice(0, MAX_INJECTED_DECISIONS);
255
467
  const drift = await computeDecisionDrift(root, selected);
256
- const staleIds = new Set(Object.keys(drift.staleDecisions));
257
- for (const record of selected) injected.add(record.id);
468
+ const freshness = drift.freshness ?? {};
469
+ for (const record of selected) injected.add(recordKey(record));
258
470
  try {
259
471
  await fs5.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
260
472
  } catch {
@@ -262,7 +474,7 @@ async function runHook(stdinText, env = {}) {
262
474
  return JSON.stringify({
263
475
  hookSpecificOutput: {
264
476
  hookEventName: "PostToolUse",
265
- additionalContext: formatContext(relPath, selected, staleIds)
477
+ additionalContext: formatContext(relPath, selected, freshness)
266
478
  }
267
479
  });
268
480
  }
@@ -297,17 +509,17 @@ var SETTINGS_CONFIG = {
297
509
  ]
298
510
  }
299
511
  };
300
- async function runHookCli(argv, stdinText, io = {
512
+ async function runHookCli(argv2, stdinText, io = {
301
513
  out: (line) => process.stdout.write(`${line}
302
514
  `),
303
515
  err: (line) => process.stderr.write(`${line}
304
516
  `)
305
517
  }, env = {}) {
306
- if (argv.includes("--help") || argv.includes("-h")) {
518
+ if (argv2.includes("--help") || argv2.includes("-h")) {
307
519
  io.out(USAGE);
308
520
  return 0;
309
521
  }
310
- if (argv.includes("--print-config")) {
522
+ if (argv2.includes("--print-config")) {
311
523
  io.out(JSON.stringify(SETTINGS_CONFIG, null, 2));
312
524
  return 0;
313
525
  }
@@ -328,5 +540,7 @@ async function readStdin() {
328
540
  }
329
541
  return Buffer.concat(chunks).toString("utf-8");
330
542
  }
331
- readStdin().then((stdinText) => runHookCli(process.argv.slice(2), stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0));
543
+ var argv = process.argv.slice(2);
544
+ var informational = argv.some((arg) => ["--help", "-h", "--print-config"].includes(arg));
545
+ (informational ? Promise.resolve("") : readStdin()).then((stdinText) => runHookCli(argv, stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0));
332
546
  //# sourceMappingURL=mason-hook.js.map