mason-context 0.9.0 → 0.10.1

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,279 @@ 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
+
157
+ // src/context/trust.ts
158
+ function assessTrust(entry, freshness) {
159
+ const verification = entry.verificationFailed ? "failed" : entry.verifiedAt ? "passed" : "unverified";
160
+ const reasons = [];
161
+ if (freshness === "unknown") reasons.push("Anchors, history, or working-tree evidence are unavailable; verify before relying on this entry.");
162
+ if (freshness === "changed") reasons.push("Anchored files changed; verify against current code before relying on this entry.");
163
+ if (verification === "failed") reasons.push(`Verification failed: ${entry.verificationNote ?? "re-map this entry before relying on it"}`);
164
+ if (verification === "unverified") reasons.push("No correctness verification has been recorded.");
165
+ return { freshness, verification, verifiedAt: entry.verifiedAt, verifiedHash: entry.verifiedHash, reasons };
166
+ }
167
+
168
+ // src/decisions/provenance.ts
169
+ var text = (max) => z2.string().trim().min(1).max(max);
170
+ var decisionSourceSchema = z2.object({
171
+ kind: z2.enum(["pull_request", "issue", "incident", "discussion", "document", "other"]),
172
+ reference: text(1e3),
173
+ note: text(500).optional()
174
+ }).strict();
175
+ var attributionSchema = z2.object({
176
+ owner: text(200).nullable().optional(),
177
+ sources: z2.array(decisionSourceSchema).max(20).optional(),
178
+ actor: text(200).optional()
179
+ });
180
+ var contentSchema = z2.object({
181
+ title: z2.string().min(1),
182
+ body: z2.string().min(1),
183
+ category: z2.enum(["decision", "gotcha", "deprecation", "convention"]),
184
+ files: z2.array(z2.string().refine((f) => normalizeRepoPath(f) !== null)),
185
+ owner: text(200).optional(),
186
+ sources: z2.array(decisionSourceSchema).max(20)
187
+ });
188
+ var approvalSchema = z2.enum(["unreviewed", "proposed", "accepted"]);
189
+ var statusSchema = z2.enum(["active", "superseded", "retired"]);
190
+ var reviewEvidenceSchema = z2.object({
191
+ baseHash: z2.string(),
192
+ headHash: z2.string(),
193
+ historyAvailable: z2.boolean(),
194
+ changedFiles: z2.array(z2.string()),
195
+ localChanges: z2.array(z2.string())
196
+ });
197
+ var eventSchema = z2.object({
198
+ kind: z2.enum(["imported", "created", "revised", "accepted", "reaffirmed", "retired", "superseded"]),
199
+ at: z2.string().datetime(),
200
+ actor: text(200).optional(),
201
+ note: text(1500).optional(),
202
+ revision: z2.number().int().positive(),
203
+ content: contentSchema,
204
+ approval: approvalSchema,
205
+ status: statusSchema,
206
+ refreshedHash: z2.string(),
207
+ evidence: reviewEvidenceSchema.optional()
208
+ });
209
+ var legacySchema = z2.object({
210
+ version: z2.literal(1),
211
+ id: z2.string().regex(/^[a-zA-Z0-9_-]+$/),
212
+ title: z2.string().min(1),
213
+ body: z2.string().min(1),
214
+ category: contentSchema.shape.category,
215
+ files: contentSchema.shape.files,
216
+ createdAt: z2.string(),
217
+ updatedAt: z2.string(),
218
+ refreshedHash: z2.string(),
219
+ status: z2.enum(["active", "superseded"]),
220
+ supersededBy: z2.string().optional()
221
+ }).passthrough();
222
+ var currentSchema = legacySchema.extend({
223
+ version: z2.literal(2),
224
+ status: statusSchema,
225
+ approval: approvalSchema,
226
+ revision: z2.number().int().positive(),
227
+ owner: text(200).optional(),
228
+ sources: z2.array(decisionSourceSchema).max(20),
229
+ history: z2.array(eventSchema).min(1)
230
+ }).superRefine((record, ctx) => {
231
+ const invalid = (message) => ctx.addIssue({ code: "custom", message });
232
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
233
+ let previous;
234
+ for (const event of record.history) {
235
+ if (!previous) {
236
+ if (!["created", "imported"].includes(event.kind) || event.revision !== 1) invalid("History must begin with creation or legacy import at revision 1");
237
+ if (event.approval !== (event.kind === "created" ? "proposed" : "unreviewed")) invalid("Initial records cannot claim acceptance");
238
+ } else {
239
+ if (["created", "imported"].includes(event.kind)) invalid("History cannot restart");
240
+ if (previous.status !== "active") invalid("Archived decisions cannot be changed");
241
+ if (event.revision !== previous.revision + (event.kind === "revised" ? 1 : 0)) invalid("Invalid revision sequence");
242
+ if (event.kind !== "revised" && !same(event.content, previous.content)) invalid("A review cannot silently revise decision content");
243
+ if (event.kind === "reaffirmed" && previous.approval !== "accepted") invalid("Only accepted decisions can be reaffirmed");
244
+ if (event.kind === "accepted" && previous.approval === "accepted") invalid("Use reaffirmation for an accepted decision");
245
+ const approval = event.kind === "revised" ? "proposed" : ["accepted", "reaffirmed"].includes(event.kind) ? "accepted" : previous.approval;
246
+ if (event.approval !== approval) invalid("Approval disagrees with review history");
247
+ if (!["accepted", "reaffirmed"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid("Only a review can refresh the evidence baseline");
248
+ }
249
+ if (event.kind !== "imported" && event.status !== (event.kind === "retired" ? "retired" : event.kind === "superseded" ? "superseded" : "active")) invalid("Lifecycle disagrees with history");
250
+ if (["accepted", "reaffirmed", "retired"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid("Reviews require a named reviewer, reason, and code evidence");
251
+ if (["accepted", "reaffirmed"].includes(event.kind)) {
252
+ if (!event.content.owner || !event.content.sources.length) invalid("Accepted decisions require an owner and source");
253
+ 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");
254
+ }
255
+ previous = event;
256
+ }
257
+ 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");
258
+ });
259
+ var decisionSchema = z2.union([legacySchema, currentSchema]);
260
+ function decisionContent(record) {
261
+ return {
262
+ title: record.title,
263
+ body: record.body,
264
+ category: record.category,
265
+ files: record.files,
266
+ ...typeof record.owner === "string" ? { owner: record.owner } : {},
267
+ sources: Array.isArray(record.sources) ? record.sources : []
268
+ };
269
+ }
270
+ function decisionApproval(record) {
271
+ return record.version === 1 ? "unreviewed" : record.approval;
272
+ }
273
+ function effectiveDecision(record) {
274
+ if (record.version !== 2 || record.status !== "active" || record.approval !== "proposed") return record;
275
+ let index = record.history.length - 1;
276
+ while (index >= 0 && !["accepted", "reaffirmed"].includes(record.history[index].kind)) index--;
277
+ if (index < 0) return record;
278
+ const event = record.history[index];
279
+ return {
280
+ ...record,
281
+ ...event.content,
282
+ owner: event.content.owner,
283
+ approval: "accepted",
284
+ revision: event.revision,
285
+ refreshedHash: event.refreshedHash,
286
+ updatedAt: event.at,
287
+ history: record.history.slice(0, index + 1)
288
+ };
289
+ }
290
+ function decisionAnchors(record) {
291
+ return [.../* @__PURE__ */ new Set([...effectiveDecision(record).files, ...record.files])];
292
+ }
293
+ function decisionProvenance(record, freshness = "unknown") {
294
+ const approval = decisionApproval(record);
295
+ const review = record.version === 2 ? [...record.history].reverse().find((e) => ["accepted", "reaffirmed"].includes(e.kind) && e.revision === record.revision) : void 0;
296
+ return {
297
+ approval,
298
+ revision: record.version === 2 ? record.revision : 0,
299
+ owner: record.version === 2 ? record.owner ?? null : null,
300
+ sources: record.version === 2 ? record.sources : [],
301
+ guidance: record.status !== "active" ? "historical" : approval === "accepted" ? "constraint" : approval === "proposed" ? "proposal" : "unreviewed",
302
+ reviewRequired: record.status === "active" && (approval !== "accepted" || freshness !== "current"),
303
+ lastReview: review ? { reviewer: review.actor, at: review.at, note: review.note, gitHash: review.refreshedHash } : null
304
+ };
305
+ }
306
+ function decisionTrust(record, freshness) {
307
+ const review = decisionProvenance(record, freshness).lastReview;
308
+ return assessTrust(review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {}, freshness);
309
+ }
310
+ function revisionKnowledge(record, freshness) {
311
+ return { ...decisionContent(record), ...decisionProvenance(record, freshness), trust: decisionTrust(record, freshness) };
312
+ }
313
+ function decisionKnowledge(record, freshness = "unknown", proposalFreshness = "unknown") {
314
+ const effective = effectiveDecision(record);
315
+ return {
316
+ ...revisionKnowledge(effective, freshness),
317
+ ...effective !== record ? { pendingProposal: revisionKnowledge(record, proposalFreshness) } : {}
318
+ };
51
319
  }
52
- async function loadDecisions(rootDir) {
320
+ 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.";
321
+
322
+ // src/decisions/decisions.ts
323
+ async function loadDecisionStore(rootDir) {
324
+ const records = [];
325
+ const diagnostics = [];
53
326
  let entries;
54
327
  try {
55
- entries = await fs3.readdir(decisionsDir(rootDir));
56
- } catch {
57
- return [];
328
+ entries = await fs3.readdir(await storePath(rootDir, ".mason/decisions"));
329
+ } catch (error) {
330
+ if (error.code !== "ENOENT") diagnostics.push({ path: ".mason/decisions", message: String(error) });
331
+ return { records, diagnostics };
58
332
  }
59
- const records = [];
60
- for (const entry of entries) {
333
+ for (const entry of entries.sort()) {
61
334
  if (!entry.endsWith(".json")) continue;
335
+ const relative = `.mason/decisions/${entry}`;
62
336
  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;
337
+ const record = decisionSchema.parse(await readStoreJson(rootDir, relative));
338
+ if (entry !== `${record.id}.json`) throw new Error("Record id does not match its filename");
339
+ records.push(record);
340
+ } catch (error) {
341
+ diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) });
74
342
  }
75
343
  }
76
- return records.sort((a, b) => a.id.localeCompare(b.id));
344
+ return { records, diagnostics };
77
345
  }
78
346
 
347
+ // src/hook/hook.ts
348
+ import { createHash as createHash2 } from "crypto";
349
+
79
350
  // src/decisions/drift.ts
80
- import path7 from "path";
351
+ import path9 from "path";
81
352
 
82
353
  // src/drift/drift.ts
83
354
  import fs4 from "fs/promises";
84
- import path6 from "path";
355
+ import path8 from "path";
85
356
  import { execFile as execFile3 } from "child_process";
86
357
  import { promisify as promisify3 } from "util";
87
358
  var exec3 = promisify3(execFile3);
88
- async function getChangesWithStatus(resolvedRoot, fromHash) {
89
- if (!fromHash || fromHash === "unknown") return null;
359
+ function parseChanges(output) {
360
+ const fields = output.split("\0");
361
+ const changes = [];
362
+ for (let i = 0; i < fields.length && fields[i]; ) {
363
+ const code = fields[i++];
364
+ const first = fields[i++];
365
+ if (!first) break;
366
+ const second = /^[RC]/.test(code) ? fields[i++] : void 0;
367
+ 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 };
368
+ if (change.path.startsWith(".mason/") && (!change.previousPath || change.previousPath.startsWith(".mason/"))) continue;
369
+ changes.push(change);
370
+ }
371
+ return changes;
372
+ }
373
+ function touchedPaths(changes) {
374
+ return [...new Set(changes.flatMap((c) => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();
375
+ }
376
+ async function getChangesWithStatus(resolvedRoot, fromHash, toHash = "HEAD") {
377
+ if (!fromHash || fromHash === "unknown" || fromHash.startsWith("-") || !toHash || toHash === "unknown" || toHash.startsWith("-")) return null;
90
378
  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;
379
+ const { stdout } = await exec3("git", ["diff", "--name-status", "-z", "-M", fromHash, toHash, "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });
380
+ return parseChanges(stdout);
119
381
  } catch {
120
382
  return null;
121
383
  }
122
384
  }
385
+ async function getWorkingTree(resolvedRoot) {
386
+ try {
387
+ const [diff, untracked] = await Promise.all([
388
+ exec3("git", ["diff", "--name-status", "-z", "-M", "HEAD", "--"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),
389
+ exec3("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 })
390
+ ]);
391
+ const untrackedFiles = untracked.stdout.split("\0").filter((f) => f && !f.startsWith(".mason/"));
392
+ return { available: true, changedFiles: [.../* @__PURE__ */ new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };
393
+ } catch {
394
+ return { available: false, changedFiles: [], untrackedFiles: [] };
395
+ }
396
+ }
123
397
 
124
398
  // src/decisions/drift.ts
125
399
  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);
400
+ const resolvedRoot = path9.resolve(rootDir);
401
+ const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);
402
+ const report = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };
403
+ const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);
134
404
  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;
405
+ const inspect = async (record) => {
406
+ if (record.files.length === 0) return { freshness: "unknown", changedFiles: [] };
138
407
  let touched = changesByHash.get(record.refreshedHash);
139
408
  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
- }
409
+ const changes = record.refreshedHash === head && head !== "unknown" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);
410
+ touched = changes === null ? null : touchedPaths(changes);
153
411
  changesByHash.set(record.refreshedHash, touched);
154
412
  }
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
- }
413
+ if (touched === null) report.historyAvailable = false;
414
+ const hits = touched ? matchingPaths(record.files, touched) : [];
415
+ const localHits = matchingPaths(record.files, workingTree.changedFiles);
416
+ return { freshness: touched === null || !workingTree.available ? "unknown" : hits.length || localHits.length ? "changed" : "current", changedFiles: hits };
417
+ };
418
+ for (const record of store.records) {
419
+ if (record.status !== "active") continue;
420
+ const effective = effectiveDecision(record);
421
+ const state = await inspect(effective);
422
+ report.freshness[record.id] = state.freshness;
423
+ if (state.changedFiles.length) report.staleDecisions[record.id] = state.changedFiles;
424
+ if (effective !== record) (report.pendingProposals ??= {})[record.id] = await inspect(record);
163
425
  }
164
426
  return report;
165
427
  }
@@ -179,22 +441,19 @@ async function exists(p) {
179
441
  async function findMasonRoot(startDir) {
180
442
  let dir = startDir;
181
443
  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);
444
+ if (await exists(path10.join(dir, ".mason", "decisions"))) return dir;
445
+ if (await exists(path10.join(dir, ".git"))) return null;
446
+ const parent = path10.dirname(dir);
185
447
  if (parent === dir) return null;
186
448
  dir = parent;
187
449
  }
188
450
  return null;
189
451
  }
190
452
  function anchorsCover(record, relPath) {
191
- return record.files.some((anchor) => {
192
- const a = anchor.replace(/\/+$/, "");
193
- return a === relPath || relPath.startsWith(`${a}/`);
194
- });
453
+ return decisionAnchors(record).some((anchor) => anchorMatches(anchor, relPath));
195
454
  }
196
455
  function exactAnchor(record, relPath) {
197
- return record.files.some((a) => a.replace(/\/+$/, "") === relPath);
456
+ return decisionAnchors(record).some((a) => a.replace(/\/+$/, "") === relPath);
198
457
  }
199
458
  function stateKey(input) {
200
459
  const raw = `${input.session_id ?? "nosession"}${input.agent_id ? `-${input.agent_id}` : ""}`;
@@ -208,16 +467,21 @@ async function loadInjected(stateFile) {
208
467
  return /* @__PURE__ */ new Set();
209
468
  }
210
469
  }
211
- function formatContext(relPath, records, staleIds) {
470
+ function formatContext(relPath, records, drift) {
212
471
  const lines = [];
213
472
  lines.push(
214
- `Mason: recorded team knowledge anchored to ${relPath} \u2014 treat as constraints. Do not modify decision records in .mason/decisions/.`
473
+ `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/.`
215
474
  );
216
- 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]" : "";
475
+ const append = (id, knowledge, label, freshness) => {
476
+ 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]";
218
477
  lines.push(
219
- `- [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")})${stale}`
478
+ `- [${label}] [${knowledge.category}] ${knowledge.title}: ${knowledge.body} (id: ${id}; revision: ${knowledge.revision}; anchors: ${knowledge.files.join(", ")}; owner: ${knowledge.owner ?? "unknown"}; sources: ${knowledge.sources.slice(0, 2).map((s) => s.reference).join(", ") || "unrecorded"})${stale}`
220
479
  );
480
+ };
481
+ for (const record of records) {
482
+ const knowledge = decisionKnowledge(record, drift.freshness?.[record.id] ?? "unknown", drift.pendingProposals?.[record.id]?.freshness ?? "unknown");
483
+ append(record.id, knowledge, record.status === "active" ? knowledge.approval : record.status, knowledge.trust.freshness);
484
+ if (knowledge.pendingProposal) append(record.id, knowledge.pendingProposal, "proposed", knowledge.pendingProposal.trust.freshness);
221
485
  }
222
486
  return lines.join("\n");
223
487
  }
@@ -228,33 +492,33 @@ async function runHook(stdinText, env = {}) {
228
492
  } catch {
229
493
  return null;
230
494
  }
495
+ if (!input || typeof input !== "object") return null;
231
496
  if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;
232
497
  const filePath = input.tool_input?.file_path;
233
498
  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));
499
+ const absPath = path10.isAbsolute(filePath) ? filePath : path10.resolve(input.cwd ?? process.cwd(), filePath);
500
+ const root = await findMasonRoot(path10.dirname(absPath));
236
501
  if (!root) return null;
237
- const relPath = path8.relative(root, absPath).split(path8.sep).join("/");
502
+ const relPath = path10.relative(root, absPath).split(path10.sep).join("/");
238
503
  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
- );
243
- if (matched.length === 0) return null;
504
+ const { records } = await loadDecisionStore(root);
244
505
  const stateDir = env.stateDir ?? os.tmpdir();
245
- const stateFile = path8.join(stateDir, `mason-hook-${stateKey(input)}.json`);
506
+ const stateFile = path10.join(stateDir, `mason-hook-${createHash2("sha256").update(root).digest("hex").slice(0, 12)}-${stateKey(input)}.json`);
246
507
  const injected = await loadInjected(stateFile);
247
- const fresh = matched.filter((r) => !injected.has(r.id));
508
+ const recordKey = (record) => `${record.id}:${createHash2("sha256").update(JSON.stringify(record)).digest("hex")}`;
509
+ const previouslyInjected = (record) => [...injected].some((key) => key === record.id || key.startsWith(`${record.id}:`));
510
+ const fresh = records.filter((r) => !injected.has(recordKey(r)) && (previouslyInjected(r) || r.status === "active" && anchorsCover(r, relPath)));
248
511
  if (fresh.length === 0) return null;
249
512
  fresh.sort((a, b) => {
513
+ const withdrawn = Number(b.status !== "active") - Number(a.status !== "active");
514
+ if (withdrawn) return withdrawn;
250
515
  const exactDiff = Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath));
251
516
  if (exactDiff !== 0) return exactDiff;
252
517
  return b.updatedAt.localeCompare(a.updatedAt);
253
518
  });
254
519
  const selected = fresh.slice(0, MAX_INJECTED_DECISIONS);
255
520
  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);
521
+ for (const record of selected) injected.add(recordKey(record));
258
522
  try {
259
523
  await fs5.writeFile(stateFile, JSON.stringify([...injected]), "utf-8");
260
524
  } catch {
@@ -262,7 +526,7 @@ async function runHook(stdinText, env = {}) {
262
526
  return JSON.stringify({
263
527
  hookSpecificOutput: {
264
528
  hookEventName: "PostToolUse",
265
- additionalContext: formatContext(relPath, selected, staleIds)
529
+ additionalContext: formatContext(relPath, selected, drift)
266
530
  }
267
531
  });
268
532
  }
@@ -297,17 +561,17 @@ var SETTINGS_CONFIG = {
297
561
  ]
298
562
  }
299
563
  };
300
- async function runHookCli(argv, stdinText, io = {
564
+ async function runHookCli(argv2, stdinText, io = {
301
565
  out: (line) => process.stdout.write(`${line}
302
566
  `),
303
567
  err: (line) => process.stderr.write(`${line}
304
568
  `)
305
569
  }, env = {}) {
306
- if (argv.includes("--help") || argv.includes("-h")) {
570
+ if (argv2.includes("--help") || argv2.includes("-h")) {
307
571
  io.out(USAGE);
308
572
  return 0;
309
573
  }
310
- if (argv.includes("--print-config")) {
574
+ if (argv2.includes("--print-config")) {
311
575
  io.out(JSON.stringify(SETTINGS_CONFIG, null, 2));
312
576
  return 0;
313
577
  }
@@ -328,5 +592,7 @@ async function readStdin() {
328
592
  }
329
593
  return Buffer.concat(chunks).toString("utf-8");
330
594
  }
331
- readStdin().then((stdinText) => runHookCli(process.argv.slice(2), stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0));
595
+ var argv = process.argv.slice(2);
596
+ var informational = argv.some((arg) => ["--help", "-h", "--print-config"].includes(arg));
597
+ (informational ? Promise.resolve("") : readStdin()).then((stdinText) => runHookCli(argv, stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0));
332
598
  //# sourceMappingURL=mason-hook.js.map