@holmes-lab/holmes-kit 0.1.18 → 0.2.1

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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +5 -1
  4. package/dist/holmes/cli/approve-context.d.ts +2 -0
  5. package/dist/holmes/cli/approve-context.js +180 -0
  6. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  7. package/dist/holmes/cli/approve-ref.js +40 -0
  8. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  9. package/dist/holmes/cli/approve-watch.js +94 -0
  10. package/dist/holmes/cli/approve.d.ts +50 -13
  11. package/dist/holmes/cli/approve.js +354 -38
  12. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  13. package/dist/holmes/cli/codex-toml.js +282 -0
  14. package/dist/holmes/cli/doctor.js +206 -0
  15. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  16. package/dist/holmes/cli/gitignore-merge.js +17 -1
  17. package/dist/holmes/cli/index.d.ts +23 -0
  18. package/dist/holmes/cli/index.js +490 -21
  19. package/dist/holmes/cli/init.js +92 -0
  20. package/dist/holmes/cli/interactive-prompt.js +4 -4
  21. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  22. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  23. package/dist/holmes/cli/screen-safe.js +760 -0
  24. package/dist/holmes/governance/approval-queue.js +56 -4
  25. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  26. package/dist/holmes/governance/ledger-rechain.js +95 -0
  27. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  28. package/dist/holmes/governance/provenance-chain.js +91 -16
  29. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  30. package/dist/holmes/governance/provenance-ledger.js +10 -0
  31. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  32. package/dist/holmes/guardrail/risk-gate.js +10 -0
  33. package/dist/holmes/guardrail/write-target.js +7 -0
  34. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  35. package/dist/holmes/mcp/elicit-approval.js +79 -0
  36. package/dist/holmes/mcp/handlers.d.ts +7 -2
  37. package/dist/holmes/mcp/handlers.js +190 -24
  38. package/dist/holmes/mcp/server.js +26 -1
  39. package/dist/holmes/spec/id-collision.d.ts +39 -0
  40. package/dist/holmes/spec/id-collision.js +86 -0
  41. package/dist/holmes/spec/spec-store.js +9 -1
  42. package/package.json +1 -1
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CODEX_TABLE = void 0;
4
+ exports.codexMcpBlock = codexMcpBlock;
5
+ exports.mergeCodexToml = mergeCodexToml;
6
+ exports.removeCodexToml = removeCodexToml;
7
+ exports.readCodexHolmesEntry = readCodexHolmesEntry;
8
+ /** The one table holmes-kit owns in a Codex config.toml. */
9
+ exports.CODEX_TABLE = 'mcp_servers.holmes-kit';
10
+ const HEADER = `[${exports.CODEX_TABLE}]`;
11
+ /** TOML basic-string escape — backslash and quote, the named whitespace escapes, then any remaining
12
+ * C0 control character as \uXXXX. Built by code point so no literal control byte lives in the source. */
13
+ function tomlStr(s) {
14
+ let out = '';
15
+ for (const ch of s) {
16
+ const code = ch.codePointAt(0);
17
+ if (ch === '\\')
18
+ out += '\\\\';
19
+ else if (ch === '"')
20
+ out += '\\"';
21
+ else if (ch === '\n')
22
+ out += '\\n';
23
+ else if (ch === '\r')
24
+ out += '\\r';
25
+ else if (ch === '\t')
26
+ out += '\\t';
27
+ else if (code < 0x20 || code === 0x7f)
28
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
29
+ else
30
+ out += ch;
31
+ }
32
+ return `"${out}"`;
33
+ }
34
+ /**
35
+ * Serialize the `[mcp_servers.holmes-kit]` table. `env` is an inline table so the whole entry is ONE
36
+ * contiguous region (no `[mcp_servers.holmes-kit.env]` child header) — that keeps the merge boundary
37
+ * unambiguous: our region runs from the header to the next table header. Ends with a trailing newline.
38
+ */
39
+ function codexMcpBlock(entry, specsDir) {
40
+ const args = entry.args.map(tomlStr).join(', ');
41
+ return [
42
+ HEADER,
43
+ `command = ${tomlStr(entry.command)}`,
44
+ `args = [${args}]`,
45
+ `env = { HOLMES_SPECS = ${tomlStr(specsDir)} }`,
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ /** Advance the string/bracket state across one line's characters (comments end the line). */
50
+ function advance(line, st) {
51
+ let { ml, depth } = st;
52
+ let i = 0;
53
+ while (i < line.length) {
54
+ if (ml === '"""') {
55
+ if (line.startsWith('"""', i)) {
56
+ ml = null;
57
+ i += 3;
58
+ }
59
+ else if (line[i] === '\\') {
60
+ i += 2;
61
+ }
62
+ else
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (ml === "'''") { // literal: no escapes
67
+ if (line.startsWith("'''", i)) {
68
+ ml = null;
69
+ i += 3;
70
+ }
71
+ else
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (line.startsWith('"""', i)) {
76
+ ml = '"""';
77
+ i += 3;
78
+ continue;
79
+ }
80
+ if (line.startsWith("'''", i)) {
81
+ ml = "'''";
82
+ i += 3;
83
+ continue;
84
+ }
85
+ const c = line[i];
86
+ if (c === '#')
87
+ break; // comment runs to end of line
88
+ if (c === '"') {
89
+ i += 1;
90
+ while (i < line.length && line[i] !== '"') {
91
+ if (line[i] === '\\')
92
+ i += 1;
93
+ i += 1;
94
+ }
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (c === "'") {
99
+ i += 1;
100
+ while (i < line.length && line[i] !== "'")
101
+ i += 1;
102
+ i += 1;
103
+ continue;
104
+ }
105
+ if (c === '[' || c === '{') {
106
+ depth += 1;
107
+ i += 1;
108
+ continue;
109
+ }
110
+ if (c === ']' || c === '}') {
111
+ depth = Math.max(0, depth - 1);
112
+ i += 1;
113
+ continue;
114
+ }
115
+ i += 1;
116
+ }
117
+ return { ml, depth };
118
+ }
119
+ /** State at the START of each line (index-aligned to `lines`). */
120
+ function lineStartStates(lines) {
121
+ const states = [];
122
+ let st = { ml: null, depth: 0 };
123
+ for (const line of lines) {
124
+ states.push(st);
125
+ st = advance(line, st);
126
+ }
127
+ return states;
128
+ }
129
+ /**
130
+ * The dotted key path of a table-header line, quotes stripped and each segment trimmed — so
131
+ * `[mcp_servers.holmes-kit]`, `[mcp_servers."holmes-kit"]`, and `[ mcp_servers . holmes-kit ]` all
132
+ * yield ['mcp_servers','holmes-kit'] (TOML treats them as the SAME table; missing an alias would
133
+ * append a duplicate table and invalidate the whole file — round-1 quoted-key, round-2 whitespace).
134
+ * Returns null when the line is not a table header.
135
+ */
136
+ function tableKeyPath(line) {
137
+ const m = line.match(/^\s*\[\[?([^\]]*)\]\]?\s*(#.*)?$/);
138
+ if (!m)
139
+ return null;
140
+ const inner = m[1];
141
+ const segs = [];
142
+ let cur = '';
143
+ let q = null;
144
+ for (let i = 0; i < inner.length; i++) {
145
+ const c = inner[i];
146
+ if (q) {
147
+ if (c === q)
148
+ q = null;
149
+ else
150
+ cur += c;
151
+ continue;
152
+ }
153
+ if (c === '"' || c === "'") {
154
+ q = c;
155
+ continue;
156
+ }
157
+ if (c === '.') {
158
+ segs.push(cur.trim());
159
+ cur = '';
160
+ continue;
161
+ }
162
+ cur += c;
163
+ }
164
+ segs.push(cur.trim());
165
+ if (q !== null)
166
+ return null; // unbalanced quote — not a clean header
167
+ return segs;
168
+ }
169
+ const isOurTable = (kp) => kp.length === 2 && kp[0] === 'mcp_servers' && kp[1] === 'holmes-kit';
170
+ const isOurPrefix = (kp) => kp.length >= 2 && kp[0] === 'mcp_servers' && kp[1] === 'holmes-kit';
171
+ /**
172
+ * Locate our region as a [start, end) line-index pair, or null if absent. `start` is our table's
173
+ * header line; `end` is the first later line that opens a DIFFERENT top-level table (exclusive), or
174
+ * lines.length. Only real headers (outside strings, bracket-depth 0) are considered — AND the end
175
+ * boundary closes on ANY top-level header line, even one `tableKeyPath` cannot normalize (e.g. a
176
+ * quoted key containing `]`). That fail-safe is the point: an unrecognized header is never OURS, so
177
+ * treating it as a boundary preserves the foreign table rather than swallowing it (round-3 finding).
178
+ */
179
+ function ourRegion(lines) {
180
+ const states = lineStartStates(lines);
181
+ const isTopLevelHeaderLine = (i) => states[i].ml === null && states[i].depth === 0 && /^\s*\[/.test(lines[i]);
182
+ const keyAt = (i) => (isTopLevelHeaderLine(i) ? tableKeyPath(lines[i]) : null);
183
+ let start = -1;
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const kp = keyAt(i);
186
+ if (kp && isOurTable(kp)) {
187
+ start = i;
188
+ break;
189
+ }
190
+ }
191
+ if (start === -1)
192
+ return null;
193
+ let end = lines.length;
194
+ for (let i = start + 1; i < lines.length; i++) {
195
+ if (!isTopLevelHeaderLine(i))
196
+ continue;
197
+ const kp = tableKeyPath(lines[i]);
198
+ if (!(kp && isOurPrefix(kp))) {
199
+ end = i;
200
+ break;
201
+ } // any non-our (incl. unparseable) header closes us
202
+ }
203
+ return { start, end };
204
+ }
205
+ /**
206
+ * Merge `block` (a full `codexMcpBlock` output) into `existing`. When `existing` is null/empty the
207
+ * block stands alone. When our table is already present its region is REPLACED (no duplicate); when
208
+ * absent the block is appended after a blank-line separator. Every other line is preserved verbatim.
209
+ */
210
+ function mergeCodexToml(existing, block) {
211
+ if (existing == null || existing.trim() === '')
212
+ return block;
213
+ const lines = existing.split('\n');
214
+ const region = ourRegion(lines);
215
+ const blockLines = block.replace(/\n$/, '').split('\n');
216
+ if (region) {
217
+ const after = lines.slice(region.end);
218
+ const merged = [...lines.slice(0, region.start), ...blockLines, ...after];
219
+ return merged.join('\n').replace(/\n*$/, '\n');
220
+ }
221
+ // Append: exactly one blank line between the user's content and our block.
222
+ const base = existing.replace(/\n*$/, '');
223
+ return `${base}\n\n${block.replace(/\n*$/, '')}\n`;
224
+ }
225
+ /** Strip our region from `existing`, preserving everything else. Absent → returned unchanged. */
226
+ function removeCodexToml(existing) {
227
+ const lines = existing.split('\n');
228
+ const region = ourRegion(lines);
229
+ if (!region)
230
+ return existing;
231
+ const before = lines.slice(0, region.start);
232
+ const after = lines.slice(region.end);
233
+ // Drop a trailing blank line left dangling between `before` and `after` so removal is clean.
234
+ while (before.length > 0 && before[before.length - 1].trim() === '')
235
+ before.pop();
236
+ const merged = [...before, ...after];
237
+ const joined = merged.join('\n');
238
+ if (joined.trim() === '')
239
+ return '';
240
+ return joined.replace(/\n*$/, '\n');
241
+ }
242
+ /**
243
+ * Read `{command,args}` from our table in a config.toml — for doctor's drift check. Parses only the
244
+ * shape `codexMcpBlock` writes; anything it cannot read returns null (doctor then WARNs rather than
245
+ * translating an unreadable wiring into a pass).
246
+ */
247
+ function readCodexHolmesEntry(raw) {
248
+ const lines = raw.split('\n');
249
+ const region = ourRegion(lines);
250
+ if (!region)
251
+ return null;
252
+ let command = null;
253
+ let args = null;
254
+ for (let i = region.start + 1; i < region.end; i++) {
255
+ const cmd = lines[i].match(/^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"\s*(#.*)?$/);
256
+ if (cmd)
257
+ command = unescapeToml(cmd[1]);
258
+ const arr = lines[i].match(/^\s*args\s*=\s*\[(.*)\]\s*(#.*)?$/);
259
+ if (arr)
260
+ args = parseTomlStringArray(arr[1]);
261
+ }
262
+ if (command === null || args === null)
263
+ return null;
264
+ return { command, args };
265
+ }
266
+ function unescapeToml(s) {
267
+ return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, e) => {
268
+ if (e[0] === 'u')
269
+ return String.fromCharCode(parseInt(e.slice(1), 16));
270
+ const map = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
271
+ return map[e] ?? e;
272
+ });
273
+ }
274
+ /** Parse a TOML inline array of basic strings: `"a", "b"` → ['a','b']. Non-conforming → []. */
275
+ function parseTomlStringArray(inner) {
276
+ const out = [];
277
+ const re = /"((?:[^"\\]|\\.)*)"/g;
278
+ let mm;
279
+ while ((mm = re.exec(inner)) !== null)
280
+ out.push(unescapeToml(mm[1]));
281
+ return out;
282
+ }
@@ -51,6 +51,7 @@ const settings_merge_1 = require("./settings-merge");
51
51
  const playbook_skills_1 = require("./playbook-skills");
52
52
  const init_1 = require("./init");
53
53
  const mcp_version_1 = require("./mcp-version");
54
+ const codex_toml_1 = require("./codex-toml");
54
55
  const mcp_launcher_1 = require("./mcp-launcher");
55
56
  const GRAMMARS = [
56
57
  'tree-sitter-typescript', 'tree-sitter-python', 'tree-sitter-c-sharp', 'tree-sitter-java',
@@ -407,6 +408,164 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
407
408
  : `상위가 approved가 아닌 승인 스펙 ${orphaned.length}건: ${orphaned.map((s) => s.id).slice(0, 5).join(', ')}`);
408
409
  }
409
410
  catch { /* an unreadable corpus is doctor's other checks' business, not this one's */ }
411
+ // @implements A-SPEC-254.1
412
+ // Distributed id preemption (the 2026-08-23 REQ-220 incident): two offline workspaces issue the
413
+ // same number, silent at create and at push. Judgment lives in the ONE pure detector; identity is
414
+ // the CANONICAL spec digest (specDigest: CRLF-folded, SEAL_FIELDS excluded — round-1: raw bytes
415
+ // turned a CRLF checkout difference into a fake collision, and trusting the self-declared seal
416
+ // hid a real one). The existing `duplicate spec ids` check above answers a different question
417
+ // (id count) and stays untouched. OWN try/catch (round-1): sharing the block above meant an
418
+ // unrelated upstream throw silently erased these checks — a failure here must surface as WARN,
419
+ // never as a vanished line.
420
+ try {
421
+ const { detectIdCollisions } = require('../spec/id-collision');
422
+ const { parseSpec } = require('../spec/spec-parser');
423
+ const crypto = require('node:crypto');
424
+ const specsRoot = path.join(target, '.ax', 'specs');
425
+ const entries = [];
426
+ let skipped = 0; // would-be specs present but not judged — a PASS must name its reduced scope (round-1)
427
+ // The collision identity is the WHOLE BODY, canonicalized — not a composite of parser splits
428
+ // (round-3): the round-2 specDigest+preamble composite inherited every split blind spot one at
429
+ // a time (preamble missing → round-2; fence-gap text in NEITHER half → round-3), and hashing
430
+ // the preamble raw made whitespace-only byte differences collide with a false forgery
431
+ // accusation on top. One key over everything after the frontmatter ends the class: CRLF folded
432
+ // (mirrors parseSpec), per-line trailing whitespace and blank lines dropped (formatting is not
433
+ // substance for an advisory WARN), plus the substantive frontmatter (type/title/dependsOn) via
434
+ // JSON array serialization — no field aliasing. The SEAL digest is untouched, as before.
435
+ const collisionKey = (folded, spec) => {
436
+ const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
437
+ const body = (fence ? folded.slice(fence[0].length) : folded)
438
+ .split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
439
+ return 'sha256:' + crypto.createHash('sha256')
440
+ .update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
441
+ };
442
+ // Symlinked directories are FOLLOWED (round-2): `Dirent.isDirectory()` is false for a dir
443
+ // symlink, so specs behind one silently left the judgment with skipped=0 — an unqualified PASS
444
+ // over an unexamined store. The realpath visited-set breaks symlink cycles. An UNREADABLE or
445
+ // un-realpathable directory counts into `skipped` (round-3) — a subtree the walk could not
446
+ // enter is an unexamined region, and a PASS must never silently span it.
447
+ const seen = new Set();
448
+ const collect = (d) => {
449
+ let real;
450
+ // "Nothing there" is not "something escaped judgment" (round-4, extended round-7): a
451
+ // NONEXISTENT (ENOENT) or path-broken (ENOTDIR — .ax created as a stray FILE;
452
+ // ENAMETOOLONG) root/dir holds nothing that could have been judged, so counting it stamped
453
+ // a phantom '판정 밖 1건' on pre-init targets, indistinguishable from a genuinely
454
+ // permission-locked store. Real refusals (EACCES) and cycles (ELOOP — an entry exists, it
455
+ // just cannot be entered) still count as unexamined regions.
456
+ try {
457
+ real = fs.realpathSync(d);
458
+ }
459
+ catch (err) {
460
+ const code = err.code;
461
+ if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ENAMETOOLONG')
462
+ skipped++;
463
+ return;
464
+ }
465
+ if (seen.has(real))
466
+ return;
467
+ seen.add(real);
468
+ let dirents;
469
+ // The FOURTH errno site of the absence class (round-8): a regular FILE at the specs path
470
+ // (realpath succeeds, readdir throws ENOTDIR) holds nothing judgeable — same doctrine as a
471
+ // TOCTOU-deleted dir (ENOENT, round-5). EACCES stays counted (an enterable-refused dir is a
472
+ // real unexamined region — the round-3 chmod pin rides this catch).
473
+ try {
474
+ dirents = fs.readdirSync(d, { withFileTypes: true });
475
+ }
476
+ catch (err) {
477
+ const code = err.code;
478
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
479
+ skipped++;
480
+ return;
481
+ }
482
+ for (const e of dirents) {
483
+ const p = path.join(d, e.name);
484
+ let isDir = e.isDirectory();
485
+ let isFile = e.isFile();
486
+ if (!isDir && !isFile && e.isSymbolicLink()) {
487
+ // The round-4 errno rule applies HERE too (round-5): a target that DOES NOT RESOLVE —
488
+ // ENOENT/ELOOP, and equally ENOTDIR/ENAMETOOLONG (round-6: "any other errno means the
489
+ // target exists" was false for a link traversing a regular file) — is absence, so only
490
+ // a spec-LOOKING name counts (round-3: a dangling assets.png fabricates a phantom scope
491
+ // reduction). A genuinely REFUSED target (EACCES …) exists but cannot even be typed: an
492
+ // unexamined region regardless of its name, counted unconditionally.
493
+ try {
494
+ const st = fs.statSync(p);
495
+ isDir = st.isDirectory();
496
+ isFile = st.isFile();
497
+ }
498
+ catch (err) {
499
+ const code = err.code;
500
+ const unresolved = code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR' || code === 'ENAMETOOLONG';
501
+ if (unresolved) {
502
+ if (e.name.endsWith('.md'))
503
+ skipped++;
504
+ }
505
+ else
506
+ skipped++;
507
+ continue;
508
+ }
509
+ }
510
+ if (isDir) {
511
+ collect(p);
512
+ continue;
513
+ }
514
+ if (!e.name.endsWith('.md'))
515
+ continue;
516
+ // TYPE before READ (round-6): readFileSync's open(2) blocks FOREVER on a FIFO with no
517
+ // writer — no throw, no survival catch, doctor simply never completes. A non-regular
518
+ // file named *.md cannot be judged; it is counted, never opened.
519
+ if (!isFile) {
520
+ skipped++;
521
+ continue;
522
+ }
523
+ try {
524
+ // Strip a UTF-8 BOM before parsing (round-1): a BOM broke the frontmatter match, so a
525
+ // BOM'd colliding replica silently vanished from the judgment instead of participating.
526
+ const raw = fs.readFileSync(p, 'utf8').replace(/^/, '');
527
+ const folded = raw.replace(/\r\n/g, '\n');
528
+ const spec = parseSpec(folded);
529
+ if (!spec.id) {
530
+ skipped++;
531
+ continue;
532
+ } // unreadable-as-spec, still counted (round-1)
533
+ entries.push({
534
+ file: path.relative(specsRoot, p).split(path.sep).join('/'),
535
+ id: spec.id,
536
+ approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
537
+ contentDigest: collisionKey(folded, spec),
538
+ });
539
+ }
540
+ catch {
541
+ skipped++;
542
+ }
543
+ }
544
+ };
545
+ collect(specsRoot);
546
+ // No cross-reference to `spec readability` (round-3): that walker does not follow dir
547
+ // symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
548
+ const scopeNote = skipped > 0 ? ` (판정 밖 ${skipped}건)` : '';
549
+ const found = detectIdCollisions(entries);
550
+ const collisions = found.filter((i) => i.kind === 'id-collision');
551
+ const families = found.filter((i) => i.kind === 'family-coexistence');
552
+ add('spec id 선점 충돌', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
553
+ ? `같은 id를 서로 다른 스펙이 쓰는 충돌이 없습니다${scopeNote}`
554
+ : collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
555
+ : '같은 번호를 두 스펙이 선점했습니다 — 한쪽을 리넘버하고(REQ-255 전까지는 수동) 재봉인하세요.');
556
+ add('spec id 가족 공존', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
557
+ ? `bare id와 dot-suffix 가족의 공존이 없습니다${scopeNote}`
558
+ : families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
559
+ : 'bare 스펙과 점 하위 가족이 공존합니다 — 두 워크스페이스가 같은 번호를 다르게 전개한 신호이니 한쪽 전개로 통일하세요.');
560
+ }
561
+ catch (e) {
562
+ // Never a silent vanish: the corpus most likely to hold a collision (a messy half-merged
563
+ // store) is exactly where an upstream throw would otherwise suppress the report. String(e),
564
+ // not e.message — a thrown non-Error must not crash the survival branch itself (round-2).
565
+ const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
566
+ add('spec id 선점 충돌', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
567
+ add('spec id 가족 공존', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
568
+ }
410
569
  }
411
570
  // @implements A-SPEC-176
412
571
  // `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
@@ -554,6 +713,53 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
554
713
  const agySkills = path.join(target, '.agents', 'skills');
555
714
  add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} 가 스킬을 가리킵니다` : `${agySkills} 가 없습니다 — 이 하네스는 스킬을 보지 못합니다`, fs.existsSync(agySkills) ? undefined : `${path.join('..', '.claude', 'skills')} 로 링크하거나 복사하십시오.`);
556
715
  }
716
+ // @implements A-SPEC-266 (was A-SPEC-264) — Codex reads MCP servers from `.codex/config.toml`
717
+ // (TOML `[mcp_servers.*]`), so we check the file Codex actually loads — not the
718
+ // `.codex/mcp_config.json` (JSON) this project used to write and Codex never read. Same A-SPEC-193
719
+ // principle: no file → no diagnosis; a present file is judged for whether it resolves to THIS
720
+ // install. A leftover JSON with no config.toml is a stale wiring in a location Codex ignores → WARN.
721
+ const cdxToml = path.join(target, '.codex', 'config.toml');
722
+ const cdxJson = path.join(target, '.codex', 'mcp_config.json');
723
+ if (fs.existsSync(cdxToml)) {
724
+ let raw = null;
725
+ try {
726
+ raw = fs.readFileSync(cdxToml, 'utf8');
727
+ }
728
+ catch { /* handled below */ }
729
+ if (raw === null) {
730
+ add('codex wiring', 'FAIL', `${cdxToml} 를 읽을 수 없습니다`, '파일 권한을 확인하거나 지우고 다시 배선하십시오.');
731
+ }
732
+ else {
733
+ const entry = (0, codex_toml_1.readCodexHolmesEntry)(raw);
734
+ if (!entry) {
735
+ add('codex wiring', 'WARN', `${cdxToml} 에 실행 가능한 [mcp_servers.${init_1.SERVER_NAME}] 항목이 없습니다 — 반쯤 된 배선입니다`, 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
736
+ }
737
+ else if (entry.command === 'node') {
738
+ // @implements A-SPEC-266 — "이 설치본으로 해석됩니다"는 존재만으로는 부족하다: node 배선은
739
+ // holmes-mcp.js 를 가리켜야 한다. 아무 존재 파일(예: /etc/hosts)에 PASS 를 주면 "해석된다"는
740
+ // 주장이 검사보다 강해진다(적대 라운드 Finding 2). 파일명까지 확인해 그 간극을 좁힌다.
741
+ const bin = entry.args[0] ?? '';
742
+ // Resolve the link before judging the name (round-2 F4): a symlink literally named
743
+ // `holmes-mcp.js` pointing at /etc/hosts must not read as "resolves to this install".
744
+ const realBin = (() => { try {
745
+ return fs.realpathSync(bin);
746
+ }
747
+ catch {
748
+ return '';
749
+ } })();
750
+ const resolvesHere = realBin !== '' && path.basename(realBin) === 'holmes-mcp.js';
751
+ add('codex wiring', resolvesHere ? 'PASS' : 'FAIL', resolvesHere ? `MCP 배선이 이 설치본으로 해석됩니다: ${bin}`
752
+ : (fs.existsSync(bin) ? `MCP 배선이 holmes-mcp.js 가 아닌 파일을 가리킵니다: ${bin}` : `MCP 배선이 없는 파일을 가리킵니다: ${bin}`), resolvesHere ? undefined : 'holmes-kit init --target <dir> --agent codex --force 로 절대 경로를 갱신하십시오.');
753
+ }
754
+ else {
755
+ const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
756
+ add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `npx 핀 ${pin} 으로 해석됩니다` : `배선에서 실행 버전을 읽을 수 없습니다: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
757
+ }
758
+ }
759
+ }
760
+ else if (fs.existsSync(cdxJson)) {
761
+ add('codex wiring', 'WARN', `codex 배선이 Codex 가 읽지 않는 구식 위치(${cdxJson})에 있습니다 — config.toml 로 옮겨야 합니다`, 'holmes-kit init --target <dir> --agent codex 로 다시 배선하면 config.toml 로 이전되고 구식 파일이 정리됩니다.');
762
+ }
557
763
  try {
558
764
  const sp = wiredSettingsPath(target);
559
765
  const st = JSON.parse(fs.readFileSync(sp, 'utf8'));
@@ -7,6 +7,10 @@ export declare const BEGIN = "# >>> holmes-kit >>>";
7
7
  export declare const END = "# <<< holmes-kit <<<";
8
8
  /** Derived/runtime paths holmes-kit creates in a target project. */
9
9
  export declare const IGNORE_LINES: string[];
10
+ export declare const ATTRIBUTE_LINES: string[];
11
+ /** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
12
+ * and user-content preservation are inherited rather than re-implemented. */
13
+ export declare function mergeGitattributes(existing: string): string;
10
14
  /**
11
15
  * Add (or refresh) the holmes-kit block. Idempotent: a second call reproduces the same text.
12
16
  * Preserves everything outside the block, and preserves whether the file ended with a newline.
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  // @implements A-SPEC-100.2
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
4
+ exports.ATTRIBUTE_LINES = exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
5
+ exports.mergeGitattributes = mergeGitattributes;
5
6
  exports.mergeGitignore = mergeGitignore;
6
7
  exports.removeGitignoreBlock = removeGitignoreBlock;
7
8
  exports.hasGitignoreBlock = hasGitignoreBlock;
@@ -42,6 +43,21 @@ exports.IGNORE_LINES = [
42
43
  '.agents/hooks.json',
43
44
  '.agents/mcp_config.json',
44
45
  ];
46
+ // @implements A-SPEC-256.1
47
+ // Merge attributes for the append-only files: `union` keeps BOTH sides' lines on merge — for these
48
+ // files "both" is always the right answer (the 2026-08-23 merge resolved every such conflict by
49
+ // hand to exactly this). The resulting chain seams are `ledger rechain`'s job, not the merge's.
50
+ exports.ATTRIBUTE_LINES = [
51
+ '# @implements A-SPEC-256.1 — append-only 파일은 병합에서 양쪽 줄을 모두 보존한다(union).',
52
+ '# 남는 체인 이음새는 `holmes-kit ledger rechain`의 일이다.',
53
+ '.ax/ledger/*.jsonl merge=union',
54
+ '.ax/approvals/queue.jsonl merge=union',
55
+ ];
56
+ /** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
57
+ * and user-content preservation are inherited rather than re-implemented. */
58
+ function mergeGitattributes(existing) {
59
+ return mergeGitignore(existing, exports.ATTRIBUTE_LINES);
60
+ }
45
61
  function findBlock(text) {
46
62
  const lines = text.split('\n');
47
63
  const start = lines.findIndex((l) => l.trim() === exports.BEGIN);
@@ -12,3 +12,26 @@
12
12
  */
13
13
  export declare function packageRoot(): string;
14
14
  export declare function main(argv: string[]): Promise<number>;
15
+ /**
16
+ * @implements A-SPEC-262.1
17
+ * Is this the normal end of a pipeline rather than a fault? Round-10: `approve --list | head` printed
18
+ * a Node stack dump and exited 1 — on a command whose own help says it is for scripts, where a
19
+ * consumer that stops reading is how pipelines end. Exported so the decision is testable without
20
+ * spawning a process and racing a pipe.
21
+ */
22
+ export declare const isBrokenPipe: (err: NodeJS.ErrnoException | undefined) => boolean;
23
+ /**
24
+ * @implements A-SPEC-262.1
25
+ * A broken stdout pipe is the normal end of `| head`, not a fault worth a thousand bytes of Node
26
+ * internals on the operator's screen; exit quietly with the conventional status.
27
+ *
28
+ * Round-10 installed this ONLY inside `if (require.main === module)`. Round-11 measured that the
29
+ * shipped `holmes-kit` never runs that block — `bin/holmes-kit.js` loads this file as a MODULE and
30
+ * calls `main()`, so `require.main` is the bin, not this module. The guard was therefore DEAD on the
31
+ * shipped binary: an interactive or `--watch` surface piped to a reader that closes (`| head`)
32
+ * crashed with an unhandled `'error'` EPIPE stack and exit 1 (measured byte-identical for one-shot
33
+ * and watch — a shared adapter property, not a watch defect). Installing from `main()` — the one
34
+ * path every shipped invocation takes — makes the round-10 fix actually run. Idempotent: the module
35
+ * flag keeps repeated `main()` calls (the test suite) from stacking listeners.
36
+ */
37
+ export declare function installPipeGuard(): void;