@wichayutdew/pi-workflows 0.3.0 → 1.0.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 (144) hide show
  1. package/README.md +55 -39
  2. package/dist/index.js +5771 -4794
  3. package/examples/mr-comments.workflow.yaml +2 -0
  4. package/package.json +1 -1
  5. package/schemas/workflow.schema.json +10 -1
  6. package/src/command-names.ts +7 -1
  7. package/src/commands.ts +126 -52
  8. package/src/config/catalog.ts +162 -0
  9. package/src/config/ceiling.ts +120 -116
  10. package/src/config/command-conflicts.ts +4 -4
  11. package/src/config/diagnostics.ts +28 -0
  12. package/src/config/load-settings.ts +37 -0
  13. package/src/config/load-types.ts +53 -0
  14. package/src/config/load-workflows.ts +135 -0
  15. package/src/config/load.ts +43 -311
  16. package/src/config/types.ts +150 -137
  17. package/src/config/validate.ts +12 -1261
  18. package/src/config/validation/permissions.ts +312 -0
  19. package/src/config/validation/prompt.ts +21 -0
  20. package/src/config/validation/settings.ts +122 -0
  21. package/src/config/validation/shared.ts +129 -0
  22. package/src/config/validation/shortcut.ts +118 -0
  23. package/src/config/validation/step.ts +228 -0
  24. package/src/config/validation/subagent.ts +288 -0
  25. package/src/config/validation/workflow.ts +156 -0
  26. package/src/config/yaml.ts +29 -0
  27. package/src/digest.ts +38 -6
  28. package/src/engine/checkpoint.ts +13 -9
  29. package/src/engine/create-run.ts +43 -0
  30. package/src/engine/gate-transitions.ts +187 -0
  31. package/src/engine/reconciliation-history.ts +88 -0
  32. package/src/engine/resume.ts +19 -7
  33. package/src/engine/run-advance.ts +122 -0
  34. package/src/engine/run-lifecycle.ts +124 -0
  35. package/src/engine/run-reconciliation.ts +116 -0
  36. package/src/engine/run-validation.ts +129 -0
  37. package/src/engine/state-types.ts +66 -0
  38. package/src/engine/state.ts +10 -193
  39. package/src/engine/transition-helpers.ts +28 -0
  40. package/src/engine/transition-types.ts +8 -0
  41. package/src/engine/transitions.ts +17 -471
  42. package/src/harness/action-context.ts +181 -0
  43. package/src/harness/catalog.ts +45 -0
  44. package/src/harness/context-idle.ts +21 -0
  45. package/src/harness/core-actions.ts +299 -0
  46. package/src/harness/delegation-control-actions.ts +250 -0
  47. package/src/harness/delegation-failure.ts +215 -0
  48. package/src/harness/delegation-plan.ts +219 -0
  49. package/src/harness/delegation-recovery-validation.ts +159 -0
  50. package/src/harness/delegation-response-actions.ts +295 -0
  51. package/src/harness/delegation-retry-policy.ts +118 -0
  52. package/src/harness/dependencies.ts +165 -0
  53. package/src/harness/gate-submission-action.ts +154 -0
  54. package/src/harness/lifecycle-actions.ts +154 -0
  55. package/src/harness/pause-actions.ts +120 -0
  56. package/src/harness/plannotator-result-actions.ts +123 -0
  57. package/src/harness/prompt-gate-actions.ts +270 -0
  58. package/src/harness/resume-action.ts +261 -0
  59. package/src/harness/start-actions.ts +183 -0
  60. package/src/harness/status-actions.ts +191 -0
  61. package/src/harness/step-execution-actions.ts +283 -0
  62. package/src/harness/types.ts +76 -0
  63. package/src/harness.ts +236 -2303
  64. package/src/index.ts +73 -11
  65. package/src/integrations/plannotator-requests.ts +127 -0
  66. package/src/integrations/plannotator-responses.ts +141 -0
  67. package/src/integrations/plannotator-types.ts +44 -0
  68. package/src/integrations/plannotator.ts +16 -235
  69. package/src/integrations/prompt-gate.ts +17 -10
  70. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  71. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  72. package/src/integrations/subagents/child-policy-sections.ts +186 -0
  73. package/src/integrations/subagents/child-policy-types.ts +32 -0
  74. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  75. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  76. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  77. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  78. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  79. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  80. package/src/integrations/subagents/child-runtime.ts +140 -301
  81. package/src/integrations/subagents/client-delegation.ts +181 -0
  82. package/src/integrations/subagents/client-messages.ts +66 -0
  83. package/src/integrations/subagents/client-types.ts +36 -0
  84. package/src/integrations/subagents/client.ts +127 -219
  85. package/src/integrations/subagents/delegated-result.ts +31 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  87. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  88. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  89. package/src/integrations/subagents/diagnostics.ts +24 -977
  90. package/src/integrations/subagents/failure-correlation.ts +235 -0
  91. package/src/integrations/subagents/failure-transcript.ts +192 -0
  92. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  93. package/src/integrations/subagents/protocol-events.ts +27 -0
  94. package/src/integrations/subagents/protocol.ts +30 -375
  95. package/src/integrations/subagents/replay-audit.ts +147 -0
  96. package/src/integrations/subagents/replay-safety.ts +82 -0
  97. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  98. package/src/policy/approved-command-extraction.ts +71 -0
  99. package/src/policy/approved-commands.ts +15 -432
  100. package/src/policy/bash-authorization.ts +86 -0
  101. package/src/policy/bash-read-only.ts +161 -0
  102. package/src/policy/bash-types.ts +20 -0
  103. package/src/policy/bash.ts +4 -346
  104. package/src/policy/completion-batch.ts +21 -16
  105. package/src/policy/immutable-input.ts +5 -2
  106. package/src/policy/mcp-authorization.ts +71 -0
  107. package/src/policy/restricted-command.ts +141 -0
  108. package/src/policy/restricted-git.ts +30 -0
  109. package/src/policy/reviewed-artifact.ts +97 -0
  110. package/src/policy/reviewed-command-safety.ts +150 -0
  111. package/src/policy/reviewed-command-shape.ts +49 -0
  112. package/src/policy/reviewed-repository-contract.ts +138 -0
  113. package/src/policy/reviewed-repository-cwd.ts +102 -0
  114. package/src/policy/tool-call-authorization.ts +58 -0
  115. package/src/policy/tool-selection.ts +72 -0
  116. package/src/policy/tool-types.ts +14 -0
  117. package/src/policy/tools.ts +11 -150
  118. package/src/preflight.ts +96 -61
  119. package/src/prompt/main-workflow-notice.ts +41 -0
  120. package/src/prompt/retry-task.ts +83 -0
  121. package/src/prompt/step-contract.ts +101 -0
  122. package/src/prompt/step-sections.ts +75 -0
  123. package/src/prompt/step-task.ts +156 -0
  124. package/src/prompt/template.ts +83 -0
  125. package/src/prompt.ts +10 -254
  126. package/src/runtime/completion-tool.ts +3 -0
  127. package/src/runtime/main-step-completion.ts +78 -0
  128. package/src/runtime/main-step-lifecycle.ts +37 -0
  129. package/src/runtime/main-step-policy.ts +84 -0
  130. package/src/runtime/main-step-runtime-types.ts +64 -0
  131. package/src/runtime/main-step-runtime.ts +129 -200
  132. package/src/runtime/main-step-state.ts +85 -0
  133. package/src/runtime/serial-task-queue.ts +48 -13
  134. package/src/runtime/step-result.ts +48 -27
  135. package/src/workflow-list.ts +15 -6
  136. package/src/workflow-status/format-status.ts +52 -0
  137. package/src/workflow-status/formatting.ts +178 -0
  138. package/src/workflow-status/layout.ts +109 -0
  139. package/src/workflow-status/render-board.ts +101 -0
  140. package/src/workflow-status/render-path.ts +123 -0
  141. package/src/workflow-status/render-summary.ts +144 -0
  142. package/src/workflow-status/types.ts +40 -0
  143. package/src/workflow-status/view.ts +273 -0
  144. package/src/workflow-status.ts +20 -820
@@ -85,6 +85,7 @@ steps:
85
85
  skills: [superpowers:test-driven-development]
86
86
  transitions:
87
87
  ready: verify
88
+ retry: implement
88
89
  blocked: $pause
89
90
  verify:
90
91
  title: Verify the result
@@ -112,4 +113,5 @@ steps:
112
113
  transitions:
113
114
  passed: $done
114
115
  failed: implement
116
+ retry: verify
115
117
  blocked: $pause
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "0.3.0",
3
+ "version": "1.0.1",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -223,9 +223,18 @@
223
223
  "enum": [
224
224
  "verification-worker",
225
225
  "verification-reviewer",
226
- "remote-actions"
226
+ "remote-actions",
227
+ "remote-push",
228
+ "remote-drafts"
227
229
  ]
228
230
  }
231
+ },
232
+ "handoffSources": {
233
+ "type": "array",
234
+ "uniqueItems": true,
235
+ "items": {
236
+ "enum": ["verification-worker", "verification-reviewer"]
237
+ }
229
238
  }
230
239
  }
231
240
  },
@@ -1,3 +1,6 @@
1
+ /**
2
+ * Slash commands owned by the workflow harness.
3
+ */
1
4
  export const HARNESS_COMMAND_NAMES = [
2
5
  'workflow-abort',
3
6
  'workflow-list',
@@ -39,7 +42,10 @@ export const PI_BUILTIN_COMMAND_NAMES = [
39
42
  'trust',
40
43
  ] as const;
41
44
 
42
- export const RESERVED_COMMAND_NAMES = new Set<string>([
45
+ /**
46
+ * Command names that workflow aliases may not shadow.
47
+ */
48
+ export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set([
43
49
  ...HARNESS_COMMAND_NAMES,
44
50
  ...PI_BUILTIN_COMMAND_NAMES,
45
51
  ]);
package/src/commands.ts CHANGED
@@ -1,74 +1,148 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
+ RegisteredCommand,
4
5
  } from '@earendil-works/pi-coding-agent';
5
6
 
6
- export interface WorkflowCommandController {
7
- workflowIds(): string[];
8
- list(ctx: ExtensionCommandContext): Promise<void>;
9
- start(
7
+ /**
8
+ * Workflow operations exposed to slash-command handlers.
9
+ */
10
+ export type WorkflowCommandController = {
11
+ /** Returns loaded workflow IDs for command completion. */
12
+ readonly workflowIds: () => ReadonlyArray<string>;
13
+ /** Lists loaded workflows. */
14
+ readonly list: (context: ExtensionCommandContext) => Promise<void>;
15
+ /** Starts a workflow. */
16
+ readonly start: (
10
17
  workflowId: string,
11
18
  input: string,
12
- ctx: ExtensionCommandContext,
13
- ): Promise<void>;
14
- pause(reason: string, ctx: ExtensionCommandContext): Promise<void>;
15
- resume(ctx: ExtensionCommandContext): Promise<void>;
16
- abort(reason: string, ctx: ExtensionCommandContext): Promise<void>;
17
- reload(ctx: ExtensionCommandContext): Promise<void>;
18
- }
19
+ context: ExtensionCommandContext,
20
+ ) => Promise<void>;
21
+ /** Pauses the active workflow. */
22
+ readonly pause: (
23
+ reason: string,
24
+ context: ExtensionCommandContext,
25
+ ) => Promise<void>;
26
+ /** Resumes the paused workflow. */
27
+ readonly resume: (context: ExtensionCommandContext) => Promise<void>;
28
+ /** Aborts the active workflow. */
29
+ readonly abort: (
30
+ reason: string,
31
+ context: ExtensionCommandContext,
32
+ ) => Promise<void>;
33
+ /** Reloads workflow configuration. */
34
+ readonly reload: (context: ExtensionCommandContext) => Promise<void>;
35
+ };
19
36
 
20
- function splitFirst(value: string): [string, string] {
21
- const trimmed = value.trim();
22
- const separator = trimmed.search(/\s/);
23
- if (separator === -1) return [trimmed, ''];
24
- return [trimmed.slice(0, separator), trimmed.slice(separator).trim()];
25
- }
37
+ /**
38
+ * Declarative Pi command registration.
39
+ */
40
+ export type HarnessCommand = {
41
+ readonly name: string;
42
+ readonly options: Omit<RegisteredCommand, 'name' | 'sourceInfo'>;
43
+ };
26
44
 
27
- export function registerHarnessCommands(
28
- pi: ExtensionAPI,
29
- controller: WorkflowCommandController,
30
- ): void {
31
- pi.registerCommand('workflow-list', {
32
- description: 'List loaded declarative workflows',
33
- handler: async (_args, ctx) => controller.list(ctx),
34
- });
45
+ const splitFirst = (value: string): readonly [string, string] => {
46
+ const trimmedValue = value.trim();
47
+ const separatorIndex = trimmedValue.search(/\s/);
35
48
 
36
- pi.registerCommand('workflow-start', {
49
+ return separatorIndex === -1
50
+ ? [trimmedValue, '']
51
+ : [
52
+ trimmedValue.slice(0, separatorIndex),
53
+ trimmedValue.slice(separatorIndex).trim(),
54
+ ];
55
+ };
56
+
57
+ const createStartCommand = (
58
+ controller: WorkflowCommandController,
59
+ ): HarnessCommand => ({
60
+ name: 'workflow-start',
61
+ options: {
37
62
  description: 'Start a workflow: /workflow-start <id> [input]',
38
63
  getArgumentCompletions: (prefix) => {
39
- const items = controller
64
+ const completions = controller
40
65
  .workflowIds()
41
- .filter((id) => id.startsWith(prefix))
42
- .map((id) => ({ value: id, label: id }));
43
- return items.length > 0 ? items : null;
66
+ .filter((workflowId) => workflowId.startsWith(prefix))
67
+ .map((workflowId) => ({
68
+ value: workflowId,
69
+ label: workflowId,
70
+ }));
71
+ return completions.length > 0 ? completions : null;
44
72
  },
45
- handler: async (args, ctx) => {
73
+ handler: async (args, context) => {
46
74
  const [workflowId, input] = splitFirst(args);
47
75
  if (!workflowId) {
48
- ctx.ui.notify('Usage: /workflow-start <id> [input]', 'warning');
76
+ context.ui.notify('Usage: /workflow-start <id> [input]', 'warning');
49
77
  return;
50
78
  }
51
- await controller.start(workflowId, input, ctx);
79
+ await controller.start(workflowId, input, context);
52
80
  },
53
- });
54
-
55
- pi.registerCommand('workflow-pause', {
56
- description: 'Pause the active workflow without losing its checkpoint',
57
- handler: async (reason, ctx) => controller.pause(reason.trim(), ctx),
58
- });
81
+ },
82
+ });
59
83
 
60
- pi.registerCommand('workflow-resume', {
61
- description: 'Reload configuration and resume the paused workflow',
62
- handler: async (_args, ctx) => controller.resume(ctx),
63
- });
64
-
65
- pi.registerCommand('workflow-abort', {
66
- description: 'Abort the active workflow',
67
- handler: async (reason, ctx) => controller.abort(reason.trim(), ctx),
68
- });
84
+ /**
85
+ * Creates the declarative command registrations for a workflow controller.
86
+ *
87
+ * @param controller - Injected workflow command operations.
88
+ * @returns Command names and Pi registration options.
89
+ */
90
+ export function createHarnessCommands(
91
+ controller: WorkflowCommandController,
92
+ ): ReadonlyArray<HarnessCommand> {
93
+ return [
94
+ {
95
+ name: 'workflow-list',
96
+ options: {
97
+ description: 'List loaded declarative workflows',
98
+ handler: async (_args, context) => controller.list(context),
99
+ },
100
+ },
101
+ createStartCommand(controller),
102
+ {
103
+ name: 'workflow-pause',
104
+ options: {
105
+ description: 'Pause the active workflow without losing its checkpoint',
106
+ handler: async (reason, context) =>
107
+ controller.pause(reason.trim(), context),
108
+ },
109
+ },
110
+ {
111
+ name: 'workflow-resume',
112
+ options: {
113
+ description: 'Reload configuration and resume the paused workflow',
114
+ handler: async (_args, context) => controller.resume(context),
115
+ },
116
+ },
117
+ {
118
+ name: 'workflow-abort',
119
+ options: {
120
+ description: 'Abort the active workflow',
121
+ handler: async (reason, context) =>
122
+ controller.abort(reason.trim(), context),
123
+ },
124
+ },
125
+ {
126
+ name: 'workflow-reload',
127
+ options: {
128
+ description: 'Reload workflow files while no workflow is running',
129
+ handler: async (_args, context) => controller.reload(context),
130
+ },
131
+ },
132
+ ];
133
+ }
69
134
 
70
- pi.registerCommand('workflow-reload', {
71
- description: 'Reload workflow files while no workflow is running',
72
- handler: async (_args, ctx) => controller.reload(ctx),
73
- });
135
+ /**
136
+ * Registers all workflow commands with Pi.
137
+ *
138
+ * @param pi - Pi extension API used for registration.
139
+ * @param controller - Injected workflow command operations.
140
+ */
141
+ export function registerHarnessCommands(
142
+ pi: ExtensionAPI,
143
+ controller: WorkflowCommandController,
144
+ ): void {
145
+ for (const command of createHarnessCommands(controller)) {
146
+ pi.registerCommand(command.name, command.options);
147
+ }
74
148
  }
@@ -0,0 +1,162 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { checkWorkflowAgainstCeiling } from './ceiling.ts';
3
+ import { createDiagnostic } from './diagnostics.ts';
4
+ import { loadSettings } from './load-settings.ts';
5
+ import type {
6
+ ConfigLoader,
7
+ ConfigLoaderDependencies,
8
+ LoadCatalogOptions,
9
+ } from './load-types.ts';
10
+ import { loadWorkflowDirectory } from './load-workflows.ts';
11
+ import type {
12
+ ConfigDiagnostic,
13
+ LoadedWorkflow,
14
+ PermissionCeiling,
15
+ WorkflowCatalog,
16
+ WorkflowSettings,
17
+ } from './types.ts';
18
+
19
+ type MutableCatalog = {
20
+ readonly workflows: Map<string, LoadedWorkflow>;
21
+ readonly commands: Map<string, string>;
22
+ readonly diagnostics: Array<ConfigDiagnostic>;
23
+ };
24
+
25
+ function addWorkflow(catalog: MutableCatalog, workflow: LoadedWorkflow): void {
26
+ const { id, command } = workflow.definition;
27
+ const existing = catalog.workflows.get(id);
28
+ if (existing) {
29
+ catalog.diagnostics.push(
30
+ createDiagnostic(
31
+ workflow.sourcePath,
32
+ `workflow id "${id}" already belongs to ${existing.sourcePath}; overrides are not allowed`,
33
+ ),
34
+ );
35
+ return;
36
+ }
37
+ const existingCommand = catalog.commands.get(command);
38
+ if (existingCommand) {
39
+ catalog.diagnostics.push(
40
+ createDiagnostic(
41
+ workflow.sourcePath,
42
+ `command "/${command}" already belongs to workflow "${existingCommand}"`,
43
+ ),
44
+ );
45
+ return;
46
+ }
47
+ catalog.workflows.set(id, workflow);
48
+ catalog.commands.set(command, id);
49
+ }
50
+
51
+ function defaultUserWorkflowDirectory(
52
+ dependencies: ConfigLoaderDependencies,
53
+ ): string {
54
+ const explicit = dependencies.environment
55
+ .getVariable('PI_WORKFLOWS_DIR')
56
+ ?.trim();
57
+ if (explicit) return resolve(explicit);
58
+ const agentDirectory =
59
+ dependencies.environment.getVariable('PI_CODING_AGENT_DIR')?.trim() ||
60
+ join(dependencies.environment.homeDirectory(), '.pi', 'agent');
61
+ return join(agentDirectory, 'workflows');
62
+ }
63
+
64
+ function requiredPermissionCeiling(
65
+ settings: WorkflowSettings,
66
+ ): PermissionCeiling {
67
+ if (!settings.permissionCeiling) {
68
+ throw new Error(
69
+ 'validated settings must define a ceiling when project workflows are enabled',
70
+ );
71
+ }
72
+ return settings.permissionCeiling;
73
+ }
74
+
75
+ async function loadCatalog(
76
+ dependencies: ConfigLoaderDependencies,
77
+ options: LoadCatalogOptions,
78
+ ): Promise<WorkflowCatalog> {
79
+ const userDirectory = resolve(
80
+ options.userDirectory ?? defaultUserWorkflowDirectory(dependencies),
81
+ );
82
+ const settingsResult = await loadSettings(
83
+ dependencies.fileSystem,
84
+ userDirectory,
85
+ );
86
+ const state: MutableCatalog = {
87
+ workflows: new Map(),
88
+ commands: new Map(),
89
+ diagnostics: [...settingsResult.diagnostics],
90
+ };
91
+
92
+ const userResult = await loadWorkflowDirectory(
93
+ dependencies.fileSystem,
94
+ userDirectory,
95
+ 'user',
96
+ );
97
+ state.diagnostics.push(...userResult.diagnostics);
98
+ userResult.workflows.forEach((workflow) => {
99
+ addWorkflow(state, workflow);
100
+ });
101
+
102
+ const projectDirectory = join(resolve(options.cwd), '.pi', 'workflows');
103
+ if (settingsResult.settings.allowProjectWorkflows) {
104
+ if (!options.projectTrusted) {
105
+ state.diagnostics.push(
106
+ createDiagnostic(
107
+ projectDirectory,
108
+ 'project workflows were skipped because the project is not trusted',
109
+ 'warning',
110
+ ),
111
+ );
112
+ } else {
113
+ const permissionCeiling = requiredPermissionCeiling(
114
+ settingsResult.settings,
115
+ );
116
+ const projectResult = await loadWorkflowDirectory(
117
+ dependencies.fileSystem,
118
+ projectDirectory,
119
+ 'project',
120
+ );
121
+ state.diagnostics.push(...projectResult.diagnostics);
122
+ projectResult.workflows.forEach((workflow) => {
123
+ const ceilingErrors = checkWorkflowAgainstCeiling(
124
+ workflow.definition,
125
+ permissionCeiling,
126
+ );
127
+ if (ceilingErrors.length > 0) {
128
+ state.diagnostics.push(
129
+ ...ceilingErrors.map((message) =>
130
+ createDiagnostic(workflow.sourcePath, message),
131
+ ),
132
+ );
133
+ return;
134
+ }
135
+ addWorkflow(state, workflow);
136
+ });
137
+ }
138
+ }
139
+
140
+ return {
141
+ workflows: state.workflows,
142
+ settings: settingsResult.settings,
143
+ diagnostics: state.diagnostics,
144
+ userDirectory,
145
+ ...(settingsResult.settings.allowProjectWorkflows
146
+ ? { projectDirectory }
147
+ : {}),
148
+ };
149
+ }
150
+
151
+ /** Bind configuration loading to explicit filesystem and environment ports. */
152
+ export function createConfigLoader(
153
+ dependencies: ConfigLoaderDependencies,
154
+ ): ConfigLoader {
155
+ return {
156
+ defaultUserWorkflowDirectory: () =>
157
+ defaultUserWorkflowDirectory(dependencies),
158
+ loadSettings: (userDirectory) =>
159
+ loadSettings(dependencies.fileSystem, userDirectory),
160
+ loadCatalog: (options) => loadCatalog(dependencies, options),
161
+ };
162
+ }
@@ -2,12 +2,14 @@ import type {
2
2
  BashPermission,
3
3
  BashRule,
4
4
  PermissionCeiling,
5
+ StepSubagent,
6
+ SubagentPermissionCeiling,
5
7
  WorkflowDefinition,
6
8
  } from './types.ts';
7
9
 
8
10
  function selectorAllowed(
9
11
  requested: string,
10
- ceiling: readonly string[],
12
+ ceiling: ReadonlyArray<string>,
11
13
  ): boolean {
12
14
  const separator = requested.indexOf('/');
13
15
  if (separator === -1) return ceiling.includes(requested);
@@ -31,131 +33,133 @@ function bashWithinCeiling(
31
33
 
32
34
  const allowedRules = new Set(ceiling.allow.map(ruleKey));
33
35
  const allowedSources = new Set(ceiling.approvedSources ?? []);
36
+ const allowedHandoffSources = new Set(ceiling.handoffSources ?? []);
34
37
  return (
35
38
  requested.allow.every((rule) => allowedRules.has(ruleKey(rule))) &&
36
39
  (requested.approvedSources ?? []).every((source) =>
37
40
  allowedSources.has(source),
41
+ ) &&
42
+ (requested.handoffSources ?? []).every((source) =>
43
+ allowedHandoffSources.has(source),
38
44
  )
39
45
  );
40
46
  }
41
47
 
48
+ function turnBudgetErrors(
49
+ subagent: StepSubagent,
50
+ ceiling: SubagentPermissionCeiling,
51
+ path: string,
52
+ ): Array<string> {
53
+ if (!subagent.turnBudget) {
54
+ return [`${path}.turnBudget: required for a project workflow`];
55
+ }
56
+ return [
57
+ ...(subagent.turnBudget.maxTurns > ceiling.maxTurns
58
+ ? [`${path}.turnBudget.maxTurns: exceeds the user permission ceiling`]
59
+ : []),
60
+ ...((subagent.turnBudget.graceTurns ?? 0) > ceiling.maxGraceTurns
61
+ ? [`${path}.turnBudget.graceTurns: exceeds the user permission ceiling`]
62
+ : []),
63
+ ];
64
+ }
65
+
66
+ function toolBudgetErrors(
67
+ subagent: StepSubagent,
68
+ ceiling: SubagentPermissionCeiling,
69
+ path: string,
70
+ ): Array<string> {
71
+ if (!subagent.toolBudget) {
72
+ return [`${path}.toolBudget: required for a project workflow`];
73
+ }
74
+ return [
75
+ ...(subagent.toolBudget.hard > ceiling.maxToolCalls
76
+ ? [`${path}.toolBudget.hard: exceeds the user permission ceiling`]
77
+ : []),
78
+ ...(subagent.toolBudget.block !== '*'
79
+ ? [`${path}.toolBudget.block: must be "*" for a project workflow`]
80
+ : []),
81
+ ];
82
+ }
83
+
84
+ function subagentErrors(
85
+ subagent: StepSubagent | undefined,
86
+ ceiling: SubagentPermissionCeiling | undefined,
87
+ path: string,
88
+ ): Array<string> {
89
+ if (!subagent) return [];
90
+ if (!ceiling) {
91
+ return [`${path}: subagent execution exceeds the user permission ceiling`];
92
+ }
93
+ return [
94
+ ...(!ceiling.agents.includes(subagent.agent)
95
+ ? [
96
+ `${path}.agent: "${subagent.agent}" exceeds the user permission ceiling`,
97
+ ]
98
+ : []),
99
+ ...(!ceiling.contexts.includes(subagent.context)
100
+ ? [
101
+ `${path}.context: "${subagent.context}" exceeds the user permission ceiling`,
102
+ ]
103
+ : []),
104
+ ...(subagent.model && !ceiling.models.includes(subagent.model)
105
+ ? [
106
+ `${path}.model: "${subagent.model}" exceeds the user permission ceiling`,
107
+ ]
108
+ : []),
109
+ ...(subagent.timeoutMs > ceiling.maxTimeoutMs
110
+ ? [`${path}.timeoutMs: exceeds the user permission ceiling`]
111
+ : []),
112
+ ...(subagent.artifacts && !ceiling.artifacts
113
+ ? [`${path}.artifacts: exceeds the user permission ceiling`]
114
+ : []),
115
+ ...(subagent.retryToolFailures && !ceiling.retryToolFailures
116
+ ? [`${path}.retryToolFailures: exceeds the user permission ceiling`]
117
+ : []),
118
+ ...turnBudgetErrors(subagent, ceiling, path),
119
+ ...toolBudgetErrors(subagent, ceiling, path),
120
+ ];
121
+ }
122
+
123
+ /** Return every project-workflow permission that exceeds the user ceiling. */
42
124
  export function checkWorkflowAgainstCeiling(
43
125
  workflow: WorkflowDefinition,
44
126
  ceiling: PermissionCeiling,
45
- ): string[] {
46
- const errors: string[] = [];
47
- for (const [stepId, step] of Object.entries(workflow.steps)) {
127
+ ): Array<string> {
128
+ return Object.entries(workflow.steps).flatMap(([stepId, step]) => {
48
129
  const path = `workflow.steps.${stepId}.permissions`;
49
- const subagentPath = `workflow.steps.${stepId}.subagent`;
50
- for (const tool of step.permissions.tools) {
51
- if (!ceiling.tools.includes(tool)) {
52
- errors.push(
53
- `${path}.tools: "${tool}" exceeds the user permission ceiling`,
54
- );
55
- }
56
- }
57
- for (const selector of step.permissions.mcp) {
58
- if (!selectorAllowed(selector, ceiling.mcp)) {
59
- errors.push(
60
- `${path}.mcp: "${selector}" exceeds the user permission ceiling`,
61
- );
62
- }
63
- }
64
- for (const extension of step.permissions.extensions) {
65
- if (!ceiling.extensions.includes(extension)) {
66
- errors.push(
67
- `${path}.extensions: "${extension}" exceeds the user permission ceiling`,
68
- );
69
- }
70
- }
71
- for (const skill of step.permissions.skills) {
72
- if (!ceiling.skills.includes(skill)) {
73
- errors.push(
74
- `${path}.skills: "${skill}" exceeds the user permission ceiling`,
75
- );
76
- }
77
- }
78
- if (!bashWithinCeiling(step.permissions.bash, ceiling.bash)) {
79
- errors.push(`${path}.bash: exceeds the user permission ceiling`);
80
- }
81
- if (!step.subagent) continue;
82
- if (!ceiling.subagent) {
83
- errors.push(
84
- `${subagentPath}: subagent execution exceeds the user permission ceiling`,
85
- );
86
- continue;
87
- }
88
- if (!ceiling.subagent.agents.includes(step.subagent.agent)) {
89
- errors.push(
90
- `${subagentPath}.agent: "${step.subagent.agent}" exceeds the user permission ceiling`,
91
- );
92
- }
93
- if (!ceiling.subagent.contexts.includes(step.subagent.context)) {
94
- errors.push(
95
- `${subagentPath}.context: "${step.subagent.context}" exceeds the user permission ceiling`,
96
- );
97
- }
98
- if (
99
- step.subagent.model &&
100
- !ceiling.subagent.models.includes(step.subagent.model)
101
- ) {
102
- errors.push(
103
- `${subagentPath}.model: "${step.subagent.model}" exceeds the user permission ceiling`,
104
- );
105
- }
106
- if (step.subagent.timeoutMs > ceiling.subagent.maxTimeoutMs) {
107
- errors.push(
108
- `${subagentPath}.timeoutMs: exceeds the user permission ceiling`,
109
- );
110
- }
111
- if (step.subagent.artifacts && !ceiling.subagent.artifacts) {
112
- errors.push(
113
- `${subagentPath}.artifacts: exceeds the user permission ceiling`,
114
- );
115
- }
116
- if (
117
- step.subagent.retryToolFailures &&
118
- !ceiling.subagent.retryToolFailures
119
- ) {
120
- errors.push(
121
- `${subagentPath}.retryToolFailures: exceeds the user permission ceiling`,
122
- );
123
- }
124
- if (!step.subagent.turnBudget) {
125
- errors.push(
126
- `${subagentPath}.turnBudget: required for a project workflow`,
127
- );
128
- } else {
129
- if (step.subagent.turnBudget.maxTurns > ceiling.subagent.maxTurns) {
130
- errors.push(
131
- `${subagentPath}.turnBudget.maxTurns: exceeds the user permission ceiling`,
132
- );
133
- }
134
- if (
135
- (step.subagent.turnBudget.graceTurns ?? 0) >
136
- ceiling.subagent.maxGraceTurns
137
- ) {
138
- errors.push(
139
- `${subagentPath}.turnBudget.graceTurns: exceeds the user permission ceiling`,
140
- );
141
- }
142
- }
143
- if (!step.subagent.toolBudget) {
144
- errors.push(
145
- `${subagentPath}.toolBudget: required for a project workflow`,
146
- );
147
- } else {
148
- if (step.subagent.toolBudget.hard > ceiling.subagent.maxToolCalls) {
149
- errors.push(
150
- `${subagentPath}.toolBudget.hard: exceeds the user permission ceiling`,
151
- );
152
- }
153
- if (step.subagent.toolBudget.block !== '*') {
154
- errors.push(
155
- `${subagentPath}.toolBudget.block: must be "*" for a project workflow`,
156
- );
157
- }
158
- }
159
- }
160
- return errors;
130
+ return [
131
+ ...step.permissions.tools
132
+ .filter((tool) => !ceiling.tools.includes(tool))
133
+ .map(
134
+ (tool) =>
135
+ `${path}.tools: "${tool}" exceeds the user permission ceiling`,
136
+ ),
137
+ ...step.permissions.mcp
138
+ .filter((selector) => !selectorAllowed(selector, ceiling.mcp))
139
+ .map(
140
+ (selector) =>
141
+ `${path}.mcp: "${selector}" exceeds the user permission ceiling`,
142
+ ),
143
+ ...step.permissions.extensions
144
+ .filter((extension) => !ceiling.extensions.includes(extension))
145
+ .map(
146
+ (extension) =>
147
+ `${path}.extensions: "${extension}" exceeds the user permission ceiling`,
148
+ ),
149
+ ...step.permissions.skills
150
+ .filter((skill) => !ceiling.skills.includes(skill))
151
+ .map(
152
+ (skill) =>
153
+ `${path}.skills: "${skill}" exceeds the user permission ceiling`,
154
+ ),
155
+ ...(!bashWithinCeiling(step.permissions.bash, ceiling.bash)
156
+ ? [`${path}.bash: exceeds the user permission ceiling`]
157
+ : []),
158
+ ...subagentErrors(
159
+ step.subagent,
160
+ ceiling.subagent,
161
+ `workflow.steps.${stepId}.subagent`,
162
+ ),
163
+ ];
164
+ });
161
165
  }