@webpresso/agent-kit 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/bin/_run.js +4 -1
  2. package/catalog/agent/rules/pre-implementation.md +15 -0
  3. package/dist/esm/audit/blueprint-trust.js +14 -10
  4. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  5. package/dist/esm/audit/changeset-evidence.js +30 -0
  6. package/dist/esm/audit/changeset-required.d.ts +10 -0
  7. package/dist/esm/audit/changeset-required.js +73 -0
  8. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  9. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  10. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +6 -1
  13. package/dist/esm/blueprint/core/parser.js +61 -4
  14. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  15. package/dist/esm/blueprint/core/schema.js +26 -0
  16. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  17. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  18. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  21. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  22. package/dist/esm/blueprint/sync/client.js +1 -1
  23. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  24. package/dist/esm/blueprint/trust/dossier.js +40 -6
  25. package/dist/esm/blueprint/trust/promotion.js +12 -0
  26. package/dist/esm/build/cli-mcp-parity.js +5 -0
  27. package/dist/esm/cli/cli.d.ts +1 -1
  28. package/dist/esm/cli/cli.js +9 -0
  29. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  30. package/dist/esm/cli/commands/agent-launch.js +31 -20
  31. package/dist/esm/cli/commands/audit.js +4 -0
  32. package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
  33. package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
  34. package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
  35. package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
  36. package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
  37. package/dist/esm/cli/commands/blueprint/router.js +5 -2
  38. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  39. package/dist/esm/cli/commands/init/convergence-apply.d.ts +87 -0
  40. package/dist/esm/cli/commands/init/convergence-apply.js +189 -23
  41. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  42. package/dist/esm/cli/commands/init/index.js +2 -2
  43. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  44. package/dist/esm/cli/commands/init/package-root.js +10 -2
  45. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  46. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  47. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  48. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  49. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  50. package/dist/esm/cli/commands/opencode-probe.d.ts +118 -0
  51. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  52. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  53. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  54. package/dist/esm/cli/commands/quality-runner.js +8 -4
  55. package/dist/esm/cli/commands/review.js +27 -5
  56. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  57. package/dist/esm/cli/commands/rust-check.js +110 -0
  58. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  59. package/dist/esm/cli/commands/typecheck.js +30 -1
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +14 -0
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +138 -2
  62. package/dist/esm/cli/commands/worktree/router.js +10 -0
  63. package/dist/esm/hooks/doctor.d.ts +2 -0
  64. package/dist/esm/hooks/doctor.js +37 -1
  65. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  66. package/dist/esm/hooks/shared/types.js +27 -1
  67. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  68. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  69. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  70. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  71. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  72. package/dist/esm/mcp/tools/audit.js +12 -0
  73. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  74. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  75. package/dist/esm/package.json +2 -0
  76. package/dist/esm/platform/client.d.ts +7 -1
  77. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  78. package/dist/esm/pr-description/contract.js +24 -4
  79. package/dist/esm/review/authority.js +14 -9
  80. package/dist/esm/review/availability.d.ts +11 -0
  81. package/dist/esm/review/availability.js +18 -3
  82. package/dist/esm/review/execution/adapters.d.ts +11 -1
  83. package/dist/esm/review/execution/adapters.js +10 -1
  84. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  85. package/dist/esm/review/execution/review-checkout.d.ts +6 -0
  86. package/dist/esm/review/execution/review-checkout.js +61 -16
  87. package/dist/esm/review/execution/supervisor.js +31 -4
  88. package/dist/esm/review/execution/types.d.ts +1 -0
  89. package/dist/esm/review/subject.d.ts +13 -0
  90. package/dist/esm/review/subject.js +42 -4
  91. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  92. package/dist/esm/rust/affected-manifests.js +69 -0
  93. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  94. package/dist/esm/status/snapshot.js +2 -6
  95. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  96. package/dist/esm/test-helpers/global-setup.js +18 -15
  97. package/dist/esm/typecheck/planner.d.ts +9 -1
  98. package/dist/esm/typecheck/planner.js +31 -0
  99. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  100. package/dist/esm/worktrees/orphan-scan.js +211 -0
  101. package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
  102. package/dist/esm/worktrees/owner-dirt.js +104 -0
  103. 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
  }
@@ -145,11 +145,15 @@ function renderSummaryLines(summary, transformed, entry, passed) {
145
145
  else if (!passed && transformed.rawOutput?.trim()) {
146
146
  lines.push(transformed.rawOutput.trimEnd());
147
147
  }
148
- if (!passed || transformed.truncated) {
148
+ // Any elided bytes must remain retrievable: fail, clip, OR compaction that
149
+ // recorded an elision without setting truncated. Hide Full log / retrieve
150
+ // hints only when the summary still contains the full content.
151
+ const hasElisions = (transformed.elisions?.length ?? 0) > 0;
152
+ if (!passed || transformed.truncated || hasElisions) {
149
153
  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}`));
154
+ if (hasElisions) {
155
+ lines.push(...transformed.elisions.map((elision) => `Retrieve elision: ${elision.retrieveTool} id=${elision.id}`));
156
+ }
153
157
  }
154
158
  if (transformed.warnings && transformed.warnings.length > 0) {
155
159
  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
+ " # ci-preflight and CI (not the default untargeted gate)",
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;
@@ -49,6 +55,12 @@ export interface MergeCleanupDecisionInput {
49
55
  readonly repoDirty: boolean;
50
56
  readonly targetDirty: boolean;
51
57
  readonly stashPrimary?: boolean;
58
+ /**
59
+ * When target is dirty, optional porcelain -z + slug enrich the refusal message
60
+ * (scaffold-only vs other dirt). Omitted messages stay generic for unit tests.
61
+ */
62
+ readonly targetPorcelainZ?: string;
63
+ readonly targetBlueprintSlug?: string;
52
64
  }
53
65
  export interface NewWorktreeTargetInput {
54
66
  branch?: string;
@@ -91,4 +103,6 @@ export interface CreateManagedWorktreeResult {
91
103
  export declare function formatWorktreeList(entries: WorktreeEntry[], currentWorktreePath: string): string[];
92
104
  export declare function formatManagedWorktreeList(entries: readonly ManagedWorktreeEntry[]): string[];
93
105
  export declare function createManagedWorktree(opts?: CreateManagedWorktreeOptions): Promise<CreateManagedWorktreeResult>;
106
+ /** `.../blueprints/<slug>/owner` → slug */
107
+ export declare function inferBlueprintSlugFromOwnerPath(ownerPath: string): string | undefined;
94
108
  export declare function executeWorktreeSubcommand(subcommand: string, args: string[], opts: WorktreeCommandOptions): Promise<void>;
@@ -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";
@@ -15,8 +15,10 @@ import { runWorktreeSetup, runWorktreeTeardown } from "#worktrees/lifecycle-scri
15
15
  import { hydrateWorktreeDependencies, reconcileWorktreeDependencies, } from "#worktrees/dependencies.js";
16
16
  import { projectWorktreeAgentSurfaces } from "#worktrees/project-agent-surfaces.js";
17
17
  import { assertNotLocalMainBranch } from "#worktrees/main-ownership.js";
18
+ import { formatTargetDirtyRefusal } from "#worktrees/owner-dirt.js";
18
19
  import { buildWorktreeInventory, canonicalizeWorktreePath, classifyMembership, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
19
20
  import { findRegistryCandidatesByPath, planStaleWorktreeRegistryPrune, readWorktreeRegistry, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
21
+ import { buildRegistryRepoRootMap, classifyOrphanCandidate, discoverOrphanScanCandidates, resolveRepoRootFromGitPointer, } from "#worktrees/orphan-scan.js";
20
22
  function parseDepsOption(value) {
21
23
  if (value === undefined || value === null || value === "")
22
24
  return undefined;
@@ -181,6 +183,13 @@ export function decideMergeCleanup(input) {
181
183
  throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} is locked`);
182
184
  }
183
185
  if (input.targetDirty) {
186
+ if (input.targetPorcelainZ !== undefined) {
187
+ throw new Error(formatTargetDirtyRefusal({
188
+ targetPath: input.targetPath,
189
+ porcelainZ: input.targetPorcelainZ,
190
+ slug: input.targetBlueprintSlug,
191
+ }));
192
+ }
184
193
  throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} has uncommitted changes`);
185
194
  }
186
195
  if (input.repoDirty) {
@@ -560,6 +569,12 @@ function isDirty(path) {
560
569
  return true;
561
570
  return String(status.stdout ?? "").trim().length > 0;
562
571
  }
572
+ /** `.../blueprints/<slug>/owner` → slug */
573
+ export function inferBlueprintSlugFromOwnerPath(ownerPath) {
574
+ const normalized = ownerPath.replace(/\\/gu, "/");
575
+ const match = /\/blueprints\/([^/]+)\/owner\/?$/u.exec(normalized);
576
+ return match?.[1];
577
+ }
563
578
  function currentBranch(repoRoot) {
564
579
  const result = spawnSync("git", ["branch", "--show-current"], {
565
580
  cwd: repoRoot,
@@ -567,6 +582,113 @@ function currentBranch(repoRoot) {
567
582
  });
568
583
  return result.status === 0 ? String(result.stdout ?? "").trim() : "";
569
584
  }
585
+ function formatOrphanScanBytes(bytes) {
586
+ if (bytes === "unknown")
587
+ return "-";
588
+ if (bytes < 1024)
589
+ return `${bytes}B`;
590
+ const units = ["K", "M", "G", "T"];
591
+ let value = bytes / 1024;
592
+ let unitIndex = 0;
593
+ while (value >= 1024 && unitIndex < units.length - 1) {
594
+ value /= 1024;
595
+ unitIndex += 1;
596
+ }
597
+ return `${value.toFixed(1)}${units[unitIndex]}`;
598
+ }
599
+ function summarizeOrphanScanRows(rows) {
600
+ const counts = new Map();
601
+ for (const row of rows)
602
+ counts.set(row.classification, (counts.get(row.classification) ?? 0) + 1);
603
+ const removable = rows.filter((row) => row.removable).length;
604
+ const parts = [...counts.entries()].map(([classification, count]) => `${classification}=${count}`);
605
+ return `${parts.join(", ")}, removable=${removable}`;
606
+ }
607
+ function printOrphanScanTable(rows) {
608
+ if (rows.length === 0) {
609
+ console.log("No candidate directories found under managed worktree roots.");
610
+ return;
611
+ }
612
+ console.log(`${"PATH".padEnd(72)} ${"CLASS".padEnd(13)} ${"DIRTY".padEnd(8)} ${"SIZE".padEnd(7)} ${"RM".padEnd(4)} REASON`);
613
+ for (const row of rows) {
614
+ const dirty = row.dirty === "unknown" ? "unknown" : row.dirty ? "yes" : "no";
615
+ 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}`);
616
+ }
617
+ console.log("");
618
+ console.log(`Summary: ${summarizeOrphanScanRows(rows)}`);
619
+ }
620
+ function resolveOrphanScanDeps() {
621
+ const registryMap = buildRegistryRepoRootMap(readWorktreeRegistry().entries);
622
+ const readGitPointer = (path) => {
623
+ try {
624
+ return readFileSync(path, "utf8");
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ };
630
+ return {
631
+ resolveRepoRoot: (candidate) => registryMap.get(candidate.repoNamespace) ??
632
+ resolveRepoRootFromGitPointer(candidate.path, readGitPointer),
633
+ repoRootExists: existsSync,
634
+ buildInventory: buildWorktreeInventory,
635
+ probeDirty: (path) => {
636
+ const result = spawnSync("git", ["status", "--porcelain"], {
637
+ cwd: path,
638
+ encoding: "utf8",
639
+ timeout: 5000,
640
+ });
641
+ if (result.status !== 0)
642
+ return "unknown";
643
+ return String(result.stdout ?? "").trim().length > 0;
644
+ },
645
+ // Native `du` traverses in kernel/C code (no per-file V8 stat overhead),
646
+ // which is materially faster than the pure-JS walker at the scale a real
647
+ // managed-worktree tree reaches (hundreds of `node_modules` trees). This
648
+ // is only invoked for already-confirmed `orphan` candidates (see
649
+ // `finalizeRow` in orphan-scan.ts), so it never runs against `alive`
650
+ // worktrees. Bounded, non-retrying: a slow/failed probe throws and
651
+ // reports `sizeBytes: "unknown"` rather than blocking the scan.
652
+ measureSize: (path) => {
653
+ const result = spawnSync("du", ["-sk", path], { encoding: "utf8", timeout: 10000 });
654
+ if (result.status !== 0) {
655
+ throw new Error(`du -sk failed or timed out for ${path}`);
656
+ }
657
+ const kilobytes = Number.parseInt(String(result.stdout ?? "")
658
+ .trim()
659
+ .split(/\s+/u)[0] ?? "", 10);
660
+ if (Number.isNaN(kilobytes))
661
+ throw new Error(`du -sk produced unparseable output for ${path}`);
662
+ return kilobytes * 1024;
663
+ },
664
+ };
665
+ }
666
+ /**
667
+ * Filesystem-level orphan sweep: dry-run by DEFAULT, prints a classification
668
+ * table, and only removes directories (via `--force`/`--yes`) that are proven
669
+ * `orphan` (absent from their resolved primary repo's `git worktree list`)
670
+ * AND proven clean (`git status --porcelain` ran successfully with no
671
+ * output). `broken` and `unrecognized` rows are always report-only. See
672
+ * `#worktrees/orphan-scan.js` for the full classification contract.
673
+ */
674
+ async function handlePruneOrphans(opts) {
675
+ const roots = resolveKnownManagedWorktreeRoots();
676
+ const candidates = discoverOrphanScanCandidates(roots, { repoNamespaceFilter: opts.repoId });
677
+ const deps = resolveOrphanScanDeps();
678
+ const rows = candidates.map((candidate) => classifyOrphanCandidate(candidate, deps));
679
+ printOrphanScanTable(rows);
680
+ if (!(opts.force === true || opts.yes === true)) {
681
+ console.log("");
682
+ console.log("Dry run only. Re-run with --force (or --yes) to remove the directories marked removable=yes.");
683
+ return;
684
+ }
685
+ const removable = rows.filter((row) => row.removable);
686
+ for (const row of removable) {
687
+ rmSync(row.path, { recursive: true, force: true });
688
+ console.log(`Removed ${row.path}`);
689
+ }
690
+ console.log(`Removed ${removable.length} orphan director${removable.length === 1 ? "y" : "ies"}.`);
691
+ }
570
692
  async function handleMergeCleanup(nameOrPath, opts) {
571
693
  const cwd = opts.cwd ?? process.cwd();
572
694
  const callerRoot = resolveWorktreeRepoRoot(cwd);
@@ -583,6 +705,14 @@ async function handleMergeCleanup(nameOrPath, opts) {
583
705
  const scopedRegistryEntries = findRegistryCandidatesByPath(resolved)
584
706
  .filter((candidate) => classifyMembership(candidate.entry.repoRoot, mergeAuthKey) === "same")
585
707
  .map((candidate) => candidate.entry);
708
+ const targetDirty = isDirty(resolved);
709
+ const targetPorcelainZ = targetDirty
710
+ ? (() => {
711
+ const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no", "-z"], { cwd: resolved, encoding: "utf8" });
712
+ return status.status === 0 ? String(status.stdout ?? "") : "?? (git-status-failed)\0";
713
+ })()
714
+ : undefined;
715
+ const targetBlueprintSlug = inferBlueprintSlugFromOwnerPath(resolved);
586
716
  const plan = decideMergeCleanup({
587
717
  repoRoot,
588
718
  targetPath: resolved,
@@ -591,8 +721,10 @@ async function handleMergeCleanup(nameOrPath, opts) {
591
721
  registryEntries: scopedRegistryEntries,
592
722
  currentBranch: currentBranch(repoRoot),
593
723
  repoDirty: isDirty(repoRoot),
594
- targetDirty: isDirty(resolved),
724
+ targetDirty,
595
725
  stashPrimary: opts.stashPrimary === true,
726
+ ...(targetPorcelainZ !== undefined ? { targetPorcelainZ } : {}),
727
+ ...(targetBlueprintSlug !== undefined ? { targetBlueprintSlug } : {}),
596
728
  });
597
729
  if (opts.dryRun) {
598
730
  console.log("[dry-run] Would run merge cleanup:");
@@ -670,6 +802,10 @@ export async function executeWorktreeSubcommand(subcommand, args, opts) {
670
802
  handleRefresh(opts);
671
803
  return;
672
804
  case "prune":
805
+ if (opts.orphans) {
806
+ await handlePruneOrphans(opts);
807
+ return;
808
+ }
673
809
  await handlePrune(opts);
674
810
  return;
675
811
  case "migrate":