@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
@@ -0,0 +1,312 @@
1
+ import {
2
+ EMPTY_PERMISSIONS,
3
+ type BashApprovalSource,
4
+ type BashMode,
5
+ type BashPermission,
6
+ type BashRule,
7
+ type StepPermissions,
8
+ type StepRequirements,
9
+ } from '../types.ts';
10
+ import {
11
+ BASH_APPROVAL_SOURCE_PATTERN,
12
+ EXECUTABLE_PATTERN,
13
+ isJsonObject,
14
+ MCP_SELECTOR_PATTERN,
15
+ readString,
16
+ readStringList,
17
+ rejectUnknownKeys,
18
+ RESOURCE_SELECTOR_PATTERN,
19
+ TOOL_PATTERN,
20
+ type ValidationErrors,
21
+ } from './shared.ts';
22
+
23
+ const BASH_APPROVAL_SOURCES = [
24
+ 'verification-worker',
25
+ 'verification-reviewer',
26
+ 'remote-actions',
27
+ 'remote-push',
28
+ 'remote-drafts',
29
+ ] as const satisfies ReadonlyArray<BashApprovalSource>;
30
+
31
+ function isBashApprovalSource(value: string): value is BashApprovalSource {
32
+ return BASH_APPROVAL_SOURCES.some((source) => source === value);
33
+ }
34
+
35
+ function emptyPermissions(): StepPermissions {
36
+ return {
37
+ tools: [],
38
+ mcp: [],
39
+ extensions: [],
40
+ skills: [],
41
+ bash: { mode: 'deny', allow: [] },
42
+ };
43
+ }
44
+
45
+ function emptyRequirements(): StepRequirements {
46
+ return {
47
+ tools: [],
48
+ extensions: [],
49
+ skills: [],
50
+ };
51
+ }
52
+
53
+ function parseBashRule(
54
+ value: unknown,
55
+ path: string,
56
+ errors: ValidationErrors,
57
+ ): Array<BashRule> {
58
+ if (!isJsonObject(value)) {
59
+ errors.push(`${path}: expected an object`);
60
+ return [];
61
+ }
62
+ rejectUnknownKeys(
63
+ value,
64
+ ['executable', 'argsPrefix', 'argsPrefixes'],
65
+ path,
66
+ errors,
67
+ );
68
+
69
+ const executable = readString(
70
+ value.executable,
71
+ `${path}.executable`,
72
+ errors,
73
+ { pattern: EXECUTABLE_PATTERN },
74
+ );
75
+ if (value.argsPrefix !== undefined && value.argsPrefixes !== undefined) {
76
+ errors.push(`${path}: argsPrefix and argsPrefixes are mutually exclusive`);
77
+ }
78
+
79
+ if (value.argsPrefixes === undefined) {
80
+ const argsPrefix = readStringList(
81
+ value.argsPrefix,
82
+ `${path}.argsPrefix`,
83
+ errors,
84
+ /^[^\s]+$/,
85
+ );
86
+ return executable ? [{ executable, argsPrefix }] : [];
87
+ }
88
+ if (!Array.isArray(value.argsPrefixes)) {
89
+ errors.push(`${path}.argsPrefixes: expected an array of argument arrays`);
90
+ return [];
91
+ }
92
+ if (value.argsPrefixes.length === 0) {
93
+ errors.push(`${path}.argsPrefixes: at least one prefix is required`);
94
+ }
95
+
96
+ const prefixes = value.argsPrefixes.reduce<Array<Array<string>>>(
97
+ (result, candidate, index) => {
98
+ const prefixPath = `${path}.argsPrefixes[${index}]`;
99
+ const prefix = readStringList(candidate, prefixPath, errors, /^[^\s]+$/);
100
+ if (Array.isArray(candidate) && candidate.length === 0) {
101
+ errors.push(`${prefixPath}: at least one argument is required`);
102
+ return result;
103
+ }
104
+ if (prefix.length === 0) return result;
105
+ if (
106
+ result.some(
107
+ (existing) => JSON.stringify(existing) === JSON.stringify(prefix),
108
+ )
109
+ ) {
110
+ errors.push(`${prefixPath}: duplicate argument prefix`);
111
+ return result;
112
+ }
113
+ return [...result, prefix];
114
+ },
115
+ [],
116
+ );
117
+ return executable
118
+ ? prefixes.map((argsPrefix) => ({ executable, argsPrefix }))
119
+ : [];
120
+ }
121
+
122
+ function parseBashPermission(
123
+ value: unknown,
124
+ path: string,
125
+ errors: ValidationErrors,
126
+ ): BashPermission {
127
+ if (value === undefined) {
128
+ return { ...EMPTY_PERMISSIONS.bash, allow: [] };
129
+ }
130
+ if (!isJsonObject(value)) {
131
+ errors.push(`${path}: expected an object`);
132
+ return { ...EMPTY_PERMISSIONS.bash, allow: [] };
133
+ }
134
+ rejectUnknownKeys(
135
+ value,
136
+ ['mode', 'allow', 'approvedSources', 'handoffSources'],
137
+ path,
138
+ errors,
139
+ );
140
+
141
+ const mode = readString(value.mode, `${path}.mode`, errors);
142
+ const isValidMode =
143
+ mode === 'deny' ||
144
+ mode === 'read-only' ||
145
+ mode === 'allow-list' ||
146
+ mode === 'unrestricted';
147
+ if (!isValidMode) {
148
+ errors.push(
149
+ `${path}.mode: expected deny, read-only, allow-list, or unrestricted`,
150
+ );
151
+ }
152
+
153
+ const allow = Array.isArray(value.allow)
154
+ ? value.allow.flatMap((rule, index) =>
155
+ parseBashRule(rule, `${path}.allow[${index}]`, errors),
156
+ )
157
+ : [];
158
+ if (value.allow !== undefined && !Array.isArray(value.allow)) {
159
+ errors.push(`${path}.allow: expected an array`);
160
+ }
161
+
162
+ const approvedSources = (
163
+ value.approvedSources === undefined
164
+ ? []
165
+ : readStringList(
166
+ value.approvedSources,
167
+ `${path}.approvedSources`,
168
+ errors,
169
+ BASH_APPROVAL_SOURCE_PATTERN,
170
+ )
171
+ ).filter(isBashApprovalSource);
172
+ const handoffSources = (
173
+ value.handoffSources === undefined
174
+ ? []
175
+ : readStringList(
176
+ value.handoffSources,
177
+ `${path}.handoffSources`,
178
+ errors,
179
+ /^(verification-worker|verification-reviewer)$/,
180
+ )
181
+ ) as Array<'verification-worker' | 'verification-reviewer'>;
182
+
183
+ const normalizedMode: BashMode = isValidMode ? mode : 'deny';
184
+ if (normalizedMode !== 'allow-list' && allow.length > 0) {
185
+ errors.push(`${path}.allow: only valid when mode is "allow-list"`);
186
+ }
187
+ if (
188
+ normalizedMode === 'allow-list' &&
189
+ allow.length === 0 &&
190
+ approvedSources.length === 0
191
+ ) {
192
+ errors.push(
193
+ `${path}: allow-list mode requires an allow rule or an approved command source`,
194
+ );
195
+ }
196
+ if (normalizedMode !== 'allow-list' && approvedSources.length > 0) {
197
+ errors.push(
198
+ `${path}.approvedSources: only valid when mode is "allow-list"`,
199
+ );
200
+ }
201
+ if (normalizedMode !== 'allow-list' && handoffSources.length > 0) {
202
+ errors.push(`${path}.handoffSources: only valid when mode is "allow-list"`);
203
+ }
204
+
205
+ return {
206
+ mode: normalizedMode,
207
+ allow,
208
+ ...(approvedSources.length > 0 ? { approvedSources } : {}),
209
+ ...(handoffSources.length > 0 ? { handoffSources } : {}),
210
+ };
211
+ }
212
+
213
+ /** Parse the permission block shared by workflow steps and user ceilings. */
214
+ export function parsePermissions(
215
+ value: unknown,
216
+ path: string,
217
+ errors: ValidationErrors,
218
+ ): StepPermissions {
219
+ if (value === undefined) {
220
+ return emptyPermissions();
221
+ }
222
+ if (!isJsonObject(value)) {
223
+ errors.push(`${path}: expected an object`);
224
+ return emptyPermissions();
225
+ }
226
+ rejectUnknownKeys(
227
+ value,
228
+ ['tools', 'mcp', 'extensions', 'skills', 'bash'],
229
+ path,
230
+ errors,
231
+ );
232
+
233
+ const permissions: StepPermissions = {
234
+ tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
235
+ mcp: readStringList(value.mcp, `${path}.mcp`, errors, MCP_SELECTOR_PATTERN),
236
+ extensions: readStringList(
237
+ value.extensions,
238
+ `${path}.extensions`,
239
+ errors,
240
+ RESOURCE_SELECTOR_PATTERN,
241
+ ),
242
+ skills: readStringList(
243
+ value.skills,
244
+ `${path}.skills`,
245
+ errors,
246
+ RESOURCE_SELECTOR_PATTERN,
247
+ ),
248
+ bash: parseBashPermission(value.bash, `${path}.bash`, errors),
249
+ };
250
+ if (permissions.bash.mode !== 'deny' && !permissions.tools.includes('bash')) {
251
+ errors.push(`${path}.tools: must include "bash" when Bash is enabled`);
252
+ }
253
+ return permissions;
254
+ }
255
+
256
+ /** Parse requirements and ensure every requirement is permitted by the step. */
257
+ export function parseRequirements(
258
+ value: unknown,
259
+ permissions: StepPermissions,
260
+ path: string,
261
+ errors: ValidationErrors,
262
+ ): StepRequirements {
263
+ if (value === undefined) return emptyRequirements();
264
+ if (!isJsonObject(value)) {
265
+ errors.push(`${path}: expected an object`);
266
+ return emptyRequirements();
267
+ }
268
+ rejectUnknownKeys(value, ['tools', 'extensions', 'skills'], path, errors);
269
+
270
+ const requirements: StepRequirements = {
271
+ tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
272
+ extensions: readStringList(
273
+ value.extensions,
274
+ `${path}.extensions`,
275
+ errors,
276
+ RESOURCE_SELECTOR_PATTERN,
277
+ ),
278
+ skills: readStringList(
279
+ value.skills,
280
+ `${path}.skills`,
281
+ errors,
282
+ RESOURCE_SELECTOR_PATTERN,
283
+ ),
284
+ };
285
+
286
+ requirements.tools
287
+ .filter(
288
+ (tool) =>
289
+ !permissions.tools.includes(tool) &&
290
+ !(tool === 'mcp' && permissions.mcp.length > 0),
291
+ )
292
+ .forEach((tool) =>
293
+ errors.push(
294
+ `${path}.tools: required tool "${tool}" is not allowed by this step`,
295
+ ),
296
+ );
297
+ requirements.extensions
298
+ .filter((extension) => !permissions.extensions.includes(extension))
299
+ .forEach((extension) =>
300
+ errors.push(
301
+ `${path}.extensions: required extension "${extension}" is not allowed by this step`,
302
+ ),
303
+ );
304
+ requirements.skills
305
+ .filter((skill) => !permissions.skills.includes(skill))
306
+ .forEach((skill) =>
307
+ errors.push(
308
+ `${path}.skills: required skill "${skill}" is not allowed by this step`,
309
+ ),
310
+ );
311
+ return requirements;
312
+ }
@@ -0,0 +1,21 @@
1
+ const PROMPT_VARIABLES = new Set([
2
+ 'workflow.input',
3
+ 'workflow.id',
4
+ 'run.id',
5
+ 'step.id',
6
+ 'step.title',
7
+ 'last.summary',
8
+ 'reviewed.artifact',
9
+ 'reviewed.feedback',
10
+ 'gate.feedback',
11
+ ]);
12
+
13
+ /** Validate template variables embedded in resolved prompt text. */
14
+ export function validatePromptText(text: string, path: string): Array<string> {
15
+ return [...text.matchAll(/\{\{([^{}]+)\}\}/g)].flatMap((match) => {
16
+ const variable = match[1]?.trim() ?? '';
17
+ return PROMPT_VARIABLES.has(variable)
18
+ ? []
19
+ : [`${path}: unknown prompt variable "{{${variable}}}"`];
20
+ });
21
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ DEFAULT_SETTINGS,
3
+ WORKFLOW_SCHEMA_VERSION,
4
+ type PermissionCeiling,
5
+ type WorkflowSettings,
6
+ } from '../types.ts';
7
+ import { parsePermissions } from './permissions.ts';
8
+ import {
9
+ isJsonObject,
10
+ rejectUnknownKeys,
11
+ type ValidationErrors,
12
+ type ValidationResult,
13
+ } from './shared.ts';
14
+ import { readStatusShortcut } from './shortcut.ts';
15
+ import { parseSubagentPermissionCeiling } from './subagent.ts';
16
+
17
+ function parsePermissionCeiling(
18
+ value: unknown,
19
+ path: string,
20
+ errors: ValidationErrors,
21
+ ): PermissionCeiling | undefined {
22
+ if (value === undefined) return undefined;
23
+ if (!isJsonObject(value)) {
24
+ errors.push(`${path}: expected an object`);
25
+ return undefined;
26
+ }
27
+ rejectUnknownKeys(
28
+ value,
29
+ ['tools', 'mcp', 'extensions', 'skills', 'bash', 'subagent'],
30
+ path,
31
+ errors,
32
+ );
33
+ const permissions = parsePermissions(
34
+ {
35
+ ...(value.tools !== undefined ? { tools: value.tools } : {}),
36
+ ...(value.mcp !== undefined ? { mcp: value.mcp } : {}),
37
+ ...(value.extensions !== undefined
38
+ ? { extensions: value.extensions }
39
+ : {}),
40
+ ...(value.skills !== undefined ? { skills: value.skills } : {}),
41
+ ...(value.bash !== undefined ? { bash: value.bash } : {}),
42
+ },
43
+ path,
44
+ errors,
45
+ );
46
+ const subagent =
47
+ value.subagent === undefined
48
+ ? undefined
49
+ : parseSubagentPermissionCeiling(
50
+ value.subagent,
51
+ `${path}.subagent`,
52
+ errors,
53
+ );
54
+ return {
55
+ ...permissions,
56
+ ...(subagent ? { subagent } : {}),
57
+ };
58
+ }
59
+
60
+ /** Validate and normalize untrusted user workflow settings. */
61
+ export function validateSettings(
62
+ value: unknown,
63
+ ): ValidationResult<WorkflowSettings> {
64
+ const errors: ValidationErrors = [];
65
+ if (!isJsonObject(value)) {
66
+ return { errors: ['settings: expected an object'] };
67
+ }
68
+ rejectUnknownKeys(
69
+ value,
70
+ [
71
+ '$schema',
72
+ 'version',
73
+ 'allowProjectWorkflows',
74
+ 'statusShortcut',
75
+ 'permissionCeiling',
76
+ ],
77
+ 'settings',
78
+ errors,
79
+ );
80
+ if (value.$schema !== undefined && typeof value.$schema !== 'string') {
81
+ errors.push('settings.$schema: expected a string');
82
+ }
83
+ if (value.version !== WORKFLOW_SCHEMA_VERSION) {
84
+ errors.push(`settings.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
85
+ }
86
+ const allowProjectWorkflows =
87
+ typeof value.allowProjectWorkflows === 'boolean'
88
+ ? value.allowProjectWorkflows
89
+ : false;
90
+ if (
91
+ value.allowProjectWorkflows !== undefined &&
92
+ typeof value.allowProjectWorkflows !== 'boolean'
93
+ ) {
94
+ errors.push('settings.allowProjectWorkflows: expected a boolean');
95
+ }
96
+ const statusShortcut = readStatusShortcut(
97
+ value.statusShortcut,
98
+ 'settings.statusShortcut',
99
+ errors,
100
+ );
101
+ const permissionCeiling = parsePermissionCeiling(
102
+ value.permissionCeiling,
103
+ 'settings.permissionCeiling',
104
+ errors,
105
+ );
106
+ if (allowProjectWorkflows && !permissionCeiling) {
107
+ errors.push(
108
+ 'settings.permissionCeiling: required when project workflows are enabled',
109
+ );
110
+ }
111
+ if (errors.length > 0) return { errors };
112
+
113
+ return {
114
+ value: {
115
+ ...DEFAULT_SETTINGS,
116
+ allowProjectWorkflows,
117
+ statusShortcut,
118
+ ...(permissionCeiling ? { permissionCeiling } : {}),
119
+ },
120
+ errors,
121
+ };
122
+ }
@@ -0,0 +1,129 @@
1
+ export type ValidationResult<TValue> = {
2
+ readonly value?: TValue;
3
+ readonly errors: ReadonlyArray<string>;
4
+ };
5
+
6
+ export type JsonObject = Record<string, unknown>;
7
+ export type ValidationErrors = Array<string>;
8
+
9
+ export const IDENTIFIER_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
10
+ export const OUTCOME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
11
+ export const TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/;
12
+ export const RESOURCE_SELECTOR_PATTERN = /^[A-Za-z0-9_@./:+-]+$/;
13
+ export const MCP_SELECTOR_PATTERN = /^[A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?$/;
14
+ export const EXECUTABLE_PATTERN = /^[A-Za-z0-9_./+-]+$/;
15
+ export const BASH_APPROVAL_SOURCE_PATTERN =
16
+ /^(verification-worker|verification-reviewer|remote-actions|remote-push|remote-drafts)$/;
17
+
18
+ type StringOptions = {
19
+ readonly pattern?: RegExp;
20
+ };
21
+
22
+ type IntegerLimits = {
23
+ readonly min: number;
24
+ readonly max: number;
25
+ };
26
+
27
+ /** Return whether an unknown value is a plain JSON-style object. */
28
+ export function isJsonObject(value: unknown): value is JsonObject {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ }
31
+
32
+ /** Append diagnostics for object properties outside the supplied allow-list. */
33
+ export function rejectUnknownKeys(
34
+ value: JsonObject,
35
+ allowed: ReadonlyArray<string>,
36
+ path: string,
37
+ errors: ValidationErrors,
38
+ ): void {
39
+ const allowedKeys = new Set(allowed);
40
+ Object.keys(value)
41
+ .filter((key) => !allowedKeys.has(key))
42
+ .forEach((key) => errors.push(`${path}: unknown property "${key}"`));
43
+ }
44
+
45
+ /** Parse and normalize a string, collecting a diagnostic when it is invalid. */
46
+ export function readString(
47
+ value: unknown,
48
+ path: string,
49
+ errors: ValidationErrors,
50
+ options: StringOptions = {},
51
+ ): string | undefined {
52
+ if (typeof value !== 'string') {
53
+ errors.push(`${path}: expected a string`);
54
+ return undefined;
55
+ }
56
+
57
+ const result = value.trim();
58
+ if (result.length === 0) {
59
+ errors.push(`${path}: must not be empty`);
60
+ return undefined;
61
+ }
62
+ if (options.pattern && !options.pattern.test(result)) {
63
+ errors.push(`${path}: invalid value "${result}"`);
64
+ return undefined;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /** Parse a bounded integer, returning the supplied fallback on invalid input. */
70
+ export function readInteger(
71
+ value: unknown,
72
+ fallback: number,
73
+ path: string,
74
+ errors: ValidationErrors,
75
+ limits: IntegerLimits,
76
+ ): number {
77
+ if (value === undefined) return fallback;
78
+ if (
79
+ typeof value !== 'number' ||
80
+ !Number.isInteger(value) ||
81
+ value < limits.min ||
82
+ value > limits.max
83
+ ) {
84
+ errors.push(
85
+ `${path}: expected an integer from ${limits.min} to ${limits.max}`,
86
+ );
87
+ return fallback;
88
+ }
89
+ return value;
90
+ }
91
+
92
+ /** Parse a boolean, returning the supplied fallback on invalid input. */
93
+ export function readBoolean(
94
+ value: unknown,
95
+ fallback: boolean,
96
+ path: string,
97
+ errors: ValidationErrors,
98
+ ): boolean {
99
+ if (value === undefined) return fallback;
100
+ if (typeof value !== 'boolean') {
101
+ errors.push(`${path}: expected a boolean`);
102
+ return fallback;
103
+ }
104
+ return value;
105
+ }
106
+
107
+ /** Parse a duplicate-free array of patterned strings. */
108
+ export function readStringList(
109
+ value: unknown,
110
+ path: string,
111
+ errors: ValidationErrors,
112
+ pattern: RegExp,
113
+ ): Array<string> {
114
+ if (value === undefined) return [];
115
+ if (!Array.isArray(value)) {
116
+ errors.push(`${path}: expected an array of strings`);
117
+ return [];
118
+ }
119
+
120
+ return value.reduce<Array<string>>((result, item, index) => {
121
+ const parsed = readString(item, `${path}[${index}]`, errors, { pattern });
122
+ if (!parsed) return result;
123
+ if (result.includes(parsed)) {
124
+ errors.push(`${path}[${index}]: duplicate value "${parsed}"`);
125
+ return result;
126
+ }
127
+ return [...result, parsed];
128
+ }, []);
129
+ }
@@ -0,0 +1,118 @@
1
+ import type { WorkflowSettings } from '../types.ts';
2
+ import { DEFAULT_STATUS_SHORTCUT } from '../types.ts';
3
+ import { readString, type ValidationErrors } from './shared.ts';
4
+
5
+ const SHORTCUT_MODIFIERS = new Set(['ctrl', 'shift', 'alt', 'super']);
6
+ const SHORTCUT_NAMED_KEYS = new Map<string, string>([
7
+ ['escape', 'escape'],
8
+ ['esc', 'esc'],
9
+ ['enter', 'enter'],
10
+ ['return', 'return'],
11
+ ['tab', 'tab'],
12
+ ['space', 'space'],
13
+ ['backspace', 'backspace'],
14
+ ['delete', 'delete'],
15
+ ['insert', 'insert'],
16
+ ['clear', 'clear'],
17
+ ['home', 'home'],
18
+ ['end', 'end'],
19
+ ['pageup', 'pageUp'],
20
+ ['pagedown', 'pageDown'],
21
+ ['up', 'up'],
22
+ ['down', 'down'],
23
+ ['left', 'left'],
24
+ ['right', 'right'],
25
+ ]);
26
+ const SHORTCUT_SYMBOL_KEYS = new Set([
27
+ '`',
28
+ '-',
29
+ '=',
30
+ '[',
31
+ ']',
32
+ '\\',
33
+ ';',
34
+ "'",
35
+ ',',
36
+ '.',
37
+ '/',
38
+ '!',
39
+ '@',
40
+ '#',
41
+ '$',
42
+ '%',
43
+ '^',
44
+ '&',
45
+ '*',
46
+ '(',
47
+ ')',
48
+ '_',
49
+ '|',
50
+ '~',
51
+ '{',
52
+ '}',
53
+ ':',
54
+ '<',
55
+ '>',
56
+ '?',
57
+ ]);
58
+ const MAX_SHORTCUT_CHARS = 64;
59
+
60
+ function canonicalShortcutKey(value: string): string | undefined {
61
+ if (/^[a-z0-9]$/.test(value) || SHORTCUT_SYMBOL_KEYS.has(value)) {
62
+ return value;
63
+ }
64
+ if (/^f(?:[1-9]|1[0-2])$/.test(value)) return value;
65
+ return SHORTCUT_NAMED_KEYS.get(value);
66
+ }
67
+
68
+ function isStatusShortcut(
69
+ value: string,
70
+ ): value is WorkflowSettings['statusShortcut'] {
71
+ const parts = value.split('+');
72
+ const key = parts.pop() ?? '';
73
+ return (
74
+ canonicalShortcutKey(key.toLowerCase()) === key &&
75
+ parts.every((modifier) => SHORTCUT_MODIFIERS.has(modifier))
76
+ );
77
+ }
78
+
79
+ /** Parse and normalize the configured workflow status shortcut. */
80
+ export function readStatusShortcut(
81
+ value: unknown,
82
+ path: string,
83
+ errors: ValidationErrors,
84
+ ): WorkflowSettings['statusShortcut'] {
85
+ if (value === undefined) return DEFAULT_STATUS_SHORTCUT;
86
+ const shortcut = readString(value, path, errors);
87
+ if (!shortcut) return DEFAULT_STATUS_SHORTCUT;
88
+ if (shortcut.length > MAX_SHORTCUT_CHARS) {
89
+ errors.push(`${path}: must be at most ${MAX_SHORTCUT_CHARS} characters`);
90
+ return DEFAULT_STATUS_SHORTCUT;
91
+ }
92
+
93
+ const parts = shortcut.toLowerCase().split('+');
94
+ const key = parts.pop() ?? '';
95
+ const modifiers = parts;
96
+ const uniqueModifiers = new Set(modifiers);
97
+ const canonicalKey = canonicalShortcutKey(key);
98
+ const isPlainTypingKey =
99
+ modifiers.length === 0 && (key.length === 1 || key === 'space');
100
+ const isUnmatchableModifiedKey =
101
+ modifiers.length > 0 &&
102
+ (key === 'escape' || key === 'esc' || /^f(?:[1-9]|1[0-2])$/.test(key));
103
+ const isInvalid =
104
+ !canonicalKey ||
105
+ modifiers.length > SHORTCUT_MODIFIERS.size ||
106
+ uniqueModifiers.size !== modifiers.length ||
107
+ modifiers.some((modifier) => !SHORTCUT_MODIFIERS.has(modifier)) ||
108
+ isPlainTypingKey ||
109
+ isUnmatchableModifiedKey;
110
+
111
+ if (isInvalid) {
112
+ errors.push(`${path}: expected a supported Pi key id such as "ctrl+alt+w"`);
113
+ return DEFAULT_STATUS_SHORTCUT;
114
+ }
115
+
116
+ const normalized = [...modifiers, canonicalKey].join('+');
117
+ return isStatusShortcut(normalized) ? normalized : DEFAULT_STATUS_SHORTCUT;
118
+ }