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.
- package/CHANGELOG.md +62 -0
- package/README.md +66 -0
- package/package.json +1 -1
- package/src/config/types.ts +5 -0
- package/src/extension/registration/team-tool.ts +145 -35
- package/src/extension/team-tool/run.ts +646 -150
- package/src/runtime/team-runner.ts +1445 -318
- package/src/workflows/discover-workflows.ts +144 -29
- package/src/workflows/preflight-validator.ts +195 -0
- package/src/workflows/topology-analyzer.ts +219 -0
- package/src/workflows/workflow-config.ts +11 -0
- package/workflows/chain.workflow.md +6 -0
- package/workflows/default.workflow.md +1 -0
- package/workflows/fast-fix.workflow.md +1 -0
- package/workflows/implementation.workflow.md +1 -0
- package/workflows/parallel-research.workflow.md +1 -0
- package/workflows/pipeline.workflow.md +1 -0
- package/workflows/research.workflow.md +1 -0
- package/workflows/review.workflow.md +1 -0
|
@@ -11,7 +11,24 @@ export interface WorkflowDiscoveryResult {
|
|
|
11
11
|
project: WorkflowConfig[];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const STEP_CONFIG_KEYS = new Set([
|
|
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:
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
57
|
-
|
|
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 = (
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
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(
|
|
210
|
+
function readWorkflowDir(
|
|
211
|
+
dir: string,
|
|
212
|
+
source: ResourceSource,
|
|
213
|
+
): WorkflowConfig[] {
|
|
122
214
|
if (!fs.existsSync(dir)) return [];
|
|
123
|
-
const staticWorkflows = fs
|
|
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(
|
|
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
|
|
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(
|
|
133
|
-
|
|
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(
|
|
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(
|
|
262
|
+
builtin: readWorkflowDir(
|
|
263
|
+
path.join(packageRoot(), "workflows"),
|
|
264
|
+
"builtin",
|
|
265
|
+
),
|
|
160
266
|
user: readWorkflowDir(path.join(userPiRoot(), "workflows"), "user"),
|
|
161
|
-
project: readWorkflowDir(
|
|
267
|
+
project: readWorkflowDir(
|
|
268
|
+
path.join(projectCrewRoot(cwd), "workflows"),
|
|
269
|
+
"project",
|
|
270
|
+
),
|
|
162
271
|
};
|
|
163
272
|
}
|
|
164
273
|
|
|
165
|
-
export function allWorkflows(
|
|
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 [
|
|
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. */
|