@holmes-lab/holmes-kit 0.23.1 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,101 @@ 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.23.3] - 2026-09-18
9
+
10
+ A suite that takes half as long, a build that admits when it is stale, and a CI matrix that stopped
11
+ reporting its own noise as a verdict.
12
+
13
+ ### Fixed
14
+ - **The Files-to-Touch declarations that were anchored, and the ones that were not** (A-SPEC-670,
15
+ A-SPEC-665, A-SPEC-672, A-SPEC-674). Five trace gaps that REQ-670's parser fix uncovered are
16
+ closed: `A-SPEC-495`, `A-SPEC-549.3` and `A-SPEC-549.4` declared scanned production files and
17
+ carried no anchor in any of them. Every one of the six places was checked before it was claimed —
18
+ an anchor is an assertion, not a metric — and all six were true. Unlinked approved specs 52 → 49,
19
+ `codeLinkedPct` 91.95 → 92.41, trace gaps 5 → 0.
20
+ - **The CI matrix was reporting its own noise as a verdict** (A-SPEC-664, A-SPEC-674). Measured over
21
+ 22 runs: **every one was red**, and the reds were dominated by two suites this repository knows as
22
+ load gauges (`dashboard.test` 20/22, `entity-store-boundaries` 12/22). The cause was contention —
23
+ the Linux VM takes 11 of the host's 12 cores and ran jest with default workers. A signal that is
24
+ always red cannot separate a regression from itself. With the worker cap below, both canaries went
25
+ green on Linux for the first time in 23 runs, and the matrix immediately produced its first real
26
+ finding.
27
+ - **A dead lock holder could stop the matrix for ever, silently** (A-SPEC-674). The runner's lock was
28
+ released by `trap ... EXIT`, which SIGKILL and a process-group teardown skip; one such lock stood
29
+ for 95 minutes while nine commits went unjudged. The lock now names its holder and liveness is
30
+ judged by PID, never by elapsed time — calling a slow run dead would start a second run on the
31
+ same machine. A skipped run also leaves a `skipped` row now: REQ-664's rule is that no row means
32
+ "not run", so a silent skip erased the skip itself.
33
+
34
+ ### Added
35
+ - **The Stop hook says when `dist/` no longer represents the source** (A-SPEC-673). Thirty suites
36
+ load `dist/` while they run and nothing asserted it was current; only the release gate compared the
37
+ build id to HEAD, and only at publish time. A stale build does not go red — it verifies old code
38
+ and returns green. The judgement is built on the build id and **not** on mtime, measured: this
39
+ tree's `.build-id` had a newer mtime than every source while naming a commit nine behind, and two
40
+ changed sources were missing from `dist` entirely. Five states, of which `fresh` and `absent` say
41
+ nothing at all, and the two unjudgeable ones say **the check could not run** rather than passing
42
+ quietly. Non-blocking, on the `tracked` channel, and silent in a workspace that does not build.
43
+
44
+ ### Changed
45
+ - **The test suite runs in 353s instead of 505s** (A-SPEC-672). The wall clock equalled the longest
46
+ single file to the decimal, so more workers could not help. Splitting the two heaviest files alone
47
+ moved it 505s → 493s while the serial total rose 2,836s → 5,109s: four heavy suites running at once
48
+ contended hard enough to swallow the win. Measured 2x2, the split and a worker cap **interact** —
49
+ alone they are worth 12s and 44s, together 132s. `jest.maxWorkers` is now `"50%"`, a proportion
50
+ rather than a number, because a fixed count means something else on a machine with a different core
51
+ count. Cases are preserved exactly, per-spec execution counts included.
52
+
53
+ ## [0.23.2] - 2026-09-18
54
+
55
+ Three rules that existed only in prose, and the one number a broken parser had published.
56
+
57
+ ### Fixed
58
+ - **The Files-to-Touch parser reads every declared path** (A-SPEC-670). It took only the **first word
59
+ of a list item**, so measured over all 678 approved specs here, **88 specs declared 172 paths that
60
+ were never read** — 24 of them parsed to zero while naming files plainly. Five forms accounted for
61
+ every miss: several paths on one line (71), an indented continuation line (39), the second path on a
62
+ line whose first was read (32), a Korean first word (22), and a prose paragraph instead of a list (5).
63
+ A slashed path is now read wherever it sits; a bare `name.ext` keeps the old position rule, because
64
+ the root-file predicate accepts any extension and without it `module.exports` and a prose `b.ts`
65
+ become declared files. A declared path that does not exist but is the suffix of exactly one
66
+ repository file is reported as `abbreviated` rather than a defect (measured: unique in all 8 cases),
67
+ and two candidates stays an ambiguity where nothing is guessed. Cost over the whole corpus: **zero**
68
+ new `missing` findings, zero new `moved`, 26 abbreviations resolved.
69
+ - **A number 0.23.0 published was the parser's, not the corpus's** (A-SPEC-665, corrected). The
70
+ declaration census read `scanned-source 0 / no-declaration 4` for this repository's 52 unlinked
71
+ specs, and the README said the remaining 8.1% of `codeLinkedPct` was therefore not a missing anchor.
72
+ With the parser fixed the same census reads **3 and 0**: `A-SPEC-495`, `A-SPEC-549.3` and
73
+ `A-SPEC-549.4` declare scanned production files that carry no anchor, and **five real trace gaps**
74
+ were behind them. The 665 chain was unsealed leaf-first and corrected, and its pin no longer names
75
+ spec ids — pinning those ids pinned the defect. Its own rule ("assert the sum and the property, not
76
+ the numbers") is what kept the fix from looking like a violation.
77
+ - **The shipped instruction says how a spec body is written** (A-SPEC-671). `init` wrote into every
78
+ consumer's `AGENTS.md` that specs are authored with four MCP tools, and **none of the 44 tools takes
79
+ section text**. Authoring here means editing the scaffolded markdown, which the same rule read as
80
+ forbidden — and the collision is guaranteed, not occasional: of the four documents `spec_slice_init`
81
+ scaffolds, exactly one section is left blank (the REQ's `Success Criteria`) and that blank is
82
+ precisely what approval refuses. An agent in another workspace hit this, refused to break the rule,
83
+ and asked for a tool that does not exist; its judgement was correct. Rule 1 now separates the two
84
+ acts the tools perform (**scaffold** and **seal**), states that a body is written by editing the
85
+ generated markdown, and says the Success Criteria is empty on purpose. The empty-criteria refusal
86
+ now names the file and the heading. No new tool and no loosened gate: taking bodies as tool
87
+ arguments is a worse authoring surface, and auto-filling the stub would restore the pass nobody
88
+ earned that A-SPEC-505.1 exists to prevent.
89
+
90
+ ### Added
91
+ - **The publish gate reads the docs** (A-SPEC-669). `playbooks/publish/PLAYBOOK.md` step 2.5 has
92
+ demanded a documentation pass since 0.16.0 with nothing enforcing it, and it failed four times —
93
+ 0.16.0, and 0.21.0/0.22.0/0.23.0, which shipped with the README feature list stuck at 0.20.0.
94
+ `verify-release.js` now refuses a missing CHANGELOG entry for the version being published, and an
95
+ entry carrying `### Added` while `README.md` has not changed since the previous release. Validated
96
+ against eight releases before it was written: it refuses exactly 0.21.0, 0.22.0 and 0.23.0 and
97
+ passes the other five. Uncited sealed specs and version-pinned README headings are **reported only**
98
+ — measured poor gates, since 0.20.0 cited none of its 57 sealed specs and was an honest release. The
99
+ drift question (is the old wording still true?) is judgement and is deliberately not automated: a
100
+ machine imitating it would produce a pass nobody earned, which is the 0.16.0 incident itself. With no
101
+ previous tag the README rule is **skipped and says so**.
102
+
8
103
  ## [0.23.1] - 2026-09-18
9
104
 
10
105
  A release that could not be published, and the documentation that had fallen three versions behind.
package/README.md CHANGED
@@ -16,9 +16,12 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🧱 **A stale build is told, not discovered** *(new in 0.23.3)*: thirty suites in this project load `dist/` while they run, and nothing asserted that it still represented the source — only the release gate compared the build id to HEAD, and only at publish time. A stale build does not go red; it verifies old code and returns green. The Stop hook now reports it on the non-blocking `tracked` channel, judged by the **build id and never by mtime**: measured here, `.build-id` had a newer mtime than every source file while naming a commit nine behind HEAD, with two changed sources missing from `dist` entirely. A workspace that does not build hears nothing, `fresh` says nothing, and the two states that cannot be judged say **that** rather than passing quietly.
20
+ - 📐 **Declarations are read as written** *(new in 0.23.2)*: `Files to Touch` is where a spec declares the files it will touch, and three things read it — fulfilment advisories, the declaration census and the approval impact note. The parser took only the **first word of a list item**, so measured over 678 approved specs here, **88 specs and 172 paths were declared and never read**; 24 of them parsed to zero while naming files plainly. Several paths on one line, an indented continuation, a Korean first word, a prose paragraph — all invisible. They are read now, wherever they sit, and a bare `name.ext` keeps its old position rule so a property access like `module.exports` is still not a file. A declared path that does not exist but is the suffix of exactly one repository file is reported as an abbreviation rather than a defect; two candidates stays an ambiguity and nothing is guessed. Cost, measured across the whole corpus: **zero** new `missing` findings.
21
+ - 📄 **The publish gate reads the docs** *(new in 0.23.2)*: the publish playbook has demanded "bring README and CHANGELOG up to this release" since 0.16.0 and only prose enforced it, so it failed four times — including 0.21.0, 0.22.0 and 0.23.0, which each shipped with a feature list frozen at 0.20.0. The release gate now refuses two things it can decide: a missing CHANGELOG entry for the version being published, and an entry with `### Added` while `README.md` has not changed since the previous release. Replayed over eight releases it refuses exactly the three that were stale and passes the other five. What needs judgement — is the old wording still true? — stays with the person and is **reported**, never faked; a check that could not run says so instead of reading as a pass.
19
22
  - 🧩 **Your config files survive a re-wire** *(new in 0.23.0)*: `init --agent antigravity` and `init --agent codex` used to replace `.agents/mcp_config.json`, `.agents/hooks.json` and `marketplace.json` **whole**. Measured with a real `--dry-run` before the fix: a neighbour MCP server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a re-wire at a rate of **zero**. They now survive — only the holmes-kit entry is refreshed, and `init` names what it kept. A `disabled` you set stays set (and `init` says the gate will not run while it stands, rather than switching it back on silently); an existing file that is not readable JSON is refused with a reason instead of being replaced. The Claude wiring already merged; the other two harnesses now have the same discipline.
20
23
  - 🫀 **The MCP supervisor notices a child that died** *(new in 0.23.0)*: under `HOLMES_MCP_AUTORELOAD` the supervisor had no exit handler, so a crashed child left it writing to a dead pipe — its in-flight count never returned to zero, the swap that would have replaced the child never fired, and the server went **permanently deaf** (observed twice in one session, then reproduced on demand against the real class). It now answers every outstanding request with a JSON-RPC error **first** (a client must never wait for ever), then resets, respawns and replays the opening exchange. A child that dies having never answered spends a restart budget, so a broken build stops quickly while one crash under load restarts freely; an intentional swap or shutdown is not counted as an accident.
21
- - 🧮 **Coverage you can explain** *(new in 0.23.0)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. On this repository, of 52 unlinked approved specs **zero** declare a file the scanner parses, so the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer.
24
+ - 🧮 **Coverage you can explain** *(new in 0.23.0, corrected in 0.23.2)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer. **0.23.0 published a wrong number here**: it read "of 52 unlinked specs, zero declare a file the scanner parses, so the remaining 8.1% is not a missing anchor". That zero was the Files-to-Touch parser's, not the corpus's see the 0.23.2 entry. The same census now reads three, and five real trace gaps were behind it.
22
25
  - 🤖 **A CI matrix that judges every commit, and never reads silence as green** *(new in 0.22.0)*: a maintainer-side runner takes one commit onto a Linux VM (clone → install → build → full suite) and appends exactly one row to a `ci-runs` ledger — for every outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`, `vm-unreachable`). A launchd agent triggers it per commit. The Stop hook reports the matrix's last word and `doctor` gains a `ci matrix` check; a missing row reads as **"not run"**, never as a pass. Workspaces that never adopted the matrix hear nothing about it.
23
26
  - 🔁 **Advisories learn what happened next** *(new in 0.22.0)*: every finding — impact, anchor density, Files-to-Touch fulfilment, trace gap, `kills`-unapplicable — now carries a deterministic id. Sealing records it as `issued`; the next `approval_status` re-runs the **same** functions and records `resolved` when the cause is gone or `persisted` when it is not, at most one row per finding per day. An author who judges a finding unhelpful passes `dismiss: [id]`; an unknown id comes back in `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains the per-kind tallies. This is the numerator every "promote to a hard gate once we know the false-positive rate" sentence was missing.
24
27
  - 🧪 **`kills` that cannot apply say so** *(new in 0.22.0)*: `test_run --mutate` reports mutations that never applied as `unapplied`, separately from `survivors`. Measured here, all 22 `kills` entries in this repository wrote `where` as a file path and `mutate` as prose, so the literal-replacement engine applied **none** of them while the response still read `survivors: []` — the shape of a clean run. Sealing a T-SPEC now reports entries whose `where` is absent from the A-SPEC's anchored source, and the authoring playbook shows the grammar.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- d2304488-mu6dv817
1
+ 970ee6cf-mu6tt9oo
@@ -70,6 +70,7 @@ export declare function shortPathIfSpaced(p: string): string;
70
70
  * space, or win32 where 8.3 resolved it).
71
71
  */
72
72
  export declare function antigravityHookWarnings(packageRoot: string): string[];
73
+ export declare const AGENTS_MD: (enforced: boolean) => string;
73
74
  export declare const MANAGED_BEGIN = "<!-- holmes-kit:managed:begin -->";
74
75
  export declare const MANAGED_END = "<!-- holmes-kit:managed:end -->";
75
76
  /**
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MANAGED_END = exports.MANAGED_BEGIN = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
36
+ exports.MANAGED_END = exports.MANAGED_BEGIN = exports.AGENTS_MD = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
37
  exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
39
  exports.antigravityHookWarnings = antigravityHookWarnings;
@@ -201,7 +201,9 @@ This repository operates under **No Spec, No Code** governance. An approved spec
201
201
 
202
202
  1. **Tool-First Principle (NO Workaround Scripts)**:
203
203
  - Creating or mutating \`.ax/specs\` or governance ledgers via arbitrary temporary scripts (\`/tmp/*.js\`) is strictly forbidden.
204
- - Use only \`spec_slice_init\`, \`spec_slice_approve\`, \`spec_create\`, and \`spec_approve\` MCP tools to author and seal specs.
204
+ - \`spec_slice_init\` and \`spec_create\` SCAFFOLD spec documents; \`spec_slice_approve\` and \`spec_approve\` SEAL them. Use these tools for those two acts — never a script.
205
+ - **Writing the body is editing the file.** After scaffolding, fill the \`TODO\` placeholders by editing the generated spec markdown in \`.ax/specs/\` with your normal editor. This is the expected authoring path, not a workaround — no MCP tool takes section text.
206
+ - A REQ's \`## Success Criteria\` is scaffolded EMPTY on purpose, because a generator must not invent what "satisfied" means. Approval is refused until you write observable criteria there.
205
207
 
206
208
  2. **Anchor-First Principle (Mandatory Code Anchors)**:
207
209
  - When creating or modifying source code and test files, line 1 MUST include an explicit anchor comment: \`// @\` + \`implements A-SPEC-XXX\`.
@@ -212,7 +214,7 @@ This repository operates under **No Spec, No Code** governance. An approved spec
212
214
 
213
215
  ## Procedure
214
216
 
215
- 1. Run \`spec_slice_init\` or \`spec_create\` to author REQ -> H-SPEC -> A-SPEC.
217
+ 1. Run \`spec_slice_init\` or \`spec_create\` to scaffold REQ -> H-SPEC -> A-SPEC, then edit the generated markdown to replace every \`TODO\` and to write the REQ's Success Criteria.
216
218
  2. Approve specs via \`spec_slice_approve\` or \`spec_approve\`.
217
219
  3. **Before editing source**, run \`maintenance_analyze({ root, request, persist: true })\` and keep the digest it returns — that persisted analysis is what the pre-edit evidence gate checks.
218
220
  4. **Write tests first** and verify failure (RED stage).
@@ -227,6 +229,7 @@ ${enforced
227
229
  ? `Gates are **enforced**. Tool calls that write un-anchored code or target unapproved specifications will be denied. Unresolved critical findings block completion.`
228
230
  : `This harness does not have measured **hook enforcement points**. Holmes-Kit provides tools and guidance here without active gate blocking.`}
229
231
  `;
232
+ exports.AGENTS_MD = AGENTS_MD;
230
233
  exports.MANAGED_BEGIN = '<!-- holmes-kit:managed:begin -->';
231
234
  exports.MANAGED_END = '<!-- holmes-kit:managed:end -->';
232
235
  const PRESERVED_NOTE = '<!-- kept from this file by holmes-kit init — holmes-kit does not write below this line -->';
@@ -415,7 +418,7 @@ function agentFiles(agent, opts) {
415
418
  return [
416
419
  { path: path.join(target, '.agents', 'hooks.json'), content: hooksJson(packageRoot) },
417
420
  { path: path.join(target, '.agents', 'mcp_config.json'), content: mcpConfig(packageRoot, specsDir, opts.launcher) },
418
- { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.antigravity) },
421
+ { path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.antigravity) },
419
422
  ];
420
423
  case 'codex':
421
424
  // @implements A-SPEC-442 (was A-SPEC-266, A-SPEC-441) — Codex plugin, at the layout Codex reads:
@@ -480,7 +483,7 @@ function agentFiles(agent, opts) {
480
483
  },
481
484
  }, null, 2)}\n`,
482
485
  },
483
- { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.codex) },
486
+ { path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.codex) },
484
487
  ];
485
488
  default:
486
489
  // 모르는 하네스를 조용히 건너뛰면 "배선했다"는 보고와 실제가 어긋난다.
@@ -48,6 +48,8 @@ exports.renderRefusals = renderRefusals;
48
48
  exports.renderNonTtyHint = renderNonTtyHint;
49
49
  exports.runInteractive = runInteractive;
50
50
  // @implements A-SPEC-246, A-SPEC-626
51
+ // @implements A-SPEC-549.4 — renderPending and renderNonTtyHint in this file are the English CLI
52
+ // surface the Hangul-absence guard reads.
51
53
  const execution_context_1 = require("../project/execution-context");
52
54
  const root_1 = require("../project/root");
53
55
  const fs = __importStar(require("node:fs"));
@@ -46,6 +46,8 @@ exports.wiredSpecsDir = wiredSpecsDir;
46
46
  exports.evaluateHook = evaluateHook;
47
47
  exports.readSpecsSync = readSpecsSync;
48
48
  // @implements A-SPEC-278
49
+ // @implements A-SPEC-549.3 — the hook's deny reasons are English here; hooks-english.test.ts drives
50
+ // evaluateHook in this file to prove it.
49
51
  // @implements A-SPEC-643
50
52
  // @implements A-SPEC-642
51
53
  // @implements A-SPEC-194, A-SPEC-195
@@ -3,7 +3,9 @@ import { Spec } from '../spec/spec-parser';
3
3
  import type { TestOutcome } from '../review/test-runner';
4
4
  import { type KnownDefectJudgement } from '../rtm/known-defects';
5
5
  import { type CiVerdict } from '../project/ci-runs';
6
+ import { type DistVerdict } from '../project/dist-freshness';
6
7
  export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
8
+ export declare function collectDistFreshness(root: string): DistVerdict | undefined;
7
9
  export declare function collectCiVerdicts(root: string, now?: Date): CiVerdict[];
8
10
  /**
9
11
  * @implements A-SPEC-100.2
@@ -38,6 +40,11 @@ export interface StopEvidence {
38
40
  * hook could not look; a `not-run` verdict when it looked and found no row (never silence).
39
41
  */
40
42
  ci?: CiVerdict[];
43
+ /**
44
+ * @implements A-SPEC-673 — whether `dist/` still represents the source. Absent when the workspace
45
+ * does not build at all; a `no-build-id`/`unknown` verdict when it looked and could not judge.
46
+ */
47
+ dist?: DistVerdict;
41
48
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
42
49
  provenance?: {
43
50
  ok: boolean;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
37
  exports.collectKnownDefects = collectKnownDefects;
38
+ exports.collectDistFreshness = collectDistFreshness;
38
39
  exports.collectCiVerdicts = collectCiVerdicts;
39
40
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
40
41
  exports.unanchoredChangedSources = unanchoredChangedSources;
@@ -55,6 +56,8 @@ exports.guardCountOrZero = guardCountOrZero;
55
56
  exports.readGuardCount = readGuardCount;
56
57
  exports.writeGuardCount = writeGuardCount;
57
58
  const fs = __importStar(require("node:fs"));
59
+ // @implements A-SPEC-549.3 — evaluateStop's messages are English here; the Hangul-absence guard
60
+ // calls into this file.
58
61
  // @implements A-SPEC-645
59
62
  // @implements A-SPEC-642
60
63
  const npx_bin_1 = require("../project/npx-bin");
@@ -81,6 +84,7 @@ const root_1 = require("../project/root");
81
84
  const known_defects_1 = require("../rtm/known-defects");
82
85
  const test_files_1 = require("../cpg/test-files");
83
86
  const ci_runs_1 = require("../project/ci-runs");
87
+ const dist_freshness_1 = require("../project/dist-freshness");
84
88
  // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
85
89
  // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
86
90
  // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
@@ -128,6 +132,32 @@ function collectKnownDefects(root, now) {
128
132
  return undefined;
129
133
  }
130
134
  }
135
+ // @implements A-SPEC-673 — the I/O half of the build-freshness line: read `dist/.build-id` and ask
136
+ // git how far that commit sits behind HEAD. A workspace that does not build hears nothing; a build
137
+ // that cannot be judged says so rather than passing quietly.
138
+ function collectDistFreshness(root) {
139
+ if (!(0, dist_freshness_1.hasDist)(root))
140
+ return undefined;
141
+ const buildId = (0, dist_freshness_1.distBuildId)(root);
142
+ let head = null;
143
+ try {
144
+ head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim();
145
+ }
146
+ catch {
147
+ head = null;
148
+ }
149
+ let behind = null;
150
+ if (buildId && head) {
151
+ try {
152
+ const n = Number((0, node_child_process_1.execFileSync)('git', ['rev-list', '--count', `${buildId}..HEAD`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim());
153
+ behind = Number.isFinite(n) ? n : null;
154
+ }
155
+ catch {
156
+ behind = null;
157
+ }
158
+ }
159
+ return (0, dist_freshness_1.distFreshness)({ distExists: true, buildId, head, behind });
160
+ }
131
161
  // @implements A-SPEC-664 — the I/O half of the CI line: read the ci-runs ledger, judge Linux (always)
132
162
  // and any other OS that has a row, with git measuring how far each judged commit sits behind HEAD.
133
163
  // A tree without a ledger yields a `not-run` verdict — a line, not an absence.
@@ -529,6 +559,7 @@ const TRACK_LABELS = {
529
559
  'ART-2': 'code-graph cycles',
530
560
  'ART-9': 'known-defect debt',
531
561
  'CI': 'matrix',
562
+ 'DIST': 'build freshness',
532
563
  };
533
564
  /**
534
565
  * One line per ARTICLE, each under its own name.
@@ -602,6 +633,14 @@ function evaluateStop(specs, evidence) {
602
633
  const t = evidence.ci.map((v) => ({ article: 'CI', detail: (0, ci_runs_1.ciStatusLine)(v) }));
603
634
  tracked = [...(tracked ?? []), ...t];
604
635
  }
636
+ // @implements A-SPEC-673 — a build that no longer represents the source. Thirty suites load `dist/`
637
+ // while they run, so a stale one does not go red: it verifies old code and returns green. `fresh`
638
+ // and `absent` produce no line, because a line is spent only on something the reader can act on.
639
+ if (evidence?.dist) {
640
+ const detail = (0, dist_freshness_1.distStatusLine)(evidence.dist);
641
+ if (detail)
642
+ tracked = [...(tracked ?? []), { article: 'DIST', detail }];
643
+ }
605
644
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
606
645
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
607
646
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -1194,7 +1233,15 @@ if (require.main === module) {
1194
1233
  catch {
1195
1234
  ci = undefined;
1196
1235
  }
1197
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}) });
1236
+ // @implements A-SPEC-673 the build-freshness line rides the same non-blocking channel.
1237
+ let dist;
1238
+ try {
1239
+ dist = collectDistFreshness(stopProjectRoot());
1240
+ }
1241
+ catch {
1242
+ dist = undefined;
1243
+ }
1244
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}) });
1198
1245
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1199
1246
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1200
1247
  // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TOOL_SCHEMAS = exports.HOOK_ENFORCED_TOOLS = void 0;
4
4
  // @implements A-SPEC-629, A-SPEC-628, A-SPEC-623, A-SPEC-277, A-SPEC-624, A-SPEC-627
5
+ // @implements A-SPEC-495 — the maintenance_analyze description carries the semantic-evidence
6
+ // consumer contract (sem-verify / semCos / semanticAlternates) beside the anti-prediction warning.
5
7
  // @implements A-SPEC-642
6
8
  // @implements A-SPEC-641
7
9
  // @implements A-SPEC-639
@@ -0,0 +1,20 @@
1
+ export interface CiLockHolder {
2
+ pid: number;
3
+ host: string;
4
+ rev: string;
5
+ at: string;
6
+ }
7
+ export type CiLockState = 'free' | 'held' | 'dead' | 'unknown';
8
+ export interface CiLockInput {
9
+ holder: CiLockHolder | null;
10
+ thisHost: string;
11
+ alive: boolean;
12
+ }
13
+ /** Null for every shape we cannot trust — including the legacy empty directory. */
14
+ export declare function readCiLock(lockDir: string): CiLockHolder | null;
15
+ /**
16
+ * A holder written by ANOTHER host is `unknown`, not dead: we cannot ask that machine about its
17
+ * pids, and guessing would hand two runners the same VM. Unknown is respected like `held`.
18
+ */
19
+ export declare function ciLockState(input: CiLockInput): CiLockState;
20
+ export declare function ciLockLine(state: CiLockState, holder: CiLockHolder | null, now?: Date): string;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readCiLock = readCiLock;
37
+ exports.ciLockState = ciLockState;
38
+ exports.ciLockLine = ciLockLine;
39
+ // @implements A-SPEC-674
40
+ /**
41
+ * Who holds the CI runner's lock, and is that holder still alive.
42
+ *
43
+ * The runner serialises itself with a `mkdir` lock — atomic, and that atomicity is the whole reason
44
+ * it works — released by `trap ... EXIT`. SIGKILL and launchd tearing down the process group skip
45
+ * that trap. Measured 2026-09-18: one such lock survived 95 minutes while the matrix judged nothing
46
+ * and nine commits went unjudged, with zero jest processes in the VM and a load of 0.57.
47
+ *
48
+ * The lock used to be an EMPTY directory, so it could not say who held it — and a lock that cannot
49
+ * name its holder cannot be asked whether that holder is alive. It now carries a holder file, and
50
+ * the judgement below is about liveness, never about elapsed time: calling a slow run dead would
51
+ * start a second run on the same VM, which is a worse failure than waiting.
52
+ */
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ /** Null for every shape we cannot trust — including the legacy empty directory. */
56
+ function readCiLock(lockDir) {
57
+ let raw;
58
+ try {
59
+ raw = fs.readFileSync(path.join(lockDir, 'holder.json'), 'utf8');
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ const h = parsed;
72
+ if (typeof h?.pid !== 'number' || !Number.isFinite(h.pid))
73
+ return null;
74
+ if (typeof h.host !== 'string' || typeof h.rev !== 'string' || typeof h.at !== 'string')
75
+ return null;
76
+ return { pid: h.pid, host: h.host, rev: h.rev, at: h.at };
77
+ }
78
+ /**
79
+ * A holder written by ANOTHER host is `unknown`, not dead: we cannot ask that machine about its
80
+ * pids, and guessing would hand two runners the same VM. Unknown is respected like `held`.
81
+ */
82
+ function ciLockState(input) {
83
+ const { holder, thisHost, alive } = input;
84
+ if (!holder)
85
+ return 'free';
86
+ if (holder.host !== thisHost)
87
+ return 'unknown';
88
+ return alive ? 'held' : 'dead';
89
+ }
90
+ const elapsed = (at, now) => {
91
+ const t = Date.parse(at);
92
+ if (!Number.isFinite(t))
93
+ return null;
94
+ const mins = Math.floor((now.getTime() - t) / 60000);
95
+ // A clock that runs backwards is a fact about the clock, not about the holder; never report it as
96
+ // negative time.
97
+ return mins < 0 ? null : `${mins} min`;
98
+ };
99
+ function ciLockLine(state, holder, now = new Date()) {
100
+ if (state === 'free' || !holder)
101
+ return '';
102
+ const held = elapsed(holder.at, now);
103
+ const forHow = held ? ` for ${held}` : '';
104
+ if (state === 'dead') {
105
+ return `ci lock holder pid ${holder.pid} is gone (held${forHow}, rev ${holder.rev}) — reclaiming it; the matrix judges nothing while a dead holder stands`;
106
+ }
107
+ if (state === 'unknown') {
108
+ return `ci lock was taken by ${holder.host} (rev ${holder.rev}${forHow}) — this host cannot judge another machine's pids, so it is respected`;
109
+ }
110
+ return `ci lock is held by pid ${holder.pid} running rev ${holder.rev}${forHow}`;
111
+ }
@@ -0,0 +1,30 @@
1
+ export type DistState = 'fresh' | 'behind' | 'absent' | 'no-build-id' | 'unknown';
2
+ export interface DistVerdict {
3
+ state: DistState;
4
+ buildId: string | null;
5
+ head: string | null;
6
+ behind: number | null;
7
+ }
8
+ export interface DistInput {
9
+ distExists: boolean;
10
+ buildId: string | null;
11
+ head: string | null;
12
+ /** Commits from the build id to HEAD, or null when it could not be obtained. */
13
+ behind: number | null;
14
+ }
15
+ /**
16
+ * The adoption predicate: existence, never contents. A workspace that does not build has no build
17
+ * freshness to report, and telling it about one would be noise it can do nothing with (the same
18
+ * reasoning REQ-664 used for the CI ledger).
19
+ */
20
+ export declare function hasDist(root: string): boolean;
21
+ /** The commit the artefact came from — the part before the dash `npm run build` writes. */
22
+ export declare function distBuildId(root: string): string | null;
23
+ /**
24
+ * Five states because each calls for something different: `behind` means build, `absent` means
25
+ * nothing at all to this workspace, and the two unjudgeable ones mean the check did not run — which
26
+ * must never be reported as a check that passed.
27
+ */
28
+ export declare function distFreshness(input: DistInput): DistVerdict;
29
+ /** Empty when there is nothing to say. A line is spent only on a fact the reader can act on. */
30
+ export declare function distStatusLine(v: DistVerdict): string;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.hasDist = hasDist;
37
+ exports.distBuildId = distBuildId;
38
+ exports.distFreshness = distFreshness;
39
+ exports.distStatusLine = distStatusLine;
40
+ // @implements A-SPEC-673
41
+ /**
42
+ * Whether the built artefact still represents the source.
43
+ *
44
+ * Thirty suites in this repository load `dist/` while they run — they spawn it or require it — and
45
+ * nothing asserted that it was current. `scripts/verify-release.js` compares the build id to HEAD,
46
+ * but only at publish time, so during development a stale build does not go red: it quietly verifies
47
+ * old code and returns green. This repository has already paid for that once.
48
+ *
49
+ * The judgement is built on the BUILD ID, never on mtime, for a measured reason. On 2026-09-18 this
50
+ * tree's `dist` was nine commits behind HEAD and two changed sources were missing from it entirely,
51
+ * while `find src -newer dist/.build-id` answered zero — the marker's mtime had been touched without
52
+ * a rebuild. mtime lies. The build id says which commit the artefact actually came from.
53
+ *
54
+ * This module only JUDGES. It never rebuilds: a hook does not change the operator's tree in silence.
55
+ */
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ /**
59
+ * The adoption predicate: existence, never contents. A workspace that does not build has no build
60
+ * freshness to report, and telling it about one would be noise it can do nothing with (the same
61
+ * reasoning REQ-664 used for the CI ledger).
62
+ */
63
+ function hasDist(root) {
64
+ try {
65
+ return fs.statSync(path.join(root, 'dist')).isDirectory();
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** The commit the artefact came from — the part before the dash `npm run build` writes. */
72
+ function distBuildId(root) {
73
+ let raw;
74
+ try {
75
+ raw = fs.readFileSync(path.join(root, 'dist', '.build-id'), 'utf8');
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const id = raw.trim().split('-')[0];
81
+ // A shape we do not recognise falls through to a judgement we cannot make, never to a pass.
82
+ return /^[0-9a-f]{7,40}$/.test(id) ? id : null;
83
+ }
84
+ /**
85
+ * Five states because each calls for something different: `behind` means build, `absent` means
86
+ * nothing at all to this workspace, and the two unjudgeable ones mean the check did not run — which
87
+ * must never be reported as a check that passed.
88
+ */
89
+ function distFreshness(input) {
90
+ const { distExists, buildId, head, behind } = input;
91
+ const base = { buildId: buildId ?? null, head: head ?? null, behind: typeof behind === 'number' ? behind : null };
92
+ if (!distExists)
93
+ return { state: 'absent', ...base };
94
+ if (!buildId)
95
+ return { state: 'no-build-id', ...base };
96
+ if (!head || typeof behind !== 'number' || !Number.isFinite(behind) || behind < 0)
97
+ return { state: 'unknown', ...base };
98
+ return { state: behind === 0 ? 'fresh' : 'behind', ...base };
99
+ }
100
+ /** Empty when there is nothing to say. A line is spent only on a fact the reader can act on. */
101
+ function distStatusLine(v) {
102
+ if (v.state === 'fresh' || v.state === 'absent')
103
+ return '';
104
+ if (v.state === 'no-build-id') {
105
+ return 'dist/.build-id is missing or unreadable — the build could not be judged against the source; `npm run build` writes it';
106
+ }
107
+ if (v.state === 'unknown') {
108
+ return `dist was built from ${v.buildId ?? 'an unknown commit'} and the distance to HEAD could not be obtained — the build could not be judged`;
109
+ }
110
+ // No invented threshold: one commit is reported like nine, because which commits matter is a
111
+ // question the reader can answer and this function cannot.
112
+ const n = v.behind ?? 0;
113
+ return `dist is ${n} commit${n === 1 ? '' : 's'} behind HEAD (built from ${v.buildId}) — suites that load dist are verifying that build, not this tree; \`npm run build\``;
114
+ }
@@ -19,6 +19,14 @@ export interface FttFulfilment {
19
19
  path: string;
20
20
  line: string;
21
21
  }>;
22
+ /**
23
+ * @implements A-SPEC-670 — a declared path that does not exist but is the suffix of exactly ONE
24
+ * repository file is the same file written short, not a defect — reported, never counted as one.
25
+ */
26
+ abbreviated?: Array<{
27
+ path: string;
28
+ resolvedTo: string;
29
+ }>;
22
30
  }
23
31
  export interface FulfilmentRecord {
24
32
  aspec: string;
@@ -29,9 +37,22 @@ export interface FulfilmentRecord {
29
37
  replica?: string;
30
38
  }
31
39
  /**
32
- * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
33
- * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
34
- * their fulfilment is not a single file's existence.
40
+ * @implements A-SPEC-670
41
+ * Every path-shaped token in the section, wherever it sits on the line.
42
+ *
43
+ * REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
44
+ * that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
45
+ * plainly, so the census called them "no declaration" and one wrong conclusion was published from
46
+ * it. Five forms accounted for every miss: several paths on one line, an indented continuation
47
+ * line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
48
+ * written instead of a list.
49
+ *
50
+ * The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
51
+ * whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
52
+ * on it is a declaration too — so there is no measured population of "mentioned but not declared".
53
+ *
54
+ * What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
55
+ * (directories) are not items, because their fulfilment is not one file's existence.
35
56
  */
36
57
  export declare function fttItems(fttText: string): FttItem[];
37
58
  export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
@@ -57,30 +57,62 @@ const fs = __importStar(require("node:fs"));
57
57
  const path = __importStar(require("node:path"));
58
58
  const scope_judgment_1 = require("../guardrail/scope-judgment");
59
59
  const replica_id_1 = require("../governance/replica-id");
60
- const ITEM_RE = /^\s*[-*]\s+(.*)$/;
61
60
  const NEW_RE = /\((신규|new)\)/i;
62
61
  const ALT_RE = /또는|\bor\b/;
63
62
  const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
64
63
  const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
64
+ /** Token boundaries as the corpus actually writes them: backticks, quotes, brackets, `·`, commas. */
65
+ const TOKEN_SPLIT = /[`'"()[\]{}<>,;·、,\s]+/;
65
66
  /**
66
- * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
67
- * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
68
- * their fulfilment is not a single file's existence.
67
+ * @implements A-SPEC-670
68
+ * Every path-shaped token in the section, wherever it sits on the line.
69
+ *
70
+ * REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
71
+ * that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
72
+ * plainly, so the census called them "no declaration" and one wrong conclusion was published from
73
+ * it. Five forms accounted for every miss: several paths on one line, an indented continuation
74
+ * line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
75
+ * written instead of a list.
76
+ *
77
+ * The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
78
+ * whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
79
+ * on it is a declaration too — so there is no measured population of "mentioned but not declared".
80
+ *
81
+ * What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
82
+ * (directories) are not items, because their fulfilment is not one file's existence.
69
83
  */
70
84
  function fttItems(fttText) {
71
85
  const out = [];
86
+ const seen = new Set();
72
87
  for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
73
- const m = ITEM_RE.exec(raw);
74
- if (!m)
88
+ const isListItem = /^\s*[-*]\s+/.test(raw);
89
+ const line = raw.replace(/^\s*[-*]\s+/, '').trim();
90
+ if (line === '')
75
91
  continue;
76
- const line = m[1].trim();
77
- const word = (line.split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '').replace(/^`?([^`]*?)`?\(/, '$1(');
78
- const token = word.replace(/\(.*$/, ''); // `src/b.ts`(new) src/b.ts
79
- if (!token || token.includes('*') || !HAS_EXT.test(token))
80
- continue;
81
- if (!(SLASHED.test(token) || (0, scope_judgment_1.isRootFileToken)(token)))
82
- continue;
83
- out.push({ path: token, line, isNew: NEW_RE.test(line), alternative: ALT_RE.test(line) });
92
+ const isNew = NEW_RE.test(line);
93
+ const alternative = ALT_RE.test(line);
94
+ // Splitting on backticks leaves an empty first piece for `` `a.ts` ``, so "first" counts real
95
+ // words, not array slots — otherwise every backticked root filename loses its position.
96
+ const pieces = line.split(TOKEN_SPLIT).filter((w) => w !== '');
97
+ for (let i = 0; i < pieces.length; i += 1) {
98
+ // Trailing sentence punctuation is not part of a path; a leading `./` is not part of its identity.
99
+ const token = pieces[i].replace(/^\.\//, '').replace(/[.,;:]+$/, '');
100
+ if (!token || token.includes('*') || !HAS_EXT.test(token))
101
+ continue;
102
+ // A SLASHED path is admitted wherever it sits — that is the whole point of REQ-670, and a
103
+ // slash makes it unmistakably a path. A bare `name.ext` is NOT: `isRootFileToken` accepts any
104
+ // extension, so mid-line it would swallow `module.exports` and a prose `b.ts` (both measured
105
+ // in A-SPEC-656's pins). Root-file tokens therefore keep REQ-654's position exactly: the first
106
+ // word of a LIST ITEM. Measured: dropping the list condition admitted `semanticArm.gates` from
107
+ // a continuation line in A-SPEC-488 — one new false "missing" across the whole corpus, and the
108
+ // only one. With the condition restored the widening adds 172 declarations and no new finding.
109
+ if (!SLASHED.test(token) && !(isListItem && i === 0 && (0, scope_judgment_1.isRootFileToken)(token)))
110
+ continue;
111
+ if (seen.has(token))
112
+ continue;
113
+ seen.add(token);
114
+ out.push({ path: token, line, isNew, alternative });
115
+ }
84
116
  }
85
117
  return out;
86
118
  }
@@ -91,6 +123,7 @@ function fttFulfilment(fttText, exists, locate) {
91
123
  const missing = [];
92
124
  const moved = [];
93
125
  const alternatives = [];
126
+ const abbreviated = [];
94
127
  for (const it of items) {
95
128
  if (it.alternative) {
96
129
  alternatives.push({ path: it.path, line: it.line });
@@ -100,14 +133,25 @@ function fttFulfilment(fttText, exists, locate) {
100
133
  continue;
101
134
  const base = it.path.slice(it.path.lastIndexOf('/') + 1);
102
135
  const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
136
+ // @implements A-SPEC-670 — `project/root.ts` beside `src/holmes/project/root.ts` is the same
137
+ // file written short, measured 8 times in this corpus and unique every time. Only a UNIQUE
138
+ // suffix match earns this: two candidates is an ambiguity, and a guess there would invent a
139
+ // fact. Anything else keeps its old verdict, so the rule swallows no real absence.
140
+ const suffixHits = foundAt.filter((p) => p.endsWith(`/${it.path}`));
141
+ if (suffixHits.length === 1) {
142
+ abbreviated.push({ path: it.path, resolvedTo: suffixHits[0] });
143
+ continue;
144
+ }
103
145
  if (foundAt.length > 0)
104
146
  moved.push({ path: it.path, foundAt });
105
147
  else
106
148
  missing.push({ path: it.path, isNew: it.isNew });
107
149
  }
108
- if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
150
+ if (missing.length === 0 && moved.length === 0 && alternatives.length === 0 && abbreviated.length === 0)
109
151
  return null;
110
- return { declared: items.length, missing, moved, alternatives };
152
+ // Present only when it has something to say: an always-there empty array would change the shape
153
+ // every existing caller and pin reads, for a fact that is not there.
154
+ return { declared: items.length, missing, moved, alternatives, ...(abbreviated.length > 0 ? { abbreviated } : {}) };
111
155
  }
112
156
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
113
157
  /** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
@@ -37,6 +37,8 @@ exports.resolveSemanticKey = resolveSemanticKey;
37
37
  exports.storeSemanticKey = storeSemanticKey;
38
38
  exports.removeSemanticKey = removeSemanticKey;
39
39
  // @implements A-SPEC-592
40
+ // @implements A-SPEC-549.4 — declared by that spec's Files to Touch and carries no Hangul; note that
41
+ // the guard's own SOURCES list does not cover this file (reported, not fixed here).
40
42
  // @implements A-SPEC-477
41
43
  /**
42
44
  * The cloud tier's credential: WHERE the consent lives, and in what order it is looked up.
@@ -272,7 +272,11 @@ function unactionableCriteriaBlocker(spec) {
272
272
  const quality = (0, acceptance_quality_1.acceptanceQuality)(spec);
273
273
  if (quality === 'stated')
274
274
  return null;
275
+ // @implements A-SPEC-671 — the refusal now says WHERE. It named what was wrong and stopped, and on
276
+ // 2026-09-18 an agent in another workspace read the shipped AGENTS.md rule ("author with these
277
+ // four tools"), found no tool that writes a section body, and halted rather than break the rule.
278
+ // The section is filled by editing the file, so the refusal hands over the file and the heading.
275
279
  return quality === 'absent'
276
- ? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — 관측 가능한 기준을 채운 뒤 승인하십시오`
280
+ ? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — \`.ax/specs/01_req/${spec.id}.md\` 의 \`## Success Criteria\` 절을 편집기로 열어 관측 가능한 기준을 채운 뒤 승인하십시오`
277
281
  : `REQ ${spec.id}의 Success Criteria가 전량 보일러플레이트입니다('구현·테스트 완료'는 충족 정의가 아닙니다) — 각 항목이 검증 동사·측정 대상을 갖게 고친 뒤 승인하십시오`;
278
282
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.23.1",
4
+ "version": "0.23.3",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -62,7 +62,8 @@
62
62
  "testTimeout": 30000,
63
63
  "setupFilesAfterEnv": [
64
64
  "<rootDir>/src/holmes/test-support/jest-timeouts-setup.ts"
65
- ]
65
+ ],
66
+ "maxWorkers": "50%"
66
67
  },
67
68
  "dependencies": {
68
69
  "@modelcontextprotocol/sdk": "^1.29.0",