switchman-dev 0.1.6 → 0.1.7

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/src/cli/index.js CHANGED
@@ -19,31 +19,36 @@
19
19
  import { program } from 'commander';
20
20
  import chalk from 'chalk';
21
21
  import ora from 'ora';
22
- import { existsSync } from 'fs';
23
- import { join } from 'path';
22
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
23
+ import { tmpdir } from 'os';
24
+ import { dirname, join, posix } from 'path';
24
25
  import { execSync, spawn } from 'child_process';
25
26
 
26
- import { findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
27
+ import { cleanupCrashedLandingTempWorktrees, findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
28
+ import { matchesPathPatterns } from '../core/ignore.js';
27
29
  import {
28
30
  initDb, openDb,
29
31
  DEFAULT_STALE_LEASE_MINUTES,
30
32
  createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
31
33
  listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
32
- registerWorktree, listWorktrees,
33
- enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
34
- claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
35
- verifyAuditTrail,
34
+ registerWorktree, listWorktrees, updateWorktreeStatus,
35
+ enqueueMergeItem, escalateMergeQueueItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
36
+ markMergeQueueState,
37
+ createPolicyOverride, listPolicyOverrides, revokePolicyOverride,
38
+ finishOperationJournalEntry, listOperationJournal, listTempResources, updateTempResource,
39
+ claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
40
+ listAuditEvents, verifyAuditTrail,
36
41
  } from '../core/db.js';
37
42
  import { scanAllWorktrees } from '../core/detector.js';
38
43
  import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
39
44
  import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
40
45
  import { runAiMergeGate } from '../core/merge-gate.js';
41
46
  import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
42
- import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
43
- import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
47
+ import { buildPipelinePrSummary, cleanupPipelineLandingRecovery, commentPipelinePr, createPipelineFollowupTasks, evaluatePipelinePolicyGate, executePipeline, exportPipelinePrBundle, getPipelineLandingBranchStatus, getPipelineLandingExplainReport, getPipelineStatus, inferPipelineIdFromBranch, materializePipelineLandingBranch, preparePipelineLandingRecovery, preparePipelineLandingTarget, publishPipelinePr, repairPipelineState, resumePipelineLandingRecovery, runPipeline, startPipeline, summarizePipelinePolicyState, syncPipelinePr } from '../core/pipeline.js';
48
+ import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus, writeGitHubPipelineLandingStatus } from '../core/ci.js';
44
49
  import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
45
- import { buildQueueStatusSummary, runMergeQueue } from '../core/queue.js';
46
- import { DEFAULT_LEASE_POLICY, loadLeasePolicy, writeLeasePolicy } from '../core/policy.js';
50
+ import { buildQueueStatusSummary, resolveQueueSource, runMergeQueue } from '../core/queue.js';
51
+ import { DEFAULT_CHANGE_POLICY, DEFAULT_LEASE_POLICY, getChangePolicyPath, loadChangePolicy, loadLeasePolicy, writeChangePolicy, writeLeasePolicy } from '../core/policy.js';
47
52
  import {
48
53
  captureTelemetryEvent,
49
54
  disableTelemetry,
@@ -55,6 +60,17 @@ import {
55
60
  sendTelemetryEvent,
56
61
  } from '../core/telemetry.js';
57
62
 
63
+ const originalProcessEmit = process.emit.bind(process);
64
+ process.emit = function patchedProcessEmit(event, ...args) {
65
+ if (event === 'warning') {
66
+ const [warning] = args;
67
+ if (warning?.name === 'ExperimentalWarning' && warning?.message?.includes('SQLite')) {
68
+ return false;
69
+ }
70
+ }
71
+ return originalProcessEmit(event, ...args);
72
+ };
73
+
58
74
  function installMcpConfig(targetDirs) {
59
75
  return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
60
76
  }
@@ -79,6 +95,54 @@ function getDb(repoRoot) {
79
95
  }
80
96
  }
81
97
 
98
+ function resolvePrNumberFromEnv(env = process.env) {
99
+ if (env.SWITCHMAN_PR_NUMBER) return String(env.SWITCHMAN_PR_NUMBER);
100
+ if (env.GITHUB_PR_NUMBER) return String(env.GITHUB_PR_NUMBER);
101
+
102
+ if (env.GITHUB_EVENT_PATH && existsSync(env.GITHUB_EVENT_PATH)) {
103
+ try {
104
+ const payload = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
105
+ const prNumber = payload.pull_request?.number || payload.issue?.number || null;
106
+ if (prNumber) return String(prNumber);
107
+ } catch {
108
+ // Ignore malformed GitHub event payloads.
109
+ }
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ function resolveBranchFromEnv(env = process.env) {
116
+ return env.SWITCHMAN_BRANCH
117
+ || env.GITHUB_HEAD_REF
118
+ || env.GITHUB_REF_NAME
119
+ || null;
120
+ }
121
+
122
+ function retryStaleTasks(db, { pipelineId = null, reason = 'bulk stale retry' } = {}) {
123
+ const invalidations = listDependencyInvalidations(db, { pipelineId });
124
+ const staleTaskIds = [...new Set(invalidations.map((item) => item.affected_task_id).filter(Boolean))];
125
+ const retried = [];
126
+ const skipped = [];
127
+
128
+ for (const taskId of staleTaskIds) {
129
+ const task = retryTask(db, taskId, reason);
130
+ if (task) {
131
+ retried.push(task);
132
+ } else {
133
+ skipped.push(taskId);
134
+ }
135
+ }
136
+
137
+ return {
138
+ pipeline_id: pipelineId,
139
+ stale_task_ids: staleTaskIds,
140
+ retried,
141
+ skipped,
142
+ invalidation_count: invalidations.length,
143
+ };
144
+ }
145
+
82
146
  function statusBadge(status) {
83
147
  const colors = {
84
148
  pending: chalk.yellow,
@@ -234,227 +298,1415 @@ function printErrorWithNext(message, nextCommand = null) {
234
298
  }
235
299
  }
236
300
 
237
- async function maybeCaptureTelemetry(event, properties = {}, { homeDir = null } = {}) {
238
- try {
239
- await maybePromptForTelemetry({ homeDir: homeDir || undefined });
240
- await captureTelemetryEvent(event, {
241
- app_version: program.version(),
242
- os: process.platform,
243
- node_version: process.version,
244
- ...properties,
245
- }, { homeDir: homeDir || undefined });
246
- } catch {
247
- // Telemetry must never block CLI usage.
248
- }
301
+ function writeDemoFile(filePath, contents) {
302
+ mkdirSync(dirname(filePath), { recursive: true });
303
+ writeFileSync(filePath, contents);
249
304
  }
250
305
 
251
- function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
252
- const dbPath = join(repoRoot, '.switchman', 'switchman.db');
253
- const rootMcpPath = join(repoRoot, '.mcp.json');
254
- const cursorMcpPath = join(repoRoot, '.cursor', 'mcp.json');
255
- const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
256
- const checks = [];
257
- const nextSteps = [];
258
- let workspaces = [];
259
- let db = null;
260
-
261
- const dbExists = existsSync(dbPath);
262
- checks.push({
263
- key: 'database',
264
- ok: dbExists,
265
- label: 'Project database',
266
- detail: dbExists ? '.switchman/switchman.db is ready' : 'Switchman database is missing',
306
+ function gitCommitAll(worktreePath, message) {
307
+ execSync('git add .', {
308
+ cwd: worktreePath,
309
+ stdio: ['ignore', 'pipe', 'pipe'],
267
310
  });
268
- if (!dbExists) {
269
- nextSteps.push('Run `switchman init` or `switchman setup --agents 3` in this repo.');
270
- }
311
+ execSync(`git -c user.email="demo@switchman.dev" -c user.name="Switchman Demo" commit -m ${JSON.stringify(message)}`, {
312
+ cwd: worktreePath,
313
+ stdio: ['ignore', 'pipe', 'pipe'],
314
+ });
315
+ }
271
316
 
272
- if (dbExists) {
317
+ async function runDemoScenario({ repoPath = null, cleanup = false } = {}) {
318
+ const repoDir = repoPath || join(tmpdir(), `switchman-demo-${Date.now()}`);
319
+ mkdirSync(repoDir, { recursive: true });
320
+
321
+ try {
322
+ execSync('git init -b main', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
323
+ execSync('git config user.email "demo@switchman.dev"', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
324
+ execSync('git config user.name "Switchman Demo"', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
325
+
326
+ writeDemoFile(join(repoDir, 'README.md'), '# Switchman demo repo\n');
327
+ writeDemoFile(join(repoDir, 'src', 'index.js'), 'export function ready() {\n return true;\n}\n');
328
+ writeDemoFile(join(repoDir, 'docs', 'overview.md'), '# Demo\n');
329
+ gitCommitAll(repoDir, 'Initial demo repo');
330
+
331
+ const db = initDb(repoDir);
332
+ registerWorktree(db, { name: 'main', path: repoDir, branch: 'main' });
333
+
334
+ const agent1Path = createGitWorktree(repoDir, 'agent1', 'switchman/demo-agent1');
335
+ const agent2Path = createGitWorktree(repoDir, 'agent2', 'switchman/demo-agent2');
336
+ registerWorktree(db, { name: 'agent1', path: agent1Path, branch: 'switchman/demo-agent1' });
337
+ registerWorktree(db, { name: 'agent2', path: agent2Path, branch: 'switchman/demo-agent2' });
338
+
339
+ const taskAuth = createTask(db, {
340
+ id: 'demo-01',
341
+ title: 'Add auth helper',
342
+ priority: 9,
343
+ });
344
+ const taskDocs = createTask(db, {
345
+ id: 'demo-02',
346
+ title: 'Document auth flow',
347
+ priority: 8,
348
+ });
349
+
350
+ const lease1 = startTaskLease(db, taskAuth, 'agent1');
351
+ claimFiles(db, taskAuth, 'agent1', ['src/auth.js']);
352
+
353
+ const lease2 = startTaskLease(db, taskDocs, 'agent2');
354
+ let blockedClaimMessage = null;
273
355
  try {
274
- db = getDb(repoRoot);
275
- workspaces = listWorktrees(db);
276
- } catch {
277
- checks.push({
278
- key: 'database_open',
279
- ok: false,
280
- label: 'Database access',
281
- detail: 'Switchman could not open the project database',
282
- });
283
- nextSteps.push('Re-run `switchman init` if the project database looks corrupted.');
284
- } finally {
285
- try { db?.close(); } catch { /* no-op */ }
356
+ claimFiles(db, taskDocs, 'agent2', ['src/auth.js']);
357
+ } catch (err) {
358
+ blockedClaimMessage = String(err.message || 'Claim blocked.');
286
359
  }
287
- }
360
+ claimFiles(db, taskDocs, 'agent2', ['docs/auth-flow.md']);
288
361
 
289
- const agentWorkspaces = workspaces.filter((entry) => entry.name !== 'main');
290
- const workspaceReady = agentWorkspaces.length > 0;
291
- checks.push({
292
- key: 'workspaces',
293
- ok: workspaceReady,
294
- label: 'Agent workspaces',
295
- detail: workspaceReady
296
- ? `${agentWorkspaces.length} agent workspace(s) registered`
297
- : 'No agent workspaces are registered yet',
298
- });
299
- if (!workspaceReady) {
300
- nextSteps.push('Run `switchman setup --agents 3` to create agent workspaces.');
301
- }
362
+ writeDemoFile(join(agent1Path, 'src', 'auth.js'), 'export function authHeader(token) {\n return `Bearer ${token}`;\n}\n');
363
+ gitCommitAll(agent1Path, 'Add auth helper');
364
+ completeTask(db, taskAuth);
302
365
 
303
- const rootMcpExists = existsSync(rootMcpPath);
304
- checks.push({
305
- key: 'claude_mcp',
306
- ok: rootMcpExists,
307
- label: 'Claude Code MCP',
308
- detail: rootMcpExists ? '.mcp.json is present in the repo root' : '.mcp.json is missing from the repo root',
309
- });
310
- if (!rootMcpExists) {
311
- nextSteps.push('Re-run `switchman setup --agents 3` to restore the repo-local MCP config.');
366
+ writeDemoFile(join(agent2Path, 'docs', 'auth-flow.md'), '# Auth flow\n\n- claims stop overlap early\n');
367
+ gitCommitAll(agent2Path, 'Document auth flow');
368
+ completeTask(db, taskDocs);
369
+
370
+ enqueueMergeItem(db, {
371
+ sourceType: 'worktree',
372
+ sourceRef: 'agent1',
373
+ sourceWorktree: 'agent1',
374
+ targetBranch: 'main',
375
+ });
376
+ enqueueMergeItem(db, {
377
+ sourceType: 'worktree',
378
+ sourceRef: 'agent2',
379
+ sourceWorktree: 'agent2',
380
+ targetBranch: 'main',
381
+ });
382
+
383
+ const queueRun = await runMergeQueue(db, repoDir, {
384
+ maxItems: 2,
385
+ targetBranch: 'main',
386
+ });
387
+ const queueItems = listMergeQueue(db);
388
+ const gateReport = await scanAllWorktrees(db, repoDir);
389
+ const aiGate = await runAiMergeGate(db, repoDir);
390
+
391
+ const result = {
392
+ repo_path: repoDir,
393
+ worktrees: listWorktrees(db).map((worktree) => ({
394
+ name: worktree.name,
395
+ path: worktree.path,
396
+ branch: worktree.branch,
397
+ })),
398
+ tasks: listTasks(db).map((task) => ({
399
+ id: task.id,
400
+ title: task.title,
401
+ status: task.status,
402
+ })),
403
+ overlap_demo: {
404
+ blocked_path: 'src/auth.js',
405
+ blocked_message: blockedClaimMessage,
406
+ safe_path: 'docs/auth-flow.md',
407
+ leases: [lease1.id, lease2.id],
408
+ },
409
+ queue: {
410
+ processed: queueRun.processed.map((entry) => ({
411
+ status: entry.status,
412
+ item_id: entry.item?.id || null,
413
+ source_ref: entry.item?.source_ref || null,
414
+ })),
415
+ final_items: queueItems.map((item) => ({
416
+ id: item.id,
417
+ status: item.status,
418
+ source_ref: item.source_ref,
419
+ })),
420
+ },
421
+ final_gate: {
422
+ ok: gateReport.conflicts.length === 0
423
+ && gateReport.fileConflicts.length === 0
424
+ && gateReport.unclaimedChanges.length === 0
425
+ && gateReport.complianceSummary.non_compliant === 0
426
+ && aiGate.status !== 'blocked',
427
+ ai_gate_status: aiGate.status,
428
+ },
429
+ next_steps: [
430
+ `cd ${repoDir}`,
431
+ 'switchman status',
432
+ 'switchman queue status',
433
+ ],
434
+ };
435
+
436
+ db.close();
437
+ if (cleanup) {
438
+ rmSync(repoDir, { recursive: true, force: true });
439
+ }
440
+ return result;
441
+ } catch (err) {
442
+ if (cleanup) {
443
+ rmSync(repoDir, { recursive: true, force: true });
444
+ }
445
+ throw err;
312
446
  }
447
+ }
313
448
 
314
- const cursorMcpExists = existsSync(cursorMcpPath);
315
- checks.push({
316
- key: 'cursor_mcp',
317
- ok: cursorMcpExists,
318
- label: 'Cursor MCP',
319
- detail: cursorMcpExists ? '.cursor/mcp.json is present in the repo root' : '.cursor/mcp.json is missing from the repo root',
320
- });
321
- if (!cursorMcpExists) {
322
- nextSteps.push('Re-run `switchman setup --agents 3` if you want Cursor to attach automatically.');
449
+ function normalizeCliRepoPath(targetPath) {
450
+ const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
451
+ const normalized = posix.normalize(rawPath.replace(/^\.\/+/, ''));
452
+ if (
453
+ normalized === '' ||
454
+ normalized === '.' ||
455
+ normalized === '..' ||
456
+ normalized.startsWith('../') ||
457
+ rawPath.startsWith('/') ||
458
+ /^[A-Za-z]:\//.test(rawPath)
459
+ ) {
460
+ throw new Error('Target path must point to a file inside the repository.');
323
461
  }
462
+ return normalized;
463
+ }
324
464
 
325
- const claudeGuideExists = existsSync(claudeGuidePath);
326
- checks.push({
327
- key: 'claude_md',
328
- ok: claudeGuideExists,
329
- label: 'Claude guide',
330
- detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
331
- });
332
- if (!claudeGuideExists) {
333
- nextSteps.push('If you use Claude Code, add `CLAUDE.md` from the repo root setup guide.');
465
+ function buildQueueExplainReport(db, repoRoot, itemId) {
466
+ const item = getMergeQueueItem(db, itemId);
467
+ if (!item) {
468
+ throw new Error(`Queue item ${itemId} does not exist.`);
334
469
  }
335
470
 
336
- const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
337
- checks.push({
338
- key: 'windsurf_mcp',
339
- ok: windsurfConfigExists,
340
- label: 'Windsurf MCP',
341
- detail: windsurfConfigExists
342
- ? 'Windsurf shared MCP config is installed'
343
- : 'Windsurf shared MCP config is optional and not installed',
344
- });
345
- if (!windsurfConfigExists) {
346
- nextSteps.push('If you use Windsurf, run `switchman mcp install --windsurf` once.');
471
+ let resolved = null;
472
+ let resolutionError = null;
473
+ try {
474
+ resolved = resolveQueueSource(db, repoRoot, item);
475
+ } catch (err) {
476
+ resolutionError = err.message;
347
477
  }
348
478
 
349
- const ok = checks.every((item) => item.ok || ['claude_md', 'windsurf_mcp'].includes(item.key));
479
+ const recentEvents = listMergeQueueEvents(db, item.id, { limit: 5 });
350
480
  return {
351
- ok,
352
- repo_root: repoRoot,
353
- checks,
354
- workspaces: workspaces.map((entry) => ({
355
- name: entry.name,
356
- path: entry.path,
357
- branch: entry.branch,
358
- })),
359
- suggested_commands: [
360
- 'switchman status --watch',
361
- 'switchman task add "Your first task" --priority 8',
362
- 'switchman gate ci',
363
- ...nextSteps.some((step) => step.includes('Windsurf')) ? ['switchman mcp install --windsurf'] : [],
364
- ],
365
- next_steps: [...new Set(nextSteps)].slice(0, 6),
481
+ item,
482
+ resolved_source: resolved,
483
+ resolution_error: resolutionError,
484
+ next_action: item.next_action || inferQueueExplainNextAction(item, resolved, resolutionError),
485
+ recent_events: recentEvents,
366
486
  };
367
487
  }
368
488
 
369
- function renderSetupVerification(report, { compact = false } = {}) {
370
- console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
371
- for (const check of report.checks) {
372
- const badge = boolBadge(check.ok);
373
- console.log(` ${badge} ${check.label} ${chalk.dim(`— ${check.detail}`)}`);
489
+ function inferQueueExplainNextAction(item, resolved, resolutionError) {
490
+ if (item.status === 'blocked' && item.next_action) return item.next_action;
491
+ if (item.status === 'blocked' && item.last_error_code === 'source_missing') {
492
+ return `Recreate the source branch, then run \`switchman queue retry ${item.id}\`.`;
374
493
  }
375
- if (report.next_steps.length > 0) {
376
- console.log('');
377
- console.log(chalk.bold('Fix next:'));
378
- for (const step of report.next_steps) {
379
- console.log(` - ${step}`);
380
- }
494
+ if (resolutionError) return 'Fix the source resolution issue, then re-run `switchman explain queue <itemId>` or queue a branch/worktree explicitly.';
495
+ if (item.status === 'retrying' && item.backoff_until) {
496
+ return item.next_action || `Wait until ${item.backoff_until}, or run \`switchman queue retry ${item.id}\` to retry sooner.`;
381
497
  }
382
- console.log('');
383
- console.log(chalk.bold('Try next:'));
384
- for (const command of report.suggested_commands.slice(0, 4)) {
385
- console.log(` ${chalk.cyan(command)}`);
498
+ if (item.status === 'wave_blocked') {
499
+ return item.next_action || `Run \`switchman explain queue ${item.id}\` to review the shared stale wave, then revalidate the affected pipelines together.`;
386
500
  }
501
+ if (item.status === 'escalated') {
502
+ return item.next_action || `Run \`switchman explain queue ${item.id}\` to review the landing risk, then \`switchman queue retry ${item.id}\` when it is ready again.`;
503
+ }
504
+ if (item.status === 'queued' || item.status === 'retrying') return 'Run `switchman queue run` to continue landing queued work.';
505
+ if (item.status === 'merged') return 'No action needed.';
506
+ if (resolved?.pipeline_id) return `Run \`switchman pipeline status ${resolved.pipeline_id}\` to inspect the pipeline state.`;
507
+ return 'Run `switchman queue status` to inspect the landing queue.';
387
508
  }
388
509
 
389
- function summarizeLeaseScope(db, lease) {
390
- const reservations = listScopeReservations(db, { leaseId: lease.id });
391
- const pathScopes = reservations
392
- .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
393
- .map((reservation) => reservation.scope_pattern);
394
- if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
395
- if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
510
+ function buildClaimExplainReport(db, filePath) {
511
+ const normalizedPath = normalizeCliRepoPath(filePath);
512
+ const activeClaims = getActiveFileClaims(db);
513
+ const directClaims = activeClaims.filter((claim) => claim.file_path === normalizedPath);
514
+ const activeLeases = listLeases(db, 'active');
515
+ const scopeOwners = activeLeases.flatMap((lease) => {
516
+ const taskSpec = getTaskSpec(db, lease.task_id);
517
+ const patterns = taskSpec?.allowed_paths || [];
518
+ if (!patterns.some((pattern) => matchesPathPatterns(normalizedPath, [pattern]))) {
519
+ return [];
520
+ }
521
+ return [{
522
+ lease_id: lease.id,
523
+ task_id: lease.task_id,
524
+ task_title: lease.task_title,
525
+ worktree: lease.worktree,
526
+ agent: lease.agent || null,
527
+ ownership_type: 'scope',
528
+ allowed_paths: patterns,
529
+ }];
530
+ });
396
531
 
397
- const subsystemScopes = reservations
398
- .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
399
- .map((reservation) => reservation.subsystem_tag);
400
- if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
401
- if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
402
- return null;
532
+ return {
533
+ file_path: normalizedPath,
534
+ claims: directClaims.map((claim) => ({
535
+ lease_id: claim.lease_id,
536
+ task_id: claim.task_id,
537
+ task_title: claim.task_title,
538
+ task_status: claim.task_status,
539
+ worktree: claim.worktree,
540
+ agent: claim.agent || null,
541
+ ownership_type: 'claim',
542
+ heartbeat_at: claim.lease_heartbeat_at || null,
543
+ })),
544
+ scope_owners: scopeOwners.filter((owner, index, all) =>
545
+ all.findIndex((candidate) => candidate.lease_id === owner.lease_id) === index,
546
+ ),
547
+ };
403
548
  }
404
549
 
405
- function isBusyError(err) {
406
- const message = String(err?.message || '').toLowerCase();
407
- return message.includes('database is locked') || message.includes('sqlite_busy');
408
- }
550
+ function buildStaleTaskExplainReport(db, taskId) {
551
+ const task = getTask(db, taskId);
552
+ if (!task) {
553
+ throw new Error(`Task ${taskId} does not exist.`);
554
+ }
409
555
 
410
- function humanizeReasonCode(reasonCode) {
411
- const labels = {
412
- no_active_lease: 'no active lease',
413
- lease_expired: 'lease expired',
414
- worktree_mismatch: 'wrong worktree',
415
- path_not_claimed: 'path not claimed',
416
- path_claimed_by_other_lease: 'claimed by another lease',
417
- path_scoped_by_other_lease: 'scoped by another lease',
418
- path_within_task_scope: 'within task scope',
419
- policy_exception_required: 'policy exception required',
420
- policy_exception_allowed: 'policy exception allowed',
421
- changes_outside_claims: 'changed files outside claims',
422
- changes_outside_task_scope: 'changed files outside task scope',
423
- missing_expected_tests: 'missing expected tests',
424
- missing_expected_docs: 'missing expected docs',
425
- missing_expected_source_changes: 'missing expected source changes',
426
- objective_not_evidenced: 'task objective not evidenced',
427
- no_changes_detected: 'no changes detected',
428
- task_execution_timeout: 'task execution timed out',
429
- task_failed: 'task failed',
430
- agent_command_failed: 'agent command failed',
431
- rejected: 'rejected',
556
+ const invalidations = listDependencyInvalidations(db, { affectedTaskId: taskId });
557
+ return {
558
+ task,
559
+ invalidations: invalidations.map((item) => ({
560
+ ...item,
561
+ details: item.details || {},
562
+ revalidation_set: item.details?.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
563
+ stale_area: item.reason_type === 'subsystem_overlap'
564
+ ? `subsystem:${item.subsystem_tag}`
565
+ : item.reason_type === 'semantic_contract_drift'
566
+ ? `contract:${(item.details?.contract_names || []).join('|') || 'unknown'}`
567
+ : item.reason_type === 'semantic_object_overlap'
568
+ ? `object:${(item.details?.object_names || []).join('|') || 'unknown'}`
569
+ : item.reason_type === 'shared_module_drift'
570
+ ? `module:${(item.details?.module_paths || []).join('|') || 'unknown'}`
571
+ : `${item.source_scope_pattern} ${item.affected_scope_pattern}`,
572
+ summary: item.reason_type === 'semantic_contract_drift'
573
+ ? `${item.details?.source_task_title || item.source_task_id} changed shared contract ${(item.details?.contract_names || []).join(', ') || 'unknown'}`
574
+ : item.reason_type === 'semantic_object_overlap'
575
+ ? `${item.details?.source_task_title || item.source_task_id} changed shared exported object ${(item.details?.object_names || []).join(', ') || 'unknown'}`
576
+ : item.reason_type === 'shared_module_drift'
577
+ ? `${item.details?.source_task_title || item.source_task_id} changed shared module ${(item.details?.module_paths || []).join(', ') || 'unknown'} used by ${(item.details?.dependent_files || []).join(', ') || item.affected_task_id}`
578
+ : `${item.details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
579
+ })),
580
+ next_action: invalidations.length > 0
581
+ ? `switchman task retry ${taskId}`
582
+ : null,
432
583
  };
433
- return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
434
584
  }
435
585
 
436
- function nextStepForReason(reasonCode) {
437
- const actions = {
438
- no_active_lease: 'reacquire the task or lease before writing',
439
- lease_expired: 'refresh or reacquire the lease, then retry',
440
- worktree_mismatch: 'run the task from the assigned worktree',
441
- path_not_claimed: 'claim the file before editing it',
442
- path_claimed_by_other_lease: 'wait for the other task or pick a different file',
443
- changes_outside_claims: 'claim all edited files or narrow the task scope',
444
- changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
445
- missing_expected_tests: 'add test coverage before rerunning',
446
- missing_expected_docs: 'add the expected docs change before rerunning',
447
- missing_expected_source_changes: 'make a source change inside the task scope',
448
- objective_not_evidenced: 'align the output more closely to the task objective',
449
- no_changes_detected: 'produce a tracked change or close the task differently',
450
- task_execution_timeout: 'raise the timeout or reduce task size',
451
- agent_command_failed: 'inspect stderr/stdout and rerun the agent',
586
+ function normalizeDependencyInvalidation(item) {
587
+ const details = item.details || {};
588
+ return {
589
+ ...item,
590
+ severity: item.severity || details.severity || (item.reason_type === 'semantic_contract_drift' ? 'blocked' : 'warn'),
591
+ details,
592
+ revalidation_set: details.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
593
+ stale_area: item.reason_type === 'subsystem_overlap'
594
+ ? `subsystem:${item.subsystem_tag}`
595
+ : item.reason_type === 'semantic_contract_drift'
596
+ ? `contract:${(details.contract_names || []).join('|') || 'unknown'}`
597
+ : item.reason_type === 'semantic_object_overlap'
598
+ ? `object:${(details.object_names || []).join('|') || 'unknown'}`
599
+ : item.reason_type === 'shared_module_drift'
600
+ ? `module:${(details.module_paths || []).join('|') || 'unknown'}`
601
+ : `${item.source_scope_pattern} ${item.affected_scope_pattern}`,
602
+ summary: item.reason_type === 'semantic_contract_drift'
603
+ ? `${details?.source_task_title || item.source_task_id} changed shared contract ${(details.contract_names || []).join(', ') || 'unknown'}`
604
+ : item.reason_type === 'semantic_object_overlap'
605
+ ? `${details?.source_task_title || item.source_task_id} changed shared exported object ${(details.object_names || []).join(', ') || 'unknown'}`
606
+ : item.reason_type === 'shared_module_drift'
607
+ ? `${details?.source_task_title || item.source_task_id} changed shared module ${(details.module_paths || []).join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || item.affected_task_id}`
608
+ : `${details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
452
609
  };
453
- return actions[reasonCode] || null;
454
610
  }
455
611
 
456
- function latestTaskFailure(task) {
457
- const failureLine = String(task.description || '')
612
+ function buildStaleClusters(invalidations = []) {
613
+ const clusters = new Map();
614
+ for (const invalidation of invalidations.map(normalizeDependencyInvalidation)) {
615
+ const clusterKey = invalidation.affected_pipeline_id
616
+ ? `pipeline:${invalidation.affected_pipeline_id}`
617
+ : `task:${invalidation.affected_task_id}`;
618
+ if (!clusters.has(clusterKey)) {
619
+ clusters.set(clusterKey, {
620
+ key: clusterKey,
621
+ affected_pipeline_id: invalidation.affected_pipeline_id || null,
622
+ affected_task_ids: new Set(),
623
+ source_task_ids: new Set(),
624
+ source_task_titles: new Set(),
625
+ source_worktrees: new Set(),
626
+ affected_worktrees: new Set(),
627
+ stale_areas: new Set(),
628
+ revalidation_sets: new Set(),
629
+ dependent_files: new Set(),
630
+ dependent_areas: new Set(),
631
+ module_paths: new Set(),
632
+ invalidations: [],
633
+ severity: 'warn',
634
+ highest_affected_priority: 0,
635
+ highest_source_priority: 0,
636
+ });
637
+ }
638
+ const cluster = clusters.get(clusterKey);
639
+ cluster.invalidations.push(invalidation);
640
+ cluster.affected_task_ids.add(invalidation.affected_task_id);
641
+ if (invalidation.source_task_id) cluster.source_task_ids.add(invalidation.source_task_id);
642
+ if (invalidation.details?.source_task_title) cluster.source_task_titles.add(invalidation.details.source_task_title);
643
+ if (invalidation.source_worktree) cluster.source_worktrees.add(invalidation.source_worktree);
644
+ if (invalidation.affected_worktree) cluster.affected_worktrees.add(invalidation.affected_worktree);
645
+ cluster.stale_areas.add(invalidation.stale_area);
646
+ if (invalidation.revalidation_set) cluster.revalidation_sets.add(invalidation.revalidation_set);
647
+ for (const filePath of invalidation.details?.dependent_files || []) cluster.dependent_files.add(filePath);
648
+ for (const area of invalidation.details?.dependent_areas || []) cluster.dependent_areas.add(area);
649
+ for (const modulePath of invalidation.details?.module_paths || []) cluster.module_paths.add(modulePath);
650
+ if (invalidation.severity === 'blocked') cluster.severity = 'block';
651
+ cluster.highest_affected_priority = Math.max(cluster.highest_affected_priority, Number(invalidation.details?.affected_task_priority || 0));
652
+ cluster.highest_source_priority = Math.max(cluster.highest_source_priority, Number(invalidation.details?.source_task_priority || 0));
653
+ }
654
+
655
+ const clusterEntries = [...clusters.values()]
656
+ .map((cluster) => {
657
+ const affectedTaskIds = [...cluster.affected_task_ids];
658
+ const sourceTaskTitles = [...cluster.source_task_titles];
659
+ const staleAreas = [...cluster.stale_areas];
660
+ const sourceWorktrees = [...cluster.source_worktrees];
661
+ const affectedWorktrees = [...cluster.affected_worktrees];
662
+ return {
663
+ key: cluster.key,
664
+ affected_pipeline_id: cluster.affected_pipeline_id,
665
+ affected_task_ids: affectedTaskIds,
666
+ invalidation_count: cluster.invalidations.length,
667
+ source_task_ids: [...cluster.source_task_ids],
668
+ source_pipeline_ids: [...new Set(cluster.invalidations.map((item) => item.source_pipeline_id).filter(Boolean))],
669
+ source_task_titles: sourceTaskTitles,
670
+ source_worktrees: sourceWorktrees,
671
+ affected_worktrees: affectedWorktrees,
672
+ stale_areas: staleAreas,
673
+ revalidation_sets: [...cluster.revalidation_sets],
674
+ dependent_files: [...cluster.dependent_files],
675
+ dependent_areas: [...cluster.dependent_areas],
676
+ module_paths: [...cluster.module_paths],
677
+ revalidation_set_type: cluster.revalidation_sets.has('contract')
678
+ ? 'contract'
679
+ : cluster.revalidation_sets.has('shared_module')
680
+ ? 'shared_module'
681
+ : cluster.revalidation_sets.has('semantic_object')
682
+ ? 'semantic_object'
683
+ : cluster.revalidation_sets.has('subsystem')
684
+ ? 'subsystem'
685
+ : 'scope',
686
+ rerun_priority: cluster.severity === 'block'
687
+ ? (cluster.revalidation_sets.has('contract') || cluster.highest_affected_priority >= 8 ? 'urgent' : 'high')
688
+ : cluster.revalidation_sets.has('shared_module') && cluster.dependent_files.size >= 3
689
+ ? 'high'
690
+ : cluster.highest_affected_priority >= 8
691
+ ? 'high'
692
+ : cluster.highest_affected_priority >= 5
693
+ ? 'medium'
694
+ : 'low',
695
+ rerun_priority_score: (cluster.severity === 'block' ? 100 : 0)
696
+ + (cluster.revalidation_sets.has('contract') ? 30 : cluster.revalidation_sets.has('shared_module') ? 20 : cluster.revalidation_sets.has('semantic_object') ? 15 : 0)
697
+ + (cluster.highest_affected_priority * 3)
698
+ + (cluster.dependent_files.size * 4)
699
+ + (cluster.dependent_areas.size * 2)
700
+ + cluster.module_paths.size
701
+ + cluster.invalidations.length,
702
+ rerun_breadth_score: (cluster.dependent_files.size * 4) + (cluster.dependent_areas.size * 2) + cluster.module_paths.size,
703
+ highest_affected_priority: cluster.highest_affected_priority,
704
+ highest_source_priority: cluster.highest_source_priority,
705
+ severity: cluster.severity,
706
+ invalidations: cluster.invalidations,
707
+ title: cluster.affected_pipeline_id
708
+ ? `Pipeline ${cluster.affected_pipeline_id} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`
709
+ : `${affectedTaskIds[0]} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`,
710
+ detail: `${sourceTaskTitles[0] || cluster.invalidations[0]?.source_task_id || 'unknown source'} -> ${affectedWorktrees.join(', ') || 'unknown target'} (${staleAreas.join(', ')})`,
711
+ next_step: cluster.revalidation_sets.has('contract')
712
+ ? (cluster.affected_pipeline_id
713
+ ? 'retry the stale pipeline tasks together so the affected contract can be revalidated before merge'
714
+ : 'retry the stale task so the affected contract can be revalidated before merge')
715
+ : cluster.revalidation_sets.has('shared_module')
716
+ ? (cluster.affected_pipeline_id
717
+ ? 'retry the stale pipeline tasks together so dependent shared-module work can be revalidated before merge'
718
+ : 'retry the stale task so its shared-module dependency can be revalidated before merge')
719
+ : cluster.affected_pipeline_id
720
+ ? 'retry the stale pipeline tasks together so the whole cluster can be revalidated before merge'
721
+ : 'retry the stale task so it can be revalidated before merge',
722
+ command: cluster.affected_pipeline_id
723
+ ? `switchman task retry-stale --pipeline ${cluster.affected_pipeline_id}`
724
+ : `switchman task retry ${affectedTaskIds[0]}`,
725
+ };
726
+ });
727
+
728
+ const causeGroups = new Map();
729
+ for (const cluster of clusterEntries) {
730
+ const primary = cluster.invalidations[0] || {};
731
+ const details = primary.details || {};
732
+ const causeKey = cluster.revalidation_set_type === 'contract'
733
+ ? `contract:${(details.contract_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
734
+ : cluster.revalidation_set_type === 'shared_module'
735
+ ? `shared_module:${(details.module_paths || cluster.module_paths || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
736
+ : cluster.revalidation_set_type === 'semantic_object'
737
+ ? `semantic_object:${(details.object_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
738
+ : `dependency:${cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`;
739
+ if (!causeGroups.has(causeKey)) causeGroups.set(causeKey, []);
740
+ causeGroups.get(causeKey).push(cluster);
741
+ }
742
+
743
+ for (const [causeKey, relatedClusters] of causeGroups.entries()) {
744
+ const relatedPipelines = [...new Set(relatedClusters.map((cluster) => cluster.affected_pipeline_id).filter(Boolean))];
745
+ const primary = relatedClusters[0];
746
+ const details = primary.invalidations[0]?.details || {};
747
+ const causeSummary = primary.revalidation_set_type === 'contract'
748
+ ? `shared contract drift in ${(details.contract_names || []).join(', ') || 'unknown contract'}`
749
+ : primary.revalidation_set_type === 'shared_module'
750
+ ? `shared module drift in ${(details.module_paths || primary.module_paths || []).join(', ') || 'unknown module'}`
751
+ : primary.revalidation_set_type === 'semantic_object'
752
+ ? `shared exported object drift in ${(details.object_names || []).join(', ') || 'unknown object'}`
753
+ : `shared dependency drift across ${primary.stale_areas.join(', ')}`;
754
+ for (let index = 0; index < relatedClusters.length; index += 1) {
755
+ relatedClusters[index].causal_group_id = `cause-${causeKey}`;
756
+ relatedClusters[index].causal_group_size = relatedClusters.length;
757
+ relatedClusters[index].causal_group_rank = index + 1;
758
+ relatedClusters[index].causal_group_summary = causeSummary;
759
+ relatedClusters[index].related_affected_pipelines = relatedPipelines;
760
+ }
761
+ }
762
+
763
+ return clusterEntries.sort((a, b) =>
764
+ b.rerun_priority_score - a.rerun_priority_score
765
+ || (a.severity === 'block' ? -1 : 1) - (b.severity === 'block' ? -1 : 1)
766
+ || (a.revalidation_set_type === 'contract' ? -1 : 1) - (b.revalidation_set_type === 'contract' ? -1 : 1)
767
+ || (a.revalidation_set_type === 'shared_module' ? -1 : 1) - (b.revalidation_set_type === 'shared_module' ? -1 : 1)
768
+ || b.invalidation_count - a.invalidation_count
769
+ || String(a.affected_pipeline_id || a.affected_task_ids[0]).localeCompare(String(b.affected_pipeline_id || b.affected_task_ids[0])));
770
+ }
771
+
772
+ function buildStalePipelineExplainReport(db, pipelineId) {
773
+ const invalidations = listDependencyInvalidations(db, { pipelineId });
774
+ const staleClusters = buildStaleClusters(invalidations)
775
+ .filter((cluster) => cluster.affected_pipeline_id === pipelineId);
776
+ return {
777
+ pipeline_id: pipelineId,
778
+ invalidations: invalidations.map(normalizeDependencyInvalidation),
779
+ stale_clusters: staleClusters,
780
+ next_action: staleClusters.length > 0
781
+ ? `switchman task retry-stale --pipeline ${pipelineId}`
782
+ : null,
783
+ };
784
+ }
785
+
786
+ function parseEventDetails(details) {
787
+ try {
788
+ return JSON.parse(details || '{}');
789
+ } catch {
790
+ return {};
791
+ }
792
+ }
793
+
794
+ function pipelineOwnsAuditEvent(event, pipelineId) {
795
+ if (event.task_id?.startsWith(`${pipelineId}-`)) return true;
796
+ const details = parseEventDetails(event.details);
797
+ if (details.pipeline_id === pipelineId) return true;
798
+ if (details.source_pipeline_id === pipelineId) return true;
799
+ if (Array.isArray(details.task_ids) && details.task_ids.some((taskId) => String(taskId).startsWith(`${pipelineId}-`))) {
800
+ return true;
801
+ }
802
+ return false;
803
+ }
804
+
805
+ function fallbackEventLabel(eventType) {
806
+ return String(eventType || 'event')
807
+ .split('_')
808
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
809
+ .join(' ');
810
+ }
811
+
812
+ function summarizePipelineAuditHistoryEvent(event, pipelineId) {
813
+ const details = parseEventDetails(event.details);
814
+ const defaultNextAction = `switchman pipeline status ${pipelineId}`;
815
+
816
+ switch (event.event_type) {
817
+ case 'pipeline_created':
818
+ return {
819
+ label: 'Pipeline created',
820
+ summary: `Created pipeline "${details.title || pipelineId}" with ${(details.task_ids || []).length} planned task${(details.task_ids || []).length === 1 ? '' : 's'}.`,
821
+ next_action: defaultNextAction,
822
+ };
823
+ case 'task_completed':
824
+ return {
825
+ label: 'Task completed',
826
+ summary: `Completed ${event.task_id}.`,
827
+ next_action: defaultNextAction,
828
+ };
829
+ case 'task_failed':
830
+ return {
831
+ label: 'Task failed',
832
+ summary: `Failed ${event.task_id}${event.reason_code ? ` because ${humanizeReasonCode(event.reason_code)}` : ''}.`,
833
+ next_action: defaultNextAction,
834
+ };
835
+ case 'task_retried':
836
+ case 'pipeline_task_retry_scheduled':
837
+ return {
838
+ label: 'Task retry scheduled',
839
+ summary: `Scheduled a retry for ${event.task_id}${details.retry_attempt ? ` (attempt ${details.retry_attempt})` : ''}.`,
840
+ next_action: defaultNextAction,
841
+ };
842
+ case 'dependency_invalidations_updated':
843
+ {
844
+ const reasonTypes = details.reason_types || [];
845
+ const revalidationSets = details.revalidation_sets || [];
846
+ return {
847
+ label: 'Stale work detected',
848
+ summary: `Marked stale work after ${details.source_task_title || details.source_task_id || 'an upstream task'} changed a shared boundary${revalidationSets.length > 0 ? ` across ${revalidationSets.join(', ')} revalidation` : reasonTypes.length > 0 ? ` across ${reasonTypes.join(', ')}` : ''}.`,
849
+ next_action: details.affected_pipeline_id
850
+ ? `switchman explain stale --pipeline ${details.affected_pipeline_id}`
851
+ : defaultNextAction,
852
+ };
853
+ }
854
+ case 'boundary_validation_state':
855
+ return {
856
+ label: 'Boundary validation updated',
857
+ summary: details.summary || 'Updated boundary validation state for the pipeline.',
858
+ next_action: defaultNextAction,
859
+ };
860
+ case 'pipeline_followups_created':
861
+ return {
862
+ label: 'Follow-up work created',
863
+ summary: `Created ${(details.created_task_ids || []).length} follow-up task${(details.created_task_ids || []).length === 1 ? '' : 's'} for review or validation.`,
864
+ next_action: `switchman pipeline review ${pipelineId}`,
865
+ };
866
+ case 'pipeline_pr_summary':
867
+ return {
868
+ label: 'PR summary built',
869
+ summary: 'Built the reviewer-facing pipeline summary.',
870
+ next_action: `switchman pipeline sync-pr ${pipelineId} --pr-from-env`,
871
+ };
872
+ case 'pipeline_pr_bundle_exported':
873
+ return {
874
+ label: 'PR bundle exported',
875
+ summary: 'Exported PR and landing artifacts for CI or review.',
876
+ next_action: `switchman pipeline sync-pr ${pipelineId} --pr-from-env`,
877
+ };
878
+ case 'pipeline_pr_commented':
879
+ return {
880
+ label: 'PR comment updated',
881
+ summary: `Updated PR #${details.pr_number || 'unknown'} with the latest pipeline status.`,
882
+ next_action: defaultNextAction,
883
+ };
884
+ case 'pipeline_pr_synced':
885
+ return {
886
+ label: 'PR sync completed',
887
+ summary: `Synced PR #${details.pr_number || 'unknown'} with bundle artifacts, comment, and CI outputs.`,
888
+ next_action: defaultNextAction,
889
+ };
890
+ case 'pipeline_pr_published':
891
+ return {
892
+ label: 'PR published',
893
+ summary: `Published pipeline PR${details.pr_number ? ` #${details.pr_number}` : ''}.`,
894
+ next_action: defaultNextAction,
895
+ };
896
+ case 'pipeline_landing_branch_materialized':
897
+ return {
898
+ label: event.status === 'allowed' ? 'Landing branch assembled' : 'Landing branch failed',
899
+ summary: event.status === 'allowed'
900
+ ? `Materialized synthetic landing branch ${details.branch || 'unknown'} from ${(details.component_branches || []).length} component branch${(details.component_branches || []).length === 1 ? '' : 'es'}.`
901
+ : `Failed to materialize the landing branch${details.failed_branch ? ` while merging ${details.failed_branch}` : ''}.`,
902
+ next_action: details.next_action || `switchman explain landing ${pipelineId}`,
903
+ };
904
+ case 'pipeline_landing_recovery_prepared':
905
+ return {
906
+ label: 'Landing recovery prepared',
907
+ summary: `Prepared a recovery worktree${details.recovery_path ? ` at ${details.recovery_path}` : ''} for the landing branch.`,
908
+ next_action: details.inspect_command || `switchman pipeline land ${pipelineId} --recover`,
909
+ };
910
+ case 'pipeline_landing_recovery_resumed':
911
+ return {
912
+ label: 'Landing recovery resumed',
913
+ summary: 'Recorded a manually resolved landing branch and marked it ready to queue again.',
914
+ next_action: details.resume_command || `switchman queue add --pipeline ${pipelineId}`,
915
+ };
916
+ case 'pipeline_landing_recovery_cleared':
917
+ return {
918
+ label: 'Landing recovery cleaned up',
919
+ summary: `Cleared the recorded landing recovery worktree${details.recovery_path ? ` at ${details.recovery_path}` : ''}.`,
920
+ next_action: defaultNextAction,
921
+ };
922
+ default:
923
+ return {
924
+ label: fallbackEventLabel(event.event_type),
925
+ summary: details.summary || fallbackEventLabel(event.event_type),
926
+ next_action: defaultNextAction,
927
+ };
928
+ }
929
+ }
930
+
931
+ function summarizePipelineQueueHistoryEvent(item, event) {
932
+ const details = parseEventDetails(event.details);
933
+
934
+ switch (event.event_type) {
935
+ case 'merge_queue_enqueued':
936
+ return {
937
+ label: 'Queued for landing',
938
+ summary: `Queued ${item.id} to land ${item.source_ref} onto ${item.target_branch}.${details.policy_override_summary ? ` ${details.policy_override_summary}` : ''}`,
939
+ next_action: 'switchman queue status',
940
+ };
941
+ case 'merge_queue_started':
942
+ return {
943
+ label: 'Queue processing started',
944
+ summary: `Started validating queue item ${item.id}.`,
945
+ next_action: 'switchman queue status',
946
+ };
947
+ case 'merge_queue_retried':
948
+ return {
949
+ label: 'Queue item retried',
950
+ summary: `Moved ${item.id} back into the landing queue for another attempt.`,
951
+ next_action: 'switchman queue status',
952
+ };
953
+ case 'merge_queue_state_changed':
954
+ return {
955
+ label: `Queue ${event.status || 'updated'}`,
956
+ summary: details.last_error_summary
957
+ || (event.status === 'merged'
958
+ ? `Merged ${item.id}${details.merged_commit ? ` at ${String(details.merged_commit).slice(0, 12)}` : ''}.`
959
+ : `Updated ${item.id} to ${event.status || 'unknown'}.`),
960
+ next_action: details.next_action || item.next_action || `switchman explain queue ${item.id}`,
961
+ };
962
+ default:
963
+ return {
964
+ label: fallbackEventLabel(event.event_type),
965
+ summary: fallbackEventLabel(event.event_type),
966
+ next_action: item.next_action || `switchman explain queue ${item.id}`,
967
+ };
968
+ }
969
+ }
970
+
971
+ function buildPipelineHistoryReport(db, repoRoot, pipelineId) {
972
+ const status = getPipelineStatus(db, pipelineId);
973
+ let landing;
974
+ try {
975
+ landing = getPipelineLandingExplainReport(db, repoRoot, pipelineId);
976
+ } catch (err) {
977
+ landing = {
978
+ pipeline_id: pipelineId,
979
+ landing: {
980
+ branch: null,
981
+ strategy: 'unresolved',
982
+ synthetic: false,
983
+ stale: false,
984
+ stale_reasons: [],
985
+ last_failure: {
986
+ reason_code: 'landing_not_ready',
987
+ summary: String(err.message || 'Landing branch is not ready yet.'),
988
+ },
989
+ last_recovery: null,
990
+ },
991
+ next_action: `switchman pipeline status ${pipelineId}`,
992
+ };
993
+ }
994
+ const staleClusters = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }))
995
+ .filter((cluster) => cluster.affected_pipeline_id === pipelineId);
996
+ const queueItems = listMergeQueue(db)
997
+ .filter((item) => item.source_pipeline_id === pipelineId)
998
+ .map((item) => ({
999
+ ...item,
1000
+ recent_events: listMergeQueueEvents(db, item.id, { limit: 20 }),
1001
+ }));
1002
+ const auditEvents = listAuditEvents(db, { limit: 2000 })
1003
+ .filter((event) => pipelineOwnsAuditEvent(event, pipelineId));
1004
+
1005
+ const events = [
1006
+ ...auditEvents.map((event) => {
1007
+ const described = summarizePipelineAuditHistoryEvent(event, pipelineId);
1008
+ return {
1009
+ source: 'audit',
1010
+ id: `audit:${event.id}`,
1011
+ created_at: event.created_at,
1012
+ event_type: event.event_type,
1013
+ status: event.status,
1014
+ reason_code: event.reason_code || null,
1015
+ task_id: event.task_id || null,
1016
+ ...described,
1017
+ };
1018
+ }),
1019
+ ...queueItems.flatMap((item) => item.recent_events.map((event) => {
1020
+ const described = summarizePipelineQueueHistoryEvent(item, event);
1021
+ return {
1022
+ source: 'queue',
1023
+ id: `queue:${item.id}:${event.id}`,
1024
+ created_at: event.created_at,
1025
+ event_type: event.event_type,
1026
+ status: event.status || item.status,
1027
+ reason_code: null,
1028
+ task_id: null,
1029
+ queue_item_id: item.id,
1030
+ ...described,
1031
+ };
1032
+ })),
1033
+ ].sort((a, b) => {
1034
+ const timeCompare = String(a.created_at || '').localeCompare(String(b.created_at || ''));
1035
+ if (timeCompare !== 0) return timeCompare;
1036
+ return a.id.localeCompare(b.id);
1037
+ });
1038
+
1039
+ const blockedQueueItem = queueItems.find((item) => item.status === 'blocked');
1040
+ const nextAction = staleClusters[0]?.command
1041
+ || blockedQueueItem?.next_action
1042
+ || landing.next_action
1043
+ || `switchman pipeline status ${pipelineId}`;
1044
+
1045
+ return {
1046
+ pipeline_id: pipelineId,
1047
+ title: status.title,
1048
+ description: status.description,
1049
+ counts: status.counts,
1050
+ current: {
1051
+ stale_clusters: staleClusters,
1052
+ queue_items: queueItems.map((item) => ({
1053
+ id: item.id,
1054
+ status: item.status,
1055
+ target_branch: item.target_branch,
1056
+ last_error_code: item.last_error_code || null,
1057
+ last_error_summary: item.last_error_summary || null,
1058
+ next_action: item.next_action || null,
1059
+ })),
1060
+ landing: {
1061
+ branch: landing.landing.branch,
1062
+ strategy: landing.landing.strategy,
1063
+ synthetic: landing.landing.synthetic,
1064
+ stale: landing.landing.stale,
1065
+ stale_reasons: landing.landing.stale_reasons,
1066
+ last_failure: landing.landing.last_failure,
1067
+ last_recovery: landing.landing.last_recovery,
1068
+ },
1069
+ },
1070
+ events,
1071
+ next_action: nextAction,
1072
+ };
1073
+ }
1074
+
1075
+ function collectKnownPipelineIds(db) {
1076
+ return [...new Set(
1077
+ listTasks(db)
1078
+ .map((task) => getTaskSpec(db, task.id)?.pipeline_id || null)
1079
+ .filter(Boolean),
1080
+ )].sort();
1081
+ }
1082
+
1083
+ function reconcileWorktreeState(db, repoRoot) {
1084
+ const actions = [];
1085
+ const dbWorktrees = listWorktrees(db);
1086
+ const gitWorktrees = listGitWorktrees(repoRoot);
1087
+
1088
+ const dbByPath = new Map(dbWorktrees.map((worktree) => [worktree.path, worktree]));
1089
+ const dbByName = new Map(dbWorktrees.map((worktree) => [worktree.name, worktree]));
1090
+ const gitByPath = new Map(gitWorktrees.map((worktree) => [worktree.path, worktree]));
1091
+
1092
+ for (const gitWorktree of gitWorktrees) {
1093
+ const dbMatch = dbByPath.get(gitWorktree.path) || dbByName.get(gitWorktree.name) || null;
1094
+ if (!dbMatch) {
1095
+ registerWorktree(db, {
1096
+ name: gitWorktree.name,
1097
+ path: gitWorktree.path,
1098
+ branch: gitWorktree.branch || 'unknown',
1099
+ agent: null,
1100
+ });
1101
+ actions.push({
1102
+ kind: 'git_worktree_registered',
1103
+ worktree: gitWorktree.name,
1104
+ path: gitWorktree.path,
1105
+ branch: gitWorktree.branch || 'unknown',
1106
+ });
1107
+ continue;
1108
+ }
1109
+
1110
+ if (dbMatch.path !== gitWorktree.path || dbMatch.branch !== (gitWorktree.branch || dbMatch.branch) || dbMatch.status === 'missing') {
1111
+ registerWorktree(db, {
1112
+ name: dbMatch.name,
1113
+ path: gitWorktree.path,
1114
+ branch: gitWorktree.branch || dbMatch.branch || 'unknown',
1115
+ agent: dbMatch.agent,
1116
+ });
1117
+ actions.push({
1118
+ kind: 'db_worktree_reconciled',
1119
+ worktree: dbMatch.name,
1120
+ path: gitWorktree.path,
1121
+ branch: gitWorktree.branch || dbMatch.branch || 'unknown',
1122
+ });
1123
+ }
1124
+ }
1125
+
1126
+ for (const dbWorktree of dbWorktrees) {
1127
+ if (!gitByPath.has(dbWorktree.path) && dbWorktree.status !== 'missing') {
1128
+ updateWorktreeStatus(db, dbWorktree.name, 'missing');
1129
+ actions.push({
1130
+ kind: 'db_worktree_marked_missing',
1131
+ worktree: dbWorktree.name,
1132
+ path: dbWorktree.path,
1133
+ branch: dbWorktree.branch,
1134
+ });
1135
+ }
1136
+ }
1137
+
1138
+ return actions;
1139
+ }
1140
+
1141
+ function reconcileTrackedTempResources(db, repoRoot) {
1142
+ const actions = [];
1143
+ const warnings = [];
1144
+ const gitWorktrees = listGitWorktrees(repoRoot);
1145
+ const gitPaths = new Set(gitWorktrees.map((worktree) => worktree.path));
1146
+ const resources = listTempResources(db, { limit: 500 }).filter((resource) => resource.status !== 'released');
1147
+
1148
+ for (const resource of resources) {
1149
+ const exists = existsSync(resource.path);
1150
+ const trackedByGit = gitPaths.has(resource.path);
1151
+
1152
+ if (resource.resource_type === 'landing_temp_worktree') {
1153
+ if (!exists && !trackedByGit) {
1154
+ updateTempResource(db, resource.id, {
1155
+ status: 'abandoned',
1156
+ details: JSON.stringify({
1157
+ repaired_by: 'switchman repair',
1158
+ reason: 'temp_worktree_missing_after_interruption',
1159
+ path: resource.path,
1160
+ }),
1161
+ });
1162
+ actions.push({
1163
+ kind: 'temp_resource_reconciled',
1164
+ resource_id: resource.id,
1165
+ resource_type: resource.resource_type,
1166
+ path: resource.path,
1167
+ status: 'abandoned',
1168
+ });
1169
+ }
1170
+ continue;
1171
+ }
1172
+
1173
+ if (resource.resource_type === 'landing_recovery_worktree') {
1174
+ if (!exists && !trackedByGit) {
1175
+ updateTempResource(db, resource.id, {
1176
+ status: 'abandoned',
1177
+ details: JSON.stringify({
1178
+ repaired_by: 'switchman repair',
1179
+ reason: 'recovery_worktree_missing',
1180
+ path: resource.path,
1181
+ }),
1182
+ });
1183
+ actions.push({
1184
+ kind: 'temp_resource_reconciled',
1185
+ resource_id: resource.id,
1186
+ resource_type: resource.resource_type,
1187
+ path: resource.path,
1188
+ status: 'abandoned',
1189
+ });
1190
+ } else if (exists && !trackedByGit) {
1191
+ warnings.push({
1192
+ kind: 'temp_resource_manual_review',
1193
+ resource_id: resource.id,
1194
+ resource_type: resource.resource_type,
1195
+ path: resource.path,
1196
+ status: resource.status,
1197
+ next_action: `Inspect ${resource.path} and either re-register it or clean it up with switchman pipeline land ${resource.scope_id} --cleanup ${JSON.stringify(resource.path)}`,
1198
+ });
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ return { actions, warnings };
1204
+ }
1205
+
1206
+ function summarizeRepairReport(actions = [], warnings = [], notes = []) {
1207
+ return {
1208
+ auto_fixed: actions,
1209
+ manual_review: warnings,
1210
+ skipped: [],
1211
+ notes,
1212
+ counts: {
1213
+ auto_fixed: actions.length,
1214
+ manual_review: warnings.length,
1215
+ skipped: 0,
1216
+ },
1217
+ };
1218
+ }
1219
+
1220
+ function renderRepairLine(action) {
1221
+ if (action.kind === 'git_worktree_registered') {
1222
+ return `${chalk.dim('registered git worktree:')} ${action.worktree} ${action.path}`;
1223
+ }
1224
+ if (action.kind === 'db_worktree_reconciled') {
1225
+ return `${chalk.dim('reconciled db worktree:')} ${action.worktree} ${action.path}`;
1226
+ }
1227
+ if (action.kind === 'db_worktree_marked_missing') {
1228
+ return `${chalk.dim('marked missing db worktree:')} ${action.worktree} ${action.path}`;
1229
+ }
1230
+ if (action.kind === 'queue_item_blocked_missing_worktree') {
1231
+ return `${chalk.dim('blocked queue item with missing worktree:')} ${action.queue_item_id} ${action.worktree}`;
1232
+ }
1233
+ if (action.kind === 'stale_temp_worktree_removed') {
1234
+ return `${chalk.dim('removed stale temp landing worktree:')} ${action.path}`;
1235
+ }
1236
+ if (action.kind === 'stale_temp_worktree_pruned') {
1237
+ return `${chalk.dim('pruned stale temp landing metadata:')} ${action.path}`;
1238
+ }
1239
+ if (action.kind === 'journal_operation_repaired') {
1240
+ return `${chalk.dim('closed interrupted operation:')} ${action.operation_type} ${action.scope_type}:${action.scope_id}`;
1241
+ }
1242
+ if (action.kind === 'queue_item_reset') {
1243
+ return `${chalk.dim('queue reset:')} ${action.queue_item_id} ${action.previous_status} -> ${action.status}`;
1244
+ }
1245
+ if (action.kind === 'pipeline_repaired') {
1246
+ return `${chalk.dim('pipeline repair:')} ${action.pipeline_id}`;
1247
+ }
1248
+ if (action.kind === 'temp_resource_reconciled') {
1249
+ return `${chalk.dim('reconciled tracked temp resource:')} ${action.resource_type} ${action.path} -> ${action.status}`;
1250
+ }
1251
+ return `${chalk.dim(action.kind + ':')} ${JSON.stringify(action)}`;
1252
+ }
1253
+
1254
+ function renderRepairWarningLine(warning) {
1255
+ if (warning.kind === 'temp_resource_manual_review') {
1256
+ return `${chalk.yellow('manual review:')} ${warning.resource_type} ${warning.path}`;
1257
+ }
1258
+ return `${chalk.yellow('manual review:')} ${warning.kind}`;
1259
+ }
1260
+
1261
+ function printRepairSummary(report, {
1262
+ repairedHeading,
1263
+ noRepairHeading,
1264
+ limit = null,
1265
+ } = {}) {
1266
+ const autoFixed = report.summary?.auto_fixed || report.actions || [];
1267
+ const manualReview = report.summary?.manual_review || report.warnings || [];
1268
+ const skipped = report.summary?.skipped || [];
1269
+ const limitedAutoFixed = limit == null ? autoFixed : autoFixed.slice(0, limit);
1270
+ const limitedManualReview = limit == null ? manualReview : manualReview.slice(0, limit);
1271
+ const limitedSkipped = limit == null ? skipped : skipped.slice(0, limit);
1272
+
1273
+ console.log(report.repaired ? repairedHeading : noRepairHeading);
1274
+ for (const note of report.notes || []) {
1275
+ console.log(` ${chalk.dim(note)}`);
1276
+ }
1277
+
1278
+ console.log(` ${chalk.green('auto-fixed:')} ${autoFixed.length}`);
1279
+ for (const action of limitedAutoFixed) {
1280
+ console.log(` ${renderRepairLine(action)}`);
1281
+ }
1282
+ console.log(` ${chalk.yellow('manual review:')} ${manualReview.length}`);
1283
+ for (const warning of limitedManualReview) {
1284
+ console.log(` ${renderRepairWarningLine(warning)}`);
1285
+ }
1286
+ console.log(` ${chalk.dim('skipped:')} ${skipped.length}`);
1287
+ for (const item of limitedSkipped) {
1288
+ console.log(` ${chalk.dim(JSON.stringify(item))}`);
1289
+ }
1290
+ }
1291
+
1292
+ function repairRepoState(db, repoRoot) {
1293
+ const actions = [];
1294
+ const warnings = [];
1295
+ const notes = [];
1296
+ const repairedQueueItems = new Set();
1297
+ for (const action of reconcileWorktreeState(db, repoRoot)) {
1298
+ actions.push(action);
1299
+ }
1300
+ const tempLandingCleanup = cleanupCrashedLandingTempWorktrees(repoRoot);
1301
+ for (const action of tempLandingCleanup.actions) {
1302
+ actions.push(action);
1303
+ }
1304
+ const tempResourceReconciliation = reconcileTrackedTempResources(db, repoRoot);
1305
+ for (const action of tempResourceReconciliation.actions) {
1306
+ actions.push(action);
1307
+ }
1308
+ for (const warning of tempResourceReconciliation.warnings) {
1309
+ warnings.push(warning);
1310
+ }
1311
+ const queueItems = listMergeQueue(db);
1312
+ const runningQueueOperations = listOperationJournal(db, { scopeType: 'queue_item', status: 'running', limit: 200 });
1313
+
1314
+ for (const operation of runningQueueOperations) {
1315
+ const item = getMergeQueueItem(db, operation.scope_id);
1316
+ if (!item) {
1317
+ finishOperationJournalEntry(db, operation.id, {
1318
+ status: 'repaired',
1319
+ details: JSON.stringify({
1320
+ repaired_by: 'switchman repair',
1321
+ summary: 'Queue item no longer exists; interrupted journal entry was cleared.',
1322
+ }),
1323
+ });
1324
+ actions.push({
1325
+ kind: 'journal_operation_repaired',
1326
+ operation_id: operation.id,
1327
+ operation_type: operation.operation_type,
1328
+ scope_type: operation.scope_type,
1329
+ scope_id: operation.scope_id,
1330
+ });
1331
+ continue;
1332
+ }
1333
+
1334
+ if (['validating', 'rebasing', 'merging'].includes(item.status)) {
1335
+ const repaired = markMergeQueueState(db, item.id, {
1336
+ status: 'retrying',
1337
+ lastErrorCode: 'interrupted_queue_run',
1338
+ lastErrorSummary: `Queue item ${item.id} was interrupted during ${operation.operation_type} and has been reset to retrying.`,
1339
+ nextAction: 'Run `switchman queue run` to resume landing.',
1340
+ });
1341
+ finishOperationJournalEntry(db, operation.id, {
1342
+ status: 'repaired',
1343
+ details: JSON.stringify({
1344
+ repaired_by: 'switchman repair',
1345
+ queue_item_id: item.id,
1346
+ previous_status: item.status,
1347
+ status: repaired.status,
1348
+ }),
1349
+ });
1350
+ repairedQueueItems.add(item.id);
1351
+ actions.push({
1352
+ kind: 'queue_item_reset',
1353
+ queue_item_id: repaired.id,
1354
+ previous_status: item.status,
1355
+ status: repaired.status,
1356
+ next_action: repaired.next_action,
1357
+ });
1358
+ actions.push({
1359
+ kind: 'journal_operation_repaired',
1360
+ operation_id: operation.id,
1361
+ operation_type: operation.operation_type,
1362
+ scope_type: operation.scope_type,
1363
+ scope_id: operation.scope_id,
1364
+ });
1365
+ continue;
1366
+ }
1367
+
1368
+ if (!['running', 'queued', 'retrying'].includes(item.status)) {
1369
+ finishOperationJournalEntry(db, operation.id, {
1370
+ status: 'repaired',
1371
+ details: JSON.stringify({
1372
+ repaired_by: 'switchman repair',
1373
+ queue_item_id: item.id,
1374
+ summary: `Queue item is already ${item.status}; stale running journal entry was cleared.`,
1375
+ }),
1376
+ });
1377
+ actions.push({
1378
+ kind: 'journal_operation_repaired',
1379
+ operation_id: operation.id,
1380
+ operation_type: operation.operation_type,
1381
+ scope_type: operation.scope_type,
1382
+ scope_id: operation.scope_id,
1383
+ });
1384
+ }
1385
+ }
1386
+
1387
+ const interruptedQueueItems = queueItems.filter((item) => ['validating', 'rebasing', 'merging'].includes(item.status) && !repairedQueueItems.has(item.id));
1388
+
1389
+ for (const item of interruptedQueueItems) {
1390
+ const repaired = markMergeQueueState(db, item.id, {
1391
+ status: 'retrying',
1392
+ lastErrorCode: 'interrupted_queue_run',
1393
+ lastErrorSummary: `Queue item ${item.id} was left in ${item.status} and has been reset to retrying.`,
1394
+ nextAction: 'Run `switchman queue run` to resume landing.',
1395
+ });
1396
+ actions.push({
1397
+ kind: 'queue_item_reset',
1398
+ queue_item_id: repaired.id,
1399
+ previous_status: item.status,
1400
+ status: repaired.status,
1401
+ next_action: repaired.next_action,
1402
+ });
1403
+ }
1404
+
1405
+ const reconciledWorktrees = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
1406
+ for (const item of queueItems.filter((entry) => ['queued', 'retrying'].includes(entry.status) && entry.source_type === 'worktree')) {
1407
+ const worktree = reconciledWorktrees.get(item.source_worktree || item.source_ref) || null;
1408
+ if (!worktree || worktree.status === 'missing') {
1409
+ const blocked = markMergeQueueState(db, item.id, {
1410
+ status: 'blocked',
1411
+ lastErrorCode: 'source_worktree_missing',
1412
+ lastErrorSummary: `Queued worktree ${item.source_worktree || item.source_ref} is no longer available.`,
1413
+ nextAction: `Restore or re-register ${item.source_worktree || item.source_ref}, then run \`switchman queue retry ${item.id}\`.`,
1414
+ });
1415
+ actions.push({
1416
+ kind: 'queue_item_blocked_missing_worktree',
1417
+ queue_item_id: blocked.id,
1418
+ worktree: item.source_worktree || item.source_ref,
1419
+ next_action: blocked.next_action,
1420
+ });
1421
+ }
1422
+ }
1423
+
1424
+ const pipelineIds = [...new Set([
1425
+ ...collectKnownPipelineIds(db),
1426
+ ...queueItems.map((item) => item.source_pipeline_id).filter(Boolean),
1427
+ ])];
1428
+ const runningPipelineOperations = listOperationJournal(db, { scopeType: 'pipeline', status: 'running', limit: 200 });
1429
+
1430
+ for (const pipelineId of pipelineIds) {
1431
+ const repaired = repairPipelineState(db, repoRoot, pipelineId);
1432
+ if (!repaired.repaired) continue;
1433
+ actions.push({
1434
+ kind: 'pipeline_repaired',
1435
+ pipeline_id: pipelineId,
1436
+ actions: repaired.actions,
1437
+ next_action: repaired.next_action,
1438
+ });
1439
+
1440
+ for (const operation of runningPipelineOperations.filter((entry) => entry.scope_id === pipelineId)) {
1441
+ finishOperationJournalEntry(db, operation.id, {
1442
+ status: 'repaired',
1443
+ details: JSON.stringify({
1444
+ repaired_by: 'switchman repair',
1445
+ pipeline_id: pipelineId,
1446
+ repair_actions: repaired.actions.map((action) => action.kind),
1447
+ }),
1448
+ });
1449
+ actions.push({
1450
+ kind: 'journal_operation_repaired',
1451
+ operation_id: operation.id,
1452
+ operation_type: operation.operation_type,
1453
+ scope_type: operation.scope_type,
1454
+ scope_id: operation.scope_id,
1455
+ });
1456
+ }
1457
+ }
1458
+
1459
+ if (actions.length === 0) {
1460
+ notes.push('No safe repair action was needed.');
1461
+ }
1462
+
1463
+ const summary = summarizeRepairReport(actions, warnings, notes);
1464
+
1465
+ return {
1466
+ repaired: actions.length > 0,
1467
+ actions,
1468
+ warnings,
1469
+ summary,
1470
+ notes,
1471
+ next_action: warnings[0]?.next_action || (interruptedQueueItems.length > 0 ? 'switchman queue run' : 'switchman status'),
1472
+ };
1473
+ }
1474
+
1475
+ function buildLandingStateLabel(landing) {
1476
+ if (!landing) return null;
1477
+ if (!landing.synthetic) {
1478
+ return `${landing.branch} ${chalk.dim('(single branch)')}`;
1479
+ }
1480
+ if (!landing.last_materialized) {
1481
+ return `${landing.branch} ${chalk.yellow('(not created yet)')}`;
1482
+ }
1483
+ if (landing.stale) {
1484
+ return `${landing.branch} ${chalk.red('(stale)')}`;
1485
+ }
1486
+ return `${landing.branch} ${chalk.green('(current)')}`;
1487
+ }
1488
+
1489
+ async function maybeCaptureTelemetry(event, properties = {}, { homeDir = null } = {}) {
1490
+ try {
1491
+ await maybePromptForTelemetry({ homeDir: homeDir || undefined });
1492
+ await captureTelemetryEvent(event, {
1493
+ app_version: program.version(),
1494
+ os: process.platform,
1495
+ node_version: process.version,
1496
+ ...properties,
1497
+ }, { homeDir: homeDir || undefined });
1498
+ } catch {
1499
+ // Telemetry must never block CLI usage.
1500
+ }
1501
+ }
1502
+
1503
+ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
1504
+ const dbPath = join(repoRoot, '.switchman', 'switchman.db');
1505
+ const rootMcpPath = join(repoRoot, '.mcp.json');
1506
+ const cursorMcpPath = join(repoRoot, '.cursor', 'mcp.json');
1507
+ const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
1508
+ const checks = [];
1509
+ const nextSteps = [];
1510
+ let workspaces = [];
1511
+ let db = null;
1512
+
1513
+ const dbExists = existsSync(dbPath);
1514
+ checks.push({
1515
+ key: 'database',
1516
+ ok: dbExists,
1517
+ label: 'Project database',
1518
+ detail: dbExists ? '.switchman/switchman.db is ready' : 'Switchman database is missing',
1519
+ });
1520
+ if (!dbExists) {
1521
+ nextSteps.push('Run `switchman init` or `switchman setup --agents 3` in this repo.');
1522
+ }
1523
+
1524
+ if (dbExists) {
1525
+ try {
1526
+ db = getDb(repoRoot);
1527
+ workspaces = listWorktrees(db);
1528
+ } catch {
1529
+ checks.push({
1530
+ key: 'database_open',
1531
+ ok: false,
1532
+ label: 'Database access',
1533
+ detail: 'Switchman could not open the project database',
1534
+ });
1535
+ nextSteps.push('Re-run `switchman init` if the project database looks corrupted.');
1536
+ } finally {
1537
+ try { db?.close(); } catch { /* no-op */ }
1538
+ }
1539
+ }
1540
+
1541
+ const agentWorkspaces = workspaces.filter((entry) => entry.name !== 'main');
1542
+ const workspaceReady = agentWorkspaces.length > 0;
1543
+ checks.push({
1544
+ key: 'workspaces',
1545
+ ok: workspaceReady,
1546
+ label: 'Agent workspaces',
1547
+ detail: workspaceReady
1548
+ ? `${agentWorkspaces.length} agent workspace(s) registered`
1549
+ : 'No agent workspaces are registered yet',
1550
+ });
1551
+ if (!workspaceReady) {
1552
+ nextSteps.push('Run `switchman setup --agents 3` to create agent workspaces.');
1553
+ }
1554
+
1555
+ const rootMcpExists = existsSync(rootMcpPath);
1556
+ checks.push({
1557
+ key: 'claude_mcp',
1558
+ ok: rootMcpExists,
1559
+ label: 'Claude Code MCP',
1560
+ detail: rootMcpExists ? '.mcp.json is present in the repo root' : '.mcp.json is missing from the repo root',
1561
+ });
1562
+ if (!rootMcpExists) {
1563
+ nextSteps.push('Re-run `switchman setup --agents 3` to restore the repo-local MCP config.');
1564
+ }
1565
+
1566
+ const cursorMcpExists = existsSync(cursorMcpPath);
1567
+ checks.push({
1568
+ key: 'cursor_mcp',
1569
+ ok: cursorMcpExists,
1570
+ label: 'Cursor MCP',
1571
+ detail: cursorMcpExists ? '.cursor/mcp.json is present in the repo root' : '.cursor/mcp.json is missing from the repo root',
1572
+ });
1573
+ if (!cursorMcpExists) {
1574
+ nextSteps.push('Re-run `switchman setup --agents 3` if you want Cursor to attach automatically.');
1575
+ }
1576
+
1577
+ const claudeGuideExists = existsSync(claudeGuidePath);
1578
+ checks.push({
1579
+ key: 'claude_md',
1580
+ ok: claudeGuideExists,
1581
+ label: 'Claude guide',
1582
+ detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
1583
+ });
1584
+ if (!claudeGuideExists) {
1585
+ nextSteps.push('If you use Claude Code, add `CLAUDE.md` from the repo root setup guide.');
1586
+ }
1587
+
1588
+ const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
1589
+ checks.push({
1590
+ key: 'windsurf_mcp',
1591
+ ok: windsurfConfigExists,
1592
+ label: 'Windsurf MCP',
1593
+ detail: windsurfConfigExists
1594
+ ? 'Windsurf shared MCP config is installed'
1595
+ : 'Windsurf shared MCP config is optional and not installed',
1596
+ });
1597
+ if (!windsurfConfigExists) {
1598
+ nextSteps.push('If you use Windsurf, run `switchman mcp install --windsurf` once.');
1599
+ }
1600
+
1601
+ const ok = checks.every((item) => item.ok || ['claude_md', 'windsurf_mcp'].includes(item.key));
1602
+ return {
1603
+ ok,
1604
+ repo_root: repoRoot,
1605
+ checks,
1606
+ workspaces: workspaces.map((entry) => ({
1607
+ name: entry.name,
1608
+ path: entry.path,
1609
+ branch: entry.branch,
1610
+ })),
1611
+ suggested_commands: [
1612
+ 'switchman status --watch',
1613
+ 'switchman task add "Your first task" --priority 8',
1614
+ 'switchman gate ci',
1615
+ ...nextSteps.some((step) => step.includes('Windsurf')) ? ['switchman mcp install --windsurf'] : [],
1616
+ ],
1617
+ next_steps: [...new Set(nextSteps)].slice(0, 6),
1618
+ };
1619
+ }
1620
+
1621
+ function renderSetupVerification(report, { compact = false } = {}) {
1622
+ console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
1623
+ for (const check of report.checks) {
1624
+ const badge = boolBadge(check.ok);
1625
+ console.log(` ${badge} ${check.label} ${chalk.dim(`— ${check.detail}`)}`);
1626
+ }
1627
+ if (report.next_steps.length > 0) {
1628
+ console.log('');
1629
+ console.log(chalk.bold('Fix next:'));
1630
+ for (const step of report.next_steps) {
1631
+ console.log(` - ${step}`);
1632
+ }
1633
+ }
1634
+ console.log('');
1635
+ console.log(chalk.bold('Try next:'));
1636
+ for (const command of report.suggested_commands.slice(0, 4)) {
1637
+ console.log(` ${chalk.cyan(command)}`);
1638
+ }
1639
+ }
1640
+
1641
+ function summarizeLeaseScope(db, lease) {
1642
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
1643
+ const pathScopes = reservations
1644
+ .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
1645
+ .map((reservation) => reservation.scope_pattern);
1646
+ if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
1647
+ if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
1648
+
1649
+ const subsystemScopes = reservations
1650
+ .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
1651
+ .map((reservation) => reservation.subsystem_tag);
1652
+ if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
1653
+ if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
1654
+ return null;
1655
+ }
1656
+
1657
+ function isBusyError(err) {
1658
+ const message = String(err?.message || '').toLowerCase();
1659
+ return message.includes('database is locked') || message.includes('sqlite_busy');
1660
+ }
1661
+
1662
+ function humanizeReasonCode(reasonCode) {
1663
+ const labels = {
1664
+ no_active_lease: 'no active lease',
1665
+ lease_expired: 'lease expired',
1666
+ worktree_mismatch: 'wrong worktree',
1667
+ path_not_claimed: 'path not claimed',
1668
+ path_claimed_by_other_lease: 'claimed by another lease',
1669
+ path_scoped_by_other_lease: 'scoped by another lease',
1670
+ path_within_task_scope: 'within task scope',
1671
+ policy_exception_required: 'policy exception required',
1672
+ policy_exception_allowed: 'policy exception allowed',
1673
+ changes_outside_claims: 'changed files outside claims',
1674
+ changes_outside_task_scope: 'changed files outside task scope',
1675
+ missing_expected_tests: 'missing expected tests',
1676
+ missing_expected_docs: 'missing expected docs',
1677
+ missing_expected_source_changes: 'missing expected source changes',
1678
+ objective_not_evidenced: 'task objective not evidenced',
1679
+ no_changes_detected: 'no changes detected',
1680
+ task_execution_timeout: 'task execution timed out',
1681
+ task_failed: 'task failed',
1682
+ agent_command_failed: 'agent command failed',
1683
+ rejected: 'rejected',
1684
+ };
1685
+ return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
1686
+ }
1687
+
1688
+ function nextStepForReason(reasonCode) {
1689
+ const actions = {
1690
+ no_active_lease: 'reacquire the task or lease before writing',
1691
+ lease_expired: 'refresh or reacquire the lease, then retry',
1692
+ worktree_mismatch: 'run the task from the assigned worktree',
1693
+ path_not_claimed: 'claim the file before editing it',
1694
+ path_claimed_by_other_lease: 'wait for the other task or pick a different file',
1695
+ changes_outside_claims: 'claim all edited files or narrow the task scope',
1696
+ changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
1697
+ missing_expected_tests: 'add test coverage before rerunning',
1698
+ missing_expected_docs: 'add the expected docs change before rerunning',
1699
+ missing_expected_source_changes: 'make a source change inside the task scope',
1700
+ objective_not_evidenced: 'align the output more closely to the task objective',
1701
+ no_changes_detected: 'produce a tracked change or close the task differently',
1702
+ task_execution_timeout: 'raise the timeout or reduce task size',
1703
+ agent_command_failed: 'inspect stderr/stdout and rerun the agent',
1704
+ };
1705
+ return actions[reasonCode] || null;
1706
+ }
1707
+
1708
+ function latestTaskFailure(task) {
1709
+ const failureLine = String(task.description || '')
458
1710
  .split('\n')
459
1711
  .map((line) => line.trim())
460
1712
  .filter(Boolean)
@@ -515,6 +1767,7 @@ function commandForFailedTask(task, failure) {
515
1767
  }
516
1768
 
517
1769
  function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
1770
+ const changePolicy = loadChangePolicy(repoRoot);
518
1771
  const failedTasks = tasks
519
1772
  .filter((task) => task.status === 'failed')
520
1773
  .map((task) => {
@@ -564,6 +1817,7 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
564
1817
  next_step: 'review the overlapping branches before merge',
565
1818
  }));
566
1819
 
1820
+ const staleClusters = buildStaleClusters(aiGate.dependency_invalidations || []);
567
1821
  const attention = [
568
1822
  ...staleLeases.map((lease) => ({
569
1823
  kind: 'stale_lease',
@@ -651,14 +1905,17 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
651
1905
  });
652
1906
  }
653
1907
 
654
- for (const invalidation of aiGate.dependency_invalidations || []) {
1908
+ for (const cluster of staleClusters) {
655
1909
  attention.push({
656
1910
  kind: 'dependency_invalidation',
657
- title: invalidation.summary,
658
- detail: `${invalidation.source_worktree || 'unknown'} -> ${invalidation.affected_worktree || 'unknown'} (${invalidation.stale_area})`,
659
- next_step: 'rerun or re-review the stale task before merge',
660
- command: invalidation.affected_pipeline_id ? `switchman pipeline status ${invalidation.affected_pipeline_id}` : 'switchman gate ai',
661
- severity: invalidation.severity === 'blocked' ? 'block' : 'warn',
1911
+ title: cluster.title,
1912
+ detail: cluster.detail,
1913
+ next_step: cluster.next_step,
1914
+ command: cluster.command,
1915
+ severity: cluster.severity,
1916
+ affected_pipeline_id: cluster.affected_pipeline_id,
1917
+ affected_task_ids: cluster.affected_task_ids,
1918
+ invalidation_count: cluster.invalidation_count,
662
1919
  });
663
1920
  }
664
1921
 
@@ -668,6 +1925,16 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
668
1925
  ? 'warn'
669
1926
  : 'healthy';
670
1927
 
1928
+ const repoPolicyState = summarizePipelinePolicyState(db, {
1929
+ tasks,
1930
+ counts: {
1931
+ done: tasks.filter((task) => task.status === 'done').length,
1932
+ in_progress: tasks.filter((task) => task.status === 'in_progress').length,
1933
+ pending: tasks.filter((task) => task.status === 'pending').length,
1934
+ failed: tasks.filter((task) => task.status === 'failed').length,
1935
+ },
1936
+ }, changePolicy, aiGate.boundary_validations || []);
1937
+
671
1938
  return {
672
1939
  repo_root: repoRoot,
673
1940
  health,
@@ -707,8 +1974,10 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
707
1974
  ai_gate_status: aiGate.status,
708
1975
  boundary_validations: aiGate.boundary_validations || [],
709
1976
  dependency_invalidations: aiGate.dependency_invalidations || [],
1977
+ stale_clusters: staleClusters,
710
1978
  compliance: scanReport.complianceSummary,
711
1979
  semantic_conflicts: scanReport.semanticConflicts || [],
1980
+ policy_state: repoPolicyState,
712
1981
  },
713
1982
  next_steps: attention.length > 0
714
1983
  ? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
@@ -776,6 +2045,20 @@ function buildUnifiedStatusReport({
776
2045
  ...(queueItems.length > 0 ? ['switchman queue status'] : []),
777
2046
  ...(queueSummary.next ? ['switchman queue run'] : []),
778
2047
  ].filter(Boolean);
2048
+ const isFirstRunReady = tasks.length === 0
2049
+ && doctorReport.active_work.length === 0
2050
+ && queueItems.length === 0
2051
+ && claims.length === 0;
2052
+ const defaultNextSteps = isFirstRunReady
2053
+ ? [
2054
+ 'add a first task with `switchman task add "Your first task" --priority 8`',
2055
+ 'keep `switchman status --watch` open while agents start work',
2056
+ 'run `switchman demo` if you want the shortest proof before using a real repo',
2057
+ ]
2058
+ : ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'];
2059
+ const defaultSuggestedCommands = isFirstRunReady
2060
+ ? ['switchman task add "Your first task" --priority 8', 'switchman status --watch', 'switchman demo']
2061
+ : ['switchman gate ci', 'switchman scan'];
779
2062
 
780
2063
  return {
781
2064
  generated_at: new Date().toISOString(),
@@ -789,6 +2072,8 @@ function buildUnifiedStatusReport({
789
2072
  ? 'Repo needs attention before more work or merge.'
790
2073
  : attention.some((item) => item.severity === 'warn')
791
2074
  ? 'Repo is running, but a few items need review.'
2075
+ : isFirstRunReady
2076
+ ? 'Switchman is set up and ready. Add a task or run the demo to start.'
792
2077
  : 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
793
2078
  lease_policy: leasePolicy,
794
2079
  counts: {
@@ -812,10 +2097,10 @@ function buildUnifiedStatusReport({
812
2097
  file_path: claim.file_path,
813
2098
  })),
814
2099
  next_steps: [...new Set([
815
- ...doctorReport.next_steps,
2100
+ ...(attention.length > 0 ? doctorReport.next_steps : defaultNextSteps),
816
2101
  ...queueAttention.map((item) => item.next_step),
817
2102
  ])].slice(0, 6),
818
- suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
2103
+ suggested_commands: [...new Set(attention.length > 0 ? suggestedCommands : defaultSuggestedCommands)].slice(0, 6),
819
2104
  };
820
2105
  }
821
2106
 
@@ -884,7 +2169,15 @@ function renderUnifiedStatusReport(report) {
884
2169
  ? ('title' in focusItem
885
2170
  ? `${focusItem.title}${focusItem.detail ? ` ${chalk.dim(`• ${focusItem.detail}`)}` : ''}`
886
2171
  : `${focusItem.title} ${chalk.dim(focusItem.id)}`)
2172
+ : report.counts.pending === 0 && report.counts.in_progress === 0 && report.queue.items.length === 0
2173
+ ? 'Nothing active yet. Add a task or run the demo to start.'
887
2174
  : 'Nothing urgent. Safe to keep parallel work moving.';
2175
+ const primaryCommand = ('command' in (focusItem || {}) && focusItem?.command)
2176
+ ? focusItem.command
2177
+ : report.suggested_commands[0] || 'switchman status --watch';
2178
+ const nextStepLine = ('next_step' in (focusItem || {}) && focusItem?.next_step)
2179
+ ? focusItem.next_step
2180
+ : report.next_steps[0] || 'Keep work moving and check back here if anything blocks.';
888
2181
  const queueLoad = queueCounts.queued + queueCounts.retrying + queueCounts.merging + queueCounts.blocked;
889
2182
  const landingLabel = report.merge_readiness.ci_gate_ok ? 'ready' : 'hold';
890
2183
 
@@ -914,8 +2207,14 @@ function renderUnifiedStatusReport(report) {
914
2207
  { label: 'merging', value: queueCounts.merging, color: chalk.blue },
915
2208
  { label: 'merged', value: queueCounts.merged, color: chalk.green },
916
2209
  ]));
917
- console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
2210
+ console.log(`${chalk.bold('Now:')} ${report.summary}`);
2211
+ console.log(`${chalk.bold('Attention:')} ${focusLine}`);
2212
+ console.log(`${chalk.bold('Run next:')} ${chalk.cyan(primaryCommand)}`);
2213
+ console.log(`${chalk.dim('why:')} ${nextStepLine}`);
918
2214
  console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
2215
+ if (report.merge_readiness.policy_state?.active) {
2216
+ console.log(chalk.dim(`change policy: ${report.merge_readiness.policy_state.domains.join(', ')} • ${report.merge_readiness.policy_state.enforcement} • missing ${report.merge_readiness.policy_state.missing_task_types.join(', ') || 'none'}`));
2217
+ }
919
2218
 
920
2219
  const runningLines = report.active_work.length > 0
921
2220
  ? report.active_work.slice(0, 5).map((item) => {
@@ -955,8 +2254,14 @@ function renderUnifiedStatusReport(report) {
955
2254
  const queueLines = report.queue.items.length > 0
956
2255
  ? [
957
2256
  ...(report.queue.summary.next
958
- ? [`${chalk.dim('next:')} ${report.queue.summary.next.id} ${report.queue.summary.next.source_type}:${report.queue.summary.next.source_ref} ${chalk.dim(`retries:${report.queue.summary.next.retry_count}/${report.queue.summary.next.max_retries}`)}`]
2257
+ ? [
2258
+ `${chalk.dim('next:')} ${report.queue.summary.next.id} ${report.queue.summary.next.source_type}:${report.queue.summary.next.source_ref} ${chalk.dim(`retries:${report.queue.summary.next.retry_count}/${report.queue.summary.next.max_retries}`)}${report.queue.summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${report.queue.summary.next.queue_assessment.goal_priority}`)}` : ''}${report.queue.summary.next.queue_assessment?.integration_risk && report.queue.summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${report.queue.summary.next.queue_assessment.integration_risk}`)}` : ''}`,
2259
+ ...(report.queue.summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${report.queue.summary.next.recommendation.summary}`] : []),
2260
+ ]
959
2261
  : []),
2262
+ ...report.queue.summary.held_back
2263
+ .slice(0, 2)
2264
+ .map((item) => ` ${chalk.dim(item.recommendation?.action === 'escalate' ? 'escalate:' : 'hold:')} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(item.recommendation?.summary || item.queue_assessment?.reason || '')}`),
960
2265
  ...report.queue.items
961
2266
  .filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
962
2267
  .slice(0, 4)
@@ -969,6 +2274,35 @@ function renderUnifiedStatusReport(report) {
969
2274
  ]
970
2275
  : [chalk.dim('No queued merges.')];
971
2276
 
2277
+ const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
2278
+ ? report.merge_readiness.stale_clusters.slice(0, 4).flatMap((cluster) => {
2279
+ const lines = [`${renderChip(cluster.severity === 'block' ? 'STALE' : 'WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], cluster.severity === 'block' ? chalk.red : chalk.yellow)} ${cluster.title}`];
2280
+ lines.push(` ${chalk.dim(cluster.detail)}`);
2281
+ if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
2282
+ lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
2283
+ lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
2284
+ lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
2285
+ lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
2286
+ return lines;
2287
+ })
2288
+ : [chalk.green('No stale dependency clusters.')];
2289
+
2290
+ const policyLines = report.merge_readiness.policy_state?.active
2291
+ ? [
2292
+ `${renderChip(report.merge_readiness.policy_state.enforcement.toUpperCase(), report.merge_readiness.policy_state.domains.join(','), report.merge_readiness.policy_state.enforcement === 'blocked' ? chalk.red : chalk.yellow)} ${report.merge_readiness.policy_state.summary}`,
2293
+ ` ${chalk.dim('required:')} ${report.merge_readiness.policy_state.required_task_types.join(', ') || 'none'}`,
2294
+ ` ${chalk.dim('missing:')} ${report.merge_readiness.policy_state.missing_task_types.join(', ') || 'none'}`,
2295
+ ` ${chalk.dim('overridden:')} ${report.merge_readiness.policy_state.overridden_task_types.join(', ') || 'none'}`,
2296
+ ...report.merge_readiness.policy_state.requirement_status
2297
+ .filter((requirement) => requirement.evidence.length > 0)
2298
+ .slice(0, 3)
2299
+ .map((requirement) => ` ${chalk.dim(`${requirement.task_type}:`)} ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path})` : entry.task_id).join(', ')}`),
2300
+ ...report.merge_readiness.policy_state.overrides
2301
+ .slice(0, 3)
2302
+ .map((entry) => ` ${chalk.dim(`override ${entry.id}:`)} ${(entry.task_types || []).join(', ') || 'all'} by ${entry.approved_by || 'unknown'}`),
2303
+ ]
2304
+ : [chalk.green('No explicit change policy requirements are active.')];
2305
+
972
2306
  const nextActionLines = [
973
2307
  ...(report.next_up.length > 0
974
2308
  ? report.next_up.map((task) => `${renderChip('NEXT', `p${task.priority}`, chalk.green)} ${task.title} ${chalk.dim(task.id)}`)
@@ -981,6 +2315,8 @@ function renderUnifiedStatusReport(report) {
981
2315
  renderPanel('Running now', runningLines, chalk.cyan),
982
2316
  renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
983
2317
  renderPanel('Warnings', warningLines, warningItems.length > 0 ? chalk.yellow : chalk.green),
2318
+ renderPanel('Stale clusters', staleClusterLines, (report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green)),
2319
+ renderPanel('Policy', policyLines, report.merge_readiness.policy_state?.active && report.merge_readiness.policy_state.missing_task_types.length > 0 ? chalk.red : chalk.green),
984
2320
  renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
985
2321
  renderPanel('Next action', nextActionLines, chalk.green),
986
2322
  ];
@@ -1095,6 +2431,7 @@ program
1095
2431
  program.showHelpAfterError('(run with --help for usage examples)');
1096
2432
  program.addHelpText('after', `
1097
2433
  Start here:
2434
+ switchman demo
1098
2435
  switchman setup --agents 5
1099
2436
  switchman status --watch
1100
2437
  switchman gate ci
@@ -1109,6 +2446,50 @@ Docs:
1109
2446
  docs/setup-cursor.md
1110
2447
  `);
1111
2448
 
2449
+ program
2450
+ .command('demo')
2451
+ .description('Create a throwaway repo that proves overlapping claims are blocked and safe landing works')
2452
+ .option('--path <dir>', 'Directory to create the demo repo in')
2453
+ .option('--cleanup', 'Delete the demo repo after the run finishes')
2454
+ .option('--json', 'Output raw JSON')
2455
+ .addHelpText('after', `
2456
+ Examples:
2457
+ switchman demo
2458
+ switchman demo --path /tmp/switchman-demo
2459
+ `)
2460
+ .action(async (opts) => {
2461
+ try {
2462
+ const result = await runDemoScenario({
2463
+ repoPath: opts.path || null,
2464
+ cleanup: Boolean(opts.cleanup),
2465
+ });
2466
+
2467
+ if (opts.json) {
2468
+ console.log(JSON.stringify(result, null, 2));
2469
+ return;
2470
+ }
2471
+
2472
+ console.log(`${chalk.green('✓')} Demo repo ready`);
2473
+ console.log(` ${chalk.dim('path:')} ${result.repo_path}`);
2474
+ console.log(` ${chalk.dim('proof:')} agent2 was blocked from ${chalk.cyan(result.overlap_demo.blocked_path)}`);
2475
+ console.log(` ${chalk.dim('safe reroute:')} agent2 claimed ${chalk.cyan(result.overlap_demo.safe_path)} instead`);
2476
+ console.log(` ${chalk.dim('landing:')} ${result.queue.processed.filter((entry) => entry.status === 'merged').length} queue item(s) merged safely`);
2477
+ console.log(` ${chalk.dim('final gate:')} ${result.final_gate.ok ? chalk.green('clean') : chalk.red('attention needed')}`);
2478
+ console.log('');
2479
+ console.log(chalk.bold('What to do next:'));
2480
+ for (const step of result.next_steps) {
2481
+ console.log(` ${chalk.cyan(step)}`);
2482
+ }
2483
+ if (!opts.cleanup) {
2484
+ console.log('');
2485
+ console.log(chalk.dim('The demo repo stays on disk so you can inspect it, record it, or keep experimenting.'));
2486
+ }
2487
+ } catch (err) {
2488
+ printErrorWithNext(err.message, 'switchman demo --json');
2489
+ process.exitCode = 1;
2490
+ }
2491
+ });
2492
+
1112
2493
  // ── init ──────────────────────────────────────────────────────────────────────
1113
2494
 
1114
2495
  program
@@ -1232,11 +2613,11 @@ Examples:
1232
2613
 
1233
2614
  console.log('');
1234
2615
  console.log(chalk.bold('Next steps:'));
1235
- console.log(` 1. Add your tasks:`);
2616
+ console.log(` 1. Add a first task:`);
1236
2617
  console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
1237
- console.log(` 2. Open Claude Code or Cursor in each folder above — the local MCP config will attach Switchman automatically`);
1238
- console.log(` 3. Check status at any time:`);
1239
- console.log(` ${chalk.cyan('switchman status')}`);
2618
+ console.log(` 2. Open Claude Code or Cursor in the workspaces above — the local MCP config will attach Switchman automatically`);
2619
+ console.log(` 3. Keep the repo dashboard open while work starts:`);
2620
+ console.log(` ${chalk.cyan('switchman status --watch')}`);
1240
2621
  console.log('');
1241
2622
 
1242
2623
  const verification = collectSetupVerification(repoRoot);
@@ -1497,6 +2878,67 @@ taskCmd
1497
2878
  }
1498
2879
  });
1499
2880
 
2881
+ taskCmd
2882
+ .command('retry <taskId>')
2883
+ .description('Return a failed or stale completed task to pending so it can be revalidated')
2884
+ .option('--reason <text>', 'Reason to record for the retry')
2885
+ .option('--json', 'Output raw JSON')
2886
+ .action((taskId, opts) => {
2887
+ const repoRoot = getRepo();
2888
+ const db = getDb(repoRoot);
2889
+ const task = retryTask(db, taskId, opts.reason || 'manual retry');
2890
+ db.close();
2891
+
2892
+ if (!task) {
2893
+ printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
2894
+ process.exitCode = 1;
2895
+ return;
2896
+ }
2897
+
2898
+ if (opts.json) {
2899
+ console.log(JSON.stringify(task, null, 2));
2900
+ return;
2901
+ }
2902
+
2903
+ console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
2904
+ console.log(` ${chalk.dim('title:')} ${task.title}`);
2905
+ console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
2906
+ });
2907
+
2908
+ taskCmd
2909
+ .command('retry-stale')
2910
+ .description('Return all currently stale tasks to pending so they can be revalidated together')
2911
+ .option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
2912
+ .option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
2913
+ .option('--json', 'Output raw JSON')
2914
+ .action((opts) => {
2915
+ const repoRoot = getRepo();
2916
+ const db = getDb(repoRoot);
2917
+ const result = retryStaleTasks(db, {
2918
+ pipelineId: opts.pipeline || null,
2919
+ reason: opts.reason,
2920
+ });
2921
+ db.close();
2922
+
2923
+ if (opts.json) {
2924
+ console.log(JSON.stringify(result, null, 2));
2925
+ return;
2926
+ }
2927
+
2928
+ if (result.retried.length === 0) {
2929
+ const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
2930
+ console.log(chalk.dim(`No stale tasks to retry${scope}.`));
2931
+ return;
2932
+ }
2933
+
2934
+ console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
2935
+ if (result.pipeline_id) {
2936
+ console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
2937
+ }
2938
+ console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
2939
+ console.log(`${chalk.yellow('next:')} switchman status`);
2940
+ });
2941
+
1500
2942
  taskCmd
1501
2943
  .command('done <taskId>')
1502
2944
  .description('Mark a task as complete and release all file claims')
@@ -1584,8 +3026,13 @@ Examples:
1584
3026
  switchman queue add feature/auth-hardening
1585
3027
  switchman queue add --worktree agent2
1586
3028
  switchman queue add --pipeline pipe-123
3029
+
3030
+ Pipeline landing rule:
3031
+ switchman queue add --pipeline <id>
3032
+ lands the pipeline's inferred landing branch.
3033
+ If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
1587
3034
  `)
1588
- .action((branch, opts) => {
3035
+ .action(async (branch, opts) => {
1589
3036
  const repoRoot = getRepo();
1590
3037
  const db = getDb(repoRoot);
1591
3038
 
@@ -1605,13 +3052,29 @@ Examples:
1605
3052
  submittedBy: opts.submittedBy || null,
1606
3053
  };
1607
3054
  } else if (opts.pipeline) {
3055
+ const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
3056
+ if (!policyGate.ok) {
3057
+ throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
3058
+ }
3059
+ const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
3060
+ baseBranch: opts.target || 'main',
3061
+ requireCompleted: true,
3062
+ allowCurrentBranchFallback: false,
3063
+ });
1608
3064
  payload = {
1609
3065
  sourceType: 'pipeline',
1610
- sourceRef: opts.pipeline,
3066
+ sourceRef: landingTarget.branch,
1611
3067
  sourcePipelineId: opts.pipeline,
3068
+ sourceWorktree: landingTarget.worktree || null,
1612
3069
  targetBranch: opts.target,
1613
3070
  maxRetries: opts.maxRetries,
1614
3071
  submittedBy: opts.submittedBy || null,
3072
+ eventDetails: policyGate.override_applied
3073
+ ? {
3074
+ policy_override_summary: policyGate.override_summary,
3075
+ overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
3076
+ }
3077
+ : null,
1615
3078
  };
1616
3079
  } else if (branch) {
1617
3080
  payload = {
@@ -1636,6 +3099,9 @@ Examples:
1636
3099
  console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
1637
3100
  console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
1638
3101
  if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
3102
+ if (payload.eventDetails?.policy_override_summary) {
3103
+ console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
3104
+ }
1639
3105
  } catch (err) {
1640
3106
  db.close();
1641
3107
  printErrorWithNext(err.message, 'switchman queue add --help');
@@ -1667,7 +3133,9 @@ queueCmd
1667
3133
  for (const item of items) {
1668
3134
  const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
1669
3135
  const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
1670
- console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}`);
3136
+ const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
3137
+ const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
3138
+ console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
1671
3139
  if (item.last_error_summary) {
1672
3140
  console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
1673
3141
  }
@@ -1698,7 +3166,7 @@ What it helps you answer:
1698
3166
  const repoRoot = getRepo();
1699
3167
  const db = getDb(repoRoot);
1700
3168
  const items = listMergeQueue(db);
1701
- const summary = buildQueueStatusSummary(items);
3169
+ const summary = buildQueueStatusSummary(items, { db, repoRoot });
1702
3170
  const recentEvents = items.slice(0, 5).flatMap((item) =>
1703
3171
  listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
1704
3172
  ).sort((a, b) => b.id - a.id).slice(0, 8);
@@ -1709,9 +3177,14 @@ What it helps you answer:
1709
3177
  return;
1710
3178
  }
1711
3179
 
1712
- const queueHealth = summary.counts.blocked > 0 ? 'block' : summary.counts.retrying > 0 ? 'warn' : 'healthy';
3180
+ const queueHealth = summary.counts.blocked > 0
3181
+ ? 'block'
3182
+ : summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
3183
+ ? 'warn'
3184
+ : 'healthy';
1713
3185
  const queueHealthColor = colorForHealth(queueHealth);
1714
- const focus = summary.blocked[0] || summary.retrying[0] || summary.next || null;
3186
+ const retryingItems = items.filter((item) => item.status === 'retrying');
3187
+ const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
1715
3188
  const focusLine = focus
1716
3189
  ? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
1717
3190
  : 'Nothing waiting. Landing queue is clear.';
@@ -1723,6 +3196,9 @@ What it helps you answer:
1723
3196
  console.log(renderSignalStrip([
1724
3197
  renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
1725
3198
  renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
3199
+ renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
3200
+ renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
3201
+ renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
1726
3202
  renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
1727
3203
  renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
1728
3204
  renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
@@ -1737,11 +3213,23 @@ What it helps you answer:
1737
3213
 
1738
3214
  const queueFocusLines = summary.next
1739
3215
  ? [
1740
- `${renderChip('NEXT', summary.next.id, chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}`,
1741
- ` ${chalk.yellow('run:')} switchman queue run`,
3216
+ `${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
3217
+ ...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
3218
+ ...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
3219
+ ` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
1742
3220
  ]
1743
3221
  : [chalk.dim('No queued landing work right now.')];
1744
3222
 
3223
+ const queueHeldBackLines = summary.held_back.length > 0
3224
+ ? summary.held_back.flatMap((item) => {
3225
+ const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
3226
+ if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
3227
+ if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
3228
+ if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
3229
+ return lines;
3230
+ })
3231
+ : [chalk.green('Nothing significant is being held back.')];
3232
+
1745
3233
  const queueBlockedLines = summary.blocked.length > 0
1746
3234
  ? summary.blocked.slice(0, 4).flatMap((item) => {
1747
3235
  const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
@@ -1751,13 +3239,14 @@ What it helps you answer:
1751
3239
  })
1752
3240
  : [chalk.green('Nothing blocked.')];
1753
3241
 
1754
- const queueWatchLines = items.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
3242
+ const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
1755
3243
  ? items
1756
- .filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status))
3244
+ .filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
1757
3245
  .slice(0, 4)
1758
3246
  .flatMap((item) => {
1759
- const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' ? chalk.yellow : chalk.blue)} ${item.source_type}:${item.source_ref}`];
3247
+ const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
1760
3248
  if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
3249
+ if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
1761
3250
  return lines;
1762
3251
  })
1763
3252
  : [chalk.green('No in-flight queue items right now.')];
@@ -1768,9 +3257,23 @@ What it helps you answer:
1768
3257
  ...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
1769
3258
  ];
1770
3259
 
3260
+ const queuePlanLines = [
3261
+ ...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
3262
+ ...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
3263
+ ...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
3264
+ ...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
3265
+ ...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
3266
+ ];
3267
+ const queueSequenceLines = summary.recommended_sequence?.length > 0
3268
+ ? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
3269
+ : [chalk.green('No recommended sequence beyond the current landing focus.')];
3270
+
1771
3271
  console.log('');
1772
3272
  for (const block of [
1773
3273
  renderPanel('Landing focus', queueFocusLines, chalk.green),
3274
+ renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
3275
+ renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
3276
+ renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
1774
3277
  renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
1775
3278
  renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
1776
3279
  renderPanel('Next commands', queueCommandLines, chalk.cyan),
@@ -1791,6 +3294,8 @@ queueCmd
1791
3294
  .command('run')
1792
3295
  .description('Process landing-queue items one at a time')
1793
3296
  .option('--max-items <n>', 'Maximum queue items to process', '1')
3297
+ .option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
3298
+ .option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
1794
3299
  .option('--target <branch>', 'Default target branch', 'main')
1795
3300
  .option('--watch', 'Keep polling for new queue items')
1796
3301
  .option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
@@ -1799,6 +3304,7 @@ queueCmd
1799
3304
  .addHelpText('after', `
1800
3305
  Examples:
1801
3306
  switchman queue run
3307
+ switchman queue run --follow-plan --merge-budget 2
1802
3308
  switchman queue run --watch
1803
3309
  switchman queue run --watch --watch-interval-ms 1000
1804
3310
  `)
@@ -1807,12 +3313,21 @@ Examples:
1807
3313
 
1808
3314
  try {
1809
3315
  const watch = Boolean(opts.watch);
3316
+ const followPlan = Boolean(opts.followPlan);
1810
3317
  const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
1811
3318
  const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
3319
+ const mergeBudget = opts.mergeBudget !== undefined
3320
+ ? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
3321
+ : null;
1812
3322
  const aggregate = {
1813
3323
  processed: [],
1814
3324
  cycles: 0,
1815
3325
  watch,
3326
+ execution_policy: {
3327
+ follow_plan: followPlan,
3328
+ merge_budget: mergeBudget,
3329
+ merged_count: 0,
3330
+ },
1816
3331
  };
1817
3332
 
1818
3333
  while (true) {
@@ -1820,102 +3335,437 @@ Examples:
1820
3335
  const result = await runMergeQueue(db, repoRoot, {
1821
3336
  maxItems: Number.parseInt(opts.maxItems, 10) || 1,
1822
3337
  targetBranch: opts.target || 'main',
3338
+ followPlan,
3339
+ mergeBudget,
1823
3340
  });
1824
3341
  db.close();
1825
3342
 
1826
3343
  aggregate.processed.push(...result.processed);
1827
3344
  aggregate.summary = result.summary;
3345
+ aggregate.deferred = result.deferred || aggregate.deferred || null;
3346
+ aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
1828
3347
  aggregate.cycles += 1;
1829
3348
 
1830
- if (!watch) break;
1831
- if (maxCycles && aggregate.cycles >= maxCycles) break;
1832
- if (result.processed.length === 0) {
1833
- sleepSync(watchIntervalMs);
1834
- }
1835
- }
3349
+ if (!watch) break;
3350
+ if (maxCycles && aggregate.cycles >= maxCycles) break;
3351
+ if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
3352
+ if (result.processed.length === 0) {
3353
+ sleepSync(watchIntervalMs);
3354
+ }
3355
+ }
3356
+
3357
+ if (opts.json) {
3358
+ console.log(JSON.stringify(aggregate, null, 2));
3359
+ return;
3360
+ }
3361
+
3362
+ if (aggregate.processed.length === 0) {
3363
+ const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
3364
+ if (deferredFocus?.recommendation?.action) {
3365
+ console.log(chalk.yellow('No landing candidate is ready to run right now.'));
3366
+ console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
3367
+ if (followPlan) {
3368
+ console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
3369
+ }
3370
+ if (deferredFocus.recommendation?.summary) {
3371
+ console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
3372
+ }
3373
+ if (deferredFocus.recommendation?.command) {
3374
+ console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
3375
+ }
3376
+ } else {
3377
+ console.log(chalk.dim('No queued merge items.'));
3378
+ }
3379
+ await maybeCaptureTelemetry('queue_used', {
3380
+ watch,
3381
+ cycles: aggregate.cycles,
3382
+ processed_count: 0,
3383
+ merged_count: 0,
3384
+ blocked_count: 0,
3385
+ });
3386
+ return;
3387
+ }
3388
+
3389
+ for (const entry of aggregate.processed) {
3390
+ const item = entry.item;
3391
+ if (entry.status === 'merged') {
3392
+ console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
3393
+ console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
3394
+ } else {
3395
+ console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
3396
+ console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3397
+ if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
3398
+ }
3399
+ }
3400
+
3401
+ if (aggregate.execution_policy.follow_plan) {
3402
+ console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
3403
+ }
3404
+
3405
+ await maybeCaptureTelemetry('queue_used', {
3406
+ watch,
3407
+ cycles: aggregate.cycles,
3408
+ processed_count: aggregate.processed.length,
3409
+ merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
3410
+ blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
3411
+ });
3412
+ } catch (err) {
3413
+ console.error(chalk.red(err.message));
3414
+ process.exitCode = 1;
3415
+ }
3416
+ });
3417
+
3418
+ queueCmd
3419
+ .command('retry <itemId>')
3420
+ .description('Retry a blocked merge queue item')
3421
+ .option('--json', 'Output raw JSON')
3422
+ .action((itemId, opts) => {
3423
+ const repoRoot = getRepo();
3424
+ const db = getDb(repoRoot);
3425
+ const item = retryMergeQueueItem(db, itemId);
3426
+ db.close();
3427
+
3428
+ if (!item) {
3429
+ printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
3430
+ process.exitCode = 1;
3431
+ return;
3432
+ }
3433
+
3434
+ if (opts.json) {
3435
+ console.log(JSON.stringify(item, null, 2));
3436
+ return;
3437
+ }
3438
+
3439
+ console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
3440
+ });
3441
+
3442
+ queueCmd
3443
+ .command('escalate <itemId>')
3444
+ .description('Mark a queue item as needing explicit operator review before landing')
3445
+ .option('--reason <text>', 'Why this item is being escalated')
3446
+ .option('--json', 'Output raw JSON')
3447
+ .action((itemId, opts) => {
3448
+ const repoRoot = getRepo();
3449
+ const db = getDb(repoRoot);
3450
+ const item = escalateMergeQueueItem(db, itemId, {
3451
+ summary: opts.reason || null,
3452
+ nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
3453
+ });
3454
+ db.close();
3455
+
3456
+ if (!item) {
3457
+ printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
3458
+ process.exitCode = 1;
3459
+ return;
3460
+ }
3461
+
3462
+ if (opts.json) {
3463
+ console.log(JSON.stringify(item, null, 2));
3464
+ return;
3465
+ }
3466
+
3467
+ console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
3468
+ if (item.last_error_summary) {
3469
+ console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3470
+ }
3471
+ if (item.next_action) {
3472
+ console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
3473
+ }
3474
+ });
3475
+
3476
+ queueCmd
3477
+ .command('remove <itemId>')
3478
+ .description('Remove a merge queue item')
3479
+ .action((itemId) => {
3480
+ const repoRoot = getRepo();
3481
+ const db = getDb(repoRoot);
3482
+ const item = removeMergeQueueItem(db, itemId);
3483
+ db.close();
3484
+
3485
+ if (!item) {
3486
+ printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
3487
+ process.exitCode = 1;
3488
+ return;
3489
+ }
3490
+
3491
+ console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
3492
+ });
3493
+
3494
+ // ── explain ───────────────────────────────────────────────────────────────────
3495
+
3496
+ const explainCmd = program.command('explain').description('Explain why Switchman blocked something and what to do next');
3497
+ explainCmd.addHelpText('after', `
3498
+ Examples:
3499
+ switchman explain queue mq-123
3500
+ switchman explain claim src/auth/login.js
3501
+ switchman explain history pipe-123
3502
+ `);
3503
+
3504
+ explainCmd
3505
+ .command('queue <itemId>')
3506
+ .description('Explain one landing-queue item in plain English')
3507
+ .option('--json', 'Output raw JSON')
3508
+ .action((itemId, opts) => {
3509
+ const repoRoot = getRepo();
3510
+ const db = getDb(repoRoot);
3511
+
3512
+ try {
3513
+ const report = buildQueueExplainReport(db, repoRoot, itemId);
3514
+ db.close();
3515
+
3516
+ if (opts.json) {
3517
+ console.log(JSON.stringify(report, null, 2));
3518
+ return;
3519
+ }
3520
+
3521
+ console.log(chalk.bold(`Queue item ${report.item.id}`));
3522
+ console.log(` ${chalk.dim('status:')} ${statusBadge(report.item.status)}`.trim());
3523
+ console.log(` ${chalk.dim('source:')} ${report.item.source_type} ${report.item.source_ref}`);
3524
+ console.log(` ${chalk.dim('target:')} ${report.item.target_branch}`);
3525
+ if (report.resolved_source) {
3526
+ console.log(` ${chalk.dim('resolved branch:')} ${chalk.cyan(report.resolved_source.branch)}`);
3527
+ if (report.resolved_source.worktree) {
3528
+ console.log(` ${chalk.dim('resolved worktree:')} ${chalk.cyan(report.resolved_source.worktree)}`);
3529
+ }
3530
+ }
3531
+ if (report.resolution_error) {
3532
+ console.log(` ${chalk.red('why:')} ${report.resolution_error}`);
3533
+ } else if (report.item.last_error_summary) {
3534
+ console.log(` ${chalk.red('why:')} ${report.item.last_error_summary}`);
3535
+ } else {
3536
+ console.log(` ${chalk.dim('why:')} waiting to land`);
3537
+ }
3538
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
3539
+ if (report.recent_events.length > 0) {
3540
+ console.log(chalk.bold('\nRecent events'));
3541
+ for (const event of report.recent_events) {
3542
+ console.log(` ${chalk.dim(event.created_at)} ${event.event_type}${event.status ? ` ${statusBadge(event.status).trim()}` : ''}`);
3543
+ }
3544
+ }
3545
+ } catch (err) {
3546
+ db.close();
3547
+ printErrorWithNext(err.message, 'switchman queue status');
3548
+ process.exitCode = 1;
3549
+ }
3550
+ });
3551
+
3552
+ explainCmd
3553
+ .command('claim <path>')
3554
+ .description('Explain who currently owns a file path')
3555
+ .option('--json', 'Output raw JSON')
3556
+ .action((filePath, opts) => {
3557
+ const repoRoot = getRepo();
3558
+ const db = getDb(repoRoot);
3559
+
3560
+ try {
3561
+ const report = buildClaimExplainReport(db, filePath);
3562
+ db.close();
1836
3563
 
1837
3564
  if (opts.json) {
1838
- console.log(JSON.stringify(aggregate, null, 2));
3565
+ console.log(JSON.stringify(report, null, 2));
1839
3566
  return;
1840
3567
  }
1841
3568
 
1842
- if (aggregate.processed.length === 0) {
1843
- console.log(chalk.dim('No queued merge items.'));
1844
- await maybeCaptureTelemetry('queue_used', {
1845
- watch,
1846
- cycles: aggregate.cycles,
1847
- processed_count: 0,
1848
- merged_count: 0,
1849
- blocked_count: 0,
1850
- });
3569
+ console.log(chalk.bold(`Claim status for ${report.file_path}`));
3570
+ if (report.claims.length === 0 && report.scope_owners.length === 0) {
3571
+ console.log(` ${chalk.green('status:')} unowned`);
3572
+ console.log(` ${chalk.yellow('next:')} switchman claim <taskId> <workspace> ${report.file_path}`);
1851
3573
  return;
1852
3574
  }
1853
3575
 
1854
- for (const entry of aggregate.processed) {
1855
- const item = entry.item;
1856
- if (entry.status === 'merged') {
1857
- console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
1858
- console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
1859
- } else {
1860
- console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
1861
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
1862
- if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
3576
+ if (report.claims.length > 0) {
3577
+ console.log(` ${chalk.red('explicit claim:')}`);
3578
+ for (const claim of report.claims) {
3579
+ console.log(` ${chalk.cyan(claim.worktree)} task:${claim.task_id} ${chalk.dim(`lease:${claim.lease_id}`)}`);
3580
+ console.log(` ${chalk.dim('title:')} ${claim.task_title}`);
1863
3581
  }
1864
3582
  }
1865
3583
 
1866
- await maybeCaptureTelemetry('queue_used', {
1867
- watch,
1868
- cycles: aggregate.cycles,
1869
- processed_count: aggregate.processed.length,
1870
- merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
1871
- blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
1872
- });
3584
+ if (report.scope_owners.length > 0) {
3585
+ console.log(` ${chalk.yellow('task scope owner:')}`);
3586
+ for (const owner of report.scope_owners) {
3587
+ console.log(` ${chalk.cyan(owner.worktree)} task:${owner.task_id} ${chalk.dim(`lease:${owner.lease_id}`)}`);
3588
+ console.log(` ${chalk.dim('title:')} ${owner.task_title}`);
3589
+ }
3590
+ }
3591
+
3592
+ const blockingOwner = report.claims[0] || report.scope_owners[0];
3593
+ if (blockingOwner) {
3594
+ console.log(` ${chalk.yellow('next:')} inspect ${chalk.cyan(blockingOwner.worktree)} or choose a different file before claiming this path`);
3595
+ }
1873
3596
  } catch (err) {
1874
- console.error(chalk.red(err.message));
3597
+ db.close();
3598
+ printErrorWithNext(err.message, 'switchman status');
1875
3599
  process.exitCode = 1;
1876
3600
  }
1877
3601
  });
1878
3602
 
1879
- queueCmd
1880
- .command('retry <itemId>')
1881
- .description('Retry a blocked merge queue item')
3603
+ explainCmd
3604
+ .command('stale [taskId]')
3605
+ .description('Explain why a task or pipeline is stale and how to revalidate it')
3606
+ .option('--pipeline <pipelineId>', 'Explain stale invalidations for a whole pipeline')
1882
3607
  .option('--json', 'Output raw JSON')
1883
- .action((itemId, opts) => {
3608
+ .action((taskId, opts) => {
1884
3609
  const repoRoot = getRepo();
1885
3610
  const db = getDb(repoRoot);
1886
- const item = retryMergeQueueItem(db, itemId);
1887
- db.close();
1888
3611
 
1889
- if (!item) {
1890
- printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
3612
+ try {
3613
+ if (!opts.pipeline && !taskId) {
3614
+ throw new Error('Pass a task id or `--pipeline <id>`.');
3615
+ }
3616
+ const report = opts.pipeline
3617
+ ? buildStalePipelineExplainReport(db, opts.pipeline)
3618
+ : buildStaleTaskExplainReport(db, taskId);
3619
+ db.close();
3620
+
3621
+ if (opts.json) {
3622
+ console.log(JSON.stringify(report, null, 2));
3623
+ return;
3624
+ }
3625
+
3626
+ if (opts.pipeline) {
3627
+ console.log(chalk.bold(`Stale status for pipeline ${report.pipeline_id}`));
3628
+ if (report.stale_clusters.length === 0) {
3629
+ console.log(` ${chalk.green('state:')} no active dependency invalidations`);
3630
+ return;
3631
+ }
3632
+ for (const cluster of report.stale_clusters) {
3633
+ console.log(` ${cluster.severity === 'block' ? chalk.red('why:') : chalk.yellow('why:')} ${cluster.title}`);
3634
+ console.log(` ${chalk.dim('source worktrees:')} ${cluster.source_worktrees.join(', ') || 'unknown'}`);
3635
+ console.log(` ${chalk.dim('affected tasks:')} ${cluster.affected_task_ids.join(', ')}`);
3636
+ console.log(` ${chalk.dim('stale areas:')} ${cluster.stale_areas.join(', ')}`);
3637
+ }
3638
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
3639
+ return;
3640
+ }
3641
+
3642
+ console.log(chalk.bold(`Stale status for ${report.task.id}`));
3643
+ console.log(` ${chalk.dim('title:')} ${report.task.title}`);
3644
+ console.log(` ${chalk.dim('status:')} ${statusBadge(report.task.status)}`.trim());
3645
+ if (report.invalidations.length === 0) {
3646
+ console.log(` ${chalk.green('state:')} no active dependency invalidations`);
3647
+ return;
3648
+ }
3649
+ for (const invalidation of report.invalidations) {
3650
+ console.log(` ${chalk.red('why:')} ${invalidation.summary}`);
3651
+ console.log(` ${chalk.dim('source task:')} ${invalidation.source_task_id}`);
3652
+ console.log(` ${chalk.dim('stale area:')} ${invalidation.stale_area}`);
3653
+ }
3654
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
3655
+ } catch (err) {
3656
+ db.close();
3657
+ printErrorWithNext(err.message, opts.pipeline ? 'switchman doctor' : 'switchman doctor');
1891
3658
  process.exitCode = 1;
1892
- return;
1893
3659
  }
3660
+ });
1894
3661
 
1895
- if (opts.json) {
1896
- console.log(JSON.stringify(item, null, 2));
1897
- return;
1898
- }
3662
+ explainCmd
3663
+ .command('history <pipelineId>')
3664
+ .description('Explain the recent change timeline for one pipeline')
3665
+ .option('--json', 'Output raw JSON')
3666
+ .action((pipelineId, opts) => {
3667
+ const repoRoot = getRepo();
3668
+ const db = getDb(repoRoot);
1899
3669
 
1900
- console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
3670
+ try {
3671
+ const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
3672
+ db.close();
3673
+
3674
+ if (opts.json) {
3675
+ console.log(JSON.stringify(report, null, 2));
3676
+ return;
3677
+ }
3678
+
3679
+ console.log(chalk.bold(`History for pipeline ${report.pipeline_id}`));
3680
+ console.log(` ${chalk.dim('title:')} ${report.title}`);
3681
+ console.log(` ${chalk.dim('tasks:')} pending ${report.counts.pending} • in progress ${report.counts.in_progress} • done ${report.counts.done} • failed ${report.counts.failed}`);
3682
+ if (report.current.queue_items.length > 0) {
3683
+ const queueSummary = report.current.queue_items
3684
+ .map((item) => `${item.id} ${item.status}`)
3685
+ .join(', ');
3686
+ console.log(` ${chalk.dim('queue:')} ${queueSummary}`);
3687
+ }
3688
+ if (report.current.stale_clusters.length > 0) {
3689
+ console.log(` ${chalk.red('stale:')} ${report.current.stale_clusters[0].title}`);
3690
+ }
3691
+ if (report.current.landing.last_failure) {
3692
+ console.log(` ${chalk.red('landing:')} ${humanizeReasonCode(report.current.landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`);
3693
+ } else if (report.current.landing.stale) {
3694
+ console.log(` ${chalk.yellow('landing:')} synthetic landing branch is stale`);
3695
+ } else {
3696
+ console.log(` ${chalk.dim('landing:')} ${report.current.landing.branch} (${report.current.landing.strategy})`);
3697
+ }
3698
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
3699
+
3700
+ console.log(chalk.bold('\nTimeline'));
3701
+ for (const event of report.events.slice(-20)) {
3702
+ const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
3703
+ console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
3704
+ console.log(` ${event.summary}`);
3705
+ if (event.next_action) {
3706
+ console.log(` ${chalk.dim(`next: ${event.next_action}`)}`);
3707
+ }
3708
+ }
3709
+ } catch (err) {
3710
+ db.close();
3711
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
3712
+ process.exitCode = 1;
3713
+ }
1901
3714
  });
1902
3715
 
1903
- queueCmd
1904
- .command('remove <itemId>')
1905
- .description('Remove a merge queue item')
1906
- .action((itemId) => {
3716
+ explainCmd
3717
+ .command('landing <pipelineId>')
3718
+ .description('Explain the current landing branch state for a pipeline')
3719
+ .option('--json', 'Output raw JSON')
3720
+ .action((pipelineId, opts) => {
1907
3721
  const repoRoot = getRepo();
1908
3722
  const db = getDb(repoRoot);
1909
- const item = removeMergeQueueItem(db, itemId);
1910
- db.close();
1911
3723
 
1912
- if (!item) {
1913
- printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
3724
+ try {
3725
+ const report = getPipelineLandingExplainReport(db, repoRoot, pipelineId);
3726
+ db.close();
3727
+
3728
+ if (opts.json) {
3729
+ console.log(JSON.stringify(report, null, 2));
3730
+ return;
3731
+ }
3732
+
3733
+ console.log(chalk.bold(`Landing status for ${report.pipeline_id}`));
3734
+ console.log(` ${chalk.dim('branch:')} ${report.landing.branch}`);
3735
+ console.log(` ${chalk.dim('strategy:')} ${report.landing.strategy}`);
3736
+ if (report.landing.last_failure) {
3737
+ console.log(` ${chalk.red('failure:')} ${humanizeReasonCode(report.landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`);
3738
+ if (report.landing.last_failure.failed_branch) {
3739
+ console.log(` ${chalk.dim('failed branch:')} ${report.landing.last_failure.failed_branch}`);
3740
+ }
3741
+ if (report.landing.last_failure.conflicting_files?.length > 0) {
3742
+ console.log(` ${chalk.dim('conflicts:')} ${report.landing.last_failure.conflicting_files.join(', ')}`);
3743
+ }
3744
+ if (report.landing.last_failure.output) {
3745
+ console.log(` ${chalk.dim('details:')} ${report.landing.last_failure.output.split('\n')[0]}`);
3746
+ }
3747
+ } else if (report.landing.last_recovery?.recovery_path) {
3748
+ console.log(` ${chalk.dim('recovery path:')} ${report.landing.last_recovery.recovery_path}`);
3749
+ if (report.landing.last_recovery.state?.status) {
3750
+ console.log(` ${chalk.dim('recovery state:')} ${report.landing.last_recovery.state.status}`);
3751
+ }
3752
+ } else if (report.landing.last_materialized?.head_commit) {
3753
+ console.log(` ${chalk.green('head:')} ${report.landing.last_materialized.head_commit.slice(0, 12)}`);
3754
+ } else if (report.landing.stale_reasons.length > 0) {
3755
+ for (const reason of report.landing.stale_reasons) {
3756
+ console.log(` ${chalk.red('why:')} ${reason.summary}`);
3757
+ }
3758
+ } else if (report.landing.last_materialized) {
3759
+ console.log(` ${chalk.green('state:')} landing branch is current`);
3760
+ } else {
3761
+ console.log(` ${chalk.yellow('state:')} landing branch has not been materialized yet`);
3762
+ }
3763
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
3764
+ } catch (err) {
3765
+ db.close();
3766
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
1914
3767
  process.exitCode = 1;
1915
- return;
1916
3768
  }
1917
-
1918
- console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
1919
3769
  });
1920
3770
 
1921
3771
  // ── pipeline ──────────────────────────────────────────────────────────────────
@@ -1926,6 +3776,7 @@ Examples:
1926
3776
  switchman pipeline start "Harden auth API permissions"
1927
3777
  switchman pipeline exec pipe-123 "/path/to/agent-command"
1928
3778
  switchman pipeline status pipe-123
3779
+ switchman pipeline land pipe-123
1929
3780
  `);
1930
3781
 
1931
3782
  pipelineCmd
@@ -1978,10 +3829,33 @@ Examples:
1978
3829
 
1979
3830
  try {
1980
3831
  const result = getPipelineStatus(db, pipelineId);
3832
+ let landing;
3833
+ let landingError = null;
3834
+ try {
3835
+ landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
3836
+ requireCompleted: false,
3837
+ });
3838
+ } catch (err) {
3839
+ landingError = String(err.message || 'Landing branch is not ready yet.');
3840
+ landing = {
3841
+ branch: null,
3842
+ synthetic: false,
3843
+ stale: false,
3844
+ stale_reasons: [],
3845
+ last_failure: null,
3846
+ last_recovery: null,
3847
+ };
3848
+ }
3849
+ const policyState = summarizePipelinePolicyState(db, result, loadChangePolicy(repoRoot), []);
1981
3850
  db.close();
1982
3851
 
1983
3852
  if (opts.json) {
1984
- console.log(JSON.stringify(result, null, 2));
3853
+ console.log(JSON.stringify({
3854
+ ...result,
3855
+ landing_branch: landing,
3856
+ landing_error: landingError,
3857
+ policy_state: policyState,
3858
+ }, null, 2));
1985
3859
  return;
1986
3860
  }
1987
3861
 
@@ -2000,6 +3874,7 @@ Examples:
2000
3874
  const focusLine = focusTask
2001
3875
  ? `${focusTask.title} ${chalk.dim(focusTask.id)}`
2002
3876
  : 'No pipeline tasks found.';
3877
+ const landingLabel = buildLandingStateLabel(landing);
2003
3878
 
2004
3879
  console.log('');
2005
3880
  console.log(pipelineHealthColor('='.repeat(72)));
@@ -2013,6 +3888,11 @@ Examples:
2013
3888
  renderChip('failed', result.counts.failed, result.counts.failed > 0 ? chalk.red : chalk.green),
2014
3889
  ]));
2015
3890
  console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
3891
+ if (landingLabel) {
3892
+ console.log(`${chalk.bold('Landing:')} ${landingLabel}`);
3893
+ } else if (landingError) {
3894
+ console.log(`${chalk.bold('Landing:')} ${chalk.yellow('not ready yet')} ${chalk.dim(landingError)}`);
3895
+ }
2016
3896
 
2017
3897
  const runningLines = result.tasks.filter((task) => task.status === 'in_progress').slice(0, 4).map((task) => {
2018
3898
  const worktree = task.worktree || task.suggested_worktree || 'unassigned';
@@ -2028,49 +3908,298 @@ Examples:
2028
3908
  const reasonLabel = humanizeReasonCode(task.failure.reason_code);
2029
3909
  lines.push(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
2030
3910
  }
2031
- if (task.next_action) lines.push(` ${chalk.yellow('next:')} ${task.next_action}`);
2032
- return lines;
2033
- });
3911
+ if (task.next_action) lines.push(` ${chalk.yellow('next:')} ${task.next_action}`);
3912
+ return lines;
3913
+ });
3914
+
3915
+ const nextLines = result.tasks.filter((task) => task.status === 'pending').slice(0, 4).map((task) => {
3916
+ const worktree = task.suggested_worktree || task.worktree || 'unassigned';
3917
+ const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
3918
+ return `${renderChip('NEXT', task.id, chalk.green)} ${task.title} ${chalk.dim(worktree)}${blocked}`;
3919
+ });
3920
+
3921
+ const landingLines = landing.synthetic
3922
+ ? [
3923
+ `${renderChip(landing.stale ? 'STALE' : 'LAND', landing.branch, landing.stale ? chalk.red : chalk.green)} ${chalk.dim(`base ${landing.base_branch}`)}`,
3924
+ ...(landing.last_failure
3925
+ ? [
3926
+ ` ${chalk.red('failure:')} ${humanizeReasonCode(landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`,
3927
+ ...(landing.last_failure.failed_branch ? [` ${chalk.dim('failed branch:')} ${landing.last_failure.failed_branch}`] : []),
3928
+ ]
3929
+ : []),
3930
+ ...(landing.last_recovery?.state?.status
3931
+ ? [
3932
+ ` ${chalk.dim('recovery:')} ${landing.last_recovery.state.status} ${landing.last_recovery.recovery_path}`,
3933
+ ]
3934
+ : []),
3935
+ ...(landing.stale_reasons.length > 0
3936
+ ? landing.stale_reasons.slice(0, 3).map((reason) => ` ${chalk.red('why:')} ${reason.summary}`)
3937
+ : [landing.last_materialized
3938
+ ? ` ${chalk.green('state:')} ready to queue`
3939
+ : ` ${chalk.yellow('next:')} switchman pipeline land ${result.pipeline_id}`]),
3940
+ (landing.last_failure?.command
3941
+ ? ` ${chalk.yellow('next:')} ${landing.last_failure.command}`
3942
+ : landing.stale
3943
+ ? ` ${chalk.yellow('next:')} switchman pipeline land ${result.pipeline_id} --refresh`
3944
+ : ` ${chalk.yellow('next:')} switchman queue add --pipeline ${result.pipeline_id}`),
3945
+ ]
3946
+ : [];
3947
+
3948
+ const policyLines = policyState.active
3949
+ ? [
3950
+ `${renderChip(policyState.enforcement.toUpperCase(), policyState.domains.join(','), policyState.enforcement === 'blocked' ? chalk.red : chalk.yellow)} ${policyState.summary}`,
3951
+ ` ${chalk.dim('required:')} ${policyState.required_task_types.join(', ') || 'none'}`,
3952
+ ` ${chalk.dim('satisfied:')} ${policyState.satisfied_task_types.join(', ') || 'none'}`,
3953
+ ` ${chalk.dim('missing:')} ${policyState.missing_task_types.join(', ') || 'none'}`,
3954
+ ` ${chalk.dim('overridden:')} ${policyState.overridden_task_types.join(', ') || 'none'}`,
3955
+ ...policyState.requirement_status
3956
+ .filter((requirement) => requirement.evidence.length > 0)
3957
+ .slice(0, 4)
3958
+ .map((requirement) => ` ${chalk.dim(`${requirement.task_type}:`)} ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path})` : entry.task_id).join(', ')}`),
3959
+ ...policyState.overrides
3960
+ .slice(0, 3)
3961
+ .map((entry) => ` ${chalk.dim(`override ${entry.id}:`)} ${(entry.task_types || []).join(', ') || 'all'} by ${entry.approved_by || 'unknown'}`),
3962
+ ]
3963
+ : [chalk.green('No explicit change policy requirements are active for this pipeline.')];
3964
+
3965
+ const commandLines = [
3966
+ `${chalk.cyan('$')} switchman pipeline exec ${result.pipeline_id} "/path/to/agent-command"`,
3967
+ `${chalk.cyan('$')} switchman pipeline pr ${result.pipeline_id}`,
3968
+ ...(landing.last_failure?.command ? [`${chalk.cyan('$')} ${landing.last_failure.command}`] : []),
3969
+ ...(landing.synthetic && landing.stale ? [`${chalk.cyan('$')} switchman pipeline land ${result.pipeline_id} --refresh`] : []),
3970
+ ...(result.counts.failed > 0 ? [`${chalk.cyan('$')} switchman pipeline status ${result.pipeline_id}`] : []),
3971
+ ];
3972
+
3973
+ console.log('');
3974
+ for (const block of [
3975
+ renderPanel('Running now', runningLines.length > 0 ? runningLines : [chalk.dim('No tasks are actively running.')], runningLines.length > 0 ? chalk.cyan : chalk.green),
3976
+ renderPanel('Blocked', blockedLines.length > 0 ? blockedLines : [chalk.green('Nothing blocked.')], blockedLines.length > 0 ? chalk.red : chalk.green),
3977
+ renderPanel('Next up', nextLines.length > 0 ? nextLines : [chalk.dim('No pending tasks left.')], chalk.green),
3978
+ renderPanel('Policy', policyLines, policyState.active ? (policyState.missing_task_types.length > 0 ? chalk.red : chalk.green) : chalk.green),
3979
+ ...(landing.synthetic ? [renderPanel('Landing branch', landingLines, landing.stale ? chalk.red : chalk.cyan)] : []),
3980
+ renderPanel('Next commands', commandLines, chalk.cyan),
3981
+ ]) {
3982
+ for (const line of block) console.log(line);
3983
+ console.log('');
3984
+ }
3985
+ } catch (err) {
3986
+ db.close();
3987
+ console.error(chalk.red(err.message));
3988
+ process.exitCode = 1;
3989
+ }
3990
+ });
3991
+
3992
+ pipelineCmd
3993
+ .command('pr <pipelineId>')
3994
+ .description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
3995
+ .option('--json', 'Output raw JSON')
3996
+ .action(async (pipelineId, opts) => {
3997
+ const repoRoot = getRepo();
3998
+ const db = getDb(repoRoot);
3999
+
4000
+ try {
4001
+ const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
4002
+ db.close();
4003
+
4004
+ if (opts.json) {
4005
+ console.log(JSON.stringify(result, null, 2));
4006
+ return;
4007
+ }
4008
+
4009
+ console.log(result.markdown);
4010
+ } catch (err) {
4011
+ db.close();
4012
+ console.error(chalk.red(err.message));
4013
+ process.exitCode = 1;
4014
+ }
4015
+ });
4016
+
4017
+ pipelineCmd
4018
+ .command('bundle <pipelineId> [outputDir]')
4019
+ .description('Export a reviewer-ready PR bundle for a pipeline to disk')
4020
+ .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
4021
+ .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
4022
+ .option('--github-output <path>', 'Path to write GitHub Actions outputs')
4023
+ .option('--json', 'Output raw JSON')
4024
+ .action(async (pipelineId, outputDir, opts) => {
4025
+ const repoRoot = getRepo();
4026
+ const db = getDb(repoRoot);
4027
+
4028
+ try {
4029
+ const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
4030
+ db.close();
4031
+
4032
+ const githubTargets = resolveGitHubOutputTargets(opts);
4033
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
4034
+ writeGitHubPipelineLandingStatus({
4035
+ result: result.landing_summary,
4036
+ stepSummaryPath: githubTargets.stepSummaryPath,
4037
+ outputPath: githubTargets.outputPath,
4038
+ });
4039
+ }
4040
+
4041
+ if (opts.json) {
4042
+ console.log(JSON.stringify(result, null, 2));
4043
+ return;
4044
+ }
4045
+
4046
+ console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
4047
+ console.log(` ${chalk.dim(result.output_dir)}`);
4048
+ console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
4049
+ console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
4050
+ console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
4051
+ console.log(` ${chalk.dim('landing json:')} ${result.files.landing_summary_json}`);
4052
+ console.log(` ${chalk.dim('landing md:')} ${result.files.landing_summary_markdown}`);
4053
+ } catch (err) {
4054
+ db.close();
4055
+ console.error(chalk.red(err.message));
4056
+ process.exitCode = 1;
4057
+ }
4058
+ });
4059
+
4060
+ pipelineCmd
4061
+ .command('land <pipelineId>')
4062
+ .description('Create or refresh one landing branch for a completed pipeline')
4063
+ .option('--base <branch>', 'Base branch for the landing branch', 'main')
4064
+ .option('--branch <branch>', 'Custom landing branch name')
4065
+ .option('--refresh', 'Rebuild the landing branch when a source branch or base branch has moved')
4066
+ .option('--recover', 'Create a recovery worktree for an unresolved landing merge conflict')
4067
+ .option('--replace-recovery', 'Replace an existing recovery worktree when creating a new one')
4068
+ .option('--resume [path]', 'Validate a resolved recovery worktree and mark the landing branch ready again')
4069
+ .option('--cleanup [path]', 'Remove a recorded recovery worktree after it is resolved or abandoned')
4070
+ .option('--json', 'Output raw JSON')
4071
+ .action((pipelineId, opts) => {
4072
+ const repoRoot = getRepo();
4073
+ const db = getDb(repoRoot);
4074
+
4075
+ try {
4076
+ const selectedModes = [opts.refresh, opts.recover, Boolean(opts.resume), Boolean(opts.cleanup)].filter(Boolean).length;
4077
+ if (selectedModes > 1) {
4078
+ throw new Error('Choose only one of --refresh, --recover, --resume, or --cleanup.');
4079
+ }
4080
+ if (opts.recover) {
4081
+ const result = preparePipelineLandingRecovery(db, repoRoot, pipelineId, {
4082
+ baseBranch: opts.base,
4083
+ landingBranch: opts.branch || null,
4084
+ replaceExisting: Boolean(opts.replaceRecovery),
4085
+ });
4086
+ db.close();
4087
+
4088
+ if (opts.json) {
4089
+ console.log(JSON.stringify(result, null, 2));
4090
+ return;
4091
+ }
4092
+
4093
+ console.log(`${chalk.green('✓')} ${result.reused_existing ? 'Recovery worktree already ready for' : 'Recovery worktree ready for'} ${chalk.cyan(result.pipeline_id)}`);
4094
+ console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
4095
+ console.log(` ${chalk.dim('path:')} ${result.recovery_path}`);
4096
+ if (result.reused_existing) {
4097
+ console.log(` ${chalk.dim('state:')} reusing existing recovery worktree`);
4098
+ }
4099
+ console.log(` ${chalk.dim('blocked by:')} ${result.failed_branch}`);
4100
+ if (result.conflicting_files.length > 0) {
4101
+ console.log(` ${chalk.dim('conflicts:')} ${result.conflicting_files.join(', ')}`);
4102
+ }
4103
+ console.log(` ${chalk.yellow('inspect:')} ${result.inspect_command}`);
4104
+ console.log(` ${chalk.yellow('after resolving + commit:')} ${result.resume_command}`);
4105
+ return;
4106
+ }
4107
+ if (opts.cleanup) {
4108
+ const result = cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
4109
+ baseBranch: opts.base,
4110
+ landingBranch: opts.branch || null,
4111
+ recoveryPath: typeof opts.cleanup === 'string' ? opts.cleanup : null,
4112
+ });
4113
+ db.close();
4114
+
4115
+ if (opts.json) {
4116
+ console.log(JSON.stringify(result, null, 2));
4117
+ return;
4118
+ }
4119
+
4120
+ console.log(`${chalk.green('✓')} Recovery worktree cleared for ${chalk.cyan(result.pipeline_id)}`);
4121
+ console.log(` ${chalk.dim('path:')} ${result.recovery_path}`);
4122
+ console.log(` ${chalk.dim('removed:')} ${result.removed ? 'yes' : 'no'}`);
4123
+ console.log(` ${chalk.yellow('next:')} switchman explain landing ${result.pipeline_id}`);
4124
+ return;
4125
+ }
4126
+ if (opts.resume) {
4127
+ const result = resumePipelineLandingRecovery(db, repoRoot, pipelineId, {
4128
+ baseBranch: opts.base,
4129
+ landingBranch: opts.branch || null,
4130
+ recoveryPath: typeof opts.resume === 'string' ? opts.resume : null,
4131
+ });
4132
+ db.close();
4133
+
4134
+ if (opts.json) {
4135
+ console.log(JSON.stringify(result, null, 2));
4136
+ return;
4137
+ }
2034
4138
 
2035
- const nextLines = result.tasks.filter((task) => task.status === 'pending').slice(0, 4).map((task) => {
2036
- const worktree = task.suggested_worktree || task.worktree || 'unassigned';
2037
- const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
2038
- return `${renderChip('NEXT', task.id, chalk.green)} ${task.title} ${chalk.dim(worktree)}${blocked}`;
4139
+ console.log(`${chalk.green('✓')} ${result.already_resumed ? 'Landing recovery already resumed for' : 'Landing recovery resumed for'} ${chalk.cyan(result.pipeline_id)}`);
4140
+ console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
4141
+ console.log(` ${chalk.dim('head:')} ${result.head_commit}`);
4142
+ if (result.recovery_path) {
4143
+ console.log(` ${chalk.dim('recovery path:')} ${result.recovery_path}`);
4144
+ }
4145
+ if (result.already_resumed) {
4146
+ console.log(` ${chalk.dim('state:')} already aligned and ready to queue`);
4147
+ }
4148
+ console.log(` ${chalk.yellow('next:')} ${result.resume_command}`);
4149
+ return;
4150
+ }
4151
+
4152
+ const result = materializePipelineLandingBranch(db, repoRoot, pipelineId, {
4153
+ baseBranch: opts.base,
4154
+ landingBranch: opts.branch || null,
4155
+ requireCompleted: true,
4156
+ refresh: Boolean(opts.refresh),
2039
4157
  });
4158
+ db.close();
2040
4159
 
2041
- const commandLines = [
2042
- `${chalk.cyan('$')} switchman pipeline exec ${result.pipeline_id} "/path/to/agent-command"`,
2043
- `${chalk.cyan('$')} switchman pipeline pr ${result.pipeline_id}`,
2044
- ...(result.counts.failed > 0 ? [`${chalk.cyan('$')} switchman pipeline status ${result.pipeline_id}`] : []),
2045
- ];
4160
+ if (opts.json) {
4161
+ console.log(JSON.stringify(result, null, 2));
4162
+ return;
4163
+ }
2046
4164
 
2047
- console.log('');
2048
- for (const block of [
2049
- renderPanel('Running now', runningLines.length > 0 ? runningLines : [chalk.dim('No tasks are actively running.')], runningLines.length > 0 ? chalk.cyan : chalk.green),
2050
- renderPanel('Blocked', blockedLines.length > 0 ? blockedLines : [chalk.green('Nothing blocked.')], blockedLines.length > 0 ? chalk.red : chalk.green),
2051
- renderPanel('Next up', nextLines.length > 0 ? nextLines : [chalk.dim('No pending tasks left.')], chalk.green),
2052
- renderPanel('Next commands', commandLines, chalk.cyan),
2053
- ]) {
2054
- for (const line of block) console.log(line);
2055
- console.log('');
4165
+ console.log(`${chalk.green('')} Landing branch ready for ${chalk.cyan(result.pipeline_id)}`);
4166
+ console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
4167
+ console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
4168
+ console.log(` ${chalk.dim('strategy:')} ${result.strategy}`);
4169
+ console.log(` ${chalk.dim('components:')} ${result.component_branches.join(', ')}`);
4170
+ if (result.reused_existing) {
4171
+ console.log(` ${chalk.dim('state:')} already current`);
4172
+ } else if (result.refreshed) {
4173
+ console.log(` ${chalk.dim('state:')} refreshed`);
2056
4174
  }
4175
+ console.log(` ${chalk.yellow('next:')} switchman queue add ${result.branch}`);
2057
4176
  } catch (err) {
2058
4177
  db.close();
2059
- console.error(chalk.red(err.message));
4178
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
2060
4179
  process.exitCode = 1;
2061
4180
  }
2062
4181
  });
2063
4182
 
2064
4183
  pipelineCmd
2065
- .command('pr <pipelineId>')
2066
- .description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
4184
+ .command('publish <pipelineId> [outputDir]')
4185
+ .description('Create a hosted GitHub pull request for a pipeline using gh')
4186
+ .option('--base <branch>', 'Base branch for the pull request', 'main')
4187
+ .option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
4188
+ .option('--draft', 'Create the pull request as a draft')
4189
+ .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
2067
4190
  .option('--json', 'Output raw JSON')
2068
- .action(async (pipelineId, opts) => {
4191
+ .action(async (pipelineId, outputDir, opts) => {
2069
4192
  const repoRoot = getRepo();
2070
4193
  const db = getDb(repoRoot);
2071
4194
 
2072
4195
  try {
2073
- const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
4196
+ const result = await publishPipelinePr(db, repoRoot, pipelineId, {
4197
+ baseBranch: opts.base,
4198
+ headBranch: opts.head || null,
4199
+ draft: Boolean(opts.draft),
4200
+ ghCommand: opts.ghCommand,
4201
+ outputDir: outputDir || null,
4202
+ });
2074
4203
  db.close();
2075
4204
 
2076
4205
  if (opts.json) {
@@ -2078,7 +4207,12 @@ pipelineCmd
2078
4207
  return;
2079
4208
  }
2080
4209
 
2081
- console.log(result.markdown);
4210
+ console.log(`${chalk.green('✓')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
4211
+ console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
4212
+ console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
4213
+ if (result.output) {
4214
+ console.log(` ${chalk.dim(result.output)}`);
4215
+ }
2082
4216
  } catch (err) {
2083
4217
  db.close();
2084
4218
  console.error(chalk.red(err.message));
@@ -2087,15 +4221,28 @@ pipelineCmd
2087
4221
  });
2088
4222
 
2089
4223
  pipelineCmd
2090
- .command('bundle <pipelineId> [outputDir]')
2091
- .description('Export a reviewer-ready PR bundle for a pipeline to disk')
4224
+ .command('comment <pipelineId> [outputDir]')
4225
+ .description('Post or update a GitHub PR comment with the pipeline landing summary')
4226
+ .option('--pr <number>', 'Pull request number to comment on')
4227
+ .option('--pr-from-env', 'Read the pull request number from GitHub Actions environment variables')
4228
+ .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
4229
+ .option('--update-existing', 'Edit the last comment from this user instead of creating a new one')
2092
4230
  .option('--json', 'Output raw JSON')
2093
4231
  .action(async (pipelineId, outputDir, opts) => {
2094
4232
  const repoRoot = getRepo();
2095
4233
  const db = getDb(repoRoot);
4234
+ const prNumber = opts.pr || (opts.prFromEnv ? resolvePrNumberFromEnv() : null);
2096
4235
 
2097
4236
  try {
2098
- const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
4237
+ if (!prNumber) {
4238
+ throw new Error('A pull request number is required. Pass `--pr <number>` or `--pr-from-env`.');
4239
+ }
4240
+ const result = await commentPipelinePr(db, repoRoot, pipelineId, {
4241
+ prNumber,
4242
+ ghCommand: opts.ghCommand,
4243
+ outputDir: outputDir || null,
4244
+ updateExisting: Boolean(opts.updateExisting),
4245
+ });
2099
4246
  db.close();
2100
4247
 
2101
4248
  if (opts.json) {
@@ -2103,11 +4250,12 @@ pipelineCmd
2103
4250
  return;
2104
4251
  }
2105
4252
 
2106
- console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
2107
- console.log(` ${chalk.dim(result.output_dir)}`);
2108
- console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
2109
- console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
2110
- console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
4253
+ console.log(`${chalk.green('✓')} Posted pipeline comment for ${chalk.cyan(result.pipeline_id)}`);
4254
+ console.log(` ${chalk.dim('pr:')} #${result.pr_number}`);
4255
+ console.log(` ${chalk.dim('body:')} ${result.bundle.files.landing_summary_markdown}`);
4256
+ if (result.updated_existing) {
4257
+ console.log(` ${chalk.dim('mode:')} update existing comment`);
4258
+ }
2111
4259
  } catch (err) {
2112
4260
  db.close();
2113
4261
  console.error(chalk.red(err.message));
@@ -2116,37 +4264,86 @@ pipelineCmd
2116
4264
  });
2117
4265
 
2118
4266
  pipelineCmd
2119
- .command('publish <pipelineId> [outputDir]')
2120
- .description('Create a hosted GitHub pull request for a pipeline using gh')
2121
- .option('--base <branch>', 'Base branch for the pull request', 'main')
2122
- .option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
2123
- .option('--draft', 'Create the pull request as a draft')
4267
+ .command('sync-pr [pipelineId] [outputDir]')
4268
+ .description('Build PR artifacts, emit GitHub outputs, and update the PR comment in one command')
4269
+ .option('--pr <number>', 'Pull request number to comment on')
4270
+ .option('--pr-from-env', 'Read the pull request number from GitHub Actions environment variables')
4271
+ .option('--pipeline-from-env', 'Infer the pipeline id from the current GitHub branch context')
4272
+ .option('--skip-missing-pipeline', 'Exit successfully when no matching pipeline can be inferred')
2124
4273
  .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
4274
+ .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
4275
+ .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
4276
+ .option('--github-output <path>', 'Path to write GitHub Actions outputs')
4277
+ .option('--no-comment', 'Skip updating the PR comment')
2125
4278
  .option('--json', 'Output raw JSON')
2126
4279
  .action(async (pipelineId, outputDir, opts) => {
2127
4280
  const repoRoot = getRepo();
2128
4281
  const db = getDb(repoRoot);
4282
+ const branchFromEnv = opts.pipelineFromEnv ? resolveBranchFromEnv() : null;
4283
+ const resolvedPipelineId = pipelineId || (branchFromEnv ? inferPipelineIdFromBranch(db, branchFromEnv) : null);
4284
+ const prNumber = opts.pr || (opts.prFromEnv ? resolvePrNumberFromEnv() : null);
2129
4285
 
2130
4286
  try {
2131
- const result = await publishPipelinePr(db, repoRoot, pipelineId, {
2132
- baseBranch: opts.base,
2133
- headBranch: opts.head || null,
2134
- draft: Boolean(opts.draft),
4287
+ if (!resolvedPipelineId) {
4288
+ if (!opts.skipMissingPipeline) {
4289
+ throw new Error(opts.pipelineFromEnv
4290
+ ? `Could not infer a pipeline from branch ${branchFromEnv || 'unknown'}. Pass a pipeline id explicitly or use a branch that maps to one Switchman pipeline.`
4291
+ : 'A pipeline id is required. Pass one explicitly or use `--pipeline-from-env`.');
4292
+ }
4293
+ const skipped = {
4294
+ skipped: true,
4295
+ reason: 'no_pipeline_inferred',
4296
+ branch: branchFromEnv,
4297
+ next_action: 'Run `switchman pipeline status <pipelineId>` locally to confirm the pipeline id, then rerun sync-pr with that id.',
4298
+ };
4299
+ db.close();
4300
+ if (opts.json) {
4301
+ console.log(JSON.stringify(skipped, null, 2));
4302
+ return;
4303
+ }
4304
+ console.log(`${chalk.green('✓')} No pipeline sync needed`);
4305
+ if (branchFromEnv) {
4306
+ console.log(` ${chalk.dim('branch:')} ${branchFromEnv}`);
4307
+ }
4308
+ console.log(` ${chalk.dim('reason:')} no matching Switchman pipeline was inferred`);
4309
+ console.log(` ${chalk.yellow('next:')} ${skipped.next_action}`);
4310
+ return;
4311
+ }
4312
+
4313
+ if (opts.comment !== false && !prNumber) {
4314
+ throw new Error('A pull request number is required for comment sync. Pass `--pr <number>`, `--pr-from-env`, or `--no-comment`.');
4315
+ }
4316
+
4317
+ const result = await syncPipelinePr(db, repoRoot, resolvedPipelineId, {
4318
+ prNumber: opts.comment === false ? null : prNumber,
2135
4319
  ghCommand: opts.ghCommand,
2136
4320
  outputDir: outputDir || null,
4321
+ updateExisting: true,
2137
4322
  });
2138
4323
  db.close();
2139
4324
 
4325
+ const githubTargets = resolveGitHubOutputTargets(opts);
4326
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
4327
+ writeGitHubPipelineLandingStatus({
4328
+ result: result.bundle.landing_summary,
4329
+ stepSummaryPath: githubTargets.stepSummaryPath,
4330
+ outputPath: githubTargets.outputPath,
4331
+ });
4332
+ }
4333
+
2140
4334
  if (opts.json) {
2141
4335
  console.log(JSON.stringify(result, null, 2));
2142
4336
  return;
2143
4337
  }
2144
4338
 
2145
- console.log(`${chalk.green('✓')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
2146
- console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
2147
- console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
2148
- if (result.output) {
2149
- console.log(` ${chalk.dim(result.output)}`);
4339
+ console.log(`${chalk.green('✓')} Synced PR artifacts for ${chalk.cyan(result.pipeline_id)}`);
4340
+ console.log(` ${chalk.dim('bundle:')} ${result.bundle.output_dir}`);
4341
+ if (result.comment) {
4342
+ console.log(` ${chalk.dim('pr:')} #${result.comment.pr_number}`);
4343
+ console.log(` ${chalk.dim('comment:')} updated existing`);
4344
+ }
4345
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
4346
+ console.log(` ${chalk.dim('github:')} wrote PR check artifacts`);
2150
4347
  }
2151
4348
  } catch (err) {
2152
4349
  db.close();
@@ -2200,6 +4397,53 @@ pipelineCmd
2200
4397
  }
2201
4398
  });
2202
4399
 
4400
+ pipelineCmd
4401
+ .command('repair <pipelineId>')
4402
+ .description('Safely repair interrupted landing state for one pipeline')
4403
+ .option('--base <branch>', 'Base branch for landing repair checks', 'main')
4404
+ .option('--branch <branch>', 'Custom landing branch name')
4405
+ .option('--json', 'Output raw JSON')
4406
+ .action((pipelineId, opts) => {
4407
+ const repoRoot = getRepo();
4408
+ const db = getDb(repoRoot);
4409
+
4410
+ try {
4411
+ const result = repairPipelineState(db, repoRoot, pipelineId, {
4412
+ baseBranch: opts.base,
4413
+ landingBranch: opts.branch || null,
4414
+ });
4415
+ db.close();
4416
+
4417
+ if (opts.json) {
4418
+ console.log(JSON.stringify(result, null, 2));
4419
+ return;
4420
+ }
4421
+
4422
+ if (!result.repaired) {
4423
+ console.log(`${chalk.green('✓')} No repair action needed for ${chalk.cyan(result.pipeline_id)}`);
4424
+ for (const note of result.notes) {
4425
+ console.log(` ${chalk.dim(note)}`);
4426
+ }
4427
+ console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
4428
+ return;
4429
+ }
4430
+
4431
+ console.log(`${chalk.green('✓')} Repaired ${chalk.cyan(result.pipeline_id)}`);
4432
+ for (const action of result.actions) {
4433
+ if (action.kind === 'recovery_state_cleared') {
4434
+ console.log(` ${chalk.dim('cleared recovery record:')} ${action.recovery_path}`);
4435
+ } else if (action.kind === 'landing_branch_refreshed') {
4436
+ console.log(` ${chalk.dim('refreshed landing branch:')} ${action.branch}${action.head_commit ? ` ${chalk.dim(action.head_commit.slice(0, 12))}` : ''}`);
4437
+ }
4438
+ }
4439
+ console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
4440
+ } catch (err) {
4441
+ db.close();
4442
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
4443
+ process.exitCode = 1;
4444
+ }
4445
+ });
4446
+
2203
4447
  pipelineCmd
2204
4448
  .command('review <pipelineId>')
2205
4449
  .description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
@@ -2985,9 +5229,48 @@ Use this first when the repo feels stuck.
2985
5229
  }
2986
5230
  });
2987
5231
 
5232
+ program
5233
+ .command('repair')
5234
+ .description('Repair safe interrupted queue and pipeline state across the repo')
5235
+ .option('--json', 'Output raw JSON')
5236
+ .action((opts) => {
5237
+ const repoRoot = getRepo();
5238
+ const db = getDb(repoRoot);
5239
+
5240
+ try {
5241
+ const result = repairRepoState(db, repoRoot);
5242
+ db.close();
5243
+
5244
+ if (opts.json) {
5245
+ console.log(JSON.stringify(result, null, 2));
5246
+ return;
5247
+ }
5248
+
5249
+ if (!result.repaired) {
5250
+ printRepairSummary(result, {
5251
+ repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state`,
5252
+ noRepairHeading: `${chalk.green('✓')} No repo repair action needed`,
5253
+ });
5254
+ console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
5255
+ return;
5256
+ }
5257
+
5258
+ printRepairSummary(result, {
5259
+ repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state`,
5260
+ noRepairHeading: `${chalk.green('✓')} No repo repair action needed`,
5261
+ });
5262
+ console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
5263
+ } catch (err) {
5264
+ db.close();
5265
+ console.error(chalk.red(err.message));
5266
+ process.exitCode = 1;
5267
+ }
5268
+ });
5269
+
2988
5270
  program
2989
5271
  .command('doctor')
2990
5272
  .description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
5273
+ .option('--repair', 'Repair safe interrupted queue and pipeline state before reporting health')
2991
5274
  .option('--json', 'Output raw JSON')
2992
5275
  .addHelpText('after', `
2993
5276
  Plain English:
@@ -3000,6 +5283,7 @@ Examples:
3000
5283
  .action(async (opts) => {
3001
5284
  const repoRoot = getRepo();
3002
5285
  const db = getDb(repoRoot);
5286
+ const repairResult = opts.repair ? repairRepoState(db, repoRoot) : null;
3003
5287
  const tasks = listTasks(db);
3004
5288
  const activeLeases = listLeases(db, 'active');
3005
5289
  const staleLeases = getStaleLeases(db);
@@ -3017,10 +5301,19 @@ Examples:
3017
5301
  db.close();
3018
5302
 
3019
5303
  if (opts.json) {
3020
- console.log(JSON.stringify(report, null, 2));
5304
+ console.log(JSON.stringify(opts.repair ? { ...report, repair: repairResult } : report, null, 2));
3021
5305
  return;
3022
5306
  }
3023
5307
 
5308
+ if (opts.repair) {
5309
+ printRepairSummary(repairResult, {
5310
+ repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state before running doctor`,
5311
+ noRepairHeading: `${chalk.green('✓')} No repo repair action needed before doctor`,
5312
+ limit: 6,
5313
+ });
5314
+ console.log('');
5315
+ }
5316
+
3024
5317
  const doctorColor = colorForHealth(report.health);
3025
5318
  const blockedCount = report.attention.filter((item) => item.severity === 'block').length;
3026
5319
  const warningCount = report.attention.filter((item) => item.severity !== 'block').length;
@@ -3071,6 +5364,19 @@ Examples:
3071
5364
  })
3072
5365
  : [chalk.green('Nothing urgent.')];
3073
5366
 
5367
+ const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
5368
+ ? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
5369
+ const lines = [`${cluster.severity === 'block' ? renderChip('STALE', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.red) : renderChip('WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.yellow)} ${cluster.title}`];
5370
+ lines.push(` ${chalk.dim(cluster.detail)}`);
5371
+ if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
5372
+ lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
5373
+ lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
5374
+ lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
5375
+ lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
5376
+ return lines;
5377
+ })
5378
+ : [chalk.green('No stale dependency clusters.')];
5379
+
3074
5380
  const nextStepLines = [
3075
5381
  ...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
3076
5382
  '',
@@ -3082,6 +5388,7 @@ Examples:
3082
5388
  for (const block of [
3083
5389
  renderPanel('Running now', runningLines, chalk.cyan),
3084
5390
  renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
5391
+ renderPanel('Stale clusters', staleClusterLines, report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green),
3085
5392
  renderPanel('Recommended next steps', nextStepLines, chalk.green),
3086
5393
  ]) {
3087
5394
  for (const line of block) console.log(line);
@@ -3101,6 +5408,39 @@ Examples:
3101
5408
 
3102
5409
  const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
3103
5410
 
5411
+ auditCmd
5412
+ .command('change <pipelineId>')
5413
+ .description('Show a signed, operator-friendly history for one pipeline')
5414
+ .option('--json', 'Output raw JSON')
5415
+ .action((pipelineId, options) => {
5416
+ const repoRoot = getRepo();
5417
+ const db = getDb(repoRoot);
5418
+
5419
+ try {
5420
+ const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
5421
+ db.close();
5422
+
5423
+ if (options.json) {
5424
+ console.log(JSON.stringify(report, null, 2));
5425
+ return;
5426
+ }
5427
+
5428
+ console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
5429
+ console.log(` ${chalk.dim('title:')} ${report.title}`);
5430
+ console.log(` ${chalk.dim('events:')} ${report.events.length}`);
5431
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
5432
+ for (const event of report.events.slice(-20)) {
5433
+ const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
5434
+ console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
5435
+ console.log(` ${event.summary}`);
5436
+ }
5437
+ } catch (err) {
5438
+ db.close();
5439
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
5440
+ process.exitCode = 1;
5441
+ }
5442
+ });
5443
+
3104
5444
  auditCmd
3105
5445
  .command('verify')
3106
5446
  .description('Verify the audit log hash chain and project signatures')
@@ -3658,7 +5998,7 @@ monitorCmd
3658
5998
 
3659
5999
  // ── policy ───────────────────────────────────────────────────────────────────
3660
6000
 
3661
- const policyCmd = program.command('policy').description('Manage enforcement policy exceptions');
6001
+ const policyCmd = program.command('policy').description('Manage enforcement and change-governance policy');
3662
6002
 
3663
6003
  policyCmd
3664
6004
  .command('init')
@@ -3675,4 +6015,133 @@ policyCmd
3675
6015
  console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
3676
6016
  });
3677
6017
 
6018
+ policyCmd
6019
+ .command('init-change')
6020
+ .description('Write a starter change policy file for governed domains like auth, payments, and schema')
6021
+ .action(() => {
6022
+ const repoRoot = getRepo();
6023
+ const policyPath = writeChangePolicy(repoRoot, DEFAULT_CHANGE_POLICY);
6024
+ console.log(`${chalk.green('✓')} Wrote change policy to ${chalk.cyan(policyPath)}`);
6025
+ });
6026
+
6027
+ policyCmd
6028
+ .command('show-change')
6029
+ .description('Show the active change policy for this repo')
6030
+ .option('--json', 'Output raw JSON')
6031
+ .action((opts) => {
6032
+ const repoRoot = getRepo();
6033
+ const policy = loadChangePolicy(repoRoot);
6034
+ const policyPath = getChangePolicyPath(repoRoot);
6035
+
6036
+ if (opts.json) {
6037
+ console.log(JSON.stringify({ path: policyPath, policy }, null, 2));
6038
+ return;
6039
+ }
6040
+
6041
+ console.log(chalk.bold('Change policy'));
6042
+ console.log(` ${chalk.dim('path:')} ${policyPath}`);
6043
+ for (const [domain, rule] of Object.entries(policy.domain_rules || {})) {
6044
+ console.log(` ${chalk.cyan(domain)} ${chalk.dim(rule.enforcement)}`);
6045
+ console.log(` ${chalk.dim('requires:')} ${(rule.required_completed_task_types || []).join(', ') || 'none'}`);
6046
+ }
6047
+ });
6048
+
6049
+ policyCmd
6050
+ .command('override <pipelineId>')
6051
+ .description('Record a policy override for one pipeline requirement or task type')
6052
+ .requiredOption('--task-types <types>', 'Comma-separated task types to override, e.g. tests,governance')
6053
+ .requiredOption('--reason <text>', 'Why this override is being granted')
6054
+ .option('--by <actor>', 'Who approved the override', 'operator')
6055
+ .option('--json', 'Output raw JSON')
6056
+ .action((pipelineId, opts) => {
6057
+ const repoRoot = getRepo();
6058
+ const db = getDb(repoRoot);
6059
+ const taskTypes = String(opts.taskTypes || '')
6060
+ .split(',')
6061
+ .map((value) => value.trim())
6062
+ .filter(Boolean);
6063
+
6064
+ if (taskTypes.length === 0) {
6065
+ db.close();
6066
+ printErrorWithNext('At least one task type is required for a policy override.', 'switchman policy override <pipelineId> --task-types tests --reason "why"');
6067
+ process.exit(1);
6068
+ }
6069
+
6070
+ const override = createPolicyOverride(db, {
6071
+ pipelineId,
6072
+ taskTypes,
6073
+ requirementKeys: taskTypes.map((taskType) => `completed_task_type:${taskType}`),
6074
+ reason: opts.reason,
6075
+ approvedBy: opts.by || null,
6076
+ });
6077
+ db.close();
6078
+
6079
+ if (opts.json) {
6080
+ console.log(JSON.stringify({ override }, null, 2));
6081
+ return;
6082
+ }
6083
+
6084
+ console.log(`${chalk.yellow('!')} Policy override ${chalk.cyan(override.id)} recorded for ${chalk.cyan(pipelineId)}`);
6085
+ console.log(` ${chalk.dim('task types:')} ${taskTypes.join(', ')}`);
6086
+ console.log(` ${chalk.dim('approved by:')} ${opts.by || 'operator'}`);
6087
+ console.log(` ${chalk.dim('reason:')} ${opts.reason}`);
6088
+ console.log(` ${chalk.dim('next:')} switchman pipeline status ${pipelineId}`);
6089
+ });
6090
+
6091
+ policyCmd
6092
+ .command('revoke <overrideId>')
6093
+ .description('Revoke a previously recorded policy override')
6094
+ .option('--reason <text>', 'Why the override is being revoked')
6095
+ .option('--by <actor>', 'Who revoked the override', 'operator')
6096
+ .option('--json', 'Output raw JSON')
6097
+ .action((overrideId, opts) => {
6098
+ const repoRoot = getRepo();
6099
+ const db = getDb(repoRoot);
6100
+ const override = revokePolicyOverride(db, overrideId, {
6101
+ revokedBy: opts.by || null,
6102
+ reason: opts.reason || null,
6103
+ });
6104
+ db.close();
6105
+
6106
+ if (opts.json) {
6107
+ console.log(JSON.stringify({ override }, null, 2));
6108
+ return;
6109
+ }
6110
+
6111
+ console.log(`${chalk.green('✓')} Policy override ${chalk.cyan(override.id)} revoked`);
6112
+ console.log(` ${chalk.dim('pipeline:')} ${override.pipeline_id}`);
6113
+ console.log(` ${chalk.dim('revoked by:')} ${opts.by || 'operator'}`);
6114
+ if (opts.reason) {
6115
+ console.log(` ${chalk.dim('reason:')} ${opts.reason}`);
6116
+ }
6117
+ });
6118
+
6119
+ policyCmd
6120
+ .command('list-overrides <pipelineId>')
6121
+ .description('Show policy overrides recorded for a pipeline')
6122
+ .option('--json', 'Output raw JSON')
6123
+ .action((pipelineId, opts) => {
6124
+ const repoRoot = getRepo();
6125
+ const db = getDb(repoRoot);
6126
+ const overrides = listPolicyOverrides(db, { pipelineId, limit: 100 });
6127
+ db.close();
6128
+
6129
+ if (opts.json) {
6130
+ console.log(JSON.stringify({ pipeline_id: pipelineId, overrides }, null, 2));
6131
+ return;
6132
+ }
6133
+
6134
+ console.log(chalk.bold(`Policy overrides for ${pipelineId}`));
6135
+ if (overrides.length === 0) {
6136
+ console.log(` ${chalk.green('No overrides recorded.')}`);
6137
+ return;
6138
+ }
6139
+ for (const entry of overrides) {
6140
+ console.log(` ${chalk.cyan(entry.id)} ${chalk.dim(entry.status)}`);
6141
+ console.log(` ${chalk.dim('task types:')} ${(entry.task_types || []).join(', ') || 'none'}`);
6142
+ console.log(` ${chalk.dim('approved by:')} ${entry.approved_by || 'unknown'}`);
6143
+ console.log(` ${chalk.dim('reason:')} ${entry.reason}`);
6144
+ }
6145
+ });
6146
+
3678
6147
  program.parse();