@holmes-lab/holmes-kit 0.23.3 → 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 +41 -0
- package/README.md +2 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/index.js +41 -1
- package/dist/holmes/cli/open-url.d.ts +30 -0
- package/dist/holmes/cli/open-url.js +38 -0
- package/dist/holmes/cli/report.d.ts +20 -0
- package/dist/holmes/cli/report.js +115 -0
- package/dist/holmes/cli/reported-log.d.ts +23 -0
- package/dist/holmes/cli/reported-log.js +31 -0
- package/dist/holmes/hooks/stop.d.ts +7 -0
- package/dist/holmes/hooks/stop.js +35 -1
- package/dist/holmes/project/analysis-currency.d.ts +22 -0
- package/dist/holmes/project/analysis-currency.js +96 -0
- package/dist/holmes/project/field-report-url.d.ts +47 -0
- package/dist/holmes/project/field-report-url.js +63 -0
- package/dist/holmes/project/field-report.d.ts +54 -0
- package/dist/holmes/project/field-report.js +107 -0
- package/dist/holmes/update/workspaces.d.ts +53 -2
- package/dist/holmes/update/workspaces.js +77 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ 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
|
+
|
|
8
49
|
## [0.23.3] - 2026-09-18
|
|
9
50
|
|
|
10
51
|
A suite that takes half as long, a build that admits when it is stale, and a CI matrix that stopped
|
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
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.
|
|
19
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.
|
|
20
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.
|
|
21
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.
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
d23fd983-mu76a9wy
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -4,7 +4,9 @@ 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
6
|
import { type DistVerdict } from '../project/dist-freshness';
|
|
7
|
+
import { type AnalysisVerdict } from '../project/analysis-currency';
|
|
7
8
|
export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
|
|
9
|
+
export declare function collectAnalysisCurrency(root: string, changedSources: number): AnalysisVerdict | undefined;
|
|
8
10
|
export declare function collectDistFreshness(root: string): DistVerdict | undefined;
|
|
9
11
|
export declare function collectCiVerdicts(root: string, now?: Date): CiVerdict[];
|
|
10
12
|
/**
|
|
@@ -45,6 +47,11 @@ export interface StopEvidence {
|
|
|
45
47
|
* does not build at all; a `no-build-id`/`unknown` verdict when it looked and could not judge.
|
|
46
48
|
*/
|
|
47
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;
|
|
48
55
|
/** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
|
|
49
56
|
provenance?: {
|
|
50
57
|
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.collectAnalysisCurrency = collectAnalysisCurrency;
|
|
38
39
|
exports.collectDistFreshness = collectDistFreshness;
|
|
39
40
|
exports.collectCiVerdicts = collectCiVerdicts;
|
|
40
41
|
exports.changedAnchoredAspecs = changedAnchoredAspecs;
|
|
@@ -85,6 +86,7 @@ const known_defects_1 = require("../rtm/known-defects");
|
|
|
85
86
|
const test_files_1 = require("../cpg/test-files");
|
|
86
87
|
const ci_runs_1 = require("../project/ci-runs");
|
|
87
88
|
const dist_freshness_1 = require("../project/dist-freshness");
|
|
89
|
+
const analysis_currency_1 = require("../project/analysis-currency");
|
|
88
90
|
// @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
|
|
89
91
|
// (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
|
|
90
92
|
// judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
|
|
@@ -132,6 +134,22 @@ function collectKnownDefects(root, now) {
|
|
|
132
134
|
return undefined;
|
|
133
135
|
}
|
|
134
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
|
+
}
|
|
135
153
|
// @implements A-SPEC-673 — the I/O half of the build-freshness line: read `dist/.build-id` and ask
|
|
136
154
|
// git how far that commit sits behind HEAD. A workspace that does not build hears nothing; a build
|
|
137
155
|
// that cannot be judged says so rather than passing quietly.
|
|
@@ -560,6 +578,7 @@ const TRACK_LABELS = {
|
|
|
560
578
|
'ART-9': 'known-defect debt',
|
|
561
579
|
'CI': 'matrix',
|
|
562
580
|
'DIST': 'build freshness',
|
|
581
|
+
'ANALYSIS': 'graph analysis',
|
|
563
582
|
};
|
|
564
583
|
/**
|
|
565
584
|
* One line per ARTICLE, each under its own name.
|
|
@@ -641,6 +660,13 @@ function evaluateStop(specs, evidence) {
|
|
|
641
660
|
if (detail)
|
|
642
661
|
tracked = [...(tracked ?? []), { article: 'DIST', detail }];
|
|
643
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
|
+
}
|
|
644
670
|
const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
|
|
645
671
|
// @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
|
|
646
672
|
// are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
|
|
@@ -1241,7 +1267,15 @@ if (require.main === module) {
|
|
|
1241
1267
|
catch {
|
|
1242
1268
|
dist = undefined;
|
|
1243
1269
|
}
|
|
1244
|
-
|
|
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 } : {}) });
|
|
1245
1279
|
// @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
|
|
1246
1280
|
// the operator observes RED-first gaps before an owner promotes the posture to strict.
|
|
1247
1281
|
// @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type AnalysisState = 'analysed' | 'missing' | 'not-adopted' | 'unknown';
|
|
2
|
+
export interface AnalysisVerdict {
|
|
3
|
+
state: AnalysisState;
|
|
4
|
+
openCount: number | null;
|
|
5
|
+
changedSources: number;
|
|
6
|
+
}
|
|
7
|
+
export interface AnalysisInput {
|
|
8
|
+
adopted: boolean;
|
|
9
|
+
openAnalyses: number | null;
|
|
10
|
+
changedSources: number;
|
|
11
|
+
}
|
|
12
|
+
/** Adoption is the directory's existence. A project that never analysed anything did not opt in. */
|
|
13
|
+
export declare function hasAnalysisDir(root: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Judged by COMMIT, never by clock. "An analysis within N hours" is an invented constant that turns
|
|
16
|
+
* a slow slice into a violation; what matters is whether an analysis stands open for the work that
|
|
17
|
+
* changed these files, which is exactly what `test_run` closes when it reconciles a persisted
|
|
18
|
+
* analysis against the files a slice actually touched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function analysisCurrency(input: AnalysisInput): AnalysisVerdict;
|
|
21
|
+
/** Empty when there is nothing to say; the two unjudgeable states say so rather than passing. */
|
|
22
|
+
export declare function analysisStatusLine(v: AnalysisVerdict): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
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.hasAnalysisDir = hasAnalysisDir;
|
|
37
|
+
exports.analysisCurrency = analysisCurrency;
|
|
38
|
+
exports.analysisStatusLine = analysisStatusLine;
|
|
39
|
+
// @implements A-SPEC-681
|
|
40
|
+
/**
|
|
41
|
+
* Whether the graph analysis this project's own instructions require was actually run.
|
|
42
|
+
*
|
|
43
|
+
* `AGENTS.md` step 3 asks for `maintenance_analyze({persist:true})` before editing source, and
|
|
44
|
+
* nothing enforced it: measured 2026-09-19, the last persisted analysis was at 10:52 the previous
|
|
45
|
+
* day and seventeen feat/fix commits followed it with none. A user had to point at the gap.
|
|
46
|
+
*
|
|
47
|
+
* The cost was concrete. Searching for the precedent for spawning a browser, grep returned a CSS
|
|
48
|
+
* class named 'open', a d3 'start' event and a schema enum; the analysis returned `wiringSpawnCheck`
|
|
49
|
+
* and `Supervisor.spawnChild`. The question was "who opens a browser" and the answer lived under
|
|
50
|
+
* "who spawns a child" — a gap lexical search cannot cross and the graph does not notice.
|
|
51
|
+
*
|
|
52
|
+
* This OBSERVES. It does not block, and it never runs the tool on the operator's behalf: analysis is
|
|
53
|
+
* expensive and what to ask is a person's judgement.
|
|
54
|
+
*/
|
|
55
|
+
const fs = __importStar(require("node:fs"));
|
|
56
|
+
const path = __importStar(require("node:path"));
|
|
57
|
+
/** Adoption is the directory's existence. A project that never analysed anything did not opt in. */
|
|
58
|
+
function hasAnalysisDir(root) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(path.join(root, '.ax', 'evidence', 'maintenance')).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Judged by COMMIT, never by clock. "An analysis within N hours" is an invented constant that turns
|
|
68
|
+
* a slow slice into a violation; what matters is whether an analysis stands open for the work that
|
|
69
|
+
* changed these files, which is exactly what `test_run` closes when it reconciles a persisted
|
|
70
|
+
* analysis against the files a slice actually touched.
|
|
71
|
+
*/
|
|
72
|
+
function analysisCurrency(input) {
|
|
73
|
+
const changedSources = typeof input.changedSources === 'number' && Number.isFinite(input.changedSources)
|
|
74
|
+
? Math.max(0, input.changedSources) : 0;
|
|
75
|
+
const open = input.openAnalyses;
|
|
76
|
+
const base = { openCount: typeof open === 'number' && Number.isFinite(open) ? open : null, changedSources };
|
|
77
|
+
if (!input.adopted)
|
|
78
|
+
return { state: 'not-adopted', ...base };
|
|
79
|
+
// The discipline is about editing source. Nothing changed, nothing to say.
|
|
80
|
+
if (changedSources === 0)
|
|
81
|
+
return { state: 'analysed', ...base };
|
|
82
|
+
if (typeof open !== 'number' || !Number.isFinite(open) || open < 0)
|
|
83
|
+
return { state: 'unknown', ...base };
|
|
84
|
+
return { state: open > 0 ? 'analysed' : 'missing', ...base };
|
|
85
|
+
}
|
|
86
|
+
/** Empty when there is nothing to say; the two unjudgeable states say so rather than passing. */
|
|
87
|
+
function analysisStatusLine(v) {
|
|
88
|
+
if (v.state === 'analysed' || v.state === 'not-adopted')
|
|
89
|
+
return '';
|
|
90
|
+
if (v.state === 'unknown') {
|
|
91
|
+
return 'the persisted analyses could not be counted — whether this change was analysed could not be judged';
|
|
92
|
+
}
|
|
93
|
+
const n = v.changedSources;
|
|
94
|
+
return `${n} source file${n === 1 ? '' : 's'} changed with no analysis standing open — `
|
|
95
|
+
+ 'AGENTS.md step 3 asks for `maintenance_analyze({ persist: true })` first; it finds precedents a name search cannot reach';
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report's way to us: a PREFILLED GitHub issue URL that a person submits on GitHub's own page.
|
|
3
|
+
*
|
|
4
|
+
* The alternative — holmes-kit holding a token and posting through the API — was rejected for a
|
|
5
|
+
* measured reason, not a stylistic one. Consent is the weakest link here: the first field report of
|
|
6
|
+
* the approval queue counted 1,412 unique requests against 8 decisions, 99.4% undecided. A yes/no
|
|
7
|
+
* buried in a stream of prompts is not a decision, and a user consents to "report this" without
|
|
8
|
+
* reading forty lines of body.
|
|
9
|
+
*
|
|
10
|
+
* A URL moves the decision to where the bytes are visible. The person sees the whole body in
|
|
11
|
+
* GitHub's own editor, can change it, and cancelling costs nothing — closing the tab is the entire
|
|
12
|
+
* undo. Four consequences follow structurally rather than by discipline: no token exists to leak,
|
|
13
|
+
* nothing can be sent silently, there is no auth/rate-limit/retry surface, and this module cannot
|
|
14
|
+
* exfiltrate anything because it has no transport at all. It only builds strings.
|
|
15
|
+
*/
|
|
16
|
+
import type { FieldReport } from './field-report';
|
|
17
|
+
export declare const ISSUE_REPO = "holmes-kit/holmes-kit";
|
|
18
|
+
/** Conservative: GitHub accepts more, but a URL that silently fails to open helps nobody. */
|
|
19
|
+
export declare const URL_BUDGET = 8000;
|
|
20
|
+
/**
|
|
21
|
+
* The repository owner, offered as the default assignee.
|
|
22
|
+
*
|
|
23
|
+
* KNOWN LIMIT, stated rather than implied: GitHub honours `assignee=` only when the person filling
|
|
24
|
+
* the form may assign (write or triage on the repo). A third-party reporter usually cannot, and the
|
|
25
|
+
* parameter is then dropped silently. It costs nothing either way — it routes when it can and is
|
|
26
|
+
* discarded when it cannot — but it is not a guarantee, and nobody should plan triage around it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const ISSUE_ASSIGNEE = "sungnamparkkorea-arch";
|
|
29
|
+
export interface UrlOptions {
|
|
30
|
+
repo?: string;
|
|
31
|
+
localPath?: string;
|
|
32
|
+
budget?: number;
|
|
33
|
+
title?: string;
|
|
34
|
+
assignee?: string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The fingerprint rides in the title so a person scanning the issue list recognises a duplicate
|
|
38
|
+
* without opening anything. The MESSAGE deliberately does not: a title is the most visible place in
|
|
39
|
+
* the tracker, and `message` is the one allowlisted field whose VALUE can be withheld as unsafe.
|
|
40
|
+
*/
|
|
41
|
+
export declare function issueTitle(r: FieldReport): string;
|
|
42
|
+
export declare function issueUrl(body: string, opts?: UrlOptions): {
|
|
43
|
+
url: string;
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
};
|
|
46
|
+
/** Empty in, empty out: a query with no fingerprint lists every issue, which is not a duplicate check. */
|
|
47
|
+
export declare function searchUrl(fingerprint: string, opts?: UrlOptions): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ISSUE_ASSIGNEE = exports.URL_BUDGET = exports.ISSUE_REPO = void 0;
|
|
4
|
+
exports.issueTitle = issueTitle;
|
|
5
|
+
exports.issueUrl = issueUrl;
|
|
6
|
+
exports.searchUrl = searchUrl;
|
|
7
|
+
exports.ISSUE_REPO = 'holmes-kit/holmes-kit';
|
|
8
|
+
/** Conservative: GitHub accepts more, but a URL that silently fails to open helps nobody. */
|
|
9
|
+
exports.URL_BUDGET = 8000;
|
|
10
|
+
/**
|
|
11
|
+
* The repository owner, offered as the default assignee.
|
|
12
|
+
*
|
|
13
|
+
* KNOWN LIMIT, stated rather than implied: GitHub honours `assignee=` only when the person filling
|
|
14
|
+
* the form may assign (write or triage on the repo). A third-party reporter usually cannot, and the
|
|
15
|
+
* parameter is then dropped silently. It costs nothing either way — it routes when it can and is
|
|
16
|
+
* discarded when it cannot — but it is not a guarantee, and nobody should plan triage around it.
|
|
17
|
+
*/
|
|
18
|
+
exports.ISSUE_ASSIGNEE = 'sungnamparkkorea-arch';
|
|
19
|
+
/**
|
|
20
|
+
* The fingerprint rides in the title so a person scanning the issue list recognises a duplicate
|
|
21
|
+
* without opening anything. The MESSAGE deliberately does not: a title is the most visible place in
|
|
22
|
+
* the tracker, and `message` is the one allowlisted field whose VALUE can be withheld as unsafe.
|
|
23
|
+
*/
|
|
24
|
+
function issueTitle(r) {
|
|
25
|
+
const what = r.article || r.tool || 'report';
|
|
26
|
+
return `holmes-kit ${r.kit}: ${what} (${r.fingerprint.slice(0, 8)})`;
|
|
27
|
+
}
|
|
28
|
+
const NOTE = (localPath) => localPath
|
|
29
|
+
? `\n\n— truncated to fit the URL; the full report is at ${localPath}`
|
|
30
|
+
: '\n\n— truncated to fit the URL; the full report stayed on the reporter\'s machine';
|
|
31
|
+
function issueUrl(body, opts = {}) {
|
|
32
|
+
const repo = opts.repo ?? exports.ISSUE_REPO;
|
|
33
|
+
const budget = opts.budget ?? exports.URL_BUDGET;
|
|
34
|
+
// The title goes in the URL, not just into a helper nobody calls. It was missing here for a
|
|
35
|
+
// slice: `issueTitle` existed, a pin called it directly, and the product path built `?body=`
|
|
36
|
+
// alone — so GitHub opened with a blank title. A function pinned in isolation says nothing about
|
|
37
|
+
// the path that is supposed to feed it.
|
|
38
|
+
const titlePart = opts.title && opts.title.trim() !== '' ? `title=${encodeURIComponent(opts.title)}&` : '';
|
|
39
|
+
// Omitted rather than emptied when absent: a fork or an internal mirror must not point at someone
|
|
40
|
+
// else's account, and `assignee=` with nothing after it is noise in the form.
|
|
41
|
+
const assigneePart = typeof opts.assignee === 'string' && opts.assignee.trim() !== ''
|
|
42
|
+
? `assignee=${encodeURIComponent(opts.assignee.trim())}&` : '';
|
|
43
|
+
const base = `https://github.com/${repo}/issues/new?${titlePart}${assigneePart}body=`;
|
|
44
|
+
const build = (text) => base + encodeURIComponent(text);
|
|
45
|
+
const whole = build(body);
|
|
46
|
+
if (whole.length <= budget)
|
|
47
|
+
return { url: whole, truncated: false };
|
|
48
|
+
// Never a silent cut: the note is part of the body that gets sent, so the reader of the ISSUE — not
|
|
49
|
+
// just the reporter — learns that something was left out.
|
|
50
|
+
const note = NOTE(opts.localPath);
|
|
51
|
+
let keep = body.length;
|
|
52
|
+
while (keep > 0 && build(body.slice(0, keep) + note).length > budget) {
|
|
53
|
+
keep = Math.floor(keep * 0.9);
|
|
54
|
+
}
|
|
55
|
+
return { url: build(body.slice(0, keep) + note), truncated: true };
|
|
56
|
+
}
|
|
57
|
+
/** Empty in, empty out: a query with no fingerprint lists every issue, which is not a duplicate check. */
|
|
58
|
+
function searchUrl(fingerprint, opts = {}) {
|
|
59
|
+
if (typeof fingerprint !== 'string' || fingerprint.trim() === '')
|
|
60
|
+
return '';
|
|
61
|
+
const repo = opts.repo ?? exports.ISSUE_REPO;
|
|
62
|
+
return `https://github.com/${repo}/issues?q=${encodeURIComponent(`is:issue ${fingerprint.trim()}`)}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The only keys a report may carry. Adding one is an edit here, which the pin makes visible. */
|
|
2
|
+
export declare const FIELD_REPORT_KEYS: readonly ["kit", "harness", "os", "arch", "node", "message", "article", "tool", "specIds", "observations", "fingerprint", "withheld"];
|
|
3
|
+
export interface FieldReportInput {
|
|
4
|
+
kit: string;
|
|
5
|
+
harness: string;
|
|
6
|
+
os: string;
|
|
7
|
+
arch: string;
|
|
8
|
+
node: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
article?: string;
|
|
11
|
+
tool?: string;
|
|
12
|
+
specIds?: string[];
|
|
13
|
+
observations?: Array<{
|
|
14
|
+
label: string;
|
|
15
|
+
value: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** Strings that identify this machine or person, injected by the caller so a pin can search for them. */
|
|
18
|
+
identity?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FieldReport {
|
|
21
|
+
kit: string;
|
|
22
|
+
harness: string;
|
|
23
|
+
os: string;
|
|
24
|
+
arch: string;
|
|
25
|
+
node: string;
|
|
26
|
+
message: string;
|
|
27
|
+
article: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
specIds: string[];
|
|
30
|
+
observations: Array<{
|
|
31
|
+
label: string;
|
|
32
|
+
value: number;
|
|
33
|
+
}>;
|
|
34
|
+
fingerprint: string;
|
|
35
|
+
withheld: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Injected rather than read from `os` here, so a pin can search a composed body for THIS machine's
|
|
39
|
+
* real values. A module that reads the environment itself can only be tested against fixtures, and
|
|
40
|
+
* fixtures are exactly what a redaction pin must not trust.
|
|
41
|
+
*
|
|
42
|
+
* Tokens shorter than two characters are dropped: a one-character identity would redact every
|
|
43
|
+
* sentence, which is destruction rather than redaction.
|
|
44
|
+
*/
|
|
45
|
+
export declare function machineIdentity(env: {
|
|
46
|
+
hostname?: string;
|
|
47
|
+
username?: string;
|
|
48
|
+
homedir?: string;
|
|
49
|
+
replicas?: string[];
|
|
50
|
+
}): string[];
|
|
51
|
+
export declare function isSafeValue(text: string, identity: string[]): boolean;
|
|
52
|
+
export declare function composeFieldReport(input: FieldReportInput): FieldReport;
|
|
53
|
+
/** The bytes a human will read and paste. Anything withheld is named, never dropped in silence. */
|
|
54
|
+
export declare function fieldReportBody(r: FieldReport): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FIELD_REPORT_KEYS = void 0;
|
|
4
|
+
exports.machineIdentity = machineIdentity;
|
|
5
|
+
exports.isSafeValue = isSafeValue;
|
|
6
|
+
exports.composeFieldReport = composeFieldReport;
|
|
7
|
+
exports.fieldReportBody = fieldReportBody;
|
|
8
|
+
// @implements A-SPEC-675
|
|
9
|
+
/**
|
|
10
|
+
* A field report: what a consumer workspace may tell us about a holmes-kit defect.
|
|
11
|
+
*
|
|
12
|
+
* There is no channel today. The one field defect we know about — REQ-671, where the AGENTS.md we
|
|
13
|
+
* ship told agents to author specs with four tools and none of them can write a section body —
|
|
14
|
+
* reached us because a user pasted a transcript. It reproduced for every consumer on every slice.
|
|
15
|
+
*
|
|
16
|
+
* What may be sent is decided by an ALLOWLIST, never by scrubbing. This repository has already been
|
|
17
|
+
* punctured by a denylist once (the secret-free path rule, holed by UNC paths), and the hazards here
|
|
18
|
+
* are measured rather than imagined: its replica ids carry a person's name, its homedir carries the
|
|
19
|
+
* OS username, and its git remote carries a forty-character token before the `@`. That last one is
|
|
20
|
+
* why an ALLOWED FIELD can still hold a dangerous VALUE — hence two layers, shape and content.
|
|
21
|
+
*
|
|
22
|
+
* It never guesses a cause. This module carries what was OBSERVED and leaves the diagnosis to a
|
|
23
|
+
* person: in one session here, four instrument failures and three confident wrong conclusions were
|
|
24
|
+
* caught only by re-measuring, and an issue tracker full of confident auto-diagnoses costs a
|
|
25
|
+
* maintainer more than an empty one.
|
|
26
|
+
*/
|
|
27
|
+
const advisory_outcomes_1 = require("../rtm/advisory-outcomes");
|
|
28
|
+
/** The only keys a report may carry. Adding one is an edit here, which the pin makes visible. */
|
|
29
|
+
exports.FIELD_REPORT_KEYS = ['kit', 'harness', 'os', 'arch', 'node', 'message',
|
|
30
|
+
'article', 'tool', 'specIds', 'observations', 'fingerprint', 'withheld'];
|
|
31
|
+
/**
|
|
32
|
+
* Injected rather than read from `os` here, so a pin can search a composed body for THIS machine's
|
|
33
|
+
* real values. A module that reads the environment itself can only be tested against fixtures, and
|
|
34
|
+
* fixtures are exactly what a redaction pin must not trust.
|
|
35
|
+
*
|
|
36
|
+
* Tokens shorter than two characters are dropped: a one-character identity would redact every
|
|
37
|
+
* sentence, which is destruction rather than redaction.
|
|
38
|
+
*/
|
|
39
|
+
function machineIdentity(env) {
|
|
40
|
+
const raw = [env.hostname, env.username, env.homedir, ...(env.replicas ?? [])];
|
|
41
|
+
return [...new Set(raw.filter((s) => typeof s === 'string' && s.trim().length >= 2).map((s) => s.trim()))];
|
|
42
|
+
}
|
|
43
|
+
const ABSOLUTE_PATH = /(^|[\s"'`(])(\/(?:Users|home|root|var|opt|private)\/|[A-Za-z]:[\\/])/;
|
|
44
|
+
/** `user@host` with a long opaque user is how this repository's own remote carries its token. */
|
|
45
|
+
const CREDENTIAL = /[A-Za-z0-9_.-]{8,}@[A-Za-z0-9.-]+/;
|
|
46
|
+
function isSafeValue(text, identity) {
|
|
47
|
+
if (typeof text !== 'string' || text === '')
|
|
48
|
+
return true;
|
|
49
|
+
if (ABSOLUTE_PATH.test(text) || CREDENTIAL.test(text))
|
|
50
|
+
return false;
|
|
51
|
+
const lower = text.toLowerCase();
|
|
52
|
+
return !identity.some((id) => id.length >= 2 && lower.includes(id.toLowerCase()));
|
|
53
|
+
}
|
|
54
|
+
/** Ids only. A spec TITLE is unreleased product intent and must not ride in on the id field. */
|
|
55
|
+
const SPEC_ID = /^(?:REQ|H-SPEC|A-SPEC|T-SPEC|C-SPEC)-\d+(?:\.\d+)?$/;
|
|
56
|
+
function composeFieldReport(input) {
|
|
57
|
+
const identity = input.identity ?? [];
|
|
58
|
+
const withheld = [];
|
|
59
|
+
const guard = (name, value) => {
|
|
60
|
+
const v = value ?? '';
|
|
61
|
+
if (v === '')
|
|
62
|
+
return '';
|
|
63
|
+
if (isSafeValue(v, identity))
|
|
64
|
+
return v;
|
|
65
|
+
withheld.push(name);
|
|
66
|
+
return '';
|
|
67
|
+
};
|
|
68
|
+
const message = guard('message', input.message);
|
|
69
|
+
const article = guard('article', input.article);
|
|
70
|
+
const tool = guard('tool', input.tool);
|
|
71
|
+
const specIds = (input.specIds ?? []).filter((s) => typeof s === 'string' && SPEC_ID.test(s));
|
|
72
|
+
// Numbers only: prose in a measurement's place is how a narrative smuggles itself past the
|
|
73
|
+
// allowlist, and the reports worth having in this repository were always numbers.
|
|
74
|
+
const observations = (input.observations ?? []).filter((o) => o && typeof o.label === 'string'
|
|
75
|
+
&& typeof o.value === 'number' && Number.isFinite(o.value) && isSafeValue(o.label, identity));
|
|
76
|
+
const fingerprint = (0, advisory_outcomes_1.advisoryId)('field-report', article || tool || 'unknown', { kit: input.kit, harness: input.harness, os: input.os, message, article, tool, specIds });
|
|
77
|
+
return {
|
|
78
|
+
kit: input.kit, harness: input.harness, os: input.os, arch: input.arch, node: input.node,
|
|
79
|
+
message, article, tool, specIds, observations, fingerprint, withheld,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The bytes a human will read and paste. Anything withheld is named, never dropped in silence. */
|
|
83
|
+
function fieldReportBody(r) {
|
|
84
|
+
const lines = [
|
|
85
|
+
`holmes-kit ${r.kit} · ${r.harness} · ${r.os}/${r.arch} · node ${r.node}`,
|
|
86
|
+
'',
|
|
87
|
+
`fingerprint: ${r.fingerprint}`,
|
|
88
|
+
];
|
|
89
|
+
if (r.article)
|
|
90
|
+
lines.push(`article: ${r.article}`);
|
|
91
|
+
if (r.tool)
|
|
92
|
+
lines.push(`tool: ${r.tool}`);
|
|
93
|
+
if (r.specIds.length > 0)
|
|
94
|
+
lines.push(`specs: ${r.specIds.join(', ')}`);
|
|
95
|
+
if (r.message)
|
|
96
|
+
lines.push('', 'what holmes-kit said:', '', '> ' + r.message);
|
|
97
|
+
if (r.observations.length > 0) {
|
|
98
|
+
lines.push('', 'observed:');
|
|
99
|
+
for (const o of r.observations)
|
|
100
|
+
lines.push(`- ${o.label}: ${o.value}`);
|
|
101
|
+
}
|
|
102
|
+
if (r.withheld.length > 0) {
|
|
103
|
+
lines.push('', `withheld (contained a path, a credential or a machine identifier): ${r.withheld.join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('', 'This report states what was observed. It does not diagnose a cause.');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
@@ -9,6 +9,20 @@ export interface Registry {
|
|
|
9
9
|
version: 1;
|
|
10
10
|
workspaces: WorkspaceEntry[];
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* @implements A-SPEC-678
|
|
14
|
+
* Where the registry lives — with one seam a test can reach.
|
|
15
|
+
*
|
|
16
|
+
* Measured 2026-09-18: inside jest, `process.env` is a plain object copy in the sandbox, so
|
|
17
|
+
* assigning `HOME` never reaches `setenv` and `os.homedir()` keeps answering the developer's real
|
|
18
|
+
* home. Plain node follows `$HOME`; jest does not. An in-process test therefore cannot isolate a
|
|
19
|
+
* module that calls `os.homedir()` directly — which is how 599 of this machine's 603 registry
|
|
20
|
+
* entries came to be test temp directories.
|
|
21
|
+
*
|
|
22
|
+
* jest DOES control `process.env`, so one lookup there is the smallest seam that works. Consumers
|
|
23
|
+
* set nothing and get `os.homedir()`; an empty value is not a setting.
|
|
24
|
+
*/
|
|
25
|
+
export declare function holmesHome(env: NodeJS.ProcessEnv, fallback: string): string;
|
|
12
26
|
export declare const EMPTY_REGISTRY: Registry;
|
|
13
27
|
/**
|
|
14
28
|
* @implements A-SPEC-543.1
|
|
@@ -28,8 +42,45 @@ export declare function mergeWorkspaceEntry(reg: Registry, entry: WorkspaceEntry
|
|
|
28
42
|
* Thin best-effort recorder: read → merge → write `~/.holmes/workspaces.json`. Every failure is
|
|
29
43
|
* swallowed (returns false) — a registry problem must never fail the init that feeds it.
|
|
30
44
|
*/
|
|
31
|
-
|
|
45
|
+
/**
|
|
46
|
+
* @implements A-SPEC-677 — the I/O a recorder needs. `exists` and `tmpdir` are injected so a pin can
|
|
47
|
+
* judge without depending on this machine's real paths.
|
|
48
|
+
*/
|
|
49
|
+
export interface RecordIo {
|
|
32
50
|
read: (p: string) => string;
|
|
33
51
|
write: (p: string, c: string) => void;
|
|
34
52
|
mkdir: (p: string) => void;
|
|
35
|
-
|
|
53
|
+
exists?: (p: string) => boolean;
|
|
54
|
+
tmpdir?: () => string;
|
|
55
|
+
onPrune?: (dropped: number) => void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @implements A-SPEC-677
|
|
59
|
+
* Whether a target lives under the OS temp root — and therefore is ephemeral by construction.
|
|
60
|
+
*
|
|
61
|
+
* macOS spells that root two ways: `os.tmpdir()` answers `/var/folders/...` while a realpath gives
|
|
62
|
+
* `/private/var/folders/...`. Both appear in this machine's registry, so both are judged. A `/tmp`
|
|
63
|
+
* literal would have missed all 599 of them.
|
|
64
|
+
*
|
|
65
|
+
* An empty root filters NOTHING. When the judgement cannot be made, keeping an entry costs a line
|
|
66
|
+
* and dropping one the user wanted costs them something they cannot get back.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isTempTarget(target: string, tmpdir: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* @implements A-SPEC-677
|
|
71
|
+
* Drop what has certainly gone; COUNT what merely cannot be seen.
|
|
72
|
+
*
|
|
73
|
+
* The asymmetry is the whole judgement. A vanished temp path is gone by construction — nothing
|
|
74
|
+
* re-creates a `mkdtemp` directory. A vanished normal path may be an unmounted volume, an external
|
|
75
|
+
* disk or a network share, and `upgrade` already skips it harmlessly with one line. Deleting it is
|
|
76
|
+
* the one outcome the user cannot undo, so absence outside the temp root is reported, never acted on.
|
|
77
|
+
*/
|
|
78
|
+
export declare function pruneRegistry(reg: Registry, p: {
|
|
79
|
+
exists(t: string): boolean;
|
|
80
|
+
isTemp(t: string): boolean;
|
|
81
|
+
}): {
|
|
82
|
+
registry: Registry;
|
|
83
|
+
dropped: number;
|
|
84
|
+
absent: number;
|
|
85
|
+
};
|
|
86
|
+
export declare function recordWorkspace(home: string, entry: WorkspaceEntry, io?: RecordIo): boolean;
|
|
@@ -34,12 +34,33 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.EMPTY_REGISTRY = void 0;
|
|
37
|
+
exports.holmesHome = holmesHome;
|
|
37
38
|
exports.readRegistry = readRegistry;
|
|
38
39
|
exports.mergeWorkspaceEntry = mergeWorkspaceEntry;
|
|
40
|
+
exports.isTempTarget = isTempTarget;
|
|
41
|
+
exports.pruneRegistry = pruneRegistry;
|
|
39
42
|
exports.recordWorkspace = recordWorkspace;
|
|
40
43
|
// @implements A-SPEC-543.1
|
|
41
44
|
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const os = __importStar(require("node:os"));
|
|
42
46
|
const path = __importStar(require("node:path"));
|
|
47
|
+
/**
|
|
48
|
+
* @implements A-SPEC-678
|
|
49
|
+
* Where the registry lives — with one seam a test can reach.
|
|
50
|
+
*
|
|
51
|
+
* Measured 2026-09-18: inside jest, `process.env` is a plain object copy in the sandbox, so
|
|
52
|
+
* assigning `HOME` never reaches `setenv` and `os.homedir()` keeps answering the developer's real
|
|
53
|
+
* home. Plain node follows `$HOME`; jest does not. An in-process test therefore cannot isolate a
|
|
54
|
+
* module that calls `os.homedir()` directly — which is how 599 of this machine's 603 registry
|
|
55
|
+
* entries came to be test temp directories.
|
|
56
|
+
*
|
|
57
|
+
* jest DOES control `process.env`, so one lookup there is the smallest seam that works. Consumers
|
|
58
|
+
* set nothing and get `os.homedir()`; an empty value is not a setting.
|
|
59
|
+
*/
|
|
60
|
+
function holmesHome(env, fallback) {
|
|
61
|
+
const override = env.HOLMES_HOME;
|
|
62
|
+
return typeof override === 'string' && override.trim() !== '' ? override : fallback;
|
|
63
|
+
}
|
|
43
64
|
exports.EMPTY_REGISTRY = { version: 1, workspaces: [] };
|
|
44
65
|
/**
|
|
45
66
|
* @implements A-SPEC-543.1
|
|
@@ -79,16 +100,59 @@ function mergeWorkspaceEntry(reg, entry) {
|
|
|
79
100
|
};
|
|
80
101
|
}
|
|
81
102
|
/**
|
|
82
|
-
* @implements A-SPEC-
|
|
83
|
-
*
|
|
84
|
-
*
|
|
103
|
+
* @implements A-SPEC-677
|
|
104
|
+
* Whether a target lives under the OS temp root — and therefore is ephemeral by construction.
|
|
105
|
+
*
|
|
106
|
+
* macOS spells that root two ways: `os.tmpdir()` answers `/var/folders/...` while a realpath gives
|
|
107
|
+
* `/private/var/folders/...`. Both appear in this machine's registry, so both are judged. A `/tmp`
|
|
108
|
+
* literal would have missed all 599 of them.
|
|
109
|
+
*
|
|
110
|
+
* An empty root filters NOTHING. When the judgement cannot be made, keeping an entry costs a line
|
|
111
|
+
* and dropping one the user wanted costs them something they cannot get back.
|
|
85
112
|
*/
|
|
113
|
+
function isTempTarget(target, tmpdir) {
|
|
114
|
+
if (typeof target !== 'string' || typeof tmpdir !== 'string' || tmpdir.trim() === '')
|
|
115
|
+
return false;
|
|
116
|
+
const roots = new Set([tmpdir, tmpdir.startsWith('/private') ? tmpdir.slice('/private'.length) : `/private${tmpdir}`]);
|
|
117
|
+
return [...roots].some((r) => r !== '' && (target === r || target.startsWith(r.endsWith('/') ? r : `${r}/`)));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* @implements A-SPEC-677
|
|
121
|
+
* Drop what has certainly gone; COUNT what merely cannot be seen.
|
|
122
|
+
*
|
|
123
|
+
* The asymmetry is the whole judgement. A vanished temp path is gone by construction — nothing
|
|
124
|
+
* re-creates a `mkdtemp` directory. A vanished normal path may be an unmounted volume, an external
|
|
125
|
+
* disk or a network share, and `upgrade` already skips it harmlessly with one line. Deleting it is
|
|
126
|
+
* the one outcome the user cannot undo, so absence outside the temp root is reported, never acted on.
|
|
127
|
+
*/
|
|
128
|
+
function pruneRegistry(reg, p) {
|
|
129
|
+
let dropped = 0, absent = 0;
|
|
130
|
+
const workspaces = reg.workspaces.filter((w) => {
|
|
131
|
+
if (p.exists(w.target))
|
|
132
|
+
return true;
|
|
133
|
+
if (p.isTemp(w.target)) {
|
|
134
|
+
dropped += 1;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
absent += 1;
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
return { registry: { version: 1, workspaces }, dropped, absent };
|
|
141
|
+
}
|
|
86
142
|
function recordWorkspace(home, entry, io = {
|
|
87
143
|
read: (p) => fs.readFileSync(p, 'utf8'),
|
|
88
144
|
write: (p, c) => fs.writeFileSync(p, c),
|
|
89
145
|
mkdir: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
|
146
|
+
exists: (p) => fs.existsSync(p),
|
|
147
|
+
tmpdir: () => os.tmpdir(),
|
|
90
148
|
}) {
|
|
91
149
|
try {
|
|
150
|
+
// @implements A-SPEC-677 — an ephemeral target is not recorded at all. What never enters cannot
|
|
151
|
+
// pile up, and 599 of this machine's 603 entries entered exactly this way. Returning true is
|
|
152
|
+
// honest: nothing failed, there was simply nothing worth remembering.
|
|
153
|
+
const tmpdir = io.tmpdir?.() ?? '';
|
|
154
|
+
if (isTempTarget(entry.target, tmpdir))
|
|
155
|
+
return true;
|
|
92
156
|
const dir = path.join(home, '.holmes');
|
|
93
157
|
const file = path.join(dir, 'workspaces.json');
|
|
94
158
|
let raw = null;
|
|
@@ -98,9 +162,18 @@ function recordWorkspace(home, entry, io = {
|
|
|
98
162
|
catch {
|
|
99
163
|
raw = null;
|
|
100
164
|
}
|
|
101
|
-
const
|
|
165
|
+
const exists = io.exists;
|
|
166
|
+
// @implements A-SPEC-677 — prune while we are already here. Without an `exists` probe there is
|
|
167
|
+
// no way to tell gone from present, so the registry is merged unpruned rather than guessed at.
|
|
168
|
+
const base = exists
|
|
169
|
+
? pruneRegistry(readRegistry(raw), { exists, isTemp: (t) => isTempTarget(t, tmpdir) })
|
|
170
|
+
: { registry: readRegistry(raw), dropped: 0, absent: 0 };
|
|
171
|
+
const merged = mergeWorkspaceEntry(base.registry, entry);
|
|
102
172
|
io.mkdir(dir);
|
|
103
173
|
io.write(file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
174
|
+
// Never a silent deletion: whoever asked can learn how many entries went.
|
|
175
|
+
if (base.dropped > 0)
|
|
176
|
+
io.onPrune?.(base.dropped);
|
|
104
177
|
return true;
|
|
105
178
|
}
|
|
106
179
|
catch {
|
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.
|
|
4
|
+
"version": "0.24.0",
|
|
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",
|