@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.
- package/catalog/agent/rules/pre-implementation.md +15 -8
- package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
- package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
- package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
- package/dist/esm/blueprint/markdown/task-heading.js +25 -0
- package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
- package/dist/esm/blueprint/trust/command-runner.js +265 -41
- package/dist/esm/build/cli-mcp-parity.js +5 -0
- package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
- package/dist/esm/ci/native-session-memory-cache.js +2 -1
- package/dist/esm/ci/release-progress.d.ts +37 -0
- package/dist/esm/ci/release-progress.js +139 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
- package/dist/esm/cli/commands/release-progress.d.ts +53 -0
- package/dist/esm/cli/commands/release-progress.js +155 -0
- package/dist/esm/cli/commands/review.js +74 -11
- package/dist/esm/cli/utils.d.ts +8 -1
- package/dist/esm/cli/utils.js +12 -2
- package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
- package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
- package/dist/esm/review/delivery-verifier.d.ts +33 -2
- package/dist/esm/review/delivery-verifier.js +130 -24
- package/dist/esm/review/execution/review-checkout.d.ts +1 -0
- package/dist/esm/review/execution/review-checkout.js +386 -23
- package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
- package/dist/esm/review/execution/sandbox/adapter.js +39 -0
- package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
- package/dist/esm/review/execution/sandbox/env.js +37 -0
- package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
- package/dist/esm/review/execution/sandbox/index.js +4 -0
- package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
- package/dist/esm/review/execution/sandbox/policy.js +95 -0
- package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
- package/dist/esm/review/execution/sandbox/probe.js +97 -0
- package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
- package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
- package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
- package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
- package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
- package/dist/esm/review/execution/sandbox/types.js +6 -0
- package/dist/esm/review/execution/supervisor.js +44 -5
- package/dist/esm/review/execution/types.d.ts +2 -0
- 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
|
-
- **
|
|
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.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
49
|
-
outcome:
|
|
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:
|
|
56
|
-
...(
|
|
57
|
-
stdout,
|
|
58
|
-
stderr,
|
|
59
|
-
...(
|
|
60
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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[];
|