singleton-pipeline 0.4.0-beta.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.
@@ -0,0 +1,2646 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import fg from 'fast-glob';
5
+ import { spawn } from 'node:child_process';
6
+ import { input } from '@inquirer/prompts';
7
+ import { parseAgentFileDetailed } from './parser.js';
8
+ import { style, line } from './theme.js';
9
+ import { createTimeline } from './timeline.js';
10
+ import { C } from './shell.js';
11
+ import { getRunner } from './runners/index.js';
12
+ import { discoverCodexProjectInstructions } from './runners/codex-instructions.js';
13
+ import {
14
+ assertWriteAllowed,
15
+ loadProjectSecurityConfig,
16
+ resolveSecurityPolicyWithConfig,
17
+ validateSecurityPolicy,
18
+ } from './security/policy.js';
19
+
20
+ export async function loadPipeline(filePath) {
21
+ const raw = await fs.readFile(filePath, 'utf8');
22
+ const pipeline = JSON.parse(raw);
23
+ if (!pipeline.steps || !Array.isArray(pipeline.steps)) {
24
+ throw new Error('Invalid pipeline: missing steps[]');
25
+ }
26
+ return pipeline;
27
+ }
28
+
29
+ // Patterns are always resolved relative to the project root (`cwd`).
30
+ // Absolute paths are accepted as-is. Globs go through fast-glob; the
31
+ // literal-path fallback handles the case where fg returns nothing but
32
+ // the file actually exists on disk.
33
+ async function resolveFileGlob(spec, cwd) {
34
+ const pattern = spec.slice('$FILE:'.length).trim();
35
+ const files = await fg(pattern, { cwd, absolute: true, dot: false });
36
+ if (files.length === 0) {
37
+ const abs = path.isAbsolute(pattern) ? pattern : path.resolve(cwd, pattern);
38
+ try {
39
+ const content = await fs.readFile(abs, 'utf8');
40
+ return [{ path: abs, content }];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+ const results = [];
46
+ for (const f of files) {
47
+ const content = await fs.readFile(f, 'utf8');
48
+ results.push({ path: f, content });
49
+ }
50
+ return results;
51
+ }
52
+
53
+ function resolvePipeRef(spec, registry) {
54
+ const ref = spec.slice('$PIPE:'.length).trim();
55
+ const [agentId, outName] = ref.split('.');
56
+ const key = outName ? `${agentId}.${outName}` : agentId;
57
+ if (!(key in registry)) {
58
+ throw new Error(`Unresolved $PIPE reference: ${ref}`);
59
+ }
60
+ return registry[key];
61
+ }
62
+
63
+ function parsePipeRef(spec) {
64
+ const ref = String(spec).slice('$PIPE:'.length).trim();
65
+ const [agentId, outName] = ref.split('.');
66
+ return { ref, agentId, outName };
67
+ }
68
+
69
+ function parseInputRef(spec) {
70
+ if (typeof spec !== 'string' || !spec.startsWith('$INPUT:')) return null;
71
+ return spec.slice('$INPUT:'.length).trim();
72
+ }
73
+
74
+ function resolveDebugInputOverridesFromEdit(step, previousInputs, nextInputs, inputDefs = []) {
75
+ const overrides = {};
76
+ for (const [name, value] of Object.entries(nextInputs || {})) {
77
+ if (previousInputs?.[name] === value) continue;
78
+ const inputId = parseInputRef(step.inputs?.[name]);
79
+ if (!inputId) continue;
80
+ const def = inputDefs.find((item) => item.id === inputId);
81
+ if (def?.subtype === 'file') continue;
82
+ overrides[inputId] = value;
83
+ }
84
+ return overrides;
85
+ }
86
+
87
+ async function resolveInput(spec, { registry, cwd, inputValues = {}, inputDefs = [] }) {
88
+ if (typeof spec !== 'string') return String(spec);
89
+ if (spec.startsWith('$INPUT:')) {
90
+ const id = spec.slice('$INPUT:'.length).trim();
91
+ const val = inputValues[id];
92
+ if (!val) return `(input not provided: ${id})`;
93
+ const def = inputDefs.find((i) => i.id === id);
94
+ if (def?.subtype === 'file') {
95
+ return resolveInput(`$FILE:${val}`, { registry, cwd, inputValues, inputDefs });
96
+ }
97
+ return val;
98
+ }
99
+ if (spec.startsWith('$FILE:')) {
100
+ const files = await resolveFileGlob(spec, cwd);
101
+ if (files.length === 0) return `(no files matched: ${spec})`;
102
+ return files.map((f) => `<file path="${path.relative(cwd, f.path)}">\n${f.content}\n</file>`).join('\n\n');
103
+ }
104
+ if (spec.startsWith('$PIPE:')) {
105
+ return resolvePipeRef(spec, registry);
106
+ }
107
+ return spec;
108
+ }
109
+
110
+ async function collectInputValues(pipeline, dryRun, promptFn = null) {
111
+ const defs = (pipeline.nodes || [])
112
+ .filter((n) => n.type === 'input')
113
+ .map((n) => ({ id: n.id, subtype: n.data?.subtype || 'text', label: n.data?.label || n.id, value: n.data?.value || '' }));
114
+ if (defs.length === 0) return {};
115
+
116
+ if (!promptFn) console.log(style.heading('\nInputs\n'));
117
+
118
+ const askFn = promptFn || ((msg, def) => input({ message: msg, ...(def ? { default: def } : {}) }));
119
+
120
+ const values = {};
121
+ for (const def of defs) {
122
+ const label = def.label || def.id;
123
+ if (def.subtype === 'file' && def.value) {
124
+ values[def.id] = def.value;
125
+ if (!promptFn) console.log(style.muted(` ${label}: ${def.value}`));
126
+ } else if (dryRun) {
127
+ values[def.id] = def.subtype === 'file' ? '(file path not provided)' : 'arbitrary response (dry-run)';
128
+ if (!promptFn) console.log(style.muted(` ${label}: (arbitrary)`));
129
+ } else {
130
+ const msg = def.subtype === 'file' ? `${label} (file path)` : label;
131
+ values[def.id] = await askFn(msg, def.value || null);
132
+ }
133
+ }
134
+ return values;
135
+ }
136
+
137
+ function buildSecurityPolicyBlock(securityPolicy) {
138
+ if (!securityPolicy) return [];
139
+
140
+ const lines = [
141
+ '<security_policy>',
142
+ `security_profile: ${securityPolicy.profile}`,
143
+ ];
144
+
145
+ if (securityPolicy.allowedPaths.length) {
146
+ lines.push('allowed_paths:');
147
+ for (const entry of securityPolicy.allowedPaths) lines.push(`- ${entry}`);
148
+ }
149
+
150
+ if (securityPolicy.blockedPaths.length) {
151
+ lines.push('blocked_paths:');
152
+ for (const entry of securityPolicy.blockedPaths) lines.push(`- ${entry}`);
153
+ }
154
+
155
+ lines.push('');
156
+ lines.push('Rules:');
157
+ if (securityPolicy.profile === 'read-only') {
158
+ lines.push('- Do not create, edit, move, or delete project files.');
159
+ lines.push('- You may read files and produce only the final pipeline output.');
160
+ lines.push('- If a change is required, describe it in your output instead of applying it.');
161
+ } else if (securityPolicy.profile === 'restricted-write') {
162
+ lines.push('- You may modify project files only inside allowed_paths.');
163
+ lines.push('- If the requested change requires files outside allowed_paths, stop and explain it in your output.');
164
+ } else if (securityPolicy.profile === 'workspace-write') {
165
+ lines.push('- You may modify project files, except blocked_paths.');
166
+ } else if (securityPolicy.profile === 'dangerous') {
167
+ lines.push('- You have broad write permissions inside the project root. Use the smallest necessary change.');
168
+ }
169
+ lines.push('- Internal run artifacts are handled by Singleton; do not write into .singleton manually.');
170
+ lines.push('</security_policy>');
171
+ return lines;
172
+ }
173
+
174
+ function buildUserMessage(resolvedInputs, outputNames, workspaceInfo, securityPolicy) {
175
+ const parts = [];
176
+ if (workspaceInfo) {
177
+ parts.push('<workspace>');
178
+ parts.push(`Project root: ${workspaceInfo.projectRoot}`);
179
+ parts.push(`Working directory for this step: ${workspaceInfo.stepDirRel}`);
180
+ parts.push('');
181
+ parts.push(`File writing rules:`);
182
+ parts.push(`- Project deliverables (source code: components, views, API, services, tests, styles, etc.): use your Write tool to place them at their natural location in the repo (example: src/components/molecules/X.vue, server/routes/api.js). Paths are relative to the project root.`);
183
+ parts.push(`- Intermediate files (reviews, plans, logs, notes, debug, scratch): write them inside the step working directory above.`);
184
+ parts.push(`- Never write deliverable source code into .singleton/ or into the step working directory.`);
185
+ parts.push('</workspace>');
186
+ parts.push('');
187
+ }
188
+ const securityBlock = buildSecurityPolicyBlock(securityPolicy);
189
+ if (securityBlock.length) {
190
+ parts.push(...securityBlock);
191
+ parts.push('');
192
+ }
193
+ for (const [name, value] of Object.entries(resolvedInputs)) {
194
+ parts.push(`<${name}>\n${value}\n</${name}>`);
195
+ }
196
+ parts.push('');
197
+ if (outputNames.length === 1) {
198
+ parts.push(`Provide your response as the <${outputNames[0]}> content directly (no XML wrapper needed).`);
199
+ } else {
200
+ parts.push('Provide your response with each output wrapped in its own XML block:');
201
+ for (const name of outputNames) parts.push(`<${name}>...</${name}>`);
202
+ }
203
+ return parts.join('\n');
204
+ }
205
+
206
+ // Project root = parent of the first `.singleton` segment found in pipelineDir.
207
+ // Handles both .singleton/foo.json and .singleton/pipelines/foo.json.
208
+ function resolveProjectRoot(pipelineDir) {
209
+ const parts = pipelineDir.split(path.sep);
210
+ const idx = parts.indexOf('.singleton');
211
+ if (idx > 0) return parts.slice(0, idx).join(path.sep) || path.sep;
212
+ return pipelineDir;
213
+ }
214
+
215
+ function isInsidePath(absPath, absRoot) {
216
+ const rel = path.relative(absRoot, absPath);
217
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
218
+ }
219
+
220
+ function isSingletonInternalPath(absPath, cwd) {
221
+ return isInsidePath(absPath, path.join(cwd, '.singleton'));
222
+ }
223
+
224
+ function assertRunArtifactWriteAllowed(absPath, artifactRoot, agentName, outputName) {
225
+ if (!isInsidePath(absPath, artifactRoot)) {
226
+ throw new Error(
227
+ `Step "${agentName}" output "${outputName}" resolves outside the run artifact workspace: ${absPath}`
228
+ );
229
+ }
230
+ }
231
+
232
+ // If an internal Singleton sink lands inside <root>/.singleton/ (but not inside
233
+ // .singleton/runs/), redirect it into the current step's workspace. Project
234
+ // deliverables are left untouched and remain subject to the security policy.
235
+ function rewriteInternalSink(sink, { cwd, stepDir }) {
236
+ if (typeof sink !== 'string') return sink;
237
+ const prefix = sink.startsWith('$FILE:') ? '$FILE:' : sink.startsWith('$FILES:') ? '$FILES:' : null;
238
+ if (!prefix) return sink;
239
+ const raw = sink.slice(prefix.length).trim();
240
+ const absOut = path.isAbsolute(raw) ? raw : path.join(cwd, raw);
241
+ const rel = path.relative(cwd, absOut);
242
+ if (!rel.startsWith('.singleton' + path.sep)) return sink;
243
+ if (rel.startsWith(path.join('.singleton', 'runs') + path.sep)) return sink;
244
+ const basename = path.basename(absOut);
245
+ return `${prefix}${path.join(stepDir, basename)}`;
246
+ }
247
+
248
+ function parseOutputs(text, outputNames) {
249
+ if (outputNames.length === 1) {
250
+ return { [outputNames[0]]: text.trim() };
251
+ }
252
+ const result = {};
253
+ for (const name of outputNames) {
254
+ const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'i');
255
+ const m = text.match(re);
256
+ result[name] = m ? m[1].trim() : '';
257
+ }
258
+ return result;
259
+ }
260
+
261
+ function summarizeParsedOutputs(parsed, outputNames) {
262
+ return outputNames.map((name) => {
263
+ const value = String(parsed[name] || '');
264
+ const trimmed = value.trim();
265
+ return {
266
+ name,
267
+ found: Boolean(trimmed),
268
+ chars: value.length,
269
+ lines: trimmed ? trimmed.split('\n').length : 0,
270
+ };
271
+ });
272
+ }
273
+
274
+ async function writeRawOutputArtifact({ stepDir, step, text, reason, timeline }) {
275
+ if (!stepDir) return null;
276
+ const rawPath = path.join(stepDir, 'raw-output.md');
277
+ const content = [
278
+ `# Raw output for ${step.agent}`,
279
+ '',
280
+ `Reason: ${reason}`,
281
+ '',
282
+ '```text',
283
+ text || '',
284
+ '```',
285
+ '',
286
+ ].join('\n');
287
+ await fs.writeFile(rawPath, content);
288
+ timeline.logMuted(`raw output saved: ${path.relative(path.dirname(stepDir), rawPath)}`);
289
+ return rawPath;
290
+ }
291
+
292
+ async function moveFileIfExists(fromAbs, toAbs) {
293
+ try {
294
+ await fs.mkdir(path.dirname(toAbs), { recursive: true });
295
+ await fs.rename(fromAbs, toAbs);
296
+ return true;
297
+ } catch (err) {
298
+ if (err.code === 'ENOENT') return false;
299
+ await fs.copyFile(fromAbs, toAbs);
300
+ await fs.rm(fromAbs, { force: true });
301
+ return true;
302
+ }
303
+ }
304
+
305
+ async function moveAttemptArtifactsToAttemptDir({ cwd, stepDir, attempt, writes, rawOutputPath }) {
306
+ if (!stepDir || attempt !== 1) {
307
+ return {
308
+ writes,
309
+ rawOutputPath,
310
+ };
311
+ }
312
+
313
+ const attemptDir = path.join(stepDir, `attempt-${attempt}`);
314
+ const movedWrites = [];
315
+ for (const entry of writes) {
316
+ if (!isInsidePath(entry.absPath, stepDir) || isInsidePath(entry.absPath, attemptDir)) {
317
+ movedWrites.push(entry);
318
+ continue;
319
+ }
320
+ const relInsideStep = path.relative(stepDir, entry.absPath);
321
+ if (!relInsideStep || relInsideStep.startsWith('..') || relInsideStep.split(path.sep)[0] === '.snapshot') {
322
+ movedWrites.push(entry);
323
+ continue;
324
+ }
325
+ const nextAbs = path.join(attemptDir, relInsideStep);
326
+ await moveFileIfExists(entry.absPath, nextAbs);
327
+ movedWrites.push({
328
+ ...entry,
329
+ absPath: nextAbs,
330
+ relPath: path.relative(cwd, nextAbs),
331
+ kind: path.relative(cwd, nextAbs).startsWith('.singleton' + path.sep) ? 'intermediate' : entry.kind,
332
+ });
333
+ }
334
+
335
+ let movedRawOutputPath = rawOutputPath;
336
+ if (rawOutputPath) {
337
+ const rawAbs = path.isAbsolute(rawOutputPath) ? rawOutputPath : path.join(cwd, rawOutputPath);
338
+ if (isInsidePath(rawAbs, stepDir) && !isInsidePath(rawAbs, attemptDir)) {
339
+ const relInsideStep = path.relative(stepDir, rawAbs);
340
+ const nextAbs = path.join(attemptDir, relInsideStep);
341
+ if (await moveFileIfExists(rawAbs, nextAbs)) {
342
+ movedRawOutputPath = path.relative(cwd, nextAbs);
343
+ }
344
+ }
345
+ }
346
+
347
+ return {
348
+ writes: movedWrites,
349
+ rawOutputPath: movedRawOutputPath,
350
+ };
351
+ }
352
+
353
+ function resolveProvider(step, agent) {
354
+ return step.provider || agent.provider || 'claude';
355
+ }
356
+
357
+ function resolveModel(step, agent) {
358
+ return step.model || agent.model || null;
359
+ }
360
+
361
+ function resolveRunnerAgent(step, agent) {
362
+ return step.runner_agent || step.opencode_agent || agent.runner_agent || agent.opencode_agent || null;
363
+ }
364
+
365
+ async function resolveCopilotProjectRoot(cwd) {
366
+ try {
367
+ const { stdout } = await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd });
368
+ return stdout.trim() || cwd;
369
+ } catch {
370
+ return cwd;
371
+ }
372
+ }
373
+
374
+ async function findCopilotRepoAgentProfile(cwd, runnerAgent) {
375
+ const name = String(runnerAgent || '').trim();
376
+ if (!name || name.includes('/') || name.includes('\\')) return null;
377
+ const projectRoot = await resolveCopilotProjectRoot(cwd);
378
+ const file = path.join(projectRoot, '.github', 'agents', `${name}.agent.md`);
379
+ try {
380
+ await fs.access(file);
381
+ const raw = await fs.readFile(file, 'utf8');
382
+ return { file, projectRoot, tools: parseCopilotAgentTools(raw) };
383
+ } catch {
384
+ const singletonRootFile = path.join(cwd, '.github', 'agents', `${name}.agent.md`);
385
+ try {
386
+ await fs.access(singletonRootFile);
387
+ const raw = await fs.readFile(singletonRootFile, 'utf8');
388
+ return {
389
+ file: singletonRootFile,
390
+ projectRoot,
391
+ notVisibleFromGitRoot: projectRoot !== cwd,
392
+ tools: parseCopilotAgentTools(raw),
393
+ };
394
+ } catch {
395
+ return { file: null, projectRoot };
396
+ }
397
+ }
398
+ }
399
+
400
+ function parseCopilotAgentTools(raw) {
401
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
402
+ if (!match) return [];
403
+
404
+ for (const line of match[1].split('\n')) {
405
+ const m = line.match(/^\s*tools\s*:\s*\[([^\]]*)\]\s*$/);
406
+ if (!m) continue;
407
+ return m[1]
408
+ .split(',')
409
+ .map((token) => token.trim().replace(/^["']|["']$/g, ''))
410
+ .filter(Boolean);
411
+ }
412
+ return [];
413
+ }
414
+
415
+ function validateCopilotAgentTools({ label, runnerAgent, securityPolicy, tools }) {
416
+ const errors = [];
417
+ const warnings = [];
418
+ const list = Array.isArray(tools) ? tools : [];
419
+ const writeEnabled = list.includes('write') || list.includes('edit');
420
+ const shellEnabled = list.includes('shell') || list.includes('bash');
421
+
422
+ if (securityPolicy.profile === 'restricted-write' || securityPolicy.profile === 'workspace-write') {
423
+ if (list.length && !writeEnabled) {
424
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" declares tools without write/edit; the step may be unable to modify allowed_paths.`);
425
+ }
426
+ if (shellEnabled) {
427
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables shell tools; Singleton cannot sandbox external side effects from shell commands.`);
428
+ }
429
+ }
430
+
431
+ if (securityPolicy.profile === 'read-only' && writeEnabled) {
432
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables write/edit tools; Singleton will override them with --deny-tool=write for security_profile "read-only".`);
433
+ }
434
+
435
+ return { errors, warnings };
436
+ }
437
+
438
+ async function findOpenCodeProjectAgentProfile(cwd, runnerAgent) {
439
+ const name = String(runnerAgent || '').trim();
440
+ if (!name || name.includes('/') || name.includes('\\')) return null;
441
+
442
+ const file = path.join(cwd, '.opencode', 'agents', `${name}.md`);
443
+ try {
444
+ await fs.access(file);
445
+ const raw = await fs.readFile(file, 'utf8');
446
+ return { file, tools: parseOpenCodeAgentTools(raw) };
447
+ } catch {
448
+ return { file: null };
449
+ }
450
+ }
451
+
452
+ function parseOpenCodeAgentTools(raw) {
453
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
454
+ if (!match) return {};
455
+
456
+ const tools = {};
457
+ let inTools = false;
458
+ for (const line of match[1].split('\n')) {
459
+ if (/^\s*tools\s*:\s*$/.test(line)) {
460
+ inTools = true;
461
+ continue;
462
+ }
463
+ if (inTools && /^\S/.test(line)) break;
464
+ const toolMatch = line.match(/^\s{2,}([A-Za-z0-9_-]+)\s*:\s*(true|false)\s*$/);
465
+ if (inTools && toolMatch) {
466
+ tools[toolMatch[1]] = toolMatch[2] === 'true';
467
+ }
468
+ }
469
+
470
+ return tools;
471
+ }
472
+
473
+ function validateOpenCodeAgentTools({ label, runnerAgent, securityPolicy, tools }) {
474
+ const errors = [];
475
+ const warnings = [];
476
+ const writeEnabled = tools.write === true || tools.edit === true;
477
+ const bashEnabled = tools.bash === true;
478
+
479
+ if (securityPolicy.profile === 'read-only') {
480
+ const enabled = ['write', 'edit', 'bash'].filter((name) => tools[name] === true);
481
+ if (enabled.length) {
482
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables legacy ${enabled.join(', ')} tools; Singleton will override them with native OpenCode permissions for security_profile "read-only".`);
483
+ }
484
+ }
485
+
486
+ if (securityPolicy.profile === 'restricted-write') {
487
+ warnings.push(`${label} uses OpenCode with security_profile "restricted-write"; Singleton will inject native OpenCode edit permissions for allowed_paths and still validate post-run changes.`);
488
+ if (!writeEnabled) {
489
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may be unable to modify allowed_paths.`);
490
+ }
491
+ if (bashEnabled) {
492
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; Singleton cannot sandbox external side effects from shell commands.`);
493
+ }
494
+ }
495
+
496
+ if (securityPolicy.profile === 'workspace-write') {
497
+ if (!writeEnabled) {
498
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may behave as read-only.`);
499
+ }
500
+ if (bashEnabled) {
501
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; keep workspace-write steps scoped and review post-run changes.`);
502
+ }
503
+ }
504
+
505
+ return { errors, warnings };
506
+ }
507
+
508
+ function resolvePermissionMode(step, agent) {
509
+ return step.permission_mode || agent.permission_mode || '';
510
+ }
511
+
512
+ function failStep(timeline, index, shortMessage, fullMessage = shortMessage) {
513
+ timeline.setError(index, String(shortMessage).slice(0, 60));
514
+ throw new Error(fullMessage);
515
+ }
516
+
517
+ function createSilentTimeline() {
518
+ return {
519
+ log() {},
520
+ logMuted() {},
521
+ setRunning() {},
522
+ setPaused() {},
523
+ setDone() {},
524
+ setError() {},
525
+ end() {},
526
+ };
527
+ }
528
+
529
+ function formatStepRuntimeMeta({ provider, model, permissionMode, securityProfile }) {
530
+ const parts = [];
531
+ if (provider) parts.push(provider);
532
+ if (model) parts.push(model);
533
+ if (securityProfile) parts.push(`security:${securityProfile}`);
534
+ if (permissionMode) parts.push(`perm:${permissionMode}`);
535
+ return parts.join(' · ');
536
+ }
537
+
538
+ function previewValue(value, max = 140) {
539
+ const compact = String(value || '').replace(/\s+/g, ' ').trim();
540
+ if (compact.length <= max) return compact || '—';
541
+ return `${compact.slice(0, max - 1)}…`;
542
+ }
543
+
544
+ const debugToken = {
545
+ key: (value) => `{${C.violet}-fg}{bold}${value}:{/}`,
546
+ identity: (value) => `{${C.mint}-fg}${value || '—'}{/}`,
547
+ text: (value) => `{#FFFFFF-fg}${value || '—'}{/}`,
548
+ path: (value) => `{${C.blue}-fg}${value || '—'}{/}`,
549
+ policy: (value) => `{${C.peach}-fg}${value || '—'}{/}`,
550
+ muted: (value) => `{${C.ghost}-fg}${value || '—'}{/}`,
551
+ };
552
+
553
+ const DEBUG_ACTION_PROMPT = [
554
+ `{#FFFFFF-fg}{bold}Debug action{/}`,
555
+ `{${C.mint}-fg}▶ continue{/}{${C.ghost}-fg}(c){/}`,
556
+ `{${C.blue}-fg}? inspect{/}{${C.ghost}-fg}(i){/}`,
557
+ `{${C.peach}-fg}✎ edit{/}{${C.ghost}-fg}(e){/}`,
558
+ `{${C.violet}-fg}→ skip{/}{${C.ghost}-fg}(s){/}`,
559
+ `{${C.salmon}-fg}■ abort{/}{${C.ghost}-fg}(a){/}`,
560
+ ].join(` {${C.ghost}-fg}·{/} `);
561
+
562
+ const DEBUG_ACTION_HELP = [
563
+ `{#FFFFFF-fg}Choose{/}`,
564
+ `{${C.mint}-fg}▶ continue{/}`,
565
+ `{${C.blue}-fg}? inspect{/}`,
566
+ `{${C.peach}-fg}✎ edit{/}`,
567
+ `{${C.violet}-fg}→ skip{/}`,
568
+ `{${C.salmon}-fg}■ abort{/}`,
569
+ ].join(` {${C.ghost}-fg}·{/} `);
570
+
571
+ const DEBUG_POST_ACTION_PROMPT = [
572
+ `{#FFFFFF-fg}{bold}Debug output{/}`,
573
+ `{${C.mint}-fg}▶ continue{/}{${C.ghost}-fg}(c){/}`,
574
+ `{${C.blue}-fg}? output{/}{${C.ghost}-fg}(o){/}`,
575
+ `{${C.violet}-fg}raw output{/}{${C.ghost}-fg}(r){/}`,
576
+ `{${C.peach}-fg}± diff{/}{${C.ghost}-fg}(d){/}`,
577
+ `{${C.violet}-fg}↻ replay{/}{${C.ghost}-fg}(p){/}`,
578
+ `{${C.salmon}-fg}■ abort{/}{${C.ghost}-fg}(a){/}`,
579
+ ].join(` {${C.ghost}-fg}·{/} `);
580
+
581
+ const DEBUG_POST_ACTION_HELP = [
582
+ `{#FFFFFF-fg}Choose{/}`,
583
+ `{${C.mint}-fg}▶ continue{/}`,
584
+ `{${C.blue}-fg}? output{/}`,
585
+ `{${C.violet}-fg}raw output{/}`,
586
+ `{${C.peach}-fg}± diff{/}`,
587
+ `{${C.violet}-fg}↻ replay{/}`,
588
+ `{${C.salmon}-fg}■ abort{/}`,
589
+ ].join(` {${C.ghost}-fg}·{/} `);
590
+
591
+ const DEFAULT_MAX_DEBUG_REPLAYS = 3;
592
+
593
+ function logDebugSection(title, timeline) {
594
+ const width = 72;
595
+ const text = ` ${title} `;
596
+ const left = Math.max(0, Math.floor((width - text.length) / 2));
597
+ const right = Math.max(0, width - text.length - left);
598
+ timeline.logMuted(' ');
599
+ timeline.logMuted(' ');
600
+ timeline.log(`{${C.ghost}-fg}${'─'.repeat(left)}{/}{${C.violet}-fg}{bold}${text}{/}{${C.ghost}-fg}${'─'.repeat(right)}{/}`);
601
+ timeline.logMuted(' ');
602
+ timeline.logMuted(' ');
603
+ }
604
+
605
+ function formatDebugList(values, fallback = 'none') {
606
+ if (!values.length) return debugToken.muted(fallback);
607
+ return values.map((value) => debugToken.identity(value)).join(` ${debugToken.muted('·')} `);
608
+ }
609
+
610
+ function pushDebugEvent(events, event) {
611
+ if (!Array.isArray(events)) return;
612
+ const { systemPrompt: _systemPrompt, workspaceInfo: _workspaceInfo, ...safeEvent } = event || {};
613
+ events.push({
614
+ timestamp: new Date().toISOString(),
615
+ ...safeEvent,
616
+ });
617
+ }
618
+
619
+ function looksLikePath(value) {
620
+ const text = String(value || '').trim();
621
+ return /^\.{0,2}\//.test(text) || /^[\w.-]+\/[\w./-]+$/.test(text) || /\.[a-z0-9]{1,8}$/i.test(text);
622
+ }
623
+
624
+ function debugValue(value, kind = 'text') {
625
+ if (!value || value === '—') return debugToken.muted('—');
626
+ if (kind === 'identity') return debugToken.identity(value);
627
+ if (kind === 'path') return debugToken.path(value);
628
+ if (kind === 'policy') return debugToken.policy(value);
629
+ return debugToken.text(value);
630
+ }
631
+
632
+ function debugLine(label, value, kind = 'text') {
633
+ return `${debugToken.key(label)} ${debugValue(value, kind)}`;
634
+ }
635
+
636
+ function isPromptCancelled(value) {
637
+ return value === '__SINGLETON_ESC__';
638
+ }
639
+
640
+ function logDebugInputs(resolvedInputs, timeline) {
641
+ const entries = Object.entries(resolvedInputs);
642
+ if (entries.length) {
643
+ timeline.logMuted(debugToken.key('inputs'));
644
+ for (const [name, value] of entries) {
645
+ const preview = previewValue(value);
646
+ timeline.logMuted(
647
+ ` ${debugToken.muted('·')} ${debugToken.key(name)} ` +
648
+ `${debugValue(preview, looksLikePath(preview) ? 'path' : 'text')}`
649
+ );
650
+ }
651
+ } else {
652
+ timeline.logMuted(`${debugToken.key('inputs')} ${debugToken.muted('none')}`);
653
+ }
654
+ }
655
+
656
+ function markEditedInputTags(line, editedInputs = new Set()) {
657
+ let text = String(line || ' ');
658
+ for (const name of editedInputs) {
659
+ const open = new RegExp(`<${name}>`, 'g');
660
+ text = text.replace(open, `<${name} debug-edited="true">`);
661
+ }
662
+ return text;
663
+ }
664
+
665
+ function debugPromptTextLine(line, { editedInputs = new Set() } = {}) {
666
+ const text = markEditedInputTags(line || ' ', editedInputs);
667
+ const tagPattern = /(<\/?[A-Za-z_][\w.-]*(?:\s+[^>]*)?>)/g;
668
+ const parts = text.split(tagPattern);
669
+ return parts.map((part) => {
670
+ if (!part) return '';
671
+ const isTag = tagPattern.test(part);
672
+ tagPattern.lastIndex = 0;
673
+ if (isTag) {
674
+ const isVariableTag = /^<\/?(workspace|security_policy|file)\b/i.test(part) === false;
675
+ const color = isVariableTag ? C.peach : C.blue;
676
+ tagPattern.lastIndex = 0;
677
+ return `{${color}-fg}{bold}${part}{/}`;
678
+ }
679
+ return `{#FFFFFF-fg}${part}{/}`;
680
+ }).join('');
681
+ }
682
+
683
+ function logDebugPromptPreview({ systemPrompt, userMessage, timeline, editedInputs = new Set() }) {
684
+ logDebugSection('Debug prompt preview', timeline);
685
+ if (editedInputs.size) {
686
+ timeline.logMuted(`${debugToken.key('edited inputs')} ${formatDebugList([...editedInputs])}`);
687
+ timeline.logMuted(`${debugToken.policy('Editing one input may not override other inputs or the agent prompt. Inspect the final prompt before continuing.')}`);
688
+ timeline.logMuted(' ');
689
+ }
690
+ timeline.logMuted(debugToken.key('system prompt'));
691
+ for (const line of systemPrompt.split('\n')) {
692
+ timeline.logMuted(` ${debugPromptTextLine(line, { editedInputs })}`);
693
+ }
694
+ timeline.logMuted(' ');
695
+ timeline.logMuted(' ');
696
+ timeline.logMuted(debugToken.key('user message'));
697
+ for (const line of userMessage.split('\n')) {
698
+ timeline.logMuted(` ${debugPromptTextLine(line, { editedInputs })}`);
699
+ }
700
+ }
701
+
702
+ function validateParsedOutputs(parsed, outputNames) {
703
+ const warnings = [];
704
+ for (const name of outputNames) {
705
+ const value = String(parsed[name] || '').trim();
706
+ if (!value) warnings.push(`output "${name}" is empty`);
707
+ }
708
+ return warnings;
709
+ }
710
+
711
+ function logDebugOutputs(parsed, outputNames, timeline) {
712
+ logDebugSection('Debug parsed outputs', timeline);
713
+ const summaries = summarizeParsedOutputs(parsed, outputNames);
714
+ for (const summary of summaries) {
715
+ const status = summary.found ? debugToken.identity('found') : debugToken.policy('missing');
716
+ timeline.logMuted(
717
+ `${debugToken.key(summary.name)} ${status} ` +
718
+ `${debugToken.muted('·')} ${debugValue(`${summary.chars} chars`, 'identity')} ` +
719
+ `${debugToken.muted('·')} ${debugValue(`${summary.lines} lines`, 'identity')}`
720
+ );
721
+ const name = summary.name;
722
+ const lines = String(parsed[name] || '').split('\n');
723
+ for (const line of lines) {
724
+ timeline.logMuted(` ${debugPromptTextLine(line)}`);
725
+ }
726
+ timeline.logMuted(' ');
727
+ }
728
+ }
729
+
730
+ function uniqueDebugPaths(entries) {
731
+ const out = [];
732
+ const seen = new Set();
733
+ for (const entry of entries) {
734
+ if (!entry?.relPath || seen.has(entry.relPath)) continue;
735
+ seen.add(entry.relPath);
736
+ out.push(entry);
737
+ }
738
+ return out;
739
+ }
740
+
741
+ async function logDebugDiffs({ changes, writes = [], cwd, timeline }) {
742
+ logDebugSection('Debug step diff', timeline);
743
+ const entries = uniqueDebugPaths([...changes, ...writes]);
744
+ if (!entries.length) {
745
+ timeline.logMuted(`${debugToken.key('changes')} ${debugToken.muted('none')}`);
746
+ return;
747
+ }
748
+
749
+ for (const change of entries.slice(0, 8)) {
750
+ timeline.log(`${debugToken.key(change.relPath)}`);
751
+ const preview = await getViolationDiffPreview(cwd, change.relPath);
752
+ for (const line of preview) timeline.logMuted(` ${line}`);
753
+ }
754
+ if (entries.length > 8) {
755
+ timeline.logMuted(`${debugToken.muted(`... ${entries.length - 8} more changed file(s)`)}`);
756
+ }
757
+ }
758
+
759
+ async function promptDebugPostStepDecision({
760
+ step,
761
+ parsed,
762
+ outputNames,
763
+ stepWrites,
764
+ stepChanges,
765
+ outputWarnings,
766
+ rawText,
767
+ rawOutputPath,
768
+ attempt,
769
+ maxDebugReplays,
770
+ cwd,
771
+ timeline,
772
+ shell,
773
+ quiet,
774
+ decisionFn,
775
+ debugEvents,
776
+ }) {
777
+ const summary = {
778
+ agent: step.agent,
779
+ outputs: outputNames,
780
+ parsedOutputs: summarizeParsedOutputs(parsed, outputNames),
781
+ outputWarnings,
782
+ rawOutputPath: rawOutputPath || null,
783
+ writtenFiles: stepWrites.map((entry) => entry.relPath),
784
+ changedFiles: stepChanges.map((entry) => entry.relPath),
785
+ };
786
+
787
+ if (decisionFn) {
788
+ const decision = await decisionFn(summary);
789
+ if (typeof decision === 'object' && decision) {
790
+ const action = String(decision.action || 'continue').trim().toLowerCase() || 'continue';
791
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action, ...summary });
792
+ return {
793
+ action,
794
+ inputs: decision.inputs && typeof decision.inputs === 'object' ? decision.inputs : null,
795
+ };
796
+ }
797
+ const action = String(decision || 'continue').trim().toLowerCase() || 'continue';
798
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action, ...summary });
799
+ return action;
800
+ }
801
+ if (quiet) {
802
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'continue', ...summary });
803
+ return 'continue';
804
+ }
805
+
806
+ logDebugSection('Debug output review', timeline);
807
+ timeline.logMuted(debugLine('agent', step.agent, 'identity'));
808
+ timeline.logMuted(`${debugToken.key('outputs')} ${formatDebugList(outputNames)}`);
809
+ timeline.logMuted(`${debugToken.key('written files')} ${formatDebugList(stepWrites.map((entry) => entry.relPath))}`);
810
+ timeline.logMuted(`${debugToken.key('changed files')} ${formatDebugList(stepChanges.map((entry) => entry.relPath))}`);
811
+ if (outputWarnings.length) {
812
+ timeline.logMuted(`${debugToken.key('warnings')} ${debugToken.policy(outputWarnings.join(' · '))}`);
813
+ } else {
814
+ timeline.logMuted(`${debugToken.key('warnings')} ${debugToken.muted('none')}`);
815
+ }
816
+
817
+ while (true) {
818
+ const raw = shell
819
+ ? await shell.prompt(DEBUG_POST_ACTION_PROMPT)
820
+ : await input({ message: 'Debug output: continue, output, raw output, diff, replay, or abort? (c/o/r/d/p/a)', default: 'c' });
821
+ if (isPromptCancelled(raw)) {
822
+ timeline.logMuted(`${debugToken.muted('Cancelled. Back to debug output menu.')}`);
823
+ continue;
824
+ }
825
+ const answer = String(raw || '').trim().toLowerCase();
826
+ if (!answer || answer === 'c' || answer === 'continue') {
827
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'continue', ...summary });
828
+ return 'continue';
829
+ }
830
+ if (answer === 'a' || answer === 'abort' || answer === 'stop') {
831
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'abort', ...summary });
832
+ return 'abort';
833
+ }
834
+ if (answer === 'o' || answer === 'output' || answer === 'inspect') {
835
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'inspect-output', ...summary });
836
+ logDebugOutputs(parsed, outputNames, timeline);
837
+ continue;
838
+ }
839
+ if (answer === 'r' || answer === 'raw' || answer === 'raw output') {
840
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'inspect-raw-output', ...summary });
841
+ logDebugSection('Debug raw output', timeline);
842
+ if (rawOutputPath) timeline.logMuted(`${debugToken.key('saved')} ${debugValue(rawOutputPath, 'path')}`);
843
+ const lines = String(rawText || '').split('\n');
844
+ for (const line of lines.slice(0, 120)) timeline.logMuted(` ${debugPromptTextLine(line)}`);
845
+ if (lines.length > 120) timeline.logMuted(`${debugToken.muted(`... ${lines.length - 120} more line(s)`)}`);
846
+ continue;
847
+ }
848
+ if (answer === 'd' || answer === 'diff') {
849
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'inspect-diff', ...summary });
850
+ await logDebugDiffs({ changes: stepChanges, writes: stepWrites, cwd, timeline });
851
+ continue;
852
+ }
853
+ if (answer === 'p' || answer === 'replay' || answer === 'retry') {
854
+ const usedReplays = Math.max(0, Number(attempt || 1) - 1);
855
+ if (usedReplays >= maxDebugReplays) {
856
+ timeline.logMuted(`${debugToken.policy(`Replay limit reached (${maxDebugReplays} per step).`)}`);
857
+ continue;
858
+ }
859
+ if (stepWrites.length || stepChanges.length) {
860
+ logDebugSection('Replay soft warning', timeline);
861
+ timeline.logMuted(`${debugToken.policy('Replay restores detected project file changes only.')}`);
862
+ const written = stepWrites.map((entry) => entry.relPath);
863
+ const changed = stepChanges.map((entry) => entry.relPath);
864
+ timeline.logMuted(`${debugToken.key('already written')} ${formatDebugList(written)}`);
865
+ timeline.logMuted(`${debugToken.key('already changed')} ${formatDebugList(changed)}`);
866
+ timeline.logMuted(`${debugToken.muted('Previous run artifacts stay in their attempt folder for traceability.')}`);
867
+ timeline.logMuted(`${debugToken.muted('Skipped folders such as .git, node_modules, dist, build, and .next are not restored.')}`);
868
+ timeline.logMuted(`${debugToken.muted('External side effects such as commits, pushes, PRs, shell state, or network calls are not rolled back.')}`);
869
+ }
870
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'post-step', action: 'replay', ...summary });
871
+ return 'replay';
872
+ }
873
+ timeline.logMuted(DEBUG_POST_ACTION_HELP);
874
+ }
875
+ }
876
+
877
+ async function editDebugInputs({ resolvedInputs, shell, timeline, step, debugEvents, editedInputs }) {
878
+ const names = Object.keys(resolvedInputs);
879
+ if (names.length === 0) {
880
+ timeline.logMuted('No inputs to edit.');
881
+ return resolvedInputs;
882
+ }
883
+
884
+ logDebugSection('Debug edit inputs', timeline);
885
+ timeline.logMuted(`Type an input name to override it. Empty input name returns to debug review.`);
886
+ timeline.logMuted(`${debugToken.policy('Warning: editing one input may not override other inputs or the agent prompt.')}`);
887
+ timeline.logMuted(`${debugToken.policy('Use inspect after editing to verify the final prompt.')}`);
888
+ timeline.logMuted(' ');
889
+ const nextInputs = { ...resolvedInputs };
890
+
891
+ while (true) {
892
+ const rawName = shell
893
+ ? await shell.prompt(`Input to edit (${names.join(', ')})`)
894
+ : await input({ message: `Input to edit (${names.join(', ')})` });
895
+ if (isPromptCancelled(rawName)) {
896
+ timeline.logMuted(`${debugToken.muted('Edit cancelled. Back to debug menu.')}`);
897
+ return nextInputs;
898
+ }
899
+ const name = String(rawName || '').trim();
900
+ if (!name) return nextInputs;
901
+ if (!Object.hasOwn(nextInputs, name)) {
902
+ timeline.logMuted(`Unknown input "${name}".`);
903
+ continue;
904
+ }
905
+
906
+ const current = previewValue(nextInputs[name], 220);
907
+ timeline.logMuted(`${debugToken.key(name)} current: ${debugValue(current, looksLikePath(current) ? 'path' : 'text')}`);
908
+ const value = shell
909
+ ? await shell.prompt(`New value for ${name}`)
910
+ : await input({ message: `New value for ${name}`, default: String(nextInputs[name] || '') });
911
+ if (isPromptCancelled(value)) {
912
+ timeline.logMuted(`${debugToken.muted('Value edit cancelled. Back to input selection.')}`);
913
+ continue;
914
+ }
915
+ nextInputs[name] = String(value || '');
916
+ if (editedInputs) editedInputs.add(name);
917
+ pushDebugEvent(debugEvents, {
918
+ step: step?.agent,
919
+ phase: 'pre-step',
920
+ action: 'edit-input',
921
+ input: name,
922
+ previousPreview: previewValue(current, 120),
923
+ nextPreview: previewValue(nextInputs[name], 120),
924
+ });
925
+ timeline.logMuted(`${debugToken.key(name)} updated.`);
926
+ }
927
+ }
928
+
929
+ async function promptDebugStepDecision({
930
+ step,
931
+ stepNumber,
932
+ totalSteps,
933
+ provider,
934
+ model,
935
+ runnerAgent,
936
+ permissionMode,
937
+ securityPolicy,
938
+ resolvedInputs,
939
+ outputNames,
940
+ systemPrompt,
941
+ workspaceInfo,
942
+ timeline,
943
+ shell,
944
+ quiet,
945
+ decisionFn,
946
+ debugEvents,
947
+ }) {
948
+ const summary = {
949
+ agent: step.agent,
950
+ stepNumber,
951
+ totalSteps,
952
+ provider,
953
+ model,
954
+ runnerAgent,
955
+ permissionMode,
956
+ securityProfile: securityPolicy.profile,
957
+ inputs: Object.keys(resolvedInputs),
958
+ outputs: outputNames,
959
+ systemPrompt,
960
+ workspaceInfo,
961
+ };
962
+
963
+ if (decisionFn) {
964
+ const decision = await decisionFn(summary);
965
+ if (typeof decision === 'object' && decision) {
966
+ const action = String(decision.action || 'continue').trim().toLowerCase();
967
+ const nextInputs = decision.inputs && typeof decision.inputs === 'object' ? decision.inputs : resolvedInputs;
968
+ const editedInputs = Object.keys(nextInputs).filter((name) => nextInputs[name] !== resolvedInputs[name]);
969
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action, editedInputs, ...summary });
970
+ return {
971
+ action,
972
+ inputs: nextInputs,
973
+ };
974
+ }
975
+ const action = String(decision || 'continue').trim().toLowerCase() || 'continue';
976
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action, ...summary });
977
+ return {
978
+ action,
979
+ inputs: resolvedInputs,
980
+ };
981
+ }
982
+ if (quiet) {
983
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'continue', ...summary });
984
+ return { action: 'continue', inputs: resolvedInputs };
985
+ }
986
+
987
+ let currentInputs = { ...resolvedInputs };
988
+ const editedInputs = new Set();
989
+
990
+ logDebugSection('Debug step review', timeline);
991
+ timeline.logMuted(debugLine('step', `${stepNumber}/${totalSteps}`));
992
+ timeline.logMuted(debugLine('agent', step.agent, 'identity'));
993
+ timeline.logMuted(debugLine('provider', provider, 'identity'));
994
+ timeline.logMuted(debugLine('model', model || '—', 'identity'));
995
+ if (runnerAgent) timeline.logMuted(debugLine('runner_agent', runnerAgent, 'identity'));
996
+ timeline.logMuted(debugLine('security', securityPolicy.profile, 'policy'));
997
+ timeline.logMuted(debugLine('permission', permissionMode || '—', 'policy'));
998
+ timeline.logMuted(
999
+ `${debugToken.key('outputs')} ${outputNames.length ? outputNames.map((name) => debugToken.identity(name)).join(` ${debugToken.muted('·')} `) : debugToken.muted('none')}`
1000
+ );
1001
+ logDebugInputs(currentInputs, timeline);
1002
+
1003
+ while (true) {
1004
+ const raw = shell
1005
+ ? await shell.prompt(DEBUG_ACTION_PROMPT)
1006
+ : await input({ message: 'Debug: continue, inspect, edit, skip, or abort? (c/i/e/s/a)', default: 'c' });
1007
+ if (isPromptCancelled(raw)) {
1008
+ timeline.logMuted(`${debugToken.muted('Cancelled. Back to debug action menu.')}`);
1009
+ continue;
1010
+ }
1011
+ const answer = String(raw || '').trim().toLowerCase();
1012
+ if (!answer || answer === 'c' || answer === 'continue') {
1013
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'continue', ...summary });
1014
+ return { action: 'continue', inputs: currentInputs };
1015
+ }
1016
+ if (answer === 's' || answer === 'skip') {
1017
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'skip', ...summary });
1018
+ return { action: 'skip', inputs: currentInputs };
1019
+ }
1020
+ if (answer === 'a' || answer === 'abort' || answer === 'stop') {
1021
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'abort', ...summary });
1022
+ return { action: 'abort', inputs: currentInputs };
1023
+ }
1024
+ if (answer === 'i' || answer === 'inspect') {
1025
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'inspect-prompt', ...summary });
1026
+ logDebugPromptPreview({
1027
+ systemPrompt: summary.systemPrompt,
1028
+ userMessage: buildUserMessage(currentInputs, outputNames, summary.workspaceInfo, securityPolicy),
1029
+ timeline,
1030
+ editedInputs,
1031
+ });
1032
+ continue;
1033
+ }
1034
+ if (answer === 'e' || answer === 'edit') {
1035
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'open-edit-inputs', ...summary });
1036
+ currentInputs = await editDebugInputs({ resolvedInputs: currentInputs, shell, timeline, step, debugEvents, editedInputs });
1037
+ logDebugInputs(currentInputs, timeline);
1038
+ if (editedInputs.size) {
1039
+ const inspectAnswer = shell
1040
+ ? await shell.prompt(`{#FFFFFF-fg}Inspect final prompt now?{/} {${C.mint}-fg}yes{/}{${C.ghost}-fg}(y){/} {${C.ghost}-fg}or{/} {${C.ghost}-fg}no(n){/}`)
1041
+ : await input({ message: 'Inspect final prompt now? (y/N)', default: 'n' });
1042
+ if (isPromptCancelled(inspectAnswer)) {
1043
+ timeline.logMuted(`${debugToken.muted('Inspect prompt cancelled.')}`);
1044
+ continue;
1045
+ }
1046
+ if (['y', 'yes'].includes(String(inspectAnswer || '').trim().toLowerCase())) {
1047
+ pushDebugEvent(debugEvents, { step: step.agent, phase: 'pre-step', action: 'inspect-prompt-after-edit', editedInputs: [...editedInputs], ...summary });
1048
+ logDebugPromptPreview({
1049
+ systemPrompt: summary.systemPrompt,
1050
+ userMessage: buildUserMessage(currentInputs, outputNames, summary.workspaceInfo, securityPolicy),
1051
+ timeline,
1052
+ editedInputs,
1053
+ });
1054
+ }
1055
+ }
1056
+ continue;
1057
+ }
1058
+ timeline.logMuted(DEBUG_ACTION_HELP);
1059
+ }
1060
+ }
1061
+
1062
+ function formatSecurityHighlight({ label, provider, permissionMode, securityPolicy }) {
1063
+ const parts = [`${label}: security_profile "${securityPolicy.profile}"`];
1064
+ if (provider === 'claude' && permissionMode) {
1065
+ parts.push(`permission_mode "${permissionMode}"`);
1066
+ }
1067
+ if (securityPolicy.profile === 'restricted-write') {
1068
+ parts.push(`allowed_paths ${securityPolicy.allowedPaths.join(', ') || '—'}`);
1069
+ }
1070
+ return parts.join(' · ');
1071
+ }
1072
+
1073
+ function shouldHighlightSecurity({ provider, permissionMode, securityPolicy }) {
1074
+ return securityPolicy.profile !== 'workspace-write' || (provider === 'claude' && Boolean(permissionMode));
1075
+ }
1076
+
1077
+ function commandExists(command) {
1078
+ return new Promise((resolve) => {
1079
+ const child = spawn('which', [command], { stdio: 'ignore' });
1080
+ child.on('error', () => resolve(false));
1081
+ child.on('close', (code) => resolve(code === 0));
1082
+ });
1083
+ }
1084
+
1085
+ function runCommand(cmd, args, { cwd }) {
1086
+ return new Promise((resolve, reject) => {
1087
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1088
+ let stdout = '';
1089
+ let stderr = '';
1090
+ child.stdout.on('data', (d) => (stdout += d.toString()));
1091
+ child.stderr.on('data', (d) => (stderr += d.toString()));
1092
+ child.on('error', reject);
1093
+ child.on('close', (code) => {
1094
+ if (code !== 0) {
1095
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
1096
+ return;
1097
+ }
1098
+ resolve({ stdout, stderr });
1099
+ });
1100
+ });
1101
+ }
1102
+
1103
+ async function runPreflightChecks({ pipeline, cwd, inputDefs, inputValues, dryRun, securityConfig }) {
1104
+ const errors = [];
1105
+ const warnings = [];
1106
+ const infos = [];
1107
+ const securityHighlights = [];
1108
+ const stepAgents = new Map();
1109
+ const availablePipeOutputs = new Set();
1110
+
1111
+ if (securityConfig) {
1112
+ const relConfig = path.relative(cwd, securityConfig.file);
1113
+ infos.push(`Project security config: ${relConfig} · default_profile "${securityConfig.defaultProfile}".`);
1114
+ }
1115
+
1116
+ for (const def of inputDefs) {
1117
+ const value = inputValues[def.id];
1118
+ if (!dryRun && !String(value || '').trim()) {
1119
+ errors.push(`Missing input "${def.id}".`);
1120
+ continue;
1121
+ }
1122
+ if (!dryRun && def.subtype === 'file' && String(value || '').trim()) {
1123
+ const files = await resolveFileGlob(`$FILE:${value}`, cwd);
1124
+ if (files.length === 0) {
1125
+ errors.push(`Input file "${def.id}" does not resolve to any file: ${value}`);
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ const parsedAgents = [];
1131
+ for (let i = 0; i < pipeline.steps.length; i += 1) {
1132
+ const step = pipeline.steps[i];
1133
+ const label = `Step ${i + 1} "${step.agent}"`;
1134
+
1135
+ if (!step.agent_file) {
1136
+ errors.push(`${label} is missing agent_file.`);
1137
+ continue;
1138
+ }
1139
+
1140
+ const agentFilePath = path.isAbsolute(step.agent_file)
1141
+ ? step.agent_file
1142
+ : path.resolve(cwd, step.agent_file);
1143
+
1144
+ let raw;
1145
+ try {
1146
+ raw = await fs.readFile(agentFilePath, 'utf8');
1147
+ } catch {
1148
+ errors.push(`${label} agent file not found: ${step.agent_file}`);
1149
+ continue;
1150
+ }
1151
+
1152
+ const { agent, error } = parseAgentFileDetailed(raw, agentFilePath);
1153
+ if (!agent) {
1154
+ errors.push(`${label} agent file is invalid: ${step.agent_file}${error ? ` (${error})` : ''}`);
1155
+ continue;
1156
+ }
1157
+
1158
+ parsedAgents.push({ step, agent });
1159
+ stepAgents.set(step.agent, agent);
1160
+ const securityPolicy = resolveSecurityPolicyWithConfig(step, agent, securityConfig);
1161
+ for (const error of validateSecurityPolicy(securityPolicy)) {
1162
+ errors.push(`${label} ${error}.`);
1163
+ }
1164
+
1165
+ let provider;
1166
+ try {
1167
+ provider = resolveProvider(step, agent);
1168
+ getRunner(provider);
1169
+ } catch (err) {
1170
+ errors.push(`${label} uses unknown provider "${step.provider || agent.provider || ''}".`);
1171
+ continue;
1172
+ }
1173
+
1174
+ const model = resolveModel(step, agent);
1175
+ if (!model) warnings.push(`${label} has no model configured for provider "${provider}".`);
1176
+ const runnerAgent = resolveRunnerAgent(step, agent);
1177
+ if (provider === 'copilot' && !runnerAgent) {
1178
+ warnings.push(`${label} uses provider "copilot" without runner_agent; Copilot will use its default agent.`);
1179
+ }
1180
+ if (provider === 'opencode' && !runnerAgent) {
1181
+ warnings.push(`${label} uses provider "opencode" without runner_agent; OpenCode will use its default agent.`);
1182
+ }
1183
+ const permissionMode = resolvePermissionMode(step, agent);
1184
+ if (provider === 'claude' && permissionMode && permissionMode !== 'bypassPermissions') {
1185
+ errors.push(`${label} uses unsupported Claude permission_mode "${permissionMode}".`);
1186
+ }
1187
+ if (provider !== 'claude' && permissionMode) {
1188
+ warnings.push(`${label} defines permission_mode "${permissionMode}", but provider "${provider}" ignores it.`);
1189
+ }
1190
+ if (provider === 'claude' && permissionMode === 'bypassPermissions') {
1191
+ infos.push(`${label} runs Claude with permission_mode "${permissionMode}".`);
1192
+ }
1193
+ if (provider === 'claude' && !permissionMode) {
1194
+ if (securityPolicy.profile === 'read-only') {
1195
+ infos.push(`${label} runs Claude in read-only mode (Write/Edit/Bash disabled via --disallowedTools).`);
1196
+ } else if (securityPolicy.profile === 'restricted-write') {
1197
+ warnings.push(`${label} uses Claude with security_profile "restricted-write"; Claude has no per-path tool filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
1198
+ } else if (securityPolicy.profile === 'dangerous') {
1199
+ warnings.push(`${label} uses Claude with security_profile "dangerous"; Singleton will pass --permission-mode bypassPermissions.`);
1200
+ }
1201
+ }
1202
+ if (provider === 'codex') {
1203
+ if (securityPolicy.profile === 'read-only') {
1204
+ infos.push(`${label} runs Codex in --sandbox read-only.`);
1205
+ } else if (securityPolicy.profile === 'restricted-write') {
1206
+ warnings.push(`${label} uses Codex with security_profile "restricted-write"; Codex has no per-path sandbox filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
1207
+ } else if (securityPolicy.profile === 'workspace-write') {
1208
+ infos.push(`${label} runs Codex in --sandbox workspace-write.`);
1209
+ } else if (securityPolicy.profile === 'dangerous') {
1210
+ warnings.push(`${label} uses Codex with security_profile "dangerous"; Singleton will pass --sandbox danger-full-access.`);
1211
+ }
1212
+ }
1213
+ if (provider === 'copilot' && runnerAgent) {
1214
+ infos.push(`${label} runs Copilot with runner_agent "${runnerAgent}".`);
1215
+ const repoAgentProfile = await findCopilotRepoAgentProfile(cwd, runnerAgent);
1216
+ if (repoAgentProfile?.file && !repoAgentProfile.notVisibleFromGitRoot) {
1217
+ infos.push(`${label} Copilot repo agent profile: ${path.relative(cwd, repoAgentProfile.file)}.`);
1218
+ } else if (repoAgentProfile?.file && repoAgentProfile.notVisibleFromGitRoot) {
1219
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" exists at ${path.relative(cwd, repoAgentProfile.file)}, but Copilot will use git root ${repoAgentProfile.projectRoot}. Move the profile to ${path.relative(cwd, path.join(repoAgentProfile.projectRoot, '.github', 'agents'))} or run inside a standalone git repo.`);
1220
+ } else {
1221
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" was not found in .github/agents; Copilot may still resolve a user-level or organization-level agent.`);
1222
+ }
1223
+ if (repoAgentProfile?.file) {
1224
+ const toolValidation = validateCopilotAgentTools({
1225
+ label,
1226
+ runnerAgent,
1227
+ securityPolicy,
1228
+ tools: repoAgentProfile.tools || [],
1229
+ });
1230
+ errors.push(...toolValidation.errors);
1231
+ warnings.push(...toolValidation.warnings);
1232
+ }
1233
+ }
1234
+ if (provider === 'opencode') {
1235
+ const opencodeRuntime = [
1236
+ model ? `model "${model}"` : null,
1237
+ runnerAgent ? `runner_agent "${runnerAgent}"` : 'default agent',
1238
+ ].filter(Boolean).join(' · ');
1239
+ infos.push(`${label} runs OpenCode${opencodeRuntime ? ` with ${opencodeRuntime}` : ''}.`);
1240
+ if (runnerAgent) {
1241
+ const projectAgentProfile = await findOpenCodeProjectAgentProfile(cwd, runnerAgent);
1242
+ if (projectAgentProfile?.file) {
1243
+ infos.push(`${label} OpenCode project agent profile: ${path.relative(cwd, projectAgentProfile.file)}.`);
1244
+ const toolValidation = validateOpenCodeAgentTools({
1245
+ label,
1246
+ runnerAgent,
1247
+ securityPolicy,
1248
+ tools: projectAgentProfile.tools || {},
1249
+ });
1250
+ errors.push(...toolValidation.errors);
1251
+ warnings.push(...toolValidation.warnings);
1252
+ } else if (projectAgentProfile === null) {
1253
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" cannot be validated as a local project agent name.`);
1254
+ } else {
1255
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" was not found in .opencode/agents; OpenCode may still resolve a user-level agent.`);
1256
+ }
1257
+ }
1258
+ if (securityPolicy.profile === 'dangerous') {
1259
+ warnings.push(`${label} uses provider "opencode" with security_profile "dangerous"; Singleton will pass --dangerously-skip-permissions.`);
1260
+ } else if (securityPolicy.profile !== 'restricted-write') {
1261
+ warnings.push(`${label} uses experimental provider "opencode"; Singleton enforces the security policy with write-time and post-run validation.`);
1262
+ }
1263
+ }
1264
+ if (shouldHighlightSecurity({ provider, permissionMode, securityPolicy })) {
1265
+ securityHighlights.push(formatSecurityHighlight({ label, provider, permissionMode, securityPolicy }));
1266
+ }
1267
+
1268
+ for (const [name, spec] of Object.entries(step.inputs || {})) {
1269
+ if (typeof spec !== 'string') continue;
1270
+
1271
+ if (spec.startsWith('$INPUT:')) {
1272
+ const id = spec.slice('$INPUT:'.length).trim();
1273
+ if (!inputDefs.some((def) => def.id === id)) {
1274
+ errors.push(`${label} input "${name}" references unknown $INPUT:${id}.`);
1275
+ }
1276
+ } else if (spec.startsWith('$PIPE:')) {
1277
+ const { ref, agentId, outName } = parsePipeRef(spec);
1278
+ if (!stepAgents.has(agentId)) {
1279
+ errors.push(`${label} input "${name}" references future or unknown $PIPE:${ref}.`);
1280
+ } else if (outName && !availablePipeOutputs.has(`${agentId}.${outName}`)) {
1281
+ errors.push(`${label} input "${name}" references missing $PIPE output: ${ref}.`);
1282
+ }
1283
+ } else if (spec.startsWith('$FILE:')) {
1284
+ const files = await resolveFileGlob(spec, cwd);
1285
+ if (files.length === 0) {
1286
+ errors.push(`${label} input "${name}" matched no files for ${spec}.`);
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ for (const [outputName, rawSink] of Object.entries(step.outputs || {})) {
1292
+ availablePipeOutputs.add(`${step.agent}.${outputName}`);
1293
+
1294
+ if (typeof rawSink !== 'string') continue;
1295
+ if (!rawSink.startsWith('$FILE:') && !rawSink.startsWith('$FILES:')) continue;
1296
+
1297
+ let sink = rawSink;
1298
+ for (const [id, val] of Object.entries(inputValues)) {
1299
+ sink = sink.replaceAll(`$INPUT:${id}`, val);
1300
+ }
1301
+ const prefix = sink.startsWith('$FILE:') ? '$FILE:' : '$FILES:';
1302
+ const rawPath = sink.slice(prefix.length).trim();
1303
+ const absOut = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
1304
+ if (isSingletonInternalPath(absOut, cwd)) continue;
1305
+ try {
1306
+ assertWriteAllowed(absOut, {
1307
+ root: cwd,
1308
+ agentName: step.agent,
1309
+ outputName,
1310
+ policy: securityPolicy,
1311
+ });
1312
+ } catch (err) {
1313
+ errors.push(err.message);
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ const usedProviders = [...new Set(parsedAgents.map(({ step, agent }) => resolveProvider(step, agent)))];
1319
+ for (const provider of usedProviders) {
1320
+ try {
1321
+ const runner = getRunner(provider);
1322
+ if (runner.command) {
1323
+ const exists = await commandExists(runner.command);
1324
+ if (!exists) errors.push(`Provider "${provider}" requires missing CLI binary: ${runner.command}`);
1325
+ }
1326
+ } catch {
1327
+ // already captured above
1328
+ }
1329
+ }
1330
+
1331
+ if (usedProviders.includes('codex')) {
1332
+ const projectInstructions = await discoverCodexProjectInstructions(cwd, cwd);
1333
+ infos.push(
1334
+ `Codex project instructions: ${projectInstructions.files.length} file${projectInstructions.files.length !== 1 ? 's' : ''} detected.`
1335
+ );
1336
+ }
1337
+
1338
+ return {
1339
+ ok: errors.length === 0,
1340
+ errors,
1341
+ warnings,
1342
+ infos,
1343
+ securityHighlights,
1344
+ providerCount: usedProviders.length,
1345
+ };
1346
+ }
1347
+
1348
+ function printVerboseBlock(label, content, colorFn = (s) => s) {
1349
+ const WIDTH = 72;
1350
+ const bar = style.muted('─'.repeat(WIDTH));
1351
+ console.log(`\n${bar}`);
1352
+ console.log(style.muted(` ${label}`));
1353
+ console.log(bar);
1354
+ console.log(colorFn(content));
1355
+ console.log(bar + '\n');
1356
+ }
1357
+
1358
+ function visibleLength(s) {
1359
+ return String(s || '').replace(/\{[^}]+\}/g, '').length;
1360
+ }
1361
+
1362
+ function stripBlessedTags(s) {
1363
+ return String(s || '').replace(/\{[^}]+\}/g, '');
1364
+ }
1365
+
1366
+ function padVisible(s, width, align = 'left') {
1367
+ const str = String(s ?? '');
1368
+ const pad = Math.max(0, width - visibleLength(str));
1369
+ return align === 'right' ? `${' '.repeat(pad)}${str}` : `${str}${' '.repeat(pad)}`;
1370
+ }
1371
+
1372
+ function formatSeconds(value) {
1373
+ return `${Number(value || 0).toFixed(1)}s`;
1374
+ }
1375
+
1376
+ function formatCost(value) {
1377
+ return value > 0 ? `$${value.toFixed(4)}` : '—';
1378
+ }
1379
+
1380
+ function formatTurns(value) {
1381
+ return value > 0 ? String(value) : '—';
1382
+ }
1383
+
1384
+ function formatPolicyLabel({ securityProfile, permissionMode }) {
1385
+ const policy = securityProfile || '—';
1386
+ return permissionMode && permissionMode !== '—' ? `${policy} · perm:${permissionMode}` : policy;
1387
+ }
1388
+
1389
+ function renderRunSummary({ stats, fileWrites, dryRun, runDir, cwd, runStatus = null }) {
1390
+ const totalSeconds = stats.reduce((sum, s) => sum + (s.seconds || 0), 0);
1391
+ const totalCost = stats.reduce((sum, s) => sum + (s.cost || 0), 0);
1392
+ const totalTurns = stats.reduce((sum, s) => sum + (s.turns || 0), 0);
1393
+
1394
+ const rows = stats.map((s, i) => ({
1395
+ step: String(i + 1),
1396
+ agent: s.agent,
1397
+ provider: s.provider || '—',
1398
+ model: s.model || '—',
1399
+ policy: formatPolicyLabel({ securityProfile: s.securityProfile, permissionMode: s.permissionMode }),
1400
+ status: s.status,
1401
+ attempts: s.attempts && s.attempts > 1 ? String(s.attempts) : '—',
1402
+ time: s.status === 'dry-run' || s.status === 'skipped' ? '—' : formatSeconds(s.seconds),
1403
+ turns: formatTurns(s.turns),
1404
+ cost: formatCost(s.cost),
1405
+ }));
1406
+
1407
+ const totalRow = {
1408
+ step: '',
1409
+ agent: 'TOTAL',
1410
+ provider: '—',
1411
+ model: '—',
1412
+ policy: '—',
1413
+ status: runStatus || (dryRun ? 'dry-run' : 'done'),
1414
+ attempts: '—',
1415
+ time: formatSeconds(totalSeconds),
1416
+ turns: formatTurns(totalTurns),
1417
+ cost: formatCost(totalCost),
1418
+ };
1419
+
1420
+ const allRows = [...rows, totalRow];
1421
+ const widths = {
1422
+ step: Math.max(1, ...allRows.map((r) => visibleLength(r.step))),
1423
+ agent: Math.max(5, ...allRows.map((r) => visibleLength(r.agent))),
1424
+ provider: Math.max(8, ...allRows.map((r) => visibleLength(r.provider))),
1425
+ model: Math.max(5, ...allRows.map((r) => visibleLength(r.model))),
1426
+ policy: Math.max(6, ...allRows.map((r) => visibleLength(r.policy))),
1427
+ status: Math.max(6, ...allRows.map((r) => visibleLength(r.status))),
1428
+ attempts: Math.max(8, ...allRows.map((r) => visibleLength(r.attempts))),
1429
+ time: Math.max(4, ...allRows.map((r) => visibleLength(r.time))),
1430
+ turns: Math.max(5, ...allRows.map((r) => visibleLength(r.turns))),
1431
+ cost: Math.max(4, ...allRows.map((r) => visibleLength(r.cost))),
1432
+ };
1433
+
1434
+ const hr = [
1435
+ '─'.repeat(widths.step + 2),
1436
+ '─'.repeat(widths.agent + 2),
1437
+ '─'.repeat(widths.provider + 2),
1438
+ '─'.repeat(widths.model + 2),
1439
+ '─'.repeat(widths.policy + 2),
1440
+ '─'.repeat(widths.status + 2),
1441
+ '─'.repeat(widths.attempts + 2),
1442
+ '─'.repeat(widths.time + 2),
1443
+ '─'.repeat(widths.turns + 2),
1444
+ '─'.repeat(widths.cost + 2),
1445
+ ].join('┼');
1446
+
1447
+ function row(r) {
1448
+ return [
1449
+ ` ${padVisible(r.step, widths.step, 'right')} `,
1450
+ ` ${padVisible(r.agent, widths.agent)} `,
1451
+ ` ${padVisible(r.provider, widths.provider)} `,
1452
+ ` ${padVisible(r.model, widths.model)} `,
1453
+ ` ${padVisible(r.policy, widths.policy)} `,
1454
+ ` ${padVisible(r.status, widths.status)} `,
1455
+ ` ${padVisible(r.attempts, widths.attempts, 'right')} `,
1456
+ ` ${padVisible(r.time, widths.time, 'right')} `,
1457
+ ` ${padVisible(r.turns, widths.turns, 'right')} `,
1458
+ ` ${padVisible(r.cost, widths.cost, 'right')} `,
1459
+ ].join('│');
1460
+ }
1461
+
1462
+ const lines = [
1463
+ '',
1464
+ '{bold}Summary{/}',
1465
+ '',
1466
+ row({ step: '#', agent: 'Agent', provider: 'Provider', model: 'Model', policy: 'Policy', status: 'Status', attempts: 'Attempts', time: 'Time', turns: 'Turns', cost: 'Cost' }),
1467
+ hr,
1468
+ ...rows.map(row),
1469
+ hr,
1470
+ row(totalRow),
1471
+ '',
1472
+ ];
1473
+
1474
+ if (runDir) {
1475
+ lines.push(`Run: {${C.dimV}-fg}${path.relative(cwd, runDir)}{/}`);
1476
+ }
1477
+
1478
+ if (fileWrites.length) {
1479
+ lines.push('', '{bold}Generated{/}');
1480
+ for (const f of fileWrites) lines.push(` {${C.dimV}-fg}·{/} ${f}`);
1481
+ } else {
1482
+ lines.push('', `{${C.dimV}-fg}No files generated.{/}`);
1483
+ }
1484
+
1485
+ return lines;
1486
+ }
1487
+
1488
+ const SNAPSHOT_SKIP_DIRS = new Set([
1489
+ '.git',
1490
+ '.singleton',
1491
+ '.opencode',
1492
+ '.idea',
1493
+ '.vscode',
1494
+ 'node_modules',
1495
+ 'dist',
1496
+ 'build',
1497
+ '.next',
1498
+ '.cache',
1499
+ 'coverage',
1500
+ ]);
1501
+
1502
+ async function snapshotProjectFiles(root, rel = '', out = new Map()) {
1503
+ const abs = path.join(root, rel);
1504
+ const entries = await fs.readdir(abs, { withFileTypes: true });
1505
+ for (const entry of entries) {
1506
+ if (entry.isDirectory()) {
1507
+ if (SNAPSHOT_SKIP_DIRS.has(entry.name)) continue;
1508
+ await snapshotProjectFiles(root, path.join(rel, entry.name), out);
1509
+ continue;
1510
+ }
1511
+ if (!entry.isFile()) continue;
1512
+ const entryRel = path.join(rel, entry.name);
1513
+ const entryAbs = path.join(root, entryRel);
1514
+ const stat = await fs.stat(entryAbs);
1515
+ out.set(entryRel, `${stat.size}:${Math.floor(stat.mtimeMs)}`);
1516
+ }
1517
+ return out;
1518
+ }
1519
+
1520
+ const SNAPSHOT_MAX_FILE_BYTES = 1024 * 1024;
1521
+ const SNAPSHOT_BINARY_PROBE_BYTES = 8192;
1522
+
1523
+ async function detectGitRepo(cwd) {
1524
+ try {
1525
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
1526
+ return true;
1527
+ } catch {
1528
+ return false;
1529
+ }
1530
+ }
1531
+
1532
+ async function gitFilterIgnoredPaths(root, relPaths) {
1533
+ if (!relPaths.length) return new Set();
1534
+ const posix = relPaths.map((p) => p.split(path.sep).join('/'));
1535
+ const ignored = new Set();
1536
+ await new Promise((resolve) => {
1537
+ const child = spawn('git', ['check-ignore', '--stdin'], {
1538
+ cwd: root,
1539
+ stdio: ['pipe', 'pipe', 'ignore'],
1540
+ });
1541
+ let stdout = '';
1542
+ child.stdout.on('data', (d) => (stdout += d.toString()));
1543
+ child.on('error', () => resolve());
1544
+ child.on('close', () => {
1545
+ for (const line of stdout.split('\n')) {
1546
+ const trimmed = line.trim();
1547
+ if (trimmed) ignored.add(trimmed);
1548
+ }
1549
+ resolve();
1550
+ });
1551
+ child.stdin.write(posix.join('\n'));
1552
+ child.stdin.end();
1553
+ });
1554
+ if (!ignored.size) return new Set();
1555
+ const result = new Set();
1556
+ for (let i = 0; i < relPaths.length; i++) {
1557
+ if (ignored.has(posix[i])) result.add(relPaths[i]);
1558
+ }
1559
+ return result;
1560
+ }
1561
+
1562
+ async function isProbablyBinaryFile(absPath) {
1563
+ let fd;
1564
+ try {
1565
+ fd = await fs.open(absPath, 'r');
1566
+ const buf = Buffer.alloc(SNAPSHOT_BINARY_PROBE_BYTES);
1567
+ const { bytesRead } = await fd.read(buf, 0, SNAPSHOT_BINARY_PROBE_BYTES, 0);
1568
+ for (let i = 0; i < bytesRead; i++) {
1569
+ if (buf[i] === 0) return true;
1570
+ }
1571
+ return false;
1572
+ } catch {
1573
+ return false;
1574
+ } finally {
1575
+ if (fd) await fd.close().catch(() => {});
1576
+ }
1577
+ }
1578
+
1579
+ async function collectSnapshotCandidates(root, rel = '', out = []) {
1580
+ const abs = path.join(root, rel);
1581
+ const entries = await fs.readdir(abs, { withFileTypes: true });
1582
+ for (const entry of entries) {
1583
+ if (entry.isDirectory()) {
1584
+ if (SNAPSHOT_SKIP_DIRS.has(entry.name)) continue;
1585
+ await collectSnapshotCandidates(root, path.join(rel, entry.name), out);
1586
+ continue;
1587
+ }
1588
+ if (!entry.isFile()) continue;
1589
+ const entryRel = path.join(rel, entry.name);
1590
+ const entryAbs = path.join(root, entryRel);
1591
+ let size = 0;
1592
+ try {
1593
+ const stat = await fs.stat(entryAbs);
1594
+ size = stat.size;
1595
+ } catch {
1596
+ continue;
1597
+ }
1598
+ out.push({ relPath: entryRel, absPath: entryAbs, size });
1599
+ }
1600
+ return out;
1601
+ }
1602
+
1603
+ async function createStepSnapshot({ root, snapshotDir, gitRepo, maxFileBytes = SNAPSHOT_MAX_FILE_BYTES }) {
1604
+ await fs.mkdir(snapshotDir, { recursive: true });
1605
+ const candidates = await collectSnapshotCandidates(root);
1606
+ const ignored = gitRepo
1607
+ ? await gitFilterIgnoredPaths(root, candidates.map((c) => c.relPath))
1608
+ : new Set();
1609
+
1610
+ const captured = new Set();
1611
+ const skippedLarge = [];
1612
+ const skippedBinary = [];
1613
+ const skippedIgnored = [];
1614
+
1615
+ for (const { relPath, absPath, size } of candidates) {
1616
+ if (ignored.has(relPath)) {
1617
+ skippedIgnored.push(relPath);
1618
+ continue;
1619
+ }
1620
+ if (size > maxFileBytes) {
1621
+ skippedLarge.push(relPath);
1622
+ continue;
1623
+ }
1624
+ if (await isProbablyBinaryFile(absPath)) {
1625
+ skippedBinary.push(relPath);
1626
+ continue;
1627
+ }
1628
+ const dest = path.join(snapshotDir, relPath);
1629
+ await fs.mkdir(path.dirname(dest), { recursive: true });
1630
+ try {
1631
+ await fs.copyFile(absPath, dest, fsConstants.COPYFILE_FICLONE);
1632
+ } catch {
1633
+ try {
1634
+ await fs.copyFile(absPath, dest);
1635
+ } catch {
1636
+ continue;
1637
+ }
1638
+ }
1639
+ captured.add(relPath);
1640
+ }
1641
+
1642
+ return { snapshotDir, captured, skippedLarge, skippedBinary, skippedIgnored };
1643
+ }
1644
+
1645
+ export function detectSnapshotChanges(before, after, root) {
1646
+ const changed = [];
1647
+ const paths = new Set([...before.keys(), ...after.keys()]);
1648
+ for (const relPath of paths) {
1649
+ const beforeSig = before.get(relPath);
1650
+ const afterSig = after.get(relPath);
1651
+ if (beforeSig === afterSig) continue;
1652
+ changed.push({
1653
+ relPath,
1654
+ absPath: path.join(root, relPath),
1655
+ kind: 'deliverable',
1656
+ });
1657
+ }
1658
+ return changed;
1659
+ }
1660
+
1661
+ async function restoreStepSnapshot({ root, snapshot, originalPaths, changes }) {
1662
+ const restored = [];
1663
+ const removed = [];
1664
+ const skipped = [];
1665
+ for (const change of changes) {
1666
+ const relPath = change?.relPath;
1667
+ if (!relPath) continue;
1668
+ const absPath = path.join(root, relPath);
1669
+ if (snapshot.captured.has(relPath)) {
1670
+ const src = path.join(snapshot.snapshotDir, relPath);
1671
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
1672
+ await fs.copyFile(src, absPath);
1673
+ restored.push(relPath);
1674
+ } else if (originalPaths.has(relPath)) {
1675
+ skipped.push(relPath);
1676
+ } else {
1677
+ await fs.rm(absPath, { recursive: true, force: true });
1678
+ removed.push(relPath);
1679
+ }
1680
+ }
1681
+ return { restored, removed, skipped };
1682
+ }
1683
+
1684
+ export function validatePostRunChanges({ changes, securityPolicy, step, cwd }) {
1685
+ const violations = [];
1686
+ for (const change of changes) {
1687
+ try {
1688
+ assertWriteAllowed(change.absPath, {
1689
+ root: cwd,
1690
+ agentName: step.agent,
1691
+ outputName: 'direct project change',
1692
+ policy: securityPolicy,
1693
+ });
1694
+ } catch (err) {
1695
+ violations.push({
1696
+ path: change.relPath,
1697
+ reason: err.message,
1698
+ });
1699
+ }
1700
+ }
1701
+ return violations;
1702
+ }
1703
+
1704
+ async function getViolationDiffPreview(cwd, relPath, { maxLines = 80 } = {}) {
1705
+ try {
1706
+ const { stdout } = await runCommand('git', ['diff', '--', relPath], { cwd });
1707
+ const lines = stdout.trimEnd().split('\n').filter(Boolean);
1708
+ if (lines.length === 0) {
1709
+ try {
1710
+ await runCommand('git', ['ls-files', '--error-unmatch', relPath], { cwd });
1711
+ return ['No git diff available for this path.'];
1712
+ } catch {
1713
+ try {
1714
+ const raw = await fs.readFile(path.join(cwd, relPath), 'utf8');
1715
+ const preview = raw.split('\n').slice(0, maxLines);
1716
+ if (raw.split('\n').length > maxLines) {
1717
+ preview.push(`... file preview truncated (${raw.split('\n').length - maxLines} more lines)`);
1718
+ }
1719
+ return [`new/untracked file: ${relPath}`, ...preview];
1720
+ } catch {
1721
+ return ['No git diff available for this path.'];
1722
+ }
1723
+ }
1724
+ }
1725
+ const clipped = lines.slice(0, maxLines);
1726
+ if (lines.length > maxLines) clipped.push(`... diff truncated (${lines.length - maxLines} more lines)`);
1727
+ return clipped;
1728
+ } catch {
1729
+ return ['No git diff available for this path.'];
1730
+ }
1731
+ }
1732
+
1733
+ async function logViolationDiffPreviews({ violations, cwd, timeline }) {
1734
+ const maxFiles = 5;
1735
+ const shown = violations.slice(0, maxFiles);
1736
+ for (const violation of shown) {
1737
+ timeline.log(`── diff ${violation.path} ──`);
1738
+ const preview = await getViolationDiffPreview(cwd, violation.path);
1739
+ for (const line of preview) timeline.logMuted(line);
1740
+ }
1741
+ if (violations.length > maxFiles) {
1742
+ timeline.logMuted(`... ${violations.length - maxFiles} more violated file(s) not shown`);
1743
+ }
1744
+ }
1745
+
1746
+ async function handlePostRunViolations({ violations, step, securityPolicy, timeline, timelineIndex, shell, cwd }) {
1747
+ if (violations.length === 0) return;
1748
+
1749
+ timeline.log(`── post-run security violation ──`);
1750
+ timeline.logMuted(`Step "${step.agent}" changed files outside its security policy.`);
1751
+ timeline.logMuted(`security_profile: ${securityPolicy.profile}`);
1752
+ for (const violation of violations) {
1753
+ timeline.logMuted(`- ${violation.path}`);
1754
+ }
1755
+ await logViolationDiffPreviews({ violations, cwd, timeline });
1756
+
1757
+ if (!shell) {
1758
+ failStep(
1759
+ timeline,
1760
+ timelineIndex,
1761
+ `${violations.length} security violation${violations.length > 1 ? 's' : ''}`,
1762
+ `Post-run security validation failed for "${step.agent}":\n- ${violations.map((v) => v.path).join('\n- ')}`
1763
+ );
1764
+ }
1765
+
1766
+ while (true) {
1767
+ const answer = (await shell.prompt('Security violation: continue, stop, or diff? (c/s/d)')).trim().toLowerCase();
1768
+ if (answer === 'd' || answer === 'diff') {
1769
+ await logViolationDiffPreviews({ violations, cwd, timeline });
1770
+ continue;
1771
+ }
1772
+ if (answer === 'c' || answer === 'continue' || answer === 'y' || answer === 'yes') {
1773
+ timeline.log(`{${C.peach}-fg}!{/} Continued after security violation for ${step.agent}.`);
1774
+ return;
1775
+ }
1776
+ if (!answer || answer === 's' || answer === 'stop' || answer === 'n' || answer === 'no') {
1777
+ break;
1778
+ }
1779
+ timeline.logMuted('Choose c/continue, s/stop, or d/diff.');
1780
+ }
1781
+
1782
+ {
1783
+ failStep(
1784
+ timeline,
1785
+ timelineIndex,
1786
+ 'stopped by security review',
1787
+ `Pipeline stopped after post-run security validation for "${step.agent}".`
1788
+ );
1789
+ }
1790
+ }
1791
+
1792
+ async function writeRunManifest({ runDir, runId, pipeline, cwd, stats, fileWrites, detectedDeliverables = [], status = 'done', error = null, debugEvents = [] }) {
1793
+ if (!runDir) return;
1794
+
1795
+ const uniqueWrites = [];
1796
+ const seen = new Set();
1797
+ for (const entry of [...fileWrites, ...detectedDeliverables]) {
1798
+ if (seen.has(entry.absPath)) continue;
1799
+ seen.add(entry.absPath);
1800
+ uniqueWrites.push(entry);
1801
+ }
1802
+
1803
+ const deliverables = uniqueWrites.filter((entry) => entry.kind === 'deliverable');
1804
+ const intermediates = uniqueWrites.filter((entry) => entry.kind === 'intermediate');
1805
+
1806
+ const manifest = {
1807
+ runId,
1808
+ pipeline: pipeline.name,
1809
+ projectRoot: cwd,
1810
+ createdAt: new Date().toISOString(),
1811
+ status,
1812
+ error: error ? {
1813
+ message: error.message,
1814
+ } : null,
1815
+ deliverables: deliverables.map((entry) => ({
1816
+ path: entry.relPath,
1817
+ absPath: entry.absPath,
1818
+ })),
1819
+ intermediates: intermediates.map((entry) => ({
1820
+ path: entry.relPath,
1821
+ absPath: entry.absPath,
1822
+ })),
1823
+ stats: stats.map((s) => ({
1824
+ agent: s.agent,
1825
+ provider: s.provider,
1826
+ model: s.model,
1827
+ runnerAgent: s.runnerAgent,
1828
+ securityProfile: s.securityProfile,
1829
+ permissionMode: s.permissionMode,
1830
+ status: s.status,
1831
+ seconds: s.seconds,
1832
+ turns: s.turns,
1833
+ cost: s.cost,
1834
+ attempts: s.attempts || 1,
1835
+ outputWarnings: s.outputWarnings || [],
1836
+ parsedOutputs: s.parsedOutputs || [],
1837
+ rawOutputPath: s.rawOutputPath || null,
1838
+ })),
1839
+ debugEvents,
1840
+ };
1841
+
1842
+ await fs.writeFile(path.join(runDir, 'run-manifest.json'), JSON.stringify(manifest, null, 2));
1843
+ }
1844
+
1845
+ export async function runPipeline(filePath, opts = {}) {
1846
+ const abs = path.resolve(filePath);
1847
+ const pipeline = await loadPipeline(abs);
1848
+ const pipelineDir = path.dirname(abs);
1849
+ const cwd = resolveProjectRoot(pipelineDir);
1850
+ const dryRun = !!opts.dryRun;
1851
+ const verbose = !!opts.verbose;
1852
+ const debug = !!opts.debug;
1853
+ const shell = opts.shell || null;
1854
+ const quiet = !!opts.quiet;
1855
+ const maxDebugReplays = Number.isInteger(opts.maxDebugReplays)
1856
+ ? Math.max(0, opts.maxDebugReplays)
1857
+ : DEFAULT_MAX_DEBUG_REPLAYS;
1858
+ const securityConfig = await loadProjectSecurityConfig(cwd);
1859
+ const beforeSnapshot = dryRun ? null : await snapshotProjectFiles(cwd);
1860
+ let currentSnapshot = beforeSnapshot;
1861
+ const isGitRepo = !dryRun && await detectGitRepo(cwd);
1862
+
1863
+ // Versioned workspace for this run — intermediate artifacts land here.
1864
+ const now = new Date();
1865
+ 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')}`;
1866
+ const runId = `${debug ? 'DEBUG-' : ''}${ts}-${pipeline.name}`;
1867
+ const runDir = dryRun ? null : path.join(cwd, '.singleton', 'runs', runId);
1868
+ if (runDir) await fs.mkdir(runDir, { recursive: true });
1869
+
1870
+ const runInfo = runDir ? `run: ${path.relative(cwd, runDir)}` : '';
1871
+ if (!shell && !quiet) {
1872
+ console.log(style.title(`\n▸ ${pipeline.name}`) + style.muted(` (${pipeline.steps.length} steps)`));
1873
+ if (runInfo) console.log(style.muted(` ${runInfo}`));
1874
+ if (dryRun) console.log(style.warn(' [dry-run] no CLI calls will be made'));
1875
+ if (debug) console.log(style.warn(' [debug] pausing before each step'));
1876
+ } else if (shell) {
1877
+ shell.log(`{bold}▸ ${pipeline.name}{/} {${C.dimV}-fg}(${pipeline.steps.length} steps){/}`);
1878
+ if (runInfo) shell.log(` {${C.dimV}-fg}${runInfo}{/}`);
1879
+ if (dryRun) shell.log(`{yellow-fg} [dry-run] no CLI calls will be made{/}`);
1880
+ if (debug) shell.log(`{yellow-fg} [debug] pausing before each step{/}`);
1881
+ }
1882
+
1883
+ const inputDefs = (pipeline.nodes || [])
1884
+ .filter((n) => n.type === 'input')
1885
+ .map((n) => ({ id: n.id, subtype: n.data?.subtype || 'text', label: n.data?.label || n.id, value: n.data?.value || '' }));
1886
+
1887
+ const promptFn = shell ? (msg) => shell.prompt(msg) : null;
1888
+ const inputValues = await collectInputValues(pipeline, dryRun, promptFn);
1889
+
1890
+ if (shell) shell.enterPipelineMode();
1891
+ const timeline = quiet
1892
+ ? createSilentTimeline()
1893
+ : createTimeline(
1894
+ ['preflight checks', ...pipeline.steps.map((s) => s.agent)],
1895
+ shell ? shell.pipelineWidgets : null
1896
+ );
1897
+
1898
+ const registry = {};
1899
+ const fileWrites = [];
1900
+ const verboseLog = [];
1901
+ const stats = [];
1902
+ const debugEvents = [];
1903
+ const debugInputOverrides = {};
1904
+ let runError = null;
1905
+
1906
+ try {
1907
+ timeline.setRunning(0);
1908
+ const preflightStarted = Date.now();
1909
+ const preflight = await runPreflightChecks({ pipeline, cwd, inputDefs, inputValues, dryRun, securityConfig });
1910
+ const preflightSeconds = (Date.now() - preflightStarted) / 1000;
1911
+
1912
+ if (preflight.infos.length) {
1913
+ timeline.log(`── preflight info ──`);
1914
+ for (const info of preflight.infos) timeline.logMuted(info);
1915
+ }
1916
+
1917
+ if (preflight.securityHighlights.length) {
1918
+ timeline.log(`── security profile preview ──`);
1919
+ for (const item of preflight.securityHighlights) timeline.logMuted(item);
1920
+ }
1921
+
1922
+ if (preflight.warnings.length) {
1923
+ timeline.log(`── preflight warnings ──`);
1924
+ for (const warning of preflight.warnings) timeline.logMuted(warning);
1925
+ }
1926
+
1927
+ if (!preflight.ok) {
1928
+ timeline.log(`── preflight errors ──`);
1929
+ for (const error of preflight.errors) timeline.logMuted(error);
1930
+ stats.push({
1931
+ agent: 'preflight checks',
1932
+ provider: 'system',
1933
+ model: '—',
1934
+ securityProfile: '—',
1935
+ permissionMode: '—',
1936
+ status: 'failed',
1937
+ seconds: preflightSeconds,
1938
+ turns: 0,
1939
+ cost: 0,
1940
+ });
1941
+ failStep(
1942
+ timeline,
1943
+ 0,
1944
+ `${preflight.errors.length} error${preflight.errors.length > 1 ? 's' : ''}`,
1945
+ `Preflight checks failed:\n- ${preflight.errors.join('\n- ')}`
1946
+ );
1947
+ }
1948
+
1949
+ timeline.setDone(0, `${preflightSeconds.toFixed(1)}s · ${preflight.providerCount} provider${preflight.providerCount > 1 ? 's' : ''}`);
1950
+ timeline.log(`✓ preflight checks — ${preflight.providerCount} provider${preflight.providerCount > 1 ? 's' : ''}`);
1951
+ stats.push({
1952
+ agent: 'preflight checks',
1953
+ provider: 'system',
1954
+ model: '—',
1955
+ securityProfile: '—',
1956
+ permissionMode: '—',
1957
+ status: 'done',
1958
+ seconds: preflightSeconds,
1959
+ turns: 0,
1960
+ cost: 0,
1961
+ });
1962
+
1963
+ for (let i = 0; i < pipeline.steps.length; i++) {
1964
+ const step = pipeline.steps[i];
1965
+ const timelineIndex = i + 1;
1966
+ if (!step.agent_file) {
1967
+ failStep(timeline, timelineIndex, 'no agent_file', `Step "${step.agent}" is missing agent_file.`);
1968
+ }
1969
+
1970
+ const agentFilePath = path.isAbsolute(step.agent_file)
1971
+ ? step.agent_file
1972
+ : path.resolve(cwd, step.agent_file);
1973
+ const raw = await fs.readFile(agentFilePath, 'utf8');
1974
+ const { agent, error } = parseAgentFileDetailed(raw, agentFilePath);
1975
+ if (!agent) {
1976
+ failStep(
1977
+ timeline,
1978
+ timelineIndex,
1979
+ `failed to parse ${step.agent_file}`,
1980
+ `Failed to parse agent file: ${step.agent_file}${error ? ` (${error})` : ''}`
1981
+ );
1982
+ }
1983
+
1984
+ const outputNames = Object.keys(step.outputs || {});
1985
+ if (outputNames.length === 0) {
1986
+ const provider = resolveProvider(step, agent);
1987
+ const model = resolveModel(step, agent);
1988
+ const runnerAgent = resolveRunnerAgent(step, agent);
1989
+ timeline.setDone(timelineIndex, 'skipped (no outputs)');
1990
+ stats.push({
1991
+ agent: step.agent,
1992
+ provider,
1993
+ model: model || '—',
1994
+ runnerAgent: runnerAgent || '—',
1995
+ securityProfile: resolveSecurityPolicyWithConfig(step, agent, securityConfig).profile,
1996
+ permissionMode: step.permission_mode || agent.permission_mode || '—',
1997
+ status: 'skipped',
1998
+ seconds: 0,
1999
+ turns: 0,
2000
+ cost: 0,
2001
+ });
2002
+ continue;
2003
+ }
2004
+
2005
+ if (dryRun) {
2006
+ const provider = resolveProvider(step, agent);
2007
+ const model = resolveModel(step, agent);
2008
+ const runnerAgent = resolveRunnerAgent(step, agent);
2009
+ const permissionMode = resolvePermissionMode(step, agent);
2010
+ const securityPolicy = resolveSecurityPolicyWithConfig(step, agent, securityConfig);
2011
+ timeline.setDone(timelineIndex, `dry-run · ${outputNames.join(', ')}`);
2012
+ for (const name of outputNames) registry[`${step.agent}.${name}`] = `(dry-run:${step.agent}.${name})`;
2013
+ stats.push({
2014
+ agent: step.agent,
2015
+ provider,
2016
+ model: model || '—',
2017
+ runnerAgent: runnerAgent || '—',
2018
+ securityProfile: securityPolicy.profile,
2019
+ permissionMode: permissionMode || '—',
2020
+ status: 'dry-run',
2021
+ seconds: 0,
2022
+ turns: 0,
2023
+ cost: 0,
2024
+ });
2025
+ continue;
2026
+ }
2027
+
2028
+ const stepIndex = String(i + 1).padStart(2, '0');
2029
+ const stepDir = runDir ? path.join(runDir, `${stepIndex}-${step.agent}`) : null;
2030
+ if (stepDir) await fs.mkdir(stepDir, { recursive: true });
2031
+
2032
+ let resolvedInputs = {};
2033
+ const runtimeInputValues = debug
2034
+ ? { ...inputValues, ...debugInputOverrides }
2035
+ : inputValues;
2036
+ for (const [name, spec] of Object.entries(step.inputs || {})) {
2037
+ resolvedInputs[name] = await resolveInput(spec, { registry, cwd, inputValues: runtimeInputValues, inputDefs });
2038
+ }
2039
+
2040
+ const provider = resolveProvider(step, agent);
2041
+ const model = resolveModel(step, agent);
2042
+ const runnerAgent = resolveRunnerAgent(step, agent);
2043
+ const permissionMode = resolvePermissionMode(step, agent);
2044
+ const securityPolicy = resolveSecurityPolicyWithConfig(step, agent, securityConfig);
2045
+ const systemPrompt = agent.prompt || agent.description;
2046
+ const workspaceInfoForAttempt = (attemptNumber) => {
2047
+ if (!stepDir) return null;
2048
+ const attemptDir = debug && attemptNumber > 1 ? path.join(stepDir, `attempt-${attemptNumber}`) : stepDir;
2049
+ return { projectRoot: cwd, stepDirRel: path.relative(cwd, attemptDir) };
2050
+ };
2051
+ const workspaceInfo = workspaceInfoForAttempt(1);
2052
+
2053
+ if (debug) {
2054
+ timeline.setPaused(timelineIndex, 'debug review');
2055
+ const decision = await promptDebugStepDecision({
2056
+ step,
2057
+ stepNumber: i + 1,
2058
+ totalSteps: pipeline.steps.length,
2059
+ provider,
2060
+ model,
2061
+ runnerAgent,
2062
+ permissionMode,
2063
+ securityPolicy,
2064
+ resolvedInputs,
2065
+ outputNames,
2066
+ systemPrompt,
2067
+ workspaceInfo,
2068
+ timeline,
2069
+ shell,
2070
+ quiet,
2071
+ decisionFn: opts.debugDecision,
2072
+ debugEvents,
2073
+ });
2074
+
2075
+ if (decision.inputs) {
2076
+ const overrides = resolveDebugInputOverridesFromEdit(step, resolvedInputs, decision.inputs, inputDefs);
2077
+ for (const [id, value] of Object.entries(overrides)) {
2078
+ debugInputOverrides[id] = value;
2079
+ }
2080
+ if (Object.keys(overrides).length) {
2081
+ pushDebugEvent(debugEvents, {
2082
+ step: step.agent,
2083
+ phase: 'pre-step',
2084
+ action: 'set-runtime-input-overrides',
2085
+ inputIds: Object.keys(overrides),
2086
+ });
2087
+ }
2088
+ resolvedInputs = decision.inputs;
2089
+ }
2090
+
2091
+ if (decision.action === 'skip') {
2092
+ for (const name of outputNames) {
2093
+ registry[`${step.agent}.${name}`] = `(debug-skipped:${step.agent}.${name})`;
2094
+ }
2095
+ timeline.setDone(timelineIndex, 'skipped by debug');
2096
+ timeline.log(`↷ ${step.agent} — skipped by debug`);
2097
+ stats.push({
2098
+ agent: step.agent,
2099
+ provider,
2100
+ model: model || '—',
2101
+ runnerAgent: runnerAgent || '—',
2102
+ securityProfile: securityPolicy.profile,
2103
+ permissionMode: permissionMode || '—',
2104
+ status: 'skipped',
2105
+ seconds: 0,
2106
+ turns: 0,
2107
+ cost: 0,
2108
+ });
2109
+ continue;
2110
+ }
2111
+
2112
+ if (decision.action === 'abort') {
2113
+ stats.push({
2114
+ agent: step.agent,
2115
+ provider,
2116
+ model: model || '—',
2117
+ runnerAgent: runnerAgent || '—',
2118
+ securityProfile: securityPolicy.profile,
2119
+ permissionMode: permissionMode || '—',
2120
+ status: 'failed',
2121
+ seconds: 0,
2122
+ turns: 0,
2123
+ cost: 0,
2124
+ });
2125
+ failStep(timeline, timelineIndex, 'aborted by debug', `Pipeline aborted before step "${step.agent}".`);
2126
+ }
2127
+ }
2128
+
2129
+ const runner = getRunner(provider);
2130
+ let attempt = 1;
2131
+ let finalAttempt = null;
2132
+ let shouldReplay = false;
2133
+ let replayInputs = resolvedInputs;
2134
+ let replayInputOverride = null;
2135
+ let replayBaseInputs = resolvedInputs;
2136
+ let totalAttemptSeconds = 0;
2137
+ let totalAttemptTurns = 0;
2138
+ let totalAttemptCost = 0;
2139
+ const stepRegistrySnapshot = new Map(
2140
+ outputNames.map((name) => {
2141
+ const key = `${step.agent}.${name}`;
2142
+ return [key, Object.prototype.hasOwnProperty.call(registry, key) ? registry[key] : undefined];
2143
+ })
2144
+ );
2145
+ const stepSnapshotDir = debug && stepDir ? path.join(stepDir, '.snapshot') : null;
2146
+ const stepSnapshot = stepSnapshotDir
2147
+ ? await createStepSnapshot({ root: cwd, snapshotDir: stepSnapshotDir, gitRepo: isGitRepo })
2148
+ : null;
2149
+ if (stepSnapshot && (stepSnapshot.skippedLarge.length || stepSnapshot.skippedBinary.length || stepSnapshot.skippedIgnored.length)) {
2150
+ timeline.logMuted(`${debugToken.muted('Replay snapshot skipped:')} ` +
2151
+ `${debugToken.key('large')} ${stepSnapshot.skippedLarge.length} ` +
2152
+ `${debugToken.muted('·')} ${debugToken.key('binary')} ${stepSnapshot.skippedBinary.length} ` +
2153
+ `${debugToken.muted('·')} ${debugToken.key('gitignored')} ${stepSnapshot.skippedIgnored.length}`);
2154
+ }
2155
+ const stepOriginalPaths = currentSnapshot ? new Set(currentSnapshot.keys()) : new Set();
2156
+
2157
+ do {
2158
+ if (shouldReplay) {
2159
+ attempt += 1;
2160
+ if (finalAttempt?.stepChanges?.length || finalAttempt?.stepWrites?.length) {
2161
+ timeline.logMuted(`${debugToken.policy('Replay restored project files touched by the previous attempt. Previous run artifacts are kept under their attempt folder.')}`);
2162
+ timeline.logMuted(`${debugToken.key('restored changes')} ${formatDebugList((finalAttempt.stepChanges || []).map((entry) => entry.relPath))}`);
2163
+ timeline.logMuted(`${debugToken.key('previous artifacts')} ${formatDebugList((finalAttempt.stepWrites || []).map((entry) => entry.relPath))}`);
2164
+ }
2165
+ if (stepSnapshot && finalAttempt?.stepChanges?.length) {
2166
+ try {
2167
+ const result = await restoreStepSnapshot({
2168
+ root: cwd,
2169
+ snapshot: stepSnapshot,
2170
+ originalPaths: stepOriginalPaths,
2171
+ changes: finalAttempt.stepChanges,
2172
+ });
2173
+ if (result.skipped.length) {
2174
+ timeline.logMuted(`${debugToken.policy('Could not restore (filtered out of snapshot):')} ${formatDebugList(result.skipped)}`);
2175
+ stats.push({
2176
+ agent: step.agent,
2177
+ provider,
2178
+ model: model || '—',
2179
+ runnerAgent: runnerAgent || '—',
2180
+ securityProfile: securityPolicy.profile,
2181
+ permissionMode: permissionMode || '—',
2182
+ status: 'failed',
2183
+ seconds: totalAttemptSeconds,
2184
+ turns: totalAttemptTurns,
2185
+ cost: totalAttemptCost,
2186
+ attempts: attempt,
2187
+ });
2188
+ failStep(
2189
+ timeline,
2190
+ timelineIndex,
2191
+ 'replay restore incomplete',
2192
+ `Replay restore incomplete before step "${step.agent}" attempt ${attempt}. These changed files were excluded from the snapshot:\n- ${result.skipped.join('\n- ')}`
2193
+ );
2194
+ }
2195
+ currentSnapshot = await snapshotProjectFiles(cwd);
2196
+ } catch (err) {
2197
+ stats.push({
2198
+ agent: step.agent,
2199
+ provider,
2200
+ model: model || '—',
2201
+ runnerAgent: runnerAgent || '—',
2202
+ securityProfile: securityPolicy.profile,
2203
+ permissionMode: permissionMode || '—',
2204
+ status: 'failed',
2205
+ seconds: totalAttemptSeconds,
2206
+ turns: totalAttemptTurns,
2207
+ cost: totalAttemptCost,
2208
+ attempts: attempt,
2209
+ });
2210
+ failStep(
2211
+ timeline,
2212
+ timelineIndex,
2213
+ 'replay restore failed',
2214
+ `Replay restore failed before step "${step.agent}" attempt ${attempt}: ${err.message}`
2215
+ );
2216
+ }
2217
+ }
2218
+ for (const [key, previousValue] of stepRegistrySnapshot) {
2219
+ if (previousValue === undefined) delete registry[key];
2220
+ else registry[key] = previousValue;
2221
+ }
2222
+ const editedInputs = new Set();
2223
+ replayBaseInputs = replayInputs;
2224
+ if (replayInputOverride) {
2225
+ const nextInputs = { ...replayInputs };
2226
+ for (const [name, value] of Object.entries(replayInputOverride)) {
2227
+ if (Object.prototype.hasOwnProperty.call(nextInputs, name)) {
2228
+ nextInputs[name] = value;
2229
+ editedInputs.add(name);
2230
+ }
2231
+ }
2232
+ replayInputs = nextInputs;
2233
+ replayInputOverride = null;
2234
+ } else {
2235
+ replayInputs = await editDebugInputs({
2236
+ resolvedInputs: replayInputs,
2237
+ shell,
2238
+ timeline,
2239
+ step,
2240
+ debugEvents,
2241
+ editedInputs,
2242
+ });
2243
+ }
2244
+ const runtimeOverrides = resolveDebugInputOverridesFromEdit(step, replayBaseInputs, replayInputs, inputDefs);
2245
+ for (const [id, value] of Object.entries(runtimeOverrides)) {
2246
+ debugInputOverrides[id] = value;
2247
+ }
2248
+ if (Object.keys(runtimeOverrides).length) {
2249
+ pushDebugEvent(debugEvents, {
2250
+ step: step.agent,
2251
+ phase: 'post-step',
2252
+ action: 'set-runtime-input-overrides',
2253
+ inputIds: Object.keys(runtimeOverrides),
2254
+ attempt,
2255
+ });
2256
+ }
2257
+ if (editedInputs.size) {
2258
+ logDebugPromptPreview({
2259
+ systemPrompt,
2260
+ userMessage: buildUserMessage(replayInputs, outputNames, workspaceInfoForAttempt(attempt), securityPolicy),
2261
+ timeline,
2262
+ editedInputs,
2263
+ });
2264
+ }
2265
+ pushDebugEvent(debugEvents, {
2266
+ step: step.agent,
2267
+ phase: 'post-step',
2268
+ action: 'replay-start',
2269
+ attempt,
2270
+ editedInputs: [...editedInputs],
2271
+ });
2272
+ }
2273
+
2274
+ const attemptDir = debug && stepDir && attempt > 1 ? path.join(stepDir, `attempt-${attempt}`) : stepDir;
2275
+ if (attemptDir) await fs.mkdir(attemptDir, { recursive: true });
2276
+ const attemptWorkspaceInfo = workspaceInfoForAttempt(attempt);
2277
+ const userMessage = buildUserMessage(replayInputs, outputNames, attemptWorkspaceInfo, securityPolicy);
2278
+ timeline.setRunning(
2279
+ timelineIndex,
2280
+ formatStepRuntimeMeta({
2281
+ provider,
2282
+ model: model || '',
2283
+ permissionMode,
2284
+ securityProfile: securityPolicy.profile,
2285
+ })
2286
+ );
2287
+
2288
+ if (verbose) {
2289
+ timeline.log(`── system prompt ──`);
2290
+ for (const l of systemPrompt.split('\n').slice(0, 8)) timeline.logMuted(l);
2291
+ timeline.log(`── user message ──`);
2292
+ for (const l of userMessage.split('\n').slice(0, 12)) timeline.logMuted(l);
2293
+ }
2294
+
2295
+ const started = Date.now();
2296
+ const stepBeforeSnapshot = currentSnapshot;
2297
+ let result;
2298
+ try {
2299
+ result = await runner.run({
2300
+ cwd,
2301
+ projectRoot: cwd,
2302
+ currentDir: cwd,
2303
+ systemPrompt,
2304
+ userPrompt: userMessage,
2305
+ model,
2306
+ runnerAgent,
2307
+ permissionMode,
2308
+ securityPolicy,
2309
+ verbose,
2310
+ });
2311
+ } catch (err) {
2312
+ const failedSeconds = (Date.now() - started) / 1000;
2313
+ stats.push({
2314
+ agent: step.agent,
2315
+ provider,
2316
+ model: model || '—',
2317
+ runnerAgent: runnerAgent || '—',
2318
+ securityProfile: securityPolicy.profile,
2319
+ permissionMode: permissionMode || '—',
2320
+ status: 'failed',
2321
+ seconds: totalAttemptSeconds + failedSeconds,
2322
+ turns: 0,
2323
+ cost: totalAttemptCost,
2324
+ attempts: attempt,
2325
+ });
2326
+ failStep(timeline, timelineIndex, err.message, `Step "${step.agent}" failed: ${err.message}`);
2327
+ }
2328
+ const elapsedSeconds = (Date.now() - started) / 1000;
2329
+ const elapsed = elapsedSeconds.toFixed(1);
2330
+ const text = result.text;
2331
+ const attemptTurns = Number(result.metadata.turns || 0);
2332
+ const attemptCost = Number(result.metadata.costUsd || 0);
2333
+ totalAttemptSeconds += elapsedSeconds;
2334
+ totalAttemptTurns += attemptTurns;
2335
+ totalAttemptCost += attemptCost;
2336
+ const stepWritesStart = fileWrites.length;
2337
+
2338
+ if (verbose) {
2339
+ timeline.log(`── output ──`);
2340
+ for (const l of text.split('\n').slice(0, 20)) timeline.logMuted(l);
2341
+ }
2342
+
2343
+ const parsed = parseOutputs(text, outputNames);
2344
+ const outputWarnings = validateParsedOutputs(parsed, outputNames);
2345
+ const parsedOutputSummary = summarizeParsedOutputs(parsed, outputNames);
2346
+ let rawOutputPath = null;
2347
+ if (debug && (outputWarnings.length || outputNames.length > 1)) {
2348
+ rawOutputPath = await writeRawOutputArtifact({
2349
+ stepDir: attemptDir,
2350
+ step,
2351
+ text,
2352
+ reason: outputWarnings.length
2353
+ ? `Output warning(s): ${outputWarnings.join(', ')}`
2354
+ : 'Debug raw output capture',
2355
+ timeline,
2356
+ });
2357
+ }
2358
+
2359
+ for (const name of outputNames) {
2360
+ registry[`${step.agent}.${name}`] = parsed[name];
2361
+ let sink = step.outputs[name];
2362
+
2363
+ if (typeof sink === 'string') {
2364
+ for (const [id, val] of Object.entries(inputValues)) {
2365
+ sink = sink.replaceAll(`$INPUT:${id}`, val);
2366
+ }
2367
+ }
2368
+
2369
+ if (attemptDir) sink = rewriteInternalSink(sink, { cwd, stepDir: attemptDir });
2370
+
2371
+ if (typeof sink === 'string' && sink.startsWith('$FILES:')) {
2372
+ const baseDir = sink.slice('$FILES:'.length).trim();
2373
+ const absBase = path.isAbsolute(baseDir) ? baseDir : path.join(cwd, baseDir);
2374
+ const isRunArtifactSink = attemptDir && isInsidePath(absBase, attemptDir);
2375
+ const rawJson = parsed[name].replace(/^```[a-z]*\n?/m, '').replace(/```\s*$/m, '').trim();
2376
+ let manifest;
2377
+ try { manifest = JSON.parse(rawJson); } catch (e) {
2378
+ await writeRawOutputArtifact({
2379
+ stepDir: attemptDir,
2380
+ step,
2381
+ text,
2382
+ reason: `Invalid $FILES JSON for output "${name}"`,
2383
+ timeline,
2384
+ });
2385
+ failStep(timeline, timelineIndex, 'invalid $FILES JSON', `Step "${step.agent}" returned invalid JSON for $FILES output "${name}".`);
2386
+ }
2387
+ for (const entry of (Array.isArray(manifest) ? manifest : [])) {
2388
+ const absOut = path.resolve(absBase, entry.path);
2389
+ if (isRunArtifactSink) {
2390
+ assertRunArtifactWriteAllowed(absOut, absBase, step.agent, name);
2391
+ } else {
2392
+ assertWriteAllowed(absOut, {
2393
+ root: cwd,
2394
+ agentName: step.agent,
2395
+ outputName: name,
2396
+ policy: securityPolicy,
2397
+ });
2398
+ }
2399
+ await fs.mkdir(path.dirname(absOut), { recursive: true });
2400
+ await fs.writeFile(absOut, entry.content);
2401
+ fileWrites.push({
2402
+ absPath: absOut,
2403
+ relPath: path.relative(cwd, absOut),
2404
+ kind: path.relative(cwd, absOut).startsWith('.singleton' + path.sep) ? 'intermediate' : 'deliverable',
2405
+ });
2406
+ }
2407
+ } else if (typeof sink === 'string' && sink.startsWith('$FILE:')) {
2408
+ const outPath = sink.slice('$FILE:'.length).trim();
2409
+ const absOut = path.isAbsolute(outPath) ? outPath : path.resolve(cwd, outPath);
2410
+ if (attemptDir && isInsidePath(absOut, attemptDir)) {
2411
+ assertRunArtifactWriteAllowed(absOut, attemptDir, step.agent, name);
2412
+ } else {
2413
+ assertWriteAllowed(absOut, {
2414
+ root: cwd,
2415
+ agentName: step.agent,
2416
+ outputName: name,
2417
+ policy: securityPolicy,
2418
+ });
2419
+ }
2420
+ await fs.mkdir(path.dirname(absOut), { recursive: true });
2421
+ await fs.writeFile(absOut, parsed[name]);
2422
+ fileWrites.push({
2423
+ absPath: absOut,
2424
+ relPath: path.relative(cwd, absOut),
2425
+ kind: path.relative(cwd, absOut).startsWith('.singleton' + path.sep) ? 'intermediate' : 'deliverable',
2426
+ });
2427
+ }
2428
+ }
2429
+
2430
+ const attemptWrites = fileWrites.slice(stepWritesStart);
2431
+ let stepChanges = [];
2432
+ if (stepBeforeSnapshot) {
2433
+ const stepAfterSnapshot = await snapshotProjectFiles(cwd);
2434
+ stepChanges = detectSnapshotChanges(stepBeforeSnapshot, stepAfterSnapshot, cwd);
2435
+ const violations = validatePostRunChanges({
2436
+ changes: stepChanges,
2437
+ securityPolicy,
2438
+ step,
2439
+ cwd,
2440
+ });
2441
+ await handlePostRunViolations({
2442
+ violations,
2443
+ step,
2444
+ securityPolicy,
2445
+ timeline,
2446
+ timelineIndex,
2447
+ shell,
2448
+ cwd,
2449
+ });
2450
+ currentSnapshot = stepAfterSnapshot;
2451
+ }
2452
+
2453
+ if (step.require_changes && stepChanges.length === 0) {
2454
+ stats.push({
2455
+ agent: step.agent,
2456
+ provider,
2457
+ model: model || '—',
2458
+ runnerAgent: runnerAgent || '—',
2459
+ securityProfile: securityPolicy.profile,
2460
+ permissionMode: permissionMode || '—',
2461
+ status: 'failed',
2462
+ seconds: totalAttemptSeconds,
2463
+ turns: totalAttemptTurns,
2464
+ cost: totalAttemptCost,
2465
+ attempts: attempt,
2466
+ outputWarnings,
2467
+ parsedOutputs: parsedOutputSummary,
2468
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2469
+ });
2470
+ failStep(
2471
+ timeline,
2472
+ timelineIndex,
2473
+ 'no project changes',
2474
+ `Step "${step.agent}" requires project file changes but did not modify any tracked project file.`
2475
+ );
2476
+ }
2477
+
2478
+ if (debug) {
2479
+ timeline.setPaused(timelineIndex, 'output review');
2480
+ const postDecision = await promptDebugPostStepDecision({
2481
+ step,
2482
+ parsed,
2483
+ outputNames,
2484
+ stepWrites: attemptWrites,
2485
+ stepChanges,
2486
+ outputWarnings,
2487
+ rawText: text,
2488
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2489
+ attempt,
2490
+ maxDebugReplays,
2491
+ cwd,
2492
+ timeline,
2493
+ shell,
2494
+ quiet,
2495
+ decisionFn: opts.debugPostDecision,
2496
+ debugEvents,
2497
+ });
2498
+
2499
+ const postAction = typeof postDecision === 'object' && postDecision
2500
+ ? postDecision.action
2501
+ : postDecision;
2502
+
2503
+ if (postAction === 'abort') {
2504
+ stats.push({
2505
+ agent: step.agent,
2506
+ provider,
2507
+ model: model || '—',
2508
+ runnerAgent: runnerAgent || '—',
2509
+ securityProfile: securityPolicy.profile,
2510
+ permissionMode: permissionMode || '—',
2511
+ status: 'failed',
2512
+ seconds: totalAttemptSeconds,
2513
+ turns: totalAttemptTurns,
2514
+ cost: totalAttemptCost,
2515
+ attempts: attempt,
2516
+ outputWarnings,
2517
+ parsedOutputs: parsedOutputSummary,
2518
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2519
+ });
2520
+ failStep(timeline, timelineIndex, 'aborted after output review', `Pipeline aborted after step "${step.agent}" output review.`);
2521
+ }
2522
+ if (postAction === 'replay') {
2523
+ if (attempt - 1 >= maxDebugReplays) {
2524
+ stats.push({
2525
+ agent: step.agent,
2526
+ provider,
2527
+ model: model || '—',
2528
+ runnerAgent: runnerAgent || '—',
2529
+ securityProfile: securityPolicy.profile,
2530
+ permissionMode: permissionMode || '—',
2531
+ status: 'failed',
2532
+ seconds: totalAttemptSeconds,
2533
+ turns: totalAttemptTurns,
2534
+ cost: totalAttemptCost,
2535
+ attempts: attempt,
2536
+ outputWarnings,
2537
+ parsedOutputs: parsedOutputSummary,
2538
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2539
+ });
2540
+ failStep(
2541
+ timeline,
2542
+ timelineIndex,
2543
+ 'replay limit reached',
2544
+ `Replay limit reached for step "${step.agent}" (${maxDebugReplays} per step).`
2545
+ );
2546
+ }
2547
+ const movedAttempt = await moveAttemptArtifactsToAttemptDir({
2548
+ cwd,
2549
+ stepDir,
2550
+ attempt,
2551
+ writes: attemptWrites,
2552
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2553
+ });
2554
+ finalAttempt = { stepChanges, stepWrites: movedAttempt.writes };
2555
+ fileWrites.splice(stepWritesStart);
2556
+ replayInputOverride = typeof postDecision === 'object' && postDecision?.inputs
2557
+ ? postDecision.inputs
2558
+ : null;
2559
+ shouldReplay = true;
2560
+ continue;
2561
+ }
2562
+ }
2563
+
2564
+ const totalElapsed = totalAttemptSeconds.toFixed(1);
2565
+ const costInfo = totalAttemptCost ? ` · $${totalAttemptCost.toFixed(4)}` : '';
2566
+ const turnInfo = totalAttemptTurns ? ` · ${totalAttemptTurns}t` : '';
2567
+ const attemptInfo = attempt > 1 ? ` · ${attempt} attempts` : '';
2568
+ timeline.setDone(timelineIndex, `${totalElapsed}s${attemptInfo}${turnInfo}${costInfo}`);
2569
+ timeline.log(`✓ ${step.agent} — ${totalElapsed}s${attemptInfo}${turnInfo}${costInfo}`);
2570
+ stats.push({
2571
+ agent: step.agent,
2572
+ provider,
2573
+ model: model || '—',
2574
+ runnerAgent: runnerAgent || '—',
2575
+ securityProfile: securityPolicy.profile,
2576
+ permissionMode: permissionMode || '—',
2577
+ status: 'done',
2578
+ seconds: totalAttemptSeconds,
2579
+ turns: totalAttemptTurns,
2580
+ cost: totalAttemptCost,
2581
+ attempts: attempt,
2582
+ outputWarnings,
2583
+ parsedOutputs: parsedOutputSummary,
2584
+ rawOutputPath: rawOutputPath ? path.relative(cwd, rawOutputPath) : null,
2585
+ });
2586
+ shouldReplay = false;
2587
+ } while (shouldReplay);
2588
+ }
2589
+ } catch (err) {
2590
+ runError = err;
2591
+ } finally {
2592
+ timeline.end();
2593
+ if (shell) shell.exitPipelineMode();
2594
+ }
2595
+
2596
+ const finalSnapshot = dryRun ? null : await snapshotProjectFiles(cwd);
2597
+ const detectedDeliverables = dryRun ? [] : detectSnapshotChanges(beforeSnapshot, finalSnapshot, cwd);
2598
+ currentSnapshot = finalSnapshot || currentSnapshot;
2599
+ const runStatus = runError ? 'failed' : (dryRun ? 'dry-run' : 'done');
2600
+
2601
+ if (runDir) {
2602
+ await writeRunManifest({
2603
+ runDir,
2604
+ runId,
2605
+ pipeline,
2606
+ cwd,
2607
+ stats,
2608
+ fileWrites,
2609
+ detectedDeliverables,
2610
+ status: runStatus,
2611
+ error: runError,
2612
+ debugEvents,
2613
+ });
2614
+ const latest = path.join(cwd, '.singleton', 'runs', 'latest');
2615
+ try { await fs.unlink(latest); } catch { /* missing is fine */ }
2616
+ try { await fs.symlink(runId, latest, 'dir'); } catch { /* non-critical */ }
2617
+ }
2618
+
2619
+ const combinedWrites = [];
2620
+ const seenWrites = new Set();
2621
+ for (const entry of [...fileWrites, ...detectedDeliverables]) {
2622
+ if (seenWrites.has(entry.absPath)) continue;
2623
+ seenWrites.add(entry.absPath);
2624
+ combinedWrites.push(entry);
2625
+ }
2626
+
2627
+ const out = quiet
2628
+ ? () => {}
2629
+ : shell
2630
+ ? (t) => shell.log(t)
2631
+ : (t) => console.log(stripBlessedTags(t));
2632
+
2633
+ for (const line of renderRunSummary({
2634
+ stats,
2635
+ fileWrites: combinedWrites.map((f) => f.relPath),
2636
+ dryRun,
2637
+ runDir,
2638
+ cwd,
2639
+ runStatus,
2640
+ })) out(line);
2641
+ if (runError) {
2642
+ out(`{${C.salmon}-fg}✕ pipeline failed{/}`);
2643
+ throw runError;
2644
+ }
2645
+ out(dryRun ? `{${C.mint}-fg}✓ dry-run complete{/}` : `{${C.mint}-fg}✓ pipeline complete{/}`);
2646
+ }