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

Potentially problematic release.


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

Files changed (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/agent/rules/changeset-release.md +15 -4
  3. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  4. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  5. package/catalog/agent/rules/pre-implementation.md +36 -20
  6. package/catalog/agent/rules/rtk-routing.md +22 -16
  7. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  8. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  9. package/dist/esm/audit/hook-surface.js +5 -1
  10. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  11. package/dist/esm/audit/repo-guardrails.js +71 -1
  12. package/dist/esm/blueprint/core/parser.js +38 -12
  13. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  14. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  15. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  16. package/dist/esm/blueprint/execution/metadata.js +8 -4
  17. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  18. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  22. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  23. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  24. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  25. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  26. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  27. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  28. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  29. package/dist/esm/blueprint/trust/promotion.js +19 -19
  30. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  31. package/dist/esm/build/atomic-file-copy.js +44 -0
  32. package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
  33. package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
  34. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  35. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  36. package/dist/esm/cli/commands/compile.d.ts +13 -0
  37. package/dist/esm/cli/commands/compile.js +182 -62
  38. package/dist/esm/cli/commands/format.js +17 -1
  39. package/dist/esm/cli/commands/gain/index.js +10 -3
  40. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  41. package/dist/esm/cli/commands/init/index.js +29 -48
  42. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  43. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  44. package/dist/esm/cli/commands/init/merge.js +30 -10
  45. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  46. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  47. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  48. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  49. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  50. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  51. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  52. package/dist/esm/cli/commands/pr-help.js +1 -1
  53. package/dist/esm/cli/commands/pr.js +18 -4
  54. package/dist/esm/cli/commands/review.js +60 -6
  55. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  56. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  57. package/dist/esm/cli/commands/worktree/router.js +5 -2
  58. package/dist/esm/cli/direct-provider-launch.js +12 -4
  59. package/dist/esm/compiler/flatten.d.ts +10 -1
  60. package/dist/esm/compiler/flatten.js +14 -4
  61. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  62. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  63. package/dist/esm/compose/compose.d.ts +8 -0
  64. package/dist/esm/compose/compose.js +115 -8
  65. package/dist/esm/compose/ops-report-schema.js +17 -3
  66. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  67. package/dist/esm/compose/registry-resolve.js +78 -11
  68. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  69. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  70. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  71. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  72. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  73. package/dist/esm/config/oxlint/oxlintrc.js +18 -21
  74. package/dist/esm/config/oxlint/path-roles.js +53 -0
  75. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  76. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  77. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  78. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  79. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  80. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  81. package/dist/esm/daemon/domains/launch.js +30 -1
  82. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  83. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  84. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  85. package/dist/esm/daemon/handlers/product.js +10 -1
  86. package/dist/esm/daemon/methods.d.ts +21 -1
  87. package/dist/esm/daemon/methods.js +20 -0
  88. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  89. package/dist/esm/daemon/protocol/contract.js +38 -0
  90. package/dist/esm/daemon/server.js +7 -0
  91. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  92. package/dist/esm/errors/wp-error.js +21 -2
  93. package/dist/esm/hooks/doctor.d.ts +11 -0
  94. package/dist/esm/hooks/doctor.js +114 -45
  95. package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
  96. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  97. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  98. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  99. package/dist/esm/hooks/status/index.js +1 -1
  100. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  101. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  102. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  103. package/dist/esm/mcp/blueprint/registration.js +1 -1
  104. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  105. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  106. package/dist/esm/mcp/tools/gain.js +28 -2
  107. package/dist/esm/mcp/tools/worktree.js +65 -3
  108. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  109. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  110. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  111. package/dist/esm/review/lifecycle-writers.js +330 -0
  112. package/dist/esm/review/subject.js +66 -32
  113. package/dist/esm/status/snapshot.d.ts +5 -0
  114. package/dist/esm/status/snapshot.js +27 -2
  115. package/dist/esm/test/shard-durations.json +0 -1
  116. package/dist/esm/test-helpers/global-setup.js +38 -3
  117. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  118. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  119. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  120. package/dist/esm/utils/package-root.d.ts +32 -0
  121. package/dist/esm/utils/package-root.js +82 -0
  122. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  123. package/dist/esm/worktrees/git-metadata.js +48 -0
  124. package/package.json +16 -21
  125. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  126. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  127. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  128. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  129. package/dist/esm/config/oxlint/index.d.ts +0 -22
  130. package/dist/esm/config/oxlint/index.js +0 -28
  131. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  132. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  133. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  134. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  135. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  136. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -1,24 +1,36 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import type { MergeOptions } from "#cli/commands/init/merge";
3
2
  import { type SpinnerFactory } from "#cli/commands/init/scaffolders/spinner";
3
+ import { type ManagedRtkDownloader } from "#tool-runtime/managed-rtk.js";
4
4
  export interface EnsureRtkInput {
5
5
  repoRoot: string;
6
6
  options: MergeOptions;
7
- spawn?: typeof spawnSync;
7
+ /** @deprecated ignored — managed rtk no longer shells out to brew/PATH install */
8
+ spawn?: unknown;
8
9
  pinFilePath?: string;
9
10
  strict?: boolean;
10
11
  spinnerFactory?: SpinnerFactory;
12
+ /** Test seam: inject archive downloader for ensureManagedRtk */
13
+ downloader?: ManagedRtkDownloader;
11
14
  }
12
15
  export type EnsureRtkResult = {
13
16
  kind: "rtk-ok";
14
17
  installed: boolean;
18
+ path: string;
15
19
  } | {
16
20
  kind: "rtk-skipped-dry-run";
17
21
  } | {
18
22
  kind: "rtk-not-found";
19
23
  hint: string;
20
- } | {
21
- kind: "rtk-init-failed";
22
- exitCode: number;
23
24
  };
25
+ /**
26
+ * Strip freestanding RTK PreToolUse surface from project Claude settings and
27
+ * delete the retired script. Also removes the historical stale legacy-runtime
28
+ * PreToolUse group when a direct wp-pretool-guard hook is already present.
29
+ * Idempotent; no-op when settings are absent or already clean.
30
+ */
31
+ export declare function stripFreestandingRtkHooks(repoRoot: string): void;
32
+ /**
33
+ * Ensure managed (embedded-cache) rtk is available and freestanding hooks are gone.
34
+ * No brew, no rtk init --auto-patch, no PATH install requirement.
35
+ */
24
36
  export declare function ensureRtk(input: EnsureRtkInput): EnsureRtkResult;
@@ -1,32 +1,28 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { makeNoopSpinnerFactory, } from "#cli/commands/init/scaffolders/spinner";
5
- import { checkVersionPin } from "#cli/commands/init/scaffolders/version-pin";
6
- const NOT_FOUND_HINT = "rtk is not on PATH. Install it manually (macOS: `brew install rtk`) and re-run.";
7
- // `rtk --version` answers in ~10ms; 3s is generous headroom. Bounding the
8
- // presence probe keeps a hung/wrong binary from stalling `wp setup`. The
9
- // `brew install` / `rtk init` calls below are intentionally left unbounded —
10
- // they are user-driven installs with inherited stdio where a fixed deadline
11
- // would wrongly kill a legitimately slow run.
12
- const RTK_PROBE_TIMEOUT_MS = 3000;
4
+ import { ensureManagedRtk } from "#tool-runtime/managed-rtk.js";
5
+ const NOT_FOUND_HINT = "managed rtk binary could not be installed (network/platform). Set WP_SKIP_RTK=1 to skip, or WP_RTK_BIN=/path/to/rtk.";
13
6
  const RTK_HOOK_RELATIVE_PATH = join(".claude", "hooks", "rtk-rewrite.sh");
14
7
  const CLAUDE_SETTINGS_RELATIVE_PATH = join(".claude", "settings.json");
15
8
  const STALE_runtime_CLAUDE_COMMAND = "external-runtime/dist/scripts/codex-native-hook.js";
16
- function hasInstalledRtkHook(repoRoot) {
9
+ const FREESTANDING_RTK_HOOK_MARKERS = ["rtk-rewrite.sh", "rtk hook claude"];
10
+ /**
11
+ * Strip freestanding RTK PreToolUse surface from project Claude settings and
12
+ * delete the retired script. Also removes the historical stale legacy-runtime
13
+ * PreToolUse group when a direct wp-pretool-guard hook is already present.
14
+ * Idempotent; no-op when settings are absent or already clean.
15
+ */
16
+ export function stripFreestandingRtkHooks(repoRoot) {
17
17
  const hookPath = join(repoRoot, RTK_HOOK_RELATIVE_PATH);
18
- const settingsPath = join(repoRoot, CLAUDE_SETTINGS_RELATIVE_PATH);
19
- if (!existsSync(hookPath) || !existsSync(settingsPath))
20
- return false;
21
- try {
22
- const settings = readFileSync(settingsPath, "utf8");
23
- return settings.includes("rtk-rewrite.sh");
24
- }
25
- catch {
26
- return false;
18
+ if (existsSync(hookPath)) {
19
+ try {
20
+ rmSync(hookPath, { force: true });
21
+ }
22
+ catch {
23
+ // best-effort; doctor will still flag if the file remains
24
+ }
27
25
  }
28
- }
29
- function normalizeClaudeRtkSettings(repoRoot) {
30
26
  const settingsPath = join(repoRoot, CLAUDE_SETTINGS_RELATIVE_PATH);
31
27
  if (!existsSync(settingsPath))
32
28
  return;
@@ -41,73 +37,58 @@ function normalizeClaudeRtkSettings(repoRoot) {
41
37
  if (!Array.isArray(groups) || groups.length === 0)
42
38
  return;
43
39
  const hasDirectWpGuard = groups.some((group) => (group.hooks ?? []).some((hook) => hook.command?.includes("wp-pretool-guard")));
44
- if (!hasDirectWpGuard)
45
- return;
46
40
  let changed = false;
47
- const nextGroups = groups.filter((group) => {
41
+ const nextGroups = groups
42
+ .map((group) => {
43
+ const hooks = group.hooks ?? [];
44
+ const nextHooks = hooks.filter((hook) => {
45
+ const command = hook.command ?? "";
46
+ if (FREESTANDING_RTK_HOOK_MARKERS.some((marker) => command.includes(marker))) {
47
+ changed = true;
48
+ return false;
49
+ }
50
+ return true;
51
+ });
52
+ if (nextHooks.length !== hooks.length) {
53
+ return { ...group, hooks: nextHooks };
54
+ }
55
+ return group;
56
+ })
57
+ .filter((group) => {
48
58
  const commands = (group.hooks ?? []).map((hook) => hook.command ?? "");
49
- const isStaleOmcGroup = commands.some((command) => command.includes(STALE_runtime_CLAUDE_COMMAND));
50
- if (isStaleOmcGroup)
59
+ if (commands.length === 0) {
60
+ changed = true;
61
+ return false;
62
+ }
63
+ const isStaleOmcGroup = hasDirectWpGuard &&
64
+ commands.some((command) => command.includes(STALE_runtime_CLAUDE_COMMAND));
65
+ if (isStaleOmcGroup) {
51
66
  changed = true;
52
- return !isStaleOmcGroup;
67
+ return false;
68
+ }
69
+ return true;
53
70
  });
54
71
  if (!changed)
55
72
  return;
56
73
  parsed.hooks = { ...parsed.hooks, PreToolUse: nextGroups };
57
74
  writeFileSync(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
58
75
  }
76
+ /**
77
+ * Ensure managed (embedded-cache) rtk is available and freestanding hooks are gone.
78
+ * No brew, no rtk init --auto-patch, no PATH install requirement.
79
+ */
59
80
  export function ensureRtk(input) {
60
81
  if (input.options.dryRun)
61
82
  return { kind: "rtk-skipped-dry-run" };
62
- const spawn = input.spawn ?? spawnSync;
63
83
  const spinner = (input.spinnerFactory ?? makeNoopSpinnerFactory())("rtk");
64
- let installed = false;
65
84
  spinner.start();
66
- let probe = spawn("rtk", ["--version"], { encoding: "utf8", timeout: RTK_PROBE_TIMEOUT_MS });
67
- if (probe.error || (probe.status !== null && probe.status !== 0)) {
68
- if (process.platform !== "darwin") {
69
- spinner.fail("rtk not found");
70
- return { kind: "rtk-not-found", hint: NOT_FOUND_HINT };
71
- }
72
- const install = spawn("brew", ["install", "rtk"], { stdio: "inherit" });
73
- if (install.status !== 0) {
74
- spinner.fail("rtk install failed");
75
- return { kind: "rtk-not-found", hint: NOT_FOUND_HINT };
76
- }
77
- installed = true;
78
- probe = spawn("rtk", ["--version"], { encoding: "utf8", timeout: RTK_PROBE_TIMEOUT_MS });
79
- if (probe.error || (probe.status !== null && probe.status !== 0)) {
80
- spinner.fail("rtk not found after install");
81
- return { kind: "rtk-not-found", hint: NOT_FOUND_HINT };
82
- }
83
- }
84
- const installedVersion = String(probe.stdout ?? "").trim();
85
- const pinCheck = checkVersionPin("rtk", installedVersion, input.pinFilePath ?? join(input.repoRoot, "compatible-versions.json"));
86
- if (!pinCheck.ok) {
87
- if (input.strict) {
88
- spinner.fail("rtk version mismatch");
89
- return { kind: "rtk-init-failed", exitCode: -1 };
90
- }
91
- console.warn(pinCheck.warning);
92
- }
93
- if (hasInstalledRtkHook(input.repoRoot)) {
94
- normalizeClaudeRtkSettings(input.repoRoot);
95
- spinner.succeed("rtk ready");
96
- return { kind: "rtk-ok", installed };
97
- }
98
- const result = spawn("rtk", ["init", "-g", "--auto-patch"], {
99
- cwd: input.repoRoot,
100
- stdio: "inherit",
101
- env: {
102
- ...process.env,
103
- RTK_TELEMETRY_DISABLED: "1",
104
- },
105
- });
106
- if (result.status !== 0) {
107
- spinner.fail("rtk init failed");
108
- return { kind: "rtk-init-failed", exitCode: result.status ?? -1 };
85
+ // Always strip freestanding surface first so setup never leaves CLAUDE_PROJECT_DIR hooks.
86
+ stripFreestandingRtkHooks(input.repoRoot);
87
+ const ensured = ensureManagedRtk({ downloader: input.downloader });
88
+ if (!ensured.ok) {
89
+ spinner.fail("rtk not available");
90
+ return { kind: "rtk-not-found", hint: `${NOT_FOUND_HINT} (${ensured.error})` };
109
91
  }
110
- normalizeClaudeRtkSettings(input.repoRoot);
111
92
  spinner.succeed("rtk ready");
112
- return { kind: "rtk-ok", installed };
93
+ return { kind: "rtk-ok", installed: ensured.installed, path: ensured.path };
113
94
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Runtime detection for tools that webpresso consumers commonly need
3
- * but that `wp setup` cannot install for them (curl-pipe install scripts
4
- * or system package managers shouldn't be triggered silently).
2
+ * Runtime detection for host-install tools that webpresso does **not**
3
+ * managed-download (see catalog rule `managed-tool-binaries`).
5
4
  *
6
- * Auto-runs at the end of `wp setup`; prints a one-line status per
7
- * runtime so the consumer knows what's missing without surprising them
8
- * with a download.
5
+ * Contrasts with managed rtk (`ensureManagedRtk`): bun/vp/actionlint stay
6
+ * host package-manager installs. This probe only prints status + hints —
7
+ * it never curl-pipes or brew-installs silently.
8
+ *
9
+ * Auto-runs at the end of `wp setup`; one line per runtime.
9
10
  */
10
11
  import { spawnSync } from "node:child_process";
11
12
  function probeVersion(cmd, args = ["--version"]) {
@@ -1,14 +1,25 @@
1
1
  import type { ConvergeUnit } from "./convergence.js";
2
- export type CollisionResolution = "none" | "tracked" | "removed-identical" | "removed-owned";
2
+ export type CollisionResolution = "none" | "tracked" | "removed-identical" | "removed-owned" | "ignored-patch-derived" | "ignored-backed-up";
3
3
  export interface CollisionResult {
4
4
  readonly ok: boolean;
5
5
  readonly resolution?: CollisionResolution;
6
6
  readonly error?: string;
7
+ /** Set when the collision proceeded but the caller should surface a notice. */
8
+ readonly warning?: string;
7
9
  }
8
10
  export interface ResolveCollisionOptions {
9
11
  readonly unit: ConvergeUnit;
10
12
  readonly desiredContent: string | Uint8Array;
11
13
  /** Compatibility input only. Unknown content remains protected when true. */
12
14
  readonly force?: boolean;
15
+ /**
16
+ * True when `desiredContent` was computed FROM the existing file's own
17
+ * content (e.g. a JSON key-preserving patch), so a divergent unmarked
18
+ * existing file is safe to write through: the caller already folded the
19
+ * user's data into `desiredContent`.
20
+ */
21
+ readonly patchDerived?: boolean;
13
22
  }
23
+ /** True when `gitPath` is git-ignored in `repoRoot` (machine-local, never committed). */
24
+ export declare function isGitIgnored(repoRoot: string, gitPath: string): boolean;
14
25
  export declare function resolveUntrackedCollision(options: ResolveCollisionOptions): CollisionResult;
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  const GIT_TIMEOUT_MS = 15_000;
4
4
  const OWNERSHIP_MARKER = /^\uFEFF?[ \t]*(?:#|\/\/|\/\*|<!--|")?[ \t]*(?:>>> managed by webpresso\b|Generated by webpresso\b)/iu;
5
5
  function git(repoRoot, args) {
@@ -12,6 +12,25 @@ function git(repoRoot, args) {
12
12
  });
13
13
  return { ok: result.status === 0 && !result.error };
14
14
  }
15
+ /** True when `gitPath` is git-ignored in `repoRoot` (machine-local, never committed). */
16
+ export function isGitIgnored(repoRoot, gitPath) {
17
+ return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath]).ok;
18
+ }
19
+ function resolveIgnoredDivergence(options) {
20
+ const { unit } = options;
21
+ if (options.patchDerived) {
22
+ return { ok: true, resolution: "ignored-patch-derived" };
23
+ }
24
+ const current = readFileSync(unit.targetPath);
25
+ const backupPath = `${unit.targetPath}.bak`;
26
+ writeFileSync(backupPath, current);
27
+ return {
28
+ ok: true,
29
+ resolution: "ignored-backed-up",
30
+ warning: `Untracked, git-ignored ${unit.gitPath} had unmanaged content; ` +
31
+ `the original was backed up to ${unit.gitPath}.bak before it was overwritten.`,
32
+ };
33
+ }
15
34
  export function resolveUntrackedCollision(options) {
16
35
  const { unit } = options;
17
36
  if (!existsSync(unit.targetPath))
@@ -34,6 +53,9 @@ export function resolveUntrackedCollision(options) {
34
53
  rmSync(unit.targetPath);
35
54
  return { ok: true, resolution: "removed-owned" };
36
55
  }
56
+ if (unit.scope === "repo-ignored") {
57
+ return resolveIgnoredDivergence(options);
58
+ }
37
59
  return {
38
60
  ok: false,
39
61
  error: `Unknown untracked content blocks ${unit.gitPath}; it was preserved${options.force ? " despite --force" : ""}. ` +
@@ -53,7 +53,7 @@ export function getPrWaitCommandHelpText() {
53
53
  " --branch <name> Branch selector (defaults to current branch)",
54
54
  " --pr <number-or-url> Explicit PR selector",
55
55
  " --until <target> ready | checks-terminal | checks-green | merged (default: checks-terminal)",
56
- " --timeout-ms <ms> Overall deadline; for ready, per-probe timeout only",
56
+ " --timeout-ms <ms> Overall deadline, max 300000 (5min); for ready, per-probe timeout only",
57
57
  " --poll-interval-ms <ms> Poll interval (default: 15000)",
58
58
  " --include-checks Include final check snapshot (default: true)",
59
59
  " --no-include-checks Omit final check snapshot from output",
@@ -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.