singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14

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 (77) hide show
  1. package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
  2. package/dist/packages/cli/src/commands/new.js +763 -0
  3. package/dist/packages/cli/src/commands/repl.js +557 -0
  4. package/dist/packages/cli/src/commands/usage.js +49 -0
  5. package/dist/packages/cli/src/executor/debug-loop.js +525 -0
  6. package/dist/packages/cli/src/executor/inputs.js +226 -0
  7. package/dist/packages/cli/src/executor/outputs.js +134 -0
  8. package/dist/packages/cli/src/executor/preflight.js +605 -0
  9. package/dist/packages/cli/src/executor/replay-loop.js +120 -0
  10. package/dist/packages/cli/src/executor/run-report.js +209 -0
  11. package/dist/packages/cli/src/executor/run-setup.js +114 -0
  12. package/dist/packages/cli/src/executor/security-review.js +97 -0
  13. package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
  14. package/dist/packages/cli/src/executor/step-runner.js +241 -0
  15. package/dist/packages/cli/src/executor.js +584 -0
  16. package/dist/packages/cli/src/index.js +107 -0
  17. package/dist/packages/cli/src/parser.js +89 -0
  18. package/dist/packages/cli/src/runners/_shared.js +96 -0
  19. package/dist/packages/cli/src/runners/claude.js +103 -0
  20. package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
  21. package/dist/packages/cli/src/runners/codex.js +141 -0
  22. package/dist/packages/cli/src/runners/copilot.js +209 -0
  23. package/dist/packages/cli/src/runners/index.js +18 -0
  24. package/dist/packages/cli/src/runners/opencode.js +240 -0
  25. package/dist/packages/cli/src/scanner.js +43 -0
  26. package/dist/packages/cli/src/security/policy.js +115 -0
  27. package/dist/packages/cli/src/sentinels.js +1 -0
  28. package/dist/packages/cli/src/shell.js +753 -0
  29. package/dist/packages/cli/src/theme.js +39 -0
  30. package/dist/packages/cli/src/timeline.js +238 -0
  31. package/dist/packages/cli/src/types.js +1 -0
  32. package/dist/packages/cli/src/usage/aggregator.js +44 -0
  33. package/dist/packages/cli/src/usage/reader.js +30 -0
  34. package/dist/packages/cli/src/usage/types.js +1 -0
  35. package/dist/packages/server/src/index.js +36 -0
  36. package/dist/packages/server/src/routes/agents.js +31 -0
  37. package/dist/packages/server/src/routes/files.js +45 -0
  38. package/dist/packages/server/src/routes/pipelines.js +74 -0
  39. package/docs/reference.md +28 -0
  40. package/package.json +15 -14
  41. package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
  42. package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
  43. package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
  44. package/packages/web/dist/index.html +2 -2
  45. package/packages/cli/package.json +0 -18
  46. package/packages/cli/src/commands/new.js +0 -786
  47. package/packages/cli/src/commands/repl.js +0 -548
  48. package/packages/cli/src/executor/debug-loop.js +0 -587
  49. package/packages/cli/src/executor/inputs.js +0 -202
  50. package/packages/cli/src/executor/outputs.js +0 -140
  51. package/packages/cli/src/executor/preflight.js +0 -459
  52. package/packages/cli/src/executor/replay-loop.js +0 -172
  53. package/packages/cli/src/executor/run-report.js +0 -189
  54. package/packages/cli/src/executor/run-setup.js +0 -93
  55. package/packages/cli/src/executor/security-review.js +0 -108
  56. package/packages/cli/src/executor/snapshot-manager.js +0 -335
  57. package/packages/cli/src/executor/step-runner.js +0 -266
  58. package/packages/cli/src/executor.js +0 -652
  59. package/packages/cli/src/index.js +0 -107
  60. package/packages/cli/src/parser.js +0 -78
  61. package/packages/cli/src/runners/_shared.js +0 -83
  62. package/packages/cli/src/runners/claude.js +0 -122
  63. package/packages/cli/src/runners/codex-instructions.js +0 -75
  64. package/packages/cli/src/runners/codex.js +0 -165
  65. package/packages/cli/src/runners/copilot.js +0 -224
  66. package/packages/cli/src/runners/index.js +0 -20
  67. package/packages/cli/src/runners/opencode.js +0 -265
  68. package/packages/cli/src/scanner.js +0 -47
  69. package/packages/cli/src/security/policy.js +0 -126
  70. package/packages/cli/src/shell.js +0 -732
  71. package/packages/cli/src/theme.js +0 -46
  72. package/packages/cli/src/timeline.js +0 -180
  73. package/packages/server/package.json +0 -11
  74. package/packages/server/src/index.js +0 -43
  75. package/packages/server/src/routes/agents.js +0 -32
  76. package/packages/server/src/routes/files.js +0 -42
  77. package/packages/server/src/routes/pipelines.js +0 -74
@@ -0,0 +1,120 @@
1
+ import { buildUserMessage, resolveDebugInputOverridesFromEdit, } from './inputs.js';
2
+ import { debugToken, editDebugInputs, formatDebugList, logDebugPromptPreview, pushDebugEvent, } from './debug-loop.js';
3
+ export async function prepareReplayAttempt({ attempt, finalAttempt, stepSnapshot, snapshotManager, stepOriginalPaths, stepRegistrySnapshot, registry, replayInputs, replayInputOverride, step, debugEvents, inputDefs, timeline, shell, outputNames, workspaceInfoForAttempt, systemPrompt, securityPolicy, debugInputOverrides, currentSnapshot, stats, provider, model, runnerAgent, permissionMode, totalAttemptSeconds, totalAttemptTurns, totalAttemptCost, timelineIndex, failStep, }) {
4
+ attempt += 1;
5
+ if (finalAttempt?.stepChanges?.length || finalAttempt?.stepWrites?.length) {
6
+ timeline.logMuted(`${debugToken.policy('Replay is restoring project files touched by the previous attempt. Previous run artifacts are kept under their attempt folder.')}`);
7
+ timeline.logMuted(`${debugToken.key('pending restore')} ${formatDebugList((finalAttempt.stepChanges || []).map((entry) => entry.relPath))}`);
8
+ timeline.logMuted(`${debugToken.key('previous artifacts')} ${formatDebugList((finalAttempt.stepWrites || []).map((entry) => entry.relPath))}`);
9
+ }
10
+ if (stepSnapshot && finalAttempt?.stepChanges?.length) {
11
+ try {
12
+ const result = await snapshotManager.restore({
13
+ snapshot: stepSnapshot,
14
+ originalPaths: stepOriginalPaths,
15
+ changes: finalAttempt.stepChanges,
16
+ });
17
+ timeline.logMuted(`${debugToken.key('restore result')} ` +
18
+ `${debugToken.key('restored')} ${formatDebugList(result.restored)} ` +
19
+ `${debugToken.muted('·')} ${debugToken.key('removed')} ${formatDebugList(result.removed)} ` +
20
+ `${debugToken.muted('·')} ${debugToken.key('skipped')} ${formatDebugList(result.skipped)}`);
21
+ if (result.skipped.length) {
22
+ timeline.logMuted(`${debugToken.policy('Could not restore (filtered out of snapshot):')} ${formatDebugList(result.skipped)}`);
23
+ stats.push({
24
+ agent: step.agent,
25
+ provider,
26
+ model: model || '—',
27
+ runnerAgent: runnerAgent || '—',
28
+ securityProfile: securityPolicy.profile,
29
+ permissionMode: permissionMode || '—',
30
+ status: 'failed',
31
+ seconds: totalAttemptSeconds,
32
+ turns: totalAttemptTurns,
33
+ cost: totalAttemptCost,
34
+ attempts: attempt,
35
+ });
36
+ failStep(timeline, timelineIndex, 'replay restore incomplete', `Replay restore incomplete before step "${step.agent}" attempt ${attempt}. These changed files were excluded from the snapshot:\n- ${result.skipped.join('\n- ')}`);
37
+ }
38
+ currentSnapshot = await snapshotManager.captureState();
39
+ }
40
+ catch (err) {
41
+ stats.push({
42
+ agent: step.agent,
43
+ provider,
44
+ model: model || '—',
45
+ runnerAgent: runnerAgent || '—',
46
+ securityProfile: securityPolicy.profile,
47
+ permissionMode: permissionMode || '—',
48
+ status: 'failed',
49
+ seconds: totalAttemptSeconds,
50
+ turns: totalAttemptTurns,
51
+ cost: totalAttemptCost,
52
+ attempts: attempt,
53
+ });
54
+ failStep(timeline, timelineIndex, 'replay restore failed', `Replay restore failed before step "${step.agent}" attempt ${attempt}: ${err instanceof Error ? err.message : String(err)}`);
55
+ }
56
+ }
57
+ for (const [key, previousValue] of stepRegistrySnapshot) {
58
+ if (previousValue === undefined)
59
+ delete registry[key];
60
+ else
61
+ registry[key] = previousValue;
62
+ }
63
+ const editedInputs = new Set();
64
+ const replayBaseInputs = replayInputs;
65
+ if (replayInputOverride) {
66
+ const nextInputs = { ...replayInputs };
67
+ for (const [name, value] of Object.entries(replayInputOverride)) {
68
+ if (Object.prototype.hasOwnProperty.call(nextInputs, name)) {
69
+ nextInputs[name] = value;
70
+ editedInputs.add(name);
71
+ }
72
+ }
73
+ replayInputs = nextInputs;
74
+ replayInputOverride = null;
75
+ }
76
+ else {
77
+ replayInputs = await editDebugInputs({
78
+ resolvedInputs: replayInputs,
79
+ shell,
80
+ timeline,
81
+ step,
82
+ debugEvents,
83
+ editedInputs,
84
+ });
85
+ }
86
+ const runtimeOverrides = resolveDebugInputOverridesFromEdit(step, replayBaseInputs, replayInputs, inputDefs);
87
+ for (const [id, value] of Object.entries(runtimeOverrides)) {
88
+ debugInputOverrides[id] = value;
89
+ }
90
+ if (Object.keys(runtimeOverrides).length) {
91
+ pushDebugEvent(debugEvents, {
92
+ step: step.agent,
93
+ phase: 'post-step',
94
+ action: 'set-runtime-input-overrides',
95
+ inputIds: Object.keys(runtimeOverrides),
96
+ attempt,
97
+ });
98
+ }
99
+ if (editedInputs.size) {
100
+ logDebugPromptPreview({
101
+ systemPrompt,
102
+ userMessage: buildUserMessage(replayInputs, outputNames, workspaceInfoForAttempt(attempt), securityPolicy),
103
+ timeline,
104
+ editedInputs,
105
+ });
106
+ }
107
+ pushDebugEvent(debugEvents, {
108
+ step: step.agent,
109
+ phase: 'post-step',
110
+ action: 'replay-start',
111
+ attempt,
112
+ editedInputs: [...editedInputs],
113
+ });
114
+ return {
115
+ attempt,
116
+ replayInputs,
117
+ replayInputOverride,
118
+ currentSnapshot,
119
+ };
120
+ }
@@ -0,0 +1,209 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { G, S } from '../shell.js';
4
+ export const LATEST_RUN_ID_FILE = 'latest-run-id';
5
+ function visibleLength(value) {
6
+ return String(value || '').replace(/\{[^}]+\}/g, '').length;
7
+ }
8
+ function padVisible(value, width, align = 'left') {
9
+ const str = String(value ?? '');
10
+ const pad = Math.max(0, width - visibleLength(str));
11
+ return align === 'right' ? `${' '.repeat(pad)}${str}` : `${str}${' '.repeat(pad)}`;
12
+ }
13
+ function formatSeconds(value) {
14
+ return `${Number(value || 0).toFixed(1)}s`;
15
+ }
16
+ function formatCost(value) {
17
+ const cost = Number(value || 0);
18
+ return cost > 0 ? `$${cost.toFixed(4)}` : '-';
19
+ }
20
+ function displayValue(value) {
21
+ return !value || value === '—' ? '-' : String(value);
22
+ }
23
+ // Status -> color. Only the Status cell is coloured; the rest of the row stays white
24
+ // so the outcome is scannable without being noisy.
25
+ function statusColor(status) {
26
+ if (status === 'done')
27
+ return S.success;
28
+ if (status === 'failed')
29
+ return S.error;
30
+ if (status === 'dry-run')
31
+ return S.warning;
32
+ if (status === 'skipped')
33
+ return S.muted;
34
+ return S.text;
35
+ }
36
+ // Section header in the debug-loop style: blank lines + `─── title ───` centered, colored accent.
37
+ function sectionHeader(title) {
38
+ const width = 72;
39
+ const text = ` ${title} `;
40
+ const left = Math.max(0, Math.floor((width - text.length) / 2));
41
+ const right = Math.max(0, width - text.length - left);
42
+ return [
43
+ '',
44
+ '',
45
+ `{${S.subtle}-fg}${G.hline.repeat(left)}{/}{${S.accent}-fg}{bold}${text}{/}{${S.subtle}-fg}${G.hline.repeat(right)}{/}`,
46
+ '',
47
+ ];
48
+ }
49
+ export function renderRunSummary({ stats, fileWrites, dryRun, runDir, cwd, runStatus = null, }) {
50
+ const totalSeconds = stats.reduce((sum, stat) => sum + (stat.seconds || 0), 0);
51
+ const totalCost = stats.reduce((sum, stat) => sum + (stat.cost || 0), 0);
52
+ // Compact 6-column table: #, agent, model, status (colored), time, cost.
53
+ // Provider/Policy/Attempts/Turns are dropped - they're available in run-manifest.json.
54
+ const rows = stats.map((stat, index) => ({
55
+ step: String(index + 1),
56
+ agent: stat.agent,
57
+ model: displayValue(stat.model),
58
+ status: stat.status,
59
+ time: stat.status === 'dry-run' || stat.status === 'skipped' ? '-' : formatSeconds(stat.seconds),
60
+ cost: formatCost(stat.cost),
61
+ }));
62
+ const finalStatus = runStatus || (dryRun ? 'dry-run' : 'done');
63
+ const totalRow = {
64
+ step: '',
65
+ agent: 'TOTAL',
66
+ model: '-',
67
+ status: finalStatus,
68
+ time: formatSeconds(totalSeconds),
69
+ cost: formatCost(totalCost),
70
+ };
71
+ const allRows = [...rows, totalRow];
72
+ const widths = {
73
+ step: Math.max(1, ...allRows.map((row) => visibleLength(row.step))),
74
+ agent: Math.max(5, ...allRows.map((row) => visibleLength(row.agent))),
75
+ model: Math.max(5, ...allRows.map((row) => visibleLength(row.model))),
76
+ status: Math.max(6, ...allRows.map((row) => visibleLength(row.status))),
77
+ time: Math.max(4, ...allRows.map((row) => visibleLength(row.time))),
78
+ cost: Math.max(4, ...allRows.map((row) => visibleLength(row.cost))),
79
+ };
80
+ const hr = [
81
+ G.hline.repeat(widths.step + 2),
82
+ G.hline.repeat(widths.agent + 2),
83
+ G.hline.repeat(widths.model + 2),
84
+ G.hline.repeat(widths.status + 2),
85
+ G.hline.repeat(widths.time + 2),
86
+ G.hline.repeat(widths.cost + 2),
87
+ ].join(`{${S.subtle}-fg}${G.cross}{/}`);
88
+ // Bold each cell individually because `{/}` from the separator would reset a row-level bold.
89
+ function row(rowData, { bold = false, colorStatus = false } = {}) {
90
+ const b = bold ? '{bold}' : '';
91
+ const bClose = bold ? '{/}' : '';
92
+ const statusPadded = padVisible(rowData.status, widths.status);
93
+ const statusCell = colorStatus
94
+ ? `{${statusColor(rowData.status)}-fg}${b}${statusPadded}${bClose}{/}`
95
+ : `${b}${statusPadded}${bClose}`;
96
+ return [
97
+ ` ${b}${padVisible(rowData.step, widths.step, 'right')}${bClose} `,
98
+ ` ${b}${padVisible(rowData.agent, widths.agent)}${bClose} `,
99
+ ` ${b}${padVisible(rowData.model, widths.model)}${bClose} `,
100
+ ` ${statusCell} `,
101
+ ` ${b}${padVisible(rowData.time, widths.time, 'right')}${bClose} `,
102
+ ` ${b}${padVisible(rowData.cost, widths.cost, 'right')}${bClose} `,
103
+ ].join(`{${S.subtle}-fg}${G.vline}{/}`);
104
+ }
105
+ const lines = [
106
+ ...sectionHeader('Run summary'),
107
+ row({ step: '#', agent: 'Agent', model: 'Model', status: 'Status', time: 'Time', cost: 'Cost' }, { bold: true }),
108
+ `{${S.subtle}-fg}${hr}{/}`,
109
+ ...rows.map((item) => row(item, { colorStatus: true })),
110
+ `{${S.subtle}-fg}${hr}{/}`,
111
+ row(totalRow, { bold: true, colorStatus: true }),
112
+ '',
113
+ ];
114
+ if (runDir) {
115
+ lines.push(` {${S.muted}-fg}Run{/} {${S.keyword}-fg}${path.relative(cwd, runDir)}{/}`);
116
+ }
117
+ if (fileWrites.length) {
118
+ lines.push(` {${S.muted}-fg}Generated{/} {${S.keyword}-fg}${fileWrites[0]}{/}`);
119
+ for (const file of fileWrites.slice(1)) {
120
+ lines.push(` {${S.keyword}-fg}${file}{/}`);
121
+ }
122
+ }
123
+ lines.push('');
124
+ return lines;
125
+ }
126
+ export async function writeRunManifest({ runDir, runId, pipeline, cwd, stats, fileWrites, detectedDeliverables = [], status = 'done', error = null, debugEvents = [], }) {
127
+ if (!runDir)
128
+ return;
129
+ const uniqueWrites = [];
130
+ const seen = new Set();
131
+ for (const entry of [...fileWrites, ...detectedDeliverables]) {
132
+ if (seen.has(entry.absPath))
133
+ continue;
134
+ seen.add(entry.absPath);
135
+ uniqueWrites.push(entry);
136
+ }
137
+ const deliverables = uniqueWrites.filter((entry) => entry.kind === 'deliverable');
138
+ const intermediates = uniqueWrites.filter((entry) => entry.kind === 'intermediate');
139
+ const manifest = {
140
+ runId,
141
+ pipeline: pipeline.name,
142
+ projectRoot: cwd,
143
+ createdAt: new Date().toISOString(),
144
+ status,
145
+ error: error ? {
146
+ message: error.message,
147
+ } : null,
148
+ deliverables: deliverables.map((entry) => ({
149
+ path: entry.relPath,
150
+ absPath: entry.absPath,
151
+ })),
152
+ intermediates: intermediates.map((entry) => ({
153
+ path: entry.relPath,
154
+ absPath: entry.absPath,
155
+ })),
156
+ stats: stats.map((stat) => ({
157
+ agent: stat.agent,
158
+ provider: stat.provider,
159
+ model: stat.model,
160
+ runnerAgent: stat.runnerAgent,
161
+ securityProfile: stat.securityProfile,
162
+ permissionMode: stat.permissionMode,
163
+ status: stat.status,
164
+ seconds: stat.seconds,
165
+ turns: stat.turns,
166
+ cost: stat.cost,
167
+ attempts: stat.attempts || 1,
168
+ outputWarnings: stat.outputWarnings || [],
169
+ parsedOutputs: stat.parsedOutputs || [],
170
+ rawOutputPath: stat.rawOutputPath || null,
171
+ })),
172
+ debugEvents,
173
+ };
174
+ await fs.writeFile(path.join(runDir, 'run-manifest.json'), JSON.stringify(manifest, null, 2));
175
+ }
176
+ export async function writeLatestRunPointer({ cwd, runId }) {
177
+ const runsDir = path.join(cwd, '.singleton', 'runs');
178
+ await fs.mkdir(runsDir, { recursive: true });
179
+ await fs.writeFile(path.join(runsDir, LATEST_RUN_ID_FILE), `${runId}\n`);
180
+ const latest = path.join(runsDir, 'latest');
181
+ try {
182
+ const stat = await fs.lstat(latest);
183
+ if (stat.isSymbolicLink() || stat.isFile())
184
+ await fs.unlink(latest);
185
+ }
186
+ catch {
187
+ // Missing or non-removable legacy pointer is non-critical.
188
+ }
189
+ try {
190
+ await fs.symlink(runId, latest, 'dir');
191
+ }
192
+ catch { /* non-critical on Windows */ }
193
+ }
194
+ export async function resolveLatestRunDir(root) {
195
+ const runsDir = path.join(root, '.singleton', 'runs');
196
+ const pointer = path.join(runsDir, LATEST_RUN_ID_FILE);
197
+ try {
198
+ const runId = (await fs.readFile(pointer, 'utf8')).trim();
199
+ if (runId && !runId.includes('/') && !runId.includes('\\')) {
200
+ const runDir = path.join(runsDir, runId);
201
+ await fs.access(path.join(runDir, 'run-manifest.json'));
202
+ return runDir;
203
+ }
204
+ }
205
+ catch {
206
+ // Fall back to legacy symlink path below.
207
+ }
208
+ return path.join(runsDir, 'latest');
209
+ }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { style } from '../theme.js';
4
+ import { createPlainTimeline, createTimeline } from '../timeline.js';
5
+ import { G, S } from '../shell.js';
6
+ import { collectInputValues } from './inputs.js';
7
+ export function isNonInteractiveRuntime({ shell = null, nonInteractive = null, } = {}) {
8
+ if (shell)
9
+ return false;
10
+ if (typeof nonInteractive === 'boolean')
11
+ return nonInteractive;
12
+ return process.env.CI === 'true' || !process.stdout.isTTY;
13
+ }
14
+ export async function loadPipeline(filePath) {
15
+ const raw = await fs.readFile(filePath, 'utf8');
16
+ const pipeline = JSON.parse(raw);
17
+ if (!pipeline.steps || !Array.isArray(pipeline.steps)) {
18
+ throw new Error('Invalid pipeline: missing steps[]');
19
+ }
20
+ return pipeline;
21
+ }
22
+ // Project root = parent of the first `.singleton` segment found in pipelineDir.
23
+ // Handles both .singleton/foo.json and .singleton/pipelines/foo.json.
24
+ export function resolveProjectRoot(pipelineDir) {
25
+ const parts = pipelineDir.split(path.sep);
26
+ const idx = parts.indexOf('.singleton');
27
+ if (idx > 0)
28
+ return parts.slice(0, idx).join(path.sep) || path.sep;
29
+ return pipelineDir;
30
+ }
31
+ function createSilentTimeline() {
32
+ return {
33
+ log() { },
34
+ logMuted() { },
35
+ logSuccess() { },
36
+ logError() { },
37
+ logDiffLine() { },
38
+ setRunning() { },
39
+ setPaused() { },
40
+ setDone() { },
41
+ setError() { },
42
+ end() { },
43
+ };
44
+ }
45
+ export async function createRunWorkspace({ cwd, pipeline, dryRun, debug, }) {
46
+ const now = new Date();
47
+ const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
48
+ const runId = `${debug ? 'DEBUG-' : ''}${ts}-${pipeline.name}`;
49
+ const runDir = dryRun ? null : path.join(cwd, '.singleton', 'runs', runId);
50
+ if (runDir)
51
+ await fs.mkdir(runDir, { recursive: true });
52
+ return { runId, runDir };
53
+ }
54
+ export function logRunStart({ pipeline, cwd, runDir, dryRun, debug, shell, quiet, }) {
55
+ const runInfo = runDir ? `run: ${path.relative(cwd, runDir)}` : '';
56
+ if (!shell && !quiet) {
57
+ console.log(style.title(`\n${G.pointer} ${pipeline.name}`) + style.muted(` (${pipeline.steps.length} steps)`));
58
+ if (runInfo)
59
+ console.log(style.muted(` ${runInfo}`));
60
+ if (dryRun)
61
+ console.log(style.warn(' [dry-run] no CLI calls will be made'));
62
+ if (debug)
63
+ console.log(style.warn(' [debug] pausing before each step'));
64
+ }
65
+ else if (shell) {
66
+ shell.log(`{bold}${G.pointer} ${pipeline.name}{/} {${S.muted}-fg}(${pipeline.steps.length} steps){/}`);
67
+ if (runInfo)
68
+ shell.log(` {${S.muted}-fg}${runInfo}{/}`);
69
+ if (dryRun)
70
+ shell.log(`{yellow-fg} [dry-run] no CLI calls will be made{/}`);
71
+ if (debug)
72
+ shell.log(`{yellow-fg} [debug] pausing before each step{/}`);
73
+ shell.setMode?.('running');
74
+ }
75
+ }
76
+ function getInputDefs(pipeline) {
77
+ return (pipeline.nodes || [])
78
+ .filter((node) => node.type === 'input')
79
+ .map((node) => ({
80
+ id: node.id,
81
+ subtype: node.data?.subtype || 'text',
82
+ label: node.data?.label || node.id,
83
+ value: node.data?.value || '',
84
+ }));
85
+ }
86
+ export async function collectPipelineInputs({ pipeline, dryRun, shell, nonInteractive = false, quiet = false, }) {
87
+ const inputDefs = getInputDefs(pipeline);
88
+ // shell.prompt auto-toggles the frame to awaiting, so this only manages the label.
89
+ const promptFn = shell ? async (msg) => {
90
+ shell.setPipelineLabel?.('input waiting');
91
+ try {
92
+ return await shell.prompt(msg);
93
+ }
94
+ finally {
95
+ shell.clearPipelineLabel?.();
96
+ }
97
+ } : null;
98
+ const inputValues = await collectInputValues(pipeline, dryRun, {
99
+ promptFn,
100
+ style: quiet ? null : style,
101
+ nonInteractive,
102
+ });
103
+ return { inputDefs, inputValues };
104
+ }
105
+ export function createRunTimeline({ pipeline, quiet, shell, nonInteractive = false, }) {
106
+ if (shell)
107
+ shell.enterPipelineMode();
108
+ if (quiet)
109
+ return createSilentTimeline();
110
+ const stepNames = ['preflight checks', ...pipeline.steps.map((step) => step.agent)];
111
+ return nonInteractive && !shell
112
+ ? createPlainTimeline(stepNames)
113
+ : createTimeline(stepNames, shell ? shell.pipelineWidgets : null);
114
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { S } from '../shell.js';
5
+ function runCommand(cmd, args, { cwd }) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
11
+ child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
12
+ child.on('error', reject);
13
+ child.on('close', (code) => {
14
+ if (code !== 0) {
15
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
16
+ return;
17
+ }
18
+ resolve({ stdout, stderr });
19
+ });
20
+ });
21
+ }
22
+ export async function getViolationDiffPreview(cwd, relPath, { maxLines = 80 } = {}) {
23
+ try {
24
+ const { stdout } = await runCommand('git', ['diff', '--', relPath], { cwd });
25
+ const lines = stdout.trimEnd().split('\n').filter(Boolean);
26
+ if (lines.length === 0) {
27
+ try {
28
+ await runCommand('git', ['ls-files', '--error-unmatch', relPath], { cwd });
29
+ return ['No git diff available for this path.'];
30
+ }
31
+ catch {
32
+ try {
33
+ const raw = await fs.readFile(path.join(cwd, relPath), 'utf8');
34
+ const preview = raw.split('\n').slice(0, maxLines);
35
+ if (raw.split('\n').length > maxLines) {
36
+ preview.push(`... file preview truncated (${raw.split('\n').length - maxLines} more lines)`);
37
+ }
38
+ return [`new/untracked file: ${relPath}`, ...preview];
39
+ }
40
+ catch {
41
+ return ['No git diff available for this path.'];
42
+ }
43
+ }
44
+ }
45
+ const clipped = lines.slice(0, maxLines);
46
+ if (lines.length > maxLines)
47
+ clipped.push(`... diff truncated (${lines.length - maxLines} more lines)`);
48
+ return clipped;
49
+ }
50
+ catch {
51
+ return ['No git diff available for this path.'];
52
+ }
53
+ }
54
+ async function logViolationDiffPreviews({ violations, cwd, timeline, }) {
55
+ const maxFiles = 5;
56
+ const shown = violations.slice(0, maxFiles);
57
+ for (const violation of shown) {
58
+ timeline.log(`── diff ${violation.path} ──`);
59
+ const preview = await getViolationDiffPreview(cwd, violation.path);
60
+ for (const line of preview)
61
+ timeline.logDiffLine(line);
62
+ }
63
+ if (violations.length > maxFiles) {
64
+ timeline.logMuted(`... ${violations.length - maxFiles} more violated file(s) not shown`);
65
+ }
66
+ }
67
+ export async function handlePostRunViolations({ violations, step, securityPolicy, timeline, timelineIndex, shell, cwd, failStep, }) {
68
+ if (violations.length === 0)
69
+ return;
70
+ timeline.log(`── post-run security violation ──`);
71
+ timeline.logMuted(`Step "${step.agent}" changed files outside its security policy.`);
72
+ timeline.logMuted(`security_profile: ${securityPolicy.profile}`);
73
+ for (const violation of violations) {
74
+ timeline.logMuted(`- ${violation.path}`);
75
+ }
76
+ await logViolationDiffPreviews({ violations, cwd, timeline });
77
+ if (!shell) {
78
+ failStep(timeline, timelineIndex, `${violations.length} security violation${violations.length > 1 ? 's' : ''}`, `Post-run security validation failed for "${step.agent}":\n- ${violations.map((violation) => violation.path).join('\n- ')}`);
79
+ }
80
+ const activeShell = shell;
81
+ while (true) {
82
+ const answer = (await activeShell.prompt('Security violation: continue, stop, or diff? (c/s/d)')).trim().toLowerCase();
83
+ if (answer === 'd' || answer === 'diff') {
84
+ await logViolationDiffPreviews({ violations, cwd, timeline });
85
+ continue;
86
+ }
87
+ if (answer === 'c' || answer === 'continue' || answer === 'y' || answer === 'yes') {
88
+ timeline.log(`{${S.warning}-fg}!{/} Continued after security violation for ${step.agent}.`);
89
+ return;
90
+ }
91
+ if (!answer || answer === 's' || answer === 'stop' || answer === 'n' || answer === 'no') {
92
+ break;
93
+ }
94
+ timeline.logMuted('Choose c/continue, s/stop, or d/diff.');
95
+ }
96
+ failStep(timeline, timelineIndex, 'stopped by security review', `Pipeline stopped after post-run security validation for "${step.agent}".`);
97
+ }