@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,178 @@
1
+ import { MAX_STEP_TRACE_LOG_EVENT_CHARS } from './engine/state-types.ts';
2
+
3
+ const REDACTED = '[redacted]';
4
+ const SECRET_KEY =
5
+ /(?:^|[-_])(authorization|cookie|set-cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|token|secret|password|passwd|credential|private[-_]?key|client[-_]?secret)(?:$|[-_])/i;
6
+ const LABELED_VALUE =
7
+ /(^|[^A-Za-z0-9_])([A-Za-z0-9_-]*(?:authorization|proxy[-_]?authorization|cookie|set[-_]?cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|password|passwd|credential|token|secret)[A-Za-z0-9_-]*)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gim;
8
+
9
+ type UnknownRecord = Readonly<Record<string, unknown>>;
10
+
11
+ const isRecord = (value: unknown): value is UnknownRecord =>
12
+ value !== null && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ const sanitizeControls = (value: string): string => {
15
+ let safe = '';
16
+ for (const character of value) {
17
+ const code = character.codePointAt(0) ?? 0;
18
+ const isAllowedWhitespace = code === 9 || code === 10;
19
+ if (
20
+ (!isAllowedWhitespace && code < 32) ||
21
+ code === 127 ||
22
+ (code >= 128 && code <= 159)
23
+ ) {
24
+ continue;
25
+ }
26
+ safe += character;
27
+ }
28
+ return safe.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
29
+ };
30
+
31
+ /** Removes terminal control bytes and bounds one persisted/displayed log event. */
32
+ export function sanitizeStepLogText(value: string): string {
33
+ const normalized = sanitizeControls(value);
34
+ return normalized.length <= MAX_STEP_TRACE_LOG_EVENT_CHARS
35
+ ? normalized
36
+ : `${normalized.slice(0, MAX_STEP_TRACE_LOG_EVENT_CHARS - 1)}…`;
37
+ }
38
+
39
+ const redactStructured = (value: unknown, depth = 0): unknown => {
40
+ if (depth > 20) return '[nested value omitted]';
41
+ if (Array.isArray(value)) {
42
+ return value.map((item) => redactStructured(item, depth + 1));
43
+ }
44
+ if (!isRecord(value)) {
45
+ return typeof value === 'string' ? sanitizeStepLogText(value) : value;
46
+ }
47
+ return Object.fromEntries(
48
+ Object.entries(value).map(([key, item]) => [
49
+ key,
50
+ SECRET_KEY.test(`-${key}-`)
51
+ ? REDACTED
52
+ : redactStructured(item, depth + 1),
53
+ ]),
54
+ );
55
+ };
56
+
57
+ const redactCommonCredentials = (value: string): string =>
58
+ value
59
+ .replaceAll(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${REDACTED}`)
60
+ .replace(
61
+ LABELED_VALUE,
62
+ (original: string, prefix: string, key: string): string =>
63
+ SECRET_KEY.test(`-${key}-`) ? `${prefix}${key}=${REDACTED}` : original,
64
+ );
65
+
66
+ /** Removes terminal controls and common credential forms from one log event. */
67
+ export function redactStepLogText(value: string): string {
68
+ return redactCommonCredentials(sanitizeStepLogText(value));
69
+ }
70
+
71
+ /** Redacts a checkpoint-bounded detail field without per-event truncation. */
72
+ export function redactStepDetailText(value: string): string {
73
+ return redactCommonCredentials(sanitizeControls(value));
74
+ }
75
+
76
+ /** Formats and redacts an unknown structured value for a step log. */
77
+ export function redactStepLogValue(value: unknown): string {
78
+ if (typeof value === 'string') return redactStepLogText(value);
79
+ try {
80
+ return redactStepLogText(
81
+ JSON.stringify(redactStructured(value), undefined, 2),
82
+ );
83
+ } catch {
84
+ return '[unserializable value]';
85
+ }
86
+ }
87
+
88
+ const contentText = (content: unknown): string => {
89
+ if (typeof content === 'string') return redactStepLogText(content);
90
+ if (!Array.isArray(content)) return '';
91
+ return content
92
+ .flatMap((item) => {
93
+ if (!isRecord(item)) return [];
94
+ if (item.type === 'text' && typeof item.text === 'string') {
95
+ return [item.text];
96
+ }
97
+ if (item.type === 'image') return ['[image]'];
98
+ if (item.type === 'audio') return ['[audio]'];
99
+ return [];
100
+ })
101
+ .map(redactStepLogText)
102
+ .filter(Boolean)
103
+ .join('\n');
104
+ };
105
+
106
+ /**
107
+ * Returns exact text only for a text-only user message.
108
+ *
109
+ * Pi converts extension-supplied workflow tasks to one or more text blocks.
110
+ * Rejecting non-text blocks keeps trace arming tied to that exact task shape.
111
+ */
112
+ export function textOnlyUserMessage(message: unknown): string | undefined {
113
+ if (!isRecord(message) || message.role !== 'user') return undefined;
114
+ if (typeof message.content === 'string') return message.content;
115
+ if (
116
+ !Array.isArray(message.content) ||
117
+ message.content.some(
118
+ (item) =>
119
+ !isRecord(item) ||
120
+ item.type !== 'text' ||
121
+ typeof item.text !== 'string',
122
+ )
123
+ ) {
124
+ return undefined;
125
+ }
126
+ return message.content
127
+ .map((item) => (item as { readonly text: string }).text)
128
+ .join('\n');
129
+ }
130
+
131
+ /** Projects one finalized assistant or tool-result message to safe log lines. */
132
+ export function stepLogLinesFromMessage(message: unknown): Array<string> {
133
+ if (!isRecord(message)) return [];
134
+ if (message.role === 'assistant') {
135
+ if (!Array.isArray(message.content)) return [];
136
+ const contentLines = message.content.flatMap((item) => {
137
+ if (!isRecord(item)) return [];
138
+ if (item.type === 'text' && typeof item.text === 'string') {
139
+ const text = redactStepLogText(item.text);
140
+ return text ? [`assistant\n${text}`] : [];
141
+ }
142
+ if (item.type === 'toolCall' && typeof item.name === 'string') {
143
+ const argumentsText = redactStepLogValue(item.arguments);
144
+ return [
145
+ `tool call · ${sanitizeStepLogText(item.name)}${
146
+ argumentsText ? `\n${argumentsText}` : ''
147
+ }`,
148
+ ];
149
+ }
150
+ // Thinking is intentionally excluded from persisted and displayed logs.
151
+ return [];
152
+ });
153
+ const error =
154
+ typeof message.errorMessage === 'string'
155
+ ? redactStepLogText(message.errorMessage)
156
+ : '';
157
+ return [...contentLines, ...(error ? [`assistant error\n${error}`] : [])];
158
+ }
159
+ if (message.role !== 'toolResult') return [];
160
+ const tool =
161
+ typeof message.toolName === 'string'
162
+ ? sanitizeStepLogText(message.toolName)
163
+ : 'unknown tool';
164
+ const result = contentText(message.content);
165
+ const state = message.isError === true ? 'error' : 'result';
166
+ return [`tool ${state} · ${tool}${result ? `\n${result}` : ''}`];
167
+ }
168
+
169
+ /** Projects a finalized Pi turn in stable assistant/source-result order. */
170
+ export function stepLogLinesFromTurn(
171
+ message: unknown,
172
+ toolResults: ReadonlyArray<unknown>,
173
+ ): Array<string> {
174
+ return [
175
+ ...stepLogLinesFromMessage(message),
176
+ ...toolResults.flatMap(stepLogLinesFromMessage),
177
+ ];
178
+ }
@@ -0,0 +1,254 @@
1
+ import type { WorkflowDefinition, WorkflowStep } from './config/types.ts';
2
+
3
+ export type WorkflowDoctorLevel = 'error' | 'warning';
4
+
5
+ export type WorkflowDoctorIssue = {
6
+ readonly level: WorkflowDoctorLevel;
7
+ readonly code:
8
+ | 'no-completion-path'
9
+ | 'reachable-step-cannot-reach-done'
10
+ | 'unreachable-steps'
11
+ | 'cycle';
12
+ readonly steps: ReadonlyArray<string>;
13
+ readonly message: string;
14
+ readonly reachable?: boolean;
15
+ readonly canReachDone?: boolean;
16
+ };
17
+
18
+ export type WorkflowDoctorReport = {
19
+ readonly workflowId: string;
20
+ readonly maxStepVisits: number;
21
+ readonly reachableSteps: ReadonlyArray<string>;
22
+ readonly issues: ReadonlyArray<WorkflowDoctorIssue>;
23
+ };
24
+
25
+ const lexical = (left: string, right: string): number =>
26
+ left < right ? -1 : left > right ? 1 : 0;
27
+
28
+ const internalTargets = (step: WorkflowStep): ReadonlyArray<string> =>
29
+ [
30
+ ...new Set(
31
+ Object.values(step.transitions).filter(
32
+ (target) => target !== '$done' && target !== '$pause',
33
+ ),
34
+ ),
35
+ ].sort(lexical);
36
+
37
+ const adjacencyFor = (
38
+ definition: WorkflowDefinition,
39
+ ): Readonly<Record<string, ReadonlyArray<string>>> =>
40
+ Object.fromEntries(
41
+ Object.entries(definition.steps)
42
+ .sort(([left], [right]) => lexical(left, right))
43
+ .map(([stepId, step]) => [stepId, internalTargets(step)]),
44
+ );
45
+
46
+ const reachableSteps = (
47
+ definition: WorkflowDefinition,
48
+ adjacency: Readonly<Record<string, ReadonlyArray<string>>>,
49
+ ): ReadonlySet<string> => {
50
+ const reachable = new Set<string>();
51
+ const pending = [definition.start];
52
+ while (pending.length > 0) {
53
+ const stepId = pending.pop();
54
+ if (!stepId || reachable.has(stepId)) continue;
55
+ if (!definition.steps[stepId]) continue;
56
+ reachable.add(stepId);
57
+ pending.push(...(adjacency[stepId] ?? []));
58
+ }
59
+ return reachable;
60
+ };
61
+
62
+ const stepsThatCanComplete = (
63
+ definition: WorkflowDefinition,
64
+ adjacency: Readonly<Record<string, ReadonlyArray<string>>>,
65
+ ): ReadonlySet<string> => {
66
+ const canComplete = new Set<string>(
67
+ Object.entries(definition.steps)
68
+ .filter(([, step]) => Object.values(step.transitions).includes('$done'))
69
+ .map(([stepId]) => stepId),
70
+ );
71
+
72
+ const reverse = new Map<string, Array<string>>();
73
+ for (const [source, targets] of Object.entries(adjacency)) {
74
+ for (const target of targets) {
75
+ reverse.set(target, [...(reverse.get(target) ?? []), source]);
76
+ }
77
+ }
78
+ const pending = [...canComplete].sort(lexical);
79
+ while (pending.length > 0) {
80
+ const stepId = pending.pop();
81
+ if (!stepId) continue;
82
+ for (const predecessor of (reverse.get(stepId) ?? []).sort(lexical)) {
83
+ if (canComplete.has(predecessor)) continue;
84
+ canComplete.add(predecessor);
85
+ pending.push(predecessor);
86
+ }
87
+ }
88
+ return canComplete;
89
+ };
90
+
91
+ const stronglyConnectedComponents = (
92
+ definition: WorkflowDefinition,
93
+ adjacency: Readonly<Record<string, ReadonlyArray<string>>>,
94
+ ): ReadonlyArray<ReadonlyArray<string>> => {
95
+ let nextIndex = 0;
96
+ const indexes = new Map<string, number>();
97
+ const lowLinks = new Map<string, number>();
98
+ const stack: Array<string> = [];
99
+ const onStack = new Set<string>();
100
+ const components: Array<Array<string>> = [];
101
+
102
+ const visit = (stepId: string): void => {
103
+ indexes.set(stepId, nextIndex);
104
+ lowLinks.set(stepId, nextIndex);
105
+ nextIndex += 1;
106
+ stack.push(stepId);
107
+ onStack.add(stepId);
108
+
109
+ for (const target of adjacency[stepId] ?? []) {
110
+ if (!indexes.has(target)) {
111
+ visit(target);
112
+ lowLinks.set(
113
+ stepId,
114
+ Math.min(lowLinks.get(stepId) ?? 0, lowLinks.get(target) ?? 0),
115
+ );
116
+ } else if (onStack.has(target)) {
117
+ lowLinks.set(
118
+ stepId,
119
+ Math.min(lowLinks.get(stepId) ?? 0, indexes.get(target) ?? 0),
120
+ );
121
+ }
122
+ }
123
+
124
+ if (lowLinks.get(stepId) !== indexes.get(stepId)) return;
125
+ const component: Array<string> = [];
126
+ let member: string | undefined;
127
+ do {
128
+ member = stack.pop();
129
+ if (!member) break;
130
+ onStack.delete(member);
131
+ component.push(member);
132
+ } while (member !== stepId);
133
+ components.push(component.sort(lexical));
134
+ };
135
+
136
+ for (const stepId of Object.keys(definition.steps).sort(lexical)) {
137
+ if (!indexes.has(stepId)) visit(stepId);
138
+ }
139
+ return components.sort((left, right) =>
140
+ lexical(left.join('\0'), right.join('\0')),
141
+ );
142
+ };
143
+
144
+ const isCycle = (
145
+ adjacency: Readonly<Record<string, ReadonlyArray<string>>>,
146
+ component: ReadonlyArray<string>,
147
+ ): boolean => {
148
+ if (component.length > 1) return true;
149
+ const [stepId] = component;
150
+ return Boolean(stepId && adjacency[stepId]?.includes(stepId));
151
+ };
152
+
153
+ /**
154
+ * Analyzes only the declarative transition graph. Outcome names and prompt
155
+ * content are intentionally opaque to the workflow engine.
156
+ */
157
+ export function analyzeWorkflow(
158
+ definition: WorkflowDefinition,
159
+ ): WorkflowDoctorReport {
160
+ const adjacency = adjacencyFor(definition);
161
+ const reachable = reachableSteps(definition, adjacency);
162
+ const canComplete = stepsThatCanComplete(definition, adjacency);
163
+ const issues: Array<WorkflowDoctorIssue> = [];
164
+ const stranded = [...reachable]
165
+ .filter((stepId) => !canComplete.has(stepId))
166
+ .sort(lexical);
167
+ if (!canComplete.has(definition.start)) {
168
+ issues.push({
169
+ level: 'error',
170
+ code: 'no-completion-path',
171
+ steps: [definition.start],
172
+ message: `start step ${definition.start} cannot reach $done`,
173
+ });
174
+ }
175
+ if (stranded.length > 0) {
176
+ issues.push({
177
+ level: 'error',
178
+ code: 'reachable-step-cannot-reach-done',
179
+ steps: stranded,
180
+ message: `reachable step${stranded.length === 1 ? '' : 's'} ${stranded.join(', ')} cannot reach $done`,
181
+ });
182
+ }
183
+
184
+ const unreachable = Object.keys(definition.steps)
185
+ .filter((stepId) => !reachable.has(stepId))
186
+ .sort(lexical);
187
+ if (unreachable.length > 0) {
188
+ issues.push({
189
+ level: 'warning',
190
+ code: 'unreachable-steps',
191
+ steps: unreachable,
192
+ message: `unreachable step${unreachable.length === 1 ? '' : 's'}: ${unreachable.join(', ')}`,
193
+ });
194
+ }
195
+
196
+ for (const component of stronglyConnectedComponents(definition, adjacency)) {
197
+ if (!isCycle(adjacency, component)) continue;
198
+ const componentIsReachable = component.some((stepId) =>
199
+ reachable.has(stepId),
200
+ );
201
+ const componentCanReachDone = component.some((stepId) =>
202
+ canComplete.has(stepId),
203
+ );
204
+ issues.push({
205
+ level: 'warning',
206
+ code: 'cycle',
207
+ steps: component,
208
+ reachable: componentIsReachable,
209
+ canReachDone: componentCanReachDone,
210
+ message: `${componentIsReachable ? 'reachable' : 'unreachable'} cyclic component: ${component.join(', ')}; ${componentCanReachDone ? 'an exit can reach $done' : 'no member can reach $done'}; maxStepVisits=${definition.maxStepVisits} bounds uninterrupted graph cycling`,
211
+ });
212
+ }
213
+
214
+ return {
215
+ workflowId: definition.id,
216
+ maxStepVisits: definition.maxStepVisits,
217
+ reachableSteps: [...reachable].sort(lexical),
218
+ issues,
219
+ };
220
+ }
221
+
222
+ const escapeMarkdown = (value: string): string =>
223
+ value.replaceAll('\\', '\\\\').replaceAll('|', '\\|');
224
+
225
+ /** Formats one or more graph reports for `/workflow-doctor`. */
226
+ export function formatWorkflowDoctor(
227
+ reports: ReadonlyArray<WorkflowDoctorReport>,
228
+ ): string {
229
+ const lines = ['# Workflow doctor', ''];
230
+ for (const report of reports) {
231
+ const errors = report.issues.filter((issue) => issue.level === 'error');
232
+ const warnings = report.issues.filter((issue) => issue.level === 'warning');
233
+ lines.push(
234
+ `## ${report.workflowId}`,
235
+ '',
236
+ `Result: ${errors.length > 0 ? 'ERROR' : warnings.length > 0 ? 'WARNING' : 'PASS'}`,
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.`,
239
+ '',
240
+ );
241
+ if (report.issues.length === 0) {
242
+ lines.push('- No liveness issues found.', '');
243
+ continue;
244
+ }
245
+ lines.push(
246
+ ...report.issues.map(
247
+ (issue) =>
248
+ `- ${issue.level.toUpperCase()} \`${issue.code}\`: ${escapeMarkdown(issue.message)}`,
249
+ ),
250
+ '',
251
+ );
252
+ }
253
+ return lines.join('\n').trimEnd();
254
+ }
@@ -1,5 +1,10 @@
1
1
  import { DEFAULT_STATUS_SHORTCUT } from '../config/types.ts';
2
- import { formatShortcutLabel } from './formatting.ts';
2
+ import {
3
+ formatShortcutLabel,
4
+ formatStepName,
5
+ stepTitle,
6
+ workflowStatusIcon,
7
+ } from './formatting.ts';
3
8
  import { renderBoard } from './render-board.ts';
4
9
  import type { WorkflowStatusSnapshot, WorkflowStatusTheme } from './types.ts';
5
10
 
@@ -9,18 +14,42 @@ const UNSTYLED_THEME = {
9
14
  bold: (value: string) => value,
10
15
  } as const satisfies WorkflowStatusTheme;
11
16
 
17
+ /** Format the persistent main-UI progress line for a running workflow. */
18
+ export function formatWorkflowProgressStatus(
19
+ snapshot: WorkflowStatusSnapshot,
20
+ statusShortcutLabel: string,
21
+ ): string {
22
+ const { run, workflow } = snapshot;
23
+ const currentStep = formatStepName(
24
+ stepTitle(workflow, run.currentStepId),
25
+ run.currentStepId,
26
+ );
27
+ const activity =
28
+ run.status === 'awaiting-gate' ? 'awaiting review' : 'working';
29
+ return `${workflowStatusIcon(run, snapshot.now)} ${run.workflowId} · step ${currentStep} · ${activity} · ${statusShortcutLabel}`;
30
+ }
31
+
12
32
  /** Format a plain-text workflow status suitable for fallback notifications. */
13
33
  export function formatWorkflowStatusText(
14
34
  snapshot: WorkflowStatusSnapshot,
15
35
  ): string {
16
- const { run } = snapshot;
36
+ const { run, workflow } = snapshot;
17
37
  const lines = [
18
- `Workflow: ${run.workflowId}`,
38
+ `Workflow ID: ${run.workflowId}`,
39
+ ...(workflow
40
+ ? [
41
+ `Command: /${workflow.definition.command}`,
42
+ `Description: ${workflow.definition.description}`,
43
+ ]
44
+ : []),
19
45
  `Run: ${run.runId}`,
20
46
  `Status: ${run.status}`,
21
47
  `Step: ${run.currentStepId}`,
22
48
  `Completed steps: ${run.history.length}`,
23
49
  ];
50
+ if (run.cwd && run.startCwd && run.cwd !== run.startCwd) {
51
+ lines.push(`Workspace: ${run.cwd}`);
52
+ }
24
53
  if (run.pendingGate?.reviewId) {
25
54
  lines.push(`Review: ${run.pendingGate.reviewId}`);
26
55
  }
@@ -12,6 +12,7 @@ export function renderBoard(
12
12
  width: number,
13
13
  shouldShowCloseHint: boolean,
14
14
  statusShortcutLabel: string,
15
+ selectedIndex?: number,
15
16
  ): Array<string> {
16
17
  const header = boxed(
17
18
  theme,
@@ -23,8 +24,8 @@ export function renderBoard(
23
24
 
24
25
  const body =
25
26
  width >= WIDE_LAYOUT_MIN_COLUMNS
26
- ? renderWideBody(theme, snapshot, width)
27
- : renderNarrowBody(theme, snapshot, width);
27
+ ? renderWideBody(theme, snapshot, width, selectedIndex)
28
+ : renderNarrowBody(theme, snapshot, width, selectedIndex);
28
29
  const lines = [...header, '', ...body];
29
30
  return shouldShowCloseHint
30
31
  ? [
@@ -39,6 +40,7 @@ function renderWideBody(
39
40
  theme: WorkflowStatusTheme,
40
41
  snapshot: WorkflowStatusSnapshot,
41
42
  width: number,
43
+ selectedIndex?: number,
42
44
  ): Array<string> {
43
45
  const gap = 2;
44
46
  const summaryWidth = Math.min(42, Math.max(36, Math.floor(width * 0.36)));
@@ -53,7 +55,7 @@ function renderWideBody(
53
55
  theme,
54
56
  'Execution Path',
55
57
  pathWidth,
56
- renderPathLines(theme, snapshot, pathWidth - 4),
58
+ renderPathLines(theme, snapshot, pathWidth - 4, selectedIndex),
57
59
  'borderAccent',
58
60
  );
59
61
  return joinPanels(summary, summaryWidth, path, pathWidth, gap);
@@ -63,6 +65,7 @@ function renderNarrowBody(
63
65
  theme: WorkflowStatusTheme,
64
66
  snapshot: WorkflowStatusSnapshot,
65
67
  width: number,
68
+ selectedIndex?: number,
66
69
  ): Array<string> {
67
70
  return [
68
71
  ...boxed(
@@ -76,7 +79,7 @@ function renderNarrowBody(
76
79
  theme,
77
80
  'Execution Path',
78
81
  width,
79
- renderPathLines(theme, snapshot, width - 4),
82
+ renderPathLines(theme, snapshot, width - 4, selectedIndex),
80
83
  'borderAccent',
81
84
  ),
82
85
  ];
@@ -22,8 +22,11 @@ function historyPathEntry(
22
22
  workflow: LoadedWorkflow | undefined,
23
23
  entry: StepHistoryEntry,
24
24
  visit: number,
25
+ index: number,
25
26
  ): PathEntry {
26
27
  return {
28
+ index,
29
+ historyIndex: index,
27
30
  stepId: entry.stepId,
28
31
  title: stepTitle(workflow, entry.stepId),
29
32
  status: 'completed',
@@ -39,16 +42,17 @@ export function buildPathEntries(
39
42
  ): Array<PathEntry> {
40
43
  const { run, workflow } = snapshot;
41
44
  const visits = new Map<string, number>();
42
- const completedEntries = run.history.map((entry) => {
45
+ const completedEntries = run.history.map((entry, index) => {
43
46
  const visit = (visits.get(entry.stepId) ?? 0) + 1;
44
47
  visits.set(entry.stepId, visit);
45
- return historyPathEntry(workflow, entry, visit);
48
+ return historyPathEntry(workflow, entry, visit, index);
46
49
  });
47
50
 
48
51
  if (run.status !== 'completed') {
49
52
  return [
50
53
  ...completedEntries,
51
54
  {
55
+ index: completedEntries.length,
52
56
  stepId: run.currentStepId,
53
57
  title: stepTitle(workflow, run.currentStepId),
54
58
  status: runDisplayStatus(run),
@@ -67,6 +71,7 @@ export function buildPathEntries(
67
71
  return [
68
72
  ...completedEntries,
69
73
  {
74
+ index: completedEntries.length,
70
75
  stepId: run.currentStepId,
71
76
  title: stepTitle(workflow, run.currentStepId),
72
77
  status: 'completed',
@@ -83,13 +88,24 @@ export function renderPathLines(
83
88
  theme: WorkflowStatusTheme,
84
89
  snapshot: WorkflowStatusSnapshot,
85
90
  width: number,
91
+ selectedIndex?: number,
86
92
  ): Array<string> {
87
93
  const entries = buildPathEntries(snapshot);
88
94
  if (entries.length === 0) {
89
95
  return [theme.fg('muted', 'No step attempts recorded')];
90
96
  }
91
97
 
92
- const hidden = Math.max(0, entries.length - MAX_PATH_ROWS);
98
+ const selection = Math.max(
99
+ 0,
100
+ Math.min(selectedIndex ?? entries.length - 1, entries.length - 1),
101
+ );
102
+ const maximumStart = Math.max(0, entries.length - MAX_PATH_ROWS);
103
+ const windowStart = Math.max(
104
+ 0,
105
+ Math.min(selection - Math.floor(MAX_PATH_ROWS / 2), maximumStart),
106
+ );
107
+ const hidden = windowStart;
108
+ const hiddenAfter = Math.max(0, entries.length - windowStart - MAX_PATH_ROWS);
93
109
  const prefix =
94
110
  hidden > 0
95
111
  ? [
@@ -99,25 +115,36 @@ export function renderPathLines(
99
115
  ),
100
116
  ]
101
117
  : [];
102
- const visibleRows = entries.slice(hidden).map((entry) => {
103
- const visit =
104
- entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
105
- const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
106
- entry.isCurrent ? 'text' : 'muted',
107
- entry.title,
108
- )}${visit}`;
109
- const right = entry.outcome
110
- ? `${statusLabel(entry.status)} · ${inline(entry.outcome)}`
111
- : statusLabel(entry.status);
112
- const row = joinColumns(
113
- left,
114
- theme.fg(statusColor(entry.status), right),
115
- width,
116
- Math.max(12, Math.floor(width * 0.58)),
117
- );
118
- return entry.isCurrent
119
- ? theme.bg('selectedBg', padAnsi(row, width))
120
- : truncateToWidth(row, width);
121
- });
122
- return [...prefix, ...visibleRows];
118
+ const visibleRows = entries
119
+ .slice(windowStart, windowStart + MAX_PATH_ROWS)
120
+ .map((entry) => {
121
+ const visit =
122
+ entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
123
+ const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
124
+ entry.isCurrent ? 'text' : 'muted',
125
+ entry.title,
126
+ )}${visit}`;
127
+ const right = entry.outcome
128
+ ? `${statusLabel(entry.status)} · ${inline(entry.outcome)}`
129
+ : statusLabel(entry.status);
130
+ const row = joinColumns(
131
+ left,
132
+ theme.fg(statusColor(entry.status), right),
133
+ width,
134
+ Math.max(12, Math.floor(width * 0.58)),
135
+ );
136
+ return entry.index === selection
137
+ ? theme.bg('selectedBg', padAnsi(row, width))
138
+ : truncateToWidth(row, width);
139
+ });
140
+ const suffix =
141
+ hiddenAfter > 0
142
+ ? [
143
+ theme.fg(
144
+ 'dim',
145
+ `… ${hiddenAfter} later attempt${hiddenAfter === 1 ? '' : 's'}`,
146
+ ),
147
+ ]
148
+ : [];
149
+ return [...prefix, ...visibleRows, ...suffix];
123
150
  }