pi-crew 0.3.7 → 0.3.9

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/src/agents/discover-agents.ts +354 -15
  4. package/src/config/config.ts +732 -208
  5. package/src/config/types.ts +34 -5
  6. package/src/extension/help.ts +1 -0
  7. package/src/extension/register.ts +1173 -257
  8. package/src/extension/registration/commands.ts +15 -2
  9. package/src/extension/registration/team-tool.ts +1 -1
  10. package/src/extension/session-summary.ts +11 -1
  11. package/src/extension/team-tool/api.ts +4 -1
  12. package/src/extension/team-tool/cache-control.ts +23 -0
  13. package/src/extension/team-tool/cancel.ts +15 -5
  14. package/src/extension/team-tool/context.ts +2 -0
  15. package/src/extension/team-tool/handle-settings.ts +2 -0
  16. package/src/extension/team-tool/health-monitor.ts +563 -0
  17. package/src/extension/team-tool/inspect.ts +10 -3
  18. package/src/extension/team-tool/respond.ts +5 -2
  19. package/src/extension/team-tool/status.ts +4 -1
  20. package/src/extension/team-tool-types.ts +2 -0
  21. package/src/extension/team-tool.ts +901 -177
  22. package/src/runtime/adaptive-plan.ts +1 -1
  23. package/src/runtime/foreground-watchdog.ts +129 -0
  24. package/src/runtime/manifest-cache.ts +4 -2
  25. package/src/runtime/run-tracker.ts +11 -0
  26. package/src/runtime/runtime-policy.ts +15 -2
  27. package/src/runtime/skill-instructions.ts +8 -2
  28. package/src/runtime/stale-reconciler.ts +322 -18
  29. package/src/runtime/task-packet.ts +48 -1
  30. package/src/runtime/task-runner.ts +6 -1
  31. package/src/schema/config-schema.ts +1 -0
  32. package/src/schema/team-tool-schema.ts +204 -76
  33. package/src/state/state-store.ts +9 -1
  34. package/src/teams/discover-teams.ts +2 -1
  35. package/src/ui/run-event-bus.ts +2 -1
  36. package/src/ui/settings-overlay.ts +2 -0
  37. package/src/workflows/discover-workflows.ts +5 -1
@@ -2,6 +2,51 @@ import * as path from "node:path";
2
2
  import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
3
3
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
4
4
 
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // SEC-007 Fix: Workflow Step Task Sanitization
7
+ // Context provided by workers comes from workflow definitions that could
8
+ // be user-controlled. Sanitize task text to prevent injection.
9
+ // See: SECURITY-ISSUES.md SEC-007
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+
12
+
13
+ /**
14
+ * Sanitize workflow step task text to reduce injection risk.
15
+ *
16
+ * The task text is used as a prompt for worker agents. In a multi-tenant
17
+ * or shared workflow scenario, malicious workflow definitions could
18
+ * embed injection instructions.
19
+ *
20
+ * Sanitization:
21
+ * - Strip zero-width Unicode characters
22
+ * - Strip known prompt injection directive patterns
23
+ * - Strip base64/hex encoded payloads
24
+ * - Collapse excessive whitespace
25
+ */
26
+ export function sanitizeTaskText(task: string): string {
27
+ let sanitized = task;
28
+
29
+ // 1. Strip zero-width and invisible Unicode characters
30
+ sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, "");
31
+
32
+ // 2. Strip known prompt injection directive patterns
33
+ sanitized = sanitized.replace(
34
+ /^\s*(?:SYSTEM|INSTRUCTION|IGNORE(?:\s+ALL)?\s+INSTRUCTIONS|OVERRIDE|YOUR\s+ROLE\s+IS|MALICIOUS)\s*:.*$/gim,
35
+ ""
36
+ );
37
+
38
+ // 3. Strip base64/hex encoded command payloads
39
+ sanitized = sanitized.replace(/\b(?:base64|base32|hex)\s*['":]\s*([A-Za-z0-9+\/=]{16,})/gi, "[encoded-redacted]");
40
+
41
+ // 4. Strip embedded instruction patterns in brackets
42
+ sanitized = sanitized.replace(/\[(?:SYSTEM|INSTRUCTION|OVERRIDE)\s*:[^\]]*\]/gi, "");
43
+
44
+ // 5. Collapse multiple blank lines
45
+ sanitized = sanitized.replace(/\n{3,}/g, "\n\n");
46
+
47
+ return sanitized.trim();
48
+ }
49
+
5
50
  export interface BuildTaskPacketInput {
6
51
  manifest: TeamRunManifest;
7
52
  step: WorkflowStep;
@@ -34,8 +79,10 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
34
79
  const scope = inferTaskScope(input.step);
35
80
  const reads = input.step.reads === false ? [] : input.step.reads ?? [];
36
81
  const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
82
+ // SEC-007: Sanitize task text before inserting into task packet
83
+ const sanitizedTask = sanitizeTaskText(input.step.task);
37
84
  return {
38
- objective: input.step.task.replaceAll("{goal}", input.manifest.goal),
85
+ objective: sanitizedTask.replaceAll("{goal}", input.manifest.goal),
39
86
  scope,
40
87
  scopePath,
41
88
  repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
@@ -829,8 +829,13 @@ export async function runTeamTask(
829
829
  // _yieldResult: preserved for future use — yield completion contract not yet wired to task.result
830
830
  let _yieldResult: YieldResult | undefined;
831
831
  let noYield = false;
832
+ // Child-process workers do not have a submit_result tool — the yield contract
833
+ // only applies to live-session workers where submit_result is injected by the
834
+ // runtime. Skipping yield detection for child-process prevents every child
835
+ // worker from incorrectly being marked needs_attention.
832
836
  const yieldEnabled =
833
- input.runtimeConfig?.yield?.enabled ?? DEFAULT_YIELD_CONFIG.enabled;
837
+ runtimeKind !== "child-process" &&
838
+ (input.runtimeConfig?.yield?.enabled ?? DEFAULT_YIELD_CONFIG.enabled);
834
839
  if (yieldEnabled && collectedJsonEvents.length > 0) {
835
840
  if (hasYieldInOutput(collectedJsonEvents)) {
836
841
  const yieldEvent = collectedJsonEvents.find((e) =>
@@ -113,6 +113,7 @@ export const PiTeamsReliabilityConfigSchema = Type.Object({
113
113
  }, { additionalProperties: false })),
114
114
  autoRecover: Type.Optional(Type.Boolean()),
115
115
  deadletterThreshold: Type.Optional(Type.Integer({ minimum: 1 })),
116
+ cleanupOrphanedTempDirs: Type.Optional(Type.Boolean()),
116
117
  }, { additionalProperties: false });
117
118
 
118
119
  export const PiTeamsOtlpConfigSchema = Type.Object({
@@ -1,10 +1,15 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
 
3
3
  const SkillOverride = Type.Unsafe({
4
- description: "Skill name(s) to add to role/default skills, an array of skill names, or false to disable all injected skills for this run.",
4
+ description:
5
+ "Skill name(s) to add to role/default skills, an array of skill names, or false to disable all injected skills for this run.",
5
6
  anyOf: [
6
7
  { type: "string", maxLength: 2048 },
7
- { type: "array", maxItems: 32, items: { type: "string", maxLength: 80 } },
8
+ {
9
+ type: "array",
10
+ maxItems: 32,
11
+ items: { type: "string", maxLength: 80 },
12
+ },
8
13
  { type: "boolean" },
9
14
  ],
10
15
  });
@@ -16,85 +21,208 @@ const FreeformConfig = Type.Unsafe({
16
21
  });
17
22
 
18
23
  export const TeamToolParams = Type.Object({
19
- action: Type.Optional(Type.Union([
20
- Type.Literal("run"),
21
- Type.Literal("parallel"),
22
- Type.Literal("plan"),
23
- Type.Literal("status"),
24
- Type.Literal("list"),
25
- Type.Literal("get"),
26
- Type.Literal("cancel"),
27
- Type.Literal("retry"),
28
- Type.Literal("resume"),
29
- Type.Literal("respond"),
30
- Type.Literal("create"),
31
- Type.Literal("update"),
32
- Type.Literal("delete"),
33
- Type.Literal("doctor"),
34
- Type.Literal("cleanup"),
35
- Type.Literal("events"),
36
- Type.Literal("artifacts"),
37
- Type.Literal("worktrees"),
38
- Type.Literal("forget"),
39
- Type.Literal("summary"),
40
- Type.Literal("prune"),
41
- Type.Literal("export"),
42
- Type.Literal("import"),
43
- Type.Literal("imports"),
44
- Type.Literal("help"),
45
- Type.Literal("validate"),
46
- Type.Literal("config"),
47
- Type.Literal("init"),
48
- Type.Literal("recommend"),
49
- Type.Literal("autonomy"),
50
- Type.Literal("api"),
51
- Type.Literal("settings"),
52
- Type.Literal("steer"),
53
- ], { description: "Team action. Defaults to 'list' when omitted." })),
54
- resource: Type.Optional(Type.Union([
55
- Type.Literal("agent"),
56
- Type.Literal("team"),
57
- Type.Literal("workflow"),
58
- ], { description: "Resource kind for get/create/update/delete/list. Defaults to all for list." })),
59
- team: Type.Optional(Type.String({ description: "Team name, e.g. default or implementation." })),
60
- workflow: Type.Optional(Type.String({ description: "Workflow name, e.g. default or review." })),
61
- role: Type.Optional(Type.String({ description: "Role name to run directly within a team." })),
62
- agent: Type.Optional(Type.String({ description: "Agent name to inspect or run directly." })),
63
- goal: Type.Optional(Type.String({ description: "High-level objective for a team run." })),
64
- task: Type.Optional(Type.String({ description: "Concrete task text for direct role/agent execution." })),
65
- runId: Type.Optional(Type.String({ description: "Run ID for status, cancel, or resume." })),
66
- taskId: Type.Optional(Type.String({ description: "Task ID for respond action." })),
67
- message: Type.Optional(Type.String({ description: "Message for respond action." })),
68
- async: Type.Optional(Type.Boolean({ description: "Run in background when execution support is enabled." })),
69
- workspaceMode: Type.Optional(Type.Union([
70
- Type.Literal("single"),
71
- Type.Literal("worktree"),
72
- ], { description: "Workspace isolation mode. Worktree mode is planned after MVP." })),
73
- context: Type.Optional(Type.Union([
74
- Type.Literal("fresh"),
75
- Type.Literal("fork"),
76
- ], { description: "Child context mode for workers." })),
77
- cwd: Type.Optional(Type.String({ description: "Working directory override." })),
78
- model: Type.Optional(Type.String({ description: "Model override for direct runs." })),
24
+ action: Type.Optional(
25
+ Type.Union(
26
+ [
27
+ Type.Literal("run"),
28
+ Type.Literal("parallel"),
29
+ Type.Literal("plan"),
30
+ Type.Literal("status"),
31
+ Type.Literal("wait"),
32
+ Type.Literal("list"),
33
+ Type.Literal("get"),
34
+ Type.Literal("cancel"),
35
+ Type.Literal("retry"),
36
+ Type.Literal("resume"),
37
+ Type.Literal("respond"),
38
+ Type.Literal("create"),
39
+ Type.Literal("update"),
40
+ Type.Literal("delete"),
41
+ Type.Literal("doctor"),
42
+ Type.Literal("cleanup"),
43
+ Type.Literal("events"),
44
+ Type.Literal("artifacts"),
45
+ Type.Literal("worktrees"),
46
+ Type.Literal("forget"),
47
+ Type.Literal("summary"),
48
+ Type.Literal("prune"),
49
+ Type.Literal("export"),
50
+ Type.Literal("import"),
51
+ Type.Literal("imports"),
52
+ Type.Literal("help"),
53
+ Type.Literal("validate"),
54
+ Type.Literal("config"),
55
+ Type.Literal("init"),
56
+ Type.Literal("recommend"),
57
+ Type.Literal("autonomy"),
58
+ Type.Literal("api"),
59
+ Type.Literal("settings"),
60
+ Type.Literal("steer"),
61
+ Type.Literal("health"),
62
+ ],
63
+ { description: "Team action. Defaults to 'list' when omitted." },
64
+ ),
65
+ ),
66
+ resource: Type.Optional(
67
+ Type.Union(
68
+ [
69
+ Type.Literal("agent"),
70
+ Type.Literal("team"),
71
+ Type.Literal("workflow"),
72
+ ],
73
+ {
74
+ description:
75
+ "Resource kind for get/create/update/delete/list. Defaults to all for list.",
76
+ },
77
+ ),
78
+ ),
79
+ team: Type.Optional(
80
+ Type.String({
81
+ description: "Team name, e.g. default or implementation.",
82
+ }),
83
+ ),
84
+ workflow: Type.Optional(
85
+ Type.String({ description: "Workflow name, e.g. default or review." }),
86
+ ),
87
+ role: Type.Optional(
88
+ Type.String({
89
+ description: "Role name to run directly within a team.",
90
+ }),
91
+ ),
92
+ agent: Type.Optional(
93
+ Type.String({ description: "Agent name to inspect or run directly." }),
94
+ ),
95
+ goal: Type.Optional(
96
+ Type.String({ description: "High-level objective for a team run." }),
97
+ ),
98
+ task: Type.Optional(
99
+ Type.String({
100
+ description: "Concrete task text for direct role/agent execution.",
101
+ }),
102
+ ),
103
+ runId: Type.Optional(
104
+ Type.String({ description: "Run ID for status, cancel, or resume." }),
105
+ ),
106
+ taskId: Type.Optional(
107
+ Type.String({ description: "Task ID for respond action." }),
108
+ ),
109
+ message: Type.Optional(
110
+ Type.String({ description: "Message for respond action." }),
111
+ ),
112
+ async: Type.Optional(
113
+ Type.Boolean({
114
+ description: "Run in background when execution support is enabled.",
115
+ }),
116
+ ),
117
+ workspaceMode: Type.Optional(
118
+ Type.Union([Type.Literal("single"), Type.Literal("worktree")], {
119
+ description:
120
+ "Workspace isolation mode. Worktree mode is planned after MVP.",
121
+ }),
122
+ ),
123
+ context: Type.Optional(
124
+ Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
125
+ description: "Child context mode for workers.",
126
+ }),
127
+ ),
128
+ cwd: Type.Optional(
129
+ Type.String({ description: "Working directory override." }),
130
+ ),
131
+ model: Type.Optional(
132
+ Type.String({ description: "Model override for direct runs." }),
133
+ ),
79
134
  skill: Type.Optional(SkillOverride),
80
- scope: Type.Optional(Type.Union([
81
- Type.Literal("user"),
82
- Type.Literal("project"),
83
- Type.Literal("both"),
84
- ], { description: "Resource scope for discovery or management." })),
135
+ scope: Type.Optional(
136
+ Type.Union(
137
+ [
138
+ Type.Literal("user"),
139
+ Type.Literal("project"),
140
+ Type.Literal("both"),
141
+ ],
142
+ { description: "Resource scope for discovery or management." },
143
+ ),
144
+ ),
85
145
  config: Type.Optional(FreeformConfig),
86
- dryRun: Type.Optional(Type.Boolean({ description: "Preview a management mutation without writing files." })),
87
- confirm: Type.Optional(Type.Boolean({ description: "Required for destructive management actions." })),
88
- force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
89
- keep: Type.Optional(Type.Integer({ minimum: 0, description: "Number of finished runs to keep for prune." })),
90
- updateReferences: Type.Optional(Type.Boolean({ description: "When renaming agents or workflows, update team references in the same project/user scope." })),
91
- replyTo: Type.Optional(Type.String({ description: "ID of the original mailbox message this is a reply to." })),
92
- replyFrom: Type.Optional(Type.String({ description: "Task ID sending the reply." })),
93
- replyDeadline: Type.Optional(Type.Integer({ description: "Ms epoch deadline for a reply." })),
146
+ dryRun: Type.Optional(
147
+ Type.Boolean({
148
+ description: "Preview a management mutation without writing files.",
149
+ }),
150
+ ),
151
+ confirm: Type.Optional(
152
+ Type.Boolean({
153
+ description: "Required for destructive management actions.",
154
+ }),
155
+ ),
156
+ force: Type.Optional(
157
+ Type.Boolean({
158
+ description:
159
+ "Override reference checks for destructive management actions.",
160
+ }),
161
+ ),
162
+ keep: Type.Optional(
163
+ Type.Integer({
164
+ minimum: 0,
165
+ description: "Number of finished runs to keep for prune.",
166
+ }),
167
+ ),
168
+ updateReferences: Type.Optional(
169
+ Type.Boolean({
170
+ description:
171
+ "When renaming agents or workflows, update team references in the same project/user scope.",
172
+ }),
173
+ ),
174
+ replyTo: Type.Optional(
175
+ Type.String({
176
+ description:
177
+ "ID of the original mailbox message this is a reply to.",
178
+ }),
179
+ ),
180
+ replyFrom: Type.Optional(
181
+ Type.String({ description: "Task ID sending the reply." }),
182
+ ),
183
+ replyDeadline: Type.Optional(
184
+ Type.Integer({ description: "Ms epoch deadline for a reply." }),
185
+ ),
94
186
  });
95
187
 
96
188
  export interface TeamToolParamsValue {
97
- action?: "run" | "parallel" | "plan" | "status" | "list" | "get" | "cancel" | "retry" | "resume" | "respond" | "create" | "update" | "delete" | "doctor" | "cleanup" | "events" | "artifacts" | "worktrees" | "forget" | "summary" | "prune" | "export" | "import" | "imports" | "help" | "validate" | "config" | "init" | "recommend" | "autonomy" | "api" | "settings" | "steer";
189
+ action?:
190
+ | "run"
191
+ | "parallel"
192
+ | "plan"
193
+ | "status"
194
+ | "wait"
195
+ | "list"
196
+ | "get"
197
+ | "cancel"
198
+ | "retry"
199
+ | "resume"
200
+ | "respond"
201
+ | "create"
202
+ | "update"
203
+ | "delete"
204
+ | "doctor"
205
+ | "cleanup"
206
+ | "events"
207
+ | "artifacts"
208
+ | "worktrees"
209
+ | "forget"
210
+ | "summary"
211
+ | "prune"
212
+ | "export"
213
+ | "import"
214
+ | "imports"
215
+ | "help"
216
+ | "validate"
217
+ | "config"
218
+ | "init"
219
+ | "recommend"
220
+ | "autonomy"
221
+ | "api"
222
+ | "settings"
223
+ | "steer"
224
+ | "invalidate"
225
+ | "health";
98
226
  resource?: "agent" | "team" | "workflow";
99
227
  team?: string;
100
228
  workflow?: string;
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { TeamRunManifest, TeamTaskState } from "./types.ts";
4
- import { canTransitionRunStatus } from "./contracts.ts";
4
+ import { canTransitionRunStatus, isTerminalRunStatus } from "./contracts.ts";
5
+ import { unregisterActiveRun } from "./active-run-registry.ts";
5
6
  import { atomicWriteJson, atomicWriteJsonAsync, atomicWriteJsonCoalesced, readJsonFile } from "./atomic-write.ts";
6
7
  import { appendEvent } from "./event-log.ts";
7
8
  import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
@@ -244,6 +245,13 @@ export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManife
244
245
  }
245
246
  const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
246
247
  saveRunManifest(updated);
248
+ // Unregister from active-run-index when run reaches a terminal status.
249
+ // Without this, stale entries accumulate (e.g. integration tests in /tmp) and
250
+ // Pi UI shows ghost "queued" runs that are actually completed/failed/cancelled.
251
+ // Note: "blocked" is excluded because blocked runs can be unblocked later.
252
+ if (status === "completed" || status === "failed" || status === "cancelled") {
253
+ try { unregisterActiveRun(updated.runId); } catch { /* non-critical */ }
254
+ }
247
255
  appendEvent(updated.eventsPath, {
248
256
  type: `run.${status}`,
249
257
  runId: updated.runId,
@@ -107,7 +107,8 @@ export function discoverTeams(cwd: string): TeamDiscoveryResult {
107
107
  };
108
108
  }
109
109
 
110
- export function allTeams(discovery: TeamDiscoveryResult): TeamConfig[] {
110
+ export function allTeams(discovery: TeamDiscoveryResult | undefined): TeamConfig[] {
111
+ if (!discovery) return [];
111
112
  const byName = new Map<string, TeamConfig>();
112
113
  for (const team of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
113
114
  byName.set(team.name, team);
@@ -11,7 +11,8 @@ export type RunEventType =
11
11
  | "run_started"
12
12
  | "run_completed"
13
13
  | "run_blocked"
14
- | "run_cancelled";
14
+ | "run_cancelled"
15
+ | "run.cache_invalidated";
15
16
 
16
17
  /** Typed channel names for category-based event subscription. */
17
18
  export type EventChannel =
@@ -85,6 +85,7 @@ const SETTINGS: SettingDef[] = [
85
85
  { id: "notifierIntervalMs", label: "Notifier Interval", type: "number", tab: "advanced", description: "Async run notifier check interval in ms." },
86
86
  { id: "reliability.autoRetry", label: "Auto Retry", type: "boolean", tab: "advanced", description: "Automatically retry failed tasks." },
87
87
  { id: "reliability.autoRecover", label: "Auto Recover", type: "boolean", tab: "advanced", description: "Automatically recover from crashes." },
88
+ { id: "reliability.cleanupOrphanedTempDirs", label: "Cleanup Orphaned Temp Dirs", type: "boolean", tab: "advanced", description: "Remove /tmp/pi-crew-* directories after reconciliation (1h age threshold)." },
88
89
  { id: "telemetry.enabled", label: "Telemetry", type: "boolean", tab: "advanced", description: "Enable telemetry collection." },
89
90
  { id: "notifications.enabled", label: "Notifications", type: "boolean", tab: "advanced", description: "Enable run notifications." },
90
91
  ];
@@ -124,6 +125,7 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
124
125
  "notifierIntervalMs": 5000,
125
126
  "reliability.autoRetry": false,
126
127
  "reliability.autoRecover": false,
128
+ "reliability.cleanupOrphanedTempDirs": true,
127
129
  "telemetry.enabled": false,
128
130
  "notifications.enabled": false,
129
131
  };
@@ -123,6 +123,9 @@ function readWorkflowDir(dir: string, source: ResourceSource): WorkflowConfig[]
123
123
  }
124
124
 
125
125
  export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
126
+ if (!cwd || typeof cwd !== "string") {
127
+ return { builtin: [], user: [], project: [] };
128
+ }
126
129
  return {
127
130
  builtin: readWorkflowDir(path.join(packageRoot(), "workflows"), "builtin"),
128
131
  user: readWorkflowDir(path.join(userPiRoot(), "workflows"), "user"),
@@ -130,7 +133,8 @@ export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
130
133
  };
131
134
  }
132
135
 
133
- export function allWorkflows(discovery: WorkflowDiscoveryResult): WorkflowConfig[] {
136
+ export function allWorkflows(discovery: WorkflowDiscoveryResult | undefined): WorkflowConfig[] {
137
+ if (!discovery) return [];
134
138
  const byName = new Map<string, WorkflowConfig>();
135
139
  for (const workflow of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
136
140
  byName.set(workflow.name, workflow);