@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 +14 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +5 -1
- package/dist/holmes/cli/codex-toml.d.ts +26 -0
- package/dist/holmes/cli/codex-toml.js +282 -0
- package/dist/holmes/cli/doctor.js +37 -13
- package/dist/holmes/cli/index.js +3 -1
- package/dist/holmes/cli/init.js +78 -0
- package/dist/holmes/cli/interactive-prompt.js +4 -4
- package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
- package/dist/holmes/guardrail/write-target.js +7 -0
- package/package.json +1 -1
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
|
-
|
|
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 —
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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);
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/holmes/cli/init.js
CHANGED
|
@@ -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 = [
|
|
55
|
+
async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = [...agents_1.AGENTS]) {
|
|
56
56
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
57
|
-
return [
|
|
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 : [
|
|
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/
|
|
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/
|
|
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.
|
|
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",
|