@wichayutdew/pi-workflows 0.1.1

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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +752 -0
  3. package/agents/step.md +17 -0
  4. package/dist/index.js +4576 -0
  5. package/examples/mr-comments.workflow.yaml +115 -0
  6. package/examples/prompts/mr-comments/implement.md +8 -0
  7. package/examples/prompts/mr-comments/inspect.md +5 -0
  8. package/examples/prompts/mr-comments/plan.md +13 -0
  9. package/examples/prompts/mr-comments/verify.md +7 -0
  10. package/examples/settings.yaml +19 -0
  11. package/package.json +81 -0
  12. package/schemas/settings.schema.json +22 -0
  13. package/schemas/workflow.schema.json +585 -0
  14. package/src/command-names.ts +46 -0
  15. package/src/commands.ts +80 -0
  16. package/src/config/ceiling.ts +153 -0
  17. package/src/config/command-conflicts.ts +31 -0
  18. package/src/config/load.ts +327 -0
  19. package/src/config/types.ts +187 -0
  20. package/src/config/validate.ts +1145 -0
  21. package/src/digest.ts +23 -0
  22. package/src/engine/checkpoint.ts +30 -0
  23. package/src/engine/resume.ts +44 -0
  24. package/src/engine/state.ts +186 -0
  25. package/src/engine/transitions.ts +426 -0
  26. package/src/harness.ts +1676 -0
  27. package/src/index.ts +15 -0
  28. package/src/integrations/plannotator.ts +235 -0
  29. package/src/integrations/prompt-gate.ts +54 -0
  30. package/src/integrations/subagents/child-runtime.ts +306 -0
  31. package/src/integrations/subagents/client.ts +239 -0
  32. package/src/integrations/subagents/protocol.ts +304 -0
  33. package/src/policy/approved-commands.ts +225 -0
  34. package/src/policy/bash.ts +355 -0
  35. package/src/policy/completion-batch.ts +36 -0
  36. package/src/policy/immutable-input.ts +18 -0
  37. package/src/policy/tools.ts +150 -0
  38. package/src/preflight.ts +76 -0
  39. package/src/prompt.ts +146 -0
  40. package/src/runtime/completion-tool.ts +22 -0
  41. package/src/runtime/main-step-runtime.ts +227 -0
  42. package/src/runtime/serial-task-queue.ts +17 -0
  43. package/src/runtime/step-result.ts +85 -0
  44. package/src/workflow-list.ts +25 -0
  45. package/src/workflow-status.ts +611 -0
@@ -0,0 +1,1145 @@
1
+ import {
2
+ DEFAULT_SETTINGS,
3
+ DEFAULT_STEP_SUBAGENT,
4
+ EMPTY_PERMISSIONS,
5
+ EMPTY_REQUIREMENTS,
6
+ SUBAGENT_RUNTIME_NAME_PATTERN,
7
+ WORKFLOW_SCHEMA_VERSION,
8
+ type BashMode,
9
+ type BashApprovalSource,
10
+ type BashPermission,
11
+ type BashRule,
12
+ type PermissionCeiling,
13
+ type PromptSpec,
14
+ type StepPermissions,
15
+ type StepRequirements,
16
+ type StepSubagent,
17
+ type SubagentContext,
18
+ type SubagentPermissionCeiling,
19
+ type SubagentToolBudget,
20
+ type SubagentTurnBudget,
21
+ type WorkflowDefinition,
22
+ type WorkflowGate,
23
+ type WorkflowSettings,
24
+ type WorkflowStep,
25
+ } from './types.ts';
26
+ import { RESERVED_COMMAND_NAMES } from '../command-names.ts';
27
+
28
+ export interface ValidationResult<T> {
29
+ value?: T;
30
+ errors: string[];
31
+ }
32
+
33
+ const IDENTIFIER_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
34
+ const OUTCOME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
35
+ const TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/;
36
+ const RESOURCE_SELECTOR_PATTERN = /^[A-Za-z0-9_@./:+-]+$/;
37
+ const MCP_SELECTOR_PATTERN = /^[A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?$/;
38
+ const EXECUTABLE_PATTERN = /^[A-Za-z0-9_./+-]+$/;
39
+ const BASH_APPROVAL_SOURCE_PATTERN =
40
+ /^(verification-worker|verification-reviewer|remote-actions)$/;
41
+ const PROMPT_VARIABLES = new Set([
42
+ 'workflow.input',
43
+ 'workflow.id',
44
+ 'run.id',
45
+ 'step.id',
46
+ 'step.title',
47
+ 'last.summary',
48
+ 'gate.feedback',
49
+ ]);
50
+
51
+ type JsonObject = Record<string, unknown>;
52
+
53
+ function isObject(value: unknown): value is JsonObject {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function rejectUnknownKeys(
58
+ value: JsonObject,
59
+ allowed: readonly string[],
60
+ path: string,
61
+ errors: string[],
62
+ ): void {
63
+ const allowedSet = new Set(allowed);
64
+ for (const key of Object.keys(value)) {
65
+ if (!allowedSet.has(key)) {
66
+ errors.push(`${path}: unknown property "${key}"`);
67
+ }
68
+ }
69
+ }
70
+
71
+ function readString(
72
+ value: unknown,
73
+ path: string,
74
+ errors: string[],
75
+ options: { pattern?: RegExp; nonEmpty?: boolean } = {},
76
+ ): string | undefined {
77
+ if (typeof value !== 'string') {
78
+ errors.push(`${path}: expected a string`);
79
+ return undefined;
80
+ }
81
+
82
+ const result = value.trim();
83
+ if ((options.nonEmpty ?? true) && !result) {
84
+ errors.push(`${path}: must not be empty`);
85
+ return undefined;
86
+ }
87
+ if (options.pattern && !options.pattern.test(result)) {
88
+ errors.push(`${path}: invalid value "${result}"`);
89
+ return undefined;
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function readInteger(
95
+ value: unknown,
96
+ fallback: number,
97
+ path: string,
98
+ errors: string[],
99
+ limits: { min: number; max: number },
100
+ ): number {
101
+ if (value === undefined) return fallback;
102
+ if (
103
+ !Number.isInteger(value) ||
104
+ (value as number) < limits.min ||
105
+ (value as number) > limits.max
106
+ ) {
107
+ errors.push(
108
+ `${path}: expected an integer from ${limits.min} to ${limits.max}`,
109
+ );
110
+ return fallback;
111
+ }
112
+ return value as number;
113
+ }
114
+
115
+ function readBoolean(
116
+ value: unknown,
117
+ fallback: boolean,
118
+ path: string,
119
+ errors: string[],
120
+ ): boolean {
121
+ if (value === undefined) return fallback;
122
+ if (typeof value !== 'boolean') {
123
+ errors.push(`${path}: expected a boolean`);
124
+ return fallback;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function readStringList(
130
+ value: unknown,
131
+ path: string,
132
+ errors: string[],
133
+ pattern: RegExp,
134
+ ): string[] {
135
+ if (value === undefined) return [];
136
+ if (!Array.isArray(value)) {
137
+ errors.push(`${path}: expected an array of strings`);
138
+ return [];
139
+ }
140
+
141
+ const result: string[] = [];
142
+ const seen = new Set<string>();
143
+ value.forEach((item, index) => {
144
+ const parsed = readString(item, `${path}[${index}]`, errors, { pattern });
145
+ if (!parsed) return;
146
+ if (seen.has(parsed)) {
147
+ errors.push(`${path}[${index}]: duplicate value "${parsed}"`);
148
+ return;
149
+ }
150
+ seen.add(parsed);
151
+ result.push(parsed);
152
+ });
153
+ return result;
154
+ }
155
+
156
+ function parseBashRule(
157
+ value: unknown,
158
+ path: string,
159
+ errors: string[],
160
+ ): BashRule[] {
161
+ if (!isObject(value)) {
162
+ errors.push(`${path}: expected an object`);
163
+ return [];
164
+ }
165
+ rejectUnknownKeys(
166
+ value,
167
+ ['executable', 'argsPrefix', 'argsPrefixes'],
168
+ path,
169
+ errors,
170
+ );
171
+
172
+ const executable = readString(
173
+ value.executable,
174
+ `${path}.executable`,
175
+ errors,
176
+ {
177
+ pattern: EXECUTABLE_PATTERN,
178
+ },
179
+ );
180
+ if (value.argsPrefix !== undefined && value.argsPrefixes !== undefined) {
181
+ errors.push(`${path}: argsPrefix and argsPrefixes are mutually exclusive`);
182
+ }
183
+
184
+ if (value.argsPrefixes !== undefined) {
185
+ if (!Array.isArray(value.argsPrefixes)) {
186
+ errors.push(`${path}.argsPrefixes: expected an array of argument arrays`);
187
+ return [];
188
+ }
189
+ if (value.argsPrefixes.length === 0) {
190
+ errors.push(`${path}.argsPrefixes: at least one prefix is required`);
191
+ }
192
+ const prefixes: string[][] = [];
193
+ const seen = new Set<string>();
194
+ value.argsPrefixes.forEach((candidate, index) => {
195
+ const prefixPath = `${path}.argsPrefixes[${index}]`;
196
+ const prefix = readStringList(candidate, prefixPath, errors, /^[^\s]+$/);
197
+ if (Array.isArray(candidate) && candidate.length === 0) {
198
+ errors.push(`${prefixPath}: at least one argument is required`);
199
+ return;
200
+ }
201
+ if (prefix.length === 0) return;
202
+ const key = JSON.stringify(prefix);
203
+ if (seen.has(key)) {
204
+ errors.push(`${prefixPath}: duplicate argument prefix`);
205
+ return;
206
+ }
207
+ seen.add(key);
208
+ prefixes.push(prefix);
209
+ });
210
+ return executable
211
+ ? prefixes.map((argsPrefix) => ({ executable, argsPrefix }))
212
+ : [];
213
+ }
214
+
215
+ const argsPrefix = readStringList(
216
+ value.argsPrefix,
217
+ `${path}.argsPrefix`,
218
+ errors,
219
+ /^[^\s]+$/,
220
+ );
221
+ return executable ? [{ executable, argsPrefix }] : [];
222
+ }
223
+
224
+ function parseBashPermission(
225
+ value: unknown,
226
+ path: string,
227
+ errors: string[],
228
+ ): BashPermission {
229
+ if (value === undefined) {
230
+ return { ...EMPTY_PERMISSIONS.bash, allow: [] };
231
+ }
232
+ if (!isObject(value)) {
233
+ errors.push(`${path}: expected an object`);
234
+ return { ...EMPTY_PERMISSIONS.bash, allow: [] };
235
+ }
236
+ rejectUnknownKeys(value, ['mode', 'allow', 'approvedSources'], path, errors);
237
+
238
+ const mode = readString(value.mode, `${path}.mode`, errors) as
239
+ BashMode | undefined;
240
+ const validMode =
241
+ mode === 'deny' ||
242
+ mode === 'read-only' ||
243
+ mode === 'allow-list' ||
244
+ mode === 'unrestricted';
245
+ if (!validMode) {
246
+ errors.push(
247
+ `${path}.mode: expected deny, read-only, allow-list, or unrestricted`,
248
+ );
249
+ }
250
+
251
+ const allow: BashRule[] = [];
252
+ if (value.allow !== undefined) {
253
+ if (!Array.isArray(value.allow)) {
254
+ errors.push(`${path}.allow: expected an array`);
255
+ } else {
256
+ value.allow.forEach((rule, index) => {
257
+ allow.push(...parseBashRule(rule, `${path}.allow[${index}]`, errors));
258
+ });
259
+ }
260
+ }
261
+
262
+ const approvedSources = (
263
+ value.approvedSources === undefined
264
+ ? []
265
+ : readStringList(
266
+ value.approvedSources,
267
+ `${path}.approvedSources`,
268
+ errors,
269
+ BASH_APPROVAL_SOURCE_PATTERN,
270
+ )
271
+ ).filter((source): source is BashApprovalSource => {
272
+ const valid =
273
+ source === 'verification-worker' ||
274
+ source === 'verification-reviewer' ||
275
+ source === 'remote-actions';
276
+ if (!valid) {
277
+ errors.push(
278
+ `${path}.approvedSources: expected verification-worker, verification-reviewer, or remote-actions`,
279
+ );
280
+ }
281
+ return valid;
282
+ });
283
+
284
+ const normalizedMode = validMode ? mode : 'deny';
285
+ if (normalizedMode !== 'allow-list' && allow.length > 0) {
286
+ errors.push(`${path}.allow: only valid when mode is "allow-list"`);
287
+ }
288
+ if (normalizedMode === 'allow-list' && allow.length === 0) {
289
+ if (approvedSources.length === 0) {
290
+ errors.push(
291
+ `${path}: allow-list mode requires an allow rule or an approved command source`,
292
+ );
293
+ }
294
+ }
295
+ if (normalizedMode !== 'allow-list' && approvedSources.length > 0) {
296
+ errors.push(
297
+ `${path}.approvedSources: only valid when mode is "allow-list"`,
298
+ );
299
+ }
300
+ return {
301
+ mode: normalizedMode,
302
+ allow,
303
+ ...(approvedSources.length > 0 ? { approvedSources } : {}),
304
+ };
305
+ }
306
+
307
+ function parsePermissions(
308
+ value: unknown,
309
+ path: string,
310
+ errors: string[],
311
+ ): StepPermissions {
312
+ if (value === undefined) {
313
+ return {
314
+ tools: [],
315
+ mcp: [],
316
+ extensions: [],
317
+ skills: [],
318
+ bash: { mode: 'deny', allow: [] },
319
+ };
320
+ }
321
+ if (!isObject(value)) {
322
+ errors.push(`${path}: expected an object`);
323
+ return {
324
+ tools: [],
325
+ mcp: [],
326
+ extensions: [],
327
+ skills: [],
328
+ bash: { mode: 'deny', allow: [] },
329
+ };
330
+ }
331
+ rejectUnknownKeys(
332
+ value,
333
+ ['tools', 'mcp', 'extensions', 'skills', 'bash'],
334
+ path,
335
+ errors,
336
+ );
337
+
338
+ const permissions: StepPermissions = {
339
+ tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
340
+ mcp: readStringList(value.mcp, `${path}.mcp`, errors, MCP_SELECTOR_PATTERN),
341
+ extensions: readStringList(
342
+ value.extensions,
343
+ `${path}.extensions`,
344
+ errors,
345
+ RESOURCE_SELECTOR_PATTERN,
346
+ ),
347
+ skills: readStringList(
348
+ value.skills,
349
+ `${path}.skills`,
350
+ errors,
351
+ RESOURCE_SELECTOR_PATTERN,
352
+ ),
353
+ bash: parseBashPermission(value.bash, `${path}.bash`, errors),
354
+ };
355
+
356
+ if (permissions.bash.mode !== 'deny' && !permissions.tools.includes('bash')) {
357
+ errors.push(`${path}.tools: must include "bash" when Bash is enabled`);
358
+ }
359
+ return permissions;
360
+ }
361
+
362
+ function parseRequirements(
363
+ value: unknown,
364
+ permissions: StepPermissions,
365
+ path: string,
366
+ errors: string[],
367
+ ): StepRequirements {
368
+ if (value === undefined) {
369
+ return {
370
+ tools: [],
371
+ extensions: [],
372
+ skills: [],
373
+ };
374
+ }
375
+ if (!isObject(value)) {
376
+ errors.push(`${path}: expected an object`);
377
+ return {
378
+ tools: [],
379
+ extensions: [],
380
+ skills: [],
381
+ };
382
+ }
383
+ rejectUnknownKeys(value, ['tools', 'extensions', 'skills'], path, errors);
384
+
385
+ const requirements: StepRequirements = {
386
+ tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
387
+ extensions: readStringList(
388
+ value.extensions,
389
+ `${path}.extensions`,
390
+ errors,
391
+ RESOURCE_SELECTOR_PATTERN,
392
+ ),
393
+ skills: readStringList(
394
+ value.skills,
395
+ `${path}.skills`,
396
+ errors,
397
+ RESOURCE_SELECTOR_PATTERN,
398
+ ),
399
+ };
400
+
401
+ for (const tool of requirements.tools) {
402
+ if (
403
+ !permissions.tools.includes(tool) &&
404
+ !(tool === 'mcp' && permissions.mcp.length > 0)
405
+ ) {
406
+ errors.push(
407
+ `${path}.tools: required tool "${tool}" is not allowed by this step`,
408
+ );
409
+ }
410
+ }
411
+ for (const extension of requirements.extensions) {
412
+ if (!permissions.extensions.includes(extension)) {
413
+ errors.push(
414
+ `${path}.extensions: required extension "${extension}" is not allowed by this step`,
415
+ );
416
+ }
417
+ }
418
+ for (const skill of requirements.skills) {
419
+ if (!permissions.skills.includes(skill)) {
420
+ errors.push(
421
+ `${path}.skills: required skill "${skill}" is not allowed by this step`,
422
+ );
423
+ }
424
+ }
425
+ return requirements;
426
+ }
427
+
428
+ function parseSubagentTurnBudget(
429
+ value: unknown,
430
+ path: string,
431
+ errors: string[],
432
+ ): SubagentTurnBudget | undefined {
433
+ if (value === undefined) return undefined;
434
+ if (!isObject(value)) {
435
+ errors.push(`${path}: expected an object`);
436
+ return undefined;
437
+ }
438
+ rejectUnknownKeys(value, ['maxTurns', 'graceTurns'], path, errors);
439
+ const maxTurns = readInteger(value.maxTurns, 0, `${path}.maxTurns`, errors, {
440
+ min: 1,
441
+ max: 1_000,
442
+ });
443
+ const graceTurns =
444
+ value.graceTurns === undefined
445
+ ? undefined
446
+ : readInteger(value.graceTurns, 0, `${path}.graceTurns`, errors, {
447
+ min: 0,
448
+ max: 100,
449
+ });
450
+ if (maxTurns === 0) return undefined;
451
+ return {
452
+ maxTurns,
453
+ ...(graceTurns !== undefined ? { graceTurns } : {}),
454
+ };
455
+ }
456
+
457
+ function parseSubagentToolBudget(
458
+ value: unknown,
459
+ path: string,
460
+ errors: string[],
461
+ ): SubagentToolBudget | undefined {
462
+ if (value === undefined) return undefined;
463
+ if (!isObject(value)) {
464
+ errors.push(`${path}: expected an object`);
465
+ return undefined;
466
+ }
467
+ rejectUnknownKeys(value, ['soft', 'hard', 'block'], path, errors);
468
+ const hard = readInteger(value.hard, 0, `${path}.hard`, errors, {
469
+ min: 1,
470
+ max: 100_000,
471
+ });
472
+ const soft =
473
+ value.soft === undefined
474
+ ? undefined
475
+ : readInteger(value.soft, 0, `${path}.soft`, errors, {
476
+ min: 1,
477
+ max: 100_000,
478
+ });
479
+
480
+ let block: string[] | '*' | undefined;
481
+ if (value.block === '*') {
482
+ block = '*';
483
+ } else if (value.block !== undefined) {
484
+ block = readStringList(value.block, `${path}.block`, errors, TOOL_PATTERN);
485
+ if (block.length === 0) {
486
+ errors.push(`${path}.block: expected "*" or at least one tool name`);
487
+ }
488
+ }
489
+ if (soft !== undefined && hard > 0 && soft > hard) {
490
+ errors.push(`${path}.soft: must not exceed hard`);
491
+ }
492
+ if (hard === 0) return undefined;
493
+ return {
494
+ hard,
495
+ ...(soft !== undefined ? { soft } : {}),
496
+ ...(block !== undefined ? { block } : {}),
497
+ };
498
+ }
499
+
500
+ function parseStepSubagent(
501
+ value: unknown,
502
+ path: string,
503
+ errors: string[],
504
+ ): StepSubagent | undefined {
505
+ if (value === undefined) return undefined;
506
+ if (typeof value === 'string') {
507
+ const agent =
508
+ readString(value, path, errors, {
509
+ pattern: SUBAGENT_RUNTIME_NAME_PATTERN,
510
+ }) ?? DEFAULT_STEP_SUBAGENT.agent;
511
+ return { ...DEFAULT_STEP_SUBAGENT, agent };
512
+ }
513
+ if (!isObject(value)) {
514
+ errors.push(`${path}: expected a workflow subagent name or object`);
515
+ return undefined;
516
+ }
517
+ rejectUnknownKeys(
518
+ value,
519
+ [
520
+ 'agent',
521
+ 'context',
522
+ 'model',
523
+ 'timeoutMs',
524
+ 'turnBudget',
525
+ 'toolBudget',
526
+ 'artifacts',
527
+ ],
528
+ path,
529
+ errors,
530
+ );
531
+
532
+ const agent =
533
+ value.agent === undefined
534
+ ? DEFAULT_STEP_SUBAGENT.agent
535
+ : (readString(value.agent, `${path}.agent`, errors, {
536
+ pattern: SUBAGENT_RUNTIME_NAME_PATTERN,
537
+ }) ?? DEFAULT_STEP_SUBAGENT.agent);
538
+ const contextValue =
539
+ value.context === undefined
540
+ ? DEFAULT_STEP_SUBAGENT.context
541
+ : readString(value.context, `${path}.context`, errors);
542
+ const context: SubagentContext =
543
+ contextValue === 'fork' || contextValue === 'fresh'
544
+ ? contextValue
545
+ : DEFAULT_STEP_SUBAGENT.context;
546
+ if (contextValue !== 'fork' && contextValue !== 'fresh') {
547
+ errors.push(`${path}.context: expected fresh or fork`);
548
+ }
549
+ const model =
550
+ value.model === undefined
551
+ ? undefined
552
+ : readString(value.model, `${path}.model`, errors, {
553
+ pattern: RESOURCE_SELECTOR_PATTERN,
554
+ });
555
+ const timeoutMs = readInteger(
556
+ value.timeoutMs,
557
+ DEFAULT_STEP_SUBAGENT.timeoutMs,
558
+ `${path}.timeoutMs`,
559
+ errors,
560
+ { min: 1_000, max: 86_400_000 },
561
+ );
562
+ const turnBudget = parseSubagentTurnBudget(
563
+ value.turnBudget,
564
+ `${path}.turnBudget`,
565
+ errors,
566
+ );
567
+ const toolBudget = parseSubagentToolBudget(
568
+ value.toolBudget,
569
+ `${path}.toolBudget`,
570
+ errors,
571
+ );
572
+ const artifacts = readBoolean(
573
+ value.artifacts,
574
+ DEFAULT_STEP_SUBAGENT.artifacts,
575
+ `${path}.artifacts`,
576
+ errors,
577
+ );
578
+ return {
579
+ agent,
580
+ context,
581
+ ...(model ? { model } : {}),
582
+ timeoutMs,
583
+ ...(turnBudget ? { turnBudget } : {}),
584
+ ...(toolBudget ? { toolBudget } : {}),
585
+ artifacts,
586
+ };
587
+ }
588
+
589
+ function parsePrompt(
590
+ value: unknown,
591
+ path: string,
592
+ errors: string[],
593
+ ): PromptSpec | undefined {
594
+ if (typeof value === 'string') {
595
+ const inline = readString(value, path, errors);
596
+ return inline ? { inline } : undefined;
597
+ }
598
+ if (!isObject(value)) {
599
+ errors.push(`${path}: expected a string or an object`);
600
+ return undefined;
601
+ }
602
+ rejectUnknownKeys(value, ['file'], path, errors);
603
+ const file = readString(value.file, `${path}.file`, errors);
604
+ if (!file) return undefined;
605
+ if (file.startsWith('/') || file.includes('\0')) {
606
+ errors.push(`${path}.file: expected a safe relative path`);
607
+ return undefined;
608
+ }
609
+ return { file };
610
+ }
611
+
612
+ function parseTransitions(
613
+ value: unknown,
614
+ path: string,
615
+ errors: string[],
616
+ ): Record<string, string> {
617
+ if (!isObject(value)) {
618
+ errors.push(`${path}: expected an object`);
619
+ return {};
620
+ }
621
+ const transitions: Record<string, string> = {};
622
+ for (const [outcome, targetValue] of Object.entries(value)) {
623
+ if (!OUTCOME_PATTERN.test(outcome)) {
624
+ errors.push(`${path}: invalid outcome "${outcome}"`);
625
+ continue;
626
+ }
627
+ const target = readString(targetValue, `${path}.${outcome}`, errors);
628
+ if (target) transitions[outcome] = target;
629
+ }
630
+ if (Object.keys(transitions).length === 0) {
631
+ errors.push(`${path}: at least one transition is required`);
632
+ }
633
+ return transitions;
634
+ }
635
+
636
+ function parseGate(
637
+ value: unknown,
638
+ path: string,
639
+ errors: string[],
640
+ ): WorkflowGate | undefined {
641
+ if (value === undefined) return undefined;
642
+ if (!isObject(value)) {
643
+ errors.push(`${path}: expected an object`);
644
+ return undefined;
645
+ }
646
+ rejectUnknownKeys(
647
+ value,
648
+ [
649
+ 'provider',
650
+ 'submitOutcome',
651
+ 'approvedOutcome',
652
+ 'rejectedOutcome',
653
+ 'timeoutMs',
654
+ ],
655
+ path,
656
+ errors,
657
+ );
658
+
659
+ const providerValue =
660
+ value.provider === undefined
661
+ ? 'prompt'
662
+ : readString(value.provider, `${path}.provider`, errors);
663
+ const provider =
664
+ providerValue === 'prompt' || providerValue === 'plannotator'
665
+ ? providerValue
666
+ : undefined;
667
+ if (!provider) {
668
+ errors.push(`${path}.provider: expected prompt or plannotator`);
669
+ }
670
+ const submitOutcome = readString(
671
+ value.submitOutcome,
672
+ `${path}.submitOutcome`,
673
+ errors,
674
+ {
675
+ pattern: OUTCOME_PATTERN,
676
+ },
677
+ );
678
+ const approvedOutcome = readString(
679
+ value.approvedOutcome,
680
+ `${path}.approvedOutcome`,
681
+ errors,
682
+ {
683
+ pattern: OUTCOME_PATTERN,
684
+ },
685
+ );
686
+ const rejectedOutcome = readString(
687
+ value.rejectedOutcome,
688
+ `${path}.rejectedOutcome`,
689
+ errors,
690
+ {
691
+ pattern: OUTCOME_PATTERN,
692
+ },
693
+ );
694
+ if (provider === 'prompt' && value.timeoutMs !== undefined) {
695
+ errors.push(`${path}.timeoutMs: only valid with provider "plannotator"`);
696
+ }
697
+ if (!submitOutcome || !approvedOutcome || !rejectedOutcome || !provider) {
698
+ return undefined;
699
+ }
700
+ if (approvedOutcome === rejectedOutcome) {
701
+ errors.push(`${path}: approvedOutcome and rejectedOutcome must differ`);
702
+ }
703
+ if (provider === 'prompt') {
704
+ return {
705
+ provider,
706
+ submitOutcome,
707
+ approvedOutcome,
708
+ rejectedOutcome,
709
+ };
710
+ }
711
+ return {
712
+ provider,
713
+ submitOutcome,
714
+ approvedOutcome,
715
+ rejectedOutcome,
716
+ timeoutMs: readInteger(
717
+ value.timeoutMs,
718
+ 5_000,
719
+ `${path}.timeoutMs`,
720
+ errors,
721
+ {
722
+ min: 1_000,
723
+ max: 30_000,
724
+ },
725
+ ),
726
+ };
727
+ }
728
+
729
+ function parseStep(
730
+ value: unknown,
731
+ stepId: string,
732
+ path: string,
733
+ errors: string[],
734
+ ): WorkflowStep | undefined {
735
+ if (!isObject(value)) {
736
+ errors.push(`${path}: expected an object`);
737
+ return undefined;
738
+ }
739
+ rejectUnknownKeys(
740
+ value,
741
+ [
742
+ 'title',
743
+ 'prompt',
744
+ 'subagent',
745
+ 'permissions',
746
+ 'requires',
747
+ 'transitions',
748
+ 'gate',
749
+ ],
750
+ path,
751
+ errors,
752
+ );
753
+
754
+ const title =
755
+ value.title === undefined
756
+ ? stepId
757
+ : readString(value.title, `${path}.title`, errors);
758
+ const prompt = parsePrompt(value.prompt, `${path}.prompt`, errors);
759
+ const subagent = parseStepSubagent(
760
+ value.subagent,
761
+ `${path}.subagent`,
762
+ errors,
763
+ );
764
+ const permissions = parsePermissions(
765
+ value.permissions,
766
+ `${path}.permissions`,
767
+ errors,
768
+ );
769
+ const requires = parseRequirements(
770
+ value.requires,
771
+ permissions,
772
+ `${path}.requires`,
773
+ errors,
774
+ );
775
+ const transitions = parseTransitions(
776
+ value.transitions,
777
+ `${path}.transitions`,
778
+ errors,
779
+ );
780
+ const gate = parseGate(value.gate, `${path}.gate`, errors);
781
+
782
+ if (gate) {
783
+ if (!Object.hasOwn(transitions, gate.approvedOutcome)) {
784
+ errors.push(
785
+ `${path}.transitions: missing gate outcome "${gate.approvedOutcome}"`,
786
+ );
787
+ }
788
+ if (!Object.hasOwn(transitions, gate.rejectedOutcome)) {
789
+ errors.push(
790
+ `${path}.transitions: missing gate outcome "${gate.rejectedOutcome}"`,
791
+ );
792
+ }
793
+ if (Object.hasOwn(transitions, gate.submitOutcome)) {
794
+ errors.push(
795
+ `${path}.transitions: submitOutcome is handled by the gate and must not be a transition`,
796
+ );
797
+ }
798
+ }
799
+
800
+ if (!title || !prompt) return undefined;
801
+ return {
802
+ title,
803
+ prompt,
804
+ ...(subagent ? { subagent } : {}),
805
+ permissions,
806
+ requires,
807
+ transitions,
808
+ ...(gate ? { gate } : {}),
809
+ };
810
+ }
811
+
812
+ export function validatePromptText(text: string, path: string): string[] {
813
+ const errors: string[] = [];
814
+ for (const match of text.matchAll(/\{\{([^{}]+)\}\}/g)) {
815
+ const variable = match[1]?.trim() ?? '';
816
+ if (!PROMPT_VARIABLES.has(variable)) {
817
+ errors.push(`${path}: unknown prompt variable "{{${variable}}}"`);
818
+ }
819
+ }
820
+ return errors;
821
+ }
822
+
823
+ export function validateWorkflow(
824
+ value: unknown,
825
+ ): ValidationResult<WorkflowDefinition> {
826
+ const errors: string[] = [];
827
+ if (!isObject(value)) {
828
+ return { errors: ['workflow: expected an object'] };
829
+ }
830
+ rejectUnknownKeys(
831
+ value,
832
+ [
833
+ '$schema',
834
+ 'version',
835
+ 'id',
836
+ 'command',
837
+ 'description',
838
+ 'start',
839
+ 'maxStepVisits',
840
+ 'summaryMaxChars',
841
+ 'steps',
842
+ ],
843
+ 'workflow',
844
+ errors,
845
+ );
846
+ if (value.$schema !== undefined && typeof value.$schema !== 'string') {
847
+ errors.push('workflow.$schema: expected a string');
848
+ }
849
+
850
+ if (value.version !== WORKFLOW_SCHEMA_VERSION) {
851
+ errors.push(`workflow.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
852
+ }
853
+ const id = readString(value.id, 'workflow.id', errors, {
854
+ pattern: IDENTIFIER_PATTERN,
855
+ });
856
+ const command = readString(value.command, 'workflow.command', errors, {
857
+ pattern: IDENTIFIER_PATTERN,
858
+ });
859
+ const description = readString(
860
+ value.description,
861
+ 'workflow.description',
862
+ errors,
863
+ );
864
+ const start = readString(value.start, 'workflow.start', errors, {
865
+ pattern: IDENTIFIER_PATTERN,
866
+ });
867
+ const maxStepVisits = readInteger(
868
+ value.maxStepVisits,
869
+ 5,
870
+ 'workflow.maxStepVisits',
871
+ errors,
872
+ {
873
+ min: 1,
874
+ max: 100,
875
+ },
876
+ );
877
+ const summaryMaxChars = readInteger(
878
+ value.summaryMaxChars,
879
+ 4_000,
880
+ 'workflow.summaryMaxChars',
881
+ errors,
882
+ { min: 100, max: 50_000 },
883
+ );
884
+
885
+ if (command && RESERVED_COMMAND_NAMES.has(command)) {
886
+ errors.push(
887
+ `workflow.command: "${command}" is reserved by Pi or the harness`,
888
+ );
889
+ }
890
+
891
+ const steps: Record<string, WorkflowStep> = {};
892
+ if (!isObject(value.steps)) {
893
+ errors.push('workflow.steps: expected an object');
894
+ } else {
895
+ for (const [stepId, stepValue] of Object.entries(value.steps)) {
896
+ if (!IDENTIFIER_PATTERN.test(stepId)) {
897
+ errors.push(`workflow.steps: invalid step id "${stepId}"`);
898
+ continue;
899
+ }
900
+ const step = parseStep(
901
+ stepValue,
902
+ stepId,
903
+ `workflow.steps.${stepId}`,
904
+ errors,
905
+ );
906
+ if (step) steps[stepId] = step;
907
+ }
908
+ }
909
+
910
+ if (Object.keys(steps).length === 0) {
911
+ errors.push('workflow.steps: at least one step is required');
912
+ }
913
+ if (start && !Object.hasOwn(steps, start)) {
914
+ errors.push(`workflow.start: unknown step "${start}"`);
915
+ }
916
+ for (const [stepId, step] of Object.entries(steps)) {
917
+ for (const [outcome, target] of Object.entries(step.transitions)) {
918
+ if (
919
+ target !== '$done' &&
920
+ target !== '$pause' &&
921
+ !Object.hasOwn(steps, target)
922
+ ) {
923
+ errors.push(
924
+ `workflow.steps.${stepId}.transitions.${outcome}: unknown target "${target}"`,
925
+ );
926
+ }
927
+ }
928
+ }
929
+
930
+ if (errors.length > 0 || !id || !command || !description || !start) {
931
+ return { errors };
932
+ }
933
+ return {
934
+ value: {
935
+ version: WORKFLOW_SCHEMA_VERSION,
936
+ id,
937
+ command,
938
+ description,
939
+ start,
940
+ maxStepVisits,
941
+ summaryMaxChars,
942
+ steps,
943
+ },
944
+ errors,
945
+ };
946
+ }
947
+
948
+ function parsePermissionCeiling(
949
+ value: unknown,
950
+ path: string,
951
+ errors: string[],
952
+ ): PermissionCeiling | undefined {
953
+ if (value === undefined) return undefined;
954
+ if (!isObject(value)) {
955
+ errors.push(`${path}: expected an object`);
956
+ return undefined;
957
+ }
958
+ rejectUnknownKeys(
959
+ value,
960
+ ['tools', 'mcp', 'extensions', 'skills', 'bash', 'subagent'],
961
+ path,
962
+ errors,
963
+ );
964
+ const permissions = parsePermissions(
965
+ {
966
+ ...(value.tools !== undefined ? { tools: value.tools } : {}),
967
+ ...(value.mcp !== undefined ? { mcp: value.mcp } : {}),
968
+ ...(value.extensions !== undefined
969
+ ? { extensions: value.extensions }
970
+ : {}),
971
+ ...(value.skills !== undefined ? { skills: value.skills } : {}),
972
+ ...(value.bash !== undefined ? { bash: value.bash } : {}),
973
+ },
974
+ path,
975
+ errors,
976
+ );
977
+ const subagent =
978
+ value.subagent === undefined
979
+ ? undefined
980
+ : parseSubagentPermissionCeiling(
981
+ value.subagent,
982
+ `${path}.subagent`,
983
+ errors,
984
+ );
985
+ return {
986
+ ...permissions,
987
+ ...(subagent ? { subagent } : {}),
988
+ };
989
+ }
990
+
991
+ function parseSubagentPermissionCeiling(
992
+ value: unknown,
993
+ path: string,
994
+ errors: string[],
995
+ ): SubagentPermissionCeiling | undefined {
996
+ if (!isObject(value)) {
997
+ errors.push(`${path}: expected an object`);
998
+ return undefined;
999
+ }
1000
+ rejectUnknownKeys(
1001
+ value,
1002
+ [
1003
+ 'agents',
1004
+ 'contexts',
1005
+ 'models',
1006
+ 'maxTimeoutMs',
1007
+ 'maxTurns',
1008
+ 'maxGraceTurns',
1009
+ 'maxToolCalls',
1010
+ 'artifacts',
1011
+ ],
1012
+ path,
1013
+ errors,
1014
+ );
1015
+ const agents = readStringList(
1016
+ value.agents,
1017
+ `${path}.agents`,
1018
+ errors,
1019
+ SUBAGENT_RUNTIME_NAME_PATTERN,
1020
+ );
1021
+ const contexts = readStringList(
1022
+ value.contexts,
1023
+ `${path}.contexts`,
1024
+ errors,
1025
+ /^(?:fresh|fork)$/,
1026
+ ) as SubagentContext[];
1027
+ const models = readStringList(
1028
+ value.models,
1029
+ `${path}.models`,
1030
+ errors,
1031
+ RESOURCE_SELECTOR_PATTERN,
1032
+ );
1033
+ if (agents.length === 0) {
1034
+ errors.push(`${path}.agents: at least one subagent is required`);
1035
+ }
1036
+ if (contexts.length === 0) {
1037
+ errors.push(`${path}.contexts: at least one context mode is required`);
1038
+ }
1039
+ for (const field of [
1040
+ 'maxTimeoutMs',
1041
+ 'maxTurns',
1042
+ 'maxGraceTurns',
1043
+ 'maxToolCalls',
1044
+ 'artifacts',
1045
+ ] as const) {
1046
+ if (value[field] === undefined) {
1047
+ errors.push(`${path}.${field}: required`);
1048
+ }
1049
+ }
1050
+ const maxTimeoutMs = readInteger(
1051
+ value.maxTimeoutMs,
1052
+ 0,
1053
+ `${path}.maxTimeoutMs`,
1054
+ errors,
1055
+ { min: 1_000, max: 86_400_000 },
1056
+ );
1057
+ const maxTurns = readInteger(value.maxTurns, 0, `${path}.maxTurns`, errors, {
1058
+ min: 1,
1059
+ max: 1_000,
1060
+ });
1061
+ const maxGraceTurns = readInteger(
1062
+ value.maxGraceTurns,
1063
+ 0,
1064
+ `${path}.maxGraceTurns`,
1065
+ errors,
1066
+ { min: 0, max: 100 },
1067
+ );
1068
+ const maxToolCalls = readInteger(
1069
+ value.maxToolCalls,
1070
+ 0,
1071
+ `${path}.maxToolCalls`,
1072
+ errors,
1073
+ { min: 1, max: 100_000 },
1074
+ );
1075
+ const artifacts = readBoolean(
1076
+ value.artifacts,
1077
+ false,
1078
+ `${path}.artifacts`,
1079
+ errors,
1080
+ );
1081
+ return {
1082
+ agents,
1083
+ contexts,
1084
+ models,
1085
+ maxTimeoutMs,
1086
+ maxTurns,
1087
+ maxGraceTurns,
1088
+ maxToolCalls,
1089
+ artifacts,
1090
+ };
1091
+ }
1092
+
1093
+ export function validateSettings(
1094
+ value: unknown,
1095
+ ): ValidationResult<WorkflowSettings> {
1096
+ const errors: string[] = [];
1097
+ if (!isObject(value)) {
1098
+ return { errors: ['settings: expected an object'] };
1099
+ }
1100
+ rejectUnknownKeys(
1101
+ value,
1102
+ ['$schema', 'version', 'allowProjectWorkflows', 'permissionCeiling'],
1103
+ 'settings',
1104
+ errors,
1105
+ );
1106
+ if (value.$schema !== undefined && typeof value.$schema !== 'string') {
1107
+ errors.push('settings.$schema: expected a string');
1108
+ }
1109
+ if (value.version !== WORKFLOW_SCHEMA_VERSION) {
1110
+ errors.push(`settings.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
1111
+ }
1112
+ let allowProjectWorkflows = false;
1113
+ if (typeof value.allowProjectWorkflows === 'boolean') {
1114
+ allowProjectWorkflows = value.allowProjectWorkflows;
1115
+ } else if (value.allowProjectWorkflows !== undefined) {
1116
+ errors.push('settings.allowProjectWorkflows: expected a boolean');
1117
+ }
1118
+ const permissionCeiling = parsePermissionCeiling(
1119
+ value.permissionCeiling,
1120
+ 'settings.permissionCeiling',
1121
+ errors,
1122
+ );
1123
+ if (allowProjectWorkflows && !permissionCeiling) {
1124
+ errors.push(
1125
+ 'settings.permissionCeiling: required when project workflows are enabled',
1126
+ );
1127
+ }
1128
+ if (errors.length > 0) return { errors };
1129
+ return {
1130
+ value: {
1131
+ ...DEFAULT_SETTINGS,
1132
+ allowProjectWorkflows,
1133
+ ...(permissionCeiling ? { permissionCeiling } : {}),
1134
+ },
1135
+ errors,
1136
+ };
1137
+ }
1138
+
1139
+ export function cloneEmptyRequirements(): StepRequirements {
1140
+ return {
1141
+ tools: [...EMPTY_REQUIREMENTS.tools],
1142
+ extensions: [...EMPTY_REQUIREMENTS.extensions],
1143
+ skills: [...EMPTY_REQUIREMENTS.skills],
1144
+ };
1145
+ }