@wichayutdew/pi-workflows 2.0.1 → 2.2.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 (43) hide show
  1. package/README.md +19 -955
  2. package/dist/index.js +841 -535
  3. package/examples/mr-comments.workflow.yaml +1 -1
  4. package/examples/prompts/mr-comments/plan.md +8 -1
  5. package/examples/starter-kit/mr-comment.workflow.yaml +1 -1
  6. package/examples/starter-kit/mr-review.workflow.yaml +4 -2
  7. package/examples/starter-kit/steps/mr-comment/plan.md +9 -2
  8. package/examples/starter-kit/steps/mr-review/publish.md +9 -2
  9. package/examples/starter-kit/steps/mr-review/review.md +9 -2
  10. package/examples/starter-kit/steps/mr-review/verify.md +16 -5
  11. package/examples/starter-kit/steps/shared/prepare-workspace.md +42 -4
  12. package/examples/starter-kit/steps/ticket/plan.md +31 -2
  13. package/examples/starter-kit/steps/work/plan.md +31 -2
  14. package/examples/starter-kit/ticket.workflow.yaml +14 -2
  15. package/examples/starter-kit/work.workflow.yaml +14 -2
  16. package/package.json +1 -1
  17. package/schemas/workflow.schema.json +1 -0
  18. package/src/command-names.ts +1 -0
  19. package/src/commands.ts +14 -0
  20. package/src/config/types.ts +1 -1
  21. package/src/config/validation/prompt.ts +3 -0
  22. package/src/engine/create-run.ts +4 -0
  23. package/src/engine/gate-transitions.ts +80 -33
  24. package/src/engine/run-advance.ts +36 -3
  25. package/src/engine/run-lifecycle.ts +69 -0
  26. package/src/engine/run-reconciliation.ts +2 -0
  27. package/src/engine/run-validation.ts +24 -1
  28. package/src/engine/state-types.ts +10 -0
  29. package/src/engine/state.ts +1 -0
  30. package/src/engine/transitions.ts +1 -0
  31. package/src/harness/action-context.ts +6 -0
  32. package/src/harness/core-actions.ts +2 -0
  33. package/src/harness/dependencies.ts +4 -0
  34. package/src/harness/lifecycle-actions.ts +14 -1
  35. package/src/harness/session-persistence.ts +66 -0
  36. package/src/harness/start-actions.ts +188 -1
  37. package/src/harness.ts +20 -0
  38. package/src/prompt/step-sections.ts +14 -0
  39. package/src/prompt/step-task.ts +3 -0
  40. package/src/prompt/template.ts +6 -3
  41. package/src/workflow-doctor.ts +1 -1
  42. package/src/workflow-status/render-summary.ts +3 -0
  43. package/src/workflow-status/view.ts +29 -3
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import { createRun } from '../engine/state.ts';
3
+ import { restartRun } from '../engine/transitions.ts';
4
+ import type { LoadedWorkflow } from '../config/types.ts';
3
5
  import { analyzeWorkflow, formatWorkflowDoctor } from '../workflow-doctor.ts';
4
6
  import { formatWorkflowList } from '../workflow-list.ts';
5
7
  import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
@@ -36,6 +38,32 @@ function isCurrentSession(
36
38
  return session.isSessionActive && session.sessionEpoch === sessionEpoch;
37
39
  }
38
40
 
41
+ type RestartWorkspaceBinding = {
42
+ readonly cwd: string;
43
+ readonly allowedRoots: ReadonlyArray<string>;
44
+ };
45
+
46
+ function completedWorkspaceBinding(
47
+ run: NonNullable<HarnessActionContext['run']>,
48
+ workflow: LoadedWorkflow,
49
+ ): RestartWorkspaceBinding | undefined {
50
+ for (let index = run.history.length - 1; index >= 0; index -= 1) {
51
+ const entry = run.history[index];
52
+ if (!entry?.workspaceCwd) continue;
53
+ const step = workflow.definition.steps[entry.stepId];
54
+ if (!step?.workspace || !step.workspace.bindOn.includes(entry.outcome)) {
55
+ throw new Error(
56
+ `workspace-binding step "${entry.stepId}" no longer matches the completed iteration`,
57
+ );
58
+ }
59
+ return {
60
+ cwd: entry.workspaceCwd,
61
+ allowedRoots: step.workspace.allowedRoots,
62
+ };
63
+ }
64
+ return undefined;
65
+ }
66
+
39
67
  export type StartActions = {
40
68
  listWorkflows: (
41
69
  this: HarnessActionContext,
@@ -53,6 +81,12 @@ export type StartActions = {
53
81
  startContext: WorkflowStartContext,
54
82
  sessionEpoch: number,
55
83
  ) => Promise<void>;
84
+ restartNow: (
85
+ this: HarnessActionContext,
86
+ input: string,
87
+ startContext: WorkflowStartContext,
88
+ sessionEpoch: number,
89
+ ) => Promise<void>;
56
90
  reloadNow: (
57
91
  this: HarnessActionContext,
58
92
  context: ExtensionCommandContext,
@@ -231,6 +265,159 @@ async function startNow(
231
265
  this.launchCurrentStep(workflow);
232
266
  }
233
267
 
268
+ async function restartNow(
269
+ this: HarnessActionContext,
270
+ input: string,
271
+ startContext: WorkflowStartContext,
272
+ sessionEpoch: number,
273
+ ): Promise<void> {
274
+ const { context } = startContext;
275
+ const completedRun = this.run;
276
+ if (!completedRun || completedRun.status !== 'completed') {
277
+ context.ui.notify('Only a completed workflow can be restarted', 'warning');
278
+ return;
279
+ }
280
+ if (this.activeDelegation) {
281
+ context.ui.notify(
282
+ `Cannot restart while subagent "${this.activeDelegation.agent}" is still cancelling`,
283
+ 'warning',
284
+ );
285
+ return;
286
+ }
287
+ if (!completedRun.startCwd) {
288
+ context.ui.notify(
289
+ 'Cannot restart this workflow on the same worktree because its original start directory was not captured; start a new workflow instead',
290
+ 'error',
291
+ );
292
+ return;
293
+ }
294
+ if (!context.isIdle()) {
295
+ context.abort();
296
+ await startContext.waitForIdle();
297
+ }
298
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
299
+ context.ui.notify(
300
+ 'Workflow restart was superseded by a session or workflow change',
301
+ 'warning',
302
+ );
303
+ return;
304
+ }
305
+
306
+ this.captureSkills(startContext.skills());
307
+ if (!(await this.reloadCatalog(context, false))) {
308
+ context.ui.notify(
309
+ 'Workflow restart was superseded by a newer configuration load',
310
+ 'warning',
311
+ );
312
+ return;
313
+ }
314
+ if (!isCurrentSession(this, sessionEpoch) || this.run !== completedRun) {
315
+ context.ui.notify(
316
+ 'Workflow restart was superseded by a session or workflow change',
317
+ 'warning',
318
+ );
319
+ return;
320
+ }
321
+
322
+ const workflow = this.catalog.workflows.get(completedRun.workflowId);
323
+ if (!workflow) {
324
+ context.ui.notify(
325
+ `Workflow "${completedRun.workflowId}" is no longer loaded`,
326
+ 'error',
327
+ );
328
+ return;
329
+ }
330
+ const livenessErrors = analyzeWorkflow(workflow.definition).issues.filter(
331
+ (issue) => issue.level === 'error',
332
+ );
333
+ if (livenessErrors.length > 0) {
334
+ context.ui.notify(
335
+ `Cannot restart workflow; run /workflow-doctor ${workflow.definition.id}:\n${livenessErrors.map((issue) => issue.message).join('\n')}`,
336
+ 'error',
337
+ );
338
+ return;
339
+ }
340
+ const preflightErrors = this.preflight(workflow, workflow.definition.start);
341
+ if (preflightErrors.length > 0) {
342
+ context.ui.notify(
343
+ `Cannot restart workflow:\n${preflightErrors.join('\n')}`,
344
+ 'error',
345
+ );
346
+ return;
347
+ }
348
+
349
+ let canonicalStartCwd: string;
350
+ let canonicalSessionCwd: string;
351
+ try {
352
+ canonicalStartCwd = this.dependencies.resolveWorkspaceDirectory({
353
+ candidateCwd: completedRun.startCwd,
354
+ startCwd: completedRun.startCwd,
355
+ allowedRoots: ['.'],
356
+ });
357
+ canonicalSessionCwd = this.dependencies.resolveWorkspaceDirectory({
358
+ candidateCwd: context.cwd,
359
+ startCwd: context.cwd,
360
+ allowedRoots: ['.'],
361
+ });
362
+ } catch (error) {
363
+ context.ui.notify(
364
+ `Cannot restart workflow on its captured worktree: ${
365
+ error instanceof Error ? error.message : String(error)
366
+ }`,
367
+ 'error',
368
+ );
369
+ return;
370
+ }
371
+ if (
372
+ canonicalStartCwd !== completedRun.startCwd ||
373
+ canonicalSessionCwd !== canonicalStartCwd
374
+ ) {
375
+ context.ui.notify(
376
+ 'Current session cwd does not match the captured workflow start directory',
377
+ 'error',
378
+ );
379
+ return;
380
+ }
381
+
382
+ try {
383
+ const binding = completedWorkspaceBinding(completedRun, workflow);
384
+ if (binding) {
385
+ const canonicalWorkspaceCwd = this.dependencies.resolveWorkspaceDirectory(
386
+ {
387
+ candidateCwd: binding.cwd,
388
+ startCwd: canonicalStartCwd,
389
+ allowedRoots: binding.allowedRoots,
390
+ },
391
+ );
392
+ if (canonicalWorkspaceCwd !== binding.cwd) {
393
+ throw new Error(
394
+ 'previous workspace no longer resolves to its captured canonical directory',
395
+ );
396
+ }
397
+ }
398
+ this.run = restartRun(
399
+ workflow,
400
+ completedRun,
401
+ input.trim() || completedRun.input,
402
+ this.pi.getActiveTools(),
403
+ this.dependencies.now(),
404
+ );
405
+ } catch (error) {
406
+ context.ui.notify(
407
+ `Cannot restart workflow on the same worktree: ${
408
+ error instanceof Error ? error.message : String(error)
409
+ }`,
410
+ 'error',
411
+ );
412
+ return;
413
+ }
414
+
415
+ this.persist();
416
+ this.isolateMainSessionTools();
417
+ this.updateStatus();
418
+ this.launchCurrentStep(workflow);
419
+ }
420
+
234
421
  async function reloadNow(
235
422
  this: HarnessActionContext,
236
423
  context: ExtensionCommandContext,
@@ -253,5 +440,5 @@ async function reloadNow(
253
440
  * Returns workflow listing, start, and reload actions for harness composition.
254
441
  */
255
442
  export function createStartActions(): StartActions {
256
- return { listWorkflows, doctorWorkflows, startNow, reloadNow };
443
+ return { listWorkflows, doctorWorkflows, startNow, restartNow, reloadNow };
257
444
  }
package/src/harness.ts CHANGED
@@ -123,6 +123,11 @@ export class WorkflowHarness implements WorkflowCommandController {
123
123
  startContext: WorkflowStartContext,
124
124
  sessionEpoch: number,
125
125
  ) => Promise<void> = START_ACTIONS.startNow;
126
+ private readonly restartNow: (
127
+ input: string,
128
+ startContext: WorkflowStartContext,
129
+ sessionEpoch: number,
130
+ ) => Promise<void> = START_ACTIONS.restartNow;
126
131
  private readonly reloadNow: (
127
132
  context: ExtensionCommandContext,
128
133
  ) => Promise<void> = START_ACTIONS.reloadNow;
@@ -345,6 +350,21 @@ export class WorkflowHarness implements WorkflowCommandController {
345
350
  );
346
351
  }
347
352
 
353
+ /** Starts another completed iteration in its existing workflow worktree. */
354
+ restart(input: string, context: ExtensionCommandContext): Promise<void> {
355
+ return this.enqueueMutation(context, (sessionEpoch) =>
356
+ this.restartNow(
357
+ input,
358
+ {
359
+ context,
360
+ skills: () => context.getSystemPromptOptions().skills,
361
+ waitForIdle: () => context.waitForIdle(),
362
+ },
363
+ sessionEpoch,
364
+ ),
365
+ );
366
+ }
367
+
348
368
  /** Pauses the active workflow while retaining its checkpoint. */
349
369
  pause(reason: string, context: ExtensionCommandContext): Promise<void> {
350
370
  return this.enqueueMutation(context, () => this.pauseNow(reason, context));
@@ -58,6 +58,20 @@ export function buildDelegatedHandoffSection(
58
58
  ];
59
59
  }
60
60
 
61
+ /** Builds the immutable same-worktree constraint for a restarted iteration. */
62
+ export function buildRestartWorkspaceSection(
63
+ workspaceCwd: string | undefined,
64
+ ): ReadonlyArray<string> {
65
+ if (!workspaceCwd) return [];
66
+ return [
67
+ '## Restart workspace constraint',
68
+ '',
69
+ `This iteration must reuse and rebind exactly this existing workspace: ${workspaceCwd}`,
70
+ 'Do not create or substitute another workspace. If it cannot be safely reused, complete with a configured non-binding outcome that pauses the workflow.',
71
+ '',
72
+ ];
73
+ }
74
+
61
75
  /**
62
76
  * Builds non-interactive recovery guidance specific to delegated steps.
63
77
  *
@@ -4,6 +4,7 @@ import { createStepContract } from './step-contract.ts';
4
4
  import {
5
5
  buildDelegatedCompletionInstructions,
6
6
  buildDelegatedHandoffSection,
7
+ buildRestartWorkspaceSection,
7
8
  buildResourceSection,
8
9
  } from './step-sections.ts';
9
10
  import {
@@ -119,6 +120,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
119
120
  '',
120
121
  `Workflow: ${workflow.definition.id}`,
121
122
  `Run: ${run.runId}`,
123
+ `Iteration: ${run.iteration ?? 1}`,
122
124
  `Step: ${run.currentStepId} (${step.title})`,
123
125
  ...(isDelegated
124
126
  ? [
@@ -132,6 +134,7 @@ export function buildStepTask(options: BuildStepTaskOptions): string {
132
134
  prompt,
133
135
  '',
134
136
  ...(isDelegated ? buildDelegatedHandoffSection(handoff) : []),
137
+ ...buildRestartWorkspaceSection(run.restartWorkspaceCwd),
135
138
  ...buildResumeInputSection(
136
139
  run,
137
140
  RESUME_INPUT_PLACEHOLDER.test(promptTemplate),
@@ -7,8 +7,8 @@ import type { WorkflowRun } from '../engine/state.ts';
7
7
  export type TemplateValues = Readonly<Record<string, string>>;
8
8
 
9
9
  /**
10
- * Combines the incoming handoff with the latest paused-attempt
11
- * summary without duplicating identical content.
10
+ * Combines the incoming handoff with the latest current-step summary without
11
+ * duplicating identical content.
12
12
  *
13
13
  * @param run - Current workflow run.
14
14
  * @returns The handoff text for the active step.
@@ -27,7 +27,7 @@ export function currentStepHandoff(run: WorkflowRun): string {
27
27
  'Incoming previous-step handoff:',
28
28
  incomingHandoff,
29
29
  '',
30
- 'Latest paused attempt:',
30
+ 'Latest current-step summary:',
31
31
  run.lastSummary,
32
32
  ].join('\n');
33
33
  }
@@ -72,13 +72,16 @@ export function createTemplateValues({
72
72
  return {
73
73
  'workflow.input': run.input,
74
74
  'workflow.id': workflow.definition.id,
75
+ 'workflow.iteration': String(run.iteration ?? 1),
75
76
  'run.id': run.runId,
76
77
  'step.id': run.currentStepId,
77
78
  'step.title': step.title,
78
79
  'last.summary': currentStepHandoff(run),
79
80
  'reviewed.artifact': run.reviewedArtifact ?? '',
80
81
  'reviewed.feedback': run.reviewedFeedback ?? '',
82
+ 'gate.artifact': run.gateArtifact ?? '',
81
83
  'gate.feedback': run.gateFeedback,
82
84
  'resume.input': run.resumeInput ?? '',
85
+ 'restart.workspace': run.restartWorkspaceCwd ?? '',
83
86
  };
84
87
  }
@@ -235,7 +235,7 @@ export function formatWorkflowDoctor(
235
235
  '',
236
236
  `Result: ${errors.length > 0 ? 'ERROR' : warnings.length > 0 ? 'WARNING' : 'PASS'}`,
237
237
  '',
238
- `Runtime loop guard: each step executes at most ${report.maxStepVisits} time${report.maxStepVisits === 1 ? '' : 's'} before the next attempted entry pauses an uninterrupted run. This bounds graph cycling; it does not guarantee $done or bound time spent inside a step or gate.`,
238
+ `Runtime loop guard: automatic graph advancement enters each step at most ${report.maxStepVisits} time${report.maxStepVisits === 1 ? '' : 's'} before the next attempted entry pauses the run. An explicit human rejection back to the same gated step bypasses that check for its transition because every revision awaits another decision; the visit is still recorded. This bounds unattended cycling; it does not guarantee $done or bound time spent inside a step or gate.`,
239
239
  '',
240
240
  );
241
241
  if (report.issues.length === 0) {
@@ -82,6 +82,9 @@ export function renderSummaryLines(
82
82
  ]
83
83
  : []),
84
84
  ...keyValueLines(theme, 'run', run.runId, width),
85
+ ...(run.iteration && run.iteration > 1
86
+ ? [...keyValueLines(theme, 'iteration', String(run.iteration), width)]
87
+ : []),
85
88
  ...keyValueLines(
86
89
  theme,
87
90
  'status',
@@ -135,6 +135,7 @@ function nextScrollOffset(state: ViewportState, value: number): number {
135
135
  export class WorkflowStatusView implements Component {
136
136
  private timer: RefreshTimer | undefined;
137
137
  private state = initialViewportState();
138
+ private pendingDetailTopKey = false;
138
139
  private readonly statusShortcutLabel: string;
139
140
  private readonly dependencies: WorkflowStatusViewDependencies;
140
141
  private readonly transcriptCache = new Map<string, StepTranscriptViewState>();
@@ -180,11 +181,15 @@ export class WorkflowStatusView implements Component {
180
181
 
181
182
  /** Handle close and scrolling key input. */
182
183
  handleInput(data: string): void {
184
+ const isDetailHalfPageDown =
185
+ this.state.mode === 'detail' && matchesKey(data, Key.ctrl('d'));
186
+ const isDetailHalfPageUp =
187
+ this.state.mode === 'detail' && matchesKey(data, Key.ctrl('u'));
183
188
  if (
184
189
  data === 'q' ||
185
190
  data === 'Q' ||
186
191
  matchesKey(data, 'ctrl+c') ||
187
- matchesKey(data, 'ctrl+d') ||
192
+ (this.state.mode !== 'detail' && matchesKey(data, 'ctrl+d')) ||
188
193
  matchesKey(data, this.statusShortcut)
189
194
  ) {
190
195
  this.close();
@@ -199,13 +204,31 @@ export class WorkflowStatusView implements Component {
199
204
  return;
200
205
  }
201
206
  const pageSize = Math.max(1, this.state.viewportRows - 2);
207
+ const contentHeight = Math.max(1, this.state.viewportRows - 1);
208
+ const halfPageSize = Math.max(1, Math.floor(contentHeight / 2));
202
209
  if (this.state.mode === 'detail') {
203
- if (matchesKey(data, Key.left) || data === 'h') {
210
+ if (data === 'gg' || (data === 'g' && this.pendingDetailTopKey)) {
211
+ this.pendingDetailTopKey = false;
212
+ this.setScrollOffset(0);
213
+ return;
214
+ }
215
+ if (data === 'g') {
216
+ this.pendingDetailTopKey = true;
217
+ return;
218
+ }
219
+ this.pendingDetailTopKey = false;
220
+ if (data === 'G') {
221
+ this.setScrollOffset(Number.MAX_SAFE_INTEGER);
222
+ } else if (matchesKey(data, Key.left) || data === 'h') {
204
223
  this.showBoard();
205
224
  } else if (matchesKey(data, Key.down) || data === 'j') {
206
225
  this.setScrollOffset(this.state.scrollOffset + 1);
207
226
  } else if (matchesKey(data, Key.up) || data === 'k') {
208
227
  this.setScrollOffset(this.state.scrollOffset - 1);
228
+ } else if (isDetailHalfPageDown) {
229
+ this.setScrollOffset(this.state.scrollOffset + halfPageSize);
230
+ } else if (isDetailHalfPageUp) {
231
+ this.setScrollOffset(this.state.scrollOffset - halfPageSize);
209
232
  } else if (matchesKey(data, Key.pageDown)) {
210
233
  this.setScrollOffset(this.state.scrollOffset + pageSize);
211
234
  } else if (matchesKey(data, Key.pageUp)) {
@@ -217,6 +240,7 @@ export class WorkflowStatusView implements Component {
217
240
  }
218
241
  return;
219
242
  }
243
+ this.pendingDetailTopKey = false;
220
244
  if (matchesKey(data, Key.down) || data === 'j') {
221
245
  this.moveSelection(1);
222
246
  } else if (matchesKey(data, Key.up) || data === 'k') {
@@ -287,7 +311,7 @@ export class WorkflowStatusView implements Component {
287
311
  this.statusShortcutLabel,
288
312
  this.theme,
289
313
  this.state.mode === 'detail'
290
- ? '↑/↓ or j/k scroll · PgUp/PgDn · ←/h/Esc back'
314
+ ? '↑↓/jk · Ctrl+D/U half-page · gg/G top/bottom · PgUp/PgDn · ←/h/Esc'
291
315
  : '↑/↓ or j/k select · Enter/→/l inspect · PgUp/PgDn',
292
316
  );
293
317
  this.state = page.state;
@@ -339,6 +363,7 @@ export class WorkflowStatusView implements Component {
339
363
  if (!snapshot) return;
340
364
  this.normalizeSelection(snapshot);
341
365
  if (!selectedStepDetail(snapshot, this.state.selectedIndex)) return;
366
+ this.pendingDetailTopKey = false;
342
367
  this.state = { ...this.state, mode: 'detail', scrollOffset: 0 };
343
368
  this.ensureSelectedTranscripts(snapshot);
344
369
  this.tui.requestRender(true);
@@ -346,6 +371,7 @@ export class WorkflowStatusView implements Component {
346
371
 
347
372
  private showBoard(): void {
348
373
  if (this.state.mode === 'board') return;
374
+ this.pendingDetailTopKey = false;
349
375
  this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
350
376
  this.tui.requestRender(true);
351
377
  }