@holmes-lab/holmes-kit 0.1.12 → 0.1.14

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.
@@ -1,3 +1,4 @@
1
+ import { PendingRequest } from '../governance/approval-queue';
1
2
  import { Spec } from '../spec/spec-parser';
2
3
  /**
3
4
  * @implements A-SPEC-100.2
@@ -57,6 +58,10 @@ export declare function evaluateStop(specs: Spec[], evidence?: StopEvidence): {
57
58
  block: boolean;
58
59
  reason?: string;
59
60
  articles?: string[];
61
+ violations?: {
62
+ article: string;
63
+ detail: string;
64
+ }[];
60
65
  };
61
66
  /**
62
67
  * @implements A-SPEC-134
@@ -76,6 +81,40 @@ export declare function stopDebtAction(evaluation: {
76
81
  articles: string[];
77
82
  };
78
83
  export declare const MAX_CONSECUTIVE_BLOCKS = 3;
84
+ /**
85
+ * Should the stop gate keep re-blocking a turn, or has every unresolved debt already been queued
86
+ * for the owner's decision?
87
+ *
88
+ * @implements A-SPEC-247
89
+ * Measured (Notion UX-2): the stop hook re-blocks a violation whose only fix is an owner approval on
90
+ * every turn — six in a row — even when the request is already waiting in the queue, giving the
91
+ * agent nothing to do and the user the same wall repeatedly. This answers "is anything left that the
92
+ * agent or a NON-waiting approval could resolve?".
93
+ *
94
+ * ASYMMETRY IS THE POINT, and it is what keeps this from becoming amnesty: acknowledged is true ONLY
95
+ * when nothing is blocking AND at least one thing is waiting. A single non-waiting violation — a
96
+ * code debt, or an approval debt nobody queued — and the gate blocks exactly as before. Waiting is
97
+ * read from the pending list (REQ-244's fold), not re-derived. A violation whose detail names no
98
+ * spec id cannot be matched to a request, so it blocks — the safe direction.
99
+ */
100
+ export declare function acknowledgeStop(violations: {
101
+ article: string;
102
+ detail: string;
103
+ }[], pending: PendingRequest[]): {
104
+ acknowledged: boolean;
105
+ blocking: string[];
106
+ waiting: string[];
107
+ };
108
+ /**
109
+ * One-line health of the env approval token, for the server's startup log.
110
+ *
111
+ * @implements A-SPEC-247
112
+ * Measured (Notion UX-4): an invalid token gives no signal until an approval is attempted, so the
113
+ * operator round-trips without knowing why. This is a LOG, not a gate — an absent or invalid token
114
+ * is a legitimate read-only posture, and the server must start regardless. The reason distinguishes
115
+ * "not JSON" from "wrong shape" so the operator knows which to fix.
116
+ */
117
+ export declare function describeTokenHealth(raw: string | undefined): string;
79
118
  export declare function decideStopGuard(wantsBlock: boolean, priorConsecutiveBlocks: number, cap?: number): {
80
119
  block: boolean;
81
120
  nextCount: number;
@@ -37,12 +37,15 @@ exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
37
  exports.governanceLostPreflight = governanceLostPreflight;
38
38
  exports.evaluateStop = evaluateStop;
39
39
  exports.stopDebtAction = stopDebtAction;
40
+ exports.acknowledgeStop = acknowledgeStop;
41
+ exports.describeTokenHealth = describeTokenHealth;
40
42
  exports.decideStopGuard = decideStopGuard;
41
43
  exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
42
44
  exports.guardCountOrZero = guardCountOrZero;
43
45
  exports.readGuardCount = readGuardCount;
44
46
  exports.writeGuardCount = writeGuardCount;
45
47
  const fs = __importStar(require("node:fs"));
48
+ const risk_gate_1 = require("../guardrail/risk-gate");
46
49
  const json_state_1 = require("../project/json-state");
47
50
  const node_child_process_1 = require("node:child_process");
48
51
  const path = __importStar(require("node:path"));
@@ -79,15 +82,20 @@ function evaluateStop(specs, evidence) {
79
82
  // The articles live in ONE place (governance/constitution.ts); this gate merely executes them.
80
83
  const violations = (0, constitution_1.verifyConstitution)({ specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings });
81
84
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
85
+ // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
86
+ // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
87
+ const structured = violations.map((x) => ({ article: x.article, detail: x.detail }));
82
88
  // Tampered audit trail blocks finishing — wires the provenance chain's verify into the live gate
83
89
  // (review finding: verify() previously had no product caller, so the chain's guarantee was inert).
84
90
  if (evidence?.provenance && !evidence.provenance.ok) {
85
91
  problems.push(`[ART-2] provenance chain broken at seq ${evidence.provenance.brokenAt}: ${evidence.provenance.detail}`);
92
+ structured.push({ article: 'ART-2', detail: `provenance chain broken at seq ${evidence.provenance.brokenAt}: ${evidence.provenance.detail}` });
86
93
  }
87
94
  // @implements A-SPEC-191 (§4a) — an existing-but-unreadable findings ledger is not a clean turn:
88
95
  // it may hold an open critical, and a "clean" verdict here would also CLEAR standing ART-7 debt.
89
96
  if (evidence?.findingsUnreadable) {
90
97
  problems.push('[ART-7] findings 원장을 읽을 수 없습니다 — 열린 치명 발견의 존재를 확인할 수 없는 턴은 깨끗한 턴이 아닙니다 (원장 파일의 권한·형식을 복구하십시오)');
98
+ structured.push({ article: 'ART-7', detail: 'findings 원장을 읽을 수 없습니다' });
91
99
  }
92
100
  if (problems.length === 0)
93
101
  return { block: false };
@@ -100,6 +108,7 @@ function evaluateStop(specs, evidence) {
100
108
  return {
101
109
  block: true,
102
110
  articles,
111
+ violations: structured,
103
112
  reason: `[Holmes-Kit] constitution gate: ${problems.length} article violation(s) must be ` +
104
113
  `fixed before finishing:\n${shown.join('\n')}${more}`,
105
114
  };
@@ -125,6 +134,76 @@ function stopDebtAction(evaluation, guard) {
125
134
  // blocking while problems persist, up to MAX_CONSECUTIVE_BLOCKS, then yields with a loud warning
126
135
  // (bounded, so an unfixable state cannot infinite-loop the session). Any clean stop resets the count.
127
136
  exports.MAX_CONSECUTIVE_BLOCKS = 3;
137
+ /**
138
+ * Constitution violation codes whose ONLY resolution is an owner's approval, not the agent's code.
139
+ *
140
+ * @implements A-SPEC-247
141
+ * stale-parent and post-approval-edit are cleared by re-approving the spec — a human act, out of
142
+ * band. ART-4 (no anchored test), a dangling RTM edge, an unmet obligation: those the agent fixes
143
+ * by writing code, so they are NOT here. Acknowledging a code-resolvable violation would bury a
144
+ * debt the agent is supposed to pay.
145
+ */
146
+ const APPROVAL_RESOLVABLE = ['stale-parent', 'post-approval-edit'];
147
+ const SPEC_ID_RE = /\b([A-Z]-SPEC-\d+(?:\.\d+)?|REQ-\d+)\b/;
148
+ /**
149
+ * Should the stop gate keep re-blocking a turn, or has every unresolved debt already been queued
150
+ * for the owner's decision?
151
+ *
152
+ * @implements A-SPEC-247
153
+ * Measured (Notion UX-2): the stop hook re-blocks a violation whose only fix is an owner approval on
154
+ * every turn — six in a row — even when the request is already waiting in the queue, giving the
155
+ * agent nothing to do and the user the same wall repeatedly. This answers "is anything left that the
156
+ * agent or a NON-waiting approval could resolve?".
157
+ *
158
+ * ASYMMETRY IS THE POINT, and it is what keeps this from becoming amnesty: acknowledged is true ONLY
159
+ * when nothing is blocking AND at least one thing is waiting. A single non-waiting violation — a
160
+ * code debt, or an approval debt nobody queued — and the gate blocks exactly as before. Waiting is
161
+ * read from the pending list (REQ-244's fold), not re-derived. A violation whose detail names no
162
+ * spec id cannot be matched to a request, so it blocks — the safe direction.
163
+ */
164
+ function acknowledgeStop(violations, pending) {
165
+ const waitingTargets = new Set((Array.isArray(pending) ? pending : []).map((p) => p.target));
166
+ const blocking = [];
167
+ const waiting = [];
168
+ for (const vln of Array.isArray(violations) ? violations : []) {
169
+ const detail = String(vln?.detail ?? '');
170
+ const approvalResolvable = APPROVAL_RESOLVABLE.some((k) => detail.includes(k));
171
+ if (!approvalResolvable) {
172
+ blocking.push(`[${vln.article}] ${detail}`);
173
+ continue;
174
+ }
175
+ const id = (SPEC_ID_RE.exec(detail) ?? [])[1];
176
+ if (id && waitingTargets.has(id))
177
+ waiting.push(id);
178
+ else
179
+ blocking.push(`[${vln.article}] ${detail}`); // named no id, or not queued → block
180
+ }
181
+ return { acknowledged: blocking.length === 0 && waiting.length > 0, blocking, waiting };
182
+ }
183
+ /**
184
+ * One-line health of the env approval token, for the server's startup log.
185
+ *
186
+ * @implements A-SPEC-247
187
+ * Measured (Notion UX-4): an invalid token gives no signal until an approval is attempted, so the
188
+ * operator round-trips without knowing why. This is a LOG, not a gate — an absent or invalid token
189
+ * is a legitimate read-only posture, and the server must start regardless. The reason distinguishes
190
+ * "not JSON" from "wrong shape" so the operator knows which to fix.
191
+ */
192
+ function describeTokenHealth(raw) {
193
+ if (raw === undefined || raw === '')
194
+ return 'HOLMES_APPROVAL 토큰 없음 (읽기 전용 — 승인이 필요한 행위만 막힙니다)';
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw);
198
+ }
199
+ catch {
200
+ return 'HOLMES_APPROVAL 무효: JSON 파싱 실패 — 승인이 필요한 행위가 막힙니다';
201
+ }
202
+ if (!(0, risk_gate_1.isValidApproval)(parsed)) {
203
+ return 'HOLMES_APPROVAL 무효: 필수 필드(actor·token·rationale) 누락 — 승인이 필요한 행위가 막힙니다';
204
+ }
205
+ return 'HOLMES_APPROVAL 유효';
206
+ }
128
207
  function decideStopGuard(wantsBlock, priorConsecutiveBlocks, cap = exports.MAX_CONSECUTIVE_BLOCKS) {
129
208
  if (!wantsBlock)
130
209
  return { block: false, nextCount: 0, capped: false };
@@ -413,7 +492,22 @@ if (require.main === module) {
413
492
  catch {
414
493
  findingsUnreadable = true;
415
494
  } // @implements A-SPEC-191 (§4a) — list() absorbs ENOENT; a throw means the ledger EXISTS and cannot be read, which must block, not launder
416
- const out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
495
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
496
+ // @implements A-SPEC-247 — before deciding to re-block, ask whether every unresolved debt is
497
+ // already queued for the owner. If so, tell the user ONCE and let the turn finish; a single
498
+ // non-waiting violation and we block exactly as before.
499
+ let ackWaiting = [];
500
+ if (out.block) {
501
+ try {
502
+ const { readQueue } = require('../governance/approval-queue');
503
+ const ack = acknowledgeStop(out.violations ?? [], readQueue(stopProjectRoot()).pending);
504
+ if (ack.acknowledged) {
505
+ out = { block: false };
506
+ ackWaiting = ack.waiting;
507
+ }
508
+ }
509
+ catch { /* awareness is best-effort; a failure leaves the block intact */ }
510
+ }
417
511
  const guard = decideStopGuard(out.block, guardCountOrZero(sessionId));
418
512
  const persisted = writeGuardCount(sessionId, guard.nextCount);
419
513
  // @implements A-SPEC-134 — the cap-yield is no longer silent: a clean turn clears any debt, a
@@ -438,6 +532,11 @@ if (require.main === module) {
438
532
  process.stdout.write(JSON.stringify({ decision: 'block', reason: out.reason }));
439
533
  process.stderr.write(`${out.reason}\n`);
440
534
  }
535
+ else if (ackWaiting.length > 0) {
536
+ // @implements A-SPEC-247 — acknowledged, not clean: name what is waiting so the user sees
537
+ // the standing approval debt exactly once, and the agent knows the ball is not in its court.
538
+ process.stderr.write(`[Holmes-Kit] 승인 대기 중 — 오너 결정을 기다리는 항목: ${ackWaiting.join(', ')}. npx holmes-kit approve 에서 결재하십시오. (이 부채로는 재차단하지 않습니다)\n`);
539
+ }
441
540
  else if (guard.capped) {
442
541
  process.stderr.write(`[Holmes-Kit] governance gate YIELDING after ${exports.MAX_CONSECUTIVE_BLOCKS} consecutive blocks — issues remain UNRESOLVED:\n${out.reason ?? ''}\n`);
443
542
  }
@@ -99,6 +99,8 @@ const findings_1 = require("../review/findings");
99
99
  const package_1 = require("../review/package");
100
100
  const risk_classifier_1 = require("../guardrail/risk-classifier");
101
101
  const risk_gate_1 = require("../guardrail/risk-gate");
102
+ const approval_queue_1 = require("../governance/approval-queue");
103
+ const approval_grants_1 = require("../governance/approval-grants");
102
104
  const spec_digest_1 = require("../spec/spec-digest");
103
105
  const spec_store_2 = require("../spec/spec-store");
104
106
  const breaking_change_1 = require("../spec/breaking-change");
@@ -148,6 +150,46 @@ const anchor_1 = require("../reverse/anchor");
148
150
  // The hazard that guard addressed is real but belongs to the GIT CHANGE SOURCE: gitChangedFiles
149
151
  // yields top-level-relative paths while scan(root, root) tags root-relative ones, so a disagreement
150
152
  // silently produces wrong impact sets. `GitChangeSource` now enforces exactly that, and only there.
153
+ /**
154
+ * @implements A-SPEC-244
155
+ * File the review request from a handler refusal. A failure to resolve the root files nothing —
156
+ * the queue is information, and a refusal path must not gain a new failure mode from it.
157
+ */
158
+ /**
159
+ * @implements A-SPEC-245
160
+ * Env-or-grant resolution for handler gates. With no resolvable root there is no disk to read
161
+ * grants from, so the env channel alone answers — same fail-closed posture as before this slice.
162
+ */
163
+ function resolveHandlerApproval(rootArg, store, envApproval, action, now) {
164
+ let root;
165
+ try {
166
+ const storeRoot = store?.specsRoot;
167
+ const base = rootArg !== undefined ? rootArg : (typeof storeRoot === 'string' ? storeRoot : undefined);
168
+ root = base !== undefined ? projectRootOf(base) : undefined;
169
+ }
170
+ catch {
171
+ root = undefined;
172
+ }
173
+ if (root === undefined) {
174
+ return (0, risk_gate_1.approvalCovers)(envApproval, action, now) ? { approval: envApproval, source: 'env' } : undefined;
175
+ }
176
+ const r = (0, approval_grants_1.resolveApproval)(root, envApproval, action, now);
177
+ return r ? { ...r, root } : undefined;
178
+ }
179
+ function refusalQueueHint(rootArg, store, req) {
180
+ try {
181
+ // The SpecStore interface carries no root; the file-backed store exposes one. A store without a
182
+ // root files nothing — the queue lives on disk, and with no disk location there is nothing to do.
183
+ const storeRoot = store?.specsRoot;
184
+ const base = rootArg !== undefined ? rootArg : (typeof storeRoot === 'string' ? storeRoot : undefined);
185
+ if (base === undefined)
186
+ return '';
187
+ return (0, approval_queue_1.queueHint)(projectRootOf(base), req);
188
+ }
189
+ catch {
190
+ return '';
191
+ }
192
+ }
151
193
  function projectRootOf(root) {
152
194
  return (0, root_2.resolveProjectRoot)(root).root;
153
195
  }
@@ -771,16 +813,22 @@ function makeRawHandlers(store) {
771
813
  catch {
772
814
  approval = undefined;
773
815
  }
774
- const covered = (0, risk_gate_1.approvalCovers)(approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
816
+ const retireResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
817
+ const covered = retireResolved !== undefined;
775
818
  const sealed = typeof spec.frontmatter.approved_digest === 'string';
776
819
  if (sealed && !covered) {
777
820
  return {
778
821
  ok: false,
779
822
  reason: `${a.id}은(는) 봉인된 문서입니다 — 폐기는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
780
823
  + ' 코드 게이트를 막고 있는 approved T-SPEC 을 폐기하면 그 게이트가 열리므로, 폐기가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
781
- + ' (범위를 쓰면 kind "spec-approve")',
824
+ + ' (범위를 쓰면 kind "spec-approve")'
825
+ + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 폐기' }),
782
826
  };
783
827
  }
828
+ // @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
829
+ if (sealed && retireResolved?.source === 'grant' && retireResolved.root && retireResolved.approval.nonce) {
830
+ (0, approval_grants_1.consumeGrantFile)(retireResolved.root, retireResolved.approval.nonce);
831
+ }
784
832
  const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
785
833
  const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
786
834
  if (blocking.length > 0) {
@@ -835,8 +883,10 @@ function makeRawHandlers(store) {
835
883
  // covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
836
884
  // expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
837
885
  // the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
838
- if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString())) {
839
- return { ok: false, reason: '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)' };
886
+ const approveResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
887
+ if (approveResolved === undefined) {
888
+ return { ok: false, reason: '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)'
889
+ + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인' }) };
840
890
  }
841
891
  // @implements A-SPEC-188 — destination BEFORE seal.
842
892
  // The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
@@ -1025,12 +1075,12 @@ function makeRawHandlers(store) {
1025
1075
  const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
1026
1076
  chain.append({
1027
1077
  ts: new Date().toISOString(),
1028
- actor: approval.actor,
1078
+ actor: approveResolved.approval.actor,
1029
1079
  kind: 'spec-approved',
1030
1080
  summary: `approved ${a.id} sealing ${digest}`,
1031
1081
  inputs: [a.id, digest],
1032
- rationale: approval.rationale,
1033
- authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
1082
+ rationale: approveResolved.approval.rationale,
1083
+ authorization: (0, provenance_chain_1.authorizationRef)(approveResolved.approval.actor, approveResolved.approval.token),
1034
1084
  });
1035
1085
  // @implements A-SPEC-135
1036
1086
  // P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
@@ -1042,14 +1092,18 @@ function makeRawHandlers(store) {
1042
1092
  try {
1043
1093
  const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
1044
1094
  chain.append({
1045
- ts: new Date().toISOString(), actor: approval.actor, kind: 'review-needed',
1095
+ ts: new Date().toISOString(), actor: approveResolved.approval.actor, kind: 'review-needed',
1046
1096
  summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
1047
1097
  inputs: [a.id, priorDigest, digest, ...files],
1048
- rationale: approval.rationale, authorization: '',
1098
+ rationale: approveResolved.approval.rationale, authorization: '',
1049
1099
  });
1050
1100
  }
1051
1101
  catch { /* a missed routing signal is not a broken seal */ }
1052
1102
  }
1103
+ // @implements A-SPEC-245 — single-use: the grant is spent by the seal it authorized.
1104
+ if (approveResolved.source === 'grant' && approveResolved.root && approveResolved.approval.nonce) {
1105
+ (0, approval_grants_1.consumeGrantFile)(approveResolved.root, approveResolved.approval.nonce);
1106
+ }
1053
1107
  return { approved: a.id, digest };
1054
1108
  },
1055
1109
  async spec_list(a) {
@@ -1602,8 +1656,13 @@ function makeRawHandlers(store) {
1602
1656
  // not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
1603
1657
  // An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
1604
1658
  const nowTs = new Date().toISOString();
1605
- if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'review-resolve', target: f.id }, nowTs)) {
1606
- throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`);
1659
+ const rrResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'review-resolve', target: f.id }, nowTs);
1660
+ if (rrResolved === undefined) {
1661
+ throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
1662
+ + refusalQueueHint(a.root, store, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
1663
+ }
1664
+ if (rrResolved.source === 'grant' && rrResolved.root && rrResolved.approval.nonce) {
1665
+ (0, approval_grants_1.consumeGrantFile)(rrResolved.root, rrResolved.approval.nonce);
1607
1666
  }
1608
1667
  // @implements A-SPEC-191 §11 — 한 배치가 같은 id 를 다시 열고 다시 닫으면 lift 는 두 번
1609
1668
  // 일어난다. 소비는 호출당 1회이므로, 세 번의 호출이면 거부됐을 일이 한 배치에서는
@@ -68,4 +68,13 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
68
68
  }
69
69
  return { content: [{ type: 'text', text: JSON.stringify(out) }] };
70
70
  });
71
+ // @implements A-SPEC-247 — surface the approval token's health once at startup, so an invalid
72
+ // token is known before an approval is attempted (Notion UX-4). STDERR only: stdout is the MCP
73
+ // protocol channel, and a single stray byte there breaks the handshake. This is a log, never a
74
+ // gate — the server starts with any token, or none.
75
+ try {
76
+ const { describeTokenHealth } = require('../hooks/stop');
77
+ process.stderr.write(`[Holmes-Kit] ${describeTokenHealth(process.env.HOLMES_APPROVAL)}\n`);
78
+ }
79
+ catch { /* a diagnostic must never keep the server from starting */ }
71
80
  void server.connect(new stdio_js_1.StdioServerTransport());
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.1.12",
4
+ "version": "0.1.14",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",