@wichayutdew/pi-workflows 0.3.0 → 1.0.0

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 (142) hide show
  1. package/README.md +53 -39
  2. package/dist/index.js +5754 -4794
  3. package/package.json +1 -1
  4. package/src/command-names.ts +7 -1
  5. package/src/commands.ts +126 -52
  6. package/src/config/catalog.ts +162 -0
  7. package/src/config/ceiling.ts +116 -116
  8. package/src/config/command-conflicts.ts +4 -4
  9. package/src/config/diagnostics.ts +28 -0
  10. package/src/config/load-settings.ts +37 -0
  11. package/src/config/load-types.ts +53 -0
  12. package/src/config/load-workflows.ts +135 -0
  13. package/src/config/load.ts +43 -311
  14. package/src/config/types.ts +137 -135
  15. package/src/config/validate.ts +12 -1261
  16. package/src/config/validation/permissions.ts +291 -0
  17. package/src/config/validation/prompt.ts +19 -0
  18. package/src/config/validation/settings.ts +122 -0
  19. package/src/config/validation/shared.ts +129 -0
  20. package/src/config/validation/shortcut.ts +118 -0
  21. package/src/config/validation/step.ts +228 -0
  22. package/src/config/validation/subagent.ts +288 -0
  23. package/src/config/validation/workflow.ts +156 -0
  24. package/src/config/yaml.ts +29 -0
  25. package/src/digest.ts +38 -6
  26. package/src/engine/checkpoint.ts +13 -9
  27. package/src/engine/create-run.ts +42 -0
  28. package/src/engine/gate-transitions.ts +184 -0
  29. package/src/engine/reconciliation-history.ts +88 -0
  30. package/src/engine/resume.ts +19 -7
  31. package/src/engine/run-advance.ts +122 -0
  32. package/src/engine/run-lifecycle.ts +124 -0
  33. package/src/engine/run-reconciliation.ts +116 -0
  34. package/src/engine/run-validation.ts +128 -0
  35. package/src/engine/state-types.ts +64 -0
  36. package/src/engine/state.ts +10 -193
  37. package/src/engine/transition-helpers.ts +28 -0
  38. package/src/engine/transition-types.ts +8 -0
  39. package/src/engine/transitions.ts +17 -471
  40. package/src/harness/action-context.ts +181 -0
  41. package/src/harness/catalog.ts +45 -0
  42. package/src/harness/context-idle.ts +21 -0
  43. package/src/harness/core-actions.ts +299 -0
  44. package/src/harness/delegation-control-actions.ts +250 -0
  45. package/src/harness/delegation-failure.ts +215 -0
  46. package/src/harness/delegation-plan.ts +219 -0
  47. package/src/harness/delegation-recovery-validation.ts +159 -0
  48. package/src/harness/delegation-response-actions.ts +295 -0
  49. package/src/harness/delegation-retry-policy.ts +118 -0
  50. package/src/harness/dependencies.ts +165 -0
  51. package/src/harness/gate-submission-action.ts +154 -0
  52. package/src/harness/lifecycle-actions.ts +154 -0
  53. package/src/harness/pause-actions.ts +120 -0
  54. package/src/harness/plannotator-result-actions.ts +123 -0
  55. package/src/harness/prompt-gate-actions.ts +270 -0
  56. package/src/harness/resume-action.ts +261 -0
  57. package/src/harness/start-actions.ts +183 -0
  58. package/src/harness/status-actions.ts +191 -0
  59. package/src/harness/step-execution-actions.ts +283 -0
  60. package/src/harness/types.ts +76 -0
  61. package/src/harness.ts +236 -2303
  62. package/src/index.ts +73 -11
  63. package/src/integrations/plannotator-requests.ts +127 -0
  64. package/src/integrations/plannotator-responses.ts +141 -0
  65. package/src/integrations/plannotator-types.ts +44 -0
  66. package/src/integrations/plannotator.ts +16 -235
  67. package/src/integrations/prompt-gate.ts +17 -10
  68. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  69. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  70. package/src/integrations/subagents/child-policy-sections.ts +174 -0
  71. package/src/integrations/subagents/child-policy-types.ts +32 -0
  72. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  73. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  74. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  75. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  76. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  77. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  78. package/src/integrations/subagents/child-runtime.ts +140 -301
  79. package/src/integrations/subagents/client-delegation.ts +181 -0
  80. package/src/integrations/subagents/client-messages.ts +66 -0
  81. package/src/integrations/subagents/client-types.ts +36 -0
  82. package/src/integrations/subagents/client.ts +127 -219
  83. package/src/integrations/subagents/delegated-result.ts +31 -0
  84. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  85. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  86. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  87. package/src/integrations/subagents/diagnostics.ts +24 -977
  88. package/src/integrations/subagents/failure-correlation.ts +235 -0
  89. package/src/integrations/subagents/failure-transcript.ts +192 -0
  90. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  91. package/src/integrations/subagents/protocol-events.ts +27 -0
  92. package/src/integrations/subagents/protocol.ts +30 -375
  93. package/src/integrations/subagents/replay-audit.ts +147 -0
  94. package/src/integrations/subagents/replay-safety.ts +82 -0
  95. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  96. package/src/policy/approved-command-extraction.ts +71 -0
  97. package/src/policy/approved-commands.ts +15 -432
  98. package/src/policy/bash-authorization.ts +86 -0
  99. package/src/policy/bash-read-only.ts +161 -0
  100. package/src/policy/bash-types.ts +20 -0
  101. package/src/policy/bash.ts +4 -346
  102. package/src/policy/completion-batch.ts +21 -16
  103. package/src/policy/immutable-input.ts +5 -2
  104. package/src/policy/mcp-authorization.ts +71 -0
  105. package/src/policy/restricted-command.ts +141 -0
  106. package/src/policy/restricted-git.ts +30 -0
  107. package/src/policy/reviewed-artifact.ts +97 -0
  108. package/src/policy/reviewed-command-safety.ts +150 -0
  109. package/src/policy/reviewed-command-shape.ts +49 -0
  110. package/src/policy/reviewed-repository-contract.ts +138 -0
  111. package/src/policy/reviewed-repository-cwd.ts +102 -0
  112. package/src/policy/tool-call-authorization.ts +58 -0
  113. package/src/policy/tool-selection.ts +72 -0
  114. package/src/policy/tool-types.ts +14 -0
  115. package/src/policy/tools.ts +11 -150
  116. package/src/preflight.ts +96 -61
  117. package/src/prompt/main-workflow-notice.ts +41 -0
  118. package/src/prompt/retry-task.ts +83 -0
  119. package/src/prompt/step-contract.ts +101 -0
  120. package/src/prompt/step-sections.ts +75 -0
  121. package/src/prompt/step-task.ts +156 -0
  122. package/src/prompt/template.ts +81 -0
  123. package/src/prompt.ts +10 -254
  124. package/src/runtime/completion-tool.ts +3 -0
  125. package/src/runtime/main-step-completion.ts +78 -0
  126. package/src/runtime/main-step-lifecycle.ts +37 -0
  127. package/src/runtime/main-step-policy.ts +84 -0
  128. package/src/runtime/main-step-runtime-types.ts +64 -0
  129. package/src/runtime/main-step-runtime.ts +129 -200
  130. package/src/runtime/main-step-state.ts +85 -0
  131. package/src/runtime/serial-task-queue.ts +48 -13
  132. package/src/runtime/step-result.ts +48 -27
  133. package/src/workflow-list.ts +15 -6
  134. package/src/workflow-status/format-status.ts +52 -0
  135. package/src/workflow-status/formatting.ts +178 -0
  136. package/src/workflow-status/layout.ts +109 -0
  137. package/src/workflow-status/render-board.ts +101 -0
  138. package/src/workflow-status/render-path.ts +123 -0
  139. package/src/workflow-status/render-summary.ts +144 -0
  140. package/src/workflow-status/types.ts +40 -0
  141. package/src/workflow-status/view.ts +273 -0
  142. package/src/workflow-status.ts +20 -820
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.0",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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);
@@ -39,123 +41,121 @@ function bashWithinCeiling(
39
41
  );
40
42
  }
41
43
 
44
+ function turnBudgetErrors(
45
+ subagent: StepSubagent,
46
+ ceiling: SubagentPermissionCeiling,
47
+ path: string,
48
+ ): Array<string> {
49
+ if (!subagent.turnBudget) {
50
+ return [`${path}.turnBudget: required for a project workflow`];
51
+ }
52
+ return [
53
+ ...(subagent.turnBudget.maxTurns > ceiling.maxTurns
54
+ ? [`${path}.turnBudget.maxTurns: exceeds the user permission ceiling`]
55
+ : []),
56
+ ...((subagent.turnBudget.graceTurns ?? 0) > ceiling.maxGraceTurns
57
+ ? [`${path}.turnBudget.graceTurns: exceeds the user permission ceiling`]
58
+ : []),
59
+ ];
60
+ }
61
+
62
+ function toolBudgetErrors(
63
+ subagent: StepSubagent,
64
+ ceiling: SubagentPermissionCeiling,
65
+ path: string,
66
+ ): Array<string> {
67
+ if (!subagent.toolBudget) {
68
+ return [`${path}.toolBudget: required for a project workflow`];
69
+ }
70
+ return [
71
+ ...(subagent.toolBudget.hard > ceiling.maxToolCalls
72
+ ? [`${path}.toolBudget.hard: exceeds the user permission ceiling`]
73
+ : []),
74
+ ...(subagent.toolBudget.block !== '*'
75
+ ? [`${path}.toolBudget.block: must be "*" for a project workflow`]
76
+ : []),
77
+ ];
78
+ }
79
+
80
+ function subagentErrors(
81
+ subagent: StepSubagent | undefined,
82
+ ceiling: SubagentPermissionCeiling | undefined,
83
+ path: string,
84
+ ): Array<string> {
85
+ if (!subagent) return [];
86
+ if (!ceiling) {
87
+ return [`${path}: subagent execution exceeds the user permission ceiling`];
88
+ }
89
+ return [
90
+ ...(!ceiling.agents.includes(subagent.agent)
91
+ ? [
92
+ `${path}.agent: "${subagent.agent}" exceeds the user permission ceiling`,
93
+ ]
94
+ : []),
95
+ ...(!ceiling.contexts.includes(subagent.context)
96
+ ? [
97
+ `${path}.context: "${subagent.context}" exceeds the user permission ceiling`,
98
+ ]
99
+ : []),
100
+ ...(subagent.model && !ceiling.models.includes(subagent.model)
101
+ ? [
102
+ `${path}.model: "${subagent.model}" exceeds the user permission ceiling`,
103
+ ]
104
+ : []),
105
+ ...(subagent.timeoutMs > ceiling.maxTimeoutMs
106
+ ? [`${path}.timeoutMs: exceeds the user permission ceiling`]
107
+ : []),
108
+ ...(subagent.artifacts && !ceiling.artifacts
109
+ ? [`${path}.artifacts: exceeds the user permission ceiling`]
110
+ : []),
111
+ ...(subagent.retryToolFailures && !ceiling.retryToolFailures
112
+ ? [`${path}.retryToolFailures: exceeds the user permission ceiling`]
113
+ : []),
114
+ ...turnBudgetErrors(subagent, ceiling, path),
115
+ ...toolBudgetErrors(subagent, ceiling, path),
116
+ ];
117
+ }
118
+
119
+ /** Return every project-workflow permission that exceeds the user ceiling. */
42
120
  export function checkWorkflowAgainstCeiling(
43
121
  workflow: WorkflowDefinition,
44
122
  ceiling: PermissionCeiling,
45
- ): string[] {
46
- const errors: string[] = [];
47
- for (const [stepId, step] of Object.entries(workflow.steps)) {
123
+ ): Array<string> {
124
+ return Object.entries(workflow.steps).flatMap(([stepId, step]) => {
48
125
  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;
126
+ return [
127
+ ...step.permissions.tools
128
+ .filter((tool) => !ceiling.tools.includes(tool))
129
+ .map(
130
+ (tool) =>
131
+ `${path}.tools: "${tool}" exceeds the user permission ceiling`,
132
+ ),
133
+ ...step.permissions.mcp
134
+ .filter((selector) => !selectorAllowed(selector, ceiling.mcp))
135
+ .map(
136
+ (selector) =>
137
+ `${path}.mcp: "${selector}" exceeds the user permission ceiling`,
138
+ ),
139
+ ...step.permissions.extensions
140
+ .filter((extension) => !ceiling.extensions.includes(extension))
141
+ .map(
142
+ (extension) =>
143
+ `${path}.extensions: "${extension}" exceeds the user permission ceiling`,
144
+ ),
145
+ ...step.permissions.skills
146
+ .filter((skill) => !ceiling.skills.includes(skill))
147
+ .map(
148
+ (skill) =>
149
+ `${path}.skills: "${skill}" exceeds the user permission ceiling`,
150
+ ),
151
+ ...(!bashWithinCeiling(step.permissions.bash, ceiling.bash)
152
+ ? [`${path}.bash: exceeds the user permission ceiling`]
153
+ : []),
154
+ ...subagentErrors(
155
+ step.subagent,
156
+ ceiling.subagent,
157
+ `workflow.steps.${stepId}.subagent`,
158
+ ),
159
+ ];
160
+ });
161
161
  }
@@ -1,6 +1,6 @@
1
- interface CommandLike {
2
- name: string;
3
- }
1
+ type CommandLike = {
2
+ readonly name: string;
3
+ };
4
4
 
5
5
  function isSuffixedInvocation(name: string, command: string): boolean {
6
6
  if (!name.startsWith(`${command}:`)) return false;
@@ -14,7 +14,7 @@ function isSuffixedInvocation(name: string, command: string): boolean {
14
14
  */
15
15
  export function hasRuntimeCommandConflict(
16
16
  command: string,
17
- availableCommands: readonly CommandLike[],
17
+ availableCommands: ReadonlyArray<CommandLike>,
18
18
  ownedAliases: ReadonlySet<string>,
19
19
  ): boolean {
20
20
  if (
@@ -0,0 +1,28 @@
1
+ import type { ConfigDiagnostic } from './types.ts';
2
+
3
+ /** Create one normalized configuration diagnostic. */
4
+ export function createDiagnostic(
5
+ path: string,
6
+ message: string,
7
+ level: ConfigDiagnostic['level'] = 'error',
8
+ ): ConfigDiagnostic {
9
+ return { path, message, level };
10
+ }
11
+
12
+ /** Convert any caught value to a stable diagnostic message. */
13
+ export function errorMessage(error: unknown): string {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+
17
+ /** Read a Node-style string error code without asserting the caught value. */
18
+ export function errorCode(error: unknown): string | undefined {
19
+ if (
20
+ typeof error !== 'object' ||
21
+ error === null ||
22
+ !('code' in error) ||
23
+ typeof error.code !== 'string'
24
+ ) {
25
+ return undefined;
26
+ }
27
+ return error.code;
28
+ }