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.
- package/CHANGELOG.md +34 -0
- package/README.md +171 -37
- package/dist/mason-audit.js +395 -153
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-drift.js +504 -209
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-hook.js +392 -126
- package/dist/mason-hook.js.map +1 -1
- package/dist/mason-mcp.js +3506 -1465
- package/dist/mason-mcp.js.map +1 -1
- package/dist/mason-review.js +1048 -105
- package/dist/mason-review.js.map +1 -1
- package/package.json +7 -2
package/dist/mason-hook.js
CHANGED
|
@@ -2,35 +2,141 @@
|
|
|
2
2
|
|
|
3
3
|
// src/hook/hook.ts
|
|
4
4
|
import fs5 from "fs/promises";
|
|
5
|
-
import
|
|
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
|
|
10
|
+
import path7 from "path";
|
|
11
11
|
import { createHash } from "crypto";
|
|
12
12
|
|
|
13
|
-
// src/
|
|
13
|
+
// src/utils/storage.ts
|
|
14
14
|
import fs2 from "fs/promises";
|
|
15
15
|
import path3 from "path";
|
|
16
|
-
import {
|
|
17
|
-
import { promisify as promisify2 } from "util";
|
|
18
|
-
import fg3 from "fast-glob";
|
|
16
|
+
import { randomUUID } from "crypto";
|
|
19
17
|
|
|
20
|
-
// src/
|
|
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
|
|
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
|
|
152
|
+
import path6 from "path";
|
|
47
153
|
|
|
48
|
-
// src/decisions/
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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(
|
|
56
|
-
} catch {
|
|
57
|
-
|
|
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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
351
|
+
import path9 from "path";
|
|
81
352
|
|
|
82
353
|
// src/drift/drift.ts
|
|
83
354
|
import fs4 from "fs/promises";
|
|
84
|
-
import
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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 =
|
|
127
|
-
const
|
|
128
|
-
const report = {
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
if (record.
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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(
|
|
183
|
-
if (await exists(
|
|
184
|
-
const parent =
|
|
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.
|
|
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.
|
|
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,
|
|
470
|
+
function formatContext(relPath, records, drift) {
|
|
212
471
|
const lines = [];
|
|
213
472
|
lines.push(
|
|
214
|
-
`Mason:
|
|
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
|
-
|
|
217
|
-
const stale =
|
|
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
|
-
`- [${
|
|
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 =
|
|
235
|
-
const root = await findMasonRoot(
|
|
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 =
|
|
502
|
+
const relPath = path10.relative(root, absPath).split(path10.sep).join("/");
|
|
238
503
|
if (relPath.startsWith("..")) return null;
|
|
239
|
-
const records = await
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
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 (
|
|
570
|
+
if (argv2.includes("--help") || argv2.includes("-h")) {
|
|
307
571
|
io.out(USAGE);
|
|
308
572
|
return 0;
|
|
309
573
|
}
|
|
310
|
-
if (
|
|
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
|
-
|
|
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
|