@webpresso/agent-kit 3.1.28 → 3.1.30

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 (98) hide show
  1. package/catalog/AGENTS.md.tpl +4 -4
  2. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  3. package/catalog/agent/rules/ci-test-perf.md +5 -1
  4. package/catalog/agent/rules/cmd-execution.md +9 -0
  5. package/catalog/agent/rules/pre-implementation.md +2 -1
  6. package/catalog/agent/rules/test-scan-perf.md +80 -0
  7. package/catalog/agent/rules/workflow-skills-routing.md +4 -2
  8. package/catalog/agent/skills/fix/SKILL.md +3 -0
  9. package/catalog/agent/skills/verify/SKILL.md +18 -0
  10. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +3 -0
  13. package/dist/esm/audit/roadmap-links.js +6 -0
  14. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  15. package/dist/esm/audit/test-scan-perf.js +275 -0
  16. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  17. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  18. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  20. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  21. package/dist/esm/ci/act-replay.js +110 -7
  22. package/dist/esm/ci/act-runner.d.ts +4 -0
  23. package/dist/esm/ci/act-runner.js +85 -26
  24. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  25. package/dist/esm/ci/act-worktree-git.js +219 -0
  26. package/dist/esm/cli/commands/audit.js +3 -0
  27. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  28. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  29. package/dist/esm/cli/commands/ci.d.ts +8 -1
  30. package/dist/esm/cli/commands/ci.js +80 -21
  31. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  32. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  33. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  34. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  35. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  36. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  37. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  38. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  39. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  40. package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
  41. package/dist/esm/cli/commands/dash/plan-once.js +41 -4
  42. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  43. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  44. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  45. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  46. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  47. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  48. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  49. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  50. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  51. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  52. package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
  53. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  54. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  55. package/dist/esm/cli/commands/format.js +0 -2
  56. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  57. package/dist/esm/cli/commands/init/config.js +1 -4
  58. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  59. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  60. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  63. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  64. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  65. package/dist/esm/cli/commands/logs.d.ts +15 -0
  66. package/dist/esm/cli/commands/logs.js +192 -3
  67. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  68. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  69. package/dist/esm/cli/commands/review.js +5 -8
  70. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  71. package/dist/esm/cli/commands/secrets.js +4 -1
  72. package/dist/esm/cli/direct-provider-launch.js +25 -1
  73. package/dist/esm/hooks/doctor.d.ts +15 -0
  74. package/dist/esm/hooks/doctor.js +56 -0
  75. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  76. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  77. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  78. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  79. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  80. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  81. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  82. package/dist/esm/mcp/tools/audit.js +10 -0
  83. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  84. package/dist/esm/mcp/tools/ci-act.js +9 -11
  85. package/dist/esm/paths/state-root.js +3 -0
  86. package/dist/esm/review/authority.js +17 -2
  87. package/dist/esm/review/availability.js +18 -5
  88. package/dist/esm/review/subject.d.ts +15 -0
  89. package/dist/esm/review/subject.js +105 -10
  90. package/dist/esm/runtime/executor.d.ts +1 -0
  91. package/dist/esm/runtime/executor.js +2 -1
  92. package/dist/esm/secret-gate/runner.d.ts +1 -0
  93. package/dist/esm/secret-gate/runner.js +4 -1
  94. package/dist/esm/worktrees/current.d.ts +1 -0
  95. package/dist/esm/worktrees/current.js +1 -0
  96. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  97. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  98. package/package.json +14 -14
@@ -1,7 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
- import { closeSync, createWriteStream, mkdirSync, openSync, readdirSync, readFileSync, statSync, renameSync, utimesSync, rmSync, writeFileSync, } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createWriteStream, mkdirSync, openSync, readdirSync, readFileSync, statSync, renameSync, utimesSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { getSurfacePath } from "#paths/state-root.js";
6
6
  import { readTrustedJsonFile } from "#shared-utils/read-json-file.js";
7
7
  import { shortId } from "#shared-utils/short-id.js";
@@ -16,6 +16,7 @@ export const CLI_LOG_COMMANDS = [
16
16
  "format",
17
17
  ];
18
18
  const ORPHAN_LOG_GRACE_MS = 60_000;
19
+ const INDEX_LOCK_STALE_MS = 5_000;
19
20
  export function isCliLogCommandName(value) {
20
21
  return CLI_LOG_COMMANDS.includes(value);
21
22
  }
@@ -63,39 +64,39 @@ function openCliLogSink(commandDir, command, cwd) {
63
64
  stream.write(chunk);
64
65
  },
65
66
  async finalize(metadata) {
66
- await new Promise((resolve, reject) => {
67
- if (streamError) {
68
- reject(streamError);
69
- return;
70
- }
71
- rejectPendingFinalize = reject;
72
- stream.end(() => {
73
- rejectPendingFinalize = undefined;
67
+ try {
68
+ await new Promise((resolve, reject) => {
74
69
  if (streamError) {
75
70
  reject(streamError);
76
71
  return;
77
72
  }
78
- resolve();
73
+ rejectPendingFinalize = reject;
74
+ stream.end(() => {
75
+ rejectPendingFinalize = undefined;
76
+ if (streamError) {
77
+ reject(streamError);
78
+ return;
79
+ }
80
+ resolve();
81
+ });
79
82
  });
80
- });
81
- const entry = {
82
- id,
83
- command,
84
- timestamp: metadata.timestamp ?? new Date().toISOString(),
85
- exitCode: metadata.exitCode,
86
- logPath: absoluteLogPath,
87
- ...(metadata.options ? { options: metadata.options } : {}),
88
- ...(metadata.summary ? { summary: metadata.summary } : {}),
89
- };
90
- try {
83
+ const entry = {
84
+ id,
85
+ command,
86
+ timestamp: metadata.timestamp ?? new Date().toISOString(),
87
+ exitCode: metadata.exitCode,
88
+ logPath: absoluteLogPath,
89
+ ...(metadata.options ? { options: metadata.options } : {}),
90
+ ...(metadata.summary ? { summary: metadata.summary } : {}),
91
+ };
91
92
  markLogRecentlyFinalized(absoluteLogPath);
92
93
  // Index/lock live next to the log file so fallback dirs stay consistent.
93
94
  writeLogEntryInDir(entry, commandDir);
95
+ return entry;
94
96
  }
95
97
  finally {
96
98
  rmSync(activeMarkerPath, { force: true });
97
99
  }
98
- return entry;
99
100
  },
100
101
  };
101
102
  }
@@ -113,7 +114,7 @@ export function createCliLogSink(command, cwd = process.cwd()) {
113
114
  catch (error) {
114
115
  if (!isUnwritableFsError(error))
115
116
  throw error;
116
- const fallbackDir = join(tmpdir(), "webpresso", "cli-logs", command);
117
+ const fallbackDir = getFallbackCommandLogDir(command, cwd);
117
118
  const code = typeof error === "object" && error !== null && "code" in error
118
119
  ? String(error.code)
119
120
  : "unknown";
@@ -122,13 +123,37 @@ export function createCliLogSink(command, cwd = process.cwd()) {
122
123
  }
123
124
  }
124
125
  export function readCliLogEntries(command, cwd = process.cwd()) {
125
- const indexPath = getCommandIndexPath(command, cwd);
126
+ const entries = [
127
+ ...readCliLogEntriesFromCommandDir(command, getCommandLogDir(command, cwd)),
128
+ ...readCliLogEntriesFromCommandDir(command, getFallbackCommandLogDir(command, cwd)),
129
+ ];
130
+ const byLogPath = new Map();
131
+ for (const entry of entries) {
132
+ byLogPath.set(entry.logPath, entry);
133
+ }
134
+ return [...byLogPath.values()]
135
+ .sort((a, b) => {
136
+ if (a.active === true && b.active !== true)
137
+ return -1;
138
+ if (b.active === true && a.active !== true)
139
+ return 1;
140
+ return b.timestamp.localeCompare(a.timestamp);
141
+ })
142
+ .slice(0, 10);
143
+ }
144
+ function readCliLogEntriesFromCommandDir(command, commandDir) {
145
+ const activeEntries = readActiveCliLogEntriesFromDir(command, commandDir);
146
+ const indexPath = join(commandDir, "index.json");
126
147
  try {
127
148
  const parsed = readTrustedJsonFile(indexPath);
128
- return Array.isArray(parsed.entries) ? parsed.entries : [];
149
+ const finalizedEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
150
+ return [
151
+ ...activeEntries,
152
+ ...finalizedEntries.filter((entry) => !activeEntries.some((active) => active.id === entry.id)),
153
+ ].slice(0, 10);
129
154
  }
130
155
  catch {
131
- return [];
156
+ return activeEntries;
132
157
  }
133
158
  }
134
159
  export function readCliLogEntry(command, ordinal = 1, cwd = process.cwd()) {
@@ -146,6 +171,45 @@ function readCliLogEntriesFromDir(commandDir) {
146
171
  return [];
147
172
  }
148
173
  }
174
+ function readActiveCliLogEntriesFromDir(command, commandDir) {
175
+ let files;
176
+ try {
177
+ files = readdirSync(commandDir);
178
+ }
179
+ catch {
180
+ return [];
181
+ }
182
+ return files
183
+ .filter((file) => file.endsWith(".log.active"))
184
+ .map((file) => readActiveCliLogEntryFromMarker(command, join(commandDir, file)))
185
+ .filter((entry) => entry !== null)
186
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
187
+ }
188
+ function readActiveCliLogEntryFromMarker(command, markerPath) {
189
+ try {
190
+ const markerStat = statSync(markerPath);
191
+ const marker = readFileSync(markerPath, "utf8");
192
+ const id = basename(markerPath).slice(0, -".log.active".length);
193
+ if (!id)
194
+ return null;
195
+ const markerLines = marker.split("\n");
196
+ const live = isActiveMarkerSnapshotLive(marker, markerStat.mtimeMs);
197
+ return {
198
+ id,
199
+ command,
200
+ timestamp: markerLines[1]?.trim() || new Date(markerStat.mtimeMs).toISOString(),
201
+ exitCode: -1,
202
+ logPath: markerPath.slice(0, -".active".length),
203
+ ...(live ? { active: true } : {}),
204
+ summary: live ? "active log" : "orphaned log from exited process",
205
+ };
206
+ }
207
+ catch (error) {
208
+ if (isMissingFileError(error))
209
+ return null;
210
+ throw error;
211
+ }
212
+ }
149
213
  function writeLogEntryInDir(entry, commandDir) {
150
214
  withCommandIndexLockInDir(commandDir, () => {
151
215
  const indexPath = join(commandDir, "index.json");
@@ -169,28 +233,86 @@ function writeLogEntryInDir(entry, commandDir) {
169
233
  }
170
234
  function withCommandIndexLockInDir(commandDir, fn) {
171
235
  mkdirSync(commandDir, { recursive: true });
172
- const lockPath = join(commandDir, "index.lock");
236
+ const lockDir = join(commandDir, "index.lock.d");
237
+ mkdirSync(lockDir, { recursive: true });
238
+ const ownerPath = join(lockDir, `${Date.now()}-${process.pid}-${randomUUID()}.owner`);
239
+ writeFileSync(ownerPath, `${process.pid}\n${new Date().toISOString()}\n`, {
240
+ encoding: "utf8",
241
+ flag: "wx",
242
+ });
173
243
  const started = Date.now();
174
- let fd;
175
- while (fd === undefined) {
176
- try {
177
- fd = openSync(lockPath, "wx");
178
- }
179
- catch (error) {
180
- if (!isFileExistsError(error))
181
- throw error;
182
- if (Date.now() - started > 5_000) {
183
- throw new Error(`Timed out waiting for CLI log index lock: ${lockPath}`);
244
+ try {
245
+ while (!isCurrentIndexLockOwner(lockDir, ownerPath)) {
246
+ if (Date.now() - started > INDEX_LOCK_STALE_MS) {
247
+ throw new Error(`Timed out waiting for CLI log index lock: ${lockDir}`);
184
248
  }
185
249
  sleepSync(10);
186
250
  }
187
- }
188
- try {
189
251
  return fn();
190
252
  }
191
253
  finally {
192
- closeSync(fd);
193
- rmSync(lockPath, { force: true });
254
+ rmSync(ownerPath, { force: true });
255
+ }
256
+ }
257
+ function isCurrentIndexLockOwner(lockDir, ownerPath) {
258
+ const liveOwners = readLiveIndexLockOwners(lockDir);
259
+ const firstOwner = liveOwners[0];
260
+ if (firstOwner === undefined)
261
+ return false;
262
+ return firstOwner.path === ownerPath;
263
+ }
264
+ function readLiveIndexLockOwners(lockDir) {
265
+ let files;
266
+ try {
267
+ files = readdirSync(lockDir);
268
+ }
269
+ catch (error) {
270
+ if (isMissingFileError(error))
271
+ return [];
272
+ throw error;
273
+ }
274
+ return files
275
+ .filter((file) => file.endsWith(".owner"))
276
+ .map((file) => readIndexLockOwner(join(lockDir, file)))
277
+ .filter((owner) => owner !== null)
278
+ .sort((a, b) => a.timestampMs - b.timestampMs || a.path.localeCompare(b.path));
279
+ }
280
+ function readIndexLockOwner(ownerPath) {
281
+ let marker = "";
282
+ let markerMtimeMs = 0;
283
+ try {
284
+ const markerStat = statSync(ownerPath);
285
+ markerMtimeMs = markerStat.mtimeMs;
286
+ marker = readFileSync(ownerPath, "utf8");
287
+ }
288
+ catch (error) {
289
+ if (isMissingFileError(error))
290
+ return null;
291
+ throw error;
292
+ }
293
+ const markerLines = marker.split("\n");
294
+ const pid = Number(markerLines[0]);
295
+ const hasOwner = Number.isInteger(pid) && pid > 0;
296
+ const timestamp = Date.parse(markerLines[1] ?? "");
297
+ const timestampMs = Number.isFinite(timestamp) ? timestamp : markerMtimeMs;
298
+ const stale = (hasOwner && !isProcessLive(pid)) ||
299
+ (!hasOwner && Date.now() - markerMtimeMs > INDEX_LOCK_STALE_MS);
300
+ if (stale) {
301
+ rmSync(ownerPath, { force: true });
302
+ return null;
303
+ }
304
+ return { path: ownerPath, timestampMs };
305
+ }
306
+ function isProcessLive(pid) {
307
+ try {
308
+ process.kill(pid, 0);
309
+ return true;
310
+ }
311
+ catch (error) {
312
+ if (typeof error === "object" && error !== null && "code" in error) {
313
+ return error.code === "EPERM";
314
+ }
315
+ return false;
194
316
  }
195
317
  }
196
318
  function writeIndexAtomically(indexPath, index) {
@@ -233,31 +355,45 @@ function canPruneLogPath(logPath) {
233
355
  }
234
356
  }
235
357
  function isActiveLogPath(logPath) {
358
+ const activeMarkerPath = getActiveMarkerPath(logPath);
236
359
  try {
237
- readFileSync(getActiveMarkerPath(logPath), "utf8");
238
- return true;
360
+ const markerStat = statSync(activeMarkerPath);
361
+ const marker = readFileSync(activeMarkerPath, "utf8");
362
+ const live = isActiveMarkerSnapshotLive(marker, markerStat.mtimeMs);
363
+ if (!live)
364
+ rmSync(activeMarkerPath, { force: true });
365
+ return live;
239
366
  }
240
- catch {
367
+ catch (error) {
368
+ if (!isMissingFileError(error))
369
+ throw error;
241
370
  return false;
242
371
  }
243
372
  }
373
+ function isActiveMarkerSnapshotLive(marker, markerMtimeMs) {
374
+ const pid = Number(marker.split("\n")[0]);
375
+ if (Number.isInteger(pid) && pid > 0) {
376
+ return isProcessLive(pid);
377
+ }
378
+ return Date.now() - markerMtimeMs <= ORPHAN_LOG_GRACE_MS;
379
+ }
244
380
  function getActiveMarkerPath(logPath) {
245
381
  return `${logPath}.active`;
246
382
  }
247
383
  function sleepSync(ms) {
248
384
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
249
385
  }
250
- function isFileExistsError(error) {
251
- return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
252
- }
253
386
  function isMissingFileError(error) {
254
387
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
255
388
  }
256
389
  function getCommandLogDir(command, cwd) {
257
390
  return getSurfacePath(join("cli-logs", command), "repo", cwd);
258
391
  }
259
- function getCommandIndexPath(command, cwd) {
260
- return join(getCommandLogDir(command, cwd), "index.json");
392
+ function getFallbackCommandLogDir(command, cwd) {
393
+ return join(tmpdir(), "webpresso", "cli-logs", fallbackScopeId(cwd), command);
394
+ }
395
+ function fallbackScopeId(cwd) {
396
+ return createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12);
261
397
  }
262
398
  function createLogId(now = new Date()) {
263
399
  const iso = now.toISOString().replaceAll(":", "-").replaceAll(".", "-");
@@ -7,6 +7,7 @@ import { z } from "zod";
7
7
  import { MAX_PORTABLE_REPOSITORY_PATH_LENGTH } from "#audit/absolute-path-policy.js";
8
8
  import { getProjectRoot } from "#cli/utils";
9
9
  import { relativeBlueprintSlug, resolveBlueprintFile } from "#lifecycle/local.js";
10
+ import { readBlueprintOverviewAtRef } from "#lifecycle/review-provenance.js";
10
11
  import { scanBlueprintDirectory } from "#service/scanner.js";
11
12
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
12
13
  import { CLAUDE_DEFAULT_REVIEW_MODEL, CODEX_DEFAULT_REVIEW_MODEL, GROK_DEFAULT_REVIEW_MODEL, DEFAULT_REVIEW_MAX_PROGRESS_EVENTS, runReviewExecution, } from "#review/execution/index.js";
@@ -860,14 +861,10 @@ function readBlueprintAtTarget(projectRoot, blueprintPath, targetSha) {
860
861
  throw new Error(`Blueprint path is outside Git toplevel: ${blueprintPath}`);
861
862
  }
862
863
  const relative = gitRelativePath.replace(/\\/gu, "/");
863
- try {
864
- return runBoundedReviewGit(gitRoot, ["show", `${targetSha}:${relative}`]);
865
- }
866
- catch (error) {
867
- throw new Error(`Blueprint ${relative} is not tracked at target ${targetSha}.`, {
868
- cause: error,
869
- });
870
- }
864
+ const content = readBlueprintOverviewAtRef(gitRoot, targetSha, absoluteBlueprintPath);
865
+ if (content)
866
+ return content.toString("utf8");
867
+ throw new Error(`Blueprint ${relative} is not tracked at target ${targetSha}.`);
871
868
  }
872
869
  function blueprintPathAtGitRoot(projectRoot, blueprintPath) {
873
870
  const gitRoot = resolveReviewGitToplevel(projectRoot);
@@ -2,6 +2,7 @@ import type { CAC } from "cac";
2
2
  import { spawnRuntimeCommandSync } from "#runtime/executor.js";
3
3
  export interface SecretsCommandOptions {
4
4
  readonly cwd?: string;
5
+ readonly commandCwd?: string;
5
6
  readonly profile?: string;
6
7
  readonly sink?: string;
7
8
  readonly json?: boolean;
@@ -14,6 +14,7 @@ export function registerSecretsCommand(cli) {
14
14
  .command("secrets <action> [target]", "Secret orchestration commands (doctor, bootstrap github)")
15
15
  .option("--profile <name>", "Secret profile name", { default: "preview" })
16
16
  .option("--sink <id>", "Secret sink id")
17
+ .option("--command-cwd <dir>", "Run the scoped command from this directory")
17
18
  .option("--json", "Emit machine-readable JSON")
18
19
  .option("--lane <lane>", "Lane to bootstrap (repeatable)", { default: [] })
19
20
  .option("--apply", "Apply GitHub bootstrap changes instead of dry-run planning")
@@ -22,6 +23,7 @@ export function registerSecretsCommand(cli) {
22
23
  cwd: process.cwd(),
23
24
  profile: flags.profile,
24
25
  sink: flags.sink,
26
+ commandCwd: flags.commandCwd,
25
27
  json: Boolean(flags.json),
26
28
  lanes: toArray(flags.lane),
27
29
  apply: Boolean(flags.apply),
@@ -109,7 +111,8 @@ export function runSecretsRun(options = {}, deps = {}) {
109
111
  const result = (deps.runSecretScopedCommand ?? spawnRuntimeCommandSync)({
110
112
  command,
111
113
  args,
112
- cwd: options.cwd,
114
+ cwd: options.commandCwd ?? options.cwd,
115
+ envCwd: options.cwd,
113
116
  profile: plan.runtimeProfile,
114
117
  secretProfile: plan.profile,
115
118
  environment: plan.environment,
@@ -11,6 +11,7 @@ import { canonicalizeWorktreePath } from "#worktrees/identity.js";
11
11
  import { dashProviderOwnershipName, parseDashPermissionMode, } from "#cli/commands/dash/provider-capabilities.js";
12
12
  import { optionalToolCanonicalCommand } from "#cli/optional-tools.js";
13
13
  import { commandExists } from "#runtime/command-exists.js";
14
+ import { ensureCodexProjectMcpServers, ensureCodexWebpressoMcp, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
14
15
  export class DirectProviderLaunchPreparationError extends Error {
15
16
  action;
16
17
  constructor(message, action) {
@@ -90,6 +91,25 @@ function validateDirectProviderLaunch(provider, cwd) {
90
91
  vpCommand,
91
92
  };
92
93
  }
94
+ function ensureCodexSessionTooling(repoRoot) {
95
+ const options = { dryRun: false, overwrite: false };
96
+ const webpresso = ensureCodexWebpressoMcp({ options });
97
+ if (webpresso.kind === "codex-webpresso-mcp-not-installed") {
98
+ throw new DirectProviderLaunchPreparationError(`Codex webpresso MCP is not configured because no usable agent-kit install root was found. Checked: ${webpresso.checked.join(", ") || "(none)"}`, "Run `wp setup --yes-global` from this repo, then retry `wp codex`.");
99
+ }
100
+ const project = ensureCodexProjectMcpServers({ repoRoot, options });
101
+ if (project.kind === "codex-project-mcp-invalid") {
102
+ throw new DirectProviderLaunchPreparationError(`Codex project MCP config is invalid: ${project.reason}`, "Fix .mcp.json or run `wp setup --yes-global` for detailed setup diagnostics.");
103
+ }
104
+ if (project.kind === "codex-project-mcp-unavailable") {
105
+ throw new DirectProviderLaunchPreparationError(`Codex project MCP server ${project.server} is unavailable: ${project.reason}`, "Run `wp setup --yes-global` from this repo to provision project MCP servers, then retry.");
106
+ }
107
+ }
108
+ function ensureProviderSessionTooling(provider, cwd) {
109
+ if (provider !== "codex")
110
+ return;
111
+ ensureCodexSessionTooling(cwd);
112
+ }
93
113
  export function isBareInteractiveProviderLaunch(args) {
94
114
  return parseDashPermissionMode(args).args.length === 0;
95
115
  }
@@ -136,14 +156,17 @@ export async function prepareDirectProviderLaunch(provider, args, cwd) {
136
156
  return { cwd, warnings: [] };
137
157
  }
138
158
  const { managedCwd, warnings } = await prepareLaunchEnvironment(provider, cwd);
139
- if (managedCwd)
159
+ if (managedCwd) {
160
+ ensureProviderSessionTooling(provider, managedCwd);
140
161
  return { cwd: managedCwd, warnings };
162
+ }
141
163
  const created = await createManagedWorktree({
142
164
  cwd,
143
165
  prefix: provider,
144
166
  base: "origin/main",
145
167
  dependencyHydration: dependencyHydrationForProvider(provider),
146
168
  });
169
+ ensureProviderSessionTooling(provider, created.path);
147
170
  return { cwd: created.path, warnings };
148
171
  }
149
172
  /**
@@ -163,5 +186,6 @@ export function dependencyHydrationForProvider(provider) {
163
186
  */
164
187
  export async function prepareDirectProviderLaunchAt(provider, cwd) {
165
188
  const { warnings } = await prepareLaunchEnvironment(provider, cwd);
189
+ ensureProviderSessionTooling(provider, cwd);
166
190
  return { cwd, warnings };
167
191
  }
@@ -116,6 +116,21 @@ export declare function checkCodexConfigKeys(configFilePath?: string, deps?: Hos
116
116
  * (exact `vp dlx` pins, launcher rules) at doctor time instead of only at setup.
117
117
  */
118
118
  export declare function checkProjectMcpPins(cwd?: string, deps?: HostConfigDoctorDeps): DoctorCheck;
119
+ export interface ClaudeMcpDuplicateRegistrationOptions {
120
+ readonly home?: string;
121
+ readonly exists?: (path: string) => boolean;
122
+ readonly readFile?: (path: string) => string;
123
+ }
124
+ /**
125
+ * `claude-mcp-duplicate-registration` — advisory. A machine can register the
126
+ * webpresso MCP server twice for the same Claude Code session: a manual
127
+ * `claude mcp add webpresso` entry in `~/.claude.json` (user scope or a
128
+ * project scope) plus the agent-kit marketplace plugin's bundled server
129
+ * (plugin cache under `~/.claude/plugins/`). Both spawn `wp mcp`, so affected
130
+ * sessions run two server processes and carry a duplicated wp_* tool list.
131
+ * Degrades to ok on missing/unreadable config — loud only on real duplication.
132
+ */
133
+ export declare function checkClaudeMcpDuplicateRegistration(options?: ClaudeMcpDuplicateRegistrationOptions): DoctorCheck;
119
134
  export interface DanglingHostPathsOptions {
120
135
  readonly configFilePath?: string;
121
136
  readonly home?: string;
@@ -1790,6 +1790,61 @@ export function checkProjectMcpPins(cwd = process.cwd(), deps = {}) {
1790
1790
  : `${parsed.definitions.length} exact-pinned vp dlx server(s) validated`,
1791
1791
  };
1792
1792
  }
1793
+ /**
1794
+ * `claude-mcp-duplicate-registration` — advisory. A machine can register the
1795
+ * webpresso MCP server twice for the same Claude Code session: a manual
1796
+ * `claude mcp add webpresso` entry in `~/.claude.json` (user scope or a
1797
+ * project scope) plus the agent-kit marketplace plugin's bundled server
1798
+ * (plugin cache under `~/.claude/plugins/`). Both spawn `wp mcp`, so affected
1799
+ * sessions run two server processes and carry a duplicated wp_* tool list.
1800
+ * Degrades to ok on missing/unreadable config — loud only on real duplication.
1801
+ */
1802
+ export function checkClaudeMcpDuplicateRegistration(options = {}) {
1803
+ const name = "claude MCP duplicate registration";
1804
+ const home = options.home ?? homedir();
1805
+ const exists = options.exists ?? existsSync;
1806
+ const readFile = options.readFile ?? ((candidate) => readFileSync(candidate, "utf8"));
1807
+ const pluginCacheDir = join(home, ".claude", "plugins", "cache", "webpresso", "agent-kit");
1808
+ if (!exists(pluginCacheDir)) {
1809
+ return { name, ok: true, detail: "skipped (agent-kit Claude plugin not installed)" };
1810
+ }
1811
+ const claudeJsonPath = join(home, ".claude.json");
1812
+ if (!exists(claudeJsonPath)) {
1813
+ return { name, ok: true, detail: "skipped (no ~/.claude.json)" };
1814
+ }
1815
+ let parsed;
1816
+ try {
1817
+ parsed = JSON.parse(readFile(claudeJsonPath));
1818
+ }
1819
+ catch {
1820
+ return { name, ok: true, detail: "skipped (~/.claude.json unreadable)" };
1821
+ }
1822
+ if (parsed === null || typeof parsed !== "object") {
1823
+ return { name, ok: true, detail: "skipped (~/.claude.json is not an object)" };
1824
+ }
1825
+ const config = parsed;
1826
+ const manualSources = [];
1827
+ if (config.mcpServers && "webpresso" in config.mcpServers) {
1828
+ manualSources.push("user scope");
1829
+ }
1830
+ for (const [projectDir, project] of Object.entries(config.projects ?? {})) {
1831
+ if (project?.mcpServers && "webpresso" in project.mcpServers) {
1832
+ manualSources.push(`project scope ${projectDir}`);
1833
+ }
1834
+ }
1835
+ if (manualSources.length === 0) {
1836
+ return { name, ok: true, detail: "plugin-scoped registration only" };
1837
+ }
1838
+ return {
1839
+ name,
1840
+ ok: false,
1841
+ advisory: true,
1842
+ detail: `the agent-kit Claude plugin bundles the webpresso MCP server, but ~/.claude.json also ` +
1843
+ `registers "webpresso" manually (${manualSources.join(", ")}). Affected sessions run two ` +
1844
+ `wp mcp processes with a duplicated wp_* tool list. Remove the manual entry: ` +
1845
+ `claude mcp remove webpresso (run inside the project directory for project-scope entries).`,
1846
+ };
1847
+ }
1793
1848
  const QUOTED_TOML_STRING = /"((?:[^"\\]|\\.)*)"/gu;
1794
1849
  const DANGLING_SYMLINK_SCAN_DEPTH = 3;
1795
1850
  function collectDanglingWorktreePaths(raw, worktreesRoot, exists, alreadyReported) {
@@ -1994,6 +2049,7 @@ export async function runHooksDoctor(opts = {}) {
1994
2049
  checks.push(checkCodexConfigKeys());
1995
2050
  }
1996
2051
  checks.push(checkProjectMcpPins(opts.cwd));
2052
+ checks.push(checkClaudeMcpDuplicateRegistration());
1997
2053
  checks.push({ advisory: true, ...checkDanglingHostPaths() });
1998
2054
  checks.push({ advisory: true, ...checkRootLauncherContract() });
1999
2055
  checks.push({ advisory: true, ...checkNativePluginRuntime() });
@@ -371,7 +371,7 @@ function findWpAuditRedirect(command) {
371
371
  for (const variant of getCommandVariants(command)) {
372
372
  const kind = WP_AUDIT_RE.exec(variant)?.[1];
373
373
  if (kind === "guardrails") {
374
- return `"${variant}" denied — use the MCP batch audit tool: mcp__webpresso__wp_audits(preset="guardrails"). Returns aggregate structured, summary-first results.`;
374
+ return `"${variant}" denied — use the webpresso MCP batch audit tool: wp_audits(preset="guardrails"). Returns aggregate structured, summary-first results.`;
375
375
  }
376
376
  if (!kind || !AUDIT_KIND_SET.has(kind))
377
377
  continue;
@@ -381,11 +381,11 @@ function findWpAuditRedirect(command) {
381
381
  knownKinds.push(kind);
382
382
  }
383
383
  if (knownKinds.length > 1) {
384
- return `Multiple wp audit commands denied — use the MCP batch audit tool: mcp__webpresso__wp_audits(kinds=[${knownKinds.map((kind) => `"${kind}"`).join(", ")}]). Returns aggregate structured, summary-first results.`;
384
+ return `Multiple wp audit commands denied — use the webpresso MCP batch audit tool: wp_audits(kinds=[${knownKinds.map((kind) => `"${kind}"`).join(", ")}]). Returns aggregate structured, summary-first results.`;
385
385
  }
386
386
  const kind = knownKinds[0];
387
387
  if (kind && firstKnownVariant) {
388
- return `"${firstKnownVariant}" denied — use the MCP audit tool: mcp__webpresso__wp_audit(kind="${kind}"). Returns structured, summary-first results.`;
388
+ return `"${firstKnownVariant}" denied — use the webpresso MCP audit tool: wp_audit(kind="${kind}"). Returns structured, summary-first results.`;
389
389
  }
390
390
  return undefined;
391
391
  }
@@ -402,7 +402,7 @@ function findScriptRouteRedirect(command, routes) {
402
402
  process.stderr.write(`[forbidden-commands] guard.scriptRoutes["${script}"] -> "${kind}" is not a known audit kind; ignoring\n`);
403
403
  continue;
404
404
  }
405
- return `"${variant}" denied — this repo routes \`${script}\` to an audit: mcp__webpresso__wp_audit(kind="${kind}").`;
405
+ return `"${variant}" denied — this repo routes \`${script}\` to an audit: use the webpresso MCP tool wp_audit(kind="${kind}").`;
406
406
  }
407
407
  return undefined;
408
408
  }
@@ -1,6 +1,5 @@
1
1
  import type { CommandCategory } from "./forbidden-commands.js";
2
2
  export interface MCPRedirectConfig {
3
- serverName?: string;
4
3
  toolPrefix?: string;
5
4
  }
6
5
  export interface MCPRedirectContext {
@@ -1,5 +1,4 @@
1
1
  import { isMcpReady } from "#hooks/shared/mcp-sentinel";
2
- const DEFAULT_SERVER_NAME = "webpresso";
3
2
  const DEFAULT_TOOL_PREFIX = "wp_";
4
3
  let cachedDefaultMcpReady;
5
4
  export function resetMcpReadyCache() {
@@ -22,7 +21,6 @@ export function resolveMcpReady(value) {
22
21
  }
23
22
  export function resolveMcpConfig(mcp) {
24
23
  return {
25
- serverName: mcp?.serverName?.trim() || DEFAULT_SERVER_NAME,
26
24
  toolPrefix: mcp?.toolPrefix?.trim() || DEFAULT_TOOL_PREFIX,
27
25
  };
28
26
  }
@@ -50,10 +48,15 @@ export function buildRedirectMessage(ctx) {
50
48
  if (!resolveMcpReady(ctx.mcpReady)) {
51
49
  return `"${ctx.command}" denied — MCP not ready. Use: ${fallbackHint}`;
52
50
  }
53
- const { serverName, toolPrefix } = resolveMcpConfig(ctx.mcp);
54
- const toolMatchers = toolSuffixesForCategory(ctx.category).map((suffix) => `mcp__${serverName}__${toolPrefix}${suffix}(...)`);
51
+ // Suffix-only tool names: hosts register the webpresso MCP server under
52
+ // different prefixes (manual `mcp__webpresso__*` vs plugin-scoped
53
+ // `mcp__plugin_agent-kit_webpresso__*`), so a hardcoded full name resolves in
54
+ // some sessions and dangles in others. The `wp_<suffix>` identity is stable
55
+ // across every registration shape.
56
+ const { toolPrefix } = resolveMcpConfig(ctx.mcp);
57
+ const toolMatchers = toolSuffixesForCategory(ctx.category).map((suffix) => `${toolPrefix}${suffix}(...)`);
55
58
  return [
56
- `"${ctx.command}" denied — use wp MCP tool:`,
59
+ `"${ctx.command}" denied — use the webpresso MCP tool:`,
57
60
  ...toolMatchers.map((matcher) => ` ${matcher}`),
58
61
  "Returns structured, summary-first results. Raw output is clipped; overflow may include a log path for deeper investigation.",
59
62
  `Fallback if MCP unavailable: ${fallbackHint}`,
@@ -17,13 +17,15 @@ import type { ToolInput, ValidationResult } from "#hooks/shared/types";
17
17
  * effective cwd elsewhere (e.g. CI) → ALLOW (rule scoped to local ~/repos primaries)
18
18
  * ```
19
19
  *
20
- * The "effective cwd" is the tool's ambient `input.cwd`, lowered only by a
21
- * success-gated cwd chain that directly governs the forbidden git op, e.g.
22
- * `cd <worktree> && git commit` or `(cd <worktree> && git commit)`, then by
23
- * EVERY `git -C <dir>` flag on the matched invocation (git applies them
24
- * cumulatively). Unsupported cwd-changing control flow, option-bearing `cd`,
25
- * `cd -`, or unresolvable `cd`/`-C` targets fail closed because they could hide
26
- * a primary-checkout mutation. Prefer `git -C <worktree> ...` or running the
20
+ * The "effective cwd" is the tool's ambient `input.cwd`, lowered by a
21
+ * success-gated `&&` chain that governs the forbidden git op, e.g.
22
+ * `cd <worktree> && git commit`, `cd <worktree> && ./bin/wp … && git commit`,
23
+ * or `(cd <worktree> && git commit)`, then by EVERY `git -C <dir>` flag on the
24
+ * matched invocation (git applies them cumulatively). Intermediate non-cwd
25
+ * commands after a proven `cd` leave the chain cwd unchanged. Unsupported
26
+ * control flow (`;` / `|` / braces), option-bearing `cd`, `cd -`, or
27
+ * unresolvable `cd`/`-C` targets fail closed because they could hide a
28
+ * primary-checkout mutation. Prefer `git -C <worktree> ...` or running the
27
29
  * command with the tool cwd already set to the worktree.
28
30
  *
29
31
  * Known best-effort limits (acceptable for an agent guard with a documented