mason-context 0.10.1 → 0.11.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.
- package/CHANGELOG.md +10 -0
- package/README.md +24 -1
- package/dist/mason-audit.js +356 -23
- package/dist/mason-audit.js.map +1 -1
- package/dist/mason-mcp.js +2559 -2181
- package/dist/mason-mcp.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 — 2026-09-05
|
|
4
|
+
|
|
5
|
+
Mason now keeps the original audit evidence visible while an assistant repairs documentation. A dependency warning suppressed by local edits stays unresolved, and a later documentation commit does not silently clear its review requirement.
|
|
6
|
+
|
|
7
|
+
- Add `mason_repair` and `mason-audit --prepare-repair` / `--verify-repair` to retain original audit evidence through edits and the final documentation commit. Verification distinguishes resolved, unresolved, review-required, unverified, and new findings; it preserves unavailable history and missing-document diagnostics.
|
|
8
|
+
- Retain suppressed dependency advisories when setup or repairs dirty context files. An advisory disappearing after a documentation commit no longer loses its evidence in a prepared repair. Ordinary audit exit codes stay unchanged; explicit repair verification reports incomplete scope separately.
|
|
9
|
+
- Route authorized repairs through preparation and verification in assistant instructions and work orders. Setup alone does not authorize rewriting existing claims; advisories require a separate assessment.
|
|
10
|
+
|
|
11
|
+
Upgrade to `mason-context@0.11.0`, restart the assistant, and refresh its marker-delimited Mason instructions through `mason_init` to enable the repair workflow. No decision-store migration is required. Explicit repair verification exits 2 for incomplete checks or outstanding advisory review; ordinary audit exit codes are unchanged.
|
|
12
|
+
|
|
3
13
|
## 0.10.1 — 2026-09-05
|
|
4
14
|
|
|
5
15
|
Editing an accepted decision previously hid its accepted content from ordinary retrieval until the draft was reviewed. Mason now keeps the accepted constraint visible alongside the proposed replacement.
|
package/README.md
CHANGED
|
@@ -130,6 +130,7 @@ Mason records assertions of review; it does not authenticate reviewer identity,
|
|
|
130
130
|
| Tool | Purpose |
|
|
131
131
|
|---|---|
|
|
132
132
|
| `mason_init` | Read-only audit/review findings and quickstart guide; optional `base` for review, `evidence` for local CI manifests, `mode: "map"` for an architecture build. |
|
|
133
|
+
| `mason_repair` | Prepare an audit repair baseline; verify the same original findings after edits. Reports unresolved advisories and unavailable checks. |
|
|
133
134
|
| `mason_complete_init` | Records assistant instruction setup; preserves prior settings on repeated calls. |
|
|
134
135
|
| `generate_snapshot_batch` | Map step — returns one batch of files for the assistant to summarize. |
|
|
135
136
|
| `save_partial_snapshot` | Persists the partial map for one batch. |
|
|
@@ -272,7 +273,29 @@ Issues drive the exit code; **advisories never do** — they're facts an agent c
|
|
|
272
273
|
|
|
273
274
|
### The context files maintain themselves
|
|
274
275
|
|
|
275
|
-
|
|
276
|
+
### Track a repair through verification
|
|
277
|
+
|
|
278
|
+
Ask your assistant: *"Use Mason to prepare a repair, fix the documented issues within scope, and verify against the original findings."* The assistant calls `mason_repair` with `action: "prepare"`, makes grounded edits, and then calls it with `action: "verify"` and the returned `baselinePath`. Setup alone only installs assistant instructions; repairing existing claims needs to be part of your request.
|
|
279
|
+
|
|
280
|
+
The CLI provides the same workflow:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
mason-audit --dir . --prepare-repair --fix-prompt
|
|
284
|
+
# After applying the work order, use the exact baseline path it returned:
|
|
285
|
+
mason-audit --dir . --verify-repair .mason/reports/repairs/<id>.json
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Preparation saves the full original audit under `.mason/reports/repairs/`; it does not edit documentation. Ordinary audits and verification remain read-only. Add `.mason/reports/` to your ignore rules if you want these local artifacts excluded from commits. Keep the same baseline through any final documentation commit, then verify again. Do not regenerate it to clear unresolved findings. `--json` is supported for preparation and verification; use `--checks` only during preparation to select a scope.
|
|
289
|
+
|
|
290
|
+
Each original finding is **resolved** (its check no longer reports it), **unresolved**, **review-required**, or **unverified**. New findings are separate. A shifted line number does not erase the original claim, and a missing document, unavailable history, or skipped check cannot count as a fix. Inspect the edit for meaning: these deterministic checks do not establish complete documentation correctness. README files and arbitrary build commands are outside this audit's current scope.
|
|
291
|
+
|
|
292
|
+
Dependency evidence suppressed by local edits is retained in `suppressedAdvisories`, including when setup has already dirtied the document. Committing that document does not prove the dependency change was reviewed: the original advisory stays in the repair report. Record your assessment separately; this workflow does not approve advisories or decisions. Baselines are validated local evidence with a checksum to detect accidental edits, not authenticated attestations.
|
|
293
|
+
|
|
294
|
+
Ordinary audit exit codes remain **0** for no issues (advisories may exist), **1** for issues, and **2** for errors. Explicit `--verify-repair` uses **0** for verified scope, **1** for remaining/new issues, and **2** for incomplete verification, including advisories needing review or skipped checks. Incomplete verification takes precedence when both issues and unavailable evidence remain.
|
|
295
|
+
|
|
296
|
+
### Repair pull requests in CI
|
|
297
|
+
|
|
298
|
+
`--fix-prompt` emits a work order scoped to the flagged claims and the user's authorization. The reusable workflow below prepares a baseline, checks that the agent touched only context files, and verifies the original issues both before and after the documentation commit. It opens a PR only when those issues are resolved by their checks and no new issues appear. Advisories and skipped checks remain visible in the PR; their review is not a condition for proposing documentation repairs. The workflow never commits to the audited branch and skips when an audit PR is already open:
|
|
276
299
|
|
|
277
300
|
```yaml
|
|
278
301
|
name: Context audit
|
package/dist/mason-audit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/audit/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path17 from "path";
|
|
5
5
|
|
|
6
6
|
// src/audit/audit.ts
|
|
7
7
|
import fs9 from "fs/promises";
|
|
@@ -35,6 +35,10 @@ function normalizeRepoPath(value) {
|
|
|
35
35
|
const normalized = path.posix.normalize(slash).replace(/\/$/, "");
|
|
36
36
|
return normalized === "." ? null : normalized;
|
|
37
37
|
}
|
|
38
|
+
function isWithinRoot(root, candidate) {
|
|
39
|
+
const relative = path.relative(root, candidate);
|
|
40
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
41
|
+
}
|
|
38
42
|
function anchorMatches(anchor, file) {
|
|
39
43
|
const a = normalizeRepoPath(anchor);
|
|
40
44
|
const f = normalizeRepoPath(file);
|
|
@@ -132,6 +136,26 @@ async function readStoreJson(root, relative) {
|
|
|
132
136
|
throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
137
|
}
|
|
134
138
|
}
|
|
139
|
+
async function writeStoreJson(root, relative, value) {
|
|
140
|
+
const payload = JSON.stringify(value, null, 2) + "\n";
|
|
141
|
+
if (Buffer.byteLength(payload) > 10 * 1024 * 1024) {
|
|
142
|
+
throw new Error(`Mason store ${relative} exceeds 10 MiB`);
|
|
143
|
+
}
|
|
144
|
+
const file = await storePath(root, relative, true);
|
|
145
|
+
const temporary = path3.join(path3.dirname(file), `.${path3.basename(file)}.${randomUUID()}.tmp`);
|
|
146
|
+
try {
|
|
147
|
+
const handle = await fs2.open(temporary, "wx", 384);
|
|
148
|
+
try {
|
|
149
|
+
await handle.writeFile(payload, "utf8");
|
|
150
|
+
await handle.sync();
|
|
151
|
+
} finally {
|
|
152
|
+
await handle.close();
|
|
153
|
+
}
|
|
154
|
+
await fs2.rename(temporary, file);
|
|
155
|
+
} finally {
|
|
156
|
+
await fs2.rm(temporary, { force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
135
159
|
|
|
136
160
|
// src/snapshot/snapshot.ts
|
|
137
161
|
import { z } from "zod";
|
|
@@ -794,8 +818,8 @@ async function checkNewModules(ctx) {
|
|
|
794
818
|
const absTop = path9.join(ctx.root, topDir);
|
|
795
819
|
const topMentioned = isMentioned(combinedDocs, topDir);
|
|
796
820
|
if (!topMentioned) {
|
|
797
|
-
const
|
|
798
|
-
if (
|
|
821
|
+
const count2 = await countSourceFiles(absTop);
|
|
822
|
+
if (count2 >= 1) await flag(topDir, count2);
|
|
799
823
|
continue;
|
|
800
824
|
}
|
|
801
825
|
const subdirs = await listSubdirs(absTop);
|
|
@@ -803,9 +827,9 @@ async function checkNewModules(ctx) {
|
|
|
803
827
|
if (mentioned.length < ENUMERATION_THRESHOLD) continue;
|
|
804
828
|
for (const sub of subdirs) {
|
|
805
829
|
if (isMentioned(combinedDocs, sub)) continue;
|
|
806
|
-
const
|
|
807
|
-
if (
|
|
808
|
-
await flag(`${topDir}/${sub}`,
|
|
830
|
+
const count2 = await countSourceFiles(path9.join(absTop, sub));
|
|
831
|
+
if (count2 >= SECOND_LEVEL_MIN_SOURCE_FILES) {
|
|
832
|
+
await flag(`${topDir}/${sub}`, count2);
|
|
809
833
|
}
|
|
810
834
|
}
|
|
811
835
|
}
|
|
@@ -931,7 +955,15 @@ async function checkStaleCounts(ctx) {
|
|
|
931
955
|
for (const doc of ctx.docs) {
|
|
932
956
|
for (const claim of doc.claims.counts) {
|
|
933
957
|
const source = await resolveCountSource(ctx.root, claim);
|
|
934
|
-
if (source === null
|
|
958
|
+
if (source === null) {
|
|
959
|
+
result.skipped.push({
|
|
960
|
+
check: "stale-count",
|
|
961
|
+
doc: doc.path,
|
|
962
|
+
reason: `${doc.path}: cannot resolve a workspace manifest for "${claim.excerpt}"`
|
|
963
|
+
});
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (source.actual === claim.count) continue;
|
|
935
967
|
result.issues.push({
|
|
936
968
|
type: "stale-count",
|
|
937
969
|
message: `says "${claim.excerpt}" but ${source.countedFrom} resolves to ${source.actual}`,
|
|
@@ -1039,10 +1071,12 @@ var MANIFEST_PATHSPECS = [
|
|
|
1039
1071
|
];
|
|
1040
1072
|
async function checkDepsChanged(ctx) {
|
|
1041
1073
|
const result = emptyResult();
|
|
1074
|
+
result.suppressedAdvisories = [];
|
|
1042
1075
|
for (const doc of ctx.docs) {
|
|
1043
1076
|
if (!doc.lastCommit) {
|
|
1044
1077
|
result.skipped.push({
|
|
1045
1078
|
check: "deps-changed",
|
|
1079
|
+
doc: doc.path,
|
|
1046
1080
|
reason: `${doc.path} has no commit history`
|
|
1047
1081
|
});
|
|
1048
1082
|
continue;
|
|
@@ -1050,9 +1084,9 @@ async function checkDepsChanged(ctx) {
|
|
|
1050
1084
|
if (doc.dirty) {
|
|
1051
1085
|
result.skipped.push({
|
|
1052
1086
|
check: "deps-changed",
|
|
1087
|
+
doc: doc.path,
|
|
1053
1088
|
reason: `${doc.path} has uncommitted edits \u2013 suppressed while in flight`
|
|
1054
1089
|
});
|
|
1055
|
-
continue;
|
|
1056
1090
|
}
|
|
1057
1091
|
const range = await commitsTouchingSince(
|
|
1058
1092
|
ctx.root,
|
|
@@ -1062,13 +1096,14 @@ async function checkDepsChanged(ctx) {
|
|
|
1062
1096
|
if (range === null) {
|
|
1063
1097
|
result.skipped.push({
|
|
1064
1098
|
check: "deps-changed",
|
|
1099
|
+
doc: doc.path,
|
|
1065
1100
|
reason: `${doc.path}: commit range unreachable (shallow clone?)`
|
|
1066
1101
|
});
|
|
1067
1102
|
continue;
|
|
1068
1103
|
}
|
|
1069
1104
|
if (range.total === 0) continue;
|
|
1070
1105
|
const latest = range.commits[0];
|
|
1071
|
-
result.advisories.push({
|
|
1106
|
+
(doc.dirty ? result.suppressedAdvisories : result.advisories).push({
|
|
1072
1107
|
type: "deps-changed",
|
|
1073
1108
|
message: `dependency manifests touched by ${range.total} commit${range.total === 1 ? "" : "s"} since ${doc.path} was last committed (latest: ${latest.hash.slice(0, 7)} "${latest.subject}")`,
|
|
1074
1109
|
anchor: { doc: doc.path, line: null, excerpt: null },
|
|
@@ -1353,6 +1388,8 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1353
1388
|
version: 1,
|
|
1354
1389
|
root: resolvedRoot,
|
|
1355
1390
|
gitAvailable: headHash !== "unknown",
|
|
1391
|
+
headHash,
|
|
1392
|
+
checksRun: [],
|
|
1356
1393
|
docs: docs.map((d) => ({
|
|
1357
1394
|
path: d.path,
|
|
1358
1395
|
lastCommit: d.lastCommit,
|
|
@@ -1362,6 +1399,7 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1362
1399
|
decisionsChecked: false,
|
|
1363
1400
|
issues: [],
|
|
1364
1401
|
advisories: [],
|
|
1402
|
+
suppressedAdvisories: [],
|
|
1365
1403
|
skippedChecks: [],
|
|
1366
1404
|
clean: true
|
|
1367
1405
|
};
|
|
@@ -1390,15 +1428,263 @@ async function computeAudit(rootDir, options = {}) {
|
|
|
1390
1428
|
const selected = options.checks ?? ALL_CHECKS;
|
|
1391
1429
|
for (const name of ALL_CHECKS) {
|
|
1392
1430
|
if (!selected.includes(name)) continue;
|
|
1393
|
-
const { issues, advisories, skipped } = await CHECKS[name](ctx);
|
|
1431
|
+
const { issues, advisories, suppressedAdvisories, skipped } = await CHECKS[name](ctx);
|
|
1432
|
+
report.checksRun.push(name);
|
|
1394
1433
|
report.issues.push(...issues);
|
|
1395
1434
|
report.advisories.push(...advisories);
|
|
1435
|
+
report.suppressedAdvisories.push(...suppressedAdvisories ?? []);
|
|
1396
1436
|
report.skippedChecks.push(...skipped);
|
|
1397
1437
|
}
|
|
1398
1438
|
report.clean = report.issues.length === 0;
|
|
1399
1439
|
return report;
|
|
1400
1440
|
}
|
|
1401
1441
|
|
|
1442
|
+
// src/audit/repair.ts
|
|
1443
|
+
import fs10 from "fs/promises";
|
|
1444
|
+
import path16 from "path";
|
|
1445
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
1446
|
+
import { z as z3 } from "zod";
|
|
1447
|
+
var checkSchema = z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command", "deps-changed", "decision-anchor-drift"]);
|
|
1448
|
+
var commitSchema = z3.object({ hash: z3.string().regex(/^[a-f0-9]{40,64}$/), date: z3.string(), subject: z3.string() });
|
|
1449
|
+
var anchorSchema = z3.object({ doc: z3.string(), line: z3.number().int().positive().nullable(), excerpt: z3.string().nullable() });
|
|
1450
|
+
var count = z3.number().int().nonnegative();
|
|
1451
|
+
var evidenceSchema = z3.discriminatedUnion("kind", [
|
|
1452
|
+
z3.object({
|
|
1453
|
+
kind: z3.literal("missing-path"),
|
|
1454
|
+
claimed: z3.string(),
|
|
1455
|
+
renamedTo: z3.string().nullable(),
|
|
1456
|
+
deletedInCommit: commitSchema.nullable(),
|
|
1457
|
+
everTracked: z3.boolean(),
|
|
1458
|
+
parentDirExists: z3.boolean()
|
|
1459
|
+
}),
|
|
1460
|
+
z3.object({
|
|
1461
|
+
kind: z3.literal("unmentioned-dir"),
|
|
1462
|
+
dir: z3.string(),
|
|
1463
|
+
sourceFileCount: count,
|
|
1464
|
+
firstCommit: commitSchema.nullable(),
|
|
1465
|
+
checkedDocs: z3.array(z3.string())
|
|
1466
|
+
}),
|
|
1467
|
+
z3.object({
|
|
1468
|
+
kind: z3.literal("count-mismatch"),
|
|
1469
|
+
claimed: count,
|
|
1470
|
+
actual: count,
|
|
1471
|
+
unit: z3.string(),
|
|
1472
|
+
countedFrom: z3.string(),
|
|
1473
|
+
members: z3.array(z3.string())
|
|
1474
|
+
}),
|
|
1475
|
+
z3.object({
|
|
1476
|
+
kind: z3.literal("missing-script"),
|
|
1477
|
+
scriptName: z3.string(),
|
|
1478
|
+
invocation: z3.string(),
|
|
1479
|
+
manifestsChecked: z3.array(z3.string()),
|
|
1480
|
+
availableScripts: z3.array(z3.string())
|
|
1481
|
+
}),
|
|
1482
|
+
z3.object({
|
|
1483
|
+
kind: z3.literal("doc-behind-manifests"),
|
|
1484
|
+
docLastCommit: commitSchema,
|
|
1485
|
+
manifestCommits: z3.array(commitSchema.extend({ files: z3.array(z3.string()) })),
|
|
1486
|
+
totalCommits: count
|
|
1487
|
+
}),
|
|
1488
|
+
z3.object({
|
|
1489
|
+
kind: z3.literal("decision-anchor"),
|
|
1490
|
+
decisionId: z3.string(),
|
|
1491
|
+
title: z3.string(),
|
|
1492
|
+
changedFiles: z3.array(z3.string()),
|
|
1493
|
+
refreshedHash: z3.string(),
|
|
1494
|
+
provenance: z3.object({}).passthrough().optional()
|
|
1495
|
+
})
|
|
1496
|
+
]);
|
|
1497
|
+
var findingSchema = z3.object({ message: z3.string(), anchor: anchorSchema, evidence: evidenceSchema });
|
|
1498
|
+
var issueSchema = findingSchema.extend({
|
|
1499
|
+
type: z3.enum(["deleted-reference", "new-module", "stale-count", "dead-command"]),
|
|
1500
|
+
confidence: z3.enum(["certain", "likely"])
|
|
1501
|
+
});
|
|
1502
|
+
var advisorySchema = findingSchema.extend({ type: z3.enum(["deps-changed", "decision-anchor-drift"]) });
|
|
1503
|
+
var reportSchema = z3.object({
|
|
1504
|
+
version: z3.literal(1),
|
|
1505
|
+
root: z3.string(),
|
|
1506
|
+
gitAvailable: z3.literal(true),
|
|
1507
|
+
headHash: commitSchema.shape.hash,
|
|
1508
|
+
checksRun: z3.array(checkSchema).nonempty(),
|
|
1509
|
+
docs: z3.array(z3.object({
|
|
1510
|
+
path: z3.enum(DOC_CANDIDATES),
|
|
1511
|
+
lastCommit: commitSchema.nullable(),
|
|
1512
|
+
dirty: z3.boolean(),
|
|
1513
|
+
lineCount: count
|
|
1514
|
+
})).nonempty(),
|
|
1515
|
+
decisionsChecked: z3.boolean(),
|
|
1516
|
+
clean: z3.boolean(),
|
|
1517
|
+
issues: z3.array(issueSchema),
|
|
1518
|
+
advisories: z3.array(advisorySchema),
|
|
1519
|
+
suppressedAdvisories: z3.array(advisorySchema).optional(),
|
|
1520
|
+
skippedChecks: z3.array(z3.object({ check: z3.string(), reason: z3.string(), doc: z3.string().optional() }))
|
|
1521
|
+
});
|
|
1522
|
+
var baselineSchema = z3.object({
|
|
1523
|
+
kind: z3.literal("mason-audit-repair"),
|
|
1524
|
+
version: z3.literal(1),
|
|
1525
|
+
createdAt: z3.string().datetime(),
|
|
1526
|
+
report: reportSchema,
|
|
1527
|
+
digest: z3.string().regex(/^[a-f0-9]{64}$/)
|
|
1528
|
+
});
|
|
1529
|
+
var digest = (value) => createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1530
|
+
function findingId(finding) {
|
|
1531
|
+
const e = finding.evidence;
|
|
1532
|
+
let key;
|
|
1533
|
+
switch (e.kind) {
|
|
1534
|
+
case "missing-path":
|
|
1535
|
+
key = e.claimed;
|
|
1536
|
+
break;
|
|
1537
|
+
case "unmentioned-dir":
|
|
1538
|
+
key = e.dir;
|
|
1539
|
+
break;
|
|
1540
|
+
case "count-mismatch":
|
|
1541
|
+
key = [e.unit.replace(/s$/, ""), e.countedFrom];
|
|
1542
|
+
break;
|
|
1543
|
+
case "missing-script":
|
|
1544
|
+
key = e.scriptName;
|
|
1545
|
+
break;
|
|
1546
|
+
case "doc-behind-manifests":
|
|
1547
|
+
key = null;
|
|
1548
|
+
break;
|
|
1549
|
+
case "decision-anchor":
|
|
1550
|
+
key = [e.decisionId, e.provenance?.revision, e.provenance?.approval];
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
return digest([finding.type, finding.anchor.doc, key]);
|
|
1554
|
+
}
|
|
1555
|
+
function allFindings(report) {
|
|
1556
|
+
return [...report.issues, ...report.advisories, ...report.suppressedAdvisories ?? []];
|
|
1557
|
+
}
|
|
1558
|
+
async function docState(root) {
|
|
1559
|
+
const docs = [];
|
|
1560
|
+
for (const doc of DOC_CANDIDATES) {
|
|
1561
|
+
try {
|
|
1562
|
+
const content = await readBoundedFile(await storePath(root, doc), 10 * 1024 * 1024);
|
|
1563
|
+
if (content === null) throw new Error("Context file is not regular or exceeds 10 MiB: " + doc);
|
|
1564
|
+
docs.push([doc, digest(content)]);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
if (error.code !== "ENOENT") throw error;
|
|
1567
|
+
docs.push([doc, null]);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return digest(docs);
|
|
1571
|
+
}
|
|
1572
|
+
async function stableAudit(root, checks) {
|
|
1573
|
+
const head = await getCurrentGitHash(root);
|
|
1574
|
+
const before = await docState(root);
|
|
1575
|
+
const report = await computeAudit(root, { checks });
|
|
1576
|
+
if (head !== await getCurrentGitHash(root) || before !== await docState(root) || report && report.headHash !== head) {
|
|
1577
|
+
throw new Error("HEAD or context files changed during the audit; retry against a stable checkout.");
|
|
1578
|
+
}
|
|
1579
|
+
return report;
|
|
1580
|
+
}
|
|
1581
|
+
async function prepareRepair(rootDir, checks = ALL_CHECKS) {
|
|
1582
|
+
const root = await fs10.realpath(rootDir);
|
|
1583
|
+
const selected = z3.array(checkSchema).nonempty().parse(checks);
|
|
1584
|
+
const report = await stableAudit(root, selected);
|
|
1585
|
+
if (!report) throw new Error("No context files found to prepare a repair.");
|
|
1586
|
+
if (!report.gitAvailable) throw new Error("Readable Git history is required to prepare a repair.");
|
|
1587
|
+
const storedReport = reportSchema.parse(report);
|
|
1588
|
+
const payload = {
|
|
1589
|
+
kind: "mason-audit-repair",
|
|
1590
|
+
version: 1,
|
|
1591
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1592
|
+
report: storedReport
|
|
1593
|
+
};
|
|
1594
|
+
const baselinePath = ".mason/reports/repairs/" + randomUUID2() + ".json";
|
|
1595
|
+
await writeStoreJson(root, baselinePath, { ...payload, digest: digest(payload) });
|
|
1596
|
+
return { version: 1, action: "prepare", baselinePath, report };
|
|
1597
|
+
}
|
|
1598
|
+
async function verifyRepair(rootDir, baselinePath) {
|
|
1599
|
+
const root = await fs10.realpath(rootDir);
|
|
1600
|
+
const declaredRoot = path16.resolve(rootDir);
|
|
1601
|
+
const relative = path16.isAbsolute(baselinePath) ? path16.relative(isWithinRoot(declaredRoot, baselinePath) ? declaredRoot : root, baselinePath) : baselinePath;
|
|
1602
|
+
const stored = baselineSchema.parse(await readStoreJson(root, relative));
|
|
1603
|
+
const { digest: savedDigest, ...payload } = stored;
|
|
1604
|
+
if (digest(payload) !== savedDigest) throw new Error("Repair baseline was modified; use the original baseline.");
|
|
1605
|
+
if (stored.report.root !== root) throw new Error("Repair baseline belongs to a different repository.");
|
|
1606
|
+
const original = stored.report;
|
|
1607
|
+
const diagnostics = [];
|
|
1608
|
+
let current = null;
|
|
1609
|
+
try {
|
|
1610
|
+
current = await stableAudit(root, original.checksRun);
|
|
1611
|
+
if (!current) diagnostics.push("No context files remain available to audit.");
|
|
1612
|
+
else if (!current.gitAvailable) diagnostics.push("Git history is unavailable.");
|
|
1613
|
+
for (const doc of original.docs) {
|
|
1614
|
+
if (!original.issues.some((f) => f.anchor.doc === doc.path) || !current?.docs.some((d) => d.path === doc.path)) continue;
|
|
1615
|
+
const content = await readBoundedFile(await storePath(root, doc.path), 10 * 1024 * 1024);
|
|
1616
|
+
if (content === null || !content.trim()) {
|
|
1617
|
+
diagnostics.push("Original context file " + doc.path + " is empty or unreadable; losing its claims does not verify a repair.");
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
if (await getChangesWithStatus(root, original.headHash) === null) {
|
|
1621
|
+
diagnostics.push("The original audit commit is unavailable; repair history cannot be verified.");
|
|
1622
|
+
}
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
diagnostics.push(error instanceof Error ? error.message : String(error));
|
|
1625
|
+
}
|
|
1626
|
+
const currentById = new Map((current ? allFindings(current) : []).map((f) => [findingId(f), f]));
|
|
1627
|
+
const originalFindings = allFindings(original);
|
|
1628
|
+
const originalIds = new Set(originalFindings.map(findingId));
|
|
1629
|
+
const missingDocs = original.docs.filter((doc) => !current?.docs.some((d) => d.path === doc.path));
|
|
1630
|
+
for (const doc of missingDocs) diagnostics.push("Original context file " + doc.path + " is unavailable; removing it does not verify a repair.");
|
|
1631
|
+
const findings = originalFindings.map((finding) => {
|
|
1632
|
+
const id = findingId(finding);
|
|
1633
|
+
const now = currentById.get(id);
|
|
1634
|
+
const base = { id, original: finding, ...now ? { current: now } : {} };
|
|
1635
|
+
if (diagnostics.length || !current) {
|
|
1636
|
+
return { ...base, status: "unverified", reason: "The original audit scope could not be verified. See diagnostics." };
|
|
1637
|
+
}
|
|
1638
|
+
if ("confidence" in finding && now) {
|
|
1639
|
+
return { ...base, status: "unresolved", reason: "The original check still reports this claim." };
|
|
1640
|
+
}
|
|
1641
|
+
const skipped = current.skippedChecks.filter((s) => s.check === finding.type && (!s.doc || s.doc === finding.anchor.doc));
|
|
1642
|
+
if (!current.checksRun?.includes(finding.type) || skipped.length) {
|
|
1643
|
+
return { ...base, status: "unverified", reason: skipped.map((s) => s.reason).join("; ") || "The original check did not run." };
|
|
1644
|
+
}
|
|
1645
|
+
if (!("confidence" in finding)) {
|
|
1646
|
+
return {
|
|
1647
|
+
...base,
|
|
1648
|
+
status: "review-required",
|
|
1649
|
+
reason: "An audit cannot establish that this advisory was reviewed. Retain its original evidence and report a separate assessment; editing or committing the doc is not approval."
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
return { ...base, status: "resolved", reason: "The original check ran and no longer reports this claim. Inspect the edit for semantic correctness." };
|
|
1653
|
+
});
|
|
1654
|
+
const newFindings = [...currentById].filter(([id]) => !originalIds.has(id)).map(([, f]) => f);
|
|
1655
|
+
const counts = { resolved: 0, unresolved: 0, "review-required": 0, unverified: 0 };
|
|
1656
|
+
for (const f of findings) counts[f.status]++;
|
|
1657
|
+
const incomplete = diagnostics.length > 0 || counts.unverified > 0 || counts["review-required"] > 0 || (current?.skippedChecks.length ?? 0) > 0 || newFindings.some((f) => !("confidence" in f));
|
|
1658
|
+
const issuesRemain = counts.unresolved > 0 || newFindings.some((f) => "confidence" in f);
|
|
1659
|
+
return {
|
|
1660
|
+
version: 1,
|
|
1661
|
+
action: "verify",
|
|
1662
|
+
baselinePath: relative,
|
|
1663
|
+
baselineHead: original.headHash,
|
|
1664
|
+
currentHead: current?.gitAvailable ? current.headHash : null,
|
|
1665
|
+
status: incomplete ? "incomplete" : issuesRemain ? "issues-remain" : "verified",
|
|
1666
|
+
findings,
|
|
1667
|
+
newFindings,
|
|
1668
|
+
diagnostics,
|
|
1669
|
+
currentAudit: current,
|
|
1670
|
+
counts,
|
|
1671
|
+
scope: "Original audit checks over current context files and repository evidence. Resolved means no longer detected by that check. Advisories require separate review; this is not a certification of documentation or application correctness."
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
function repairExitCode(report) {
|
|
1675
|
+
return report.status === "verified" ? 0 : report.status === "issues-remain" ? 1 : 2;
|
|
1676
|
+
}
|
|
1677
|
+
function formatRepairSummary(report) {
|
|
1678
|
+
return [
|
|
1679
|
+
"Repair verification: " + report.status + ". Baseline: " + report.baselinePath,
|
|
1680
|
+
...report.findings.map((f) => " [" + f.status + "] " + f.original.type + " " + f.original.anchor.doc + ": " + f.original.message + "\n " + f.reason),
|
|
1681
|
+
...report.newFindings.map((f) => " [new] " + f.type + " " + f.anchor.doc + ": " + f.message),
|
|
1682
|
+
...report.diagnostics.map((d) => " [unverified] " + d),
|
|
1683
|
+
...(report.currentAudit?.skippedChecks ?? []).map((s) => " [skipped] " + s.check + ": " + s.reason),
|
|
1684
|
+
report.scope
|
|
1685
|
+
].join("\n");
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1402
1688
|
// src/audit/cli.ts
|
|
1403
1689
|
var USAGE = `Usage: mason-audit [--dir <path>] [--json | --fix-prompt] [--checks <list>]
|
|
1404
1690
|
|
|
@@ -1413,7 +1699,10 @@ Options:
|
|
|
1413
1699
|
--json Print the full audit report as JSON (additive-only schema)
|
|
1414
1700
|
--fix-prompt When issues exist, print a work order for ANY coding agent
|
|
1415
1701
|
(Claude, Codex, Gemini, ...) \u2013 pipe it to your agent CLI to
|
|
1416
|
-
|
|
1702
|
+
repair the findings. Includes advisories that require review.
|
|
1703
|
+
--prepare-repair Save the original audit under .mason/reports/repairs/ before edits
|
|
1704
|
+
--verify-repair <path>
|
|
1705
|
+
Compare against that saved baseline, using its original checks
|
|
1417
1706
|
--checks <list> Comma-separated subset of checks to run (default: all):
|
|
1418
1707
|
${ALL_CHECKS.join(", ")}
|
|
1419
1708
|
--help Show this help
|
|
@@ -1421,14 +1710,19 @@ Options:
|
|
|
1421
1710
|
Exit codes:
|
|
1422
1711
|
0 no issues (advisories may still be present)
|
|
1423
1712
|
1 provable issues found
|
|
1424
|
-
2 error (no context file, not a git repository, bad arguments)
|
|
1713
|
+
2 error (no context file, not a git repository, bad arguments)
|
|
1714
|
+
|
|
1715
|
+
With --verify-repair: 0 verified by the original checks; 1 issues remain;
|
|
1716
|
+
2 incomplete (unverified findings, skipped checks, or advisories needing review).
|
|
1717
|
+
Preparation writes only a baseline; verification and ordinary audits are read-only.`;
|
|
1425
1718
|
function parseArgs(argv) {
|
|
1426
1719
|
const parsed = {
|
|
1427
1720
|
dir: process.cwd(),
|
|
1428
1721
|
json: false,
|
|
1429
1722
|
fixPrompt: false,
|
|
1430
1723
|
help: false,
|
|
1431
|
-
checks: void 0
|
|
1724
|
+
checks: void 0,
|
|
1725
|
+
prepareRepair: false
|
|
1432
1726
|
};
|
|
1433
1727
|
for (let i = 0; i < argv.length; i++) {
|
|
1434
1728
|
const arg = argv[i];
|
|
@@ -1436,6 +1730,12 @@ function parseArgs(argv) {
|
|
|
1436
1730
|
parsed.json = true;
|
|
1437
1731
|
} else if (arg === "--fix-prompt") {
|
|
1438
1732
|
parsed.fixPrompt = true;
|
|
1733
|
+
} else if (arg === "--prepare-repair") {
|
|
1734
|
+
parsed.prepareRepair = true;
|
|
1735
|
+
} else if (arg === "--verify-repair") {
|
|
1736
|
+
const value = argv[++i];
|
|
1737
|
+
if (!value || value.startsWith("--")) throw new Error("--verify-repair requires a baseline path");
|
|
1738
|
+
parsed.baseline = value;
|
|
1439
1739
|
} else if (arg === "--help" || arg === "-h") {
|
|
1440
1740
|
parsed.help = true;
|
|
1441
1741
|
} else if (arg === "--dir") {
|
|
@@ -1446,6 +1746,7 @@ function parseArgs(argv) {
|
|
|
1446
1746
|
const value = argv[++i];
|
|
1447
1747
|
if (!value) throw new Error("--checks requires a comma-separated list");
|
|
1448
1748
|
const names = value.split(",").map((n) => n.trim()).filter(Boolean);
|
|
1749
|
+
if (!names.length) throw new Error("--checks requires at least one check");
|
|
1449
1750
|
for (const name of names) {
|
|
1450
1751
|
if (!ALL_CHECKS.includes(name)) {
|
|
1451
1752
|
throw new Error(
|
|
@@ -1469,6 +1770,7 @@ function issueLine(issue) {
|
|
|
1469
1770
|
}
|
|
1470
1771
|
function formatAuditSummary(report) {
|
|
1471
1772
|
const lines = [];
|
|
1773
|
+
const reviewCount = report.advisories.length + (report.suppressedAdvisories?.length ?? 0);
|
|
1472
1774
|
for (const doc of report.docs) {
|
|
1473
1775
|
const docIssues = report.issues.filter((i) => i.anchor.doc === doc.path);
|
|
1474
1776
|
const committed = doc.lastCommit ? `last committed ${doc.lastCommit.date.slice(0, 10)}, ${doc.lastCommit.hash.slice(0, 7)}` : "untracked";
|
|
@@ -1496,21 +1798,25 @@ function formatAuditSummary(report) {
|
|
|
1496
1798
|
lines.push(` [skipped] ${skip.check}: ${skip.reason}`);
|
|
1497
1799
|
}
|
|
1498
1800
|
}
|
|
1801
|
+
for (const advisory of report.suppressedAdvisories ?? []) {
|
|
1802
|
+
lines.push(` [suppressed; unresolved] ${advisory.type} ${advisory.anchor.doc}: ${advisory.message}`);
|
|
1803
|
+
}
|
|
1499
1804
|
lines.push(
|
|
1500
|
-
report.clean ? `Context files are clean (${report.docs.length} doc${report.docs.length === 1 ? "" : "s"} audited).` : `${report.issues.length} issue${report.issues.length === 1 ? "" : "s"} across ${report.docs.length} doc${report.docs.length === 1 ? "" : "s"}.`
|
|
1805
|
+
report.clean ? reviewCount || report.skippedChecks.length ? `No audit issues detected (${report.docs.length} docs audited); ${reviewCount} advisories remain for review, ${report.skippedChecks.length} checks skipped.` : `Context files are clean (${report.docs.length} doc${report.docs.length === 1 ? "" : "s"} audited).` : `${report.issues.length} issue${report.issues.length === 1 ? "" : "s"} across ${report.docs.length} doc${report.docs.length === 1 ? "" : "s"}.`
|
|
1501
1806
|
);
|
|
1502
1807
|
return lines.join("\n");
|
|
1503
1808
|
}
|
|
1504
|
-
function formatFixPrompt(report) {
|
|
1809
|
+
function formatFixPrompt(report, baselinePath) {
|
|
1505
1810
|
const flaggedDocs = [...new Set(report.issues.map((i) => i.anchor.doc))];
|
|
1506
1811
|
const lines = [];
|
|
1507
1812
|
lines.push(
|
|
1508
|
-
"
|
|
1813
|
+
"Review the flagged context claims using the evidence below. Make minimal repairs within the user's authorized scope. A setup-only or audit-only request does not authorize rewriting existing documentation."
|
|
1509
1814
|
);
|
|
1510
1815
|
lines.push("");
|
|
1511
1816
|
lines.push("RULES:");
|
|
1817
|
+
lines.push(baselinePath ? `- Preserve the original repair baseline: ${JSON.stringify(baselinePath)}. Do not replace it after editing.` : "- Before the first edit, call mason_repair with action: prepare, or run mason-audit --prepare-repair --json with the same --dir and --checks. Keep the returned baselinePath through verification.");
|
|
1512
1818
|
lines.push(
|
|
1513
|
-
`- Edit ONLY these files: ${flaggedDocs.join(", ")
|
|
1819
|
+
`- Edit ONLY these files: ${flaggedDocs.join(", ") || "none (advisory review only)"}. Bring the docs into agreement with verified source evidence; do not change source code or configs to silence findings.`
|
|
1514
1820
|
);
|
|
1515
1821
|
lines.push(
|
|
1516
1822
|
"- Keep diffs minimal: change the smallest span that makes each claim true."
|
|
@@ -1518,6 +1824,7 @@ function formatFixPrompt(report) {
|
|
|
1518
1824
|
lines.push(
|
|
1519
1825
|
"- Never invent content. Every replacement must be grounded in the evidence below or in files you read from this repository."
|
|
1520
1826
|
);
|
|
1827
|
+
lines.push("- Inspect likely findings before editing: heuristic evidence may describe an intentional omission or an example.");
|
|
1521
1828
|
lines.push(
|
|
1522
1829
|
"- deleted-reference: if evidence shows renamedTo, update the path; otherwise remove the reference, or rephrase to past tense if the sentence is about history. Deleted paths inside directory trees: delete the tree line."
|
|
1523
1830
|
);
|
|
@@ -1531,20 +1838,27 @@ function formatFixPrompt(report) {
|
|
|
1531
1838
|
"- new-module: add a one-line factual mention of the directory where sibling modules are described; read the directory's files first and describe only what you verified."
|
|
1532
1839
|
);
|
|
1533
1840
|
lines.push(
|
|
1534
|
-
"-
|
|
1841
|
+
"- ADVISORIES require a separate assessment of the cited commits or decision evidence. Report any review you perform and what remains unknown. Their disappearance after edits or a commit does not establish review or approval."
|
|
1535
1842
|
);
|
|
1536
1843
|
lines.push("");
|
|
1537
|
-
lines.push("AUDIT REPORT (
|
|
1844
|
+
lines.push("AUDIT REPORT (current context files and repository evidence, including local edits):");
|
|
1538
1845
|
lines.push(
|
|
1539
1846
|
JSON.stringify(
|
|
1540
|
-
{
|
|
1847
|
+
{
|
|
1848
|
+
root: report.root,
|
|
1849
|
+
checks: report.checksRun,
|
|
1850
|
+
issues: report.issues,
|
|
1851
|
+
advisories: report.advisories,
|
|
1852
|
+
suppressedAdvisories: report.suppressedAdvisories,
|
|
1853
|
+
skippedChecks: report.skippedChecks
|
|
1854
|
+
},
|
|
1541
1855
|
null,
|
|
1542
1856
|
2
|
|
1543
1857
|
)
|
|
1544
1858
|
);
|
|
1545
1859
|
lines.push("");
|
|
1546
1860
|
lines.push(
|
|
1547
|
-
"
|
|
1861
|
+
"After edits, call mason_repair with action: verify and the original baselinePath, or mason-audit --verify-repair <baselinePath> --dir <project>. Repeat against the same baseline after any final documentation commit. Summarize resolved, unresolved, review-required, unverified, and new findings with their evidence. Do not report a suppressed or unavailable check as fixed. This audit covers the listed context files; independently discovered README or application issues need their own validation."
|
|
1548
1862
|
);
|
|
1549
1863
|
return lines.join("\n");
|
|
1550
1864
|
}
|
|
@@ -1560,6 +1874,9 @@ async function runAuditCli(argv, io = {
|
|
|
1560
1874
|
if (args.json && args.fixPrompt) {
|
|
1561
1875
|
throw new Error("--json and --fix-prompt are mutually exclusive");
|
|
1562
1876
|
}
|
|
1877
|
+
if (args.baseline && (args.prepareRepair || args.checks || args.fixPrompt)) {
|
|
1878
|
+
throw new Error("--verify-repair cannot be combined with --prepare-repair, --checks, or --fix-prompt; verification uses the original scope");
|
|
1879
|
+
}
|
|
1563
1880
|
} catch (error) {
|
|
1564
1881
|
io.err(error instanceof Error ? error.message : String(error));
|
|
1565
1882
|
io.err(USAGE);
|
|
@@ -1569,7 +1886,23 @@ async function runAuditCli(argv, io = {
|
|
|
1569
1886
|
io.out(USAGE);
|
|
1570
1887
|
return 0;
|
|
1571
1888
|
}
|
|
1572
|
-
const rootDir =
|
|
1889
|
+
const rootDir = path17.resolve(args.dir);
|
|
1890
|
+
if (args.baseline || args.prepareRepair) {
|
|
1891
|
+
try {
|
|
1892
|
+
if (args.baseline) {
|
|
1893
|
+
const verification = await verifyRepair(rootDir, args.baseline);
|
|
1894
|
+
io.out(args.json ? JSON.stringify(verification, null, 2) : formatRepairSummary(verification));
|
|
1895
|
+
return repairExitCode(verification);
|
|
1896
|
+
}
|
|
1897
|
+
const prepared = await prepareRepair(rootDir, args.checks);
|
|
1898
|
+
io.out(args.json ? JSON.stringify({ ...prepared, workOrder: formatFixPrompt(prepared.report, prepared.baselinePath) }, null, 2) : args.fixPrompt ? formatFixPrompt(prepared.report, prepared.baselinePath) : `Repair baseline: ${prepared.baselinePath}
|
|
1899
|
+
${formatAuditSummary(prepared.report)}`);
|
|
1900
|
+
return prepared.report.clean ? 0 : 1;
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
1903
|
+
return 2;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1573
1906
|
const report = await computeAudit(rootDir, { checks: args.checks });
|
|
1574
1907
|
if (!report) {
|
|
1575
1908
|
io.err(
|
|
@@ -1585,7 +1918,7 @@ async function runAuditCli(argv, io = {
|
|
|
1585
1918
|
}
|
|
1586
1919
|
if (args.fixPrompt) {
|
|
1587
1920
|
io.out(
|
|
1588
|
-
report.clean ? formatAuditSummary(report) : formatFixPrompt(report)
|
|
1921
|
+
report.clean && !report.advisories.length && !report.suppressedAdvisories?.length ? formatAuditSummary(report) : formatFixPrompt(report)
|
|
1589
1922
|
);
|
|
1590
1923
|
return report.clean ? 0 : 1;
|
|
1591
1924
|
}
|