sidecar-cli 0.1.5-beta.1 → 0.1.5-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -12,6 +12,8 @@ import { SidecarError } from './lib/errors.js';
12
12
  import { GLOBAL_INSTRUCTIONS_DIR, resolveInstructionsSource } from './lib/instructions.js';
13
13
  import { jsonFailure, jsonSuccess, printJsonEnvelope } from './lib/output.js';
14
14
  import { bannerDisabled, renderBanner } from './lib/banner.js';
15
+ import { c } from './lib/color.js';
16
+ import { renderTable } from './lib/table.js';
15
17
  import { getUpdateNotice } from './lib/update-check.js';
16
18
  import { ensureUiInstalled, launchUiServer } from './lib/ui.js';
17
19
  import { requireInitialized } from './db/client.js';
@@ -22,6 +24,11 @@ import { getCapabilitiesManifest } from './services/capabilities-service.js';
22
24
  import { addArtifact, listArtifacts } from './services/artifact-service.js';
23
25
  import { addDecision, addNote, addWorklog, getActiveSessionId, listRecentEvents } from './services/event-service.js';
24
26
  import { currentSession, endSession, startSession, verifySessionHygiene } from './services/session-service.js';
27
+ import { HOOK_EVENTS, handleHookEvent, hookEventSchema, hookPayloadSchema } from './services/hook-service.js';
28
+ import { loadPromptSpec } from './prompts/prompt-spec.js';
29
+ import { compileSections } from './prompts/sections.js';
30
+ import { loadPromptPreferences } from './runners/config.js';
31
+ import { renderClaudeCodeHooksJson } from './templates/hooks.js';
25
32
  import { eventIngestSchema, ingestEvent } from './services/event-ingest-service.js';
26
33
  import { buildExportJson, buildExportJsonlEvents, writeOutputFile } from './services/export-service.js';
27
34
  import { createTaskPacketRecord, getTaskPacket, listTaskPackets } from './tasks/task-service.js';
@@ -29,7 +36,7 @@ import { taskPacketPrioritySchema, taskPacketStatusSchema, taskPacketTypeSchema
29
36
  import { getRunRecord, listRunRecords, listRunRecordsForTask } from './runs/run-service.js';
30
37
  import { runStatusSchema, runnerTypeSchema } from './runs/run-record.js';
31
38
  import { compileTaskPrompt } from './prompts/prompt-service.js';
32
- import { runTaskExecution } from './services/run-orchestrator-service.js';
39
+ import { runPipelineExecution, runTaskExecution } from './services/run-orchestrator-service.js';
33
40
  import { loadRunnerPreferences } from './runners/config.js';
34
41
  import { assignTask, queueReadyTasks } from './services/task-orchestration-service.js';
35
42
  import { buildReviewSummary, createFollowupTaskFromRun, reviewRun } from './services/run-review-service.js';
@@ -41,6 +48,20 @@ const runListStatusSchema = runStatusSchema.or(z.literal('all'));
41
48
  const agentRoleSchema = z.enum(['planner', 'builder-ui', 'builder-app', 'reviewer', 'tester']);
42
49
  const exportFormatSchema = z.enum(['json', 'jsonl']);
43
50
  const NOT_INITIALIZED_MSG = 'Sidecar is not initialized in this directory or any parent directory';
51
+ function formatStatus(value) {
52
+ const v = value.toLowerCase();
53
+ if (v === 'ready' || v === 'draft')
54
+ return c.cyan(value);
55
+ if (v === 'running' || v === 'queued')
56
+ return c.yellow(value);
57
+ if (v === 'review')
58
+ return c.magenta(value);
59
+ if (v === 'blocked')
60
+ return c.red(value);
61
+ if (v === 'done' || v === 'merged' || v === 'approved')
62
+ return c.green(value);
63
+ return value;
64
+ }
44
65
  function fail(message) {
45
66
  throw new SidecarError(message);
46
67
  }
@@ -60,7 +81,7 @@ function handleCommandError(command, asJson, err) {
60
81
  printJsonEnvelope(jsonFailure(command, message));
61
82
  }
62
83
  else {
63
- console.error(message);
84
+ console.error(`${c.red(c.bold('Error:'))} ${message}`);
64
85
  }
65
86
  process.exit(err instanceof SidecarError ? err.exitCode : 1);
66
87
  }
@@ -73,17 +94,6 @@ function respondSuccess(command, asJson, data, lines = []) {
73
94
  console.log(line);
74
95
  }
75
96
  }
76
- function renderInitBanner() {
77
- return [
78
- ' [■]─[▪]',
79
- ' ███████╗██╗██████╗ ███████╗ ██████╗ █████╗ ██████╗',
80
- ' ██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗',
81
- ' ███████╗██║██║ ██║█████╗ ██║ ███████║██████╔╝',
82
- ' ╚════██║██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗',
83
- ' ███████║██║██████╔╝███████╗╚██████╗██║ ██║██║ ██║',
84
- ' ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝',
85
- ].join('\n');
86
- }
87
97
  function summaryWasRefreshedRecently(db, projectId) {
88
98
  return Boolean(db
89
99
  .prepare(`SELECT id FROM events WHERE project_id = ? AND type = 'summary_generated' AND created_at >= datetime('now', '-3 day') LIMIT 1`)
@@ -223,8 +233,58 @@ async function askWithDefault(rl, question, fallback) {
223
233
  return fallback ?? '';
224
234
  }
225
235
  const program = new Command();
226
- program.name('sidecar').description('Local-first project memory and recording CLI').version(pkg.version);
236
+ program
237
+ .name('sidecar')
238
+ .description('Local-first project memory and agent runner. Two namespaces:\n' +
239
+ ' sidecar log <memory-cmd> (worklog, decision, note, recent, context, summary, session, event, artifact)\n' +
240
+ ' sidecar work <runner-cmd> (task, run, prompt, hooks)\n' +
241
+ 'The underlying verbs also work directly (e.g. `sidecar worklog record`), so existing scripts keep working.')
242
+ .version(pkg.version);
227
243
  program.option('--no-banner', 'Disable Sidecar banner output');
244
+ const LOG_NAMESPACE_MEMBERS = [
245
+ 'worklog',
246
+ 'decision',
247
+ 'note',
248
+ 'recent',
249
+ 'context',
250
+ 'summary',
251
+ 'session',
252
+ 'event',
253
+ 'artifact',
254
+ ];
255
+ const WORK_NAMESPACE_MEMBERS = ['task', 'run', 'prompt', 'hooks'];
256
+ // Approach C for the namespace split (memory vs runner): NEW top-level groups
257
+ // `log` and `work` that proxy to the existing verbs by rewriting argv before
258
+ // commander parses it. Existing verbs remain registered as-is, so scripts,
259
+ // agents, and CLAUDE.md files in the wild keep working — the new namespaces
260
+ // just add a clean positioning surface on top.
261
+ function rewriteNamespaceArgv(argv) {
262
+ const out = [...argv];
263
+ if (out.length < 4)
264
+ return out;
265
+ const ns = out[2];
266
+ const next = out[3];
267
+ if (ns === 'log' && LOG_NAMESPACE_MEMBERS.includes(next)) {
268
+ out.splice(2, 1);
269
+ return out;
270
+ }
271
+ if (ns === 'work' && WORK_NAMESPACE_MEMBERS.includes(next)) {
272
+ out.splice(2, 1);
273
+ return out;
274
+ }
275
+ return out;
276
+ }
277
+ function printNamespaceHelp(kind) {
278
+ const members = kind === 'log' ? LOG_NAMESPACE_MEMBERS : WORK_NAMESPACE_MEMBERS;
279
+ const heading = kind === 'log' ? 'Memory commands' : 'Runner commands';
280
+ console.log(`${heading} — use ${c.cyan(`sidecar ${kind} <command> …`)} or the verb directly.`);
281
+ console.log('');
282
+ for (const m of members) {
283
+ console.log(` ${c.cyan(`sidecar ${kind} ${m}`)} → ${c.dim(`sidecar ${m}`)}`);
284
+ }
285
+ console.log('');
286
+ console.log(`Run ${c.cyan(`sidecar <command> --help`)} for details on any verb.`);
287
+ }
228
288
  function maybePrintUpdateNotice() {
229
289
  const jsonRequested = process.argv.includes('--json');
230
290
  const notice = getUpdateNotice({
@@ -236,8 +296,8 @@ function maybePrintUpdateNotice() {
236
296
  return;
237
297
  const installTag = notice.channel === 'latest' ? 'latest' : notice.channel;
238
298
  console.log('');
239
- console.log(`Update available: ${pkg.version} -> ${notice.latestVersion}`);
240
- console.log(`Run: npm install -g sidecar-cli@${installTag}`);
299
+ console.log(c.yellow(`Update available: ${pkg.version} -> ${notice.latestVersion}`));
300
+ console.log(`Run: ${c.cyan(`npm install -g sidecar-cli@${installTag}`)}`);
241
301
  }
242
302
  program
243
303
  .command('ui')
@@ -261,13 +321,16 @@ program
261
321
  console.log('');
262
322
  }
263
323
  console.log('Launching Sidecar UI');
264
- console.log(`Project: ${projectRoot}`);
324
+ // Format info with aligned labels
325
+ const projectLabel = 'Project:'.padEnd(15);
326
+ const versionLabel = 'UI version:'.padEnd(15);
327
+ console.log(` ${projectLabel}${projectRoot}`);
265
328
  const { installedVersion } = ensureUiInstalled({
266
329
  cliVersion: pkg.version,
267
330
  reinstall: Boolean(opts.reinstall),
268
- onStatus: (line) => console.log(line),
331
+ onStatus: (line) => console.log(` … ${line}`),
269
332
  });
270
- console.log(`UI version: ${installedVersion}`);
333
+ console.log(` ${versionLabel}${installedVersion}`);
271
334
  if (opts.installOnly) {
272
335
  console.log('Install-only mode complete.');
273
336
  return;
@@ -277,9 +340,11 @@ program
277
340
  port,
278
341
  openBrowser: opts.open !== false,
279
342
  });
280
- console.log(`URL: ${url}`);
343
+ console.log('');
344
+ const openLabel = 'Open:'.padEnd(15);
345
+ console.log(` ${openLabel}${c.cyan(url)}`);
281
346
  if (opts.open === false) {
282
- console.log('Browser auto-open disabled.');
347
+ console.log(' Browser auto-open disabled.');
283
348
  }
284
349
  }
285
350
  catch (err) {
@@ -408,7 +473,7 @@ program
408
473
  };
409
474
  const shouldShowBanner = !opts.json && !bannerDisabled();
410
475
  if (shouldShowBanner) {
411
- console.log(renderInitBanner());
476
+ console.log(renderBanner('block'));
412
477
  console.log('');
413
478
  }
414
479
  respondSuccess(command, Boolean(opts.json), data, [
@@ -422,6 +487,158 @@ program
422
487
  handleCommandError(command, Boolean(opts.json), err);
423
488
  }
424
489
  });
490
+ program
491
+ .command('demo')
492
+ .description('One-shot walkthrough in a temp dir: init → task → prompt → worklog → decision → summary')
493
+ .option('--cleanup', 'Delete the demo directory when done (default: keep so you can inspect the files)')
494
+ .option('--json', 'Print machine-readable JSON output')
495
+ .addHelpText('after', '\nExamples:\n $ sidecar demo\n $ sidecar demo --cleanup\n $ sidecar demo --json')
496
+ .action(async (opts) => {
497
+ const command = 'demo';
498
+ const os = await import('node:os');
499
+ const asJson = Boolean(opts.json);
500
+ const previousCwd = process.cwd();
501
+ const demoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sidecar-demo-'));
502
+ const log = asJson ? () => { } : (line = '') => console.log(line);
503
+ const step = (n, title) => log(`\n${c.bold(c.cyan(`[${n}] ${title}`))}`);
504
+ try {
505
+ process.chdir(demoRoot);
506
+ log(c.bold('Sidecar demo'));
507
+ log(c.dim(`Sandbox: ${demoRoot}`));
508
+ log(c.dim('Nothing below modifies your current project.'));
509
+ step(1, 'Initialize (.sidecar/, config, DB)');
510
+ const sidecar = getSidecarPaths(demoRoot);
511
+ fs.mkdirSync(sidecar.sidecarPath, { recursive: true });
512
+ fs.mkdirSync(sidecar.tasksPath, { recursive: true });
513
+ fs.mkdirSync(sidecar.runsPath, { recursive: true });
514
+ fs.mkdirSync(sidecar.promptsPath, { recursive: true });
515
+ const ts = nowIso();
516
+ const projectName = 'demo-project';
517
+ {
518
+ const db = new DatabaseSync(sidecar.dbPath);
519
+ initializeSchema(db);
520
+ db.prepare(`DELETE FROM projects`).run();
521
+ db.prepare(`INSERT INTO projects (name, root_path, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(projectName, demoRoot, ts, ts);
522
+ db.close();
523
+ }
524
+ const config = {
525
+ schemaVersion: 1,
526
+ project: { name: projectName, rootPath: demoRoot, createdAt: ts },
527
+ defaults: { summary: { recentLimit: 10 } },
528
+ settings: {},
529
+ };
530
+ fs.writeFileSync(sidecar.configPath, stringifyJson(config));
531
+ fs.writeFileSync(sidecar.preferencesPath, stringifyJson({
532
+ summary: { format: 'markdown', recentLimit: 8 },
533
+ output: { humanTime: true },
534
+ runner: {
535
+ defaultRunner: 'codex',
536
+ preferredRunners: ['codex', 'claude'],
537
+ defaultAgentRole: 'builder-app',
538
+ },
539
+ }));
540
+ fs.writeFileSync(sidecar.agentsPath, renderAgentsMarkdown(projectName));
541
+ log(c.green(' ✓ ') + 'Sidecar initialized');
542
+ log(c.dim(` try: sidecar status`));
543
+ step(2, 'Create a sample task packet');
544
+ const created = createTaskPacketRecord(demoRoot, {
545
+ title: 'Add welcome banner',
546
+ summary: 'Show a friendly greeting on first launch so new users know the tool is working.',
547
+ goal: 'Render a configurable banner at the top of the home page.',
548
+ type: 'feature',
549
+ status: 'ready',
550
+ priority: 'medium',
551
+ scope_in_scope: ['Render banner component', 'Wire to /home route'],
552
+ scope_out_of_scope: ['Dismissal persistence'],
553
+ files_to_read: ['src/pages/home.tsx'],
554
+ definition_of_done: ['Banner visible on load', 'No layout shift'],
555
+ validation_commands: ['typecheck@30s:tsc --noEmit', 'test@2m:npm test'],
556
+ });
557
+ log(c.green(' ✓ ') + `${created.task.task_id} — ${created.task.title}`);
558
+ log(c.dim(` packet: ${path.relative(demoRoot, created.path)}`));
559
+ log(c.dim(` try: sidecar task show ${created.task.task_id}`));
560
+ step(3, 'Compile a prompt (no runner spawn)');
561
+ const compiled = compileTaskPrompt({
562
+ rootPath: demoRoot,
563
+ taskId: created.task.task_id,
564
+ runner: 'codex',
565
+ agentRole: 'builder-app',
566
+ });
567
+ const promptPreview = compiled.prompt_markdown
568
+ .split('\n')
569
+ .slice(0, 10)
570
+ .map((l) => ' ' + l)
571
+ .join('\n');
572
+ log(c.green(' ✓ ') + `${compiled.run_id} compiled to ${path.relative(demoRoot, compiled.prompt_path)}`);
573
+ log(c.dim(` tokens: ${compiled.prompt_optimization.estimated_tokens_before} → ${compiled.prompt_optimization.estimated_tokens_after} (budget ${compiled.prompt_optimization.budget_target})`));
574
+ log(c.dim(' preview:'));
575
+ log(c.dim(promptPreview));
576
+ log(c.dim(` try: sidecar run-exec ${created.task.task_id} --dry-run`));
577
+ step(4, 'Record a worklog + decision');
578
+ const db = new DatabaseSync(sidecar.dbPath);
579
+ const row = db.prepare(`SELECT id FROM projects LIMIT 1`).get();
580
+ const projectId = row.id;
581
+ addWorklog(db, {
582
+ projectId,
583
+ goal: 'welcome banner',
584
+ done: 'Scaffolded banner component and wired the home route.',
585
+ files: 'src/pages/home.tsx,src/components/Banner.tsx',
586
+ by: 'agent',
587
+ });
588
+ addDecision(db, {
589
+ projectId,
590
+ title: 'Use inline SVG for the banner icon',
591
+ summary: 'Avoids an extra network request on first load; matches existing icon patterns.',
592
+ by: 'agent',
593
+ });
594
+ log(c.green(' ✓ ') + 'worklog + decision recorded');
595
+ log(c.dim(' try: sidecar worklog list | sidecar decision list'));
596
+ step(5, 'Refresh summary + show context');
597
+ const refreshed = refreshSummaryFile(db, demoRoot, projectId, 10);
598
+ const ctx = buildContext(db, { projectId, limit: 10 });
599
+ db.close();
600
+ log(c.green(' ✓ ') + `summary.md refreshed (${refreshed.generatedAt})`);
601
+ log(c.dim(` path: ${path.relative(demoRoot, sidecar.summaryPath)}`));
602
+ log(c.dim(` worklogs: ${ctx.recentWorklogs.length}, decisions: ${ctx.recentDecisions.length}, open tasks: ${ctx.openTasks.length}`));
603
+ log(c.dim(' try: sidecar context --format markdown'));
604
+ log('');
605
+ log(c.bold('Done.'));
606
+ if (opts.cleanup) {
607
+ process.chdir(previousCwd);
608
+ fs.rmSync(demoRoot, { recursive: true, force: true });
609
+ log(c.dim('Sandbox cleaned up.'));
610
+ }
611
+ else {
612
+ log(`Sandbox kept at ${c.cyan(demoRoot)} — poke around, then ${c.dim('rm -rf')} when done.`);
613
+ }
614
+ const data = {
615
+ demo_root: demoRoot,
616
+ task_id: created.task.task_id,
617
+ run_id: compiled.run_id,
618
+ prompt_path: compiled.prompt_path,
619
+ cleaned_up: Boolean(opts.cleanup),
620
+ };
621
+ if (asJson) {
622
+ process.chdir(previousCwd);
623
+ printJsonEnvelope(jsonSuccess(command, data));
624
+ }
625
+ }
626
+ catch (err) {
627
+ try {
628
+ process.chdir(previousCwd);
629
+ }
630
+ catch { /* ignore */ }
631
+ handleCommandError(command, asJson, err);
632
+ }
633
+ finally {
634
+ if (process.cwd() !== previousCwd) {
635
+ try {
636
+ process.chdir(previousCwd);
637
+ }
638
+ catch { /* ignore */ }
639
+ }
640
+ }
641
+ });
425
642
  program
426
643
  .command('status')
427
644
  .description('Show Sidecar status and recent project activity')
@@ -666,14 +883,24 @@ program
666
883
  if (opts.json)
667
884
  printJsonEnvelope(jsonSuccess(command, { events: rows }));
668
885
  else {
669
- if (rows.length === 0) {
886
+ const typed = rows;
887
+ if (typed.length === 0) {
670
888
  console.log('No events found.');
671
889
  return;
672
890
  }
673
- for (const row of rows) {
674
- console.log(`#${row.id} ${humanTime(row.created_at)} | ${row.type} | ${row.title}`);
675
- console.log(` ${row.summary}`);
676
- }
891
+ renderTable([
892
+ { key: 'id', label: 'ID', align: 'right' },
893
+ { key: 'when', label: 'When' },
894
+ { key: 'type', label: 'Type' },
895
+ { key: 'title', label: 'Title', maxWidth: 40 },
896
+ { key: 'summary', label: 'Summary', maxWidth: 60 },
897
+ ], typed.map((row) => ({
898
+ id: `#${row.id}`,
899
+ when: humanTime(row.created_at),
900
+ type: row.type,
901
+ title: row.title ?? '',
902
+ summary: row.summary ?? '',
903
+ })));
677
904
  }
678
905
  }
679
906
  catch (err) {
@@ -790,7 +1017,7 @@ task
790
1017
  .option('--files-avoid <paths>', 'Comma-separated files to avoid')
791
1018
  .option('--constraint-tech <items>', 'Comma-separated technical constraints')
792
1019
  .option('--constraint-design <items>', 'Comma-separated design constraints')
793
- .option('--validate-cmds <commands>', 'Comma-separated validation commands')
1020
+ .option('--validate-cmds <commands>', 'Comma-separated validation commands. Use "kind:command" to tag (typecheck|lint|test|build|custom), e.g. "typecheck:tsc --noEmit,test:npm test". Append "@30s" / "@2m" / "@1500ms" to the kind to override the timeout, e.g. "test@2m:npm test".')
794
1021
  .option('--dod <items>', 'Comma-separated definition-of-done checks')
795
1022
  .option('--branch <name>', 'Branch name')
796
1023
  .option('--worktree <path>', 'Worktree path')
@@ -888,13 +1115,18 @@ task
888
1115
  console.log('No tasks found.');
889
1116
  return;
890
1117
  }
891
- const idWidth = Math.max(6, ...rows.map((r) => r.task_id.length));
892
- const statusWidth = Math.max(11, ...rows.map((r) => r.status.length));
893
- const priorityWidth = Math.max(8, ...rows.map((r) => r.priority.length));
894
- console.log(`${'TASK ID'.padEnd(idWidth)} ${'STATUS'.padEnd(statusWidth)} ${'PRIORITY'.padEnd(priorityWidth)} TITLE`);
895
- for (const row of rows) {
896
- console.log(`${row.task_id.padEnd(idWidth)} ${row.status.padEnd(statusWidth)} ${row.priority.padEnd(priorityWidth)} ${row.title}`);
897
- }
1118
+ const tableRows = rows.map((r) => ({
1119
+ task_id: r.task_id,
1120
+ status: r.status,
1121
+ priority: r.priority,
1122
+ title: r.title,
1123
+ }));
1124
+ renderTable([
1125
+ { key: 'task_id', label: 'TASK ID', minWidth: 6 },
1126
+ { key: 'status', label: 'STATUS', minWidth: 8, format: formatStatus },
1127
+ { key: 'priority', label: 'PRIORITY', minWidth: 8 },
1128
+ { key: 'title', label: 'TITLE', maxWidth: 60 },
1129
+ ], tableRows);
898
1130
  }
899
1131
  catch (err) {
900
1132
  handleCommandError(command, Boolean(opts.json), err);
@@ -946,23 +1178,131 @@ task
946
1178
  }
947
1179
  });
948
1180
  const prompt = program.command('prompt').description('Prompt compilation commands');
1181
+ function parseSectionPolicy(raw) {
1182
+ if (!raw)
1183
+ return undefined;
1184
+ const out = {};
1185
+ for (const pair of raw.split(',')) {
1186
+ const [id, policy] = pair.split('=').map((s) => s.trim());
1187
+ if (!id || !policy)
1188
+ continue;
1189
+ if (policy !== 'keep' && policy !== 'trim-last' && policy !== 'drop') {
1190
+ fail(`Invalid policy for ${id}: ${policy} (expected keep|trim-last|drop)`);
1191
+ }
1192
+ out[id] = policy;
1193
+ }
1194
+ return Object.keys(out).length > 0 ? out : undefined;
1195
+ }
1196
+ function isSpecFileTarget(target) {
1197
+ const lower = target.toLowerCase();
1198
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.json'))
1199
+ return true;
1200
+ if (target.includes('/') || target.startsWith('.'))
1201
+ return true;
1202
+ if (fs.existsSync(target))
1203
+ return true;
1204
+ return false;
1205
+ }
949
1206
  prompt
950
- .command('compile <task-id>')
951
- .description('Compile a markdown execution brief from a task packet')
952
- .requiredOption('--runner <runner>', 'codex|claude')
953
- .requiredOption('--agent-role <role>', 'Agent role, for example builder')
1207
+ .command('compile <task-or-file>')
1208
+ .description('Compile a markdown execution brief from a task id OR a freestanding prompt spec file (.yaml|.yml|.json)')
1209
+ .option('--runner <runner>', 'codex|claude (task-id mode only)')
1210
+ .option('--agent-role <role>', 'Agent role, for example builder (task-id mode only)')
954
1211
  .option('--preview', 'Print compiled prompt content after writing file')
1212
+ .option('--budget <tokens>', 'Override target budget (spec-file mode)', (v) => Number.parseInt(v, 10))
1213
+ .option('--budget-max <tokens>', 'Override ceiling budget (spec-file mode)', (v) => Number.parseInt(v, 10))
1214
+ .option('--section-policy <id=policy,...>', 'Per-section policy overrides (keep|trim-last|drop), spec-file mode')
1215
+ .option('--explain', 'Print a per-section trace of what got kept, trimmed, or dropped')
1216
+ .option('-o, --out <path>', 'Write compiled markdown to this path (spec-file mode; default prints to stdout)')
1217
+ .option('--format <format>', 'markdown|json (spec-file mode; default markdown)', 'markdown')
955
1218
  .option('--json', 'Print machine-readable JSON output')
956
- .addHelpText('after', '\nExamples:\n $ sidecar prompt compile T-001 --runner codex --agent-role builder\n $ sidecar prompt compile T-001 --runner claude --agent-role builder --preview\n $ sidecar prompt compile T-001 --runner codex --agent-role reviewer --json')
957
- .action((taskIdText, opts) => {
1219
+ .addHelpText('after', '\nExamples:\n' +
1220
+ ' $ sidecar prompt compile T-001 --runner codex --agent-role builder\n' +
1221
+ ' $ sidecar prompt compile T-001 --runner claude --agent-role builder --preview\n' +
1222
+ ' $ sidecar prompt compile ./prompt.yaml\n' +
1223
+ ' $ sidecar prompt compile prompt.yaml --budget 2000 --explain\n' +
1224
+ ' $ sidecar prompt compile prompt.yaml --section-policy notes=drop,decisions=keep -o out.md\n' +
1225
+ '\nSpec schema (YAML or JSON):\n' +
1226
+ ' header: ["# Title", "..."]\n' +
1227
+ ' sections:\n' +
1228
+ ' - id: objective\n' +
1229
+ ' title: Objective\n' +
1230
+ ' content: "What to build"\n' +
1231
+ ' required: true\n' +
1232
+ ' - id: scope\n' +
1233
+ ' title: In scope\n' +
1234
+ ' list: ["...", "..."]\n' +
1235
+ ' trim: { policy: trim-last, limit: 8, limit_strict: 3, overflow_label: "in-scope items" }\n' +
1236
+ ' budget: { target: 1200, max: 1500 }\n')
1237
+ .action((target, opts) => {
958
1238
  const command = 'prompt compile';
959
1239
  try {
1240
+ const targetText = String(target).trim();
1241
+ const usingSpec = isSpecFileTarget(targetText);
1242
+ if (usingSpec) {
1243
+ const rootPath = resolveProjectRoot();
1244
+ const pref = loadPromptPreferences(rootPath);
1245
+ const { spec, input } = loadPromptSpec(targetText);
1246
+ const target_budget = Number.isFinite(opts.budget) ? Number(opts.budget) : spec.budget?.target ?? pref.budget_target;
1247
+ const max_budget = Number.isFinite(opts.budgetMax)
1248
+ ? Number(opts.budgetMax)
1249
+ : spec.budget?.max ?? Math.max(target_budget, pref.budget_max);
1250
+ const policyOverrides = {
1251
+ ...(input.policy_overrides ?? {}),
1252
+ ...(parseSectionPolicy(opts.sectionPolicy) ?? {}),
1253
+ };
1254
+ const result = compileSections({
1255
+ ...input,
1256
+ budget: { target: target_budget, max: max_budget },
1257
+ ...(Object.keys(policyOverrides).length > 0 ? { policy_overrides: policyOverrides } : {}),
1258
+ });
1259
+ const format = String(opts.format ?? 'markdown').toLowerCase();
1260
+ if (opts.out) {
1261
+ fs.mkdirSync(path.dirname(path.resolve(String(opts.out))), { recursive: true });
1262
+ fs.writeFileSync(path.resolve(String(opts.out)), result.markdown, 'utf8');
1263
+ }
1264
+ if (opts.json || format === 'json') {
1265
+ printJsonEnvelope(jsonSuccess(command, {
1266
+ source: targetText,
1267
+ out_path: opts.out ? path.resolve(String(opts.out)) : null,
1268
+ markdown: result.markdown,
1269
+ metadata: result.metadata,
1270
+ }));
1271
+ return;
1272
+ }
1273
+ if (!opts.out) {
1274
+ process.stdout.write(result.markdown);
1275
+ }
1276
+ else {
1277
+ console.log(`Compiled ${targetText} -> ${path.resolve(String(opts.out))}`);
1278
+ console.log(`Estimate: ${result.metadata.estimated_tokens_before} -> ${result.metadata.estimated_tokens_after} tokens (target ${target_budget}, max ${max_budget})`);
1279
+ }
1280
+ if (opts.explain) {
1281
+ console.error(''); // blank line before explain block when stdout carried markdown
1282
+ console.error('--- explain ---');
1283
+ for (const s of result.metadata.sections) {
1284
+ const status = s.was_dropped ? 'dropped' : s.was_trimmed ? 'trimmed' : 'kept';
1285
+ const counts = s.kind === 'list' ? ` ${s.kept_items ?? 0}/${s.total_items ?? 0}` : '';
1286
+ console.error(` [${status}] ${s.id} (${s.kind}${counts}) — ~${s.estimated_tokens} tokens — policy=${s.policy_applied}`);
1287
+ }
1288
+ if (result.metadata.trimmed_sections.length > 0) {
1289
+ console.error(`trimmed: ${result.metadata.trimmed_sections.join(', ')}`);
1290
+ }
1291
+ if (result.metadata.dropped_sections.length > 0) {
1292
+ console.error(`dropped: ${result.metadata.dropped_sections.join(', ')}`);
1293
+ }
1294
+ }
1295
+ return;
1296
+ }
1297
+ // Task-id mode (legacy path).
960
1298
  const rootPath = resolveProjectRoot();
961
- const taskId = taskIdText.trim().toUpperCase();
1299
+ const taskId = targetText.toUpperCase();
1300
+ if (!opts.runner)
1301
+ fail('--runner is required when compiling a task packet');
1302
+ if (!opts.agentRole)
1303
+ fail('--agent-role is required when compiling a task packet');
962
1304
  const runner = runnerTypeSchema.parse(opts.runner);
963
1305
  const agentRole = String(opts.agentRole ?? '').trim();
964
- if (!agentRole)
965
- fail('Agent role is required');
966
1306
  const compiled = compileTaskPrompt({
967
1307
  rootPath,
968
1308
  taskId,
@@ -995,25 +1335,52 @@ prompt
995
1335
  program
996
1336
  .command('run-exec <task-id>')
997
1337
  .description('Internal command backing `sidecar run <task-id>`')
998
- .option('--runner <runner>', 'codex|claude')
1338
+ .option('--runner <runner>', 'codex|claude — comma-separated for a dual-runner pipeline (e.g. codex,claude)')
999
1339
  .option('--agent-role <role>', 'planner|builder-ui|builder-app|reviewer|tester')
1000
1340
  .option('--dry-run', 'Prepare and compile only without executing external runner')
1001
1341
  .option('--json', 'Print machine-readable JSON output')
1002
- .action((taskIdText, opts) => {
1342
+ .action(async (taskIdText, opts) => {
1003
1343
  const command = 'run';
1004
1344
  try {
1005
1345
  const rootPath = resolveProjectRoot();
1006
1346
  const defaults = loadRunnerPreferences(rootPath);
1007
- const selectedRunner = opts.runner ? runnerTypeSchema.parse(opts.runner) : defaults.default_runner;
1347
+ const runnersRaw = typeof opts.runner === 'string' ? opts.runner : '';
1348
+ const runners = runnersRaw
1349
+ ? runnersRaw
1350
+ .split(',')
1351
+ .map((s) => s.trim())
1352
+ .filter(Boolean)
1353
+ .map((s) => runnerTypeSchema.parse(s))
1354
+ : [defaults.default_runner];
1008
1355
  const selectedAgentRole = opts.agentRole
1009
1356
  ? agentRoleSchema.parse(opts.agentRole)
1010
1357
  : defaults.default_agent_role;
1011
- const result = runTaskExecution({
1358
+ const taskId = String(taskIdText).trim().toUpperCase();
1359
+ if (runners.length > 1) {
1360
+ const pipeline = await runPipelineExecution({
1361
+ rootPath,
1362
+ taskId,
1363
+ runners,
1364
+ agentRole: selectedAgentRole,
1365
+ dryRun: Boolean(opts.dryRun),
1366
+ streamOutput: opts.json ? 'stderr' : 'stdout',
1367
+ });
1368
+ const lines = [
1369
+ `Pipeline ${pipeline.pipeline_id} — ${pipeline.steps.length} runners for ${taskId}.`,
1370
+ ];
1371
+ pipeline.steps.forEach((r, i) => {
1372
+ lines.push(` [${i + 1}/${pipeline.steps.length}] ${r.runner_type} (${r.agent_role}) → ${r.run_id} · ${r.status} · ${(r.duration_ms / 1000).toFixed(1)}s · changed ${r.changed_files.length}`);
1373
+ });
1374
+ respondSuccess(command, Boolean(opts.json), pipeline, lines);
1375
+ return;
1376
+ }
1377
+ const result = await runTaskExecution({
1012
1378
  rootPath,
1013
- taskId: String(taskIdText).trim().toUpperCase(),
1014
- runner: selectedRunner,
1379
+ taskId,
1380
+ runner: runners[0],
1015
1381
  agentRole: selectedAgentRole,
1016
1382
  dryRun: Boolean(opts.dryRun),
1383
+ streamOutput: opts.json ? 'stderr' : 'stdout',
1017
1384
  });
1018
1385
  respondSuccess(command, Boolean(opts.json), result, [
1019
1386
  `Prepared run ${result.run_id} for ${result.task_id}.`,
@@ -1022,6 +1389,9 @@ program
1022
1389
  `Command: ${result.shell_command}`,
1023
1390
  `Status: ${result.status}`,
1024
1391
  `Summary: ${result.summary}`,
1392
+ `Changed files: ${result.changed_files.length}`,
1393
+ `Duration: ${(result.duration_ms / 1000).toFixed(1)}s`,
1394
+ `Log: ${result.log_path ?? 'n/a'}`,
1025
1395
  ]);
1026
1396
  }
1027
1397
  catch (err) {
@@ -1031,7 +1401,7 @@ program
1031
1401
  const run = program
1032
1402
  .command('run')
1033
1403
  .description('Run task execution or inspect run records')
1034
- .addHelpText('after', '\nExamples:\n $ sidecar run T-001 --dry-run\n $ sidecar run T-001 --runner claude --agent-role reviewer\n $ sidecar run queue\n $ sidecar run start-ready --dry-run\n $ sidecar run list --task T-001\n $ sidecar run show R-001');
1404
+ .addHelpText('after', '\nExamples:\n $ sidecar run T-001 --dry-run\n $ sidecar run T-001 --runner claude --agent-role reviewer\n $ sidecar run replay R-010 --edit-prompt\n $ sidecar run replay R-010 --runner claude --reason "second opinion"\n $ sidecar run queue\n $ sidecar run start-ready --dry-run\n $ sidecar run list --task T-001\n $ sidecar run show R-001');
1035
1405
  run
1036
1406
  .command('queue')
1037
1407
  .description('Queue all ready tasks with satisfied dependencies')
@@ -1057,13 +1427,22 @@ run
1057
1427
  .option('--dry-run', 'Prepare and compile only without executing external runners')
1058
1428
  .option('--json', 'Print machine-readable JSON output')
1059
1429
  .addHelpText('after', '\nExamples:\n $ sidecar run start-ready\n $ sidecar run start-ready --dry-run --json')
1060
- .action((opts) => {
1430
+ .action(async (opts) => {
1061
1431
  const command = 'run start-ready';
1062
1432
  try {
1063
1433
  const rootPath = resolveProjectRoot();
1064
1434
  const queueDecisions = queueReadyTasks(rootPath);
1065
1435
  const queuedTasks = listTaskPackets(rootPath).filter((task) => task.status === 'queued');
1066
- const results = queuedTasks.map((task) => runTaskExecution({ rootPath, taskId: task.task_id, dryRun: Boolean(opts.dryRun) }));
1436
+ const results = [];
1437
+ for (const task of queuedTasks) {
1438
+ const result = await runTaskExecution({
1439
+ rootPath,
1440
+ taskId: task.task_id,
1441
+ dryRun: Boolean(opts.dryRun),
1442
+ streamOutput: opts.json ? 'stderr' : 'stdout',
1443
+ });
1444
+ results.push(result);
1445
+ }
1067
1446
  respondSuccess(command, Boolean(opts.json), { queued: queueDecisions, results }, [
1068
1447
  `Queued in this pass: ${queueDecisions.filter((d) => d.queued).length}`,
1069
1448
  `Started: ${results.length}`,
@@ -1074,6 +1453,45 @@ run
1074
1453
  handleCommandError(command, Boolean(opts.json), err);
1075
1454
  }
1076
1455
  });
1456
+ run
1457
+ .command('replay <run-id>')
1458
+ .description('Replay an existing run as a new run on the same task')
1459
+ .option('--runner <runner>', 'Override the runner for the replay (codex|claude)')
1460
+ .option('--agent-role <role>', 'Override the agent role (planner|builder-ui|builder-app|reviewer|tester)')
1461
+ .option('--reason <text>', 'Why you are replaying (stored on the new run)')
1462
+ .option('--edit-prompt', 'Open the compiled prompt in $EDITOR before executing')
1463
+ .option('--dry-run', 'Prepare and compile only without executing external runners')
1464
+ .option('--json', 'Print machine-readable JSON output')
1465
+ .addHelpText('after', '\nExamples:\n $ sidecar run replay R-010\n $ sidecar run replay R-010 --runner claude --reason "second opinion"\n $ sidecar run replay R-010 --edit-prompt\n $ sidecar run replay R-010 --dry-run --json')
1466
+ .action(async (runIdText, opts) => {
1467
+ const command = 'run replay';
1468
+ try {
1469
+ const rootPath = resolveProjectRoot();
1470
+ const parentRunId = String(runIdText).trim().toUpperCase();
1471
+ const parent = getRunRecord(rootPath, parentRunId);
1472
+ const runner = opts.runner ? runnerTypeSchema.parse(opts.runner) : parent.runner_type;
1473
+ const agentRole = opts.agentRole ? agentRoleSchema.parse(opts.agentRole) : parent.agent_role;
1474
+ const result = await runTaskExecution({
1475
+ rootPath,
1476
+ taskId: parent.task_id,
1477
+ runner,
1478
+ agentRole: agentRole,
1479
+ dryRun: Boolean(opts.dryRun),
1480
+ streamOutput: opts.json ? 'stderr' : 'stdout',
1481
+ parentRunId,
1482
+ replayReason: opts.reason ? String(opts.reason) : undefined,
1483
+ editPrompt: Boolean(opts.editPrompt),
1484
+ });
1485
+ respondSuccess(command, Boolean(opts.json), result, [
1486
+ `Replayed ${parentRunId} as ${result.run_id} (${result.status}).`,
1487
+ `Runner: ${result.runner_type} · Role: ${result.agent_role}`,
1488
+ ...(result.summary ? [result.summary] : []),
1489
+ ]);
1490
+ }
1491
+ catch (err) {
1492
+ handleCommandError(command, Boolean(opts.json), err);
1493
+ }
1494
+ });
1077
1495
  run
1078
1496
  .command('approve <run-id>')
1079
1497
  .description('Review a completed run as approved, needs changes, or merged')
@@ -1147,13 +1565,18 @@ run
1147
1565
  console.log('No run records found.');
1148
1566
  return;
1149
1567
  }
1150
- const idWidth = Math.max(6, ...rows.map((r) => r.run_id.length));
1151
- const taskWidth = Math.max(7, ...rows.map((r) => r.task_id.length));
1152
- const statusWidth = Math.max(10, ...rows.map((r) => r.status.length));
1153
- console.log(`${'RUN ID'.padEnd(idWidth)} ${'TASK ID'.padEnd(taskWidth)} ${'STATUS'.padEnd(statusWidth)} STARTED`);
1154
- for (const row of rows) {
1155
- console.log(`${row.run_id.padEnd(idWidth)} ${row.task_id.padEnd(taskWidth)} ${row.status.padEnd(statusWidth)} ${humanTime(row.started_at)}`);
1156
- }
1568
+ const tableRows = rows.map((r) => ({
1569
+ run_id: r.run_id,
1570
+ task_id: r.task_id,
1571
+ status: r.status,
1572
+ started: humanTime(r.started_at),
1573
+ }));
1574
+ renderTable([
1575
+ { key: 'run_id', label: 'RUN ID', minWidth: 6 },
1576
+ { key: 'task_id', label: 'TASK ID', minWidth: 7 },
1577
+ { key: 'status', label: 'STATUS', minWidth: 8, format: formatStatus },
1578
+ { key: 'started', label: 'STARTED', minWidth: 16 },
1579
+ ], tableRows);
1157
1580
  }
1158
1581
  catch (err) {
1159
1582
  handleCommandError(command, Boolean(opts.json), err);
@@ -1195,12 +1618,78 @@ run
1195
1618
  respondSuccess(command, true, { run: runRecord }, []);
1196
1619
  return;
1197
1620
  }
1198
- console.log(stringifyJson(runRecord));
1621
+ printRunHuman(runRecord);
1199
1622
  }
1200
1623
  catch (err) {
1201
1624
  handleCommandError(command, Boolean(opts.json), err);
1202
1625
  }
1203
1626
  });
1627
+ function printRunHuman(run) {
1628
+ console.log(`${c.bold(run.run_id)} — ${run.task_id} [${formatStatus(run.status)}]`);
1629
+ console.log(`Runner: ${run.runner_type} · Role: ${run.agent_role}`);
1630
+ console.log(`Started: ${humanTime(run.started_at)}${run.completed_at ? ` · Completed: ${humanTime(run.completed_at)}` : ''}`);
1631
+ if (run.parent_run_id) {
1632
+ const reason = run.replay_reason ? ` — ${run.replay_reason}` : '';
1633
+ console.log(`Replay of: ${c.cyan(run.parent_run_id)}${c.dim(reason)}`);
1634
+ }
1635
+ if (run.summary)
1636
+ console.log(`Summary: ${run.summary}`);
1637
+ // Show child replays if any (rooted lineage is rendered at the start of the tree).
1638
+ try {
1639
+ const rootPath = resolveProjectRoot();
1640
+ const children = listRunRecordsForTask(rootPath, run.task_id).filter((r) => r.parent_run_id === run.run_id);
1641
+ if (children.length > 0) {
1642
+ console.log(`Replayed as: ${children.map((r) => c.cyan(r.run_id)).join(', ')}`);
1643
+ }
1644
+ }
1645
+ catch {
1646
+ // ignore — lineage is a nice-to-have, not critical path
1647
+ }
1648
+ const isAuto = run.reviewed_by === 'sidecar:auto';
1649
+ const reviewLine = isAuto
1650
+ ? `Review: ${formatStatus(run.review_state)} ${c.dim('(auto-approved)')}`
1651
+ : `Review: ${formatStatus(run.review_state)}${run.reviewed_by ? ` by ${run.reviewed_by}` : ''}`;
1652
+ console.log(reviewLine);
1653
+ if (run.review_note)
1654
+ console.log(` Note: ${run.review_note}`);
1655
+ if (run.validation.length > 0) {
1656
+ console.log('');
1657
+ console.log(c.bold('Validation:'));
1658
+ for (const v of run.validation) {
1659
+ const kindLabel = v.name ? `${v.kind}:${v.name}` : v.kind;
1660
+ const badge = v.ok ? c.green('✓ ok') : v.timed_out ? c.red('⏱ timed out') : c.red(`✗ failed (exit ${v.exit_code})`);
1661
+ const duration = `${(v.duration_ms / 1000).toFixed(1)}s`;
1662
+ console.log(` ${c.cyan(`[${kindLabel}]`)} ${badge} ${c.dim(duration)} ${v.command}`);
1663
+ }
1664
+ }
1665
+ else if (run.validation_results.length > 0) {
1666
+ // Legacy pre-kind records — still show them.
1667
+ console.log('');
1668
+ console.log(c.bold('Validation (legacy):'));
1669
+ for (const line of run.validation_results)
1670
+ console.log(` ${line}`);
1671
+ }
1672
+ if (run.changed_files.length > 0) {
1673
+ console.log('');
1674
+ console.log(c.bold(`Changed files (${run.changed_files.length}):`));
1675
+ for (const f of run.changed_files.slice(0, 20))
1676
+ console.log(` ${f}`);
1677
+ if (run.changed_files.length > 20)
1678
+ console.log(c.dim(` … ${run.changed_files.length - 20} more`));
1679
+ }
1680
+ if (run.blockers.length > 0) {
1681
+ console.log('');
1682
+ console.log(c.bold('Blockers:'));
1683
+ for (const b of run.blockers)
1684
+ console.log(` - ${b}`);
1685
+ }
1686
+ if (run.follow_ups.length > 0) {
1687
+ console.log('');
1688
+ console.log(c.bold('Follow-ups:'));
1689
+ for (const f of run.follow_ups)
1690
+ console.log(` - ${f}`);
1691
+ }
1692
+ }
1204
1693
  const session = program.command('session').description('Session commands');
1205
1694
  session
1206
1695
  .command('start')
@@ -1340,13 +1829,22 @@ artifact
1340
1829
  if (opts.json)
1341
1830
  printJsonEnvelope(jsonSuccess(command, { artifacts: rows }));
1342
1831
  else {
1343
- if (rows.length === 0) {
1832
+ const typed = rows;
1833
+ if (typed.length === 0) {
1344
1834
  console.log('No artifacts found.');
1345
1835
  return;
1346
1836
  }
1347
- for (const row of rows) {
1348
- console.log(`#${row.id} ${row.kind} ${row.path}${row.note ? ` - ${row.note}` : ''}`);
1349
- }
1837
+ renderTable([
1838
+ { key: 'id', label: 'ID', align: 'right' },
1839
+ { key: 'kind', label: 'Kind' },
1840
+ { key: 'path', label: 'Path', maxWidth: 60 },
1841
+ { key: 'note', label: 'Note', maxWidth: 40 },
1842
+ ], typed.map((row) => ({
1843
+ id: `#${row.id}`,
1844
+ kind: row.kind,
1845
+ path: row.path,
1846
+ note: row.note ?? '',
1847
+ })));
1350
1848
  }
1351
1849
  }
1352
1850
  catch (err) {
@@ -1361,6 +1859,108 @@ if (process.argv.length === 2) {
1361
1859
  program.outputHelp();
1362
1860
  process.exit(0);
1363
1861
  }
1862
+ const hooks = program.command('hooks').description('Hook integration helpers');
1863
+ hooks
1864
+ .command('print')
1865
+ .description('Print a Claude Code settings.json hooks block wiring ambient capture')
1866
+ .option('--json', 'Print machine-readable JSON output')
1867
+ .addHelpText('after', '\nCopy the output into .claude/settings.json (project) or ~/.claude/settings.json (user). Claude Code merges hook arrays across scopes.')
1868
+ .action((opts) => {
1869
+ const command = 'hooks print';
1870
+ try {
1871
+ const json = renderClaudeCodeHooksJson();
1872
+ if (opts.json) {
1873
+ printJsonEnvelope(jsonSuccess(command, { settings_json: json }));
1874
+ }
1875
+ else {
1876
+ console.log(json);
1877
+ }
1878
+ }
1879
+ catch (err) {
1880
+ handleCommandError(command, Boolean(opts.json), err);
1881
+ }
1882
+ });
1883
+ program
1884
+ .command('hook <event>')
1885
+ .description(`Ambient capture entry point for Claude Code / Codex hooks (event: ${HOOK_EVENTS.join('|')})`)
1886
+ .option('--actor-name <name>', 'Override the session actor_name (default: claude-code[:session])')
1887
+ .option('--json', 'Print machine-readable JSON output')
1888
+ .addHelpText('after', '\nReads an optional JSON payload from stdin. Exit code is always 0 so hooks never block the caller — internal errors go to stderr.\n' +
1889
+ '\nExamples:\n' +
1890
+ ' $ echo \'{"session_id":"abc"}\' | sidecar hook session-start\n' +
1891
+ ' $ echo \'{"tool_name":"Edit","tool_input":{"file_path":"/abs/src/foo.ts"}}\' | sidecar hook file-edit\n' +
1892
+ ' $ sidecar hook session-end')
1893
+ .action(async (eventArg, opts) => {
1894
+ const command = `hook ${eventArg}`;
1895
+ const asJson = Boolean(opts.json);
1896
+ try {
1897
+ const event = hookEventSchema.parse(eventArg);
1898
+ let payload = {};
1899
+ if (!process.stdin.isTTY) {
1900
+ const raw = (await readStdinText()).trim();
1901
+ if (raw.length > 0) {
1902
+ try {
1903
+ payload = hookPayloadSchema.parse(JSON.parse(raw));
1904
+ }
1905
+ catch (parseErr) {
1906
+ const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
1907
+ console.error(`sidecar hook: ignoring malformed payload (${msg})`);
1908
+ }
1909
+ }
1910
+ }
1911
+ const { db, projectId, rootPath } = requireInitialized();
1912
+ const result = handleHookEvent({
1913
+ db,
1914
+ projectId,
1915
+ projectRoot: rootPath,
1916
+ event,
1917
+ payload,
1918
+ ...(opts.actorName ? { actorName: String(opts.actorName) } : {}),
1919
+ });
1920
+ db.close();
1921
+ if (asJson) {
1922
+ printJsonEnvelope(jsonSuccess(command, result));
1923
+ }
1924
+ process.exit(0);
1925
+ }
1926
+ catch (err) {
1927
+ // Hooks must never block the caller — log to stderr and exit 0.
1928
+ const message = err instanceof Error ? err.message : String(err);
1929
+ if (asJson) {
1930
+ printJsonEnvelope(jsonSuccess(command, { ok: true, event: eventArg, action: 'skipped', detail: message }));
1931
+ }
1932
+ else {
1933
+ console.error(`sidecar hook: ${message}`);
1934
+ }
1935
+ process.exit(0);
1936
+ }
1937
+ });
1938
+ program
1939
+ .command('log')
1940
+ .description('Memory namespace (alias group) — see `sidecar log --help`')
1941
+ .allowUnknownOption(true)
1942
+ .allowExcessArguments(true)
1943
+ .action(() => {
1944
+ printNamespaceHelp('log');
1945
+ })
1946
+ .addHelpText('after', `\nMembers:\n${LOG_NAMESPACE_MEMBERS.map((m) => ` sidecar log ${m} → sidecar ${m}`).join('\n')}\n`);
1947
+ program
1948
+ .command('work')
1949
+ .description('Runner namespace (alias group) — see `sidecar work --help`')
1950
+ .allowUnknownOption(true)
1951
+ .allowExcessArguments(true)
1952
+ .action(() => {
1953
+ printNamespaceHelp('work');
1954
+ })
1955
+ .addHelpText('after', `\nMembers:\n${WORK_NAMESPACE_MEMBERS.map((m) => ` sidecar work ${m} → sidecar ${m}`).join('\n')}\n`);
1956
+ // Rewrite `sidecar log <member> …` and `sidecar work <member> …` before
1957
+ // commander sees argv, so the verb's existing subcommand tree handles the call
1958
+ // verbatim (options, help, JSON envelopes, everything).
1959
+ const rewrittenArgv = rewriteNamespaceArgv(process.argv);
1960
+ if (rewrittenArgv !== process.argv) {
1961
+ process.argv.length = 0;
1962
+ process.argv.push(...rewrittenArgv);
1963
+ }
1364
1964
  if (process.argv[2] === 'run' &&
1365
1965
  process.argv[3] &&
1366
1966
  !process.argv[3].startsWith('-') &&
@@ -1370,7 +1970,8 @@ if (process.argv[2] === 'run' &&
1370
1970
  process.argv[3] !== 'start-ready' &&
1371
1971
  process.argv[3] !== 'approve' &&
1372
1972
  process.argv[3] !== 'block' &&
1373
- process.argv[3] !== 'summary') {
1973
+ process.argv[3] !== 'summary' &&
1974
+ process.argv[3] !== 'replay') {
1374
1975
  process.argv.splice(2, 1, 'run-exec');
1375
1976
  }
1376
1977
  program.parse(process.argv);