@webpresso/agent-kit 3.3.3 → 3.3.4

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 (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/agent/rules/changeset-release.md +15 -4
  3. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  4. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  5. package/catalog/agent/rules/pre-implementation.md +36 -20
  6. package/catalog/agent/rules/rtk-routing.md +22 -16
  7. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  8. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  9. package/dist/esm/audit/hook-surface.js +5 -1
  10. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  11. package/dist/esm/audit/repo-guardrails.js +71 -1
  12. package/dist/esm/blueprint/core/parser.js +38 -12
  13. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  14. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  15. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  16. package/dist/esm/blueprint/execution/metadata.js +8 -4
  17. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  18. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  22. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  23. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  24. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  25. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  26. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  27. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  28. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  29. package/dist/esm/blueprint/trust/promotion.js +19 -19
  30. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  31. package/dist/esm/build/atomic-file-copy.js +44 -0
  32. package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
  33. package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
  34. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  35. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  36. package/dist/esm/cli/commands/compile.d.ts +13 -0
  37. package/dist/esm/cli/commands/compile.js +182 -62
  38. package/dist/esm/cli/commands/format.js +17 -1
  39. package/dist/esm/cli/commands/gain/index.js +10 -3
  40. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  41. package/dist/esm/cli/commands/init/index.js +29 -48
  42. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  43. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  44. package/dist/esm/cli/commands/init/merge.js +30 -10
  45. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  46. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  47. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  48. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  49. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  50. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  51. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  52. package/dist/esm/cli/commands/pr-help.js +1 -1
  53. package/dist/esm/cli/commands/pr.js +18 -4
  54. package/dist/esm/cli/commands/review.js +60 -6
  55. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  56. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  57. package/dist/esm/cli/commands/worktree/router.js +5 -2
  58. package/dist/esm/cli/direct-provider-launch.js +12 -4
  59. package/dist/esm/compiler/flatten.d.ts +10 -1
  60. package/dist/esm/compiler/flatten.js +14 -4
  61. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  62. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  63. package/dist/esm/compose/compose.d.ts +8 -0
  64. package/dist/esm/compose/compose.js +115 -8
  65. package/dist/esm/compose/ops-report-schema.js +17 -3
  66. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  67. package/dist/esm/compose/registry-resolve.js +78 -11
  68. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  69. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  70. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  71. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  72. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  73. package/dist/esm/config/oxlint/oxlintrc.js +18 -21
  74. package/dist/esm/config/oxlint/path-roles.js +53 -0
  75. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  76. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  77. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  78. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  79. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  80. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  81. package/dist/esm/daemon/domains/launch.js +30 -1
  82. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  83. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  84. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  85. package/dist/esm/daemon/handlers/product.js +10 -1
  86. package/dist/esm/daemon/methods.d.ts +21 -1
  87. package/dist/esm/daemon/methods.js +20 -0
  88. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  89. package/dist/esm/daemon/protocol/contract.js +38 -0
  90. package/dist/esm/daemon/server.js +7 -0
  91. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  92. package/dist/esm/errors/wp-error.js +21 -2
  93. package/dist/esm/hooks/doctor.d.ts +11 -0
  94. package/dist/esm/hooks/doctor.js +114 -45
  95. package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
  96. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  97. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  98. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  99. package/dist/esm/hooks/status/index.js +1 -1
  100. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  101. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  102. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  103. package/dist/esm/mcp/blueprint/registration.js +1 -1
  104. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  105. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  106. package/dist/esm/mcp/tools/gain.js +28 -2
  107. package/dist/esm/mcp/tools/worktree.js +65 -3
  108. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  109. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  110. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  111. package/dist/esm/review/lifecycle-writers.js +330 -0
  112. package/dist/esm/review/subject.js +66 -32
  113. package/dist/esm/status/snapshot.d.ts +5 -0
  114. package/dist/esm/status/snapshot.js +27 -2
  115. package/dist/esm/test/shard-durations.json +0 -1
  116. package/dist/esm/test-helpers/global-setup.js +38 -3
  117. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  118. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  119. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  120. package/dist/esm/utils/package-root.d.ts +32 -0
  121. package/dist/esm/utils/package-root.js +82 -0
  122. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  123. package/dist/esm/worktrees/git-metadata.js +48 -0
  124. package/package.json +16 -21
  125. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  126. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  127. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  128. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  129. package/dist/esm/config/oxlint/index.d.ts +0 -22
  130. package/dist/esm/config/oxlint/index.js +0 -28
  131. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  132. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  133. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  134. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  135. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  136. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -4,10 +4,15 @@
4
4
  * One source of truth for how every supported host launches the webpresso MCP
5
5
  * server. Each host channel (Claude plugin manifest, Codex plugin manifest,
6
6
  * Codex `config.toml`, OpenCode `opencode.json`, Grok `.grok/config.toml`)
7
- * projects this spec into its own config dialect. The two committed plugin
8
- * manifests reference the launcher via `${CLAUDE_PLUGIN_ROOT}`/`${PLUGIN_ROOT}`;
9
- * the setup-time writers resolve an absolute `bin/wp` path via
10
- * {@link findWebpressoMcpEntry}. Compare channels *semantically* (command
7
+ * projects this spec into its own config dialect. The Claude plugin manifest
8
+ * references the launcher via `${CLAUDE_PLUGIN_ROOT}`, which Claude Code
9
+ * expands. Codex does **not** expand `${PLUGIN_ROOT}` in a bundled `.mcp.json`
10
+ * that variable reaches hook commands only — and it spawns bundled MCP
11
+ * servers with the *project* cwd and no plugin env, so the Codex manifest can
12
+ * anchor on neither the plugin root nor a relative path and uses the bare
13
+ * `wp` name resolved from PATH at spawn time. The remaining setup-time writers
14
+ * (Grok, OpenCode) resolve an absolute `bin/wp` path via
15
+ * {@link resolveWebpressoMcpEntry}. Compare channels *semantically* (command
11
16
  * basename, args, env) — never byte-for-byte.
12
17
  */
13
18
  import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
@@ -17,6 +22,8 @@ import { execFileSync } from "node:child_process";
17
22
  import { fileURLToPath } from "node:url";
18
23
  import { isVersionedAgentKitPayloadPath } from "#cli/auto-update/self-invocation.js";
19
24
  import { pathCandidates } from "#runtime/command-exists.js";
25
+ import { isManagedWorktreePath, isPathContained } from "#worktrees/identity.js";
26
+ import { resolveKnownManagedWorktreeRoots } from "#worktrees/location.js";
20
27
  export const WEBPRESSO_MCP_SERVER_NAME = "webpresso";
21
28
  /**
22
29
  * The single canonical launch spec for the webpresso MCP server. `name` is the
@@ -63,26 +70,6 @@ const WP_LAUNCHER_REQUIRED_RELATIVES = [
63
70
  join("bin", "runtime-lanes.js"),
64
71
  join("bin", "runtime-manifest.json"),
65
72
  ];
66
- /**
67
- * Resolve the absolute path to webpresso's global `bin/wp` launcher on this
68
- * machine. Probes the locations consumers use to install webpresso, in order
69
- * of stability:
70
- *
71
- * 1. the stable global `wp` shim available on PATH (including the
72
- * vite-plus multi-package shim at `~/.vite-plus/bin/wp`)
73
- * 2. the currently executing `@webpresso/agent-kit` package **only when it
74
- * is not a vite-plus `agent-kit#<installId>` payload path**
75
- * 3. Claude plugin install — `~/.claude/plugins/cache/.../agent-kit/`
76
- * 4. bun global — `~/.bun/install/global/node_modules/@webpresso/agent-kit/`
77
- *
78
- * Host MCP configs (Grok/Codex/etc.) must never be pinned to a versioned
79
- * installId tree: the next `wp update` / `vp update -g` deletes that path and
80
- * leaves every host with a dead launcher. Returns `null` when none of the
81
- * candidates yield a durable `wp`.
82
- */
83
- export function findWebpressoMcpEntry(probe = {}) {
84
- return resolveWebpressoMcpEntry(probe).entryPath;
85
- }
86
73
  function isUsableWebpressoMcpRoot(root) {
87
74
  return WP_LAUNCHER_REQUIRED_RELATIVES.every((relativePath) => existsSync(join(root, relativePath)));
88
75
  }
@@ -117,6 +104,23 @@ export function isVitePlusManagedWebpressoWp(entryPath) {
117
104
  function isDurableMcpEntryPath(entryPath) {
118
105
  return !isVersionedAgentKitPayloadPath(entryPath);
119
106
  }
107
+ /**
108
+ * Resolve the absolute path to webpresso's global `bin/wp` launcher on this
109
+ * machine. Probes the locations consumers use to install webpresso, in order
110
+ * of stability:
111
+ *
112
+ * 1. the stable global `wp` shim available on PATH (including the
113
+ * vite-plus multi-package shim at `~/.vite-plus/bin/wp`)
114
+ * 2. the currently executing `@webpresso/agent-kit` package **only when it
115
+ * is not a vite-plus `agent-kit#<installId>` payload path**
116
+ * 3. Claude plugin install — `~/.claude/plugins/cache/.../agent-kit/`
117
+ * 4. bun global — `~/.bun/install/global/node_modules/@webpresso/agent-kit/`
118
+ *
119
+ * Host MCP configs (Grok/OpenCode) must never be pinned to a versioned
120
+ * installId tree: the next `wp update` / `vp update -g` deletes that path and
121
+ * leaves every host with a dead launcher. `entryPath` is `null` when none of
122
+ * the candidates yield a durable `wp`.
123
+ */
120
124
  export function resolveWebpressoMcpEntry(probe = {}) {
121
125
  const checked = [];
122
126
  if (probe.candidates === undefined) {
@@ -151,8 +155,9 @@ export function resolveWebpressoMcpEntry(probe = {}) {
151
155
  // Never materialize host MCP configs against a vite-plus installId tree.
152
156
  if (!isDurableMcpEntryPath(wpBin))
153
157
  continue;
154
- if (isTrustedWebpressoPackageRoot(root))
158
+ if (isTrustedWebpressoPackageRoot(root, probe.sourceRoot)) {
155
159
  return { entryPath: wpBin, checked };
160
+ }
156
161
  }
157
162
  }
158
163
  return { entryPath: null, checked };
@@ -200,27 +205,78 @@ function isPathInsideOrEqual(parent, candidate) {
200
205
  const rel = relative(resolve(parentRoot), resolve(candidateRoot));
201
206
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
202
207
  }
203
- function isTrustedWebpressoPackageRoot(packageRoot) {
208
+ function isTrustedWebpressoPackageRoot(packageRoot, resolveSourceRoot = currentAgentKitPackageRoot) {
204
209
  try {
205
210
  if (!isUsableWebpressoPackageRoot(packageRoot))
206
211
  return false;
207
212
  const realPackageRoot = realpathSync(packageRoot);
208
- const sourceRoot = currentAgentKitPackageRoot();
209
- if (sourceRoot !== null && realPackageRoot === realpathSync(sourceRoot))
213
+ const sourceRoot = resolveSourceRoot();
214
+ // A managed blueprint worktree is deleted by `wp worktree merge-cleanup`
215
+ // once its PR lands, so self-trusting it here would persist a path into
216
+ // host MCP configs that later resolves to nothing. Only the durable
217
+ // primary checkout may earn self-trust.
218
+ const home = process.env.HOME || homedir();
219
+ const isManagedWorktree = isManagedWorktreePath(realPackageRoot, resolveKnownManagedWorktreeRoots(home));
220
+ if (sourceRoot !== null && !isManagedWorktree && realPackageRoot === realpathSync(sourceRoot)) {
210
221
  return true;
222
+ }
211
223
  return trustedHomeInstallPackageRoots().some((anchor) => existsSync(anchor) && isPathInsideOrEqual(anchor, realPackageRoot));
212
224
  }
213
225
  catch {
214
226
  return false;
215
227
  }
216
228
  }
217
- function trustedHomeInstallPackageRoots() {
218
- const home = process.env.HOME || homedir();
229
+ function trustedHomeInstallPackageRoots(home = process.env.HOME || homedir()) {
219
230
  return [
220
231
  join(home, ".claude", "plugins", "cache", "webpresso", "agent-kit"),
221
232
  join(home, ".bun", "install", "global", "node_modules", "@webpresso", "agent-kit"),
222
233
  ];
223
234
  }
235
+ /**
236
+ * Every root under which agent-kit itself installs (or checks out) a `bin/wp`.
237
+ * A `wp` launcher recorded inside one of these was written by agent-kit; a
238
+ * launcher anywhere else is an operator's deliberate choice.
239
+ */
240
+ export function agentKitOwnedCommandRoots(probe = {}) {
241
+ const home = probe.home ?? process.env.HOME ?? homedir();
242
+ const sourceRoot = (probe.sourceRoot ?? currentAgentKitPackageRoot)();
243
+ return [
244
+ ...trustedHomeInstallPackageRoots(home),
245
+ join(home, ".vite-plus"),
246
+ ...resolveKnownManagedWorktreeRoots(home),
247
+ ...(sourceRoot === null ? [] : [sourceRoot]),
248
+ ];
249
+ }
250
+ const WP_LAUNCHER_BASENAMES = new Set(["wp", "wp.exe"]);
251
+ /**
252
+ * Ownership — not shape — decides whether setup may retire a persisted MCP
253
+ * entry. An entry is agent-kit's own when it launches `wp mcp` from an
254
+ * ABSOLUTE path AND either that path is gone (one we would have deleted) or
255
+ * it lives inside one
256
+ * of {@link agentKitOwnedCommandRoots}. A *live* `wp mcp` launcher outside
257
+ * those roots is an operator pointing Codex at a custom build: preserve it.
258
+ */
259
+ export function isAgentKitOwnedWpCommand(command, args, probe = {}) {
260
+ if (!WP_LAUNCHER_BASENAMES.has(basename(command).toLowerCase()))
261
+ return false;
262
+ if (args.length !== WEBPRESSO_MCP_SPEC.args.length)
263
+ return false;
264
+ if (args.some((value, index) => value !== WEBPRESSO_MCP_SPEC.args[index]))
265
+ return false;
266
+ // Every agent-kit writer of this block persisted an ABSOLUTE launcher path
267
+ // (see resolveWebpressoMcpEntry). A bare PATH name — the spelling the Codex
268
+ // plugin manifest itself ships — or a cwd-relative path is therefore
269
+ // operator-authored, and its meaning depends on a PATH/cwd this predicate
270
+ // cannot reconstruct. Preserve it and let the doctor report it.
271
+ if (!isAbsolute(command))
272
+ return false;
273
+ const exists = probe.exists ?? existsSync;
274
+ // Only an absolute path can have been written by us and then rotted away
275
+ // (deleted managed worktree, replaced versioned install tree).
276
+ if (!exists(command))
277
+ return true;
278
+ return agentKitOwnedCommandRoots(probe).some((root) => isPathContained(root, command));
279
+ }
224
280
  function packageWpFromAdjacentNpmCmdShim(entryPath) {
225
281
  if (!entryPath.toLowerCase().endsWith(".cmd"))
226
282
  return null;
@@ -9,10 +9,21 @@
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, readdirSync } from "node:fs";
10
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
  import { convergeUnitFromTarget } from "./convergence.js";
12
- import { resolveUntrackedCollision } from "./untracked-collision.js";
13
- function guardUntrackedOverwrite(targetPath, incoming, opts) {
12
+ import { isGitIgnored, resolveUntrackedCollision } from "./untracked-collision.js";
13
+ /**
14
+ * Guard an overwrite against clobbering unknown untracked content. Returns a
15
+ * warning to surface to the caller when the write proceeded past divergent
16
+ * content it could safely recover from (git-ignored lane only); throws when
17
+ * the write must be blocked.
18
+ *
19
+ * `patchDerived` must be true only when `incoming` was computed FROM the
20
+ * target's existing content (e.g. `patchJsonFile`'s key-preserving patch) —
21
+ * that is what makes it safe to write through divergent unmarked content
22
+ * without a backup.
23
+ */
24
+ function guardUntrackedOverwrite(targetPath, incoming, opts, patchDerived) {
14
25
  if (!opts.repoRoot)
15
- return;
26
+ return undefined;
16
27
  const repoRoot = resolve(opts.repoRoot);
17
28
  const target = resolve(targetPath);
18
29
  const repoRelative = relative(repoRoot, target);
@@ -20,19 +31,25 @@ function guardUntrackedOverwrite(targetPath, incoming, opts) {
20
31
  repoRelative === ".." ||
21
32
  repoRelative.startsWith(`..${sep}`) ||
22
33
  isAbsolute(repoRelative)) {
23
- return;
34
+ return undefined;
24
35
  }
36
+ const gitPath = repoRelative.split(sep).join("/");
37
+ const scope = isGitIgnored(repoRoot, gitPath) ? "repo-ignored" : "repo-committed";
25
38
  const result = resolveUntrackedCollision({
26
39
  unit: convergeUnitFromTarget(repoRoot, target, {
27
40
  action: "modify",
28
- scope: "repo-committed",
41
+ scope,
29
42
  reason: "setup merge target",
30
43
  }),
31
44
  desiredContent: incoming,
32
45
  force: opts.overwrite,
46
+ patchDerived,
33
47
  });
34
48
  if (!result.ok)
35
49
  throw new Error(result.error);
50
+ if (result.warning)
51
+ console.error(`wp setup: ${result.warning}`);
52
+ return result.warning;
36
53
  }
37
54
  export function writeFileMerged(targetPath, incoming, opts = {}) {
38
55
  const exists = existsSync(targetPath);
@@ -51,9 +68,9 @@ export function writeFileMerged(targetPath, incoming, opts = {}) {
51
68
  if (shouldOverwrite) {
52
69
  if (opts.dryRun)
53
70
  return { targetPath, action: "skipped-dry" };
54
- guardUntrackedOverwrite(targetPath, incoming, opts);
71
+ const warning = guardUntrackedOverwrite(targetPath, incoming, opts, false);
55
72
  writeFileSync(targetPath, incoming);
56
- return { targetPath, action: "overwritten" };
73
+ return { targetPath, action: "overwritten", ...(warning ? { note: warning } : {}) };
57
74
  }
58
75
  if (opts.dryRun)
59
76
  return { targetPath, action: "skipped-dry" };
@@ -119,11 +136,14 @@ export function patchJsonFile(targetPath, patcher, opts = {}) {
119
136
  if (opts.dryRun) {
120
137
  return { targetPath, action: "skipped-dry" };
121
138
  }
122
- if (exists)
123
- guardUntrackedOverwrite(targetPath, incoming, opts);
139
+ const warning = exists ? guardUntrackedOverwrite(targetPath, incoming, opts, true) : undefined;
124
140
  mkdirSync(dirname(targetPath), { recursive: true });
125
141
  writeFileSync(targetPath, incoming);
126
- return { targetPath, action: exists ? "overwritten" : "created" };
142
+ return {
143
+ targetPath,
144
+ action: exists ? "overwritten" : "created",
145
+ ...(warning ? { note: warning } : {}),
146
+ };
127
147
  }
128
148
  export function summarizeResults(results) {
129
149
  const summary = {
@@ -6,21 +6,18 @@
6
6
  * the patch tiny and deterministic: per-server upserts, no TOML parser
7
7
  * dependency, no edits to unrelated user config.
8
8
  *
9
- * Managed blocks come from two sources:
10
- * 1. `[mcp_servers.webpresso]`points at webpresso's own MCP server.
11
- * Path-stability requires discovery: webpresso lives in different
12
- * locations depending on how the user installed it (Claude plugin
13
- * install, bun global, pnpm/npm global). Discovery happens at scaffold
14
- * time; the resolved absolute path is written into the codex config.
15
- * When the unified-cli sibling cutover lands (`webpresso mcp serve`
16
- * from a path-stable bin), this block collapses to a fixed `command`.
17
- * 2. Consumer-owned `.mcp.json` package declarations — compiled to setup-
18
- * provisioned, exact-version binaries with environment-variable names
19
- * forwarded through Codex's stdio `env_vars` allow-list.
9
+ * Webpresso's own MCP server is NOT one of them. Codex receives `webpresso`
10
+ * over exactly one delivery channel the bundled Codex plugin's `.mcp.json`.
11
+ * Setup therefore *retires* the `[mcp_servers.webpresso]` block earlier
12
+ * versions wrote here (see {@link pruneLegacyCodexWebpressoMcp}) instead of
13
+ * refreshing it. What remains managed here is consumer-owned `.mcp.json`
14
+ * package declarations compiled to setup-provisioned, exact-version binaries
15
+ * with environment-variable names forwarded through Codex's stdio `env_vars`
16
+ * allow-list.
20
17
  */
21
18
  import { spawnSync } from "node:child_process";
22
- import { findWebpressoMcpEntry, WEBPRESSO_MCP_SERVER_NAME, type WebpressoInstallProbe } from "#cli/commands/init/mcp-spec.js";
23
- export { findWebpressoMcpEntry, WEBPRESSO_MCP_SERVER_NAME };
19
+ import { WEBPRESSO_MCP_SERVER_NAME, type AgentKitOwnedCommandProbe, type WebpressoInstallProbe } from "#cli/commands/init/mcp-spec.js";
20
+ export { WEBPRESSO_MCP_SERVER_NAME };
24
21
  export type { WebpressoInstallProbe };
25
22
  import type { MergeOptions } from "#cli/commands/init/merge";
26
23
  import { type PinnedCodexMcpDefinition } from "#hooks/host-config-contract.js";
@@ -143,35 +140,63 @@ export type EnsureClaudePlaywrightMcpResult = {
143
140
  path: string;
144
141
  };
145
142
  export declare function ensureClaudePlaywrightMcp(input: EnsureClaudePlaywrightMcpInput): EnsureClaudePlaywrightMcpResult;
146
- export declare function agentKitMcpBlock(entryPath: string): string;
147
- export declare function upsertWebpressoMcpServer(raw: string, entryPath: string): string;
148
- export interface EnsureCodexWebpressoMcpInput {
143
+ export type PruneOwnedMcpServerOutcome = {
144
+ readonly kind: "removed";
145
+ readonly raw: string;
146
+ readonly command: string;
147
+ } | {
148
+ readonly kind: "absent";
149
+ readonly raw: string;
150
+ } | {
151
+ readonly kind: "preserved";
152
+ readonly raw: string;
153
+ readonly command: string | null;
154
+ } | {
155
+ readonly kind: "ambiguous";
156
+ readonly raw: string;
157
+ };
158
+ /**
159
+ * Remove `[mcp_servers.<serverId>]` (plus its contiguous sub-tables) when
160
+ * `isOwned` claims the block's launch tuple. Fails closed — leaving `raw`
161
+ * byte-identical — for every TOML spelling this line scanner cannot rewrite
162
+ * safely (dotted keys, inline parent table, array tables, non-contiguous
163
+ * sub-tables).
164
+ */
165
+ export declare function pruneOwnedMcpServerBlock(raw: string, serverId: string, isOwned: (command: string, args: readonly string[]) => boolean): PruneOwnedMcpServerOutcome;
166
+ export interface PruneLegacyCodexWebpressoMcpInput {
149
167
  options: MergeOptions;
150
- /** Test seam — override the resolved MCP entry path. */
151
- entryPath?: string;
152
168
  /** Test seam — override `$CODEX_HOME/config.toml`. */
153
169
  configPath?: string;
154
- /** Test seam — override the install-discovery probe. */
155
- probe?: WebpressoInstallProbe;
170
+ /** Test seam — override agent-kit install-root ownership resolution. */
171
+ ownership?: AgentKitOwnedCommandProbe;
156
172
  }
157
- export type EnsureCodexWebpressoMcpResult = {
158
- kind: "codex-webpresso-mcp-written";
173
+ export type PruneLegacyCodexWebpressoMcpResult = {
174
+ kind: "codex-legacy-webpresso-mcp-removed";
159
175
  path: string;
160
- entryPath: string;
176
+ command: string;
161
177
  } | {
162
- kind: "codex-webpresso-mcp-unchanged";
178
+ kind: "codex-legacy-webpresso-mcp-absent";
163
179
  path: string;
164
- entryPath: string;
165
180
  } | {
166
- kind: "codex-webpresso-mcp-ambiguous";
181
+ kind: "codex-legacy-webpresso-mcp-preserved";
167
182
  path: string;
168
- entryPath: string;
183
+ command: string | null;
169
184
  } | {
170
- kind: "codex-webpresso-mcp-skipped-dry-run";
185
+ kind: "codex-legacy-webpresso-mcp-ambiguous";
171
186
  path: string;
172
187
  } | {
173
- kind: "codex-webpresso-mcp-not-installed";
188
+ kind: "codex-legacy-webpresso-mcp-skipped-dry-run";
174
189
  path: string;
175
- checked: readonly string[];
176
190
  };
177
- export declare function ensureCodexWebpressoMcp(input: EnsureCodexWebpressoMcpInput): EnsureCodexWebpressoMcpResult;
191
+ /**
192
+ * Retire the legacy user-global `[mcp_servers.webpresso]` block. Runs from
193
+ * setup AND from the `wp codex` launch preflight — the launch path is the only
194
+ * one that reaches machines that never re-run setup.
195
+ */
196
+ export declare function pruneLegacyCodexWebpressoMcp(input: PruneLegacyCodexWebpressoMcpInput): PruneLegacyCodexWebpressoMcpResult;
197
+ /**
198
+ * Installed Codex plugin MCP manifests — the surviving delivery channel.
199
+ * Cheap directory probe only: no `codex plugin` subprocess belongs on the
200
+ * launch hot path.
201
+ */
202
+ export declare function findInstalledCodexPluginMcpManifests(homeDir?: string): readonly string[];
@@ -6,26 +6,23 @@
6
6
  * the patch tiny and deterministic: per-server upserts, no TOML parser
7
7
  * dependency, no edits to unrelated user config.
8
8
  *
9
- * Managed blocks come from two sources:
10
- * 1. `[mcp_servers.webpresso]`points at webpresso's own MCP server.
11
- * Path-stability requires discovery: webpresso lives in different
12
- * locations depending on how the user installed it (Claude plugin
13
- * install, bun global, pnpm/npm global). Discovery happens at scaffold
14
- * time; the resolved absolute path is written into the codex config.
15
- * When the unified-cli sibling cutover lands (`webpresso mcp serve`
16
- * from a path-stable bin), this block collapses to a fixed `command`.
17
- * 2. Consumer-owned `.mcp.json` package declarations — compiled to setup-
18
- * provisioned, exact-version binaries with environment-variable names
19
- * forwarded through Codex's stdio `env_vars` allow-list.
9
+ * Webpresso's own MCP server is NOT one of them. Codex receives `webpresso`
10
+ * over exactly one delivery channel the bundled Codex plugin's `.mcp.json`.
11
+ * Setup therefore *retires* the `[mcp_servers.webpresso]` block earlier
12
+ * versions wrote here (see {@link pruneLegacyCodexWebpressoMcp}) instead of
13
+ * refreshing it. What remains managed here is consumer-owned `.mcp.json`
14
+ * package declarations compiled to setup-provisioned, exact-version binaries
15
+ * with environment-variable names forwarded through Codex's stdio `env_vars`
16
+ * allow-list.
20
17
  */
21
18
  import { spawnSync } from "node:child_process";
22
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
23
20
  import { homedir } from "node:os";
24
21
  import { dirname, join } from "node:path";
25
- import { agentKitMcpLaunchCommand, findWebpressoMcpEntry, resolveExecutableOnPath, resolveWebpressoMcpEntry, WEBPRESSO_MCP_SERVER_NAME, WEBPRESSO_MCP_SPEC, } from "#cli/commands/init/mcp-spec.js";
26
- // Re-exported so existing importers (tests) keep a stable path; the canonical
27
- // implementation now lives in `mcp-spec.ts`.
28
- export { findWebpressoMcpEntry, WEBPRESSO_MCP_SERVER_NAME };
22
+ import { isAgentKitOwnedWpCommand, resolveExecutableOnPath, WEBPRESSO_MCP_SERVER_NAME, } from "#cli/commands/init/mcp-spec.js";
23
+ // Re-exported so existing importers keep a stable path; the canonical
24
+ // implementation lives in `mcp-spec.ts`.
25
+ export { WEBPRESSO_MCP_SERVER_NAME };
29
26
  import { updateScaffolderTextFile } from "#cli/commands/init/scaffolders/file-transaction.js";
30
27
  import { isJsonRecord, isUnsafeRuntimeLauncher, parseJson, parseProjectMcpServers, parseTomlString, } from "#hooks/host-config-contract.js";
31
28
  import { appendGlobalCapableVpArgs, resolveGlobalCapableVpCommand, } from "#cli/global-vp.js";
@@ -309,8 +306,10 @@ function hasDottedServerDefinition(lines, startsInsideMultiline, serverId) {
309
306
  export function hasAmbiguousMcpServerDottedDefinition(raw, serverId) {
310
307
  const lines = raw.trimEnd().split(/\r?\n/);
311
308
  const startsInsideMultiline = tomlLinesStartingInsideMultilineString(lines);
312
- const start = lines.findIndex((line, index) => !startsInsideMultiline[index] && isServerTableHeader(line, serverId));
313
- return start === -1 && hasDottedServerDefinition(lines, startsInsideMultiline, serverId);
309
+ // Scanned unconditionally, not only when no table header exists: a dotted key
310
+ // alongside the table would outlive the table's removal and re-declare the
311
+ // server with no launcher, which breaks the whole config load.
312
+ return hasDottedServerDefinition(lines, startsInsideMultiline, serverId);
314
313
  }
315
314
  export function hasManagedMcpServerTableConflict(raw, serverId) {
316
315
  const lines = raw.trimEnd().split(/\r?\n/);
@@ -658,39 +657,177 @@ export function ensureClaudePlaywrightMcp(input) {
658
657
  return { kind: "claude-playwright-mcp-written", path: configPath };
659
658
  }
660
659
  // ────────────────────────────────────────────────────────────────────────────
661
- // Agent-kit MCP server registration
660
+ // Legacy `[mcp_servers.webpresso]` retirement
661
+ //
662
+ // Earlier versions delivered webpresso to Codex over a SECOND channel: a
663
+ // `[mcp_servers.webpresso]` block in the user-global `config.toml` pinned to
664
+ // whatever absolute install path existed at setup time. The bundled Codex
665
+ // plugin is now the single channel, so setup must remove what it used to write
666
+ // — otherwise every machine keeps a duplicate registration pinned to a path
667
+ // agent-kit itself deletes on the next update or worktree cleanup.
668
+ //
669
+ // The removal predicate is OWNERSHIP-based, never shape-based: a live `wp mcp`
670
+ // launcher outside every agent-kit-owned root is an operator pointing Codex at
671
+ // a custom build and is preserved (and reported by `wp doctor`).
662
672
  // ────────────────────────────────────────────────────────────────────────────
663
- export function agentKitMcpBlock(entryPath) {
664
- const launch = agentKitMcpLaunchCommand(entryPath);
665
- return commandMcpCodexBlock({
666
- id: WEBPRESSO_MCP_SERVER_NAME,
673
+ const TOML_COMMAND_ASSIGNMENT = /^\s*command\s*=\s*(.+?)\s*$/u;
674
+ const TOML_ARGS_ASSIGNMENT = /^\s*args\s*=\s*(.+?)\s*$/u;
675
+ function firstTomlAssignmentValue(body, pattern) {
676
+ for (const line of body) {
677
+ const match = pattern.exec(line);
678
+ if (match?.[1] !== undefined)
679
+ return match[1];
680
+ }
681
+ return null;
682
+ }
683
+ function parseTomlStringArray(value) {
684
+ const trimmed = value.trim();
685
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
686
+ return null;
687
+ const inner = trimmed.slice(1, -1).trim();
688
+ if (inner.length === 0)
689
+ return [];
690
+ const parsed = inner
691
+ .split(",")
692
+ .map((element) => element.trim())
693
+ .filter((element) => element.length > 0)
694
+ .map(parseTomlString);
695
+ return parsed.every((element) => element !== null) ? parsed : null;
696
+ }
697
+ /**
698
+ * Read the parent block's `command`/`args`, stopping at the first sub-table
699
+ * header so a `[mcp_servers.<id>.env]` child never supplies the launch tuple.
700
+ */
701
+ function readManagedBlockLaunch(lines, startsInsideMultiline, start, end) {
702
+ const body = [];
703
+ for (let index = start + 1; index < end; index += 1) {
704
+ // Multiline-string content is document text, not configuration: reading a
705
+ // decoy tuple from it — or letting a bracket line there end the scan —
706
+ // would delete the operator's real registration.
707
+ if (startsInsideMultiline[index])
708
+ continue;
709
+ if (lines[index].trim().startsWith("["))
710
+ break;
711
+ body.push(lines[index]);
712
+ }
713
+ const commandValue = firstTomlAssignmentValue(body, TOML_COMMAND_ASSIGNMENT);
714
+ const command = commandValue === null ? null : parseTomlString(commandValue);
715
+ if (command === null)
716
+ return null;
717
+ const argsValue = firstTomlAssignmentValue(body, TOML_ARGS_ASSIGNMENT);
718
+ const args = argsValue === null ? [] : parseTomlStringArray(argsValue);
719
+ return args === null ? null : { command, args };
720
+ }
721
+ /**
722
+ * The removal path is the only one that rewrites lines it did not classify, so
723
+ * it must reproduce the document's existing newline style rather than impose LF.
724
+ */
725
+ function dominantEol(raw) {
726
+ const crlf = raw.split("\r\n").length - 1;
727
+ const lf = raw.split("\n").length - 1;
728
+ return crlf * 2 > lf ? "\r\n" : "\n";
729
+ }
730
+ function rebuildWithoutSection(lines, start, end, eol) {
731
+ const kept = lines.slice(0, start);
732
+ while (kept.at(-1)?.trim() === "")
733
+ kept.pop();
734
+ const next = kept
735
+ .concat(lines.slice(end))
736
+ .join(eol)
737
+ .replace(/^(?:[ \t]*(?:\r\n|\n))+/u, "")
738
+ .trimEnd();
739
+ return next.length > 0 ? `${next}${eol}` : "";
740
+ }
741
+ /**
742
+ * Remove `[mcp_servers.<serverId>]` (plus its contiguous sub-tables) when
743
+ * `isOwned` claims the block's launch tuple. Fails closed — leaving `raw`
744
+ * byte-identical — for every TOML spelling this line scanner cannot rewrite
745
+ * safely (dotted keys, inline parent table, array tables, non-contiguous
746
+ * sub-tables).
747
+ */
748
+ export function pruneOwnedMcpServerBlock(raw, serverId, isOwned) {
749
+ if (hasAmbiguousMcpServerDottedDefinition(raw, serverId) ||
750
+ hasManagedMcpServerTableConflict(raw, serverId)) {
751
+ return { kind: "ambiguous", raw };
752
+ }
753
+ const lines = raw.trimEnd().split(/\r?\n/);
754
+ const startsInsideMultiline = tomlLinesStartingInsideMultilineString(lines);
755
+ const start = lines.findIndex((line, index) => !startsInsideMultiline[index] && isServerTableHeader(line, serverId));
756
+ if (start === -1)
757
+ return { kind: "absent", raw };
758
+ const end = managedSectionEnd(lines, startsInsideMultiline, start, serverId);
759
+ const launch = readManagedBlockLaunch(lines, startsInsideMultiline, start, end);
760
+ if (launch === null)
761
+ return { kind: "preserved", raw, command: null };
762
+ if (!isOwned(launch.command, launch.args)) {
763
+ return { kind: "preserved", raw, command: launch.command };
764
+ }
765
+ return {
766
+ kind: "removed",
767
+ raw: rebuildWithoutSection(lines, start, end, dominantEol(raw)),
667
768
  command: launch.command,
668
- args: launch.args,
669
- codexEnv: WEBPRESSO_MCP_SPEC.env,
670
- });
769
+ };
671
770
  }
672
- export function upsertWebpressoMcpServer(raw, entryPath) {
673
- return upsertMcpServerBlock(raw, WEBPRESSO_MCP_SERVER_NAME, agentKitMcpBlock(entryPath));
771
+ function legacyPruneResult(outcome, path) {
772
+ switch (outcome.kind) {
773
+ case "removed":
774
+ return { kind: "codex-legacy-webpresso-mcp-removed", path, command: outcome.command };
775
+ case "preserved":
776
+ return { kind: "codex-legacy-webpresso-mcp-preserved", path, command: outcome.command };
777
+ case "ambiguous":
778
+ return { kind: "codex-legacy-webpresso-mcp-ambiguous", path };
779
+ case "absent":
780
+ return { kind: "codex-legacy-webpresso-mcp-absent", path };
781
+ }
674
782
  }
675
- export function ensureCodexWebpressoMcp(input) {
783
+ /**
784
+ * Retire the legacy user-global `[mcp_servers.webpresso]` block. Runs from
785
+ * setup AND from the `wp codex` launch preflight — the launch path is the only
786
+ * one that reaches machines that never re-run setup.
787
+ */
788
+ export function pruneLegacyCodexWebpressoMcp(input) {
676
789
  const configPath = input.configPath ?? defaultConfigPath();
677
790
  if (input.options.dryRun) {
678
- return { kind: "codex-webpresso-mcp-skipped-dry-run", path: configPath };
791
+ return { kind: "codex-legacy-webpresso-mcp-skipped-dry-run", path: configPath };
679
792
  }
680
- const resolution = input.entryPath === undefined ? resolveWebpressoMcpEntry(input.probe) : undefined;
681
- const entryPath = input.entryPath ?? resolution?.entryPath;
682
- if (!entryPath) {
683
- const checked = resolution?.checked ?? [];
684
- return { kind: "codex-webpresso-mcp-not-installed", path: configPath, checked };
793
+ if (!existsSync(configPath)) {
794
+ return { kind: "codex-legacy-webpresso-mcp-absent", path: configPath };
685
795
  }
686
- const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
687
- if (hasAmbiguousMcpServerDottedDefinition(existing, WEBPRESSO_MCP_SERVER_NAME) ||
688
- hasManagedMcpServerTableConflict(existing, WEBPRESSO_MCP_SERVER_NAME)) {
689
- return { kind: "codex-webpresso-mcp-ambiguous", path: configPath, entryPath };
796
+ // Classify INSIDE the locked transaction so a concurrent writer's block is
797
+ // never judged from a pre-lock read (PR#767 lost-update guarantee).
798
+ let outcome = { kind: "absent", raw: "" };
799
+ updateCodexConfig(configPath, (raw) => {
800
+ outcome = pruneOwnedMcpServerBlock(raw, WEBPRESSO_MCP_SERVER_NAME, (command, args) => isAgentKitOwnedWpCommand(command, args, input.ownership));
801
+ return outcome.raw;
802
+ });
803
+ return legacyPruneResult(outcome, configPath);
804
+ }
805
+ /**
806
+ * Installed Codex plugin MCP manifests — the surviving delivery channel.
807
+ * Cheap directory probe only: no `codex plugin` subprocess belongs on the
808
+ * launch hot path.
809
+ */
810
+ export function findInstalledCodexPluginMcpManifests(homeDir = process.env.HOME || homedir()) {
811
+ const base = join(homeDir, ".codex", "plugins", "cache");
812
+ const manifests = [];
813
+ for (const marketplace of safeReadDir(base)) {
814
+ const pluginDir = join(base, marketplace, "agent-kit");
815
+ for (const version of safeReadDir(pluginDir)) {
816
+ const manifest = join(pluginDir, version, ".mcp.json");
817
+ if (existsSync(manifest))
818
+ manifests.push(manifest);
819
+ }
690
820
  }
691
- const result = updateCodexConfig(configPath, (raw) => upsertWebpressoMcpServer(raw, entryPath));
692
- if (result === "unchanged") {
693
- return { kind: "codex-webpresso-mcp-unchanged", path: configPath, entryPath };
821
+ return manifests;
822
+ }
823
+ function safeReadDir(dir) {
824
+ try {
825
+ const entries = readdirSync(dir);
826
+ if (!Array.isArray(entries))
827
+ return [];
828
+ return entries.filter((entry) => typeof entry === "string");
829
+ }
830
+ catch {
831
+ return [];
694
832
  }
695
- return { kind: "codex-webpresso-mcp-written", path: configPath, entryPath };
696
833
  }