@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
@@ -7,7 +7,7 @@ import type { WorkflowCatalog } from '../config/types.ts';
7
7
  import type { WorkflowRun } from '../engine/state.ts';
8
8
  import type { MainStepRuntimeController } from '../runtime/main-step-runtime.ts';
9
9
  import {
10
- workflowStatusIcon,
10
+ formatWorkflowProgressStatus,
11
11
  type WorkflowStatusExecution,
12
12
  type WorkflowStatusSnapshot,
13
13
  } from '../workflow-status.ts';
@@ -45,7 +45,6 @@ export type StatusActions = {
45
45
  refreshStatusWhileRunning: (this: StatusContext) => void;
46
46
  stopStatusRefresh: (this: StatusContext) => void;
47
47
  registerWorkflowStatusShortcut: (this: StatusContext) => void;
48
- openWorkflowStatus: (this: StatusContext, context: ExtensionContext) => void;
49
48
  showWorkflowStatus: (
50
49
  this: StatusContext,
51
50
  context: ExtensionContext,
@@ -88,13 +87,17 @@ function updateStatus(this: StatusContext): void {
88
87
  return;
89
88
  }
90
89
  const snapshot = this.workflowStatusSnapshot();
91
- if (this.run.status !== 'running') {
90
+ if (this.run.status !== 'running' && this.run.status !== 'awaiting-gate') {
91
+ this.latestContext.ui.setStatus(STATUS_KEY, undefined);
92
+ return;
93
+ }
94
+ if (!snapshot) {
92
95
  this.latestContext.ui.setStatus(STATUS_KEY, undefined);
93
96
  return;
94
97
  }
95
98
  this.latestContext.ui.setStatus(
96
99
  STATUS_KEY,
97
- `${workflowStatusIcon(this.run, snapshot?.now)} ${this.run.workflowId}: working · ${this.statusShortcutLabel}`,
100
+ formatWorkflowProgressStatus(snapshot, this.statusShortcutLabel),
98
101
  );
99
102
  }
100
103
 
@@ -135,18 +138,6 @@ function registerWorkflowStatusShortcut(this: StatusContext): void {
135
138
  });
136
139
  }
137
140
 
138
- function openWorkflowStatus(
139
- this: StatusContext,
140
- context: ExtensionContext,
141
- ): void {
142
- void this.showWorkflowStatus(context).catch((error: unknown) => {
143
- context.ui.notify(
144
- `Cannot open workflow status: ${error instanceof Error ? error.message : String(error)}`,
145
- 'error',
146
- );
147
- });
148
- }
149
-
150
141
  async function showWorkflowStatus(
151
142
  this: StatusContext,
152
143
  context: ExtensionContext,
@@ -185,7 +176,6 @@ export function createStatusActions(): StatusActions {
185
176
  refreshStatusWhileRunning,
186
177
  stopStatusRefresh,
187
178
  registerWorkflowStatusShortcut,
188
- openWorkflowStatus,
189
179
  showWorkflowStatus,
190
180
  };
191
181
  }
@@ -0,0 +1,61 @@
1
+ import type { WorkflowStep } from '../config/types.ts';
2
+ import type { WorkflowRun } from '../engine/state.ts';
3
+ import type { RunStepEffects } from '../engine/run-advance.ts';
4
+ import type { WorkflowStepResult } from '../runtime/step-result.ts';
5
+ import type { WorkflowHarnessDependencies } from './dependencies.ts';
6
+
7
+ type StepEffectDependencies = Pick<
8
+ WorkflowHarnessDependencies,
9
+ 'resolveWorkspaceDirectory'
10
+ >;
11
+
12
+ const boundWorkspaceCwd = (run: WorkflowRun): string | undefined => {
13
+ for (let index = run.history.length - 1; index >= 0; index -= 1) {
14
+ const cwd = run.history[index]?.workspaceCwd;
15
+ if (cwd) return cwd;
16
+ }
17
+ return undefined;
18
+ };
19
+
20
+ /**
21
+ * Validates declarative effects carried by a structured step result.
22
+ *
23
+ * No prompt text, summary, artifact, command, or repository convention is
24
+ * interpreted here.
25
+ */
26
+ export function resolveStepEffects(
27
+ run: WorkflowRun,
28
+ step: WorkflowStep,
29
+ result: WorkflowStepResult,
30
+ dependencies: StepEffectDependencies,
31
+ ): RunStepEffects {
32
+ const shouldBind = step.workspace?.bindOn.includes(result.outcome) ?? false;
33
+ if (shouldBind && !result.workspace) {
34
+ throw new Error(
35
+ `step outcome "${result.outcome}" requires a workspace result`,
36
+ );
37
+ }
38
+ if (!shouldBind && result.workspace) {
39
+ throw new Error(
40
+ `step outcome "${result.outcome}" is not allowed to bind a workspace`,
41
+ );
42
+ }
43
+ if (!result.workspace || !step.workspace) return {};
44
+ const startCwd = run.startCwd ?? run.cwd;
45
+ if (!startCwd) {
46
+ throw new Error(
47
+ 'workflow run has no starting directory for workspace validation',
48
+ );
49
+ }
50
+
51
+ const workspaceCwd = dependencies.resolveWorkspaceDirectory({
52
+ candidateCwd: result.workspace.cwd,
53
+ startCwd,
54
+ allowedRoots: step.workspace.allowedRoots,
55
+ });
56
+ const existingCwd = boundWorkspaceCwd(run);
57
+ if (existingCwd && existingCwd !== workspaceCwd) {
58
+ throw new Error(`workflow workspace is already bound to "${existingCwd}"`);
59
+ }
60
+ return { workspaceCwd };
61
+ }
@@ -1,16 +1,22 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { LoadedWorkflow, WorkflowStep } from '../config/types.ts';
3
3
  import type { WorkflowRun } from '../engine/state.ts';
4
+ import {
5
+ appendMainStepLog,
6
+ beginMainStepAttempt,
7
+ beginSubagentStepAttempt,
8
+ recordCurrentStepResult,
9
+ } from '../engine/step-trace.ts';
4
10
  import { advanceRun, allowedOutcomes } from '../engine/transitions.ts';
5
11
  import { digest } from '../digest.ts';
6
12
  import type { SubagentDelegationResponse } from '../integrations/subagents/protocol.ts';
7
13
  import { buildMainStepTask } from '../prompt.ts';
8
- import { narrowApprovedBashCommands } from '../policy/approved-commands.ts';
9
14
  import type { MainStepExecution } from '../runtime/main-step-runtime.ts';
10
15
  import type { WorkflowStepResult } from '../runtime/step-result.ts';
11
16
  import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
12
17
  import { createDelegationPlan } from './delegation-plan.ts';
13
18
  import type { DelegationRecovery, MainStepIdentity } from './types.ts';
19
+ import { resolveStepEffects } from './step-effects.ts';
14
20
 
15
21
  type HarnessActionContext = Pick<
16
22
  FullHarnessActionContext,
@@ -25,10 +31,13 @@ type HarnessActionContext = Pick<
25
31
  | 'mainSteps'
26
32
  | 'mutationQueue'
27
33
  | 'pauseForExecutionFailure'
34
+ | 'persist'
28
35
  | 'pi'
29
36
  | 'queueDelegationFailure'
30
37
  | 'queueDelegationResponse'
38
+ | 'queueMainStepLog'
31
39
  | 'queueMainStepResult'
40
+ | 'recordMainStepLog'
32
41
  | 'run'
33
42
  | 'sessionEpoch'
34
43
  | 'settleAfterTransition'
@@ -49,6 +58,18 @@ export type StepExecutionActions = {
49
58
  run: WorkflowRun,
50
59
  step: WorkflowStep,
51
60
  ) => void;
61
+ queueMainStepLog: (
62
+ this: HarnessActionContext,
63
+ identity: MainStepIdentity,
64
+ lines: ReadonlyArray<string>,
65
+ context: ExtensionContext,
66
+ ) => Promise<void>;
67
+ recordMainStepLog: (
68
+ this: HarnessActionContext,
69
+ identity: MainStepIdentity,
70
+ lines: ReadonlyArray<string>,
71
+ context: ExtensionContext,
72
+ ) => Promise<void>;
52
73
  queueMainStepResult: (
53
74
  this: HarnessActionContext,
54
75
  identity: MainStepIdentity,
@@ -107,6 +128,18 @@ function launchCurrentStep(
107
128
  }
108
129
  const { active, request } = plan;
109
130
 
131
+ try {
132
+ this.run = beginSubagentStepAttempt(
133
+ run,
134
+ active.requestId,
135
+ active.agent,
136
+ active.transcriptTask,
137
+ this.dependencies.now(),
138
+ );
139
+ this.persist();
140
+ } catch {
141
+ // Status evidence is best-effort and must never block step execution.
142
+ }
110
143
  this.activeDelegation = active;
111
144
  this.updateStatus();
112
145
  this.latestContext?.ui.notify(
@@ -149,17 +182,38 @@ function launchMainStep(
149
182
  run: WorkflowRun,
150
183
  step: WorkflowStep,
151
184
  ): void {
152
- const approvedBashCommands = narrowApprovedBashCommands(
153
- run.reviewedArtifact ?? '',
154
- run.stepHandoff ?? '',
155
- step.permissions.bash.approvedSources ?? [],
156
- );
157
- const identity: MainStepIdentity = {
158
- runId: run.runId,
159
- stepId: run.currentStepId,
160
- stepDigest: run.currentStepDigest,
161
- sessionEpoch: this.sessionEpoch,
162
- };
185
+ const actualCwd = this.latestContext?.cwd;
186
+ if (!run.cwd || !actualCwd) {
187
+ this.pauseForExecutionFailure(
188
+ 'Main-agent step',
189
+ 'Workflow working directory is unavailable; abort it and start a new run',
190
+ );
191
+ return;
192
+ }
193
+ try {
194
+ const expectedCanonicalCwd = this.dependencies.resolveWorkspaceDirectory({
195
+ candidateCwd: run.cwd,
196
+ startCwd: run.cwd,
197
+ allowedRoots: ['.'],
198
+ });
199
+ const actualCanonicalCwd = this.dependencies.resolveWorkspaceDirectory({
200
+ candidateCwd: actualCwd,
201
+ startCwd: actualCwd,
202
+ allowedRoots: ['.'],
203
+ });
204
+ if (actualCanonicalCwd !== expectedCanonicalCwd) {
205
+ throw new Error(
206
+ `current session cwd "${actualCwd}" does not match captured workflow cwd "${run.cwd}"`,
207
+ );
208
+ }
209
+ } catch (error) {
210
+ this.pauseForExecutionFailure(
211
+ 'Main-agent step',
212
+ error instanceof Error ? error.message : String(error),
213
+ );
214
+ return;
215
+ }
216
+
163
217
  const policyDigest = digest({
164
218
  version: 1,
165
219
  execution: 'main',
@@ -168,32 +222,52 @@ function launchMainStep(
168
222
  stepId: run.currentStepId,
169
223
  stepDigest: run.currentStepDigest,
170
224
  permissions: step.permissions,
171
- approvedBashCommands,
172
225
  nonce: this.dependencies.createRequestId(),
173
226
  });
227
+ const task = buildMainStepTask(workflow, run);
228
+ const identity: MainStepIdentity = {
229
+ requestId: policyDigest,
230
+ runId: run.runId,
231
+ stepId: run.currentStepId,
232
+ stepDigest: run.currentStepDigest,
233
+ sessionEpoch: this.sessionEpoch,
234
+ };
174
235
  const execution: MainStepExecution = {
175
236
  workflowId: workflow.definition.id,
176
237
  runId: run.runId,
177
238
  stepId: run.currentStepId,
178
239
  stepDigest: run.currentStepDigest,
179
240
  policyDigest,
241
+ task,
180
242
  step: structuredClone(step),
181
- approvedBashCommands,
182
243
  outcomes: allowedOutcomes(workflow, run),
183
244
  summaryMaxChars: workflow.definition.summaryMaxChars,
184
245
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
246
+ onTrace: (lines, context) =>
247
+ this.queueMainStepLog(identity, lines, context),
185
248
  onSettled: (result, context) =>
186
249
  this.queueMainStepResult(identity, result, context),
187
250
  };
188
251
 
189
252
  try {
253
+ try {
254
+ this.run = beginMainStepAttempt(
255
+ run,
256
+ policyDigest,
257
+ task,
258
+ this.dependencies.now(),
259
+ );
260
+ this.persist();
261
+ } catch {
262
+ // Status evidence is best-effort and must never block step execution.
263
+ }
190
264
  this.mainSteps.activate(execution);
191
265
  this.updateStatus();
192
266
  this.latestContext?.ui.notify(
193
267
  `Started "${run.currentStepId}" in the main agent`,
194
268
  'info',
195
269
  );
196
- this.pi.sendUserMessage(buildMainStepTask(workflow, run), {
270
+ this.pi.sendUserMessage(task, {
197
271
  deliverAs: 'followUp',
198
272
  });
199
273
  } catch (error) {
@@ -205,6 +279,73 @@ function launchMainStep(
205
279
  }
206
280
  }
207
281
 
282
+ function hasCurrentMainStepIdentity(
283
+ context: HarnessActionContext,
284
+ identity: MainStepIdentity,
285
+ ): context is HarnessActionContext & { run: WorkflowRun } {
286
+ return (
287
+ context.isSessionActive &&
288
+ context.sessionEpoch === identity.sessionEpoch &&
289
+ context.run?.status === 'running' &&
290
+ context.run.runId === identity.runId &&
291
+ context.run.currentStepId === identity.stepId &&
292
+ context.run.currentStepDigest === identity.stepDigest
293
+ );
294
+ }
295
+
296
+ function matchesLatestMainStepAttempt(
297
+ run: WorkflowRun,
298
+ identity: MainStepIdentity,
299
+ ): boolean {
300
+ const attempt = run.currentStepAttempts?.at(-1);
301
+ return (
302
+ attempt === undefined ||
303
+ (attempt.kind === 'main' && attempt.requestId === identity.requestId)
304
+ );
305
+ }
306
+
307
+ function queueMainStepLog(
308
+ this: HarnessActionContext,
309
+ identity: MainStepIdentity,
310
+ lines: ReadonlyArray<string>,
311
+ context: ExtensionContext,
312
+ ): Promise<void> {
313
+ return this.mutationQueue
314
+ .run(() => this.recordMainStepLog(identity, lines, context))
315
+ .catch(() => {
316
+ // Status evidence is best-effort and must never pause step execution.
317
+ });
318
+ }
319
+
320
+ async function recordMainStepLog(
321
+ this: HarnessActionContext,
322
+ identity: MainStepIdentity,
323
+ lines: ReadonlyArray<string>,
324
+ context: ExtensionContext,
325
+ ): Promise<void> {
326
+ if (
327
+ !hasCurrentMainStepIdentity(this, identity) ||
328
+ !matchesLatestMainStepAttempt(this.run, identity)
329
+ ) {
330
+ return;
331
+ }
332
+ const attempt = this.run.currentStepAttempts?.at(-1);
333
+ if (attempt?.kind !== 'main' || attempt.requestId !== identity.requestId) {
334
+ return;
335
+ }
336
+ this.latestContext = context;
337
+ const traced = appendMainStepLog(
338
+ this.run,
339
+ identity.requestId,
340
+ lines,
341
+ this.dependencies.now(),
342
+ );
343
+ if (traced === this.run) return;
344
+ this.run = traced;
345
+ this.persist();
346
+ this.updateStatus();
347
+ }
348
+
208
349
  function queueMainStepResult(
209
350
  this: HarnessActionContext,
210
351
  identity: MainStepIdentity,
@@ -227,18 +368,13 @@ async function finishMainStep(
227
368
  result: WorkflowStepResult | undefined,
228
369
  context: ExtensionContext,
229
370
  ): Promise<void> {
230
- this.latestContext = context;
231
371
  if (
232
- !this.isSessionActive ||
233
- this.sessionEpoch !== identity.sessionEpoch ||
234
- !this.run ||
235
- this.run.status !== 'running' ||
236
- this.run.runId !== identity.runId ||
237
- this.run.currentStepId !== identity.stepId ||
238
- this.run.currentStepDigest !== identity.stepDigest
372
+ !hasCurrentMainStepIdentity(this, identity) ||
373
+ !matchesLatestMainStepAttempt(this.run, identity)
239
374
  ) {
240
375
  return;
241
376
  }
377
+ this.latestContext = context;
242
378
  if (!result) {
243
379
  throw new Error(
244
380
  'agent settled without calling workflow_complete_step exactly once',
@@ -251,23 +387,41 @@ async function finishMainStep(
251
387
  throw new Error('Active workflow configuration is unavailable');
252
388
  }
253
389
  if (step.gate?.submitOutcome === result.outcome) {
390
+ this.run = recordCurrentStepResult(
391
+ this.run,
392
+ result,
393
+ this.dependencies.now(),
394
+ );
254
395
  await this.submitGate(
255
396
  workflow,
256
397
  this.run,
257
398
  result.outcome,
399
+ result.summary,
258
400
  result.artifact ?? '',
259
401
  );
260
402
  return;
261
403
  }
262
404
 
405
+ const effects = resolveStepEffects(this.run, step, result, this.dependencies);
406
+ const tracedRun = recordCurrentStepResult(
407
+ this.run,
408
+ result,
409
+ this.dependencies.now(),
410
+ effects.workspaceCwd,
411
+ );
263
412
  this.run = advanceRun(
264
413
  workflow,
265
- this.run,
414
+ tracedRun,
266
415
  result.outcome,
267
416
  result.summary,
268
417
  this.dependencies.now(),
418
+ effects,
269
419
  );
270
- this.settleAfterTransition(workflow);
420
+ this.settleAfterTransition(workflow, {
421
+ stepId: identity.stepId,
422
+ outcome: result.outcome,
423
+ summary: result.summary,
424
+ });
271
425
  }
272
426
 
273
427
  /**
@@ -277,6 +431,8 @@ export function createStepExecutionActions(): StepExecutionActions {
277
431
  return {
278
432
  launchCurrentStep,
279
433
  launchMainStep,
434
+ queueMainStepLog,
435
+ recordMainStepLog,
280
436
  queueMainStepResult,
281
437
  finishMainStep,
282
438
  };
@@ -0,0 +1,168 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import type { LoadedWorkflow } from '../config/types.ts';
3
+ import type { WorkflowRun } from '../engine/state.ts';
4
+ import { redactStepDetailText } from '../step-log.ts';
5
+
6
+ export const WORKFLOW_STEP_SUMMARY_MESSAGE_TYPE = 'workflow-step-summary';
7
+ export const MAX_POSTED_STEP_SUMMARY_CHARS = 4_000;
8
+ export const MAX_POSTED_STEP_FAILURE_CHARS = 1_000;
9
+
10
+ export type SettledStepReport = {
11
+ readonly stepId: string;
12
+ readonly outcome: string;
13
+ readonly summary: string;
14
+ };
15
+
16
+ type StepSummaryDetails = {
17
+ readonly workflowId: string;
18
+ readonly runId: string;
19
+ readonly stepId: string;
20
+ readonly stepTitle: string;
21
+ readonly status: 'completed' | 'paused' | 'failed';
22
+ readonly outcome?: string;
23
+ readonly workflowCompleted: boolean;
24
+ };
25
+
26
+ function boundedPostedText(value: string, maxChars: number): string {
27
+ const normalized = redactStepDetailText(value).trim();
28
+ if (normalized.length <= maxChars) return normalized;
29
+ return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
30
+ }
31
+
32
+ /** Produces the only failure text suitable for compact chat/toast surfaces. */
33
+ export function conciseStepFailureSummary(reason: string): string {
34
+ return (
35
+ boundedPostedText(reason, MAX_POSTED_STEP_FAILURE_CHARS) ||
36
+ 'The step failed without a diagnostic summary.'
37
+ );
38
+ }
39
+
40
+ /** Produces a compact pause reason while full state remains in the checkpoint. */
41
+ export function conciseStepPauseSummary(reason: string): string {
42
+ return (
43
+ boundedPostedText(reason, MAX_POSTED_STEP_FAILURE_CHARS) ||
44
+ 'Manual action is required before this step can continue.'
45
+ );
46
+ }
47
+
48
+ function stepTitle(
49
+ workflow: LoadedWorkflow | undefined,
50
+ stepId: string,
51
+ ): string {
52
+ const title = workflow?.definition.steps[stepId]?.title ?? stepId;
53
+ return boundedPostedText(title, 200) || stepId;
54
+ }
55
+
56
+ function postStepMessage(
57
+ pi: ExtensionAPI,
58
+ content: string,
59
+ details: StepSummaryDetails,
60
+ ): void {
61
+ try {
62
+ pi.sendMessage(
63
+ {
64
+ customType: WORKFLOW_STEP_SUMMARY_MESSAGE_TYPE,
65
+ content,
66
+ display: true,
67
+ details,
68
+ },
69
+ { triggerTurn: false },
70
+ );
71
+ } catch {
72
+ // Reporting is best-effort and must never block a durable transition.
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Posts only the structured handoff supplied by a successfully settled step.
78
+ *
79
+ * Execution transcripts and artifacts are deliberately unavailable to this
80
+ * function, so they cannot leak into the concise chat summary.
81
+ */
82
+ export function reportSettledStep(
83
+ pi: ExtensionAPI,
84
+ workflow: LoadedWorkflow,
85
+ run: WorkflowRun,
86
+ report: SettledStepReport,
87
+ ): void {
88
+ const workflowCompleted = run.status === 'completed';
89
+ const paused =
90
+ run.status === 'paused' &&
91
+ run.failedStepId === undefined &&
92
+ run.currentStepId === report.stepId;
93
+ const status = paused ? 'paused' : 'completed';
94
+ const title = stepTitle(workflow, report.stepId);
95
+ const summary =
96
+ boundedPostedText(report.summary, MAX_POSTED_STEP_SUMMARY_CHARS) ||
97
+ 'No summary was provided.';
98
+ const completion = workflowCompleted
99
+ ? `\n\nWorkflow \`${run.workflowId}\` completed.`
100
+ : '';
101
+ postStepMessage(
102
+ pi,
103
+ `### Step ${status}: ${title} (\`${report.stepId}\`)\n\n` +
104
+ `Outcome: \`${report.outcome}\`\n\n${summary}${completion}`,
105
+ {
106
+ workflowId: run.workflowId,
107
+ runId: run.runId,
108
+ stepId: report.stepId,
109
+ stepTitle: title,
110
+ status,
111
+ outcome: report.outcome,
112
+ workflowCompleted,
113
+ },
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Posts a concise failure reason for the current step.
119
+ *
120
+ * The full diagnostic remains in the workflow checkpoint/status explorer.
121
+ * Only a redacted prefix is displayed in chat.
122
+ */
123
+ export function reportFailedStep(
124
+ pi: ExtensionAPI,
125
+ workflow: LoadedWorkflow | undefined,
126
+ run: WorkflowRun,
127
+ reason: string,
128
+ ): void {
129
+ const title = stepTitle(workflow, run.currentStepId);
130
+ const summary = conciseStepFailureSummary(reason);
131
+ postStepMessage(
132
+ pi,
133
+ `### Step failed: ${title} (\`${run.currentStepId}\`)\n\n${summary}\n\n` +
134
+ `Workflow \`${run.workflowId}\` is paused.`,
135
+ {
136
+ workflowId: run.workflowId,
137
+ runId: run.runId,
138
+ stepId: run.currentStepId,
139
+ stepTitle: title,
140
+ status: 'failed',
141
+ workflowCompleted: false,
142
+ },
143
+ );
144
+ }
145
+
146
+ /** Posts the concise reason for a non-failure workflow pause. */
147
+ export function reportPausedStep(
148
+ pi: ExtensionAPI,
149
+ workflow: LoadedWorkflow | undefined,
150
+ run: WorkflowRun,
151
+ reason: string,
152
+ ): void {
153
+ const title = stepTitle(workflow, run.currentStepId);
154
+ const summary = conciseStepPauseSummary(reason);
155
+ postStepMessage(
156
+ pi,
157
+ `### Step paused: ${title} (\`${run.currentStepId}\`)\n\n${summary}\n\n` +
158
+ `Workflow \`${run.workflowId}\` is paused.`,
159
+ {
160
+ workflowId: run.workflowId,
161
+ runId: run.runId,
162
+ stepId: run.currentStepId,
163
+ stepTitle: title,
164
+ status: 'paused',
165
+ workflowCompleted: false,
166
+ },
167
+ );
168
+ }
@@ -45,6 +45,7 @@ export type DelegationFailureDetails = {
45
45
  };
46
46
 
47
47
  export type MainStepIdentity = {
48
+ requestId: string;
48
49
  runId: string;
49
50
  stepId: string;
50
51
  stepDigest: string;
@@ -0,0 +1,67 @@
1
+ import { realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep, win32 } from 'node:path';
3
+
4
+ export type ResolveWorkspaceDirectoryOptions = {
5
+ readonly candidateCwd: string;
6
+ readonly startCwd: string;
7
+ readonly allowedRoots: ReadonlyArray<string>;
8
+ };
9
+
10
+ const isWithin = (root: string, candidate: string): boolean => {
11
+ const pathFromRoot = relative(root, candidate);
12
+ return (
13
+ pathFromRoot === '' ||
14
+ (pathFromRoot !== '..' &&
15
+ !pathFromRoot.startsWith(`..${sep}`) &&
16
+ !isAbsolute(pathFromRoot))
17
+ );
18
+ };
19
+
20
+ /**
21
+ * Canonicalizes and validates one YAML-authorized execution directory.
22
+ *
23
+ * This boundary intentionally knows only filesystem paths. The workflow prompt
24
+ * owns how the directory was selected or created.
25
+ */
26
+ export function resolveWorkspaceDirectory({
27
+ candidateCwd,
28
+ startCwd,
29
+ allowedRoots,
30
+ }: ResolveWorkspaceDirectoryOptions): string {
31
+ if (
32
+ !candidateCwd ||
33
+ !isAbsolute(candidateCwd) ||
34
+ candidateCwd.includes('\0')
35
+ ) {
36
+ throw new Error('workspace cwd must be a non-empty absolute path');
37
+ }
38
+ if (!startCwd || !isAbsolute(startCwd) || startCwd.includes('\0')) {
39
+ throw new Error('workflow start cwd must be a non-empty absolute path');
40
+ }
41
+ if (allowedRoots.length === 0) {
42
+ throw new Error('workspace binding must declare an allowed root');
43
+ }
44
+
45
+ const canonicalStart = realpathSync(startCwd);
46
+ const canonicalRoots = allowedRoots.map((allowedRoot) => {
47
+ if (
48
+ !allowedRoot ||
49
+ isAbsolute(allowedRoot) ||
50
+ win32.parse(allowedRoot).root !== '' ||
51
+ allowedRoot.includes('\0')
52
+ ) {
53
+ throw new Error(
54
+ 'workspace allowed roots must be non-empty relative paths',
55
+ );
56
+ }
57
+ return realpathSync(resolve(canonicalStart, allowedRoot));
58
+ });
59
+ const canonicalCwd = realpathSync(candidateCwd);
60
+ if (!statSync(canonicalCwd).isDirectory()) {
61
+ throw new Error('workspace cwd must resolve to a directory');
62
+ }
63
+ if (!canonicalRoots.some((root) => isWithin(root, canonicalCwd))) {
64
+ throw new Error('workspace cwd is outside the YAML-authorized roots');
65
+ }
66
+ return canonicalCwd;
67
+ }