@holmes-lab/holmes-kit 0.23.3 → 0.24.1

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.
@@ -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
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}) });
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
- export declare function recordWorkspace(home: string, entry: WorkspaceEntry, io?: {
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
- }): boolean;
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;