@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.
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
@@ -312,26 +312,32 @@ function neutralizePromotionGateResults(text) {
312
312
  // continuation lines). Each mirrors exactly one lifecycle writer:
313
313
  // `completed_at` (upsertCompletedAt) and the `wp blueprint start` /
314
314
  // progress-stamp fields (setBlueprintFrontmatterFields lifecycle intents).
315
- const PLAN_LIFECYCLE_STRIPPED_KEYS = [
315
+ // Lifecycle writers only — the pre-tags-strip normalization (compat tier).
316
+ const PLAN_LIFECYCLE_STRIPPED_KEYS_CORE = [
316
317
  "completed_at",
317
318
  "last_updated",
318
319
  "progress",
319
320
  "worktree_owner_id",
320
321
  "worktree_owner_branch",
321
322
  ];
323
+ // Current plan-subject strip set: core lifecycle keys plus advisory `tags`.
324
+ // Tags are not plan substance; MCP put and CLI serialize disagree on array-item
325
+ // quoting (`- "x"` vs `- x`). Approvals recorded before the tags strip must
326
+ // still verify via `preTagsStripNormalizePlanSubjectContent`.
327
+ const PLAN_LIFECYCLE_STRIPPED_KEYS = [...PLAN_LIFECYCLE_STRIPPED_KEYS_CORE, "tags"];
322
328
  // Neutralize the lifecycle-owned frontmatter fields (`status:` value plus the
323
- // stripped keys above) INSIDE the leading frontmatter block only. A body line
329
+ // stripped keys above, including advisory `tags`) INSIDE the leading frontmatter block only. A body line
324
330
  // that merely looks like one of these (e.g. task text reading
325
331
  // "completed_at: must remain unset until release") is plan substance and must
326
332
  // keep affecting the digest. Stripping removes the key line and any indented
327
333
  // continuation lines so folded (`>-`) values normalize away too.
328
- function neutralizeLifecycleFrontmatter(text) {
334
+ function neutralizeLifecycleFrontmatter(text, strippedKeys = PLAN_LIFECYCLE_STRIPPED_KEYS) {
329
335
  const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(text);
330
336
  if (!match)
331
337
  return text;
332
338
  let frontmatter = match[0];
333
339
  frontmatter = frontmatter.replace(/^(status:\s*)(['"]?)[^'"\r\n]+?(['"]?)(\s*)$/m, `$1${PLAN_STATUS_PLACEHOLDER}$4`);
334
- for (const key of PLAN_LIFECYCLE_STRIPPED_KEYS) {
340
+ for (const key of strippedKeys) {
335
341
  frontmatter = frontmatter.replace(new RegExp(String.raw `^${key}:[^\n]*\n?(?:[ \t]+[^\n]*\n?)*`, "gmu"), "");
336
342
  }
337
343
  return `${frontmatter}${text.slice(match[0].length)}`;
@@ -463,6 +469,20 @@ export function legacyNormalizePlanSubjectContent(content) {
463
469
  text = neutralizePromotionGateResults(text);
464
470
  return Buffer.from(text, "utf8");
465
471
  }
472
+ /**
473
+ * Digest-compatibility variant for review events recorded after the lifecycle
474
+ * stripped-key extension but before the advisory `tags` strip. Verification
475
+ * accepts a match against this tier so existing plan approvals on tagged
476
+ * blueprints are not stranded by the tags-strip rollout.
477
+ */
478
+ export function preTagsStripNormalizePlanSubjectContent(content) {
479
+ let text = typeof content === "string" ? content : content.toString("utf8");
480
+ text = neutralizeLifecycleFrontmatter(text, PLAN_LIFECYCLE_STRIPPED_KEYS_CORE);
481
+ text = neutralizeReadinessStamps(text);
482
+ text = neutralizePromotionGateResults(text);
483
+ text = neutralizeTaskExecutionState(text);
484
+ return Buffer.from(text, "utf8");
485
+ }
466
486
  function planSubjectAtRef(cwd, ref, blueprintSlug, normalize, blueprintPath) {
467
487
  const commit = resolveReviewCommit(cwd, ref);
468
488
  const entries = parseTree(cwd, commit);
@@ -491,6 +511,24 @@ export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
491
511
  export function createLegacyPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
492
512
  return planSubjectAtRef(cwd, ref, blueprintSlug, legacyNormalizePlanSubjectContent, blueprintPath);
493
513
  }
514
+ /** Plan subject digested without the advisory `tags` strip (pre-tags-strip tier). */
515
+ export function createPreTagsStripPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
516
+ return planSubjectAtRef(cwd, ref, blueprintSlug, preTagsStripNormalizePlanSubjectContent, blueprintPath);
517
+ }
518
+ /** Working-ledger digest for the pre-tags-strip compatibility tier. */
519
+ export function createPreTagsStripPlanSubjectDigestForContent(blueprintSlug, content) {
520
+ const hash = createHash("sha256").update(PLAN_DOMAIN);
521
+ updateField(hash, blueprintSlug);
522
+ updateField(hash, preTagsStripNormalizePlanSubjectContent(content));
523
+ return hash.digest("hex");
524
+ }
525
+ /** Working-ledger digest for the pre-extension legacy compatibility tier. */
526
+ export function createLegacyPlanSubjectDigestForContent(blueprintSlug, content) {
527
+ const hash = createHash("sha256").update(PLAN_DOMAIN);
528
+ updateField(hash, blueprintSlug);
529
+ updateField(hash, legacyNormalizePlanSubjectContent(content));
530
+ return hash.digest("hex");
531
+ }
494
532
  export const reviewSubjectInternals = {
495
533
  isExcludedReviewEnvelope,
496
534
  isReviewEvidencePath,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Maps changed repo paths to the tracked Rust crate(s) they belong to.
3
+ *
4
+ * Pure and dependency-free so it is unit-testable without shelling out to
5
+ * `cargo` or `git` — see `#cli/commands/rust-check.js` for the CLI command
6
+ * that spawns `cargo check`/`cargo test` for whatever this resolves.
7
+ */
8
+ export interface TrackedRustCrate {
9
+ /** Crate directory relative to the repo root, e.g. "native/dash-tui". */
10
+ readonly root: string;
11
+ /** Manifest path relative to the repo root. */
12
+ readonly manifest: string;
13
+ /** `cargo` args (after `cargo`) that prove this crate's tests still pass. */
14
+ readonly testArgs: readonly string[];
15
+ }
16
+ /**
17
+ * The Rust crates `wp ci-preflight` proves compile + pass on an affected
18
+ * change. Mirrors exactly what `.github/workflows/ci.agent-kit.yml` (native
19
+ * session-memory + dash-tui jobs) and `.github/workflows/desktop-perf.yml`
20
+ * (desktop `src-tauri`) already build and test in CI — no new cargo
21
+ * invocation shape is invented here.
22
+ *
23
+ * Deliberately EXCLUDES the `spikes/**` crates (`spikes/pty-spawn-transport/
24
+ * rust`, `spikes/desktop-pty-loadtest/rust`,
25
+ * `spikes/desktop-pty-loadtest/src-tauri`): no CI workflow builds or tests
26
+ * them today — they are scratch/prototype crates with no compile guarantee.
27
+ * Gating pre-push on them would fail closed on code nobody has ever promised
28
+ * compiles. Revisit this list if a spike graduates to a tracked crate.
29
+ */
30
+ export declare const TRACKED_RUST_CRATES: readonly TrackedRustCrate[];
31
+ /**
32
+ * Returns the sorted, deduped list of tracked-crate manifest paths touched by
33
+ * `files`. Rust files under an untracked crate (e.g. `spikes/**`) or outside
34
+ * any crate are ignored, not escalated to "affects everything" — each
35
+ * manifest maps back to `TRACKED_RUST_CRATES` for its `testArgs`.
36
+ */
37
+ export declare function resolveAffectedRustManifests(files: readonly string[]): readonly string[];
38
+ export declare function trackedCrateForManifest(manifest: string): TrackedRustCrate | undefined;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Maps changed repo paths to the tracked Rust crate(s) they belong to.
3
+ *
4
+ * Pure and dependency-free so it is unit-testable without shelling out to
5
+ * `cargo` or `git` — see `#cli/commands/rust-check.js` for the CLI command
6
+ * that spawns `cargo check`/`cargo test` for whatever this resolves.
7
+ */
8
+ /**
9
+ * The Rust crates `wp ci-preflight` proves compile + pass on an affected
10
+ * change. Mirrors exactly what `.github/workflows/ci.agent-kit.yml` (native
11
+ * session-memory + dash-tui jobs) and `.github/workflows/desktop-perf.yml`
12
+ * (desktop `src-tauri`) already build and test in CI — no new cargo
13
+ * invocation shape is invented here.
14
+ *
15
+ * Deliberately EXCLUDES the `spikes/**` crates (`spikes/pty-spawn-transport/
16
+ * rust`, `spikes/desktop-pty-loadtest/rust`,
17
+ * `spikes/desktop-pty-loadtest/src-tauri`): no CI workflow builds or tests
18
+ * them today — they are scratch/prototype crates with no compile guarantee.
19
+ * Gating pre-push on them would fail closed on code nobody has ever promised
20
+ * compiles. Revisit this list if a spike graduates to a tracked crate.
21
+ */
22
+ export const TRACKED_RUST_CRATES = [
23
+ {
24
+ root: "native/session-memory-engine",
25
+ manifest: "native/session-memory-engine/Cargo.toml",
26
+ testArgs: ["test", "--manifest-path", "native/session-memory-engine/Cargo.toml", "--all"],
27
+ },
28
+ {
29
+ root: "native/dash-tui",
30
+ manifest: "native/dash-tui/Cargo.toml",
31
+ testArgs: ["test", "--manifest-path", "native/dash-tui/Cargo.toml"],
32
+ },
33
+ {
34
+ root: "apps/desktop/src-tauri",
35
+ manifest: "apps/desktop/src-tauri/Cargo.toml",
36
+ testArgs: ["test", "--manifest-path", "apps/desktop/src-tauri/Cargo.toml", "--lib"],
37
+ },
38
+ ];
39
+ function isRustSignalFile(file) {
40
+ return (file.endsWith(".rs") ||
41
+ file === "Cargo.toml" ||
42
+ file.endsWith("/Cargo.toml") ||
43
+ file === "Cargo.lock" ||
44
+ file.endsWith("/Cargo.lock"));
45
+ }
46
+ function owningTrackedCrate(file) {
47
+ return TRACKED_RUST_CRATES.find((candidate) => file === candidate.root || file.startsWith(`${candidate.root}/`));
48
+ }
49
+ /**
50
+ * Returns the sorted, deduped list of tracked-crate manifest paths touched by
51
+ * `files`. Rust files under an untracked crate (e.g. `spikes/**`) or outside
52
+ * any crate are ignored, not escalated to "affects everything" — each
53
+ * manifest maps back to `TRACKED_RUST_CRATES` for its `testArgs`.
54
+ */
55
+ export function resolveAffectedRustManifests(files) {
56
+ const manifests = new Set();
57
+ for (const rawFile of files) {
58
+ const file = rawFile.replaceAll("\\", "/");
59
+ if (!isRustSignalFile(file))
60
+ continue;
61
+ const crate = owningTrackedCrate(file);
62
+ if (crate)
63
+ manifests.add(crate.manifest);
64
+ }
65
+ return [...manifests].sort();
66
+ }
67
+ export function trackedCrateForManifest(manifest) {
68
+ return TRACKED_RUST_CRATES.find((candidate) => candidate.manifest === manifest);
69
+ }
@@ -20,7 +20,7 @@ export interface FetchAndIndexOptions {
20
20
  signal?: AbortSignal;
21
21
  }
22
22
  export declare function fetchAndIndex(options: FetchAndIndexOptions, deps?: {
23
- readonly fetchImpl?: typeof fetch;
23
+ readonly fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
24
24
  /** Internal/test-only escape hatch for loopback fixtures; never exposed in CLI/MCP input. */
25
25
  readonly allowedHosts?: readonly string[];
26
26
  }): Promise<SessionMemoryChunk[]>;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { z } from "zod";
3
+ import { hasChangesetEvidence } from "#audit/changeset-evidence.js";
3
4
  import { evaluateApprovalReviewers } from "#lifecycle/audit.js";
4
5
  import { collectV2ReviewProvenance } from "#lifecycle/review-provenance.js";
5
6
  import { evaluateRequiredChecks } from "#mcp/tools/_shared/required-checks.js";
@@ -210,12 +211,7 @@ function hasDisclosure(body, label) {
210
211
  return new RegExp(`(?:^|\\n)\\s*(?:-\\s*)?${escaped}:\\s*\\S`, "iu").test(body);
211
212
  }
212
213
  function hasReleaseEvidence(pr) {
213
- const files = prFiles(pr);
214
- if (files.length > 0 && files.every((file) => file.endsWith(".md")))
215
- return true;
216
- if (files.some((file) => /^\.changeset\/[^/]+\.md$/u.test(file)))
217
- return true;
218
- return /Changeset-exempt:\s*\S/iu.test(stringField(pr.body) ?? "");
214
+ return hasChangesetEvidence(prFiles(pr), [stringField(pr.body) ?? ""]);
219
215
  }
220
216
  export function evaluatePrReadiness(evidence) {
221
217
  const blockers = [];
@@ -1,3 +1,2 @@
1
1
  import type { TestProject } from "vitest/node";
2
- export declare function shouldPrepareDistForProject(projectName: string | undefined): boolean;
3
2
  export declare function setup(project?: Pick<TestProject, "name">): Promise<void>;
@@ -11,10 +11,6 @@ const MIGRATION_SENTINEL = join(REPO_ROOT, "dist", "esm", "blueprint", "db", "mi
11
11
  // Lock target lives outside dist/ (tshy rewrites dist/) and under the always-present, gitignored
12
12
  // node_modules/.cache so a concurrent test run cannot clobber it mid-build.
13
13
  const BUILD_LOCK_TARGET = join(REPO_ROOT, "node_modules", ".cache", "wp-test-setup");
14
- const DIST_PREP_PROJECTS = new Set(["subprocess", "serial-subprocess"]);
15
- export function shouldPrepareDistForProject(projectName) {
16
- return projectName !== undefined && DIST_PREP_PROJECTS.has(projectName);
17
- }
18
14
  // Serialize the dist build across concurrent test runs sharing this checkout: tshy's non-atomic
19
15
  // dist/package.json writes corrupt each other if two runs build at once. proper-lockfile gives a
20
16
  // bounded wait plus native stale-owner recovery (a dead builder's lock is reclaimed after `stale`).
@@ -33,19 +29,21 @@ async function withDistBuildLock(fn) {
33
29
  await release();
34
30
  }
35
31
  }
36
- // Build before subprocess workers fork — tshy's non-atomic package.json write races with bun subprocess reads in init.e2e.test.ts.
37
- // Unit-only runs should not pay this build/setup cost, and Vitest passes the selected TestProject to globalSetup.
38
- export async function setup(project) {
39
- syncSastRuleAssets(REPO_ROOT);
40
- if (!shouldPrepareDistForProject(project?.name))
41
- return;
32
+ // h-019: scripts/run-vitest-projects.ts spawns the "unit" and "subprocess" vitest projects as two
33
+ // independent, unsynchronized child processes (Promise.all) the same exposure exists for any two
34
+ // `vitest run --project <name>` invocations that happen to share this checkout. tshy's package.json
35
+ // write below is non-atomic (truncate+write, not write-temp+rename): a project whose setup() returned
36
+ // without taking this lock could start reading package.json (module resolution, etc.) while a sibling
37
+ // project's process was mid-tshy-write, observing a truncated/empty file (JSONError "File is empty").
38
+ // Every project therefore passes through this SAME gate before its tests can start, whether or not
39
+ // that project needs dist output for itself: whichever process reaches the lock first performs the
40
+ // idempotent, re-checked-under-lock build; every other process blocks until that release, then finds
41
+ // the sentinels already satisfied and returns immediately. In the steady state (dist/ already built)
42
+ // this is a single existsSync check with no lock contention at all.
43
+ async function ensureDistPrepared() {
42
44
  // Fast path: nothing to build. Only pay the lock when a build artifact is actually missing.
43
- if (existsSync(DIST_SENTINEL) && existsSync(MIGRATION_SENTINEL)) {
44
- if (project?.name === "serial-subprocess") {
45
- ensureHostRuntimeArtifactsForSerialTests({ rootDir: REPO_ROOT });
46
- }
45
+ if (existsSync(DIST_SENTINEL) && existsSync(MIGRATION_SENTINEL))
47
46
  return;
48
- }
49
47
  await withDistBuildLock(async () => {
50
48
  let builtDist = false;
51
49
  // Re-check under the lock: another run may have completed the build while we waited.
@@ -83,6 +81,11 @@ export async function setup(project) {
83
81
  });
84
82
  }
85
83
  });
84
+ }
85
+ // Vitest passes the selected TestProject to globalSetup, once per project process.
86
+ export async function setup(project) {
87
+ syncSastRuleAssets(REPO_ROOT);
88
+ await ensureDistPrepared();
86
89
  if (project?.name === "serial-subprocess") {
87
90
  ensureHostRuntimeArtifactsForSerialTests({ rootDir: REPO_ROOT });
88
91
  }
@@ -13,7 +13,7 @@ export interface PlannedTypecheckCommand {
13
13
  }
14
14
  export interface TypecheckExecutionPlan {
15
15
  readonly repoRoot: string;
16
- readonly mode: "default" | "files" | "packages";
16
+ readonly mode: "default" | "files" | "packages" | "tests";
17
17
  readonly commands: readonly PlannedTypecheckCommand[];
18
18
  readonly resolvedScopes: readonly TypecheckScope[];
19
19
  readonly preambleLine?: string;
@@ -24,6 +24,14 @@ export interface PlanTypecheckExecutionOptions {
24
24
  readonly files?: readonly string[];
25
25
  readonly packages?: readonly string[];
26
26
  readonly pretty?: boolean;
27
+ /**
28
+ * Run the separate test-surface typecheck lane (`tsconfig.test.json`)
29
+ * instead of the default production-source lane. Mutually exclusive with
30
+ * `files`/`packages`. This lane is invoked via `wp typecheck --tests` and is
31
+ * it is not part of the default `wp typecheck` gate or `ci-preflight`
32
+ * until the test-surface error corpus is burned down to zero.
33
+ */
34
+ readonly tests?: boolean;
27
35
  }
28
36
  export declare function planTypecheckExecution(options: PlanTypecheckExecutionOptions): TypecheckExecutionPlan;
29
37
  export declare function formatResolvedScopesLine(scopes: readonly TypecheckScope[]): string;
@@ -3,6 +3,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { getPackageScript, readPackageJson, isRecursiveWpScript } from "#cli/package-scripts.js";
4
4
  import { getWorkspacePackages } from "#quality-engine/target-resolver.js";
5
5
  import { getManagedRunner, resolveEffectiveRunnerRoot } from "#tool-runtime";
6
+ const TEST_TSCONFIG_FILENAME = "tsconfig.test.json";
6
7
  const GENERATED_ROOT_PREFIXES = [".webpresso/generated"];
7
8
  const OUTPUT_SEGMENTS = new Set(["coverage", "dist", "node_modules"]);
8
9
  export function planTypecheckExecution(options) {
@@ -14,6 +15,17 @@ export function planTypecheckExecution(options) {
14
15
  if (files.length > 0 && packages.length > 0) {
15
16
  throw new Error("Cannot use both --file and --package for typecheck targeting.");
16
17
  }
18
+ if (options.tests) {
19
+ if (files.length > 0 || packages.length > 0) {
20
+ throw new Error("Cannot use --tests together with --file or --package.");
21
+ }
22
+ return {
23
+ repoRoot,
24
+ mode: "tests",
25
+ commands: [buildTestsCommand(defaultScopeRoot, options.pretty)],
26
+ resolvedScopes: [],
27
+ };
28
+ }
17
29
  if (packages.length > 0) {
18
30
  const scopes = resolvePackageScopes(repoRoot, realRepoRoot, packages);
19
31
  return {
@@ -63,6 +75,25 @@ function buildUntargetedCommand(cwd, pretty = false) {
63
75
  cwd,
64
76
  };
65
77
  }
78
+ function buildTestsCommand(cwd, pretty = false) {
79
+ const testTsconfigPath = join(cwd, TEST_TSCONFIG_FILENAME);
80
+ if (!existsSync(testTsconfigPath)) {
81
+ throw new Error(`--tests requires ${TEST_TSCONFIG_FILENAME} at the repo root (not found at ${testTsconfigPath}).`);
82
+ }
83
+ const resolution = getManagedRunner("tsc");
84
+ return {
85
+ scope: null,
86
+ command: resolution.command,
87
+ args: [
88
+ ...resolution.args,
89
+ "--noEmit",
90
+ "-p",
91
+ TEST_TSCONFIG_FILENAME,
92
+ ...(pretty ? [] : ["--pretty", "false"]),
93
+ ],
94
+ cwd,
95
+ };
96
+ }
66
97
  function buildTargetedCommand(scope, pretty = false) {
67
98
  const checkTypesScript = getPackageScript(scope.root, "check-types");
68
99
  if (checkTypesScript && !isRecursiveWpScript(checkTypesScript, "typecheck")) {
@@ -0,0 +1,50 @@
1
+ import { type WorktreeInventory } from "./identity.js";
2
+ export type OrphanClassification = "alive" | "orphan" | "broken" | "unrecognized";
3
+ export interface OrphanScanCandidate {
4
+ readonly path: string;
5
+ readonly repoNamespace: string;
6
+ }
7
+ export interface OrphanScanRow extends OrphanScanCandidate {
8
+ readonly classification: OrphanClassification;
9
+ readonly dirty: boolean | "unknown";
10
+ /**
11
+ * Recursive size, computed ONLY for the final `orphan` classification.
12
+ * `alive` worktrees are actively worked on (often large `node_modules`
13
+ * trees) and are never removable, and `broken`/`unrecognized` rows need
14
+ * manual review regardless of size, so walking their full tree would burn
15
+ * significant time for no decision-relevant signal. `"unknown"` also
16
+ * covers a measurement failure/timeout on an orphan candidate.
17
+ */
18
+ readonly sizeBytes: number | "unknown";
19
+ readonly removable: boolean;
20
+ readonly reason: string;
21
+ }
22
+ export interface OrphanClassifyDeps {
23
+ readonly resolveRepoRoot: (candidate: OrphanScanCandidate) => string | undefined;
24
+ readonly repoRootExists: (repoRoot: string) => boolean;
25
+ readonly buildInventory: (repoRoot: string) => WorktreeInventory;
26
+ readonly probeDirty: (path: string) => boolean | "unknown";
27
+ readonly measureSize: (path: string) => number;
28
+ }
29
+ /** Classify a single on-disk candidate. Pure aside from the injected deps. */
30
+ export declare function classifyOrphanCandidate(candidate: OrphanScanCandidate, deps: OrphanClassifyDeps): OrphanScanRow;
31
+ /** Namespace-keyed lookup of a known-live `repoRoot`, first-registered-wins. */
32
+ export declare function buildRegistryRepoRootMap(entries: readonly {
33
+ readonly repoNamespace: string;
34
+ readonly repoRoot: string;
35
+ }[]): ReadonlyMap<string, string>;
36
+ /**
37
+ * Fallback primary-repo resolver for candidates the registry never recorded:
38
+ * a linked worktree's `.git` is a file containing `gitdir: <repoRoot>/.git/
39
+ * worktrees/<name>`. Returns `undefined` when unreadable or unparseable
40
+ * (e.g. `.git` is a directory, or the pointer target was already reaped).
41
+ */
42
+ export declare function resolveRepoRootFromGitPointer(path: string, readGitPointer: (gitPath: string) => string | undefined): string | undefined;
43
+ export interface DiscoverOrphanCandidatesOptions {
44
+ readonly repoNamespaceFilter?: string;
45
+ readonly dirExists?: (path: string) => boolean;
46
+ }
47
+ /** Walk every known managed-worktree root and collect worktree-shaped leaf dirs. */
48
+ export declare function discoverOrphanScanCandidates(roots: readonly string[], options?: DiscoverOrphanCandidatesOptions): OrphanScanCandidate[];
49
+ /** Bounded recursive directory size, symlinks excluded, unreadable entries skipped. */
50
+ export declare function measureDirectorySizeBytes(path: string): number;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Filesystem-level orphan scan for `wp worktree prune --orphans`.
3
+ *
4
+ * Complements the existing registry-only `wp worktree prune --all`: that
5
+ * command only drops stale bookkeeping rows from `registry.json`. This module
6
+ * instead walks the physical managed-worktree roots
7
+ * (`~/.webpresso/worktrees`, legacy `~/.agent/worktrees`) directly, because a
8
+ * large share of stale directories on disk were never registered at all
9
+ * (crashed sessions, `gh pr merge --delete-branch` deregistering the git
10
+ * worktree out from under the directory, manual `rm` of only part of a
11
+ * worktree). Git metadata and the registry can both be perfectly clean while
12
+ * the `~/.webpresso` tree still accumulates dozens of dead directories.
13
+ *
14
+ * Classification (see `catalog blueprint worktree-prune-stale-dirs`):
15
+ *
16
+ * - `alive` — path is listed by `git worktree list` for its resolved
17
+ * primary repo. Never removable.
18
+ * - `orphan` — a resolvable, existing primary repo's `git worktree
19
+ * list` output does NOT include this path. Removable only
20
+ * when a `git status --porcelain` probe inside the
21
+ * directory succeeds AND reports no changes.
22
+ * - `broken` — a primary repo pointer was resolved, but the target
23
+ * repo itself is missing on disk, or its `git worktree
24
+ * list` probe failed outright (bounded, non-retrying).
25
+ * Never removable — the directory's true state cannot be
26
+ * proven.
27
+ * - `unrecognized` — no primary repo pointer could be resolved at all
28
+ * (neither `registry.json` nor the directory's own
29
+ * `.git` file yields one), or the directory's `.git`
30
+ * pointer resolves but `git status --porcelain` cannot
31
+ * even run inside it (not a functioning checkout).
32
+ * Reported for manual review; never removable.
33
+ */
34
+ import { readdirSync, statSync } from "node:fs";
35
+ import { join, relative, sep } from "node:path";
36
+ import { canonicalizeWorktreePath } from "./identity.js";
37
+ function measureSizeSafely(path, deps) {
38
+ try {
39
+ return deps.measureSize(path);
40
+ }
41
+ catch {
42
+ return "unknown";
43
+ }
44
+ }
45
+ function finalizeRow(candidate, classification, reason, deps) {
46
+ if (classification !== "orphan") {
47
+ return {
48
+ ...candidate,
49
+ classification,
50
+ dirty: "unknown",
51
+ sizeBytes: "unknown",
52
+ removable: false,
53
+ reason,
54
+ };
55
+ }
56
+ const dirty = deps.probeDirty(candidate.path);
57
+ if (dirty === "unknown") {
58
+ return {
59
+ ...candidate,
60
+ classification: "unrecognized",
61
+ dirty,
62
+ sizeBytes: "unknown",
63
+ removable: false,
64
+ reason: "git status probe failed — not a functioning git checkout",
65
+ };
66
+ }
67
+ // Size is decision-relevant only once a row is a confirmed orphan, so it is
68
+ // measured here — never for alive/broken/unrecognized rows above.
69
+ const sizeBytes = measureSizeSafely(candidate.path, deps);
70
+ return {
71
+ ...candidate,
72
+ classification,
73
+ dirty,
74
+ sizeBytes,
75
+ removable: dirty === false,
76
+ reason: dirty ? "uncommitted/untracked changes present" : reason,
77
+ };
78
+ }
79
+ /** Classify a single on-disk candidate. Pure aside from the injected deps. */
80
+ export function classifyOrphanCandidate(candidate, deps) {
81
+ const repoRoot = deps.resolveRepoRoot(candidate);
82
+ if (!repoRoot) {
83
+ return finalizeRow(candidate, "unrecognized", "no resolvable primary repo pointer", deps);
84
+ }
85
+ if (!deps.repoRootExists(repoRoot)) {
86
+ return finalizeRow(candidate, "broken", `target repo missing: ${repoRoot}`, deps);
87
+ }
88
+ const inventory = deps.buildInventory(repoRoot);
89
+ if (!inventory.complete) {
90
+ return finalizeRow(candidate, "broken", "git worktree list unavailable for target repo", deps);
91
+ }
92
+ const canonical = canonicalizeWorktreePath(candidate.path);
93
+ if (inventory.canonicalPaths.has(canonical)) {
94
+ return finalizeRow(candidate, "alive", "present in git worktree list", deps);
95
+ }
96
+ return finalizeRow(candidate, "orphan", "absent from git worktree list", deps);
97
+ }
98
+ /** Namespace-keyed lookup of a known-live `repoRoot`, first-registered-wins. */
99
+ export function buildRegistryRepoRootMap(entries) {
100
+ const map = new Map();
101
+ for (const entry of entries) {
102
+ if (!map.has(entry.repoNamespace))
103
+ map.set(entry.repoNamespace, entry.repoRoot);
104
+ }
105
+ return map;
106
+ }
107
+ const WORKTREES_GITDIR_MARKER = "/.git/worktrees/";
108
+ /**
109
+ * Fallback primary-repo resolver for candidates the registry never recorded:
110
+ * a linked worktree's `.git` is a file containing `gitdir: <repoRoot>/.git/
111
+ * worktrees/<name>`. Returns `undefined` when unreadable or unparseable
112
+ * (e.g. `.git` is a directory, or the pointer target was already reaped).
113
+ */
114
+ export function resolveRepoRootFromGitPointer(path, readGitPointer) {
115
+ const content = readGitPointer(join(path, ".git"));
116
+ if (!content)
117
+ return undefined;
118
+ const match = /^gitdir:\s*(.+)$/mu.exec(content.trim());
119
+ const gitdir = match?.[1]?.trim();
120
+ if (!gitdir)
121
+ return undefined;
122
+ const markerIndex = gitdir.lastIndexOf(WORKTREES_GITDIR_MARKER);
123
+ if (markerIndex === -1)
124
+ return undefined;
125
+ return gitdir.slice(0, markerIndex);
126
+ }
127
+ const MAX_SCAN_DEPTH = 8;
128
+ const SKIP_DIR_NAMES = new Set([".git", "node_modules"]);
129
+ function repoNamespaceFromPath(root, path) {
130
+ const segments = relative(root, path).split(sep).filter(Boolean);
131
+ const reposIndex = segments.indexOf("repos");
132
+ if (reposIndex === -1)
133
+ return undefined;
134
+ return segments[reposIndex + 1];
135
+ }
136
+ function walkForCandidates(root, dir, depth, out) {
137
+ if (depth > MAX_SCAN_DEPTH)
138
+ return;
139
+ let entries;
140
+ try {
141
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ if (entries.some((entry) => entry.name === ".git")) {
147
+ const repoNamespace = repoNamespaceFromPath(root, dir);
148
+ if (repoNamespace)
149
+ out.push({ path: dir, repoNamespace });
150
+ return; // never descend into a worktree checkout's own contents
151
+ }
152
+ for (const entry of entries) {
153
+ if (!entry.isDirectory() || SKIP_DIR_NAMES.has(entry.name))
154
+ continue;
155
+ walkForCandidates(root, join(dir, entry.name), depth + 1, out);
156
+ }
157
+ }
158
+ /** Walk every known managed-worktree root and collect worktree-shaped leaf dirs. */
159
+ export function discoverOrphanScanCandidates(roots, options = {}) {
160
+ const dirExists = options.dirExists ??
161
+ ((path) => {
162
+ try {
163
+ return statSync(path).isDirectory();
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ });
169
+ const out = [];
170
+ for (const root of roots) {
171
+ if (!dirExists(root))
172
+ continue;
173
+ walkForCandidates(root, root, 0, out);
174
+ }
175
+ return options.repoNamespaceFilter
176
+ ? out.filter((candidate) => candidate.repoNamespace === options.repoNamespaceFilter)
177
+ : out;
178
+ }
179
+ /** Bounded recursive directory size, symlinks excluded, unreadable entries skipped. */
180
+ export function measureDirectorySizeBytes(path) {
181
+ let total = 0;
182
+ const stack = [path];
183
+ while (stack.length > 0) {
184
+ const current = stack.pop();
185
+ if (current === undefined)
186
+ break;
187
+ let entries;
188
+ try {
189
+ entries = readdirSync(current, { withFileTypes: true, encoding: "utf8" });
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ for (const entry of entries) {
195
+ if (entry.isSymbolicLink())
196
+ continue;
197
+ const full = join(current, entry.name);
198
+ if (entry.isDirectory()) {
199
+ stack.push(full);
200
+ continue;
201
+ }
202
+ try {
203
+ total += statSync(full).size;
204
+ }
205
+ catch {
206
+ // unreadable file: skip rather than fail the whole scan
207
+ }
208
+ }
209
+ }
210
+ return total;
211
+ }