@holmes-lab/holmes-kit 0.19.6 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +8 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  13. package/dist/holmes/cli/gitignore-merge.js +6 -2
  14. package/dist/holmes/cli/index.js +176 -139
  15. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  16. package/dist/holmes/cli/playbook-skills.js +10 -54
  17. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  18. package/dist/holmes/governance/approval-grants.js +55 -1
  19. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  20. package/dist/holmes/governance/approval-queue.js +86 -12
  21. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  22. package/dist/holmes/governance/display-id-aliases.js +86 -0
  23. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  24. package/dist/holmes/governance/ledger-store.js +49 -3
  25. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  26. package/dist/holmes/governance/ledger-timeline.js +11 -1
  27. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  28. package/dist/holmes/governance/provenance-chain.js +13 -2
  29. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  30. package/dist/holmes/governance/provenance-ledger.js +69 -16
  31. package/dist/holmes/governance/session-context.d.ts +3 -0
  32. package/dist/holmes/governance/session-context.js +26 -8
  33. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  34. package/dist/holmes/guardrail/write-target.js +2 -1
  35. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  36. package/dist/holmes/hooks/stop.js +14 -3
  37. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  38. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  39. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  40. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  41. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  42. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  43. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  44. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  45. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  46. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  47. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  48. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  49. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  50. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  51. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  52. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  53. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  54. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  55. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  56. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  57. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  58. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  59. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  60. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  61. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
  62. package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
  63. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  64. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  65. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  66. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  67. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  68. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  69. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  70. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  71. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  72. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  73. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  74. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  75. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  76. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  77. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  78. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  79. package/dist/holmes/mcp/handlers.d.ts +653 -424
  80. package/dist/holmes/mcp/handlers.js +267 -3250
  81. package/dist/holmes/mcp/server.js +2 -1
  82. package/dist/holmes/mcp/tool-schemas.js +43 -2
  83. package/dist/holmes/project/execution-context.d.ts +17 -0
  84. package/dist/holmes/project/execution-context.js +120 -0
  85. package/dist/holmes/project/installer-markers.d.ts +34 -0
  86. package/dist/holmes/project/installer-markers.js +65 -0
  87. package/dist/holmes/project/root.d.ts +3 -1
  88. package/dist/holmes/project/root.js +4 -3
  89. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  90. package/dist/holmes/project/workspace-identity.js +181 -0
  91. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  92. package/dist/holmes/review/test-outcomes.js +23 -5
  93. package/dist/holmes/review/test-runner.d.ts +18 -0
  94. package/dist/holmes/review/test-runner.js +132 -4
  95. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  96. package/dist/holmes/rtm/graph-store.js +244 -3
  97. package/dist/holmes/rtm/incremental.d.ts +1 -0
  98. package/dist/holmes/rtm/incremental.js +12 -3
  99. package/dist/holmes/rtm/localize.js +7 -0
  100. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  101. package/dist/holmes/rtm/rtm-graph.js +13 -0
  102. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  103. package/dist/holmes/semantic/vector-cache.js +155 -19
  104. package/dist/holmes/spec/approval-status.d.ts +10 -0
  105. package/dist/holmes/spec/approval-status.js +7 -3
  106. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  107. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  108. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  109. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  110. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  111. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  112. package/dist/holmes/spec/entity-integration.d.ts +207 -0
  113. package/dist/holmes/spec/entity-integration.js +747 -0
  114. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  115. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  116. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  117. package/dist/holmes/spec/entity-renumber.js +156 -0
  118. package/dist/holmes/spec/entity-store.d.ts +135 -0
  119. package/dist/holmes/spec/entity-store.js +1051 -0
  120. package/dist/holmes/spec/entity-transaction.d.ts +89 -0
  121. package/dist/holmes/spec/entity-transaction.js +701 -0
  122. package/dist/holmes/spec/renumber.d.ts +58 -0
  123. package/dist/holmes/spec/renumber.js +200 -1
  124. package/dist/holmes/spec/spec-store.d.ts +3 -2
  125. package/dist/holmes/spec/spec-store.js +23 -2
  126. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  127. package/dist/holmes/spec/transition-policy.js +135 -0
  128. package/package.json +1 -1
@@ -0,0 +1,105 @@
1
+ import { SpecStore } from '../../spec/spec-store';
2
+ import { Spec } from '../../spec/spec-parser';
3
+ import { ScannedFile } from '../../cpg/cpg-scanner';
4
+ import { Approval } from '../../guardrail/risk-gate';
5
+ import { ElicitOutcome } from '../elicit-approval';
6
+ export interface SpecApprovalContext {
7
+ store: SpecStore;
8
+ resolver(specs: Spec[]): (id: string) => Spec | null;
9
+ projectRootOf(root: string): string;
10
+ cachedScan(root: string, repoRoot?: string): ScannedFile[];
11
+ tryElicit(kind: string, target: string, summary: string): Promise<ElicitOutcome>;
12
+ elicitApproval(reason?: string): Approval;
13
+ autonomousApproval(derivedFrom?: string[]): Approval;
14
+ resolveHandlerApproval(root: string | undefined, approval: Approval | undefined, action: {
15
+ kind: string;
16
+ target: string;
17
+ subject?: string;
18
+ }, now: string): {
19
+ approval: Approval;
20
+ source: 'env' | 'grant';
21
+ root?: string;
22
+ } | undefined;
23
+ refusalQueueHint(root: string | undefined, request: {
24
+ kind: string;
25
+ target: string;
26
+ why: string;
27
+ subject?: string;
28
+ risk?: string;
29
+ }): string;
30
+ foreignRootReason(root?: string): string | null;
31
+ }
32
+ export declare function createSpecApprovalHandlers(context: SpecApprovalContext): {
33
+ /**
34
+ * @implements A-SPEC-132
35
+ * Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
36
+ * reversal of "no approval tool exists" (which promote-slice documented while it was true) —
37
+ * the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
38
+ * the SERVER-environment approval: nothing in the request payload can substitute, because the
39
+ * agent authors the payload and the operator authors the environment.
40
+ */
41
+ spec_approve(a: {
42
+ root?: string;
43
+ id: string;
44
+ }): Promise<{
45
+ ok: false;
46
+ reason: string;
47
+ findings: import("../../spec/validator").Finding[];
48
+ candidate?: undefined;
49
+ digest?: undefined;
50
+ parentDigests?: undefined;
51
+ } | {
52
+ ok: boolean;
53
+ reason: string;
54
+ conflict?: undefined;
55
+ } | {
56
+ ok: boolean;
57
+ reason: string;
58
+ conflict: import("../../spec/version-conflict").ConflictDetail;
59
+ } | {
60
+ impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
61
+ impactGraph?: {
62
+ status: "current" | "stale" | "unverified";
63
+ sourceCommit: string | null;
64
+ head: string | null;
65
+ graphAsOf?: string;
66
+ } | undefined;
67
+ anchorDensity?: import("../../rtm/anchor-density").AnchorDensityFinding[] | undefined;
68
+ impactAdvisory?: import("../../rtm/impact-advisory").ImpactAdvisory | undefined;
69
+ approved: string;
70
+ digest: string;
71
+ ok?: undefined;
72
+ reason?: undefined;
73
+ conflict?: undefined;
74
+ }>;
75
+ /**
76
+ * Pin a REQ's citations: compute the content digest of every cited source that resolves inside
77
+ * the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
78
+ * always derived from what is actually on disk.
79
+ *
80
+ * Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
81
+ * re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
82
+ * detector into a concealer.
83
+ */
84
+ citation_pin(a: {
85
+ root: string;
86
+ id: string;
87
+ dryRun?: boolean;
88
+ }): Promise<{
89
+ ok: boolean;
90
+ reason: string;
91
+ id?: undefined;
92
+ dryRun?: undefined;
93
+ pinned?: undefined;
94
+ findings?: undefined;
95
+ citations?: undefined;
96
+ } | {
97
+ ok: boolean;
98
+ id: string;
99
+ dryRun: boolean;
100
+ pinned: string[];
101
+ findings: import("../../spec/validator").Finding[];
102
+ citations: unknown[];
103
+ reason?: undefined;
104
+ }>;
105
+ };
@@ -0,0 +1,504 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createSpecApprovalHandlers = createSpecApprovalHandlers;
37
+ // @implements A-SPEC-627.1, A-SPEC-622, A-SPEC-100.2, A-SPEC-128, A-SPEC-132, A-SPEC-133, A-SPEC-151, A-SPEC-188, A-SPEC-189, A-SPEC-244, A-SPEC-245
38
+ // @implements A-SPEC-631
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const spec_store_1 = require("../../spec/spec-store");
42
+ const transition_policy_1 = require("../../spec/transition-policy");
43
+ const validator_1 = require("../../spec/validator");
44
+ const elicit_approval_1 = require("../elicit-approval");
45
+ const autonomy_1 = require("../../governance/autonomy");
46
+ const write_target_1 = require("../../guardrail/write-target");
47
+ const approval_grants_1 = require("../../governance/approval-grants");
48
+ const spec_digest_1 = require("../../spec/spec-digest");
49
+ const version_conflict_1 = require("../../spec/version-conflict");
50
+ const ledger_store_1 = require("../../governance/ledger-store");
51
+ const provenance_chain_1 = require("../../governance/provenance-chain");
52
+ const review_targets_1 = require("../../review/review-targets");
53
+ function createSpecApprovalHandlers(context) {
54
+ const { store, resolver, projectRootOf, cachedScan, tryElicit, elicitApproval, autonomousApproval } = context;
55
+ return {
56
+ /**
57
+ * @implements A-SPEC-132
58
+ * Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
59
+ * reversal of "no approval tool exists" (which promote-slice documented while it was true) —
60
+ * the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
61
+ * the SERVER-environment approval: nothing in the request payload can substitute, because the
62
+ * agent authors the payload and the operator authors the environment.
63
+ */
64
+ async spec_approve(a) {
65
+ const approvalRaw = process.env.HOLMES_APPROVAL;
66
+ let approval;
67
+ try {
68
+ approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
69
+ }
70
+ catch {
71
+ approval = undefined;
72
+ }
73
+ // covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
74
+ // expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
75
+ // the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
76
+ // @implements A-SPEC-649 — coverage is resolved by kind/target here; the SUBJECT binding of a
77
+ // grant is checked against the ONE versioned read the seal uses (below), never against an
78
+ // extra read — an extra read moved the optimistic-write window and let an external edit be
79
+ // sealed (measured by the A-SPEC-188 TOCTOU pin).
80
+ const action = { kind: 'spec-approve', target: a.id };
81
+ let approveResolved = context.resolveHandlerApproval(a.root, approval, action, new Date().toISOString());
82
+ // @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
83
+ // door never summons a human), and only about a spec that exists (a question about a missing
84
+ // id helps no one — the standard refusal handles it).
85
+ let elicitExpiredMs;
86
+ let risk;
87
+ let subject;
88
+ if (approveResolved === undefined) {
89
+ const target = await store.read(a.id).catch(() => null);
90
+ // @implements A-SPEC-649 — the digest of what the operator will read rides the request.
91
+ subject = target ? (0, spec_digest_1.specDigest)(target.spec) : undefined;
92
+ if (target) {
93
+ // @implements A-SPEC-532.2 — the autonomous gate sits BEFORE the human ask: when the
94
+ // out-of-band autonomy switch is on AND the spec is low/mid-risk (never gate-behavior, an
95
+ // architecture/taint file, or an upstream REQ/H/C — those stay human), the agent seals it
96
+ // itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
97
+ // A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
98
+ // unchanged — the autonomous-OFF path is byte-identical to before.
99
+ // @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
100
+ // session envelope marker under this project's `.ax/state/` (which an agent cannot write).
101
+ const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
102
+ // @implements A-SPEC-587 — an upstream REQ/H-SPEC is graded by the A-SPECs beneath it, so
103
+ // the store is consulted for its descendants (only then: the list is a cost the auto-grade
104
+ // A-SPEC/T-SPEC path does not pay). Unknown scope (none yet) keeps the human answer.
105
+ const upstream = target.spec.type === 'REQ' || target.spec.type === 'H-SPEC';
106
+ const below = upstream ? (0, autonomy_1.descendantAspecs)(target.spec, await store.list()) : undefined;
107
+ // @implements A-SPEC-649 — the grade is recorded on the request either way (the screen shows it).
108
+ risk = (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below);
109
+ if (autonomyOn
110
+ && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below) === 'auto') {
111
+ approveResolved = { approval: autonomousApproval(below?.map((s) => s.id)), source: 'autonomous' };
112
+ }
113
+ else if (autonomyOn) {
114
+ // @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
115
+ // dialog is auto-acceptable by an auto-mode client (the protocol cannot tell a human
116
+ // from an auto-accept), so a governance-critical spec is never offered it. Do nothing
117
+ // here — approveResolved stays undefined and the fail-closed refuse+enqueue path below
118
+ // routes the act to the out-of-band human queue (holmes-kit approve). The A-SPEC-532.1
119
+ // bound ("governance-critical specs never leave the human channel") thus becomes
120
+ // ENFORCED, not aspirational. Autonomy OFF (the else) and the auto-grade branch above
121
+ // stay byte-identical to before.
122
+ }
123
+ else {
124
+ const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
125
+ // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
126
+ // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
127
+ // more consequential act) as a first approval. The cap cuts the title, never the marker.
128
+ const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
129
+ if (out.kind === 'answered' && out.decision.granted) {
130
+ approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
131
+ }
132
+ else if (out.kind === 'answered') {
133
+ // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
134
+ // entry is filed — a decided request is not a pending one (REQ-246 visibility).
135
+ return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
136
+ }
137
+ else if (out.kind === 'expired') {
138
+ // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
139
+ // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
140
+ // message learned to say what happened.
141
+ elicitExpiredMs = out.waitedMs;
142
+ }
143
+ // silent: the channel gave no answer — fall through to the byte-identical refusal.
144
+ }
145
+ }
146
+ }
147
+ if (approveResolved === undefined) {
148
+ // @implements A-SPEC-649 — a grant that exists but is refused by its binding is NAMED, so the
149
+ // operator learns to revoke or re-decide instead of wondering why nothing opened.
150
+ let refusedGrants = '';
151
+ try {
152
+ const projectRoot = context.projectRootOf(a.root ?? store.specsRoot);
153
+ refusedGrants = (0, approval_grants_1.refusedGrantReasons)(projectRoot, { ...action, ...(subject ? { subject } : {}) }, new Date().toISOString()).map((g) => `\n[grant ${g.nonce} refused: ${g.reason}]`).join('');
154
+ }
155
+ catch {
156
+ refusedGrants = '';
157
+ }
158
+ return { ok: false, reason: (elicitExpiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(elicitExpiredMs) : '') + refusedGrants
159
+ + 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
160
+ + context.refusalQueueHint(a.root, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인', ...(subject ? { subject } : {}), ...(risk ? { risk } : {}) }) };
161
+ }
162
+ // @implements A-SPEC-188 — destination BEFORE seal.
163
+ // The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
164
+ // bad root left a standing seal with no ledger entry anywhere while the CALLER WAS TOLD IT
165
+ // FAILED, and a foreign root filed the only audit record in another project's ledger. This
166
+ // repository lived the first one — REQ-182's chain was approved rootless on 2026-08-13 and
167
+ // the missing entries were found two review rounds later.
168
+ //
169
+ // The destination is DERIVED from the store the server was bound to (same principle as
170
+ // spec_create's A-SPEC-163 guard); a supplied `root` is a confirmation that must match. Any
171
+ // failure here is a refusal with nothing written.
172
+ let ledgerRoot;
173
+ try {
174
+ if (store instanceof spec_store_1.LocalMarkdownRepository) {
175
+ // Compared at PROJECT level, through the same walk-up the old code used: a root pointing
176
+ // anywhere INSIDE this project (the specs dir, a subdirectory) resolves to the same
177
+ // project and is accepted — round-2 review caught the first cut refusing those with a
178
+ // message that printed the identical path on both sides. Only a root resolving to a
179
+ // DIFFERENT project is refused, and the message names both projects.
180
+ //
181
+ // Round-3 caught the fallback: resolveProjectRoot returns its INPUT when no .ax ancestor
182
+ // exists, so a custom HOLMES_SPECS outside any .ax tree "derived" the specs dir itself as
183
+ // the project — a rootless approval then minted .ax/ledger INSIDE the spec store, and the
184
+ // correct explicit root was refused as a "different project". Derivation only counts when
185
+ // the walk actually found a marker; otherwise the old contract stands: root is required
186
+ // and names the project.
187
+ const derived = projectRootOf(store.specsRoot);
188
+ const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
189
+ if (derivationFoundMarker) {
190
+ ledgerRoot = derived;
191
+ if (typeof a.root === 'string' && a.root !== '') {
192
+ const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
193
+ const boundProject = (0, write_target_1.resolveTarget)(ledgerRoot, '.');
194
+ if (askedProject !== boundProject) {
195
+ return {
196
+ ok: false,
197
+ reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${askedProject} 프로젝트를 가리킵니다.`
198
+ + ' 다른 프로젝트의 원장에 기록하지 않기 위해 봉인 전에 거부합니다.',
199
+ };
200
+ }
201
+ }
202
+ }
203
+ else if (typeof a.root === 'string' && a.root !== '') {
204
+ ledgerRoot = projectRootOf(a.root);
205
+ }
206
+ else {
207
+ return {
208
+ ok: false,
209
+ reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 봉인 전에 거부합니다. root를 지정하십시오.',
210
+ };
211
+ }
212
+ }
213
+ else {
214
+ if (typeof a.root !== 'string' || a.root === '') {
215
+ return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 봉인 전에 거부합니다. root를 지정하십시오.' };
216
+ }
217
+ ledgerRoot = projectRootOf(a.root);
218
+ }
219
+ }
220
+ catch (e) {
221
+ return {
222
+ ok: false,
223
+ reason: `원장 위치를 확정할 수 없어 봉인 전에 거부합니다: ${String(e.message)}.`
224
+ + ' 올바른 root를 지정하거나, 파일 스토어에 바인딩된 서버에서는 root를 생략하십시오.',
225
+ };
226
+ }
227
+ const specs = await store.list();
228
+ // @implements A-SPEC-188 — with DUPLICATE ids the tool cannot know which copy is canonical:
229
+ // read() resolves the last-walked file while list().find sees the first, so approving would
230
+ // seal a stray's content and delete the canonical file — success reported, edit destroyed
231
+ // (round-2 review reproduced exactly that). Duplicates are a recognized invalid state
232
+ // (doctor detects them); the act refuses rather than picking a side.
233
+ // @implements A-SPEC-188 — the spec this act validates and seals is the one read NOW, with
234
+ // its version captured for the optimistic write below. Basing the candidate on the list()
235
+ // element instead leaves a window in which an external edit is silently destroyed and the
236
+ // STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
237
+ const cur = await store.read(a.id);
238
+ // @implements A-SPEC-649 — a grant bound to a subject opens only for THAT subject: the digest of
239
+ // the spec read now (the same read the seal is based on) must equal the one the operator read.
240
+ // Measured 2026-09-14: a grant read against "Original title" sealed "CHANGED after the grant".
241
+ if (cur && approveResolved.source === 'grant') {
242
+ const bound = approveResolved.approval;
243
+ if (typeof bound.subject === 'string' && bound.subject !== '' && bound.subject !== (0, spec_digest_1.specDigest)(cur.spec)) {
244
+ const current = (0, spec_digest_1.specDigest)(cur.spec);
245
+ return { ok: false, reason: `[grant ${bound.nonce ?? '?'} refused: stale-subject] the grant was minted for another content of ${a.id}; the operator must read the current content and decide again (revoke the stale grant with holmes-kit approve --revoke).`
246
+ + context.refusalQueueHint(a.root, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인', subject: current }) };
247
+ }
248
+ }
249
+ // @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
250
+ // so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
251
+ // files when there are any; byte-identical to the legacy message when there are none. The
252
+ // store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
253
+ if (!cur) {
254
+ const dir = store.specsRoot;
255
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
256
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
257
+ }
258
+ const spec = cur.spec;
259
+ const prepared = (0, transition_policy_1.prepareApprovedSpec)(spec, specs, resolver, (rel) => {
260
+ try {
261
+ return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ });
267
+ if (!prepared.ok)
268
+ return prepared;
269
+ const { candidate, digest } = prepared;
270
+ const priorDigest = (0, spec_digest_1.sealOf)(spec).approvedDigest;
271
+ // @implements A-SPEC-188 — the seal is written only at the version this act READ. On
272
+ // conflict the EDIT wins and the APPROVAL loses: the edit is what a person just wrote; the
273
+ // approval is an act that can simply be retried. The reverse (the old behaviour) sealed
274
+ // stale content over a destroyed edit with no notice.
275
+ try {
276
+ await store.write(candidate, { expectedVersion: cur.version });
277
+ }
278
+ catch (e) {
279
+ if (e instanceof spec_store_1.SpecVersionConflictError) {
280
+ // @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
281
+ // wins); it now also CARRIES the conflict: the version this act read, the version now on
282
+ // disk, and what to retry. Re-read to learn the current version (null if it vanished).
283
+ const now = await store.read(a.id).catch(() => null);
284
+ return {
285
+ ok: false,
286
+ reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
287
+ + ' 이번 승인은 아무것도 쓰지 않았습니다.',
288
+ conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
289
+ };
290
+ }
291
+ // @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
292
+ // an H-SPEC into its subfolder). Round-3 probed that landing on an unreadable human note
293
+ // destroyed it with success reported; the store now refuses, and this act relays why.
294
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
295
+ return {
296
+ ok: false,
297
+ reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
298
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 승인하십시오. 이번 승인은 아무것도 쓰지 않았습니다.',
299
+ };
300
+ }
301
+ throw e;
302
+ }
303
+ // @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
304
+ const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
305
+ chain.append({
306
+ ts: new Date().toISOString(),
307
+ actor: approveResolved.approval.actor,
308
+ kind: 'spec-approved',
309
+ summary: `approved ${a.id} sealing ${digest}`,
310
+ inputs: [a.id, digest],
311
+ rationale: approveResolved.approval.rationale,
312
+ authorization: (0, provenance_chain_1.authorizationRef)(approveResolved.approval.actor, approveResolved.approval.token),
313
+ });
314
+ // @implements A-SPEC-135
315
+ // P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
316
+ // a `review-needed` entry naming the anchored source files — the exact set a targeted review must
317
+ // re-examine against the new content. A first approval or an idempotent re-seal is not drift and
318
+ // mints nothing. This ROUTES review; it never verdicts the code. Best-effort: a missed route is
319
+ // not a broken seal, so an append failure does not fail the approval.
320
+ if (priorDigest && priorDigest !== digest) {
321
+ try {
322
+ const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
323
+ chain.append({
324
+ ts: new Date().toISOString(), actor: approveResolved.approval.actor, kind: 'review-needed',
325
+ summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
326
+ inputs: [a.id, priorDigest, digest, ...files],
327
+ rationale: approveResolved.approval.rationale, authorization: '',
328
+ });
329
+ }
330
+ catch { /* a missed routing signal is not a broken seal */ }
331
+ }
332
+ // @implements A-SPEC-245 — single-use: the grant is spent by the seal it authorized.
333
+ if (approveResolved.source === 'grant' && approveResolved.root && approveResolved.approval.nonce) {
334
+ (0, approval_grants_1.consumeGrantFile)(approveResolved.root, approveResolved.approval.nonce);
335
+ }
336
+ // @implements A-SPEC-566.2 — the impact advisory rides the SUCCESS, after the seal is done:
337
+ // the verdict is already committed, so nothing here can change it (advisory, never gate —
338
+ // Judgments must not be budgeted). Reuses the persisted graph READ-ONLY; it never scans,
339
+ // parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
340
+ // failure below degrades to "no advisory field" on an otherwise identical response.
341
+ let impactAdvisory;
342
+ let anchorDensity;
343
+ // @implements A-SPEC-631 — the graph's CONDITION rides beside the advisory: current/stale/unverified,
344
+ // or the reason no advisory could be derived. An empty graph is never reported as no impact.
345
+ let impactGraph;
346
+ let impactAdvisoryUnavailable;
347
+ try {
348
+ if (spec.type === 'A-SPEC' && a.root) {
349
+ const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
350
+ // @implements A-SPEC-631 — non-mutating inspection: never clears, never deletes, and says
351
+ // what the file is. Freshness costs one `git rev-parse`, never a scan.
352
+ const { openPublishedGraph, headCommitOf } = require('../../rtm/graph-store');
353
+ const { cleanSubprocessEnv } = require('../../project/root');
354
+ const view = openPublishedGraph(dbPath, headCommitOf(a.root, cleanSubprocessEnv()));
355
+ if (view.status === 'empty' || view.status === 'unreadable') {
356
+ impactAdvisoryUnavailable = view.status;
357
+ }
358
+ else if (view.graph !== null) {
359
+ const { declaredImpactGap, appendImpactAdvisory } = require('../../rtm/impact-advisory');
360
+ const { filesToTouch } = require('../../spec/compat-impact');
361
+ // Closed in finally (high-effort review F4): this handler lives in a long-running MCP
362
+ // server, and an unclosed native handle per approval accumulates for the process
363
+ // lifetime — and on Windows can hold rtm.sqlite locked against the next rebuild.
364
+ const graph = view.graph;
365
+ impactGraph = { status: view.status, sourceCommit: view.sourceCommit, head: view.head, ...(view.graphAsOf ? { graphAsOf: view.graphAsOf } : {}) };
366
+ try {
367
+ const ftt = filesToTouch(candidate);
368
+ const gap = declaredImpactGap(ftt, graph, (rel) => { try {
369
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
370
+ }
371
+ catch {
372
+ return null;
373
+ } });
374
+ if (gap) {
375
+ const graphAsOf = (() => { try {
376
+ return fs.statSync(dbPath).mtime.toISOString();
377
+ }
378
+ catch {
379
+ return undefined;
380
+ } })();
381
+ impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
382
+ appendImpactAdvisory(a.root, {
383
+ aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
384
+ ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
385
+ });
386
+ }
387
+ // @implements A-SPEC-569.5 — anchor-density OBSERVATION, same reopened graph, same
388
+ // no-scan contract, same lifecycle as the advisory above (observe → ledger → measure
389
+ // before anyone proposes promotion). Never a verdict input: the seal is already done,
390
+ // and its own failure degrades to "no field" on an otherwise identical response.
391
+ try {
392
+ const { anchorDensityFindings, appendAnchorDensity } = require('../../rtm/anchor-density');
393
+ const findings = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
394
+ if (findings.length > 0) {
395
+ anchorDensity = findings;
396
+ appendAnchorDensity(a.root, {
397
+ aspec: a.id, files: findings.map((f) => ({ path: f.path, anchors: f.anchors })),
398
+ p90: findings[0].p90, ts: new Date().toISOString(),
399
+ });
400
+ }
401
+ }
402
+ catch {
403
+ anchorDensity = undefined;
404
+ }
405
+ }
406
+ finally {
407
+ graph.close();
408
+ }
409
+ }
410
+ }
411
+ }
412
+ catch {
413
+ impactAdvisory = undefined;
414
+ anchorDensity = undefined;
415
+ impactGraph = undefined;
416
+ impactAdvisoryUnavailable = undefined;
417
+ }
418
+ return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}),
419
+ ...(impactGraph ? { impactGraph } : {}), ...(impactAdvisoryUnavailable ? { impactAdvisoryUnavailable } : {}) };
420
+ },
421
+ /**
422
+ * Pin a REQ's citations: compute the content digest of every cited source that resolves inside
423
+ * the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
424
+ * always derived from what is actually on disk.
425
+ *
426
+ * Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
427
+ * re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
428
+ * detector into a concealer.
429
+ */
430
+ async citation_pin(a) {
431
+ // @implements A-SPEC-189 §15 (round 13) — every sibling that reads or writes on behalf of a
432
+ // project refuses a foreign root (spec_create, phase_status, review_record, review_status,
433
+ // spec_approve). `citation_pin` did not, and it SEALS what it read: the sha256 of another
434
+ // project's file was written into this store's REQ frontmatter as evidence, permanently —
435
+ // the design deliberately never overwrites an existing digest.
436
+ const foreignPin = context.foreignRootReason(a.root);
437
+ if (foreignPin !== null)
438
+ return { ok: false, reason: `citation_pin: ${foreignPin}` };
439
+ // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
440
+ // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
441
+ const root = projectRootOf(a.root);
442
+ // @implements A-SPEC-188 — the third writer, same duplicate hazard as approve/upgrade:
443
+ // read() resolves the last-walked copy and the write's orphan sweep deletes the other.
444
+ if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
445
+ return {
446
+ ok: false,
447
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 기록 전에 거부합니다.`
448
+ + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
449
+ };
450
+ }
451
+ // @implements A-SPEC-151
452
+ // `read` now yields the spec together with the version it was read at, so a write can say
453
+ // "only if nobody touched this since". citation_pin uses it: the pin is derived from what was
454
+ // read, and writing it back over someone else's edit would silently discard their change.
455
+ const found = await store.read(a.id);
456
+ if (!found)
457
+ return { ok: false, reason: `spec ${a.id} not found` };
458
+ const { spec, version } = found;
459
+ if (spec.type !== 'REQ')
460
+ return { ok: false, reason: `citations live on REQ; ${a.id} is ${spec.type}` };
461
+ if (spec.frontmatter.source == null)
462
+ return { ok: false, reason: `${a.id} has no source to pin` };
463
+ const readLocal = (ref) => {
464
+ const abs = path.resolve(root, ref);
465
+ // A ref that escapes the repository is not ours to read, and following it would let a spec
466
+ // pull arbitrary host files into the provenance record.
467
+ if (!abs.startsWith(path.resolve(root) + path.sep))
468
+ return null;
469
+ try {
470
+ return fs.readFileSync(abs, 'utf8');
471
+ }
472
+ catch {
473
+ return null;
474
+ }
475
+ };
476
+ const { citations, pinned, findings } = (0, validator_1.pinCitations)(spec.frontmatter.source, readLocal);
477
+ const dryRun = a.dryRun !== false; // opt IN to writing
478
+ if (!dryRun && pinned.length > 0) {
479
+ try {
480
+ try {
481
+ await store.write({ ...spec, frontmatter: { ...spec.frontmatter, source: citations } }, { expectedVersion: version });
482
+ }
483
+ catch (e) {
484
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
485
+ return {
486
+ ok: false,
487
+ reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
488
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
489
+ };
490
+ }
491
+ throw e;
492
+ }
493
+ }
494
+ catch (err) {
495
+ if (err instanceof spec_store_1.SpecVersionConflictError) {
496
+ return { ok: false, reason: `${a.id} changed while its citations were being pinned — re-read and retry (${err.message})` };
497
+ }
498
+ throw err;
499
+ }
500
+ }
501
+ return { ok: true, id: a.id, dryRun, pinned, findings, citations };
502
+ },
503
+ };
504
+ }