@wichayutdew/pi-workflows 1.0.1 → 2.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 (131) hide show
  1. package/README.md +312 -223
  2. package/agents/step.md +4 -11
  3. package/dist/index.js +2554 -976
  4. package/examples/mr-comments.workflow.yaml +16 -8
  5. package/examples/prompts/mr-comments/implement.md +13 -9
  6. package/examples/prompts/mr-comments/plan.md +44 -15
  7. package/examples/prompts/mr-comments/verify.md +8 -7
  8. package/examples/settings.yaml +7 -1
  9. package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
  10. package/examples/starter-kit/mr-review.workflow.yaml +130 -0
  11. package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
  12. package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
  13. package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
  14. package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
  15. package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
  16. package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
  17. package/examples/starter-kit/steps/mr-review/publish.md +30 -0
  18. package/examples/starter-kit/steps/mr-review/review.md +69 -0
  19. package/examples/starter-kit/steps/mr-review/verify.md +24 -0
  20. package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
  21. package/examples/starter-kit/steps/ticket/implement.md +35 -0
  22. package/examples/starter-kit/steps/ticket/plan.md +40 -0
  23. package/examples/starter-kit/steps/ticket/verify.md +29 -0
  24. package/examples/starter-kit/steps/work/implement.md +34 -0
  25. package/examples/starter-kit/steps/work/plan.md +38 -0
  26. package/examples/starter-kit/steps/work/verify.md +30 -0
  27. package/examples/starter-kit/ticket.workflow.yaml +100 -0
  28. package/examples/starter-kit/work.workflow.yaml +97 -0
  29. package/package.json +3 -3
  30. package/schemas/workflow.schema.json +78 -44
  31. package/src/command-names.ts +1 -0
  32. package/src/commands.ts +33 -4
  33. package/src/config/ceiling.ts +6 -12
  34. package/src/config/load-workflows.ts +12 -1
  35. package/src/config/step-digests.ts +19 -0
  36. package/src/config/types.ts +17 -22
  37. package/src/config/validation/permissions.ts +5 -66
  38. package/src/config/validation/prompt.ts +1 -0
  39. package/src/config/validation/shared.ts +0 -2
  40. package/src/config/validation/step.ts +106 -2
  41. package/src/config/validation/workflow.ts +76 -0
  42. package/src/engine/create-run.ts +3 -0
  43. package/src/engine/gate-transitions.ts +58 -9
  44. package/src/engine/reconciliation-history.ts +61 -24
  45. package/src/engine/run-advance.ts +37 -4
  46. package/src/engine/run-lifecycle.ts +11 -0
  47. package/src/engine/run-reconciliation.ts +51 -21
  48. package/src/engine/run-validation.ts +235 -0
  49. package/src/engine/run-workflow-validation.ts +191 -0
  50. package/src/engine/state-types.ts +108 -5
  51. package/src/engine/state.ts +15 -1
  52. package/src/engine/step-trace.ts +495 -0
  53. package/src/engine/transitions.ts +1 -0
  54. package/src/harness/action-context.ts +31 -6
  55. package/src/harness/core-actions.ts +30 -1
  56. package/src/harness/delegation-control-actions.ts +27 -2
  57. package/src/harness/delegation-failure.ts +42 -9
  58. package/src/harness/delegation-plan.ts +96 -40
  59. package/src/harness/delegation-recovery-validation.ts +3 -1
  60. package/src/harness/delegation-response-actions.ts +73 -3
  61. package/src/harness/delegation-retry-policy.ts +5 -3
  62. package/src/harness/dependencies.ts +11 -2
  63. package/src/harness/gate-submission-action.ts +5 -31
  64. package/src/harness/lifecycle-actions.ts +4 -5
  65. package/src/harness/pause-actions.ts +10 -1
  66. package/src/harness/plannotator-result-actions.ts +20 -11
  67. package/src/harness/prompt-gate-actions.ts +40 -2
  68. package/src/harness/resume-action.ts +75 -1
  69. package/src/harness/start-actions.ts +77 -3
  70. package/src/harness/status-actions.ts +7 -17
  71. package/src/harness/step-effects.ts +61 -0
  72. package/src/harness/step-execution-actions.ts +181 -25
  73. package/src/harness/step-reporting.ts +168 -0
  74. package/src/harness/types.ts +1 -0
  75. package/src/harness/workspace-directory.ts +67 -0
  76. package/src/harness.ts +37 -9
  77. package/src/integrations/plannotator-requests.ts +40 -5
  78. package/src/integrations/subagents/child-policy-sections.ts +71 -84
  79. package/src/integrations/subagents/child-policy-types.ts +7 -7
  80. package/src/integrations/subagents/child-policy-validation.ts +10 -4
  81. package/src/integrations/subagents/child-runtime-completion.ts +1 -0
  82. package/src/integrations/subagents/child-runtime-files.ts +29 -86
  83. package/src/integrations/subagents/child-runtime-policy.ts +8 -33
  84. package/src/integrations/subagents/child-runtime.ts +3 -15
  85. package/src/integrations/subagents/delegated-result.ts +1 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +5 -0
  87. package/src/integrations/subagents/diagnostic-types.ts +7 -1
  88. package/src/integrations/subagents/diagnostics.ts +2 -0
  89. package/src/integrations/subagents/failure-correlation.ts +52 -2
  90. package/src/integrations/subagents/failure-transcript.ts +60 -1
  91. package/src/integrations/subagents/replay-audit.ts +0 -1
  92. package/src/integrations/subagents/replay-safety.ts +12 -27
  93. package/src/integrations/subagents/session-diagnostics.ts +102 -3
  94. package/src/policy/bash-authorization.ts +2 -21
  95. package/src/policy/bash-types.ts +0 -5
  96. package/src/policy/bash.ts +1 -2
  97. package/src/policy/tool-call-authorization.ts +1 -7
  98. package/src/preflight.ts +0 -5
  99. package/src/prompt/retry-task.ts +11 -3
  100. package/src/prompt/step-contract.ts +10 -56
  101. package/src/prompt/step-sections.ts +8 -10
  102. package/src/prompt/step-task.ts +41 -10
  103. package/src/prompt/template.ts +3 -2
  104. package/src/runtime/completion-tool.ts +14 -0
  105. package/src/runtime/main-step-completion.ts +3 -0
  106. package/src/runtime/main-step-policy.ts +2 -1
  107. package/src/runtime/main-step-runtime-types.ts +9 -1
  108. package/src/runtime/main-step-runtime.ts +2 -0
  109. package/src/runtime/main-step-state.ts +6 -0
  110. package/src/runtime/main-step-trace.ts +45 -0
  111. package/src/runtime/step-result.ts +57 -0
  112. package/src/step-log.ts +178 -0
  113. package/src/workflow-doctor.ts +254 -0
  114. package/src/workflow-status/format-status.ts +32 -3
  115. package/src/workflow-status/render-board.ts +7 -4
  116. package/src/workflow-status/render-path.ts +51 -24
  117. package/src/workflow-status/render-step-detail.ts +379 -0
  118. package/src/workflow-status/render-summary.ts +22 -0
  119. package/src/workflow-status/transcript-reader.ts +231 -0
  120. package/src/workflow-status/types.ts +8 -0
  121. package/src/workflow-status/view.ts +161 -14
  122. package/src/workflow-status.ts +6 -0
  123. package/src/policy/approved-command-extraction.ts +0 -71
  124. package/src/policy/approved-commands.ts +0 -15
  125. package/src/policy/bash-read-only.ts +0 -161
  126. package/src/policy/restricted-git.ts +0 -30
  127. package/src/policy/reviewed-artifact.ts +0 -97
  128. package/src/policy/reviewed-command-safety.ts +0 -150
  129. package/src/policy/reviewed-command-shape.ts +0 -49
  130. package/src/policy/reviewed-repository-contract.ts +0 -138
  131. package/src/policy/reviewed-repository-cwd.ts +0 -102
@@ -1,10 +1,5 @@
1
1
  import { basename } from 'node:path';
2
2
  import type { BashPermission, BashRule } from '../config/types.ts';
3
- import {
4
- authorizeHostedApiRead,
5
- authorizeReadOnlyBash,
6
- usesReadOnlyBashPreset,
7
- } from './bash-read-only.ts';
8
3
  import type { BashAuthorization } from './bash-types.ts';
9
4
  import { tokenizeRestrictedCommand } from './restricted-command.ts';
10
5
 
@@ -34,23 +29,15 @@ const matchesRule = (tokens: ReadonlyArray<string>, rule: BashRule): boolean =>
34
29
  /**
35
30
  * Authorizes a Bash command for a workflow step.
36
31
  *
37
- * Exact reviewed commands are considered first, followed by the configured
38
- * terminal mode and its static restrictions.
39
- *
40
32
  * @param command - Bash command text.
41
33
  * @param permission - Bash permission configured for the active step.
42
- * @param approvedCommands - Exact commands derived from a reviewed artifact.
43
34
  * @returns The authorization decision.
44
35
  */
45
36
  export const authorizeBash = (
46
37
  command: string,
47
38
  permission: BashPermission,
48
- approvedCommands: ReadonlyArray<string> = [],
49
39
  ): BashAuthorization => {
50
- const isReviewedCommand =
51
- (permission.approvedSources?.length ?? 0) > 0 &&
52
- approvedCommands.includes(command);
53
- if (isReviewedCommand || permission.mode === 'unrestricted') {
40
+ if (permission.mode === 'unrestricted') {
54
41
  return { allowed: true };
55
42
  }
56
43
  if (permission.mode === 'deny') {
@@ -70,17 +57,11 @@ export const authorizeBash = (
70
57
  return reject('environment assignments are not allowed in restricted mode');
71
58
  }
72
59
 
73
- if (permission.mode === 'read-only') {
74
- return authorizeReadOnlyBash(parsed.tokens);
75
- }
76
-
77
60
  const rule = permission.allow.find((candidate) =>
78
61
  matchesRule(parsed.tokens, candidate),
79
62
  );
80
63
  if (!rule) {
81
64
  return reject("command does not match this step's Bash allow-list");
82
65
  }
83
- return usesReadOnlyBashPreset(parsed.tokens)
84
- ? authorizeReadOnlyBash(parsed.tokens)
85
- : authorizeHostedApiRead(parsed.tokens);
66
+ return { allowed: true, tokens: [...parsed.tokens] };
86
67
  };
@@ -13,8 +13,3 @@ export type RestrictedCommandTokens =
13
13
  readonly tokens?: never;
14
14
  readonly error: string;
15
15
  };
16
-
17
- export type RestrictedGitCommand = {
18
- readonly subcommand: string;
19
- readonly subcommandIndex: number;
20
- };
@@ -1,4 +1,3 @@
1
1
  export { authorizeBash } from './bash-authorization.ts';
2
- export type { BashAuthorization, RestrictedGitCommand } from './bash-types.ts';
2
+ export type { BashAuthorization } from './bash-types.ts';
3
3
  export { tokenizeRestrictedCommand } from './restricted-command.ts';
4
- export { parseRestrictedGitCommand } from './restricted-git.ts';
@@ -19,7 +19,6 @@ const reject = (reason: string): ToolAuthorization => ({
19
19
  * @param input - Requested tool input.
20
20
  * @param step - Active workflow step.
21
21
  * @param inventory - Registered tool inventory.
22
- * @param approvedBashCommands - Exact commands derived from human review.
23
22
  * @returns The authorization decision.
24
23
  */
25
24
  export const authorizeToolCall = (
@@ -27,7 +26,6 @@ export const authorizeToolCall = (
27
26
  input: Readonly<Record<string, unknown>>,
28
27
  step: WorkflowStep,
29
28
  inventory: ReadonlyArray<ToolInventoryItem>,
30
- approvedBashCommands: ReadonlyArray<string> = [],
31
29
  ): ToolAuthorization => {
32
30
  if (toolName === 'mcp') {
33
31
  return authorizeMcpProxy(input, step.permissions.mcp);
@@ -47,11 +45,7 @@ export const authorizeToolCall = (
47
45
  if (typeof command !== 'string') {
48
46
  return reject('Bash call is missing command text');
49
47
  }
50
- const result = authorizeBash(
51
- command,
52
- step.permissions.bash,
53
- approvedBashCommands,
54
- );
48
+ const result = authorizeBash(command, step.permissions.bash);
55
49
  return result.allowed
56
50
  ? { allowed: true }
57
51
  : reject(result.reason ?? 'Bash command is not allowed');
package/src/preflight.ts CHANGED
@@ -87,11 +87,6 @@ export function preflightStep(
87
87
  hasResource: (toolName) => toolNames.has(toolName),
88
88
  resourceKind: 'tool',
89
89
  }),
90
- ...(step.permissions.mcp.length > 0 && !toolNames.has('mcp')
91
- ? [
92
- 'MCP selectors are configured, but the "mcp" proxy tool is not installed',
93
- ]
94
- : []),
95
90
  ...(isPlannotatorRequired && !hasExtension('plannotator')
96
91
  ? [
97
92
  'Plannotator is required by this gate, but its extension is not installed or detectable',
@@ -46,6 +46,9 @@ export function automaticRecoveryTask(
46
46
  maxAttempts: number,
47
47
  ): string {
48
48
  const diagnostics = serializeDiagnostics(reasons);
49
+ const missingStructuredOutput = reasons.some((reason) =>
50
+ reason.includes('Missing structured_output call'),
51
+ );
49
52
 
50
53
  return [
51
54
  '## Automatic recovery after subagent failure',
@@ -57,10 +60,15 @@ export function automaticRecoveryTask(
57
60
  '</pi-workflows-retry-diagnostic-v1>',
58
61
  '',
59
62
  'Diagnose and resolve the specific causes before completing the original step. Treat every listed approach as already attempted. When `Failed tool`, `Command` or `Arguments`, and `Tool error` are present, use them to choose a permitted alternative; do not repeat a failing call unchanged.',
60
- 'This is a continuation, not a blind replay. Inspect current repository and external state first, assume a prior call may already have applied its effect, and do not repeat a side effect that is already present.',
61
- 'Keep working after a successful recovery and complete the original step; do not return a pause outcome merely because the first call failed.',
63
+ 'This is a continuation, not a blind replay. Inspect current state first, assume a prior call may already have applied its effect, and do not repeat a side effect that is already present.',
64
+ 'Keep working after a successful recovery and complete the original step according to its configured prompt and outcomes.',
65
+ ...(missingStructuredOutput
66
+ ? [
67
+ 'A prior child ended without the required `structured_output` call. After completing the original work, call `structured_output` exactly once as the only tool call in its message; a prose final response does not complete this workflow step.',
68
+ ]
69
+ : []),
62
70
  'Use only tools enabled for this step. If the named tool is unavailable, use an enabled alternative. In restricted Bash modes, use one allowed command per tool call; do not use shell operators, substitutions, escapes in double quotes, environment assignments, or wrappers.',
63
- 'If no permitted alternative resolves the failure, follow the step outcome contract: use `retry` for another safe attempt or `replan` for an authority change when those outcomes are offered. Use a pause outcome only after those routes cannot resolve it, and include the exact failed call, exact error, alternatives attempted, and why they could not resolve it.',
71
+ 'If no permitted alternative resolves the failure, follow the step prompt when choosing a configured outcome; the engine assigns no special meaning to outcome names. Include the exact failed call, exact error, alternatives attempted, and observed state in the handoff.',
64
72
  ].join('\n');
65
73
  }
66
74
 
@@ -9,7 +9,7 @@ export type StepContract = {
9
9
  readonly outcomes: ReadonlyArray<string>;
10
10
  readonly transitionLines: string;
11
11
  readonly gateLine: string;
12
- readonly recoveryInstructions: ReadonlyArray<string>;
12
+ readonly workspaceLines: ReadonlyArray<string>;
13
13
  };
14
14
 
15
15
  type CreateStepContractOptions = {
@@ -18,57 +18,8 @@ type CreateStepContractOptions = {
18
18
  readonly step: WorkflowStep;
19
19
  };
20
20
 
21
- const pauseOutcomesFor = (
22
- step: WorkflowStep,
23
- allowedOutcomeSet: ReadonlySet<string>,
24
- ): ReadonlyArray<string> =>
25
- Object.entries(step.transitions)
26
- .filter(
27
- ([outcome, target]) =>
28
- target === '$pause' && allowedOutcomeSet.has(outcome),
29
- )
30
- .map(([outcome]) => outcome);
31
-
32
- type RecoveryInstructionsOptions = {
33
- readonly allowedOutcomeSet: ReadonlySet<string>;
34
- readonly pauseOutcomes: ReadonlyArray<string>;
35
- };
36
-
37
- const buildRecoveryInstructions = ({
38
- allowedOutcomeSet,
39
- pauseOutcomes,
40
- }: RecoveryInstructionsOptions): ReadonlyArray<string> => {
41
- const retryInstruction = allowedOutcomeSet.has('retry')
42
- ? [
43
- 'Use outcome `retry` when the execution contract remains valid and another bounded fresh attempt can safely continue from inspected state. Include the exact failure, attempts, observed state, and next alternative in `summary`.',
44
- ]
45
- : [];
46
- const replanInstruction = allowedOutcomeSet.has('replan')
47
- ? [
48
- 'Use outcome `replan` when recovery requires a material change to reviewed intent, commands, targets, or authority. Include the exact invalid contract evidence and proposed correction in `summary`.',
49
- ]
50
- : [];
51
-
52
- if (pauseOutcomes.length > 0) {
53
- return [
54
- ...retryInstruction,
55
- ...replanInstruction,
56
- `Use a pause outcome (${pauseOutcomes.join(', ')}) only when permitted alternatives and offered recovery outcomes cannot resolve the workflow definition, environment, or execution contract. Describe the exhausted recovery evidence declaratively in \`summary\`.`,
57
- ];
58
- }
59
- if (allowedOutcomeSet.has('retry') || allowedOutcomeSet.has('replan')) {
60
- return [...retryInstruction, ...replanInstruction];
61
- }
62
-
63
- return [
64
- ...retryInstruction,
65
- ...replanInstruction,
66
- 'If the workflow definition, environment, or final execution contract is wrong, do not fabricate success or call the completion tool; end with a concise declarative error so the harness pauses the step.',
67
- ];
68
- };
69
-
70
21
  /**
71
- * Derives the completion and recovery contract for an active workflow step.
22
+ * Derives the completion contract for an active workflow step.
72
23
  *
73
24
  * @param options - Workflow state used to resolve currently allowed outcomes.
74
25
  * @returns Immutable text fragments for the step prompt.
@@ -80,7 +31,6 @@ export function createStepContract({
80
31
  }: CreateStepContractOptions): StepContract {
81
32
  const outcomes = allowedOutcomes(workflow, run);
82
33
  const allowedOutcomeSet = new Set(outcomes);
83
- const pauseOutcomes = pauseOutcomesFor(step, allowedOutcomeSet);
84
34
  const transitionLines = Object.entries(step.transitions)
85
35
  .filter(([outcome]) => allowedOutcomeSet.has(outcome))
86
36
  .map(([outcome, target]) => `- ${outcome}: ${target}`)
@@ -88,14 +38,18 @@ export function createStepContract({
88
38
  const gateLine = step.gate
89
39
  ? `- ${step.gate.submitOutcome}: submit the artifact to ${step.gate.provider}; include the full artifact argument`
90
40
  : '';
41
+ const workspaceLines = step.workspace
42
+ ? [
43
+ `Workspace-binding outcomes: ${step.workspace.bindOn.join(', ')}`,
44
+ `For those outcomes, include \`workspace.cwd\` as an absolute directory under one allowed root relative to the run-start directory: ${step.workspace.allowedRoots.join(', ')}`,
45
+ 'For every other outcome, omit `workspace`.',
46
+ ]
47
+ : ['This step cannot bind a workspace; omit `workspace`.'];
91
48
 
92
49
  return {
93
50
  outcomes,
94
51
  transitionLines,
95
52
  gateLine,
96
- recoveryInstructions: buildRecoveryInstructions({
97
- allowedOutcomeSet,
98
- pauseOutcomes,
99
- }),
53
+ workspaceLines,
100
54
  };
101
55
  }
@@ -30,6 +30,11 @@ export function buildResourceSection({
30
30
  `Extension selectors: ${formatList(step.permissions.extensions)}`,
31
31
  `Skills: ${formatList(step.permissions.skills)}`,
32
32
  `Bash policy: ${step.permissions.bash.mode}`,
33
+ `Bash allow rules: ${
34
+ step.permissions.bash.allow.length > 0
35
+ ? JSON.stringify(step.permissions.bash.allow)
36
+ : '(none)'
37
+ }`,
33
38
  '',
34
39
  `Use only the listed skills for this step. Tool calls are enforced ${isDelegated ? 'inside this child process' : 'by the workflow harness'}.`,
35
40
  '',
@@ -59,17 +64,10 @@ export function buildDelegatedHandoffSection(
59
64
  * @param step - Active delegated workflow step.
60
65
  * @returns Delegated completion-guidance lines.
61
66
  */
62
- export function buildDelegatedCompletionInstructions(
63
- step: WorkflowStep,
64
- ): ReadonlyArray<string> {
65
- const finalContractInstruction = step.gate
66
- ? 'Put every unresolved decision in the gate artifact with evidence, options, a recommendation, and an adopted default; do not ask a terminal question.'
67
- : 'Treat the step instructions and incoming handoff as the final execution contract; do not ask a terminal question.';
68
-
67
+ export function buildDelegatedCompletionInstructions(): ReadonlyArray<string> {
69
68
  return [
70
69
  'This child is non-interactive. Never call `contact_supervisor`, `subagent_supervisor`, or `intercom`.',
71
- 'When a tool or command fails, inspect its exact error, diagnose the cause, and try a permitted semantically equivalent alternative before ending the step. Continue the original work after recovery; do not treat the first recoverable failure as terminal.',
72
- 'Never broaden mutation targets or external side effects while recovering. Before using a pause outcome, exhaust safe permitted alternatives and include the exact failed call, exact error, alternatives attempted, observed state, and why recovery is impossible.',
73
- finalContractInstruction,
70
+ 'Follow the step instructions when choosing one valid outcome; outcome names have no built-in domain meaning.',
71
+ 'Stay within the configured permissions and do not broaden mutation targets or external side effects.',
74
72
  ];
75
73
  }
@@ -45,6 +45,8 @@ type RenderStepPromptOptions = {
45
45
  readonly execution: StepExecution;
46
46
  };
47
47
 
48
+ const RESUME_INPUT_PLACEHOLDER = /\{\{\s*resume\.input\s*\}\}/;
49
+
48
50
  const renderStepPrompt = ({
49
51
  workflow,
50
52
  run,
@@ -53,20 +55,44 @@ const renderStepPrompt = ({
53
55
  }: RenderStepPromptOptions): string => {
54
56
  const promptTemplate = workflow.prompts[run.currentStepId] ?? '';
55
57
  const templateValues = createTemplateValues({ workflow, run, step });
56
- const hidesRepeatedHandoff =
58
+ const hidesRepeatedLastSummary =
57
59
  execution === 'delegated' &&
58
60
  /\{\{\s*last\.summary\s*\}\}/.test(promptTemplate);
59
- const values = hidesRepeatedHandoff
60
- ? {
61
- ...templateValues,
62
- 'last.summary':
63
- '(Provided once in the Previous step handoff section below.)',
64
- }
65
- : templateValues;
61
+ const handoffReference =
62
+ '(Provided once in the Previous step handoff section below.)';
63
+ const values = {
64
+ ...templateValues,
65
+ ...(hidesRepeatedLastSummary ? { 'last.summary': handoffReference } : {}),
66
+ };
66
67
 
67
68
  return renderTemplate(promptTemplate, values);
68
69
  };
69
70
 
71
+ const buildResumeInputSection = (
72
+ run: WorkflowRun,
73
+ promptContainsResumeInput: boolean,
74
+ ): ReadonlyArray<string> => {
75
+ if (!run.resumeInput) return [];
76
+ const authority =
77
+ 'The user-supplied resume guidance for this attempt is authoritative when it conflicts with task instructions in the step prompt or previous handoff. Inspect current state before applying it. It does not change the workflow graph or the YAML-enforced tools, MCP, extensions, skills, Bash policy, or workspace boundary.';
78
+ if (promptContainsResumeInput) {
79
+ return ['## Resume guidance authority', '', authority, ''];
80
+ }
81
+ const serialized = JSON.stringify({ input: run.resumeInput }, null, 2)
82
+ .replaceAll('<', '\\u003c')
83
+ .replaceAll('>', '\\u003e');
84
+ return [
85
+ '## User guidance supplied with `/workflow-resume`',
86
+ '',
87
+ authority,
88
+ '',
89
+ '<pi-workflows-resume-input-v1>',
90
+ serialized,
91
+ '</pi-workflows-resume-input-v1>',
92
+ '',
93
+ ];
94
+ };
95
+
70
96
  /**
71
97
  * Builds the complete task for either a main-agent or delegated workflow step.
72
98
  *
@@ -77,6 +103,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
77
103
  const { execution, workflow, run } = options;
78
104
  const step = resolveStep(workflow, run);
79
105
  const isDelegated = execution === 'delegated';
106
+ const promptTemplate = workflow.prompts[run.currentStepId] ?? '';
80
107
  const prompt = renderStepPrompt({ workflow, run, step, execution });
81
108
  const handoff = currentStepHandoff(run);
82
109
  const contract = createStepContract({ workflow, run, step });
@@ -105,6 +132,10 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
105
132
  prompt,
106
133
  '',
107
134
  ...(isDelegated ? buildDelegatedHandoffSection(handoff) : []),
135
+ ...buildResumeInputSection(
136
+ run,
137
+ RESUME_INPUT_PLACEHOLDER.test(promptTemplate),
138
+ ),
108
139
  ...buildResourceSection({ execution, step }),
109
140
  '## Completion contract',
110
141
  '',
@@ -112,11 +143,11 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
112
143
  `Valid outcomes: ${contract.outcomes.join(', ')}`,
113
144
  contract.transitionLines,
114
145
  contract.gateLine,
146
+ ...contract.workspaceLines,
115
147
  '',
116
148
  'Put a self-contained compact handoff in `summary`; this is the only step context passed to the next fresh child.',
117
- ...(isDelegated ? buildDelegatedCompletionInstructions(step) : []),
149
+ ...(isDelegated ? buildDelegatedCompletionInstructions() : []),
118
150
  'Do not call the completion tool alongside other tool calls.',
119
- ...contract.recoveryInstructions,
120
151
  ].join('\n');
121
152
  }
122
153
 
@@ -7,7 +7,7 @@ import type { WorkflowRun } from '../engine/state.ts';
7
7
  export type TemplateValues = Readonly<Record<string, string>>;
8
8
 
9
9
  /**
10
- * Combines the approved incoming handoff with the latest paused-attempt
10
+ * Combines the incoming handoff with the latest paused-attempt
11
11
  * summary without duplicating identical content.
12
12
  *
13
13
  * @param run - Current workflow run.
@@ -24,7 +24,7 @@ export function currentStepHandoff(run: WorkflowRun): string {
24
24
  }
25
25
 
26
26
  return [
27
- 'Incoming approved or previous-step handoff:',
27
+ 'Incoming previous-step handoff:',
28
28
  incomingHandoff,
29
29
  '',
30
30
  'Latest paused attempt:',
@@ -79,5 +79,6 @@ export function createTemplateValues({
79
79
  'reviewed.artifact': run.reviewedArtifact ?? '',
80
80
  'reviewed.feedback': run.reviewedFeedback ?? '',
81
81
  'gate.feedback': run.gateFeedback,
82
+ 'resume.input': run.resumeInput ?? '',
82
83
  };
83
84
  }
@@ -1,4 +1,5 @@
1
1
  import { Type } from 'typebox';
2
+ import { MAX_WORKSPACE_PATH_CHARS } from '../config/types.ts';
2
3
 
3
4
  export const WORKFLOW_COMPLETION_TOOL = 'workflow_complete_step';
4
5
 
@@ -20,6 +21,19 @@ export const WORKFLOW_COMPLETION_PARAMETERS = Type.Object(
20
21
  maxLength: 200_000,
21
22
  }),
22
23
  ),
24
+ workspace: Type.Optional(
25
+ Type.Object(
26
+ {
27
+ cwd: Type.String({
28
+ description:
29
+ 'Absolute working directory established for subsequent workflow steps',
30
+ minLength: 1,
31
+ maxLength: MAX_WORKSPACE_PATH_CHARS,
32
+ }),
33
+ },
34
+ { additionalProperties: false },
35
+ ),
36
+ ),
23
37
  },
24
38
  { additionalProperties: false },
25
39
  );
@@ -52,6 +52,9 @@ export function registerMainStepCompletion({
52
52
  ...(params.artifact !== undefined
53
53
  ? { artifact: params.artifact }
54
54
  : {}),
55
+ ...(params.workspace !== undefined
56
+ ? { workspace: { cwd: params.workspace.cwd } }
57
+ : {}),
55
58
  },
56
59
  activeExecution,
57
60
  );
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
2
  import { WORKFLOW_COMPLETION_TOOL } from './completion-tool.ts';
3
+ import { armMainStepTrace } from './main-step-trace.ts';
3
4
  import type {
4
5
  MainStepRuntimeDependencies,
5
6
  MainStepRuntimeState,
@@ -26,6 +27,7 @@ export function registerMainStepPolicy({
26
27
  });
27
28
 
28
29
  pi.on('message_end', (event) => {
30
+ armMainStepTrace(state, event.message);
29
31
  if (!state.active) {
30
32
  return;
31
33
  }
@@ -70,7 +72,6 @@ export function registerMainStepPolicy({
70
72
  Object.fromEntries(Object.entries(event.input)),
71
73
  state.active.step,
72
74
  pi.getAllTools(),
73
- state.active.approvedBashCommands,
74
75
  );
75
76
  if (!authorization.allowed) {
76
77
  return {
@@ -17,8 +17,14 @@ export type MainStepExecution = StepResultPolicy & {
17
17
  readonly runId: string;
18
18
  readonly stepId: string;
19
19
  readonly stepDigest: string;
20
+ /** Exact task whose finalized user message arms this attempt's trace. */
21
+ readonly task: string;
20
22
  readonly step: WorkflowStep;
21
- readonly approvedBashCommands: ReadonlyArray<string>;
23
+ /** Persists one finalized, already-redacted turn before Pi begins a later turn. */
24
+ readonly onTrace: (
25
+ lines: ReadonlyArray<string>,
26
+ context: ExtensionContext,
27
+ ) => Promise<void> | void;
22
28
  /** Handles the captured result after Pi fully settles the agent run. */
23
29
  readonly onSettled: (
24
30
  result: WorkflowStepResult | undefined,
@@ -60,5 +66,7 @@ export type MainStepRuntimeState = {
60
66
  active: MainStepExecution | undefined;
61
67
  pendingResult: WorkflowStepResult | undefined;
62
68
  invalidCompletionCalls: ReadonlySet<string>;
69
+ traceArmed: boolean;
70
+ traceClosed: boolean;
63
71
  isSuspended: boolean;
64
72
  };
@@ -5,6 +5,7 @@ import { authorizeToolCall, resolveActiveTools } from '../policy/tools.ts';
5
5
  import { registerMainStepCompletion } from './main-step-completion.ts';
6
6
  import { registerMainStepLifecycle } from './main-step-lifecycle.ts';
7
7
  import { registerMainStepPolicy } from './main-step-policy.ts';
8
+ import { registerMainStepTrace } from './main-step-trace.ts';
8
9
  import type {
9
10
  MainStepExecution,
10
11
  MainStepRuntimeController,
@@ -84,6 +85,7 @@ export function createMainStepRuntime({
84
85
 
85
86
  registerMainStepLifecycle({ pi, state });
86
87
  registerMainStepPolicy({ pi, state, dependencies });
88
+ registerMainStepTrace({ pi, state });
87
89
  registerMainStepCompletion({ pi, state, dependencies });
88
90
 
89
91
  return {
@@ -14,6 +14,8 @@ export function createMainStepRuntimeState(): MainStepRuntimeState {
14
14
  active: undefined,
15
15
  pendingResult: undefined,
16
16
  invalidCompletionCalls: new Set(),
17
+ traceArmed: false,
18
+ traceClosed: false,
17
19
  isSuspended: false,
18
20
  };
19
21
  }
@@ -43,6 +45,8 @@ export function activateMainStep({
43
45
  state.active = execution;
44
46
  state.pendingResult = undefined;
45
47
  state.invalidCompletionCalls = new Set();
48
+ state.traceArmed = false;
49
+ state.traceClosed = false;
46
50
  }
47
51
 
48
52
  /**
@@ -56,6 +60,8 @@ export function deactivateMainStep(state: MainStepRuntimeState): boolean {
56
60
  state.active = undefined;
57
61
  state.pendingResult = undefined;
58
62
  state.invalidCompletionCalls = new Set();
63
+ state.traceArmed = false;
64
+ state.traceClosed = false;
59
65
  return wasActive;
60
66
  }
61
67
 
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { stepLogLinesFromTurn, textOnlyUserMessage } from '../step-log.ts';
3
+ import type { MainStepRuntimeState } from './main-step-runtime-types.ts';
4
+
5
+ type RegisterMainStepTraceOptions = {
6
+ readonly pi: ExtensionAPI;
7
+ readonly state: MainStepRuntimeState;
8
+ };
9
+
10
+ /** Arms a trace only after Pi finalizes the exact extension-supplied task. */
11
+ export function armMainStepTrace(
12
+ state: MainStepRuntimeState,
13
+ message: unknown,
14
+ ): void {
15
+ if (!state.active || state.traceArmed || state.traceClosed) return;
16
+ if (textOnlyUserMessage(message) === state.active.task) {
17
+ state.traceArmed = true;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Persists finalized main-agent turns while one exact workflow attempt is armed.
23
+ *
24
+ * Successful completion is closed only after its assistant call and tool result
25
+ * are captured, preventing queued parent-session follow-ups from leaking in.
26
+ */
27
+ export function registerMainStepTrace({
28
+ pi,
29
+ state,
30
+ }: RegisterMainStepTraceOptions): void {
31
+ pi.on('turn_end', async (event, context) => {
32
+ const active = state.active;
33
+ if (!active || !state.traceArmed || state.traceClosed) return;
34
+ const lines = stepLogLinesFromTurn(event.message, event.toolResults);
35
+ try {
36
+ if (lines.length > 0) await active.onTrace(lines, context);
37
+ } catch {
38
+ // Status evidence is best-effort and must never interrupt the agent loop.
39
+ } finally {
40
+ if (state.active === active && state.pendingResult) {
41
+ state.traceClosed = true;
42
+ }
43
+ }
44
+ });
45
+ }
@@ -1,3 +1,9 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import {
3
+ MAX_WORKSPACE_PATH_CHARS,
4
+ type StepWorkspaceBinding,
5
+ } from '../config/types.ts';
6
+
1
7
  const MAX_ARTIFACT_CHARS = 200_000;
2
8
  const RESULT_KEYS = new Set([
3
9
  'version',
@@ -5,6 +11,7 @@ const RESULT_KEYS = new Set([
5
11
  'outcome',
6
12
  'summary',
7
13
  'artifact',
14
+ 'workspace',
8
15
  ]);
9
16
 
10
17
  /**
@@ -15,6 +22,11 @@ export type StepResultPolicy = {
15
22
  readonly outcomes: ReadonlyArray<string>;
16
23
  readonly summaryMaxChars: number;
17
24
  readonly gateSubmitOutcome?: string;
25
+ readonly workspace?: StepWorkspaceBinding;
26
+ };
27
+
28
+ export type WorkflowResultWorkspace = {
29
+ readonly cwd: string;
18
30
  };
19
31
 
20
32
  /**
@@ -26,12 +38,51 @@ export type WorkflowStepResult = {
26
38
  readonly outcome: string;
27
39
  readonly summary: string;
28
40
  readonly artifact?: string;
41
+ readonly workspace?: WorkflowResultWorkspace;
29
42
  };
30
43
 
31
44
  const isObject = (value: unknown): value is Record<string, unknown> => {
32
45
  return value !== null && typeof value === 'object' && !Array.isArray(value);
33
46
  };
34
47
 
48
+ function parseResultWorkspace(
49
+ value: unknown,
50
+ outcome: string,
51
+ policy: StepResultPolicy,
52
+ ): WorkflowResultWorkspace | undefined {
53
+ const requiresWorkspace = policy.workspace?.bindOn.includes(outcome) === true;
54
+ if (!requiresWorkspace) {
55
+ if (value !== undefined) {
56
+ throw new Error('workflow step workspace is forbidden for this outcome');
57
+ }
58
+ return undefined;
59
+ }
60
+ if (!isObject(value)) {
61
+ throw new Error(
62
+ `workflow step outcome "${outcome}" requires workspace.cwd`,
63
+ );
64
+ }
65
+ const unknownKey = Object.keys(value).find((key) => key !== 'cwd');
66
+ if (unknownKey) {
67
+ throw new Error(
68
+ `workflow step workspace has unknown property "${unknownKey}"`,
69
+ );
70
+ }
71
+ if (typeof value.cwd !== 'string') {
72
+ throw new Error('workflow step workspace cwd must be a string');
73
+ }
74
+ const cwd = value.cwd;
75
+ if (!cwd || cwd.includes('\0') || !isAbsolute(cwd)) {
76
+ throw new Error('workflow step workspace cwd must be an absolute path');
77
+ }
78
+ if (cwd.length > MAX_WORKSPACE_PATH_CHARS) {
79
+ throw new Error(
80
+ `workflow step workspace cwd exceeds ${MAX_WORKSPACE_PATH_CHARS} characters`,
81
+ );
82
+ }
83
+ return { cwd };
84
+ }
85
+
35
86
  /**
36
87
  * Validates and normalizes the structured result returned by a workflow step.
37
88
  *
@@ -96,11 +147,17 @@ export function parseWorkflowStepResult(
96
147
  ) {
97
148
  throw new Error('workflow gate outcome requires a non-empty artifact');
98
149
  }
150
+ const workspace = parseResultWorkspace(
151
+ value.workspace,
152
+ value.outcome,
153
+ policy,
154
+ );
99
155
  return {
100
156
  version: 1,
101
157
  policyDigest: policy.policyDigest,
102
158
  outcome: value.outcome,
103
159
  summary,
104
160
  ...(artifact !== undefined ? { artifact } : {}),
161
+ ...(workspace ? { workspace } : {}),
105
162
  };
106
163
  }