@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
@@ -227,16 +227,23 @@ Ultragoal events, or approval frontmatter.
227
227
  killed writer is never overwritten. A complete content rejection is terminal
228
228
  for that unchanged target, while active provider limits/unavailability remain
229
229
  suppressed until their recorded retry window permits another attempt.
230
- - **Completed rejections have a two-subject circuit breaker.** Plan review
230
+ - **Rejections have a convergence-aware subject budget (4).** Plan review
231
231
  lineage uses the canonical `wp-blueprint-v1` subject, so lifecycle/trust
232
232
  bookkeeping commits reuse the prior rejection and do not invoke another
233
- reviewer. One semantically changed remediation subject may receive the final
234
- formal review; its prompt includes the prior rejection artifact and requires
235
- all remaining blockers together. After two distinct semantic subjects are
236
- rejected, `wp review gate` returns `scope-exhausted` before provider
237
- invocation. Stop automated retries and obtain an explicit human scope
238
- decision before creating a successor blueprint. Plan and delivery purposes
239
- have separate budgets; delivery retains its existing whole-tree subject.
233
+ reviewer. An identical resubmission of an already-rejected subject is likewise
234
+ reused for free (no reviewer call). Each genuinely different converging
235
+ subject one that addressed the prior findings is reviewed on its own, and
236
+ its prompt includes the prior rejection artifacts. After **four** distinct
237
+ semantic subjects are rejected, `wp review gate` returns `scope-exhausted`
238
+ before provider invocation. This is an anti-runaway cap, not a hard fork
239
+ point: automated runs must stop and escalate, but a human who judges the
240
+ attempt to be good-faith convergence may extend the budget **in place** by
241
+ setting `WP_REVIEW_SCOPE_CONTINUE=1` (recorded as an audit gate-issue) and keep
242
+ reviewing THIS blueprint — creating a successor blueprint only launders the
243
+ counter and is not required. The approval gate itself is unchanged: the
244
+ reviewer's verdict is still required under scope-continue. Plan and delivery
245
+ purposes have separate budgets; delivery retains its existing whole-tree
246
+ subject.
240
247
  - **`provider-event-too-large` means one captured provider JSON event exceeded
241
248
  the bounded event limit; it is not a reviewer verdict.** Keep the bound in
242
249
  place, retry the same gate id, and switch provider/model when possible because
@@ -109,24 +109,11 @@ Resolve models from the live catalog every time:
109
109
  opencode models opencode-go
110
110
  ```
111
111
 
112
- Pick by purpose (same intent as the committed OpenCode reviewer policy / `opencode-go` skill):
113
-
114
- | Purpose | Preferred family (live catalog) | Why |
115
- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------- |
116
- | Plan / architecture critique | DeepSeek **Pro** (`opencode-go/deepseek…-pro`) | Deeper plan review depth |
117
- | Coding / implementation quality | Kimi **Code** first (`…kimi…-code`) | Coding quality lane |
118
- | Cheap recheck / high-availability follow-up | DeepSeek **Flash** (`…deepseek…-flash`) | Fast secondary pass |
119
- | Aggregate / unspecified | Follow `opencode-go` skill order: Kimi Code → DeepSeek Pro → DeepSeek Flash → remaining families | Default outside-voice lane |
120
-
121
- Resolve the actual model ID with catalog greps (never paste a fixed model string into agent instructions as the only option):
122
-
123
- ```bash
124
- CATALOG=$(opencode models opencode-go)
125
- # plan critique example:
126
- MODEL=$(echo "$CATALOG" | grep '^opencode-go/deepseek' | grep -- '-pro$' | sort -V | tail -1)
127
- # coding example:
128
- # MODEL=$(echo "$CATALOG" | grep '^opencode-go/kimi' | grep -- '-code$' | sort -V | tail -1)
129
- ```
112
+ **Selection order and the purpose→family mapping live in one place: the committed
113
+ reviewer policy (`packages/workflow-skills/src/opencode-reviewer-policy.ts`),
114
+ rendered into the `opencode-go` skill and drift-gated against it.** Follow that
115
+ skill; do not restate the ordering here. A second copy of a preference list is a
116
+ second thing to update, and the copy that goes stale is the one an agent reads.
130
117
 
131
118
  Treat a provider-wide usage limit as terminal for every OpenCode Go model in the current review window; do not hop families to evade a usage block on the same provider.
132
119
 
@@ -1,6 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { executionBackendSchema } from "#types/execution-backend";
3
- const TASK_HEADING_REGEX = /^####\s+Task\s+(\d+\.\d+):/;
3
+ import { buildTaskHeadingRegex } from "#markdown/task-heading";
4
+ /**
5
+ * Non-global on purpose: this regex is tested line-by-line via `.exec`, and a
6
+ * shared `g`-flagged instance would carry `lastIndex` between lines and skip
7
+ * alternating headings.
8
+ */
9
+ const TASK_HEADING_REGEX = buildTaskHeadingRegex("m");
4
10
  /**
5
11
  * Zod schema for optional TASK-level frontmatter fields.
6
12
  *
@@ -5,3 +5,24 @@ export declare function buildTaskHeadingRegex(flags?: string): RegExp;
5
5
  export declare function buildTaskHeaderRegexForId(taskId: string, flags?: string): RegExp;
6
6
  export declare function buildTaskSectionBoundaryRegex(flags?: string): RegExp;
7
7
  export declare function isTaskHeaderLine(line: string): boolean;
8
+ /**
9
+ * Detect a task heading written at the wrong depth (`###` instead of `####`).
10
+ *
11
+ * Shares {@link TASK_HEADING_PREFIX_PATTERN} and {@link TASK_ID_PATTERN} with
12
+ * the canonical matcher so the "you used three hashes" diagnostic can never
13
+ * drift from the grammar it is diagnosing against — a detector built from a
14
+ * private copy of the pattern silently stops recognizing headings the moment
15
+ * the real grammar gains a prefix or id shape.
16
+ */
17
+ export declare function buildWrongDepthTaskHeadingRegex(flags?: string): RegExp;
18
+ /**
19
+ * Serialize a task heading. The single writer-side counterpart to
20
+ * {@link buildTaskHeadingRegex}: every emitter goes through here so the
21
+ * written form and the parsed form cannot diverge.
22
+ *
23
+ * Lane is deliberately not a parameter. The reader accepts an optional
24
+ * `[lane]` heading prefix for hand-authored blueprints, but the writer emits
25
+ * lane as a `**Lane:**` body line instead, so round-tripping never has to
26
+ * reconstruct a prefix the writer does not produce.
27
+ */
28
+ export declare function formatTaskHeading(taskId: string, title: string): string;
@@ -16,3 +16,28 @@ export function buildTaskSectionBoundaryRegex(flags = "m") {
16
16
  export function isTaskHeaderLine(line) {
17
17
  return buildTaskHeadingRegex("m").test(line);
18
18
  }
19
+ /**
20
+ * Detect a task heading written at the wrong depth (`###` instead of `####`).
21
+ *
22
+ * Shares {@link TASK_HEADING_PREFIX_PATTERN} and {@link TASK_ID_PATTERN} with
23
+ * the canonical matcher so the "you used three hashes" diagnostic can never
24
+ * drift from the grammar it is diagnosing against — a detector built from a
25
+ * private copy of the pattern silently stops recognizing headings the moment
26
+ * the real grammar gains a prefix or id shape.
27
+ */
28
+ export function buildWrongDepthTaskHeadingRegex(flags = "gm") {
29
+ return new RegExp(`^###\\s+${TASK_HEADING_PREFIX_PATTERN}Task\\s+(${TASK_ID_PATTERN}):`, flags);
30
+ }
31
+ /**
32
+ * Serialize a task heading. The single writer-side counterpart to
33
+ * {@link buildTaskHeadingRegex}: every emitter goes through here so the
34
+ * written form and the parsed form cannot diverge.
35
+ *
36
+ * Lane is deliberately not a parameter. The reader accepts an optional
37
+ * `[lane]` heading prefix for hand-authored blueprints, but the writer emits
38
+ * lane as a `**Lane:**` body line instead, so round-tripping never has to
39
+ * reconstruct a prefix the writer does not produce.
40
+ */
41
+ export function formatTaskHeading(taskId, title) {
42
+ return `#### Task ${taskId}: ${title}`;
43
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  export declare const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30000;
3
3
  export declare const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
4
+ export declare const ALLOWED_WP_COMMAND_CAPTURE_LIMIT = 1000000;
4
5
  export type AllowedWpCommandOutcome = "passed" | "failed" | "timed-out";
5
6
  export interface AllowedWpCommandResult {
6
7
  readonly command: string;
@@ -21,6 +22,45 @@ export interface ExecuteAllowedWpCommandInput {
21
22
  readonly env?: NodeJS.ProcessEnv;
22
23
  readonly spawn?: typeof spawnSync;
23
24
  }
25
+ export interface ResolvedWpInvocation {
26
+ readonly command: string;
27
+ readonly args: string[];
28
+ readonly env: NodeJS.ProcessEnv;
29
+ }
30
+ export interface ResolveInvocationOptions {
31
+ /**
32
+ * Replace `process.env` with `extraEnv` as the base environment instead of
33
+ * spreading over it. Used by the sandboxed delivery path so caller secrets
34
+ * never reach the child; the promotion path keeps the default spread.
35
+ */
36
+ readonly replaceProcessEnv?: boolean;
37
+ }
38
+ export interface RunResolvedInvocationContext {
39
+ /** The original human-readable command string, for logging/reporting. */
40
+ readonly command: string;
41
+ readonly executionRoot: string;
42
+ readonly logRoot: string;
43
+ readonly startedAt: number;
44
+ readonly spawn?: typeof spawnSync;
45
+ readonly signal?: AbortSignal;
46
+ }
24
47
  export declare function executeAllowedWpCommand(input: ExecuteAllowedWpCommandInput): AllowedWpCommandResult;
48
+ /** Parses an allowed `wp` command string and resolves it to a spawnable invocation. */
49
+ export declare function resolveInvocationFromCommand(executionRoot: string, command: string, env: NodeJS.ProcessEnv | undefined, options?: ResolveInvocationOptions): ResolvedWpInvocation;
50
+ /** Builds the `command-policy-rejected` result (invocation could not be resolved). */
51
+ export declare function rejectedInvocationResult(command: string, logRoot: string, startedAt: number, error: unknown): AllowedWpCommandResult;
52
+ /** Runs a resolved invocation synchronously (promotion trust path). */
53
+ export declare function runResolvedInvocationSync(invocation: ResolvedWpInvocation, context: RunResolvedInvocationContext): AllowedWpCommandResult;
54
+ /**
55
+ * Runs a resolved invocation asynchronously with bounded process-tree
56
+ * termination. The child leads its own process group (`detached`), so a manual
57
+ * timeout, an abort signal, or normal completion all `SIGKILL` the whole group —
58
+ * reaping descendants a hostile gate may have forked. On Linux srt's bwrap
59
+ * `--unshare-pid` already reaps the sandbox PID namespace; this is the macOS
60
+ * backstop (a descendant that calls `setsid()` can still escape the group — a
61
+ * documented residual, contained by the disposable checkout and network deny).
62
+ */
63
+ export declare function runResolvedInvocationAsync(invocation: ResolvedWpInvocation, context: RunResolvedInvocationContext): Promise<AllowedWpCommandResult>;
25
64
  export declare function tailBounded(text: string): string;
26
65
  export declare function relativeCommandLogPath(root: string, logPath: string | undefined): string | undefined;
66
+ export declare function resolveAllowedWpInvocation(executionRoot: string, binary: string, args: readonly string[], extraEnv: NodeJS.ProcessEnv | undefined, options?: ResolveInvocationOptions): ResolvedWpInvocation;
@@ -1,70 +1,294 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn as spawnChild, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { parseAllowedWpCommand } from "./gates.js";
5
5
  import { resolvePackageAssetPreferred } from "#utils/package-assets.js";
6
6
  export const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30_000;
7
7
  export const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
8
+ // Per-stream capture ceiling. `spawnSync` enforced this implicitly via
9
+ // `maxBuffer` (default 1 MB); the async runner must replace that bound or a
10
+ // target-controlled gate can stream output fast enough to exhaust reviewer
11
+ // memory before the timeout fires. On breach we stop accumulating and kill the
12
+ // process group, mirroring spawnSync's ENOBUFS termination.
13
+ export const ALLOWED_WP_COMMAND_CAPTURE_LIMIT = 1_000_000;
8
14
  export function executeAllowedWpCommand(input) {
9
- const started = Date.now();
15
+ const startedAt = Date.now();
16
+ const context = {
17
+ command: input.command,
18
+ executionRoot: input.executionRoot,
19
+ logRoot: input.logRoot,
20
+ startedAt,
21
+ ...(input.spawn ? { spawn: input.spawn } : {}),
22
+ };
10
23
  let invocation;
11
24
  try {
12
- const argv = parseAllowedWpCommand(input.command);
13
- const [binary, ...args] = argv;
14
- if (binary === undefined)
15
- throw new Error(`Promotion gate command is empty: ${input.command}`);
16
- invocation = resolveAllowedWpInvocation(input.executionRoot, binary, args, input.env);
25
+ invocation = resolveInvocationFromCommand(input.executionRoot, input.command, input.env);
17
26
  }
18
27
  catch (error) {
19
- const message = error instanceof Error ? error.message : String(error);
20
- const result = {
21
- command: input.command,
22
- outcome: "failed",
23
- durationMs: Date.now() - started,
24
- exitCode: null,
25
- stdout: "",
26
- stderr: message,
27
- failureCode: "command-policy-rejected",
28
- errorMessage: message,
29
- };
30
- return {
31
- ...result,
32
- logPath: writeAllowedWpCommandLog(input.logRoot, input.command, null, result),
33
- };
28
+ return rejectedInvocationResult(input.command, input.logRoot, startedAt, error);
34
29
  }
35
- const spawn = input.spawn ?? spawnSync;
30
+ return runResolvedInvocationSync(invocation, context);
31
+ }
32
+ /** Parses an allowed `wp` command string and resolves it to a spawnable invocation. */
33
+ export function resolveInvocationFromCommand(executionRoot, command, env, options) {
34
+ const argv = parseAllowedWpCommand(command);
35
+ const [binary, ...args] = argv;
36
+ if (binary === undefined)
37
+ throw new Error(`Promotion gate command is empty: ${command}`);
38
+ return resolveAllowedWpInvocation(executionRoot, binary, args, env, options);
39
+ }
40
+ /** Builds the `command-policy-rejected` result (invocation could not be resolved). */
41
+ export function rejectedInvocationResult(command, logRoot, startedAt, error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ const result = {
44
+ command,
45
+ outcome: "failed",
46
+ durationMs: Date.now() - startedAt,
47
+ exitCode: null,
48
+ stdout: "",
49
+ stderr: message,
50
+ failureCode: "command-policy-rejected",
51
+ errorMessage: message,
52
+ };
53
+ return { ...result, logPath: writeAllowedWpCommandLog(logRoot, command, null, result) };
54
+ }
55
+ /** Runs a resolved invocation synchronously (promotion trust path). */
56
+ export function runResolvedInvocationSync(invocation, context) {
57
+ const spawn = context.spawn ?? spawnSync;
36
58
  const result = spawn(invocation.command, invocation.args, {
37
- cwd: input.executionRoot,
59
+ cwd: context.executionRoot,
38
60
  encoding: "utf8",
39
61
  stdio: ["ignore", "pipe", "pipe"],
40
62
  timeout: ALLOWED_WP_COMMAND_TIMEOUT_MS,
41
63
  env: invocation.env,
42
64
  });
43
- const durationMs = Date.now() - started;
44
65
  const stdout = typeof result.stdout === "string" ? result.stdout : "";
45
66
  const stderr = typeof result.stderr === "string" ? result.stderr : "";
46
67
  const timedOut = result.error ? errorCode(result.error) === "ETIMEDOUT" : false;
68
+ return shapeInvocationResult(context.command, invocation, {
69
+ status: typeof result.status === "number" ? result.status : null,
70
+ signal: result.signal ?? null,
71
+ error: result.error,
72
+ stdout,
73
+ stderr,
74
+ timedOut,
75
+ durationMs: Date.now() - context.startedAt,
76
+ }, context.logRoot);
77
+ }
78
+ /**
79
+ * Runs a resolved invocation asynchronously with bounded process-tree
80
+ * termination. The child leads its own process group (`detached`), so a manual
81
+ * timeout, an abort signal, or normal completion all `SIGKILL` the whole group —
82
+ * reaping descendants a hostile gate may have forked. On Linux srt's bwrap
83
+ * `--unshare-pid` already reaps the sandbox PID namespace; this is the macOS
84
+ * backstop (a descendant that calls `setsid()` can still escape the group — a
85
+ * documented residual, contained by the disposable checkout and network deny).
86
+ */
87
+ export function runResolvedInvocationAsync(invocation, context) {
88
+ return new Promise((resolve) => {
89
+ const child = spawnChild(invocation.command, invocation.args, {
90
+ cwd: context.executionRoot,
91
+ env: invocation.env,
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ detached: true,
94
+ });
95
+ let stdout = "";
96
+ let stderr = "";
97
+ let timedOut = false;
98
+ let captureExceeded = false;
99
+ let settled = false;
100
+ const appendBounded = (current, chunk) => {
101
+ if (current.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT)
102
+ return current;
103
+ return (current + chunk.toString("utf8")).slice(0, ALLOWED_WP_COMMAND_CAPTURE_LIMIT);
104
+ };
105
+ const enforceCapture = () => {
106
+ if (!captureExceeded &&
107
+ (stdout.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT ||
108
+ stderr.length >= ALLOWED_WP_COMMAND_CAPTURE_LIMIT)) {
109
+ captureExceeded = true;
110
+ killProcessGroup(child.pid);
111
+ }
112
+ };
113
+ child.stdout?.on("data", (chunk) => {
114
+ stdout = appendBounded(stdout, chunk);
115
+ enforceCapture();
116
+ });
117
+ child.stderr?.on("data", (chunk) => {
118
+ stderr = appendBounded(stderr, chunk);
119
+ enforceCapture();
120
+ });
121
+ const timer = setTimeout(() => {
122
+ timedOut = true;
123
+ killProcessGroup(child.pid);
124
+ }, ALLOWED_WP_COMMAND_TIMEOUT_MS);
125
+ let aborted = false;
126
+ const onAbort = () => {
127
+ aborted = true;
128
+ killProcessGroup(child.pid);
129
+ };
130
+ context.signal?.addEventListener("abort", onAbort, { once: true });
131
+ // Close the abort race: a signal that fired between the caller's pre-check
132
+ // and this listener registration does not replay, so recheck now and kill
133
+ // immediately if it is already aborted.
134
+ if (context.signal?.aborted)
135
+ onAbort();
136
+ const finish = (status, signal, error) => {
137
+ if (settled)
138
+ return;
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ context.signal?.removeEventListener("abort", onAbort);
142
+ killProcessGroup(child.pid);
143
+ // Confirm the process GROUP is actually dead before resolving, so the
144
+ // caller's sandbox/filesystem cleanup cannot race a descendant a hostile
145
+ // gate forked: SIGKILL is delivered above but reaping is asynchronous. A
146
+ // DETECTABLE survivor still in the group fails the gate (reapUnconfirmed,
147
+ // never silent). A descendant that ESCAPED the group (macOS `setsid()`) is
148
+ // invisible here — the documented, contained residual (see
149
+ // awaitProcessGroupExit).
150
+ void awaitProcessGroupExit(child.pid).then((reaped) => {
151
+ resolve(shapeInvocationResult(context.command, invocation, {
152
+ status,
153
+ signal,
154
+ error: error ? { message: error.message, code: errorCode(error) } : undefined,
155
+ stdout,
156
+ stderr,
157
+ timedOut,
158
+ captureExceeded,
159
+ aborted,
160
+ reapUnconfirmed: !reaped,
161
+ durationMs: Date.now() - context.startedAt,
162
+ }, context.logRoot));
163
+ });
164
+ };
165
+ let exitCode = null;
166
+ let exitSignal = null;
167
+ // B1: on macOS a setsid() descendant can leave the process group while still
168
+ // holding inherited stdout/stderr write ends. Node fires `exit` when the
169
+ // direct child ends, but `close` only after all stdio ends — so a survivor
170
+ // wedging the pipes would hang forever. On `exit`, start a stdio grace equal
171
+ // to PROCESS_GROUP_REAP_TIMEOUT_MS; if `close` has not settled by then,
172
+ // destroy the streams and finish anyway (reapUnconfirmed already reports
173
+ // detectable survivors).
174
+ let stdioGrace;
175
+ child.on("error", (error) => {
176
+ if (stdioGrace !== undefined)
177
+ clearTimeout(stdioGrace);
178
+ finish(null, null, error);
179
+ });
180
+ child.on("exit", (code, signal) => {
181
+ exitCode = code;
182
+ exitSignal = signal;
183
+ // Reap backgrounded descendants still holding the stdio pipes so `close`
184
+ // can fire promptly (a hostile gate may fork a child that outlives it).
185
+ killProcessGroup(child.pid);
186
+ if (settled)
187
+ return;
188
+ stdioGrace = setTimeout(() => {
189
+ if (settled)
190
+ return;
191
+ child.stdout?.destroy();
192
+ child.stderr?.destroy();
193
+ finish(exitCode, exitSignal);
194
+ }, PROCESS_GROUP_REAP_TIMEOUT_MS);
195
+ });
196
+ child.on("close", () => {
197
+ if (stdioGrace !== undefined)
198
+ clearTimeout(stdioGrace);
199
+ finish(exitCode, exitSignal);
200
+ });
201
+ });
202
+ }
203
+ // Poll interval and ceiling for confirming a killed process group has actually
204
+ // exited. Short interval; the normal case (no surviving descendant) resolves on
205
+ // the first probe. The ceiling bounds a wedged/escaped descendant.
206
+ const PROCESS_GROUP_REAP_POLL_MS = 20;
207
+ const PROCESS_GROUP_REAP_TIMEOUT_MS = 2_000;
208
+ function processGroupAlive(pid) {
209
+ try {
210
+ // Signal 0 to the NEGATIVE pid probes the whole group without delivering a
211
+ // signal: no error => a member is alive; ESRCH => the group is empty.
212
+ process.kill(-pid, 0);
213
+ return true;
214
+ }
215
+ catch (error) {
216
+ // EPERM means a member exists but is not signalable by us — treat as alive.
217
+ return error instanceof Error && errorCode(error) === "EPERM";
218
+ }
219
+ }
220
+ // Confirm a killed process group has actually exited. Returns true when the
221
+ // group is empty; false when a member is still alive after the ceiling — a
222
+ // DETECTABLE survivor that must surface as a failed verification, not be
223
+ // swallowed silently.
224
+ //
225
+ // macOS RESIDUAL: a descendant that calls `setsid()` LEAVES this group and
226
+ // reparents to launchd, so it is invisible here. macOS has no PID
227
+ // namespace/cgroup to track it, and `ps` cannot enumerate a descendant's env
228
+ // either (verified), so such a process cannot be guaranteed-reaped in
229
+ // unprivileged userspace. It is contained by the network deny (no exfiltration)
230
+ // and the disposable checkout (no persistence) and can only spin CPU until it
231
+ // exits. On Linux, srt's bwrap `--unshare-pid` reaps the whole tree, so this
232
+ // ceiling is never reached there.
233
+ async function awaitProcessGroupExit(pid) {
234
+ if (pid === undefined)
235
+ return true;
236
+ const deadline = Date.now() + PROCESS_GROUP_REAP_TIMEOUT_MS;
237
+ while (processGroupAlive(pid)) {
238
+ if (Date.now() >= deadline)
239
+ return false;
240
+ await new Promise((resolve) => {
241
+ setTimeout(resolve, PROCESS_GROUP_REAP_POLL_MS);
242
+ });
243
+ }
244
+ return true;
245
+ }
246
+ function shapeInvocationResult(command, invocation, parts, logRoot) {
47
247
  const output = {
48
- command: input.command,
49
- outcome: result.status === 0 && !result.error && result.signal === null
248
+ command,
249
+ outcome: parts.status === 0 &&
250
+ !parts.error &&
251
+ parts.signal === null &&
252
+ !parts.captureExceeded &&
253
+ !parts.aborted &&
254
+ !parts.reapUnconfirmed
50
255
  ? "passed"
51
- : timedOut
256
+ : parts.timedOut
52
257
  ? "timed-out"
53
258
  : "failed",
54
- durationMs,
55
- exitCode: typeof result.status === "number" ? result.status : null,
56
- ...(result.signal ? { signal: result.signal } : {}),
57
- stdout,
58
- stderr,
59
- ...(timedOut ? { failureCode: "command-timed-out" } : {}),
60
- ...(result.error ? { errorMessage: result.error.message } : {}),
259
+ durationMs: parts.durationMs,
260
+ exitCode: parts.status,
261
+ ...(parts.signal ? { signal: parts.signal } : {}),
262
+ stdout: parts.stdout,
263
+ stderr: parts.stderr,
264
+ ...(parts.aborted
265
+ ? { failureCode: "command-cancelled" }
266
+ : parts.timedOut
267
+ ? { failureCode: "command-timed-out" }
268
+ : parts.captureExceeded
269
+ ? { failureCode: "command-output-exceeded" }
270
+ : parts.reapUnconfirmed
271
+ ? { failureCode: "process-reap-unconfirmed" }
272
+ : {}),
273
+ ...(parts.error
274
+ ? { errorMessage: parts.error.message }
275
+ : parts.captureExceeded
276
+ ? { errorMessage: "gate output exceeded the capture limit (possible resource exhaustion)" }
277
+ : {}),
61
278
  };
62
279
  if (output.outcome === "passed")
63
280
  return output;
64
- return {
65
- ...output,
66
- logPath: writeAllowedWpCommandLog(input.logRoot, input.command, invocation, output),
67
- };
281
+ return { ...output, logPath: writeAllowedWpCommandLog(logRoot, command, invocation, output) };
282
+ }
283
+ function killProcessGroup(pid) {
284
+ if (pid === undefined)
285
+ return;
286
+ try {
287
+ process.kill(-pid, "SIGKILL");
288
+ }
289
+ catch {
290
+ // The group is already gone (normal completion) — nothing to reap.
291
+ }
68
292
  }
69
293
  export function tailBounded(text) {
70
294
  if (text.length <= ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT)
@@ -77,8 +301,8 @@ export function relativeCommandLogPath(root, logPath) {
77
301
  const rel = relative(root, logPath).replace(/\\/gu, "/");
78
302
  return rel === "" || rel.startsWith("../") ? logPath : rel;
79
303
  }
80
- function resolveAllowedWpInvocation(executionRoot, binary, args, extraEnv) {
81
- const baseEnv = { ...process.env, ...extraEnv };
304
+ export function resolveAllowedWpInvocation(executionRoot, binary, args, extraEnv, options) {
305
+ const baseEnv = options?.replaceProcessEnv ? { ...extraEnv } : { ...process.env, ...extraEnv };
82
306
  if (binary === "./bin/wp" || binary === "wp") {
83
307
  const localLauncher = resolveRepoWpLauncher(executionRoot);
84
308
  if (localLauncher !== null) {
@@ -50,6 +50,11 @@ export const CLI_MCP_PARITY_CLASSIFICATION = {
50
50
  classification: "follow-up-candidate",
51
51
  reason: "local pre-push aggregator of the required-check verbs; CLI-only dev gate with no dedicated MCP tool",
52
52
  },
53
+ "release-progress": {
54
+ command: "release-progress",
55
+ classification: "follow-up-candidate",
56
+ reason: "operator/agent Release workflow phase reporter (detect/native-matrix/publish); CLI-only, uses gh API",
57
+ },
53
58
  cleanup: {
54
59
  command: "cleanup",
55
60
  classification: "unsafe-credentialed-destructive",
@@ -22,7 +22,8 @@ export declare const NATIVE_SESSION_MEMORY_CACHE_SUBDIR = "session-memory-engine
22
22
  export declare const NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE_ENV = "WP_NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE";
23
23
  /**
24
24
  * Relative path fragments used in GHA `hashFiles` for the warm-cache key.
25
- * Must match the Test job warm step in ci.agent-kit.yml exactly (contract test).
25
+ * Must match the Test job warm step in ci.agent-kit.yml and the release native
26
+ * matrix `sm-release-node-*` key in release.yml exactly (contract tests).
26
27
  * Absolute cache dir still comes from env-paths / CACHE_ROOT override at runtime.
27
28
  */
28
29
  export declare const NATIVE_SESSION_MEMORY_CACHE_HASH_GLOBS: readonly ["native/session-memory-engine/Cargo.lock", "native/session-memory-engine/Cargo.toml", "native/session-memory-engine/crates/**/*.rs", "native/session-memory-engine/crates/**/Cargo.toml"];
@@ -22,7 +22,8 @@ export const NATIVE_SESSION_MEMORY_CACHE_SUBDIR = "session-memory-engine";
22
22
  export const NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE_ENV = "WP_NATIVE_SESSION_MEMORY_TRUST_CACHED_NODE";
23
23
  /**
24
24
  * Relative path fragments used in GHA `hashFiles` for the warm-cache key.
25
- * Must match the Test job warm step in ci.agent-kit.yml exactly (contract test).
25
+ * Must match the Test job warm step in ci.agent-kit.yml and the release native
26
+ * matrix `sm-release-node-*` key in release.yml exactly (contract tests).
26
27
  * Absolute cache dir still comes from env-paths / CACHE_ROOT override at runtime.
27
28
  */
28
29
  export const NATIVE_SESSION_MEMORY_CACHE_HASH_GLOBS = [
@@ -0,0 +1,37 @@
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
+ export type ReleaseProgressPhase = "detect" | "native-matrix" | "publish" | "done" | "failed";
9
+ export interface ReleaseJobSnapshot {
10
+ readonly name: string;
11
+ readonly status: string;
12
+ readonly conclusion: string | null | undefined;
13
+ }
14
+ export interface ReleaseProgress {
15
+ readonly phase: ReleaseProgressPhase;
16
+ readonly detail: string;
17
+ readonly native: {
18
+ readonly done: number;
19
+ readonly total: number;
20
+ readonly failed: number;
21
+ readonly inProgress: number;
22
+ };
23
+ readonly publishRunning: boolean;
24
+ readonly publishDone: boolean;
25
+ readonly publishFailed: boolean;
26
+ }
27
+ /**
28
+ * Classify release workflow jobs into a single progress phase for agent/ops reporting.
29
+ */
30
+ export declare function classifyReleaseRunProgress(jobs: readonly ReleaseJobSnapshot[]): ReleaseProgress;
31
+ /** Format one line for agent/ops logs (merge vs matrix vs publish). */
32
+ export declare function formatReleaseProgressLine(progress: ReleaseProgress): string;
33
+ /**
34
+ * Parse `gh run view <id> --json jobs` (or full run object with `jobs`) into snapshots.
35
+ * Production CLI and tests share this path so classification is never reimplemented.
36
+ */
37
+ export declare function jobsFromGhRunViewJson(payload: unknown): ReleaseJobSnapshot[];