@lannguyensi/harness 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +9 -0
  3. package/dist/cli/adopt/derive.d.ts +15 -0
  4. package/dist/cli/adopt/derive.js +34 -7
  5. package/dist/cli/adopt/derive.js.map +1 -1
  6. package/dist/cli/adopt/index.d.ts +30 -0
  7. package/dist/cli/adopt/index.js +23 -3
  8. package/dist/cli/adopt/index.js.map +1 -1
  9. package/dist/cli/apply/generate-settings.d.ts +14 -1
  10. package/dist/cli/apply/generate-settings.js +7 -3
  11. package/dist/cli/apply/generate-settings.js.map +1 -1
  12. package/dist/cli/approve/stdin-report.js +2 -0
  13. package/dist/cli/approve/stdin-report.js.map +1 -1
  14. package/dist/cli/doctor/claude-mcp.d.ts +50 -0
  15. package/dist/cli/doctor/claude-mcp.js +170 -0
  16. package/dist/cli/doctor/claude-mcp.js.map +1 -0
  17. package/dist/cli/doctor/format.js +37 -0
  18. package/dist/cli/doctor/format.js.map +1 -1
  19. package/dist/cli/doctor/index.d.ts +10 -0
  20. package/dist/cli/doctor/index.js +24 -0
  21. package/dist/cli/doctor/index.js.map +1 -1
  22. package/dist/cli/doctor/npm-bin-path.js +42 -0
  23. package/dist/cli/doctor/npm-bin-path.js.map +1 -1
  24. package/dist/cli/doctor/types.d.ts +11 -0
  25. package/dist/cli/doctor/types.js.map +1 -1
  26. package/dist/cli/index.js +253 -5
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/init/agent-tasks-auth.js +32 -0
  29. package/dist/cli/init/agent-tasks-auth.js.map +1 -1
  30. package/dist/cli/init/composer.js +3 -9
  31. package/dist/cli/init/composer.js.map +1 -1
  32. package/dist/cli/init/dependencies.js +21 -0
  33. package/dist/cli/init/dependencies.js.map +1 -1
  34. package/dist/cli/init/detect.d.ts +20 -3
  35. package/dist/cli/init/detect.js +34 -36
  36. package/dist/cli/init/detect.js.map +1 -1
  37. package/dist/cli/init/interactive.d.ts +33 -0
  38. package/dist/cli/init/interactive.js +399 -5
  39. package/dist/cli/init/interactive.js.map +1 -1
  40. package/dist/cli/init/profiles.d.ts +1 -1
  41. package/dist/cli/init/profiles.js +1 -1
  42. package/dist/cli/init/templates.d.ts +1 -1
  43. package/dist/cli/init/templates.js +64 -5
  44. package/dist/cli/init/templates.js.map +1 -1
  45. package/dist/cli/main.js +10 -0
  46. package/dist/cli/main.js.map +1 -1
  47. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +30 -0
  48. package/dist/cli/pack/hook-post-merge-gate-record.js +153 -0
  49. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -0
  50. package/dist/cli/pack/hook-post-merge-gate.d.ts +28 -0
  51. package/dist/cli/pack/hook-post-merge-gate.js +244 -0
  52. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -0
  53. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +148 -2
  54. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  55. package/dist/cli/pack/understanding-report-schema-hint.js +1 -1
  56. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  57. package/dist/cli/record/index.d.ts +64 -0
  58. package/dist/cli/record/index.js +211 -0
  59. package/dist/cli/record/index.js.map +1 -0
  60. package/dist/cli/session-start/index.d.ts +7 -0
  61. package/dist/cli/session-start/index.js +133 -38
  62. package/dist/cli/session-start/index.js.map +1 -1
  63. package/dist/cli/session-start/toolchain-parity.d.ts +149 -0
  64. package/dist/cli/session-start/toolchain-parity.js +618 -0
  65. package/dist/cli/session-start/toolchain-parity.js.map +1 -0
  66. package/dist/cli/uninstall/index.d.ts +73 -1
  67. package/dist/cli/uninstall/index.js +134 -2
  68. package/dist/cli/uninstall/index.js.map +1 -1
  69. package/dist/io/claude-mcp.d.ts +258 -0
  70. package/dist/io/claude-mcp.js +544 -0
  71. package/dist/io/claude-mcp.js.map +1 -0
  72. package/dist/policies/ledger-client.d.ts +34 -1
  73. package/dist/policies/ledger-client.js +21 -16
  74. package/dist/policies/ledger-client.js.map +1 -1
  75. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +195 -0
  76. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +290 -0
  77. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -0
  78. package/dist/policy-packs/builtin/post-merge-gate.d.ts +56 -0
  79. package/dist/policy-packs/builtin/post-merge-gate.js +291 -0
  80. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -0
  81. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +28 -13
  82. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  83. package/dist/policy-packs/registry.d.ts +1 -1
  84. package/dist/policy-packs/registry.js +12 -0
  85. package/dist/policy-packs/registry.js.map +1 -1
  86. package/dist/runtime/git-context.d.ts +50 -0
  87. package/dist/runtime/git-context.js +108 -1
  88. package/dist/runtime/git-context.js.map +1 -1
  89. package/dist/runtime/hermetic-spawn-guard.d.ts +25 -0
  90. package/dist/runtime/hermetic-spawn-guard.js +136 -0
  91. package/dist/runtime/hermetic-spawn-guard.js.map +1 -0
  92. package/dist/runtime/ledger-writer.d.ts +27 -0
  93. package/dist/runtime/ledger-writer.js +46 -0
  94. package/dist/runtime/ledger-writer.js.map +1 -0
  95. package/dist/runtime/read-only-bash.js +118 -0
  96. package/dist/runtime/read-only-bash.js.map +1 -1
  97. package/dist/schema/index.d.ts +41 -0
  98. package/dist/schema/index.js +5 -0
  99. package/dist/schema/index.js.map +1 -1
  100. package/dist/schema/toolchain-parity.d.ts +18 -0
  101. package/dist/schema/toolchain-parity.js +35 -0
  102. package/dist/schema/toolchain-parity.js.map +1 -0
  103. package/package.json +4 -2
@@ -21,14 +21,23 @@
21
21
  // write + file-lock + post-write validate path is reused. The wizard
22
22
  // is essentially a UI for picking the `--template` value.
23
23
  import { select, confirm, input, checkbox } from "@inquirer/prompts";
24
+ import * as fs from "node:fs";
24
25
  import * as path from "node:path";
25
26
  import { spawn } from "node:child_process";
26
27
  import { resolveHomeDir } from "../../runtime/home-dir.js";
28
+ import { assertNoRealSpawnInTests, HermeticSpawnViolationError } from "../../runtime/hermetic-spawn-guard.js";
27
29
  import { EX_FAIL, HarnessExitError } from "../exit-codes.js";
28
30
  import { detect, } from "./detect.js";
29
31
  import { init } from "./index.js";
30
32
  import { validate } from "../validate/index.js";
31
- import { apply, CODEX_CONFIG_BASENAME } from "../apply/index.js";
33
+ import { apply, CODEX_CONFIG_BASENAME, SETTINGS_BASENAME } from "../apply/index.js";
34
+ import { buildMcpServers, projectGroundingEnv, } from "../apply/generate-settings.js";
35
+ import { loadManifest } from "../loader.js";
36
+ import { ensureMcpServers, manualRemoveLines, posixSingleQuote, stripOwnedMcpServers, } from "../../io/claude-mcp.js";
37
+ import { atomicWriteFile } from "../../io/atomic-write.js";
38
+ import { resolveGeneratedDir } from "../../io/generated-dir.js";
39
+ import { readLastApply } from "../../io/last-apply.js";
40
+ import { DEFAULT_OWNED_MCP_SERVERS } from "../uninstall/index.js";
32
41
  import { checkDependencies, checkDependencyList, dependenciesForCustom, formatDependencyTable, installPackagesGlobally, } from "./dependencies.js";
33
42
  import { probeAgentTasksAuth, runBridgeLogin, } from "./agent-tasks-auth.js";
34
43
  import { COMPOSABLE_MCPS, COMPOSABLE_PACKS, COMPOSABLE_POLICIES, composeCustom, } from "./composer.js";
@@ -68,7 +77,7 @@ function summariseDetection(d) {
68
77
  lines.push(` MCP wired ${d.mcpServers.map((s) => s.name).join(", ")}`);
69
78
  }
70
79
  else {
71
- lines.push(` MCP wired (none detected in Claude settings.json)`);
80
+ lines.push(` MCP wired (none detected in the Claude Code user-scope registry)`);
72
81
  }
73
82
  lines.push(` harness v${d.harness.version}`);
74
83
  return lines.join("\n");
@@ -87,6 +96,33 @@ function runtimeIsConfigured(r) {
87
96
  return false;
88
97
  return r.homeExists || r.settingsExists;
89
98
  }
99
+ /**
100
+ * Read `.last-apply` BEFORE `apply()` gets a chance to re-stamp it (task
101
+ * 363a6de0, Reviewer-Finding 2 / D-107). `apply()` overwrites `.last-apply`
102
+ * with the CURRENT manifest's snapshot whenever anything changed —
103
+ * including a combined edit that both removes a `tools.mcp[]` entry AND
104
+ * touches something else (e.g. a hook via a pack) — which would erase the
105
+ * PRE-edit provenance GC's ownership union needs. Malformed JSON or a
106
+ * schema-invalid record (`readLastApply`'s own validation throws for
107
+ * both) degrades to `null` — the same conservative "no snapshot" fallback
108
+ * D-107 already specifies — rather than letting a corrupt file crash
109
+ * wire-now (previously this was read late and only reachable after
110
+ * Ensure succeeded; hoisting it here means a throw would otherwise
111
+ * surface twice, once from the try branch and again from the catch
112
+ * branch's own `wireClaudeMcp` call).
113
+ */
114
+ function readPriorLastApply(o) {
115
+ const generatedDir = resolveGeneratedDir({
116
+ ...(o.homeDir !== undefined ? { homeDir: o.homeDir } : {}),
117
+ manifestPath: o.configPath,
118
+ });
119
+ try {
120
+ return readLastApply(generatedDir);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
90
126
  async function wireRuntime(o) {
91
127
  // Defensive: only claude-code and codex have apply paths in v1. The
92
128
  // checkbox UI disables "opencode" until task f34eb233 lands, so this
@@ -97,6 +133,15 @@ async function wireRuntime(o) {
97
133
  throw new HarnessExitError(`wireRuntime: ${o.runtime} is not a wirable runtime in this harness build`, EX_FAIL);
98
134
  }
99
135
  if (o.runtime === "claude-code") {
136
+ // D-107 / Reviewer-Finding 2 (task 363a6de0): capture `.last-apply`'s
137
+ // manifest snapshot BEFORE apply() runs. apply() re-stamps
138
+ // `.last-apply` with the CURRENT (already-edited) manifest whenever
139
+ // anything changed — including a combined edit that both removes a
140
+ // `tools.mcp[]` entry AND touches something else (e.g. a hook via a
141
+ // pack). Reading it only after apply() (the pre-fix ordering) would
142
+ // silently erase the very provenance GC's ownership union needs to
143
+ // see a manifest entry that existed just before THIS run.
144
+ const priorLastApply = readPriorLastApply(o);
100
145
  // init's wire-now intent is "wire this freshly written manifest"
101
146
  // — the operator already confirmed by ticking claude-code in the
102
147
  // wire-now checkbox. A pre-existing drift in
@@ -147,14 +192,30 @@ async function wireRuntime(o) {
147
192
  if (!r.targetWritten && !r.targetInSync) {
148
193
  outcome.recoveryHint = `harness apply --target ${o.claudeSettingsPath} --merge --overwrite-drift`;
149
194
  }
195
+ // T-002: MCP registration below is independent of this hooks/
196
+ // settings.json merge — it goes through the `claude mcp` CLI, not
197
+ // through settings.json. Run it regardless of the merge outcome so
198
+ // a hooks-merge hiccup doesn't also withhold MCP wiring.
199
+ await wireClaudeMcp(o, outcome, priorLastApply);
150
200
  return outcome;
151
201
  }
152
202
  catch (err) {
203
+ // Hermetic guard (task 0d80e969): a real-spawn violation from
204
+ // `wireClaudeMcp`'s FIRST call above (inside this `try`) must
205
+ // never be treated as an ordinary `apply()` failure — this catch
206
+ // otherwise degrades any thrown error to a "Failed to wire ..."
207
+ // warning AND calls `wireClaudeMcp` a SECOND time below, which
208
+ // would both print a misleading message and retry a spawn that
209
+ // must never happen under vitest. Re-throw before any of that.
210
+ if (err instanceof HermeticSpawnViolationError)
211
+ throw err;
153
212
  const message = err instanceof Error ? err.message : String(err);
154
213
  const recoveryHint = `harness apply --target ${o.claudeSettingsPath} --merge --overwrite-drift`;
155
214
  o.stderr(`\nFailed to wire ${o.claudeSettingsPath}: ${message}\n`);
156
215
  o.stderr(`Manifest is on disk. To retry the merge manually:\n ${recoveryHint}\n`);
157
- return { runtime: "claude-code", recoveryHint };
216
+ const outcome = { runtime: "claude-code", recoveryHint };
217
+ await wireClaudeMcp(o, outcome, priorLastApply);
218
+ return outcome;
158
219
  }
159
220
  }
160
221
  // Codex path: apply --runtime codex emits harness.generated/codex/config.toml.
@@ -199,6 +260,289 @@ async function wireRuntime(o) {
199
260
  return { runtime: "codex", recoveryHint };
200
261
  }
201
262
  }
263
+ /**
264
+ * Reload the effective manifest and translate `tools.mcp[]` into the
265
+ * server-spec shape the `claude mcp` CLI wants (task
266
+ * init-mcp-wiring-claude-code/T-002). Reuses the exact same
267
+ * `buildMcpServers` + `projectGroundingEnv` functions the settings.json
268
+ * projection used to feed into the (now dead) `mcpServers` block —
269
+ * see generate-settings.ts's `GenerateSettingsResult.mcpServers` doc.
270
+ * `harnessHomeDir` is the harness STATE root (`harnessHomeArg(opts)`),
271
+ * not the operator's `$HOME`; `projectGroundingEnv`'s tilde-expansion is
272
+ * deliberately left on its default (`os.homedir()`) here, mirroring
273
+ * `apply.ts`'s own `buildExpectedFiles` call, which never overrides it
274
+ * either.
275
+ *
276
+ * Returns `null` when the manifest can't be reloaded (should not happen
277
+ * right after a successful `init()`, but a hand-edited/deleted manifest
278
+ * between write and this read is possible); callers degrade gracefully.
279
+ */
280
+ function loadDesiredMcpServers(configPath, harnessHomeDir) {
281
+ let manifest;
282
+ try {
283
+ manifest = loadManifest({
284
+ configPath,
285
+ ...(harnessHomeDir !== undefined ? { homeDir: harnessHomeDir } : {}),
286
+ }).manifest;
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ const warnings = [];
292
+ const desired = buildMcpServers(manifest.tools.mcp, warnings);
293
+ projectGroundingEnv(manifest, desired);
294
+ return { manifest, desired, warnings };
295
+ }
296
+ /** One `claude mcp add-json --scope user <name> '<json>'` line per desired server, sorted by name. */
297
+ function manualAddJsonLines(desired) {
298
+ return Object.keys(desired)
299
+ .sort()
300
+ .map((name) => `claude mcp add-json --scope user ${name} ${posixSingleQuote(JSON.stringify(desired[name]))}`);
301
+ }
302
+ /**
303
+ * Extract the mcpServers names a PREVIOUS apply wrote into
304
+ * `harness.generated/settings.json`, from `.last-apply`. Pre-T-002
305
+ * harness versions projected `mcpServers` into that file; a leftover
306
+ * record from one of those versions is the provenance signal the
307
+ * migration step (below) needs to safely strip the matching names out of
308
+ * the live (dead) settings.json block without guessing. Mirrors the
309
+ * equivalent snippet in `apply.ts`'s `--target --merge` provenance
310
+ * handling (kept separate rather than shared: apply.ts's version is
311
+ * private to that module and the two call sites read a different record
312
+ * shape's field, not worth a shared export for ~10 lines).
313
+ */
314
+ function priorGeneratedMcpNames(lastApply) {
315
+ const content = lastApply?.files[SETTINGS_BASENAME]?.content;
316
+ if (content === undefined)
317
+ return [];
318
+ try {
319
+ const prior = JSON.parse(content);
320
+ const mcp = prior["mcpServers"];
321
+ if (mcp !== null && typeof mcp === "object" && !Array.isArray(mcp)) {
322
+ return Object.keys(mcp);
323
+ }
324
+ }
325
+ catch {
326
+ // Corrupt .last-apply record: no provenance names from it; the
327
+ // manifest + DEFAULT_OWNED_MCP_SERVERS sets still apply.
328
+ }
329
+ return [];
330
+ }
331
+ /**
332
+ * Extract `tools.mcp[]` names from the manifest snapshot `.last-apply`
333
+ * stores (`LastApplyRecord.manifest`, Phase 3 #1) — the effective manifest
334
+ * as of the apply BEFORE this run (see `readPriorLastApply` — the record
335
+ * passed in here is captured before this run's `apply()` re-stamps the
336
+ * file), not necessarily the manifest on disk right now. GC ownership
337
+ * (task 363a6de0, D-107) needs this: a server whose `tools.mcp[]` entry
338
+ * was removed or disabled just before THIS run is still harness-owned,
339
+ * and the CURRENT manifest alone can never surface it — by definition
340
+ * it's gone from there. An older `.last-apply` record (pre-manifest-
341
+ * snapshot, so `.manifest` is absent) or a snapshot that fails to parse
342
+ * both degrade to an EMPTY result — the conservative fallback D-107
343
+ * calls for is to fall back to JUST the current manifest's names (NOT
344
+ * `DEFAULT_OWNED_MCP_SERVERS` — see the D-107 note on `wireClaudeMcp`)
345
+ * rather than guess at this one.
346
+ */
347
+ function priorManifestMcpNames(lastApply) {
348
+ const content = lastApply?.manifest?.content;
349
+ if (content === undefined)
350
+ return [];
351
+ try {
352
+ const prior = JSON.parse(content);
353
+ const mcp = prior.tools?.mcp;
354
+ if (!Array.isArray(mcp))
355
+ return [];
356
+ const names = [];
357
+ for (const entry of mcp) {
358
+ if (entry !== null &&
359
+ typeof entry === "object" &&
360
+ typeof entry.name === "string") {
361
+ names.push(entry.name);
362
+ }
363
+ }
364
+ return names;
365
+ }
366
+ catch {
367
+ // Corrupt .last-apply record: no provenance names from it; the
368
+ // current manifest's names still apply.
369
+ return [];
370
+ }
371
+ }
372
+ /**
373
+ * Strip harness-owned names from the dead `mcpServers` block in
374
+ * `o.claudeSettingsPath` (D-002/D-003: only ever called AFTER Ensure has
375
+ * confirmed every desired server is correctly registered via the `claude`
376
+ * CLI). Owned = the current manifest's `tools.mcp[]` names, UNION any
377
+ * names a pre-T-002 harness version generated into settings.json
378
+ * (`.last-apply` provenance), UNION the uninstall module's default
379
+ * ownership set (covers a manifest-less/first-run migration the same way
380
+ * `harness uninstall` already does). Foreign entries (anything outside
381
+ * that union — an operator hand-add, or another tool's MCP server) are
382
+ * left untouched. Writes only when something actually changes.
383
+ *
384
+ * `lastApply` is read once, BEFORE `apply()` runs, by `wireRuntime`
385
+ * (`readPriorLastApply`, task 363a6de0 / D-107) and threaded through
386
+ * `wireClaudeMcp` rather than re-read here — both this function's
387
+ * pre-T-002 provenance check and GC's ownership-union construction need
388
+ * the SAME pre-apply snapshot (apply() re-stamps `.last-apply` with the
389
+ * current manifest whenever anything changed, which would otherwise be
390
+ * read back here as if it were "prior").
391
+ */
392
+ function migrateDeadSettingsMcpBlock(o, manifest, lastApply) {
393
+ let raw;
394
+ try {
395
+ raw = fs.readFileSync(o.claudeSettingsPath, "utf8");
396
+ }
397
+ catch {
398
+ return []; // No settings.json (yet) — nothing to migrate.
399
+ }
400
+ let parsed;
401
+ try {
402
+ parsed = JSON.parse(raw);
403
+ }
404
+ catch {
405
+ o.stderr(`\n⚠ ${o.claudeSettingsPath} is not valid JSON; skipped the dead mcpServers cleanup. Fix the file and re-run \`harness init --interactive\`.\n`);
406
+ return [];
407
+ }
408
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
409
+ return [];
410
+ }
411
+ const ownedNames = new Set([
412
+ ...manifest.tools.mcp.map((m) => m.name),
413
+ ...priorGeneratedMcpNames(lastApply),
414
+ ...DEFAULT_OWNED_MCP_SERVERS,
415
+ ]);
416
+ const { settings, removedNames } = stripOwnedMcpServers(parsed, [...ownedNames]);
417
+ if (removedNames.length === 0)
418
+ return [];
419
+ atomicWriteFile(o.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`);
420
+ return removedNames;
421
+ }
422
+ /**
423
+ * Register the manifest's `tools.mcp[]` servers with Claude Code's
424
+ * user-scope registry via the `claude mcp` CLI (task
425
+ * init-mcp-wiring-claude-code/T-001/T-002), then — only once every
426
+ * desired server is confirmed correctly registered (D-002) — strip the
427
+ * dead settings.json `mcpServers` block. Mutates `outcome` in place so
428
+ * both the try and catch branches of the caller (the settings.json/hooks
429
+ * apply above) get the same MCP wiring regardless of whether that apply
430
+ * itself succeeded.
431
+ *
432
+ * `claude` CLI missing (`cli-missing`) is NOT a hard failure: the wizard
433
+ * warns, prints copy-pasteable `claude mcp add-json --scope user <name>
434
+ * '<json>'` commands per server, and continues — the manifest is on disk
435
+ * and hooks may already be wired even if MCP isn't yet. Migration is
436
+ * skipped in that case (and for any other incomplete registration) so a
437
+ * still-effective legacy settings.json entry is never removed out from
438
+ * under a server that couldn't be re-registered.
439
+ *
440
+ * Also drives GC (task 363a6de0): `ensureMcpServers`'s `gc` option is
441
+ * given the D-107 ownership union — the current manifest's `tools.mcp[]`
442
+ * names UNION the `tools.mcp[]` names of `priorLastApply` (the
443
+ * `.last-apply` manifest snapshot captured by the caller BEFORE this
444
+ * run's `apply()` ran, see `readPriorLastApply`) — so a server whose
445
+ * manifest entry was removed or disabled since it was last registered
446
+ * gets `claude mcp remove`d. `DEFAULT_OWNED_MCP_SERVERS` is DELIBERATELY
447
+ * NOT part of this union (D-107, reviewer HIGH finding on the original
448
+ * D-103): a server sharing a name with a harness default (grounding-mcp,
449
+ * agent-tasks, codebase-oracle) that the operator registered themselves
450
+ * — outside of, or before, any harness manifest on this machine — must
451
+ * never be GC'd just because it never appears in `desired`; "never in
452
+ * the manifest" is not the same ownership claim as "removed from the
453
+ * manifest". GC reporting is independent of the add/replace gate below —
454
+ * it runs, and is reported, regardless of whether the add/replace pass
455
+ * for `desired` succeeded.
456
+ */
457
+ async function wireClaudeMcp(o, outcome, priorLastApply) {
458
+ const loaded = loadDesiredMcpServers(o.configPath, o.homeDir);
459
+ if (loaded === null) {
460
+ o.stderr(`\n⚠ Could not reload the manifest to register MCP servers with the claude CLI; skipped.\n`);
461
+ return;
462
+ }
463
+ for (const w of loaded.warnings)
464
+ o.stderr(`mcp warning: ${w}\n`);
465
+ // D-107: GC eligibility is manifest-provenance-based ONLY — current
466
+ // manifest names ∪ the pre-apply `.last-apply` snapshot's names.
467
+ // DEFAULT_OWNED_MCP_SERVERS is intentionally excluded here (see the
468
+ // function doc above); it remains the ownership source for the
469
+ // read-only paths below (dead-settings-block migration, detection).
470
+ const gcOwnedNames = [
471
+ ...new Set([
472
+ ...loaded.manifest.tools.mcp.map((m) => m.name),
473
+ ...priorManifestMcpNames(priorLastApply),
474
+ ]),
475
+ ];
476
+ const claudeHomeDir = path.dirname(o.claudeSettingsPath);
477
+ const ensureResult = await ensureMcpServers({
478
+ desired: loaded.desired,
479
+ homeDir: claudeHomeDir,
480
+ gc: { ownedNames: gcOwnedNames },
481
+ ...(o.mcpExec ? { exec: o.mcpExec } : {}),
482
+ });
483
+ outcome.mcpEnsure = ensureResult;
484
+ const gcResults = ensureResult.gc?.results ?? [];
485
+ const gcRemoved = gcResults.filter((r) => r.action === "removed");
486
+ const gcSkipped = gcResults.filter((r) => r.action === "skipped");
487
+ if (gcRemoved.length > 0) {
488
+ o.stderr(`deregistered ${gcRemoved.length} stale MCP server(s) no longer in the manifest ` +
489
+ `(user scope, claude CLI): ${gcRemoved.map((r) => r.name).join(", ")}\n`);
490
+ }
491
+ if (gcSkipped.length > 0) {
492
+ o.stderr([
493
+ "",
494
+ "⚠ Could not deregister one or more stale MCP server(s) via the `claude` CLI:",
495
+ ...gcSkipped.map((r) => ` ${r.name}: ${r.reason ?? r.remove.message}`),
496
+ " Remove them yourself:",
497
+ ...manualRemoveLines(gcSkipped.map((r) => r.name)).map((l) => ` ${l}`),
498
+ "",
499
+ ].join("\n"));
500
+ }
501
+ if (ensureResult.gc?.registryReadError) {
502
+ o.stderr("⚠ Could not read the claude CLI user-scope registry to garbage-collect stale MCP " +
503
+ `servers: ${ensureResult.gc.registryReadError}\n`);
504
+ }
505
+ const actionable = ensureResult.results.filter((r) => r.action !== "noop");
506
+ const cliMissing = actionable.some((r) => r.add?.status === "cli-missing" || r.remove?.status === "cli-missing");
507
+ const allOk = ensureResult.results.every((r) => r.action === "noop" ||
508
+ ((r.action === "add" || r.action === "replace") && r.add?.status === "added"));
509
+ if (!allOk) {
510
+ if (cliMissing) {
511
+ o.stderr([
512
+ "",
513
+ "⚠ The `claude` CLI is not on PATH — MCP servers were not registered automatically.",
514
+ " Install Claude Code, then register the manifest's MCP servers yourself:",
515
+ "",
516
+ ...manualAddJsonLines(Object.fromEntries(actionable.map((r) => [r.name, loaded.desired[r.name]]))).map((l) => ` ${l}`),
517
+ "",
518
+ ].join("\n"));
519
+ }
520
+ else {
521
+ o.stderr([
522
+ "",
523
+ "⚠ Registering one or more MCP servers with the `claude` CLI failed:",
524
+ ...actionable.map((r) => ` ${r.name}: ${r.add?.message ?? r.remove?.message ?? r.reason ?? r.action}`),
525
+ "",
526
+ ].join("\n"));
527
+ }
528
+ o.stderr(" Re-run `harness init --interactive` after fixing the issue to finish MCP registration and the settings.json cleanup.\n");
529
+ if (outcome.recoveryHint === undefined) {
530
+ outcome.recoveryHint =
531
+ "re-run `harness init --interactive` to finish registering MCP servers with the claude CLI";
532
+ }
533
+ return; // D-002: migration runs only after every desired server registers successfully.
534
+ }
535
+ if (actionable.length > 0) {
536
+ o.stderr(`\nregistered ${actionable.length} MCP server(s) with the claude CLI (user scope): ${actionable
537
+ .map((r) => r.name)
538
+ .join(", ")}\n`);
539
+ }
540
+ const removedNames = migrateDeadSettingsMcpBlock(o, loaded.manifest, priorLastApply);
541
+ if (removedNames.length > 0) {
542
+ outcome.mcpMigrationRemovedNames = removedNames;
543
+ o.stderr(`removed ${removedNames.length} dead mcpServers entr${removedNames.length === 1 ? "y" : "ies"} from ${o.claudeSettingsPath}: ${removedNames.join(", ")}\n`);
544
+ }
545
+ }
202
546
  /**
203
547
  * Post-install auth check for the agent-tasks bridge. Probes the
204
548
  * bridge's `status` verb and, when no token is configured, offers the
@@ -322,8 +666,17 @@ async function ensureAgentTasksAuth(o) {
322
666
  * PATH) resolves `code: 1` rather than throwing, so the caller's
323
667
  * graceful-failure path handles a missing toolchain the same as a
324
668
  * non-zero exit.
669
+ *
670
+ * Hermetic guard (task 54739002): asserts BEFORE touching
671
+ * `child_process` that we are not running under vitest without a test
672
+ * having injected `owInitSpawn`. See
673
+ * src/runtime/hermetic-spawn-guard.ts for why and the env signal used.
674
+ * The thrown `HermeticSpawnViolationError` is re-thrown past the
675
+ * caller's try/catch (which otherwise degrades a thrown runner to a
676
+ * warning) — see the catch in offerOrchestratorWorkflow below.
325
677
  */
326
678
  function realOwInitSpawn(cmd, args) {
679
+ assertNoRealSpawnInTests("npx orchestrator-workflow init", "Inject a fake `owInitSpawn` runner in the test instead of exercising the real spawn path.");
327
680
  return new Promise((resolve) => {
328
681
  const child = spawn(cmd, args, { stdio: ["ignore", "inherit", "pipe"] });
329
682
  let stderr = "";
@@ -414,6 +767,12 @@ async function offerOrchestratorWorkflow(o) {
414
767
  result = await run("npx", ["orchestrator-workflow", "init", "--yes", o.repoDir]);
415
768
  }
416
769
  catch (err) {
770
+ // Hermetic guard (task 54739002): a real-spawn violation under
771
+ // vitest is NOT an ordinary runner failure — it must propagate past
772
+ // this optional-and-warn handling and fail the test hard, so it is
773
+ // re-thrown here before the generic degrade-to-warning below runs.
774
+ if (err instanceof HermeticSpawnViolationError)
775
+ throw err;
417
776
  // A thrown runner (an injected spawn that rejects, or an unexpected
418
777
  // throw) is treated exactly like a non-zero exit: OW is optional, so
419
778
  // we warn and continue rather than failing harness init.
@@ -498,7 +857,7 @@ export async function runInteractive(opts = {}) {
498
857
  }
499
858
  if (profileNeedsAgentTasks(profile) && !detectionHasAgentTasks(detection)) {
500
859
  const proceed = await prompts.confirm({
501
- message: "The Team profile wires the agent-tasks MCP via the `agent-tasks-mcp-bridge` binary AND assumes you have an agent-tasks account (hosted or self-hosted). Claude's settings.json does not yet declare the MCP; the wizard will offer to install missing packages and wire it in a moment via `harness apply --target ~/.claude/settings.json --merge`. Proceed?",
860
+ message: "The Team profile wires the agent-tasks MCP via the `agent-tasks-mcp-bridge` binary AND assumes you have an agent-tasks account (hosted or self-hosted). It is not registered with Claude Code yet; the wizard will offer to install missing packages and register it in a moment via the `claude mcp` CLI (user scope). Proceed?",
502
861
  default: true,
503
862
  });
504
863
  if (!proceed) {
@@ -596,6 +955,11 @@ export async function runInteractive(opts = {}) {
596
955
  if (opts.dependencyPathEnv !== undefined) {
597
956
  initOpts.pathEnv = opts.dependencyPathEnv;
598
957
  }
958
+ // Task 325ace29: same forwarding for the `npm prefix -g` runner, so a
959
+ // test can keep `init()`'s bin-resolution check off the real npm.
960
+ if (opts.npmBinExec !== undefined) {
961
+ initOpts.npmBinExec = opts.npmBinExec;
962
+ }
599
963
  const initResult = await init(initOpts);
600
964
  stdout(initResult.stdout);
601
965
  const tailResult = await runPostInitTail({
@@ -623,6 +987,14 @@ export async function runInteractive(opts = {}) {
623
987
  return tailResult;
624
988
  }
625
989
  catch (err) {
990
+ // Defense-in-depth (task 54739002): a hermetic-spawn-guard violation
991
+ // must always propagate out of runInteractive as a hard failure.
992
+ // `isAbortError` below already wouldn't match a
993
+ // HermeticSpawnViolationError (it checks for an ExitPromptError/
994
+ // abort-shaped name), so this is belt-and-suspenders against a
995
+ // future change to isAbortError narrowing that behavior by name.
996
+ if (err instanceof HermeticSpawnViolationError)
997
+ throw err;
626
998
  if (isAbortError(err)) {
627
999
  stderr("Aborted: Ctrl-C received during prompt; no manifest written.\n");
628
1000
  return { aborted: true };
@@ -728,10 +1100,27 @@ async function runPostInitTail(t) {
728
1100
  ],
729
1101
  }));
730
1102
  if (selectedRuntimes.length === 0) {
1103
+ // T-002: claude-code wiring is now two independent steps — the
1104
+ // settings.json/hooks merge (still `harness apply --target ... --merge`)
1105
+ // and MCP registration (via the `claude mcp` CLI, not settings.json;
1106
+ // see io/claude-mcp.ts). There is no single non-interactive command for
1107
+ // the latter yet, so print the manifest's per-server add-json commands
1108
+ // directly — the same manual fallback the wizard itself prints when
1109
+ // the `claude` CLI is missing.
1110
+ const desiredMcp = loadDesiredMcpServers(initResult.path, harnessHomeArg(opts));
1111
+ const mcpLines = desiredMcp !== null && Object.keys(desiredMcp.desired).length > 0
1112
+ ? manualAddJsonLines(desiredMcp.desired)
1113
+ : [];
731
1114
  stderr([
732
1115
  "",
733
1116
  "Manifest written; no runtimes selected for wiring. To wire later:",
734
- ` claude-code: harness apply --target ${claudeSettingsPath} --merge`,
1117
+ ` claude-code hooks: harness apply --target ${claudeSettingsPath} --merge`,
1118
+ ...(mcpLines.length > 0
1119
+ ? [
1120
+ " claude-code MCP: re-run `harness init --interactive` and select claude-code, or run:",
1121
+ ...mcpLines.map((l) => ` ${l}`),
1122
+ ]
1123
+ : []),
735
1124
  ` codex: harness apply --runtime codex --install --codex-config ${codexConfigPath}`,
736
1125
  "",
737
1126
  ].join("\n"));
@@ -752,6 +1141,8 @@ async function runPostInitTail(t) {
752
1141
  const homeArg = harnessHomeArg(opts);
753
1142
  if (homeArg !== undefined)
754
1143
  wireOpts.homeDir = homeArg;
1144
+ if (opts.mcpExec)
1145
+ wireOpts.mcpExec = opts.mcpExec;
755
1146
  const outcome = await wireRuntime(wireOpts);
756
1147
  applies.push(outcome);
757
1148
  }
@@ -904,6 +1295,9 @@ async function runCustomProfile(rc) {
904
1295
  // Task 7f8fb4bc: see the named-profile path above for rationale.
905
1296
  if (opts.dependencyPathEnv !== undefined)
906
1297
  initOpts.pathEnv = opts.dependencyPathEnv;
1298
+ // Task 325ace29: see the named-profile path above for rationale.
1299
+ if (opts.npmBinExec !== undefined)
1300
+ initOpts.npmBinExec = opts.npmBinExec;
907
1301
  const initResult = await init(initOpts);
908
1302
  stdout(initResult.stdout);
909
1303
  return await runPostInitTail({