@holmes-lab/holmes-kit 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,92 @@ 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.24.0] - 2026-09-19
9
+
10
+ A way for a consumer's defect to reach us, and two rules that existed only in prose.
11
+
12
+ ### Added
13
+ - **`holmes-kit report`** (A-SPEC-675, A-SPEC-676, A-SPEC-679, A-SPEC-680). A consumer project had no channel by which a
14
+ holmes-kit defect could reach us: the one field defect we knew about arrived because a user pasted
15
+ a transcript, and it had reproduced for every consumer on every slice. The command writes a
16
+ **redacted** report to `.ax/reports/<fingerprint>.md` and prints a prefilled GitHub issue link —
17
+ carrying the title, the repository owner as assignee, and the body — plus a search link for the
18
+ same fingerprint so a duplicate is visible before filing. `--open` opens it; a headless box, an SSH
19
+ session or CI simply keeps the printed link. **No token, no API, no automatic submission**: the
20
+ person presses Submit on GitHub's own page with the body visible and editable, which puts consent
21
+ where the bytes are. Redaction is an **allowlist**, not a scrubber, because a denylist has been
22
+ punctured here before; the machine's hostname, username, home directory and replica ids are
23
+ searched for in the composed body by the pins themselves. A spec id passes by shape and a spec
24
+ TITLE does not — that is unreleased product intent. Observations take numbers only.
25
+ What holmes-kit cannot know, it says: no ledger keeps the product's refusal text, so a run without
26
+ `--message` writes the report, states that no description was given, explains why it cannot be
27
+ recovered, and exits non-zero rather than handing a maintainer a version string.
28
+ - **A stale graph analysis is visible** (A-SPEC-681). The Stop hook's tracked channel reports source
29
+ changed with no `maintenance_analyze` standing open. Measured here: the procedure had been skipped
30
+ for seventeen consecutive commits, and the one run that followed found child-process precedents a
31
+ name search had missed entirely. Non-blocking, silent in a workspace that never analysed anything,
32
+ and judged by commit rather than by clock.
33
+
34
+ ### Fixed
35
+ - **The workspace registry no longer fills with dead temp directories** (A-SPEC-677, 678). Measured:
36
+ 603 entries, 458 (76%) pointing at paths that no longer existed and 599 of them temp directories —
37
+ **three** were real projects. `upgrade` believed the list: it would print 458 skip lines and then
38
+ run `runInit({force:true})` against 142 abandoned temp directories, writing wiring files into them.
39
+ A temp target is now never recorded, and recording prunes what has vanished. The pruning is
40
+ deliberately asymmetric: a vanished temp path is gone by construction, while a vanished normal path
41
+ may be an unmounted volume, so it is counted and kept — `upgrade` already skips it harmlessly, and
42
+ deleting it is the one outcome a user cannot undo.
43
+ - **Tests can no longer write into the developer's real registry** (A-SPEC-678). That is how 599 of
44
+ those entries arrived. Inside jest, `process.env` is a sandbox copy, so setting `HOME` never
45
+ reaches `setenv` and `os.homedir()` keeps answering the real home — an in-process test cannot
46
+ isolate a module that calls it directly. `HOLMES_HOME` is the seam; consumers set nothing and
47
+ behaviour is unchanged for them.
48
+
49
+ ## [0.23.3] - 2026-09-18
50
+
51
+ A suite that takes half as long, a build that admits when it is stale, and a CI matrix that stopped
52
+ reporting its own noise as a verdict.
53
+
54
+ ### Fixed
55
+ - **The Files-to-Touch declarations that were anchored, and the ones that were not** (A-SPEC-670,
56
+ A-SPEC-665, A-SPEC-672, A-SPEC-674). Five trace gaps that REQ-670's parser fix uncovered are
57
+ closed: `A-SPEC-495`, `A-SPEC-549.3` and `A-SPEC-549.4` declared scanned production files and
58
+ carried no anchor in any of them. Every one of the six places was checked before it was claimed —
59
+ an anchor is an assertion, not a metric — and all six were true. Unlinked approved specs 52 → 49,
60
+ `codeLinkedPct` 91.95 → 92.41, trace gaps 5 → 0.
61
+ - **The CI matrix was reporting its own noise as a verdict** (A-SPEC-664, A-SPEC-674). Measured over
62
+ 22 runs: **every one was red**, and the reds were dominated by two suites this repository knows as
63
+ load gauges (`dashboard.test` 20/22, `entity-store-boundaries` 12/22). The cause was contention —
64
+ the Linux VM takes 11 of the host's 12 cores and ran jest with default workers. A signal that is
65
+ always red cannot separate a regression from itself. With the worker cap below, both canaries went
66
+ green on Linux for the first time in 23 runs, and the matrix immediately produced its first real
67
+ finding.
68
+ - **A dead lock holder could stop the matrix for ever, silently** (A-SPEC-674). The runner's lock was
69
+ released by `trap ... EXIT`, which SIGKILL and a process-group teardown skip; one such lock stood
70
+ for 95 minutes while nine commits went unjudged. The lock now names its holder and liveness is
71
+ judged by PID, never by elapsed time — calling a slow run dead would start a second run on the
72
+ same machine. A skipped run also leaves a `skipped` row now: REQ-664's rule is that no row means
73
+ "not run", so a silent skip erased the skip itself.
74
+
75
+ ### Added
76
+ - **The Stop hook says when `dist/` no longer represents the source** (A-SPEC-673). Thirty suites
77
+ load `dist/` while they run and nothing asserted it was current; only the release gate compared the
78
+ build id to HEAD, and only at publish time. A stale build does not go red — it verifies old code
79
+ and returns green. The judgement is built on the build id and **not** on mtime, measured: this
80
+ tree's `.build-id` had a newer mtime than every source while naming a commit nine behind, and two
81
+ changed sources were missing from `dist` entirely. Five states, of which `fresh` and `absent` say
82
+ nothing at all, and the two unjudgeable ones say **the check could not run** rather than passing
83
+ quietly. Non-blocking, on the `tracked` channel, and silent in a workspace that does not build.
84
+
85
+ ### Changed
86
+ - **The test suite runs in 353s instead of 505s** (A-SPEC-672). The wall clock equalled the longest
87
+ single file to the decimal, so more workers could not help. Splitting the two heaviest files alone
88
+ moved it 505s → 493s while the serial total rose 2,836s → 5,109s: four heavy suites running at once
89
+ contended hard enough to swallow the win. Measured 2x2, the split and a worker cap **interact** —
90
+ alone they are worth 12s and 44s, together 132s. `jest.maxWorkers` is now `"50%"`, a proportion
91
+ rather than a number, because a fixed count means something else on a machine with a different core
92
+ count. Cases are preserved exactly, per-spec execution counts included.
93
+
8
94
  ## [0.23.2] - 2026-09-18
9
95
 
10
96
  Three rules that existed only in prose, and the one number a broken parser had published.
package/README.md CHANGED
@@ -16,6 +16,9 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 📮 **`holmes-kit report` — a defect can reach the maintainers** *(new in 0.24.0)*: until now a consumer's holmes-kit defect had no way back to us; the one we learned about arrived because someone pasted a transcript, and it had been reproducing for every consumer on every slice. The command writes a **redacted** report to `.ax/reports/<fingerprint>.md` and prints a prefilled GitHub issue link — title, assignee, body — plus a search link for the same fingerprint so you can see whether it is already known. `--open` opens it; on a headless box or over SSH the printed link is the whole of it. **No token, no API, nothing sent automatically**: you press Submit on GitHub's own page with the body in front of you and editable. Redaction is an allowlist rather than a scrubber — this project's own remote carries a token before the `@`, its replica ids carry a person's name, and its spec titles are unreleased product intent, so a path, a credential or a machine identifier withholds the field and the report says which. A spec id passes by shape; a spec title does not. And what holmes-kit cannot know, it says: no ledger keeps its own refusal text, so a report with no description states that rather than pretending.
20
+ - 🧭 **A skipped graph analysis is visible** *(new in 0.24.0)*: `AGENTS.md` asks for `maintenance_analyze` before editing source, and nothing checked. Measured on this repository, the step had been skipped for seventeen consecutive commits — and the run that followed named child-process precedents a name search had missed completely, because the question was "who opens a browser" while the answer lived under "who spawns a child". The Stop hook now reports source changed with no analysis standing open: non-blocking, judged by commit rather than by clock, and silent in a workspace that never adopted the habit.
21
+ - 🧱 **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.
19
22
  - 📐 **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.
20
23
  - 📄 **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.
21
24
  - 🧩 **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.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 86f8d89c-mu6hz0ua
1
+ d23fd983-mu76a9wy
@@ -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"));
@@ -234,6 +234,8 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
234
234
  Setting the key is consent to egress (spec prose, paths, symbol names sent externally).
235
235
 
236
236
  holmes-kit doctor [flags] diagnose the install
237
+ holmes-kit report --message "<what happened>" [--harness <name>]
238
+ write a redacted field report and print a prefilled issue link
237
239
  --target <dir> also verify that target's wiring points at this install
238
240
  --json machine-readable output
239
241
 
@@ -347,6 +349,33 @@ async function main(argv) {
347
349
  return r.exitCode;
348
350
  });
349
351
  }
352
+ // @implements A-SPEC-679 — the surface that calls the report composer. Nothing here reaches the
353
+ // network: it writes a file and prints links, and a person decides whether to open them.
354
+ if (cmd === 'report') {
355
+ const { runReport } = require('./report');
356
+ const { machineIdentity } = require('../project/field-report');
357
+ const os3 = require('node:os');
358
+ const mIdx = argv.indexOf('--message');
359
+ const message = mIdx >= 0 ? argv[mIdx + 1] : undefined;
360
+ const reported = runReport({ message, open: argv.includes('--open'), root: process.cwd(), packageRoot: packageRoot(), harness: (() => { const i = argv.indexOf('--harness'); return i >= 0 ? argv[i + 1] : undefined; })() }, {
361
+ write: (p2, c) => fs.writeFileSync(p2, c),
362
+ mkdir: (p2) => { fs.mkdirSync(p2, { recursive: true }); },
363
+ stdout: (t) => process.stdout.write(t),
364
+ readFile: (p2) => fs.readFileSync(p2, 'utf8'),
365
+ now: () => new Date(),
366
+ identity: machineIdentity({ hostname: os3.hostname(), username: os3.userInfo().username, homedir: os3.homedir() }),
367
+ });
368
+ // @implements A-SPEC-680 — the flag now does what it says. Failing to open is the NORMAL case on
369
+ // a headless box and never changes the report's own exit code: the URL is already on stdout.
370
+ if (argv.includes('--open')) {
371
+ const { openUrl } = require('./open-url');
372
+ const { spawn } = require('node:child_process');
373
+ const opened = openUrl(reported.url, process.platform, (f, a, o) => spawn(f, a, o).unref());
374
+ process.stdout.write(opened ? '\nopened in your browser — press Submit there.\n'
375
+ : '\ncould not open a browser here; the link above is the whole of it.\n');
376
+ }
377
+ return reported.code;
378
+ }
350
379
  if (cmd === 'doctor') {
351
380
  // A nonexistent target read as "healthy but unwired" and both advised remedies then refused it
352
381
  // (round-6) — the typo is the diagnosis, so say it.
@@ -1183,7 +1212,18 @@ async function main(argv) {
1183
1212
  return '';
1184
1213
  }
1185
1214
  })();
1186
- recordWorkspace(os2.homedir(), { target: opts.target, agents: [...agents], version: pkgVersion, ts: new Date().toISOString() });
1215
+ // @implements A-SPEC-677 the recorder prunes what has vanished while it is already here.
1216
+ // A deletion the user cannot see is a deletion they cannot check, so the count is printed.
1217
+ // @implements A-SPEC-678 — the seam a test can reach; consumers set nothing and get homedir().
1218
+ const { holmesHome } = require('../update/workspaces');
1219
+ recordWorkspace(holmesHome(process.env, os2.homedir()), { target: opts.target, agents: [...agents], version: pkgVersion, ts: new Date().toISOString() }, {
1220
+ read: (p2) => fs.readFileSync(p2, 'utf8'),
1221
+ write: (p2, c) => fs.writeFileSync(p2, c),
1222
+ mkdir: (p2) => { fs.mkdirSync(p2, { recursive: true }); },
1223
+ exists: (p2) => fs.existsSync(p2),
1224
+ tmpdir: () => os2.tmpdir(),
1225
+ onPrune: (n) => process.stdout.write(` registry: dropped ${n} workspace entr${n === 1 ? 'y' : 'ies'} whose temporary path no longer exists\n`),
1226
+ });
1187
1227
  }
1188
1228
  catch { /* registry failure never fails init */ }
1189
1229
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Opening a URL in whatever the operating system calls a browser.
3
+ *
4
+ * `report --open` used to parse the flag and do nothing: A-SPEC-679 said "opening is left to the
5
+ * caller" and that caller was never written. A flag accepted and ignored is worse than no flag —
6
+ * the user believes the browser opened and walks away. This repository has the lesson on file as
7
+ * "left as an integration point means dead".
8
+ *
9
+ * Judgement is separated from doing so all three platforms can be pinned from one machine. Wiring
10
+ * being present has never been the same thing as wiring that works.
11
+ *
12
+ * NO SHELL. The URL carries `&` and `#`, and a shell would split the command at them; it goes in as
13
+ * a single argument, which is the same discipline `Supervisor.spawnChild` and doctor's spawn checks
14
+ * already follow here.
15
+ */
16
+ export interface OpenCommand {
17
+ file: string;
18
+ args: string[];
19
+ }
20
+ export type SpawnLike = (file: string, args: string[], opts: Record<string, unknown>) => unknown;
21
+ /** `null` when the platform cannot be judged: not opening is better than guessing a command. */
22
+ export declare function openCommand(platform: string): OpenCommand | null;
23
+ /**
24
+ * True only when the spawn was actually attempted and did not throw.
25
+ *
26
+ * A missing browser is the NORMAL case on a headless box, an SSH session or CI. It costs the user
27
+ * nothing — the URL is already on stdout — so it returns false rather than throwing, and the
28
+ * caller's exit code does not change.
29
+ */
30
+ export declare function openUrl(url: string, platform: string, spawn: SpawnLike): boolean;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openCommand = openCommand;
4
+ exports.openUrl = openUrl;
5
+ /** `null` when the platform cannot be judged: not opening is better than guessing a command. */
6
+ function openCommand(platform) {
7
+ if (typeof platform !== 'string' || platform.trim() === '')
8
+ return null;
9
+ if (platform === 'darwin')
10
+ return { file: 'open', args: [] };
11
+ // `start` reads its first quoted argument as the WINDOW TITLE, so a URL passed without the empty
12
+ // title becomes a title and nothing opens.
13
+ if (platform === 'win32')
14
+ return { file: 'cmd', args: ['/c', 'start', ''] };
15
+ return { file: 'xdg-open', args: [] };
16
+ }
17
+ /**
18
+ * True only when the spawn was actually attempted and did not throw.
19
+ *
20
+ * A missing browser is the NORMAL case on a headless box, an SSH session or CI. It costs the user
21
+ * nothing — the URL is already on stdout — so it returns false rather than throwing, and the
22
+ * caller's exit code does not change.
23
+ */
24
+ function openUrl(url, platform, spawn) {
25
+ if (typeof url !== 'string' || url.trim() === '')
26
+ return false;
27
+ const cmd = openCommand(platform);
28
+ if (!cmd)
29
+ return false;
30
+ try {
31
+ // detached + ignored stdio: the browser outlives this process and never holds the terminal.
32
+ spawn(cmd.file, [...cmd.args, url], { detached: true, stdio: 'ignore' });
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
@@ -0,0 +1,20 @@
1
+ export interface ReportOptions {
2
+ message?: string;
3
+ open?: boolean;
4
+ root: string;
5
+ packageRoot: string;
6
+ harness?: string;
7
+ }
8
+ export interface ReportIo {
9
+ write(p: string, c: string): void;
10
+ mkdir(p: string): void;
11
+ stdout(s: string): void;
12
+ readFile(p: string): string;
13
+ now(): Date;
14
+ /** Strings that identify this machine or person, injected so a pin can search for them. */
15
+ identity: string[];
16
+ }
17
+ export declare function runReport(o: ReportOptions, io: ReportIo): {
18
+ code: number;
19
+ url: string;
20
+ };
@@ -0,0 +1,115 @@
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.runReport = runReport;
37
+ // @implements A-SPEC-679
38
+ /**
39
+ * `holmes-kit report` — the surface that finally calls the report composer.
40
+ *
41
+ * Two measurements decided what this command can honestly claim. No ledger keeps the product's
42
+ * refusal text: it is returned to the caller and vanishes, so holmes-kit cannot know what went
43
+ * wrong. And a report assembled from only what IS auto-fillable — version, harness, OS, node, a
44
+ * spec id out of a ledger — cannot be triaged; a maintainer receives a version string and a
45
+ * fingerprint.
46
+ *
47
+ * So the human's one sentence IS the report, and everything here is context wrapped around it. That
48
+ * is still worth having: a pasted transcript leaks paths, spec titles and commit messages and can
49
+ * never be deduplicated, while this carries a redacted context and a fingerprint that recognises
50
+ * the same defect reported by someone else.
51
+ *
52
+ * Nothing here reaches the network. Opening a browser is the caller's act, which keeps this module
53
+ * pure enough to pin and keeps "going out" a decision a person makes.
54
+ */
55
+ const path = __importStar(require("node:path"));
56
+ const field_report_1 = require("../project/field-report");
57
+ const field_report_url_1 = require("../project/field-report-url");
58
+ const reported_log_1 = require("./reported-log");
59
+ /** `unknown` rather than an empty string: a blank version reads as a version we know to be blank. */
60
+ const versionOf = (io, packageRoot) => {
61
+ try {
62
+ return String(JSON.parse(io.readFile(path.join(packageRoot, 'package.json'))).version ?? 'unknown');
63
+ }
64
+ catch {
65
+ return 'unknown';
66
+ }
67
+ };
68
+ function runReport(o, io) {
69
+ const described = typeof o.message === 'string' && o.message.trim() !== '';
70
+ const report = (0, field_report_1.composeFieldReport)({
71
+ kit: versionOf(io, o.packageRoot),
72
+ harness: o.harness ?? 'unknown',
73
+ os: process.platform,
74
+ arch: process.arch,
75
+ node: process.version,
76
+ ...(described ? { message: o.message.trim() } : {}),
77
+ identity: io.identity,
78
+ });
79
+ let body = (0, field_report_1.fieldReportBody)(report);
80
+ if (!described) {
81
+ // Incomplete, not failed. A quiet empty report hands a maintainer a version string and calls it
82
+ // a bug report; saying so is the difference between a report and a shrug.
83
+ body += '\n\nno description was given — rerun with `--message "<what happened>"`; holmes-kit cannot'
84
+ + ' recover what it said to you, because no ledger keeps its refusal text.';
85
+ }
86
+ const dir = path.join(o.root, '.ax', 'reports');
87
+ const file = path.join(dir, `${report.fingerprint}.md`);
88
+ io.mkdir(dir);
89
+ io.write(file, `${body}\n`);
90
+ // @implements A-SPEC-679 — leave the fingerprint of THIS report, so a future briefing can match it.
91
+ // Best-effort: a log problem must never fail the report it accompanies.
92
+ try {
93
+ const logFile = path.join(dir, 'reported.jsonl');
94
+ let prior = null;
95
+ try {
96
+ prior = io.readFile(logFile);
97
+ }
98
+ catch {
99
+ prior = null;
100
+ }
101
+ io.write(logFile, (0, reported_log_1.mergeReported)(prior, { fingerprint: report.fingerprint, kit: report.kit, ts: io.now().toISOString() }).lines);
102
+ }
103
+ catch { /* the report itself still stands */ }
104
+ const url = (0, field_report_url_1.issueUrl)(body, { localPath: file, title: (0, field_report_url_1.issueTitle)(report), assignee: field_report_url_1.ISSUE_ASSIGNEE });
105
+ io.stdout(`report written: ${file}\n`);
106
+ io.stdout(`\nopen this to file it (you press Submit, and you can edit the body first):\n${url.url}\n`);
107
+ io.stdout(`\ncheck whether it is already known:\n${(0, field_report_url_1.searchUrl)(report.fingerprint)}\n`);
108
+ if (url.truncated)
109
+ io.stdout('\n(the URL body was truncated; the file above holds the whole report)\n');
110
+ if (!described)
111
+ io.stdout('\nno description was given — this report says so rather than pretending.\n');
112
+ // @implements A-SPEC-680 — the URL is returned rather than rebuilt by the caller: two places
113
+ // composing it would drift, and the caller needs the exact bytes it printed.
114
+ return { code: described ? 0 : 2, url: url.url };
115
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What this machine has reported — a SET of fingerprints, not a history.
3
+ *
4
+ * It exists for a briefing that does not exist yet: "the thing you reported is fixed in 0.24.0".
5
+ * That can only work if the fingerprint from the MOMENT of reporting is on disk, because a
6
+ * fingerprint is derived from content and recomputing one later can disagree — redaction may
7
+ * withhold a different field next time. A line costs nothing now; without it such a briefing would
8
+ * stay silent about every report made before it shipped.
9
+ *
10
+ * A set rather than a log because the question is "what have I reported", not "when". Reporting the
11
+ * same defect twice adds nothing.
12
+ *
13
+ * Local only. Nothing here sends anything anywhere.
14
+ */
15
+ export interface ReportedEntry {
16
+ fingerprint: string;
17
+ kit: string;
18
+ ts: string;
19
+ }
20
+ export declare function mergeReported(existing: string | null, entry: ReportedEntry): {
21
+ lines: string;
22
+ added: boolean;
23
+ };
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mergeReported = mergeReported;
4
+ function mergeReported(existing, entry) {
5
+ const kept = [];
6
+ const seen = new Set();
7
+ for (const line of (existing ?? '').split('\n')) {
8
+ const t = line.trim();
9
+ if (t === '')
10
+ continue;
11
+ try {
12
+ const row = JSON.parse(t);
13
+ // A row that cannot name a fingerprint cannot answer the question this file exists for.
14
+ if (typeof row.fingerprint !== 'string' || row.fingerprint === '')
15
+ continue;
16
+ if (seen.has(row.fingerprint))
17
+ continue;
18
+ seen.add(row.fingerprint);
19
+ kept.push({ fingerprint: row.fingerprint, kit: String(row.kit ?? ''), ts: String(row.ts ?? '') });
20
+ }
21
+ catch {
22
+ // A torn line is dropped, never fatal: a broken log must not fail the report it accompanies,
23
+ // the same best-effort rule the workspace registry states for itself.
24
+ continue;
25
+ }
26
+ }
27
+ const added = !seen.has(entry.fingerprint);
28
+ if (added)
29
+ kept.push(entry);
30
+ return { lines: `${kept.map((e) => JSON.stringify(e)).join('\n')}\n`, added };
31
+ }
@@ -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,11 @@ 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';
7
+ import { type AnalysisVerdict } from '../project/analysis-currency';
6
8
  export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
9
+ export declare function collectAnalysisCurrency(root: string, changedSources: number): AnalysisVerdict | undefined;
10
+ export declare function collectDistFreshness(root: string): DistVerdict | undefined;
7
11
  export declare function collectCiVerdicts(root: string, now?: Date): CiVerdict[];
8
12
  /**
9
13
  * @implements A-SPEC-100.2
@@ -38,6 +42,16 @@ export interface StopEvidence {
38
42
  * hook could not look; a `not-run` verdict when it looked and found no row (never silence).
39
43
  */
40
44
  ci?: CiVerdict[];
45
+ /**
46
+ * @implements A-SPEC-673 — whether `dist/` still represents the source. Absent when the workspace
47
+ * does not build at all; a `no-build-id`/`unknown` verdict when it looked and could not judge.
48
+ */
49
+ dist?: DistVerdict;
50
+ /**
51
+ * @implements A-SPEC-681 — whether the graph analysis AGENTS.md asks for was run before this
52
+ * turn's source edits. Absent when the workspace never analysed anything.
53
+ */
54
+ analysis?: AnalysisVerdict;
41
55
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
42
56
  provenance?: {
43
57
  ok: boolean;
@@ -35,6 +35,8 @@ 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.collectAnalysisCurrency = collectAnalysisCurrency;
39
+ exports.collectDistFreshness = collectDistFreshness;
38
40
  exports.collectCiVerdicts = collectCiVerdicts;
39
41
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
40
42
  exports.unanchoredChangedSources = unanchoredChangedSources;
@@ -55,6 +57,8 @@ exports.guardCountOrZero = guardCountOrZero;
55
57
  exports.readGuardCount = readGuardCount;
56
58
  exports.writeGuardCount = writeGuardCount;
57
59
  const fs = __importStar(require("node:fs"));
60
+ // @implements A-SPEC-549.3 — evaluateStop's messages are English here; the Hangul-absence guard
61
+ // calls into this file.
58
62
  // @implements A-SPEC-645
59
63
  // @implements A-SPEC-642
60
64
  const npx_bin_1 = require("../project/npx-bin");
@@ -81,6 +85,8 @@ const root_1 = require("../project/root");
81
85
  const known_defects_1 = require("../rtm/known-defects");
82
86
  const test_files_1 = require("../cpg/test-files");
83
87
  const ci_runs_1 = require("../project/ci-runs");
88
+ const dist_freshness_1 = require("../project/dist-freshness");
89
+ const analysis_currency_1 = require("../project/analysis-currency");
84
90
  // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
85
91
  // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
86
92
  // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
@@ -128,6 +134,48 @@ function collectKnownDefects(root, now) {
128
134
  return undefined;
129
135
  }
130
136
  }
137
+ // @implements A-SPEC-681 — the I/O half of the analysis line: count the analyses still open against
138
+ // the source files this turn changed. A workspace that never analysed anything did not adopt the
139
+ // discipline and hears nothing; a count that cannot be taken says so rather than passing.
140
+ function collectAnalysisCurrency(root, changedSources) {
141
+ if (!(0, analysis_currency_1.hasAnalysisDir)(root))
142
+ return undefined;
143
+ let openAnalyses = null;
144
+ try {
145
+ const { openArtifacts } = require('../mcp/maintenance-evidence');
146
+ openAnalyses = openArtifacts(path.join(root, '.ax', 'evidence', 'maintenance')).length;
147
+ }
148
+ catch {
149
+ openAnalyses = null;
150
+ }
151
+ return (0, analysis_currency_1.analysisCurrency)({ adopted: true, openAnalyses, changedSources });
152
+ }
153
+ // @implements A-SPEC-673 — the I/O half of the build-freshness line: read `dist/.build-id` and ask
154
+ // git how far that commit sits behind HEAD. A workspace that does not build hears nothing; a build
155
+ // that cannot be judged says so rather than passing quietly.
156
+ function collectDistFreshness(root) {
157
+ if (!(0, dist_freshness_1.hasDist)(root))
158
+ return undefined;
159
+ const buildId = (0, dist_freshness_1.distBuildId)(root);
160
+ let head = null;
161
+ try {
162
+ 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();
163
+ }
164
+ catch {
165
+ head = null;
166
+ }
167
+ let behind = null;
168
+ if (buildId && head) {
169
+ try {
170
+ 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());
171
+ behind = Number.isFinite(n) ? n : null;
172
+ }
173
+ catch {
174
+ behind = null;
175
+ }
176
+ }
177
+ return (0, dist_freshness_1.distFreshness)({ distExists: true, buildId, head, behind });
178
+ }
131
179
  // @implements A-SPEC-664 — the I/O half of the CI line: read the ci-runs ledger, judge Linux (always)
132
180
  // and any other OS that has a row, with git measuring how far each judged commit sits behind HEAD.
133
181
  // A tree without a ledger yields a `not-run` verdict — a line, not an absence.
@@ -529,6 +577,8 @@ const TRACK_LABELS = {
529
577
  'ART-2': 'code-graph cycles',
530
578
  'ART-9': 'known-defect debt',
531
579
  'CI': 'matrix',
580
+ 'DIST': 'build freshness',
581
+ 'ANALYSIS': 'graph analysis',
532
582
  };
533
583
  /**
534
584
  * One line per ARTICLE, each under its own name.
@@ -602,6 +652,21 @@ function evaluateStop(specs, evidence) {
602
652
  const t = evidence.ci.map((v) => ({ article: 'CI', detail: (0, ci_runs_1.ciStatusLine)(v) }));
603
653
  tracked = [...(tracked ?? []), ...t];
604
654
  }
655
+ // @implements A-SPEC-673 — a build that no longer represents the source. Thirty suites load `dist/`
656
+ // while they run, so a stale one does not go red: it verifies old code and returns green. `fresh`
657
+ // and `absent` produce no line, because a line is spent only on something the reader can act on.
658
+ if (evidence?.dist) {
659
+ const detail = (0, dist_freshness_1.distStatusLine)(evidence.dist);
660
+ if (detail)
661
+ tracked = [...(tracked ?? []), { article: 'DIST', detail }];
662
+ }
663
+ // @implements A-SPEC-681 — AGENTS.md step 3 asks for a graph analysis before editing source, and
664
+ // nothing enforced it: seventeen feat/fix commits ran without one. Observed here, never blocked.
665
+ if (evidence?.analysis) {
666
+ const detail = (0, analysis_currency_1.analysisStatusLine)(evidence.analysis);
667
+ if (detail)
668
+ tracked = [...(tracked ?? []), { article: 'ANALYSIS', detail }];
669
+ }
605
670
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
606
671
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
607
672
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -1194,7 +1259,23 @@ if (require.main === module) {
1194
1259
  catch {
1195
1260
  ci = undefined;
1196
1261
  }
1197
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}) });
1262
+ // @implements A-SPEC-673 the build-freshness line rides the same non-blocking channel.
1263
+ let dist;
1264
+ try {
1265
+ dist = collectDistFreshness(stopProjectRoot());
1266
+ }
1267
+ catch {
1268
+ dist = undefined;
1269
+ }
1270
+ // @implements A-SPEC-681 — the analysis line rides the same non-blocking channel.
1271
+ let analysis;
1272
+ try {
1273
+ analysis = collectAnalysisCurrency(stopProjectRoot(), (unanchored ?? []).length);
1274
+ }
1275
+ catch {
1276
+ analysis = undefined;
1277
+ }
1278
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}) });
1198
1279
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1199
1280
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1200
1281
  // @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