@holmes-lab/holmes-kit 0.8.1 → 0.10.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 (37) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.js +17 -16
  5. package/dist/holmes/cli/doctor.js +19 -7
  6. package/dist/holmes/cli/mcp-schema-cost.d.ts +18 -0
  7. package/dist/holmes/cli/mcp-schema-cost.js +28 -0
  8. package/dist/holmes/config/config.d.ts +8 -0
  9. package/dist/holmes/config/config.js +1 -1
  10. package/dist/holmes/governance/constitution.d.ts +26 -0
  11. package/dist/holmes/governance/constitution.js +33 -0
  12. package/dist/holmes/governance/ledger-timeline.d.ts +21 -0
  13. package/dist/holmes/governance/ledger-timeline.js +21 -0
  14. package/dist/holmes/hooks/stop.d.ts +24 -0
  15. package/dist/holmes/hooks/stop.js +83 -3
  16. package/dist/holmes/mcp/elicit-approval.d.ts +9 -0
  17. package/dist/holmes/mcp/elicit-approval.js +15 -0
  18. package/dist/holmes/mcp/handlers.d.ts +108 -0
  19. package/dist/holmes/mcp/handlers.js +239 -3
  20. package/dist/holmes/mcp/tool-schemas.js +33 -0
  21. package/dist/holmes/review/mutate.d.ts +17 -0
  22. package/dist/holmes/review/mutate.js +66 -0
  23. package/dist/holmes/review/test-outcomes.d.ts +35 -0
  24. package/dist/holmes/review/test-outcomes.js +108 -0
  25. package/dist/holmes/review/test-runner.d.ts +30 -0
  26. package/dist/holmes/review/test-runner.js +71 -5
  27. package/dist/holmes/spec/approval-status.d.ts +29 -0
  28. package/dist/holmes/spec/approval-status.js +33 -0
  29. package/dist/holmes/spec/kills.d.ts +14 -0
  30. package/dist/holmes/spec/kills.js +28 -0
  31. package/dist/holmes/spec/spec-store.d.ts +9 -0
  32. package/dist/holmes/spec/spec-store.js +17 -0
  33. package/dist/holmes/spec/validator.js +18 -0
  34. package/dist/holmes/spec/version-conflict.d.ts +21 -0
  35. package/dist/holmes/spec/version-conflict.js +21 -0
  36. package/package.json +1 -1
  37. package/playbooks/tdd-slice/PLAYBOOK.md +82 -0
@@ -20,6 +20,15 @@
20
20
  */
21
21
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
22
22
  export declare const ELICITABLE_KINDS: ReadonlySet<string>;
23
+ /**
24
+ * @implements A-SPEC-540.1
25
+ * True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
26
+ * folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
27
+ * the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
28
+ * (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
29
+ * non-disabling value, keeps elicitation ON — byte-identical to before.
30
+ */
31
+ export declare function elicitationDisabled(env: NodeJS.ProcessEnv): boolean;
23
32
  export interface ElicitApprovalRequest {
24
33
  kind: string;
25
34
  target: string;
@@ -2,6 +2,7 @@
2
2
  // @implements A-SPEC-263.1
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.MAX_PRESENTATIONS = exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
5
+ exports.elicitationDisabled = elicitationDisabled;
5
6
  exports.elicitTimeoutMsFor = elicitTimeoutMsFor;
6
7
  exports.classifyElicitError = classifyElicitError;
7
8
  exports.expiredNotice = expiredNotice;
@@ -31,6 +32,20 @@ exports.interpretElicitResult = interpretElicitResult;
31
32
  */
32
33
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
33
34
  exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
35
+ /** The `HOLMES_ELICIT` values that turn the in-client prompt OFF. */
36
+ const ELICIT_DISABLED_TOKENS = new Set(['off', '0', 'false', 'no', 'disabled']);
37
+ /**
38
+ * @implements A-SPEC-540.1
39
+ * True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
40
+ * folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
41
+ * the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
42
+ * (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
43
+ * non-disabling value, keeps elicitation ON — byte-identical to before.
44
+ */
45
+ function elicitationDisabled(env) {
46
+ const v = env.HOLMES_ELICIT;
47
+ return typeof v === 'string' && ELICIT_DISABLED_TOKENS.has(v.trim().toLowerCase());
48
+ }
34
49
  /**
35
50
  * The lock-holding path's timeout truth (and the stage-2 reason dialog's): review-resolve holds
36
51
  * the findings-ledger lock while the dialog waits, so its ceiling never grows (REQ-497 기아 방지).
@@ -158,6 +158,85 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
158
158
  dependents: string[];
159
159
  reason?: undefined;
160
160
  }>;
161
+ /**
162
+ * @implements A-SPEC-538.1
163
+ * The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
164
+ * `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
165
+ * only `status` leaves seal residue that later trips validation; this atomizes the reverse.
166
+ *
167
+ * Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
168
+ * out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
169
+ * gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
170
+ * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
171
+ * drift detection compares against — would silently go stale.
172
+ */
173
+ spec_unseal(a: {
174
+ root?: string;
175
+ id: string;
176
+ }): Promise<{
177
+ ok: boolean;
178
+ reason: string;
179
+ unsealed?: undefined;
180
+ id?: undefined;
181
+ dependents?: undefined;
182
+ } | {
183
+ ok: boolean;
184
+ unsealed: boolean;
185
+ id: string;
186
+ dependents: never[];
187
+ reason: string;
188
+ } | {
189
+ ok: boolean;
190
+ unsealed: boolean;
191
+ id: string;
192
+ dependents: string[];
193
+ reason?: undefined;
194
+ }>;
195
+ /**
196
+ * @implements A-SPEC-538.2
197
+ * Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
198
+ * state, and the concrete blockers still standing between it and approval — so a caller need not
199
+ * parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
200
+ * (the same predicates the code gate and spec_approve read) so the report cannot drift from the
201
+ * acts it describes. No writes, no ledger append.
202
+ */
203
+ approval_status(a: {
204
+ root?: string;
205
+ id: string;
206
+ }): Promise<{
207
+ ok: boolean;
208
+ reason: string;
209
+ } | {
210
+ id: string;
211
+ type?: string;
212
+ status: string;
213
+ sealed: boolean;
214
+ approvedDigest?: string;
215
+ parents: import("../spec/approval-status").ParentApproval[];
216
+ blockers: string[];
217
+ ok: boolean;
218
+ reason?: undefined;
219
+ }>;
220
+ /**
221
+ * @implements A-SPEC-538.3
222
+ * Read-only: return the provenance ledger's events in time order (optionally narrowed to one
223
+ * spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
224
+ * at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
225
+ * projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
226
+ * error.
227
+ */
228
+ ledger_timeline(a: {
229
+ root?: string;
230
+ id?: string;
231
+ }): Promise<{
232
+ ok: boolean;
233
+ reason: string;
234
+ events?: undefined;
235
+ } | {
236
+ ok: boolean;
237
+ events: import("../governance/ledger-timeline").TimelineEntry[];
238
+ reason?: undefined;
239
+ }>;
161
240
  spec_approve(a: {
162
241
  root?: string;
163
242
  id: string;
@@ -165,12 +244,21 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
165
244
  ok: boolean;
166
245
  reason: string;
167
246
  findings?: undefined;
247
+ conflict?: undefined;
168
248
  approved?: undefined;
169
249
  digest?: undefined;
170
250
  } | {
171
251
  ok: boolean;
172
252
  reason: string;
173
253
  findings: import("../spec/validator").Finding[];
254
+ conflict?: undefined;
255
+ approved?: undefined;
256
+ digest?: undefined;
257
+ } | {
258
+ ok: boolean;
259
+ reason: string;
260
+ conflict: import("../spec/version-conflict").ConflictDetail;
261
+ findings?: undefined;
174
262
  approved?: undefined;
175
263
  digest?: undefined;
176
264
  } | {
@@ -179,6 +267,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
179
267
  ok?: undefined;
180
268
  reason?: undefined;
181
269
  findings?: undefined;
270
+ conflict?: undefined;
182
271
  }>;
183
272
  spec_list(a: any): Promise<{
184
273
  specs: {
@@ -347,7 +436,25 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
347
436
  head?: string;
348
437
  since?: string;
349
438
  mark?: string;
439
+ mutate?: string;
350
440
  }): Promise<{
441
+ mutate: {
442
+ tspec: string;
443
+ aspec: string | undefined;
444
+ coveringFiles: string[];
445
+ results: ({
446
+ mutation: import("../spec/kills").Mutation;
447
+ applied: boolean;
448
+ reason: string;
449
+ } | {
450
+ applied: boolean;
451
+ verdict?: "killed" | "survived";
452
+ mutation: import("../spec/kills").Mutation;
453
+ reason?: undefined;
454
+ })[];
455
+ survivors: import("../spec/kills").Mutation[];
456
+ };
457
+ } | {
351
458
  baselineRecorded?: string | undefined;
352
459
  scopeFallback?: "full" | undefined;
353
460
  tier: import("../rtm/test-scope").RegressionTier;
@@ -359,6 +466,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
359
466
  tail: string;
360
467
  unresolvedFiles: string[];
361
468
  changeSource: ChangeSourceInfo;
469
+ mutate?: undefined;
362
470
  }>;
363
471
  issue_localize(a: {
364
472
  root: string;
@@ -68,7 +68,10 @@ const rtm_check_1 = require("../rtm/rtm-check");
68
68
  const test_scope_1 = require("../rtm/test-scope");
69
69
  const gap_analyzer_1 = require("../rtm/gap-analyzer");
70
70
  const test_runner_1 = require("../review/test-runner");
71
+ const kills_1 = require("../spec/kills");
72
+ const mutate_1 = require("../review/mutate");
71
73
  const test_evidence_1 = require("../review/test-evidence");
74
+ const test_outcomes_1 = require("../review/test-outcomes");
72
75
  const localize_1 = require("../rtm/localize");
73
76
  const maintenance_analyze_1 = require("./maintenance-analyze");
74
77
  const maintenance_evidence_1 = require("./maintenance-evidence");
@@ -90,6 +93,53 @@ const root_1 = require("../project/root");
90
93
  // under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
91
94
  // left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
92
95
  // only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
96
+ // @implements A-SPEC-534.8
97
+ // The governed source file a `kills` mutation targets: one that @implements the A-SPEC AND contains
98
+ // the `where` literal. Bounded walk, skips vendored/test/hidden dirs. null when none qualifies.
99
+ function sourceFileWithMutation(root, aspecId, where) {
100
+ if (!where)
101
+ return null;
102
+ const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
103
+ let found = null;
104
+ const walk = (d) => {
105
+ if (found)
106
+ return;
107
+ let entries;
108
+ try {
109
+ entries = fs.readdirSync(d, { withFileTypes: true });
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ for (const e of entries) {
115
+ if (found)
116
+ return;
117
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'reference'
118
+ || e.name === 'vendor' || e.name === 'third_party' || e.name.startsWith('.'))
119
+ continue;
120
+ const p = path.join(d, e.name);
121
+ if (e.isDirectory()) {
122
+ walk(p);
123
+ continue;
124
+ }
125
+ if (!SOURCE.test(e.name) || /\.test\./.test(e.name))
126
+ continue;
127
+ let text;
128
+ try {
129
+ text = fs.readFileSync(p, 'utf8');
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (text.includes(`@implements ${aspecId}`) && text.includes(where)) {
135
+ found = p;
136
+ return;
137
+ }
138
+ }
139
+ };
140
+ walk(root);
141
+ return found;
142
+ }
93
143
  const cacheDirFor = (root) => {
94
144
  const r = (0, root_2.resolveProjectRoot)(root);
95
145
  if (r.marker !== 'given')
@@ -139,6 +189,9 @@ const spec_digest_1 = require("../spec/spec-digest");
139
189
  const spec_store_2 = require("../spec/spec-store");
140
190
  const breaking_change_1 = require("../spec/breaking-change");
141
191
  const approval_blockers_1 = require("../spec/approval-blockers");
192
+ const approval_status_1 = require("../spec/approval-status");
193
+ const ledger_timeline_1 = require("../governance/ledger-timeline");
194
+ const version_conflict_1 = require("../spec/version-conflict");
142
195
  const ledger_store_1 = require("../governance/ledger-store");
143
196
  const provenance_chain_1 = require("../governance/provenance-chain");
144
197
  const ledger_lock_1 = require("../governance/ledger-lock");
@@ -653,6 +706,11 @@ function makeRawHandlers(store, opts) {
653
706
  // stays byte-identical to the pre-elicitation one — nothing is ever worse than before the
654
707
  // channel existed.
655
708
  const tryElicit = async (kind, target, summary) => {
709
+ // @implements A-SPEC-540.1 — operator opt-out: with HOLMES_ELICIT=off the server never shows the
710
+ // in-client prompt, folding to the byte-identical `silent` (refuse+enqueue) path so claude/agy/
711
+ // codex all converge on one out-of-band surface (holmes-kit approve). Unset ⇒ unchanged.
712
+ if ((0, elicit_approval_1.elicitationDisabled)(process.env))
713
+ return { kind: 'silent' };
656
714
  if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
657
715
  return { kind: 'silent' };
658
716
  try {
@@ -1124,6 +1182,146 @@ function makeRawHandlers(store, opts) {
1124
1182
  });
1125
1183
  return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
1126
1184
  },
1185
+ /**
1186
+ * @implements A-SPEC-538.1
1187
+ * The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
1188
+ * `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
1189
+ * only `status` leaves seal residue that later trips validation; this atomizes the reverse.
1190
+ *
1191
+ * Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
1192
+ * out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
1193
+ * gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
1194
+ * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
1195
+ * drift detection compares against — would silently go stale.
1196
+ */
1197
+ async spec_unseal(a) {
1198
+ const all = await store.list();
1199
+ if (all.filter((s) => s.id === a.id).length > 1) {
1200
+ return {
1201
+ ok: false,
1202
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 해제 전에 거부합니다.`,
1203
+ };
1204
+ }
1205
+ const cur = await store.read(a.id);
1206
+ if (!cur) {
1207
+ const dir = store.specsRoot;
1208
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1209
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1210
+ }
1211
+ const spec = cur.spec;
1212
+ if (!spec.type) {
1213
+ return {
1214
+ ok: false,
1215
+ reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 봉인 해제할 수 없습니다.`
1216
+ + ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 시도하십시오.`,
1217
+ };
1218
+ }
1219
+ // Sealed ⇔ carries approved_digest. Un-sealing an already-draft spec writes NOTHING (a second
1220
+ // un-seal is history, not graffiti) — mirrors spec_retire's idempotent no-op.
1221
+ const sealed = typeof spec.frontmatter.approved_digest === 'string';
1222
+ if (!sealed) {
1223
+ return { ok: true, unsealed: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 미봉인(draft) 상태입니다` };
1224
+ }
1225
+ const approvalRaw = process.env.HOLMES_APPROVAL;
1226
+ let approval;
1227
+ try {
1228
+ approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
1229
+ }
1230
+ catch {
1231
+ approval = undefined;
1232
+ }
1233
+ const resolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
1234
+ if (resolved === undefined) {
1235
+ return {
1236
+ ok: false,
1237
+ reason: `${a.id}은(는) 봉인된 문서입니다 — 봉인 해제는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
1238
+ + ' 봉인을 해제하면 코드 게이트가 요구하던 approved 스펙의 봉인이 사라지므로, 해제가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
1239
+ + ' (범위를 쓰면 kind "spec-approve")'
1240
+ + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 해제' }),
1241
+ };
1242
+ }
1243
+ // @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
1244
+ if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce) {
1245
+ (0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
1246
+ }
1247
+ const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
1248
+ const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
1249
+ if (blocking.length > 0) {
1250
+ return {
1251
+ ok: false,
1252
+ reason: `${a.id}의 봉인을 해제하면 approved 문서 ${blocking.join(', ')}의 parent_digests 가 stale 이 됩니다 — 지금 그 사슬을 지탱하고 있으므로 거부합니다.`
1253
+ + ' 해당 문서를 먼저 해제/폐기하거나 부모를 다른 문서로 옮기십시오.',
1254
+ };
1255
+ }
1256
+ // Destination BEFORE the write (A-SPEC-188 order): a resolution failure leaves nothing behind.
1257
+ const dest = resolveLedgerRoot(a.root);
1258
+ if (!dest.ok)
1259
+ return { ok: false, reason: dest.reason };
1260
+ const ledgerRoot = dest.root;
1261
+ const fm = { ...spec.frontmatter };
1262
+ delete fm.approved_digest; // the seal this act withdraws
1263
+ delete fm.parent_digests;
1264
+ try {
1265
+ await store.write({ ...spec, status: 'draft', frontmatter: fm }, { expectedVersion: cur.version });
1266
+ }
1267
+ catch (e) {
1268
+ if (e instanceof spec_store_2.SpecVersionConflictError) {
1269
+ return { ok: false, reason: `봉인 해제 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 해제는 아무것도 쓰지 않았습니다.` };
1270
+ }
1271
+ throw e;
1272
+ }
1273
+ new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
1274
+ ts: new Date().toISOString(),
1275
+ actor: approval?.actor ?? 'unattributed',
1276
+ kind: 'spec-unsealed',
1277
+ summary: `unsealed ${a.id}`,
1278
+ inputs: [a.id],
1279
+ rationale: approval?.rationale ?? 'unsealed',
1280
+ ...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
1281
+ });
1282
+ return { ok: true, unsealed: true, id: a.id, dependents: dependents.map((s) => s.id) };
1283
+ },
1284
+ /**
1285
+ * @implements A-SPEC-538.2
1286
+ * Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
1287
+ * state, and the concrete blockers still standing between it and approval — so a caller need not
1288
+ * parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
1289
+ * (the same predicates the code gate and spec_approve read) so the report cannot drift from the
1290
+ * acts it describes. No writes, no ledger append.
1291
+ */
1292
+ async approval_status(a) {
1293
+ assertSpecStoreReachable('approval_status', store, a.root); // @implements A-SPEC-419
1294
+ const all = await store.list();
1295
+ if (all.filter((s) => s.id === a.id).length > 1) {
1296
+ return {
1297
+ ok: false,
1298
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본의 상태를 물었는지 도구가 고를 수 없어 거부합니다.`,
1299
+ };
1300
+ }
1301
+ const cur = await store.read(a.id);
1302
+ if (!cur) {
1303
+ const dir = store.specsRoot;
1304
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1305
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1306
+ }
1307
+ return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)) };
1308
+ },
1309
+ /**
1310
+ * @implements A-SPEC-538.3
1311
+ * Read-only: return the provenance ledger's events in time order (optionally narrowed to one
1312
+ * spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
1313
+ * at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
1314
+ * projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
1315
+ * error.
1316
+ */
1317
+ async ledger_timeline(a) {
1318
+ const dest = resolveLedgerRoot(a.root);
1319
+ if (!dest.ok)
1320
+ return { ok: false, reason: dest.reason };
1321
+ const dir = path.join(dest.root, '.ax', 'ledger');
1322
+ const events = fs.existsSync(dir) ? new ledger_store_1.FileLedgerStore(dir).loadAll() : [];
1323
+ return { ok: true, events: (0, ledger_timeline_1.timelineFrom)(events, a.id) };
1324
+ },
1127
1325
  async spec_approve(a) {
1128
1326
  const approvalRaw = process.env.HOLMES_APPROVAL;
1129
1327
  let approval;
@@ -1259,8 +1457,15 @@ function makeRawHandlers(store, opts) {
1259
1457
  // element instead leaves a window in which an external edit is silently destroyed and the
1260
1458
  // STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
1261
1459
  const cur = await store.read(a.id);
1262
- if (!cur)
1263
- return { ok: false, reason: `spec ${a.id} not found` };
1460
+ // @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
1461
+ // so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
1462
+ // files when there are any; byte-identical to the legacy message when there are none. The
1463
+ // store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
1464
+ if (!cur) {
1465
+ const dir = store.specsRoot;
1466
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1467
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1468
+ }
1264
1469
  const spec = cur.spec;
1265
1470
  // @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
1266
1471
  // PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
@@ -1358,10 +1563,15 @@ function makeRawHandlers(store, opts) {
1358
1563
  }
1359
1564
  catch (e) {
1360
1565
  if (e instanceof spec_store_2.SpecVersionConflictError) {
1566
+ // @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
1567
+ // wins); it now also CARRIES the conflict: the version this act read, the version now on
1568
+ // disk, and what to retry. Re-read to learn the current version (null if it vanished).
1569
+ const now = await store.read(a.id).catch(() => null);
1361
1570
  return {
1362
1571
  ok: false,
1363
1572
  reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
1364
1573
  + ' 이번 승인은 아무것도 쓰지 않았습니다.',
1574
+ conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
1365
1575
  };
1366
1576
  }
1367
1577
  // @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
@@ -1829,6 +2039,26 @@ function makeRawHandlers(store, opts) {
1829
2039
  // Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
1830
2040
  // (what the constitution's ART-4 prefers over the syntactic count).
1831
2041
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
2042
+ // @implements A-SPEC-534.8 — `mutate`: run a T-SPEC's declared `kills` mutations against the
2043
+ // A-SPEC's source and report which SURVIVED (a discriminating-power gap). Selective, opt-in via
2044
+ // the argument; absent → the ordinary run below is untouched.
2045
+ if (a.mutate) {
2046
+ const tspec = specs.find((s) => s.id === a.mutate && s.type === 'T-SPEC');
2047
+ const kills = tspec ? (0, kills_1.parseKills)(tspec.frontmatter) : [];
2048
+ const aspecId = tspec?.dependsOn[0];
2049
+ const anchors = (0, test_scope_1.scanTestAnchors)(root);
2050
+ const coveringFiles = aspecId
2051
+ ? Object.entries(anchors).filter(([, ids]) => ids.includes(aspecId)).map(([f]) => f) : [];
2052
+ const results = kills.map((m) => {
2053
+ const src = aspecId ? sourceFileWithMutation(root, aspecId, m.where) : null;
2054
+ if (!src)
2055
+ return { mutation: m, applied: false, reason: 'no governed source anchors this A-SPEC and contains `where`' };
2056
+ const r = (0, mutate_1.runKillsOnFile)(src, m, coveringFiles, (files) => (0, test_runner_1.runJestOutcomes)(files, root));
2057
+ return { mutation: m, ...r };
2058
+ });
2059
+ const survivors = results.filter((r) => r.verdict === 'survived').map((r) => r.mutation);
2060
+ return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors } };
2061
+ }
1832
2062
  const g = new rtm_graph_1.RtmGraph();
1833
2063
  let testScope;
1834
2064
  try {
@@ -1869,6 +2099,12 @@ function makeRawHandlers(store, opts) {
1869
2099
  if (verified) {
1870
2100
  (0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
1871
2101
  }
2102
+ // @implements A-SPEC-534.5 — RED-first outcome evidence for ART-8. Recorded UNCONDITIONALLY,
2103
+ // unlike the green-only baseline above: a red-assertion recorded before the code is exactly what
2104
+ // the red→green sequence needs. Append-only, one record per (A-SPEC, outcome) at this HEAD.
2105
+ if (result.outcomeByFile) {
2106
+ (0, test_outcomes_1.appendOutcomes)(root, (0, test_outcomes_1.buildOutcomeRecords)(result.outcomeByFile, anchors, head, new Date().toISOString()));
2107
+ }
1872
2108
  // @implements A-SPEC-128
1873
2109
  // The baseline is written under EXACTLY the condition that already gates evidence: a run that
1874
2110
  // actually executed and passed. A red or skipped run must never become the reference point for
@@ -3060,7 +3296,7 @@ id: ${aspecId}
3060
3296
  type: A-SPEC
3061
3297
  title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
3062
3298
  status: draft
3063
- slice: ${a.sliceName}
3299
+ slice: ${(0, yaml_scalar_1.yamlScalar)(a.sliceName)}
3064
3300
  priority: P1
3065
3301
  independent_test: true
3066
3302
  depends_on:
@@ -101,6 +101,38 @@ exports.TOOL_SCHEMAS = {
101
101
  required: ['id'],
102
102
  },
103
103
  },
104
+ spec_unseal: {
105
+ description: "The inverse of spec_approve: return ONE sealed (approved) spec to an editable `status: draft`, clearing `approved_digest` and `parent_digests` in a single ACT, and record `spec-unsealed` in the provenance ledger. Un-sealing WITHDRAWS a seal, so it requires the same out-of-band HOLMES_APPROVAL as spec_approve/spec_retire's sealed path (fail-closed): un-sealing an approved T-SPEC removes the code gate's demand, which unguarded would be an approval bypass. Refuses when an APPROVED spec depends on the target — that dependent's parent_digests would silently go stale — naming the blockers. Idempotent: un-sealing a spec that is already draft (never sealed) writes nothing and returns unsealed:false. Written only at the version this act read; a concurrent edit wins and the un-seal is refused for retry. Distinct from spec_retire (which withdraws authority to status:outdated); un-seal keeps the spec alive and editable.",
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ id: str('Id of the spec to un-seal (return to draft).'),
110
+ root: str('Optional when the server is bound to a file store — the ledger location is derived from the store itself; if supplied it must resolve to the SAME project.'),
111
+ },
112
+ required: ['id'],
113
+ },
114
+ },
115
+ approval_status: {
116
+ description: "Read-only: report ONE spec's approval/seal state without the caller parsing files — { sealed, approvedDigest?, status, parents: [{ id, status, sealed, resolved }], blockers: [] }. `sealed` is the same approved_digest predicate the code gate reads; `blockers` is the exact list spec_approve would refuse on (empty once approved), so the report cannot drift from the acts it describes. Each parent entry shows that parent's OWN live seal state (an unresolved depends_on id is reported as status:'missing', resolved:false — not dropped). No writes, no ledger append. Refuses a duplicate or unknown id.",
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ id: str('Id of the spec whose approval/seal state to report.'),
121
+ root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
122
+ },
123
+ required: ['id'],
124
+ },
125
+ },
126
+ ledger_timeline: {
127
+ description: "Read-only: return the provenance ledger's events in time order — { events: [{ ts, kind, actor, summary, inputs }] } ascending by ts (tiebroken by chain seq). Pass an `id` to narrow to one spec (only events whose `inputs` reference it: spec-approved, spec-unsealed, spec-retired, review-needed …). The chain-integrity fields (hash/prevHash/seq/replicaId) are plumbing and are projected away — this is the governance history, not the tamper-evidence chain. No writes. An absent ledger is an empty history, not an error.",
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: {
131
+ id: str('Optional spec id — narrow the timeline to events that reference it.'),
132
+ root: str('Optional when the server is bound to a file store; the ledger location is derived from the store, or from this root.'),
133
+ },
134
+ },
135
+ },
104
136
  spec_approve: {
105
137
  description: 'Approve a spec as a sealing ACT: confirm the ledger destination BEFORE sealing → validate (zero errors) → record approved_digest + parent_digests snapshots → status: approved (written only at the version this act read; a concurrent edit wins and the approval is refused for retry) → append spec-approved to the provenance ledger. Requires a valid out-of-band HOLMES_APPROVAL in the SERVER environment (fail-closed; nothing in the request can substitute). Refuses an unsealed approved parent — seal parents first.',
106
138
  inputSchema: {
@@ -203,6 +235,7 @@ exports.TOOL_SCHEMAS = {
203
235
  head: str('Head rev (e.g. HEAD). Optional — omit to compare against a baseline.'),
204
236
  since: str('Baseline label to compare against when no git range is given (default `last-green`, recorded by a passing test_run). Works with or without version control.'),
205
237
  mark: str('Baseline label to record when the run passes (default `last-green`). Nothing is recorded for a red or skipped run.'),
238
+ mutate: str('T-SPEC id (e.g. T-SPEC-129.1). When set, runs that spec’s declared `kills` mutations against the A-SPEC’s source and reports which SURVIVED — a discriminating-power gap. Selective, opt-in; the ordinary run is skipped.'),
206
239
  },
207
240
  required: ['root'],
208
241
  },
@@ -0,0 +1,17 @@
1
+ import { Mutation } from '../spec/kills';
2
+ import { TestOutcome } from './test-runner';
3
+ /**
4
+ * Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
5
+ * file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
6
+ * is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
7
+ */
8
+ export declare function mutationVerdict(outcomeByFile: Record<string, TestOutcome>, coveringFiles: string[]): 'killed' | 'survived';
9
+ /**
10
+ * Apply one mutation to a source file, run the covering tests through the injected runner, judge the
11
+ * verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
12
+ * is never touched).
13
+ */
14
+ export declare function runKillsOnFile(sourceFile: string, mutation: Mutation, coveringFiles: string[], run: (files: string[]) => Record<string, TestOutcome>): {
15
+ applied: boolean;
16
+ verdict?: 'killed' | 'survived';
17
+ };
@@ -0,0 +1,66 @@
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.mutationVerdict = mutationVerdict;
37
+ exports.runKillsOnFile = runKillsOnFile;
38
+ // @implements A-SPEC-534.8
39
+ const fs = __importStar(require("node:fs"));
40
+ const kills_1 = require("../spec/kills");
41
+ /**
42
+ * Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
43
+ * file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
44
+ * is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
45
+ */
46
+ function mutationVerdict(outcomeByFile, coveringFiles) {
47
+ return coveringFiles.some((f) => outcomeByFile[f] === 'red-assertion') ? 'killed' : 'survived';
48
+ }
49
+ /**
50
+ * Apply one mutation to a source file, run the covering tests through the injected runner, judge the
51
+ * verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
52
+ * is never touched).
53
+ */
54
+ function runKillsOnFile(sourceFile, mutation, coveringFiles, run) {
55
+ const original = fs.readFileSync(sourceFile, 'utf8');
56
+ const mutated = (0, kills_1.applyMutation)(original, mutation);
57
+ if (mutated === null)
58
+ return { applied: false }; // where absent: never touch the file
59
+ fs.writeFileSync(sourceFile, mutated);
60
+ try {
61
+ return { applied: true, verdict: mutationVerdict(run(coveringFiles), coveringFiles) };
62
+ }
63
+ finally {
64
+ fs.writeFileSync(sourceFile, original); // ALWAYS restore, even if run() threw
65
+ }
66
+ }