@webpresso/agent-kit 3.3.1 → 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 (46) hide show
  1. package/catalog/agent/rules/pre-implementation.md +15 -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/mutations.js +2 -3
  16. package/dist/esm/cli/commands/release-progress.d.ts +53 -0
  17. package/dist/esm/cli/commands/release-progress.js +155 -0
  18. package/dist/esm/cli/commands/review.js +74 -11
  19. package/dist/esm/cli/utils.d.ts +8 -1
  20. package/dist/esm/cli/utils.js +12 -2
  21. package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
  22. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
  23. package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
  24. package/dist/esm/review/delivery-verifier.d.ts +33 -2
  25. package/dist/esm/review/delivery-verifier.js +130 -24
  26. package/dist/esm/review/execution/review-checkout.d.ts +1 -0
  27. package/dist/esm/review/execution/review-checkout.js +386 -23
  28. package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
  29. package/dist/esm/review/execution/sandbox/adapter.js +39 -0
  30. package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
  31. package/dist/esm/review/execution/sandbox/env.js +37 -0
  32. package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
  33. package/dist/esm/review/execution/sandbox/index.js +4 -0
  34. package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
  35. package/dist/esm/review/execution/sandbox/policy.js +95 -0
  36. package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
  37. package/dist/esm/review/execution/sandbox/probe.js +97 -0
  38. package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
  39. package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
  40. package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
  41. package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
  42. package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
  43. package/dist/esm/review/execution/sandbox/types.js +6 -0
  44. package/dist/esm/review/execution/supervisor.js +44 -5
  45. package/dist/esm/review/execution/types.d.ts +2 -0
  46. package/package.json +13 -12
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Classify a GitHub Actions Release run into operator-facing progress phases.
3
+ *
4
+ * After Version Packages merge, agents must not sit on silent `npm view` polls:
5
+ * merge, native matrix, and npm publish are separate states. This pure helper
6
+ * maps job list snapshots (from `gh run view --json jobs`) into those states.
7
+ */
8
+ const NATIVE_JOB_PREFIX = "Session memory native";
9
+ function isNativeJob(name) {
10
+ return name.startsWith(NATIVE_JOB_PREFIX);
11
+ }
12
+ function isFailure(conclusion) {
13
+ return conclusion === "failure" || conclusion === "cancelled" || conclusion === "timed_out";
14
+ }
15
+ function isSuccess(conclusion) {
16
+ return conclusion === "success" || conclusion === "skipped";
17
+ }
18
+ /**
19
+ * Classify release workflow jobs into a single progress phase for agent/ops reporting.
20
+ */
21
+ export function classifyReleaseRunProgress(jobs) {
22
+ const nativeJobs = jobs.filter((job) => isNativeJob(job.name));
23
+ const detect = jobs.find((job) => job.name === "Detect publishable versions");
24
+ const release = jobs.find((job) => job.name === "Release");
25
+ const nativeDone = nativeJobs.filter((job) => job.status === "completed" && isSuccess(job.conclusion)).length;
26
+ const nativeFailed = nativeJobs.filter((job) => job.status === "completed" && isFailure(job.conclusion)).length;
27
+ const nativeInProgress = nativeJobs.filter((job) => job.status === "in_progress").length;
28
+ const native = {
29
+ done: nativeDone,
30
+ total: nativeJobs.length,
31
+ failed: nativeFailed,
32
+ inProgress: nativeInProgress,
33
+ };
34
+ const publishRunning = release?.status === "in_progress";
35
+ const publishDone = release?.status === "completed" && isSuccess(release.conclusion);
36
+ const publishFailed = release?.status === "completed" && isFailure(release.conclusion);
37
+ if (nativeFailed > 0 || publishFailed) {
38
+ return {
39
+ phase: "failed",
40
+ detail: nativeFailed > 0
41
+ ? `Native matrix failed (${nativeFailed} cell(s))`
42
+ : `Release/publish job failed (${release?.conclusion ?? "unknown"})`,
43
+ native,
44
+ publishRunning,
45
+ publishDone,
46
+ publishFailed,
47
+ };
48
+ }
49
+ if (publishDone) {
50
+ return {
51
+ phase: "done",
52
+ detail: "Release job completed successfully (check npm for version)",
53
+ native,
54
+ publishRunning,
55
+ publishDone,
56
+ publishFailed,
57
+ };
58
+ }
59
+ if (publishRunning || (native.total > 0 && nativeDone === native.total && release)) {
60
+ return {
61
+ phase: "publish",
62
+ detail: publishRunning
63
+ ? "Native matrix complete; Release/publish job in progress"
64
+ : "Native matrix complete; waiting for Release job",
65
+ native,
66
+ publishRunning,
67
+ publishDone,
68
+ publishFailed,
69
+ };
70
+ }
71
+ if (native.total > 0 && (nativeInProgress > 0 || nativeDone < native.total)) {
72
+ return {
73
+ phase: "native-matrix",
74
+ detail: `Building session-memory natives ${nativeDone}/${native.total} complete` +
75
+ (nativeInProgress > 0 ? ` (${nativeInProgress} in progress)` : ""),
76
+ native,
77
+ publishRunning,
78
+ publishDone,
79
+ publishFailed,
80
+ };
81
+ }
82
+ if (detect && detect.status !== "completed") {
83
+ return {
84
+ phase: "detect",
85
+ detail: "Detecting whether this run will publish",
86
+ native,
87
+ publishRunning,
88
+ publishDone,
89
+ publishFailed,
90
+ };
91
+ }
92
+ return {
93
+ phase: "detect",
94
+ detail: "Release run starting",
95
+ native,
96
+ publishRunning,
97
+ publishDone,
98
+ publishFailed,
99
+ };
100
+ }
101
+ /** Format one line for agent/ops logs (merge vs matrix vs publish). */
102
+ export function formatReleaseProgressLine(progress) {
103
+ return `release-progress phase=${progress.phase} native=${progress.native.done}/${progress.native.total} ${progress.detail}`;
104
+ }
105
+ /**
106
+ * Parse `gh run view <id> --json jobs` (or full run object with `jobs`) into snapshots.
107
+ * Production CLI and tests share this path so classification is never reimplemented.
108
+ */
109
+ export function jobsFromGhRunViewJson(payload) {
110
+ if (payload == null || typeof payload !== "object") {
111
+ throw new Error("gh run view JSON must be an object with a jobs array");
112
+ }
113
+ const record = payload;
114
+ const jobsRaw = Array.isArray(payload)
115
+ ? payload
116
+ : Array.isArray(record.jobs)
117
+ ? record.jobs
118
+ : null;
119
+ if (!jobsRaw) {
120
+ throw new Error("gh run view JSON must include a jobs array");
121
+ }
122
+ return jobsRaw.map((entry, index) => {
123
+ if (entry == null || typeof entry !== "object") {
124
+ throw new Error(`jobs[${index}] must be an object`);
125
+ }
126
+ const job = entry;
127
+ const name = typeof job.name === "string" ? job.name : "";
128
+ const status = typeof job.status === "string" ? job.status : "";
129
+ if (!name || !status) {
130
+ throw new Error(`jobs[${index}] requires string name and status`);
131
+ }
132
+ const conclusion = job.conclusion === null || job.conclusion === undefined
133
+ ? null
134
+ : typeof job.conclusion === "string"
135
+ ? job.conclusion
136
+ : null;
137
+ return { name, status, conclusion };
138
+ });
139
+ }
@@ -5,7 +5,7 @@
5
5
  * Lazy-loads subcommand modules based on the first argv to keep startup
6
6
  * cheap. Modeled on apps/cli-wp/src/cli.ts.
7
7
  */
8
- declare const SUPPORTED_COMMANDS: readonly ["blueprint", "browser", "dash", "dash-helper-start", "dash-helper-complete", "claude", "codex", "grok", "opencode", "yolo", "config", "secrets", "roadmap", "status", "review", "sync", "audit", "qa", "ultragoal", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "deploy", "preview", "cleanup", "migrate", "doctor", "err", "test", "e2e", "ci", "ci-preflight", "public:readiness", "typecheck", "lint", "format", "rust-check", "logs", "session-id", "session-info", "session", "tech-debt", "pr", "fleet", "worktree", "mcp", "hook", "hooks", "self-install-guard", "optional-tool-refresh", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
8
+ declare const SUPPORTED_COMMANDS: readonly ["blueprint", "browser", "dash", "dash-helper-start", "dash-helper-complete", "claude", "codex", "grok", "opencode", "yolo", "config", "secrets", "roadmap", "status", "review", "sync", "audit", "qa", "ultragoal", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "deploy", "preview", "cleanup", "migrate", "doctor", "err", "test", "e2e", "ci", "ci-preflight", "release-progress", "public:readiness", "typecheck", "lint", "format", "rust-check", "logs", "session-id", "session-info", "session", "tech-debt", "pr", "fleet", "worktree", "mcp", "hook", "hooks", "self-install-guard", "optional-tool-refresh", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
9
9
  export type SupportedCommand = (typeof SUPPORTED_COMMANDS)[number];
10
10
  type RootHelpEntry = {
11
11
  readonly command: string;
@@ -55,6 +55,7 @@ const SUPPORTED_COMMANDS = [
55
55
  "e2e",
56
56
  "ci",
57
57
  "ci-preflight",
58
+ "release-progress",
58
59
  "public:readiness",
59
60
  "typecheck",
60
61
  "lint",
@@ -118,6 +119,10 @@ const ROOT_HELP_CORE_ENTRIES = [
118
119
  command: "ci-preflight",
119
120
  description: "Run the locally-runnable required CI checks fail-fast (the pre-push gate)",
120
121
  },
122
+ {
123
+ command: "release-progress",
124
+ description: "Report Release workflow phase (detect / native-matrix / publish) instead of silent npm view waits",
125
+ },
121
126
  {
122
127
  command: "public:readiness",
123
128
  description: "Run the public package readiness gate through the portable wp surface",
@@ -375,6 +380,10 @@ const COMMAND_REGISTRARS = {
375
380
  const { registerCiPreflightCommand } = await import("./commands/ci-preflight.js");
376
381
  registerCiPreflightCommand(cli);
377
382
  },
383
+ "release-progress": async (cli) => {
384
+ const { registerReleaseProgressCommand } = await import("./commands/release-progress.js");
385
+ registerReleaseProgressCommand(cli);
386
+ },
378
387
  "public:readiness": async (cli) => {
379
388
  const { registerPublicReadinessCommand } = await import("./commands/public-readiness.js");
380
389
  registerPublicReadinessCommand(cli);
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import { tmpdir } from "node:os";
18
18
  import path from "node:path";
19
19
  import { parseBlueprint } from "#core/parser";
20
- import { escapeRegex } from "#utils/string";
20
+ import { buildTaskHeaderRegexForId } from "#markdown/task-heading";
21
21
  import { openDb } from "#db/connection.js";
22
22
  import { resolveBlueprintProjectionDbPath, withMarkdownWriteLock } from "#db/paths.js";
23
23
  import { getBlueprintDocumentPaths } from "#utils/document-paths.js";
@@ -209,8 +209,7 @@ function upsertCompletedAt(content, isoDate) {
209
209
  * Returns { lineIndex, currentStatus } or null if not found.
210
210
  */
211
211
  function findTaskStatusLine(lines, taskId) {
212
- const escapedId = escapeRegex(taskId);
213
- const taskPattern = new RegExp(`^####\\s+(?:\\[[^\\]]+\\]\\s+)?Task\\s+${escapedId}[:\\s]`);
212
+ const taskPattern = buildTaskHeaderRegexForId(taskId);
214
213
  let inBlock = false;
215
214
  for (let i = 0; i < lines.length; i++) {
216
215
  const line = lines[i] ?? "";
@@ -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()) {
@@ -15,7 +15,14 @@ interface GetProjectRootOptions {
15
15
  }
16
16
  /**
17
17
  * Walks upward from startDir looking for any marker in
18
- * `PROJECT_ROOT_MARKERS` (priority order). Throws if nothing is found.
18
+ * `PROJECT_ROOT_MARKERS` (priority order). The walk is clamped at the
19
+ * enclosing git repository boundary: a directory containing `.git` (dir for
20
+ * a normal checkout, file for a linked worktree or submodule) is itself a
21
+ * project root when no marker matched at or below it. Without the clamp, a
22
+ * markerless git repo silently resolves to whatever ancestor happens to
23
+ * carry a `package.json` (e.g. editor-tooling junk in `$HOME`) and commands
24
+ * like `wp blueprint new` write outside the repo. Throws if neither a
25
+ * marker nor a git boundary is found.
19
26
  */
20
27
  export declare function findProjectRoot(startDir: string): string;
21
28
  export declare function getProjectRoot(options?: GetProjectRootOptions): string;
@@ -35,7 +35,14 @@ function findMarker(rootDir) {
35
35
  }
36
36
  /**
37
37
  * Walks upward from startDir looking for any marker in
38
- * `PROJECT_ROOT_MARKERS` (priority order). Throws if nothing is found.
38
+ * `PROJECT_ROOT_MARKERS` (priority order). The walk is clamped at the
39
+ * enclosing git repository boundary: a directory containing `.git` (dir for
40
+ * a normal checkout, file for a linked worktree or submodule) is itself a
41
+ * project root when no marker matched at or below it. Without the clamp, a
42
+ * markerless git repo silently resolves to whatever ancestor happens to
43
+ * carry a `package.json` (e.g. editor-tooling junk in `$HOME`) and commands
44
+ * like `wp blueprint new` write outside the repo. Throws if neither a
45
+ * marker nor a git boundary is found.
39
46
  */
40
47
  export function findProjectRoot(startDir) {
41
48
  let current = path.resolve(startDir);
@@ -43,9 +50,12 @@ export function findProjectRoot(startDir) {
43
50
  if (findMarker(current)) {
44
51
  return current;
45
52
  }
53
+ if (existsSync(path.join(current, ".git"))) {
54
+ return current;
55
+ }
46
56
  const parent = path.dirname(current);
47
57
  if (parent === current) {
48
- throw new Error(`Could not find project root (looked for ${PROJECT_ROOT_MARKERS.join(", ")}). Started from: ${startDir}`);
58
+ throw new Error(`Could not find project root (looked for ${PROJECT_ROOT_MARKERS.join(", ")} or a .git boundary). Started from: ${startDir}`);
49
59
  }
50
60
  current = parent;
51
61
  }
@@ -1,5 +1,6 @@
1
1
  import { parse as parseYaml } from "yaml";
2
2
  import { getContent, getFilePath } from "#hooks/shared/types";
3
+ import { buildTaskHeadingRegex, buildWrongDepthTaskHeadingRegex } from "#markdown/task-heading";
3
4
  import { getNonCanonicalPlanningPathViolation, isBlueprintPath, isCanonicalBlueprintDocumentPath, } from "./path-contract.js";
4
5
  import { createSkipResult } from "./skip-result.js";
5
6
  // Keep aligned with webpresso/blueprint planStatusSchema + plan type enum.
@@ -53,10 +54,10 @@ export function collectFieldViolations(data) {
53
54
  return violations;
54
55
  }
55
56
  export function countTaskHeadings(content) {
56
- return content.match(/^####\s+Task\s+\d+(?:\.\d+)+:/gm)?.length ?? 0;
57
+ return content.match(buildTaskHeadingRegex("gm"))?.length ?? 0;
57
58
  }
58
59
  export function detectWrongTaskFormat(content) {
59
- return content.match(/^###\s+Task\s+\d+(?:\.\d+)+:/gm)?.length ?? 0;
60
+ return content.match(buildWrongDepthTaskHeadingRegex("gm"))?.length ?? 0;
60
61
  }
61
62
  export function validatePlanFrontmatter(input) {
62
63
  const filePath = getFilePath(input);