pi-subagents 0.68.0 → 0.69.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.69.0] - 2026-09-18
6
+
7
+ ### Highlights
8
+ - Gates can now return a JSON verdict. Point `gate` at a script that prints JSON, and its output becomes the child's structured output, so workflows can branch on a post-run check without the parent reading the child's report.
9
+ - Ghostty detection no longer misfires inside terminals like cmux that embed Ghostty, so you stop seeing AppleScript `-1728`/`-2741` errors or the wrong window being targeted.
10
+ - Hosts without `npm` start up quietly instead of printing `npm: command not found`.
11
+
12
+ ### Added
13
+
14
+ - Typed gates: `gate` accepts `{ command, output: "json", schema?, timeoutMs? }` alongside the plain string form. When the command passes, its JSON stdout becomes the child's `structuredOutput` (validated against `schema` when given). Empty, truncated, or invalid output fails the gate rather than silently dropping the verdict. Typed gates always run (they are never cached), and a run cannot combine one with an `outputSchema`. See `docs/` for using command-runner agents as typed workflow steps and `examples/typed-gate` for a runnable example.
15
+
16
+ ### Fixed
17
+
18
+ - The Ghostty inspector only activates when the macOS host bundle id identifies the standalone Ghostty app. Terminals that embed Ghostty (such as cmux) set `TERM_PROGRAM=ghostty` too, which previously targeted an unrelated Ghostty window or emitted `-1728`/`-2741` AppleScript errors; those hosts now fall back to the `inspector.command` hint. Thanks to [@wangpi26](https://github.com/wangpi26) for #2281.
19
+ - Hosts without `npm` no longer print `/bin/sh: npm: command not found` during startup; global package-root discovery is optional and now stays silent when the package manager is missing. Thanks to [@PhrZer](https://github.com/PhrZer) for #2287.
20
+ - Fixed `docs/tool-reference.md`, which claimed a default `maxOutput` cap of 200 KB / 5,000 lines. The cap applies only when `maxOutput` is set.
21
+
5
22
  ## [0.68.0] - 2026-09-15
6
23
 
7
24
  ### Highlights
@@ -9,7 +26,7 @@
9
26
  - Reuse workflow scripts with different JSON inputs, including scheduled runs.
10
27
  - Start npm-installed children much faster and let slow local models use Pi's configured HTTP timeout.
11
28
  - Keep local foreground children on the same extension-provided models as their parent without sharing provider state between sessions.
12
- - Get simpler, more predictable failures: each launch uses one resolved model instead of switching models automatically.
29
+ - Ask async agents to checkpoint before a hard deadline, giving long-running work a chance to return useful progress instead of being killed.
13
30
 
14
31
  ### Added
15
32
 
@@ -34,6 +51,9 @@
34
51
 
35
52
  ### Fixed
36
53
 
54
+ - Fail managed worktree setup before child launch when a required shared `node_modules` link cannot be created and verified, while preserving absent sources and preexisting destinations (#2283).
55
+ - Allow checked writers to explicitly preserve a host-bound staged index while still rejecting child-created index changes (#2280).
56
+
37
57
  - Preserve the main watchdog's user scope across session compaction while clearing temporary activity state. Thanks to [@nimeetshah0](https://github.com/nimeetshah0) for #2263.
38
58
  - Resolve provider-extension models in local, in-process foreground children. Such a child never loads the parent's ambient extensions, so its model runtime only knew Pi's built-in providers and every model from an extension-registered provider failed with `Model "…" not found`; the child now inherits the providers registered in the parent session before resolving its model. Pane-native remote foreground children continue to use the remote machine's provider discovery and configuration. Builtin agents on such a model no longer need `async: true`. Thanks to [@lallenlowe](https://github.com/lallenlowe) for #2274.
39
59
  - Preserve a readable async result when result indexing or archiving fails, then retry saving it without delivering it twice. Thanks to [@shaharmor](https://github.com/shaharmor) for #2267 and #2266.
package/docs/agents.md CHANGED
@@ -58,6 +58,10 @@ If you disabled the old bundled `gpt-pro` workaround with `agentOverrides.gpt-pr
58
58
 
59
59
  The Pi async run remains the source of truth for status, artifacts, wake/wait, mission attachment, retention, and diagnostics.
60
60
 
61
+ ### Command-runner agents as typed steps
62
+
63
+ `runner.type: external-cli` with a plain `command` (no `adapter`) runs any local executable as a subagent: the assembled prompt is written to stdin, stdout becomes the child's output, and the run gets the usual run id, status, mission entry, and workflow key. This is how a classifier, a scoring script, or a small evaluation model becomes a `runs.run` step. Such agents are async-only (workflows launch children async by default; a direct `async: false` call is refused), receive no forked transcript, and cannot produce `structuredOutput` themselves; parse their `output` in the workflow, or pair them with a [typed gate](tool-reference.md#typed-gates). Generic commands are local-only; saved-machine placement accepts only the code-owned adapters below.
64
+
61
65
  ### Advisory runner data boundary
62
66
 
63
67
  External CLI agents use their own runner contract. They are deliberate execution modes, not implicit recovery paths for a failed native `subagent` workflow. For backlog lanes and other subagent-governed workflows, switching to an external, foreground, or CLI runner requires explicit owner approval after the exact failure/run/worktree state is recorded and the worktree is verified clean or its partial diff is captured. Do not pass native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the adapter explicitly implements them.
@@ -122,13 +122,13 @@ The complete plain-JSON inventory is validated before the first launch (maximum
122
122
  | `usageBudget` | object | none | Optional root-only reported-usage budget `{ tokens?: { soft?, hard }, costUsd?: { soft?, hard } }`. Soft limits are status-only. Hard limits prevent later child launches after reported usage is reconciled; already-running children are not stopped and no reservations are made. |
123
123
  | `cwd` | string | runtime cwd | Override working directory. With `machine`, the directory on that machine. |
124
124
  | `machine` | string | - | Herdr saved machine (label or profile id) for external-cli agents; see [agents.md](agents.md#running-external-cli-agents-on-a-herdr-saved-machine). |
125
- | `maxOutput` | object | 200KB, 5000 lines | Final output truncation limits. |
125
+ | `maxOutput` | object | none | Final output truncation limits `{ bytes?, lines? }`. Only applied when set; there is no default cap on the inline path, so use `outputMode: "file-only"` for large outputs. |
126
126
  | `artifacts` | boolean | true | Write debug artifacts. |
127
127
  | `includeProgress` | boolean | false | Include full progress in result. |
128
128
  | `share` | boolean | false | Upload session export to GitHub Gist. |
129
129
  | `sessionDir` | string | derived | Override session log directory. |
130
130
  | `acceptance` | string/object/false | inferred | Configure evidence gates. See [Acceptance gates](#acceptance-gates). |
131
- | `gate` | string | - | One host-run verification command, shorthand for `acceptance: { level: "verified", verify: [{ id: "gate", command }] }`. Also valid on individual `runs.run`/`runs.all` items. Rejects `acceptance` except `false` (treated as omitted), and rejects retained `resume`. |
131
+ | `gate` | string \| object | - | One host-run verification command, shorthand for `acceptance: { level: "verified", verify: [{ id: "gate", command }] }`. The object form `{ command, output?: "json", schema?, timeoutMs? }` adds a [typed gate](#typed-gates): with `output: "json"`, a passing command's stdout becomes the child's `structuredOutput`. Also valid on individual `runs.run`/`runs.all` items. Rejects `acceptance` except `false` (treated as omitted), rejects retained `resume`, and `output: "json"` rejects `outputSchema`. |
132
132
 
133
133
  ### Budget guidance for writers
134
134
 
@@ -381,6 +381,8 @@ The `/subagents-steer <run-id> [--child <child-id>] <message>` slash command is
381
381
 
382
382
  Every run resolves an effective acceptance policy. Callers may omit `acceptance` for the inferred default, or set it on single runs, top-level parallel task items, chain steps, static parallel tasks, and dynamic fanout templates.
383
383
 
384
+ Checked writers reject staged files by default. When a parent intentionally starts a single writer with reviewed staged content, opt in with `acceptance: { level: "checked", preserveStagedIndex: true }`. The host captures the repository-wide index tree immediately before each launch (including each retained resume) and accepts only if `git write-tree` produces the same tree at completion. Working-tree-only fixes are allowed; child-created staging is rejected. Capture or terminal Git failures, including an unavailable or unmerged index, fail closed. This option does not stage or restore files and should not be used for concurrent writers sharing one worktree.
385
+
384
386
  Prefer an inline JSON object. JSON-encoded object strings are tolerated only during input normalization; invalid strings fail closed. `true` is invalid. Supported evidence kinds are `changed-files`, `tests-added`, `commands-run`, `validation-output`, `residual-risks`, `no-staged-files`, `diff-summary`, `review-findings`, and `manual-notes`. For example: `{level:"checked",evidence:["commands-run","changed-files"],review:{required:true}}`. Evidence levels end at `verified`; independent review is a separate gate, not a stronger evidence level.
385
387
 
386
388
  ```ts
@@ -406,6 +408,25 @@ When one host-run command is the entire verification contract, use the `gate` sh
406
408
 
407
409
  `gate` normalizes to verified acceptance with that single command, so the runtime executes it on the host and records the result as evidence. Verification results are memoized per tracked workspace state and effective environment, so an unchanged tree does not rerun the same command. Use explicit `acceptance.verify` when you need multiple commands, timeouts, or custom criteria. `gate` rejects `acceptance` except `false` (treated as omitted), and rejects retained `resume` items. With `worktree: true`, the gate runs inside the child's managed worktree.
408
410
 
411
+ ### Typed gates
412
+
413
+ A gate given as `{ command, output: "json" }` runs like a string gate, and then parses the command's stdout:
414
+
415
+ ```js
416
+ { workflowScript: `return runs.run("review", {
417
+ agent: "reviewer", task: "Review the change", output: "reports/review.md", outputMode: "file-only",
418
+ gate: { command: "classify --report reports/review.md", output: "json",
419
+ schema: { type: "object", properties: { verdict: { enum: ["ok", "blocked"] } }, required: ["verdict"] } }
420
+ })` }
421
+ ```
422
+
423
+ - The command runs after the child's output file is saved, in the child's cwd or managed worktree, so it can read what the child wrote.
424
+ - A passing command must print one JSON document on stdout (at most 12,000 characters). The parsed value becomes `result.structuredOutput`, is recorded on the verify run as `structuredOutput`, and is projected into `status.json`. When `schema` is given, the value must validate against it.
425
+ - Empty, truncated, non-JSON, or schema-invalid stdout marks the gate `failed` with a `structuredOutputError`; explicit acceptance then fails the run, exactly as a non-zero exit would. The verdict is never silently dropped.
426
+ - Typed gates are never memoized: their input (a report, a log) can change without the tracked tree changing.
427
+ - `output: "json"` cannot be combined with an `outputSchema` from any source: the launch `outputSchema` param or the agent's frontmatter, paired with `gate`, an explicit `acceptance.verify` entry, or the agent's `defaultAcceptance`. Preflight rejects the launch and names both sources. A child has exactly one structured-output source.
428
+ - `runs.lanes` treats a bridged `structuredOutput.verdict === "blocked"` like any other blocked stage. Scripts read the value as `result.structuredOutput`.
429
+
409
430
  ### Levels and inference
410
431
 
411
432
  Acceptance evidence levels are `auto`, `none`, `attested`, `checked`, and `verified`. `acceptance: "auto"` is the default.
package/docs/workflows.md CHANGED
@@ -194,6 +194,54 @@ The helper validates the complete plain-JSON lane inventory before launching any
194
194
 
195
195
  The board is bounded and contains only lane/stage keys, state, success, retained run ids, explicit output references, bounded errors, and an optional structured verdict. It does not return child transcripts or create a lane registry or cleanup authority. Use raw `runs.run(...)`/`runs.all(...)` when a workflow needs conditional or rolling orchestration beyond this helper.
196
196
 
197
+ ### Typed post-run checks and typed steps
198
+
199
+ Two ways to put a small classifier (a script, a lookup, a fast evaluation model) into a workflow without spending an LLM turn on it.
200
+
201
+ **A typed gate** runs a command after a child finishes and turns its JSON stdout into that child's `structuredOutput`:
202
+
203
+ ```js
204
+ subagent({ workflowScript: `
205
+ const review = await runs.run("review", {
206
+ agent: "reviewer", task: packet,
207
+ output: "reports/review.md", outputMode: "file-only",
208
+ gate: { command: "classify --report reports/review.md", output: "json" }
209
+ });
210
+ if (review.structuredOutput.verdict === "blocked") {
211
+ return runs.run("fix", { agent: "worker", task: "Fix the findings in reports/review.md" });
212
+ }
213
+ return { verdict: review.structuredOutput.verdict, report: "reports/review.md" };
214
+ ` });
215
+ ```
216
+
217
+ The parent receives a pointer plus a verdict instead of the review text. See [typed gates](tool-reference.md#typed-gates) for the contract and failure rules.
218
+
219
+ **A typed step** is an agent whose runner is a command rather than a Pi session. The prompt arrives on stdin, stdout is the child's output, and the workflow uses it like any other child:
220
+
221
+ ```yaml
222
+ ---
223
+ name: classifier
224
+ description: Typed classification of the task text
225
+ runner:
226
+ type: external-cli
227
+ command: /path/to/classify
228
+ args: [--stdin, --json]
229
+ promptDelivery: stdin
230
+ async: true
231
+ systemPromptMode: replace
232
+ inheritProjectContext: false
233
+ inheritGlobalContext: false
234
+ inheritSkills: false
235
+ ---
236
+ ```
237
+
238
+ ```js
239
+ const results = await runs.all(items.map((item) => ({ key: item.key, agent: "classifier", task: item.text })));
240
+ const routed = results.map((r, i) => ({ key: items[i].key, ...JSON.parse(r.output) }));
241
+ ```
242
+
243
+ Command-runner agents are async-only; workflows launch children async by default, but a direct `async: false` call is refused. The runner gets only the assembled prompt, never a forked transcript, so keep `inheritProjectContext` and `inheritGlobalContext` off unless the command wants that text. See [examples/typed-gate](https://github.com/nicobailon/pi-subagents/tree/main/examples/typed-gate) for a runnable version of both shapes.
244
+
197
245
  ### Host command steps
198
246
 
199
247
  Use the named `run-ci` resource when a permission/policy extension needs to admit one supported non-interactive command as workflow evidence instead of a child-agent run:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@ Project settings resolve from the nearest parent directory containing `.pi` or `
24
24
 
25
25
  An agent may set `runner.type: external-cli` with a non-empty `command`, optional string `args`, and `promptDelivery: stdin` (the default). The command runs with `shell: false`, inherits the resolved cwd and environment, and receives the combined agent instructions and task through stdin. It must already be installed; pi-subagents adds no CLI dependency.
26
26
 
27
- External CLI profiles are async-only and one-shot. They support lifecycle artifacts, stdout/stderr logs, timeout, and stop. Full stdout and stderr are retained in their log files, while the final stdout response and stderr error kept in memory are each limited to their last 64 KiB. They do not support native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the runner explicitly implements them. Foreground/clarify, steer/resume/interrupt-as-pause, nested subagents, and sessions are also unsupported.
27
+ A command-runner agent with a plain `command` (no adapter) is also how a classifier or scoring script becomes a typed workflow step: the prompt arrives on stdin, stdout is the child's `output`, and the script parses it. Keep `inheritProjectContext`, `inheritGlobalContext`, and `inheritSkills` off unless the command wants that text. External CLI profiles are async-only and one-shot. They support lifecycle artifacts, stdout/stderr logs, timeout, and stop. Full stdout and stderr are retained in their log files, while the final stdout response and stderr error kept in memory are each limited to their last 64 KiB. They do not support native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the runner explicitly implements them. Foreground/clarify, steer/resume/interrupt-as-pause, nested subagents, and sessions are also unsupported.
28
28
 
29
29
  ### External job profiles
30
30
 
@@ -99,6 +99,8 @@ If `runs.all` is missing in a running session, reload or update `pi-subagents` b
99
99
 
100
100
  For one host-run verification command, pass `gate: "npm test"` on a `runs.run`/`runs.all` item (or at the top level as a workflow default). It is shorthand for verified acceptance with that single command: the runtime executes it on the host, records the result as evidence, and memoizes it per tracked workspace state and effective environment. `gate` cannot be combined with `acceptance`; use explicit `acceptance.verify` for multiple commands or custom criteria.
101
101
 
102
+ For a typed post-run check, pass the object form `gate: { command, output: "json", schema?, timeoutMs? }`. A passing command must print one JSON document (under 12,000 characters); the parsed value, validated against `schema` when given, becomes the child's `structuredOutput`, so a script can branch on `result.structuredOutput` and `runs.lanes` blocks on `verdict === "blocked"` without the parent reading the child's output. Pair it with `output` + `outputMode: "file-only"` so the command reads the saved file. Empty, non-JSON, or schema-invalid stdout fails the gate and rejects the run. Typed gates are never memoized. A typed gate cannot be combined with an `outputSchema` from the launch or the agent; the launch is rejected before any child starts. See the `tool-reference` guide, "Typed gates".
103
+
102
104
  If omitted, acceptance is inferred from role, mode, and risk. Use `level: "checked"` for ordinary writer evidence and `level: "verified"` when the runtime should run explicit validation commands. Independent review is orthogonal: use `review: { required: true, agent: "reviewer" }`; reviewer/read-only calls omit `acceptance`. `review-required` means evidence passed but review is pending; `reviewed` means an independent review found no blockers. Never request `level: "reviewed"`; it is recognized only so preflight can return an actionable correction. Disable gates with `{ level: "none", reason: "..." }`; bare `"none"` is rejected and `false` is only a deprecated shorthand. Child-reported command success is evidence, not runtime verification.
103
105
 
104
106
  Completed workflow children from this parent session stay addressable as retained children. `subagent({ action: "children.list" })` lists up to the last 10 with run ids and reports each row as `resumable` or `not resumable` with a reason. Resume only rows reported `resumable`. For a retained-child challenge, use `resume` instead of `steer` when the child is complete. If no retained child is resumable, launch a same-role fallback challenge and label it as fallback. A later workflow continues a resumable child with `runs.run(key, { resume: "<run-id>", task: "follow-up" })`. Inside `workflowScript`, awaiting that call waits for the revived child to finish and returns its completed output and new `runId`; top-level `{ action: "resume" }` remains detached. Pass explicit follow-up task text. Assign each returned child result back to the loop variable because every resume can return a new retained `runId`; always resume the latest returned id. `resume` and `agent` are mutually exclusive, the revived child keeps its stored agent/model/tool contract, and `gate` is rejected on retained resume items.
@@ -495,7 +495,7 @@ function getGlobalNpmRoot(): string | null {
495
495
  }
496
496
 
497
497
  try {
498
- cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true }).trim());
498
+ cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim());
499
499
  return cachedGlobalNpmRoot;
500
500
  } catch {
501
501
  cachedGlobalNpmRoot = "";
@@ -142,7 +142,7 @@ function getGlobalNpmRoot(): string | null {
142
142
  }
143
143
 
144
144
  try {
145
- cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true }).trim());
145
+ cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim());
146
146
  return cachedGlobalNpmRoot;
147
147
  } catch {
148
148
  // Global npm root is optional in constrained environments.
@@ -389,7 +389,13 @@ const SubagentParamProperties = {
389
389
  outputSchema: Type.Optional(OutputSchemaOverride),
390
390
  agentContract: Type.Optional(AgentContractOverride),
391
391
  acceptance: Type.Optional(AcceptanceOverride),
392
- gate: Type.Optional(Type.String({ minLength: 1, description: "Host gate command. Cannot be combined with acceptance; an explicit acceptance of false is treated as omitted." })),
392
+ gate: Type.Optional(Type.Unsafe({
393
+ anyOf: [
394
+ { type: "string", minLength: 1 },
395
+ { type: "object", properties: { command: { type: "string", minLength: 1 }, output: { type: "string", enum: ["json"] }, schema: { type: "object" }, timeoutMs: { type: "integer", minimum: 1 } }, required: ["command"], additionalProperties: false },
396
+ ],
397
+ description: "Host gate command run after the child finishes: a string, or { command, output: \"json\", schema?, timeoutMs? } whose passing stdout becomes structuredOutput (not with outputSchema). Cannot be combined with acceptance; an explicit acceptance of false is treated as omitted.",
398
+ })),
393
399
  };
394
400
 
395
401
  const SubagentParamsSchema = Type.Object(SubagentParamProperties);
@@ -1,6 +1,9 @@
1
1
  import { openGhosttyInspector, type GhosttyRunner } from "./actions.ts";
2
2
  import type { InspectorPlugin } from "../types.ts";
3
3
 
4
+ /** 独立 Ghostty.app 的 bundle id (作者 Mitchell Hashimoto)。 */
5
+ const GHOSTTY_BUNDLE_ID = "com.mitchellh.ghostty";
6
+
4
7
  export interface GhosttyPluginDeps {
5
8
  platform?: NodeJS.Platform;
6
9
  runner?: GhosttyRunner;
@@ -10,7 +13,16 @@ export function createGhosttyInspectorPlugin(deps: GhosttyPluginDeps = {}): Insp
10
13
  const platform = deps.platform ?? process.platform;
11
14
  return {
12
15
  name: "ghostty",
13
- available: (context) => platform === "darwin" && context.env.TERM_PROGRAM?.toLowerCase() === "ghostty",
16
+ available: (context) => {
17
+ // cmux 内嵌 Ghostty 内核, 也会把 TERM_PROGRAM 设成 "ghostty"。仅凭环境变量会让 plugin
18
+ // 在 cmux 下误接管, 随后 osascript 连不上真正的 Ghostty 应用而抛 -1728/-2741。
19
+ if (platform !== "darwin") return false;
20
+ if (context.env.TERM_PROGRAM?.toLowerCase() !== "ghostty") return false;
21
+ // macOS GUI 应用启动子进程时注入 __CFBundleIdentifier, 标识当前终端宿主 app。
22
+ // cmux 的 bundle id 是 com.cmuxterm.app, 而非 Ghostty; 即便系统同时装了独立 Ghostty,
23
+ // 也能据此判定当前终端不是 Ghostty, 避免误连独立 Ghostty 的窗口。
24
+ return context.env.__CFBundleIdentifier?.trim() === GHOSTTY_BUNDLE_ID;
25
+ },
14
26
  owns: () => false,
15
27
  open: (context, launch, params) => openGhosttyInspector(context, launch, params, deps.runner),
16
28
  };
@@ -136,7 +136,7 @@ import { assertThinkingWithinCeiling } from "../../shared/thinking-ceiling.ts";
136
136
  import { resolveLaunchBinding } from "../../shared/launch-contract.ts";
137
137
  import { writeInitialProgressFile } from "../../shared/settings.ts";
138
138
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
139
- import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveAcceptanceReportMode, resolveEffectiveAcceptance, stripAcceptanceReport } from "../shared/acceptance.ts";
139
+ import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAcceptanceLedger, captureStagedIndexBaseline, evaluateAcceptance, formatAcceptancePrompt, resolveAcceptanceReportMode, resolveEffectiveAcceptance, stripAcceptanceReport, typedVerifyOutput } from "../shared/acceptance.ts";
140
140
  import { attachContractProjections, isAgentContract } from "../shared/agent-contract.ts";
141
141
  import { waitForImportedAsyncRoot } from "./chain-root-attachment.ts";
142
142
  import { appendRunnerStepsToStatus, consumeChainAppendRequests, countPendingChainAppendRequests, statusStepDescription } from "./chain-append.ts";
@@ -1118,6 +1118,7 @@ export async function runSingleStepInner(
1118
1118
  const aggregateUsage = emptyUsage();
1119
1119
  let launched = false;
1120
1120
  let recoveryTask = task;
1121
+ let stagedIndexBaseline: string | undefined;
1121
1122
  singleLaunch: for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1122
1123
  if (ctx.timeoutSignal?.aborted || ctx.stopSignal?.aborted || ctx.skipAcceptance?.()) break singleLaunch;
1123
1124
  const expectedModelForVerification = candidate && !step.skipPrimaryModelVerification ? candidate : undefined;
@@ -1214,6 +1215,14 @@ export async function runSingleStepInner(
1214
1215
  // Each attempt rewrites the step output log; synchronous appends keep a
1215
1216
  // retried attempt from interleaving with the previous attempt's flush.
1216
1217
  fs.writeFileSync(ctx.outputFile, "", "utf-8");
1218
+ if (step.effectiveAcceptance?.preserveStagedIndex && stagedIndexBaseline === undefined) {
1219
+ try {
1220
+ stagedIndexBaseline = captureStagedIndexBaseline(step.cwd ?? ctx.cwd);
1221
+ } catch (error) {
1222
+ const message = error instanceof Error ? error.message : String(error);
1223
+ return { agent: step.agent, output: message, error: message, exitCode: 1, context: step.context };
1224
+ }
1225
+ }
1217
1226
  const run = await runChildSession(omitUndefinedProperties({
1218
1227
  factory: ctx.childSessions,
1219
1228
  launch,
@@ -1494,6 +1503,7 @@ export async function runSingleStepInner(
1494
1503
  ? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only", durable: resolvedOutput.savedPath !== undefined }
1495
1504
  : undefined,
1496
1505
  cwd: step.cwd ?? ctx.cwd,
1506
+ stagedIndexBaseline,
1497
1507
  signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
1498
1508
  abortMessage: ctx.stopSignal?.aborted ? ctx.stopMessage ?? "Subagent stopped by user." : ctx.timeoutMessage ?? "Subagent timed out.",
1499
1509
  reportOptional: isAgentContract(step.agentContract),
@@ -1514,6 +1524,12 @@ export async function runSingleStepInner(
1514
1524
  const acceptanceFailure = effectiveAcceptance ? acceptanceFailureMessage(effectiveAcceptance) : undefined;
1515
1525
  const acceptanceCanFailRun = acceptanceFailure && effectiveAcceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted && !timedOutAfterAcceptance && !stoppedAfterAcceptance && !isAgentContract(step.agentContract);
1516
1526
  const effectiveFinalExitCode = timedOutAfterAcceptance || stoppedAfterAcceptance ? 1 : acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
1527
+ // A passing typed gate supplies the structured output for runs that have no
1528
+ // outputSchema of their own; preflight rejects the combination.
1529
+ const typedGate = typedVerifyOutput(effectiveAcceptance);
1530
+ if (typedGate && finalResult && finalResult.structuredOutput === undefined && effectiveFinalExitCode === 0) {
1531
+ finalResult = { ...finalResult, structuredOutput: typedGate.value };
1532
+ }
1517
1533
  const intercomDetachReceipt = finalResult?.finalOutput === INTERCOM_DETACH_RECEIPT;
1518
1534
  const baseFinalError = stoppedAfterAcceptance
1519
1535
  ? ctx.stopMessage ?? "Subagent stopped by user."
@@ -88,7 +88,7 @@ import {
88
88
  shouldEscalateMutatingFailures,
89
89
  summarizeRecentMutatingFailures,
90
90
  } from "../shared/long-running-guard.ts";
91
- import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport, validateAcceptanceInput } from "../shared/acceptance.ts";
91
+ import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, captureStagedIndexBaseline, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport, validateAcceptanceInput, typedVerifyOutput } from "../shared/acceptance.ts";
92
92
  import { PROMPT_REDACTED } from "../../shared/utils.ts";
93
93
  import { attachContractProjections, isAgentContract } from "../shared/agent-contract.ts";
94
94
  import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
@@ -1892,6 +1892,22 @@ async function runSyncCompletionInner(
1892
1892
  const verifyModel = Boolean(candidate) && !options.modelOverrideFromParent;
1893
1893
  let lastResult: SingleResult | undefined;
1894
1894
  let recoveryPrompt = task;
1895
+ let stagedIndexBaseline: string | undefined;
1896
+ if (effectiveAcceptance.preserveStagedIndex) {
1897
+ try {
1898
+ stagedIndexBaseline = captureStagedIndexBaseline(options.cwd ?? runtimeCwd);
1899
+ } catch (error) {
1900
+ return redactResultPrompt(withRunContext({
1901
+ index: options.index ?? 0,
1902
+ agent: agentName,
1903
+ task,
1904
+ exitCode: 1,
1905
+ messages: [],
1906
+ usage: emptyUsage(),
1907
+ error: error instanceof Error ? error.message : String(error),
1908
+ }, options.context));
1909
+ }
1910
+ }
1895
1911
  for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
1896
1912
  const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
1897
1913
  const attemptResult = await runSingleAttempt(runtimeCwd, agent, recoveryPrompt, candidate, attemptOptions, {
@@ -2022,6 +2038,7 @@ async function runSyncCompletionInner(
2022
2038
  ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only", durable: result.savedOutputPath !== undefined }
2023
2039
  : undefined,
2024
2040
  cwd: options.cwd ?? runtimeCwd,
2041
+ stagedIndexBaseline,
2025
2042
  reportOptional: isAgentContract(options.agentContract),
2026
2043
  artifactsDir: options.artifactsDir,
2027
2044
  runId: options.runId,
@@ -2034,6 +2051,12 @@ async function runSyncCompletionInner(
2034
2051
  }
2035
2052
  const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
2036
2053
  stripAcceptanceReportsFromMessages(result.messages);
2054
+ // A passing typed gate supplies the structured output for runs that have no
2055
+ // outputSchema of their own; preflight rejects the combination.
2056
+ const typedGate = typedVerifyOutput(result.acceptance);
2057
+ if (typedGate && result.structuredOutput === undefined && !acceptanceFailure && result.exitCode === 0) {
2058
+ result.structuredOutput = typedGate.value;
2059
+ }
2037
2060
  if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.interrupted && !result.timedOut && !isAgentContract(options.agentContract)) {
2038
2061
  result.exitCode = 1;
2039
2062
  if (result.savedOutputPath) {
@@ -55,7 +55,7 @@ import { isScheduledRunAction } from "../background/scheduled-runs.ts";
55
55
  import { encodeIndexSegment } from "../background/index-segment.ts";
56
56
  import { enqueueChainAppendRequest, readPendingChainAppendRequests, runnerStepOutputNames } from "../background/chain-append.ts";
57
57
  import { ChainOutputValidationError, validateChainOutputBindingsWithContext } from "../shared/chain-outputs.ts";
58
- import { normalizeGateAcceptance, resolveAcceptanceReportMode, validateAcceptanceInput, validateExecutionAcceptance, validateExecutionAcceptancePolicy } from "../shared/acceptance.ts";
58
+ import { acceptanceHasTypedVerify, normalizeGateAcceptance, resolveAcceptanceReportMode, TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT, validateAcceptanceInput, validateExecutionAcceptance, validateExecutionAcceptancePolicy } from "../shared/acceptance.ts";
59
59
  import { canPreferFork, createForkContextResolver, resolveSubagentLaunchContext } from "../../shared/fork-context.ts";
60
60
  import { createPrunedForkSessionWriter } from "../../shared/pruned-fork.ts";
61
61
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
@@ -4814,6 +4814,15 @@ function normalizeGateParams(params: SubagentParamsLike): GateParamsNormalizatio
4814
4814
  return { ok: true, params: { ...rest, ...(normalized.acceptance !== undefined ? { acceptance: normalized.acceptance } : {}) } };
4815
4815
  }
4816
4816
 
4817
+ function describeTypedVerifyOutputSchemaConflict(effective: SubagentParamsLike, requested: SubagentParamsLike): string | undefined {
4818
+ if (!acceptanceHasTypedVerify(effective.acceptance)) return undefined;
4819
+ if (effective.outputSchema === undefined || effective.outputSchema === false) return undefined;
4820
+ const agent = effective.agent ?? "?";
4821
+ const schemaSource = requested.outputSchema !== undefined && requested.outputSchema !== false ? "outputSchema" : `agent '${agent}' outputSchema`;
4822
+ const verifySource = requested.gate !== undefined ? "gate.output" : requested.acceptance !== undefined ? "acceptance.verify" : `agent '${agent}' defaultAcceptance`;
4823
+ return `${verifySource}: ${TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT.replace("with outputSchema", `with ${schemaSource}`)}`;
4824
+ }
4825
+
4817
4826
  function formatWorkflowValue(value: unknown): string {
4818
4827
  if (value === undefined) return "(undefined)";
4819
4828
  if (typeof value === "string") return value;
@@ -6889,6 +6898,12 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
6889
6898
  }
6890
6899
  const modelScope = discovered.modelScope;
6891
6900
  effectiveParams = applySingleAgentLaunchDefaults(effectiveParams, discoveredAgents);
6901
+ // The gate shorthand, an explicit acceptance.verify list, and an agent's
6902
+ // defaultAcceptance all normalize to verify commands, and the agent's
6903
+ // frontmatter outputSchema has been merged by now, so this one check keeps
6904
+ // a run to a single structured-output source regardless of spelling.
6905
+ const typedVerifyConflict = describeTypedVerifyOutputSchemaConflict(effectiveParams, params);
6906
+ if (typedVerifyConflict) return buildRequestedModeError(effectiveParams, typedVerifyConflict);
6892
6907
  // An agent-level defaultContext is a preference, unlike an explicit request.
6893
6908
  // Prefer fork only when the parent session is persisted and has a current leaf;
6894
6909
  // otherwise use fresh immediately instead of launching a guaranteed-to-fail fork.
@@ -18,6 +18,7 @@ import type {
18
18
  AcceptanceReviewResult,
19
19
  AcceptanceVerifyCommand,
20
20
  AcceptanceVerifyResult,
21
+ JsonSchemaObject,
21
22
  ResolvedAcceptanceConfig,
22
23
  ResolvedAcceptanceGate,
23
24
  SingleResult,
@@ -26,6 +27,7 @@ import type {
26
27
  } from "../../shared/types.ts";
27
28
  import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
28
29
  import { isAgentContract } from "./agent-contract.ts";
30
+ import { validateStructuredOutputValue } from "./structured-output.ts";
29
31
  import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
30
32
 
31
33
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
@@ -50,9 +52,9 @@ const VALID_EVIDENCE_KINDS: AcceptanceEvidenceKind[] = [
50
52
  const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>(VALID_EVIDENCE_KINDS);
51
53
  const ACCEPTANCE_EVIDENCE_HELP = `Supported evidence kinds: ${VALID_EVIDENCE_KINDS.join(", ")}. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }.`;
52
54
  const ACCEPTANCE_OBJECT_EXAMPLE = "Example: { level: \"checked\", evidence: [\"commands-run\", \"changed-files\"] }.";
53
- const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
55
+ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "preserveStagedIndex", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
54
56
  const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
55
- const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
57
+ const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure", "output", "schema"]);
56
58
  const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
57
59
  const EXPLICIT_REVIEWED_UNAVAILABLE = "is an achieved status, not a requestable acceptance level. For a read-only reviewer call, omit acceptance. To require independent review of a writer result, use acceptance.review.required and orchestrate the reviewer separately.";
58
60
 
@@ -200,15 +202,59 @@ type GateAcceptanceNormalizationResult =
200
202
  | { ok: true; acceptance?: AcceptanceInput }
201
203
  | { ok: false; error: string };
202
204
 
205
+ const GATE_OBJECT_KEYS = new Set(["command", "output", "schema", "timeoutMs"]);
206
+ export const GATE_INPUT_ERROR = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
207
+
208
+ export interface GateObjectInput {
209
+ command: string;
210
+ output?: "json";
211
+ schema?: JsonSchemaObject;
212
+ timeoutMs?: number;
213
+ }
214
+
215
+ /** Validates the two accepted gate shapes without normalizing them. Shared by preflight and the workflow sandbox mirror. */
216
+ export function parseGateInput(gate: unknown): { ok: true; gate: GateObjectInput } | { ok: false; error: string } {
217
+ if (typeof gate === "string") {
218
+ return gate.trim() ? { ok: true, gate: { command: gate.trim() } } : { ok: false, error: GATE_INPUT_ERROR };
219
+ }
220
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return { ok: false, error: GATE_INPUT_ERROR };
221
+ const value = gate as Record<string, unknown>;
222
+ for (const key of Object.keys(value)) {
223
+ if (!GATE_OBJECT_KEYS.has(key)) return { ok: false, error: `gate.${key} is not supported.` };
224
+ }
225
+ if (typeof value.command !== "string" || !value.command.trim()) return { ok: false, error: GATE_INPUT_ERROR };
226
+ if (value.output !== undefined && value.output !== "json") return { ok: false, error: "gate.output must be \"json\" when present." };
227
+ if (value.schema !== undefined) {
228
+ if (value.output !== "json") return { ok: false, error: "gate.schema requires gate.output: \"json\"." };
229
+ if (!value.schema || typeof value.schema !== "object" || Array.isArray(value.schema)) return { ok: false, error: "gate.schema must be a JSON Schema object." };
230
+ }
231
+ if (value.timeoutMs !== undefined && (!Number.isInteger(value.timeoutMs) || (value.timeoutMs as number) < 1)) return { ok: false, error: "gate.timeoutMs must be an integer >= 1." };
232
+ const parsed: GateObjectInput = { command: value.command.trim() };
233
+ if (value.output === "json") parsed.output = "json";
234
+ // SAFETY: schema was checked above to be a non-array object; timeoutMs to be an integer >= 1.
235
+ if (value.schema !== undefined) parsed.schema = value.schema as JsonSchemaObject;
236
+ if (value.timeoutMs !== undefined) parsed.timeoutMs = value.timeoutMs as number;
237
+ return { ok: true, gate: parsed };
238
+ }
239
+
240
+ /** True when an acceptance policy declares at least one `output: "json"` verify command, from either the gate shorthand or an explicit verify list. */
241
+ export function acceptanceHasTypedVerify(acceptance: AcceptanceInput | undefined): boolean {
242
+ if (!acceptance || typeof acceptance !== "object") return false;
243
+ return Array.isArray(acceptance.verify) && acceptance.verify.some((command) => command && typeof command === "object" && command.output === "json");
244
+ }
245
+
246
+ export const TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT = "a typed verify command (output: \"json\") cannot be combined with outputSchema; the child would have two structured-output sources.";
247
+
203
248
  export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
204
249
  if (gate === undefined) {
205
250
  if (acceptance === undefined) return { ok: true };
206
251
  const normalized = normalizeAcceptanceValue(acceptance);
207
252
  return normalized.error ? { ok: false, error: normalized.error } : { ok: true, acceptance: normalized.value as AcceptanceInput };
208
253
  }
209
- if (typeof gate !== "string" || !gate.trim()) return { ok: false, error: "gate must be a non-empty command string." };
254
+ const parsed = parseGateInput(gate);
255
+ if (!parsed.ok) return { ok: false, error: parsed.error };
210
256
  if (acceptance !== undefined && acceptance !== false) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(gate, acceptance) };
211
- return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", command: gate.trim() }] } };
257
+ return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", ...parsed.gate }] } };
212
258
  }
213
259
 
214
260
  export function describeGateAcceptanceConflict(gate: unknown, acceptance: unknown): string {
@@ -267,6 +313,12 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
267
313
  if (value.report !== undefined && value.report !== "on" && value.report !== "off") {
268
314
  errors.push(`${pathLabel}.report must be on or off.`);
269
315
  }
316
+ if (value.preserveStagedIndex !== undefined && value.preserveStagedIndex !== true) {
317
+ errors.push(`${pathLabel}.preserveStagedIndex must be true when provided.`);
318
+ }
319
+ if (value.preserveStagedIndex === true && value.level !== "checked" && value.level !== "verified") {
320
+ errors.push(`${pathLabel}.preserveStagedIndex requires level checked or verified.`);
321
+ }
270
322
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
271
323
  errors.push(`${pathLabel}.reason is required when level is none.`);
272
324
  }
@@ -348,6 +400,11 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
348
400
  if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") {
349
401
  errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
350
402
  }
403
+ if (cmd.output !== undefined && cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].output must be "json" when present.`);
404
+ if (cmd.schema !== undefined) {
405
+ if (cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].schema requires output: "json".`);
406
+ if (!cmd.schema || typeof cmd.schema !== "object" || Array.isArray(cmd.schema)) errors.push(`${pathLabel}.verify[${index}].schema must be a JSON Schema object.`);
407
+ }
351
408
  }
352
409
  }
353
410
  if (value.review !== undefined && value.review !== false) {
@@ -471,6 +528,7 @@ export function resolveEffectiveAcceptance(input: {
471
528
  inferredReason: [],
472
529
  criteria,
473
530
  evidence,
531
+ preserveStagedIndex: explicit.preserveStagedIndex,
474
532
  verify: explicit.verify ?? [],
475
533
  review: explicit.review,
476
534
  stopRules: explicit.stopRules ?? [],
@@ -496,6 +554,7 @@ export function resolveEffectiveAcceptance(input: {
496
554
  inferredReason: inferred.reasons,
497
555
  criteria: level === "none" ? [] : criteria,
498
556
  evidence: level === "none" ? [] : evidence,
557
+ preserveStagedIndex: explicit.preserveStagedIndex,
499
558
  verify: explicit.verify ?? [],
500
559
  review,
501
560
  stopRules: explicit.stopRules ?? [],
@@ -527,6 +586,9 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
527
586
  "",
528
587
  `Required evidence: ${acceptance.evidence.join(", ") || "none"}`,
529
588
  ];
589
+ if (acceptance.preserveStagedIndex) {
590
+ lines.push("The host will verify that the staged index is unchanged from launch; report noStagedFiles truthfully even when the preserved index is non-empty.");
591
+ }
530
592
  if (acceptance.verify.length > 0) {
531
593
  lines.push("", "Runtime verification commands configured by parent:");
532
594
  for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
@@ -1079,9 +1141,32 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
1079
1141
  : { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
1080
1142
  }
1081
1143
 
1144
+ /** Capture the repository-wide index tree. Throws when Git cannot provide trustworthy evidence. */
1145
+ export function captureStagedIndexBaseline(cwd: string): string {
1146
+ const result = spawnSync("git", ["write-tree"], { cwd, encoding: "utf-8", windowsHide: true });
1147
+ const oid = result.stdout.trim();
1148
+ if (result.status !== 0 || !oid) {
1149
+ const detail = result.stderr.trim();
1150
+ throw new Error(`Unable to capture staged index baseline${detail ? `: ${detail}` : "."}`);
1151
+ }
1152
+ return oid;
1153
+ }
1154
+
1155
+ function checkStagedIndexUnchanged(cwd: string, baseline: string): AcceptanceRuntimeCheck {
1156
+ try {
1157
+ const terminal = captureStagedIndexBaseline(cwd);
1158
+ return terminal === baseline
1159
+ ? { id: "staged-index-unchanged", status: "passed", message: "Staged index matches the launch baseline." }
1160
+ : { id: "staged-index-unchanged", status: "failed", message: "Staged index changed after launch." };
1161
+ } catch (error) {
1162
+ return { id: "staged-index-unchanged", status: "failed", message: error instanceof Error ? error.message : String(error) };
1163
+ }
1164
+ }
1165
+
1082
1166
  function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
1083
1167
  const checks: AcceptanceRuntimeCheck[] = [];
1084
1168
  for (const kind of acceptance.evidence) {
1169
+ if (kind === "no-staged-files" && acceptance.preserveStagedIndex) continue;
1085
1170
  if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
1086
1171
  const status = reportEvidenceStatus(report, kind);
1087
1172
  checks.push({
@@ -1094,7 +1179,7 @@ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: Accep
1094
1179
  : `${kind} evidence missing from child report.`,
1095
1180
  });
1096
1181
  }
1097
- if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
1182
+ if (!acceptance.preserveStagedIndex && acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
1098
1183
  return checks;
1099
1184
  }
1100
1185
 
@@ -1202,6 +1287,43 @@ function isCachedVerifyResult(value: unknown): value is AcceptanceVerifyResult {
1202
1287
  && typeof result.durationMs === "number";
1203
1288
  }
1204
1289
 
1290
+ const TYPED_VERIFY_OUTPUT_MAX_BYTES = 12_000;
1291
+
1292
+ /**
1293
+ * A passing `output: "json"` command must print one JSON document on stdout.
1294
+ * The parsed value becomes the run's structured output; anything else turns
1295
+ * the passing run into a failed one so acceptance rejects rather than
1296
+ * silently dropping the verdict. Stdout is already bounded by trimOutput, so a
1297
+ * truncated document is detected by its marker instead of a parse guess.
1298
+ */
1299
+ async function applyTypedVerifyOutput(command: AcceptanceVerifyCommand, run: AcceptanceVerifyResult): Promise<void> {
1300
+ if (run.status !== "passed") return;
1301
+ const stdout = run.stdout ?? "";
1302
+ const fail = (message: string): void => {
1303
+ run.status = "failed";
1304
+ run.structuredOutputError = message;
1305
+ };
1306
+ if (!stdout.trim()) return fail("output: \"json\" command printed nothing on stdout.");
1307
+ if (stdout.endsWith("...[truncated]")) return fail(`output: "json" stdout exceeded ${TYPED_VERIFY_OUTPUT_MAX_BYTES} characters and was truncated.`);
1308
+ let value: unknown;
1309
+ try {
1310
+ value = JSON.parse(stdout);
1311
+ } catch (error) {
1312
+ return fail(`output: "json" stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
1313
+ }
1314
+ if (command.schema) {
1315
+ const validation = await validateStructuredOutputValue(command.schema, value);
1316
+ if (validation.status === "invalid") return fail(`output: "json" stdout does not match gate.schema: ${validation.message}`);
1317
+ }
1318
+ run.structuredOutput = value;
1319
+ }
1320
+
1321
+ /** The first typed verify result on a ledger, if any command produced one. */
1322
+ export function typedVerifyOutput(ledger: AcceptanceLedger | undefined): { value: unknown } | undefined {
1323
+ const run = ledger?.verifyRuns?.find((entry) => entry.structuredOutput !== undefined);
1324
+ return run ? { value: run.structuredOutput } : undefined;
1325
+ }
1326
+
1205
1327
  async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: {
1206
1328
  signal?: AbortSignal;
1207
1329
  abortMessage?: string;
@@ -1215,7 +1337,9 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
1215
1337
  } catch {
1216
1338
  workspaceState = undefined;
1217
1339
  }
1218
- if (!workspaceState || !options.artifactsDir || !options.runId) {
1340
+ // Typed gates read inputs (a report file, a log) that can change without
1341
+ // the tracked tree changing, so their verdicts are never reused.
1342
+ if (!workspaceState || !options.artifactsDir || !options.runId || command.output === "json") {
1219
1343
  return runVerifyCommand(command, defaultCwd, options);
1220
1344
  }
1221
1345
  const envKeys = Object.keys(command.env ?? {}).sort();
@@ -1387,6 +1511,8 @@ export async function evaluateAcceptance(input: {
1387
1511
  acceptance: ResolvedAcceptanceConfig;
1388
1512
  output: string;
1389
1513
  cwd: string;
1514
+ /** Host-captured launch index tree; required by preserveStagedIndex. */
1515
+ stagedIndexBaseline?: string;
1390
1516
  /**
1391
1517
  * Content the child sent to its configured output file (from its own write
1392
1518
  * tool calls, not from disk, so a concurrent writer to the same path cannot
@@ -1482,30 +1608,36 @@ export async function evaluateAcceptance(input: {
1482
1608
  ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
1483
1609
  ledger.status = "rejected";
1484
1610
  ledger.evidenceStatus = "rejected";
1485
- return ledger;
1486
- }
1487
- ledger.verifyRuns = [];
1488
- for (const command of acceptance.verify) {
1489
- ledger.verifyRuns.push(await runMemoizedVerifyCommand(command, input.cwd, {
1490
- signal: input.signal,
1491
- abortMessage: input.abortMessage,
1492
- artifactsDir: input.artifactsDir,
1493
- runId: input.runId,
1494
- }));
1495
- if (input.signal?.aborted) break;
1496
- }
1497
- if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
1498
- ledger.status = "rejected";
1499
- ledger.evidenceStatus = "rejected";
1500
- return ledger;
1501
- }
1502
- if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1503
- ledger.status = "verified";
1504
- ledger.evidenceStatus = "verified";
1611
+ } else {
1612
+ ledger.verifyRuns = [];
1613
+ for (const command of acceptance.verify) {
1614
+ const run = await runMemoizedVerifyCommand(command, input.cwd, {
1615
+ signal: input.signal,
1616
+ abortMessage: input.abortMessage,
1617
+ artifactsDir: input.artifactsDir,
1618
+ runId: input.runId,
1619
+ });
1620
+ if (command.output === "json") await applyTypedVerifyOutput(command, run);
1621
+ ledger.verifyRuns.push(run);
1622
+ if (input.signal?.aborted) break;
1623
+ }
1624
+ if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
1625
+ ledger.status = "rejected";
1626
+ ledger.evidenceStatus = "rejected";
1627
+ } else if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1628
+ ledger.status = "verified";
1629
+ ledger.evidenceStatus = "verified";
1630
+ }
1505
1631
  }
1506
1632
  }
1507
1633
 
1508
- if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
1634
+ if (acceptance.preserveStagedIndex) {
1635
+ ledger.runtimeChecks.push(input.stagedIndexBaseline
1636
+ ? checkStagedIndexUnchanged(input.cwd, input.stagedIndexBaseline)
1637
+ : { id: "staged-index-unchanged", status: "failed", message: "Staged index launch baseline is unavailable." });
1638
+ }
1639
+
1640
+ if (ledger.status === "rejected" || ledger.runtimeChecks.some((check) => check.status === "failed")) {
1509
1641
  ledger.status = "rejected";
1510
1642
  ledger.evidenceStatus = "rejected";
1511
1643
  return ledger;
@@ -1559,6 +1691,7 @@ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | und
1559
1691
  const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
1560
1692
  if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
1561
1693
  const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
1694
+ if (failedVerify?.structuredOutputError) return `Acceptance verification '${failedVerify.id}' failed: ${failedVerify.structuredOutputError}`;
1562
1695
  if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
1563
1696
  if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
1564
1697
  return "Acceptance rejected.";
@@ -743,13 +743,35 @@ export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, inde
743
743
  function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boolean {
744
744
  const nodeModulesPath = path.join(toplevel, "node_modules");
745
745
  const nodeModulesLinkPath = path.join(worktreePath, "node_modules");
746
- if (!fs.existsSync(nodeModulesPath) || fs.existsSync(nodeModulesLinkPath)) return false;
746
+ const hasDirectoryEntry = (candidate: string): boolean => {
747
+ try { fs.lstatSync(candidate); return true; }
748
+ catch (error) {
749
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
750
+ throw error;
751
+ }
752
+ };
747
753
  try {
748
- fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath);
754
+ if (hasDirectoryEntry(nodeModulesLinkPath)) return false;
755
+ let sourceRealPath: string;
756
+ try {
757
+ if (!fs.statSync(nodeModulesPath).isDirectory()) throw new Error("source node_modules is not a directory");
758
+ sourceRealPath = fs.realpathSync.native(nodeModulesPath);
759
+ } catch (error) {
760
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
761
+ throw error;
762
+ }
763
+ fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath, process.platform === "win32" ? "junction" : "dir");
764
+ if (!fs.lstatSync(nodeModulesLinkPath).isSymbolicLink()
765
+ || fs.realpathSync.native(nodeModulesLinkPath) !== sourceRealPath) {
766
+ throw new Error("created link does not resolve to the source node_modules");
767
+ }
749
768
  return true;
750
- } catch {
751
- // Symlink creation is optional (e.g., unsupported filesystems on CI runners).
752
- return false;
769
+ } catch (error) {
770
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
771
+ throw new Error(
772
+ `failed to link node_modules from ${nodeModulesPath} to ${nodeModulesLinkPath}: ${code}${error instanceof Error ? error.message : String(error)}`,
773
+ { cause: error },
774
+ );
753
775
  }
754
776
  }
755
777
 
@@ -1028,6 +1028,10 @@ export interface AcceptanceVerifyCommand {
1028
1028
  cwd?: string;
1029
1029
  env?: Record<string, string>;
1030
1030
  allowFailure?: boolean;
1031
+ /** When "json", a passing command's stdout is parsed and becomes the run's structured output. */
1032
+ output?: "json";
1033
+ /** Optional JSON Schema the parsed stdout must satisfy; only meaningful with `output: "json"`. */
1034
+ schema?: JsonSchemaObject;
1031
1035
  }
1032
1036
 
1033
1037
  export interface AcceptanceReviewGate {
@@ -1039,6 +1043,8 @@ export interface AcceptanceReviewGate {
1039
1043
  export interface AcceptanceConfig {
1040
1044
  level?: AcceptanceLevel;
1041
1045
  report?: "on" | "off";
1046
+ /** Preserve an intentional launch-time staged index, while rejecting any terminal index change. */
1047
+ preserveStagedIndex?: true;
1042
1048
  criteria?: Array<string | AcceptanceGate>;
1043
1049
  evidence?: AcceptanceEvidenceKind[];
1044
1050
  verify?: AcceptanceVerifyCommand[];
@@ -1063,6 +1069,7 @@ export interface ResolvedAcceptanceConfig {
1063
1069
  inferredReason: string[];
1064
1070
  criteria: ResolvedAcceptanceGate[];
1065
1071
  evidence: AcceptanceEvidenceKind[];
1072
+ preserveStagedIndex?: true;
1066
1073
  verify: AcceptanceVerifyCommand[];
1067
1074
  review?: AcceptanceReviewGate | false;
1068
1075
  stopRules: string[];
@@ -1121,6 +1128,10 @@ export interface AcceptanceVerifyResult {
1121
1128
  diffHash: string;
1122
1129
  };
1123
1130
  artifactError?: string;
1131
+ /** Parsed stdout of a passing `output: "json"` command. */
1132
+ structuredOutput?: unknown;
1133
+ /** Why a passing `output: "json"` command still failed: invalid JSON, truncated stdout, or schema mismatch. */
1134
+ structuredOutputError?: string;
1124
1135
  }
1125
1136
 
1126
1137
  export interface AcceptanceReviewResult {
@@ -5,7 +5,7 @@ import { Worker } from "node:worker_threads";
5
5
  import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
6
6
  import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
7
7
  import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
8
- import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
8
+ import { describeGateAcceptanceConflict, parseGateInput } from "../runs/shared/acceptance.ts";
9
9
  import type { AcceptanceRecoveryMetadata, HostStepNode, SingleResult } from "../shared/types.ts";
10
10
  import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
11
11
 
@@ -633,6 +633,24 @@ function validateLaneMetadata(value, label, workflowKey) {
633
633
  }
634
634
  }
635
635
 
636
+ // Mirrors parseGateInput in src/runs/shared/acceptance.ts; the sandbox cannot import it.
637
+ function describeGateShapeError(gate) {
638
+ const shape = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
639
+ if (typeof gate === "string") return gate.trim() ? undefined : shape;
640
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return shape;
641
+ for (const key of Object.keys(gate)) {
642
+ if (!["command", "output", "schema", "timeoutMs"].includes(key)) return "gate." + key + " is not supported.";
643
+ }
644
+ if (typeof gate.command !== "string" || !gate.command.trim()) return shape;
645
+ if (gate.output !== undefined && gate.output !== "json") return "gate.output must be \"json\" when present.";
646
+ if (gate.schema !== undefined) {
647
+ if (gate.output !== "json") return "gate.schema requires gate.output: \"json\".";
648
+ if (!gate.schema || typeof gate.schema !== "object" || Array.isArray(gate.schema)) return "gate.schema must be a JSON Schema object.";
649
+ }
650
+ if (gate.timeoutMs !== undefined && (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1)) return "gate.timeoutMs must be an integer >= 1.";
651
+ return undefined;
652
+ }
653
+
636
654
  function describeGateAcceptanceConflict(gate, acceptance) {
637
655
  const render = (value) => {
638
656
  let encoded;
@@ -658,7 +676,10 @@ function validateRunCall(key, params, label, fingerprints) {
658
676
  if (params.worktree !== undefined && typeof params.worktree !== "boolean") throw new Error(label + " worktree must be true or false.");
659
677
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
660
678
  validateLaneMetadata(params.lane, label + " lane", key);
661
- if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
679
+ if (params.gate !== undefined) {
680
+ const gateError = describeGateShapeError(params.gate);
681
+ if (gateError) throw new Error(label + " " + gateError);
682
+ }
662
683
  if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
663
684
  if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
664
685
  if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
@@ -2418,8 +2439,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2418
2439
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) {
2419
2440
  return respond(Promise.reject(new Error(`runs.run('${key}') ${BASE_REF_VALIDATION_ERROR}`)));
2420
2441
  }
2421
- if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) {
2422
- return respond(Promise.reject(new Error(`runs.run('${key}') gate must be a non-empty command string.`)));
2442
+ if (params.gate !== undefined) {
2443
+ const parsedGate = parseGateInput(params.gate);
2444
+ if (!parsedGate.ok) return respond(Promise.reject(new Error(`runs.run('${key}') ${parsedGate.error}`)));
2423
2445
  }
2424
2446
  if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) {
2425
2447
  return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.` + describeGateAcceptanceConflict(params.gate, params.acceptance))));