@wichayutdew/pi-workflows 0.2.2 → 0.3.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.
package/src/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { defaultUserWorkflowDirectory, loadSettings } from './config/load.ts';
2
3
  import { WorkflowHarness } from './harness.ts';
3
4
  import { registerSubagentChildRuntime } from './integrations/subagents/child-runtime.ts';
4
5
  import { isSubagentRuntimeName } from './integrations/subagents/protocol.ts';
5
6
 
6
- export default function piWorkflowsExtension(pi: ExtensionAPI): void {
7
+ export default async function piWorkflowsExtension(
8
+ pi: ExtensionAPI,
9
+ ): Promise<void> {
7
10
  if (process.env.PI_SUBAGENT_CHILD === '1') {
8
11
  const childAgent = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
9
12
  if (isSubagentRuntimeName(childAgent)) {
@@ -11,5 +14,6 @@ export default function piWorkflowsExtension(pi: ExtensionAPI): void {
11
14
  }
12
15
  return;
13
16
  }
14
- new WorkflowHarness(pi);
17
+ const { settings } = await loadSettings(defaultUserWorkflowDirectory());
18
+ new WorkflowHarness(pi, settings.statusShortcut);
15
19
  }
@@ -33,22 +33,23 @@ export async function requestPromptGateReview(
33
33
  return { status: 'dismissed' };
34
34
  }
35
35
 
36
- while (true) {
37
- const feedback = await ui.input(
36
+ let feedback = await ui.input(
37
+ 'Workflow review feedback',
38
+ 'Describe the required changes',
39
+ ...(signal ? [{ signal }] : []),
40
+ );
41
+ while (feedback !== undefined && !feedback.trim()) {
42
+ ui.notify('Feedback cannot be empty', 'warning');
43
+ feedback = await ui.input(
38
44
  'Workflow review feedback',
39
45
  'Describe the required changes',
40
46
  ...(signal ? [{ signal }] : []),
41
47
  );
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
48
  }
49
+ if (feedback === undefined) return { status: 'dismissed' };
50
+ return {
51
+ status: 'resolved',
52
+ approved: false,
53
+ feedback: feedback.trim(),
54
+ };
54
55
  }
@@ -1,20 +1,20 @@
1
1
  import { randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import {
3
3
  existsSync,
4
+ lstatSync,
4
5
  readFileSync,
6
+ realpathSync,
5
7
  renameSync,
8
+ statSync,
6
9
  unlinkSync,
7
10
  writeFileSync,
8
11
  } from 'node:fs';
12
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
9
13
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
14
  import type { WorkflowStep } from '../../config/types.ts';
11
15
  import { invalidCompletionCallIds } from '../../policy/completion-batch.ts';
12
16
  import { freezeToolInput } from '../../policy/immutable-input.ts';
13
17
  import { authorizeToolCall, resolveActiveTools } from '../../policy/tools.ts';
14
- import {
15
- WORKFLOW_COMPLETION_PARAMETERS,
16
- WORKFLOW_COMPLETION_TOOL,
17
- } from '../../runtime/completion-tool.ts';
18
18
  import {
19
19
  extractChildPolicy,
20
20
  isSubagentRuntimeName,
@@ -22,7 +22,14 @@ import {
22
22
  type ChildStepPolicy,
23
23
  } from './protocol.ts';
24
24
 
25
- export const CHILD_COMPLETION_TOOL = WORKFLOW_COMPLETION_TOOL;
25
+ export const CHILD_COMPLETION_TOOL = 'structured_output';
26
+ const CHILD_COORDINATION_TOOLS = new Set([
27
+ 'contact_supervisor',
28
+ 'subagent_supervisor',
29
+ 'intercom',
30
+ ]);
31
+ const STRUCTURED_RESULT_KEYS = new Set(['outcome', 'summary', 'artifact']);
32
+ const FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
26
33
 
27
34
  function policyStep(policy: ChildStepPolicy): WorkflowStep {
28
35
  return {
@@ -33,6 +40,7 @@ function policyStep(policy: ChildStepPolicy): WorkflowStep {
33
40
  context: 'fresh',
34
41
  timeoutMs: 900_000,
35
42
  artifacts: false,
43
+ retryToolFailures: false,
36
44
  },
37
45
  permissions: policy.permissions,
38
46
  requires: { tools: [], extensions: [], skills: [] },
@@ -41,6 +49,7 @@ function policyStep(policy: ChildStepPolicy): WorkflowStep {
41
49
  }
42
50
 
43
51
  function childSystemPrompt(policy: ChildStepPolicy): string {
52
+ const hasPauseOutcome = policy.pauseOutcomes.length > 0;
44
53
  return [
45
54
  '# Pi Workflows delegated step',
46
55
  '',
@@ -50,15 +59,50 @@ function childSystemPrompt(policy: ChildStepPolicy): string {
50
59
  '',
51
60
  'The parent workflow harness owns orchestration and state transitions.',
52
61
  '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.',
62
+ 'When finished, call `structured_output` exactly once and as the only tool call in that message.',
63
+ 'Pass the workflow result as its `value`: outcome, summary, and optional artifact.',
54
64
  `Valid outcomes: ${policy.outcomes.join(', ')}`,
65
+ `Pause outcomes: ${policy.pauseOutcomes.join(', ') || '(none)'}`,
55
66
  `Summary limit: ${policy.summaryMaxChars} characters`,
56
67
  ...(policy.gateSubmitOutcome
57
68
  ? [
58
69
  `Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`,
59
70
  ]
60
71
  : []),
61
- 'If the workflow definition or environment is wrong, choose an outcome that transitions to $pause.',
72
+ ...(hasPauseOutcome
73
+ ? [
74
+ `If the workflow definition or environment is wrong, choose a pause outcome (${policy.pauseOutcomes.join(', ')}).`,
75
+ ]
76
+ : [
77
+ 'If the workflow definition or environment is wrong, do not fabricate success or call the completion tool; end with a concise declarative error so the parent pauses the step.',
78
+ ]),
79
+ 'This is a non-interactive workflow child. Never call contact_supervisor, subagent_supervisor, or intercom.',
80
+ ...(policy.gateSubmitOutcome
81
+ ? [
82
+ 'Put every unresolved decision in the gate artifact with evidence, options, a recommendation, and an adopted default; do not ask a terminal question.',
83
+ ]
84
+ : hasPauseOutcome
85
+ ? [
86
+ 'Treat the step instructions and incoming handoff as the final execution contract.',
87
+ 'If that contract is missing, stale, or contradictory, finish with a pause outcome and describe the unresolved contract and evidence declaratively in the summary; do not ask a terminal question.',
88
+ ]
89
+ : [
90
+ 'Treat the step instructions and incoming handoff as the final execution contract.',
91
+ 'If that contract is missing, stale, or contradictory, do not fabricate success or call the completion tool; end with a concise declarative error so the parent pauses the step. Do not ask a terminal question.',
92
+ ]),
93
+ ...(policy.repositoryCwd
94
+ ? [
95
+ `Reviewed repository root: ${policy.repositoryCwd}`,
96
+ ...(policy.bootstrapCwd
97
+ ? [
98
+ `Bootstrap directory: ${policy.bootstrapCwd}`,
99
+ 'The reviewed repository root does not exist yet. Run only its exact approved setup command first, then use absolute paths under the reviewed repository root for every edit and write. Never mutate the bootstrap directory.',
100
+ ]
101
+ : [
102
+ 'Keep every edit and write inside the reviewed repository root.',
103
+ ]),
104
+ ]
105
+ : []),
62
106
  ].join('\n');
63
107
  }
64
108
 
@@ -84,6 +128,43 @@ function writeResult(policy: ChildStepPolicy, result: unknown): void {
84
128
  }
85
129
  }
86
130
 
131
+ function structuredResult(
132
+ input: unknown,
133
+ policy: ChildStepPolicy,
134
+ ): ReturnType<typeof parseDelegatedStepResult> {
135
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
136
+ throw new Error('structured_output input must be an object');
137
+ }
138
+ const wrapper = input as Record<string, unknown>;
139
+ if (Object.keys(wrapper).length !== 1 || !Object.hasOwn(wrapper, 'value')) {
140
+ throw new Error('structured_output input must contain only value');
141
+ }
142
+ if (
143
+ wrapper.value === null ||
144
+ typeof wrapper.value !== 'object' ||
145
+ Array.isArray(wrapper.value)
146
+ ) {
147
+ throw new Error('structured_output value must be an object');
148
+ }
149
+ const value = wrapper.value as Record<string, unknown>;
150
+ const unknownKey = Object.keys(value).find(
151
+ (key) => !STRUCTURED_RESULT_KEYS.has(key),
152
+ );
153
+ if (unknownKey) {
154
+ throw new Error(
155
+ `structured_output value has unknown property "${unknownKey}"`,
156
+ );
157
+ }
158
+ return parseDelegatedStepResult(
159
+ {
160
+ version: 1,
161
+ policyDigest: policy.policyDigest,
162
+ ...value,
163
+ },
164
+ policy,
165
+ );
166
+ }
167
+
87
168
  function verifyCapability(
88
169
  policy: ChildStepPolicy,
89
170
  childAgent: string | undefined,
@@ -104,6 +185,67 @@ function verifyCapability(
104
185
  unlinkSync(policy.capabilityPath);
105
186
  }
106
187
 
188
+ function authorizeRepositoryMutation(
189
+ toolName: string,
190
+ input: Record<string, unknown>,
191
+ policy: ChildStepPolicy,
192
+ ): string | undefined {
193
+ if (!policy.repositoryCwd || !FILE_MUTATION_TOOLS.has(toolName)) return;
194
+ if (typeof input.path !== 'string' || !input.path.trim()) {
195
+ return `${toolName} must name a path inside the reviewed repository root`;
196
+ }
197
+ const candidate = resolve(process.cwd(), input.path);
198
+ const root = resolve(policy.repositoryCwd);
199
+ if (!pathIsInside(root, candidate)) {
200
+ return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
201
+ }
202
+ let canonicalRoot: string;
203
+ try {
204
+ if (!statSync(root).isDirectory()) throw new Error('not a directory');
205
+ canonicalRoot = realpathSync(root);
206
+ } catch {
207
+ return `reviewed repository root is not an existing directory: ${policy.repositoryCwd}`;
208
+ }
209
+ const canonicalAncestor = nearestCanonicalAncestor(candidate);
210
+ if (
211
+ canonicalAncestor === undefined ||
212
+ !pathIsInside(canonicalRoot, canonicalAncestor)
213
+ ) {
214
+ return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
215
+ }
216
+ return;
217
+ }
218
+
219
+ function pathIsInside(root: string, candidate: string): boolean {
220
+ const fromRoot = relative(root, candidate);
221
+ return (
222
+ fromRoot !== '..' &&
223
+ !fromRoot.startsWith(`..${sep}`) &&
224
+ !isAbsolute(fromRoot)
225
+ );
226
+ }
227
+
228
+ function nearestCanonicalAncestor(path: string): string | undefined {
229
+ let candidate = path;
230
+ while (true) {
231
+ try {
232
+ lstatSync(candidate);
233
+ } catch (error) {
234
+ const code = (error as { code?: unknown }).code;
235
+ if (code !== 'ENOENT') return undefined;
236
+ const parent = dirname(candidate);
237
+ if (parent === candidate) return undefined;
238
+ candidate = parent;
239
+ continue;
240
+ }
241
+ try {
242
+ return realpathSync(candidate);
243
+ } catch {
244
+ return undefined;
245
+ }
246
+ }
247
+ }
248
+
107
249
  export interface SubagentChildRuntimeOptions {
108
250
  childAgent?: string;
109
251
  }
@@ -116,61 +258,9 @@ export function registerSubagentChildRuntime(
116
258
  let policyError: string | undefined;
117
259
  let invalidCompletionCalls = new Set<string>();
118
260
  let effectiveTools = new Set<string>();
119
- let completionRegistered = false;
120
261
  const childAgent =
121
262
  options.childAgent ?? process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
122
263
 
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
264
  pi.on('input', (event) => {
175
265
  let extracted;
176
266
  try {
@@ -198,18 +288,19 @@ export function registerSubagentChildRuntime(
198
288
  try {
199
289
  verifyCapability(extracted.policy, childAgent);
200
290
  const profileTools = new Set(pi.getActiveTools());
291
+ if (!profileTools.has(CHILD_COMPLETION_TOOL)) {
292
+ throw new Error(
293
+ 'pi-subagents structured_output completion is unavailable',
294
+ );
295
+ }
201
296
  activePolicy = extracted.policy;
202
297
  policyError = undefined;
203
- registerCompletionTool();
204
298
  effectiveTools = new Set(
205
299
  resolveActiveTools(
206
300
  pi.getAllTools(),
207
301
  policyStep(activePolicy),
208
302
  CHILD_COMPLETION_TOOL,
209
- ).filter(
210
- (toolName) =>
211
- toolName === CHILD_COMPLETION_TOOL || profileTools.has(toolName),
212
- ),
303
+ ).filter((toolName) => !CHILD_COORDINATION_TOOLS.has(toolName)),
213
304
  );
214
305
  } catch (error) {
215
306
  policyError = error instanceof Error ? error.message : String(error);
@@ -278,16 +369,25 @@ export function registerSubagentChildRuntime(
278
369
  reason: policyError,
279
370
  };
280
371
  }
281
- freezeToolInput(event.input);
282
- return;
372
+ try {
373
+ const result = structuredResult(event.input, activePolicy);
374
+ writeResult(activePolicy, result);
375
+ freezeToolInput(event.input);
376
+ return;
377
+ } catch (error) {
378
+ return {
379
+ block: true,
380
+ reason: error instanceof Error ? error.message : String(error),
381
+ };
382
+ }
283
383
  }
284
- if (!effectiveTools.has(event.toolName)) {
384
+ if (CHILD_COORDINATION_TOOLS.has(event.toolName)) {
285
385
  return {
286
386
  block: true,
287
- reason: `tool "${event.toolName}" is not enabled by subagent "${childAgent ?? 'unknown'}"`,
387
+ reason:
388
+ 'workflow children are non-interactive; use structured_output with a pause outcome and describe the unresolved contract in summary',
288
389
  };
289
390
  }
290
-
291
391
  const authorization = authorizeToolCall(
292
392
  event.toolName,
293
393
  event.input as unknown as Record<string, unknown>,
@@ -301,6 +401,24 @@ export function registerSubagentChildRuntime(
301
401
  reason: authorization.reason ?? 'Tool blocked by workflow child policy',
302
402
  };
303
403
  }
404
+ if (!effectiveTools.has(event.toolName)) {
405
+ return {
406
+ block: true,
407
+ reason: `tool "${event.toolName}" is allowed by the workflow but unavailable in this child runtime`,
408
+ };
409
+ }
410
+
411
+ const mutationError = authorizeRepositoryMutation(
412
+ event.toolName,
413
+ event.input as unknown as Record<string, unknown>,
414
+ activePolicy,
415
+ );
416
+ if (mutationError) {
417
+ return {
418
+ block: true,
419
+ reason: mutationError,
420
+ };
421
+ }
304
422
  freezeToolInput(event.input);
305
423
  });
306
424
  }
@@ -41,6 +41,7 @@ const DELEGATION_STATUSES = new Set<SubagentDelegationStatus>([
41
41
  'interrupted',
42
42
  'turn_budget_exhausted',
43
43
  'tool_budget_exhausted',
44
+ 'structured_output_failed',
44
45
  'acceptance_failed',
45
46
  'invalid_request',
46
47
  'unavailable_context',
@@ -105,9 +106,9 @@ export class SubagentDelegationClient {
105
106
  return Promise.reject(new Error('subagent delegation was cancelled'));
106
107
  }
107
108
 
108
- let start: () => void = () => undefined;
109
- let requestCancellation: () => void = () => undefined;
110
- let resolveTerminal: () => void = () => undefined;
109
+ let start!: () => void;
110
+ let requestCancellation!: () => void;
111
+ let resolveTerminal!: () => void;
111
112
  const terminal = new Promise<void>((resolve) => {
112
113
  resolveTerminal = resolve;
113
114
  });