singleton-pipeline 0.4.0-beta.1 → 0.4.0-beta.13
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/CHANGELOG.md +49 -0
- package/README.md +170 -129
- package/docs/reference.md +63 -18
- package/package.json +3 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/new.js +455 -109
- package/packages/cli/src/commands/repl.js +86 -89
- package/packages/cli/src/executor/debug-loop.js +587 -0
- package/packages/cli/src/executor/inputs.js +202 -0
- package/packages/cli/src/executor/outputs.js +140 -0
- package/packages/cli/src/executor/preflight.js +459 -0
- package/packages/cli/src/executor/replay-loop.js +172 -0
- package/packages/cli/src/executor/run-report.js +189 -0
- package/packages/cli/src/executor/run-setup.js +93 -0
- package/packages/cli/src/executor/security-review.js +108 -0
- package/packages/cli/src/executor/snapshot-manager.js +335 -0
- package/packages/cli/src/executor/step-runner.js +266 -0
- package/packages/cli/src/executor.js +233 -2228
- package/packages/cli/src/index.js +1 -1
- package/packages/cli/src/runners/claude.js +6 -3
- package/packages/cli/src/runners/codex.js +6 -3
- package/packages/cli/src/runners/copilot.js +25 -9
- package/packages/cli/src/runners/opencode.js +1 -1
- package/packages/cli/src/shell.js +244 -54
- package/packages/cli/src/timeline.js +54 -20
- package/packages/server/package.json +1 -1
- package/packages/web/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { S } from '../shell.js';
|
|
4
|
+
|
|
5
|
+
function visibleLength(s) {
|
|
6
|
+
return String(s || '').replace(/\{[^}]+\}/g, '').length;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function padVisible(s, width, align = 'left') {
|
|
10
|
+
const str = String(s ?? '');
|
|
11
|
+
const pad = Math.max(0, width - visibleLength(str));
|
|
12
|
+
return align === 'right' ? `${' '.repeat(pad)}${str}` : `${str}${' '.repeat(pad)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatSeconds(value) {
|
|
16
|
+
return `${Number(value || 0).toFixed(1)}s`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatCost(value) {
|
|
20
|
+
return value > 0 ? `$${value.toFixed(4)}` : '—';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatTurns(value) {
|
|
24
|
+
return value > 0 ? String(value) : '—';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Status → color. Only the Status cell is coloured; the rest of the row stays white
|
|
28
|
+
// so the outcome is scannable without being noisy.
|
|
29
|
+
function statusColor(status) {
|
|
30
|
+
if (status === 'done') return S.success;
|
|
31
|
+
if (status === 'failed') return S.error;
|
|
32
|
+
if (status === 'dry-run') return S.warning;
|
|
33
|
+
if (status === 'skipped') return S.muted;
|
|
34
|
+
return S.text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Section header in the debug-loop style: blank lines + `─── title ───` centered, colored accent.
|
|
38
|
+
function sectionHeader(title) {
|
|
39
|
+
const width = 72;
|
|
40
|
+
const text = ` ${title} `;
|
|
41
|
+
const left = Math.max(0, Math.floor((width - text.length) / 2));
|
|
42
|
+
const right = Math.max(0, width - text.length - left);
|
|
43
|
+
return [
|
|
44
|
+
'',
|
|
45
|
+
'',
|
|
46
|
+
`{${S.subtle}-fg}${'─'.repeat(left)}{/}{${S.accent}-fg}{bold}${text}{/}{${S.subtle}-fg}${'─'.repeat(right)}{/}`,
|
|
47
|
+
'',
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderRunSummary({ stats, fileWrites, dryRun, runDir, cwd, runStatus = null }) {
|
|
52
|
+
const totalSeconds = stats.reduce((sum, s) => sum + (s.seconds || 0), 0);
|
|
53
|
+
const totalCost = stats.reduce((sum, s) => sum + (s.cost || 0), 0);
|
|
54
|
+
|
|
55
|
+
// Compact 6-column table: #, agent, model, status (colored), time, cost.
|
|
56
|
+
// Provider/Policy/Attempts/Turns are dropped — they're available in run-manifest.json.
|
|
57
|
+
const rows = stats.map((s, i) => ({
|
|
58
|
+
step: String(i + 1),
|
|
59
|
+
agent: s.agent,
|
|
60
|
+
model: s.model || '—',
|
|
61
|
+
status: s.status,
|
|
62
|
+
time: s.status === 'dry-run' || s.status === 'skipped' ? '—' : formatSeconds(s.seconds),
|
|
63
|
+
cost: formatCost(s.cost),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const finalStatus = runStatus || (dryRun ? 'dry-run' : 'done');
|
|
67
|
+
const totalRow = {
|
|
68
|
+
step: '',
|
|
69
|
+
agent: 'TOTAL',
|
|
70
|
+
model: '—',
|
|
71
|
+
status: finalStatus,
|
|
72
|
+
time: formatSeconds(totalSeconds),
|
|
73
|
+
cost: formatCost(totalCost),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const allRows = [...rows, totalRow];
|
|
77
|
+
const widths = {
|
|
78
|
+
step: Math.max(1, ...allRows.map((r) => visibleLength(r.step))),
|
|
79
|
+
agent: Math.max(5, ...allRows.map((r) => visibleLength(r.agent))),
|
|
80
|
+
model: Math.max(5, ...allRows.map((r) => visibleLength(r.model))),
|
|
81
|
+
status: Math.max(6, ...allRows.map((r) => visibleLength(r.status))),
|
|
82
|
+
time: Math.max(4, ...allRows.map((r) => visibleLength(r.time))),
|
|
83
|
+
cost: Math.max(4, ...allRows.map((r) => visibleLength(r.cost))),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const hr = [
|
|
87
|
+
'─'.repeat(widths.step + 2),
|
|
88
|
+
'─'.repeat(widths.agent + 2),
|
|
89
|
+
'─'.repeat(widths.model + 2),
|
|
90
|
+
'─'.repeat(widths.status + 2),
|
|
91
|
+
'─'.repeat(widths.time + 2),
|
|
92
|
+
'─'.repeat(widths.cost + 2),
|
|
93
|
+
].join(`{${S.subtle}-fg}┼{/}`);
|
|
94
|
+
|
|
95
|
+
// Bold each cell individually because `{/}` from the separator would reset a row-level bold.
|
|
96
|
+
function row(r, { bold = false, colorStatus = false } = {}) {
|
|
97
|
+
const b = bold ? '{bold}' : '';
|
|
98
|
+
const bClose = bold ? '{/}' : '';
|
|
99
|
+
const statusPadded = padVisible(r.status, widths.status);
|
|
100
|
+
const statusCell = colorStatus
|
|
101
|
+
? `{${statusColor(r.status)}-fg}${b}${statusPadded}${bClose}{/}`
|
|
102
|
+
: `${b}${statusPadded}${bClose}`;
|
|
103
|
+
return [
|
|
104
|
+
` ${b}${padVisible(r.step, widths.step, 'right')}${bClose} `,
|
|
105
|
+
` ${b}${padVisible(r.agent, widths.agent)}${bClose} `,
|
|
106
|
+
` ${b}${padVisible(r.model, widths.model)}${bClose} `,
|
|
107
|
+
` ${statusCell} `,
|
|
108
|
+
` ${b}${padVisible(r.time, widths.time, 'right')}${bClose} `,
|
|
109
|
+
` ${b}${padVisible(r.cost, widths.cost, 'right')}${bClose} `,
|
|
110
|
+
].join(`{${S.subtle}-fg}│{/}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = [
|
|
114
|
+
...sectionHeader('Run summary'),
|
|
115
|
+
row({ step: '#', agent: 'Agent', model: 'Model', status: 'Status', time: 'Time', cost: 'Cost' }, { bold: true }),
|
|
116
|
+
`{${S.subtle}-fg}${hr}{/}`,
|
|
117
|
+
...rows.map((r) => row(r, { colorStatus: true })),
|
|
118
|
+
`{${S.subtle}-fg}${hr}{/}`,
|
|
119
|
+
row(totalRow, { bold: true, colorStatus: true }),
|
|
120
|
+
'',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
if (runDir) {
|
|
124
|
+
lines.push(` {${S.muted}-fg}Run{/} {${S.keyword}-fg}${path.relative(cwd, runDir)}{/}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (fileWrites.length) {
|
|
128
|
+
lines.push(` {${S.muted}-fg}Generated{/} {${S.keyword}-fg}${fileWrites[0]}{/}`);
|
|
129
|
+
for (const f of fileWrites.slice(1)) {
|
|
130
|
+
lines.push(` {${S.keyword}-fg}${f}{/}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function writeRunManifest({ runDir, runId, pipeline, cwd, stats, fileWrites, detectedDeliverables = [], status = 'done', error = null, debugEvents = [] }) {
|
|
139
|
+
if (!runDir) return;
|
|
140
|
+
|
|
141
|
+
const uniqueWrites = [];
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
for (const entry of [...fileWrites, ...detectedDeliverables]) {
|
|
144
|
+
if (seen.has(entry.absPath)) continue;
|
|
145
|
+
seen.add(entry.absPath);
|
|
146
|
+
uniqueWrites.push(entry);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const deliverables = uniqueWrites.filter((entry) => entry.kind === 'deliverable');
|
|
150
|
+
const intermediates = uniqueWrites.filter((entry) => entry.kind === 'intermediate');
|
|
151
|
+
|
|
152
|
+
const manifest = {
|
|
153
|
+
runId,
|
|
154
|
+
pipeline: pipeline.name,
|
|
155
|
+
projectRoot: cwd,
|
|
156
|
+
createdAt: new Date().toISOString(),
|
|
157
|
+
status,
|
|
158
|
+
error: error ? {
|
|
159
|
+
message: error.message,
|
|
160
|
+
} : null,
|
|
161
|
+
deliverables: deliverables.map((entry) => ({
|
|
162
|
+
path: entry.relPath,
|
|
163
|
+
absPath: entry.absPath,
|
|
164
|
+
})),
|
|
165
|
+
intermediates: intermediates.map((entry) => ({
|
|
166
|
+
path: entry.relPath,
|
|
167
|
+
absPath: entry.absPath,
|
|
168
|
+
})),
|
|
169
|
+
stats: stats.map((s) => ({
|
|
170
|
+
agent: s.agent,
|
|
171
|
+
provider: s.provider,
|
|
172
|
+
model: s.model,
|
|
173
|
+
runnerAgent: s.runnerAgent,
|
|
174
|
+
securityProfile: s.securityProfile,
|
|
175
|
+
permissionMode: s.permissionMode,
|
|
176
|
+
status: s.status,
|
|
177
|
+
seconds: s.seconds,
|
|
178
|
+
turns: s.turns,
|
|
179
|
+
cost: s.cost,
|
|
180
|
+
attempts: s.attempts || 1,
|
|
181
|
+
outputWarnings: s.outputWarnings || [],
|
|
182
|
+
parsedOutputs: s.parsedOutputs || [],
|
|
183
|
+
rawOutputPath: s.rawOutputPath || null,
|
|
184
|
+
})),
|
|
185
|
+
debugEvents,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await fs.writeFile(path.join(runDir, 'run-manifest.json'), JSON.stringify(manifest, null, 2));
|
|
189
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { style } from '../theme.js';
|
|
4
|
+
import { createTimeline } from '../timeline.js';
|
|
5
|
+
import { S } from '../shell.js';
|
|
6
|
+
import { collectInputValues } from './inputs.js';
|
|
7
|
+
|
|
8
|
+
export async function loadPipeline(filePath) {
|
|
9
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
10
|
+
const pipeline = JSON.parse(raw);
|
|
11
|
+
if (!pipeline.steps || !Array.isArray(pipeline.steps)) {
|
|
12
|
+
throw new Error('Invalid pipeline: missing steps[]');
|
|
13
|
+
}
|
|
14
|
+
return pipeline;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Project root = parent of the first `.singleton` segment found in pipelineDir.
|
|
18
|
+
// Handles both .singleton/foo.json and .singleton/pipelines/foo.json.
|
|
19
|
+
export function resolveProjectRoot(pipelineDir) {
|
|
20
|
+
const parts = pipelineDir.split(path.sep);
|
|
21
|
+
const idx = parts.indexOf('.singleton');
|
|
22
|
+
if (idx > 0) return parts.slice(0, idx).join(path.sep) || path.sep;
|
|
23
|
+
return pipelineDir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createSilentTimeline() {
|
|
27
|
+
return {
|
|
28
|
+
log() {},
|
|
29
|
+
logMuted() {},
|
|
30
|
+
logSuccess() {},
|
|
31
|
+
logError() {},
|
|
32
|
+
logDiffLine() {},
|
|
33
|
+
setRunning() {},
|
|
34
|
+
setPaused() {},
|
|
35
|
+
setDone() {},
|
|
36
|
+
setError() {},
|
|
37
|
+
end() {},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function createRunWorkspace({ cwd, pipeline, dryRun, debug }) {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
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')}`;
|
|
44
|
+
const runId = `${debug ? 'DEBUG-' : ''}${ts}-${pipeline.name}`;
|
|
45
|
+
const runDir = dryRun ? null : path.join(cwd, '.singleton', 'runs', runId);
|
|
46
|
+
if (runDir) await fs.mkdir(runDir, { recursive: true });
|
|
47
|
+
return { runId, runDir };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function logRunStart({ pipeline, cwd, runDir, dryRun, debug, shell, quiet }) {
|
|
51
|
+
const runInfo = runDir ? `run: ${path.relative(cwd, runDir)}` : '';
|
|
52
|
+
if (!shell && !quiet) {
|
|
53
|
+
console.log(style.title(`\n▸ ${pipeline.name}`) + style.muted(` (${pipeline.steps.length} steps)`));
|
|
54
|
+
if (runInfo) console.log(style.muted(` ${runInfo}`));
|
|
55
|
+
if (dryRun) console.log(style.warn(' [dry-run] no CLI calls will be made'));
|
|
56
|
+
if (debug) console.log(style.warn(' [debug] pausing before each step'));
|
|
57
|
+
} else if (shell) {
|
|
58
|
+
shell.log(`{bold}▸ ${pipeline.name}{/} {${S.muted}-fg}(${pipeline.steps.length} steps){/}`);
|
|
59
|
+
if (runInfo) shell.log(` {${S.muted}-fg}${runInfo}{/}`);
|
|
60
|
+
if (dryRun) shell.log(`{yellow-fg} [dry-run] no CLI calls will be made{/}`);
|
|
61
|
+
if (debug) shell.log(`{yellow-fg} [debug] pausing before each step{/}`);
|
|
62
|
+
shell.setMode?.('running');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getInputDefs(pipeline) {
|
|
67
|
+
return (pipeline.nodes || [])
|
|
68
|
+
.filter((n) => n.type === 'input')
|
|
69
|
+
.map((n) => ({ id: n.id, subtype: n.data?.subtype || 'text', label: n.data?.label || n.id, value: n.data?.value || '' }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function collectPipelineInputs({ pipeline, dryRun, shell }) {
|
|
73
|
+
const inputDefs = getInputDefs(pipeline);
|
|
74
|
+
|
|
75
|
+
// shell.prompt auto-toggles the frame to awaiting, so this only manages the label.
|
|
76
|
+
const promptFn = shell ? async (msg) => {
|
|
77
|
+
shell.setPipelineLabel?.('input waiting');
|
|
78
|
+
try { return await shell.prompt(msg); }
|
|
79
|
+
finally { shell.clearPipelineLabel?.(); }
|
|
80
|
+
} : null;
|
|
81
|
+
const inputValues = await collectInputValues(pipeline, dryRun, { promptFn, style });
|
|
82
|
+
return { inputDefs, inputValues };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createRunTimeline({ pipeline, quiet, shell }) {
|
|
86
|
+
if (shell) shell.enterPipelineMode();
|
|
87
|
+
return quiet
|
|
88
|
+
? createSilentTimeline()
|
|
89
|
+
: createTimeline(
|
|
90
|
+
['preflight checks', ...pipeline.steps.map((s) => s.agent)],
|
|
91
|
+
shell ? shell.pipelineWidgets : null
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
|
|
6
|
+
function runCommand(cmd, args, { cwd }) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
9
|
+
let stdout = '';
|
|
10
|
+
let stderr = '';
|
|
11
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
12
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
13
|
+
child.on('error', reject);
|
|
14
|
+
child.on('close', (code) => {
|
|
15
|
+
if (code !== 0) {
|
|
16
|
+
reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
resolve({ stdout, stderr });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getViolationDiffPreview(cwd, relPath, { maxLines = 80 } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await runCommand('git', ['diff', '--', relPath], { cwd });
|
|
27
|
+
const lines = stdout.trimEnd().split('\n').filter(Boolean);
|
|
28
|
+
if (lines.length === 0) {
|
|
29
|
+
try {
|
|
30
|
+
await runCommand('git', ['ls-files', '--error-unmatch', relPath], { cwd });
|
|
31
|
+
return ['No git diff available for this path.'];
|
|
32
|
+
} catch {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(path.join(cwd, relPath), 'utf8');
|
|
35
|
+
const preview = raw.split('\n').slice(0, maxLines);
|
|
36
|
+
if (raw.split('\n').length > maxLines) {
|
|
37
|
+
preview.push(`... file preview truncated (${raw.split('\n').length - maxLines} more lines)`);
|
|
38
|
+
}
|
|
39
|
+
return [`new/untracked file: ${relPath}`, ...preview];
|
|
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) clipped.push(`... diff truncated (${lines.length - maxLines} more lines)`);
|
|
47
|
+
return clipped;
|
|
48
|
+
} catch {
|
|
49
|
+
return ['No git diff available for this path.'];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function logViolationDiffPreviews({ violations, cwd, timeline }) {
|
|
54
|
+
const maxFiles = 5;
|
|
55
|
+
const shown = violations.slice(0, maxFiles);
|
|
56
|
+
for (const violation of shown) {
|
|
57
|
+
timeline.log(`── diff ${violation.path} ──`);
|
|
58
|
+
const preview = await getViolationDiffPreview(cwd, violation.path);
|
|
59
|
+
for (const line of preview) timeline.logDiffLine(line);
|
|
60
|
+
}
|
|
61
|
+
if (violations.length > maxFiles) {
|
|
62
|
+
timeline.logMuted(`... ${violations.length - maxFiles} more violated file(s) not shown`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function handlePostRunViolations({ violations, step, securityPolicy, timeline, timelineIndex, shell, cwd, failStep }) {
|
|
67
|
+
if (violations.length === 0) return;
|
|
68
|
+
|
|
69
|
+
timeline.log(`── post-run security violation ──`);
|
|
70
|
+
timeline.logMuted(`Step "${step.agent}" changed files outside its security policy.`);
|
|
71
|
+
timeline.logMuted(`security_profile: ${securityPolicy.profile}`);
|
|
72
|
+
for (const violation of violations) {
|
|
73
|
+
timeline.logMuted(`- ${violation.path}`);
|
|
74
|
+
}
|
|
75
|
+
await logViolationDiffPreviews({ violations, cwd, timeline });
|
|
76
|
+
|
|
77
|
+
if (!shell) {
|
|
78
|
+
failStep(
|
|
79
|
+
timeline,
|
|
80
|
+
timelineIndex,
|
|
81
|
+
`${violations.length} security violation${violations.length > 1 ? 's' : ''}`,
|
|
82
|
+
`Post-run security validation failed for "${step.agent}":\n- ${violations.map((v) => v.path).join('\n- ')}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const answer = (await shell.prompt('Security violation: continue, stop, or diff? (c/s/d)')).trim().toLowerCase();
|
|
88
|
+
if (answer === 'd' || answer === 'diff') {
|
|
89
|
+
await logViolationDiffPreviews({ violations, cwd, timeline });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (answer === 'c' || answer === 'continue' || answer === 'y' || answer === 'yes') {
|
|
93
|
+
timeline.log(`{${S.warning}-fg}!{/} Continued after security violation for ${step.agent}.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!answer || answer === 's' || answer === 'stop' || answer === 'n' || answer === 'no') {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
timeline.logMuted('Choose c/continue, s/stop, or d/diff.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
failStep(
|
|
103
|
+
timeline,
|
|
104
|
+
timelineIndex,
|
|
105
|
+
'stopped by security review',
|
|
106
|
+
`Pipeline stopped after post-run security validation for "${step.agent}".`
|
|
107
|
+
);
|
|
108
|
+
}
|