@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
@@ -23,24 +23,34 @@ function frame(hash, label, value) {
23
23
  hash.update(`${label}\0${bytes.byteLength}\0`, "utf8");
24
24
  hash.update(bytes);
25
25
  }
26
- function worktreeFingerprint(cwd) {
26
+ // Fingerprint over the filtered line set `status()` produces below, minus
27
+ // pure gitignored (`!!`) entries, so the two contamination checks
28
+ // (`changes()`'s status() diff and the caller worktree fingerprint) cannot
29
+ // drift apart on what counts as "source" content while still tolerating
30
+ // expected runtime bookkeeping.
31
+ //
32
+ // Before the status()-alignment fix, the fingerprint hashed the raw
33
+ // unfiltered `git status` output (without `--ignored`), so anything status()
34
+ // already tolerates (the self-hosting bootstrap's idempotent `package.json`
35
+ // trailing-whitespace normalization, and non-source ignored paths under
36
+ // dist/.tshy/node_modules) could still flip the fingerprint and false-flag a
37
+ // clean caller worktree as contaminated.
38
+ //
39
+ // Reusing status() alone re-introduces a different false positive: the
40
+ // supervisor writes process records under gitignored `.webpresso/` (and similar
41
+ // runtime roots) during the run. Those appear as `!!` lines only when
42
+ // `--ignored` is on. Dropping `!!` here restores the prior fingerprint
43
+ // contract for ignored paths while keeping the package.json / non-`!!` filters
44
+ // shared with status(). Checkout-side status() still reports non-derived
45
+ // gitignored writes (e.g. a stray `.env`) on the disposable review tree.
46
+ function worktreeFingerprint(cwd, ignoredPaths = new Set()) {
27
47
  const hash = createHash("sha256");
28
- const porcelain = git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
29
- frame(hash, "porcelain", porcelain);
48
+ const lines = status(cwd, ignoredPaths).filter((line) => !line.startsWith("!!"));
49
+ frame(hash, "status-lines", lines.join("\n"));
30
50
  frame(hash, "staged", gitBytes(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "HEAD", "--"]));
31
- const records = porcelain.split("\0").filter(Boolean);
32
51
  const dirtyPaths = new Set();
33
- for (let index = 0; index < records.length; index += 1) {
34
- const record = records[index] ?? "";
35
- dirtyPaths.add(record.slice(3));
36
- if (record.slice(0, 2).match(/[RC]/u)) {
37
- const pairedPath = records[index + 1];
38
- if (pairedPath) {
39
- dirtyPaths.add(pairedPath);
40
- index += 1;
41
- }
42
- }
43
- }
52
+ for (const line of lines)
53
+ dirtyPaths.add(statusEntryPath(line));
44
54
  for (const relativePath of [...dirtyPaths].filter(Boolean).toSorted()) {
45
55
  const absolutePath = join(cwd, relativePath);
46
56
  frame(hash, "dirty-path", relativePath);
@@ -108,13 +118,44 @@ function isNormalizedPackageJsonModification(cwd, line) {
108
118
  return false;
109
119
  }
110
120
  }
121
+ // Single source of truth for turning a `git status --porcelain=v1` line into
122
+ // its relative path, shared by status()'s filter predicate and
123
+ // worktreeFingerprint()'s dirty-path hashing so both stay aligned by
124
+ // construction.
125
+ //
126
+ // Rename/copy lines use non-null porcelain form `XY ORIG -> DEST` (or quoted
127
+ // paths). The destination is the live path whose bytes must be fingerprinted;
128
+ // using the raw `ORIG -> DEST` tail makes lstat miss both sides and silently
129
+ // drops content from the hash (regression vs paired-record `-z` parsing).
130
+ function statusEntryPath(line) {
131
+ const tail = line.slice(3).replace(/\/$/u, "");
132
+ const renameSep = " -> ";
133
+ const sep = tail.lastIndexOf(renameSep);
134
+ const pathPart = sep === -1 ? tail : tail.slice(sep + renameSep.length);
135
+ return unquotePorcelainPath(pathPart);
136
+ }
137
+ /** Strip git porcelain double-quotes and reverse C-style escapes for a path. */
138
+ function unquotePorcelainPath(pathPart) {
139
+ if (!pathPart.startsWith('"') || !pathPart.endsWith('"') || pathPart.length < 2) {
140
+ return pathPart;
141
+ }
142
+ return pathPart.slice(1, -1).replace(/\\([\\"ntr])/gu, (_m, ch) => {
143
+ if (ch === "n")
144
+ return "\n";
145
+ if (ch === "t")
146
+ return "\t";
147
+ if (ch === "r")
148
+ return "\r";
149
+ return ch;
150
+ });
151
+ }
111
152
  function status(cwd, ignoredPaths = new Set()) {
112
153
  const output = git(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"]);
113
154
  return output
114
155
  .split(/\r?\n/u)
115
156
  .filter((line) => line.length > 0)
116
157
  .filter((line) => {
117
- const relativePath = line.slice(3).replace(/\/$/u, "");
158
+ const relativePath = statusEntryPath(line);
118
159
  if (ignoredPaths.has(relativePath))
119
160
  return false;
120
161
  if (line.startsWith("!!") && isNonSourceIgnoredPath(relativePath))
@@ -226,3 +267,7 @@ function projectCallerDependencies(repositoryRoot, checkoutRoot) {
226
267
  projected.add(dependencyPath);
227
268
  return projected;
228
269
  }
270
+ /** Test-only surface for porcelain path parsing. */
271
+ export const reviewCheckoutInternals = {
272
+ statusEntryPath,
273
+ };
@@ -123,6 +123,10 @@ export async function superviseReviewExecution(input, adapter, deps = {}) {
123
123
  let fallbackSelectionTimer;
124
124
  let pendingFallback;
125
125
  const seenFrontiers = new Set();
126
+ const droppedEventWarnings = [];
127
+ const recordDroppedProviderEvent = (reason) => {
128
+ droppedEventWarnings.push(reason);
129
+ };
126
130
  const decoder = new StringDecoder("utf8");
127
131
  const diagnosticDecoder = new StringDecoder("utf8");
128
132
  let buffered = "";
@@ -230,19 +234,41 @@ export async function superviseReviewExecution(input, adapter, deps = {}) {
230
234
  const processLine = (line) => {
231
235
  if (line.trim().length === 0 || status !== null)
232
236
  return;
233
- if (Buffer.byteLength(line, "utf8") > MAX_PROVIDER_EVENT_BYTES) {
234
- fail("protocol-unsupported", "provider-event-too-large");
235
- return;
236
- }
237
+ const oversized = Buffer.byteLength(line, "utf8") > MAX_PROVIDER_EVENT_BYTES;
237
238
  let value;
238
239
  try {
239
240
  value = JSON.parse(line);
240
241
  }
241
242
  catch {
243
+ // An oversized frame that also fails to parse carries no recoverable
244
+ // signal (a genuinely truncated/malformed line looks the same either
245
+ // way) -- drop it with a warning instead of failing a run that may
246
+ // still receive a complete, well-formed terminal verdict later. A
247
+ // normal-sized malformed line is still a protocol violation.
248
+ if (oversized) {
249
+ recordDroppedProviderEvent("provider-event-too-large-unparseable");
250
+ return;
251
+ }
242
252
  fail("protocol-unsupported", "invalid-json-event");
243
253
  return;
244
254
  }
245
255
  const event = adapter.parseEvent(value);
256
+ if (oversized && (event.kind === "terminal" || event.kind === "failure")) {
257
+ // Terminal/verdict and failure frames are load-bearing: an oversized
258
+ // one is treated as a protocol violation, same as before this fix.
259
+ fail("protocol-unsupported", "provider-event-too-large");
260
+ return;
261
+ }
262
+ if (oversized) {
263
+ // Non-terminal (progress/neutral/unsupported) oversized frames are
264
+ // dropped rather than failing the whole run -- a provider that echoes
265
+ // e.g. a large lockfile read in a progress frame must not discard an
266
+ // otherwise-complete review verdict. See catalog/agent/rules
267
+ // /no-timeout-as-fix.md: the byte bound stays in place, we just stop
268
+ // treating "one big frame" as fatal to the whole run.
269
+ recordDroppedProviderEvent(`provider-event-too-large-${event.kind}`);
270
+ return;
271
+ }
246
272
  if (event.kind === "unsupported") {
247
273
  fail("protocol-unsupported", "unknown-provider-event");
248
274
  return;
@@ -433,6 +459,7 @@ export async function superviseReviewExecution(input, adapter, deps = {}) {
433
459
  ...(limitWindow === undefined ? {} : { limitWindow }),
434
460
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
435
461
  summaryPath: artifacts.summaryPath,
462
+ ...(droppedEventWarnings.length > 0 ? { warnings: [...droppedEventWarnings] } : {}),
436
463
  };
437
464
  }
438
465
  function recordReviewProcessStart(reviewChild, processGroupId) {
@@ -81,4 +81,5 @@ export interface ReviewRunSummary {
81
81
  readonly limitWindow?: ReviewLimitWindow;
82
82
  readonly retryAfterMs?: number;
83
83
  readonly summaryPath: string;
84
+ readonly warnings?: readonly string[];
84
85
  }
@@ -43,9 +43,22 @@ export declare function createPlanSubjectDigestForContent(blueprintSlug: string,
43
43
  * `normalizePlanSubjectContent`.
44
44
  */
45
45
  export declare function legacyNormalizePlanSubjectContent(content: Buffer | string): Buffer;
46
+ /**
47
+ * Digest-compatibility variant for review events recorded after the lifecycle
48
+ * stripped-key extension but before the advisory `tags` strip. Verification
49
+ * accepts a match against this tier so existing plan approvals on tagged
50
+ * blueprints are not stranded by the tags-strip rollout.
51
+ */
52
+ export declare function preTagsStripNormalizePlanSubjectContent(content: Buffer | string): Buffer;
46
53
  export declare function createPlanSubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
47
54
  /** Plan subject digested with the pre-extension normalization (see above). */
48
55
  export declare function createLegacyPlanSubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
56
+ /** Plan subject digested without the advisory `tags` strip (pre-tags-strip tier). */
57
+ export declare function createPreTagsStripPlanSubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
58
+ /** Working-ledger digest for the pre-tags-strip compatibility tier. */
59
+ export declare function createPreTagsStripPlanSubjectDigestForContent(blueprintSlug: string, content: Buffer | string): string;
60
+ /** Working-ledger digest for the pre-extension legacy compatibility tier. */
61
+ export declare function createLegacyPlanSubjectDigestForContent(blueprintSlug: string, content: Buffer | string): string;
49
62
  export declare const reviewSubjectInternals: {
50
63
  isExcludedReviewEnvelope: typeof isExcludedReviewEnvelope;
51
64
  isReviewEvidencePath: typeof isReviewEvidencePath;
@@ -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;