pi-cohort 7.2.2 → 7.3.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.
@@ -1,36 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
 
5
4
  export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
6
5
 
7
- export function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
8
- let dir = path.dirname(entryPoint);
9
- while (true) {
10
- const packageJsonPath = path.join(dir, "package.json");
11
- if (fs.existsSync(packageJsonPath)) {
12
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown };
13
- if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
14
- }
15
- if (dir === path.dirname(dir)) return undefined;
16
- dir = path.dirname(dir);
17
- }
18
- }
19
-
20
- export function resolveInstalledPiPackageRoot(): string | undefined {
21
- return findPiPackageRootFromEntry(fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE)));
22
- }
23
-
24
- export function resolvePiPackageRoot(): string | undefined {
25
- try {
26
- const entry = process.argv[1];
27
- return entry ? findPiPackageRootFromEntry(fs.realpathSync(entry)) : undefined;
28
- } catch {
29
- // Non-launch package inspection is best-effort.
30
- return undefined;
31
- }
32
- }
33
-
34
6
  export interface PiRuntimeIdentity {
35
7
  readonly nodeExecPath: string;
36
8
  readonly piEntryPath: string;
@@ -90,13 +62,46 @@ function checkAvailability(file: string, executable: boolean): void {
90
62
  }
91
63
  }
92
64
 
93
- export function capturePiRuntime(deps: { execPath?: string; argv1?: string } = {}): PiRuntimeIdentity {
94
- const nodeExecPath = canonicalPath(deps.execPath ?? process.execPath, "Node executable");
95
- const piEntryPath = canonicalPath(deps.argv1 ?? process.argv[1], "Pi entry");
96
- checkAvailability(nodeExecPath, true);
65
+ function identifyFromEntry(entry: string | undefined): { piEntryPath: string; piPackageRoot: string } {
66
+ const piEntryPath = canonicalPath(entry, "Pi entry");
97
67
  checkAvailability(piEntryPath, false);
98
68
  const piPackageRoot = canonicalPath(owningPackageRoot(piEntryPath), "Pi package root");
99
- return { nodeExecPath, piEntryPath, piPackageRoot };
69
+ return { piEntryPath, piPackageRoot };
70
+ }
71
+
72
+ function identifyFromInstallRoot(installRoot: string): { piEntryPath: string; piPackageRoot: string } {
73
+ const piPackageRoot = canonicalPath(installRoot, "Pi installation root");
74
+ const manifest = path.join(piPackageRoot, "package.json");
75
+ let bin: unknown;
76
+ try {
77
+ const pkg = JSON.parse(fs.readFileSync(manifest, "utf-8"));
78
+ if (pkg?.name !== PI_CODING_AGENT_PACKAGE) {
79
+ throw new PiRuntimeLaunchError(`${manifest}: installation is not ${PI_CODING_AGENT_PACKAGE}`);
80
+ }
81
+ bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.pi;
82
+ } catch (error) {
83
+ if (error instanceof PiRuntimeLaunchError) throw error;
84
+ throw new PiRuntimeLaunchError(`${manifest}: ${String(error)}`);
85
+ }
86
+ if (typeof bin !== "string" || !bin) throw new PiRuntimeLaunchError(`${manifest}: missing declared Pi bin`);
87
+ const piEntryPath = canonicalPath(path.resolve(piPackageRoot, bin), "Declared Pi bin");
88
+ checkAvailability(piEntryPath, false);
89
+ const owner = canonicalPath(owningPackageRoot(piEntryPath), "Pi package root");
90
+ if (owner !== piPackageRoot) {
91
+ throw new PiRuntimeLaunchError(`${piEntryPath}: owning package ${owner} does not match installation ${piPackageRoot}`);
92
+ }
93
+ return { piEntryPath, piPackageRoot };
94
+ }
95
+
96
+ export function capturePiRuntime(deps: { execPath?: string; argv1?: string; installRoot?: string } = {}): PiRuntimeIdentity {
97
+ const nodeExecPath = canonicalPath(deps.execPath ?? process.execPath, "Node executable");
98
+ checkAvailability(nodeExecPath, true);
99
+ try {
100
+ return { nodeExecPath, ...identifyFromEntry(deps.argv1 ?? process.argv[1]) };
101
+ } catch (error) {
102
+ if (!(error instanceof PiRuntimeLaunchError) || deps.installRoot === undefined) throw error;
103
+ return { nodeExecPath, ...identifyFromInstallRoot(deps.installRoot) };
104
+ }
100
105
  }
101
106
 
102
107
  export function parsePiRuntime(value: unknown, source: string): PiRuntimeIdentity {
@@ -0,0 +1,100 @@
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
+ import type { StopCondition, StopMatch, StopRecord } from "../../shared/types.ts";
3
+ import { assertJsonPointer, DynamicFanoutError, resolveJsonPointer } from "./dynamic-fanout.ts";
4
+
5
+ export interface StopEvaluationTask {
6
+ agent: string;
7
+ as?: string;
8
+ label?: string;
9
+ exitCode: number | null | undefined;
10
+ structuredOutput?: unknown;
11
+ }
12
+
13
+ /** Shape check for one condition value; `label` prefixes the message (`Chain step 1 stopOn`, `config.steps[0].stopOn`). */
14
+ export function stopConditionShapeError(value: unknown, label: string): string | undefined {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${label} must be an object with path and in.`;
16
+ const record = value as Record<string, unknown>;
17
+ try {
18
+ if (typeof record.path !== "string") throw new DynamicFanoutError("Invalid JSON Pointer type.");
19
+ assertJsonPointer(record.path, `${label}.path`);
20
+ } catch (error) {
21
+ if (!(error instanceof DynamicFanoutError)) throw error;
22
+ return `${label}.path '${String(record.path)}' is not a JSON Pointer (must be "" or start with "/").`;
23
+ }
24
+ const list = record.in;
25
+ if (!Array.isArray(list) || list.length === 0 || list.some((member) => typeof member !== "string")) {
26
+ return `${label}.in must be a non-empty array of strings.`;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ /** Pre-run check across a chain; returns the first error message or undefined. */
32
+ export function validateStopConditions(steps: ChainStep[]): string | undefined {
33
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
34
+ const step = steps[stepIndex]!;
35
+ const stopOn = (step as { stopOn?: unknown }).stopOn;
36
+ if (stopOn === undefined) continue;
37
+ const stepNumber = stepIndex + 1;
38
+ const shapeError = stopConditionShapeError(stopOn, `Chain step ${stepNumber} stopOn`);
39
+ if (shapeError) return shapeError;
40
+ if (isParallelStep(step)) {
41
+ for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
42
+ if (!step.parallel[taskIndex]!.outputSchema) {
43
+ return `Chain step ${stepNumber} task ${taskIndex + 1} has no outputSchema; stopOn requires one on every task.`;
44
+ }
45
+ }
46
+ continue;
47
+ }
48
+ if (isDynamicParallelStep(step)) {
49
+ if (!step.parallel.outputSchema) return `Chain step ${stepNumber} task 1 has no outputSchema; stopOn requires one on every task.`;
50
+ continue;
51
+ }
52
+ if (!(step as SequentialStep).outputSchema) return `Chain step ${stepNumber} has stopOn but no outputSchema.`;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ function resolveStringAt(value: unknown, pointer: string): string | undefined {
58
+ let resolved: unknown;
59
+ try {
60
+ resolved = resolveJsonPointer(value, pointer, "stopOn.path");
61
+ } catch (error) {
62
+ if (error instanceof DynamicFanoutError) return undefined;
63
+ throw error;
64
+ }
65
+ return typeof resolved === "string" ? resolved : undefined;
66
+ }
67
+
68
+ /** Matches in task order; only exit-0 tasks with structured output are considered. Never throws for data. */
69
+ export function evaluateStopCondition(
70
+ condition: StopCondition,
71
+ stepIndex: number,
72
+ tasks: StopEvaluationTask[],
73
+ options: { sequential?: boolean } = {},
74
+ ): StopMatch[] {
75
+ const matches: StopMatch[] = [];
76
+ for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
77
+ const task = tasks[taskIndex]!;
78
+ if (task.exitCode !== 0 || task.structuredOutput === undefined) continue;
79
+ const value = resolveStringAt(task.structuredOutput, condition.path);
80
+ if (value === undefined || !condition.in.includes(value)) continue;
81
+ matches.push({
82
+ stepIndex,
83
+ ...(options.sequential ? {} : { taskIndex }),
84
+ ...(task.as ? { as: task.as } : {}),
85
+ ...(task.label ? { label: task.label } : {}),
86
+ agent: task.agent,
87
+ value,
88
+ });
89
+ }
90
+ return matches;
91
+ }
92
+
93
+ export function buildStopRecord(condition: StopCondition, stepIndex: number, matches: StopMatch[]): StopRecord {
94
+ return { reason: "condition", stepIndex, condition, matches };
95
+ }
96
+
97
+ export function formatStopCondition(condition: StopCondition): string {
98
+ const pointer = condition.path === "" ? '""' : condition.path;
99
+ return `${pointer} in [${condition.in.join(", ")}]`;
100
+ }
@@ -18,7 +18,7 @@ interface WorktreeInfo {
18
18
  syntheticPaths: string[];
19
19
  }
20
20
 
21
- interface WorktreeDiff {
21
+ export interface WorktreeDiff {
22
22
  index: number;
23
23
  agent: string;
24
24
  branch: string;
@@ -490,6 +490,12 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
490
490
  return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
491
491
  }
492
492
 
493
+ /** The patch a later chain step may reference: only a clean capture with real changes qualifies. */
494
+ export function patchPathForDiff(diff: WorktreeDiff): string | undefined {
495
+ if (diff.captureError) return undefined;
496
+ return hasWorktreeChanges(diff) ? diff.patchPath : undefined;
497
+ }
498
+
493
499
  export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
494
500
  const repo = resolveRepoState(cwd);
495
501
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
@@ -4,7 +4,8 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
- import type { Usage, SingleResult } from "./types.ts";
7
+ import type { Usage, SingleResult, ChainTaskOutputEntry, StopRecord } from "./types.ts";
8
+ import { formatStopCondition } from "../runs/shared/stop-conditions.ts";
8
9
  import type { ChainStep } from "./settings.ts";
9
10
  import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
10
11
  import { splitKnownThinkingSuffix, THINKING_LEVELS } from "./model-info.ts";
@@ -52,15 +53,62 @@ export function formatDuration(ms: number): string {
52
53
  return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
53
54
  }
54
55
 
56
+ export interface ChainSummaryOptions {
57
+ stop?: StopRecord;
58
+ outputs?: ChainTaskOutputEntry[];
59
+ }
60
+
61
+ function renderTaskHeading(entry: ChainTaskOutputEntry): string {
62
+ const asPart = entry.as ? `, as ${entry.as}` : "";
63
+ const base = `#### Task ${entry.taskIndex + 1} (${entry.agent}${asPart})`;
64
+ if (entry.exitCode === -1) return `${base} - skipped (fail-fast)`;
65
+ if (entry.exitCode !== 0) return `${base} - failed`;
66
+ return base;
67
+ }
68
+
69
+ /** `## Outputs`, grouped by step in execution order; skipped tasks get a heading only, failed tasks their error line. */
70
+ export function buildChainOutputsSection(entries: ChainTaskOutputEntry[]): string {
71
+ const lines: string[] = ["## Outputs"];
72
+ let currentStep = -1;
73
+ for (const entry of entries) {
74
+ if (entry.stepIndex !== currentStep) {
75
+ currentStep = entry.stepIndex;
76
+ lines.push(`### Step ${currentStep + 1}`);
77
+ }
78
+ lines.push(renderTaskHeading(entry));
79
+ if (entry.exitCode === -1) continue;
80
+ if (entry.exitCode !== 0) {
81
+ lines.push(entry.error || "failed");
82
+ continue;
83
+ }
84
+ if (entry.text) lines.push(entry.text);
85
+ if (entry.structured !== undefined) lines.push("```json", JSON.stringify(entry.structured), "```");
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ export function buildChainStopSection(stop: StopRecord): string {
91
+ return `## Stop\n\`\`\`json\n${JSON.stringify(stop, null, 2)}\n\`\`\``;
92
+ }
93
+
94
+ function appendChainSections(text: string, options: ChainSummaryOptions | undefined): string {
95
+ if (!options) return text;
96
+ const sections: string[] = [];
97
+ if (options.stop) sections.push(buildChainStopSection(options.stop));
98
+ if (options.outputs) sections.push(buildChainOutputsSection(options.outputs));
99
+ return sections.length ? `${text}\n\n${sections.join("\n\n")}\n` : text;
100
+ }
101
+
55
102
  /**
56
- * Build a summary string for a completed/failed chain
103
+ * Build a summary string for a completed/failed/stopped chain
57
104
  */
58
105
  export function buildChainSummary(
59
106
  steps: ChainStep[],
60
107
  results: SingleResult[],
61
108
  chainDir: string,
62
- status: "completed" | "failed",
109
+ status: "completed" | "failed" | "stopped",
63
110
  failedStep?: { index: number; error: string },
111
+ options?: ChainSummaryOptions,
64
112
  ): string {
65
113
  const stepNames = steps
66
114
  .map((step) => (isParallelStep(step) ? `parallel[${step.parallel.length}]` : isDynamicParallelStep(step) ? `expand:${step.parallel.agent}` : step.agent))
@@ -77,20 +125,21 @@ export function buildChainSummary(
77
125
  }
78
126
  const skillsLine = allSkills.size > 0 ? `šŸ”§ Skills: ${[...allSkills].join(", ")}` : "";
79
127
 
80
- if (status === "completed") {
81
- const stepWord = results.length === 1 ? "step" : "steps";
82
- return `āœ… Chain completed: ${stepNames} (${results.length} ${stepWord}, ${durationStr})${skillsLine ? `\n${skillsLine}` : ""}
83
-
84
- šŸ“‹ Progress: ${hasProgress ? progressPath : "(none)"}
85
- šŸ“ Artifacts: ${chainDir}`;
86
- } else {
87
- const stepInfo = failedStep ? ` at step ${failedStep.index + 1}` : "";
88
- const errorInfo = failedStep?.error ? `: ${failedStep.error}` : "";
89
- return `āŒ Chain failed${stepInfo}${errorInfo}${skillsLine ? `\n${skillsLine}` : ""}
128
+ const footer = `\n\nšŸ“‹ Progress: ${hasProgress ? progressPath : "(none)"}\nšŸ“ Artifacts: ${chainDir}`;
90
129
 
91
- šŸ“‹ Progress: ${hasProgress ? progressPath : "(none)"}
92
- šŸ“ Artifacts: ${chainDir}`;
130
+ if (status === "stopped" && options?.stop) {
131
+ const header = `ā¹ Chain stopped at step ${options.stop.stepIndex + 1}/${steps.length} by condition ${formatStopCondition(options.stop.condition)}`;
132
+ return appendChainSections(`${header}${skillsLine ? `\n${skillsLine}` : ""}${footer}`, options);
133
+ }
134
+ if (status === "completed" || status === "stopped") {
135
+ const stepWord = results.length === 1 ? "step" : "steps";
136
+ const header = `āœ… Chain completed: ${stepNames} (${results.length} ${stepWord}, ${durationStr})${skillsLine ? `\n${skillsLine}` : ""}`;
137
+ return appendChainSections(`${header}${footer}`, options);
93
138
  }
139
+ const stepInfo = failedStep ? ` at step ${failedStep.index + 1}` : "";
140
+ const errorInfo = failedStep?.error ? `: ${failedStep.error}` : "";
141
+ const header = `āŒ Chain failed${stepInfo}${errorInfo}${skillsLine ? `\n${skillsLine}` : ""}`;
142
+ return appendChainSections(`${header}${footer}`, options);
94
143
  }
95
144
 
96
145
  /**
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import type { AgentConfig } from "../agents/agents.ts";
8
8
  import { normalizeSkillInput } from "../agents/skills.ts";
9
- import { CHAIN_RUNS_DIR, type AcceptanceInput, type JsonSchemaObject, type OutputMode } from "./types.ts";
9
+ import { CHAIN_RUNS_DIR, type AcceptanceInput, type JsonSchemaObject, type OutputMode, type StopCondition } from "./types.ts";
10
10
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
11
11
  const INITIAL_PROGRESS_CONTENT = "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n";
12
12
 
@@ -56,6 +56,7 @@ export interface SequentialStep {
56
56
  skill?: string | string[] | false;
57
57
  model?: string;
58
58
  acceptance?: AcceptanceInput;
59
+ stopOn?: StopCondition;
59
60
  }
60
61
 
61
62
  /** Parallel task item within a parallel step */
@@ -104,6 +105,7 @@ export interface DynamicParallelStep {
104
105
  phase?: string;
105
106
  label?: string;
106
107
  acceptance?: AcceptanceInput;
108
+ stopOn?: StopCondition;
107
109
  }
108
110
 
109
111
  /** Parallel step: multiple agents running concurrently */
@@ -113,6 +115,7 @@ export interface ParallelStep {
113
115
  failFast?: boolean;
114
116
  worktree?: boolean;
115
117
  cwd?: string;
118
+ stopOn?: StopCondition;
116
119
  }
117
120
 
118
121
  /** Union type for chain steps */
@@ -23,11 +23,47 @@ export type OutputMode = "inline" | "file-only";
23
23
 
24
24
  export type JsonSchemaObject = Record<string, unknown>;
25
25
 
26
+ export interface StopCondition {
27
+ path: string;
28
+ in: string[];
29
+ }
30
+
31
+ export interface StopMatch {
32
+ stepIndex: number;
33
+ taskIndex?: number;
34
+ as?: string;
35
+ label?: string;
36
+ agent: string;
37
+ value: string;
38
+ }
39
+
40
+ export interface StopRecord {
41
+ reason: "condition";
42
+ stepIndex: number;
43
+ condition: StopCondition;
44
+ matches: StopMatch[];
45
+ }
46
+
47
+ /** One finished task's output as rendered in the chain result's `## Outputs` section. */
48
+ export interface ChainTaskOutputEntry {
49
+ stepIndex: number;
50
+ taskIndex: number;
51
+ agent: string;
52
+ as?: string;
53
+ label?: string;
54
+ text: string;
55
+ structured?: unknown;
56
+ exitCode: number | null | undefined;
57
+ error?: string;
58
+ }
59
+
26
60
  export interface ChainOutputMapEntry {
27
61
  text: string;
28
62
  structured?: unknown;
29
63
  agent: string;
30
64
  stepIndex: number;
65
+ patchPath?: string;
66
+ patchError?: string;
31
67
  }
32
68
 
33
69
  export type ChainOutputMap = Record<string, ChainOutputMapEntry>;
@@ -435,6 +471,7 @@ export interface Details {
435
471
  currentStepIndex?: number; // 0-indexed current step (for running chains)
436
472
  workflowGraph?: WorkflowGraphSnapshot;
437
473
  outputs?: ChainOutputMap;
474
+ stop?: StopRecord;
438
475
  }
439
476
 
440
477
  // ============================================================================
@@ -174,6 +174,7 @@ const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] =
174
174
  progress: step.progress,
175
175
  skill: step.skill ?? step.skills,
176
176
  model: step.model,
177
+ ...(step.stopOn ? { stopOn: step.stopOn } : {}),
177
178
  };
178
179
  });
179
180
  };