@webpresso/agent-kit 3.3.1 → 3.3.3

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 (87) hide show
  1. package/catalog/AGENTS.md.tpl +3 -5
  2. package/catalog/agent/rules/pre-implementation.md +15 -8
  3. package/catalog/agent/skills/ultragoal/SKILL.md +5 -18
  4. package/catalog/agent/skills/verify/SKILL.md +12 -12
  5. package/catalog/compose/admin-blocks-registry.json +659 -0
  6. package/dist/esm/blueprint/core/parser.js +2 -4
  7. package/dist/esm/blueprint/core/validation/criteria.js +2 -1
  8. package/dist/esm/blueprint/core/validation/task-blocks.js +7 -1
  9. package/dist/esm/blueprint/lifecycle/review-provenance.js +85 -30
  10. package/dist/esm/blueprint/markdown/task-heading.d.ts +21 -0
  11. package/dist/esm/blueprint/markdown/task-heading.js +25 -0
  12. package/dist/esm/blueprint/trust/command-runner.d.ts +40 -0
  13. package/dist/esm/blueprint/trust/command-runner.js +265 -41
  14. package/dist/esm/build/cli-mcp-parity.js +6 -0
  15. package/dist/esm/build/package-manifest.js +7 -2
  16. package/dist/esm/ci/native-session-memory-cache.d.ts +2 -1
  17. package/dist/esm/ci/native-session-memory-cache.js +2 -1
  18. package/dist/esm/ci/release-progress.d.ts +37 -0
  19. package/dist/esm/ci/release-progress.js +139 -0
  20. package/dist/esm/cli/cli.d.ts +1 -1
  21. package/dist/esm/cli/cli.js +9 -0
  22. package/dist/esm/cli/commands/audit-core.js +9 -1
  23. package/dist/esm/cli/commands/audit.js +16 -5
  24. package/dist/esm/cli/commands/blueprint/mutations.js +2 -3
  25. package/dist/esm/cli/commands/init/mcp-spec.d.ts +18 -3
  26. package/dist/esm/cli/commands/init/mcp-spec.js +54 -6
  27. package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
  28. package/dist/esm/cli/commands/package-manager.js +8 -2
  29. package/dist/esm/cli/commands/release-progress.d.ts +53 -0
  30. package/dist/esm/cli/commands/release-progress.js +155 -0
  31. package/dist/esm/cli/commands/review.js +74 -11
  32. package/dist/esm/cli/utils.d.ts +8 -1
  33. package/dist/esm/cli/utils.js +12 -2
  34. package/dist/esm/compose/compose.d.ts +38 -0
  35. package/dist/esm/compose/compose.js +143 -0
  36. package/dist/esm/compose/escape-html.d.ts +5 -0
  37. package/dist/esm/compose/escape-html.js +12 -0
  38. package/dist/esm/compose/ops-report-render.d.ts +5 -0
  39. package/dist/esm/compose/ops-report-render.js +71 -0
  40. package/dist/esm/compose/ops-report-schema.d.ts +42 -0
  41. package/dist/esm/compose/ops-report-schema.js +46 -0
  42. package/dist/esm/compose/preview.d.ts +14 -0
  43. package/dist/esm/compose/preview.js +32 -0
  44. package/dist/esm/compose/registry-resolve.d.ts +47 -0
  45. package/dist/esm/compose/registry-resolve.js +133 -0
  46. package/dist/esm/git/changed-files.d.ts +20 -0
  47. package/dist/esm/git/changed-files.js +63 -0
  48. package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -2
  49. package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
  50. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +2 -1
  51. package/dist/esm/mcp/blueprint/handlers/task-advance.js +2 -12
  52. package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
  53. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  54. package/dist/esm/mcp/tools/_names.js +4 -0
  55. package/dist/esm/mcp/tools/_registry.js +8 -0
  56. package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
  57. package/dist/esm/mcp/tools/release-progress.js +110 -0
  58. package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
  59. package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
  60. package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
  61. package/dist/esm/mcp/tools/wp-ui-compose.js +62 -0
  62. package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
  63. package/dist/esm/mcp/tools/wp-ui-preview.js +52 -0
  64. package/dist/esm/package.json +2 -0
  65. package/dist/esm/review/delivery-verifier.d.ts +33 -2
  66. package/dist/esm/review/delivery-verifier.js +130 -24
  67. package/dist/esm/review/execution/review-checkout.d.ts +1 -0
  68. package/dist/esm/review/execution/review-checkout.js +386 -23
  69. package/dist/esm/review/execution/sandbox/adapter.d.ts +37 -0
  70. package/dist/esm/review/execution/sandbox/adapter.js +39 -0
  71. package/dist/esm/review/execution/sandbox/env.d.ts +12 -0
  72. package/dist/esm/review/execution/sandbox/env.js +37 -0
  73. package/dist/esm/review/execution/sandbox/index.d.ts +5 -0
  74. package/dist/esm/review/execution/sandbox/index.js +4 -0
  75. package/dist/esm/review/execution/sandbox/policy.d.ts +19 -0
  76. package/dist/esm/review/execution/sandbox/policy.js +95 -0
  77. package/dist/esm/review/execution/sandbox/probe.d.ts +20 -0
  78. package/dist/esm/review/execution/sandbox/probe.js +97 -0
  79. package/dist/esm/review/execution/sandbox/shell-quote.d.ts +14 -0
  80. package/dist/esm/review/execution/sandbox/shell-quote.js +18 -0
  81. package/dist/esm/review/execution/sandbox/tmpdir.d.ts +9 -0
  82. package/dist/esm/review/execution/sandbox/tmpdir.js +36 -0
  83. package/dist/esm/review/execution/sandbox/types.d.ts +31 -0
  84. package/dist/esm/review/execution/sandbox/types.js +6 -0
  85. package/dist/esm/review/execution/supervisor.js +44 -5
  86. package/dist/esm/review/execution/types.d.ts +2 -0
  87. package/package.json +24 -12
@@ -139,6 +139,83 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
139
139
  return false;
140
140
  }
141
141
  }
142
+ /**
143
+ * Candidate delivery subjects for the working path.
144
+ *
145
+ * Two sources, in this order:
146
+ *
147
+ * 1. **The live tree at HEAD.** This mirrors what {@link readReviewAuthorityAtRef}
148
+ * already does for delivery on the ref path: it builds the subject from the
149
+ * ref under audit and matches events by digest, never resolving the commit an
150
+ * event recorded. Doing the same here removes the working path's outlier
151
+ * behaviour.
152
+ * 2. **Reconstruction at each event's `reviewedCommit`**, for events the live
153
+ * subject does not already cover.
154
+ *
155
+ * Why the live tree has to come first: `reviewedCommit` is a *locator*, not the
156
+ * binding. The binding is `subjectDigest`, which content-addresses the reviewed
157
+ * tree. A squash merge destroys the branch commit while preserving its tree, so
158
+ * an approval recorded at the branch tip kept a digest that still describes the
159
+ * post-merge tree exactly — yet resolving its now-orphaned SHA threw, the event
160
+ * was marked compromised, and a genuinely valid approval was discarded. The
161
+ * standing workaround was to hand-append a rebind event after every squash.
162
+ *
163
+ * Matching on the digest first is not a loosening: an event is accepted only
164
+ * when the tree it recorded is byte-for-byte the tree we have. Content that
165
+ * differs still fails, and an event whose commit is gone *and* whose digest
166
+ * matches nothing reachable is still marked unresolvable — fail-closed.
167
+ */
168
+ function collectWorkingDeliverySubjects(input) {
169
+ const { repoRoot, slug, blueprintPath, events, unresolvableEventIds, issues } = input;
170
+ const candidates = new Map();
171
+ // Nothing to resolve against: skip before paying for a whole-tree read. This
172
+ // path also runs for plan-only ledgers, where the old code cost nothing.
173
+ if (!events.some((event) => event.purpose === "delivery"))
174
+ return [];
175
+ let liveDigest = null;
176
+ try {
177
+ const live = createDeliverySubjectAtRef(repoRoot, "HEAD", slug, blueprintPath);
178
+ liveDigest = live.digest;
179
+ candidates.set(live.digest, { scheme: live.scheme, digest: live.digest });
180
+ }
181
+ catch (error) {
182
+ // No HEAD (fresh repo) or no blueprint document at HEAD (first commit of a
183
+ // new blueprint). Not fatal: fall through to per-event reconstruction.
184
+ issues?.push(`Working delivery subject unavailable at HEAD for ${slug}: ${error instanceof Error ? error.message : String(error)}`);
185
+ }
186
+ for (const event of events) {
187
+ if (event.purpose !== "delivery")
188
+ continue;
189
+ // Already covered by the live tree — the reviewed content is exactly what
190
+ // we have, so the recorded commit does not need to exist any more.
191
+ if (liveDigest !== null && event.subjectDigest === liveDigest)
192
+ continue;
193
+ // Scope subject-construction failure to THIS event. A rebase rewrites
194
+ // commit SHAs, so a previously-recorded `reviewedCommit` can become
195
+ // unresolvable; letting that throw would escape to the outer catch and
196
+ // discard every approval, including valid ones for commits that still
197
+ // resolve. Mirror the ref-path's markCompromisedArtifactEvents: record the
198
+ // issue, mark the event compromised, and drop only it.
199
+ let subject;
200
+ try {
201
+ subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, blueprintPath);
202
+ }
203
+ catch (error) {
204
+ issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
205
+ // Compromised (not merely skipped) so the event can never grant approval
206
+ // while still participating in reviewer supersession — see
207
+ // evaluateReviewEvents.
208
+ unresolvableEventIds.add(event.id);
209
+ continue;
210
+ }
211
+ if (subject.digest !== event.subjectDigest) {
212
+ issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
213
+ continue;
214
+ }
215
+ candidates.set(subject.digest, { scheme: subject.scheme, digest: subject.digest });
216
+ }
217
+ return [...candidates.values()];
218
+ }
142
219
  // Working-ledger acceptance: pre-commit validates a blueprint whose new review
143
220
  // events are staged alongside it and therefore not yet at any git ref. An
144
221
  // event still counts only when its subjectDigest matches the digest of the
@@ -167,36 +244,14 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
167
244
  createLegacyPlanSubjectDigestForContent(slug, readFileSync(file)),
168
245
  ].map((digest) => [digest, { scheme: "wp-blueprint-v1", digest }])).values(),
169
246
  ]
170
- : [
171
- ...new Map(working.events
172
- .filter((event) => event.purpose === "delivery")
173
- .flatMap((event) => {
174
- // Scope subject-construction failure to THIS event. A rebase
175
- // rewrites commit SHAs, so a previously-recorded
176
- // `reviewedCommit` can become unresolvable; letting that throw
177
- // would escape to the outer catch and discard every approval,
178
- // including valid ones for commits that still resolve. Mirror
179
- // the ref-path's markCompromisedArtifactEvents: record the
180
- // issue, mark the event compromised, and drop only it.
181
- let subject;
182
- try {
183
- subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
184
- }
185
- catch (error) {
186
- issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
187
- // Compromised (not merely skipped) so the event can never
188
- // grant approval while still participating in reviewer
189
- // supersession — see evaluateReviewEvents.
190
- unresolvableEventIds.add(event.id);
191
- return [];
192
- }
193
- if (subject.digest !== event.subjectDigest) {
194
- issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
195
- return [];
196
- }
197
- return [[subject.digest, subject]];
198
- })).values(),
199
- ];
247
+ : collectWorkingDeliverySubjects({
248
+ repoRoot,
249
+ slug,
250
+ blueprintPath: normalizePath(path.relative(repoRoot, file)),
251
+ events: working.events,
252
+ unresolvableEventIds,
253
+ issues,
254
+ });
200
255
  return subjects
201
256
  .flatMap((subject) => evaluateReviewEvents(working.events, {
202
257
  purpose,
@@ -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,12 @@ 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: "existing-mcp",
56
+ tools: ["wp_release_progress"],
57
+ reason: "Release workflow phase reporter (detect/native-matrix/publish) is available as wp_release_progress MCP and CLI",
58
+ },
53
59
  cleanup: {
54
60
  command: "cleanup",
55
61
  classification: "unsafe-credentialed-destructive",
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { assertBuiltBlueprintMigrationSqlAssets } from "./blueprint-migration-assets.js";
5
- import { assertBuiltSastRuleAssets } from "./sast-rule-assets.js";
4
+ import { assertBuiltBlueprintMigrationSqlAssets, syncBlueprintMigrationSqlAssets, } from "./blueprint-migration-assets.js";
5
+ import { assertBuiltSastRuleAssets, syncSastRuleAssets } from "./sast-rule-assets.js";
6
6
  import { SESSION_MEMORY_NATIVE_TARGETS } from "#session-memory/native-targets.js";
7
7
  import { RUNTIME_TARGETS } from "./runtime-targets.js";
8
8
  const DEPENDENCY_SECTIONS = [
@@ -304,6 +304,11 @@ export function preparePackedManifest(rootDir, options = {}) {
304
304
  // out-of-org consumers cannot resolve.
305
305
  const packedManifest = createPackedManifest(manifest, readWorkspaceCatalogs(workspacePath), options);
306
306
  if (options.assertBlueprintMigrationAssets ?? true) {
307
+ // Sync authoring sources into dist before assert so pack/prepack paths
308
+ // (version dry-run, npm pack) do not require a full `pnpm run build` first.
309
+ // Assert still fails closed on missing source or content drift.
310
+ syncBlueprintMigrationSqlAssets(rootDir);
311
+ syncSastRuleAssets(rootDir);
307
312
  assertBuiltBlueprintMigrationSqlAssets(rootDir);
308
313
  assertBuiltSastRuleAssets(rootDir);
309
314
  }
@@ -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 = [