@holmes-lab/holmes-kit 0.1.17 → 0.1.18

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,15 @@ 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.1.18] - 2026-08-23
9
+
10
+ ### Fixed
11
+ - **The npx MCP wiring actually starts the server (REQ-259)**: `init` on an npm-installed package wrote `npx -y @holmes-lab/holmes-kit@<ver> holmes-mcp`, which npx resolves to the package-name-matching *default* binary (`holmes-kit`) with `holmes-mcp` as its argument — exit 1, so **every npm-installed wiring since the 0.1.15 npx pin launched a dead MCP server** (`CONNECTION_CLOSED` in the harness). The wiring now rides `-p` (`npx -y -p <pkg>@<ver> holmes-mcp`), the launcher test spawns the generated form against a locally packed tarball (with the pre-fix form kept as a failing control), and `doctor` gains **`mcp wiring spawn`** — it runs the target's `.mcp.json` command verbatim inside a 30s bound and names a dead wiring FAIL with the re-init remediation. Existing projects: re-run `holmes-kit init` (doctor will point at the dead wiring).
12
+ - **`serverInfo.version` reports the real package version** — it was hardcoded to `0.1.0`, blinding client-side drift diagnosis.
13
+
14
+ ### Changed
15
+ - **`approve` without a TTY names the next command (REQ-260)**: the implicit non-TTY fallback (e.g. Claude Code's `!` shell) used to print the pending list and exit 0 silently; it now appends runnable `--grant` / `--deny` / `--ask` examples carrying the first real pending id. Explicit `--list` output is unchanged for scripts.
16
+
8
17
  ## [0.1.17] - 2026-08-23
9
18
 
10
19
  The two `main` lines — the Windows-compatibility line (12 commits, verified on Windows 11 and
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- e6199ca-mt5qbc5d
1
+ f8c9769-mt5t8gnu
@@ -30,6 +30,17 @@ export declare function holdRequest(root: string, id: string, question: string,
30
30
  * teaches syntax, humans start typing it.
31
31
  */
32
32
  export declare function renderPending(state: QueueState): string;
33
+ /**
34
+ * The non-TTY fallback's next-command hint. Pure.
35
+ *
36
+ * @implements A-SPEC-260
37
+ * Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
38
+ * with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
39
+ * The examples carry the FIRST pending id read from the queue, never a placeholder the operator
40
+ * must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
41
+ * advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
42
+ */
43
+ export declare function renderNonTtyHint(state: QueueState): string;
33
44
  export interface ApproveIO {
34
45
  print: (s: string) => void;
35
46
  /** Ask one question, resolve with the raw answer. Injected so tests script the conversation. */
@@ -37,6 +37,7 @@ exports.grantRequest = grantRequest;
37
37
  exports.denyRequest = denyRequest;
38
38
  exports.holdRequest = holdRequest;
39
39
  exports.renderPending = renderPending;
40
+ exports.renderNonTtyHint = renderNonTtyHint;
40
41
  exports.runInteractive = runInteractive;
41
42
  // @implements A-SPEC-246
42
43
  const fs = __importStar(require("node:fs"));
@@ -154,6 +155,27 @@ function renderPending(state) {
154
155
  lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
155
156
  return lines.join('\n');
156
157
  }
158
+ /**
159
+ * The non-TTY fallback's next-command hint. Pure.
160
+ *
161
+ * @implements A-SPEC-260
162
+ * Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
163
+ * with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
164
+ * The examples carry the FIRST pending id read from the queue, never a placeholder the operator
165
+ * must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
166
+ * advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
167
+ */
168
+ function renderNonTtyHint(state) {
169
+ if (state.pending.length === 0)
170
+ return '';
171
+ const id = state.pending[0].id;
172
+ return [
173
+ '대화형 결정은 TTY에서만 동작합니다 — 이 셸에서는 아래 비대화형 명령을 사용하십시오:',
174
+ ` holmes-kit approve --grant ${id}`,
175
+ ` holmes-kit approve --deny ${id} --reason "<사유>"`,
176
+ ` holmes-kit approve --ask ${id} --question "<질문>"`,
177
+ ].join('\n');
178
+ }
157
179
  /**
158
180
  * The interactive loop — one entry at a time, one letter per decision.
159
181
  *
@@ -487,6 +487,38 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
487
487
  }
488
488
  }
489
489
  catch { /* .mcp.json 부재/파싱 실패 → 이 점검 없이 진행 */ }
490
+ // @implements A-SPEC-259 — the wiring is judged by SPAWNING it. String comparison is exactly how
491
+ // the dead `npx -y <pkg>@<ver> holmes-mcp` form (npx resolves the default bin and hands
492
+ // `holmes-mcp` to it as an argument) passed the launcher test, this doctor's own handshake check
493
+ // (which starts the server directly, not through the wiring), and the release tarball probe —
494
+ // measured 2026-08-23. A node wiring is judged on path existence (a source checkout must not pay
495
+ // a spawn), an absent .mcp.json is skipped by name, and the spawn is BOUNDED: it succeeds or
496
+ // fails inside its deadline, never hangs (REQ-258's lesson, applied from this check onward).
497
+ {
498
+ const mcpPath = path.join(target, '.mcp.json');
499
+ if (!fs.existsSync(mcpPath)) {
500
+ add('mcp wiring spawn', 'PASS', 'skipped — no .mcp.json in the target: an absent wiring is not a dead one');
501
+ }
502
+ else {
503
+ try {
504
+ const server = JSON.parse(fs.readFileSync(mcpPath, 'utf8'))
505
+ .mcpServers?.[init_1.SERVER_NAME];
506
+ if (!server || typeof server.command !== 'string' || !Array.isArray(server.args)) {
507
+ add('mcp wiring spawn', 'WARN', `.mcp.json carries no runnable ${init_1.SERVER_NAME} entry`, 'Run `holmes-kit init` in the target to write the wiring.');
508
+ }
509
+ else if (server.command === 'node') {
510
+ const bin = server.args[0] ?? '';
511
+ add('mcp wiring spawn', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `node wiring resolves: ${bin}` : `node wiring points at a missing file: ${bin}`, fs.existsSync(bin) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
512
+ }
513
+ else {
514
+ checks.push(await wiringSpawnCheck(server.command, server.args));
515
+ }
516
+ }
517
+ catch {
518
+ add('mcp wiring spawn', 'WARN', 'could not read .mcp.json', 'Run `holmes-kit init` in the target to rewrite the wiring.');
519
+ }
520
+ }
521
+ }
490
522
  // @implements A-SPEC-193 — 배선된 하네스마다 그 하네스의 배선을 검사한다. 배선되지 않은
491
523
  // 하네스는 진단하지 않는다(없는 것을 결함이라 부르면 doctor 가 소음이 된다).
492
524
  const agyHooks = path.join(target, '.agents', 'hooks.json');
@@ -616,6 +648,54 @@ function cleanupOnSignal(dir) {
616
648
  * and closes stdin makes the server exit before answering (a false FAIL this check produced on its
617
649
  * very first run against a healthy server).
618
650
  */
651
+ // @implements A-SPEC-259
652
+ // Spawn the TARGET's wiring verbatim and require initialize inside the deadline. Unlike
653
+ // `mcpHandshakeCheck` below (which proves this INSTALL can serve), this proves the WIRING the
654
+ // harness will actually run reaches a server — the gap the dead npx form lived in.
655
+ function wiringSpawnCheck(command, args, timeoutMs = 30000) {
656
+ const quoted = `${command} ${args.join(' ')}`;
657
+ return new Promise((resolve) => {
658
+ let done = false;
659
+ let out = '';
660
+ let child;
661
+ const finish = (level, detail, fix) => {
662
+ if (done)
663
+ return;
664
+ done = true;
665
+ clearTimeout(timer);
666
+ try {
667
+ child.kill();
668
+ }
669
+ catch { /* already gone */ }
670
+ resolve({ name: 'mcp wiring spawn', level, detail, fix });
671
+ };
672
+ const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring, then re-run doctor.'), timeoutMs);
673
+ try {
674
+ child = (0, node_child_process_1.spawn)(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
675
+ }
676
+ catch (e) {
677
+ finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.');
678
+ return;
679
+ }
680
+ child.on('error', (e) => finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
681
+ child.on('exit', (code) => finish('FAIL', `wiring exited (code ${code}) before answering initialize: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
682
+ child.stdin?.on('error', () => { });
683
+ child.stdout?.on('data', (d) => {
684
+ out += String(d);
685
+ for (const line of out.split('\n')) {
686
+ try {
687
+ const m = JSON.parse(line);
688
+ if (m?.result?.serverInfo) {
689
+ finish('PASS', `wiring reaches a live server (v${m.result.serverInfo.version ?? '?'}): \`${quoted}\``);
690
+ return;
691
+ }
692
+ }
693
+ catch { /* partial line */ }
694
+ }
695
+ });
696
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } }) + '\n');
697
+ });
698
+ }
619
699
  function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
620
700
  return new Promise((resolve) => {
621
701
  const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
@@ -359,7 +359,7 @@ async function main(argv) {
359
359
  // @implements A-SPEC-246 — the surface where a human decides. Interactive on a TTY with no
360
360
  // decision flags; every interactive decision has a flag equivalent so a CI or remote operator
361
361
  // is never locked out.
362
- const { grantRequest, denyRequest, holdRequest, renderPending, runInteractive } = require('./approve');
362
+ const { grantRequest, denyRequest, holdRequest, renderPending, renderNonTtyHint, runInteractive } = require('./approve');
363
363
  const { readQueue } = require('../governance/approval-queue');
364
364
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
365
365
  const actor = (() => { try {
@@ -402,10 +402,18 @@ async function main(argv) {
402
402
  process.stdout.write(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다\n' : `✗ ${r.reason}\n`);
403
403
  return r.ok ? 0 : 1;
404
404
  }
405
- if (flags.list || !process.stdin.isTTY) {
405
+ if (flags.list) {
406
406
  process.stdout.write(renderPending(readQueue(root)) + '\n');
407
407
  return 0;
408
408
  }
409
+ // @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
410
+ // with the real pending id filled in; the explicit --list above stays script-clean.
411
+ if (!process.stdin.isTTY) {
412
+ const state = readQueue(root);
413
+ const hint = renderNonTtyHint(state);
414
+ process.stdout.write(renderPending(state) + (hint ? '\n\n' + hint : '') + '\n');
415
+ return 0;
416
+ }
409
417
  const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
410
418
  void runInteractive(root, {
411
419
  print: (t) => process.stdout.write(t + '\n'),
@@ -52,7 +52,12 @@ exports.MCP_PACKAGE = '@holmes-lab/holmes-kit';
52
52
  */
53
53
  function mcpServerEntry(opts) {
54
54
  if (opts.mode === 'npx') {
55
- return { command: 'npx', args: ['-y', `${exports.MCP_PACKAGE}@${opts.version}`, 'holmes-mcp'] };
55
+ // @implements A-SPEC-259 the package must ride `-p` with the command named separately.
56
+ // `npx <pkg> holmes-mcp` runs the package-name-matching DEFAULT bin (holmes-kit) and hands
57
+ // `holmes-mcp` to it as an argument: measured 2026-08-23, exit 1 "unknown command" — every
58
+ // npm-installed wiring since the npx pin was a dead server. The `-p` form is the one that
59
+ // reached initialize in the same measurement.
60
+ return { command: 'npx', args: ['-y', '-p', `${exports.MCP_PACKAGE}@${opts.version}`, 'holmes-mcp'] };
56
61
  }
57
62
  return { command: 'node', args: [opts.mcpBinPath] };
58
63
  }
@@ -12,7 +12,19 @@ const validate_args_1 = require("./validate-args");
12
12
  // @implements A-SPEC-100.2
13
13
  const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS ?? '.ax/specs');
14
14
  const handlers = (0, handlers_1.makeHandlers)(store);
15
- const server = new index_js_1.Server({ name: 'holmes-kit', version: '0.1.0' }, { capabilities: { tools: {} } });
15
+ // @implements A-SPEC-259 the advertised version is the package's own, not a literal that froze at
16
+ // 0.1.0: a hardcoded serverInfo.version blinds any client-side drift diagnosis.
17
+ const PKG_VERSION = (() => {
18
+ try {
19
+ // dist/holmes/mcp/server.js → three levels up is the package root, same as the source layout.
20
+ const raw = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', '..', '..', 'package.json'), 'utf8');
21
+ return JSON.parse(raw).version ?? '0.0.0';
22
+ }
23
+ catch {
24
+ return '0.0.0';
25
+ }
26
+ })();
27
+ const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} } });
16
28
  const fullProfile = process.env.HOLMES_MCP_PROFILE === 'full';
17
29
  // Register each handler as a tool with its real typed inputSchema so MCP
18
30
  // clients can marshal complex (array/object) arguments; fall back to a
@@ -765,7 +765,7 @@ function renderDashboardHtml() {
765
765
  items.forEach(s => { statuses[s.status] = (statuses[s.status] || 0) + 1; });
766
766
  const statusSummary = Object.keys(statuses).map(k => escapeHtml(k) + ' × ' + statuses[k]).join(' · ');
767
767
  html += '<div class="req-card" style="margin-bottom:20px;">'
768
- + '<div class="req-header" onclick="toggleAccordion(\'legacy-body-' + escapeHtml(kind) + '\')">'
768
+ + '<div class="req-header" onclick="toggleAccordion(\\'legacy-body-' + escapeHtml(kind) + '\\')">'
769
769
  + '<div class="req-title-group"><span class="req-id-badge">' + escapeHtml(kind) + '</span>'
770
770
  + '<span class="req-title">' + items.length + ' unmapped document' + (items.length === 1 ? '' : 's') + '</span>'
771
771
  + '<span class="neighbor-rel badge-unmeasured">' + statusSummary + '</span></div>'
@@ -851,7 +851,7 @@ function renderDashboardHtml() {
851
851
  if (!label) {
852
852
  return '<td class="' + cls + ' stage-missing" title="No artifact for this stage">' + arrow + '— missing</td>';
853
853
  }
854
- const onclick = jumpId ? ' onclick="focusSpecInGraph(\'' + escapeHtml(String(jumpId)) + '\')"' : '';
854
+ const onclick = jumpId ? ' onclick="focusSpecInGraph(\\'' + escapeHtml(String(jumpId)) + '\\')"' : '';
855
855
  return '<td class="' + cls + '"' + onclick + '>' + arrow + icon + ' ' + escapeHtml(String(label)) + '</td>';
856
856
  }
857
857
  // REQ-Grouped Structured 6-Stage Pipeline Heatmap Matrix Renderer
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.1.17",
4
+ "version": "0.1.18",
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",