@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
@@ -0,0 +1,379 @@
1
+ import { wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
+ import type {
3
+ StepExecutionAttempt,
4
+ StepHistoryEntry,
5
+ } from '../engine/state.ts';
6
+ import { redactStepDetailText } from '../step-log.ts';
7
+ import { boxed, keyValueLines } from './layout.ts';
8
+ import { buildPathEntries } from './render-path.ts';
9
+ import type { StepTranscriptLog } from './transcript-reader.ts';
10
+ import type { WorkflowStatusSnapshot, WorkflowStatusTheme } from './types.ts';
11
+
12
+ const MAX_DETAIL_ARTIFACT_CHARS = 20_000;
13
+
14
+ export type StepTranscriptViewState =
15
+ { readonly status: 'loading' } | StepTranscriptLog;
16
+
17
+ export type StepTranscriptViewCache = ReadonlyMap<
18
+ string,
19
+ StepTranscriptViewState
20
+ >;
21
+
22
+ export type SelectedStepDetail = {
23
+ readonly entryIndex: number;
24
+ readonly historyEntry?: StepHistoryEntry;
25
+ readonly attempts: ReadonlyArray<StepExecutionAttempt>;
26
+ readonly omittedAttempts: number;
27
+ };
28
+
29
+ export function stepTranscriptCacheKey(
30
+ runId: string,
31
+ attempt: StepExecutionAttempt,
32
+ ): string {
33
+ return `${runId}\0${attempt.requestId}`;
34
+ }
35
+
36
+ /** Resolves one displayed path row back to its durable checkpoint evidence. */
37
+ export function selectedStepDetail(
38
+ snapshot: WorkflowStatusSnapshot,
39
+ selectedIndex: number,
40
+ ): SelectedStepDetail | undefined {
41
+ const entries = buildPathEntries(snapshot);
42
+ const entry = entries[selectedIndex];
43
+ if (!entry) return undefined;
44
+ if (entry.historyIndex !== undefined) {
45
+ const historyEntry = snapshot.run.history[entry.historyIndex];
46
+ if (!historyEntry) return undefined;
47
+ return {
48
+ entryIndex: entry.index,
49
+ historyEntry,
50
+ attempts: historyEntry.attempts ?? [],
51
+ omittedAttempts: historyEntry.omittedAttempts ?? 0,
52
+ };
53
+ }
54
+ return {
55
+ entryIndex: entry.index,
56
+ attempts: snapshot.run.currentStepAttempts ?? [],
57
+ omittedAttempts: snapshot.run.currentStepOmittedAttempts ?? 0,
58
+ };
59
+ }
60
+
61
+ function wrapPlain(
62
+ value: string,
63
+ width: number,
64
+ theme: WorkflowStatusTheme,
65
+ color: 'text' | 'muted' | 'dim' | 'warning' = 'text',
66
+ ): Array<string> {
67
+ const sanitized = redactStepDetailText(value);
68
+ const logicalLines = sanitized.split('\n');
69
+ const wrapped = logicalLines.flatMap((line) => {
70
+ const themed = theme.fg(color, line || ' ');
71
+ return wrapTextWithAnsi(themed, Math.max(1, width));
72
+ });
73
+ return wrapped.length > 0 ? wrapped : [theme.fg(color, ' ')];
74
+ }
75
+
76
+ function boundedArtifact(value: string): {
77
+ readonly value: string;
78
+ readonly truncated: boolean;
79
+ } {
80
+ return value.length <= MAX_DETAIL_ARTIFACT_CHARS
81
+ ? { value, truncated: false }
82
+ : {
83
+ value: value.slice(0, MAX_DETAIL_ARTIFACT_CHARS),
84
+ truncated: true,
85
+ };
86
+ }
87
+
88
+ function renderResult(
89
+ theme: WorkflowStatusTheme,
90
+ attempt: StepExecutionAttempt,
91
+ width: number,
92
+ ): Array<string> {
93
+ const result = attempt.result;
94
+ if (!result) return [];
95
+ const artifact =
96
+ result.artifact === undefined
97
+ ? undefined
98
+ : boundedArtifact(result.artifact);
99
+ return [
100
+ '',
101
+ theme.bold(theme.fg('accent', 'Submitted result')),
102
+ ...keyValueLines(theme, 'outcome', result.outcome, width, 'success'),
103
+ ...keyValueLines(
104
+ theme,
105
+ 'summary',
106
+ `${result.summary}${result.summaryTruncated ? ' … [trace truncated]' : ''}`,
107
+ width,
108
+ ),
109
+ ...(result.workspaceCwd
110
+ ? keyValueLines(theme, 'workspace', result.workspaceCwd, width)
111
+ : []),
112
+ ...(artifact
113
+ ? [
114
+ theme.fg('muted', 'artifact'),
115
+ ...wrapPlain(
116
+ `${artifact.value}${
117
+ artifact.truncated || result.artifactTruncated
118
+ ? '\n… [artifact truncated in status trace]'
119
+ : ''
120
+ }`,
121
+ width,
122
+ theme,
123
+ ),
124
+ ]
125
+ : []),
126
+ ];
127
+ }
128
+
129
+ function renderGateDecision(
130
+ theme: WorkflowStatusTheme,
131
+ attempt: StepExecutionAttempt,
132
+ width: number,
133
+ ): Array<string> {
134
+ const decision = attempt.gateDecision;
135
+ if (!decision) return [];
136
+ return [
137
+ '',
138
+ theme.bold(theme.fg('accent', 'Human gate decision')),
139
+ ...keyValueLines(theme, 'provider', decision.provider, width),
140
+ ...keyValueLines(
141
+ theme,
142
+ 'decision',
143
+ decision.approved ? 'approved' : 'rejected',
144
+ width,
145
+ decision.approved ? 'success' : 'warning',
146
+ ),
147
+ ...(decision.reviewId
148
+ ? keyValueLines(theme, 'review', decision.reviewId, width, 'muted')
149
+ : []),
150
+ ...(decision.feedback
151
+ ? keyValueLines(
152
+ theme,
153
+ 'feedback',
154
+ `${decision.feedback}${
155
+ decision.feedbackTruncated ? ' … [trace truncated]' : ''
156
+ }`,
157
+ width,
158
+ )
159
+ : []),
160
+ ];
161
+ }
162
+
163
+ function renderTranscript(
164
+ theme: WorkflowStatusTheme,
165
+ snapshot: WorkflowStatusSnapshot,
166
+ attempt: StepExecutionAttempt,
167
+ cache: StepTranscriptViewCache,
168
+ width: number,
169
+ ): Array<string> {
170
+ if (attempt.kind === 'main') {
171
+ const events = (attempt.log ?? []).flatMap((event, index) => [
172
+ theme.fg('muted', `#${index + 1}`),
173
+ ...wrapPlain(event, width, theme),
174
+ ]);
175
+ return [
176
+ ...(events.length > 0
177
+ ? events
178
+ : [
179
+ theme.fg(
180
+ 'muted',
181
+ 'No main-agent reaction log was recorded for this attempt. Legacy checkpoints still show the exact task and result.',
182
+ ),
183
+ ]),
184
+ ...(attempt.logTruncated
185
+ ? [
186
+ theme.fg(
187
+ 'warning',
188
+ `… Main-agent log was bounded; ${attempt.omittedLogEvents ?? 0} later event${attempt.omittedLogEvents === 1 ? '' : 's'} omitted.`,
189
+ ),
190
+ ]
191
+ : []),
192
+ ];
193
+ }
194
+ if (!attempt.transcript) {
195
+ return wrapPlain(
196
+ 'No trusted child transcript reference was recorded for this attempt.',
197
+ width,
198
+ theme,
199
+ 'muted',
200
+ );
201
+ }
202
+ const state = cache.get(stepTranscriptCacheKey(snapshot.run.runId, attempt));
203
+ if (!state || state.status === 'loading') {
204
+ return [theme.fg('muted', 'Loading trusted child transcript…')];
205
+ }
206
+ if (state.status === 'unavailable') {
207
+ return wrapPlain(state.reason, width, theme, 'warning');
208
+ }
209
+ const events = state.lines.flatMap((event, index) => [
210
+ theme.fg('muted', `#${index + 1}`),
211
+ ...wrapPlain(event, width, theme),
212
+ ]);
213
+ return [
214
+ ...(events.length > 0
215
+ ? events
216
+ : [theme.fg('muted', 'No displayable assistant or tool events found.')]),
217
+ ...(state.truncated
218
+ ? [
219
+ theme.fg(
220
+ 'warning',
221
+ '… Transcript display was bounded; additional events were omitted.',
222
+ ),
223
+ ]
224
+ : []),
225
+ ];
226
+ }
227
+
228
+ function renderAttempt(
229
+ theme: WorkflowStatusTheme,
230
+ snapshot: WorkflowStatusSnapshot,
231
+ attempt: StepExecutionAttempt,
232
+ attemptNumber: number,
233
+ cache: StepTranscriptViewCache,
234
+ width: number,
235
+ ): Array<string> {
236
+ const actor =
237
+ attempt.kind === 'subagent' ? `subagent · ${attempt.agent}` : 'main agent';
238
+ const truncation = attempt.taskTruncated
239
+ ? `\n… [${attempt.omittedTaskChars ?? 0} task characters omitted from the bounded checkpoint trace]`
240
+ : '';
241
+ return [
242
+ theme.bold(theme.fg('accent', `Attempt ${attemptNumber} · ${actor}`)),
243
+ ...keyValueLines(theme, 'request', attempt.requestId, width, 'muted'),
244
+ '',
245
+ theme.bold('Requirement fed to the agent'),
246
+ ...wrapPlain(`${attempt.task}${truncation}`, width, theme),
247
+ '',
248
+ theme.bold('Chronological reaction and tool log'),
249
+ ...renderTranscript(theme, snapshot, attempt, cache, width),
250
+ ...renderResult(theme, attempt, width),
251
+ ...renderGateDecision(theme, attempt, width),
252
+ ];
253
+ }
254
+
255
+ function attemptDisplayOrdinal(
256
+ attempt: StepExecutionAttempt,
257
+ index: number,
258
+ omittedAttempts: number,
259
+ ): number {
260
+ if (attempt.ordinal !== undefined) return attempt.ordinal;
261
+ if (omittedAttempts === 0 || index === 0) return index + 1;
262
+ return omittedAttempts + index + 1;
263
+ }
264
+
265
+ /** Renders one selected execution-path row and its persisted evidence. */
266
+ export function renderStepDetail(
267
+ theme: WorkflowStatusTheme,
268
+ snapshot: WorkflowStatusSnapshot,
269
+ selectedIndex: number,
270
+ cache: StepTranscriptViewCache,
271
+ width: number,
272
+ ): Array<string> {
273
+ const entries = buildPathEntries(snapshot);
274
+ const entry = entries[selectedIndex];
275
+ const detail = selectedStepDetail(snapshot, selectedIndex);
276
+ if (!entry || !detail) {
277
+ return boxed(
278
+ theme,
279
+ 'Step Explorer',
280
+ width,
281
+ [theme.fg('muted', 'The selected step is no longer available.')],
282
+ 'borderAccent',
283
+ );
284
+ }
285
+
286
+ const history = detail.historyEntry;
287
+ const finalArtifact =
288
+ history?.artifact ??
289
+ (entry.isCurrent ? snapshot.run.pendingGate?.artifact : undefined);
290
+ const artifact =
291
+ finalArtifact === undefined ? undefined : boundedArtifact(finalArtifact);
292
+ const attempts = detail.attempts.flatMap((attempt, index) => [
293
+ ...(index > 0 ? ['', theme.fg('dim', '─'.repeat(Math.max(1, width)))] : []),
294
+ ...renderAttempt(
295
+ theme,
296
+ snapshot,
297
+ attempt,
298
+ attemptDisplayOrdinal(attempt, index, detail.omittedAttempts),
299
+ cache,
300
+ width,
301
+ ),
302
+ ]);
303
+ const currentReason =
304
+ entry.isCurrent && snapshot.run.pauseReason
305
+ ? snapshot.run.pauseReason
306
+ : undefined;
307
+ const body = [
308
+ ...keyValueLines(theme, 'workflow', snapshot.run.workflowId, width),
309
+ ...keyValueLines(theme, 'run', snapshot.run.runId, width, 'muted'),
310
+ ...keyValueLines(theme, 'step', entry.stepId, width),
311
+ ...keyValueLines(theme, 'visit', String(entry.visit), width),
312
+ ...keyValueLines(theme, 'status', entry.status, width),
313
+ ...(history
314
+ ? [
315
+ ...keyValueLines(theme, 'outcome', history.outcome, width, 'success'),
316
+ ...keyValueLines(theme, 'summary', history.summary, width),
317
+ ]
318
+ : []),
319
+ ...(currentReason
320
+ ? keyValueLines(theme, 'reason', currentReason, width, 'warning')
321
+ : []),
322
+ ...(entry.isCurrent && snapshot.run.gateFeedback
323
+ ? keyValueLines(
324
+ theme,
325
+ 'feedback',
326
+ snapshot.run.gateFeedback,
327
+ width,
328
+ 'warning',
329
+ )
330
+ : []),
331
+ ...(history?.workspaceCwd
332
+ ? keyValueLines(theme, 'workspace', history.workspaceCwd, width)
333
+ : entry.isCurrent && snapshot.run.cwd
334
+ ? keyValueLines(theme, 'workspace', snapshot.run.cwd, width)
335
+ : []),
336
+ ...(artifact
337
+ ? [
338
+ '',
339
+ theme.bold('Final/review artifact'),
340
+ ...wrapPlain(
341
+ `${artifact.value}${
342
+ artifact.truncated
343
+ ? '\n… [artifact truncated in status display]'
344
+ : ''
345
+ }`,
346
+ width,
347
+ theme,
348
+ ),
349
+ ]
350
+ : []),
351
+ '',
352
+ ...(detail.omittedAttempts > 0
353
+ ? [
354
+ theme.fg(
355
+ 'warning',
356
+ `${detail.omittedAttempts} attempt${
357
+ detail.omittedAttempts === 1 ? ' was' : 's were'
358
+ } compacted from the bounded checkpoint trace; retained attempt numbers preserve the chronological gap.`,
359
+ ),
360
+ '',
361
+ ]
362
+ : []),
363
+ ...(attempts.length > 0
364
+ ? attempts
365
+ : [
366
+ theme.fg(
367
+ 'muted',
368
+ 'No execution trace was recorded. This step may predate the step-explorer feature.',
369
+ ),
370
+ ]),
371
+ ];
372
+ return boxed(
373
+ theme,
374
+ `Step Explorer · ${entry.title} · visit ${entry.visit}`,
375
+ width,
376
+ body,
377
+ 'borderAccent',
378
+ );
379
+ }
@@ -25,9 +25,11 @@ export function renderHeaderLines(
25
25
  width: number,
26
26
  ): Array<string> {
27
27
  const { run } = snapshot;
28
+ const command = snapshot.workflow?.definition.command;
28
29
  const firstLine = [
29
30
  statusGlyph(theme, runDisplayStatus(run), snapshot.now),
30
31
  theme.bold(inline(run.workflowId)),
32
+ ...(command ? [theme.fg('accent', `/${inline(command)}`)] : []),
31
33
  statusBadge(theme, run.status),
32
34
  theme.fg('muted', '·'),
33
35
  theme.fg(
@@ -62,6 +64,23 @@ export function renderSummaryLines(
62
64
  const { run, workflow } = snapshot;
63
65
  const lines = [
64
66
  ...keyValueLines(theme, 'workflow', run.workflowId, width),
67
+ ...(workflow
68
+ ? [
69
+ ...keyValueLines(
70
+ theme,
71
+ 'command',
72
+ `/${workflow.definition.command}`,
73
+ width,
74
+ 'accent',
75
+ ),
76
+ ...keyValueLines(
77
+ theme,
78
+ 'about',
79
+ workflow.definition.description,
80
+ width,
81
+ ),
82
+ ]
83
+ : []),
65
84
  ...keyValueLines(theme, 'run', run.runId, width),
66
85
  ...keyValueLines(
67
86
  theme,
@@ -91,6 +110,9 @@ export function renderSummaryLines(
91
110
  ),
92
111
  ];
93
112
 
113
+ if (run.cwd && run.startCwd && run.cwd !== run.startCwd) {
114
+ lines.push(...keyValueLines(theme, 'workspace', run.cwd, width, 'accent'));
115
+ }
94
116
  const execution = formatExecution(snapshot.execution);
95
117
  if (execution) {
96
118
  lines.push(
@@ -0,0 +1,231 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import {
5
+ MAX_STEP_TRACE_LOG_CHARS,
6
+ MAX_STEP_TRACE_LOG_EVENTS,
7
+ type SubagentTranscriptReference,
8
+ } from '../engine/state.ts';
9
+ import {
10
+ redactStepLogText,
11
+ redactStepLogValue,
12
+ sanitizeStepLogText,
13
+ stepLogLinesFromMessage,
14
+ } from '../step-log.ts';
15
+
16
+ const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
17
+
18
+ export type StepTranscriptLog =
19
+ | {
20
+ readonly status: 'available';
21
+ readonly lines: ReadonlyArray<string>;
22
+ readonly truncated: boolean;
23
+ }
24
+ | {
25
+ readonly status: 'unavailable';
26
+ readonly reason: string;
27
+ };
28
+
29
+ type UnknownRecord = Readonly<Record<string, unknown>>;
30
+
31
+ function isRecord(value: unknown): value is UnknownRecord {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function isWithin(root: string, candidate: string): boolean {
36
+ const pathFromRoot = relative(resolve(root), resolve(candidate));
37
+ return (
38
+ pathFromRoot !== '' &&
39
+ pathFromRoot !== '..' &&
40
+ !pathFromRoot.startsWith(`..${sep}`) &&
41
+ !isAbsolute(pathFromRoot)
42
+ );
43
+ }
44
+
45
+ function hasSafeIdentity(reference: SubagentTranscriptReference): boolean {
46
+ return (
47
+ isAbsolute(reference.trustedRoot) &&
48
+ isAbsolute(reference.sessionFile) &&
49
+ !reference.trustedRoot.includes('\0') &&
50
+ !reference.sessionFile.includes('\0') &&
51
+ reference.runId.length > 0 &&
52
+ !reference.runId.includes('\0') &&
53
+ !reference.runId.includes('/') &&
54
+ !reference.runId.includes('\\') &&
55
+ reference.runId !== '.' &&
56
+ reference.runId !== '..' &&
57
+ Number.isSafeInteger(reference.childIndex) &&
58
+ reference.childIndex >= 0 &&
59
+ resolve(reference.sessionFile) ===
60
+ resolve(
61
+ reference.trustedRoot,
62
+ reference.runId,
63
+ `run-${reference.childIndex}`,
64
+ 'session.jsonl',
65
+ )
66
+ );
67
+ }
68
+
69
+ export {
70
+ redactStepDetailText as redactStatusDetailText,
71
+ redactStepLogText as redactStatusLogText,
72
+ sanitizeStepLogText as sanitizeStatusLogText,
73
+ } from '../step-log.ts';
74
+
75
+ function transcriptEntryLines(entry: unknown): Array<string> {
76
+ if (!isRecord(entry)) return [];
77
+ if (entry.type === 'custom_message') {
78
+ const customType =
79
+ typeof entry.customType === 'string' ? entry.customType : 'custom';
80
+ const content =
81
+ typeof entry.content === 'string'
82
+ ? redactStepLogText(entry.content)
83
+ : redactStepLogValue(entry.details);
84
+ return content
85
+ ? [`event ${sanitizeStepLogText(customType)}\n${content}`]
86
+ : [];
87
+ }
88
+ if (entry.type !== 'message' || !isRecord(entry.message)) return [];
89
+ return stepLogLinesFromMessage(entry.message);
90
+ }
91
+
92
+ function parseTranscript(
93
+ content: string,
94
+ sourceTruncated: boolean,
95
+ ): StepTranscriptLog {
96
+ const lines: Array<string> = [];
97
+ let chars = 0;
98
+ let truncated = sourceTruncated;
99
+ for (const rawLine of content.split('\n')) {
100
+ if (!rawLine.trim()) continue;
101
+ let entry: unknown;
102
+ try {
103
+ entry = JSON.parse(rawLine);
104
+ } catch {
105
+ continue;
106
+ }
107
+ for (const line of transcriptEntryLines(entry)) {
108
+ if (
109
+ lines.length >= MAX_STEP_TRACE_LOG_EVENTS ||
110
+ chars + line.length > MAX_STEP_TRACE_LOG_CHARS
111
+ ) {
112
+ truncated = true;
113
+ break;
114
+ }
115
+ lines.push(line);
116
+ chars += line.length;
117
+ }
118
+ if (
119
+ lines.length >= MAX_STEP_TRACE_LOG_EVENTS ||
120
+ chars >= MAX_STEP_TRACE_LOG_CHARS
121
+ ) {
122
+ break;
123
+ }
124
+ }
125
+ return { status: 'available', lines, truncated };
126
+ }
127
+
128
+ /**
129
+ * Reads one child transcript through a confined, no-follow, stable prefix.
130
+ *
131
+ * The private child-policy user message is never rendered. Callers display
132
+ * the separately persisted policy-envelope-free task body instead.
133
+ */
134
+ export async function readStepTranscript(
135
+ reference: SubagentTranscriptReference,
136
+ ): Promise<StepTranscriptLog> {
137
+ if (!hasSafeIdentity(reference)) {
138
+ return {
139
+ status: 'unavailable',
140
+ reason: 'The recorded child transcript identity is invalid.',
141
+ };
142
+ }
143
+ try {
144
+ const runDirectory = resolve(reference.trustedRoot, reference.runId);
145
+ const childDirectory = resolve(runDirectory, `run-${reference.childIndex}`);
146
+ const [runDirectoryInfo, childDirectoryInfo] = await Promise.all([
147
+ lstat(runDirectory),
148
+ lstat(childDirectory),
149
+ ]);
150
+ if (
151
+ runDirectoryInfo.isSymbolicLink() ||
152
+ !runDirectoryInfo.isDirectory() ||
153
+ childDirectoryInfo.isSymbolicLink() ||
154
+ !childDirectoryInfo.isDirectory()
155
+ ) {
156
+ return {
157
+ status: 'unavailable',
158
+ reason:
159
+ 'The recorded child transcript directory identity is not trusted.',
160
+ };
161
+ }
162
+ const inspected = await lstat(reference.sessionFile);
163
+ if (inspected.isSymbolicLink() || !inspected.isFile()) {
164
+ return {
165
+ status: 'unavailable',
166
+ reason: 'The recorded child transcript is not a regular file.',
167
+ };
168
+ }
169
+ const [canonicalRoot, canonicalSession] = await Promise.all([
170
+ realpath(reference.trustedRoot),
171
+ realpath(reference.sessionFile),
172
+ ]);
173
+ const canonicalExpected = resolve(
174
+ canonicalRoot,
175
+ reference.runId,
176
+ `run-${reference.childIndex}`,
177
+ 'session.jsonl',
178
+ );
179
+ if (
180
+ !isWithin(canonicalRoot, canonicalSession) ||
181
+ canonicalSession !== canonicalExpected
182
+ ) {
183
+ return {
184
+ status: 'unavailable',
185
+ reason:
186
+ 'The recorded child transcript does not match its trusted canonical identity.',
187
+ };
188
+ }
189
+
190
+ const handle = await open(
191
+ canonicalSession,
192
+ constants.O_RDONLY | constants.O_NOFOLLOW,
193
+ );
194
+ try {
195
+ const before = await handle.stat();
196
+ if (!before.isFile()) {
197
+ return {
198
+ status: 'unavailable',
199
+ reason: 'The recorded child transcript is not readable.',
200
+ };
201
+ }
202
+ const bytesToRead = Math.min(before.size, MAX_TRANSCRIPT_BYTES);
203
+ const buffer = Buffer.alloc(bytesToRead);
204
+ const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
205
+ const after = await handle.stat();
206
+ if (
207
+ after.dev !== before.dev ||
208
+ after.ino !== before.ino ||
209
+ after.size !== before.size ||
210
+ after.mtimeMs !== before.mtimeMs
211
+ ) {
212
+ return {
213
+ status: 'unavailable',
214
+ reason: 'The child transcript changed while it was being read.',
215
+ };
216
+ }
217
+ return parseTranscript(
218
+ buffer.subarray(0, bytesRead).toString('utf8'),
219
+ before.size > bytesToRead,
220
+ );
221
+ } finally {
222
+ await handle.close();
223
+ }
224
+ } catch {
225
+ return {
226
+ status: 'unavailable',
227
+ reason:
228
+ 'The recorded child transcript is missing or cannot be read safely.',
229
+ };
230
+ }
231
+ }
@@ -2,6 +2,8 @@ import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent';
2
2
  import type { TUI } from '@earendil-works/pi-tui';
3
3
  import type { LoadedWorkflow } from '../config/types.ts';
4
4
  import type { WorkflowRun, WorkflowRunStatus } from '../engine/state.ts';
5
+ import type { StepExecutionAttempt } from '../engine/state.ts';
6
+ import type { StepTranscriptLog } from './transcript-reader.ts';
5
7
 
6
8
  export type WorkflowStatusExecution =
7
9
  | {
@@ -28,6 +30,8 @@ export type StatusViewTui = Pick<TUI, 'requestRender'> & {
28
30
  };
29
31
 
30
32
  export type PathEntry = {
33
+ readonly index: number;
34
+ readonly historyIndex?: number;
31
35
  readonly stepId: string;
32
36
  readonly title: string;
33
37
  readonly status: StepDisplayStatus;
@@ -36,5 +40,9 @@ export type PathEntry = {
36
40
  readonly isCurrent: boolean;
37
41
  };
38
42
 
43
+ export type StepTranscriptLoader = (
44
+ attempt: Extract<StepExecutionAttempt, { readonly kind: 'subagent' }>,
45
+ ) => Promise<StepTranscriptLog>;
46
+
39
47
  export type WorkflowStatusThemeColor = ThemeColor;
40
48
  export type WorkflowStatusTheme = Pick<Theme, 'fg' | 'bg' | 'bold'>;