sidecar-cli 0.1.4 → 0.1.5-beta.2

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, [
@@ -416,11 +481,164 @@ program
416
481
  'Documentation: https://usesidecar.dev/',
417
482
  ...(resolvedInstructions ? ['', `Loaded instructions.md from ${resolvedInstructions.sourceLabel}`] : []),
418
483
  ]);
484
+ maybePrintUpdateNotice();
419
485
  }
420
486
  catch (err) {
421
487
  handleCommandError(command, Boolean(opts.json), err);
422
488
  }
423
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
+ });
424
642
  program
425
643
  .command('status')
426
644
  .description('Show Sidecar status and recent project activity')
@@ -665,14 +883,24 @@ program
665
883
  if (opts.json)
666
884
  printJsonEnvelope(jsonSuccess(command, { events: rows }));
667
885
  else {
668
- if (rows.length === 0) {
886
+ const typed = rows;
887
+ if (typed.length === 0) {
669
888
  console.log('No events found.');
670
889
  return;
671
890
  }
672
- for (const row of rows) {
673
- console.log(`#${row.id} ${humanTime(row.created_at)} | ${row.type} | ${row.title}`);
674
- console.log(` ${row.summary}`);
675
- }
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
+ })));
676
904
  }
677
905
  }
678
906
  catch (err) {
@@ -789,7 +1017,7 @@ task
789
1017
  .option('--files-avoid <paths>', 'Comma-separated files to avoid')
790
1018
  .option('--constraint-tech <items>', 'Comma-separated technical constraints')
791
1019
  .option('--constraint-design <items>', 'Comma-separated design constraints')
792
- .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".')
793
1021
  .option('--dod <items>', 'Comma-separated definition-of-done checks')
794
1022
  .option('--branch <name>', 'Branch name')
795
1023
  .option('--worktree <path>', 'Worktree path')
@@ -887,13 +1115,18 @@ task
887
1115
  console.log('No tasks found.');
888
1116
  return;
889
1117
  }
890
- const idWidth = Math.max(6, ...rows.map((r) => r.task_id.length));
891
- const statusWidth = Math.max(11, ...rows.map((r) => r.status.length));
892
- const priorityWidth = Math.max(8, ...rows.map((r) => r.priority.length));
893
- console.log(`${'TASK ID'.padEnd(idWidth)} ${'STATUS'.padEnd(statusWidth)} ${'PRIORITY'.padEnd(priorityWidth)} TITLE`);
894
- for (const row of rows) {
895
- console.log(`${row.task_id.padEnd(idWidth)} ${row.status.padEnd(statusWidth)} ${row.priority.padEnd(priorityWidth)} ${row.title}`);
896
- }
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);
897
1130
  }
898
1131
  catch (err) {
899
1132
  handleCommandError(command, Boolean(opts.json), err);
@@ -945,23 +1178,131 @@ task
945
1178
  }
946
1179
  });
947
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
+ }
948
1206
  prompt
949
- .command('compile <task-id>')
950
- .description('Compile a markdown execution brief from a task packet')
951
- .requiredOption('--runner <runner>', 'codex|claude')
952
- .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)')
953
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')
954
1218
  .option('--json', 'Print machine-readable JSON output')
955
- .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')
956
- .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) => {
957
1238
  const command = 'prompt compile';
958
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).
959
1298
  const rootPath = resolveProjectRoot();
960
- 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');
961
1304
  const runner = runnerTypeSchema.parse(opts.runner);
962
1305
  const agentRole = String(opts.agentRole ?? '').trim();
963
- if (!agentRole)
964
- fail('Agent role is required');
965
1306
  const compiled = compileTaskPrompt({
966
1307
  rootPath,
967
1308
  taskId,
@@ -974,11 +1315,16 @@ prompt
974
1315
  runner_type: compiled.runner_type,
975
1316
  agent_role: compiled.agent_role,
976
1317
  prompt_path: compiled.prompt_path,
1318
+ prompt_optimization: compiled.prompt_optimization,
977
1319
  preview: opts.preview ? compiled.prompt_markdown : null,
978
1320
  }, [
979
1321
  `Compiled prompt for ${compiled.task_id}.`,
980
1322
  `Run: ${compiled.run_id}`,
981
1323
  `Path: ${compiled.prompt_path}`,
1324
+ `Prompt estimate: ${compiled.prompt_optimization.estimated_tokens_before} -> ${compiled.prompt_optimization.estimated_tokens_after} tokens (target ${compiled.prompt_optimization.budget_target})`,
1325
+ ...(compiled.prompt_optimization.trimmed_sections.length > 0
1326
+ ? [`Trimmed: ${compiled.prompt_optimization.trimmed_sections.join(', ')}`]
1327
+ : []),
982
1328
  ...(opts.preview ? ['', compiled.prompt_markdown] : []),
983
1329
  ]);
984
1330
  }
@@ -989,25 +1335,52 @@ prompt
989
1335
  program
990
1336
  .command('run-exec <task-id>')
991
1337
  .description('Internal command backing `sidecar run <task-id>`')
992
- .option('--runner <runner>', 'codex|claude')
1338
+ .option('--runner <runner>', 'codex|claude — comma-separated for a dual-runner pipeline (e.g. codex,claude)')
993
1339
  .option('--agent-role <role>', 'planner|builder-ui|builder-app|reviewer|tester')
994
1340
  .option('--dry-run', 'Prepare and compile only without executing external runner')
995
1341
  .option('--json', 'Print machine-readable JSON output')
996
- .action((taskIdText, opts) => {
1342
+ .action(async (taskIdText, opts) => {
997
1343
  const command = 'run';
998
1344
  try {
999
1345
  const rootPath = resolveProjectRoot();
1000
1346
  const defaults = loadRunnerPreferences(rootPath);
1001
- 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];
1002
1355
  const selectedAgentRole = opts.agentRole
1003
1356
  ? agentRoleSchema.parse(opts.agentRole)
1004
1357
  : defaults.default_agent_role;
1005
- 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({
1006
1378
  rootPath,
1007
- taskId: String(taskIdText).trim().toUpperCase(),
1008
- runner: selectedRunner,
1379
+ taskId,
1380
+ runner: runners[0],
1009
1381
  agentRole: selectedAgentRole,
1010
1382
  dryRun: Boolean(opts.dryRun),
1383
+ streamOutput: opts.json ? 'stderr' : 'stdout',
1011
1384
  });
1012
1385
  respondSuccess(command, Boolean(opts.json), result, [
1013
1386
  `Prepared run ${result.run_id} for ${result.task_id}.`,
@@ -1016,6 +1389,9 @@ program
1016
1389
  `Command: ${result.shell_command}`,
1017
1390
  `Status: ${result.status}`,
1018
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'}`,
1019
1395
  ]);
1020
1396
  }
1021
1397
  catch (err) {
@@ -1025,7 +1401,7 @@ program
1025
1401
  const run = program
1026
1402
  .command('run')
1027
1403
  .description('Run task execution or inspect run records')
1028
- .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');
1029
1405
  run
1030
1406
  .command('queue')
1031
1407
  .description('Queue all ready tasks with satisfied dependencies')
@@ -1051,13 +1427,22 @@ run
1051
1427
  .option('--dry-run', 'Prepare and compile only without executing external runners')
1052
1428
  .option('--json', 'Print machine-readable JSON output')
1053
1429
  .addHelpText('after', '\nExamples:\n $ sidecar run start-ready\n $ sidecar run start-ready --dry-run --json')
1054
- .action((opts) => {
1430
+ .action(async (opts) => {
1055
1431
  const command = 'run start-ready';
1056
1432
  try {
1057
1433
  const rootPath = resolveProjectRoot();
1058
1434
  const queueDecisions = queueReadyTasks(rootPath);
1059
1435
  const queuedTasks = listTaskPackets(rootPath).filter((task) => task.status === 'queued');
1060
- 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
+ }
1061
1446
  respondSuccess(command, Boolean(opts.json), { queued: queueDecisions, results }, [
1062
1447
  `Queued in this pass: ${queueDecisions.filter((d) => d.queued).length}`,
1063
1448
  `Started: ${results.length}`,
@@ -1068,6 +1453,45 @@ run
1068
1453
  handleCommandError(command, Boolean(opts.json), err);
1069
1454
  }
1070
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
+ });
1071
1495
  run
1072
1496
  .command('approve <run-id>')
1073
1497
  .description('Review a completed run as approved, needs changes, or merged')
@@ -1141,13 +1565,18 @@ run
1141
1565
  console.log('No run records found.');
1142
1566
  return;
1143
1567
  }
1144
- const idWidth = Math.max(6, ...rows.map((r) => r.run_id.length));
1145
- const taskWidth = Math.max(7, ...rows.map((r) => r.task_id.length));
1146
- const statusWidth = Math.max(10, ...rows.map((r) => r.status.length));
1147
- console.log(`${'RUN ID'.padEnd(idWidth)} ${'TASK ID'.padEnd(taskWidth)} ${'STATUS'.padEnd(statusWidth)} STARTED`);
1148
- for (const row of rows) {
1149
- console.log(`${row.run_id.padEnd(idWidth)} ${row.task_id.padEnd(taskWidth)} ${row.status.padEnd(statusWidth)} ${humanTime(row.started_at)}`);
1150
- }
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);
1151
1580
  }
1152
1581
  catch (err) {
1153
1582
  handleCommandError(command, Boolean(opts.json), err);
@@ -1189,12 +1618,78 @@ run
1189
1618
  respondSuccess(command, true, { run: runRecord }, []);
1190
1619
  return;
1191
1620
  }
1192
- console.log(stringifyJson(runRecord));
1621
+ printRunHuman(runRecord);
1193
1622
  }
1194
1623
  catch (err) {
1195
1624
  handleCommandError(command, Boolean(opts.json), err);
1196
1625
  }
1197
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
+ }
1198
1693
  const session = program.command('session').description('Session commands');
1199
1694
  session
1200
1695
  .command('start')
@@ -1334,13 +1829,22 @@ artifact
1334
1829
  if (opts.json)
1335
1830
  printJsonEnvelope(jsonSuccess(command, { artifacts: rows }));
1336
1831
  else {
1337
- if (rows.length === 0) {
1832
+ const typed = rows;
1833
+ if (typed.length === 0) {
1338
1834
  console.log('No artifacts found.');
1339
1835
  return;
1340
1836
  }
1341
- for (const row of rows) {
1342
- console.log(`#${row.id} ${row.kind} ${row.path}${row.note ? ` - ${row.note}` : ''}`);
1343
- }
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
+ })));
1344
1848
  }
1345
1849
  }
1346
1850
  catch (err) {
@@ -1353,9 +1857,110 @@ if (process.argv.length === 2) {
1353
1857
  console.log('');
1354
1858
  }
1355
1859
  program.outputHelp();
1356
- maybePrintUpdateNotice();
1357
1860
  process.exit(0);
1358
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
+ }
1359
1964
  if (process.argv[2] === 'run' &&
1360
1965
  process.argv[3] &&
1361
1966
  !process.argv[3].startsWith('-') &&
@@ -1365,8 +1970,8 @@ if (process.argv[2] === 'run' &&
1365
1970
  process.argv[3] !== 'start-ready' &&
1366
1971
  process.argv[3] !== 'approve' &&
1367
1972
  process.argv[3] !== 'block' &&
1368
- process.argv[3] !== 'summary') {
1973
+ process.argv[3] !== 'summary' &&
1974
+ process.argv[3] !== 'replay') {
1369
1975
  process.argv.splice(2, 1, 'run-exec');
1370
1976
  }
1371
1977
  program.parse(process.argv);
1372
- maybePrintUpdateNotice();