@holmes-lab/holmes-kit 0.1.9 → 0.1.10

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/test-platform.d.ts +25 -0
  5. package/dist/holmes/cli/test-platform.js +38 -0
  6. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  7. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  8. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  9. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  10. package/dist/holmes/cpg/hash-cache.js +13 -5
  11. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  12. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  13. package/dist/holmes/cpg/proposed-content.js +72 -0
  14. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  15. package/dist/holmes/cpg/required-calls.js +93 -0
  16. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  17. package/dist/holmes/guardrail/cspec-change.js +70 -0
  18. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  19. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  20. package/dist/holmes/guardrail/write-target.js +69 -18
  21. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  22. package/dist/holmes/hooks/stop.d.ts +17 -0
  23. package/dist/holmes/hooks/stop.js +39 -2
  24. package/dist/holmes/mcp/handlers.d.ts +41 -0
  25. package/dist/holmes/mcp/handlers.js +173 -3
  26. package/dist/holmes/mcp/tool-schemas.js +12 -0
  27. package/dist/holmes/project/dependencies.d.ts +15 -0
  28. package/dist/holmes/project/dependencies.js +58 -0
  29. package/dist/holmes/project/json-state.d.ts +24 -0
  30. package/dist/holmes/project/json-state.js +30 -0
  31. package/dist/holmes/reverse/scan.js +8 -1
  32. package/dist/holmes/review/scope.d.ts +29 -0
  33. package/dist/holmes/review/scope.js +44 -0
  34. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  35. package/dist/holmes/rtm/test-scope.js +92 -2
  36. package/dist/holmes/server/dashboard.d.ts +22 -0
  37. package/dist/holmes/server/dashboard.js +114 -24
  38. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  39. package/dist/holmes/spec/approval-blockers.js +49 -6
  40. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  41. package/dist/holmes/spec/legacy-format.js +15 -1
  42. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  43. package/dist/holmes/spec/nonfunctional.js +119 -0
  44. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  45. package/dist/holmes/spec/spec-parser.js +46 -2
  46. package/dist/holmes/spec/spec-types.d.ts +4 -1
  47. package/dist/holmes/spec/spec-types.js +13 -1
  48. package/dist/holmes/testing/effects.d.ts +54 -0
  49. package/dist/holmes/testing/effects.js +107 -0
  50. package/package.json +3 -2
  51. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseNonFunctional = parseNonFunctional;
4
+ exports.caseObligationTags = caseObligationTags;
5
+ exports.unmetObligations = unmetObligations;
6
+ // @implements A-SPEC-234
7
+ const spec_types_1 = require("./spec-types");
8
+ /**
9
+ * `- [tag] text` — the whole grammar.
10
+ *
11
+ * WHY THIS SPELLING. Measured 2026-08-22 across the approved corpus: a leading `- [tag]` appears in
12
+ * 0 of the 174 `## Non-Functional` lines and 0 of the 1,511 T-SPEC case lines. The obvious
13
+ * alternative, `- name: text`, already appears 21 times (13%) — adopting it would have invented 21
14
+ * false obligations on the day it shipped, breaking the rule this repository keeps: the first rule
15
+ * applied must be one that is ALREADY TRUE.
16
+ *
17
+ * The tag is only recognised at the START of a line. Eleven approved T-SPEC case lines contain
18
+ * brackets mid-prose (`[f1]`, `[V]`, `[REQ-007]`) and every one is a code fragment; counting those
19
+ * would invent coverage nobody wrote.
20
+ */
21
+ const OBLIGATION = /^\[([A-Za-z][A-Za-z0-9-]*)\]\s+(\S.*)$/;
22
+ /** A line that reaches for the grammar: `- [` … and then gets it wrong. */
23
+ const IMITATES = /^\[/;
24
+ const clean = (s) => s.replace(/`/g, '').trim().replace(/\s+/g, ' ');
25
+ /**
26
+ * Read the obligations out of a `## Non-Functional` section.
27
+ *
28
+ * @implements A-SPEC-234
29
+ * A malformed list item is RETURNED, never dropped. A typo that silently parses to nothing reads as
30
+ * "the rule passed" — the shape this repository has paid for in A-SPEC-191 §17, §29 and again in
31
+ * A-SPEC-222.2. Prose and blank lines are neither: a section is allowed to explain itself.
32
+ *
33
+ * THE TAG VOCABULARY IS NOT FIXED, AND THAT IS THE POINT. A closed set of allowed tags becomes a
34
+ * thing to maintain and then falls behind — the enumeration-chasing this repository already rejected
35
+ * for tool names (`write-target.ts`). Whatever the author writes IS the vocabulary; the machine
36
+ * judges CORRESPONDENCE between an H-SPEC tag and a T-SPEC tag, never meaning.
37
+ *
38
+ * WHAT THIS DOES NOT SEE. Whether the obligation is a good one. Whether the case tagged with it
39
+ * actually exercises it — a case reading `- [perf] Given nothing When nothing Then nothing` counts
40
+ * as met. Obligations written as prose without a tag (the other 105 documents). Those are human
41
+ * judgements, and simulating them would recreate the "documented but unenforced" state this slice
42
+ * exists to remove.
43
+ */
44
+ function parseNonFunctional(section) {
45
+ const obligations = [];
46
+ const malformed = [];
47
+ if (typeof section !== 'string')
48
+ return { obligations, malformed };
49
+ for (const raw of section.split('\n')) {
50
+ const line = raw.trim();
51
+ if (!line.startsWith('- '))
52
+ continue; // prose, blank, or a non-list line
53
+ const body = clean(line.slice(2));
54
+ const m = OBLIGATION.exec(body);
55
+ if (m) {
56
+ obligations.push({ tag: m[1].toLowerCase(), text: m[2].trim(), line });
57
+ continue;
58
+ }
59
+ // Only lines that REACH for the grammar are errors. Every other list item is ordinary prose and
60
+ // reporting it would bury the real typos in noise.
61
+ if (IMITATES.test(body))
62
+ malformed.push(line);
63
+ }
64
+ return { obligations, malformed };
65
+ }
66
+ /**
67
+ * The obligation tags a T-SPEC's CASES carry.
68
+ *
69
+ * @implements A-SPEC-234
70
+ * Only the four case sections count, and the section names are read from the type table rather than
71
+ * spelled here — a second spelling of a location is a second thing to keep in step, and this
72
+ * repository has already paid for that twice (`isTestFile` drifted into two copies; a restated
73
+ * section-splitting regex returned an empty section while the check using it went green).
74
+ *
75
+ * A tag anywhere else in the document states an INTENTION. Only a case is evidence that something
76
+ * was tested.
77
+ */
78
+ function caseObligationTags(sections) {
79
+ const tags = new Set();
80
+ if (!sections || typeof sections !== 'object')
81
+ return tags;
82
+ const map = sections;
83
+ for (const name of spec_types_1.SPEC_TYPES['T-SPEC'].requiredSections) {
84
+ const body = map[name];
85
+ if (typeof body !== 'string')
86
+ continue;
87
+ for (const raw of body.split('\n')) {
88
+ const line = raw.trim();
89
+ if (!line.startsWith('- '))
90
+ continue;
91
+ const m = OBLIGATION.exec(clean(line.slice(2)));
92
+ if (m)
93
+ tags.add(m[1].toLowerCase());
94
+ }
95
+ }
96
+ return tags;
97
+ }
98
+ /**
99
+ * Obligations an H-SPEC declares that no child T-SPEC case answers.
100
+ *
101
+ * @implements A-SPEC-234
102
+ * Matching is EXACT after lower-casing. A prefix match would let `perf` be satisfied by
103
+ * `performance`, and a false "met" is a gate switched off that says nothing — the same asymmetry
104
+ * that made `call` targets exact in `forbidden-edges.ts`.
105
+ *
106
+ * `malformed` lines are NOT reported here. Flagging syntax errors and judging fulfilment are
107
+ * different questions; mixed together, one typo invents an obligation nobody declared.
108
+ */
109
+ function unmetObligations(nonFunctional, tspecSections) {
110
+ const { obligations } = parseNonFunctional(nonFunctional);
111
+ if (obligations.length === 0)
112
+ return [];
113
+ const covered = new Set();
114
+ for (const s of Array.isArray(tspecSections) ? tspecSections : []) {
115
+ for (const t of caseObligationTags(s))
116
+ covered.add(t);
117
+ }
118
+ return obligations.filter((o) => !covered.has(o.tag));
119
+ }
@@ -7,6 +7,31 @@ export interface Spec {
7
7
  dependsOn: string[];
8
8
  frontmatter: Record<string, unknown>;
9
9
  sections: Record<string, string>;
10
+ /**
11
+ * Body text before the first `##`, or `''`.
12
+ *
13
+ * @implements A-SPEC-221
14
+ * `splitSections` keys on `## ` because the heading IS the key, which is right — but serialization
15
+ * rebuilt the body from those sections alone, so a legacy document's opening `# 개요` section was
16
+ * dropped on every write. Captured separately so it can be put back without changing what a
17
+ * section means.
18
+ *
19
+ * Optional so the four call sites that BUILD a Spec (reverse drafts, the conformance fixture,
20
+ * spec_create) need no change: they have no preamble, and serialization treats absent and empty
21
+ * identically. `parseSpec` always sets it.
22
+ */
23
+ preamble?: string;
10
24
  }
25
+ /**
26
+ * Read `depends_on` in every spelling the corpus contains, keeping what cannot be represented.
27
+ *
28
+ * @implements A-SPEC-221
29
+ * Three forms exist: the current array, a bare string, and the legacy `ID:digest` string. Only the
30
+ * first was understood; the other two silently became no parents at all.
31
+ */
32
+ export declare function parseDependsOn(raw: unknown): {
33
+ ids: string[];
34
+ legacy: string | null;
35
+ };
11
36
  export declare function parseSpec(input: string): Spec;
12
37
  export declare function serializeSpec(spec: Spec): string;
@@ -33,9 +33,38 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseDependsOn = parseDependsOn;
36
37
  exports.parseSpec = parseSpec;
37
38
  exports.serializeSpec = serializeSpec;
38
39
  const yaml = __importStar(require("js-yaml"));
40
+ const legacy_format_1 = require("./legacy-format");
41
+ /**
42
+ * Read `depends_on` in every spelling the corpus contains, keeping what cannot be represented.
43
+ *
44
+ * @implements A-SPEC-221
45
+ * Three forms exist: the current array, a bare string, and the legacy `ID:digest` string. Only the
46
+ * first was understood; the other two silently became no parents at all.
47
+ */
48
+ function parseDependsOn(raw) {
49
+ if (Array.isArray(raw))
50
+ return { ids: raw, legacy: null };
51
+ if (typeof raw !== 'string' || raw.length === 0)
52
+ return { ids: [], legacy: null };
53
+ const colon = raw.indexOf(':');
54
+ // The id ends at the FIRST colon — `REQ-001:sha256:abcd` names REQ-001, not `REQ-001:sha256`.
55
+ const id = colon === -1 ? raw : raw.slice(0, colon);
56
+ if (id.length === 0)
57
+ return { ids: [], legacy: null };
58
+ return { ids: [id], legacy: colon === -1 ? null : raw };
59
+ }
60
+ function splitPreamble(body) {
61
+ const idx = body.search(/^##\s+/m);
62
+ const head = idx === -1 ? body : body.slice(0, idx);
63
+ // Whitespace is not a preamble. The frontmatter delimiter is followed by a blank line, so nearly
64
+ // every document has a stray newline here; treating it as content would add one blank line to the
65
+ // file on every single write, forever.
66
+ return head.trim().length === 0 ? '' : head;
67
+ }
39
68
  function splitSections(body) {
40
69
  const out = {};
41
70
  const lines = body.split('\n');
@@ -89,18 +118,33 @@ function parseSpec(input) {
89
118
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw);
90
119
  const fm = ((m ? yaml.load(m[1]) : {}) ?? {});
91
120
  const body = m ? m[2] : raw;
121
+ const deps = parseDependsOn(fm.depends_on);
122
+ // @implements A-SPEC-221 — never overwrite an existing preservation: a second round trip must not
123
+ // chew up what the first one saved, the same rule `legacy_status` follows.
124
+ if (deps.legacy !== null && fm[legacy_format_1.LEGACY_DEPENDS_FIELD] === undefined) {
125
+ fm[legacy_format_1.LEGACY_DEPENDS_FIELD] = deps.legacy;
126
+ }
92
127
  return {
93
128
  id: String(fm.id ?? ''),
94
129
  type: fm.type,
95
130
  title: String(fm.title ?? ''),
96
131
  status: (fm.status ?? 'draft'),
97
- dependsOn: Array.isArray(fm.depends_on) ? fm.depends_on : [],
132
+ dependsOn: deps.ids,
98
133
  frontmatter: fm,
99
134
  sections: splitSections(body),
135
+ preamble: splitPreamble(body),
100
136
  };
101
137
  }
102
138
  function serializeSpec(spec) {
103
139
  const fm = { ...spec.frontmatter, id: spec.id, type: spec.type, title: spec.title, status: spec.status, depends_on: spec.dependsOn };
104
140
  const body = Object.entries(spec.sections).map(([h, t]) => `## ${h}\n${t}\n`).join('\n');
105
- return `---\n${yaml.dump(fm)}---\n\n${body}`;
141
+ // @implements A-SPEC-221 — an empty preamble must contribute NOTHING, and a non-empty one must
142
+ // contribute the SAME thing every time. The delimiter below already supplies the blank line, so
143
+ // emitting the preamble's own leading newlines on top of it grew the file by one line on every
144
+ // write — measured: 2 → 3 → 4 → 5 blank lines over three round trips, unbounded. Trimming the
145
+ // leading whitespace makes serialization idempotent; the trailing blank line is restored so the
146
+ // preamble stays separated from the first section.
147
+ const head = (spec.preamble ?? '').replace(/^\s+/, '');
148
+ const preamble = head ? `${head.replace(/\s+$/, '')}\n\n` : '';
149
+ return `---\n${yaml.dump(fm)}---\n\n${preamble}${body}`;
106
150
  }
@@ -97,7 +97,8 @@ export declare const SPEC_TYPES: Record<SpecType, SpecTypeDef>;
97
97
  * The GOVERNED set is defined by the modern spec format: a spec is governed
98
98
  * iff it carries a `type` field naming a known SpecType. This deliberately
99
99
  * excludes (a) pre-`type` legacy specs retained only as archived history, and
100
- * (b) the `06_job/JOB-*` runtime ledger, which is not a governed spec type.
100
+ * (b) any document that is not a governed spec type. `06_job/JOB-*` was the historical case and
101
+ * was removed under REQ-223; `spec-store-boundary.test.ts` now fails the build if another appears.
101
102
  * Validation and RTM integrity checks operate on the governed set — validating
102
103
  * archived legacy or runtime-ledger entries is a category error (audit C1).
103
104
  * Generic over `{ type?: SpecType }` to stay free of an import cycle with the
@@ -113,9 +114,11 @@ export declare const SPEC_TYPES: Record<SpecType, SpecTypeDef>;
113
114
  export declare function specTypeDef(type: string | undefined | null): SpecTypeDef | undefined;
114
115
  export declare function isGoverned(spec: {
115
116
  type?: SpecType;
117
+ status?: SpecStatus;
116
118
  }): boolean;
117
119
  export declare function filterGoverned<T extends {
118
120
  type?: SpecType;
121
+ status?: SpecStatus;
119
122
  }>(specs: T[]): T[];
120
123
  /**
121
124
  * @implements A-SPEC-174
@@ -106,7 +106,8 @@ exports.SPEC_TYPES = {
106
106
  * The GOVERNED set is defined by the modern spec format: a spec is governed
107
107
  * iff it carries a `type` field naming a known SpecType. This deliberately
108
108
  * excludes (a) pre-`type` legacy specs retained only as archived history, and
109
- * (b) the `06_job/JOB-*` runtime ledger, which is not a governed spec type.
109
+ * (b) any document that is not a governed spec type. `06_job/JOB-*` was the historical case and
110
+ * was removed under REQ-223; `spec-store-boundary.test.ts` now fails the build if another appears.
110
111
  * Validation and RTM integrity checks operate on the governed set — validating
111
112
  * archived legacy or runtime-ledger entries is a category error (audit C1).
112
113
  * Generic over `{ type?: SpecType }` to stay free of an import cycle with the
@@ -127,6 +128,17 @@ function specTypeDef(type) {
127
128
  : undefined;
128
129
  }
129
130
  function isGoverned(spec) {
131
+ // @implements A-SPEC-222.1 — a retired document is the canonical form of "archived", which the
132
+ // comment above already excludes as a category error. The rule was expressed as `type` alone only
133
+ // because no document had ever carried `outdated`: it was in SPEC_STATUSES with nothing able to
134
+ // write it. Measured the day retirement shipped — moving 23 legacy REQs to `outdated` surfaced 24
135
+ // constitution violations, among them `missing section: ## Success Criteria` and
136
+ // `root-has-parent`. Demanding those of a retired document is demanding that history be rewritten,
137
+ // and `root-has-parent` was not a violation at all under the 2026-04 model the document was
138
+ // written in. Retirement has to mean removal from the ACTIVE graph, or it means nothing beyond a
139
+ // changed string.
140
+ if (spec.status === 'outdated')
141
+ return false;
130
142
  return specTypeDef(spec.type) !== undefined;
131
143
  }
132
144
  function filterGoverned(specs) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What a function actually touched while it ran.
3
+ *
4
+ * @implements A-SPEC-238
5
+ * WHY OBSERVATION RATHER THAN SOURCE READING. Sixteen approved H-SPECs claim "순수", "I/O 없음",
6
+ * "파일시스템 접근 0". Measured 2026-08-23, every one of those claims is TRUE — but nothing held
7
+ * them true, and the `[purity]` cases written earlier in this session were weak in a specific way:
8
+ *
9
+ * expect(fn.toString()).not.toMatch(/readFileSync/) // reads the function's TEXT
10
+ * expect(input).toEqual(copy) // proves only that input is unchanged
11
+ *
12
+ * Both ask how the function is WRITTEN, not what it DOES. One helper call into another module and
13
+ * the first passes; a file-reading function that mutates nothing passes the second. This session has
14
+ * corrected that same shape twice already — ADR-016's off-switch check guessed at flag names and
15
+ * missed its mutation, and A-SPEC-237's `[symmetry]` check grepped for `try` and passed before the
16
+ * fix existed.
17
+ */
18
+ export interface ObservedEffects {
19
+ /** Names of the filesystem methods called, in order. */
20
+ fs: string[];
21
+ /** How many times the clock was read. */
22
+ clock: number;
23
+ /** How many times randomness was drawn. */
24
+ random: number;
25
+ /** What the body threw, if anything — returned rather than propagated. */
26
+ threw?: unknown;
27
+ /**
28
+ * Read methods that could NOT be wrapped, so the caller knows the observation has holes.
29
+ *
30
+ * Silence about what a tool could not watch is the same defect as a gate that passes because it
31
+ * saw nothing. Under normal Node this is empty; it is reported rather than assumed.
32
+ */
33
+ unwatched: string[];
34
+ }
35
+ /**
36
+ * Run `fn` and report what it touched.
37
+ *
38
+ * @implements A-SPEC-238
39
+ * The body's exception is RETURNED in `threw`, not re-thrown. "Why did it fail" and "what did it
40
+ * touch" are different pieces of information and a caller usually needs both; re-throwing would
41
+ * restore the globals but discard the observation.
42
+ *
43
+ * WHAT THIS DOES NOT SEE. Network calls and child processes — no claim in this repository's
44
+ * `## Non-Functional` sections mentions them, and offering coverage would recreate the
45
+ * "documented but unenforced" state. Asynchronous work that lands after `fn` returns: every function
46
+ * making a purity claim here is synchronous. Reads through a handle obtained before observation
47
+ * began. And a grammar loaded once per process — measured 2026-08-23, `extractSymbols` performs 26
48
+ * filesystem calls on the FIRST call in a process and zero on every call after, including the first
49
+ * call for a different language. That is one-time lazy loading, not per-call I/O, and this tool
50
+ * cannot tell them apart: warm the callee first.
51
+ */
52
+ export declare function observeEffects(fn: () => unknown): ObservedEffects;
53
+ /** Assert that `fn` touched nothing, naming what it touched when it did. */
54
+ export declare function expectPure(fn: () => unknown): void;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ // @implements A-SPEC-238
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.observeEffects = observeEffects;
5
+ exports.expectPure = expectPure;
6
+ /**
7
+ * Every filesystem READ surface, found by prefix rather than listed by name.
8
+ *
9
+ * Enumerating method names would let a new one through, and a pass reads as "no access". Judging the
10
+ * surface whole is the direction this repository has already taken three times: tool names in
11
+ * `write-target.ts`, shell verbs in `risk-classifier.ts`, and dependency declaration surfaces in
12
+ * `project/dependencies.ts`. Prefix matching also means a future Node release's new read method is
13
+ * covered the day it appears.
14
+ */
15
+ const READ_PREFIXES = /^(read|stat|lstat|fstat|exists|open|access|opendir|realpath|watch|glob)/;
16
+ /**
17
+ * The CommonJS `fs` module object, NOT an `import * as fs` namespace.
18
+ *
19
+ * The namespace an ES import produces exposes getter-only properties: assigning to one throws
20
+ * `TypeError: Cannot set property access of #<Object> which has only a getter`. Measured while
21
+ * writing this — the standalone probe worked because it used `require`, and the first version of
22
+ * this module did not, so every case failed at the first patch. The module object is what the code
23
+ * under test actually calls through.
24
+ */
25
+ const fsModule = () =>
26
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
27
+ require('node:fs');
28
+ /**
29
+ * Run `fn` and report what it touched.
30
+ *
31
+ * @implements A-SPEC-238
32
+ * The body's exception is RETURNED in `threw`, not re-thrown. "Why did it fail" and "what did it
33
+ * touch" are different pieces of information and a caller usually needs both; re-throwing would
34
+ * restore the globals but discard the observation.
35
+ *
36
+ * WHAT THIS DOES NOT SEE. Network calls and child processes — no claim in this repository's
37
+ * `## Non-Functional` sections mentions them, and offering coverage would recreate the
38
+ * "documented but unenforced" state. Asynchronous work that lands after `fn` returns: every function
39
+ * making a purity claim here is synchronous. Reads through a handle obtained before observation
40
+ * began. And a grammar loaded once per process — measured 2026-08-23, `extractSymbols` performs 26
41
+ * filesystem calls on the FIRST call in a process and zero on every call after, including the first
42
+ * call for a different language. That is one-time lazy loading, not per-call I/O, and this tool
43
+ * cannot tell them apart: warm the callee first.
44
+ */
45
+ function observeEffects(fn) {
46
+ const target = fsModule();
47
+ const surface = Object.keys(target).filter((k) => READ_PREFIXES.test(k) && typeof target[k] === 'function');
48
+ const originals = {};
49
+ const unwatched = [];
50
+ const calls = [];
51
+ for (const name of surface) {
52
+ const original = target[name];
53
+ try {
54
+ target[name] = function (...args) {
55
+ calls.push(name);
56
+ return original.apply(target, args);
57
+ };
58
+ // Assignment can silently no-op on a non-writable property, which would look like a wrapped
59
+ // method that never fires. Confirm it took.
60
+ if (target[name] === original) {
61
+ unwatched.push(name);
62
+ continue;
63
+ }
64
+ originals[name] = original;
65
+ }
66
+ catch {
67
+ unwatched.push(name);
68
+ }
69
+ }
70
+ const realNow = Date.now;
71
+ const realRandom = Math.random;
72
+ let clock = 0;
73
+ let random = 0;
74
+ Date.now = function () { clock++; return realNow.call(Date); };
75
+ Math.random = function () { random++; return realRandom.call(Math); };
76
+ let threw;
77
+ let didThrow = false;
78
+ try {
79
+ fn();
80
+ }
81
+ catch (e) {
82
+ threw = e;
83
+ didThrow = true;
84
+ }
85
+ finally {
86
+ // Restoration is in `finally` because a tool that patches globals and fails to put them back
87
+ // poisons every test that runs afterwards — and that poisoning surfaces far from its cause.
88
+ for (const name of Object.keys(originals))
89
+ target[name] = originals[name];
90
+ Date.now = realNow;
91
+ Math.random = realRandom;
92
+ }
93
+ const out = { fs: calls, clock, random, unwatched };
94
+ return didThrow ? { ...out, threw } : out;
95
+ }
96
+ /** Assert that `fn` touched nothing, naming what it touched when it did. */
97
+ function expectPure(fn) {
98
+ const e = observeEffects(fn);
99
+ const touched = [
100
+ ...e.fs.map((m) => `fs.${m}`),
101
+ ...(e.clock ? [`Date.now ×${e.clock}`] : []),
102
+ ...(e.random ? [`Math.random ×${e.random}`] : []),
103
+ ];
104
+ if (touched.length > 0) {
105
+ throw new Error(`expected no effects, observed: ${[...new Set(touched)].join(', ')}`);
106
+ }
107
+ }
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.1.9",
4
+ "version": "0.1.10",
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",
@@ -23,7 +23,8 @@
23
23
  "test": "npm run typecheck && jest",
24
24
  "mcp": "node bin/holmes-mcp.js",
25
25
  "release": "npm run build && node scripts/make-release.js",
26
- "typecheck": "tsc --noEmit -p tsconfig.test.json"
26
+ "typecheck": "tsc --noEmit -p tsconfig.test.json",
27
+ "prepublishOnly": "node scripts/verify-release.js"
27
28
  },
28
29
  "keywords": [
29
30
  "ase",
@@ -107,6 +107,25 @@ depends_on:
107
107
  승인 후에 발견된 것은 **새 리비전**이지 승인 취소가 아니다. 되돌리는 비용이 승인을 미루게 만들면,
108
108
  게이트는 품질 장치가 아니라 지연 장치가 된다.
109
109
 
110
+ ## 문서를 물리는 법 — `spec_retire`
111
+
112
+ 승인이 권한을 주는 행위라면 폐기는 거두는 행위다. 그래서 기본 문턱이 낮다 — 잘못 만든 초안을
113
+ 치우는 데 대역외 열쇠를 요구하면 일상 작업이 막힌다. **다만 문턱은 행위의 이름이 아니라 결과의
114
+ 위험을 따른다.** 두 경우는 폐기가 곧 게이트 열기다:
115
+
116
+ - **봉인된 문서.** 코드 게이트는 "대상 A-SPEC을 `depends_on`에 담은 approved T-SPEC"을 요구한다.
117
+ 그 T-SPEC을 폐기하면 요구가 사라진다. 그래서 `approved_digest`를 가진 문서의 폐기는
118
+ `spec_approve`와 **같은** `HOLMES_APPROVAL`을 요구하며 fail-closed다.
119
+ - **활성 문서가 의존하는 문서.** `approved` 자식이 하나라도 있으면 거부하고, 문면이 그 자식을
120
+ id로 지목한다. 그 사슬은 지금 무언가를 지탱하고 있다.
121
+
122
+ 사유는 `retired_reason`에 남는다 — 비어 있으면 적지 않고, `superseded_by`가 이미 답이면 묻지
123
+ 않는다. 멱등이다: 이미 `outdated`인 문서를 다시 폐기하면 아무것도 쓰지 않는다. 두 번째 호출이
124
+ 첫 번째의 사유를 덮어쓰면 기록이 아니라 낙서다.
125
+
126
+ > `outdated`는 `SPEC_STATUSES`에 처음부터 있었지만 이를 쓰는 코드가 0곳, 그 상태인 문서도
127
+ > 0건이었다(실측 2026-08-21). 도달할 수 없는 상태는 선언이 아니라 장식이다.
128
+
110
129
  ## 흔한 오해
111
130
 
112
131
  | 오해 | 사실 |
@@ -116,6 +135,7 @@ depends_on:
116
135
  | "A-SPEC을 승인했으니 코드를 쓸 수 있다" | 테스트는 그렇다. 코드는 T-SPEC이 남았다 |
117
136
  | "T-SPEC 승인했는데 막힌다 = 게이트 버그" | `depends_on` 누락. 메시지가 동일해서 구별되지 않는다 |
118
137
  | "일단 승인하고 검증은 나중에" | 통과한 채 깨진 그래프가 남는다. 4단계 참조 |
138
+ | "폐기는 `status: outdated`로 손편집한다" | `spec_retire(id, reason)`이 유일한 경로다. 봉인된 문서는 승인 열쇠를 요구하고, approved 의존자가 있으면 거부한다 |
119
139
  | "`missing`이 'approved A-SPEC'이니 A-SPEC을 승인하면 된다" | `AUTHOR_TSPEC`이면 아무거나 하나, `WRITE_TEST`면 대상 그것. 같은 문자열이 두 가지를 뜻한다 |
120
140
 
121
141
  ## 검증