@wichayutdew/pi-workflows 1.0.0 → 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.
- package/README.md +312 -221
- package/agents/step.md +4 -11
- package/dist/index.js +2556 -961
- package/examples/mr-comments.workflow.yaml +18 -8
- package/examples/prompts/mr-comments/implement.md +13 -9
- package/examples/prompts/mr-comments/plan.md +44 -15
- package/examples/prompts/mr-comments/verify.md +8 -7
- package/examples/settings.yaml +7 -1
- package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
- package/examples/starter-kit/mr-review.workflow.yaml +130 -0
- package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
- package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
- package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
- package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
- package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
- package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
- package/examples/starter-kit/steps/mr-review/publish.md +30 -0
- package/examples/starter-kit/steps/mr-review/review.md +69 -0
- package/examples/starter-kit/steps/mr-review/verify.md +24 -0
- package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
- package/examples/starter-kit/steps/ticket/implement.md +35 -0
- package/examples/starter-kit/steps/ticket/plan.md +40 -0
- package/examples/starter-kit/steps/ticket/verify.md +29 -0
- package/examples/starter-kit/steps/work/implement.md +34 -0
- package/examples/starter-kit/steps/work/plan.md +38 -0
- package/examples/starter-kit/steps/work/verify.md +30 -0
- package/examples/starter-kit/ticket.workflow.yaml +100 -0
- package/examples/starter-kit/work.workflow.yaml +97 -0
- package/package.json +3 -3
- package/schemas/workflow.schema.json +78 -35
- package/src/command-names.ts +1 -0
- package/src/commands.ts +33 -4
- package/src/config/ceiling.ts +6 -8
- package/src/config/load-workflows.ts +12 -1
- package/src/config/step-digests.ts +19 -0
- package/src/config/types.ts +17 -11
- package/src/config/validation/permissions.ts +5 -45
- package/src/config/validation/prompt.ts +3 -0
- package/src/config/validation/shared.ts +0 -2
- package/src/config/validation/step.ts +106 -2
- package/src/config/validation/workflow.ts +76 -0
- package/src/engine/create-run.ts +4 -0
- package/src/engine/gate-transitions.ts +58 -6
- package/src/engine/reconciliation-history.ts +61 -24
- package/src/engine/run-advance.ts +37 -4
- package/src/engine/run-lifecycle.ts +11 -0
- package/src/engine/run-reconciliation.ts +51 -21
- package/src/engine/run-validation.ts +236 -0
- package/src/engine/run-workflow-validation.ts +191 -0
- package/src/engine/state-types.ts +109 -4
- package/src/engine/state.ts +15 -1
- package/src/engine/step-trace.ts +495 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +31 -6
- package/src/harness/core-actions.ts +30 -1
- package/src/harness/delegation-control-actions.ts +27 -2
- package/src/harness/delegation-failure.ts +42 -9
- package/src/harness/delegation-plan.ts +96 -40
- package/src/harness/delegation-recovery-validation.ts +3 -1
- package/src/harness/delegation-response-actions.ts +73 -3
- package/src/harness/delegation-retry-policy.ts +5 -3
- package/src/harness/dependencies.ts +11 -2
- package/src/harness/gate-submission-action.ts +5 -31
- package/src/harness/lifecycle-actions.ts +4 -5
- package/src/harness/pause-actions.ts +10 -1
- package/src/harness/plannotator-result-actions.ts +20 -11
- package/src/harness/prompt-gate-actions.ts +40 -2
- package/src/harness/resume-action.ts +75 -1
- package/src/harness/start-actions.ts +77 -3
- package/src/harness/status-actions.ts +7 -17
- package/src/harness/step-effects.ts +61 -0
- package/src/harness/step-execution-actions.ts +181 -25
- package/src/harness/step-reporting.ts +168 -0
- package/src/harness/types.ts +1 -0
- package/src/harness/workspace-directory.ts +67 -0
- package/src/harness.ts +37 -9
- package/src/integrations/plannotator-requests.ts +40 -5
- package/src/integrations/subagents/child-policy-sections.ts +71 -72
- package/src/integrations/subagents/child-policy-types.ts +7 -7
- package/src/integrations/subagents/child-policy-validation.ts +10 -4
- package/src/integrations/subagents/child-runtime-completion.ts +1 -0
- package/src/integrations/subagents/child-runtime-files.ts +29 -86
- package/src/integrations/subagents/child-runtime-policy.ts +8 -33
- package/src/integrations/subagents/child-runtime.ts +3 -15
- package/src/integrations/subagents/delegated-result.ts +1 -0
- package/src/integrations/subagents/diagnostic-format.ts +5 -0
- package/src/integrations/subagents/diagnostic-types.ts +7 -1
- package/src/integrations/subagents/diagnostics.ts +2 -0
- package/src/integrations/subagents/failure-correlation.ts +52 -2
- package/src/integrations/subagents/failure-transcript.ts +60 -1
- package/src/integrations/subagents/replay-audit.ts +0 -1
- package/src/integrations/subagents/replay-safety.ts +12 -27
- package/src/integrations/subagents/session-diagnostics.ts +102 -3
- package/src/policy/bash-authorization.ts +2 -21
- package/src/policy/bash-types.ts +0 -5
- package/src/policy/bash.ts +1 -2
- package/src/policy/tool-call-authorization.ts +1 -7
- package/src/preflight.ts +0 -5
- package/src/prompt/retry-task.ts +11 -3
- package/src/prompt/step-contract.ts +10 -56
- package/src/prompt/step-sections.ts +8 -10
- package/src/prompt/step-task.ts +41 -10
- package/src/prompt/template.ts +5 -2
- package/src/runtime/completion-tool.ts +14 -0
- package/src/runtime/main-step-completion.ts +3 -0
- package/src/runtime/main-step-policy.ts +2 -1
- package/src/runtime/main-step-runtime-types.ts +9 -1
- package/src/runtime/main-step-runtime.ts +2 -0
- package/src/runtime/main-step-state.ts +6 -0
- package/src/runtime/main-step-trace.ts +45 -0
- package/src/runtime/step-result.ts +57 -0
- package/src/step-log.ts +178 -0
- package/src/workflow-doctor.ts +254 -0
- package/src/workflow-status/format-status.ts +32 -3
- package/src/workflow-status/render-board.ts +7 -4
- package/src/workflow-status/render-path.ts +51 -24
- package/src/workflow-status/render-step-detail.ts +379 -0
- package/src/workflow-status/render-summary.ts +22 -0
- package/src/workflow-status/transcript-reader.ts +231 -0
- package/src/workflow-status/types.ts +8 -0
- package/src/workflow-status/view.ts +161 -14
- package/src/workflow-status.ts +6 -0
- package/src/policy/approved-command-extraction.ts +0 -71
- package/src/policy/approved-commands.ts +0 -15
- package/src/policy/bash-read-only.ts +0 -161
- package/src/policy/restricted-git.ts +0 -30
- package/src/policy/reviewed-artifact.ts +0 -97
- package/src/policy/reviewed-command-safety.ts +0 -150
- package/src/policy/reviewed-command-shape.ts +0 -49
- package/src/policy/reviewed-repository-contract.ts +0 -138
- package/src/policy/reviewed-repository-cwd.ts +0 -102
|
@@ -15,8 +15,17 @@ import {
|
|
|
15
15
|
} from './formatting.ts';
|
|
16
16
|
import { padAnsi } from './layout.ts';
|
|
17
17
|
import { renderBoard, renderEmptyBoard } from './render-board.ts';
|
|
18
|
+
import { buildPathEntries } from './render-path.ts';
|
|
19
|
+
import {
|
|
20
|
+
renderStepDetail,
|
|
21
|
+
selectedStepDetail,
|
|
22
|
+
stepTranscriptCacheKey,
|
|
23
|
+
type StepTranscriptViewState,
|
|
24
|
+
} from './render-step-detail.ts';
|
|
25
|
+
import { readStepTranscript } from './transcript-reader.ts';
|
|
18
26
|
import type {
|
|
19
27
|
SnapshotProvider,
|
|
28
|
+
StepTranscriptLoader,
|
|
20
29
|
StatusViewTui,
|
|
21
30
|
WorkflowStatusTheme,
|
|
22
31
|
} from './types.ts';
|
|
@@ -31,10 +40,13 @@ export type WorkflowStatusViewDependencies = {
|
|
|
31
40
|
intervalMilliseconds: number,
|
|
32
41
|
) => RefreshTimer;
|
|
33
42
|
readonly cancelRefresh: (timer: RefreshTimer) => void;
|
|
43
|
+
readonly loadStepTranscript: StepTranscriptLoader;
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
type ViewportState = {
|
|
37
47
|
readonly isClosed: boolean;
|
|
48
|
+
readonly mode: 'board' | 'detail';
|
|
49
|
+
readonly selectedIndex: number;
|
|
38
50
|
readonly scrollOffset: number;
|
|
39
51
|
readonly viewportRows: number;
|
|
40
52
|
readonly contentRows: number;
|
|
@@ -51,11 +63,20 @@ const DEFAULT_VIEW_DEPENDENCIES = {
|
|
|
51
63
|
cancelRefresh: (timer) => {
|
|
52
64
|
clearInterval(timer);
|
|
53
65
|
},
|
|
66
|
+
loadStepTranscript: async (attempt) =>
|
|
67
|
+
attempt.transcript
|
|
68
|
+
? readStepTranscript(attempt.transcript)
|
|
69
|
+
: {
|
|
70
|
+
status: 'unavailable',
|
|
71
|
+
reason: 'No trusted child transcript reference was recorded.',
|
|
72
|
+
},
|
|
54
73
|
} as const satisfies WorkflowStatusViewDependencies;
|
|
55
74
|
|
|
56
75
|
function initialViewportState(): ViewportState {
|
|
57
76
|
return {
|
|
58
77
|
isClosed: false,
|
|
78
|
+
mode: 'board',
|
|
79
|
+
selectedIndex: -1,
|
|
59
80
|
scrollOffset: 0,
|
|
60
81
|
viewportRows: 0,
|
|
61
82
|
contentRows: 0,
|
|
@@ -69,6 +90,7 @@ function paginateBoard(
|
|
|
69
90
|
terminalRows: number | undefined,
|
|
70
91
|
statusShortcutLabel: string,
|
|
71
92
|
theme: WorkflowStatusTheme,
|
|
93
|
+
controls: string,
|
|
72
94
|
): PaginatedBoard {
|
|
73
95
|
const maximumRows =
|
|
74
96
|
terminalRows === undefined
|
|
@@ -82,8 +104,8 @@ function paginateBoard(
|
|
|
82
104
|
const last = Math.min(lines.length, scrollOffset + contentHeight);
|
|
83
105
|
const hint =
|
|
84
106
|
maximumOffset > 0
|
|
85
|
-
?
|
|
86
|
-
: `${statusShortcutLabel} / q
|
|
107
|
+
? `${controls} · rows ${first}-${last}/${lines.length} · ${statusShortcutLabel} / q hide`
|
|
108
|
+
: `${controls} · ${statusShortcutLabel} / q hide · live refresh`;
|
|
87
109
|
return {
|
|
88
110
|
state: {
|
|
89
111
|
...state,
|
|
@@ -114,6 +136,8 @@ export class WorkflowStatusView implements Component {
|
|
|
114
136
|
private timer: RefreshTimer | undefined;
|
|
115
137
|
private state = initialViewportState();
|
|
116
138
|
private readonly statusShortcutLabel: string;
|
|
139
|
+
private readonly dependencies: WorkflowStatusViewDependencies;
|
|
140
|
+
private readonly transcriptCache = new Map<string, StepTranscriptViewState>();
|
|
117
141
|
|
|
118
142
|
/**
|
|
119
143
|
* Creates the stateful adapter around the pure status renderer.
|
|
@@ -131,9 +155,10 @@ export class WorkflowStatusView implements Component {
|
|
|
131
155
|
private readonly theme: WorkflowStatusTheme,
|
|
132
156
|
private readonly done: () => void,
|
|
133
157
|
private readonly statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
|
|
134
|
-
|
|
158
|
+
dependencies: Partial<WorkflowStatusViewDependencies> = {},
|
|
135
159
|
) {
|
|
136
160
|
this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
|
|
161
|
+
this.dependencies = { ...DEFAULT_VIEW_DEPENDENCIES, ...dependencies };
|
|
137
162
|
}
|
|
138
163
|
|
|
139
164
|
/** Start periodic live-refresh requests. */
|
|
@@ -158,7 +183,6 @@ export class WorkflowStatusView implements Component {
|
|
|
158
183
|
if (
|
|
159
184
|
data === 'q' ||
|
|
160
185
|
data === 'Q' ||
|
|
161
|
-
matchesKey(data, 'escape') ||
|
|
162
186
|
matchesKey(data, 'ctrl+c') ||
|
|
163
187
|
matchesKey(data, 'ctrl+d') ||
|
|
164
188
|
matchesKey(data, this.statusShortcut)
|
|
@@ -166,11 +190,43 @@ export class WorkflowStatusView implements Component {
|
|
|
166
190
|
this.close();
|
|
167
191
|
return;
|
|
168
192
|
}
|
|
193
|
+
if (matchesKey(data, 'escape')) {
|
|
194
|
+
if (this.state.mode === 'detail') {
|
|
195
|
+
this.showBoard();
|
|
196
|
+
} else {
|
|
197
|
+
this.close();
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
169
201
|
const pageSize = Math.max(1, this.state.viewportRows - 2);
|
|
202
|
+
if (this.state.mode === 'detail') {
|
|
203
|
+
if (matchesKey(data, Key.left) || data === 'h') {
|
|
204
|
+
this.showBoard();
|
|
205
|
+
} else if (matchesKey(data, Key.down) || data === 'j') {
|
|
206
|
+
this.setScrollOffset(this.state.scrollOffset + 1);
|
|
207
|
+
} else if (matchesKey(data, Key.up) || data === 'k') {
|
|
208
|
+
this.setScrollOffset(this.state.scrollOffset - 1);
|
|
209
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
210
|
+
this.setScrollOffset(this.state.scrollOffset + pageSize);
|
|
211
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
212
|
+
this.setScrollOffset(this.state.scrollOffset - pageSize);
|
|
213
|
+
} else if (matchesKey(data, Key.home)) {
|
|
214
|
+
this.setScrollOffset(0);
|
|
215
|
+
} else if (matchesKey(data, Key.end)) {
|
|
216
|
+
this.setScrollOffset(Number.MAX_SAFE_INTEGER);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
170
220
|
if (matchesKey(data, Key.down) || data === 'j') {
|
|
171
|
-
this.
|
|
221
|
+
this.moveSelection(1);
|
|
172
222
|
} else if (matchesKey(data, Key.up) || data === 'k') {
|
|
173
|
-
this.
|
|
223
|
+
this.moveSelection(-1);
|
|
224
|
+
} else if (
|
|
225
|
+
matchesKey(data, Key.enter) ||
|
|
226
|
+
matchesKey(data, Key.right) ||
|
|
227
|
+
data === 'l'
|
|
228
|
+
) {
|
|
229
|
+
this.openDetail();
|
|
174
230
|
} else if (matchesKey(data, Key.pageDown)) {
|
|
175
231
|
this.setScrollOffset(this.state.scrollOffset + pageSize);
|
|
176
232
|
} else if (matchesKey(data, Key.pageUp)) {
|
|
@@ -198,15 +254,28 @@ export class WorkflowStatusView implements Component {
|
|
|
198
254
|
}
|
|
199
255
|
|
|
200
256
|
const contentWidth = viewportWidth - 2;
|
|
257
|
+
if (snapshot) this.normalizeSelection(snapshot);
|
|
201
258
|
const lines = snapshot
|
|
202
|
-
?
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
259
|
+
? this.state.mode === 'detail'
|
|
260
|
+
? renderStepDetail(
|
|
261
|
+
this.theme,
|
|
262
|
+
snapshot,
|
|
263
|
+
this.state.selectedIndex,
|
|
264
|
+
this.transcriptCache,
|
|
265
|
+
contentWidth,
|
|
266
|
+
)
|
|
267
|
+
: renderBoard(
|
|
268
|
+
this.theme,
|
|
269
|
+
snapshot,
|
|
270
|
+
contentWidth,
|
|
271
|
+
false,
|
|
272
|
+
this.statusShortcutLabel,
|
|
273
|
+
this.state.selectedIndex,
|
|
274
|
+
)
|
|
209
275
|
: renderEmptyBoard(this.theme, contentWidth);
|
|
276
|
+
if (snapshot && this.state.mode === 'detail') {
|
|
277
|
+
this.ensureSelectedTranscripts(snapshot);
|
|
278
|
+
}
|
|
210
279
|
const rendered = lines.map((line) =>
|
|
211
280
|
padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
|
|
212
281
|
);
|
|
@@ -217,6 +286,9 @@ export class WorkflowStatusView implements Component {
|
|
|
217
286
|
this.tui.terminal?.rows,
|
|
218
287
|
this.statusShortcutLabel,
|
|
219
288
|
this.theme,
|
|
289
|
+
this.state.mode === 'detail'
|
|
290
|
+
? '↑/↓ or j/k scroll · PgUp/PgDn · ←/h/Esc back'
|
|
291
|
+
: '↑/↓ or j/k select · Enter/→/l inspect · PgUp/PgDn',
|
|
220
292
|
);
|
|
221
293
|
this.state = page.state;
|
|
222
294
|
return page.lines;
|
|
@@ -229,6 +301,81 @@ export class WorkflowStatusView implements Component {
|
|
|
229
301
|
this.tui.requestRender(true);
|
|
230
302
|
}
|
|
231
303
|
|
|
304
|
+
private normalizeSelection(
|
|
305
|
+
snapshot: NonNullable<ReturnType<SnapshotProvider>>,
|
|
306
|
+
): void {
|
|
307
|
+
const entries = buildPathEntries(snapshot);
|
|
308
|
+
const selectedIndex =
|
|
309
|
+
entries.length === 0
|
|
310
|
+
? -1
|
|
311
|
+
: this.state.selectedIndex < 0
|
|
312
|
+
? entries.length - 1
|
|
313
|
+
: Math.min(this.state.selectedIndex, entries.length - 1);
|
|
314
|
+
if (selectedIndex !== this.state.selectedIndex) {
|
|
315
|
+
this.state = { ...this.state, selectedIndex };
|
|
316
|
+
}
|
|
317
|
+
if (entries.length === 0 && this.state.mode === 'detail') {
|
|
318
|
+
this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private moveSelection(delta: number): void {
|
|
323
|
+
const snapshot = this.getSnapshot();
|
|
324
|
+
if (!snapshot) return;
|
|
325
|
+
this.normalizeSelection(snapshot);
|
|
326
|
+
const entries = buildPathEntries(snapshot);
|
|
327
|
+
if (entries.length === 0) return;
|
|
328
|
+
const selectedIndex = Math.max(
|
|
329
|
+
0,
|
|
330
|
+
Math.min(this.state.selectedIndex + delta, entries.length - 1),
|
|
331
|
+
);
|
|
332
|
+
if (selectedIndex === this.state.selectedIndex) return;
|
|
333
|
+
this.state = { ...this.state, selectedIndex };
|
|
334
|
+
this.tui.requestRender(true);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private openDetail(): void {
|
|
338
|
+
const snapshot = this.getSnapshot();
|
|
339
|
+
if (!snapshot) return;
|
|
340
|
+
this.normalizeSelection(snapshot);
|
|
341
|
+
if (!selectedStepDetail(snapshot, this.state.selectedIndex)) return;
|
|
342
|
+
this.state = { ...this.state, mode: 'detail', scrollOffset: 0 };
|
|
343
|
+
this.ensureSelectedTranscripts(snapshot);
|
|
344
|
+
this.tui.requestRender(true);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private showBoard(): void {
|
|
348
|
+
if (this.state.mode === 'board') return;
|
|
349
|
+
this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
|
|
350
|
+
this.tui.requestRender(true);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private ensureSelectedTranscripts(
|
|
354
|
+
snapshot: NonNullable<ReturnType<SnapshotProvider>>,
|
|
355
|
+
): void {
|
|
356
|
+
const detail = selectedStepDetail(snapshot, this.state.selectedIndex);
|
|
357
|
+
if (!detail) return;
|
|
358
|
+
for (const attempt of detail.attempts) {
|
|
359
|
+
if (attempt.kind !== 'subagent' || !attempt.transcript) continue;
|
|
360
|
+
const key = stepTranscriptCacheKey(snapshot.run.runId, attempt);
|
|
361
|
+
if (this.transcriptCache.has(key)) continue;
|
|
362
|
+
this.transcriptCache.set(key, { status: 'loading' });
|
|
363
|
+
void this.dependencies.loadStepTranscript(attempt).then(
|
|
364
|
+
(result) => {
|
|
365
|
+
this.transcriptCache.set(key, result);
|
|
366
|
+
if (!this.state.isClosed) this.tui.requestRender(true);
|
|
367
|
+
},
|
|
368
|
+
() => {
|
|
369
|
+
this.transcriptCache.set(key, {
|
|
370
|
+
status: 'unavailable',
|
|
371
|
+
reason: 'The child transcript could not be loaded safely.',
|
|
372
|
+
});
|
|
373
|
+
if (!this.state.isClosed) this.tui.requestRender(true);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
232
379
|
private close(): void {
|
|
233
380
|
if (this.state.isClosed) return;
|
|
234
381
|
this.state = { ...this.state, isClosed: true };
|
|
@@ -243,7 +390,7 @@ export async function showWorkflowStatus(
|
|
|
243
390
|
ctx: ExtensionContext,
|
|
244
391
|
getSnapshot: SnapshotProvider,
|
|
245
392
|
statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
|
|
246
|
-
dependencies: WorkflowStatusViewDependencies =
|
|
393
|
+
dependencies: Partial<WorkflowStatusViewDependencies> = {},
|
|
247
394
|
): Promise<void> {
|
|
248
395
|
await ctx.ui.custom<undefined>(
|
|
249
396
|
(tui, theme, _keybindings, done) => {
|
package/src/workflow-status.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {
|
|
2
|
+
formatWorkflowProgressStatus,
|
|
2
3
|
formatWorkflowStatusBoard,
|
|
3
4
|
formatWorkflowStatusText,
|
|
4
5
|
} from './workflow-status/format-status.ts';
|
|
@@ -14,7 +15,12 @@ export {
|
|
|
14
15
|
export type {
|
|
15
16
|
SnapshotProvider,
|
|
16
17
|
StatusViewTui,
|
|
18
|
+
StepTranscriptLoader,
|
|
17
19
|
WorkflowStatusExecution,
|
|
18
20
|
WorkflowStatusSnapshot,
|
|
19
21
|
WorkflowStatusTheme,
|
|
20
22
|
} from './workflow-status/types.ts';
|
|
23
|
+
export {
|
|
24
|
+
readStepTranscript,
|
|
25
|
+
type StepTranscriptLog,
|
|
26
|
+
} from './workflow-status/transcript-reader.ts';
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import type { BashApprovalSource } from '../config/types.ts';
|
|
2
|
-
import {
|
|
3
|
-
parseJsonDocuments,
|
|
4
|
-
remoteActionCommands,
|
|
5
|
-
verificationCommands,
|
|
6
|
-
} from './reviewed-artifact.ts';
|
|
7
|
-
import {
|
|
8
|
-
isSafeRemoteActionCommand,
|
|
9
|
-
isSafeVerificationCommand,
|
|
10
|
-
} from './reviewed-command-safety.ts';
|
|
11
|
-
|
|
12
|
-
const commandsFromSource = (
|
|
13
|
-
document: unknown,
|
|
14
|
-
source: BashApprovalSource,
|
|
15
|
-
): Array<string> => {
|
|
16
|
-
if (source === 'verification-worker') {
|
|
17
|
-
return verificationCommands(document, 'worker').filter(
|
|
18
|
-
isSafeVerificationCommand,
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
if (source === 'verification-reviewer') {
|
|
22
|
-
return verificationCommands(document, 'reviewer').filter(
|
|
23
|
-
isSafeVerificationCommand,
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
return remoteActionCommands(document).filter(isSafeRemoteActionCommand);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Extracts exact Bash capabilities from human-approved, machine-readable JSON.
|
|
31
|
-
*
|
|
32
|
-
* @param artifact - Artifact displayed by the review gate.
|
|
33
|
-
* @param sources - Approved artifact sections to inspect.
|
|
34
|
-
* @returns Unique safe commands in source order.
|
|
35
|
-
*/
|
|
36
|
-
export const extractApprovedBashCommands = (
|
|
37
|
-
artifact: string,
|
|
38
|
-
sources: ReadonlyArray<BashApprovalSource>,
|
|
39
|
-
): Array<string> => {
|
|
40
|
-
if (!artifact.trim() || sources.length === 0) return [];
|
|
41
|
-
|
|
42
|
-
const commands = parseJsonDocuments(artifact).flatMap((document) =>
|
|
43
|
-
sources.flatMap((source) => commandsFromSource(document, source)),
|
|
44
|
-
);
|
|
45
|
-
return [...new Set(commands)];
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Narrows reviewed commands to those retained by the latest step handoff.
|
|
50
|
-
*
|
|
51
|
-
* A child can remove reviewed authority, but its unreviewed output can never
|
|
52
|
-
* introduce a new Bash capability.
|
|
53
|
-
*
|
|
54
|
-
* @param artifact - Original human-approved artifact.
|
|
55
|
-
* @param handoff - Latest completed-step handoff.
|
|
56
|
-
* @param sources - Approved artifact sections to inspect.
|
|
57
|
-
* @returns Commands present in both artifacts.
|
|
58
|
-
*/
|
|
59
|
-
export const narrowApprovedBashCommands = (
|
|
60
|
-
artifact: string,
|
|
61
|
-
handoff: string,
|
|
62
|
-
sources: ReadonlyArray<BashApprovalSource>,
|
|
63
|
-
): Array<string> => {
|
|
64
|
-
const approvedCommands = extractApprovedBashCommands(artifact, sources);
|
|
65
|
-
if (approvedCommands.length === 0) return [];
|
|
66
|
-
|
|
67
|
-
const retainedCommands = new Set(
|
|
68
|
-
extractApprovedBashCommands(handoff, sources),
|
|
69
|
-
);
|
|
70
|
-
return approvedCommands.filter((command) => retainedCommands.has(command));
|
|
71
|
-
};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
extractApprovedBashCommands,
|
|
3
|
-
narrowApprovedBashCommands,
|
|
4
|
-
} from './approved-command-extraction.ts';
|
|
5
|
-
export { reviewedCommandShapeError } from './reviewed-command-shape.ts';
|
|
6
|
-
export {
|
|
7
|
-
createReviewedRepositoryCwdResolver,
|
|
8
|
-
resolveReviewedRepositoryCwd,
|
|
9
|
-
} from './reviewed-repository-cwd.ts';
|
|
10
|
-
export type {
|
|
11
|
-
DirectoryState,
|
|
12
|
-
ResolveReviewedRepositoryCwd,
|
|
13
|
-
ReviewedRepositoryCwdDependencies,
|
|
14
|
-
ReviewedRepositoryCwdResolution,
|
|
15
|
-
} from './reviewed-repository-cwd.ts';
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { basename } from 'node:path';
|
|
2
|
-
import type { BashAuthorization } from './bash-types.ts';
|
|
3
|
-
import { parseRestrictedGitCommand } from './restricted-git.ts';
|
|
4
|
-
|
|
5
|
-
const READ_ONLY_EXECUTABLES: ReadonlySet<string> = new Set([
|
|
6
|
-
'grep',
|
|
7
|
-
'head',
|
|
8
|
-
'ls',
|
|
9
|
-
'pwd',
|
|
10
|
-
'rg',
|
|
11
|
-
'stat',
|
|
12
|
-
'tail',
|
|
13
|
-
'wc',
|
|
14
|
-
]);
|
|
15
|
-
const READ_ONLY_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
16
|
-
'diff',
|
|
17
|
-
'grep',
|
|
18
|
-
'log',
|
|
19
|
-
'ls-files',
|
|
20
|
-
'rev-parse',
|
|
21
|
-
'show',
|
|
22
|
-
'status',
|
|
23
|
-
]);
|
|
24
|
-
const DANGEROUS_GIT_OPTIONS = [
|
|
25
|
-
'--config-env',
|
|
26
|
-
'--exec',
|
|
27
|
-
'--ext-diff',
|
|
28
|
-
'--open-files-in-pager',
|
|
29
|
-
'--output',
|
|
30
|
-
'--textconv',
|
|
31
|
-
] as const;
|
|
32
|
-
const DANGEROUS_GIT_SHORT_OPTIONS = [
|
|
33
|
-
// `git grep -O<pager>` executes the supplied pager command.
|
|
34
|
-
{ subcommand: 'grep', option: '-O' },
|
|
35
|
-
] as const;
|
|
36
|
-
const HOSTED_API_MUTATION_OPTIONS = [
|
|
37
|
-
'--field',
|
|
38
|
-
'--form',
|
|
39
|
-
'--input',
|
|
40
|
-
'--method',
|
|
41
|
-
'--raw-field',
|
|
42
|
-
'-F',
|
|
43
|
-
'-X',
|
|
44
|
-
'-f',
|
|
45
|
-
] as const;
|
|
46
|
-
|
|
47
|
-
const reject = (reason: string): BashAuthorization => ({
|
|
48
|
-
allowed: false,
|
|
49
|
-
reason,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const allow = (tokens: ReadonlyArray<string>): BashAuthorization => ({
|
|
53
|
-
allowed: true,
|
|
54
|
-
tokens: [...tokens],
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const hasOption = (tokens: ReadonlyArray<string>, option: string): boolean =>
|
|
58
|
-
tokens.some((token) => token === option || token.startsWith(`${option}=`));
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Authorizes a tokenized command against the static read-only Bash preset.
|
|
62
|
-
*
|
|
63
|
-
* @param tokens - Restricted command tokens.
|
|
64
|
-
* @returns The authorization decision.
|
|
65
|
-
*/
|
|
66
|
-
export const authorizeReadOnlyBash = (
|
|
67
|
-
tokens: ReadonlyArray<string>,
|
|
68
|
-
): BashAuthorization => {
|
|
69
|
-
const executable = tokens[0] ?? '';
|
|
70
|
-
if (READ_ONLY_EXECUTABLES.has(executable)) {
|
|
71
|
-
const hasRipgrepPreprocessor =
|
|
72
|
-
executable === 'rg' &&
|
|
73
|
-
(hasOption(tokens, '--pre') || hasOption(tokens, '--pre-glob'));
|
|
74
|
-
return hasRipgrepPreprocessor
|
|
75
|
-
? reject('rg preprocessors are not allowed in read-only mode')
|
|
76
|
-
: allow(tokens);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (executable !== 'git') {
|
|
80
|
-
return reject(`"${executable}" is not in the read-only Bash preset`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const gitCommand = parseRestrictedGitCommand(tokens);
|
|
84
|
-
if (!gitCommand || !READ_ONLY_GIT_SUBCOMMANDS.has(gitCommand.subcommand)) {
|
|
85
|
-
return reject(
|
|
86
|
-
`git subcommand "${gitCommand?.subcommand ?? ''}" is not read-only`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const dangerousOption = DANGEROUS_GIT_OPTIONS.find((option) =>
|
|
91
|
-
hasOption(tokens, option),
|
|
92
|
-
);
|
|
93
|
-
if (dangerousOption) {
|
|
94
|
-
return reject(
|
|
95
|
-
`git option "${dangerousOption}" is not allowed in read-only mode`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const dangerousShortOption = DANGEROUS_GIT_SHORT_OPTIONS.find(
|
|
100
|
-
({ subcommand, option }) =>
|
|
101
|
-
gitCommand.subcommand === subcommand &&
|
|
102
|
-
tokens
|
|
103
|
-
.slice(gitCommand.subcommandIndex + 1)
|
|
104
|
-
.some((token) => token === option || token.startsWith(option)),
|
|
105
|
-
);
|
|
106
|
-
return dangerousShortOption
|
|
107
|
-
? reject(
|
|
108
|
-
`git option "${dangerousShortOption.option}" is not allowed in read-only mode`,
|
|
109
|
-
)
|
|
110
|
-
: allow(tokens);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Reports whether a tokenized command belongs to the read-only preset.
|
|
115
|
-
*
|
|
116
|
-
* @param tokens - Restricted command tokens.
|
|
117
|
-
* @returns `true` when the read-only preset owns the command.
|
|
118
|
-
*/
|
|
119
|
-
export const usesReadOnlyBashPreset = (
|
|
120
|
-
tokens: ReadonlyArray<string>,
|
|
121
|
-
): boolean => {
|
|
122
|
-
const executable = tokens[0] ?? '';
|
|
123
|
-
return (
|
|
124
|
-
READ_ONLY_EXECUTABLES.has(executable) ||
|
|
125
|
-
(executable === 'git' &&
|
|
126
|
-
READ_ONLY_GIT_SUBCOMMANDS.has(
|
|
127
|
-
parseRestrictedGitCommand(tokens)?.subcommand ?? '',
|
|
128
|
-
))
|
|
129
|
-
);
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Prevents mutation flags in allow-listed GitHub and GitLab API reads.
|
|
134
|
-
*
|
|
135
|
-
* @param tokens - Restricted command tokens.
|
|
136
|
-
* @returns The authorization decision.
|
|
137
|
-
*/
|
|
138
|
-
export const authorizeHostedApiRead = (
|
|
139
|
-
tokens: ReadonlyArray<string>,
|
|
140
|
-
): BashAuthorization => {
|
|
141
|
-
const executable = basename(tokens[0] ?? '');
|
|
142
|
-
if ((executable !== 'gh' && executable !== 'glab') || tokens[1] !== 'api') {
|
|
143
|
-
return allow(tokens);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const mutationOption = HOSTED_API_MUTATION_OPTIONS.find((option) =>
|
|
147
|
-
tokens
|
|
148
|
-
.slice(2)
|
|
149
|
-
.some(
|
|
150
|
-
(token) =>
|
|
151
|
-
token === option ||
|
|
152
|
-
token.startsWith(`${option}=`) ||
|
|
153
|
-
(option.length === 2 && token.startsWith(option)),
|
|
154
|
-
),
|
|
155
|
-
);
|
|
156
|
-
return mutationOption
|
|
157
|
-
? reject(
|
|
158
|
-
`${executable} api option "${mutationOption}" is not allowed by a static read-only rule`,
|
|
159
|
-
)
|
|
160
|
-
: allow(tokens);
|
|
161
|
-
};
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { RestrictedGitCommand } from './bash-types.ts';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Finds the Git subcommand in a tokenized restricted command.
|
|
5
|
-
*
|
|
6
|
-
* Only the global options deliberately supported by the policy are skipped.
|
|
7
|
-
*
|
|
8
|
-
* @param tokens - Tokenized Git command.
|
|
9
|
-
* @returns The subcommand and its index, or `undefined` for an invalid shape.
|
|
10
|
-
*/
|
|
11
|
-
export const parseRestrictedGitCommand = (
|
|
12
|
-
tokens: ReadonlyArray<string>,
|
|
13
|
-
): RestrictedGitCommand | undefined => {
|
|
14
|
-
let index = 1;
|
|
15
|
-
while (index < tokens.length) {
|
|
16
|
-
const token = tokens[index];
|
|
17
|
-
if (token === '-C') {
|
|
18
|
-
if (!tokens[index + 1]) return undefined;
|
|
19
|
-
index += 2;
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
|
-
if (token === '--no-pager') {
|
|
23
|
-
index += 1;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (!token || token.startsWith('-')) return undefined;
|
|
27
|
-
return { subcommand: token, subcommandIndex: index };
|
|
28
|
-
}
|
|
29
|
-
return undefined;
|
|
30
|
-
};
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
export type ParsedJsonDocuments = {
|
|
2
|
-
readonly documents: ReadonlyArray<unknown>;
|
|
3
|
-
readonly hasMalformedCandidate: boolean;
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Narrows an unknown value to a non-array record.
|
|
8
|
-
*
|
|
9
|
-
* @param value - Value to inspect.
|
|
10
|
-
* @returns `true` when the value is a record.
|
|
11
|
-
*/
|
|
12
|
-
export const isRecord = (
|
|
13
|
-
value: unknown,
|
|
14
|
-
): value is Readonly<Record<string, unknown>> =>
|
|
15
|
-
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Parses a complete JSON artifact and JSON fenced blocks while retaining
|
|
19
|
-
* whether any JSON-looking candidate was malformed.
|
|
20
|
-
*
|
|
21
|
-
* @param text - Reviewed artifact text.
|
|
22
|
-
* @returns Parsed documents and malformed-candidate state.
|
|
23
|
-
*/
|
|
24
|
-
export const parseJsonDocumentsWithValidity = (
|
|
25
|
-
text: string,
|
|
26
|
-
): ParsedJsonDocuments => {
|
|
27
|
-
const documents: Array<unknown> = [];
|
|
28
|
-
let hasMalformedCandidate = false;
|
|
29
|
-
const addDocument = (candidate: string): void => {
|
|
30
|
-
try {
|
|
31
|
-
documents.push(JSON.parse(candidate));
|
|
32
|
-
} catch {
|
|
33
|
-
hasMalformedCandidate = true;
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const trimmed = text.trim();
|
|
38
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
39
|
-
addDocument(trimmed);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/giu;
|
|
43
|
-
for (const match of text.matchAll(fences)) {
|
|
44
|
-
const candidate = match[1]?.trim();
|
|
45
|
-
if (candidate) addDocument(candidate);
|
|
46
|
-
}
|
|
47
|
-
return { documents, hasMalformedCandidate };
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Parses all valid JSON documents from a reviewed artifact.
|
|
52
|
-
*
|
|
53
|
-
* @param text - Reviewed artifact text.
|
|
54
|
-
* @returns Valid JSON documents in source order.
|
|
55
|
-
*/
|
|
56
|
-
export const parseJsonDocuments = (text: string): ReadonlyArray<unknown> =>
|
|
57
|
-
parseJsonDocumentsWithValidity(text).documents;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Extracts verification commands for one role from a parsed artifact.
|
|
61
|
-
*
|
|
62
|
-
* @param value - Parsed artifact document.
|
|
63
|
-
* @param role - Verification role whose commands should be returned.
|
|
64
|
-
* @returns Command strings in artifact order.
|
|
65
|
-
*/
|
|
66
|
-
export const verificationCommands = (
|
|
67
|
-
value: unknown,
|
|
68
|
-
role: 'worker' | 'reviewer',
|
|
69
|
-
): Array<string> => {
|
|
70
|
-
if (!isRecord(value) || !Array.isArray(value.repositories)) return [];
|
|
71
|
-
return value.repositories.flatMap((repository) => {
|
|
72
|
-
if (!isRecord(repository) || !Array.isArray(repository[role])) return [];
|
|
73
|
-
return repository[role].flatMap((check) =>
|
|
74
|
-
isRecord(check) && typeof check.command === 'string'
|
|
75
|
-
? [check.command]
|
|
76
|
-
: [],
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Extracts Bash command actions from a parsed remote-action artifact.
|
|
83
|
-
*
|
|
84
|
-
* @param value - Parsed artifact document.
|
|
85
|
-
* @returns Bash commands in artifact order.
|
|
86
|
-
*/
|
|
87
|
-
export const remoteActionCommands = (value: unknown): Array<string> => {
|
|
88
|
-
if (!isRecord(value) || !Array.isArray(value.actions)) return [];
|
|
89
|
-
return value.actions.flatMap((action) =>
|
|
90
|
-
isRecord(action) &&
|
|
91
|
-
action.toolName === 'bash' &&
|
|
92
|
-
isRecord(action.input) &&
|
|
93
|
-
typeof action.input.command === 'string'
|
|
94
|
-
? [action.input.command]
|
|
95
|
-
: [],
|
|
96
|
-
);
|
|
97
|
-
};
|