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.
@@ -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 {
@@ -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
- return supportsEmoji ? ` 🔔${count}` : ` [!${count}]`;
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 runElapsedMs = Math.max(0, Date.now() - new Date(run.createdAt).getTime());
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 progressPart = `${agentCountText} · ${runElapsedText}`;
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);
@@ -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(["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
+ }