@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,86 @@
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.createPhaseQueryHandlers = createPhaseQueryHandlers;
37
+ // @implements A-SPEC-606, A-SPEC-100.2
38
+ const fs = __importStar(require("node:fs"));
39
+ const path = __importStar(require("node:path"));
40
+ const phase_1 = require("../../guardrail/phase");
41
+ const write_target_1 = require("../../guardrail/write-target");
42
+ function createPhaseQueryHandlers(context) {
43
+ return {
44
+ async phase_status(a) {
45
+ // @implements A-SPEC-189 §6 — `root` was advertised and never read, so a call naming another
46
+ // project (or a path that does not exist) got THIS server's corpus in a success shape and the
47
+ // caller derived its phase from someone else's specs. Same anchor discipline as spec_approve.
48
+ const boundSpecsRoot = typeof a?.root === 'string' && a.root !== '' ? context.boundSpecsRoot() : undefined;
49
+ if (typeof a?.root === 'string' && a.root !== '' && boundSpecsRoot !== undefined) {
50
+ let asked;
51
+ let bound;
52
+ let derivationFoundMarker = false;
53
+ try {
54
+ asked = (0, write_target_1.resolveTarget)(context.projectRootOf(a.root), '.');
55
+ const derived = context.projectRootOf(boundSpecsRoot);
56
+ derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
57
+ bound = (0, write_target_1.resolveTarget)(derived, '.');
58
+ }
59
+ catch (e) {
60
+ return { ok: false, reason: `root를 해석할 수 없습니다: ${String(e.message)}` };
61
+ }
62
+ // @implements A-SPEC-189 §7 (round 10) — spec_approve's round-3 lesson, copied here at last:
63
+ // resolveProjectRoot returns its INPUT when no `.ax` ancestor exists, so a custom
64
+ // HOLMES_SPECS outside any project "derived" the spec store itself as the project and this
65
+ // guard then refused the CORRECT root. Only a derivation that actually found a marker
66
+ // speaks for a project.
67
+ if (derivationFoundMarker && asked !== bound) {
68
+ return {
69
+ ok: false,
70
+ reason: `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${asked} 를 가리킵니다.`
71
+ + ' 다른 프로젝트의 코퍼스를 이 서버가 대신 답하지 않습니다.',
72
+ };
73
+ }
74
+ }
75
+ const specs = await context.listSpecs();
76
+ return { specs: specs.map((s) => ({ id: s.id, type: s.type, status: s.status })), note: 'derive phase via phase_check' };
77
+ },
78
+ async phase_check(a) {
79
+ const specs = await context.listSpecs();
80
+ const action = a.action ?? (0, phase_1.classifyAction)(a.target);
81
+ if (!action)
82
+ return { decision: 'allow', note: 'unclassified target' };
83
+ return (0, phase_1.phaseCheck)(action, { specs, targetAspecId: a.targetAspecId });
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,47 @@
1
+ import { Finding } from '../../review/findings';
2
+ import { Approval } from '../../guardrail/risk-gate';
3
+ import { ElicitOutcome } from '../elicit-approval';
4
+ import { Basis } from '../basis';
5
+ export interface ReviewEvidenceContext {
6
+ foreignRootReason(root?: string): string | null;
7
+ refusal(reason: string): Error;
8
+ boundFindingsLedger(root: string): string;
9
+ boundNonceLedger(): string | null;
10
+ projectRootOf(root: string): string;
11
+ basisFor(root: string | undefined, withDisk?: boolean): Basis;
12
+ hasElicitor(): boolean;
13
+ tryElicit(kind: string, target: string, summary: string): Promise<ElicitOutcome>;
14
+ elicitApproval(reason?: string): Approval;
15
+ resolveHandlerApproval(root: string | undefined, approval: Approval | undefined, action: {
16
+ kind: string;
17
+ target: 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
+ }): string;
28
+ }
29
+ export declare function createReviewEvidenceHandlers(context: ReviewEvidenceContext): {
30
+ review_record(a: {
31
+ root: string;
32
+ findings: Finding[];
33
+ }): Promise<{
34
+ recorded: number;
35
+ }>;
36
+ review_status(a: {
37
+ root: string;
38
+ }): Promise<{
39
+ blocked: boolean;
40
+ open: {
41
+ critical: number;
42
+ important: number;
43
+ minor: number;
44
+ };
45
+ reviewNeeded: import("../../review/review-targets").ReviewNeeded[];
46
+ }>;
47
+ };
@@ -0,0 +1,370 @@
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.createReviewEvidenceHandlers = createReviewEvidenceHandlers;
37
+ // @implements A-SPEC-617, A-SPEC-100.2, A-SPEC-102.1, A-SPEC-133, A-SPEC-135, A-SPEC-141, A-SPEC-157, A-SPEC-160, A-SPEC-189, A-SPEC-191, A-SPEC-244, A-SPEC-245, A-SPEC-263.1, A-SPEC-497.1
38
+ const path = __importStar(require("node:path"));
39
+ const findings_1 = require("../../review/findings");
40
+ const review_targets_1 = require("../../review/review-targets");
41
+ const elicit_approval_1 = require("../elicit-approval");
42
+ const basis_1 = require("../basis");
43
+ const ledger_store_1 = require("../../governance/ledger-store");
44
+ const ledger_lock_1 = require("../../governance/ledger-lock");
45
+ const approval_grants_1 = require("../../governance/approval-grants");
46
+ const provenance_chain_1 = require("../../governance/provenance-chain");
47
+ function createReviewEvidenceHandlers(context) {
48
+ return {
49
+ async review_record(a) {
50
+ // @implements A-SPEC-189 §8 (round 11) — §15 shut the door the merge gate READS through and
51
+ // left the one it is WRITTEN through wide open. Measured: `review_record` joined the caller's
52
+ // `root` straight into a path and created `.ax/ledger/findings.jsonl` in any directory on the
53
+ // machine, answering `{recorded: N}` — while `review_status`, bound to this server's project,
54
+ // could never see those findings. An open critical filed at the wrong root is worse than none:
55
+ // it reads as recorded and gates nothing. The two doors now ask the same question.
56
+ const foreignWrite = context.foreignRootReason(a.root);
57
+ if (foreignWrite !== null)
58
+ throw context.refusal(`review_record: ${foreignWrite}`);
59
+ // MCP input is unvalidated JSON: an out-of-enum severity or status
60
+ // (e.g. a typo like "blocker") would land in the ledger as a stray
61
+ // value that review_status's counts never match, so it counts toward
62
+ // neither its own bucket nor `blocked` — a mislabeled critical could
63
+ // silently pass the gate. Validate up front so the gate's counts stay
64
+ // trustworthy.
65
+ const validSeverities = new Set(['critical', 'important', 'minor']);
66
+ const validStatuses = new Set(['open', 'resolved']);
67
+ for (const f of a.findings) {
68
+ if (!validSeverities.has(f.severity)) {
69
+ throw context.refusal(`review_record: finding ${f.id} has invalid severity ${JSON.stringify(f.severity)} (must be one of critical|important|minor)`);
70
+ }
71
+ if (!validStatuses.has(f.status)) {
72
+ throw context.refusal(`review_record: finding ${f.id} has invalid status ${JSON.stringify(f.status)} (must be one of open|resolved)`);
73
+ }
74
+ }
75
+ // @implements A-SPEC-191 (§4e) — the ledger's meaning is guarded at RECORD time, where the
76
+ // collision actually happens. Measured: conventional ids (C1…) recur across review rounds, so
77
+ // one round's resolve of "C1" silently lifted the gate over ANOTHER round's unfixed critical;
78
+ // and a severity-downgrade re-open cleared ART-7 with no resolution at all. Both die at one
79
+ // door: an id whose latest state is open cannot be opened again — resolve first, then
80
+ // re-record. Lifting an open CRITICAL requires the out-of-band approval channel (ART-5's
81
+ // principle): the blocked party must not be able to pull its own teeth in-band. A resolved
82
+ // record for an id never opened lifts nothing and stays approval-free (found-and-fixed audit
83
+ // records keep working).
84
+ // The guard and the append hold ONE lock (round-6: the read→judge→append section was a
85
+ // cross-process TOCTOU — two sessions both recorded open under the same id, silencing an
86
+ // open critical at both readers; consumeNonceExclusively already earned this discipline).
87
+ const findingsFile = context.boundFindingsLedger(a.root);
88
+ // @implements A-SPEC-263.1 — elicitation happens BEFORE the lock: the human may take up to
89
+ // the wiring's timeout to answer, and holding the findings ledger lock for that long starves
90
+ // every other writer (the lock callback is synchronous by design). This is a best-effort
91
+ // PRE-SCAN over an unlocked snapshot: for each finding that LOOKS like an open-critical lift
92
+ // not covered by env/grant, ask now and carry the grant into the lock as data. TOCTOU folds
93
+ // fail-closed — state moved so the lift is no longer needed → the grant is simply unused;
94
+ // state moved so a lift IS needed that the snapshot missed → no grant → the standard refusal.
95
+ const elicitGrants = new Map();
96
+ // A DENY at pre-scan is carried as data too (round-1): throwing here judged a stale snapshot —
97
+ // a finding concurrently resolved by someone else no longer needs a lift inside the lock, and
98
+ // the human's "no" must not abort a batch that never needed the question. The deny bites only
99
+ // at the in-lock site, and only if the lift is ACTUALLY needed there.
100
+ const elicitDenials = new Map();
101
+ // @implements A-SPEC-497.1 — an expiry is carried as data like a denial: it bites only at the
102
+ // in-lock refusal site, where it prefixes the standard message so the agent can tell the user
103
+ // a session dialog died (instead of the anonymous "no approval" the old null-fold produced).
104
+ const elicitExpiries = new Map();
105
+ if (context.hasElicitor()) {
106
+ try {
107
+ const snapshot = new Map();
108
+ for (const f of new findings_1.FindingsLedger(findingsFile).list())
109
+ snapshot.set(f.id, f);
110
+ for (const f of a.findings) {
111
+ const last = snapshot.get(f.id);
112
+ if (!(f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical'))
113
+ continue;
114
+ const raw0 = process.env.HOLMES_APPROVAL;
115
+ let env0;
116
+ try {
117
+ env0 = raw0 ? JSON.parse(raw0) : undefined;
118
+ }
119
+ catch {
120
+ env0 = undefined;
121
+ }
122
+ if (context.resolveHandlerApproval(a.root, env0, { kind: 'review-resolve', target: f.id }, new Date().toISOString()) !== undefined)
123
+ continue; // an open door never summons a human
124
+ const out = await context.tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
125
+ if (out.kind === 'answered' && out.decision.granted)
126
+ elicitGrants.set(f.id, context.elicitApproval(out.decision.reason));
127
+ else if (out.kind === 'answered')
128
+ elicitDenials.set(f.id, out.decision.reason ?? '(사유 없음)');
129
+ else if (out.kind === 'expired')
130
+ elicitExpiries.set(f.id, out.waitedMs);
131
+ }
132
+ }
133
+ catch {
134
+ // A pre-scan failure is never worse than no channel: fall through with no grants.
135
+ }
136
+ }
137
+ (0, ledger_lock_1.withLedgerLock)(findingsFile, () => {
138
+ const ledger = new findings_1.FindingsLedger(findingsFile);
139
+ const latest = new Map();
140
+ const liftedCriticals = [];
141
+ const envLiftedCriticals = []; // lifts the ENV approval authorized (round-1: nonce charges only these)
142
+ const grantConsumptions = []; // spent AFTER the batch validates (round-2)
143
+ const elicitationLifts = []; // ledgered per lift (round-5: the promised audit trace)
144
+ for (const f of ledger.list())
145
+ latest.set(f.id, f);
146
+ for (const f of a.findings) {
147
+ if (!f.id || !f.id.trim()) {
148
+ throw context.refusal('review_record: finding id 가 비어 있습니다 — 모든 발견은 원장에서 유일하게 식별될 id 가 필요합니다');
149
+ }
150
+ // Round-5: pre-round-4 ads declared `message`, but the ledger whitelist persists only
151
+ // `summary` — clients following the OLD ad lost their text with zero signal. A pointed
152
+ // refusal turns that silent loss into the one-line fix it needs.
153
+ // Round-9: `=== undefined` let `summary: null` through, so the ledger dropped the text
154
+ // and the caller saw success — the very silence this guard exists to end. Absence is
155
+ // null OR undefined here, as everywhere else in this validator's vocabulary.
156
+ if ('message' in f && (f.summary === undefined || f.summary === null)) {
157
+ throw context.refusal(`review_record: finding ${f.id} 의 message 는 폐지된 광고 키입니다 — 원장은 summary 만 보존합니다. 같은 내용을 summary 로 보내십시오`);
158
+ }
159
+ const last = latest.get(f.id);
160
+ if (f.status === 'open' && last?.status === 'open') {
161
+ throw context.refusal(`review_record: id ${f.id} 는 이미 미해소(open) 상태입니다 — 다른 발견이면 새 id 를 쓰고, 같은 발견의 갱신이면 먼저 resolved 를 기록한 뒤 재기록하십시오 (id 충돌이 남의 critical 을 침묵시키는 것을 막는 문입니다)`);
162
+ }
163
+ if (f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical') {
164
+ // @implements A-SPEC-191 §11 — checked BEFORE any approval is resolved or consumed
165
+ // (round-2): with the guard after resolution, a single-use elicitation grant (or grant
166
+ // file) was spent on the FIRST lift, so the second occurrence saw "no approval" and
167
+ // surfaced a bogus set-HOLMES_APPROVAL message plus a queue entry for an act a human
168
+ // already decided. The double-lift is named honestly on EVERY channel, and nothing
169
+ // single-use is touched by a batch that dies here.
170
+ if (liftedCriticals.includes(f.id)) {
171
+ throw context.refusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
172
+ }
173
+ const raw = process.env.HOLMES_APPROVAL;
174
+ let approval;
175
+ try {
176
+ approval = raw ? JSON.parse(raw) : undefined;
177
+ }
178
+ catch {
179
+ approval = undefined;
180
+ }
181
+ // covers, not merely well-formed (round-2): A-SPEC-133 built the seam so one token is
182
+ // not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
183
+ // An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
184
+ const nowTs = new Date().toISOString();
185
+ let rrResolved = context.resolveHandlerApproval(a.root, approval, { kind: 'review-resolve', target: f.id }, nowTs);
186
+ // @implements A-SPEC-263.1 — the second (and last) elicitable kind. The HUMAN was asked
187
+ // BEFORE the lock (pre-scan above — a lock must not wait on a person); in here the grant
188
+ // is plain data, consumed synchronously and at most once per id.
189
+ if (rrResolved === undefined) {
190
+ const g = elicitGrants.get(f.id);
191
+ if (g) {
192
+ elicitGrants.delete(f.id);
193
+ rrResolved = { approval: g, source: 'elicitation' };
194
+ elicitationLifts.push({ id: f.id, approval: g });
195
+ }
196
+ }
197
+ if (rrResolved === undefined) {
198
+ // A pre-scan DENY surfaces HERE — only when the lift is genuinely needed at lock time
199
+ // (round-1). The human decided, so the answer is the message and nothing is queued.
200
+ const denied = elicitDenials.get(f.id);
201
+ if (denied !== undefined) {
202
+ throw context.refusal(`review_record: 세션에서 거부됨 — ${denied}. 사유를 해소한 뒤 다시 기록하십시오.`);
203
+ }
204
+ // @implements A-SPEC-497.1 — an expired dialog leads the refusal by name; silence
205
+ // keeps the pre-elicitation face.
206
+ const expiredMs = elicitExpiries.get(f.id);
207
+ throw context.refusal((expiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(expiredMs) : '')
208
+ + `review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
209
+ + context.refusalQueueHint(a.root, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
210
+ }
211
+ // Grant-file consumption is DEFERRED past the loop (round-2): consuming here burned the
212
+ // single-use file when a LATER finding in the batch failed validation — nothing was
213
+ // recorded, yet the operator's legitimate grant was gone and the retry refused. Same
214
+ // harm class the env nonce fixed in its round-4; collected now, spent only once the
215
+ // whole batch has validated (directly before the append, like the nonce block).
216
+ if (rrResolved.source === 'grant' && rrResolved.root && rrResolved.approval.nonce) {
217
+ grantConsumptions.push({ root: rrResolved.root, nonce: rrResolved.approval.nonce });
218
+ }
219
+ liftedCriticals.push(f.id);
220
+ // The nonce block below enforces single-use on the ENV approval — so it must key on the
221
+ // lifts the ENV approval actually authorized (round-1): before elicitation existed,
222
+ // reaching it implied env/grant coverage, but an elicitation-authorized lift flows past
223
+ // it with a possibly unrelated env token in the environment. Charging THAT token burned
224
+ // an innocent nonce, misattributed the ledger line, and a stale env nonce made a
225
+ // human-approved lift permanently refusable.
226
+ if (rrResolved.source === 'env')
227
+ envLiftedCriticals.push(f.id);
228
+ }
229
+ latest.set(f.id, f); // 한 배치 안의 순서도 기록 순서다
230
+ }
231
+ // A-SPEC-133 promises nonce = single-use "consumed on the ledger, a replay is denied".
232
+ // Consumed AFTER the whole batch validates and ONCE per call (round-4: consuming inside
233
+ // the loop burned the grant when a LATER row failed validation — the audit trail asserted
234
+ // an act that never happened, the retry was refused, and two resolves in one batch would
235
+ // have double-spent). Consumption directly precedes the append; the only failure between
236
+ // them is the append itself, which throws loudly.
237
+ // ONE NONCE, ONE ACT — on the grant channel too (round-4): the round-2 deferral removed the
238
+ // arity the in-loop spend had accidentally enforced, so a wide-scoped single-use grant
239
+ // backed N lifts in one batch while the same lifts split across calls refused after the
240
+ // first (the §13 batch-shape dependence). Checked BEFORE anything is spent (round-3 order
241
+ // doctrine: every throwable validation precedes every single-use spend).
242
+ {
243
+ const liftsPerNonce = new Map();
244
+ for (const g of grantConsumptions)
245
+ liftsPerNonce.set(g.nonce, (liftsPerNonce.get(g.nonce) ?? 0) + 1);
246
+ for (const [, n] of liftsPerNonce) {
247
+ if (n > 1) {
248
+ throw context.refusal(`review_record: 단일 사용 승인(grant)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 한 grant 로 ${n}건을 듭니다. 한 건씩 보내십시오`);
249
+ }
250
+ }
251
+ }
252
+ // The elicitation channel's AUDIT TRACE (round-5, repositioned round-6): the trust model
253
+ // admits review-resolve at a boundary closer than env/grant BECAUSE every grant is ledgered
254
+ // under an `elicitation:<client>` actor. This append is throwable I/O (chain lock
255
+ // contention, disk faults), so it stands BEFORE every single-use spend — round-5 placed it
256
+ // between the env-nonce spend and the findings append, and a lock-contended mixed batch
257
+ // burned the operator's env nonce with nothing recorded (the §7 doctrine regression). A
258
+ // failure HERE burns nothing; an orphaned event left when a LATER step throws stays honest,
259
+ // because it records the AUTHORIZATION, which already happened at pre-scan.
260
+ if (elicitationLifts.length > 0) {
261
+ const chain = new ledger_store_1.FileLedgerStore(path.join(context.projectRootOf(a.root), '.ax', 'ledger'));
262
+ for (const lift of elicitationLifts) {
263
+ chain.append({
264
+ ts: new Date().toISOString(), actor: lift.approval.actor, kind: 'review-resolve-authorized',
265
+ summary: `elicitation grant authorized lifting open critical ${lift.id}`.slice(0, 200),
266
+ inputs: [lift.id], rationale: lift.approval.rationale,
267
+ authorization: (0, provenance_chain_1.authorizationRef)(lift.approval.actor, lift.approval.token),
268
+ });
269
+ }
270
+ }
271
+ // Keyed on the lifts the ENV approval AUTHORIZED, not on the ambient env var (round-1):
272
+ // an elicitation- or grant-authorized lift must not charge — or be blocked by — an env
273
+ // token that never covered the act. When env authorized nothing, its nonce is untouched.
274
+ if (envLiftedCriticals.length > 0) {
275
+ const raw = process.env.HOLMES_APPROVAL;
276
+ let approval;
277
+ try {
278
+ approval = raw ? JSON.parse(raw) : undefined;
279
+ }
280
+ catch {
281
+ approval = undefined;
282
+ }
283
+ if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
284
+ throw context.refusal('review_record: 승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
285
+ }
286
+ // @implements A-SPEC-191 §13 — 1회용 승인은 한 번의 행위를 authorize 한다. 9라운드
287
+ // 실측: 서로 다른 id 의 치명 해소 N 건이 한 배치에서 nonce 한 장으로 통과했고,
288
+ // 같은 세 건을 세 호출로 나누면 두 번째부터 거부됐다 — 판정이 묶음 방식에 의존했다.
289
+ if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) && envLiftedCriticals.length > 1) {
290
+ throw context.refusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${envLiftedCriticals.length}건(${envLiftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
291
+ }
292
+ if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce)) {
293
+ // @implements A-SPEC-191 §10 — the same bound anchor as risk_check: two consumers must
294
+ // spend into ONE ledger, or a nonce spent here reopens the gate there (and back).
295
+ const ledgerFile = context.boundNonceLedger();
296
+ if (ledgerFile === null) {
297
+ throw context.refusal('review_record: 단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다');
298
+ }
299
+ const won = (0, provenance_chain_1.consumeNonceExclusively)(String(approval.nonce), ledgerFile, {
300
+ ts: new Date().toISOString(), actor: approval.actor, kind: 'nonce-consumed',
301
+ summary: `consumed single-use approval for: review-resolve ${envLiftedCriticals.join(', ')}`.slice(0, 200),
302
+ inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
303
+ authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
304
+ }, { isNonceConsumed: (0, ledger_store_1.nonceConsumedIn)(ledgerFile) });
305
+ if (!won) {
306
+ throw context.refusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
307
+ }
308
+ }
309
+ }
310
+ // Deferred single-use spends — LAST, after every throwable validation including the
311
+ // env-nonce block above (round-3: placed before it, a mixed batch's env-nonce refusal
312
+ // burned the grant with nothing recorded — the exact harm the round-2 deferral was written
313
+ // to fix, reintroduced through the env throw path). Only the append itself follows, and it
314
+ // fails loudly.
315
+ for (const g of grantConsumptions)
316
+ (0, approval_grants_1.consumeGrantFile)(g.root, g.nonce);
317
+ // @implements A-SPEC-157 — the server's OWN observation, not anything the caller sent.
318
+ // @implements A-SPEC-160 — the ONLY caller that reads the on-disk build, so the divergence
319
+ // marker can be sealed. Append INSIDE the same lock as the guard (round-6 TOCTOU).
320
+ ledger.record(a.findings, (0, basis_1.basisDigest)(context.basisFor(a.root, true)));
321
+ });
322
+ return { recorded: a.findings.length };
323
+ },
324
+ async review_status(a) {
325
+ // @implements A-SPEC-191 §15 — the merge gate must not answer about a project it did not read.
326
+ const foreign = context.foreignRootReason(a.root);
327
+ if (foreign !== null)
328
+ throw context.refusal(`review_status: ${foreign}`);
329
+ // @implements A-SPEC-191 (§4f) — counted on the LATEST record per id, the same folding the
330
+ // constitution's ART-7 uses. Line-based counting could never decrease on an append-only
331
+ // ledger, so the canonical exit (a resolved line) left `blocked: true` forever while the Stop
332
+ // gate passed — two official verdicts about one ledger, disagreeing permanently (measured).
333
+ const all = new findings_1.FindingsLedger(context.boundFindingsLedger(a.root)).list();
334
+ const latest = new Map();
335
+ // Severity of the LATEST open row per id — a forever-sticky "ever critical" set resurrected
336
+ // a properly-closed critical when a later minor re-finding got a diverged resolve (r6).
337
+ const lastOpenCritical = new Map();
338
+ for (const f of all) {
339
+ latest.set(f.id, f);
340
+ if (f.status === 'open')
341
+ lastOpenCritical.set(f.id, f.severity === 'critical');
342
+ }
343
+ // SAME ledger meaning as ART-7 (round-3: the two official readers disagreed): a resolution
344
+ // sealed on a diverged build does not close a critical here either. What it keeps open is
345
+ // the CRITICAL it failed to close — bucketing by the resolved line's own severity let a
346
+ // severity-downgraded diverged resolve report blocked:false while the Stop gate blocked
347
+ // (round-4 HIGH), and a severity-less row minted an "undefined" bucket.
348
+ const counts = { critical: 0, important: 0, minor: 0 };
349
+ for (const f of latest.values()) {
350
+ if (f.status === 'open') {
351
+ if (Object.prototype.hasOwnProperty.call(counts, f.severity))
352
+ counts[f.severity]++; // in-체크는 프로토타입 키(toString)로 새었다(r6)
353
+ continue;
354
+ }
355
+ if (f.status === 'resolved' && lastOpenCritical.get(f.id) === true && (0, basis_1.basisDiverged)(f.basis))
356
+ counts.critical++;
357
+ }
358
+ // @implements A-SPEC-135
359
+ // Surface outstanding P4 routing signals: specs whose approved content moved, with the anchored
360
+ // files awaiting targeted review. This is a backlog to route human attention, NOT a hard block —
361
+ // the review layer decides severity — so it does not touch `blocked`.
362
+ // @implements A-SPEC-189 §12 (round 12) — the findings moved onto the bound project in §8 and
363
+ // this line stayed on the caller's raw root, so the SAME project answered with or without a
364
+ // P4 routing backlog depending on how its root was spelled (a subdirectory passes the
365
+ // project-level foreign check). One question, one tree.
366
+ const reviewNeeded = (0, review_targets_1.readReviewNeeded)(path.join(path.dirname(context.boundFindingsLedger(a.root)), 'provenance.jsonl'));
367
+ return { blocked: counts.critical > 0 || counts.important > 0, open: counts, reviewNeeded };
368
+ },
369
+ };
370
+ }
@@ -0,0 +1,68 @@
1
+ import { Spec } from '../../spec/spec-parser';
2
+ import { ScannedFile } from '../../cpg/cpg-scanner';
3
+ import { ChangeSourceInfo } from '../../project/change-source';
4
+ import { ContentSource } from '../../context/bundler';
5
+ export interface ReviewQueryContext {
6
+ listSpecs(): Promise<Spec[]>;
7
+ assertStoreReachable(tool: string, root: unknown): void;
8
+ foreignRootReason(root?: string): string | null;
9
+ refusal(reason: string): Error;
10
+ projectRootOf(root: string): string;
11
+ cachedScan(root: string, repoRoot: string): ScannedFile[];
12
+ buildContentSource(specs: Spec[], scanned: ScannedFile[]): ContentSource;
13
+ deriveChangedContext(root: string, args: {
14
+ base?: string;
15
+ head?: string;
16
+ since?: string;
17
+ }, tool: string): Promise<{
18
+ root: string;
19
+ specs: Spec[];
20
+ scanned: ScannedFile[];
21
+ changedFiles: string[];
22
+ changedSymbols: string[];
23
+ changeSource: ChangeSourceInfo;
24
+ scopeFallback?: 'full';
25
+ anchorImpactedSpecs: string[];
26
+ changedTestFiles: string[];
27
+ unresolvedFiles: string[];
28
+ }>;
29
+ }
30
+ export declare function createReviewQueryHandlers(context: ReviewQueryContext): {
31
+ context_bundle(a: {
32
+ root: string;
33
+ seedId: string;
34
+ budget: number;
35
+ }): Promise<import("../../context/bundler").ContextBundle>;
36
+ review_scope(a: {
37
+ root: string;
38
+ base?: string;
39
+ head?: string;
40
+ since?: string;
41
+ }): Promise<{
42
+ scopeFallback?: "full" | undefined;
43
+ unscannedChangedFiles: string[];
44
+ testScope: import("../../rtm/test-scope").TestScope;
45
+ changeSource: ChangeSourceInfo;
46
+ changedSymbols: string[];
47
+ impactedSpecs: string[];
48
+ acceptanceCriteria: Array<{
49
+ specId: string;
50
+ criteria: string;
51
+ }>;
52
+ unrequestedSymbols: string[];
53
+ coverageGaps: string[];
54
+ obligationGaps: string[];
55
+ }>;
56
+ review_prepare(a: {
57
+ root: string;
58
+ base?: string;
59
+ head?: string;
60
+ since?: string;
61
+ budget?: number;
62
+ }): Promise<{
63
+ scopeFallback?: "full" | undefined;
64
+ package: import("../../review/package").ReviewPackage;
65
+ unscannedChangedFiles: string[];
66
+ changeSource: ChangeSourceInfo;
67
+ }>;
68
+ };