pi-agent-flow 1.2.4 → 1.2.6

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/src/cli-args.ts CHANGED
@@ -61,6 +61,17 @@ export interface ParsedFlowCliArgs {
61
61
  * - alwaysProxy: forwarded verbatim to every child
62
62
  * - fallbackModel/thinking/tools: used only when the flow file does not set them
63
63
  */
64
+ let _cachedArgs: ParsedFlowCliArgs | undefined;
65
+
66
+ /**
67
+ * Lazily parse process.argv once and cache the result.
68
+ * Avoids module-level side effects at import time.
69
+ */
70
+ export function getInheritedCliArgs(): ParsedFlowCliArgs {
71
+ if (!_cachedArgs) _cachedArgs = parseFlowCliArgs(process.argv);
72
+ return _cachedArgs;
73
+ }
74
+
64
75
  export function parseFlowCliArgs(argv: string[]): ParsedFlowCliArgs {
65
76
  const extensionArgs: string[] = [];
66
77
  const alwaysProxy: string[] = [];
package/src/config.ts CHANGED
@@ -29,6 +29,10 @@ export interface FlowSettings {
29
29
  toolOptimize?: boolean;
30
30
  /** Whether to inject structured JSON output instructions into flow prompts. Default: true. */
31
31
  structuredOutput?: boolean;
32
+ /** Maximum number of flows to execute concurrently. Default: 4. */
33
+ maxConcurrency?: number;
34
+ /** Whether to automatically queue follow-up flows based on hook transitions. Default: false. */
35
+ autoTransition?: boolean;
32
36
  }
33
37
 
34
38
  const BUILTIN_FLOW_MODEL_CONFIGS: FlowModelConfigs = {
@@ -176,6 +180,12 @@ function extractFlowSettings(settings: Record<string, unknown> | null): FlowSett
176
180
  if (typeof obj.structuredOutput === "boolean") {
177
181
  result.structuredOutput = obj.structuredOutput;
178
182
  }
183
+ if (typeof obj.maxConcurrency === "number" && Number.isSafeInteger(obj.maxConcurrency) && obj.maxConcurrency >= 1) {
184
+ result.maxConcurrency = obj.maxConcurrency;
185
+ }
186
+ if (typeof obj.autoTransition === "boolean") {
187
+ result.autoTransition = obj.autoTransition;
188
+ }
179
189
  return result;
180
190
  }
181
191
 
@@ -0,0 +1,399 @@
1
+ /**
2
+ * FlowExecutor — extracted from index.ts for testability.
3
+ *
4
+ * Encapsulates the orchestration logic for running flows: cycle detection,
5
+ * project-flow confirmation, parallel execution with failover, caching,
6
+ * hook invocation, auto-transition, and telemetry.
7
+ */
8
+
9
+ import type { FlowConfig } from "./agents.js";
10
+ import type {
11
+ SingleResult,
12
+ FlowDetails,
13
+ CompressedFlowResult,
14
+ FlowMetrics,
15
+ } from "./types.js";
16
+ import { isFlowSuccess, isFlowError, getFlowOutput, emptyFlowUsage } from "./types.js";
17
+ import { extractStructuredOutput } from "./structured-output.js";
18
+ import { runHooksDetailed, type RunHooksResult } from "./hooks.js";
19
+ import { mapFlowConcurrent, runFlow } from "./flow.js";
20
+ import { getFlowSummaryText } from "./runner-events.js";
21
+ import { resolveFlowModelCandidates, selectFlowModelStrategy, type LoadedFlowModelConfigs, type FlowModelStrategy } from "./config.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface FlowExecutorDeps {
28
+ /** All discovered flow configs. */
29
+ flows: FlowConfig[];
30
+ /** Current delegation depth. */
31
+ currentDepth: number;
32
+ /** Maximum delegation depth. */
33
+ maxDepth: number;
34
+ /** Ancestor flow stack (names). */
35
+ ancestorFlowStack: string[];
36
+ /** Whether cycle prevention is enabled. */
37
+ preventCycles: boolean;
38
+ /** Whether to use optimized tool list. */
39
+ toolOptimize: boolean;
40
+ /** Whether to inject structured output instructions. */
41
+ structuredOutput: boolean;
42
+ /** Working directory. */
43
+ cwd: string;
44
+ /** Loaded flow model configs. */
45
+ loadedFlowModelConfigs: LoadedFlowModelConfigs;
46
+ /** Max concurrency for parallel flow execution. */
47
+ maxConcurrency: number;
48
+ /** Whether auto-transition is enabled. */
49
+ autoTransition: boolean;
50
+ /** Abort signal. */
51
+ signal?: AbortSignal;
52
+ /** Streaming update callback. */
53
+ onUpdate?: (result: import("@mariozechner/pi-agent-core").AgentToolResult<FlowDetails>) => void;
54
+ /** Factory to wrap results into FlowDetails. */
55
+ makeDetails: (results: SingleResult[]) => FlowDetails;
56
+ /** Get a CLI flag value. */
57
+ getFlag: (name: string) => unknown;
58
+ /** Inherited CLI args for tier overrides. */
59
+ tierOverrideResolver: (tier: "lite" | "flash" | "full") => string | undefined;
60
+ /** Inherited fallback model. */
61
+ fallbackModel?: string;
62
+ /** Fork session snapshot JSONL. */
63
+ forkSessionSnapshotJsonl: string | null;
64
+ /** Flow result cache for compression. */
65
+ flowResultCache: Map<string, CompressedFlowResult[]>;
66
+ /** Project flows directory. */
67
+ projectFlowsDir: string | null;
68
+ /** Session manager for fork snapshot. */
69
+ sessionManager: { getHeader: () => unknown; getBranch: () => unknown[] };
70
+ /** Whether UI is available for confirmation. */
71
+ hasUI: boolean;
72
+ /** UI confirmation callback. */
73
+ uiConfirm: (title: string, body: string) => Promise<boolean>;
74
+ /** Telemetry callback. */
75
+ onFlowMetrics?: (metrics: FlowMetrics) => void;
76
+ /** Whether to prompt the user before running project-local flows. Default: true. */
77
+ confirmProjectFlows?: boolean;
78
+ }
79
+
80
+ export interface ExecuteFlowParams {
81
+ type: string;
82
+ intent: string;
83
+ aim: string;
84
+ cwd?: string;
85
+ }
86
+
87
+ export interface ExecuteFlowResult {
88
+ content: Array<{ type: string; text: string }>;
89
+ details: FlowDetails;
90
+ isError?: boolean;
91
+ /** Auto-queued transitions for the caller to execute. */
92
+ autoTransitions?: Array<{ type: string; intent: string }>;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Helpers
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function getFlowCycleViolations(
100
+ requestedNames: Set<string>,
101
+ ancestorFlowStack: string[],
102
+ ): string[] {
103
+ if (requestedNames.size === 0 || ancestorFlowStack.length === 0) return [];
104
+ const stackSet = new Set(ancestorFlowStack);
105
+ return Array.from(requestedNames).filter((name) => stackSet.has(name));
106
+ }
107
+
108
+ function getRequestedProjectFlows(
109
+ flows: FlowConfig[],
110
+ requestedNames: Set<string>,
111
+ ): FlowConfig[] {
112
+ return Array.from(requestedNames)
113
+ .map((name) => flows.find((f) => f.name === name.toLowerCase()))
114
+ .filter((f): f is FlowConfig => f?.source === "project");
115
+ }
116
+
117
+ async function confirmProjectFlowsIfNeeded(
118
+ projectFlows: FlowConfig[],
119
+ projectFlowsDir: string | null,
120
+ hasUI: boolean,
121
+ uiConfirm: (title: string, body: string) => Promise<boolean>,
122
+ ): Promise<{ ok: boolean; blocked?: string }> {
123
+ if (projectFlows.length === 0) return { ok: true };
124
+
125
+ const names = projectFlows.map((f) => f.name).join(", ");
126
+ const dir = projectFlowsDir ?? "(unknown)";
127
+
128
+ if (hasUI) {
129
+ const ok = await uiConfirm(
130
+ "Run project-local flows?",
131
+ `Flows: ${names}\nSource: ${dir}\n\nProject flows are repo-controlled. Only continue for trusted repositories.`,
132
+ );
133
+ return { ok };
134
+ }
135
+
136
+ return {
137
+ ok: false,
138
+ blocked: `Blocked: project-local flow confirmation required in non-UI mode.\nFlows: ${names}\nRe-run with confirmProjectFlows: false if trusted.`,
139
+ };
140
+ }
141
+
142
+ function shouldFailover(result: SingleResult): boolean {
143
+ if (result.stopReason === "aborted") return false;
144
+ const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
145
+ if (!text.trim()) return false;
146
+ if (text.includes("permission") || text.includes("invalid tool") || text.includes("bad settings")) {
147
+ return false;
148
+ }
149
+ return result.exitCode > 0;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // FlowExecutor
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Execute a set of flow tasks with full orchestration: cycle detection,
158
+ * project confirmation, parallel execution with model failover, hook
159
+ * invocation, auto-transition, and telemetry.
160
+ */
161
+ export async function executeFlows(
162
+ deps: FlowExecutorDeps,
163
+ params: ExecuteFlowParams[],
164
+ toolCallId: string,
165
+ ): Promise<ExecuteFlowResult> {
166
+ const {
167
+ flows, currentDepth, maxDepth, ancestorFlowStack, preventCycles,
168
+ toolOptimize, structuredOutput, cwd, loadedFlowModelConfigs,
169
+ maxConcurrency, autoTransition, signal, onUpdate, makeDetails,
170
+ getFlag, tierOverrideResolver, fallbackModel, forkSessionSnapshotJsonl,
171
+ flowResultCache, projectFlowsDir, hasUI, uiConfirm, onFlowMetrics,
172
+ confirmProjectFlows,
173
+ } = deps;
174
+
175
+ const requested = new Set<string>(params.map((f) => f.type.toLowerCase()));
176
+
177
+ // Cycle check
178
+ if (preventCycles) {
179
+ const violations = getFlowCycleViolations(requested, ancestorFlowStack);
180
+ if (violations.length > 0) {
181
+ const stack = ancestorFlowStack.join(" -> ") || "(root)";
182
+ return {
183
+ content: [{
184
+ type: "text",
185
+ text: `Blocked: cycle detected. Flow(s) in stack: ${violations.join(", ")}\nStack: ${stack}`,
186
+ }],
187
+ details: makeDetails([]),
188
+ isError: true,
189
+ };
190
+ }
191
+ }
192
+
193
+ // Project flow confirmation
194
+ const projectFlows = getRequestedProjectFlows(flows, requested);
195
+ if (projectFlows.length > 0 && confirmProjectFlows !== false) {
196
+ const { ok, blocked } = await confirmProjectFlowsIfNeeded(projectFlows, projectFlowsDir, hasUI, uiConfirm);
197
+ if (!ok) {
198
+ return {
199
+ content: [{ type: "text", text: blocked ?? "Canceled: project-local flows not approved." }],
200
+ details: makeDetails([]),
201
+ isError: !blocked,
202
+ };
203
+ }
204
+ }
205
+
206
+ // Resolve model strategy
207
+ const cliFlowModelConfig =
208
+ typeof getFlag("flow-model-config") === "string"
209
+ ? (getFlag("flow-model-config") as string)
210
+ : undefined;
211
+ const selectedFlowModelConfig = selectFlowModelStrategy(
212
+ loadedFlowModelConfigs.configs,
213
+ cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
214
+ );
215
+
216
+ // Pre-allocate results array
217
+ const allResults: SingleResult[] = new Array(params.length);
218
+ for (let i = 0; i < params.length; i++) {
219
+ allResults[i] = {
220
+ type: params[i].type,
221
+ agentSource: "unknown",
222
+ intent: params[i].intent,
223
+ aim: params[i].aim,
224
+ exitCode: -1,
225
+ messages: [],
226
+ stderr: "",
227
+ usage: emptyFlowUsage(),
228
+ };
229
+ }
230
+
231
+ // Streaming progress
232
+ let lastStreamingText = "";
233
+ let lastEmittedSignature: string | undefined;
234
+ const emitProgress = (streamingText?: string) => {
235
+ if (!onUpdate) return;
236
+ if (streamingText !== undefined) lastStreamingText = streamingText;
237
+ const text = lastStreamingText || "";
238
+ const signature =
239
+ text +
240
+ "|" +
241
+ allResults
242
+ .map(
243
+ (r) =>
244
+ `${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.errorMessage ?? ""}`,
245
+ )
246
+ .join(";");
247
+ if (signature === lastEmittedSignature) return;
248
+ lastEmittedSignature = signature;
249
+ onUpdate({
250
+ content: [{ type: "text", text }],
251
+ details: makeDetails([...allResults]),
252
+ });
253
+ };
254
+
255
+ if (onUpdate) emitProgress();
256
+
257
+ // Execute all flows in parallel
258
+ const executionStart = Date.now();
259
+ const results = await mapFlowConcurrent(params, maxConcurrency, async (item, index) => {
260
+ const normalizedType = item.type.toLowerCase();
261
+ const targetFlow = flows.find((f) => f.name === normalizedType);
262
+ const effectiveMaxDepth =
263
+ targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
264
+
265
+ const shouldInheritContext = targetFlow?.inheritContext !== false;
266
+ const tier = targetFlow?.tier ?? "flash";
267
+ const { candidates } = resolveFlowModelCandidates({
268
+ tier,
269
+ flowModel: targetFlow?.model,
270
+ cliTierOverride: tierOverrideResolver(tier),
271
+ strategy: selectedFlowModelConfig.strategy,
272
+ fallbackModel,
273
+ });
274
+ const attemptModels = candidates.length > 0 ? candidates : [undefined];
275
+ const attemptedModels: string[] = [];
276
+ let result = allResults[index];
277
+ const flowStart = Date.now();
278
+
279
+ for (let attempt = 0; attempt < attemptModels.length; attempt++) {
280
+ const candidateModel = attemptModels[attempt];
281
+ if (candidateModel) attemptedModels.push(candidateModel);
282
+ result = await runFlow({
283
+ cwd,
284
+ flows,
285
+ flowName: normalizedType,
286
+ intent: item.intent,
287
+ aim: item.aim,
288
+ taskCwd: item.cwd,
289
+ forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
290
+ parentDepth: currentDepth,
291
+ parentFlowStack: ancestorFlowStack,
292
+ maxDepth: effectiveMaxDepth,
293
+ preventCycles,
294
+ toolOptimize,
295
+ structuredOutput,
296
+ model: candidateModel,
297
+ signal,
298
+ onUpdate: (partial) => {
299
+ if (partial.details?.results[0]) {
300
+ allResults[index] = partial.details.results[0];
301
+ emitProgress(partial.content?.[0]?.text);
302
+ }
303
+ },
304
+ makeDetails,
305
+ });
306
+ allResults[index] = result;
307
+ emitProgress();
308
+ if (isFlowSuccess(result) || signal?.aborted) break;
309
+ if (attempt < attemptModels.length - 1 && shouldFailover(result)) {
310
+ continue;
311
+ }
312
+ break;
313
+ }
314
+
315
+ if (result && !isFlowSuccess(result) && attemptedModels.length > 1) {
316
+ const summary = `Model failover attempts: ${attemptedModels.join(" -> ")}`;
317
+ const baseStderr = result.stderr.trim();
318
+ result.stderr = baseStderr ? `${baseStderr}\n\n${summary}` : summary;
319
+ allResults[index] = result;
320
+ emitProgress();
321
+ }
322
+
323
+ // Telemetry for individual flow
324
+ if (onFlowMetrics) {
325
+ const flowDuration = Date.now() - flowStart;
326
+ onFlowMetrics({
327
+ type: normalizedType,
328
+ durationMs: flowDuration,
329
+ exitCode: result.exitCode,
330
+ success: isFlowSuccess(result),
331
+ model: result.model,
332
+ failoverCount: Math.max(0, attemptedModels.length - 1),
333
+ usage: result.usage,
334
+ source: result.agentSource,
335
+ depth: currentDepth + 1,
336
+ });
337
+ }
338
+
339
+ return result;
340
+ });
341
+
342
+ // Cache flow results
343
+ for (const result of results) {
344
+ const so = result.structuredOutput;
345
+ if (!so) continue;
346
+ const compressed: CompressedFlowResult = {
347
+ type: result.type,
348
+ status: isFlowError(result) ? "failed" : "accomplished",
349
+ };
350
+ if (so.files.length > 0) compressed.files = so.files;
351
+ if (so.commands.length > 0) compressed.commands = so.commands;
352
+ if (result.errorMessage) compressed.error = result.errorMessage;
353
+ const existing = flowResultCache.get(toolCallId) ?? [];
354
+ existing.push(compressed);
355
+ flowResultCache.set(toolCallId, existing);
356
+ }
357
+
358
+ // Build tool result
359
+ const successCount = results.filter((r) => isFlowSuccess(r)).length;
360
+ const flowReports = results.map((r) => {
361
+ const output = getFlowSummaryText(r);
362
+ const status = isFlowError(r) ? "failed" : "accomplished";
363
+ return `flow [${r.type}] ${status}\n\n${output}`;
364
+ });
365
+
366
+ // Post-flow hooks
367
+ const hookResult: RunHooksResult = runHooksDetailed(params, results);
368
+ const advisorBlock = hookResult.advisors.length > 0
369
+ ? "\n\n---\n\n💡 " + hookResult.advisors.join("\n💡 ")
370
+ : "";
371
+
372
+ // Auto-transition: collect qualifying transitions
373
+ const queuedTransitions: Array<{ type: string; intent: string }> = [];
374
+ if (autoTransition && hookResult.autoTransitions.length > 0) {
375
+ for (const transition of hookResult.autoTransitions) {
376
+ if (transition.confidence >= 0.7) {
377
+ const normalizedType = transition.type.toLowerCase();
378
+ const flowExists = flows.some((f) => f.name === normalizedType);
379
+ const notAlreadyRequested = !requested.has(normalizedType);
380
+ const noCycles = !preventCycles || !ancestorFlowStack.includes(normalizedType);
381
+ if (flowExists && notAlreadyRequested && noCycles) {
382
+ queuedTransitions.push({
383
+ type: transition.type,
384
+ intent: transition.intent,
385
+ });
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ return {
392
+ content: [{
393
+ type: "text" as const,
394
+ text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}${advisorBlock}`,
395
+ }],
396
+ details: makeDetails(results),
397
+ autoTransitions: queuedTransitions.length > 0 ? queuedTransitions : undefined,
398
+ };
399
+ }
package/src/flow.ts CHANGED
@@ -11,7 +11,7 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
13
  import { type FlowConfig } from "./agents.js";
14
- import { parseFlowCliArgs } from "./cli-args.js";
14
+ import { getInheritedCliArgs } from "./cli-args.js";
15
15
  import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate, drainCtxEstimate, updateSmoothedTps, drainSmoothedTps } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
@@ -102,7 +102,7 @@ function cleanupFlowTempDir(dir: string | null): void {
102
102
  // Build pi CLI arguments (fork-only)
103
103
  // ---------------------------------------------------------------------------
104
104
 
105
- const inheritedCliArgs = parseFlowCliArgs(process.argv);
105
+ const inheritedCliArgs = getInheritedCliArgs();
106
106
 
107
107
  /**
108
108
  * Transform a flow's tool list when toolOptimize is enabled.
@@ -167,19 +167,20 @@ function buildFlowArgs(
167
167
  if (thinking) args.push("--thinking", thinking);
168
168
 
169
169
  // Child flows get their configured tools from flow.tools, optimized by
170
- // getOptimizedTools, with web explicitly filtered out.
171
- // When flow.tools is undefined and toolOptimize=true, default to batch+bash+web
172
- // (flow is unnecessary since the child is already inside a flow).
173
- // When toolOptimize=false, include batch and web alongside legacy tools.
170
+ // Child flows cannot browse or search the web they work with whatever
171
+ // context the parent provides in the intent. `web` is always stripped.
174
172
  const defaultTools = toolOptimize
175
- ? ["batch", "bash", "web"]
176
- : ["read", "write", "edit", "batch", "bash", "flow", "web"];
173
+ ? ["batch", "bash"]
174
+ : ["read", "write", "edit", "batch", "bash", "flow"];
175
+ // getOptimizedTools replaces legacy read/write/edit with batch when
176
+ // toolOptimize is on. The result may still include `web` if the flow's
177
+ // frontmatter explicitly lists it, so we filter here.
177
178
  const optimizedTools = getOptimizedTools(flow.tools, toolOptimize) ?? defaultTools;
178
179
  let harnessTools = optimizedTools.filter((t) => t !== "web");
179
- // If the flow explicitly listed only "web" (or nothing after filtering),
180
- // fall back to defaultTools so the child isn't orphaned with zero tools.
180
+ // If the flow explicitly listed only tools that got filtered (e.g. just
181
+ // "web"), fall back to defaultTools so the child isn't orphaned.
181
182
  if (harnessTools.length === 0) {
182
- harnessTools = defaultTools.filter((t) => t !== "web");
183
+ harnessTools = [...defaultTools];
183
184
  }
184
185
  args.push("--tools", harnessTools.join(","));
185
186
 
package/src/hooks.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  type PostFlowHook,
10
+ type AutoTransition,
10
11
  type SingleResult,
11
12
  isFlowSuccess,
12
13
  } from "./types.js";
@@ -27,15 +28,34 @@ export function registerHook(hook: PostFlowHook): void {
27
28
  else hooks.push(hook);
28
29
  }
29
30
 
31
+ export interface RunHooksResult {
32
+ /** Advisory messages sorted by priority. */
33
+ advisors: string[];
34
+ /** Auto-transitions collected from hooks, sorted by confidence descending. */
35
+ autoTransitions: AutoTransition[];
36
+ }
37
+
30
38
  /**
31
39
  * Run all registered hooks against the given flow results.
32
- * Returns an array of advisory strings sorted by priority.
40
+ * Returns advisory strings sorted by priority and auto-transitions.
33
41
  */
34
42
  export function runHooks(
35
43
  params: Array<{ type: string; intent: string }>,
36
44
  results: SingleResult[],
37
45
  ): string[] {
46
+ const raw = runHooksDetailed(params, results);
47
+ return raw.advisors;
48
+ }
49
+
50
+ /**
51
+ * Run all hooks and return both advisors and auto-transitions.
52
+ */
53
+ export function runHooksDetailed(
54
+ params: Array<{ type: string; intent: string }>,
55
+ results: SingleResult[],
56
+ ): RunHooksResult {
38
57
  const messages: Array<{ priority: number; content: string }> = [];
58
+ const transitions: AutoTransition[] = [];
39
59
 
40
60
  for (const hook of hooks) {
41
61
  const triggerTypes = new Set(
@@ -50,11 +70,25 @@ export function runHooks(
50
70
  const result = hook.action({ results: matching, params });
51
71
  if (result) {
52
72
  messages.push({ priority: result.priority ?? 0, content: result.content });
73
+ if (result.autoTransition) {
74
+ transitions.push(result.autoTransition);
75
+ }
53
76
  }
54
77
  }
55
78
 
56
79
  messages.sort((a, b) => a.priority - b.priority);
57
- return messages.map((m) => m.content);
80
+ transitions.sort((a, b) => b.confidence - a.confidence);
81
+ return {
82
+ advisors: messages.map((m) => m.content),
83
+ autoTransitions: transitions,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Get a snapshot of all registered hooks. For introspection and plugin API.
89
+ */
90
+ export function getRegisteredHooks(): PostFlowHook[] {
91
+ return [...hooks];
58
92
  }
59
93
 
60
94
  /**
@@ -121,3 +155,79 @@ registerHook({
121
155
  };
122
156
  },
123
157
  });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Extended transition hooks
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** Suggest build or debug flow after a successful scout flow. */
164
+ registerHook({
165
+ name: "pi-agent-flow/scout-to-build",
166
+ trigger: { flowTypes: ["scout"], onlyOnSuccess: true },
167
+ action: (ctx) => {
168
+ const alreadyRequested = ctx.params.some(
169
+ (p) => ["build", "debug"].includes(p.type.toLowerCase()),
170
+ );
171
+ if (alreadyRequested) return null;
172
+
173
+ return {
174
+ content:
175
+ "Context mapped. Consider running a [build] flow to implement changes, or [debug] if investigating an issue.",
176
+ priority: 20,
177
+ };
178
+ },
179
+ });
180
+
181
+ /** Suggest build flow after a successful craft flow. */
182
+ registerHook({
183
+ name: "pi-agent-flow/craft-to-build",
184
+ trigger: { flowTypes: ["craft"], onlyOnSuccess: true },
185
+ action: (ctx) => {
186
+ const buildWasRequested = ctx.params.some(
187
+ (p) => p.type.toLowerCase() === "build",
188
+ );
189
+ if (buildWasRequested) return null;
190
+
191
+ return {
192
+ content:
193
+ "Plan ready. Consider running a [build] flow to implement the design.",
194
+ priority: 10,
195
+ };
196
+ },
197
+ });
198
+
199
+ /** Suggest craft or build flow after a successful ideas flow. */
200
+ registerHook({
201
+ name: "pi-agent-flow/ideas-to-craft",
202
+ trigger: { flowTypes: ["ideas"], onlyOnSuccess: true },
203
+ action: (ctx) => {
204
+ const alreadyRequested = ctx.params.some(
205
+ (p) => ["craft", "build"].includes(p.type.toLowerCase()),
206
+ );
207
+ if (alreadyRequested) return null;
208
+
209
+ return {
210
+ content:
211
+ "Ideas explored. Consider running a [craft] flow to design the approach, or [build] to implement directly.",
212
+ priority: 15,
213
+ };
214
+ },
215
+ });
216
+
217
+ /** Suggest audit flow after a successful debug flow. */
218
+ registerHook({
219
+ name: "pi-agent-flow/debug-to-audit",
220
+ trigger: { flowTypes: ["debug"], onlyOnSuccess: true },
221
+ action: (ctx) => {
222
+ const auditWasRequested = ctx.params.some(
223
+ (p) => p.type.toLowerCase() === "audit",
224
+ );
225
+ if (auditWasRequested) return null;
226
+
227
+ return {
228
+ content:
229
+ "Root cause identified. Consider running an [audit] flow to verify the fix area for related issues.",
230
+ priority: 20,
231
+ };
232
+ },
233
+ });