@holmes-lab/holmes-kit 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -37,6 +37,8 @@ exports.resolveSemanticKey = resolveSemanticKey;
37
37
  exports.storeSemanticKey = storeSemanticKey;
38
38
  exports.removeSemanticKey = removeSemanticKey;
39
39
  // @implements A-SPEC-592
40
+ // @implements A-SPEC-549.4 — declared by that spec's Files to Touch and carries no Hangul; note that
41
+ // the guard's own SOURCES list does not cover this file (reported, not fixed here).
40
42
  // @implements A-SPEC-477
41
43
  /**
42
44
  * The cloud tier's credential: WHERE the consent lives, and in what order it is looked up.
@@ -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;
@@ -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-543.1
83
- * Thin best-effort recorder: read merge write `~/.holmes/workspaces.json`. Every failure is
84
- * swallowed (returns false) — a registry problem must never fail the init that feeds it.
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 merged = mergeWorkspaceEntry(readRegistry(raw), entry);
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.23.2",
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",
@@ -62,7 +62,8 @@
62
62
  "testTimeout": 30000,
63
63
  "setupFilesAfterEnv": [
64
64
  "<rootDir>/src/holmes/test-support/jest-timeouts-setup.ts"
65
- ]
65
+ ],
66
+ "maxWorkers": "50%"
66
67
  },
67
68
  "dependencies": {
68
69
  "@modelcontextprotocol/sdk": "^1.29.0",