pi-cohort 7.3.1 → 7.4.1

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.
@@ -46,7 +46,7 @@ import { PINNED_AGENT_DIR_OVERRIDE } from "../../shared/agent-dir.ts";
46
46
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
47
47
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
48
48
  import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
49
- import { captureSingleOutputSnapshot, validateFileOnlyOutputMode } from "../shared/single-output.ts";
49
+ import { captureSingleOutputSnapshot, formatFileOnlyFailure, validateFileOnlyOutputMode } from "../shared/single-output.ts";
50
50
  import {
51
51
  buildModelCandidates,
52
52
  formatModelAttemptNote,
@@ -669,7 +669,7 @@ export async function runSync(
669
669
  dynamic: options.acceptanceContext?.dynamic,
670
670
  dynamicGroup: options.acceptanceContext?.dynamicGroup,
671
671
  });
672
- const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance);
672
+ const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance, { structuredOutput: Boolean(options.structuredOutput) });
673
673
  const taskWithAcceptance = acceptancePrompt ? `${task}\n${acceptancePrompt}` : task;
674
674
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
675
675
  const skillNames = options.skills ?? agent.skills ?? [];
@@ -815,6 +815,8 @@ export async function runSync(
815
815
  } satisfies SingleResult;
816
816
 
817
817
  let postLoopError: unknown;
818
+ let preservedAt: string | undefined;
819
+ let notPreservedReason = "artifact output disabled";
818
820
  try {
819
821
  result.usage = aggregateUsage;
820
822
  result.attemptedModels = attemptedModels.length > 0 ? attemptedModels : undefined;
@@ -834,7 +836,14 @@ export async function runSync(
834
836
  if (artifactPathsResult && options.artifactConfig?.enabled !== false) {
835
837
  result.artifactPaths = artifactPathsResult;
836
838
  if (options.artifactConfig?.includeOutput !== false) {
837
- writeArtifact(artifactPathsResult.outputPath, artifactOutputByResult.get(result) ?? result.finalOutput ?? "");
839
+ try {
840
+ writeArtifact(artifactPathsResult.outputPath, artifactOutputByResult.get(result) ?? result.finalOutput ?? "");
841
+ preservedAt = artifactPathsResult.outputPath;
842
+ } catch (error) {
843
+ const message = error instanceof Error ? error.message : String(error);
844
+ notPreservedReason = `artifact write failed: ${message}`;
845
+ throw error;
846
+ }
838
847
  }
839
848
  if (options.artifactConfig?.includeMetadata !== false) {
840
849
  writeMetadata(artifactPathsResult.metadataPath, {
@@ -912,9 +921,19 @@ export async function runSync(
912
921
  : safeMessage;
913
922
  result.executionSurface ??= { handle: externalOwner.surface, retained: true };
914
923
  }
915
- } else if (postLoopError) {
916
- throw postLoopError;
917
924
  }
918
925
 
926
+ if (options.outputMode === "file-only"
927
+ && (result.exitCode !== 0 || result.outputSaveError)
928
+ && !blockedLine(result.error ?? "")) {
929
+ result.finalOutput = formatFileOnlyFailure({
930
+ outputPath: options.outputPath,
931
+ saveError: result.outputSaveError,
932
+ ...(preservedAt !== undefined ? { preservedAt } : { notPreservedReason }),
933
+ });
934
+ result.truncation = undefined;
935
+ }
936
+ if (!externalOwner && postLoopError) throw postLoopError;
937
+
919
938
  return result;
920
939
  }
@@ -39,6 +39,7 @@ import { createForkContextResolver } from "../../shared/fork-context.ts";
39
39
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
40
40
  import { formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
41
41
  import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
42
+ import { blockedLine } from "../shared/completion-guard.ts";
42
43
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd } from "../../shared/utils.ts";
43
44
  import { resolveSubagentResultStatus } from "../shared/result-children.ts";
44
45
  import { buildRevivedAsyncTask, resolveAsyncResumeTarget } from "../background/async-resume.ts";
@@ -2064,7 +2065,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2064
2065
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
2065
2066
 
2066
2067
  const fullOutput = getSingleResultOutput(r);
2067
- const finalizedOutput = finalizeSingleOutput({
2068
+ const displayOutput = r.outputMode === "file-only" ? r.finalOutput : finalizeSingleOutput({
2068
2069
  fullOutput,
2069
2070
  truncatedOutput: r.truncation?.text,
2070
2071
  outputPath,
@@ -2073,7 +2074,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2073
2074
  savedPath: r.savedOutputPath,
2074
2075
  outputReference: r.outputReference,
2075
2076
  saveError: r.outputSaveError,
2076
- });
2077
+ }).displayOutput;
2077
2078
  const details = compactForegroundDetails({
2078
2079
  mode: "single",
2079
2080
  runId,
@@ -2096,14 +2097,14 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2096
2097
  };
2097
2098
  }
2098
2099
 
2099
- if (r.exitCode !== 0)
2100
- return {
2101
- content: [{ type: "text", text: r.error || "Failed" }],
2102
- details,
2103
- isError: true,
2104
- };
2100
+ if (r.exitCode !== 0) {
2101
+ const text = r.outputMode === "file-only" && !blockedLine(r.error ?? "")
2102
+ ? `FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}\n\n${r.finalOutput ?? ""}`
2103
+ : r.error || "Failed";
2104
+ return { content: [{ type: "text", text }], details, isError: true };
2105
+ }
2105
2106
  return {
2106
- content: [{ type: "text", text: finalizedOutput.displayOutput || "(no output)" }],
2107
+ content: [{ type: "text", text: displayOutput || "(no output)" }],
2107
2108
  details,
2108
2109
  };
2109
2110
  }
@@ -238,7 +238,7 @@ export function resolveEffectiveAcceptance(input: {
238
238
  };
239
239
  }
240
240
 
241
- export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
241
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { structuredOutput: boolean } = { structuredOutput: false }): string {
242
242
  if (acceptance.level === "none") return "";
243
243
  const lines = [
244
244
  "",
@@ -264,7 +264,9 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): st
264
264
  }
265
265
  lines.push(
266
266
  "",
267
- "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
267
+ options.structuredOutput
268
+ ? "Write a fenced JSON block tagged `acceptance-report` in your final text message, then call `structured_output` as your final action. Shape:"
269
+ : "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
268
270
  "```acceptance-report",
269
271
  JSON.stringify({
270
272
  criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
@@ -1,20 +1,23 @@
1
+ import * as path from "node:path";
1
2
  import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
3
  import type { ChainOutputMap, ChainOutputMapEntry, SingleResult } from "../../shared/types.ts";
3
4
  import { getSingleResultOutput } from "../../shared/utils.ts";
4
5
  import { DynamicFanoutError, hasDynamicFanoutFields, type DynamicFanoutConfig, validateDynamicStepShape } from "./dynamic-fanout.ts";
6
+ import { materializeTree } from "./worktree.ts";
5
7
  import { validateStopConditions } from "./stop-conditions.ts";
6
8
 
7
9
  const OUTPUT_REF_PATTERN = /\{outputs\.([^}]*)\}/g;
8
10
  const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
11
  const PATCH_PATH_FIELD = "patchPath";
10
12
 
11
- /** `name` or `name.patchPath`; anything else is invalid. */
12
- function parseOutputReference(raw: string): { name: string; field?: typeof PATCH_PATH_FIELD } | undefined {
13
+ /** Only text and filesystem fields are valid output references. */
14
+ function parseOutputReference(raw: string): { name: string; field?: "patchPath" | "treePath" } | undefined {
13
15
  const dot = raw.indexOf(".");
14
16
  const name = dot === -1 ? raw : raw.slice(0, dot);
15
17
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) return undefined;
16
18
  if (dot === -1) return { name };
17
- return raw.slice(dot + 1) === PATCH_PATH_FIELD ? { name, field: PATCH_PATH_FIELD } : undefined;
19
+ const field = raw.slice(dot + 1);
20
+ return field === PATCH_PATH_FIELD || field === "treePath" ? { name, field } : undefined;
18
21
  }
19
22
 
20
23
  export class ChainOutputValidationError extends Error {}
@@ -26,10 +29,22 @@ function outputNamesForStep(step: ChainStep): string[] {
26
29
  return name ? [name] : [];
27
30
  }
28
31
 
29
- function taskTemplatesForStep(step: ChainStep): string[] {
30
- if (isParallelStep(step)) return step.parallel.map((task) => task.task ?? "{previous}");
31
- if (isDynamicParallelStep(step)) return [step.parallel.task ?? "{previous}", step.parallel.label ?? ""].filter(Boolean);
32
- return [(step as SequentialStep).task ?? "{previous}"];
32
+ function taskTemplatesForStep(step: ChainStep): Array<{ template: string; cwd: boolean }> {
33
+ const templates: Array<{ template: string; cwd: boolean }> = [];
34
+ const add = (value: { task?: string; label?: string; cwd?: string }) => {
35
+ for (const template of [value.task, value.label]) {
36
+ if (template) templates.push({ template, cwd: false });
37
+ }
38
+ if (value.cwd) templates.push({ template: value.cwd, cwd: true });
39
+ };
40
+ if (isParallelStep(step)) {
41
+ if (step.cwd) templates.push({ template: step.cwd, cwd: true });
42
+ for (const task of step.parallel) add(task);
43
+ } else if (isDynamicParallelStep(step)) {
44
+ add(step);
45
+ add(step.parallel);
46
+ } else add(step);
47
+ return templates;
33
48
  }
34
49
 
35
50
  export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
@@ -64,17 +79,23 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
64
79
  }
65
80
  seen.add(name);
66
81
  }
67
- for (const template of taskTemplatesForStep(step)) {
82
+ for (const { template, cwd } of taskTemplatesForStep(step)) {
68
83
  for (const match of template.matchAll(OUTPUT_REF_PATTERN)) {
84
+ if (cwd && isParallelStep(step) && step.worktree) {
85
+ throw new ChainOutputValidationError(`Chain step ${stepIndex + 1}: cwd output references cannot be combined with worktree: true.`);
86
+ }
69
87
  const rawReference = match[0];
70
88
  const parsed = parseOutputReference(match[1]!);
71
89
  if (!parsed) {
72
90
  throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
73
91
  }
92
+ if (cwd && parsed.field !== "treePath") {
93
+ throw new ChainOutputValidationError(`Chain step ${stepIndex + 1}: only {outputs.<name>.treePath} may appear in cwd.`);
94
+ }
74
95
  if (!available.has(parsed.name)) {
75
96
  throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
76
97
  }
77
- if (parsed.field === PATCH_PATH_FIELD && !worktreeProducers.has(parsed.name)) {
98
+ if (parsed.field && !worktreeProducers.has(parsed.name)) {
78
99
  const producerStep = producerSteps.get(parsed.name)!;
79
100
  throw new ChainOutputValidationError(`Chain step ${stepIndex + 1} references ${rawReference} but step ${producerStep + 1} is not a worktree step.`);
80
101
  }
@@ -88,7 +109,7 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
88
109
  }
89
110
  }
90
111
 
91
- export function resolveOutputReferences(template: string, outputs: ChainOutputMap): string {
112
+ export function resolveOutputReferences(template: string, outputs: ChainOutputMap, trees?: TreeRegistry): string {
92
113
  return template.replace(OUTPUT_REF_PATTERN, (rawReference, rawName: string) => {
93
114
  const parsed = parseOutputReference(rawName);
94
115
  if (!parsed) {
@@ -96,25 +117,65 @@ export function resolveOutputReferences(template: string, outputs: ChainOutputMa
96
117
  }
97
118
  const entry = outputs[parsed.name];
98
119
  if (!entry) throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}'.`);
99
- if (parsed.field !== PATCH_PATH_FIELD) return entry.text;
120
+ if (parsed.field === "treePath") {
121
+ if (!trees) throw new ChainOutputValidationError(`Output '${parsed.name}' tree registry is unavailable.`);
122
+ return trees.resolve(parsed.name, entry);
123
+ }
124
+ if (!parsed.field) return entry.text;
100
125
  if (entry.patchPath) return entry.patchPath;
101
126
  const reason = entry.patchError ? `capture failed: ${entry.patchError}` : "no changes";
102
127
  throw new ChainOutputValidationError(`Output '${parsed.name}' has no patch (${reason}).`);
103
128
  });
104
129
  }
105
130
 
131
+ export class TreeRegistry {
132
+ readonly built = new Map<string, string>();
133
+ readonly created: Array<{ repoCwd: string; dir: string }> = [];
134
+
135
+ private readonly chainDir: string;
136
+
137
+ constructor(chainDir: string) {
138
+ this.chainDir = chainDir;
139
+ }
140
+
141
+ resolve(name: string, entry: ChainOutputMapEntry): string {
142
+ if (this.built.has(name)) return this.built.get(name)!;
143
+ if (!entry.patchPath) {
144
+ const reason = entry.patchError ? `capture failed: ${entry.patchError}` : "no changes";
145
+ throw new ChainOutputValidationError(`Output '${name}' has no patch (${reason}).`);
146
+ }
147
+ const dir = path.join(this.chainDir, "worktree-trees", `step-${entry.stepIndex}`, `task-${entry.taskIndex}-${entry.agent}`);
148
+ this.created.push({ repoCwd: entry.repoCwd!, dir });
149
+ try {
150
+ materializeTree({ repoCwd: entry.repoCwd!, baseCommit: entry.baseCommit!, patchPath: entry.patchPath, dir });
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ throw new ChainOutputValidationError(`Output '${name}' tree build failed: ${message.split("\n")[0]}`);
154
+ }
155
+ this.built.set(name, dir);
156
+ entry.treePath = dir;
157
+ return dir;
158
+ }
159
+ }
160
+
106
161
  function compactStructuredText(value: unknown): string {
107
162
  return JSON.stringify(value);
108
163
  }
109
164
 
110
165
  export interface OutputEntryPatchInfo {
166
+ taskIndex?: number;
167
+ baseCommit?: string;
168
+ repoCwd?: string;
111
169
  patchPath?: string;
112
170
  patchError?: string;
113
171
  }
114
172
 
115
- function patchFields(patch: OutputEntryPatchInfo | undefined): Pick<ChainOutputMapEntry, "patchPath" | "patchError"> {
173
+ function patchFields(patch: OutputEntryPatchInfo | undefined): OutputEntryPatchInfo {
116
174
  if (!patch) return {};
117
175
  return {
176
+ ...(patch.taskIndex !== undefined ? { taskIndex: patch.taskIndex } : {}),
177
+ ...(patch.baseCommit !== undefined ? { baseCommit: patch.baseCommit } : {}),
178
+ ...(patch.repoCwd !== undefined ? { repoCwd: patch.repoCwd } : {}),
118
179
  ...(patch.patchPath ? { patchPath: patch.patchPath } : {}),
119
180
  ...(patch.patchError ? { patchError: patch.patchError } : {}),
120
181
  };
@@ -33,6 +33,7 @@ export interface RunnerSubagentStep {
33
33
 
34
34
  export interface ParallelStepGroup {
35
35
  parallel: RunnerSubagentStep[];
36
+ cwd?: string;
36
37
  concurrency?: number;
37
38
  failFast?: boolean;
38
39
  worktree?: boolean;
@@ -106,6 +106,16 @@ export function formatSavedOutputReference(savedPath: string, fullOutput: string
106
106
  };
107
107
  }
108
108
 
109
+ export function formatFileOnlyFailure(params: {
110
+ outputPath?: string;
111
+ saveError?: string;
112
+ } & ({ preservedAt: string; notPreservedReason?: never } | { preservedAt?: never; notPreservedReason: string })): string {
113
+ const pointer = params.preservedAt !== undefined
114
+ ? `Full reply preserved at ${params.preservedAt}`
115
+ : `Full reply not preserved (${params.notPreservedReason})`;
116
+ return params.saveError ? `Output file error: ${params.outputPath}\n${params.saveError}\n\n${pointer}` : pointer;
117
+ }
118
+
109
119
  export function validateFileOnlyOutputMode(outputMode: OutputMode | undefined, outputPath: string | undefined, context: string): string | undefined {
110
120
  if (outputMode === "file-only" && !outputPath) {
111
121
  return `${context} sets outputMode: "file-only" but does not configure an output file. Set output to a path or use outputMode: "inline".`;
@@ -557,6 +557,38 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
557
557
  return diffs;
558
558
  }
559
559
 
560
+ export function materializeTree({ repoCwd, baseCommit, patchPath, dir }: {
561
+ repoCwd: string;
562
+ baseCommit: string;
563
+ patchPath: string;
564
+ dir: string;
565
+ }): string {
566
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
567
+ const add = runGit(repoCwd, ["worktree", "add", "--detach", dir, baseCommit]);
568
+ if (add.status !== 0) {
569
+ throw new Error(add.stderr.split("\n").map((line) => line.trim()).find(Boolean) || "git worktree add failed");
570
+ }
571
+ const apply = runGit(dir, ["apply", "--index", patchPath]);
572
+ if (apply.status !== 0) {
573
+ throw new Error(apply.stderr.split("\n").map((line) => line.trim()).find(Boolean) || "git apply failed");
574
+ }
575
+ return dir;
576
+ }
577
+
578
+ export function removeTrees(entries: Array<{ repoCwd: string; dir: string }>): void {
579
+ for (const { repoCwd, dir } of entries) {
580
+ if (!fs.existsSync(dir)) continue;
581
+ try { runGitChecked(repoCwd, ["worktree", "remove", "--force", dir]); } catch (error) {
582
+ console.warn(`Failed to remove tree '${dir}': ${error instanceof Error ? error.message : String(error)}`);
583
+ }
584
+ }
585
+ for (const repoCwd of new Set(entries.map((entry) => entry.repoCwd))) {
586
+ try { runGitChecked(repoCwd, ["worktree", "prune"]); } catch (error) {
587
+ console.warn(`Failed to prune worktrees in '${repoCwd}': ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ }
590
+ }
591
+
560
592
  export function cleanupWorktrees(setup: WorktreeSetup): void {
561
593
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
562
594
  cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!);
@@ -58,6 +58,10 @@ export interface ChainTaskOutputEntry {
58
58
  }
59
59
 
60
60
  export interface ChainOutputMapEntry {
61
+ taskIndex?: number;
62
+ baseCommit?: string;
63
+ repoCwd?: string;
64
+ treePath?: string;
61
65
  text: string;
62
66
  structured?: unknown;
63
67
  agent: string;