pi-crew 0.9.14 → 0.9.15

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.
@@ -11,7 +11,24 @@ export interface WorkflowDiscoveryResult {
11
11
  project: WorkflowConfig[];
12
12
  }
13
13
 
14
- const STEP_CONFIG_KEYS = new Set(["role", "dependsOn", "parallelGroup", "output", "reads", "model", "skills", "progress", "worktree", "verify", "task", "seedPaths", "preStepScript", "preStepArgs", "preStepTimeout", "preStepOptional"]);
14
+ const STEP_CONFIG_KEYS = new Set([
15
+ "role",
16
+ "dependsOn",
17
+ "parallelGroup",
18
+ "output",
19
+ "reads",
20
+ "model",
21
+ "skills",
22
+ "progress",
23
+ "worktree",
24
+ "verify",
25
+ "task",
26
+ "seedPaths",
27
+ "preStepScript",
28
+ "preStepArgs",
29
+ "preStepTimeout",
30
+ "preStepOptional",
31
+ ]);
15
32
 
16
33
  function parseStepSection(id: string, body: string): WorkflowStep | undefined {
17
34
  const lines = body.trim().split("\n");
@@ -47,24 +64,62 @@ function parseStepSection(id: string, body: string): WorkflowStep | undefined {
47
64
  reads: config.reads === "false" ? false : parseCsv(config.reads),
48
65
  model: config.model || undefined,
49
66
  skills: config.skills === "false" ? false : parseCsv(config.skills),
50
- progress: config.progress === "true" ? true : config.progress === "false" ? false : undefined,
51
- worktree: config.worktree === "true" ? true : config.worktree === "false" ? false : undefined,
52
- verify: config.verify === "true" ? true : config.verify === "false" ? false : undefined,
67
+ progress:
68
+ config.progress === "true"
69
+ ? true
70
+ : config.progress === "false"
71
+ ? false
72
+ : undefined,
73
+ worktree:
74
+ config.worktree === "true"
75
+ ? true
76
+ : config.worktree === "false"
77
+ ? false
78
+ : undefined,
79
+ verify:
80
+ config.verify === "true"
81
+ ? true
82
+ : config.verify === "false"
83
+ ? false
84
+ : undefined,
53
85
  seedPaths: parseCsv(config.seedPaths) || undefined,
54
86
  preStepScript: config.preStepScript || undefined,
55
87
  preStepArgs: parseCsv(config.preStepArgs) || undefined,
56
- preStepTimeout: parseOptionalInteger(config.preStepTimeout) ?? undefined,
57
- preStepOptional: config.preStepOptional === "true" || config.preStepOptional === "1",
88
+ preStepTimeout:
89
+ parseOptionalInteger(config.preStepTimeout) ?? undefined,
90
+ preStepOptional:
91
+ config.preStepOptional === "true" || config.preStepOptional === "1",
58
92
  };
59
93
  }
60
94
 
61
- const parseOptionalInteger = (value: string | undefined): number | undefined => {
95
+ const parseOptionalInteger = (
96
+ value: string | undefined,
97
+ ): number | undefined => {
62
98
  if (!value) return undefined;
63
99
  const parsed = Number.parseInt(value, 10);
64
100
  if (!Number.isFinite(parsed) || parsed < 1) return undefined;
65
101
  return Math.trunc(parsed);
66
102
  };
67
103
 
104
+ /** Parse frontmatter `topology:` field. Validates against allowed enum; bad values are silently
105
+ * dropped (fall through to auto-classification in topology-analyzer). */
106
+ const parseTopology = (
107
+ value: string | undefined,
108
+ ): WorkflowConfig["topology"] => {
109
+ if (!value) return undefined;
110
+ const v = value.trim().toLowerCase();
111
+ if (
112
+ v === "single" ||
113
+ v === "sequential" ||
114
+ v === "concurrent" ||
115
+ v === "complex-dag" ||
116
+ v === "dynamic"
117
+ ) {
118
+ return v;
119
+ }
120
+ return undefined;
121
+ };
122
+
68
123
  function hasSectionBoundary(body: string, match: RegExpMatchArray): boolean {
69
124
  const index = match.index ?? 0;
70
125
  if (index === 0 || body.slice(0, index).trim() === "") return true;
@@ -74,9 +129,15 @@ function hasSectionBoundary(body: string, match: RegExpMatchArray): boolean {
74
129
  }
75
130
 
76
131
  function isStepHeading(body: string, match: RegExpMatchArray): boolean {
77
- const sectionStart = match.index! + match[0].length + (body[match.index! + match[0].length] === "\n" ? 1 : 0);
132
+ const sectionStart =
133
+ match.index! +
134
+ match[0].length +
135
+ (body[match.index! + match[0].length] === "\n" ? 1 : 0);
78
136
  const nextHeading = body.slice(sectionStart).search(/^##\s+.+[^\S\n]*$/m);
79
- const section = body.slice(sectionStart, nextHeading >= 0 ? sectionStart + nextHeading : body.length);
137
+ const section = body.slice(
138
+ sectionStart,
139
+ nextHeading >= 0 ? sectionStart + nextHeading : body.length,
140
+ );
80
141
  for (const line of section.split("\n")) {
81
142
  const trimmed = line.trim();
82
143
  if (!trimmed) continue;
@@ -87,30 +148,58 @@ function isStepHeading(body: string, match: RegExpMatchArray): boolean {
87
148
  return false;
88
149
  }
89
150
 
90
- function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowConfig | undefined {
151
+ function parseWorkflowFile(
152
+ filePath: string,
153
+ source: ResourceSource,
154
+ ): WorkflowConfig | undefined {
91
155
  try {
92
156
  const content = fs.readFileSync(filePath, "utf-8");
93
157
  const { frontmatter, body } = parseFrontmatter(content);
94
- const name = frontmatter.name?.trim() || path.basename(filePath, ".workflow.md");
158
+ const name =
159
+ frontmatter.name?.trim() || path.basename(filePath, ".workflow.md");
95
160
  const matches = [...body.matchAll(/^##\s+(.+)[^\S\n]*$/gm)];
96
- const explicitStepIndexes = new Set(matches.map((match, index) => isStepHeading(body, match) ? index : undefined).filter((index): index is number => index !== undefined));
97
- const effectiveMatches = matches.filter((match, index) => explicitStepIndexes.has(index) || (hasSectionBoundary(body, match) && /^[a-z][a-z0-9-]*$/.test(match[1]?.trim() ?? "")));
98
- const parseMatches = explicitStepIndexes.size ? effectiveMatches : matches;
161
+ const explicitStepIndexes = new Set(
162
+ matches
163
+ .map((match, index) =>
164
+ isStepHeading(body, match) ? index : undefined,
165
+ )
166
+ .filter((index): index is number => index !== undefined),
167
+ );
168
+ const effectiveMatches = matches.filter(
169
+ (match, index) =>
170
+ explicitStepIndexes.has(index) ||
171
+ (hasSectionBoundary(body, match) &&
172
+ /^[a-z][a-z0-9-]*$/.test(match[1]?.trim() ?? "")),
173
+ );
174
+ const parseMatches = explicitStepIndexes.size
175
+ ? effectiveMatches
176
+ : matches;
99
177
  const steps: WorkflowStep[] = [];
100
178
  for (let i = 0; i < parseMatches.length; i++) {
101
179
  const match = parseMatches[i]!;
102
180
  const id = match[1]!.trim();
103
- const sectionStart = match.index! + match[0].length + (body[match.index! + match[0].length] === "\n" ? 1 : 0);
104
- const sectionEnd = i + 1 < parseMatches.length ? parseMatches[i + 1]!.index! : body.length;
105
- const step = parseStepSection(id, body.slice(sectionStart, sectionEnd));
181
+ const sectionStart =
182
+ match.index! +
183
+ match[0].length +
184
+ (body[match.index! + match[0].length] === "\n" ? 1 : 0);
185
+ const sectionEnd =
186
+ i + 1 < parseMatches.length
187
+ ? parseMatches[i + 1]!.index!
188
+ : body.length;
189
+ const step = parseStepSection(
190
+ id,
191
+ body.slice(sectionStart, sectionEnd),
192
+ );
106
193
  if (step) steps.push(step);
107
194
  }
108
195
  return {
109
196
  name,
110
- description: frontmatter.description?.trim() || "No description provided.",
197
+ description:
198
+ frontmatter.description?.trim() || "No description provided.",
111
199
  source,
112
200
  filePath,
113
201
  maxConcurrency: parseOptionalInteger(frontmatter.maxConcurrency),
202
+ topology: parseTopology(frontmatter.topology),
114
203
  steps,
115
204
  };
116
205
  } catch {
@@ -118,23 +207,37 @@ function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowCo
118
207
  }
119
208
  }
120
209
 
121
- function readWorkflowDir(dir: string, source: ResourceSource): WorkflowConfig[] {
210
+ function readWorkflowDir(
211
+ dir: string,
212
+ source: ResourceSource,
213
+ ): WorkflowConfig[] {
122
214
  if (!fs.existsSync(dir)) return [];
123
- const staticWorkflows = fs.readdirSync(dir)
215
+ const staticWorkflows = fs
216
+ .readdirSync(dir)
124
217
  .filter((entry) => entry.endsWith(".workflow.md"))
125
218
  .map((entry) => parseWorkflowFile(path.join(dir, entry), source))
126
- .filter((workflow): workflow is WorkflowConfig => workflow !== undefined)
219
+ .filter(
220
+ (workflow): workflow is WorkflowConfig => workflow !== undefined,
221
+ )
127
222
  .sort((a, b) => a.name.localeCompare(b.name));
128
223
  // P2: also discover dynamic workflows (*.dwf.ts). A .dwf.ts's default export is a JS orchestrator.
129
- const dynamicWorkflows = fs.readdirSync(dir)
224
+ const dynamicWorkflows = fs
225
+ .readdirSync(dir)
130
226
  .filter((entry) => entry.endsWith(".dwf.ts"))
131
227
  .map((entry) => parseDynamicWorkflowFile(path.join(dir, entry), source))
132
- .filter((workflow): workflow is WorkflowConfig => workflow !== undefined);
133
- return [...staticWorkflows, ...dynamicWorkflows].sort((a, b) => a.name.localeCompare(b.name));
228
+ .filter(
229
+ (workflow): workflow is WorkflowConfig => workflow !== undefined,
230
+ );
231
+ return [...staticWorkflows, ...dynamicWorkflows].sort((a, b) =>
232
+ a.name.localeCompare(b.name),
233
+ );
134
234
  }
135
235
 
136
236
  /** P2: a .dwf.ts is a dynamic workflow. Name = filename stem; script = the file itself. */
137
- function parseDynamicWorkflowFile(filePath: string, source: ResourceSource): WorkflowConfig | undefined {
237
+ function parseDynamicWorkflowFile(
238
+ filePath: string,
239
+ source: ResourceSource,
240
+ ): WorkflowConfig | undefined {
138
241
  try {
139
242
  const basename = path.basename(filePath, ".dwf.ts");
140
243
  return {
@@ -156,16 +259,28 @@ export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
156
259
  return { builtin: [], user: [], project: [] };
157
260
  }
158
261
  return {
159
- builtin: readWorkflowDir(path.join(packageRoot(), "workflows"), "builtin"),
262
+ builtin: readWorkflowDir(
263
+ path.join(packageRoot(), "workflows"),
264
+ "builtin",
265
+ ),
160
266
  user: readWorkflowDir(path.join(userPiRoot(), "workflows"), "user"),
161
- project: readWorkflowDir(path.join(projectCrewRoot(cwd), "workflows"), "project"),
267
+ project: readWorkflowDir(
268
+ path.join(projectCrewRoot(cwd), "workflows"),
269
+ "project",
270
+ ),
162
271
  };
163
272
  }
164
273
 
165
- export function allWorkflows(discovery: WorkflowDiscoveryResult | undefined): WorkflowConfig[] {
274
+ export function allWorkflows(
275
+ discovery: WorkflowDiscoveryResult | undefined,
276
+ ): WorkflowConfig[] {
166
277
  if (!discovery) return [];
167
278
  const byName = new Map<string, WorkflowConfig>();
168
- for (const workflow of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
279
+ for (const workflow of [
280
+ ...discovery.project,
281
+ ...discovery.builtin,
282
+ ...discovery.user,
283
+ ]) {
169
284
  byName.set(workflow.name, workflow);
170
285
  }
171
286
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,195 @@
1
+ // src/workflows/preflight-validator.ts
2
+ //
3
+ // Decision tree from .crew/knowledge.md "pi-crew USAGE THRESHOLD RULE"
4
+ // (3-step refined rule, 2026-06-29). The validator runs synchronously
5
+ // before executeTeamRun and returns a structured result; it NEVER throws.
6
+ // Call sites decide whether to short-circuit (block) or just log (warn).
7
+ //
8
+ // The validator is a pure function: it accepts an optional eventAppender
9
+ // in PreflightOptions for future telemetry wiring but does NOT call it
10
+ // itself. Integration call sites (Phase 3) will fire telemetry from the
11
+ // result they receive. This keeps the validator side-effect free and
12
+ // trivially testable.
13
+
14
+ import {
15
+ analyzeWorkflowTopology,
16
+ type WorkflowTopology,
17
+ } from "./topology-analyzer.ts";
18
+ import type { WorkflowConfig } from "./workflow-config.ts";
19
+
20
+ export interface PreflightOptions {
21
+ /** When true, downgrade any block/warn to allow. Audit trail should log the override. */
22
+ force?: boolean;
23
+ /** Optional hook reserved for future telemetry wiring. Not invoked by the validator. */
24
+ eventAppender?: (eventsPath: string, event: object) => void;
25
+ }
26
+
27
+ export interface PreflightResult {
28
+ /**
29
+ * Advisory severity. NEVER blocks execution — all levels log a note and proceed.
30
+ * - `info` — context-only, e.g. dynamic workflow, force-bypass acknowledged.
31
+ * - `note` — topology=`concurrent` or `complex-dag`: validated good use cases.
32
+ * - `warn` — potential misuse (single / sequential 2–3 / 4+ sequential). Provides
33
+ * the measured cost evidence (Run #3, Run #1) so the agent can decide
34
+ * whether to proceed or refactor into raw Agent calls.
35
+ */
36
+ level: "info" | "note" | "warn";
37
+ message: string;
38
+ suggestion: string;
39
+ topology: WorkflowTopology;
40
+ /** Mirrored from TopologyAnalysis for telemetry — useful to scope warn events. */
41
+ stepCount: number;
42
+ /** Mirrored from TopologyAnalysis — what the analyzer recommends. */
43
+ recommendation: string;
44
+ }
45
+
46
+ function info(
47
+ topology: WorkflowTopology,
48
+ stepCount: number,
49
+ recommendation: string,
50
+ message: string,
51
+ ): PreflightResult {
52
+ return {
53
+ level: "info",
54
+ message,
55
+ suggestion: "",
56
+ topology,
57
+ stepCount,
58
+ recommendation,
59
+ };
60
+ }
61
+
62
+ function note(
63
+ topology: WorkflowTopology,
64
+ stepCount: number,
65
+ recommendation: string,
66
+ message: string,
67
+ ): PreflightResult {
68
+ return {
69
+ level: "note",
70
+ message,
71
+ suggestion: "Validated use case — proceeding.",
72
+ topology,
73
+ stepCount,
74
+ recommendation,
75
+ };
76
+ }
77
+
78
+ function warn(
79
+ topology: WorkflowTopology,
80
+ stepCount: number,
81
+ recommendation: string,
82
+ message: string,
83
+ suggestion: string,
84
+ ): PreflightResult {
85
+ return {
86
+ level: "warn",
87
+ message,
88
+ suggestion,
89
+ topology,
90
+ stepCount,
91
+ recommendation,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Validate workflow usage against the topology threshold rule.
97
+ *
98
+ * **Returns a PreflightResult. NEVER blocks the call.** The validator is advisory only —
99
+ * the agent (caller) decides whether to proceed, refactor, or override. This honors
100
+ * Pi's design philosophy: tooling provides information, agents exercise judgment.
101
+ *
102
+ * Severity levels:
103
+ * - `info` — context-only (dynamic, force-bypass acknowledged).
104
+ * - `note` — validated use case (`concurrent`, `complex-dag`). Proceed.
105
+ * - `warn` — potential inefficiency. Provides measured cost evidence so the agent
106
+ * can weigh the trade-off (e.g. audit-trail value vs. raw-call speed).
107
+ *
108
+ * No `block` level exists — there is no scenario where pi-crew hard-rejects a call.
109
+ * The agent always gets to decide.
110
+ */
111
+ export function validateWorkflowUsage(
112
+ workflow: WorkflowConfig,
113
+ options: PreflightOptions = {},
114
+ ): PreflightResult {
115
+ const analysis = analyzeWorkflowTopology(workflow);
116
+ const { topology, stepCount, recommendation } = analysis;
117
+
118
+ // Rule 0: dynamic workflows are runtime-decided.
119
+ if (topology === "dynamic") {
120
+ return info(
121
+ topology,
122
+ stepCount,
123
+ recommendation,
124
+ "Dynamic workflow — runtime decides topology.",
125
+ );
126
+ }
127
+
128
+ // Rule 0b: explicit force-bypass acknowledged (still log it).
129
+ if (options.force === true) {
130
+ return info(
131
+ topology,
132
+ stepCount,
133
+ recommendation,
134
+ `Force-bypassed preflight acknowledged (topology=${topology}, stepCount=${stepCount}). Proceeding as requested.`,
135
+ );
136
+ }
137
+
138
+ // Rule 1: SINGLE — advisory note that raw Agent would be simpler.
139
+ if (topology === "single") {
140
+ return warn(
141
+ topology,
142
+ stepCount,
143
+ recommendation,
144
+ "Single-task workflow: pi-crew overhead exceeds benefit; raw Agent tool would be ~30× faster and ~5× cheaper. Proceeding anyway — proceed only if audit trail or team coordination matters here.",
145
+ "Consider using the raw Agent tool instead. If proceeding, no action needed — pi-crew will run the workflow as configured.",
146
+ );
147
+ }
148
+
149
+ // Rule 2: SEQUENTIAL — note per chain length with measured cost evidence.
150
+ if (topology === "sequential") {
151
+ if (stepCount === 2) {
152
+ return warn(
153
+ topology,
154
+ stepCount,
155
+ recommendation,
156
+ "2-step sequential chain: pi-crew adds overhead; 2 raw Agent calls would be faster. Proceeding anyway.",
157
+ "Consider 2 raw Agent calls in one turn if speed matters. Otherwise, no action needed.",
158
+ );
159
+ }
160
+ if (stepCount === 3) {
161
+ return warn(
162
+ topology,
163
+ stepCount,
164
+ recommendation,
165
+ "3-step sequential chain: measured 5.7× slower and 1.9× costlier than 3 raw Agent calls (Run #3 in .crew/state/runs/). Proceeding anyway.",
166
+ "Consider 3 raw Agent calls. If audit trail justifies overhead, proceed.",
167
+ );
168
+ }
169
+ return warn(
170
+ topology,
171
+ stepCount,
172
+ recommendation,
173
+ `${stepCount}-step sequential chain: longer chains may justify pi-crew for audit/dag-context reasons, but raw Agent calls remain faster. Proceeding anyway.`,
174
+ "Consider chaining raw Agent calls if speed matters. Otherwise, no action needed.",
175
+ );
176
+ }
177
+
178
+ // Rule 3: CONCURRENT — validated good use case, informational note.
179
+ if (topology === "concurrent") {
180
+ return note(
181
+ topology,
182
+ stepCount,
183
+ recommendation,
184
+ `Validated use case: ${analysis.fanOutDegree}-way parallel fan-out. pi-crew's parallelism wins here.`,
185
+ );
186
+ }
187
+
188
+ // Rule 4: COMPLEX_DAG — validated good use case, informational note.
189
+ return note(
190
+ topology,
191
+ stepCount,
192
+ recommendation,
193
+ `Validated use case: complex DAG with ${stepCount} steps, depth ${analysis.dagDepth}. pi-crew's dependency-context injection and adaptive-plan support wins here.`,
194
+ );
195
+ }
@@ -0,0 +1,219 @@
1
+ // src/workflows/topology-analyzer.ts
2
+ //
3
+ // Workflow topology classifier. Given a WorkflowConfig, returns a TopologyAnalysis
4
+ // describing the structural shape (single / sequential / concurrent / complex-dag
5
+ // / dynamic). The shape is used by the preflight validator to enforce the
6
+ // "don't use pi-crew for sequential independent agents" rule from .crew/knowledge.md.
7
+ //
8
+ // Algorithm (high level):
9
+ // 1. runtime === "dynamic" → DYNAMIC (chain/script workflows decide at runtime)
10
+ // 2. stepCount === 1 → SINGLE (one step: no concurrency, no DAG)
11
+ // 3. parallelGroupCount >= 1 AND max-fanout >= 3 → CONCURRENT
12
+ // 4. stepCount >= 4 AND has-multi-deps → COMPLEX_DAG
13
+ // 5. else → SEQUENTIAL
14
+ //
15
+ // "Has-multi-deps" = at least one step lists 2+ dependsOn. This is the
16
+ // unambiguous signal of a branching DAG (diamond patterns, fan-in joins).
17
+ // Plain linear chains with high depth are still SEQUENTIAL — Run #3 fast-fix
18
+ // (3 steps linear, depth 3) and the default workflow (4 steps linear, depth 4)
19
+ // are both sequential by this rule; raw Agent calls are faster for both.
20
+
21
+ import type { WorkflowConfig, WorkflowStep } from "./workflow-config.ts";
22
+
23
+ export type WorkflowTopology =
24
+ | "single"
25
+ | "sequential"
26
+ | "concurrent"
27
+ | "complex-dag"
28
+ | "dynamic";
29
+
30
+ export type TopologyRecommendation =
31
+ | "raw_agent"
32
+ | "fast_fix"
33
+ | "parallel_research"
34
+ | "implementation_adaptive"
35
+ | "any";
36
+
37
+ export interface TopologyAnalysis {
38
+ topology: WorkflowTopology;
39
+ stepCount: number;
40
+ parallelGroupCount: number;
41
+ fanOutDegree: number;
42
+ dagDepth: number;
43
+ recommendation: TopologyRecommendation;
44
+ reason: string;
45
+ }
46
+
47
+ /** Distinct parallelGroup values across all steps. Empty if no step sets a group. */
48
+ export function parallelGroupsFromSteps(steps: WorkflowStep[]): Set<string> {
49
+ const out = new Set<string>();
50
+ for (const step of steps) {
51
+ if (step.parallelGroup !== undefined) out.add(step.parallelGroup);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Max group size across all parallelGroup values. 0 if no step sets a group. */
57
+ export function fanOutDegreeFromSteps(steps: WorkflowStep[]): number {
58
+ const counts = new Map<string, number>();
59
+ for (const step of steps) {
60
+ if (step.parallelGroup === undefined) continue;
61
+ counts.set(
62
+ step.parallelGroup,
63
+ (counts.get(step.parallelGroup) ?? 0) + 1,
64
+ );
65
+ }
66
+ let max = 0;
67
+ for (const count of counts.values()) if (count > max) max = count;
68
+ return max;
69
+ }
70
+
71
+ /**
72
+ * Longest-path DAG depth from roots. A root is a step with no dependsOn.
73
+ * Returns 0 for an empty list, 1 for a list of roots only with no inter-step deps.
74
+ *
75
+ * Defensive: a step depending on an unknown id is treated as a root
76
+ * (validate-workflow.ts catches real cycles before this runs; this is
77
+ * belt-and-suspenders against malformed inputs).
78
+ */
79
+ export function dagDepthFromSteps(steps: WorkflowStep[]): number {
80
+ if (steps.length === 0) return 0;
81
+ const ids = new Set(steps.map((s) => s.id));
82
+ // Build adjacency and indegree. Unknown deps are dropped to avoid phantom edges.
83
+ const indeg = new Map<string, number>();
84
+ const adj = new Map<string, string[]>();
85
+ for (const step of steps) {
86
+ const deps = (step.dependsOn ?? []).filter((d) => ids.has(d));
87
+ indeg.set(step.id, deps.length);
88
+ // step.id's parents are deps; deps's children include step.id.
89
+ adj.set(step.id, adj.get(step.id) ?? []);
90
+ for (const dep of deps) {
91
+ const children = adj.get(dep) ?? [];
92
+ children.push(step.id);
93
+ adj.set(dep, children);
94
+ }
95
+ }
96
+ // Kahn-style BFS. depth[v] = max(depth[parent]) + 1, roots = 1.
97
+ const depth = new Map<string, number>();
98
+ const queue: string[] = [];
99
+ for (const [id, n] of indeg) {
100
+ if (n === 0) {
101
+ depth.set(id, 1);
102
+ queue.push(id);
103
+ }
104
+ }
105
+ let maxDepth = 0;
106
+ while (queue.length > 0) {
107
+ const v = queue.shift()!;
108
+ const dv = depth.get(v) ?? 0;
109
+ if (dv > maxDepth) maxDepth = dv;
110
+ for (const child of adj.get(v) ?? []) {
111
+ const cand = dv + 1;
112
+ if (cand > (depth.get(child) ?? 0)) depth.set(child, cand);
113
+ indeg.set(child, (indeg.get(child) ?? 0) - 1);
114
+ if ((indeg.get(child) ?? 0) === 0) queue.push(child);
115
+ }
116
+ }
117
+ return maxDepth;
118
+ }
119
+
120
+ /** True if at least one step lists 2+ dependsOn entries. */
121
+ function hasMultiDeps(steps: WorkflowStep[]): boolean {
122
+ for (const step of steps) {
123
+ if ((step.dependsOn?.length ?? 0) >= 2) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Classify a workflow's topology. See file header for the rule order.
130
+ * Pure function — no I/O, no side effects, no exceptions.
131
+ */
132
+ export function analyzeWorkflowTopology(
133
+ workflow: WorkflowConfig,
134
+ ): TopologyAnalysis {
135
+ // Chain/dynamic workflows: runtime decides the topology at execution time.
136
+ // Don't try to classify the empty `steps: []` (DynamicWorkflowConfig forces
137
+ // it to be empty).
138
+ if (workflow.runtime === "dynamic") {
139
+ return {
140
+ topology: "dynamic",
141
+ stepCount: 0,
142
+ parallelGroupCount: 0,
143
+ fanOutDegree: 0,
144
+ dagDepth: 0,
145
+ recommendation: "any",
146
+ reason: "Chain/dynamic workflow — runtime decides topology",
147
+ };
148
+ }
149
+
150
+ const steps = workflow.steps;
151
+ const stepCount = steps.length;
152
+ const parallelGroupCount = parallelGroupsFromSteps(steps).size;
153
+ const fanOutDegree = fanOutDegreeFromSteps(steps);
154
+ const dagDepth = dagDepthFromSteps(steps);
155
+
156
+ // Explicit topology override from frontmatter `topology:` field.
157
+ // When set, the author has already classified the workflow — respect that.
158
+ // We still compute structural metrics so telemetry can show the delta.
159
+ if (workflow.topology !== undefined && workflow.topology !== "dynamic") {
160
+ const recommendation: TopologyRecommendation =
161
+ workflow.topology === "single" || workflow.topology === "sequential"
162
+ ? "raw_agent"
163
+ : workflow.topology === "concurrent"
164
+ ? "parallel_research"
165
+ : workflow.topology === "complex-dag"
166
+ ? "implementation_adaptive"
167
+ : "any";
168
+ return {
169
+ topology: workflow.topology,
170
+ stepCount,
171
+ parallelGroupCount,
172
+ fanOutDegree,
173
+ dagDepth,
174
+ recommendation,
175
+ reason: `Explicit topology from frontmatter: '${workflow.topology}'`,
176
+ };
177
+ }
178
+
179
+ let topology: WorkflowTopology;
180
+ let recommendation: TopologyRecommendation;
181
+ let reason: string;
182
+
183
+ // Note: explicit `workflow.topology` override is handled BEFORE the variable
184
+ // declarations below (in the block above). If we reach here, auto-classify.
185
+
186
+ if (stepCount === 1) {
187
+ topology = "single";
188
+ recommendation = "raw_agent";
189
+ reason =
190
+ "Single-task workflow: no concurrency or DAG structure to justify pi-crew overhead.";
191
+ } else if (parallelGroupCount >= 1 && fanOutDegree >= 3) {
192
+ topology = "concurrent";
193
+ recommendation = "parallel_research";
194
+ reason = `Concurrent fan-out: ${fanOutDegree} steps in parallel group(s) of size ≥3.`;
195
+ } else if (stepCount >= 4 && hasMultiDeps(steps)) {
196
+ topology = "complex-dag";
197
+ recommendation = "implementation_adaptive";
198
+ reason = `Complex DAG: ${stepCount} steps with branching (≥2 deps on ≥1 node), depth ${dagDepth}.`;
199
+ } else {
200
+ topology = "sequential";
201
+ if (stepCount <= 3) {
202
+ recommendation = "raw_agent";
203
+ reason = `Sequential chain of ${stepCount} steps — raw Agent calls are faster.`;
204
+ } else {
205
+ recommendation = "fast_fix";
206
+ reason = `Sequential chain of ${stepCount} steps — audit-trail-justified, but raw calls remain faster.`;
207
+ }
208
+ }
209
+
210
+ return {
211
+ topology,
212
+ stepCount,
213
+ parallelGroupCount,
214
+ fanOutDegree,
215
+ dagDepth,
216
+ recommendation,
217
+ reason,
218
+ };
219
+ }
@@ -48,6 +48,17 @@ export interface WorkflowConfig {
48
48
  /** For runtime:"dynamic" — per-workflow token budget. When set, ctx.agent() auto-rejects with
49
49
  * ok:false once exhausted. Accumulated from each agent run's reported usage. */
50
50
  maxTokenBudget?: number;
51
+ /** Explicit topology classification from frontmatter `topology:` field.
52
+ * When set, overrides the auto-classified topology in analyzeWorkflowTopology().
53
+ * Used by preflight-validator to enforce "don't use pi-crew for sequential chains".
54
+ * Valid values: 'single' | 'sequential' | 'concurrent' | 'complex-dag' | 'dynamic'.
55
+ * Absent = auto-classify from step structure (default). */
56
+ topology?:
57
+ | "single"
58
+ | "sequential"
59
+ | "concurrent"
60
+ | "complex-dag"
61
+ | "dynamic";
51
62
  }
52
63
 
53
64
  /** A dynamic workflow (runtime === "dynamic"). steps is empty — the script is the source of truth. */