@holmes-lab/holmes-kit 0.20.2 → 0.22.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +1 -0
  4. package/dist/holmes/cli/doctor.d.ts +1 -0
  5. package/dist/holmes/cli/doctor.js +42 -0
  6. package/dist/holmes/cli/index.js +1 -0
  7. package/dist/holmes/cli/init.js +1 -0
  8. package/dist/holmes/cpg/language-parser-walk.js +1 -0
  9. package/dist/holmes/governance/constitution.d.ts +11 -0
  10. package/dist/holmes/governance/constitution.js +15 -1
  11. package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
  12. package/dist/holmes/guardrail/impact-gate.js +19 -0
  13. package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
  14. package/dist/holmes/guardrail/risk-classifier.js +26 -3
  15. package/dist/holmes/hooks/stop.d.ts +16 -0
  16. package/dist/holmes/hooks/stop.js +116 -1
  17. package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
  18. package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
  19. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
  20. package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
  21. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +28 -1
  22. package/dist/holmes/mcp/handlers/operator-inspection.js +91 -3
  23. package/dist/holmes/mcp/handlers/spec-approval.d.ts +6 -0
  24. package/dist/holmes/mcp/handlers/spec-approval.js +87 -1
  25. package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
  26. package/dist/holmes/mcp/handlers/test-execution.js +6 -2
  27. package/dist/holmes/mcp/handlers.d.ts +34 -1
  28. package/dist/holmes/mcp/handlers.js +5 -0
  29. package/dist/holmes/mcp/maintenance-analyze.js +1 -0
  30. package/dist/holmes/mcp/tool-schemas.js +1 -0
  31. package/dist/holmes/project/ci-runs.d.ts +46 -0
  32. package/dist/holmes/project/ci-runs.js +137 -0
  33. package/dist/holmes/project/install-scripts-policy.js +1 -0
  34. package/dist/holmes/review/evaluation-metrics.js +1 -0
  35. package/dist/holmes/review/kills-check.d.ts +40 -0
  36. package/dist/holmes/review/kills-check.js +147 -0
  37. package/dist/holmes/review/manual-baseline.js +1 -0
  38. package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
  39. package/dist/holmes/rtm/advisory-outcomes.js +314 -0
  40. package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
  41. package/dist/holmes/rtm/anchor-comment.js +8 -0
  42. package/dist/holmes/rtm/file-anchors.d.ts +9 -0
  43. package/dist/holmes/rtm/file-anchors.js +128 -0
  44. package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
  45. package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
  46. package/dist/holmes/rtm/known-defects.d.ts +26 -0
  47. package/dist/holmes/rtm/known-defects.js +77 -0
  48. package/dist/holmes/rtm/link-census.d.ts +61 -0
  49. package/dist/holmes/rtm/link-census.js +90 -0
  50. package/dist/holmes/rtm/rtm-graph.js +1 -0
  51. package/dist/holmes/rtm/taint-benchmark.js +1 -0
  52. package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
  53. package/dist/holmes/rtm/trace-gaps.js +64 -0
  54. package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
  55. package/dist/holmes/server/dashboard-launcher.js +24 -1
  56. package/dist/holmes/server/dashboard.js +40 -2
  57. package/package.json +1 -1
  58. package/playbooks/author-slice/PLAYBOOK.md +30 -0
  59. package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,106 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.22.0] - 2026-09-18
9
+
10
+ The advisories learn what happened next. Until now this repository issued findings and recorded that
11
+ it had issued them; nothing recorded whether anyone acted. That missing half is the numerator every
12
+ "promote this to a hard gate once we know the false-positive rate" sentence needed.
13
+
14
+ ### Added
15
+ - **Advisory reaction ledger** (A-SPEC-663). Every finding — impact-advisory, anchor-density,
16
+ Files-to-Touch fulfilment, trace-gap, kills-unapplicable — now has a deterministic id (kind + spec +
17
+ canonical payload). `spec_approve` records it as `issued` and answers with `advisoryIds`; the next
18
+ `approval_status` re-runs the SAME finding functions and records `resolved` when the cause is gone or
19
+ `persisted` when it is not, at most one row per finding per day. An author who judges a finding
20
+ unhelpful passes `dismiss: [id]` to `spec_approve`; an id the ledger does not know comes back in
21
+ `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains `advisoryOutcomes`
22
+ (issued / resolved / persisted / dismissed per kind) and `approval_status` gains `advisoryHistory`.
23
+ Rows live in `.ax/ledger/advisory-outcomes.<replica>.jsonl` and carry ids, spec ids, outcome words and
24
+ commit hashes only. Queries never issue a finding the seal did not, so the observation denominator
25
+ stays the seal's.
26
+ - **`kills` applicability** (A-SPEC-662). `test_run --mutate` now reports mutations that never applied
27
+ as `unapplied`, separately from `survivors` — measured here, all 22 `kills` entries in this repository
28
+ write `where` as a file path and `mutate` as prose, so the literal-replacement engine applied none of
29
+ them and the response still read `survivors: []`, the shape of a clean run. Sealing a T-SPEC (and
30
+ previewing it with `approval_status`) reports entries whose `where` literal is absent from the
31
+ A-SPEC's anchored production source as a `kills-unapplicable` finding. Never blocking. The
32
+ author-slice playbook now shows the grammar.
33
+ - **CI matrix, minimal form** (A-SPEC-664). `scripts/ci-orb-linux.sh` runs one commit's full suite on an
34
+ OrbStack Linux machine and appends exactly one row to `.ax/ledger/ci-runs.<host>.jsonl` — for every
35
+ outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`,
36
+ `vm-unreachable`). A watch script and a launchd agent trigger it. The Stop hook reports the matrix's
37
+ last word on a tracked line and `doctor` gains a `ci matrix` check, and neither ever reads a missing
38
+ row as green: absence is "not run". The scripts are maintainer tools and are not shipped; a workspace
39
+ with no `ci-runs` ledger hears nothing about the matrix at all.
40
+
41
+ ### Fixed
42
+ - **Trace-gap anchors: 21 approved specs joined the graph.** Each declared a scanned production file in
43
+ its Files to Touch while anchoring only tests, so `rtm_impact` could not see them. Their anchors now
44
+ sit in the files they declared. Unlinked approved specs fell from 73 to 52 and trace gaps from 21 to
45
+ 0; `codeLinkedPct` rose from 88.6 to 91.9. The remaining 52 declare no scanned production file — they
46
+ are test- and document-only specs, not gaps.
47
+ - **The runner counted focused suites as failures.** jest reports a suite that used `test.only` with
48
+ status `focused`; filtering on `!== 'passed'` made five healthy suites look red in the first two
49
+ Linux rows. Only `failed` counts now.
50
+
51
+ ### Measured
52
+ - macOS: 524 suites / 6,581 passed / 11 skipped; every slice recorded red-assertion → green; official
53
+ `test_run` green at each step.
54
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 22): five recorded runs, the last one unattended via launchd.
55
+ The residual reds are load-dependent suites (`dashboard.test`, `entity-store-boundaries`,
56
+ `entity-git-snapshot`) that pass when run alone; the host was running other agents' work at the time.
57
+ - First reaction rows in this repository: 22 trace-gap findings issued, 22 resolved by the anchor move.
58
+ - Windows: not re-run for this release; no Windows-specific change landed.
59
+
60
+ ## [0.21.0] - 2026-09-17
61
+
62
+ The backlog-hardening cycle: the RTM stops claiming coverage it cannot see, sealing reports what a
63
+ spec's Files to Touch left behind, and a known defect pinned by a test now carries a marker with an
64
+ expiry — reported as debt, never a block, until the grace runs out.
65
+
66
+ ### Added
67
+ - **Code-link census with reasons** (A-SPEC-655). `rtm_dashboard` and `/api/rtm` report `codeLinkedPct`,
68
+ `unlinkedCount` and `unlinkedByReason` (`test-only`, `file-anchor`, `test-and-file`, `weak-anchor`, `none`)
69
+ next to the old `coveragePct`, which still reads 100 while 11.4% of approved specs have no `implements`
70
+ edge. The scanner now reads every anchor the injector can write (`.sh/.yml/.toml/...` file anchors were
71
+ planted and never indexed). Measured here: 636 approved, 563 linked, 73 unlinked — 72 test-only.
72
+ - **Files-to-Touch fulfilment advisory** (A-SPEC-656). At `spec_approve` and in `approval_status`, a sealed
73
+ A-SPEC whose declared paths do not exist is reported as `missing`, `moved` (same basename elsewhere) or
74
+ `alternatives`; the verdict is appended to `.ax/ledger/ftt-fulfilment.<replica>.jsonl`. Never blocks.
75
+ Four genuinely unfulfilled paths on three sealed specs were found on the first run.
76
+ - **Trace gaps** (A-SPEC-658). `rtm_impact` returns `traceGaps` — approved specs that declare a scanned
77
+ production file in Files to Touch yet anchor only tests — and `impact_gate_check` answers `trace-gap`
78
+ with remediation instead of silently widening. Measured here: 21 specs, 29 spec/file pairs.
79
+ - **`@known-defect(reason, expires=YYYY-MM-DD)`** (A-SPEC-660) and constitution article **ART-9**. A test
80
+ that pins a known defect as its expected value carries the marker on a comment line; the Stop hook walks
81
+ test files, lists unexpired markers on the `tracked` channel as `known-defect debt`, and blocks only when
82
+ a marker has expired or cannot be read (`no-reason`, `no-expires`, `bad-date`). String literals are
83
+ stripped first, the expiry day itself counts as expired (UTC), and a walk that cannot start is no signal.
84
+ Walk cost ≈61 ms per Stop on this tree; zero markers exist yet.
85
+ - **`[observability]` obligation in the authoring playbooks** (A-SPEC-659). A slice that creates a record
86
+ must declare the obligation in its H-SPEC and carry a record→read round-trip case in its T-SPEC; the
87
+ existing tag-correspondence check reports the gap. Pinned so the prose cannot claim the gate blocks.
88
+
89
+ ### Fixed
90
+ - **Windows-spelled in-project `.ax` paths are judged by location, not spelling** (A-SPEC-657). A Bash
91
+ write to `C:\proj\.ax\...`, `/c/proj/.ax/...` or `C:/proj/.ax/...` reached the protected-path check
92
+ as an outside-project path and was masked out of it; drive, MSYS and backslash spellings now normalise
93
+ to the project root first. Found by the Windows agent's residual-red evidence, reproduced on macOS.
94
+
95
+ ### Measured
96
+ - macOS: 515 suites / 6,534 passed / 11 skipped; every slice recorded red-assertion → green; official
97
+ `test_run` green at b18ff0bf, last-green recorded.
98
+ - `kills` mutation testing: all 22 existing entries write `where` as a file path and `mutate` as prose,
99
+ so the literal-replacement engine applies none of them and `--mutate` reports `survivors: []`. Making
100
+ `kills` mandatory is deferred behind a `where`-validation slice (`docs/goals/EVIDENCE-kills-cost-2026-09-17.md`).
101
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 24): 514 / 515 suites green; the one red is the documented
102
+ `tree-sitter@0.21.1` source-compile failure against Node 24 headers on ARM Linux (README, unchanged since
103
+ 0.19.4) — the npx wiring case installs the packed tarball and that install fails there. On Node 22 the
104
+ same tarball installs, the MCP handshake answers with the package version, and the suite is green.
105
+ - Windows: not re-run for this release; the only Windows-relevant change is A-SPEC-657, reproduced and
106
+ pinned on macOS from the Windows agent's evidence.
107
+
8
108
  ## [0.20.2] - 2026-09-16
9
109
 
10
110
  Two findings the Windows cycle recorded and could not close, closed — without giving autonomy or the
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 5796f530-mu3b082d
1
+ 71586207-mu68mv0a
@@ -40,6 +40,7 @@ exports.antigravityHookWarnings = antigravityHookWarnings;
40
40
  exports.mergeAgentsMd = mergeAgentsMd;
41
41
  exports.agentFiles = agentFiles;
42
42
  exports.agentLinks = agentLinks;
43
+ // @implements A-SPEC-202, A-SPEC-250, A-SPEC-341
43
44
  // @implements A-SPEC-442
44
45
  // @implements A-SPEC-193
45
46
  const path = __importStar(require("node:path"));
@@ -171,6 +171,7 @@ export declare function wiringSpawnCheck(command: string, args: string[], timeou
171
171
  * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
172
172
  */
173
173
  export declare function pushGateCheck(target: string): Check | null;
174
+ export declare function ciMatrixCheck(target: string): Check | null;
174
175
  export declare function formatChecks(checks: Check[]): string;
175
176
  /**
176
177
  * Prove that EVERY wired harness can actually start a server, not merely that its file parses.
@@ -47,10 +47,12 @@ exports.resolveWiringPath = resolveWiringPath;
47
47
  exports.runDoctor = runDoctor;
48
48
  exports.wiringSpawnCheck = wiringSpawnCheck;
49
49
  exports.pushGateCheck = pushGateCheck;
50
+ exports.ciMatrixCheck = ciMatrixCheck;
50
51
  exports.formatChecks = formatChecks;
51
52
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
52
53
  exports.semanticTierVerdict = semanticTierVerdict;
53
54
  exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
55
+ // @implements A-SPEC-264, A-SPEC-423, A-SPEC-549.1, A-SPEC-590
54
56
  // @implements A-SPEC-594
55
57
  // @implements A-SPEC-592
56
58
  // @implements A-SPEC-591
@@ -65,6 +67,7 @@ const native_deps_1 = require("./native-deps");
65
67
  const tier_1 = require("../semantic/tier");
66
68
  const probe_process_1 = require("./probe-process");
67
69
  const npx_cache_check_1 = require("./npx-cache-check");
70
+ const ci_runs_1 = require("../project/ci-runs");
68
71
  const path = __importStar(require("node:path"));
69
72
  const role_policy_1 = require("../governance/role-policy");
70
73
  const blind_spots_1 = require("../guardrail/blind-spots");
@@ -1261,6 +1264,9 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1261
1264
  const pg = pushGateCheck(target ?? process.cwd());
1262
1265
  if (pg)
1263
1266
  checks.push(pg);
1267
+ const ci = ciMatrixCheck(target ?? process.cwd()); // @implements A-SPEC-664
1268
+ if (ci)
1269
+ checks.push(ci);
1264
1270
  }
1265
1271
  if (extraChecks) {
1266
1272
  checks.push(...extraChecks);
@@ -1554,6 +1560,42 @@ function pushGateCheck(target) {
1554
1560
  return null;
1555
1561
  } // diagnosis must never crash doctor
1556
1562
  }
1563
+ // @implements A-SPEC-664
1564
+ // The CI matrix as doctor sees it: PASS only for a green row at HEAD; everything else is a WARN that
1565
+ // says exactly what the ledger says — "not run" when there is no row, the failed suites when red,
1566
+ // the infrastructure status when the run could not judge. Never a FAIL: the matrix informs.
1567
+ function ciMatrixCheck(target) {
1568
+ try {
1569
+ if (!fs.existsSync(path.join(target, '.ax')))
1570
+ return null;
1571
+ if (!(0, ci_runs_1.hasCiLedger)(target))
1572
+ return null; // @implements A-SPEC-664 — same adoption predicate as the hook
1573
+ const runs = (0, ci_runs_1.readCiRuns)(target);
1574
+ let head;
1575
+ try {
1576
+ head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1577
+ }
1578
+ catch {
1579
+ head = undefined;
1580
+ }
1581
+ const behind = (rev) => {
1582
+ try {
1583
+ return Number((0, node_child_process_1.execFileSync)('git', ['rev-list', '--count', `${rev}..HEAD`], { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim());
1584
+ }
1585
+ catch {
1586
+ return undefined;
1587
+ }
1588
+ };
1589
+ const v = (0, ci_runs_1.ciVerdict)(runs, 'linux', head, behind);
1590
+ const detail = (0, ci_runs_1.ciStatusLine)(v);
1591
+ if (v.state === 'green' && v.behind === 0)
1592
+ return { name: 'ci matrix', level: 'PASS', detail };
1593
+ return { name: 'ci matrix', level: 'WARN', detail, fix: 'Run scripts/ci-orb-linux.sh HEAD (or install the launchd agent: scripts/ci-orb-linux-install.sh)' };
1594
+ }
1595
+ catch {
1596
+ return null;
1597
+ } // diagnosis must never crash doctor
1598
+ }
1557
1599
  function formatChecks(checks) {
1558
1600
  const lines = checks.map((c) => {
1559
1601
  const head = `${c.level.padEnd(4)} ${c.name} — ${c.detail}`;
@@ -37,6 +37,7 @@ exports.isBrokenPipe = void 0;
37
37
  exports.packageRoot = packageRoot;
38
38
  exports.main = main;
39
39
  exports.installPipeGuard = installPipeGuard;
40
+ // @implements A-SPEC-549.2
40
41
  // @implements A-SPEC-591, A-SPEC-626
41
42
  const cli_execution_1 = require("./cli-execution");
42
43
  // @implements A-SPEC-100.2
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.settingsPathOf = exports.MATCHERS = exports.SERVER_NAME = void 0;
37
37
  exports.buildHookPlan = buildHookPlan;
38
38
  exports.runInit = runInit;
39
+ // @implements A-SPEC-340
39
40
  // @implements A-SPEC-100.2
40
41
  const fs = __importStar(require("node:fs"));
41
42
  const path = __importStar(require("node:path"));
@@ -1,3 +1,4 @@
1
+ // @implements A-SPEC-302
1
2
  'use strict';
2
3
  // Single source of truth for the per-language symbol/edge tree-walking
3
4
  // logic. Called from BOTH the inline (production) parse path and the
@@ -1,5 +1,6 @@
1
1
  import { Spec } from '../spec/spec-parser';
2
2
  import type { TestOutcome } from '../review/test-runner';
3
+ import type { KnownDefect, MalformedMarker } from '../rtm/known-defects';
3
4
  /**
4
5
  * L1 — Governance CONSTITUTION (target-architecture §7 L1).
5
6
  *
@@ -26,6 +27,16 @@ export interface ConstitutionViolation {
26
27
  }
27
28
  export interface ConstitutionContext {
28
29
  specs: Spec[];
30
+ /**
31
+ * @implements A-SPEC-660
32
+ * `@known-defect` markers the hook judged EXPIRED or could not read — ART-9's evidence. Supplied by
33
+ * the hook like every other article's evidence; absent means silent (no signal, not clean).
34
+ * Unexpired markers never reach the constitution: they are debt on the tracked channel.
35
+ */
36
+ knownDefects?: {
37
+ expired: KnownDefect[];
38
+ malformed: MalformedMarker[];
39
+ };
29
40
  /**
30
41
  * @implements A-SPEC-160
31
42
  * Recorded review findings, for ART-6. Supplied by the hook, like ART-4's evidence — the
@@ -42,13 +42,18 @@ exports.ARTICLES = {
42
42
  'ART-4': 'Coverage honesty — declared coverage must be backed by real anchored test cases, not prose',
43
43
  'ART-5': 'Approval is out-of-band — a spec cannot self-approve; governance config cannot be self-written',
44
44
  'ART-8': 'Test-first is observed — a changed A-SPEC must show a recorded red-assertion→green sequence before it is done (a red-error is not a valid RED)',
45
+ // @implements A-SPEC-660
46
+ 'ART-9': '알려진 결함을 기대값으로 고정한 단언은 표식(@known-defect)과 만료일을 가진다 — 만료가 지났거나 표식이 깨진 단언은 미완이다 (미만료 표식은 tracked 로 보고만 된다: 우회는 때로 옳은 선택이다)',
45
47
  };
46
48
  function verifyConstitution(ctx) {
47
49
  const governed = (0, spec_types_1.filterGoverned)(ctx.specs);
48
50
  // @implements A-SPEC-191 (§4b) — the early return may NOT swallow recorded findings: a project
49
51
  // that reviews before authoring specs (measured: adopt flow, reverse_scan first) held an open
50
52
  // critical while Stop passed, because ART-7 was accidentally coupled to spec-graph existence.
51
- if (governed.length === 0 && (ctx.findings ?? []).length === 0)
53
+ // @implements A-SPEC-660 nor may it swallow judged @known-defect markers: an expired bypass in a
54
+ // spec-less project is still an expired bypass.
55
+ const knownDefectEvidence = (ctx.knownDefects?.expired.length ?? 0) + (ctx.knownDefects?.malformed.length ?? 0) > 0;
56
+ if (governed.length === 0 && (ctx.findings ?? []).length === 0 && !knownDefectEvidence)
52
57
  return []; // rows 가 아니라 원본 기준 — 오염 행만 있는 원장도 감식 대상이다
53
58
  const resolve = (id) => ctx.specs.find((s) => s.id === id) ?? null;
54
59
  const v = [];
@@ -175,5 +180,14 @@ function verifyConstitution(ctx) {
175
180
  v.push({ article: 'ART-7', detail: `finding ${f.id}${gist}: 해소가 갈라진 빌드에서 봉인되었습니다 (basis ${f.basis}) — 현재 빌드로 재검증해 다시 기록하십시오` });
176
181
  }
177
182
  }
183
+ // @implements A-SPEC-660 — ART-9. An expired marker is a bypass whose grace ran out; an unreadable
184
+ // one is a debt nobody can see. Unexpired markers never reach here (tracked channel). Absent input
185
+ // is silence, not a clean verdict — the hook decides whether it could look.
186
+ for (const k of ctx.knownDefects?.expired ?? []) {
187
+ v.push({ article: 'ART-9', detail: `${k.file}:${k.line}: known defect "${k.reason}" expired ${k.expires} — fix the root cause or renew the marker with a reason (ART-9)` });
188
+ }
189
+ for (const m of ctx.knownDefects?.malformed ?? []) {
190
+ v.push({ article: 'ART-9', detail: `${m.file}:${m.line}: @known-defect marker is ${m.why} — a marker that cannot be read is a debt nobody can see (ART-9)` });
191
+ }
178
192
  return v;
179
193
  }
@@ -13,7 +13,7 @@ import type { EvidenceArtifact } from '../mcp/maintenance-evidence';
13
13
  */
14
14
  export type EditDecision = 'allow' | 'widen' | 'refuse';
15
15
  export interface EditEvidenceReason {
16
- code: 'no-analysis' | 'basis-drift' | 'target-out-of-scope' | 'contract-untested' | 'unproven-absence' | 'architecture-unchecked' | 'target-drift' | 'basis-unverifiable' | 'security-unchecked' | 'compatibility-unchecked';
16
+ code: 'no-analysis' | 'basis-drift' | 'target-out-of-scope' | 'contract-untested' | 'unproven-absence' | 'architecture-unchecked' | 'target-drift' | 'basis-unverifiable' | 'security-unchecked' | 'compatibility-unchecked' | 'trace-gap';
17
17
  detail: string;
18
18
  }
19
19
  export interface EditEvidenceInput {
@@ -30,6 +30,15 @@ export interface EditEvidenceInput {
30
30
  * is caught even while HEAD sits still.
31
31
  */
32
32
  currentTargetDigest?: string | null;
33
+ /**
34
+ * @implements A-SPEC-658
35
+ * Approved A-SPECs that declare `target` in their Files to Touch but are anchored by no source —
36
+ * computed by the caller (the handler owns the spec store and the scan; this module stays pure).
37
+ */
38
+ traceGaps?: Array<{
39
+ id: string;
40
+ file: string;
41
+ }>;
33
42
  }
34
43
  export interface EditEvidenceVerdict {
35
44
  decision: EditDecision;
@@ -80,6 +80,8 @@ const REMEDIATION = {
80
80
  'security-unchecked': 'The security axis was never examined — re-run maintenance_analyze against a tree it can actually scan so data-flow reachability is walked.',
81
81
  'compatibility-unchecked': 'The compatibility axis was never examined — re-run maintenance_analyze where the spec store is readable so breaking-change declarations can be read.',
82
82
  'basis-unverifiable': 'The analysis recorded no digest for this file, or the file cannot be read now, so drift cannot be ruled out — re-run maintenance_analyze with persist: true to take a verifiable basis.',
83
+ // @implements A-SPEC-658
84
+ 'trace-gap': 'The target is declared in the Files to Touch of approved A-SPEC(s) that no source file anchors, so the graph cannot see their impact — anchor the implementing source (spec_remediate) or include those specs in the analysis request and re-run maintenance_analyze.',
83
85
  };
84
86
  const RANK = { allow: 0, widen: 1, refuse: 2 };
85
87
  function evaluateEditEvidence(inputArgs) {
@@ -89,6 +91,21 @@ function evaluateEditEvidence(inputArgs) {
89
91
  let decision = 'allow';
90
92
  const escalate = (next) => { if (RANK[next] > RANK[decision])
91
93
  decision = next; };
94
+ // @implements A-SPEC-658 — a trace gap is "we could not look" wearing a file's name: the target is
95
+ // declared by an approved A-SPEC no source anchors, so the graph's silence about that spec is
96
+ // ignorance, not absence. It widens, never lowers a refusal, and never speaks for one (the
97
+ // primary remediation below stays the refusal's). The caller computes the list; this stays pure.
98
+ const traceGapReason = () => {
99
+ const gaps = inputArgs.traceGaps ?? [];
100
+ if (gaps.length === 0)
101
+ return;
102
+ const ids = [...new Set(gaps.map((g) => g.id))].sort();
103
+ reasons.push({
104
+ code: 'trace-gap',
105
+ detail: `${target} is declared by approved ${ids.join(', ')} that no source anchors — the graph cannot see their impact.`,
106
+ });
107
+ escalate('widen');
108
+ };
92
109
  const artifact = inputArgs.artifact;
93
110
  if (!artifact) {
94
111
  // The proportional path: ordinary prose needs no graph evidence. A governing document does, and
@@ -96,6 +113,7 @@ function evaluateEditEvidence(inputArgs) {
96
113
  if (lowRisk)
97
114
  return { decision: 'allow', reasons: [], remediation: '', lowRisk: true };
98
115
  reasons.push({ code: 'no-analysis', detail: `No impact analysis backs an edit to ${target}.` });
116
+ traceGapReason(); // two reasons, both stated; the remediation stays the refusal's
99
117
  return { decision: 'refuse', reasons, remediation: REMEDIATION['no-analysis'], lowRisk };
100
118
  }
101
119
  // (2) An analysis describes one commit. Once HEAD moves, its claims are about a tree that no
@@ -183,6 +201,7 @@ function evaluateEditEvidence(inputArgs) {
183
201
  });
184
202
  escalate('widen');
185
203
  }
204
+ traceGapReason();
186
205
  // Lead with whichever refusal the caller must fix FIRST; a widen reason never speaks for a refusal.
187
206
  const primary = reasons.find((r) => r.code === 'basis-drift')
188
207
  ?? reasons.find((r) => r.code === 'target-drift')
@@ -13,6 +13,7 @@ export declare function rmCommandTargets(cmd: string): string[];
13
13
  * project and stays protected, so nothing is weakened for the case the rule exists to cover. With no
14
14
  * projectRoot supplied the command is returned unchanged — unknown scope stays conservative.
15
15
  */
16
+ export declare function normalizeWindowsPathSpellings(cmd: string): string;
16
17
  export declare function scopeAxToProject(cmd: string, projectRoot?: string): string;
17
18
  /**
18
19
  * Normalize a command string for protected-path matching: collapse `//` runs and `./` segments so
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.rmCommandTargets = rmCommandTargets;
4
+ exports.normalizeWindowsPathSpellings = normalizeWindowsPathSpellings;
4
5
  exports.scopeAxToProject = scopeAxToProject;
5
6
  exports.normalizeCommandPaths = normalizeCommandPaths;
6
7
  exports.classifyReversibility = classifyReversibility;
@@ -248,11 +249,33 @@ const DESTRUCTIVE_CMD_CHECKS = [
248
249
  * project and stays protected, so nothing is weakened for the case the rule exists to cover. With no
249
250
  * projectRoot supplied the command is returned unchanged — unknown scope stays conservative.
250
251
  */
252
+ // @implements A-SPEC-657 — Windows spellings (drive `C:/…`, `C:\…`, MSYS `/c/…`) of a path INSIDE
253
+ // the project were masked as another project's, and the backslash spelling matched no `.ax/` rule at
254
+ // all (measured on Windows 2026-09-17, reproduced on macOS 4/4). The five `.ax` rules below consume
255
+ // the string this function hands them, so one normalisation closes every spelling at once. Pure
256
+ // string logic: no process.platform, no filesystem — a POSIX suite verifies the Windows verdicts.
257
+ function normalizeWindowsPathSpellings(cmd) {
258
+ return cmd
259
+ // `C:\a\b`, `C:/a/b` → `c:/a/b` — a drive is one letter, a colon and a separator, at a token
260
+ // boundary; `http://` and `a:b` never match.
261
+ .replace(/(^|[\s'"=(])([A-Za-z]):[\\/]([^\s'"()]*)/g, (_m, pre, d, rest) => `${pre}${d.toLowerCase()}:/${rest.replace(/\\/g, '/')}`)
262
+ // MSYS/Git-Bash `/c/a/b` → `c:/a/b`.
263
+ .replace(/(^|[\s'"=(])\/([A-Za-z])\/(?=[^\s'"()/])/g, (_m, pre, d) => `${pre}${d.toLowerCase()}:/`);
264
+ }
251
265
  function scopeAxToProject(cmd, projectRoot) {
266
+ const spelled = normalizeWindowsPathSpellings(cmd);
252
267
  if (!projectRoot)
253
- return cmd;
254
- const root = projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
255
- return cmd.replace(/(^|[\s'"=(:])(\/[^\s'"()]*\/\.ax\/[^\s'"()]*)/g, (m, pre, abs) => (abs === root || abs.startsWith(`${root}/`) ? m : `${pre}<other-project-path>`));
268
+ return spelled;
269
+ const root = normalizeWindowsPathSpellings(projectRoot.replace(/\\/g, '/')).replace(/\/+$/, '');
270
+ // NTFS folds case (foldsCaseBySyntax says the same of a drive letter); POSIX does not.
271
+ const drive = /^[a-z]:\//.test(root);
272
+ const inside = (abs) => {
273
+ const a = drive ? abs.toLowerCase() : abs;
274
+ const r = drive ? root.toLowerCase() : root;
275
+ return a === r || a.startsWith(`${r}/`);
276
+ };
277
+ // The pre-class no longer admits `:` — that is what cut `C:/proj/.ax/x` down to `/proj/.ax/x`.
278
+ return spelled.replace(/(^|[\s'"=(])((?:[a-z]:)?\/[^\s'"()]*\/\.ax\/[^\s'"()]*)/g, (m, pre, abs) => (inside(abs) ? m : `${pre}<other-project-path>`));
256
279
  }
257
280
  // `state` joined the protected set with P3 (REQ-134): it holds the constitution-debt and the
258
281
  // last-green baseline, so a single-file `rm` there would bypass the WRITE_CODE debt gate — measured
@@ -1,6 +1,10 @@
1
1
  import { PendingRequest } from '../governance/approval-queue';
2
2
  import { Spec } from '../spec/spec-parser';
3
3
  import type { TestOutcome } from '../review/test-runner';
4
+ import { type KnownDefectJudgement } from '../rtm/known-defects';
5
+ import { type CiVerdict } from '../project/ci-runs';
6
+ export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
7
+ export declare function collectCiVerdicts(root: string, now?: Date): CiVerdict[];
4
8
  /**
5
9
  * @implements A-SPEC-100.2
6
10
  * Stop-hook governance gate (Phase-2 #1: push, not pull).
@@ -22,6 +26,18 @@ import type { TestOutcome } from '../review/test-runner';
22
26
  export interface StopEvidence {
23
27
  /** A-SPEC id -> number of real test cases (`it(`/`test(`) found in test files anchored to it. */
24
28
  testCasesByAspec?: Record<string, number>;
29
+ /**
30
+ * @implements A-SPEC-660
31
+ * `@known-defect` markers found in the workspace's test files, judged against the clock by the
32
+ * CLI: unexpired ones are DEBT (tracked, never blocking), expired and malformed ones are ART-9.
33
+ * `undefined` is no signal — a walk that could not run must not read as marker-free.
34
+ */
35
+ knownDefects?: KnownDefectJudgement;
36
+ /**
37
+ * @implements A-SPEC-664 — what the CI matrix last said about this tree, per OS. Absent when the
38
+ * hook could not look; a `not-run` verdict when it looked and found no row (never silence).
39
+ */
40
+ ci?: CiVerdict[];
25
41
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
26
42
  provenance?: {
27
43
  ok: boolean;
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
+ exports.collectKnownDefects = collectKnownDefects;
38
+ exports.collectCiVerdicts = collectCiVerdicts;
37
39
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
38
40
  exports.unanchoredChangedSources = unanchoredChangedSources;
39
41
  exports.dependencyReappraisals = dependencyReappraisals;
@@ -76,6 +78,90 @@ const pre_tool_use_1 = require("./pre-tool-use");
76
78
  const governance_history_1 = require("../guardrail/governance-history");
77
79
  const constitution_debt_1 = require("../governance/constitution-debt");
78
80
  const root_1 = require("../project/root");
81
+ const known_defects_1 = require("../rtm/known-defects");
82
+ const test_files_1 = require("../cpg/test-files");
83
+ const ci_runs_1 = require("../project/ci-runs");
84
+ // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
85
+ // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
86
+ // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
87
+ // an empty judgement; an unreadable file inside a readable tree is skipped like scanTestAnchors does.
88
+ function collectKnownDefects(root, now) {
89
+ try {
90
+ if (!fs.statSync(root).isDirectory())
91
+ return undefined;
92
+ const found = { markers: [], malformed: [] };
93
+ const walk = (dir) => {
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(dir, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ for (const e of entries) {
102
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist' || e.name === '.ax')
103
+ continue;
104
+ const abs = path.join(dir, e.name);
105
+ if (e.isDirectory()) {
106
+ walk(abs);
107
+ continue;
108
+ }
109
+ const rel = path.relative(root, abs).split(path.sep).join('/');
110
+ if (!(0, test_files_1.isTestFile)(rel))
111
+ continue;
112
+ let text;
113
+ try {
114
+ text = fs.readFileSync(abs, 'utf8');
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const r = (0, known_defects_1.knownDefectsIn)(text, rel);
120
+ found.markers.push(...r.markers);
121
+ found.malformed.push(...r.malformed);
122
+ }
123
+ };
124
+ walk(root);
125
+ return (0, known_defects_1.judgeKnownDefects)(found, now);
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ // @implements A-SPEC-664 — the I/O half of the CI line: read the ci-runs ledger, judge Linux (always)
132
+ // and any other OS that has a row, with git measuring how far each judged commit sits behind HEAD.
133
+ // A tree without a ledger yields a `not-run` verdict — a line, not an absence.
134
+ function collectCiVerdicts(root, now = new Date()) {
135
+ void now;
136
+ // @implements A-SPEC-664 — a workspace that never ran the matrix hears nothing about it. Past this
137
+ // line the matrix IS adopted here, so a missing row is reported rather than passed over in silence.
138
+ if (!(0, ci_runs_1.hasCiLedger)(root))
139
+ return [];
140
+ let runs = [];
141
+ try {
142
+ runs = (0, ci_runs_1.readCiRuns)(root);
143
+ }
144
+ catch {
145
+ runs = [];
146
+ }
147
+ let head;
148
+ try {
149
+ head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim();
150
+ }
151
+ catch {
152
+ head = undefined;
153
+ }
154
+ const behind = (rev) => {
155
+ try {
156
+ return Number((0, node_child_process_1.execFileSync)('git', ['rev-list', '--count', `${rev}..HEAD`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim());
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ };
162
+ const oses = ['linux', ...[...new Set(runs.map((r) => r.os))].filter((o) => o !== 'linux').sort()];
163
+ return oses.map((os) => (0, ci_runs_1.ciVerdict)(runs, os, head, behind));
164
+ }
79
165
  /**
80
166
  * @implements A-SPEC-534.4
81
167
  * ART-8 evidence (I/O half): the A-SPECs whose DIRTY source files carry an @implements anchor. git is
@@ -441,6 +527,8 @@ function governanceLostPreflight(specsDir, projectRoot) {
441
527
  const TRACK_LABELS = {
442
528
  'ART-8': 'RED-first',
443
529
  'ART-2': 'code-graph cycles',
530
+ 'ART-9': 'known-defect debt',
531
+ 'CI': 'matrix',
444
532
  };
445
533
  /**
446
534
  * One line per ARTICLE, each under its own name.
@@ -476,6 +564,8 @@ function evaluateStop(specs, evidence) {
476
564
  specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings,
477
565
  redFirstMode: evidence?.redFirstMode, changedAspecs: evidence?.changedAspecs, outcomesByAspec: evidence?.outcomesByAspec,
478
566
  cycles: evidence?.cycles,
567
+ // @implements A-SPEC-660 — only the expired and unreadable markers are the constitution's business.
568
+ ...(evidence?.knownDefects ? { knownDefects: { expired: evidence.knownDefects.expired, malformed: evidence.knownDefects.malformed } } : {}),
479
569
  });
480
570
  // @implements A-SPEC-534.4 — `track` records ART-8 findings without blocking the turn. Computed
481
571
  // separately (the constitution stays silent on ART-8 outside strict) and returned in `tracked` for
@@ -497,6 +587,21 @@ function evaluateStop(specs, evidence) {
497
587
  if (t.length)
498
588
  tracked = [...(tracked ?? []), ...t];
499
589
  }
590
+ // @implements A-SPEC-660 — unexpired known-defect markers are DEBT: recorded on the tracked channel
591
+ // with file, line, reason and expiry, never a block. A bypass is sometimes the right call; the
592
+ // marker exists so the next person can see it, and ART-9 speaks only once the grace runs out.
593
+ if (evidence?.knownDefects && evidence.knownDefects.unexpired.length > 0) {
594
+ const t = evidence.knownDefects.unexpired.map((k) => ({ article: 'ART-9', detail: `${k.file}:${k.line}: known defect "${k.reason}" until ${k.expires}` }));
595
+ tracked = [...(tracked ?? []), ...t];
596
+ }
597
+ // @implements A-SPEC-664 — the CI matrix's last word, one tracked line per OS. Never a block: a
598
+ // red Linux run is information for the person closing the turn, and "not run" is said out loud
599
+ // because the alternative — silence — reads as green (measured 2026-09-17: 136 environmental reds
600
+ // surfaced only because someone ran the suite by hand before a release).
601
+ if (evidence?.ci && evidence.ci.length > 0) {
602
+ const t = evidence.ci.map((v) => ({ article: 'CI', detail: (0, ci_runs_1.ciStatusLine)(v) }));
603
+ tracked = [...(tracked ?? []), ...t];
604
+ }
500
605
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
501
606
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
502
607
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -1079,7 +1184,17 @@ if (require.main === module) {
1079
1184
  });
1080
1185
  }
1081
1186
  catch { /* maintenance, never a hook failure */ }
1082
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec });
1187
+ // @implements A-SPEC-660 the marker walk: no signal when it cannot run (never a clean verdict).
1188
+ const knownDefects = collectKnownDefects(stopProjectRoot(), new Date());
1189
+ // @implements A-SPEC-664 — the CI matrix line rides beside the constitution's verdict.
1190
+ let ci;
1191
+ try {
1192
+ ci = collectCiVerdicts(stopProjectRoot());
1193
+ }
1194
+ catch {
1195
+ ci = undefined;
1196
+ }
1197
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}) });
1083
1198
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1084
1199
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1085
1200
  // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
@@ -64,6 +64,7 @@ export declare function createGraphOperationsHandlers(context: GraphOperationsCo
64
64
  reason: "hub" | "depth";
65
65
  inDegree?: number;
66
66
  }[] | undefined;
67
+ traceGaps?: import("../../rtm/trace-gaps").TraceGap[] | undefined;
67
68
  summariesOmitted?: number | undefined;
68
69
  impacted: string[];
69
70
  impactedSummaries: {