pi-cohort 7.3.0 → 7.4.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.
@@ -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 ?? [];
@@ -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;
@@ -448,11 +448,10 @@ function captureWorktreeDiff(
448
448
  removeSyntheticPathsBeforeDiff(worktree);
449
449
  runGitChecked(worktree.path, ["add", "-A"]);
450
450
  const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
451
- const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
451
+ runGitChecked(worktree.path, ["diff", "--cached", "--binary", `--output=${path.resolve(patchPath)}`, setup.baseCommit]);
452
452
  const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
453
- fs.writeFileSync(patchPath, patch, "utf-8");
454
453
 
455
- if (!patch.trim()) {
454
+ if (fs.statSync(patchPath).size === 0) {
456
455
  return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
457
456
  }
458
457
 
@@ -558,6 +557,38 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
558
557
  return diffs;
559
558
  }
560
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
+
561
592
  export function cleanupWorktrees(setup: WorktreeSetup): void {
562
593
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
563
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;