@webpresso/agent-kit 3.3.0 → 3.3.2

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 (62) hide show
  1. package/catalog/agent/rules/pre-implementation.md +30 -8
  2. package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
  3. package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
  4. package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
  5. package/dist/esm/blueprint/markdown/task-heading.js +25 -0
  6. package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
  7. package/dist/esm/blueprint/trust/command-runner.js +265 -41
  8. package/dist/esm/build/cli-mcp-parity.js +5 -0
  9. package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
  10. package/dist/esm/ci/native-session-memory-cache.js +2 -1
  11. package/dist/esm/ci/release-progress.d.ts +37 -0
  12. package/dist/esm/ci/release-progress.js +139 -0
  13. package/dist/esm/cli/cli.d.ts +1 -1
  14. package/dist/esm/cli/cli.js +9 -0
  15. package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
  16. package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
  17. package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
  18. package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
  19. package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
  20. package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
  21. package/dist/esm/cli/commands/blueprint/router.js +5 -2
  22. package/dist/esm/cli/commands/init/convergence-apply.d.ts +28 -5
  23. package/dist/esm/cli/commands/init/convergence-apply.js +75 -13
  24. package/dist/esm/cli/commands/opencode-probe.d.ts +11 -0
  25. package/dist/esm/cli/commands/opencode-probe.js +1 -1
  26. package/dist/esm/cli/commands/quality-runner.js +6 -2
  27. package/dist/esm/cli/commands/release-progress.d.ts +53 -0
  28. package/dist/esm/cli/commands/release-progress.js +155 -0
  29. package/dist/esm/cli/commands/review.js +74 -11
  30. package/dist/esm/cli/commands/typecheck.js +1 -1
  31. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +8 -0
  32. package/dist/esm/cli/commands/worktree/router-dispatch.js +25 -1
  33. package/dist/esm/cli/utils.d.ts +8 -1
  34. package/dist/esm/cli/utils.js +12 -2
  35. package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
  36. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
  37. package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
  38. package/dist/esm/review/delivery-verifier.d.ts +33 -2
  39. package/dist/esm/review/delivery-verifier.js +130 -24
  40. package/dist/esm/review/execution/review-checkout.d.ts +7 -0
  41. package/dist/esm/review/execution/review-checkout.js +416 -27
  42. package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
  43. package/dist/esm/review/execution/sandbox/adapter.js +39 -0
  44. package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
  45. package/dist/esm/review/execution/sandbox/env.js +37 -0
  46. package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
  47. package/dist/esm/review/execution/sandbox/index.js +4 -0
  48. package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
  49. package/dist/esm/review/execution/sandbox/policy.js +95 -0
  50. package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
  51. package/dist/esm/review/execution/sandbox/probe.js +97 -0
  52. package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
  53. package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
  54. package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
  55. package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
  56. package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
  57. package/dist/esm/review/execution/sandbox/types.js +6 -0
  58. package/dist/esm/review/execution/supervisor.js +44 -5
  59. package/dist/esm/review/execution/types.d.ts +2 -0
  60. package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
  61. package/dist/esm/worktrees/owner-dirt.js +104 -0
  62. package/package.json +13 -12
@@ -14,25 +14,44 @@ export const GIT_PROBE_TIMEOUT_MS = 15_000;
14
14
  * raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
15
15
  */
16
16
  export const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600_000;
17
- const GIT_MAX_BUFFER = 1024 * 1024;
18
- function git(repoRoot, args, timeoutMs) {
17
+ /** Max stdout+stderr retained from probe git subprocesses (Node spawnSync maxBuffer). */
18
+ export const GIT_MAX_BUFFER = 1024 * 1024;
19
+ /**
20
+ * Max stdout+stderr for the single hookful `git commit`. Quiet hooks cost nothing
21
+ * (Node does not preallocate); chatty consumer format+audit chains can exceed 1 MiB
22
+ * and would otherwise die with ENOBUFS+SIGTERM mid-commit.
23
+ */
24
+ export const GIT_COMMIT_MAX_BUFFER = 16 * 1024 * 1024;
25
+ /** Hook output snippet size kept in first-class failure diagnoses (tail, not head). */
26
+ const HOOK_OUTPUT_SNIPPET_CHARS = 800;
27
+ /** spawnSync error codes that mean maxBuffer / stdio overflow killed the child. */
28
+ const BUFFER_OVERFLOW_ERROR_CODES = new Set(["ENOBUFS", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]);
29
+ function git(repoRoot, args, timeoutMs, maxBuffer = GIT_MAX_BUFFER) {
19
30
  const result = spawnSync("git", [...args], {
20
31
  cwd: repoRoot,
21
32
  encoding: "utf8",
22
33
  // Node: timeout > 0 arms SIGTERM; 0 / omitted means no artificial wall.
23
34
  ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
24
- maxBuffer: GIT_MAX_BUFFER,
35
+ maxBuffer,
25
36
  stdio: ["ignore", "pipe", "pipe"],
26
37
  });
27
- const timedOut = result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT";
38
+ const errorCode = result.error !== undefined && "code" in result.error && result.error.code != null
39
+ ? String(result.error.code)
40
+ : null;
41
+ const timedOut = errorCode === "ETIMEDOUT";
28
42
  return {
29
43
  ok: result.status === 0 && !result.error,
30
44
  stdout: (result.stdout ?? "").trim(),
31
45
  stderr: (result.stderr ?? "").trim(),
32
46
  timedOut,
33
47
  signal: result.signal,
48
+ errorCode,
34
49
  };
35
50
  }
51
+ /** True when spawnSync killed the child for maxBuffer / stdio overflow. */
52
+ export function isBufferOverflowErrorCode(code) {
53
+ return typeof code === "string" && BUFFER_OVERFLOW_ERROR_CODES.has(code);
54
+ }
36
55
  /**
37
56
  * Resolve the hookful-commit timeout. Preference order:
38
57
  * 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
@@ -60,7 +79,10 @@ function appendHookOutput(diagnosis, hookOutput) {
60
79
  const trimmed = hookOutput?.trim();
61
80
  if (!trimmed)
62
81
  return diagnosis;
63
- const snippet = trimmed.length > 400 ? `${trimmed.slice(0, 400)}…` : trimmed;
82
+ // Keep the tail: failing audits / last lines at kill time matter more than husky banner.
83
+ const snippet = trimmed.length > HOOK_OUTPUT_SNIPPET_CHARS
84
+ ? `…${trimmed.slice(-HOOK_OUTPUT_SNIPPET_CHARS)}`
85
+ : trimmed;
64
86
  return `${diagnosis}\n--- hook output (truncated) ---\n${snippet}`;
65
87
  }
66
88
  /** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
@@ -87,24 +109,57 @@ export function formatCommitInterruptError(options) {
87
109
  `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
88
110
  return appendHookOutput(diagnosis, options.hookOutput);
89
111
  }
112
+ /**
113
+ * First-class diagnosis when Node spawnSync kills the child because hook/git
114
+ * output exceeded maxBuffer (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER).
115
+ * Distinct from budget timeout and from external signal kills.
116
+ */
117
+ export function formatCommitBufferError(options) {
118
+ const code = options.errorCode?.trim() || "ENOBUFS";
119
+ const diagnosis = `Convergence commit failed because git/hook output exceeded the spawnSync maxBuffer limit ` +
120
+ `(${options.maxBufferBytes} bytes; Node error ${code}), so no setup commit was authored. ` +
121
+ `This is a buffer/output-limit failure, not a time budget timeout and not an external kill. ` +
122
+ `Staged and worktree changes were preserved for remediation. ` +
123
+ `Reduce hook verbosity, raise the maxBuffer only with measured need, or fix chatty hooks. ` +
124
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
125
+ return appendHookOutput(diagnosis, options.hookOutput);
126
+ }
90
127
  /**
91
128
  * Classify a failed hookful `git commit` result.
92
129
  *
93
- * - `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
94
- * - non-null `signal` without timedOutinterrupt diagnosis (names the signal)
95
- * - otherwise hook/git stderr when present, else a generic preserved-index message
130
+ * Order (first match wins):
131
+ * 1. `timedOut` (spawnSync ETIMEDOUT)budget timeout diagnosis
132
+ * 2. buffer overflow (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER) buffer-limit diagnosis
133
+ * 3. non-null `signal` → interrupt diagnosis (names the signal)
134
+ * 4. otherwise → hook/git stderr when present, else spawn errorCode / generic preserved-index
96
135
  *
97
- * Never lead with bare hook "passed" lines for timeout/interrupt paths.
136
+ * Never lead with bare hook "passed" lines for first-class failure paths.
98
137
  */
99
- export function classifyCommitFailure(result, timeoutMs) {
138
+ export function classifyCommitFailure(result, timeoutMs, maxBufferBytes = GIT_COMMIT_MAX_BUFFER) {
100
139
  const hookOutput = [result.stderr, result.stdout].filter(Boolean).join("\n");
101
140
  if (result.timedOut) {
102
141
  return formatCommitTimeoutError({ timeoutMs, hookOutput });
103
142
  }
143
+ if (isBufferOverflowErrorCode(result.errorCode)) {
144
+ return formatCommitBufferError({
145
+ maxBufferBytes,
146
+ errorCode: result.errorCode,
147
+ hookOutput,
148
+ });
149
+ }
104
150
  if (result.signal) {
105
151
  return formatCommitInterruptError({ signal: result.signal, hookOutput });
106
152
  }
107
- return hookOutput || "git commit failed; hook changes were preserved.";
153
+ if (hookOutput) {
154
+ if (result.errorCode) {
155
+ return `${hookOutput}\n(spawn error ${result.errorCode})`;
156
+ }
157
+ return hookOutput;
158
+ }
159
+ if (result.errorCode) {
160
+ return `git commit failed (spawn error ${result.errorCode}); hook changes were preserved.`;
161
+ }
162
+ return "git commit failed; hook changes were preserved.";
108
163
  }
109
164
  /**
110
165
  * Prefix every line of a setup failure so multi-line timeout diagnoses stay
@@ -230,13 +285,20 @@ export function authorConvergenceCommit(options) {
230
285
  const commitTimeoutMs = resolveCommitTimeoutMs({
231
286
  commitTimeoutMs: options.commitTimeoutMs,
232
287
  });
233
- const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs);
288
+ const budgetLabel = commitTimeoutMs > 0
289
+ ? commitTimeoutMs >= 60_000
290
+ ? `up to ${Math.round(commitTimeoutMs / 60_000)}m`
291
+ : `up to ${commitTimeoutMs}ms`
292
+ : "no artificial time wall";
293
+ // stderr: setup success path stays machine-friendly on stdout; hooks can take minutes.
294
+ console.error(`wp setup: running repository pre-commit hooks (${budgetLabel})…`);
295
+ const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs, GIT_COMMIT_MAX_BUFFER);
234
296
  if (!commit.ok) {
235
297
  return {
236
298
  status: "failed",
237
299
  ok: false,
238
300
  allowlist,
239
- error: classifyCommitFailure(commit, commitTimeoutMs),
301
+ error: classifyCommitFailure(commit, commitTimeoutMs, GIT_COMMIT_MAX_BUFFER),
240
302
  };
241
303
  }
242
304
  const committedHead = probe(["rev-parse", "--verify", "HEAD"]);
@@ -71,6 +71,17 @@ export interface OpencodeProbeRunnerInput {
71
71
  }
72
72
  export type OpencodeProbeRunner = (input: OpencodeProbeRunnerInput) => Promise<OpencodeProbeResult>;
73
73
  export declare function isOpencodeProbeEnabled(env?: NodeJS.ProcessEnv): boolean;
74
+ /**
75
+ * Spawn a bounded `opencode run` against one account's isolated credential
76
+ * store and classify the first conclusive thing it says.
77
+ *
78
+ * There is no OpenCode Go HTTP endpoint anywhere in this codebase — the review
79
+ * adapter also reaches the provider only by spawning this binary — so a
80
+ * subprocess is the only available probe vehicle. Per-account isolation is the
81
+ * same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
82
+ * a live interactive TUI's global `auth.json`.
83
+ */
84
+ export declare function spawnOpencodeProbe(input: OpencodeProbeRunnerInput): Promise<OpencodeProbeResult>;
74
85
  export interface ProbeOpencodeAccountDeps {
75
86
  readonly run?: OpencodeProbeRunner;
76
87
  readonly materialize?: (key: string, dataDir: string) => void;
@@ -157,7 +157,7 @@ function classifyChunk(captured) {
157
157
  * same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
158
158
  * a live interactive TUI's global `auth.json`.
159
159
  */
160
- function spawnOpencodeProbe(input) {
160
+ export function spawnOpencodeProbe(input) {
161
161
  return new Promise((resolve) => {
162
162
  let probeCwd;
163
163
  try {
@@ -145,9 +145,13 @@ function renderSummaryLines(summary, transformed, entry, passed) {
145
145
  else if (!passed && transformed.rawOutput?.trim()) {
146
146
  lines.push(transformed.rawOutput.trimEnd());
147
147
  }
148
- if (!passed || transformed.truncated) {
148
+ // Any elided bytes must remain retrievable: fail, clip, OR compaction that
149
+ // recorded an elision without setting truncated. Hide Full log / retrieve
150
+ // hints only when the summary still contains the full content.
151
+ const hasElisions = (transformed.elisions?.length ?? 0) > 0;
152
+ if (!passed || transformed.truncated || hasElisions) {
149
153
  lines.push(`Full log: wp logs ${entry.command}`);
150
- if (transformed.elisions && transformed.elisions.length > 0) {
154
+ if (hasElisions) {
151
155
  lines.push(...transformed.elisions.map((elision) => `Retrieve elision: ${elision.retrieveTool} id=${elision.id}`));
152
156
  }
153
157
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `wp release-progress` — report Release workflow phase (detect / native-matrix / publish).
3
+ *
4
+ * Agents must not poll only `npm view` after Version Packages merge. This command
5
+ * loads the live GitHub Actions job list and classifies progress via
6
+ * {@link classifyReleaseRunProgress}.
7
+ */
8
+ import type { CAC } from "cac";
9
+ import { type ReleaseJobSnapshot, type ReleaseProgress } from "#ci/release-progress.js";
10
+ export declare const RELEASE_PROGRESS_COMMAND_HELP: string;
11
+ export interface GhRunJobsFetcher {
12
+ (options: {
13
+ readonly runId: string;
14
+ readonly repo: string;
15
+ }): ReleaseJobSnapshot[];
16
+ }
17
+ export interface LatestReleaseRunResolver {
18
+ (options: {
19
+ readonly repo: string;
20
+ }): string;
21
+ }
22
+ /** Production fetcher: `gh run view --json jobs`. */
23
+ export declare function fetchReleaseJobsViaGh(options: {
24
+ readonly runId: string;
25
+ readonly repo: string;
26
+ }): ReleaseJobSnapshot[];
27
+ /** Production resolver: latest Release workflow run for the repo. */
28
+ export declare function resolveLatestReleaseRunIdViaGh(options: {
29
+ readonly repo: string;
30
+ }): string;
31
+ export interface RunReleaseProgressOptions {
32
+ readonly runId?: string | number;
33
+ readonly repo?: string;
34
+ readonly json?: boolean;
35
+ readonly fetchJobs?: GhRunJobsFetcher;
36
+ readonly resolveLatestRunId?: LatestReleaseRunResolver;
37
+ readonly stdout?: {
38
+ write(chunk: string): void;
39
+ };
40
+ readonly stderr?: {
41
+ write(chunk: string): void;
42
+ };
43
+ }
44
+ /**
45
+ * Core command body — injectable for tests; production uses gh CLI defaults.
46
+ */
47
+ export declare function runReleaseProgress(options?: RunReleaseProgressOptions): {
48
+ readonly exitCode: number;
49
+ readonly progress: ReleaseProgress;
50
+ readonly runId: string;
51
+ readonly line: string;
52
+ };
53
+ export declare function registerReleaseProgressCommand(cli: CAC): void;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `wp release-progress` — report Release workflow phase (detect / native-matrix / publish).
3
+ *
4
+ * Agents must not poll only `npm view` after Version Packages merge. This command
5
+ * loads the live GitHub Actions job list and classifies progress via
6
+ * {@link classifyReleaseRunProgress}.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { classifyReleaseRunProgress, formatReleaseProgressLine, jobsFromGhRunViewJson, } from "#ci/release-progress.js";
10
+ export const RELEASE_PROGRESS_COMMAND_HELP = [
11
+ "Report agent-kit Release workflow progress by phase.",
12
+ "",
13
+ "Phases: detect | native-matrix | publish | done | failed.",
14
+ "Use after merging Version Packages so long session-memory native builds are",
15
+ "not mistaken for a hung merge or silent npm wait.",
16
+ "",
17
+ "Examples:",
18
+ " wp release-progress",
19
+ " wp release-progress --run-id 30152651072",
20
+ " wp release-progress --repo webpresso/agent-kit --json",
21
+ ].join("\n");
22
+ const DEFAULT_REPO = "webpresso/agent-kit";
23
+ const RELEASE_WORKFLOW = "release.yml";
24
+ function runGhJson(args) {
25
+ const result = spawnSync("gh", [...args], {
26
+ encoding: "utf8",
27
+ timeout: 60_000,
28
+ maxBuffer: 8 * 1024 * 1024,
29
+ });
30
+ if (result.error)
31
+ throw result.error;
32
+ if ((result.status ?? 1) !== 0) {
33
+ const stderr = (result.stderr ?? "").trim();
34
+ throw new Error(stderr || `gh ${args.join(" ")} failed (exit ${result.status ?? 1})`);
35
+ }
36
+ const stdout = (result.stdout ?? "").trim();
37
+ if (!stdout)
38
+ throw new Error(`gh ${args.join(" ")} returned empty stdout`);
39
+ return JSON.parse(stdout);
40
+ }
41
+ /** Production fetcher: `gh run view --json jobs`. */
42
+ export function fetchReleaseJobsViaGh(options) {
43
+ const payload = runGhJson([
44
+ "run",
45
+ "view",
46
+ options.runId,
47
+ "--repo",
48
+ options.repo,
49
+ "--json",
50
+ "jobs,status,conclusion,url,displayTitle",
51
+ ]);
52
+ return jobsFromGhRunViewJson(payload);
53
+ }
54
+ /** Production resolver: latest Release workflow run for the repo. */
55
+ export function resolveLatestReleaseRunIdViaGh(options) {
56
+ const payload = runGhJson([
57
+ "run",
58
+ "list",
59
+ "--repo",
60
+ options.repo,
61
+ "--workflow",
62
+ RELEASE_WORKFLOW,
63
+ "--limit",
64
+ "1",
65
+ "--json",
66
+ "databaseId,status,displayTitle",
67
+ ]);
68
+ if (!Array.isArray(payload) || payload.length === 0) {
69
+ throw new Error(`No Release workflow runs found for ${options.repo}`);
70
+ }
71
+ const first = payload[0];
72
+ const id = first.databaseId;
73
+ if (typeof id === "number" && Number.isFinite(id))
74
+ return String(id);
75
+ if (typeof id === "string" && id.trim())
76
+ return id.trim();
77
+ throw new Error("Latest Release run is missing databaseId");
78
+ }
79
+ function coerceNonEmptyString(value) {
80
+ if (typeof value === "string") {
81
+ const trimmed = value.trim();
82
+ return trimmed.length > 0 ? trimmed : undefined;
83
+ }
84
+ if (typeof value === "number" && Number.isFinite(value))
85
+ return String(value);
86
+ return undefined;
87
+ }
88
+ /**
89
+ * Core command body — injectable for tests; production uses gh CLI defaults.
90
+ */
91
+ export function runReleaseProgress(options = {}) {
92
+ const repo = coerceNonEmptyString(options.repo) || DEFAULT_REPO;
93
+ const resolveLatest = options.resolveLatestRunId ?? resolveLatestReleaseRunIdViaGh;
94
+ const fetchJobs = options.fetchJobs ?? fetchReleaseJobsViaGh;
95
+ const stdout = options.stdout ?? process.stdout;
96
+ const stderr = options.stderr ?? process.stderr;
97
+ // cac may parse purely numeric --run-id values as numbers; coerce before trim.
98
+ const runId = coerceNonEmptyString(options.runId) || resolveLatest({ repo });
99
+ const jobs = fetchJobs({ runId, repo });
100
+ const progress = classifyReleaseRunProgress(jobs);
101
+ const line = formatReleaseProgressLine(progress);
102
+ if (options.json) {
103
+ stdout.write(`${JSON.stringify({
104
+ runId,
105
+ repo,
106
+ phase: progress.phase,
107
+ detail: progress.detail,
108
+ native: progress.native,
109
+ publishRunning: progress.publishRunning,
110
+ publishDone: progress.publishDone,
111
+ publishFailed: progress.publishFailed,
112
+ line,
113
+ }, null, 2)}\n`);
114
+ }
115
+ else {
116
+ stdout.write(`${line}\n`);
117
+ stdout.write(` run-id: ${runId}\n`);
118
+ stdout.write(` repo: ${repo}\n`);
119
+ if (progress.phase === "done") {
120
+ stdout.write(" next: npm view @webpresso/agent-kit version # only after phase=done\n");
121
+ }
122
+ else if (progress.phase === "native-matrix" || progress.phase === "publish") {
123
+ stdout.write(" note: do not treat npm view alone as progress; matrix/publish still running\n");
124
+ }
125
+ }
126
+ if (progress.phase === "failed") {
127
+ stderr.write(`wp release-progress: ${progress.detail}\n`);
128
+ return { exitCode: 1, progress, runId, line };
129
+ }
130
+ return { exitCode: 0, progress, runId, line };
131
+ }
132
+ export function registerReleaseProgressCommand(cli) {
133
+ cli
134
+ .command("release-progress", RELEASE_PROGRESS_COMMAND_HELP)
135
+ .option("--run-id <id>", "GitHub Actions run id (default: latest Release workflow run)")
136
+ .option("--repo <owner/name>", `GitHub repo (default: ${DEFAULT_REPO})`)
137
+ .option("--json", "Emit structured JSON")
138
+ .action((flags) => {
139
+ try {
140
+ const result = runReleaseProgress({
141
+ runId: flags.runId,
142
+ repo: flags.repo,
143
+ json: Boolean(flags.json),
144
+ });
145
+ process.exitCode = result.exitCode;
146
+ return result.exitCode;
147
+ }
148
+ catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ process.stderr.write(`wp release-progress: ${message}\n`);
151
+ process.exitCode = 1;
152
+ return 1;
153
+ }
154
+ });
155
+ }
@@ -167,6 +167,11 @@ function resolveReviewModel(provider, model) {
167
167
  const fableVersion = /^fable[-\s]?(\d+)$/iu.exec(model)?.[1];
168
168
  return fableVersion ? `claude-fable-${fableVersion}` : model;
169
169
  }
170
+ // How many DISTINCT rejected semantic subjects are allowed before the anti-runaway
171
+ // budget cap trips. Identical resubmissions never reach here (they reuse the prior
172
+ // rejection for free), so this only bounds genuinely different converging attempts.
173
+ // A human may extend past it in place with WP_REVIEW_SCOPE_CONTINUE=1 (recorded).
174
+ const REVIEW_SUBJECT_REJECTION_LIMIT = 4;
170
175
  function classificationForFailureCode(code) {
171
176
  if (!code)
172
177
  return undefined;
@@ -182,6 +187,7 @@ function isLocalReviewFailureCode(code) {
182
187
  code === "expected-marker-missing" ||
183
188
  code === "missing-terminal-result" ||
184
189
  code === "semantic-progress-budget-exceeded" ||
190
+ code === "sandbox-unavailable" ||
185
191
  code.startsWith("provider-stdin-"));
186
192
  }
187
193
  function reviewRunTerminalClassification(summary) {
@@ -1157,16 +1163,24 @@ const reviewGateAttemptSchema = z.object({
1157
1163
  });
1158
1164
  const reviewGateVerificationCommandSchema = z.object({
1159
1165
  command: z.string(),
1160
- outcome: z.enum(["passed", "failed", "timed-out"]),
1166
+ // "deferred" is produced for approval-dependent lifecycle gates
1167
+ // (delivery-verifier.ts) and persisted into gate state, so it MUST round-trip
1168
+ // through the reload schema or resumption/reuse of a lifecycle-gated review
1169
+ // throws "Review gate state is malformed".
1170
+ outcome: z.enum(["passed", "failed", "timed-out", "deferred"]),
1161
1171
  durationMs: z.number(),
1162
1172
  exitCode: z.number().nullable(),
1163
1173
  signal: z.string().optional(),
1164
1174
  });
1175
+ const _assertVerificationCommandReloadable = true;
1176
+ void _assertVerificationCommandReloadable;
1165
1177
  const reviewGateVerificationSchema = z.object({
1166
1178
  status: z.enum(["passed", "failed"]),
1167
1179
  commands: z.array(reviewGateVerificationCommandSchema),
1168
1180
  failureCode: z.string().optional(),
1169
1181
  logPath: z.string().optional(),
1182
+ sandbox: z.enum(["srt", "disabled-by-operator", "unavailable"]).optional(),
1183
+ sandboxReason: z.string().optional(),
1170
1184
  });
1171
1185
  const reviewGateStateSchema = z
1172
1186
  .object({
@@ -1259,15 +1273,42 @@ function buildGatePrompt(input) {
1259
1273
  ...(input.priorRejections && input.priorRejections.length > 0
1260
1274
  ? [
1261
1275
  "",
1262
- "Prior formal rejection evidence (this is the final semantic remediation review):",
1276
+ "Prior formal rejection evidence for this converging change:",
1263
1277
  ...input.priorRejections.map((entry) => `- ${entry.subjectDigest}: ${entry.artifactPath}`),
1264
- "Inspect every prior finding and its disposition. Return all remaining blockers together; do not defer discoverable blockers to another review round.",
1278
+ "For each prior finding, state its disposition (fixed / still-open / not-a-blocker) with evidence.",
1265
1279
  "",
1266
1280
  ]
1267
1281
  : []),
1268
- "Inspect the target SHA and blueprint directly. Focus on correctness, security, concurrency, recovery, tests, and scope compliance.",
1282
+ "Inspect the target SHA and blueprint directly.",
1283
+ "",
1284
+ "This is a CONVERGENCE gate. Your goal is to surface EVERY blocker in ONE pass so the author can fix them all at once; a blocker you leave for a later round costs a full review cycle. Be exhaustive, not incremental.",
1285
+ "",
1286
+ 'Walk ALL of these dimensions in order. For EACH, either report specific findings or write "no blocker". Do not skip a dimension and do not stop early:',
1287
+ "1. Correctness & logic — wrong results, edge cases, off-by-one, error handling.",
1288
+ "2. Security & trust boundaries — trace EVERY place external or target-controlled input, config, or code influences execution, file writes, network, or process spawning, and confirm each is confined. Untrusted code or config that runs with insufficient isolation is a blocker.",
1289
+ "3. Concurrency & races — shared state, ordering, cleanup-vs-use races.",
1290
+ "4. Resource, recovery & process lifecycle — timeouts, orphaned/daemonized processes, unbounded growth, cleanup ordering, and every failure/cancellation path.",
1291
+ "5. Test coverage adequacy — is every material claim backed by a test that would FAIL if the claim were false? Name the specific missing test; a claim asserted but not exercised by a test is a blocker.",
1292
+ "6. Scope & spec compliance — does the delivered code match the blueprint's stated decisions and claims?",
1293
+ "7. Documentation & lifecycle accuracy — do the blueprint's stated status, evidence, and verified-head match reality?",
1294
+ "",
1295
+ "For EVERY finding, include all three of:",
1296
+ "- Trigger: a concrete scenario or input that produces the failure. If you cannot name one, do not flag it as a blocker.",
1297
+ "- Severity: BLOCKER (must fix to ship) or NIT (non-blocking), plus a one-line confidence note.",
1298
+ "- Fix: the concrete change to make; when there is a real tradeoff, give 2 options with their costs so the author can choose and fix in one pass.",
1299
+ "",
1300
+ "Only flag as BLOCKER what a concrete failing scenario justifies. When impact is high (data loss, security, corruption) but your confidence is limited, still report it as a BLOCKER and state explicitly what remains uncertain — do not omit it.",
1301
+ "",
1302
+ "Before the verdict, do a COMPLETENESS pass: list what you did NOT inspect or could not verify that might still hide a blocker. If that list contains a plausible high-impact gap, convert it into a finding rather than leaving it for a later round.",
1303
+ ...(input.purpose === "delivery"
1304
+ ? [
1305
+ "",
1306
+ "Blueprint lifecycle scope: this delivery review judges the CODE at the target SHA. Blueprint lifecycle advancement — moving tasks to `done`, checking acceptance criteria, and transitioning the blueprint out of `draft` into `completed/` — happens AFTER this approval, at landing. A blueprint that is still `status: draft` with `todo` tasks and unchecked acceptance is EXPECTED here and is NOT a blocker: the lifecycle audit requires a draft to have zero checked acceptance, and a blueprint cannot leave `draft` without the very approval this review would grant. Do verify that the blueprint's stated verified-head matches the target and that its documented decisions match the delivered code; do NOT block on draft/todo lifecycle state.",
1307
+ ]
1308
+ : []),
1309
+ "",
1269
1310
  "End with exactly one standalone verdict line: APPROVED, APPROVE-WITH-NITS, or BLOCKED.",
1270
- "APPROVE-WITH-NITS satisfies the gate only when every finding is non-blocking.",
1311
+ "APPROVE-WITH-NITS satisfies the gate only when every finding is a NIT and carries a proposed fix.",
1271
1312
  "If any unresolved correctness, security, recovery, test, or scope blocker remains, end with BLOCKED.",
1272
1313
  ].join("\n");
1273
1314
  }
@@ -1886,7 +1927,7 @@ function rejectedReviewSubjects(input) {
1886
1927
  */
1887
1928
  export function reviewGateNonConvergenceHint(slug, context = {}) {
1888
1929
  if (context.status === "scope-exhausted") {
1889
- return `hint: formal review budget is exhausted for ${slug}. Stop automated review retries and obtain an explicit human scope decision before creating a successor blueprint.`;
1930
+ return `hint: formal review budget is exhausted for ${slug} after ${REVIEW_SUBJECT_REJECTION_LIMIT} rejected semantic subjects. Stop automated review retries. If you have addressed the prior findings and this is a good-faith converging attempt, a human may record an explicit scope decision and continue reviewing this blueprint in place by setting WP_REVIEW_SCOPE_CONTINUE=1 — no successor blueprint is needed.`;
1890
1931
  }
1891
1932
  if (context.failureCode === "provider-event-too-large" && context.gateId) {
1892
1933
  return `hint: provider transport emitted a single provider JSON event above the bounded capture limit for ${slug}; no review verdict was produced. Retry the unchanged gate with "wp review gate ${slug} --resume ${context.gateId} --provider <provider>" and select a different provider when possible. Retrying the same provider/model is allowed for recovery but is likely to recur for deterministic oversized output. An independent "wp review run" can be recorded only as a human-adjudicated verdict through wp_blueprint_review_log; that tool cannot create a Claude/Codex/Grok/OpenCode-Go approval. The event-size and timeout bounds were not increased. See catalog/agent/rules/pre-implementation.md.`;
@@ -2114,7 +2155,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2114
2155
  // (verifyDeliveryPromotionGates is a no-op for every other purpose).
2115
2156
  const deliveryCheckout = purpose === "delivery" ? ensureSharedCheckout() : undefined;
2116
2157
  if (deliveryCheckout) {
2117
- verification = verifyDeliveryPromotionGates({
2158
+ verification = await verifyDeliveryPromotionGates({
2118
2159
  purpose,
2119
2160
  blueprintContent,
2120
2161
  projectRoot,
@@ -2122,6 +2163,9 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2122
2163
  checkout: deliveryCheckout,
2123
2164
  ...(dependencies.signal ? { signal: dependencies.signal } : {}),
2124
2165
  });
2166
+ if (verification?.sandbox === "disabled-by-operator") {
2167
+ process.stderr.write("WARNING: WP_REVIEW_UNSANDBOXED_GATES=1 — delivery gates ran WITHOUT OS isolation (recorded as disabled-by-operator; approval reuse is disabled so this run publishes committed evidence).\n");
2168
+ }
2125
2169
  const summary = verificationPromptSummary(verification);
2126
2170
  if (summary) {
2127
2171
  prompt = renderPrompt(summary);
@@ -2135,7 +2179,11 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2135
2179
  }
2136
2180
  }
2137
2181
  let approvalGate = currentApprovalGate();
2138
- if (approvalGate.satisfied) {
2182
+ // Under an unsandboxed operator override, never take the approval-reuse
2183
+ // fast path: force a fresh provider review so the run publishes a
2184
+ // committed artifact carrying `Sandbox: disabled-by-operator`. (The
2185
+ // override is also recorded in the persisted gate state on every path.)
2186
+ if (approvalGate.satisfied && verification?.sandbox !== "disabled-by-operator") {
2139
2187
  const result = makeResult(approvalGate, reusedApprovals.length > 0 ? "approved" : "recorded-untracked");
2140
2188
  assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2141
2189
  persistGateState(projectRoot, result);
@@ -2157,15 +2205,30 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2157
2205
  cleanupSharedCheckout();
2158
2206
  return result;
2159
2207
  }
2160
- if (rejectedSubjects.length >= 2) {
2161
- const result = makeResult(approvalGate, "scope-exhausted", "Formal review budget exhausted after two rejected semantic subjects. No review provider was invoked; stop and obtain an explicit human scope decision before creating a successor blueprint.");
2208
+ // Anti-runaway budget cap. Identical resubmissions are already
2209
+ // short-circuited for free by the currentSubjectRejection check above, so
2210
+ // this only ever fires on GENUINELY DIFFERENT (converging) subjects —
2211
+ // i.e. changes that addressed the prior findings. That is legitimate
2212
+ // iteration, not thrash, so a human MAY record an explicit in-place scope
2213
+ // decision to continue reviewing THIS blueprint instead of being forced to
2214
+ // fork a successor (which would only launder the counter). Automated runs
2215
+ // do not set the flag and still hard-stop.
2216
+ if (rejectedSubjects.length >= REVIEW_SUBJECT_REJECTION_LIMIT &&
2217
+ process.env["WP_REVIEW_SCOPE_CONTINUE"] !== "1") {
2218
+ const result = makeResult(approvalGate, "scope-exhausted", `Formal review budget exhausted after ${REVIEW_SUBJECT_REJECTION_LIMIT} rejected semantic subjects; no reviewer was invoked. If you have addressed the prior findings and this is a good-faith converging attempt, set WP_REVIEW_SCOPE_CONTINUE=1 to record an explicit human scope decision and continue reviewing this blueprint — no successor blueprint is needed. Automated runs must stop and escalate.`);
2162
2219
  assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2163
2220
  persistGateState(projectRoot, result);
2164
2221
  cleanupSharedCheckout();
2165
2222
  return result;
2166
2223
  }
2224
+ if (rejectedSubjects.length >= REVIEW_SUBJECT_REJECTION_LIMIT) {
2225
+ // Recorded for audit: the operator explicitly extended the budget past
2226
+ // the cap for this converging attempt. The reviewer's verdict is still
2227
+ // required — the approval gate itself is unchanged.
2228
+ gateIssues.push(`human scope-continue: WP_REVIEW_SCOPE_CONTINUE=1 extended the review budget past ${rejectedSubjects.length} rejected subjects`);
2229
+ }
2167
2230
  if (rejectedSubjects.length > 0) {
2168
- prompt = renderPrompt(verification ? verificationPromptSummary(verification) : undefined, rejectedSubjects.slice(0, 2));
2231
+ prompt = renderPrompt(verification ? verificationPromptSummary(verification) : undefined, rejectedSubjects.slice(0, REVIEW_SUBJECT_REJECTION_LIMIT));
2169
2232
  }
2170
2233
  const artifactDirectory = path.join(path.dirname(location.path), "review-artifacts");
2171
2234
  if (existsSync(artifactDirectory) && lstatSync(artifactDirectory).isSymbolicLink()) {
@@ -24,7 +24,7 @@ export const TYPECHECK_COMMAND_HELP = [
24
24
  " wp typecheck --affected --branch # changed vs origin/${GITHUB_BASE_REF:-main}",
25
25
  " wp typecheck --pretty",
26
26
  " wp typecheck --tests # test-surface lane (tsconfig.test.json); required in",
27
- " # not part of the default gate or ci-preflight yet",
27
+ " # ci-preflight and CI (not the default untargeted gate)",
28
28
  ].join("\n");
29
29
  export function registerTypecheckCommand(cli, deps = {}) {
30
30
  addAffectedOptions(cli
@@ -55,6 +55,12 @@ export interface MergeCleanupDecisionInput {
55
55
  readonly repoDirty: boolean;
56
56
  readonly targetDirty: boolean;
57
57
  readonly stashPrimary?: boolean;
58
+ /**
59
+ * When target is dirty, optional porcelain -z + slug enrich the refusal message
60
+ * (scaffold-only vs other dirt). Omitted messages stay generic for unit tests.
61
+ */
62
+ readonly targetPorcelainZ?: string;
63
+ readonly targetBlueprintSlug?: string;
58
64
  }
59
65
  export interface NewWorktreeTargetInput {
60
66
  branch?: string;
@@ -97,4 +103,6 @@ export interface CreateManagedWorktreeResult {
97
103
  export declare function formatWorktreeList(entries: WorktreeEntry[], currentWorktreePath: string): string[];
98
104
  export declare function formatManagedWorktreeList(entries: readonly ManagedWorktreeEntry[]): string[];
99
105
  export declare function createManagedWorktree(opts?: CreateManagedWorktreeOptions): Promise<CreateManagedWorktreeResult>;
106
+ /** `.../blueprints/<slug>/owner` → slug */
107
+ export declare function inferBlueprintSlugFromOwnerPath(ownerPath: string): string | undefined;
100
108
  export declare function executeWorktreeSubcommand(subcommand: string, args: string[], opts: WorktreeCommandOptions): Promise<void>;