pi-crew 0.9.13 → 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 +114 -0
- package/README.md +66 -0
- package/docs/bugs/bug-021-notification-badge-counter-misleading.md +293 -0
- package/docs/bugs/bug-023-chain-windows-path-resolution.md +106 -0
- package/package.json +1 -1
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +5 -0
- package/src/extension/pi-api.ts +1 -1
- package/src/extension/registration/team-tool.ts +145 -35
- package/src/extension/team-tool/run.ts +646 -150
- package/src/runtime/child-pi.ts +87 -1
- package/src/runtime/goal-achievement.ts +131 -0
- package/src/runtime/recovery-recipes.ts +35 -0
- package/src/runtime/task-runner.ts +6 -0
- package/src/runtime/team-runner.ts +1482 -310
- package/src/state/types.ts +3 -0
- package/src/ui/widget/index.ts +1 -1
- package/src/ui/widget/widget-formatters.ts +14 -1
- package/src/ui/widget/widget-renderer.ts +13 -2
- package/src/utils/redaction.ts +20 -0
- 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
package/src/state/types.ts
CHANGED
|
@@ -201,6 +201,9 @@ export interface TeamRunManifest {
|
|
|
201
201
|
args?: unknown;
|
|
202
202
|
summary?: string;
|
|
203
203
|
policyDecisions?: PolicyDecision[];
|
|
204
|
+
/** #2 (assessment): goal-achievement verdict — kills the silent false-green. */
|
|
205
|
+
goalAchieved?: boolean | "unknown";
|
|
206
|
+
goalAchievementNote?: string;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
export interface UsageState {
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function getRenderWidth(width?: number): number {
|
|
|
42
42
|
if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
|
|
43
43
|
return DEFAULT_WIDGET_WIDTH;
|
|
44
44
|
}
|
|
45
|
-
export { notificationBadge } from "./widget-formatters.ts";
|
|
45
|
+
export { notificationBadge, NOTIFICATION_BADGE_CAP } from "./widget-formatters.ts";
|
|
46
46
|
|
|
47
47
|
// ── Constants ─────────────────────────────────────────────────────────
|
|
48
48
|
|
|
@@ -144,9 +144,22 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
|
|
|
144
144
|
|
|
145
145
|
// ── Notification badge ────────────────────────────────────────────────
|
|
146
146
|
|
|
147
|
+
// Bug 021: the bell glyph 🔔 was misread as "queued messages" — users saw
|
|
148
|
+
// `🔔227` and concluded there were 227 pending items, when the value is a
|
|
149
|
+
// CUMULATIVE warning/error/critical count with zero actual queue behind it.
|
|
150
|
+
// Fix: relabel to an explicit "alerts" segment (no bell) and cap the display
|
|
151
|
+
// at 99+ (standard badge practice). The cumulative count stays accurate
|
|
152
|
+
// internally (widgetState.notificationCount) and remains fully logged in
|
|
153
|
+
// .crew/state/notifications/YYYY-MM-DD.jsonl — this bounds presentation only.
|
|
154
|
+
// Deeper fixes (decay window, owner-scope, auto-reset on all-runs-terminal,
|
|
155
|
+
// full deprecation) are product decisions documented in
|
|
156
|
+
// docs/bugs/bug-021-notification-badge-counter-misleading.md.
|
|
157
|
+
export const NOTIFICATION_BADGE_CAP = 99;
|
|
158
|
+
|
|
147
159
|
export function notificationBadge(count: number | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
|
148
160
|
if (!count || count <= 0) return "";
|
|
149
161
|
const term = `${env.TERM ?? ""} ${env.WT_SESSION ?? ""} ${env.TERM_PROGRAM ?? ""}`.toLowerCase();
|
|
150
162
|
const supportsEmoji = !term.includes("dumb") && env.NO_COLOR !== "1";
|
|
151
|
-
|
|
163
|
+
const label = count > NOTIFICATION_BADGE_CAP ? `${NOTIFICATION_BADGE_CAP}+ alerts` : `${count} alerts`;
|
|
164
|
+
return supportsEmoji ? ` · ${label}` : ` [${label}]`;
|
|
152
165
|
}
|
|
@@ -15,6 +15,7 @@ import { computeLiveDurationMs } from "../live-duration.ts";
|
|
|
15
15
|
import { getTaskUsage } from "../../runtime/usage-tracker.ts";
|
|
16
16
|
import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
|
|
17
17
|
import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
|
|
18
|
+
import { isFinishedRunStatus } from "../../runtime/process-status.ts";
|
|
18
19
|
import type { WidgetRun } from "./widget-types.ts";
|
|
19
20
|
|
|
20
21
|
const MAX_AGENTS_DISPLAY = 3;
|
|
@@ -69,6 +70,7 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
69
70
|
});
|
|
70
71
|
const completed = agents.filter((a) => a.status === "completed").length;
|
|
71
72
|
const runGlyph = iconForStatus(run.status, { runningGlyph });
|
|
73
|
+
const isTerminal = isFinishedRunStatus(run.status);
|
|
72
74
|
// Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
|
|
73
75
|
// too minimal (`0/1 agents` only), v5 duplicated the worker activity
|
|
74
76
|
// line (tools/tokens/duration already shown one row below). v6 (this)
|
|
@@ -81,10 +83,19 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
81
83
|
// for a healthy run), and `run.createdAt` is immutable. The format
|
|
82
84
|
// shape `"X/Y agents · Ns"` is therefore truly invariant: same number
|
|
83
85
|
// of `·`-separated fields, same field meanings, every render tick.
|
|
86
|
+
//
|
|
87
|
+
// Bug 022 (timer-fix + label): for TERMINAL runs (failed/cancelled/
|
|
88
|
+
// completed) the elapsed counter previously kept ticking up forever
|
|
89
|
+
// from createdAt (a failed run showed `2028s` and climbing, read as
|
|
90
|
+
// "still running"). Now it FREEZES at updatedAt (when the run
|
|
91
|
+
// reached its terminal status). The status label is also surfaced
|
|
92
|
+
// explicitly so the row cannot be misread as an active run.
|
|
84
93
|
const agentCountText = `${completed}/${agents.length} agents`;
|
|
85
|
-
const
|
|
94
|
+
const runEndMs = isTerminal ? new Date(run.updatedAt).getTime() : Date.now();
|
|
95
|
+
const runElapsedMs = Math.max(0, Number.isFinite(runEndMs) ? runEndMs - new Date(run.createdAt).getTime() : 0);
|
|
86
96
|
const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
|
|
87
|
-
const
|
|
97
|
+
const statusLabel = isTerminal ? ` · ${run.status}` : "";
|
|
98
|
+
const progressPart = `${agentCountText} · ${runElapsedText}${statusLabel}`;
|
|
88
99
|
lines.push(truncate(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`, width));
|
|
89
100
|
|
|
90
101
|
const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
|
package/src/utils/redaction.ts
CHANGED
|
@@ -19,6 +19,24 @@ export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]
|
|
|
19
19
|
// Full mitigation ladder: (1) redaction here + at artifact-write; (2) Phase 1.5
|
|
20
20
|
// sanitized-env verification; (3) sandbox (deferred).
|
|
21
21
|
|
|
22
|
+
// Exclusion list: LLM usage-count key names that contain "token" but are
|
|
23
|
+
// observable metrics, NOT credentials. These must NEVER be redacted so that
|
|
24
|
+
// token-usage observability in events.jsonl is preserved. The isSecretKey()
|
|
25
|
+
// keyword-scan matches '_token' in 'prompt_tokens' (underscore + "token"),
|
|
26
|
+
// falsely classifying usage counts as secrets. See performance/quality
|
|
27
|
+
// assessment fix #5.
|
|
28
|
+
const TOKEN_COUNT_KEYS = new Set([
|
|
29
|
+
"prompt_tokens",
|
|
30
|
+
"completion_tokens",
|
|
31
|
+
"total_tokens",
|
|
32
|
+
"cached_tokens",
|
|
33
|
+
"reasoning_tokens",
|
|
34
|
+
"cached_read_tokens",
|
|
35
|
+
"cached_write_tokens",
|
|
36
|
+
"input_tokens",
|
|
37
|
+
"output_tokens",
|
|
38
|
+
]);
|
|
39
|
+
|
|
22
40
|
// JWT — three base64url segments separated by dots, distinctive "eyJ" headers.
|
|
23
41
|
// Linear: single + on [A-Za-z0-9_-] per segment, no nesting.
|
|
24
42
|
export const JWT_PATTERN = /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
@@ -43,6 +61,8 @@ export const STRIPE_KEY_PATTERN = /(?<![A-Za-z0-9_])sk_live_[0-9a-zA-Z]{24}(?![0
|
|
|
43
61
|
// a more complex regex, catastrophic backtracking (ReDoS) could result.
|
|
44
62
|
// Any modifications must preserve O(n) complexity where n = keyName.length.
|
|
45
63
|
export function isSecretKey(keyName: string): boolean {
|
|
64
|
+
// Fast path: known token-count keys are never secrets.
|
|
65
|
+
if (TOKEN_COUNT_KEYS.has(keyName.toLowerCase())) return false;
|
|
46
66
|
// Fast path: common secret key names (safe anchored regex, no backtracking)
|
|
47
67
|
const lower = keyName.toLowerCase();
|
|
48
68
|
if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
|
|
@@ -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
|
+
}
|