@webpresso/agent-kit 3.3.2 → 3.3.4

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 (167) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/AGENTS.md.tpl +3 -5
  3. package/catalog/agent/rules/changeset-release.md +15 -4
  4. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  5. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  6. package/catalog/agent/rules/pre-implementation.md +36 -20
  7. package/catalog/agent/rules/rtk-routing.md +22 -16
  8. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  9. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  10. package/catalog/agent/skills/verify/SKILL.md +12 -12
  11. package/catalog/compose/admin-blocks-registry.json +659 -0
  12. package/dist/esm/audit/hook-surface.js +5 -1
  13. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  14. package/dist/esm/audit/repo-guardrails.js +71 -1
  15. package/dist/esm/blueprint/core/parser.js +40 -16
  16. package/dist/esm/blueprint/core/validation/criteria.js +2 -1
  17. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  18. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  19. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  20. package/dist/esm/blueprint/execution/metadata.js +8 -4
  21. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  22. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  23. package/dist/esm/blueprint/lifecycle/review-provenance.js +148 -40
  24. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  25. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  26. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  27. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  28. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  29. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  30. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  31. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  32. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  33. package/dist/esm/blueprint/trust/promotion.js +19 -19
  34. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  35. package/dist/esm/build/atomic-file-copy.js +44 -0
  36. package/dist/esm/build/cli-mcp-parity.js +3 -2
  37. package/dist/esm/build/package-manifest.js +7 -2
  38. package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
  39. package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
  40. package/dist/esm/cli/commands/audit-core.js +9 -1
  41. package/dist/esm/cli/commands/audit.js +16 -5
  42. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  43. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  44. package/dist/esm/cli/commands/compile.d.ts +13 -0
  45. package/dist/esm/cli/commands/compile.js +182 -62
  46. package/dist/esm/cli/commands/format.js +17 -1
  47. package/dist/esm/cli/commands/gain/index.js +10 -3
  48. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  49. package/dist/esm/cli/commands/init/index.js +29 -48
  50. package/dist/esm/cli/commands/init/mcp-spec.d.ts +54 -4
  51. package/dist/esm/cli/commands/init/mcp-spec.js +126 -22
  52. package/dist/esm/cli/commands/init/merge.js +30 -10
  53. package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
  54. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  55. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  56. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  57. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  58. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  59. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  60. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  61. package/dist/esm/cli/commands/package-manager.js +8 -2
  62. package/dist/esm/cli/commands/pr-help.js +1 -1
  63. package/dist/esm/cli/commands/pr.js +18 -4
  64. package/dist/esm/cli/commands/review.js +60 -6
  65. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  66. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  67. package/dist/esm/cli/commands/worktree/router.js +5 -2
  68. package/dist/esm/cli/direct-provider-launch.js +12 -4
  69. package/dist/esm/compiler/flatten.d.ts +10 -1
  70. package/dist/esm/compiler/flatten.js +14 -4
  71. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  72. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  73. package/dist/esm/compose/compose.d.ts +46 -0
  74. package/dist/esm/compose/compose.js +250 -0
  75. package/dist/esm/compose/escape-html.d.ts +5 -0
  76. package/dist/esm/compose/escape-html.js +12 -0
  77. package/dist/esm/compose/ops-report-render.d.ts +5 -0
  78. package/dist/esm/compose/ops-report-render.js +71 -0
  79. package/dist/esm/compose/ops-report-schema.d.ts +42 -0
  80. package/dist/esm/compose/ops-report-schema.js +60 -0
  81. package/dist/esm/compose/preview.d.ts +14 -0
  82. package/dist/esm/compose/preview.js +32 -0
  83. package/dist/esm/compose/registry-resolve.d.ts +64 -0
  84. package/dist/esm/compose/registry-resolve.js +200 -0
  85. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  86. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  87. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  88. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  89. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  90. package/dist/esm/config/oxlint/oxlintrc.js +18 -21
  91. package/dist/esm/config/oxlint/path-roles.js +53 -0
  92. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  93. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  94. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  95. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  96. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  97. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  98. package/dist/esm/daemon/domains/launch.js +30 -1
  99. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  100. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  101. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  102. package/dist/esm/daemon/handlers/product.js +10 -1
  103. package/dist/esm/daemon/methods.d.ts +21 -1
  104. package/dist/esm/daemon/methods.js +20 -0
  105. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  106. package/dist/esm/daemon/protocol/contract.js +38 -0
  107. package/dist/esm/daemon/server.js +7 -0
  108. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  109. package/dist/esm/errors/wp-error.js +21 -2
  110. package/dist/esm/git/changed-files.d.ts +20 -0
  111. package/dist/esm/git/changed-files.js +63 -0
  112. package/dist/esm/hooks/doctor.d.ts +11 -0
  113. package/dist/esm/hooks/doctor.js +114 -45
  114. package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
  115. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  116. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  117. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  118. package/dist/esm/hooks/status/index.js +1 -1
  119. package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
  120. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  121. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  122. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  123. package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
  124. package/dist/esm/mcp/blueprint/registration.js +1 -1
  125. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  126. package/dist/esm/mcp/tools/_names.js +4 -0
  127. package/dist/esm/mcp/tools/_registry.js +8 -0
  128. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  129. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  130. package/dist/esm/mcp/tools/gain.js +28 -2
  131. package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
  132. package/dist/esm/mcp/tools/release-progress.js +110 -0
  133. package/dist/esm/mcp/tools/worktree.js +65 -3
  134. package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
  135. package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
  136. package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
  137. package/dist/esm/mcp/tools/wp-ui-compose.js +96 -0
  138. package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
  139. package/dist/esm/mcp/tools/wp-ui-preview.js +59 -0
  140. package/dist/esm/package.json +2 -0
  141. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  142. package/dist/esm/review/lifecycle-writers.js +330 -0
  143. package/dist/esm/review/subject.js +66 -32
  144. package/dist/esm/status/snapshot.d.ts +5 -0
  145. package/dist/esm/status/snapshot.js +27 -2
  146. package/dist/esm/test/shard-durations.json +0 -1
  147. package/dist/esm/test-helpers/global-setup.js +38 -3
  148. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  149. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  150. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  151. package/dist/esm/utils/package-root.d.ts +32 -0
  152. package/dist/esm/utils/package-root.js +82 -0
  153. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  154. package/dist/esm/worktrees/git-metadata.js +48 -0
  155. package/package.json +27 -21
  156. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  157. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  158. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  159. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  160. package/dist/esm/config/oxlint/index.d.ts +0 -22
  161. package/dist/esm/config/oxlint/index.js +0 -28
  162. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  163. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  164. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  165. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  166. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  167. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -2,15 +2,29 @@ import { PR_WAIT_UNTIL_VALUES, runPrWait } from "#mcp/tools/pr-wait.js";
2
2
  import { buildStatusSnapshot, formatStatusSnapshot } from "#status/snapshot.js";
3
3
  // PR help text lives in ./pr-help.ts (dependency-free) so the `wp pr --help`
4
4
  // fast path can render it without importing this heavy command module.
5
- function parsePositiveInteger(value, name) {
5
+ // Mirrors the hard cap `readonlyOpsBaseSchema` enforces downstream for `pr
6
+ // wait` (src/mcp/tools/_readonly-ops.ts). `pr status` shares the same
7
+ // `--timeout-ms` flag but reaches `buildStatusSnapshot` directly, which has
8
+ // no schema of its own, so an over-cap value was silently accepted with no
9
+ // error and no clamp. Validating here makes both subcommands fail the same
10
+ // way, with a clear message, before any network probe starts, instead of
11
+ // relying on a downstream schema that not every code path shares.
12
+ const MAX_TIMEOUT_MS = 300_000;
13
+ function parsePositiveInteger(value, name, max) {
6
14
  if (value === undefined)
7
15
  return undefined;
8
16
  const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
9
17
  if (!Number.isFinite(parsed) || parsed <= 0) {
10
18
  throw new Error(`${name} must be a positive integer`);
11
19
  }
20
+ if (max !== undefined && parsed > max) {
21
+ throw new Error(`${name} must not exceed ${max}`);
22
+ }
12
23
  return parsed;
13
24
  }
25
+ function parseTimeoutMs(value) {
26
+ return parsePositiveInteger(value, "--timeout-ms", MAX_TIMEOUT_MS);
27
+ }
14
28
  function normalizeUntil(value) {
15
29
  if (value === undefined)
16
30
  return undefined;
@@ -26,7 +40,7 @@ export function registerPrCommand(cli) {
26
40
  .option("--branch <name>", "Branch selector")
27
41
  .option("--pr <number-or-url>", "Explicit PR selector")
28
42
  .option("--until <target>", "ready | checks-terminal | checks-green | merged")
29
- .option("--timeout-ms <ms>", "Overall deadline; for ready, per-probe timeout only")
43
+ .option("--timeout-ms <ms>", "Overall deadline (max 300000ms / 5min); for ready, per-probe timeout only")
30
44
  .option("--poll-interval-ms <ms>", "Poll interval")
31
45
  .option("--include-checks", "Include final check snapshot", { default: true })
32
46
  .option("--max-output-bytes <n>", "Per-command output budget")
@@ -41,7 +55,7 @@ export function registerPrCommand(cli) {
41
55
  includePr: true,
42
56
  maxOutputBytes: parsePositiveInteger(options.maxOutputBytes, "--max-output-bytes"),
43
57
  pr: options.pr === undefined ? undefined : String(options.pr),
44
- timeoutMs: parsePositiveInteger(options.timeoutMs, "--timeout-ms"),
58
+ timeoutMs: parseTimeoutMs(options.timeoutMs),
45
59
  });
46
60
  console.log(options.json ? JSON.stringify(snapshot, null, 2) : formatStatusSnapshot(snapshot));
47
61
  return options.requireReady && snapshot.status !== "ready" ? 1 : 0;
@@ -57,7 +71,7 @@ export function registerPrCommand(cli) {
57
71
  maxOutputBytes: parsePositiveInteger(options.maxOutputBytes, "--max-output-bytes"),
58
72
  pollIntervalMs: parsePositiveInteger(options.pollIntervalMs, "--poll-interval-ms"),
59
73
  pr: options.pr === undefined ? undefined : String(options.pr),
60
- timeoutMs: parsePositiveInteger(options.timeoutMs, "--timeout-ms"),
74
+ timeoutMs: parseTimeoutMs(options.timeoutMs),
61
75
  until,
62
76
  });
63
77
  console.log(JSON.stringify(payload, null, 2));
@@ -1463,6 +1463,35 @@ function gateFailureFromSummary(summary) {
1463
1463
  ...(summary.retryAfterMs === undefined ? {} : { retryAfterMs: summary.retryAfterMs }),
1464
1464
  };
1465
1465
  }
1466
+ // `providerErrorDetail`'s doc comment promises "sanitized, length-bounded"
1467
+ // text; a checkout-creation error's message/cause chain is target/environment
1468
+ // text (pnpm/git stderr), not attacker-shaped, but is still capped here so
1469
+ // the invariant holds regardless.
1470
+ const MAX_PROVIDER_ERROR_DETAIL_LENGTH = 2000;
1471
+ // Walks an `Error.cause` chain into a single human-readable line. Used to
1472
+ // surface the real reason a review checkout could not be created instead of
1473
+ // discarding it behind a fixed string (see the bare `catch` this replaces in
1474
+ // `invokeProvider`). Bounded depth + a `seen` set guard against a pathological
1475
+ // or cyclic cause chain.
1476
+ function formatErrorWithCauseChain(error, maxDepth = 5) {
1477
+ const parts = [];
1478
+ const seen = new Set();
1479
+ let current = error;
1480
+ for (let depth = 0; depth < maxDepth; depth += 1) {
1481
+ if (current === undefined || current === null || seen.has(current))
1482
+ break;
1483
+ seen.add(current);
1484
+ if (current instanceof Error) {
1485
+ parts.push(current.message);
1486
+ current = current.cause;
1487
+ }
1488
+ else {
1489
+ parts.push(String(current));
1490
+ break;
1491
+ }
1492
+ }
1493
+ return parts.join(" -> caused by: ");
1494
+ }
1466
1495
  export async function invokeProvider(input, executeReview = runReviewExecution, runCatalogCommand = runCommand, createTargetCheckout = createReviewTargetCheckout,
1467
1496
  // A thunk (not a checkout instance) so callers that never reach a real
1468
1497
  // provider attempt — injected fakes included — never pay the checkout's
@@ -1475,19 +1504,34 @@ getSharedCheckout) {
1475
1504
  checkout = existingCheckout ?? createTargetCheckout(input.projectRoot, input.targetSha);
1476
1505
  checkout.beginAttempt();
1477
1506
  }
1478
- catch {
1507
+ catch (error) {
1508
+ // Bind + chain via Error.cause (ES2022) rather than discarding the real
1509
+ // failure — this exact swallow previously hid a genuine supply-chain
1510
+ // signal (`ERR_PNPM_TRUST_DOWNGRADE`) behind a fixed string for a full
1511
+ // day. See catalog/agent/rules/ts-coding-conventions.md.
1512
+ const isolationFailure = new Error("Unable to create the disposable review checkout; review was not started", { cause: error });
1513
+ let cleanupError;
1479
1514
  if (ownsCheckout) {
1480
1515
  try {
1481
1516
  checkout?.cleanup();
1482
1517
  }
1483
- catch {
1484
- // The attempt already fails closed as an isolation failure.
1518
+ catch (cleanupFailure) {
1519
+ // Cleanup failing must not mask the primary isolation failure above;
1520
+ // capture it as secondary diagnostic context instead of discarding it.
1521
+ cleanupError = cleanupFailure;
1485
1522
  }
1486
1523
  }
1487
1524
  const requestedModel = input.model ?? defaultReviewModel(input.provider) ?? "unresolved";
1488
1525
  const model = resolveReviewModel(input.provider, requestedModel);
1526
+ // Chain from the original `error`, not `isolationFailure` — the latter's
1527
+ // own message is already the fixed prefix below, so chaining from it
1528
+ // would print that fixed sentence twice.
1529
+ const providerErrorDetail = (formatErrorWithCauseChain(error) +
1530
+ (cleanupError === undefined
1531
+ ? ""
1532
+ : ` | cleanup after isolation failure also failed: ${formatErrorWithCauseChain(cleanupError)}`)).slice(0, MAX_PROVIDER_ERROR_DETAIL_LENGTH);
1489
1533
  return {
1490
- output: "Unable to create the disposable review checkout; review was not started.",
1534
+ output: `${isolationFailure.message}: ${providerErrorDetail}`,
1491
1535
  model,
1492
1536
  reviewer: input.provider === "opencode" && model !== "unresolved"
1493
1537
  ? reviewerFamily(model)
@@ -1497,6 +1541,7 @@ getSharedCheckout) {
1497
1541
  verdict: "no-verdict",
1498
1542
  terminalClassification: "review-isolation-failure",
1499
1543
  failureCode: "review-isolation-failure",
1544
+ providerErrorDetail,
1500
1545
  };
1501
1546
  }
1502
1547
  try {
@@ -2127,21 +2172,30 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2127
2172
  // never need one (plan-purpose reuse of an existing approval, terminal
2128
2173
  // rejections, exhausted scope). A setup failure is remembered so provider
2129
2174
  // attempts still surface the same isolation-failure evidence.
2175
+ // Captures the real failure the first time checkoutFactory is
2176
+ // attempted this run, so the re-throw below (reached whenever a later
2177
+ // caller reuses the memoized failure) can chain to it via Error.cause
2178
+ // instead of discarding it — see ts-coding-conventions.md "never
2179
+ // swallow an error".
2180
+ let checkoutSetupError;
2130
2181
  const ensureSharedCheckout = () => {
2131
2182
  if (sharedCheckout !== undefined || checkoutSetupFailed)
2132
2183
  return sharedCheckout;
2133
2184
  try {
2134
2185
  sharedCheckout = checkoutFactory(projectRoot, targetSha);
2135
2186
  }
2136
- catch {
2187
+ catch (error) {
2137
2188
  checkoutSetupFailed = true;
2189
+ checkoutSetupError = error;
2138
2190
  }
2139
2191
  return sharedCheckout;
2140
2192
  };
2141
2193
  const createCheckoutForAttempt = () => {
2142
2194
  // Only reachable when ensureSharedCheckout() already failed — a live
2143
2195
  // shared checkout is always passed to the attempt as existingCheckout.
2144
- throw new Error("review checkout setup failed");
2196
+ throw new Error("review checkout setup failed", {
2197
+ cause: checkoutSetupError,
2198
+ });
2145
2199
  };
2146
2200
  let sharedCheckoutCleaned = false;
2147
2201
  const cleanupSharedCheckout = () => {
@@ -8,6 +8,18 @@ export interface WorktreeCommandOptions {
8
8
  prefix?: string;
9
9
  dryRun?: boolean;
10
10
  force?: boolean;
11
+ /**
12
+ * Bypass the "primary must be on the expected branch" merge-cleanup
13
+ * refusal by switching a CLEAN primary checkout to that branch. Never
14
+ * bypasses the "primary is dirty" refusal — a dirty primary always
15
+ * refuses, regardless of this flag (see `decideMergeCleanup`).
16
+ */
17
+ switchPrimary?: boolean;
18
+ /**
19
+ * @deprecated Renamed to `switchPrimary`. Kept as an accepted alias only
20
+ * (mapped from the CLI's legacy `--stash-primary` flag) — it no longer
21
+ * stashes anything; a dirty primary always refuses regardless of this flag.
22
+ */
11
23
  stashPrimary?: boolean;
12
24
  noSetup?: boolean;
13
25
  /** Dependency hydration for `new` only: install-locked (default) or reconcile. */
@@ -41,7 +53,6 @@ export interface MergeCleanupPlan {
41
53
  readonly removeArgs: readonly string[];
42
54
  readonly fetchArgs: readonly string[];
43
55
  readonly mergeArgs: readonly string[];
44
- readonly stashArgs?: readonly string[];
45
56
  readonly switchArgs?: readonly string[];
46
57
  readonly preservedPrimaryBranch?: string;
47
58
  }
@@ -54,7 +65,18 @@ export interface MergeCleanupDecisionInput {
54
65
  readonly currentBranch: string;
55
66
  readonly repoDirty: boolean;
56
67
  readonly targetDirty: boolean;
57
- readonly stashPrimary?: boolean;
68
+ /**
69
+ * Bypasses the wrong-branch refusal for a CLEAN primary only. A dirty
70
+ * primary always refuses — see `decideMergeCleanup` — this never bypasses
71
+ * that refusal, and merge-cleanup never stashes primary state.
72
+ */
73
+ readonly switchPrimary?: boolean;
74
+ /**
75
+ * When the primary is dirty, optional porcelain -z lists the offending
76
+ * paths in the refusal message. Omitted messages stay generic (e.g. for
77
+ * unit tests that don't care about message content).
78
+ */
79
+ readonly repoPorcelainZ?: string;
58
80
  /**
59
81
  * When target is dirty, optional porcelain -z + slug enrich the refusal message
60
82
  * (scaffold-only vs other dirt). Omitted messages stay generic for unit tests.
@@ -15,9 +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
+ import { formatBoundedPaths, formatTargetDirtyRefusal, parsePorcelainV1Z, } from "#worktrees/owner-dirt.js";
19
19
  import { buildWorktreeInventory, canonicalizeWorktreePath, classifyMembership, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
20
20
  import { findRegistryCandidatesByPath, planStaleWorktreeRegistryPrune, readWorktreeRegistry, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
21
+ import { isStaleWorktreeRegistration, pruneStaleGitWorktreeMetadata, } from "#worktrees/git-metadata.js";
21
22
  import { buildRegistryRepoRootMap, classifyOrphanCandidate, discoverOrphanScanCandidates, resolveRepoRootFromGitPointer, } from "#worktrees/orphan-scan.js";
22
23
  function parseDepsOption(value) {
23
24
  if (value === undefined || value === null || value === "")
@@ -165,9 +166,24 @@ export function planMergeCleanup(targetPath, baseRef) {
165
166
  mergeArgs: ["merge", "--ff-only", baseRef],
166
167
  };
167
168
  }
168
- function primaryStashMessage(input) {
169
- const current = input.currentBranch || "(detached)";
170
- return `wp worktree merge-cleanup preserve primary state from ${current} before syncing ${input.expectedPrimaryBranch} and removing ${basename(input.targetPath)}`;
169
+ /**
170
+ * Refusal for a DIRTY primary checkout. Reuses the same low-level porcelain
171
+ * parser and bounded-list formatter `formatTargetDirtyRefusal` uses for the
172
+ * target worktree (`#worktrees/owner-dirt.js`) — this is NOT a classifier:
173
+ * it never decides whose dirt it is or whether it's "safe" to move. A dirty
174
+ * primary always refuses; this only lists the paths so the human knows what
175
+ * blocked the command.
176
+ */
177
+ function formatPrimaryDirtyRefusal(repoRoot, porcelainZ) {
178
+ const paths = [
179
+ ...new Set(parsePorcelainV1Z(porcelainZ).flatMap((record) => record.paths.filter((path) => path.length > 0))),
180
+ ].toSorted();
181
+ return [
182
+ `wp worktree merge-cleanup refused: primary checkout ${repoRoot} has uncommitted changes`,
183
+ "Paths:",
184
+ formatBoundedPaths(paths),
185
+ "fix: commit or `git stash` the listed paths yourself (or `wp blueprint abandon <slug> --confirm` if this is leftover blueprint draft scaffold), then re-run merge-cleanup",
186
+ ].join("\n");
171
187
  }
172
188
  export function decideMergeCleanup(input) {
173
189
  const entry = input.entries.find((candidate) => candidate.path === input.targetPath);
@@ -193,34 +209,18 @@ export function decideMergeCleanup(input) {
193
209
  throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} has uncommitted changes`);
194
210
  }
195
211
  if (input.repoDirty) {
196
- if (input.stashPrimary === true) {
197
- return {
198
- ...plan,
199
- stashArgs: [
200
- "stash",
201
- "push",
202
- "--include-untracked",
203
- "--message",
204
- primaryStashMessage({
205
- currentBranch: input.currentBranch,
206
- expectedPrimaryBranch: plan.expectedPrimaryBranch,
207
- targetPath: input.targetPath,
208
- }),
209
- ],
210
- ...(input.currentBranch !== plan.expectedPrimaryBranch
211
- ? {
212
- switchArgs: ["switch", plan.expectedPrimaryBranch],
213
- preservedPrimaryBranch: input.currentBranch || "(detached)",
214
- }
215
- : {
216
- preservedPrimaryBranch: input.currentBranch || "(detached)",
217
- }),
218
- };
219
- }
220
- throw new Error(`wp worktree merge-cleanup refused: primary checkout ${input.repoRoot} has uncommitted changes`);
212
+ // A dirty primary ALWAYS refuses -- unconditionally, regardless of
213
+ // --switch-primary/--stash-primary. merge-cleanup never stashes primary
214
+ // state; it never decides whose dirt it is or whether moving it is safe.
215
+ throw new Error(input.repoPorcelainZ !== undefined
216
+ ? formatPrimaryDirtyRefusal(input.repoRoot, input.repoPorcelainZ)
217
+ : `wp worktree merge-cleanup refused: primary checkout ${input.repoRoot} has uncommitted changes`);
221
218
  }
222
219
  if (input.currentBranch !== plan.expectedPrimaryBranch) {
223
- if (input.stashPrimary === true) {
220
+ // --switch-primary bypasses ONLY this refusal, and only for a CLEAN
221
+ // primary: switching a clean checkout to the expected branch is
222
+ // harmless (nothing to lose), unlike stashing dirty state.
223
+ if (input.switchPrimary === true) {
224
224
  return {
225
225
  ...plan,
226
226
  switchArgs: ["switch", plan.expectedPrimaryBranch],
@@ -456,10 +456,14 @@ function handleRefresh(opts) {
456
456
  async function handlePrune(opts) {
457
457
  if (!opts.all)
458
458
  throw new Error("Usage: wp worktree prune --all");
459
+ const repoRoot = resolveWorktreeRepoRoot(opts.repo ?? opts.cwd ?? process.cwd());
460
+ // Reconcile git's own admin records FIRST: a hand-deleted worktree directory
461
+ // leaves a record that still claims its branch, and it would otherwise report
462
+ // as live below and pin the matching registry entry in place too.
463
+ const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, listEntries(repoRoot));
459
464
  // Repository-scoped, three-state prune: only THIS repo's entries that are
460
465
  // proven stale against a COMPLETE git-live inventory are dropped; unknown,
461
466
  // other-repo, and live-legacy entries are retained.
462
- const repoRoot = resolveWorktreeRepoRoot(opts.repo ?? opts.cwd ?? process.cwd());
463
467
  const inventory = buildWorktreeInventory(repoRoot);
464
468
  const result = planStaleWorktreeRegistryPrune({
465
469
  inventory,
@@ -468,6 +472,13 @@ async function handlePrune(opts) {
468
472
  await removeCodexProjectTrusts(result.removed.map((entry) => entry.path));
469
473
  result.commit();
470
474
  console.log(`Pruned ${result.removed.length} stale managed registry entr${result.removed.length === 1 ? "y" : "ies"}.`);
475
+ if (gitPruned.error)
476
+ console.log(`warning: git worktree prune failed: ${gitPruned.error}`);
477
+ else {
478
+ console.log(`Cleared ${gitPruned.prunedPaths.length} stale git worktree registration${gitPruned.prunedPaths.length === 1 ? "" : "s"}.`);
479
+ for (const path of gitPruned.prunedPaths)
480
+ console.log(` ${path}`);
481
+ }
471
482
  for (const warning of result.warnings)
472
483
  console.log(`warning: ${warning}`);
473
484
  }
@@ -546,6 +557,23 @@ async function handleRemove(nameOrPath, opts) {
546
557
  const repoRoot = resolvePrimaryRoot(callerRoot, entries);
547
558
  const resolved = resolveWorktreePath(nameOrPath, entries);
548
559
  const entry = entries.find((candidate) => candidate.path === resolved);
560
+ // The directory is already gone (hand-deleted, or a failed earlier removal):
561
+ // `git worktree remove` cannot act on it and teardown scripts have no cwd, so
562
+ // reconcile git's leftover admin record instead of failing.
563
+ if (isStaleWorktreeRegistration(entry, resolved, existsSync)) {
564
+ // Staleness is not a bypass for the managed-root boundary, which is the
565
+ // same protection the MCP surface applies to registry-gone stale targets.
566
+ if (!isManagedWorktreePath(canonicalizeWorktreePath(resolved), resolveKnownManagedWorktreeRoots())) {
567
+ throw new Error(`Refusing to reconcile ${resolved}: its directory is gone but it is not under a known managed worktree root.`);
568
+ }
569
+ const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, entries);
570
+ if (gitPruned.error)
571
+ throw new Error(`git worktree prune failed: ${gitPruned.error}`);
572
+ await removeCodexProjectTrust(resolved);
573
+ removeWorktreeRegistryEntries(repoScopedPathPredicate(resolved, { authoritativeRepoKey: resolveRepoIdentity(repoRoot) }));
574
+ console.log(`Cleared stale worktree registration for ${resolved} (directory already gone).`);
575
+ return;
576
+ }
549
577
  if (!opts.force) {
550
578
  runWorktreeTeardown(readConfig(resolved) ?? readConfig(repoRoot), {
551
579
  worktreePath: resolved,
@@ -713,6 +741,22 @@ async function handleMergeCleanup(nameOrPath, opts) {
713
741
  })()
714
742
  : undefined;
715
743
  const targetBlueprintSlug = inferBlueprintSlugFromOwnerPath(resolved);
744
+ // `--switch-primary`/legacy `--stash-primary` only ever bypasses the
745
+ // clean-primary wrong-branch refusal; a dirty primary always refuses (see
746
+ // `decideMergeCleanup`), so merge-cleanup never stashes primary state.
747
+ const switchPrimary = opts.switchPrimary === true || opts.stashPrimary === true;
748
+ const repoDirty = isDirty(repoRoot);
749
+ const repoPorcelainZ = repoDirty
750
+ ? (() => {
751
+ const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no", "-z"], { cwd: repoRoot, encoding: "utf8" });
752
+ // Unlike the target-worktree probe, do NOT substitute a sentinel path
753
+ // on failure: a fake "(git-status-failed)" path in the refusal message
754
+ // would misrepresent what's actually dirty. Leaving this undefined
755
+ // falls back to the generic (path-free) refusal in decideMergeCleanup
756
+ // -- the refusal itself is unaffected either way.
757
+ return status.status === 0 ? String(status.stdout ?? "") : undefined;
758
+ })()
759
+ : undefined;
716
760
  const plan = decideMergeCleanup({
717
761
  repoRoot,
718
762
  targetPath: resolved,
@@ -720,16 +764,15 @@ async function handleMergeCleanup(nameOrPath, opts) {
720
764
  entries,
721
765
  registryEntries: scopedRegistryEntries,
722
766
  currentBranch: currentBranch(repoRoot),
723
- repoDirty: isDirty(repoRoot),
767
+ repoDirty,
724
768
  targetDirty,
725
- stashPrimary: opts.stashPrimary === true,
769
+ switchPrimary,
726
770
  ...(targetPorcelainZ !== undefined ? { targetPorcelainZ } : {}),
727
771
  ...(targetBlueprintSlug !== undefined ? { targetBlueprintSlug } : {}),
772
+ ...(repoPorcelainZ !== undefined ? { repoPorcelainZ } : {}),
728
773
  });
729
774
  if (opts.dryRun) {
730
775
  console.log("[dry-run] Would run merge cleanup:");
731
- if (plan.stashArgs)
732
- console.log(` stash: git ${plan.stashArgs.join(" ")}`);
733
776
  if (plan.switchArgs)
734
777
  console.log(` switch: git ${plan.switchArgs.join(" ")}`);
735
778
  console.log(` remove: git ${plan.removeArgs.join(" ")}`);
@@ -737,12 +780,6 @@ async function handleMergeCleanup(nameOrPath, opts) {
737
780
  console.log(` sync: git ${plan.mergeArgs.join(" ")}`);
738
781
  return;
739
782
  }
740
- if (plan.stashArgs) {
741
- const stashResult = spawnSync("git", [...plan.stashArgs], { cwd: repoRoot, stdio: "inherit" });
742
- if (stashResult.status !== 0) {
743
- throw new Error("git stash push failed");
744
- }
745
- }
746
783
  if (plan.switchArgs) {
747
784
  const switchResult = spawnSync("git", [...plan.switchArgs], {
748
785
  cwd: repoRoot,
@@ -775,13 +812,8 @@ async function handleMergeCleanup(nameOrPath, opts) {
775
812
  throw new Error(`git merge --ff-only ${baseRef} failed`);
776
813
  }
777
814
  console.log(`Removed worktree ${resolved} and fast-forwarded ${repoRoot} to ${baseRef}.`);
778
- if (plan.stashArgs) {
779
- const stashRef = spawnSync("git", ["stash", "list", "--format=%gd %gs", "-n", "1"], {
780
- cwd: repoRoot,
781
- encoding: "utf8",
782
- });
783
- const summary = String(stashRef.stdout ?? "").trim();
784
- console.log(`Preserved prior primary checkout state${plan.preservedPrimaryBranch ? ` from ${plan.preservedPrimaryBranch}` : ""} in ${summary || "git stash list"}.`);
815
+ if (plan.switchArgs && plan.preservedPrimaryBranch) {
816
+ console.log(`Switched primary checkout from ${plan.preservedPrimaryBranch} to ${plan.expectedPrimaryBranch}.`);
785
817
  }
786
818
  }
787
819
  export async function executeWorktreeSubcommand(subcommand, args, opts) {
@@ -27,7 +27,9 @@ const HELP_TEXT = [
27
27
  " --dry-run Print the resolved worktree target without writing",
28
28
  " --no-setup Skip configured worktree.setup scripts (new only)",
29
29
  " --force Skip configured teardown scripts and force removal",
30
- " --stash-primary Stash dirty primary state and switch to the expected primary branch before merge-cleanup",
30
+ " --switch-primary Switch a CLEAN primary checkout to the expected branch before merge-cleanup",
31
+ " (a DIRTY primary always refuses, with or without this flag; it is never stashed)",
32
+ " --stash-primary Deprecated alias for --switch-primary. No longer stashes anything.",
31
33
  " --orphans Filesystem-level orphan sweep for prune (see prune --orphans above)",
32
34
  " --repo-id <id> Scope prune --orphans to one repo namespace under repos/<id>",
33
35
  " --yes Alias for --force (prune --orphans only)",
@@ -44,7 +46,8 @@ export function registerWorktreeRouter(cli) {
44
46
  .option("--no-setup", "Skip configured worktree.setup scripts (new only)")
45
47
  .option("--deps <mode>", "Dependency hydration for new: install-locked (default, pnpm GVS path) or reconcile")
46
48
  .option("--force", "Skip configured teardown scripts and force removal (remove/merge-cleanup only)")
47
- .option("--stash-primary", "Stash dirty primary state and switch to the expected primary branch before merge-cleanup")
49
+ .option("--switch-primary", "Switch a CLEAN primary checkout to the expected branch before merge-cleanup (a dirty primary always refuses; never stashed)")
50
+ .option("--stash-primary", "Deprecated alias for --switch-primary. No longer stashes anything.")
48
51
  .option("--all", "Use global managed inventory where supported")
49
52
  .option("--repo <dir>", "Repo root for refresh/rebind (default: cwd)")
50
53
  .option("--orphans", "Filesystem-level orphan sweep for prune (dry-run unless --force/--yes)")
@@ -11,7 +11,7 @@ import { canonicalizeWorktreePath } from "#worktrees/identity.js";
11
11
  import { dashProviderOwnershipName, parseDashPermissionMode, } from "#cli/commands/dash/provider-capabilities.js";
12
12
  import { optionalToolCanonicalCommand } from "#cli/optional-tools.js";
13
13
  import { commandExists } from "#runtime/command-exists.js";
14
- import { ensureCodexProjectMcpServers, ensureCodexWebpressoMcp, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
14
+ import { ensureCodexProjectMcpServers, findInstalledCodexPluginMcpManifests, pruneLegacyCodexWebpressoMcp, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
15
15
  export class DirectProviderLaunchPreparationError extends Error {
16
16
  action;
17
17
  constructor(message, action) {
@@ -91,11 +91,19 @@ function validateDirectProviderLaunch(provider, cwd) {
91
91
  vpCommand,
92
92
  };
93
93
  }
94
+ /**
95
+ * `wp codex` is the only path that reaches machines which never re-run setup,
96
+ * so it carries the legacy-channel retirement too. The surviving channel is
97
+ * the bundled Codex plugin: probe its cache directly and WARN when it is
98
+ * absent. A `codex plugin marketplace` subprocess here would add latency to
99
+ * every launch and could hard-fail a machine where the plugin already works —
100
+ * a degraded session beats a blocked launch.
101
+ */
94
102
  function ensureCodexSessionTooling(repoRoot) {
95
103
  const options = { dryRun: false, overwrite: false };
96
- const webpresso = ensureCodexWebpressoMcp({ options });
97
- if (webpresso.kind === "codex-webpresso-mcp-not-installed") {
98
- throw new DirectProviderLaunchPreparationError(`Codex webpresso MCP is not configured because no usable agent-kit install root was found. Checked: ${webpresso.checked.join(", ") || "(none)"}`, "Run `wp setup --yes-global` from this repo, then retry `wp codex`.");
104
+ pruneLegacyCodexWebpressoMcp({ options });
105
+ if (findInstalledCodexPluginMcpManifests().length === 0) {
106
+ console.warn("⚠ The webpresso Codex plugin is not installed, so the webpresso MCP server will be unavailable in this session. Run `wp setup` to install it.");
99
107
  }
100
108
  const project = ensureCodexProjectMcpServers({ repoRoot, options });
101
109
  if (project.kind === "codex-project-mcp-invalid") {
@@ -5,5 +5,14 @@ export interface FlattenedAssets {
5
5
  }
6
6
  /** Reads `.agent/{skills,commands,agents}/` and returns an in-memory flattened structure. */
7
7
  export declare function flattenAgentDir(agentDir: string): FlattenedAssets;
8
- /** Writes flattened assets to a directory structure mirroring rulesync's expected layout. */
8
+ /**
9
+ * Writes flattened assets to the layout rulesync reads.
10
+ *
11
+ * The three shapes are not interchangeable, and getting one wrong is silent:
12
+ * rulesync discovers skills by globbing `skills/` for *directories* and reading
13
+ * `SKILL.md` inside each, reads subagents from `subagents/` (not `agents/`),
14
+ * and reads commands as flat `commands/*.md`. An asset written in the wrong
15
+ * shape is simply never seen — rulesync still exits 0, having generated
16
+ * nothing for it.
17
+ */
9
18
  export declare function writeFlattenedAssets(assets: FlattenedAssets, outDir: string): Promise<void>;
@@ -60,19 +60,29 @@ export function flattenAgentDir(agentDir) {
60
60
  }
61
61
  return { skills, commands, agents };
62
62
  }
63
- /** Writes flattened assets to a directory structure mirroring rulesync's expected layout. */
63
+ /**
64
+ * Writes flattened assets to the layout rulesync reads.
65
+ *
66
+ * The three shapes are not interchangeable, and getting one wrong is silent:
67
+ * rulesync discovers skills by globbing `skills/` for *directories* and reading
68
+ * `SKILL.md` inside each, reads subagents from `subagents/` (not `agents/`),
69
+ * and reads commands as flat `commands/*.md`. An asset written in the wrong
70
+ * shape is simply never seen — rulesync still exits 0, having generated
71
+ * nothing for it.
72
+ */
64
73
  export async function writeFlattenedAssets(assets, outDir) {
65
- const subdirs = ["skills", "commands", "agents"];
74
+ const subdirs = ["skills", "commands", "subagents"];
66
75
  for (const sub of subdirs) {
67
76
  mkdirSync(join(outDir, sub), { recursive: true });
68
77
  }
69
78
  for (const [name, content] of Object.entries(assets.skills)) {
70
- writeFileSync(join(outDir, "skills", `${name}.md`), content);
79
+ mkdirSync(join(outDir, "skills", name), { recursive: true });
80
+ writeFileSync(join(outDir, "skills", name, "SKILL.md"), content);
71
81
  }
72
82
  for (const [name, content] of Object.entries(assets.commands)) {
73
83
  writeFileSync(join(outDir, "commands", `${name}.md`), content);
74
84
  }
75
85
  for (const [name, content] of Object.entries(assets.agents)) {
76
- writeFileSync(join(outDir, "agents", `${name}.md`), content);
86
+ writeFileSync(join(outDir, "subagents", `${name}.md`), content);
77
87
  }
78
88
  }
@@ -0,0 +1,22 @@
1
+ import { type BlockRegistry } from "./registry-resolve.js";
2
+ export interface SnapshotIntegrityResult {
3
+ readonly ok: boolean;
4
+ readonly path: string;
5
+ readonly registry?: BlockRegistry;
6
+ readonly diagnostics: readonly string[];
7
+ }
8
+ /**
9
+ * Fail-on-drift **equivalent** for agent-kit CI without a framework dependency:
10
+ * load the committed catalog snapshot and enforce generator-contract invariants
11
+ * (schema parse, version shape, unique name+view, well-formed slots).
12
+ *
13
+ * Full regenerate-and-diff against `@webpresso/codegen` remains a cross-repo
14
+ * operator step; this gate prevents a rotten or hand-broken snapshot from landing.
15
+ */
16
+ /**
17
+ * Check (does not throw). Callers must inspect `.ok` — this is a gate helper,
18
+ * not a Node assertion that aborts the process.
19
+ */
20
+ export declare function checkAdminBlocksSnapshotIntegrity(options?: {
21
+ readonly snapshotPath?: string;
22
+ }): SnapshotIntegrityResult;
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { blockRegistrySchema, defaultSnapshotPath, } from "./registry-resolve.js";
3
+ const SEMVER_LIKE = /^\d+\.\d+\.\d+/;
4
+ const slotSchemaLoose = {
5
+ isSlot(value) {
6
+ if (value === null || typeof value !== "object" || Array.isArray(value))
7
+ return false;
8
+ const rec = value;
9
+ return typeof rec["name"] === "string" && rec["name"].length > 0;
10
+ },
11
+ };
12
+ /**
13
+ * Fail-on-drift **equivalent** for agent-kit CI without a framework dependency:
14
+ * load the committed catalog snapshot and enforce generator-contract invariants
15
+ * (schema parse, version shape, unique name+view, well-formed slots).
16
+ *
17
+ * Full regenerate-and-diff against `@webpresso/codegen` remains a cross-repo
18
+ * operator step; this gate prevents a rotten or hand-broken snapshot from landing.
19
+ */
20
+ /**
21
+ * Check (does not throw). Callers must inspect `.ok` — this is a gate helper,
22
+ * not a Node assertion that aborts the process.
23
+ */
24
+ export function checkAdminBlocksSnapshotIntegrity(options = {}) {
25
+ const path = options.snapshotPath ?? defaultSnapshotPath();
26
+ const diagnostics = [];
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(path, "utf8");
30
+ }
31
+ catch (error) {
32
+ const detail = error instanceof Error ? error.message : String(error);
33
+ return { ok: false, path, diagnostics: [`cannot read snapshot: ${detail}`] };
34
+ }
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch (error) {
40
+ const detail = error instanceof Error ? error.message : String(error);
41
+ return { ok: false, path, diagnostics: [`snapshot is not valid JSON: ${detail}`] };
42
+ }
43
+ const result = blockRegistrySchema.safeParse(parsed);
44
+ if (!result.success) {
45
+ return {
46
+ ok: false,
47
+ path,
48
+ diagnostics: result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`),
49
+ };
50
+ }
51
+ const registry = result.data;
52
+ if (!SEMVER_LIKE.test(registry.version)) {
53
+ diagnostics.push(`version "${registry.version}" is not semver-like`);
54
+ }
55
+ if (registry.minVersion !== undefined && !SEMVER_LIKE.test(registry.minVersion)) {
56
+ diagnostics.push(`minVersion "${registry.minVersion}" is not semver-like`);
57
+ }
58
+ const seen = new Set();
59
+ for (const [index, block] of registry.blocks.entries()) {
60
+ const key = `${block.view}::${block.name}`;
61
+ if (seen.has(key)) {
62
+ diagnostics.push(`duplicate block ${key} at index ${index}`);
63
+ }
64
+ seen.add(key);
65
+ if (block.slots !== undefined) {
66
+ if (!Array.isArray(block.slots)) {
67
+ diagnostics.push(`blocks[${index}].slots must be an array`);
68
+ continue;
69
+ }
70
+ for (const [slotIndex, slot] of block.slots.entries()) {
71
+ if (!slotSchemaLoose.isSlot(slot)) {
72
+ diagnostics.push(`blocks[${index}].slots[${slotIndex}] requires string name`);
73
+ continue;
74
+ }
75
+ const accepts = slot.accepts;
76
+ if (accepts !== undefined) {
77
+ if (!Array.isArray(accepts) || accepts.some((a) => typeof a !== "string")) {
78
+ diagnostics.push(`blocks[${index}].slots[${slotIndex}].accepts must be string[] when present`);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return {
85
+ ok: diagnostics.length === 0,
86
+ path,
87
+ registry,
88
+ diagnostics,
89
+ };
90
+ }