@holmes-lab/holmes-kit 0.19.2 → 0.19.4

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 (47) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +22 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +8 -0
  5. package/dist/holmes/cli/agents.js +26 -2
  6. package/dist/holmes/cli/codex-toml.d.ts +10 -0
  7. package/dist/holmes/cli/codex-toml.js +76 -12
  8. package/dist/holmes/cli/doctor.d.ts +34 -0
  9. package/dist/holmes/cli/doctor.js +139 -46
  10. package/dist/holmes/cli/index.js +13 -0
  11. package/dist/holmes/cli/init.js +10 -3
  12. package/dist/holmes/cli/native-deps.js +4 -1
  13. package/dist/holmes/cli/playbook-skills.js +6 -4
  14. package/dist/holmes/cli/probe-process.d.ts +8 -0
  15. package/dist/holmes/cli/probe-process.js +73 -0
  16. package/dist/holmes/cli/spawn-spec.js +3 -1
  17. package/dist/holmes/cli/test-platform.d.ts +37 -0
  18. package/dist/holmes/cli/test-platform.js +126 -1
  19. package/dist/holmes/governance/approval-grants.js +26 -3
  20. package/dist/holmes/governance/autonomy.d.ts +17 -1
  21. package/dist/holmes/governance/autonomy.js +37 -5
  22. package/dist/holmes/mcp/handlers.d.ts +30 -5
  23. package/dist/holmes/mcp/handlers.js +111 -13
  24. package/dist/holmes/mcp/spec-id-guard.d.ts +1 -1
  25. package/dist/holmes/mcp/spec-id-guard.js +9 -13
  26. package/dist/holmes/mcp/tool-schemas.js +13 -0
  27. package/dist/holmes/project/install-scripts-policy.d.ts +16 -2
  28. package/dist/holmes/project/install-scripts-policy.js +16 -2
  29. package/dist/holmes/review/point-in-time-replay.js +43 -3
  30. package/dist/holmes/rtm/graph-store.d.ts +2 -0
  31. package/dist/holmes/rtm/graph-store.js +14 -0
  32. package/dist/holmes/rtm/rtm-graph.js +42 -30
  33. package/dist/holmes/semantic/credentials.js +86 -9
  34. package/dist/holmes/semantic/embedder.js +6 -39
  35. package/dist/holmes/semantic/local-model.d.ts +30 -0
  36. package/dist/holmes/semantic/local-model.js +92 -0
  37. package/dist/holmes/semantic/model-cache.d.ts +8 -0
  38. package/dist/holmes/semantic/model-cache.js +67 -0
  39. package/dist/holmes/semantic/tier.d.ts +7 -0
  40. package/dist/holmes/semantic/tier.js +9 -3
  41. package/dist/holmes/spec/renumber.d.ts +72 -0
  42. package/dist/holmes/spec/renumber.js +341 -0
  43. package/dist/holmes/spec/spec-id.d.ts +9 -0
  44. package/dist/holmes/spec/spec-id.js +23 -0
  45. package/docs/install-guide.md +90 -2
  46. package/package.json +6 -3
  47. package/scripts/install.ps1 +30 -27
@@ -1,8 +1,48 @@
1
1
  "use strict";
2
- // @implements A-SPEC-220.3
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
+ })();
3
35
  Object.defineProperty(exports, "__esModule", { value: true });
4
36
  exports.posixOnly = posixOnly;
5
37
  exports.posixOnlyNonRoot = posixOnlyNonRoot;
38
+ exports.foldsCase = foldsCase;
39
+ exports.posixOnlyWithCommand = posixOnlyWithCommand;
40
+ exports.win32Only = win32Only;
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const node_crypto_1 = require("node:crypto");
44
+ // @implements A-SPEC-220.3
45
+ // @implements A-SPEC-597
6
46
  /**
7
47
  * A test that cannot run here says so, instead of returning early and being counted as a pass.
8
48
  *
@@ -36,3 +76,88 @@ function posixOnlyNonRoot(reason, platform = process.platform, uid = typeof proc
36
76
  const base = posixOnly(reason, platform);
37
77
  return uid === 0 ? it.skip : base;
38
78
  }
79
+ /**
80
+ * @implements A-SPEC-597
81
+ * Whether THIS directory's filesystem folds case — measured, never inferred from the platform.
82
+ *
83
+ * macOS defaults to case-insensitive APFS and Linux to case-sensitive ext4, but neither is a rule:
84
+ * a case-sensitive volume mounts fine on macOS, and `os.tmpdir()` need not share the repository's
85
+ * volume. The same reasoning already governs the write gate (`pre-tool-use.ts`), which probes the
86
+ * project root rather than trusting `process.platform`; a test must probe the directory it actually
87
+ * writes into.
88
+ *
89
+ * An unanswerable probe returns FALSE. Claiming "folds" when it cannot tell would let a
90
+ * folding-world assertion run — and pass vacuously — on a case-sensitive box; claiming "sensitive"
91
+ * merely runs the other assertion, which fails loudly if it is wrong. Be wrong toward the side that
92
+ * still produces information.
93
+ */
94
+ function foldsCase(dir) {
95
+ if (typeof dir !== 'string' || dir === '')
96
+ return false;
97
+ const probe = path.join(dir, `.case-probe-${process.pid}-${(0, node_crypto_1.randomUUID)()}`);
98
+ try {
99
+ fs.writeFileSync(probe, '');
100
+ const flipped = path.join(dir, path.basename(probe).toUpperCase());
101
+ return fs.existsSync(flipped);
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ finally {
107
+ // The probe leaves no trace, on every path out of here.
108
+ try {
109
+ fs.rmSync(probe, { force: true });
110
+ }
111
+ catch { /* nothing was created */ }
112
+ }
113
+ }
114
+ /** PATH lookup without a shell — spawning one to ask about a command is a cost and a surface. */
115
+ function onPath(command) {
116
+ if (typeof command !== 'string' || command === '')
117
+ return false;
118
+ if (command.includes('/') || command.includes('\\'))
119
+ return fs.existsSync(command);
120
+ const sep = process.platform === 'win32' ? ';' : ':';
121
+ const exts = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
122
+ for (const dir of (process.env.PATH ?? '').split(sep)) {
123
+ if (dir === '')
124
+ continue;
125
+ for (const ext of exts) {
126
+ try {
127
+ if (fs.existsSync(path.join(dir, command + ext)))
128
+ return true;
129
+ }
130
+ catch { /* unreadable PATH entry */ }
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ /**
136
+ * @implements A-SPEC-597
137
+ * As `posixOnlyNonRoot`, and additionally skipped when `command` is not on PATH.
138
+ *
139
+ * `chflags` is macOS/BSD only. Guarded by platform and uid alone, the assertion that uses it RAN on
140
+ * Linux and died with ENOENT — which reads as a defect in the code under test rather than as a tool
141
+ * this machine does not have. Availability of the tool is knowable at definition time, exactly like
142
+ * the platform and the uid, so it belongs in the same choice.
143
+ */
144
+ function posixOnlyWithCommand(reason, command, platform = process.platform, uid = typeof process.getuid === 'function' ? process.getuid() : undefined, lookup = onPath) {
145
+ const base = posixOnlyNonRoot(reason, platform, uid); // reason is validated here, before anything else
146
+ return lookup(command) ? base : it.skip;
147
+ }
148
+ /**
149
+ * @implements A-SPEC-255
150
+ * The mirror of `posixOnly`: a test whose SUBJECT only exists on Windows.
151
+ *
152
+ * Without this, the Windows work had nowhere to put such a test and improvised
153
+ * `(process.platform === 'win32' ? it : it.skip)('…')` — a skip with no stated reason, which is the
154
+ * silence `posixOnly` was written to end, in different syntax. NTFS ACLs, PowerShell 5.1 parsing and
155
+ * 8.3 short names are real subjects; they simply cannot be exercised on a POSIX box, and saying so
156
+ * in the report is the honest form. `reason` is required for the same reason it is required there.
157
+ */
158
+ function win32Only(reason, platform = process.platform) {
159
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
160
+ throw new Error('win32Only(reason): 사유 없는 스킵은 문법만 바뀐 침묵입니다 — 왜 이 단언이 POSIX에서 무의미한지 적으십시오.');
161
+ }
162
+ return platform === 'win32' ? it : it.skip;
163
+ }
@@ -132,11 +132,34 @@ function resolveApproval(root, envApproval, action, now) {
132
132
  * The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
133
133
  */
134
134
  function consumeGrantFile(root, nonce) {
135
+ // @implements A-SPEC-596 — address the grant the SAME way `readGrants` does: by the nonce INSIDE
136
+ // the file, never by a path built from it. Building the path meant a grant whose filename differed
137
+ // from its nonce was honoured (reading scans, and never compares the two) yet never spent, with
138
+ // `force: true` swallowing the miss — a single-use grant that stayed spendable forever. Scanning
139
+ // also makes traversal structurally impossible: no nonce becomes a path, so none can point out.
140
+ const dir = path.join(root, exports.GRANTS_RELDIR);
141
+ const wanted = String(nonce);
142
+ let removed = 0;
135
143
  try {
136
- const name = path.basename(String(nonce)) + '.json';
137
- fs.rmSync(path.join(root, exports.GRANTS_RELDIR, name), { force: true });
144
+ for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json'))) {
145
+ const file = path.join(dir, name);
146
+ try {
147
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
148
+ if (String(parsed?.nonce) !== wanted)
149
+ continue; // someone else's grant is not ours to spend
150
+ fs.rmSync(file, { force: true });
151
+ removed++;
152
+ }
153
+ catch { /* corrupt or vanished: it was never honoured either, so it is not ours to spend */ }
154
+ }
138
155
  }
139
156
  catch {
140
- /* the act already succeeded; a stuck file is bounded by expires */
157
+ // No directory, or it cannot be read: there is nothing to spend, which is an ordinary state.
158
+ return;
159
+ }
160
+ if (removed === 0) {
161
+ // Consumption runs AFTER the act succeeded, so this must never throw — but it must not be silent
162
+ // either: an unspent single-use grant is still spendable, and nobody would know.
163
+ process.stderr.write(`[Holmes-Kit] approval grant was used but no grant file matched it — the grant may still be spendable. Remove it by hand from ${exports.GRANTS_RELDIR}.\n`);
141
164
  }
142
165
  }
@@ -9,7 +9,23 @@ export declare function isHighRiskPath(p: string): boolean;
9
9
  * parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
10
10
  * from the spec itself.
11
11
  */
12
- export declare function specApprovalAutonomy(spec: Spec, _resolveParent: (id: string) => Spec | null): ApprovalAutonomy;
12
+ /**
13
+ * @implements A-SPEC-587
14
+ * The A-SPECs whose chain leads up to `spec`: one hop for an H-SPEC (A-SPECs that depend on it),
15
+ * two for a REQ (through its H-SPECs). Anything else has no downstream A-SPECs. Pure; `depends_on`
16
+ * is the only edge this repository's chain uses.
17
+ */
18
+ export declare function descendantAspecs(spec: Spec, all: Spec[]): Spec[];
19
+ /**
20
+ * @implements A-SPEC-587
21
+ * The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future parent-aware
22
+ * rules. `descendants` — the A-SPECs beneath a REQ/H-SPEC (see `descendantAspecs`) — is what lets an
23
+ * upstream document be graded by the code it will admit: a REQ or H-SPEC carries no code of its
24
+ * own, so its risk IS the risk its A-SPECs declare (REQ-587, the owner's line: only irreversible,
25
+ * architectural or high-risk changes ask a human). Every downstream A-SPEC auto → auto; any hitl,
26
+ * or no A-SPEC at all (unknown scope) → human. The two-argument call keeps the old answer.
27
+ */
28
+ export declare function specApprovalAutonomy(spec: Spec, resolveParent: (id: string) => Spec | null, descendants?: Spec[]): ApprovalAutonomy;
13
29
  export declare const SESSION_AUTONOMY_MARKER: readonly [".ax", "state", "autonomy.json"];
14
30
  export declare function sessionAutonomyActive(root: string, now: string): boolean;
15
31
  /** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SESSION_AUTONOMY_MARKER = exports.AUTONOMY_ENV = void 0;
37
37
  exports.isHighRiskPath = isHighRiskPath;
38
+ exports.descendantAspecs = descendantAspecs;
38
39
  exports.specApprovalAutonomy = specApprovalAutonomy;
39
40
  exports.sessionAutonomyActive = sessionAutonomyActive;
40
41
  exports.projectDefaultAutonomy = projectDefaultAutonomy;
@@ -48,8 +49,9 @@ exports.releaseAutonomy = releaseAutonomy;
48
49
  // the MCP elicitation TUI. The governance chain (REQ→H→A→T→CPG + RTM + taint + phase gate) already
49
50
  // forces quality structurally, so a low/mid-risk spec self-approving under an explicitly-enabled
50
51
  // autonomous mode is a small, bounded relaxation (REQ-532). The bound is what this module owns: the
51
- // governance-critical specs — gate behaviour, architecture, taint/security boundaries, and every
52
- // upstream REQ/H-SPEC/C-SPEC never leave the human channel.
52
+ // governance-critical specs — gate behaviour, architecture (C-SPEC/ADR), taint/security boundaries
53
+ // never leave the human channel. An upstream REQ/H-SPEC is graded by the A-SPECs beneath it
54
+ // (REQ-587): all auto → auto; any hitl, or none yet → human.
53
55
  //
54
56
  // PURE: the spec, a parent resolver, and (separately) the env are the only inputs; the wiring layer
55
57
  // (A-SPEC-532.2) injects process.env and the elicitor.
@@ -126,15 +128,45 @@ const AUTO_GRADES = new Set(['none', 'persisted-artifact', 'derived-artifact', '
126
128
  * parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
127
129
  * from the spec itself.
128
130
  */
129
- function specApprovalAutonomy(spec, _resolveParent) {
131
+ /**
132
+ * @implements A-SPEC-587
133
+ * The A-SPECs whose chain leads up to `spec`: one hop for an H-SPEC (A-SPECs that depend on it),
134
+ * two for a REQ (through its H-SPECs). Anything else has no downstream A-SPECs. Pure; `depends_on`
135
+ * is the only edge this repository's chain uses.
136
+ */
137
+ function descendantAspecs(spec, all) {
138
+ const dependsOn = (s, id) => Array.isArray(s.dependsOn) && s.dependsOn.includes(id);
139
+ if (spec.type === 'H-SPEC')
140
+ return all.filter((s) => s.type === 'A-SPEC' && dependsOn(s, spec.id));
141
+ if (spec.type === 'REQ') {
142
+ const hs = all.filter((s) => s.type === 'H-SPEC' && dependsOn(s, spec.id));
143
+ return all.filter((s) => s.type === 'A-SPEC' && hs.some((h) => dependsOn(s, h.id)));
144
+ }
145
+ return [];
146
+ }
147
+ /**
148
+ * @implements A-SPEC-587
149
+ * The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future parent-aware
150
+ * rules. `descendants` — the A-SPECs beneath a REQ/H-SPEC (see `descendantAspecs`) — is what lets an
151
+ * upstream document be graded by the code it will admit: a REQ or H-SPEC carries no code of its
152
+ * own, so its risk IS the risk its A-SPECs declare (REQ-587, the owner's line: only irreversible,
153
+ * architectural or high-risk changes ask a human). Every downstream A-SPEC auto → auto; any hitl,
154
+ * or no A-SPEC at all (unknown scope) → human. The two-argument call keeps the old answer.
155
+ */
156
+ function specApprovalAutonomy(spec, resolveParent, descendants) {
130
157
  switch (spec.type) {
131
158
  case 'T-SPEC':
132
159
  return 'auto'; // tests are low risk
133
160
  case 'REQ':
134
- case 'H-SPEC':
161
+ case 'H-SPEC': {
162
+ const below = (descendants ?? []).filter((s) => s.type === 'A-SPEC');
163
+ if (below.length === 0)
164
+ return 'hitl'; // unknown scope stays human
165
+ return below.every((a) => specApprovalAutonomy(a, resolveParent) === 'auto') ? 'auto' : 'hitl';
166
+ }
135
167
  case 'C-SPEC':
136
168
  case 'ADR': // @implements A-SPEC-571.1 — a decision is the human's to seal
137
- return 'hitl'; // wide blast radius / structural constraint
169
+ return 'hitl'; // architecture / structural constraint — the owner's exception
138
170
  case 'A-SPEC': {
139
171
  const grade = breakingGrade(spec);
140
172
  if (grade === null || !AUTO_GRADES.has(grade))
@@ -183,6 +183,30 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
183
183
  * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
184
184
  * drift detection compares against — would silently go stale.
185
185
  */
186
+ spec_renumber(a: {
187
+ root?: string;
188
+ oldBase: string;
189
+ newBase: string;
190
+ dryRun?: boolean;
191
+ }): Promise<{
192
+ ok: boolean;
193
+ reason: string;
194
+ dryRun?: undefined;
195
+ plan?: undefined;
196
+ movedSpecs?: undefined;
197
+ } | {
198
+ ok: boolean;
199
+ dryRun: boolean;
200
+ plan: import("../spec/renumber").RenumberPlan;
201
+ reason?: undefined;
202
+ movedSpecs?: undefined;
203
+ } | {
204
+ ok: boolean;
205
+ dryRun: boolean;
206
+ movedSpecs: number;
207
+ plan: import("../spec/renumber").RenumberPlan;
208
+ reason?: undefined;
209
+ }>;
186
210
  spec_unseal(a: {
187
211
  root?: string;
188
212
  id: string;
@@ -579,15 +603,15 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
579
603
  head?: string;
580
604
  since?: string;
581
605
  }): Promise<{
582
- semanticWarm?: {
583
- tier: string;
584
- computed: number;
585
- cached: number;
586
- } | undefined;
587
606
  changed: number;
588
607
  nodes: number;
589
608
  edges: number;
590
609
  changeSource: ChangeSourceInfo;
610
+ semanticWarm?: {
611
+ tier: string;
612
+ computed: number;
613
+ cached: number;
614
+ };
591
615
  }>;
592
616
  context_bundle(a: {
593
617
  root: string;
@@ -779,6 +803,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
779
803
  }): Promise<{
780
804
  ok: boolean;
781
805
  approvedSpecs: string[];
806
+ alreadySealed: string[];
782
807
  refused: {
783
808
  id: string;
784
809
  reason: string;
@@ -38,6 +38,7 @@ exports.isHandlerRefusal = isHandlerRefusal;
38
38
  exports.unreadableAmong = unreadableAmong;
39
39
  exports.makeHandlers = makeHandlers;
40
40
  exports.collectDecisions = collectDecisions;
41
+ // @implements A-SPEC-599
41
42
  // @implements A-SPEC-293
42
43
  // @implements A-SPEC-292
43
44
  // @implements A-SPEC-290
@@ -255,6 +256,7 @@ const ignore_1 = require("../project/ignore");
255
256
  // @implements A-SPEC-126
256
257
  const scan_1 = require("../reverse/scan");
257
258
  const draft_1 = require("../reverse/draft");
259
+ const renumber_1 = require("../spec/renumber");
258
260
  const adr_refs_1 = require("../spec/adr-refs");
259
261
  const spec_id_guard_1 = require("./spec-id-guard");
260
262
  const anchor_1 = require("../reverse/anchor");
@@ -376,7 +378,7 @@ function specStoreBlindReason(store, root, loaded) {
376
378
  // Compared through realpath: on macOS a temp root is `/var/...` while the same directory resolves
377
379
  // to `/private/var/...`, and a plain string compare calls the correctly-bound store foreign.
378
380
  const real = (p) => { try {
379
- return fs.realpathSync(p);
381
+ return (0, root_2.canonicalPath)(p);
380
382
  }
381
383
  catch {
382
384
  return path.resolve(p);
@@ -779,10 +781,11 @@ function makeRawHandlers(store, opts) {
779
781
  // rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
780
782
  // self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
781
783
  // by construction — it exists only inside this call, persisted nowhere.
782
- const autonomousApproval = () => ({
784
+ const autonomousApproval = (derivedFrom) => ({
783
785
  actor: `autonomous:${opts?.clientName?.() ?? 'unknown'}`,
784
786
  token: crypto.randomUUID(),
785
- rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto)`,
787
+ // @implements A-SPEC-587 an upstream grade is DERIVED; the audit line names the A-SPECs it came from.
788
+ rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto${derivedFrom && derivedFrom.length > 0 ? ` — derived from ${derivedFrom.join(', ')}` : ''})`,
786
789
  });
787
790
  /**
788
791
  * Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
@@ -1241,6 +1244,38 @@ function makeRawHandlers(store, opts) {
1241
1244
  * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
1242
1245
  * drift detection compares against — would silently go stale.
1243
1246
  */
1247
+ // @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
1248
+ // tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
1249
+ // `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
1250
+ // @implements A-SPEC-255 — WIRING only. The judgment lives in `planRenumber`, which is pure and
1251
+ // tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
1252
+ // is the only sealer (a second sealer becomes a second truth), so the plan reports the two
1253
+ // ORDERS and the caller runs them. `dryRun` defaults to true — a renumber is read before it runs.
1254
+ async spec_renumber(a) {
1255
+ const specsRoot = store.specsRoot;
1256
+ if (typeof specsRoot !== 'string')
1257
+ return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
1258
+ const projectRoot = path.resolve(specsRoot, '..', '..');
1259
+ const plan = (0, renumber_1.planRenumber)({
1260
+ specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
1261
+ sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
1262
+ oldBase: String(a.oldBase), newBase: String(a.newBase),
1263
+ });
1264
+ if (plan.refusal)
1265
+ return { ok: false, reason: plan.refusal };
1266
+ if (a.dryRun !== false)
1267
+ return { ok: true, dryRun: true, plan };
1268
+ const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
1269
+ (0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
1270
+ new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
1271
+ ts: new Date().toISOString(),
1272
+ actor: 'spec_renumber',
1273
+ kind: 'spec-renumbered',
1274
+ summary: `renumbered base ${a.oldBase} -> ${a.newBase}: ${movedSpecs} spec(s), ${plan.anchors.length} anchored file(s), ${plan.proseCandidates.length} prose candidate(s) left for a human`,
1275
+ inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
1276
+ });
1277
+ return { ok: true, dryRun: false, movedSpecs, plan };
1278
+ },
1244
1279
  async spec_unseal(a) {
1245
1280
  const all = await store.list();
1246
1281
  if (all.filter((s) => s.id === a.id).length > 1) {
@@ -1526,9 +1561,14 @@ function makeRawHandlers(store, opts) {
1526
1561
  // @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
1527
1562
  // session envelope marker under this project's `.ax/state/` (which an agent cannot write).
1528
1563
  const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
1564
+ // @implements A-SPEC-587 — an upstream REQ/H-SPEC is graded by the A-SPECs beneath it, so
1565
+ // the store is consulted for its descendants (only then: the list is a cost the auto-grade
1566
+ // A-SPEC/T-SPEC path does not pay). Unknown scope (none yet) keeps the human answer.
1567
+ const upstream = target.spec.type === 'REQ' || target.spec.type === 'H-SPEC';
1568
+ const below = autonomyOn && upstream ? (0, autonomy_1.descendantAspecs)(target.spec, await store.list()) : undefined;
1529
1569
  if (autonomyOn
1530
- && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec])) === 'auto') {
1531
- approveResolved = { approval: autonomousApproval(), source: 'autonomous' };
1570
+ && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below) === 'auto') {
1571
+ approveResolved = { approval: autonomousApproval(below?.map((s) => s.id)), source: 'autonomous' };
1532
1572
  }
1533
1573
  else if (autonomyOn) {
1534
1574
  // @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
@@ -2765,7 +2805,7 @@ function makeRawHandlers(store, opts) {
2765
2805
  graphSchema: RTM_GRAPH_SCHEMA,
2766
2806
  extractorVersion: RTM_EXTRACTOR_VERSION,
2767
2807
  sourceCommit: head,
2768
- specFingerprint: `n:${specs.length}/${specs.map((s) => `${s.id}:${s.status}`).sort().join(',')}`,
2808
+ specFingerprint: (0, graph_store_1.specFingerprint)(specs),
2769
2809
  scanDigest: (0, graph_store_1.scanDigest)(scanned),
2770
2810
  });
2771
2811
  const g = opened.graph;
@@ -2863,8 +2903,26 @@ function makeRawHandlers(store, opts) {
2863
2903
  deleted: changes.deleted,
2864
2904
  renamed: changes.renamed,
2865
2905
  };
2866
- const g = new rtm_graph_1.RtmGraph();
2906
+ let head = '';
2867
2907
  try {
2908
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2909
+ }
2910
+ catch { /* non-git */ }
2911
+ const graphPath = path.join(root, '.ax', 'rtm.sqlite');
2912
+ const tempGraphPath = `${graphPath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
2913
+ const opened = (0, graph_store_1.openReusableGraph)(tempGraphPath, {
2914
+ graphSchema: RTM_GRAPH_SCHEMA,
2915
+ extractorVersion: RTM_EXTRACTOR_VERSION,
2916
+ sourceCommit: head,
2917
+ specFingerprint: (0, graph_store_1.specFingerprint)(specs),
2918
+ scanDigest: (0, graph_store_1.scanDigest)(scanned),
2919
+ });
2920
+ const g = opened.graph;
2921
+ let result;
2922
+ try {
2923
+ // Explicit reindex is a persistence operation: discard the previous contents, rebuild the
2924
+ // complete graph, then commit the matching basis before releasing the file (A-SPEC-589).
2925
+ g.clear();
2868
2926
  (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2869
2927
  // Scaffold note: `g` was just full-built at HEAD, so applying the
2870
2928
  // diff on top of it is currently an idempotent no-op in practice
@@ -2877,6 +2935,7 @@ function makeRawHandlers(store, opts) {
2877
2935
  // graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
2878
2936
  // returned a graph with no call edges at all.
2879
2937
  (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
2938
+ opened.commitBasis();
2880
2939
  // @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
2881
2940
  // operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
2882
2941
  // cache; a tier of none (the shipped default) computes nothing.
@@ -2891,12 +2950,39 @@ function makeRawHandlers(store, opts) {
2891
2950
  }
2892
2951
  }
2893
2952
  catch { /* warming is best-effort; reindex's own result is unaffected */ }
2894
- return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
2953
+ result = { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
2895
2954
  ...(semanticWarm !== undefined ? { semanticWarm } : {}) };
2896
2955
  }
2897
2956
  finally {
2898
2957
  g.close(); // release native SQLite handle even if build/apply throws
2899
2958
  }
2959
+ // Publish only after the complete temporary graph is closed. Rename the old file aside so a
2960
+ // Windows reader either keeps the old complete graph or sees the new complete graph; it never
2961
+ // observes a partially built database or a basis that belongs to another file (A-SPEC-589).
2962
+ const backupPath = `${graphPath}.bak-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
2963
+ const hadOld = fs.existsSync(graphPath);
2964
+ try {
2965
+ if (hadOld)
2966
+ fs.renameSync(graphPath, backupPath);
2967
+ fs.renameSync(tempGraphPath, graphPath);
2968
+ if (hadOld)
2969
+ fs.rmSync(backupPath, { force: true });
2970
+ }
2971
+ catch (error) {
2972
+ try {
2973
+ if (!fs.existsSync(graphPath) && fs.existsSync(backupPath))
2974
+ fs.renameSync(backupPath, graphPath);
2975
+ }
2976
+ catch { /* preserve the publication error; the old path is reported as unavailable */ }
2977
+ throw error;
2978
+ }
2979
+ finally {
2980
+ try {
2981
+ fs.rmSync(tempGraphPath, { force: true });
2982
+ }
2983
+ catch { /* already renamed or retained for diagnosis */ }
2984
+ }
2985
+ return result;
2900
2986
  },
2901
2987
  async context_bundle(a) {
2902
2988
  assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
@@ -3779,14 +3865,26 @@ depends_on:
3779
3865
  }
3780
3866
  const rawHandlers = makeRawHandlers(rawStore);
3781
3867
  const approvedSpecs = [];
3868
+ const alreadySealed = [];
3782
3869
  const refused = [];
3783
3870
  if (idsToApprove.length === 0) {
3784
3871
  // Reporting this as a success is how a caller ends up believing an unapproved slice was
3785
3872
  // approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
3786
3873
  // here, and the tool answered `ok: true, approvedSpecs: []`.
3787
- return { ok: false, approvedSpecs, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
3788
- }
3874
+ return { ok: false, approvedSpecs, alreadySealed, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
3875
+ }
3876
+ // @implements A-SPEC-587.2 — a sealed spec is SKIPPED, not re-sealed: re-approving asked the
3877
+ // human the same question again (same request id back in the queue) and stopped the chain
3878
+ // there, so the H-SPEC was never even attempted (measured 2026-09-10, three slices).
3879
+ const sealed = (id) => {
3880
+ const s = specs.find((x) => x.id === id);
3881
+ return s?.status === 'approved' && typeof s.frontmatter?.approved_digest === 'string';
3882
+ };
3789
3883
  for (const id of idsToApprove) {
3884
+ if (sealed(id)) {
3885
+ alreadySealed.push(id);
3886
+ continue;
3887
+ }
3790
3888
  const res = (await rawHandlers.spec_approve({ root, id }));
3791
3889
  if (res.approved || res.ok) {
3792
3890
  approvedSpecs.push(id);
@@ -3800,11 +3898,11 @@ depends_on:
3800
3898
  // again or, worse, succeed against an unsealed parent. Stop and say what is left.
3801
3899
  break;
3802
3900
  }
3803
- for (const id of idsToApprove.slice(approvedSpecs.length + refused.length)) {
3901
+ for (const id of idsToApprove.slice(approvedSpecs.length + alreadySealed.length + refused.length)) {
3804
3902
  refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
3805
3903
  }
3806
- // `ok` means the slice is approved. Anything less is not a success.
3807
- return { ok: refused.length === 0 && approvedSpecs.length > 0, approvedSpecs, refused };
3904
+ // `ok` means the slice is approved — freshly or already. Anything less is not a success.
3905
+ return { ok: refused.length === 0 && approvedSpecs.length + alreadySealed.length > 0, approvedSpecs, alreadySealed, refused };
3808
3906
  },
3809
3907
  async spec_remediate(a) {
3810
3908
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
@@ -7,7 +7,7 @@
7
7
  * 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
8
8
  */
9
9
  /** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
10
- export declare function specIdBase(id: string): number | null;
10
+ export { specIdBase } from '../spec/spec-id';
11
11
  export type IdVerdict = {
12
12
  ok: true;
13
13
  } | {
@@ -9,19 +9,15 @@
9
9
  * 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.specIdBase = specIdBase;
12
+ exports.specIdBase = void 0;
13
13
  exports.sequentialIdVerdict = sequentialIdVerdict;
14
14
  /** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
15
- function specIdBase(id) {
16
- if (typeof id !== 'string')
17
- return null;
18
- // 마지막 하이픈 뒤의 숫자 토막(점 앞)을 base 로 본다.
19
- const m = /-(\d+)(?:\.\d+)?\s*$/.exec(id.trim());
20
- if (!m)
21
- return null;
22
- const n = parseInt(m[1], 10);
23
- return Number.isNaN(n) ? null : n;
24
- }
15
+ // @implements A-SPEC-255 — the parser moved DOWN to the spec layer so `spec/renumber.ts` can share it
16
+ // without importing mcp (C-SPEC-224 forbids that direction). Re-exported so existing callers and the
17
+ // A-SPEC-252 tests keep their import path.
18
+ var spec_id_1 = require("../spec/spec-id");
19
+ Object.defineProperty(exports, "specIdBase", { enumerable: true, get: function () { return spec_id_1.specIdBase; } });
20
+ const spec_id_2 = require("../spec/spec-id");
25
21
  /**
26
22
  * 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
27
23
  *
@@ -37,13 +33,13 @@ function idSpaceOf(id) {
37
33
  return /^ADR-/.test(id.trim()) ? 'ADR' : 'functional';
38
34
  }
39
35
  function sequentialIdVerdict(newId, existingIds) {
40
- const base = specIdBase(newId);
36
+ const base = (0, spec_id_2.specIdBase)(newId);
41
37
  if (base === null)
42
38
  return { ok: true };
43
39
  // @implements A-SPEC-571.1 — compare only within the same number space.
44
40
  const space = idSpaceOf(newId);
45
41
  const bases = (existingIds ?? []).filter((id) => idSpaceOf(id) === space)
46
- .map(specIdBase).filter((n) => n !== null);
42
+ .map(spec_id_2.specIdBase).filter((n) => n !== null);
47
43
  if (bases.length === 0)
48
44
  return { ok: true };
49
45
  const maxBase = Math.max(...bases);
@@ -101,6 +101,19 @@ exports.TOOL_SCHEMAS = {
101
101
  required: ['id'],
102
102
  },
103
103
  },
104
+ spec_renumber: {
105
+ description: "Move a whole spec FAMILY (REQ/H-SPEC/A-SPEC/T-SPEC sharing one base, dot-suffixed members included) from one number to another, and report what a machine must not touch. The tool rewrites only places with a definite grammar: filenames, frontmatter `id`, every `depends_on` in the store, `slice` tags, and source `@implements` anchors. Prose — comments, test titles, CHANGELOG entries, `source.ref` citations — is REPORTED as candidates with file and line, never substituted: measured 2026-08-23, a bulk regex doing exactly that turned REQ-253's own source citation into a reference to itself, while a hand renumber left seven prose sites behind including a comment in the shipped install.ps1. Re-sealing is NOT performed here; `spec_approve` remains the only sealer. Instead the plan returns two orders, which are opposites because the two acts refuse in opposite directions: `unsealOrder` is child-first (spec_unseal refuses while an approved child depends on the target) and `approveOrder` is parent-first (spec_approve refuses while a parent is unsealed). A spec whose `depends_on` is empty appears in neither, because specDigest does not hash `id` and its seal survives the move. Refuses outright when the destination base is already claimed or the source base does not exist, emptying every list so a partial plan cannot become a partial move. `dryRun` defaults to TRUE.",
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ oldBase: str('The base number to move FROM, digits only (e.g. "596").'),
110
+ newBase: str('The base number to move TO, digits only (e.g. "599"). Must be unclaimed.'),
111
+ dryRun: { type: 'boolean', description: 'Default true — return the plan without touching anything. Pass false to apply.' },
112
+ root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
113
+ },
114
+ required: ['oldBase', 'newBase'],
115
+ },
116
+ },
104
117
  spec_unseal: {
105
118
  description: "The inverse of spec_approve: return ONE sealed (approved) spec to an editable `status: draft`, clearing `approved_digest` and `parent_digests` in a single ACT, and record `spec-unsealed` in the provenance ledger. Un-sealing WITHDRAWS a seal, so it requires the same out-of-band HOLMES_APPROVAL as spec_approve/spec_retire's sealed path (fail-closed): un-sealing an approved T-SPEC removes the code gate's demand, which unguarded would be an approval bypass. Refuses when an APPROVED spec depends on the target — that dependent's parent_digests would silently go stale — naming the blockers. Idempotent: un-sealing a spec that is already draft (never sealed) writes nothing and returns unsealed:false. Written only at the version this act read; a concurrent edit wins and the un-seal is refused for retry. Distinct from spec_retire (which withdraws authority to status:outdated); un-seal keeps the spec alive and editable.",
106
119
  inputSchema: {