@holmes-lab/holmes-kit 0.19.3 → 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 +137 -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 +19 -0
  9. package/dist/holmes/cli/doctor.js +107 -42
  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
@@ -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: {
@@ -12,8 +12,22 @@
12
12
  * This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
13
13
  * verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
14
14
  */
15
- /** Dependencies whose install script must actually RUN for the module to load. */
16
- export declare const NATIVE_INSTALL_SCRIPT_DEPS: readonly ["better-sqlite3"];
15
+ /**
16
+ * Dependencies whose install script must actually RUN for the module to load.
17
+ *
18
+ * A-SPEC-595, measured 2026-09-10: `sharp@0.32.6` belongs here on EVERY platform, not just the
19
+ * Windows machine that first hit it. `npm pack sharp@0.32.6` yields a tarball with zero entries
20
+ * matching `.node`, `prebuilds/` or `vendor/`; its declared hook is
21
+ * `(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile
22
+ * && node-gyp rebuild && node install/dll-copy)`; the lockfile records `hasInstallScript: true`;
23
+ * and this repository's macOS tree holds `build/Release/sharp-darwin-arm64v8.node` and
24
+ * `vendor/8.14.5` only because that script ran. (sharp 0.33+ moved to platform packages that DO
25
+ * ship prebuilds — a version bump makes this entry a re-measurement, not a given.)
26
+ *
27
+ * This list is what doctor reads, and `allowScripts` is a root-manifest policy that does not
28
+ * transfer to consumers — so a name missing here is a name no consumer is ever told to approve.
29
+ */
30
+ export declare const NATIVE_INSTALL_SCRIPT_DEPS: readonly ["better-sqlite3", "sharp"];
17
31
  /** Native dependencies that ship prebuilds and load with the script blocked (measured). */
18
32
  export declare const PREBUILT_NATIVE_DEPS: readonly ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-c-sharp", "tree-sitter-java", "tree-sitter-go", "tree-sitter-rust", "tree-sitter-cpp"];
19
33
  export type AllowScripts = Record<string, boolean>;
@@ -20,8 +20,22 @@ exports.policyVerdict = policyVerdict;
20
20
  * This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
21
21
  * verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
22
22
  */
23
- /** Dependencies whose install script must actually RUN for the module to load. */
24
- exports.NATIVE_INSTALL_SCRIPT_DEPS = ['better-sqlite3'];
23
+ /**
24
+ * Dependencies whose install script must actually RUN for the module to load.
25
+ *
26
+ * A-SPEC-595, measured 2026-09-10: `sharp@0.32.6` belongs here on EVERY platform, not just the
27
+ * Windows machine that first hit it. `npm pack sharp@0.32.6` yields a tarball with zero entries
28
+ * matching `.node`, `prebuilds/` or `vendor/`; its declared hook is
29
+ * `(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile
30
+ * && node-gyp rebuild && node install/dll-copy)`; the lockfile records `hasInstallScript: true`;
31
+ * and this repository's macOS tree holds `build/Release/sharp-darwin-arm64v8.node` and
32
+ * `vendor/8.14.5` only because that script ran. (sharp 0.33+ moved to platform packages that DO
33
+ * ship prebuilds — a version bump makes this entry a re-measurement, not a given.)
34
+ *
35
+ * This list is what doctor reads, and `allowScripts` is a root-manifest policy that does not
36
+ * transfer to consumers — so a name missing here is a name no consumer is ever told to approve.
37
+ */
38
+ exports.NATIVE_INSTALL_SCRIPT_DEPS = ['better-sqlite3', 'sharp'];
25
39
  /** Native dependencies that ship prebuilds and load with the script blocked (measured). */
26
40
  exports.PREBUILT_NATIVE_DEPS = [
27
41
  'tree-sitter', 'tree-sitter-typescript', 'tree-sitter-python', 'tree-sitter-c-sharp',
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.materializeTreeAt = materializeTreeAt;
37
37
  exports.verifyTreeFidelity = verifyTreeFidelity;
38
+ // @implements A-SPEC-599
38
39
  // @implements A-SPEC-297
39
40
  const fs = __importStar(require("node:fs"));
40
41
  const os = __importStar(require("node:os"));
@@ -56,12 +57,14 @@ function git(repoRoot, args) {
56
57
  }
57
58
  /** `path mode sha` triples the commit records for the pathspec, keyed by repo-relative path. */
58
59
  function blobsAt(repoRoot, commitish, pathspec) {
59
- const out = git(repoRoot, ['ls-tree', '-r', `${commitish}^{tree}`, '--', pathspec]);
60
+ const out = git(repoRoot, ['ls-tree', '-rz', `${commitish}^{tree}`, '--', pathspec]);
60
61
  const blobs = new Map();
61
- for (const line of out.split('\n')) {
62
+ for (const line of out.split('\0')) {
62
63
  if (line.trim() === '')
63
64
  continue;
64
- const [meta, file] = line.split('\t');
65
+ const tab = line.indexOf('\t');
66
+ const meta = line.slice(0, tab);
67
+ const file = line.slice(tab + 1);
65
68
  const parts = meta.split(/\s+/);
66
69
  if (parts[1] !== 'blob')
67
70
  continue; // submodules and trees are not files we can compare
@@ -119,6 +122,43 @@ function materializeTreeAt(repoRoot, commitish, pathspec, destDir, opts) {
119
122
  const tar = (0, node_child_process_1.execFileSync)('git', ['-C', repoRoot, 'archive', '--format=tar', commitish, '--', pathspec], { maxBuffer: 512 * 1024 * 1024 });
120
123
  fs.writeFileSync(tarPath, tar);
121
124
  (0, node_child_process_1.execFileSync)('tar', ['-x', '-C', destDir, '-f', tarPath], { maxBuffer: 512 * 1024 * 1024 });
125
+ // Archive preserves modes/layout, but applies EOL/export substitutions. Read raw objects in
126
+ // one batch so fidelity is independent of user config and committed attributes alike.
127
+ const refsPath = path.join(path.dirname(tarPath), 'objects.txt');
128
+ fs.writeFileSync(refsPath, [...blobs.values()].join('\n') + '\n');
129
+ const refs = fs.openSync(refsPath, 'r');
130
+ let raw;
131
+ try {
132
+ raw = (0, node_child_process_1.execFileSync)('git', ['-C', repoRoot, 'cat-file', '--batch'], {
133
+ stdio: [refs, 'pipe', 'pipe'], maxBuffer: 512 * 1024 * 1024,
134
+ });
135
+ }
136
+ finally {
137
+ fs.closeSync(refs);
138
+ }
139
+ let offset = 0;
140
+ for (const [file, sha] of blobs) {
141
+ const end = raw.indexOf(10, offset);
142
+ const header = raw.subarray(offset, end).toString('ascii').split(' ');
143
+ const size = Number(header[2]);
144
+ if (end < offset || header[0] !== sha || header[1] !== 'blob' || !Number.isSafeInteger(size)
145
+ || size < 0 || end + 1 + size >= raw.length)
146
+ throw new Error('invalid cat-file response');
147
+ const bytes = raw.subarray(end + 1, end + 1 + size);
148
+ offset = end + 1 + size + 1;
149
+ const target = path.join(destDir, file);
150
+ // Keep archive's symlink representation; never follow it to overwrite a target.
151
+ try {
152
+ if (fs.lstatSync(target).isSymbolicLink())
153
+ continue;
154
+ }
155
+ catch (error) {
156
+ if (error.code !== 'ENOENT')
157
+ throw error;
158
+ }
159
+ fs.mkdirSync(path.dirname(target), { recursive: true });
160
+ fs.writeFileSync(target, bytes);
161
+ }
122
162
  }
123
163
  catch (err) {
124
164
  return { ok: false, reason: `archive-or-extract-failed: could not materialize ${commitish}: ${err.message}` };
@@ -27,6 +27,8 @@ export interface GraphBasis {
27
27
  */
28
28
  scanDigest: string;
29
29
  }
30
+ /** Deterministic content fingerprint for every spec field consumed by buildRtm. */
31
+ export declare function specFingerprint(specs: readonly unknown[]): string;
30
32
  /**
31
33
  * Content address of a scan: every file's path and its symbols/edges, sorted so the digest depends
32
34
  * on the scan's CONTENT and not on the order the scanner happened to walk the tree in.