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,551 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { createShell, C } from '../shell.js';
5
+ import { scanAgents } from '../scanner.js';
6
+ import { runPipeline } from '../executor.js';
7
+ import { newAgentShellCommand } from './new.js';
8
+ import { loadProjectSecurityConfig } from '../security/policy.js';
9
+
10
+ const PIPELINES_DIRS = ['.singleton/pipelines'];
11
+
12
+ const HELP = [
13
+ '',
14
+ `{bold}Commands{/}`,
15
+ '',
16
+ ` {${C.violet}-fg}/run <name>{/} run a pipeline`,
17
+ ` {${C.violet}-fg}/run <name> --dry{/} dry-run (plan without API calls)`,
18
+ ` {${C.violet}-fg}/run <name> --verbose{/} show prompts and outputs`,
19
+ ` {${C.violet}-fg}/run <name> --debug{/} pause before each step`,
20
+ ` {${C.blue}-fg}/scan{/} scan .md agents`,
21
+ ` {${C.blue}-fg}/new{/} create a new agent`,
22
+ ` {${C.blue}-fg}/serve{/} start the web server`,
23
+ ` {${C.blue}-fg}/stop{/} stop the web server`,
24
+ ` {${C.blue}-fg}/commit-last{/} commit deliverables from the last run`,
25
+ ` {${C.pink}-fg}/ls{/} list pipelines`,
26
+ ` {${C.pink}-fg}/help{/} show help`,
27
+ ` {${C.pink}-fg}/quit{/} quit {${C.dimV}-fg}(or Ctrl+C){/}`,
28
+ '',
29
+ ].join('\n');
30
+
31
+ const COMMANDS = [
32
+ { label: '/run', value: '/run ', description: 'run a pipeline' },
33
+ { label: '/scan', value: '/scan ', description: 'scan .md agents' },
34
+ { label: '/new', value: '/new ', description: 'create a new agent' },
35
+ { label: '/serve', value: '/serve ', description: 'start the web server' },
36
+ { label: '/stop', value: '/stop', description: 'stop the web server' },
37
+ { label: '/commit-last', value: '/commit-last', description: 'commit the last run' },
38
+ { label: '/ls', value: '/ls', description: 'list pipelines' },
39
+ { label: '/help', value: '/help', description: 'show help' },
40
+ { label: '/quit', value: '/quit', description: 'quit' },
41
+ ];
42
+
43
+ const RUN_FLAGS = [
44
+ { label: '--dry', description: 'plan without API calls' },
45
+ { label: '--verbose', description: 'show prompts and outputs' },
46
+ { label: '--debug', description: 'pause before each step' },
47
+ { label: '-v', description: 'alias for --verbose' },
48
+ ];
49
+
50
+ async function listPipelines(root) {
51
+ const names = [];
52
+ for (const dir of PIPELINES_DIRS) {
53
+ try {
54
+ const files = (await fs.readdir(path.resolve(root, dir)))
55
+ .filter((f) => f.endsWith('.json') && f !== 'agents.json');
56
+ names.push(...files.map((f) => f.replace(/\.json$/, '')));
57
+ } catch { /* dir doesn't exist */ }
58
+ }
59
+ return [...new Set(names)];
60
+ }
61
+
62
+ async function resolvePipelinePath(name, root) {
63
+ const candidates = [
64
+ ...PIPELINES_DIRS.map((d) => path.resolve(root, d, `${name}.json`)),
65
+ path.resolve(root, `${name}.json`),
66
+ path.resolve(name),
67
+ ];
68
+ for (const c of candidates) {
69
+ try { await fs.access(c); return c; } catch { /* skip */ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function runCommand(cmd, args, { cwd }) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
77
+ let stdout = '';
78
+ let stderr = '';
79
+ child.stdout.on('data', (d) => (stdout += d.toString()));
80
+ child.stderr.on('data', (d) => (stderr += d.toString()));
81
+ child.on('error', reject);
82
+ child.on('close', (code) => {
83
+ if (code !== 0) {
84
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
85
+ return;
86
+ }
87
+ resolve({ stdout, stderr });
88
+ });
89
+ });
90
+ }
91
+
92
+ async function loadLastRunManifest(root) {
93
+ const latestDir = path.join(root, '.singleton', 'runs', 'latest');
94
+ const manifestPath = path.join(latestDir, 'run-manifest.json');
95
+ const raw = await fs.readFile(manifestPath, 'utf8');
96
+ return JSON.parse(raw);
97
+ }
98
+
99
+ function splitInput(buffer) {
100
+ const leadingTrimmed = buffer.trimStart();
101
+ const parts = leadingTrimmed.length ? leadingTrimmed.split(/\s+/) : [];
102
+ const endsWithSpace = /\s$/.test(buffer);
103
+ const current = endsWithSpace ? '' : (parts.at(-1) || '');
104
+ return { parts, current, endsWithSpace };
105
+ }
106
+
107
+ function replaceCurrentToken(buffer, replacement) {
108
+ if (/\s$/.test(buffer)) return `${buffer}${replacement}`;
109
+ const idx = buffer.search(/\S+$/);
110
+ if (idx === -1) return replacement;
111
+ return `${buffer.slice(0, idx)}${replacement}`;
112
+ }
113
+
114
+ function matchesPrefix(value, prefix) {
115
+ return value.toLowerCase().startsWith(prefix.toLowerCase());
116
+ }
117
+
118
+ function matchesCommitExclude(relPath, pattern) {
119
+ const rel = String(relPath || '').replaceAll('\\', '/').replace(/^\/+/, '');
120
+ const pat = String(pattern || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
121
+ return pat && (rel === pat || rel.startsWith(`${pat}/`));
122
+ }
123
+
124
+ async function completeRepl(buffer, root) {
125
+ const { parts, current } = splitInput(buffer);
126
+
127
+ if (parts.length <= 1 && !/\s$/.test(buffer)) {
128
+ return COMMANDS
129
+ .filter((cmd) => matchesPrefix(cmd.label, current || buffer.trim()))
130
+ .map((cmd) => ({ ...cmd, value: cmd.value }));
131
+ }
132
+
133
+ const cmd = parts[0];
134
+
135
+ if (cmd === '/run') {
136
+ const args = parts.slice(1).filter(Boolean);
137
+ const used = new Set(args);
138
+ const hasPipelineArg = args.some((arg) => !arg.startsWith('-'));
139
+ const pipelines = await listPipelines(root);
140
+ const pipelineItems = pipelines
141
+ .filter((name) => !current || matchesPrefix(name, current))
142
+ .map((name) => ({
143
+ label: name,
144
+ value: replaceCurrentToken(buffer, name),
145
+ description: 'pipeline',
146
+ }));
147
+
148
+ const flagItems = RUN_FLAGS
149
+ .filter((flag) => !used.has(flag.label))
150
+ .filter((flag) => !current || matchesPrefix(flag.label, current))
151
+ .map((flag) => ({
152
+ label: flag.label,
153
+ value: replaceCurrentToken(buffer, flag.label),
154
+ description: flag.description,
155
+ }));
156
+
157
+ if (hasPipelineArg && current === '') return flagItems;
158
+
159
+ return current.startsWith('--') || current.startsWith('-')
160
+ ? flagItems
161
+ : [...pipelineItems, ...flagItems];
162
+ }
163
+
164
+ return [];
165
+ }
166
+
167
+ // Strip blessed tags to get the visible string length.
168
+ function tw(s) { return s.replace(/\{[^}]+\}/g, '').length; }
169
+
170
+ function layoutRow(left, right, width) {
171
+ const spaces = Math.max(2, width - tw(left) - tw(right));
172
+ return left + ' '.repeat(spaces) + right;
173
+ }
174
+
175
+ async function countAgents(root) {
176
+ try {
177
+ const raw = await fs.readFile(path.resolve(root, '.singleton', 'agents.json'), 'utf8');
178
+ return JSON.parse(raw).agents?.length ?? 0;
179
+ } catch { return 0; }
180
+ }
181
+
182
+ // Violet → peach gradient (4 interpolated steps)
183
+ const SINGLETON_GRAD = ['#C084FC', '#D499E8', '#EBB0D8', '#F9A8D4'];
184
+
185
+ // Gradient by column position so all rows share the same color mapping
186
+ function gradientLine(text, totalWidth, colors = SINGLETON_GRAD) {
187
+ return text.split('').map((ch, i) => {
188
+ if (ch === ' ') return ch;
189
+ const color = colors[Math.min(Math.floor((i / totalWidth) * colors.length), colors.length - 1)];
190
+ return `{${color}-fg}${ch}{/}`;
191
+ }).join('');
192
+ }
193
+
194
+ function plainBrightLine(text) {
195
+ return text.split('').map((ch) => (ch === ' ' ? ch : `{#FFFFFF-fg}${ch}{/}`)).join('');
196
+ }
197
+
198
+ const SINGLETON_RAW = [
199
+ '▄█████ ▄▄ ▄▄ ▄▄ ▄▄▄▄ ▄▄ ▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄ ▄▄ ▄▄ ',
200
+ '▀▀▀▄▄▄ ██ ███▄██ ██ ▄▄ ██ ▄▄██ ██ ██▀██ ███▄██ ',
201
+ '█████▀ ██ ██ ▀██ ▀███▀ ██▄▄▄ ▄▄▄██ ██ ▀███▀ ██ ▀██ ',
202
+ ];
203
+
204
+ const ART_WIDTH = Math.max(...SINGLETON_RAW.map((l) => l.length));
205
+ const APP_VERSION = 'v0.4.0-beta.0';
206
+
207
+ async function showWelcome(root, shell) {
208
+ const now = new Date();
209
+ const dateStr = now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
210
+ const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
211
+
212
+ const [pipelines, agentCount] = await Promise.all([
213
+ listPipelines(root),
214
+ countAgents(root),
215
+ ]);
216
+
217
+ const CONTENT_PAD_LEFT = 2;
218
+ const CONTENT_PAD_TOP = 1;
219
+ const contentHeight = Math.max(12, (shell.screen.height ?? 24) - 4);
220
+ const headerLines = [
221
+ '',
222
+ ' '.repeat(tw('Welcome back')),
223
+ `${dateStr} ${timeStr} {${C.ghost}-fg}${APP_VERSION}{/}`,
224
+ '',
225
+ `${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''}`,
226
+ `${agentCount} agent${agentCount !== 1 ? 's' : ''}`,
227
+ '',
228
+ `{${C.peach}-fg}{bold}New{/} {#FFFFFF-fg}you can now debug pipeline runs{/}`,
229
+ `{${C.ghost}-fg}·{/} {${C.mint}-fg}pause steps{/} {${C.ghost}-fg}·{/} {${C.blue}-fg}inspect prompts{/} {${C.ghost}-fg}·{/} {${C.peach}-fg}edit inputs{/} {${C.ghost}-fg}·{/} {${C.violet}-fg}review diffs{/}`,
230
+ `{${C.peach}-fg}{bold}New{/} {#FFFFFF-fg}Copilot runner support{/}`,
231
+ `{${C.ghost}-fg}·{/} {${C.mint}-fg}provider copilot{/} {${C.ghost}-fg}·{/} {${C.blue}-fg}runner_agent optional{/} {${C.ghost}-fg}·{/} {${C.peach}-fg}native tool permissions{/}`,
232
+ `{${C.peach}-fg}{bold}New{/} {#FFFFFF-fg}experimental OpenCode runner support{/}`,
233
+ `{${C.ghost}-fg}·{/} {${C.mint}-fg}provider opencode{/} {${C.ghost}-fg}·{/} {${C.blue}-fg}runner_agent optional{/} {${C.ghost}-fg}·{/} {${C.peach}-fg}post-run security{/}`,
234
+ '',
235
+ ];
236
+ const TAGLINE = 'one to rule them all';
237
+ const CREDIT = 'Developed by Romain LENTZ';
238
+ const bottomBlockHeight = 3 + SINGLETON_RAW.length;
239
+ const spacerLines = Math.max(0, contentHeight - headerLines.length - bottomBlockHeight);
240
+
241
+ // Track shimmer positions.
242
+ const welcomeRow = CONTENT_PAD_TOP + 2;
243
+ const creditRow = CONTENT_PAD_TOP + headerLines.length + spacerLines;
244
+ const taglineRow = creditRow + 1;
245
+
246
+ for (const line of headerLines) {
247
+ shell.log(line);
248
+ }
249
+ for (let i = 0; i < spacerLines; i += 1) {
250
+ shell.log('');
251
+ }
252
+
253
+ shell.log(`{${C.dimV}-fg}${CREDIT}{/}`);
254
+ shell.log(' '.repeat(TAGLINE.length));
255
+ shell.log('');
256
+ for (const line of SINGLETON_RAW) {
257
+ shell.log(plainBrightLine(line));
258
+ }
259
+
260
+ const stopWelcome = shell.createShimmer('Welcome back', welcomeRow, CONTENT_PAD_LEFT);
261
+ const stopTagline = shell.createShimmer(TAGLINE, taglineRow, CONTENT_PAD_LEFT);
262
+
263
+ return () => { stopWelcome(); stopTagline(); };
264
+ }
265
+
266
+ async function refreshFooter(root, shell) {
267
+ const [pipelines, agentCount] = await Promise.all([
268
+ listPipelines(root),
269
+ countAgents(root),
270
+ ]);
271
+ shell.setFooter(
272
+ `${agentCount} agent${agentCount !== 1 ? 's' : ''} /scan to refresh`,
273
+ `${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''}`
274
+ );
275
+ }
276
+
277
+ function createServeState(shell) {
278
+ return {
279
+ server: null,
280
+ url: '',
281
+ suppressCloseLog: false,
282
+ clear() {
283
+ this.server = null;
284
+ this.url = '';
285
+ this.suppressCloseLog = false;
286
+ shell.setFooterCenter('');
287
+ },
288
+ };
289
+ }
290
+
291
+ export async function replCommand(opts) {
292
+ const root = path.resolve(opts.root || process.cwd());
293
+ const shell = createShell();
294
+ const serveState = createServeState(shell);
295
+
296
+ let stopShimmer = await showWelcome(root, shell);
297
+ await refreshFooter(root, shell);
298
+
299
+ shell.setCompleter(({ buffer }) => completeRepl(buffer, root));
300
+
301
+ shell.onCommand(async (raw) => {
302
+ if (stopShimmer) { stopShimmer(); stopShimmer = null; shell.clear(); }
303
+ const [cmd, ...args] = raw.trim().split(/\s+/);
304
+ shell.disableInput();
305
+ try {
306
+ switch (cmd) {
307
+ case '/run': await cmdRun(args, root, shell); break;
308
+ case '/ls': await cmdLs(root, shell); break;
309
+ case '/scan': await cmdScan(root, shell); await refreshFooter(root, shell); break;
310
+ case '/new': await cmdNew(root, shell); await refreshFooter(root, shell); break;
311
+ case '/serve': await cmdServe(root, shell, serveState); break;
312
+ case '/stop': await cmdStop(shell, serveState); break;
313
+ case '/commit-last': await cmdCommitLast(root, shell); break;
314
+ case '/help': shell.log(HELP); break;
315
+ case '/quit':
316
+ case '/exit':
317
+ if (serveState.server) {
318
+ await closeServer(serveState.server);
319
+ serveState.clear();
320
+ }
321
+ shell.log('{#676498-fg}See you soon.{/}');
322
+ setTimeout(() => { shell.destroy(); process.exit(0); }, 300);
323
+ return;
324
+ default:
325
+ shell.log(`{${C.peach}-fg}!{/} Unknown command: {bold}${cmd}{/} — type /help`);
326
+ }
327
+ } catch (err) {
328
+ shell.log(`{${C.salmon}-fg}✕{/} ${err.message}`);
329
+ }
330
+ shell.enableInput();
331
+ });
332
+ }
333
+
334
+ async function cmdRun(args, root, shell) {
335
+ const dry = args.includes('--dry');
336
+ const verbose = args.includes('--verbose') || args.includes('-v');
337
+ const debug = args.includes('--debug');
338
+ const name = args.filter((a) => !['--dry', '--verbose', '--debug', '-v'].includes(a))[0];
339
+
340
+ if (!name) {
341
+ const pipelines = await listPipelines(root);
342
+ if (pipelines.length === 0) {
343
+ shell.log(`{${C.peach}-fg}!{/} No pipelines found.`);
344
+ return;
345
+ }
346
+ shell.log(`{${C.dimV}-fg} Pipelines: ${pipelines.join(', ')}{/}`);
347
+ shell.log(`{${C.dimV}-fg} Usage: /run <name> [--dry] [--verbose] [--debug]{/}`);
348
+ return;
349
+ }
350
+
351
+ const filePath = await resolvePipelinePath(name, root);
352
+ if (!filePath) {
353
+ shell.log(`{${C.salmon}-fg}✕{/} Pipeline "{bold}${name}{/}" not found.`);
354
+ const pipelines = await listPipelines(root);
355
+ if (pipelines.length) shell.log(`{${C.dimV}-fg} Available: ${pipelines.join(', ')}{/}`);
356
+ return;
357
+ }
358
+
359
+ await runPipeline(filePath, { dryRun: dry, verbose, debug, shell });
360
+ }
361
+
362
+ async function cmdLs(root, shell) {
363
+ const pipelines = await listPipelines(root);
364
+ if (pipelines.length === 0) {
365
+ shell.log('{yellow-fg}!{/} No pipelines found.');
366
+ return;
367
+ }
368
+ shell.log(`{bold}Pipelines (${pipelines.length}){/}`);
369
+ shell.log('');
370
+ for (const p of pipelines) shell.log(` {${C.dimV}-fg}·{/} {${C.pink}-fg}${p}{/}`);
371
+ shell.log('');
372
+ }
373
+
374
+ function groupAgentsByProvider(agents) {
375
+ return agents.reduce((groups, agent) => {
376
+ const provider = agent.provider || 'unknown';
377
+ if (!groups.has(provider)) groups.set(provider, []);
378
+ groups.get(provider).push(agent);
379
+ return groups;
380
+ }, new Map());
381
+ }
382
+
383
+ async function cmdScan(root, shell) {
384
+ shell.log(`{${C.dimV}-fg}Scanning ${root}…{/}`);
385
+ const agents = await scanAgents(root);
386
+ if (agents.length === 0) {
387
+ shell.log(`{${C.peach}-fg}!{/} No agents found (no .md files with ## Config).`);
388
+ return;
389
+ }
390
+ shell.log(`{bold}Agents (${agents.length}){/}`);
391
+ shell.log('');
392
+ const groups = groupAgentsByProvider(agents);
393
+ [...groups.entries()].forEach(([provider, providerAgents]) => {
394
+ shell.log(` {${C.dimV}-fg}════════════════════════════════════════{/}`);
395
+ shell.log(` {bold}${provider}{/} {${C.dimV}-fg}(${providerAgents.length}){/}`);
396
+ shell.log(` {${C.dimV}-fg}════════════════════════════════════════{/}`);
397
+ shell.log('');
398
+
399
+ providerAgents.forEach((a, index) => {
400
+ shell.log(` {${C.violet}-fg}{bold}${a.id}{/} {${C.dimV}-fg}${a.description || '(no description)'}{/}`);
401
+ shell.log(` {${C.blue}-fg}{bold}source{/}: {${C.dimV}-fg}${a.source || 'repo'}{/}${a.permission_mode ? ` {${C.peach}-fg}{bold}permission{/}: {${C.dimV}-fg}${a.permission_mode}{/}` : ''}`);
402
+ shell.log(` {${C.mint}-fg}{bold}in{/}: {${C.dimV}-fg}${a.inputs.join(', ') || '—'}{/} {${C.pink}-fg}{bold}out{/}: {${C.dimV}-fg}${a.outputs.join(', ') || '—'}{/}`);
403
+ if (index < providerAgents.length - 1) shell.log(` {${C.dimV}-fg}──────────────────────────────────────{/}`);
404
+ });
405
+ shell.log('');
406
+ });
407
+ const outPath = path.resolve(root, '.singleton', 'agents.json');
408
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
409
+ await fs.writeFile(outPath, JSON.stringify({ scannedAt: new Date().toISOString(), root, agents }, null, 2));
410
+ shell.log('');
411
+ shell.log(`{${C.mint}-fg}✓{/} Cache → .singleton/agents.json`);
412
+ }
413
+
414
+ async function cmdNew(root, shell) {
415
+ await newAgentShellCommand({ root, shell });
416
+ }
417
+
418
+ function closeServer(server) {
419
+ return new Promise((resolve, reject) => {
420
+ server.close((err) => {
421
+ if (err) {
422
+ reject(err);
423
+ return;
424
+ }
425
+ resolve();
426
+ });
427
+ });
428
+ }
429
+
430
+ async function cmdServe(root, shell, serveState) {
431
+ if (serveState.server) {
432
+ shell.log(`{${C.peach}-fg}!{/} The server is already running on {${C.blue}-fg}${serveState.url}{/}.`);
433
+ return;
434
+ }
435
+ const { startServer } = await import('../../../server/src/index.js');
436
+ const serverUrl = 'http://localhost:4317';
437
+ shell.log('{#676498-fg}Starting server… (/stop to stop){/}');
438
+ shell.enableInput();
439
+ const server = await startServer({
440
+ port: 4317,
441
+ root,
442
+ logger: (message) => {
443
+ const urlMatch = String(message).match(/https?:\/\/\S+/);
444
+ if (urlMatch) {
445
+ const url = urlMatch[0];
446
+ const prefix = message.slice(0, urlMatch.index);
447
+ const suffix = message.slice(urlMatch.index + url.length);
448
+ shell.log(`{#FFFFFF-fg}{bold}${prefix}{/}{${C.blue}-fg}${url}{/}{${C.dimV}-fg}${suffix}{/}`);
449
+ return;
450
+ }
451
+ shell.log(`{${C.dimV}-fg}${message}{/}`);
452
+ },
453
+ });
454
+ serveState.server = server;
455
+ serveState.url = serverUrl;
456
+ server.on('close', () => {
457
+ const shouldLog = !serveState.suppressCloseLog;
458
+ serveState.clear();
459
+ if (shouldLog) shell.log(`{${C.dimV}-fg}Serve stopped.{/}`);
460
+ });
461
+ shell.setFooterCenter(
462
+ `{${C.dimV}-fg}serve running{/} {${C.blue}-fg}${serverUrl}{/}`
463
+ );
464
+ }
465
+
466
+ async function cmdStop(shell, serveState) {
467
+ if (!serveState.server) {
468
+ shell.log(`{${C.peach}-fg}!{/} No running server.`);
469
+ return;
470
+ }
471
+ const url = serveState.url;
472
+ const server = serveState.server;
473
+ serveState.suppressCloseLog = true;
474
+ serveState.server = null;
475
+ serveState.url = '';
476
+ shell.setFooterCenter('');
477
+ await closeServer(server);
478
+ shell.log(`{${C.mint}-fg}✓{/} Serve stopped {${C.dimV}-fg}${url}{/}`);
479
+ }
480
+
481
+ async function cmdCommitLast(root, shell) {
482
+ let manifest;
483
+ try {
484
+ manifest = await loadLastRunManifest(root);
485
+ } catch {
486
+ shell.log(`{${C.peach}-fg}!{/} No usable latest run found in .singleton/runs/latest.`);
487
+ return;
488
+ }
489
+
490
+ let securityConfig;
491
+ try {
492
+ securityConfig = await loadProjectSecurityConfig(root);
493
+ } catch (err) {
494
+ shell.log(`{${C.salmon}-fg}✕{/} ${err.message}`);
495
+ return;
496
+ }
497
+
498
+ const excluded = [];
499
+ const files = (Array.isArray(manifest.deliverables) ? manifest.deliverables : []).filter((file) => {
500
+ const excludedBy = securityConfig.commit.excludePaths.find((pattern) => matchesCommitExclude(file.path, pattern));
501
+ if (excludedBy) {
502
+ excluded.push({ file, excludedBy });
503
+ return false;
504
+ }
505
+ return true;
506
+ });
507
+ if (files.length === 0) {
508
+ shell.log(`{${C.peach}-fg}!{/} The last run produced no deliverables to commit.`);
509
+ if (excluded.length) {
510
+ shell.log(`{${C.dimV}-fg}All deliverables were excluded by .singleton/security.json commit rules.{/}`);
511
+ }
512
+ return;
513
+ }
514
+
515
+ try {
516
+ await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd: root });
517
+ } catch {
518
+ shell.log(`{${C.salmon}-fg}✕{/} This project is not inside a Git repository.`);
519
+ return;
520
+ }
521
+
522
+ shell.log(`{bold}Commit last preview{/} {${C.dimV}-fg}${manifest.pipeline || 'unknown pipeline'}{/}`);
523
+ shell.log('');
524
+ shell.log(`{${C.blue}-fg}Files to stage{/}`);
525
+ for (const file of files) shell.log(` {${C.dimV}-fg}·{/} ${file.path}`);
526
+ if (excluded.length) {
527
+ shell.log('');
528
+ shell.log(`{${C.peach}-fg}Excluded by security config{/}`);
529
+ for (const { file, excludedBy } of excluded) shell.log(` {${C.dimV}-fg}·{/} ${file.path} {${C.dimV}-fg}(${excludedBy}){/}`);
530
+ }
531
+ shell.log('');
532
+ shell.log(`{${C.dimV}-fg}.singleton artifacts are not committed by /commit-last.{/}`);
533
+ shell.log('');
534
+
535
+ if (securityConfig.commit.requireConfirmation) {
536
+ const confirmation = (await shell.prompt('Stage and commit these files? (y/N)')).trim().toLowerCase();
537
+ if (confirmation !== 'y' && confirmation !== 'yes') {
538
+ shell.log(`{${C.peach}-fg}!{/} Commit cancelled.`);
539
+ return;
540
+ }
541
+ }
542
+
543
+ const defaultMessage = `Update files from ${manifest.pipeline || 'last pipeline'}`;
544
+ const message = (await shell.prompt(`Commit message (default: ${defaultMessage})`)).trim() || defaultMessage;
545
+
546
+ const relFiles = files.map((file) => file.path);
547
+ await runCommand('git', ['add', '--', ...relFiles], { cwd: root });
548
+ await runCommand('git', ['commit', '-m', message], { cwd: root });
549
+
550
+ shell.log(`{${C.mint}-fg}✓{/} Commit created: {${C.dimV}-fg}${message}{/}`);
551
+ }