@holmes-lab/holmes-kit 0.20.1 → 0.21.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 (42) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/gitignore-merge.js +10 -0
  4. package/dist/holmes/governance/autonomy.js +6 -0
  5. package/dist/holmes/governance/constitution.d.ts +11 -0
  6. package/dist/holmes/governance/constitution.js +15 -1
  7. package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
  8. package/dist/holmes/guardrail/impact-gate.js +19 -0
  9. package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
  10. package/dist/holmes/guardrail/risk-classifier.js +26 -3
  11. package/dist/holmes/guardrail/scope-judgment.d.ts +12 -1
  12. package/dist/holmes/guardrail/scope-judgment.js +31 -2
  13. package/dist/holmes/hooks/stop.d.ts +9 -0
  14. package/dist/holmes/hooks/stop.js +63 -1
  15. package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
  16. package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
  17. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
  18. package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
  19. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +2 -1
  20. package/dist/holmes/mcp/handlers/operator-inspection.js +25 -3
  21. package/dist/holmes/mcp/handlers/spec-approval.d.ts +1 -0
  22. package/dist/holmes/mcp/handlers/spec-approval.js +29 -1
  23. package/dist/holmes/mcp/handlers.d.ts +4 -1
  24. package/dist/holmes/mcp/handlers.js +4 -0
  25. package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
  26. package/dist/holmes/rtm/anchor-comment.js +8 -0
  27. package/dist/holmes/rtm/file-anchors.d.ts +9 -0
  28. package/dist/holmes/rtm/file-anchors.js +128 -0
  29. package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
  30. package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
  31. package/dist/holmes/rtm/known-defects.d.ts +26 -0
  32. package/dist/holmes/rtm/known-defects.js +77 -0
  33. package/dist/holmes/rtm/link-census.d.ts +61 -0
  34. package/dist/holmes/rtm/link-census.js +90 -0
  35. package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
  36. package/dist/holmes/rtm/trace-gaps.js +64 -0
  37. package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
  38. package/dist/holmes/server/dashboard-launcher.js +24 -1
  39. package/dist/holmes/server/dashboard.js +40 -2
  40. package/package.json +1 -1
  41. package/playbooks/author-slice/PLAYBOOK.md +11 -0
  42. package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,91 @@ 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.21.0] - 2026-09-17
9
+
10
+ The backlog-hardening cycle: the RTM stops claiming coverage it cannot see, sealing reports what a
11
+ spec's Files to Touch left behind, and a known defect pinned by a test now carries a marker with an
12
+ expiry — reported as debt, never a block, until the grace runs out.
13
+
14
+ ### Added
15
+ - **Code-link census with reasons** (A-SPEC-655). `rtm_dashboard` and `/api/rtm` report `codeLinkedPct`,
16
+ `unlinkedCount` and `unlinkedByReason` (`test-only`, `file-anchor`, `test-and-file`, `weak-anchor`, `none`)
17
+ next to the old `coveragePct`, which still reads 100 while 11.4% of approved specs have no `implements`
18
+ edge. The scanner now reads every anchor the injector can write (`.sh/.yml/.toml/...` file anchors were
19
+ planted and never indexed). Measured here: 636 approved, 563 linked, 73 unlinked — 72 test-only.
20
+ - **Files-to-Touch fulfilment advisory** (A-SPEC-656). At `spec_approve` and in `approval_status`, a sealed
21
+ A-SPEC whose declared paths do not exist is reported as `missing`, `moved` (same basename elsewhere) or
22
+ `alternatives`; the verdict is appended to `.ax/ledger/ftt-fulfilment.<replica>.jsonl`. Never blocks.
23
+ Four genuinely unfulfilled paths on three sealed specs were found on the first run.
24
+ - **Trace gaps** (A-SPEC-658). `rtm_impact` returns `traceGaps` — approved specs that declare a scanned
25
+ production file in Files to Touch yet anchor only tests — and `impact_gate_check` answers `trace-gap`
26
+ with remediation instead of silently widening. Measured here: 21 specs, 29 spec/file pairs.
27
+ - **`@known-defect(reason, expires=YYYY-MM-DD)`** (A-SPEC-660) and constitution article **ART-9**. A test
28
+ that pins a known defect as its expected value carries the marker on a comment line; the Stop hook walks
29
+ test files, lists unexpired markers on the `tracked` channel as `known-defect debt`, and blocks only when
30
+ a marker has expired or cannot be read (`no-reason`, `no-expires`, `bad-date`). String literals are
31
+ stripped first, the expiry day itself counts as expired (UTC), and a walk that cannot start is no signal.
32
+ Walk cost ≈61 ms per Stop on this tree; zero markers exist yet.
33
+ - **`[observability]` obligation in the authoring playbooks** (A-SPEC-659). A slice that creates a record
34
+ must declare the obligation in its H-SPEC and carry a record→read round-trip case in its T-SPEC; the
35
+ existing tag-correspondence check reports the gap. Pinned so the prose cannot claim the gate blocks.
36
+
37
+ ### Fixed
38
+ - **Windows-spelled in-project `.ax` paths are judged by location, not spelling** (A-SPEC-657). A Bash
39
+ write to `C:\proj\.ax\...`, `/c/proj/.ax/...` or `C:/proj/.ax/...` reached the protected-path check
40
+ as an outside-project path and was masked out of it; drive, MSYS and backslash spellings now normalise
41
+ to the project root first. Found by the Windows agent's residual-red evidence, reproduced on macOS.
42
+
43
+ ### Measured
44
+ - macOS: 515 suites / 6,534 passed / 11 skipped; every slice recorded red-assertion → green; official
45
+ `test_run` green at b18ff0bf, last-green recorded.
46
+ - `kills` mutation testing: all 22 existing entries write `where` as a file path and `mutate` as prose,
47
+ so the literal-replacement engine applies none of them and `--mutate` reports `survivors: []`. Making
48
+ `kills` mandatory is deferred behind a `where`-validation slice (`docs/goals/EVIDENCE-kills-cost-2026-09-17.md`).
49
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 24): 514 / 515 suites green; the one red is the documented
50
+ `tree-sitter@0.21.1` source-compile failure against Node 24 headers on ARM Linux (README, unchanged since
51
+ 0.19.4) — the npx wiring case installs the packed tarball and that install fails there. On Node 22 the
52
+ same tarball installs, the MCP handshake answers with the package version, and the suite is green.
53
+ - Windows: not re-run for this release; the only Windows-relevant change is A-SPEC-657, reproduced and
54
+ pinned on macOS from the Windows agent's evidence.
55
+
56
+ ## [0.20.2] - 2026-09-16
57
+
58
+ Two findings the Windows cycle recorded and could not close, closed — without giving autonomy or the
59
+ drift check one inch of slack.
60
+
61
+ ### Fixed
62
+ - **A sealed spec can now admit a root-level file** (A-SPEC-654). `Files to Touch` tokens had to contain
63
+ a slash, so a spec listing `- jest.config.js` produced no token at all and its own anchored write was
64
+ refused as "outside its Files to Touch scope" — measured 2026-09-15 on Windows, where A-SPEC-652.4 had
65
+ to be redesigned around it. A list item whose first word is a filename with an extension is now a token;
66
+ a filename mentioned inside prose, a version-like item (`- 0.20.1`) and a bare word still yield nothing,
67
+ and the enforcer still admits exactly that one path.
68
+ - **A CRLF checkout is no longer reported as skill drift.** Under `core.autocrlf=true`, git rewrote the
69
+ skill files the installer writes with LF, so `doctor` reported three untouched skills as `drifted` and
70
+ advised a refresh that produced LF for the next checkout to convert straight back (measured 2026-09-16,
71
+ Windows). `init`'s `.gitattributes` block now declares `.claude/skills/**/SKILL.md` and
72
+ `.agents/skills/**/SKILL.md` as `text eol=lf`, so checkout stops re-creating the difference. One
73
+ `holmes-kit skills refresh` converges an existing CRLF working tree.
74
+
75
+ ### Unchanged on purpose
76
+ - **Autonomy did not widen with the new tokens.** Measured before the change: 36 A-SPECs in this
77
+ repository have prose-only Files to Touch and 8 of them would have gained tokens — one mentioning
78
+ `handlers.ts` in a sentence would have flipped from human approval to self-approval. The grader now
79
+ requires at least one directory-scoped token, so a spec whose tokens are all root-level files still asks
80
+ a human: a root-level file is project-wide configuration. A test walks every A-SPEC in the store and
81
+ asserts each verdict equals the pre-change verdict.
82
+ - **The drift comparison is still byte-exact.** Folding CRLF into it was implemented, measured against
83
+ A-SPEC-190 §9a — whitespace-touched files are `drifted` *and a refresh reclaims them* — and reverted:
84
+ it would have made the first of those three mutations invisible rather than recoverable. That refusal
85
+ is pinned in the new suite.
86
+
87
+ ### Measured
88
+ - macOS: 503 suites / 6,461 passed / 11 skipped; official `test_run` green, last-green recorded.
89
+ - Still open: the Windows residual single-assertion reds. They need the failure text from the Windows box
90
+ (`npx jest --json`); nothing in this repository names why they fail, and guessing from a macOS run would
91
+ be inventing evidence.
92
+
8
93
  ## [0.20.1] - 2026-09-16
9
94
 
10
95
  The Windows release. 0.20.0's external matrix was run natively on Windows 11 (Node 24, npm 12, pytest 9)
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 37e99456-mu2vxydy
1
+ bdbd4f85-mu4ywfad
@@ -61,6 +61,16 @@ exports.ATTRIBUTE_LINES = [
61
61
  '# 남는 체인 이음새는 `holmes-kit ledger rechain`의 일이다.',
62
62
  '.ax/ledger/*.jsonl merge=union',
63
63
  '.ax/approvals/queue.jsonl merge=union',
64
+ // @implements A-SPEC-654
65
+ // The installer writes these with LF and doctor compares them byte for byte (A-SPEC-190 §9a: a
66
+ // whitespace-touched file is `drifted` so a refresh can reclaim it). Under `core.autocrlf=true`
67
+ // checkout rewrote them to CRLF, so three untouched skills read `drifted` and the advised refresh
68
+ // produced LF for the next checkout to convert straight back — measured 2026-09-16 on Windows.
69
+ // Declaring the files settles it where the disagreement is, instead of teaching the drift check to
70
+ // ignore a difference it exists to catch.
71
+ '# @implements A-SPEC-654 — 설치기가 LF 로 쓰는 파일은 체크아웃에서 CRLF 로 바뀌지 않는다.',
72
+ '.claude/skills/**/SKILL.md text eol=lf',
73
+ '.agents/skills/**/SKILL.md text eol=lf',
64
74
  ];
65
75
  /** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
66
76
  * and user-content preservation are inherited rather than re-implemented. */
@@ -178,6 +178,12 @@ function specApprovalAutonomy(spec, resolveParent, descendants) {
178
178
  return 'hitl';
179
179
  if (paths.some(isHighRiskPath))
180
180
  return 'hitl'; // gate/governance/taint file
181
+ // @implements A-SPEC-654 — a ROOT-level file is project-wide configuration (the test runner, the
182
+ // manifest, the ignore policy): admitting it at the gate was the point of that slice, but grading
183
+ // it auto would be a widening nobody asked for. A spec earns self-approval only when it also names
184
+ // a directory-scoped surface — the same doubt `paths.length === 0` already encodes.
185
+ if (!paths.some((p) => p.includes('/')))
186
+ return 'hitl';
181
187
  return 'auto';
182
188
  }
183
189
  default:
@@ -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
@@ -29,7 +29,18 @@ export declare function isTestPath(relPath: string): boolean;
29
29
  * one bounded fragment, so no ReDoS surface.
30
30
  */
31
31
  export declare function matchesFtt(token: string, relPath: string): boolean;
32
- /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
32
+ export declare function isRootFileToken(token: string): boolean;
33
+ /**
34
+ * Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none.
35
+ *
36
+ * @implements A-SPEC-654
37
+ * The slashed match alone could not see a ROOT-level file, so a sealed spec listing `- jest.config.js`
38
+ * admitted nothing at all and its own anchored write was refused as out of scope (measured 2026-09-15 on
39
+ * Windows; A-SPEC-652.4 was redesigned around it). A bare filename counts only as a LIST ITEM's first
40
+ * word: `- \`tsconfig.test.json\` (신규)` is a token, while `\`cfg.ts\`, \`ddg.ts\` 를 고친다` — prose, and a
41
+ * word carrying a comma — stays what it always was, nothing. The enforcer is unchanged: a root token
42
+ * admits that exact path (`matchesFtt`), and the grader requires a directory-scoped token of its own.
43
+ */
33
44
  export declare function fttPathTokens(fttText: string): string[];
34
45
  export interface ScopeInput {
35
46
  relPath: string;
@@ -25,6 +25,7 @@
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.isTestPath = isTestPath;
27
27
  exports.matchesFtt = matchesFtt;
28
+ exports.isRootFileToken = isRootFileToken;
28
29
  exports.fttPathTokens = fttPathTokens;
29
30
  exports.judgeScope = judgeScope;
30
31
  /** Repo test conventions — the single census every dispensation consumer shares. */
@@ -63,9 +64,37 @@ function matchesFtt(token, relPath) {
63
64
  return relPath === tok; // file literal: that file only
64
65
  return relPath === tok || relPath.startsWith(tok + '/'); // dir literal: its subtree
65
66
  }
66
- /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
67
+ /**
68
+ * @implements A-SPEC-654
69
+ * A repository-ROOT file named as a Files-to-Touch item: a bare filename with an extension that starts
70
+ * with a letter, so a version (`0.20.1`) is not a file and a bare word (`README`) is not either.
71
+ */
72
+ const ROOT_FILE = /^[\w@.-]+\.[A-Za-z][A-Za-z0-9]*$/;
73
+ function isRootFileToken(token) {
74
+ return !token.includes('/') && ROOT_FILE.test(token);
75
+ }
76
+ /**
77
+ * Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none.
78
+ *
79
+ * @implements A-SPEC-654
80
+ * The slashed match alone could not see a ROOT-level file, so a sealed spec listing `- jest.config.js`
81
+ * admitted nothing at all and its own anchored write was refused as out of scope (measured 2026-09-15 on
82
+ * Windows; A-SPEC-652.4 was redesigned around it). A bare filename counts only as a LIST ITEM's first
83
+ * word: `- \`tsconfig.test.json\` (신규)` is a token, while `\`cfg.ts\`, \`ddg.ts\` 를 고친다` — prose, and a
84
+ * word carrying a comma — stays what it always was, nothing. The enforcer is unchanged: a root token
85
+ * admits that exact path (`matchesFtt`), and the grader requires a directory-scoped token of its own.
86
+ */
67
87
  function fttPathTokens(fttText) {
68
- return fttText.match(/[\w@.*-]+(?:\/[\w@.*-]+)+/g) ?? [];
88
+ const tokens = [...(fttText.match(/[\w@.*-]+(?:\/[\w@.*-]+)+/g) ?? [])];
89
+ for (const line of fttText.split('\n')) {
90
+ const item = /^\s*[-*]\s+(.*)$/.exec(line);
91
+ if (!item)
92
+ continue;
93
+ const word = (item[1].trim().split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '');
94
+ if (isRootFileToken(word) && !tokens.includes(word))
95
+ tokens.push(word);
96
+ }
97
+ return tokens;
69
98
  }
70
99
  // @implements A-SPEC-508.1
71
100
  function judgeScope(input) {
@@ -1,6 +1,8 @@
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
+ export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
4
6
  /**
5
7
  * @implements A-SPEC-100.2
6
8
  * Stop-hook governance gate (Phase-2 #1: push, not pull).
@@ -22,6 +24,13 @@ import type { TestOutcome } from '../review/test-runner';
22
24
  export interface StopEvidence {
23
25
  /** A-SPEC id -> number of real test cases (`it(`/`test(`) found in test files anchored to it. */
24
26
  testCasesByAspec?: Record<string, number>;
27
+ /**
28
+ * @implements A-SPEC-660
29
+ * `@known-defect` markers found in the workspace's test files, judged against the clock by the
30
+ * CLI: unexpired ones are DEBT (tracked, never blocking), expired and malformed ones are ART-9.
31
+ * `undefined` is no signal — a walk that could not run must not read as marker-free.
32
+ */
33
+ knownDefects?: KnownDefectJudgement;
25
34
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
26
35
  provenance?: {
27
36
  ok: boolean;
@@ -34,6 +34,7 @@ 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;
37
38
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
38
39
  exports.unanchoredChangedSources = unanchoredChangedSources;
39
40
  exports.dependencyReappraisals = dependencyReappraisals;
@@ -76,6 +77,55 @@ const pre_tool_use_1 = require("./pre-tool-use");
76
77
  const governance_history_1 = require("../guardrail/governance-history");
77
78
  const constitution_debt_1 = require("../governance/constitution-debt");
78
79
  const root_1 = require("../project/root");
80
+ const known_defects_1 = require("../rtm/known-defects");
81
+ const test_files_1 = require("../cpg/test-files");
82
+ // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
83
+ // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
84
+ // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
85
+ // an empty judgement; an unreadable file inside a readable tree is skipped like scanTestAnchors does.
86
+ function collectKnownDefects(root, now) {
87
+ try {
88
+ if (!fs.statSync(root).isDirectory())
89
+ return undefined;
90
+ const found = { markers: [], malformed: [] };
91
+ const walk = (dir) => {
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(dir, { withFileTypes: true });
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ for (const e of entries) {
100
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist' || e.name === '.ax')
101
+ continue;
102
+ const abs = path.join(dir, e.name);
103
+ if (e.isDirectory()) {
104
+ walk(abs);
105
+ continue;
106
+ }
107
+ const rel = path.relative(root, abs).split(path.sep).join('/');
108
+ if (!(0, test_files_1.isTestFile)(rel))
109
+ continue;
110
+ let text;
111
+ try {
112
+ text = fs.readFileSync(abs, 'utf8');
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ const r = (0, known_defects_1.knownDefectsIn)(text, rel);
118
+ found.markers.push(...r.markers);
119
+ found.malformed.push(...r.malformed);
120
+ }
121
+ };
122
+ walk(root);
123
+ return (0, known_defects_1.judgeKnownDefects)(found, now);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
79
129
  /**
80
130
  * @implements A-SPEC-534.4
81
131
  * ART-8 evidence (I/O half): the A-SPECs whose DIRTY source files carry an @implements anchor. git is
@@ -441,6 +491,7 @@ function governanceLostPreflight(specsDir, projectRoot) {
441
491
  const TRACK_LABELS = {
442
492
  'ART-8': 'RED-first',
443
493
  'ART-2': 'code-graph cycles',
494
+ 'ART-9': 'known-defect debt',
444
495
  };
445
496
  /**
446
497
  * One line per ARTICLE, each under its own name.
@@ -476,6 +527,8 @@ function evaluateStop(specs, evidence) {
476
527
  specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings,
477
528
  redFirstMode: evidence?.redFirstMode, changedAspecs: evidence?.changedAspecs, outcomesByAspec: evidence?.outcomesByAspec,
478
529
  cycles: evidence?.cycles,
530
+ // @implements A-SPEC-660 — only the expired and unreadable markers are the constitution's business.
531
+ ...(evidence?.knownDefects ? { knownDefects: { expired: evidence.knownDefects.expired, malformed: evidence.knownDefects.malformed } } : {}),
479
532
  });
480
533
  // @implements A-SPEC-534.4 — `track` records ART-8 findings without blocking the turn. Computed
481
534
  // separately (the constitution stays silent on ART-8 outside strict) and returned in `tracked` for
@@ -497,6 +550,13 @@ function evaluateStop(specs, evidence) {
497
550
  if (t.length)
498
551
  tracked = [...(tracked ?? []), ...t];
499
552
  }
553
+ // @implements A-SPEC-660 — unexpired known-defect markers are DEBT: recorded on the tracked channel
554
+ // with file, line, reason and expiry, never a block. A bypass is sometimes the right call; the
555
+ // marker exists so the next person can see it, and ART-9 speaks only once the grace runs out.
556
+ if (evidence?.knownDefects && evidence.knownDefects.unexpired.length > 0) {
557
+ const t = evidence.knownDefects.unexpired.map((k) => ({ article: 'ART-9', detail: `${k.file}:${k.line}: known defect "${k.reason}" until ${k.expires}` }));
558
+ tracked = [...(tracked ?? []), ...t];
559
+ }
500
560
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
501
561
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
502
562
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -1079,7 +1139,9 @@ if (require.main === module) {
1079
1139
  });
1080
1140
  }
1081
1141
  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 });
1142
+ // @implements A-SPEC-660 the marker walk: no signal when it cannot run (never a clean verdict).
1143
+ const knownDefects = collectKnownDefects(stopProjectRoot(), new Date());
1144
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}) });
1083
1145
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1084
1146
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1085
1147
  // @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: {
@@ -45,6 +45,7 @@ const root_1 = require("../../project/root");
45
45
  const rtm_builder_1 = require("../../rtm/rtm-builder");
46
46
  const graph_store_1 = require("../../rtm/graph-store");
47
47
  const assoc_arm_1 = require("../../assoc/assoc-arm");
48
+ const trace_gaps_1 = require("../../rtm/trace-gaps");
48
49
  // @implements A-SPEC-478 — the semantic tier runtime stays at the async reindex edge.
49
50
  const tier_1 = require("../../semantic/tier");
50
51
  const vector_cache_1 = require("../../semantic/vector-cache");
@@ -119,7 +120,8 @@ function createGraphOperationsHandlers(context) {
119
120
  // drops outdated/legacy), which is exactly what let this in. Diagnosis (rtm_check) and
120
121
  // matching (issue_localize / maintenance_analyze) keep their own populations — the replay
121
122
  // pins were measured on them.
122
- const specs = (await context.listSpecs()).filter((s) => s.status === 'approved');
123
+ const specsAll = await context.listSpecs();
124
+ const specs = specsAll.filter((s) => s.status === 'approved');
123
125
  // @implements A-SPEC-283
124
126
  // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
125
127
  // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
@@ -181,11 +183,26 @@ function createGraphOperationsHandlers(context) {
181
183
  return { id, summary };
182
184
  });
183
185
  const summariesOmitted = impacted.length - shownSummaries.length;
186
+ // @implements A-SPEC-658 — the specs the graph CANNOT see for these changes: approved,
187
+ // anchored by no production source, and declaring a changed symbol's file. Information
188
+ // beside the closure, never inside it; unresolved symbols yield no files and therefore no
189
+ // guess.
190
+ const changedFiles = a.changed.flatMap((qn) => g.codeNodeIds(qn))
191
+ .map((id) => id.slice(id.lastIndexOf('@') + 1)).filter((f) => f !== '');
192
+ const gaps = changedFiles.length > 0
193
+ ? (0, trace_gaps_1.traceGaps)(changedFiles, (0, trace_gaps_1.unlinkedApproved)(specsAll, scanned), (id) => { try {
194
+ return g.summaryOf(id);
195
+ }
196
+ catch {
197
+ return null;
198
+ } })
199
+ : [];
184
200
  answered = true;
185
201
  return {
186
202
  impacted,
187
203
  impactedSummaries,
188
204
  ...(summariesOmitted > 0 ? { summariesOmitted } : {}),
205
+ ...(gaps.length > 0 ? { traceGaps: gaps } : {}),
189
206
  rankedImpact,
190
207
  reachedByDepth,
191
208
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
@@ -1,8 +1,12 @@
1
+ import type { Spec } from '../../spec/spec-parser';
2
+ import type { ScannedFile } from '../../cpg/cpg-scanner';
1
3
  interface MaintenanceEvidenceContext {
2
4
  foreignRootReason(root: string): string | null;
3
5
  projectRootOf(root: string): string;
4
6
  refusal(reason: string): Error;
5
7
  fileDigestOf(root: string, relativePath: string): string | null;
8
+ listSpecs(): Promise<Spec[]>;
9
+ cachedScan(root: string): ScannedFile[];
6
10
  }
7
11
  /** Raw handlers: the public facade attaches basis exactly once. */
8
12
  export declare function createMaintenanceEvidenceHandlers(context: MaintenanceEvidenceContext): {
@@ -41,6 +41,7 @@ const node_child_process_1 = require("node:child_process");
41
41
  const maintenance_evidence_1 = require("../maintenance-evidence");
42
42
  const impact_gate_1 = require("../../guardrail/impact-gate");
43
43
  const root_1 = require("../../project/root");
44
+ const trace_gaps_1 = require("../../rtm/trace-gaps");
44
45
  /** Raw handlers: the public facade attaches basis exactly once. */
45
46
  function createMaintenanceEvidenceHandlers(context) {
46
47
  return {
@@ -77,11 +78,25 @@ function createMaintenanceEvidenceHandlers(context) {
77
78
  throw context.refusal(`이 프로젝트에 저장된 분석이 아닙니다: ${a.digest}`);
78
79
  artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
79
80
  }
81
+ const target = a.target.replace(/\\/g, '/');
82
+ // @implements A-SPEC-658 — approved specs that declare this file but anchor nothing: the graph
83
+ // cannot see them, so the gate must not read their silence as "no impact". A failed lookup
84
+ // drops the input (information lost, verdict unchanged) rather than inventing a gap.
85
+ let gaps;
86
+ try {
87
+ const found = (0, trace_gaps_1.traceGaps)([target], (0, trace_gaps_1.unlinkedApproved)(await context.listSpecs(), context.cachedScan(root)));
88
+ if (found.length > 0)
89
+ gaps = found.map(({ id, file }) => ({ id, file }));
90
+ }
91
+ catch {
92
+ gaps = undefined;
93
+ }
80
94
  return (0, impact_gate_1.evaluateEditEvidence)({
81
- target: a.target.replace(/\\/g, '/'),
95
+ target,
82
96
  artifact,
83
97
  currentHead: head,
84
98
  currentTargetDigest: context.fileDigestOf(root, a.target),
99
+ ...(gaps ? { traceGaps: gaps } : {}),
85
100
  });
86
101
  },
87
102
  // @implements A-SPEC-268