@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
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { WorkflowHarness } from './harness.ts';
3
+ import { registerSubagentChildRuntime } from './integrations/subagents/child-runtime.ts';
4
+ import { isSubagentRuntimeName } from './integrations/subagents/protocol.ts';
5
+
6
+ export default function piWorkflowsExtension(pi: ExtensionAPI): void {
7
+ if (process.env.PI_SUBAGENT_CHILD === '1') {
8
+ const childAgent = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
9
+ if (isSubagentRuntimeName(childAgent)) {
10
+ registerSubagentChildRuntime(pi, { childAgent });
11
+ }
12
+ return;
13
+ }
14
+ new WorkflowHarness(pi);
15
+ }
@@ -0,0 +1,235 @@
1
+ export const PLANNOTATOR_REQUEST_CHANNEL = 'plannotator:request';
2
+ export const PLANNOTATOR_RESULT_CHANNEL = 'plannotator:review-result';
3
+
4
+ export interface EventBusLike {
5
+ emit(channel: string, data: unknown): void;
6
+ on(channel: string, handler: (data: unknown) => void): () => void;
7
+ }
8
+
9
+ export interface PlannotatorReviewStarted {
10
+ status: 'pending';
11
+ reviewId: string;
12
+ }
13
+
14
+ export type PlannotatorStartResponse =
15
+ | { status: 'handled'; result: PlannotatorReviewStarted }
16
+ | { status: 'unavailable'; error?: string }
17
+ | { status: 'error'; error: string };
18
+
19
+ export type PlannotatorReviewStatus =
20
+ | { status: 'pending' }
21
+ | {
22
+ status: 'completed';
23
+ reviewId: string;
24
+ approved: boolean;
25
+ feedback: string;
26
+ }
27
+ | { status: 'missing' };
28
+
29
+ export type PlannotatorStatusResponse =
30
+ | { status: 'handled'; result: PlannotatorReviewStatus }
31
+ | { status: 'unavailable'; error?: string }
32
+ | { status: 'error'; error: string };
33
+
34
+ export interface PlannotatorReviewResult {
35
+ reviewId: string;
36
+ approved: boolean;
37
+ feedback: string;
38
+ }
39
+
40
+ function errorText(value: Record<string, unknown>, fallback: string): string {
41
+ return typeof value.error === 'string' && value.error.trim()
42
+ ? value.error
43
+ : fallback;
44
+ }
45
+
46
+ function normalizeStartResponse(value: unknown): PlannotatorStartResponse {
47
+ if (value === null || typeof value !== 'object') {
48
+ return {
49
+ status: 'error',
50
+ error: 'Plannotator returned an invalid response',
51
+ };
52
+ }
53
+ const response = value as Record<string, unknown>;
54
+ if (response.status === 'unavailable') {
55
+ return {
56
+ status: 'unavailable',
57
+ error: errorText(response, 'Plannotator is unavailable'),
58
+ };
59
+ }
60
+ if (response.status === 'error') {
61
+ return {
62
+ status: 'error',
63
+ error: errorText(response, 'Plannotator failed'),
64
+ };
65
+ }
66
+ const result =
67
+ response.result !== null && typeof response.result === 'object'
68
+ ? (response.result as Record<string, unknown>)
69
+ : undefined;
70
+ if (
71
+ response.status === 'handled' &&
72
+ result?.status === 'pending' &&
73
+ typeof result.reviewId === 'string'
74
+ ) {
75
+ return {
76
+ status: 'handled',
77
+ result: { status: 'pending', reviewId: result.reviewId },
78
+ };
79
+ }
80
+ return {
81
+ status: 'error',
82
+ error: 'Plannotator returned an invalid start result',
83
+ };
84
+ }
85
+
86
+ function normalizeStatusResponse(
87
+ value: unknown,
88
+ requestedReviewId: string,
89
+ ): PlannotatorStatusResponse {
90
+ if (value === null || typeof value !== 'object') {
91
+ return {
92
+ status: 'error',
93
+ error: 'Plannotator returned an invalid response',
94
+ };
95
+ }
96
+ const response = value as Record<string, unknown>;
97
+ if (response.status === 'unavailable') {
98
+ return {
99
+ status: 'unavailable',
100
+ error: errorText(response, 'Plannotator is unavailable'),
101
+ };
102
+ }
103
+ if (response.status === 'error') {
104
+ return {
105
+ status: 'error',
106
+ error: errorText(response, 'Plannotator failed'),
107
+ };
108
+ }
109
+ const result =
110
+ response.result !== null && typeof response.result === 'object'
111
+ ? (response.result as Record<string, unknown>)
112
+ : undefined;
113
+ if (response.status !== 'handled' || !result) {
114
+ return {
115
+ status: 'error',
116
+ error: 'Plannotator returned an invalid status result',
117
+ };
118
+ }
119
+ if (result.status === 'pending' || result.status === 'missing') {
120
+ return { status: 'handled', result: { status: result.status } };
121
+ }
122
+ if (
123
+ result.status === 'completed' &&
124
+ typeof result.reviewId === 'string' &&
125
+ typeof result.approved === 'boolean'
126
+ ) {
127
+ if (result.reviewId !== requestedReviewId) {
128
+ return {
129
+ status: 'error',
130
+ error: 'Plannotator returned a result for a different review',
131
+ };
132
+ }
133
+ return {
134
+ status: 'handled',
135
+ result: {
136
+ status: 'completed',
137
+ reviewId: result.reviewId,
138
+ approved: result.approved,
139
+ feedback: typeof result.feedback === 'string' ? result.feedback : '',
140
+ },
141
+ };
142
+ }
143
+ return {
144
+ status: 'error',
145
+ error: 'Plannotator returned an invalid status result',
146
+ };
147
+ }
148
+
149
+ export function parsePlannotatorResult(
150
+ value: unknown,
151
+ ): PlannotatorReviewResult | undefined {
152
+ if (value === null || typeof value !== 'object') return undefined;
153
+ const result = value as Record<string, unknown>;
154
+ if (
155
+ typeof result.reviewId !== 'string' ||
156
+ typeof result.approved !== 'boolean'
157
+ ) {
158
+ return undefined;
159
+ }
160
+ return {
161
+ reviewId: result.reviewId,
162
+ approved: result.approved,
163
+ feedback: typeof result.feedback === 'string' ? result.feedback : '',
164
+ };
165
+ }
166
+
167
+ export function requestPlannotatorReview(
168
+ events: EventBusLike,
169
+ requestId: string,
170
+ content: string,
171
+ origin: string,
172
+ timeoutMs: number,
173
+ ): Promise<PlannotatorStartResponse> {
174
+ return new Promise((resolve) => {
175
+ let settled = false;
176
+ const finish = (response: unknown) => {
177
+ if (settled) return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ resolve(normalizeStartResponse(response));
181
+ };
182
+ const timer = setTimeout(
183
+ () =>
184
+ finish({
185
+ status: 'unavailable',
186
+ error: `Plannotator did not respond within ${timeoutMs}ms`,
187
+ }),
188
+ timeoutMs,
189
+ );
190
+ timer.unref?.();
191
+
192
+ events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
193
+ requestId,
194
+ action: 'plan-review',
195
+ payload: {
196
+ planContent: content,
197
+ origin,
198
+ },
199
+ respond: finish,
200
+ });
201
+ });
202
+ }
203
+
204
+ export function requestPlannotatorReviewStatus(
205
+ events: EventBusLike,
206
+ requestId: string,
207
+ reviewId: string,
208
+ timeoutMs: number,
209
+ ): Promise<PlannotatorStatusResponse> {
210
+ return new Promise((resolve) => {
211
+ let settled = false;
212
+ const finish = (response: unknown) => {
213
+ if (settled) return;
214
+ settled = true;
215
+ clearTimeout(timer);
216
+ resolve(normalizeStatusResponse(response, reviewId));
217
+ };
218
+ const timer = setTimeout(
219
+ () =>
220
+ finish({
221
+ status: 'unavailable',
222
+ error: `Plannotator did not respond within ${timeoutMs}ms`,
223
+ }),
224
+ timeoutMs,
225
+ );
226
+ timer.unref?.();
227
+
228
+ events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
229
+ requestId,
230
+ action: 'review-status',
231
+ payload: { reviewId },
232
+ respond: finish,
233
+ });
234
+ });
235
+ }
@@ -0,0 +1,54 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
+
3
+ const APPROVE = 'Approve';
4
+ const REQUEST_CHANGES = 'Request changes';
5
+ const PAUSE = 'Pause workflow';
6
+
7
+ export type PromptGateReviewResult =
8
+ | {
9
+ status: 'resolved';
10
+ approved: boolean;
11
+ feedback: string;
12
+ }
13
+ | {
14
+ status: 'dismissed';
15
+ };
16
+
17
+ export async function requestPromptGateReview(
18
+ ui: ExtensionUIContext,
19
+ title: string,
20
+ artifact: string,
21
+ signal?: AbortSignal,
22
+ ): Promise<PromptGateReviewResult> {
23
+ const choice = await ui.select(
24
+ `${title}\n\n${artifact}`,
25
+ [APPROVE, REQUEST_CHANGES, PAUSE],
26
+ ...(signal ? [{ signal }] : []),
27
+ );
28
+
29
+ if (choice === APPROVE) {
30
+ return { status: 'resolved', approved: true, feedback: '' };
31
+ }
32
+ if (choice !== REQUEST_CHANGES) {
33
+ return { status: 'dismissed' };
34
+ }
35
+
36
+ while (true) {
37
+ const feedback = await ui.input(
38
+ 'Workflow review feedback',
39
+ 'Describe the required changes',
40
+ ...(signal ? [{ signal }] : []),
41
+ );
42
+ if (feedback === undefined) {
43
+ return { status: 'dismissed' };
44
+ }
45
+ if (feedback.trim()) {
46
+ return {
47
+ status: 'resolved',
48
+ approved: false,
49
+ feedback: feedback.trim(),
50
+ };
51
+ }
52
+ ui.notify('Feedback cannot be empty', 'warning');
53
+ }
54
+ }
@@ -0,0 +1,306 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
+ import type { WorkflowStep } from '../../config/types.ts';
11
+ import { invalidCompletionCallIds } from '../../policy/completion-batch.ts';
12
+ import { freezeToolInput } from '../../policy/immutable-input.ts';
13
+ import { authorizeToolCall, resolveActiveTools } from '../../policy/tools.ts';
14
+ import {
15
+ WORKFLOW_COMPLETION_PARAMETERS,
16
+ WORKFLOW_COMPLETION_TOOL,
17
+ } from '../../runtime/completion-tool.ts';
18
+ import {
19
+ extractChildPolicy,
20
+ isSubagentRuntimeName,
21
+ parseDelegatedStepResult,
22
+ type ChildStepPolicy,
23
+ } from './protocol.ts';
24
+
25
+ export const CHILD_COMPLETION_TOOL = WORKFLOW_COMPLETION_TOOL;
26
+
27
+ function policyStep(policy: ChildStepPolicy): WorkflowStep {
28
+ return {
29
+ title: policy.stepTitle,
30
+ prompt: { inline: 'Delegated workflow step' },
31
+ subagent: {
32
+ agent: policy.agent,
33
+ context: 'fresh',
34
+ timeoutMs: 900_000,
35
+ artifacts: false,
36
+ },
37
+ permissions: policy.permissions,
38
+ requires: { tools: [], extensions: [], skills: [] },
39
+ transitions: {},
40
+ };
41
+ }
42
+
43
+ function childSystemPrompt(policy: ChildStepPolicy): string {
44
+ return [
45
+ '# Pi Workflows delegated step',
46
+ '',
47
+ `Workflow: ${policy.workflowId}`,
48
+ `Run: ${policy.runId}`,
49
+ `Step: ${policy.stepId} (${policy.stepTitle})`,
50
+ '',
51
+ 'The parent workflow harness owns orchestration and state transitions.',
52
+ 'Perform only this delegated step. Its child-side tool policy is enforced.',
53
+ 'When finished, call `workflow_complete_step` exactly once and as the only tool call in that message.',
54
+ `Valid outcomes: ${policy.outcomes.join(', ')}`,
55
+ `Summary limit: ${policy.summaryMaxChars} characters`,
56
+ ...(policy.gateSubmitOutcome
57
+ ? [
58
+ `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`,
59
+ ]
60
+ : []),
61
+ 'If the workflow definition or environment is wrong, choose an outcome that transitions to $pause.',
62
+ ].join('\n');
63
+ }
64
+
65
+ function writeResult(policy: ChildStepPolicy, result: unknown): void {
66
+ if (existsSync(policy.resultPath)) {
67
+ throw new Error('Delegated workflow step already produced a result');
68
+ }
69
+ const temporaryPath = `${policy.resultPath}.${randomUUID()}.tmp`;
70
+ try {
71
+ writeFileSync(temporaryPath, JSON.stringify(result), {
72
+ encoding: 'utf8',
73
+ flag: 'wx',
74
+ mode: 0o600,
75
+ });
76
+ renameSync(temporaryPath, policy.resultPath);
77
+ } catch (error) {
78
+ try {
79
+ unlinkSync(temporaryPath);
80
+ } catch {
81
+ // The temporary file may not have been created.
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function verifyCapability(
88
+ policy: ChildStepPolicy,
89
+ childAgent: string | undefined,
90
+ ): void {
91
+ if (!isSubagentRuntimeName(childAgent) || childAgent !== policy.agent) {
92
+ throw new Error('child agent does not match the delegated workflow policy');
93
+ }
94
+ let actual: Buffer;
95
+ try {
96
+ actual = Buffer.from(readFileSync(policy.capabilityPath, 'utf8'), 'utf8');
97
+ } catch {
98
+ throw new Error('delegated workflow capability is missing');
99
+ }
100
+ const expected = Buffer.from(policy.capabilityToken, 'utf8');
101
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
102
+ throw new Error('delegated workflow capability is invalid');
103
+ }
104
+ unlinkSync(policy.capabilityPath);
105
+ }
106
+
107
+ export interface SubagentChildRuntimeOptions {
108
+ childAgent?: string;
109
+ }
110
+
111
+ export function registerSubagentChildRuntime(
112
+ pi: ExtensionAPI,
113
+ options: SubagentChildRuntimeOptions = {},
114
+ ): void {
115
+ let activePolicy: ChildStepPolicy | undefined;
116
+ let policyError: string | undefined;
117
+ let invalidCompletionCalls = new Set<string>();
118
+ let effectiveTools = new Set<string>();
119
+ let completionRegistered = false;
120
+ const childAgent =
121
+ options.childAgent ?? process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
122
+
123
+ const registerCompletionTool = (): void => {
124
+ if (completionRegistered) return;
125
+ completionRegistered = true;
126
+ pi.registerTool({
127
+ name: CHILD_COMPLETION_TOOL,
128
+ label: 'Complete Delegated Workflow Step',
129
+ description:
130
+ 'Return one validated result from a pi-workflows delegated child step',
131
+ promptSnippet: 'Complete the delegated workflow step',
132
+ promptGuidelines: [
133
+ 'Call workflow_complete_step alone after all delegated work is complete.',
134
+ ],
135
+ parameters: WORKFLOW_COMPLETION_PARAMETERS,
136
+ executionMode: 'sequential',
137
+ execute: async (_toolCallId, params) => {
138
+ if (!activePolicy) {
139
+ throw new Error('No delegated workflow policy is active');
140
+ }
141
+ if (policyError) throw new Error(policyError);
142
+ const result = parseDelegatedStepResult(
143
+ {
144
+ version: 1,
145
+ policyDigest: activePolicy.policyDigest,
146
+ outcome: params.outcome,
147
+ summary: params.summary,
148
+ ...(params.artifact !== undefined
149
+ ? { artifact: params.artifact }
150
+ : {}),
151
+ },
152
+ activePolicy,
153
+ );
154
+ writeResult(activePolicy, result);
155
+ return {
156
+ content: [
157
+ {
158
+ type: 'text' as const,
159
+ text: `Captured workflow step outcome "${result.outcome}".`,
160
+ },
161
+ ],
162
+ details: {
163
+ workflowId: activePolicy.workflowId,
164
+ runId: activePolicy.runId,
165
+ stepId: activePolicy.stepId,
166
+ outcome: result.outcome,
167
+ },
168
+ terminate: true,
169
+ };
170
+ },
171
+ });
172
+ };
173
+
174
+ pi.on('input', (event) => {
175
+ let extracted;
176
+ try {
177
+ extracted = extractChildPolicy(event.text);
178
+ } catch (error) {
179
+ policyError = error instanceof Error ? error.message : String(error);
180
+ pi.setActiveTools([]);
181
+ return {
182
+ action: 'transform' as const,
183
+ text: `Delegated workflow policy is invalid: ${policyError}`,
184
+ ...(event.images ? { images: event.images } : {}),
185
+ };
186
+ }
187
+ if (!extracted) return;
188
+ if (activePolicy) {
189
+ policyError = 'child received more than one workflow policy';
190
+ pi.setActiveTools([]);
191
+ return {
192
+ action: 'transform' as const,
193
+ text: `Delegated workflow policy is invalid: ${policyError}`,
194
+ ...(event.images ? { images: event.images } : {}),
195
+ };
196
+ }
197
+
198
+ try {
199
+ verifyCapability(extracted.policy, childAgent);
200
+ const profileTools = new Set(pi.getActiveTools());
201
+ activePolicy = extracted.policy;
202
+ policyError = undefined;
203
+ registerCompletionTool();
204
+ effectiveTools = new Set(
205
+ resolveActiveTools(
206
+ pi.getAllTools(),
207
+ policyStep(activePolicy),
208
+ CHILD_COMPLETION_TOOL,
209
+ ).filter(
210
+ (toolName) =>
211
+ toolName === CHILD_COMPLETION_TOOL || profileTools.has(toolName),
212
+ ),
213
+ );
214
+ } catch (error) {
215
+ policyError = error instanceof Error ? error.message : String(error);
216
+ effectiveTools.clear();
217
+ pi.setActiveTools([]);
218
+ return {
219
+ action: 'transform' as const,
220
+ text: `Delegated workflow policy is invalid: ${policyError}`,
221
+ ...(event.images ? { images: event.images } : {}),
222
+ };
223
+ }
224
+ pi.setActiveTools([...effectiveTools]);
225
+ return {
226
+ action: 'transform' as const,
227
+ text: extracted.task,
228
+ ...(event.images ? { images: event.images } : {}),
229
+ };
230
+ });
231
+
232
+ pi.on('before_agent_start', (event) => {
233
+ if (!activePolicy) {
234
+ if (policyError) pi.setActiveTools([]);
235
+ return;
236
+ }
237
+ return {
238
+ systemPrompt: `${event.systemPrompt}\n\n${childSystemPrompt(activePolicy)}`,
239
+ };
240
+ });
241
+
242
+ pi.on('turn_start', () => {
243
+ invalidCompletionCalls.clear();
244
+ });
245
+
246
+ pi.on('message_end', (event) => {
247
+ if (!activePolicy) return;
248
+ const invalid = invalidCompletionCallIds(
249
+ event.message,
250
+ CHILD_COMPLETION_TOOL,
251
+ );
252
+ if (
253
+ invalid.size > 0 ||
254
+ (event.message as { role?: unknown }).role === 'assistant'
255
+ ) {
256
+ invalidCompletionCalls = invalid;
257
+ }
258
+ });
259
+
260
+ pi.on('tool_call', (event) => {
261
+ if (!activePolicy) {
262
+ if (!policyError) return;
263
+ return {
264
+ block: true,
265
+ reason: policyError,
266
+ };
267
+ }
268
+ if (invalidCompletionCalls.has(event.toolCallId)) {
269
+ return {
270
+ block: true,
271
+ reason: `${CHILD_COMPLETION_TOOL} must be the only tool call in its message`,
272
+ };
273
+ }
274
+ if (event.toolName === CHILD_COMPLETION_TOOL) {
275
+ if (policyError) {
276
+ return {
277
+ block: true,
278
+ reason: policyError,
279
+ };
280
+ }
281
+ freezeToolInput(event.input);
282
+ return;
283
+ }
284
+ if (!effectiveTools.has(event.toolName)) {
285
+ return {
286
+ block: true,
287
+ reason: `tool "${event.toolName}" is not enabled by subagent "${childAgent ?? 'unknown'}"`,
288
+ };
289
+ }
290
+
291
+ const authorization = authorizeToolCall(
292
+ event.toolName,
293
+ event.input as unknown as Record<string, unknown>,
294
+ policyStep(activePolicy),
295
+ pi.getAllTools(),
296
+ activePolicy.approvedBashCommands ?? [],
297
+ );
298
+ if (!authorization.allowed) {
299
+ return {
300
+ block: true,
301
+ reason: authorization.reason ?? 'Tool blocked by workflow child policy',
302
+ };
303
+ }
304
+ freezeToolInput(event.input);
305
+ });
306
+ }