@webpresso/agent-kit 3.2.0 → 3.3.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.

Potentially problematic release.


This version of @webpresso/agent-kit might be problematic. Click here for more details.

Files changed (93) hide show
  1. package/bin/_run.js +4 -1
  2. package/dist/esm/audit/blueprint-trust.js +14 -10
  3. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  4. package/dist/esm/audit/changeset-evidence.js +30 -0
  5. package/dist/esm/audit/changeset-required.d.ts +10 -0
  6. package/dist/esm/audit/changeset-required.js +73 -0
  7. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  8. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  9. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  10. package/dist/esm/audit/registry.d.ts +4 -4
  11. package/dist/esm/audit/registry.js +6 -1
  12. package/dist/esm/blueprint/core/parser.js +61 -4
  13. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  14. package/dist/esm/blueprint/core/schema.js +26 -0
  15. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  16. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  17. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  18. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  20. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  21. package/dist/esm/blueprint/sync/client.js +1 -1
  22. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  23. package/dist/esm/blueprint/trust/dossier.js +40 -6
  24. package/dist/esm/blueprint/trust/promotion.js +12 -0
  25. package/dist/esm/build/cli-mcp-parity.js +5 -0
  26. package/dist/esm/cli/cli.d.ts +1 -1
  27. package/dist/esm/cli/cli.js +9 -0
  28. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  29. package/dist/esm/cli/commands/agent-launch.js +31 -20
  30. package/dist/esm/cli/commands/audit.js +4 -0
  31. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  32. package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
  33. package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
  34. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  35. package/dist/esm/cli/commands/init/index.js +2 -2
  36. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  37. package/dist/esm/cli/commands/init/package-root.js +10 -2
  38. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  39. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  40. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  41. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  42. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  43. package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
  44. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  45. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  46. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  47. package/dist/esm/cli/commands/quality-runner.js +3 -3
  48. package/dist/esm/cli/commands/review.js +27 -5
  49. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  50. package/dist/esm/cli/commands/rust-check.js +110 -0
  51. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  52. package/dist/esm/cli/commands/typecheck.js +30 -1
  53. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
  54. package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
  55. package/dist/esm/cli/commands/worktree/router.js +10 -0
  56. package/dist/esm/hooks/doctor.d.ts +2 -0
  57. package/dist/esm/hooks/doctor.js +37 -1
  58. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  59. package/dist/esm/hooks/shared/types.js +27 -1
  60. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  61. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  62. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  63. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  64. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  65. package/dist/esm/mcp/tools/audit.js +12 -0
  66. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  67. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  68. package/dist/esm/package.json +2 -0
  69. package/dist/esm/platform/client.d.ts +7 -1
  70. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  71. package/dist/esm/pr-description/contract.js +24 -4
  72. package/dist/esm/review/authority.js +14 -9
  73. package/dist/esm/review/availability.d.ts +11 -0
  74. package/dist/esm/review/availability.js +18 -3
  75. package/dist/esm/review/execution/adapters.d.ts +11 -1
  76. package/dist/esm/review/execution/adapters.js +10 -1
  77. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  78. package/dist/esm/review/execution/review-checkout.js +35 -16
  79. package/dist/esm/review/execution/supervisor.js +31 -4
  80. package/dist/esm/review/execution/types.d.ts +1 -0
  81. package/dist/esm/review/subject.d.ts +13 -0
  82. package/dist/esm/review/subject.js +42 -4
  83. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  84. package/dist/esm/rust/affected-manifests.js +69 -0
  85. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  86. package/dist/esm/status/snapshot.js +2 -6
  87. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  88. package/dist/esm/test-helpers/global-setup.js +18 -15
  89. package/dist/esm/typecheck/planner.d.ts +9 -1
  90. package/dist/esm/typecheck/planner.js +31 -0
  91. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  92. package/dist/esm/worktrees/orphan-scan.js +211 -0
  93. package/package.json +14 -12
@@ -31,6 +31,23 @@ export interface OpencodeRotateDeps {
31
31
  readonly materializeGlobalOpencodeAuth?: (key: string) => void;
32
32
  readonly recordCooldown?: (canonicalId: string) => Promise<unknown>;
33
33
  }
34
+ /**
35
+ * Cooldown to record when an account is known to be capped but the real reset
36
+ * window is unknown: it assumes the shortest real OpenCode Go window (5 hours).
37
+ *
38
+ * A manual `--wp-rotate` has no provider response at all, so it always lands
39
+ * here. The launch-time health probe (`#cli/commands/opencode-probe.js`) uses it
40
+ * only as a fallback, because that path *does* have a provider response and
41
+ * normally records the true parsed window instead.
42
+ *
43
+ * Recording no window at all would leave the record permanently suppressed —
44
+ * `suppressionExpired` only clears a record that carries `retryAfterMs` — so
45
+ * every rotated account would be cooled forever and selection would degrade to
46
+ * "first account" again. A too-short guess is self-correcting: the account is
47
+ * retried, and if it is still capped the gate (or the next probe/rotation)
48
+ * simply cools it again.
49
+ */
50
+ export declare const OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS: number;
34
51
  /**
35
52
  * Cool the currently-materialized opencode-go account and materialize the next
36
53
  * usable one into the global `auth.json`.
@@ -45,5 +62,11 @@ export interface OpencodeRotateDeps {
45
62
  * Never throws: the caller launches the TUI regardless of the outcome.
46
63
  */
47
64
  export declare function rotateActiveOpencodeAccount(cwd: string, secretProfile: string, deps?: OpencodeRotateDeps): Promise<OpencodeRotateOutcome>;
65
+ /**
66
+ * Render a cooldown remainder as a short ` (resets in ~Xd)` suffix, or the empty
67
+ * string when the window is unknown. Shared with the launch-time health probe so
68
+ * rotation and probe output cannot drift apart.
69
+ */
70
+ export declare function formatOpencodeResetWait(waitMs: number | undefined): string;
48
71
  /** Human-readable lines describing a rotation outcome, in print order. */
49
72
  export declare function describeOpencodeRotate(outcome: OpencodeRotateOutcome): readonly string[];
@@ -5,15 +5,22 @@ import { resolveOpencodeAccountsGracefully } from "#runtime/opencode-account-mat
5
5
  /** The `--wp-rotate` flag, namespaced like `--wp-permission-mode`. */
6
6
  export const OPENCODE_ROTATE_FLAG = "--wp-rotate";
7
7
  /**
8
- * A manual rotation has no provider response to parse a reset window from, so it
9
- * assumes the shortest real OpenCode Go window (5 hours). Recording no window at
10
- * all would leave the record permanently suppressed — `suppressionExpired` only
11
- * clears a record that carries `retryAfterMs` so every rotated account would
12
- * be cooled forever and selection would degrade to "first account" again. A
13
- * too-short guess is self-correcting: the account is retried, and if it is still
14
- * capped the gate (or the next rotation) simply cools it again.
8
+ * Cooldown to record when an account is known to be capped but the real reset
9
+ * window is unknown: it assumes the shortest real OpenCode Go window (5 hours).
10
+ *
11
+ * A manual `--wp-rotate` has no provider response at all, so it always lands
12
+ * here. The launch-time health probe (`#cli/commands/opencode-probe.js`) uses it
13
+ * only as a fallback, because that path *does* have a provider response and
14
+ * normally records the true parsed window instead.
15
+ *
16
+ * Recording no window at all would leave the record permanently suppressed —
17
+ * `suppressionExpired` only clears a record that carries `retryAfterMs` — so
18
+ * every rotated account would be cooled forever and selection would degrade to
19
+ * "first account" again. A too-short guess is self-correcting: the account is
20
+ * retried, and if it is still capped the gate (or the next probe/rotation)
21
+ * simply cools it again.
15
22
  */
16
- const MANUAL_ROTATION_COOLDOWN_MS = 5 * 60 * 60 * 1_000;
23
+ export const OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS = 5 * 60 * 60 * 1_000;
17
24
  function coolActiveAccount(canonicalId, deps) {
18
25
  if (deps.recordCooldown)
19
26
  return deps.recordCooldown(canonicalId);
@@ -23,7 +30,7 @@ function coolActiveAccount(canonicalId, deps) {
23
30
  failureCode: "opencode-go-usage-limit",
24
31
  terminalClassification: "provider-limit-reached",
25
32
  limitWindow: "unknown",
26
- retryAfterMs: MANUAL_ROTATION_COOLDOWN_MS,
33
+ retryAfterMs: OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS,
27
34
  });
28
35
  }
29
36
  /**
@@ -97,7 +104,12 @@ async function rotateOrThrow(cwd, secretProfile, deps) {
97
104
  ...(next.waitMs === undefined ? {} : { waitMs: next.waitMs }),
98
105
  };
99
106
  }
100
- function formatWait(waitMs) {
107
+ /**
108
+ * Render a cooldown remainder as a short ` (resets in ~Xd)` suffix, or the empty
109
+ * string when the window is unknown. Shared with the launch-time health probe so
110
+ * rotation and probe output cannot drift apart.
111
+ */
112
+ export function formatOpencodeResetWait(waitMs) {
101
113
  if (waitMs === undefined)
102
114
  return "";
103
115
  const hours = waitMs / 3_600_000;
@@ -127,7 +139,7 @@ export function describeOpencodeRotate(outcome) {
127
139
  `wp opencode: now active -> opencode-go:${outcome.activeAccountId}`,
128
140
  ];
129
141
  if (outcome.activeIsCooled) {
130
- lines.push(`wp opencode: every account is within its cooldown; using the soonest-expiring one${formatWait(outcome.waitMs)}. Run again after it resets, or add another OPENCODE_GO_KEY_<ID>.`);
142
+ lines.push(`wp opencode: every account is within its cooldown; using the soonest-expiring one${formatOpencodeResetWait(outcome.waitMs)}. Run again after it resets, or add another OPENCODE_GO_KEY_<ID>.`);
131
143
  }
132
144
  return lines;
133
145
  }
@@ -147,9 +147,9 @@ function renderSummaryLines(summary, transformed, entry, passed) {
147
147
  }
148
148
  if (!passed || transformed.truncated) {
149
149
  lines.push(`Full log: wp logs ${entry.command}`);
150
- }
151
- if (transformed.elisions && transformed.elisions.length > 0) {
152
- lines.push(...transformed.elisions.map((elision) => `Retrieve elision: ${elision.retrieveTool} id=${elision.id}`));
150
+ if (transformed.elisions && transformed.elisions.length > 0) {
151
+ lines.push(...transformed.elisions.map((elision) => `Retrieve elision: ${elision.retrieveTool} id=${elision.id}`));
152
+ }
153
153
  }
154
154
  if (transformed.warnings && transformed.warnings.length > 0) {
155
155
  lines.push(...transformed.warnings.map((warning) => `Warning: ${warning}`));
@@ -1853,9 +1853,22 @@ function rejectedReviewSubjects(input) {
1853
1853
  !isFormalGateRejection(event)) {
1854
1854
  continue;
1855
1855
  }
1856
- const canonical = input.purpose === "plan"
1857
- ? createPlanSubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug)
1858
- : createDeliverySubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug);
1856
+ // A recorded rejection's `reviewedCommit` can be orphaned by a later rebase.
1857
+ // Rebuilding its subject then throws, and this loop had no guard, so the
1858
+ // failure propagated to the caller and took down the whole rejection
1859
+ // lineage. Skip only the unrebuildable event: the remaining rejections still
1860
+ // count toward the scope-exhaustion circuit breaker, which is the
1861
+ // fail-closed direction.
1862
+ let canonical;
1863
+ try {
1864
+ canonical =
1865
+ input.purpose === "plan"
1866
+ ? createPlanSubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug)
1867
+ : createDeliverySubjectAtRef(input.projectRoot, event.reviewedCommit, input.stableSlug);
1868
+ }
1869
+ catch {
1870
+ continue;
1871
+ }
1859
1872
  if (!bySubject.has(canonical.digest)) {
1860
1873
  bySubject.set(canonical.digest, {
1861
1874
  artifactPath: event.artifactPath,
@@ -2356,14 +2369,21 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2356
2369
  }
2357
2370
  break;
2358
2371
  }
2372
+ // OpenCode family-wide stop: `isProviderWideOpenCodeGateResult`
2373
+ // is exactly `reviewer === "opencode"`, so the final branch below
2374
+ // already covers provider-limit-reached for that case. Keep the
2375
+ // provider-limit break only for non-opencode providers here.
2359
2376
  if (providerResult.terminalClassification === "provider-limit-reached" &&
2360
- (provider !== "opencode" || isProviderWideOpenCodeGateResult(providerResult))) {
2377
+ provider !== "opencode") {
2361
2378
  break;
2362
2379
  }
2363
2380
  if ((provider === "claude" || provider === "codex") &&
2364
2381
  providerResult.status === "complete")
2365
2382
  break;
2366
- if (provider === "opencode" && providerResult.reviewer === "opencode")
2383
+ // Also stops the opencode family on any result from the
2384
+ // family-agnostic "opencode" reviewer (complete, limit, or
2385
+ // other failure) once per-account failover has already run.
2386
+ if (provider === "opencode" && isProviderWideOpenCodeGateResult(providerResult))
2367
2387
  break;
2368
2388
  }
2369
2389
  }
@@ -2779,6 +2799,8 @@ export function registerReviewCommand(cli, deps = {}) {
2779
2799
  const slug = args[0];
2780
2800
  if (!slug)
2781
2801
  throw new Error("Usage: wp review gate <blueprint-slug> [--purpose plan|delivery] [--target <git-ref>] [--provider claude|codex|opencode|grok] [--model <model-id>] [--effort medium|high] [--resume <gate-id>] [--json]");
2802
+ if (options.targetId !== undefined || options.targetKind !== undefined)
2803
+ throw new Error("wp review gate does not accept --target-id/--target-kind: the blueprint slug is positional (wp review gate <blueprint-slug>). --target-id/--target-kind apply only to `wp review log`.");
2782
2804
  const interrupt = createReviewInterruptScope();
2783
2805
  try {
2784
2806
  const result = await runReviewGate(projectRoot, slug, options, {
@@ -0,0 +1,24 @@
1
+ import type { CAC } from "cac";
2
+ import type { AffectedResolutionDeps } from "#git/affected";
3
+ /**
4
+ * `wp rust-check` — the Rust counterpart of `wp typecheck`/`wp test`: proves
5
+ * an affected tracked Rust crate still compiles and passes its tests.
6
+ *
7
+ * Exists because `wp ci-preflight`'s other stages only resolve TS/JS
8
+ * affectedness, so a Rust-only branch previously reported every stage as
9
+ * "no affected files" and exited green without ever invoking `cargo` — a
10
+ * silent false pass (see `catalog/agent/rules/ci-cost-local-first.md`).
11
+ * This command always prints an explicit status line, whether or not it
12
+ * found Rust crates to check, so "green" is never ambiguous.
13
+ */
14
+ export declare const RUST_CHECK_COMMAND_HELP: string;
15
+ interface RustCheckCommandDeps {
16
+ readonly getGitTopLevel?: AffectedResolutionDeps["getGitTopLevel"];
17
+ readonly getStagedFiles?: AffectedResolutionDeps["getStagedFiles"];
18
+ readonly getBranchChangedFiles?: AffectedResolutionDeps["getBranchChangedFiles"];
19
+ readonly runCargo?: (args: readonly string[], cwd: string) => {
20
+ readonly exitCode: number;
21
+ };
22
+ }
23
+ export declare function registerRustCheckCommand(cli: CAC, deps?: RustCheckCommandDeps): void;
24
+ export {};
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { addAffectedOptions, resolveAffectedTargets } from "#git/affected";
3
+ import { resolveAffectedRustManifests, trackedCrateForManifest } from "#rust/affected-manifests";
4
+ /**
5
+ * `wp rust-check` — the Rust counterpart of `wp typecheck`/`wp test`: proves
6
+ * an affected tracked Rust crate still compiles and passes its tests.
7
+ *
8
+ * Exists because `wp ci-preflight`'s other stages only resolve TS/JS
9
+ * affectedness, so a Rust-only branch previously reported every stage as
10
+ * "no affected files" and exited green without ever invoking `cargo` — a
11
+ * silent false pass (see `catalog/agent/rules/ci-cost-local-first.md`).
12
+ * This command always prints an explicit status line, whether or not it
13
+ * found Rust crates to check, so "green" is never ambiguous.
14
+ */
15
+ export const RUST_CHECK_COMMAND_HELP = [
16
+ "Prove affected tracked Rust crates compile and pass tests (cargo check + cargo test).",
17
+ "",
18
+ "Examples:",
19
+ " wp rust-check --affected # staged changed files → owning crate(s)",
20
+ " wp rust-check --affected --branch # changed vs origin/${GITHUB_BASE_REF:-main}",
21
+ "",
22
+ "Tracked crates: native/session-memory-engine, native/dash-tui,",
23
+ "apps/desktop/src-tauri. spikes/** crates are intentionally excluded — no CI",
24
+ "workflow builds or tests them today.",
25
+ ].join("\n");
26
+ export function registerRustCheckCommand(cli, deps = {}) {
27
+ addAffectedOptions(cli.command("rust-check", RUST_CHECK_COMMAND_HELP)).action(async (flags) => {
28
+ const affected = Boolean(flags.affected);
29
+ const branch = Boolean(flags.branch);
30
+ const cwd = process.cwd();
31
+ if (!affected && !branch) {
32
+ console.error("wp rust-check requires --affected (optionally with --branch).");
33
+ return 1;
34
+ }
35
+ const resolution = resolveAffectedTargets({
36
+ commandName: "rust-check",
37
+ cwd,
38
+ affected,
39
+ branch,
40
+ policy: "fail-closed",
41
+ mapChangedFiles: resolveAffectedRustManifests,
42
+ emptyMessage: rustCheckEmptyMessage,
43
+ degradedFallbackMessage: rustCheckDegradedMessage,
44
+ degradedFailClosedMessage: rustCheckDegradedMessage,
45
+ }, deps);
46
+ if (resolution.kind === "invalid") {
47
+ console.error(resolution.message);
48
+ return 1;
49
+ }
50
+ if (resolution.kind === "degraded-fail-closed" || resolution.kind === "degraded-fallback") {
51
+ console.error(resolution.message);
52
+ return 1;
53
+ }
54
+ if (resolution.kind === "empty") {
55
+ console.log(resolution.message);
56
+ return 0;
57
+ }
58
+ if (resolution.kind === "disabled") {
59
+ // Unreachable: the --affected/--branch guard above always sets
60
+ // `affected: true` before calling resolveAffectedTargets. Handled
61
+ // for exhaustiveness only.
62
+ console.error("wp rust-check requires --affected (optionally with --branch).");
63
+ return 1;
64
+ }
65
+ const manifests = resolution.targets;
66
+ console.log(`wp rust-check: ${manifests.length} tracked Rust crate(s) affected: ${manifests.join(", ")}`);
67
+ const runCargo = deps.runCargo ?? defaultRunCargo;
68
+ for (const manifest of manifests) {
69
+ const crate = trackedCrateForManifest(manifest);
70
+ if (!crate) {
71
+ console.error(`wp rust-check: no tracked crate metadata for ${manifest}.`);
72
+ return 1;
73
+ }
74
+ console.log(`wp rust-check: cargo check --manifest-path ${crate.manifest}`);
75
+ const checkResult = runCargo(["check", "--manifest-path", crate.manifest], resolution.cwd);
76
+ if (checkResult.exitCode !== 0) {
77
+ console.error(`wp rust-check: cargo check failed for ${crate.manifest}.`);
78
+ return checkResult.exitCode;
79
+ }
80
+ console.log(`wp rust-check: cargo ${crate.testArgs.join(" ")}`);
81
+ const testResult = runCargo(crate.testArgs, resolution.cwd);
82
+ if (testResult.exitCode !== 0) {
83
+ console.error(`wp rust-check: cargo test failed for ${crate.manifest}.`);
84
+ return testResult.exitCode;
85
+ }
86
+ }
87
+ console.log(`wp rust-check: ${manifests.length} crate(s) passed: ${manifests.join(", ")}`);
88
+ return 0;
89
+ });
90
+ }
91
+ function rustCheckEmptyMessage(scope) {
92
+ const suffix = scope === "branch" ? "changed vs base ref" : "staged";
93
+ return (`wp rust-check: 0 tracked Rust crates affected (no ${suffix} files under ` +
94
+ "native/session-memory-engine, native/dash-tui, or apps/desktop/src-tauri; " +
95
+ "spikes/** Rust changes, if any, are intentionally not gated) — skipping cargo.");
96
+ }
97
+ function rustCheckDegradedMessage(reason) {
98
+ return `Unable to determine affected Rust files (${reason}); refusing to skip cargo verification.`;
99
+ }
100
+ function defaultRunCargo(args, cwd) {
101
+ const result = spawnSync("cargo", args, { cwd, stdio: "inherit" });
102
+ if (result.error) {
103
+ const isMissingBinary = result.error.code === "ENOENT";
104
+ console.error(isMissingBinary
105
+ ? "wp rust-check: cargo not found on PATH; install the Rust toolchain (https://rustup.rs) to verify Rust changes."
106
+ : `wp rust-check: ${result.error.message}`);
107
+ return { exitCode: 1 };
108
+ }
109
+ return { exitCode: typeof result.status === "number" ? result.status : 1 };
110
+ }
@@ -6,6 +6,7 @@ export interface TypecheckOptions {
6
6
  readonly cwd?: string;
7
7
  readonly files?: readonly string[];
8
8
  readonly packages?: readonly string[];
9
+ readonly tests?: boolean;
9
10
  }
10
11
  interface TypecheckCommandDeps {
11
12
  readonly getGitTopLevel?: AffectedResolutionDeps["getGitTopLevel"];
@@ -23,6 +23,8 @@ export const TYPECHECK_COMMAND_HELP = [
23
23
  " wp typecheck --affected # staged changed source → reverse-importer closure",
24
24
  " wp typecheck --affected --branch # changed vs origin/${GITHUB_BASE_REF:-main}",
25
25
  " wp typecheck --pretty",
26
+ " wp typecheck --tests # test-surface lane (tsconfig.test.json); required in",
27
+ " # not part of the default gate or ci-preflight yet",
26
28
  ].join("\n");
27
29
  export function registerTypecheckCommand(cli, deps = {}) {
28
30
  addAffectedOptions(cli
@@ -31,6 +33,7 @@ export function registerTypecheckCommand(cli, deps = {}) {
31
33
  .option("--package <name>", "Run the owning-scope typecheck for an exact package.json name (repeatable)"))
32
34
  .option("--pretty", "Keep TypeScript pretty output enabled")
33
35
  .option("--full", "Print the full raw output instead of the default summary-first view")
36
+ .option("--tests", "Run the test-surface lane (tsconfig.test.json); required by ci-preflight and CI")
34
37
  .action(async (targetsOrFlags, maybeFlags) => {
35
38
  const positionalTargets = Array.isArray(targetsOrFlags)
36
39
  ? targetsOrFlags.filter((target) => typeof target === "string")
@@ -45,9 +48,29 @@ export function registerTypecheckCommand(cli, deps = {}) {
45
48
  const packages = toArray(flags.package);
46
49
  const affected = Boolean(flags.affected);
47
50
  const branch = Boolean(flags.branch);
51
+ const tests = Boolean(flags.tests);
48
52
  const cwd = process.cwd();
49
53
  let files = explicitFiles;
50
54
  let executionCwd = cwd;
55
+ if (tests && (affected || branch || explicitFiles.length > 0 || packages.length > 0)) {
56
+ console.error("--tests cannot be combined with --affected, --branch, --file, or --package.");
57
+ return 1;
58
+ }
59
+ if (tests) {
60
+ const result = await runTypecheckCommand({
61
+ pretty: Boolean(flags.pretty),
62
+ tests: true,
63
+ cwd,
64
+ });
65
+ emitCliCommandOutput({
66
+ entry: result.entry,
67
+ summary: result.entry.summary ?? "",
68
+ passed: result.exitCode === 0,
69
+ full: Boolean(flags.full),
70
+ toolName: "wp_typecheck",
71
+ });
72
+ return result.exitCode;
73
+ }
51
74
  if (affected || branch) {
52
75
  const resolution = resolveAffectedTargets({
53
76
  commandName: "typecheck",
@@ -131,9 +154,14 @@ export async function runTypecheckCommand(options = {}) {
131
154
  options.packages.length > 0) {
132
155
  throw new Error("Cannot use both --file and --package for typecheck targeting.");
133
156
  }
157
+ if (options.tests && ((options.files?.length ?? 0) > 0 || (options.packages?.length ?? 0) > 0)) {
158
+ throw new Error("Cannot use --tests together with --file or --package.");
159
+ }
134
160
  const cwd = options.cwd ?? process.cwd();
135
161
  repairCurrentManagedWorktreeDependencies(cwd);
136
- const targeted = (options.files?.length ?? 0) > 0 || (options.packages?.length ?? 0) > 0;
162
+ const targeted = (options.files?.length ?? 0) > 0 ||
163
+ (options.packages?.length ?? 0) > 0 ||
164
+ Boolean(options.tests);
137
165
  const repoRoot = resolveProjectRoot({ cwd });
138
166
  const plan = targeted
139
167
  ? planTypecheckExecution({
@@ -141,6 +169,7 @@ export async function runTypecheckCommand(options = {}) {
141
169
  defaultScopeRoot: cwd,
142
170
  files: options.files,
143
171
  packages: options.packages,
172
+ tests: options.tests,
144
173
  pretty: options.pretty,
145
174
  })
146
175
  : {
@@ -14,6 +14,12 @@ export interface WorktreeCommandOptions {
14
14
  deps?: WorktreeDependencyHydration;
15
15
  cwd?: string;
16
16
  repo?: string;
17
+ /** `prune` only: filesystem-level orphan sweep instead of registry-only prune. */
18
+ orphans?: boolean;
19
+ /** `prune --orphans` only: scope the sweep to one repo namespace. */
20
+ repoId?: string;
21
+ /** `prune --orphans` only: alias for `--force` (execute the removal plan). */
22
+ yes?: boolean;
17
23
  }
18
24
  export interface WorktreeEntry {
19
25
  path: string;
@@ -2,7 +2,7 @@
2
2
  * `wp worktree` subcommand dispatch.
3
3
  */
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
- import { existsSync, mkdirSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
6
6
  import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { findGitRoot } from "#cli/commands/init/detect-consumer.js";
8
8
  import { removeCodexProjectTrust, removeCodexProjectTrusts, } from "#worktrees/codex-project-trust.js";
@@ -17,6 +17,7 @@ import { projectWorktreeAgentSurfaces } from "#worktrees/project-agent-surfaces.
17
17
  import { assertNotLocalMainBranch } from "#worktrees/main-ownership.js";
18
18
  import { buildWorktreeInventory, canonicalizeWorktreePath, classifyMembership, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
19
19
  import { findRegistryCandidatesByPath, planStaleWorktreeRegistryPrune, readWorktreeRegistry, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
20
+ import { buildRegistryRepoRootMap, classifyOrphanCandidate, discoverOrphanScanCandidates, resolveRepoRootFromGitPointer, } from "#worktrees/orphan-scan.js";
20
21
  function parseDepsOption(value) {
21
22
  if (value === undefined || value === null || value === "")
22
23
  return undefined;
@@ -567,6 +568,113 @@ function currentBranch(repoRoot) {
567
568
  });
568
569
  return result.status === 0 ? String(result.stdout ?? "").trim() : "";
569
570
  }
571
+ function formatOrphanScanBytes(bytes) {
572
+ if (bytes === "unknown")
573
+ return "-";
574
+ if (bytes < 1024)
575
+ return `${bytes}B`;
576
+ const units = ["K", "M", "G", "T"];
577
+ let value = bytes / 1024;
578
+ let unitIndex = 0;
579
+ while (value >= 1024 && unitIndex < units.length - 1) {
580
+ value /= 1024;
581
+ unitIndex += 1;
582
+ }
583
+ return `${value.toFixed(1)}${units[unitIndex]}`;
584
+ }
585
+ function summarizeOrphanScanRows(rows) {
586
+ const counts = new Map();
587
+ for (const row of rows)
588
+ counts.set(row.classification, (counts.get(row.classification) ?? 0) + 1);
589
+ const removable = rows.filter((row) => row.removable).length;
590
+ const parts = [...counts.entries()].map(([classification, count]) => `${classification}=${count}`);
591
+ return `${parts.join(", ")}, removable=${removable}`;
592
+ }
593
+ function printOrphanScanTable(rows) {
594
+ if (rows.length === 0) {
595
+ console.log("No candidate directories found under managed worktree roots.");
596
+ return;
597
+ }
598
+ console.log(`${"PATH".padEnd(72)} ${"CLASS".padEnd(13)} ${"DIRTY".padEnd(8)} ${"SIZE".padEnd(7)} ${"RM".padEnd(4)} REASON`);
599
+ for (const row of rows) {
600
+ const dirty = row.dirty === "unknown" ? "unknown" : row.dirty ? "yes" : "no";
601
+ console.log(`${row.path.padEnd(72)} ${row.classification.padEnd(13)} ${dirty.padEnd(8)} ${formatOrphanScanBytes(row.sizeBytes).padEnd(7)} ${(row.removable ? "yes" : "no").padEnd(4)} ${row.reason}`);
602
+ }
603
+ console.log("");
604
+ console.log(`Summary: ${summarizeOrphanScanRows(rows)}`);
605
+ }
606
+ function resolveOrphanScanDeps() {
607
+ const registryMap = buildRegistryRepoRootMap(readWorktreeRegistry().entries);
608
+ const readGitPointer = (path) => {
609
+ try {
610
+ return readFileSync(path, "utf8");
611
+ }
612
+ catch {
613
+ return undefined;
614
+ }
615
+ };
616
+ return {
617
+ resolveRepoRoot: (candidate) => registryMap.get(candidate.repoNamespace) ??
618
+ resolveRepoRootFromGitPointer(candidate.path, readGitPointer),
619
+ repoRootExists: existsSync,
620
+ buildInventory: buildWorktreeInventory,
621
+ probeDirty: (path) => {
622
+ const result = spawnSync("git", ["status", "--porcelain"], {
623
+ cwd: path,
624
+ encoding: "utf8",
625
+ timeout: 5000,
626
+ });
627
+ if (result.status !== 0)
628
+ return "unknown";
629
+ return String(result.stdout ?? "").trim().length > 0;
630
+ },
631
+ // Native `du` traverses in kernel/C code (no per-file V8 stat overhead),
632
+ // which is materially faster than the pure-JS walker at the scale a real
633
+ // managed-worktree tree reaches (hundreds of `node_modules` trees). This
634
+ // is only invoked for already-confirmed `orphan` candidates (see
635
+ // `finalizeRow` in orphan-scan.ts), so it never runs against `alive`
636
+ // worktrees. Bounded, non-retrying: a slow/failed probe throws and
637
+ // reports `sizeBytes: "unknown"` rather than blocking the scan.
638
+ measureSize: (path) => {
639
+ const result = spawnSync("du", ["-sk", path], { encoding: "utf8", timeout: 10000 });
640
+ if (result.status !== 0) {
641
+ throw new Error(`du -sk failed or timed out for ${path}`);
642
+ }
643
+ const kilobytes = Number.parseInt(String(result.stdout ?? "")
644
+ .trim()
645
+ .split(/\s+/u)[0] ?? "", 10);
646
+ if (Number.isNaN(kilobytes))
647
+ throw new Error(`du -sk produced unparseable output for ${path}`);
648
+ return kilobytes * 1024;
649
+ },
650
+ };
651
+ }
652
+ /**
653
+ * Filesystem-level orphan sweep: dry-run by DEFAULT, prints a classification
654
+ * table, and only removes directories (via `--force`/`--yes`) that are proven
655
+ * `orphan` (absent from their resolved primary repo's `git worktree list`)
656
+ * AND proven clean (`git status --porcelain` ran successfully with no
657
+ * output). `broken` and `unrecognized` rows are always report-only. See
658
+ * `#worktrees/orphan-scan.js` for the full classification contract.
659
+ */
660
+ async function handlePruneOrphans(opts) {
661
+ const roots = resolveKnownManagedWorktreeRoots();
662
+ const candidates = discoverOrphanScanCandidates(roots, { repoNamespaceFilter: opts.repoId });
663
+ const deps = resolveOrphanScanDeps();
664
+ const rows = candidates.map((candidate) => classifyOrphanCandidate(candidate, deps));
665
+ printOrphanScanTable(rows);
666
+ if (!(opts.force === true || opts.yes === true)) {
667
+ console.log("");
668
+ console.log("Dry run only. Re-run with --force (or --yes) to remove the directories marked removable=yes.");
669
+ return;
670
+ }
671
+ const removable = rows.filter((row) => row.removable);
672
+ for (const row of removable) {
673
+ rmSync(row.path, { recursive: true, force: true });
674
+ console.log(`Removed ${row.path}`);
675
+ }
676
+ console.log(`Removed ${removable.length} orphan director${removable.length === 1 ? "y" : "ies"}.`);
677
+ }
570
678
  async function handleMergeCleanup(nameOrPath, opts) {
571
679
  const cwd = opts.cwd ?? process.cwd();
572
680
  const callerRoot = resolveWorktreeRepoRoot(cwd);
@@ -670,6 +778,10 @@ export async function executeWorktreeSubcommand(subcommand, args, opts) {
670
778
  handleRefresh(opts);
671
779
  return;
672
780
  case "prune":
781
+ if (opts.orphans) {
782
+ await handlePruneOrphans(opts);
783
+ return;
784
+ }
673
785
  await handlePrune(opts);
674
786
  return;
675
787
  case "migrate":
@@ -8,6 +8,10 @@ const HELP_TEXT = [
8
8
  " list [--all] List repo or global managed worktrees",
9
9
  " refresh [--all|--repo <dir>] Refresh managed registry from git",
10
10
  " prune --all Prune stale managed registry entries",
11
+ " prune --orphans [--repo-id <id>] [--force|--yes]",
12
+ " Scan managed roots for on-disk dirs absent",
13
+ " from git worktree list; dry-run by default,",
14
+ " removes only proven-clean orphans with --force",
11
15
  " migrate Move legacy sibling _worktrees",
12
16
  " adopt <blueprint-slug> <path> Claim existing checkout as owner",
13
17
  " rebind <blueprint-slug> [--path <path>] Repair owner metadata",
@@ -24,6 +28,9 @@ const HELP_TEXT = [
24
28
  " --no-setup Skip configured worktree.setup scripts (new only)",
25
29
  " --force Skip configured teardown scripts and force removal",
26
30
  " --stash-primary Stash dirty primary state and switch to the expected primary branch before merge-cleanup",
31
+ " --orphans Filesystem-level orphan sweep for prune (see prune --orphans above)",
32
+ " --repo-id <id> Scope prune --orphans to one repo namespace under repos/<id>",
33
+ " --yes Alias for --force (prune --orphans only)",
27
34
  " --cwd <dir> Repo root (default: process.cwd())",
28
35
  ].join("\n");
29
36
  export function registerWorktreeRouter(cli) {
@@ -40,6 +47,9 @@ export function registerWorktreeRouter(cli) {
40
47
  .option("--stash-primary", "Stash dirty primary state and switch to the expected primary branch before merge-cleanup")
41
48
  .option("--all", "Use global managed inventory where supported")
42
49
  .option("--repo <dir>", "Repo root for refresh/rebind (default: cwd)")
50
+ .option("--orphans", "Filesystem-level orphan sweep for prune (dry-run unless --force/--yes)")
51
+ .option("--repo-id <id>", "Scope prune --orphans to one repo namespace under repos/<id>")
52
+ .option("--yes", "Alias for --force (prune --orphans only)")
43
53
  .option("--cwd <dir>", "Repo root to operate from (default: process.cwd())")
44
54
  .action(async (subcommand, args, options) => {
45
55
  if (!subcommand) {
@@ -118,6 +118,8 @@ export declare function checkCodexConfigKeys(configFilePath?: string, deps?: Hos
118
118
  export declare function checkProjectMcpPins(cwd?: string, deps?: HostConfigDoctorDeps): DoctorCheck;
119
119
  export interface ClaudeMcpDuplicateRegistrationOptions {
120
120
  readonly home?: string;
121
+ /** Project root used to discover a project-local `.mcp.json` registration. */
122
+ readonly projectRoot?: string;
121
123
  readonly exists?: (path: string) => boolean;
122
124
  readonly readFile?: (path: string) => string;
123
125
  }
@@ -1838,6 +1838,29 @@ export function checkClaudeMcpDuplicateRegistration(options = {}) {
1838
1838
  manualSources.push(`project scope ${projectDir}`);
1839
1839
  }
1840
1840
  }
1841
+ // Also scan project-local `.mcp.json` (and ancestors). Claude Code honors
1842
+ // this file as a third registration surface; the home JSON projects map
1843
+ // does not always mirror it, so a plugin + project-file pair still doubles
1844
+ // the tool list without the home-JSON check ever firing.
1845
+ const projectRoot = options.projectRoot ?? process.cwd();
1846
+ for (const candidate of collectProjectMcpJsonCandidates(projectRoot)) {
1847
+ if (!exists(candidate))
1848
+ continue;
1849
+ try {
1850
+ const projectParsed = JSON.parse(readFile(candidate));
1851
+ if (projectParsed !== null &&
1852
+ typeof projectParsed === "object" &&
1853
+ "mcpServers" in projectParsed &&
1854
+ projectParsed.mcpServers &&
1855
+ "webpresso" in
1856
+ (projectParsed.mcpServers ?? {})) {
1857
+ manualSources.push(`project file ${candidate}`);
1858
+ }
1859
+ }
1860
+ catch {
1861
+ // Unreadable/malformed .mcp.json is not a duplicate signal — degrade.
1862
+ }
1863
+ }
1841
1864
  if (manualSources.length === 0) {
1842
1865
  return { name, ok: true, detail: "plugin-scoped registration only" };
1843
1866
  }
@@ -1851,6 +1874,19 @@ export function checkClaudeMcpDuplicateRegistration(options = {}) {
1851
1874
  `claude mcp remove webpresso (run inside the project directory for project-scope entries).`,
1852
1875
  };
1853
1876
  }
1877
+ /** Walk projectRoot → filesystem root collecting `.mcp.json` paths that may register MCP servers. */
1878
+ function collectProjectMcpJsonCandidates(projectRoot) {
1879
+ const candidates = [];
1880
+ let current = projectRoot;
1881
+ for (;;) {
1882
+ candidates.push(join(current, ".mcp.json"));
1883
+ const parent = dirname(current);
1884
+ if (parent === current)
1885
+ break;
1886
+ current = parent;
1887
+ }
1888
+ return candidates;
1889
+ }
1854
1890
  const QUOTED_TOML_STRING = /"((?:[^"\\]|\\.)*)"/gu;
1855
1891
  const DANGLING_SYMLINK_SCAN_DEPTH = 3;
1856
1892
  function collectDanglingWorktreePaths(raw, worktreesRoot, exists, alreadyReported) {
@@ -2055,7 +2091,7 @@ export async function runHooksDoctor(opts = {}) {
2055
2091
  checks.push(checkCodexConfigKeys());
2056
2092
  }
2057
2093
  checks.push(checkProjectMcpPins(opts.cwd));
2058
- checks.push(checkClaudeMcpDuplicateRegistration());
2094
+ checks.push(checkClaudeMcpDuplicateRegistration({ projectRoot: opts.cwd ?? process.cwd() }));
2059
2095
  checks.push({ advisory: true, ...checkDanglingHostPaths() });
2060
2096
  checks.push({ advisory: true, ...checkRootLauncherContract() });
2061
2097
  checks.push({ advisory: true, ...checkNativePluginRuntime() });