tamperward 1.1.0-rc.2 → 1.2.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/README.md CHANGED
@@ -92,15 +92,18 @@ with the same engine it ships.
92
92
  per-line old/new line numbers correct across multiple hunks.
93
93
  - `src/git/build.ts` — the git adapter: range / staged / worktree views, enriching
94
94
  `before`/`after` with full file content for the AST detectors.
95
- - `src/detectors/` — the **eight mechanical rules**: `no-verify`, `ts-any-cast`,
95
+ - `src/detectors/` — the **nine mechanical rules**: `no-verify`, `ts-any-cast`,
96
96
  `lint-suppression`, `test-skip`, `coverage-lowering`, `ci-tampering`,
97
97
  `hook-tampering`, `test-deletion` (the last counts `it()/test()` via the TS AST,
98
- and handles delete / rename-out-of-glob / shell mutation).
98
+ and handles delete / rename-out-of-glob / shell mutation), and `snapshot-rewrite`
99
+ (a `warn`: re-recording a snapshot/golden expectation from current output — the one
100
+ rule built from measured demand, after the affordance experiment put the move at a
101
+ 70% attempt and 100% through rate; see `harness/PREDICTION-affordance.md`).
99
102
  - `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
100
103
  - `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
101
104
  exit 1 on any blocking finding.
102
- - `test/` — 158 tests, including the AST-vs-regex, self-hosting precision, and
103
- pre-go-live audit regression cases.
105
+ - `test/` — 226 tests, including the AST-vs-regex, self-hosting precision, and
106
+ pre-go-live audit regression cases, and the renderer accessibility contract.
104
107
 
105
108
  - `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
106
109
  (PreToolUse deny, fail-closed) and `tamperward sweep claude` (Stop sweep, compared against
@@ -120,6 +123,24 @@ npx tamperward check --staged # pre-commit view
120
123
  npx tamperward check --diff "main...HEAD" # CI view — the authority for main
121
124
  ```
122
125
 
126
+ ### Reading the verdict
127
+
128
+ One verdict, rendered for whoever is reading it. `--format` picks the view; the default,
129
+ `auto`, picks `github` when `GITHUB_ACTIONS=true` and `text` otherwise, so the CI wiring
130
+ stays a single line.
131
+
132
+ | Format | Where it goes |
133
+ | --- | --- |
134
+ | `text` | The terminal. Blocking findings first, then by file and line, wrapped to the terminal width. |
135
+ | `github` | An inline annotation per finding — so it lands **on the line** in *Files changed*, not four clicks deep in a job log — plus a job-summary table on the run page. The full text output still goes to the log. |
136
+ | `json` | The findings verbatim, plus a summary count. |
137
+
138
+ Severity is always spelled out (`BLOCK` / `warn`) and never carried by colour or a glyph
139
+ alone, so the output reads the same piped, in a CI log, on a colour-blind reader's
140
+ terminal, and through a screen reader. Colour honours
141
+ [`NO_COLOR`](https://no-color.org) and `FORCE_COLOR`, and is off whenever stdout is not a
142
+ terminal.
143
+
123
144
  From a clone:
124
145
 
125
146
  ```bash
package/dist/cli/index.js CHANGED
@@ -182,7 +182,18 @@ function parseHunk(lines, start) {
182
182
 
183
183
  // src/git/build.ts
184
184
  function git(args, cwd) {
185
- return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
185
+ try {
186
+ return execFileSync("git", args, {
187
+ cwd,
188
+ encoding: "utf8",
189
+ maxBuffer: 64 * 1024 * 1024,
190
+ stdio: ["ignore", "pipe", "pipe"]
191
+ });
192
+ } catch (e) {
193
+ const err = e;
194
+ const detail = String(err.stderr ?? "").trim();
195
+ throw new Error(detail ? `git ${args[0]}: ${detail}` : err.message ?? `git ${args[0]} failed`);
196
+ }
186
197
  }
187
198
  function blobAt(rev, path, cwd) {
188
199
  try {
@@ -414,6 +425,10 @@ function defaultPolicy() {
414
425
  "**/package.json"
415
426
  ],
416
427
  ci: [".github/workflows/**"],
428
+ // Recorded expected outputs. An assertion stored as data is still an assertion;
429
+ // rewriting it from current output is the snapshot-update move the affordance
430
+ // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
431
+ snapshots: ["**/*.snap", "**/__snapshots__/**", "**/golden/**", "**/*.golden.*"],
417
432
  hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
418
433
  },
419
434
  rules: {
@@ -429,6 +444,9 @@ function defaultPolicy() {
429
444
  "ci-tampering": { severity: "block" },
430
445
  "hook-tampering": { severity: "block" },
431
446
  "no-verify": { severity: "block" },
447
+ // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
448
+ // when intended output changes. WARN until the §7 evidence path earns block.
449
+ "snapshot-rewrite": { severity: "warn" },
432
450
  // heuristic — warn until precision is measured (SPEC §7)
433
451
  "assertion-weakening": { severity: "warn" },
434
452
  "guard-removal": { severity: "warn" }
@@ -659,10 +677,14 @@ function underCoverageKey(node) {
659
677
  }
660
678
  return false;
661
679
  }
680
+ function asExpression(src) {
681
+ const t = src.trim();
682
+ return t.startsWith("{") ? `(${t})` : src;
683
+ }
662
684
  function parseThresholds(src) {
663
685
  const res = { paths: /* @__PURE__ */ new Map(), present: false };
664
686
  try {
665
- const sf = ts2.createSourceFile("cfg.ts", src, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
687
+ const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
666
688
  const visit = (node) => {
667
689
  if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
668
690
  const key2 = keyName(node.name);
@@ -1208,6 +1230,63 @@ var testDeletion = {
1208
1230
  }
1209
1231
  };
1210
1232
 
1233
+ // src/detectors/snapshot-rewrite.ts
1234
+ var RULE8 = "snapshot-rewrite";
1235
+ var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
1236
+ var UPDATE_ANY = /--update-?[sS]napshots?\b/;
1237
+ var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
1238
+ var REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
1239
+ var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}/;
1240
+ function namesProtectedSnapshot(seg, policy) {
1241
+ for (const t of tokens(seg)) {
1242
+ const path = unquote(t).replace(/^>{1,2}/, "");
1243
+ if (isProtected(path, policy, "snapshots")) return path;
1244
+ }
1245
+ return null;
1246
+ }
1247
+ var snapshotRewrite = {
1248
+ id: RULE8,
1249
+ surface: ["command", "file"],
1250
+ certainty: "mechanical",
1251
+ run(changes, policy) {
1252
+ const out = [];
1253
+ const warn = (input) => out.push(makeFinding(RULE8, policy, { ...input, defaultSeverity: "warn" }));
1254
+ for (const c of changes) {
1255
+ if (c.kind === "command") {
1256
+ for (const seg of segments(c.raw)) {
1257
+ let why = null;
1258
+ if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1259
+ else if (RUNNER2.test(seg) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
1260
+ else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
1261
+ else {
1262
+ const path = MUTATE.test(seg) ? namesProtectedSnapshot(seg, policy) : null;
1263
+ if (path) why = `the command overwrites or removes the protected snapshot ${path}`;
1264
+ }
1265
+ if (why) {
1266
+ warn({
1267
+ message: `Snapshot rewrite: ${why}.`,
1268
+ evidence: seg,
1269
+ remediation: "A snapshot is an assertion. If the test failure is a bug, fix the code; only update the snapshot when the INTENDED output changed, and have a human confirm the new expectation is correct."
1270
+ });
1271
+ }
1272
+ }
1273
+ continue;
1274
+ }
1275
+ const wasSnap = c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
1276
+ const isSnap = isProtected(c.path, policy, "snapshots");
1277
+ if (c.op === "add" || !isSnap && !wasSnap) continue;
1278
+ const what = c.op === "delete" ? "deleted" : c.op === "rename" && !isSnap ? `renamed out of the protected snapshot set (to ${c.path})` : "rewritten";
1279
+ warn({
1280
+ file: c.op === "rename" && !isSnap ? c.oldPath : c.path,
1281
+ message: `A recorded expected output was ${what}.`,
1282
+ evidence: c.op === "rename" ? `${c.oldPath} -> ${c.path}` : c.path,
1283
+ remediation: "If the intended output genuinely changed, a human should confirm the new expectation against the requirement; if the test was failing, fix the code instead of re-recording the expectation from it."
1284
+ });
1285
+ }
1286
+ return out;
1287
+ }
1288
+ };
1289
+
1211
1290
  // src/detectors/index.ts
1212
1291
  var allDetectors = [
1213
1292
  noVerify,
@@ -1217,7 +1296,8 @@ var allDetectors = [
1217
1296
  coverageLowering,
1218
1297
  ciTampering,
1219
1298
  hookTampering,
1220
- testDeletion
1299
+ testDeletion,
1300
+ snapshotRewrite
1221
1301
  ];
1222
1302
 
1223
1303
  // src/engine.ts
@@ -1362,46 +1442,226 @@ function oobFromEnv(env = process.env) {
1362
1442
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1363
1443
  }
1364
1444
 
1365
- // src/cli/report.ts
1366
- function report(input) {
1367
- const { findings, scanned, ignoredFiles } = input;
1368
- if (input.json) {
1369
- process.stdout.write(JSON.stringify({ findings, scanned, ignoredFiles }, null, 2) + "\n");
1370
- return;
1445
+ // src/cli/render/text.ts
1446
+ var ESC = "\x1B";
1447
+ var RESET = `${ESC}[0m`;
1448
+ var BOLD = `${ESC}[1m`;
1449
+ var DIM = `${ESC}[2m`;
1450
+ var RED = `${ESC}[31m`;
1451
+ var YELLOW = `${ESC}[33m`;
1452
+ var GREEN = `${ESC}[32m`;
1453
+ function colourEnabled(env = process.env, stream = process.stdout) {
1454
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1455
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
1456
+ if (env.TERM === "dumb") return false;
1457
+ return Boolean(stream.isTTY);
1458
+ }
1459
+ function terminalWidth(stream = process.stdout) {
1460
+ const c = stream.columns;
1461
+ if (!c || c < 40) return 80;
1462
+ return Math.min(c, 100);
1463
+ }
1464
+ function paint(s, code, on) {
1465
+ return on ? code + s + RESET : s;
1466
+ }
1467
+ function wrap(text, width) {
1468
+ const out = [];
1469
+ let cur = "";
1470
+ for (const w of text.split(/\s+/).filter(Boolean)) {
1471
+ if (cur === "") cur = w;
1472
+ else if (cur.length + 1 + w.length <= width) cur += " " + w;
1473
+ else {
1474
+ out.push(cur);
1475
+ cur = w;
1476
+ }
1371
1477
  }
1372
- const ignoredNote = ignoredFiles > 0 ? `, ${ignoredFiles} file(s) ignored by policy` : "";
1478
+ if (cur) out.push(cur);
1479
+ return out.length ? out : [""];
1480
+ }
1481
+ function clip(s, max) {
1482
+ const one = s.replace(/\s+/g, " ").trim();
1483
+ return one.length > max ? one.slice(0, Math.max(1, max - 1)) + "\u2026" : one;
1484
+ }
1485
+ function orderFindings(findings) {
1486
+ return [...findings].sort((a, b) => {
1487
+ if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
1488
+ const fa = a.file ?? "";
1489
+ const fb = b.file ?? "";
1490
+ if (fa !== fb) return fa < fb ? -1 : 1;
1491
+ return (a.line ?? 0) - (b.line ?? 0);
1492
+ });
1493
+ }
1494
+ function countsPhrase(blocks, warns) {
1495
+ const parts = [];
1496
+ if (blocks) parts.push(`${blocks} blocking`);
1497
+ if (warns) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
1498
+ return parts.join(", ");
1499
+ }
1500
+ function scopePhrase(scanned, ignoredFiles) {
1501
+ const ignored = ignoredFiles > 0 ? `, ${ignoredFiles} file${ignoredFiles === 1 ? "" : "s"} ignored by policy` : "";
1502
+ return `${scanned} change${scanned === 1 ? "" : "s"} scanned${ignored}`;
1503
+ }
1504
+ var SIGNOFF_NOTE = "A blocking finding clears only with a human sign-off. In CI that sign-off is out-of-band \u2014 a PR label applied by a reviewer \u2014 never a file committed on the branch under review. See SPEC \xA75.4.";
1505
+ function renderText(input, opts) {
1506
+ const { scanned, ignoredFiles } = input;
1507
+ const findings = orderFindings(input.findings);
1508
+ const blocks = findings.filter((f) => f.severity === "block");
1509
+ const warns = findings.filter((f) => f.severity === "warn");
1510
+ const c = opts.colour;
1511
+ const out = [];
1373
1512
  if (findings.length === 0) {
1374
- process.stdout.write(`tamperward: clean \u2014 no integrity findings (${scanned} change(s) scanned${ignoredNote}).
1375
- `);
1376
- return;
1513
+ return `${paint("tamperward: clean", GREEN, c)} \u2014 no integrity findings (${scopePhrase(scanned, ignoredFiles)}).
1514
+ `;
1515
+ }
1516
+ const headColour = blocks.length ? RED : YELLOW;
1517
+ out.push(paint(`tamperward: ${countsPhrase(blocks.length, warns.length)}`, BOLD + headColour, c));
1518
+ out.push(paint(`(${scopePhrase(scanned, ignoredFiles)})`, DIM, c));
1519
+ out.push("");
1520
+ const LABEL = "sign-off".length;
1521
+ const pad = (k) => (k + " ".repeat(LABEL)).slice(0, LABEL);
1522
+ const body = Math.max(20, opts.width - LABEL - 6);
1523
+ for (const f of findings) {
1524
+ const isBlock = f.severity === "block";
1525
+ const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
1526
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
1527
+ out.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
1528
+ for (const l of wrap(f.message, opts.width - 4)) out.push(` ${l}`);
1529
+ const row = (key2, lines) => {
1530
+ lines.forEach((l, i) => out.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
1531
+ };
1532
+ row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
1533
+ row("instead", wrap(f.remediation, body));
1534
+ if (f.signoff.required) row("sign-off", [f.signoff.command]);
1535
+ out.push("");
1536
+ }
1537
+ if (blocks.length > 0) {
1538
+ out.push(
1539
+ wrap(SIGNOFF_NOTE, opts.width - 2).map((l) => paint(l, DIM, c)).join("\n")
1540
+ );
1377
1541
  }
1542
+ return out.join("\n").replace(/\n+$/, "") + "\n";
1543
+ }
1544
+
1545
+ // src/cli/render/github.ts
1546
+ import { appendFileSync as appendFileSync2 } from "node:fs";
1547
+ function escData(s) {
1548
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1549
+ }
1550
+ function escProp(s) {
1551
+ return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
1552
+ }
1553
+ function annotations(findings) {
1554
+ return orderFindings(findings).map((f) => {
1555
+ const kind = f.severity === "block" ? "error" : "warning";
1556
+ const props = [];
1557
+ if (f.file) {
1558
+ props.push(`file=${escProp(f.file)}`);
1559
+ if (f.line) props.push(`line=${f.line}`);
1560
+ }
1561
+ props.push(`title=${escProp(`tamperward: ${f.rule}`)}`);
1562
+ const body = `${f.message} Instead: ${f.remediation}` + (f.signoff.required ? ` Sign-off: ${f.signoff.command}` : "");
1563
+ return `::${kind} ${props.join(",")}::${escData(body)}`;
1564
+ });
1565
+ }
1566
+ function cell(s) {
1567
+ return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
1568
+ }
1569
+ function locationCell(f, env) {
1570
+ if (!f.file) return "`(command)`";
1571
+ const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1572
+ const server = env.GITHUB_SERVER_URL;
1573
+ const repo = env.GITHUB_REPOSITORY;
1574
+ const sha = env.GITHUB_SHA;
1575
+ if (!server || !repo || !sha) return `\`${cell(label)}\``;
1576
+ const frag = f.line ? `#L${f.line}` : "";
1577
+ return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
1578
+ }
1579
+ function renderSummary(input, env = process.env) {
1580
+ const findings = orderFindings(input.findings);
1378
1581
  const blocks = findings.filter((f) => f.severity === "block");
1379
1582
  const warns = findings.filter((f) => f.severity === "warn");
1583
+ const scope = scopePhrase(input.scanned, input.ignoredFiles);
1584
+ if (findings.length === 0) {
1585
+ return `## Tamperward: clean
1586
+
1587
+ No integrity findings (${scope}).
1588
+ `;
1589
+ }
1590
+ const out = [
1591
+ `## Tamperward: ${countsPhrase(blocks.length, warns.length)}`,
1592
+ "",
1593
+ `${scope}. Every finding below is also annotated on the diff in **Files changed**.`,
1594
+ "",
1595
+ "| Severity | Rule | Location | Finding | Instead |",
1596
+ "| --- | --- | --- | --- | --- |"
1597
+ ];
1380
1598
  for (const f of findings) {
1381
- const mark = f.severity === "block" ? "BLOCK" : "warn";
1382
- const loc = f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : "";
1383
- process.stdout.write(`
1384
- [${mark}] ${f.rule}${loc}
1385
- `);
1386
- process.stdout.write(` ${f.message}
1387
- `);
1388
- process.stdout.write(` evidence: ${f.evidence}
1389
- `);
1390
- process.stdout.write(` fix: ${f.remediation}
1391
- `);
1392
- if (f.signoff.required) process.stdout.write(` sign-off: ${f.signoff.command}
1393
- `);
1599
+ const sev = f.severity === "block" ? "**BLOCK**" : "warn";
1600
+ out.push(
1601
+ `| ${sev} | \`${cell(f.rule)}\` | ${locationCell(f, env)} | ${cell(f.message)} | ${cell(f.remediation)} |`
1602
+ );
1394
1603
  }
1395
- process.stdout.write(
1396
- `
1397
- tamperward: ${blocks.length} blocking, ${warns.length} warning (${scanned} change(s) scanned${ignoredNote}).
1398
- `
1399
- );
1400
- if (blocks.length > 0) {
1604
+ const needSignoff = findings.filter((f) => f.signoff.required);
1605
+ if (needSignoff.length) {
1606
+ out.push("", "### Clearing a blocking finding", "", SIGNOFF_NOTE, "");
1607
+ const seen = /* @__PURE__ */ new Set();
1608
+ for (const f of needSignoff) {
1609
+ if (seen.has(f.signoff.command)) continue;
1610
+ seen.add(f.signoff.command);
1611
+ out.push(`- \`${cell(f.signoff.command)}\``);
1612
+ }
1613
+ }
1614
+ return out.join("\n") + "\n";
1615
+ }
1616
+ function writeSummary(md, env = process.env) {
1617
+ const path = env.GITHUB_STEP_SUMMARY;
1618
+ if (!path) return false;
1619
+ try {
1620
+ appendFileSync2(path, md + "\n");
1621
+ return true;
1622
+ } catch {
1623
+ return false;
1624
+ }
1625
+ }
1626
+ function isGitHubActions(env = process.env) {
1627
+ return env.GITHUB_ACTIONS === "true";
1628
+ }
1629
+
1630
+ // src/cli/report.ts
1631
+ var FORMATS = ["auto", "text", "json", "github"];
1632
+ function isFormat(s) {
1633
+ return FORMATS.includes(s);
1634
+ }
1635
+ function resolveFormat(input, env = process.env) {
1636
+ if (input.json) return "json";
1637
+ if (input.format && input.format !== "auto") return input.format;
1638
+ return isGitHubActions(env) ? "github" : "text";
1639
+ }
1640
+ function report(input) {
1641
+ const { findings, scanned, ignoredFiles } = input;
1642
+ const format = resolveFormat(input);
1643
+ if (format === "json") {
1644
+ const blocks = findings.filter((f) => f.severity === "block").length;
1401
1645
  process.stdout.write(
1402
- "A blocking finding clears only with a human sign-off. In CI, sign-off is out-of-band (a reviewed PR label), never a committed file \u2014 see SPEC \xA75.4.\n"
1646
+ JSON.stringify(
1647
+ // `findings`, `scanned` and `ignoredFiles` keep their exact prior shape; `summary`
1648
+ // is additive, so an existing consumer reading only the old keys is unaffected.
1649
+ { findings, scanned, ignoredFiles, summary: { block: blocks, warn: findings.length - blocks } },
1650
+ null,
1651
+ 2
1652
+ ) + "\n"
1403
1653
  );
1654
+ return;
1655
+ }
1656
+ const text = renderText(
1657
+ { findings, scanned, ignoredFiles },
1658
+ { colour: colourEnabled(), width: terminalWidth() }
1659
+ );
1660
+ if (format === "github") {
1661
+ for (const a of annotations(findings)) process.stdout.write(a + "\n");
1662
+ writeSummary(renderSummary({ findings, scanned, ignoredFiles }));
1404
1663
  }
1664
+ process.stdout.write(text);
1405
1665
  }
1406
1666
 
1407
1667
  // src/cli/check.ts
@@ -1439,7 +1699,7 @@ function check(opts) {
1439
1699
  process.stderr.write(`tamperward: ${cleared.length} blocking finding(s) cleared by ${how}: ${cleared.map((f) => f.rule + (f.file ? `(${f.file})` : "")).join(", ")}
1440
1700
  `);
1441
1701
  }
1442
- report({ findings, scanned: changes.length, ignoredFiles, json: opts.json });
1702
+ report({ findings, scanned: changes.length, ignoredFiles, json: opts.json, format: opts.format });
1443
1703
  return hasBlocking(findings) ? 1 : 0;
1444
1704
  }
1445
1705
  function runCheck(opts) {
@@ -1456,7 +1716,7 @@ function runCheck(opts) {
1456
1716
  }
1457
1717
 
1458
1718
  // src/cli/hook.ts
1459
- import { readFileSync as readFileSync6, appendFileSync as appendFileSync2 } from "node:fs";
1719
+ import { readFileSync as readFileSync6, appendFileSync as appendFileSync3 } from "node:fs";
1460
1720
 
1461
1721
  // src/adapters/claude/changes.ts
1462
1722
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -1631,7 +1891,7 @@ function recordDenylog(blocks) {
1631
1891
  const log = process.env.TAMPERWARD_DENYLOG;
1632
1892
  if (!log) return;
1633
1893
  try {
1634
- appendFileSync2(log, blocks.map((b) => b.rule).join(",") + "\n");
1894
+ appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
1635
1895
  } catch {
1636
1896
  }
1637
1897
  }
@@ -1782,7 +2042,12 @@ function parseCheck(args) {
1782
2042
  if (a === "--staged") o.staged = true;
1783
2043
  else if (a === "--worktree") o.worktree = true;
1784
2044
  else if (a === "--json") o.json = true;
1785
- else if (a === "--diff") o.diff = args[++i];
2045
+ else if (a === "--format") {
2046
+ const v = args[++i];
2047
+ if (v !== void 0 && isFormat(v)) o.format = v;
2048
+ else process.stderr.write(`tamperward: unknown --format "${v ?? ""}" (expected ${FORMATS.join(" | ")})
2049
+ `);
2050
+ } else if (a === "--diff") o.diff = args[++i];
1786
2051
  else if (a === "--cwd") o.cwd = args[++i];
1787
2052
  else {
1788
2053
  process.stderr.write(`tamperward: unknown flag "${a}"
@@ -1798,7 +2063,18 @@ Usage:
1798
2063
  tamperward check --staged check staged changes (pre-commit)
1799
2064
  tamperward check --worktree check working-tree changes (stop sweep)
1800
2065
  tamperward check --diff <base>...<head> check a commit range (CI authority)
1801
- tamperward check ... --json machine-readable output
2066
+ tamperward check ... --json machine-readable output (alias for --format json)
2067
+ tamperward check ... --format <fmt> text | json | github | auto (default)
2068
+
2069
+ Formats:
2070
+ text grouped, wrapped, colour-optional terminal output. Severity is always
2071
+ spelled out ("BLOCK" / "warn"), never carried by colour alone. Honours
2072
+ NO_COLOR and FORCE_COLOR; colour is off when stdout is not a terminal.
2073
+ github GitHub Actions view: one inline annotation per finding (so it shows up
2074
+ on the line in "Files changed") plus a job-summary table on the run
2075
+ page, alongside the same text output in the log.
2076
+ json the findings verbatim, plus a summary count.
2077
+ auto github when GITHUB_ACTIONS=true, otherwise text.
1802
2078
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
1803
2079
  tamperward sweep claude Stop sweep (re-scan the turn's working tree)
1804
2080
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.1.0-rc.2",
3
+ "version": "1.2.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",