@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 +95 -0
- package/README.md +4 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +1 -0
- package/dist/holmes/cli/agents.js +8 -5
- package/dist/holmes/cli/approve.js +2 -0
- package/dist/holmes/hooks/pre-tool-use.js +2 -0
- package/dist/holmes/hooks/stop.d.ts +7 -0
- package/dist/holmes/hooks/stop.js +48 -1
- package/dist/holmes/mcp/tool-schemas.js +2 -0
- package/dist/holmes/project/ci-lock.d.ts +20 -0
- package/dist/holmes/project/ci-lock.js +111 -0
- package/dist/holmes/project/dist-freshness.d.ts +30 -0
- package/dist/holmes/project/dist-freshness.js +114 -0
- package/dist/holmes/rtm/ftt-fulfilment.d.ts +24 -3
- package/dist/holmes/rtm/ftt-fulfilment.js +60 -16
- package/dist/holmes/semantic/credentials.js +2 -0
- package/dist/holmes/spec/approval-blockers.js +5 -1
- package/package.json +3 -2
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
74
|
-
|
|
88
|
+
const isListItem = /^\s*[-*]\s+/.test(raw);
|
|
89
|
+
const line = raw.replace(/^\s*[-*]\s+/, '').trim();
|
|
90
|
+
if (line === '')
|
|
75
91
|
continue;
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|