@holmes-lab/holmes-kit 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.2.1] - 2026-08-28
9
+
10
+ Codex governance that is actually wired: the MCP server holmes-kit writes for Codex now lives where Codex reads it.
11
+
12
+ ### Fixed
13
+ - **Codex MCP wiring goes where Codex actually reads it (REQ-266)**: `init` wrote the Codex harness's MCP server to a project-local `.codex/mcp_config.json` (JSON) — a file Codex never loads. Codex reads `[mcp_servers.*]` tables from `.codex/config.toml` (TOML), so **every Codex wiring since it was added was dead**, and because Codex enforces no hooks (`HARNESS_ENFORCES.codex = false`) that MCP server is Codex's *only* governance path — so Codex was wired but ungoverned. `init` now writes `[mcp_servers.holmes-kit]` into `.codex/config.toml`, **merging** so the user's other Codex settings and servers are preserved byte-for-byte (the same discipline as the `.mcp.json` merge), migrates away the obsolete `.codex/mcp_config.json` (only after the new write is confirmed), and `doctor`'s `codex wiring` check now verifies the file Codex reads (a leftover JSON with no `config.toml` is a WARN). The merge is string/bracket-aware — a `[mcp_servers.holmes-kit]` line inside a neighbor's multi-line string or array is not mistaken for a real table, and an unrecognized foreign header always closes our region rather than swallowing it. The three-harness parity meta-test now asserts not only identical launch command/args but that each harness's wiring lands where that harness reads it. Existing Codex projects: re-run `holmes-kit init --agent codex` (doctor will flag the stale wiring).
14
+ - **`.codex/config.toml` is protected from self-disarm like its siblings (REQ-266)**: because that file is now Codex's sole governance path, an un-approved `Write`/`Edit` to it is denied for a config-write approval, exactly as `.mcp.json` and `.agents/*` already were — a session can no longer quietly re-point or delete Codex's MCP wiring.
15
+
16
+ ### Changed
17
+ - **`init` pre-selects every harness by default (REQ-266)**: the interactive harness picker now checks all harnesses (Claude Code, Antigravity, Codex) by default, matching its own "Select All (Recommended)" affordance; Codex was previously left unchecked. Deselecting a harness is one keystroke; a silently un-wired harness is not.
18
+
19
+ ### Unverified (named)
20
+ - `doctor`'s `codex wiring` check does not WARN on an out-of-date npx pin the way the `.mcp.json` version-drift check does; porting that drift WARN to `.codex/config.toml` is a named follow-up (A-SPEC-251.2 family), not a wiring defect.
21
+
8
22
  ## [0.2.0] - 2026-08-27
9
23
 
10
24
  The approval decision surface, world-tier: the operator stays at one screen, and a grant is exactly what they saw.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- f7a048d-mtbm5atb
1
+ 9a2f304-mtbs8fh6
@@ -169,8 +169,12 @@ function agentFiles(agent, opts) {
169
169
  { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.antigravity) },
170
170
  ];
171
171
  case 'codex':
172
+ // @implements A-SPEC-266 — the MCP wiring is NOT a whole-file overwrite here: Codex reads
173
+ // `.codex/config.toml` (TOML `[mcp_servers.*]`), a file that also holds the user's other Codex
174
+ // settings, so it must be MERGED. That merge lives in init.ts alongside the `.mcp.json` merge
175
+ // (it reads the existing target file); agentFiles stays pure. Here we emit only AGENTS.md.
176
+ // The former `.codex/mcp_config.json` (JSON) was never read by Codex — init cleans it up.
172
177
  return [
173
- { path: path.join(target, '.codex', 'mcp_config.json'), content: mcpConfig(packageRoot, specsDir, opts.launcher) },
174
178
  { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.codex) },
175
179
  ];
176
180
  default:
@@ -0,0 +1,26 @@
1
+ import { McpServerEntry } from './mcp-launcher';
2
+ /** The one table holmes-kit owns in a Codex config.toml. */
3
+ export declare const CODEX_TABLE = "mcp_servers.holmes-kit";
4
+ /**
5
+ * Serialize the `[mcp_servers.holmes-kit]` table. `env` is an inline table so the whole entry is ONE
6
+ * contiguous region (no `[mcp_servers.holmes-kit.env]` child header) — that keeps the merge boundary
7
+ * unambiguous: our region runs from the header to the next table header. Ends with a trailing newline.
8
+ */
9
+ export declare function codexMcpBlock(entry: McpServerEntry, specsDir: string): string;
10
+ /**
11
+ * Merge `block` (a full `codexMcpBlock` output) into `existing`. When `existing` is null/empty the
12
+ * block stands alone. When our table is already present its region is REPLACED (no duplicate); when
13
+ * absent the block is appended after a blank-line separator. Every other line is preserved verbatim.
14
+ */
15
+ export declare function mergeCodexToml(existing: string | null, block: string): string;
16
+ /** Strip our region from `existing`, preserving everything else. Absent → returned unchanged. */
17
+ export declare function removeCodexToml(existing: string): string;
18
+ /**
19
+ * Read `{command,args}` from our table in a config.toml — for doctor's drift check. Parses only the
20
+ * shape `codexMcpBlock` writes; anything it cannot read returns null (doctor then WARNs rather than
21
+ * translating an unreadable wiring into a pass).
22
+ */
23
+ export declare function readCodexHolmesEntry(raw: string): {
24
+ command: string;
25
+ args: string[];
26
+ } | null;
@@ -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',
@@ -712,29 +713,52 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
712
713
  const agySkills = path.join(target, '.agents', 'skills');
713
714
  add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} 가 스킬을 가리킵니다` : `${agySkills} 가 없습니다 — 이 하네스는 스킬을 보지 못합니다`, fs.existsSync(agySkills) ? undefined : `${path.join('..', '.claude', 'skills')} 로 링크하거나 복사하십시오.`);
714
715
  }
715
- // @implements A-SPEC-264 — 하네스 codex 배선 검사가 없었다(REQ-264 실측). 배선된
716
- // 하네스마다 하네스의 배선을 검사한다는 A-SPEC-193 원칙 그대로: 파일이 없으면
717
- // 진단하지 않고, 있으면 설치본으로 해석되는지를 판정한다.
718
- const cdxMcp = path.join(target, '.codex', 'mcp_config.json');
719
- if (fs.existsSync(cdxMcp)) {
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;
720
725
  try {
721
- const doc = JSON.parse(fs.readFileSync(cdxMcp, 'utf8'));
722
- const entry = doc.mcpServers?.[init_1.SERVER_NAME];
723
- if (!entry || typeof entry.command !== 'string' || !Array.isArray(entry.args)) {
724
- add('codex wiring', 'WARN', `${cdxMcp} 에 실행 가능한 ${init_1.SERVER_NAME} 항목이 없습니다 — 반쯤 된 배선입니다`, 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
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 로 다시 배선하십시오.');
725
736
  }
726
737
  else if (entry.command === 'node') {
738
+ // @implements A-SPEC-266 — "이 설치본으로 해석됩니다"는 존재만으로는 부족하다: node 배선은
739
+ // holmes-mcp.js 를 가리켜야 한다. 아무 존재 파일(예: /etc/hosts)에 PASS 를 주면 "해석된다"는
740
+ // 주장이 검사보다 강해진다(적대 라운드 Finding 2). 파일명까지 확인해 그 간극을 좁힌다.
727
741
  const bin = entry.args[0] ?? '';
728
- add('codex wiring', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `MCP 배선이 이 설치본으로 해석됩니다: ${bin}` : `MCP 배선이 없는 파일을 가리킵니다: ${bin}`, fs.existsSync(bin) ? undefined : 'holmes-kit init --target <dir> --agent codex --force 로 절대 경로를 갱신하십시오.');
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 로 절대 경로를 갱신하십시오.');
729
753
  }
730
754
  else {
731
755
  const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
732
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 로 다시 배선하십시오.');
733
757
  }
734
758
  }
735
- catch {
736
- add('codex wiring', 'FAIL', `${cdxMcp} 를 읽을 수 없습니다(JSON 아님)`, '파일을 고치거나 지우고 다시 배선하십시오.');
737
- }
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 로 이전되고 구식 파일이 정리됩니다.');
738
762
  }
739
763
  try {
740
764
  const sp = wiredSettingsPath(target);
@@ -933,7 +933,9 @@ async function main(argv) {
933
933
  isAdditive = true;
934
934
  }
935
935
  else if (process.stdin.isTTY && process.stdout.isTTY && flags['dry-run'] !== true && flags.remove !== true) {
936
- agents = await promptAgentSelection(AGENTS, ['claude', 'antigravity']);
936
+ // Default to all harnesses checked — matches the menu's "Select All (Recommended)" and
937
+ // spares users a toggle per harness. Deselecting is one keystroke; a missed wiring is not.
938
+ agents = await promptAgentSelection(AGENTS, [...AGENTS]);
937
939
  isAdditive = true;
938
940
  }
939
941
  else {
@@ -46,6 +46,7 @@ const playbook_skills_1 = require("./playbook-skills");
46
46
  const agents_1 = require("./agents");
47
47
  const roles_readme_1 = require("./roles-readme");
48
48
  const mcp_launcher_1 = require("./mcp-launcher");
49
+ const codex_toml_1 = require("./codex-toml");
49
50
  const pre_tool_use_1 = require("../hooks/pre-tool-use");
50
51
  const governed_precondition_1 = require("./governed-precondition");
51
52
  const risk_gate_1 = require("../guardrail/risk-gate");
@@ -343,6 +344,71 @@ function runInit(opts) {
343
344
  }
344
345
  messages.push('Restart Claude Code — hooks and MCP servers are read at session start.');
345
346
  }
347
+ // @implements A-SPEC-266 — Codex reads MCP servers from `.codex/config.toml` (TOML
348
+ // `[mcp_servers.*]`), NOT the `.codex/mcp_config.json` (JSON) this project used to write and Codex
349
+ // never loaded. That file also holds the user's other Codex settings, so — like `.mcp.json` — we
350
+ // MERGE only our table. The obsolete JSON is migrated away so no dead wiring is left behind.
351
+ let codexJsonToMigrate = null; // deleted only after the write loop confirms config.toml
352
+ {
353
+ const codexDir = path.join(opts.target, '.codex');
354
+ const codexTomlPath = path.join(codexDir, 'config.toml');
355
+ const codexJsonPath = path.join(codexDir, 'mcp_config.json');
356
+ // A `.codex/config.toml` we cannot read (a directory, a permission wall, a dangling symlink) is
357
+ // NOT touched — reading it eagerly with readFileSync threw and crashed the whole init. Mirror the
358
+ // `.mcp.json` "refusing to touch it" discipline: skip codex wiring and say so, migrate nothing.
359
+ let codexTomlBefore = null;
360
+ let codexUnreadable = false;
361
+ if (fs.existsSync(codexTomlPath)) {
362
+ try {
363
+ codexTomlBefore = fs.readFileSync(codexTomlPath, 'utf8');
364
+ }
365
+ catch {
366
+ codexUnreadable = true;
367
+ }
368
+ }
369
+ const wantsCodex = (opts.agents ?? []).includes('codex') && !codexUnreadable;
370
+ if (codexUnreadable)
371
+ messages.push(`Refusing to touch ${codexTomlPath} — it is not a readable file. Fix or remove it, then re-run.`);
372
+ // @implements A-SPEC-266 (round-2 F3) — the obsolete JSON is deleted AFTER the config.toml write is
373
+ // confirmed, never before: an eager delete followed by a failed toml write left the user with NO
374
+ // codex wiring at all (and a message claiming nothing was lost). Here we only PLAN the migration;
375
+ // the deletion runs post-write-loop (dry-run just previews it in `removals`).
376
+ const migrateAwayCodexJson = () => {
377
+ if (!fs.existsSync(codexJsonPath))
378
+ return;
379
+ if (opts.dryRun) {
380
+ removals.push(codexJsonPath);
381
+ return;
382
+ }
383
+ codexJsonToMigrate = codexJsonPath;
384
+ };
385
+ if (opts.remove) {
386
+ if (codexTomlBefore !== null) {
387
+ const after = (0, codex_toml_1.removeCodexToml)(codexTomlBefore);
388
+ if (after.trim() === '' && after !== codexTomlBefore) {
389
+ // Our table was the only content — delete the file rather than leave it empty.
390
+ if (opts.dryRun)
391
+ removals.push(codexTomlPath);
392
+ else if (fs.existsSync(codexTomlPath)) {
393
+ fs.rmSync(codexTomlPath, { force: true });
394
+ messages.push(`Removed ${codexTomlPath} (holmes-kit MCP table was its only content).`);
395
+ }
396
+ }
397
+ else if (after !== codexTomlBefore) {
398
+ changes.push({ path: codexTomlPath, before: codexTomlBefore, after });
399
+ }
400
+ }
401
+ migrateAwayCodexJson();
402
+ }
403
+ else if (wantsCodex && opts.mcp) {
404
+ const mcpBin = path.join(opts.packageRoot, 'bin', 'holmes-mcp.js');
405
+ const entry = (0, mcp_launcher_1.mcpEntryForInstall)({ packageRoot: opts.packageRoot, mcpBinPath: mcpBin, flag: opts.mcpLauncher });
406
+ const after = (0, codex_toml_1.mergeCodexToml)(codexTomlBefore, (0, codex_toml_1.codexMcpBlock)(entry, opts.specsDir));
407
+ if (after !== codexTomlBefore)
408
+ changes.push({ path: codexTomlPath, before: codexTomlBefore, after });
409
+ migrateAwayCodexJson();
410
+ }
411
+ }
346
412
  if (opts.dryRun) {
347
413
  // @implements A-SPEC-190 (round 8) — dry-run must plan the SAME set the real run touches, and
348
414
  // must not print a deletion under the verb 'would write'. Round-7 planned installs only from
@@ -422,6 +488,18 @@ function runInit(opts) {
422
488
  ] };
423
489
  }
424
490
  }
491
+ // @implements A-SPEC-266 (round-2 F3) — the config.toml write above is confirmed now, so it is
492
+ // safe to remove the obsolete JSON. Doing it earlier risked deleting codex's only wiring and then
493
+ // failing the toml write.
494
+ if (codexJsonToMigrate !== null && fs.existsSync(codexJsonToMigrate)) {
495
+ try {
496
+ fs.rmSync(codexJsonToMigrate, { force: true });
497
+ messages.push(`Removed obsolete ${codexJsonToMigrate} (Codex never read it — MCP wiring now lives in config.toml).`);
498
+ }
499
+ catch (e) {
500
+ messages.push(`Could not remove obsolete ${codexJsonToMigrate}: ${e instanceof Error ? e.message : String(e)}`);
501
+ }
502
+ }
425
503
  if (!opts.remove && opts.mode === 'governed') {
426
504
  for (const d of SPEC_SUBDIRS)
427
505
  fs.mkdirSync(path.join(opts.target, opts.specsDir, d), { recursive: true });
@@ -52,12 +52,12 @@ function parseAgentList(input) {
52
52
  /**
53
53
  * Renders an interactive TTY checkbox selection menu using standard readline & ANSI codes.
54
54
  */
55
- async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = ['claude']) {
55
+ async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = [...agents_1.AGENTS]) {
56
56
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
57
- return ['claude', 'antigravity']; // Fallback for non-TTY
57
+ return [...availableAgents]; // Fallback for non-TTY — default to all harnesses
58
58
  }
59
59
  return new Promise((resolve) => {
60
- const selected = new Set(currentWired.length ? currentWired : ['claude', 'antigravity']);
60
+ const selected = new Set(currentWired.length ? currentWired : [...availableAgents]);
61
61
  let cursor = 0;
62
62
  const items = [...availableAgents, 'all'];
63
63
  const rl = readline.createInterface({
@@ -91,7 +91,7 @@ async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWi
91
91
  }
92
92
  else if (item === 'codex') {
93
93
  isChecked = selected.has('codex');
94
- label = '💻 Codex CLI (.codex/mcp_config.json)';
94
+ label = '💻 Codex CLI (.codex/config.toml)';
95
95
  }
96
96
  const box = isChecked ? '[X]' : '[ ]';
97
97
  const line = `${prefix}${box} ${label}\n`;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * MCP 서버 배선의 launch 방식을 한 곳에서 계산한다.
3
3
  *
4
- * 왜 한 곳인가: `.mcp.json`(Claude)·`.agents/mcp_config.json`(antigravity)·`.codex/mcp_config.json`
5
- * 세 배선이 같은 서버를 띄운다. 셋이 각자 command/args 를 지으면 하나가 npx 로 옮겨갈 때 나머지가
4
+ * 왜 한 곳인가: `.mcp.json`(Claude)·`.agents/mcp_config.json`(antigravity)·`.codex/config.toml`
5
+ * (codex, TOML `[mcp_servers.holmes-kit]` — A-SPEC-266) 세 배선이 같은 서버를 띄운다. 셋이 각자 command/args 를 지으면 하나가 npx 로 옮겨갈 때 나머지가
6
6
  * 절대경로에 남아 어긋난다 — 그 드리프트가 REQ-251 자체의 출발점이었다.
7
7
  *
8
8
  * 왜 두 모드인가: 최종 사용자는 npm 설치본에서 init 하므로 `npx ...@<정확한버전> holmes-mcp` 가
@@ -256,6 +256,13 @@ function protectedFileKindOf(root, raw) {
256
256
  const parent = pp.basename(pp.dirname(resolved)).toLowerCase();
257
257
  if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
258
258
  return `.agents/${name}`;
259
+ // @implements A-SPEC-266 — codex 는 훅 게이트가 없어(HARNESS_ENFORCES.codex=false) `.codex/config.toml`
260
+ // 의 [mcp_servers.holmes-kit] 가 codex 지배의 **유일한** 경로다. 이 슬라이스가 그 배선을 죽은 JSON 에서
261
+ // 실제 거버넌스로 바꿨으므로, `.agents/*`·`.mcp.json` 과 정확히 같은 성질이 된다 — 세션이 고칠 수 있으면
262
+ // 세션이 codex 게이트를 끈다. A-SPEC-193 §8 의 원칙을 새로 자격을 갖춘 파일에 적용해 같은 self-disarm
263
+ // 보호를 준다. 구식 `.codex/mcp_config.json` 도 잠근다 — 그 파일을 되살려 배선을 흐릴 수 없게.
264
+ if (parent === '.codex' && (name === 'config.toml' || name === 'mcp_config.json'))
265
+ return `.codex/${name}`;
259
266
  // Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
260
267
  if (/^\.env(\.[\w-]+)?$/.test(name) && !/\.(example|sample|template|dist)$/.test(name))
261
268
  return '.env';
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.2.0",
4
+ "version": "0.2.1",
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",