switchman-dev 0.1.5 → 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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/README.md +173 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2941 -314
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
- package/src/core/telemetry.js +210 -0
package/src/cli/index.js
CHANGED
|
@@ -19,31 +19,57 @@
|
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
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';
|
|
52
|
+
import {
|
|
53
|
+
captureTelemetryEvent,
|
|
54
|
+
disableTelemetry,
|
|
55
|
+
enableTelemetry,
|
|
56
|
+
getTelemetryConfigPath,
|
|
57
|
+
getTelemetryRuntimeConfig,
|
|
58
|
+
loadTelemetryConfig,
|
|
59
|
+
maybePromptForTelemetry,
|
|
60
|
+
sendTelemetryEvent,
|
|
61
|
+
} from '../core/telemetry.js';
|
|
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
|
+
};
|
|
47
73
|
|
|
48
74
|
function installMcpConfig(targetDirs) {
|
|
49
75
|
return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
|
|
@@ -69,6 +95,54 @@ function getDb(repoRoot) {
|
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
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
|
+
|
|
72
146
|
function statusBadge(status) {
|
|
73
147
|
const colors = {
|
|
74
148
|
pending: chalk.yellow,
|
|
@@ -224,224 +298,1426 @@ function printErrorWithNext(message, nextCommand = null) {
|
|
|
224
298
|
}
|
|
225
299
|
}
|
|
226
300
|
|
|
227
|
-
function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
|
|
232
|
-
const checks = [];
|
|
233
|
-
const nextSteps = [];
|
|
234
|
-
let workspaces = [];
|
|
235
|
-
let db = null;
|
|
301
|
+
function writeDemoFile(filePath, contents) {
|
|
302
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
303
|
+
writeFileSync(filePath, contents);
|
|
304
|
+
}
|
|
236
305
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
label: 'Project database',
|
|
242
|
-
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'],
|
|
243
310
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
316
|
|
|
248
|
-
|
|
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;
|
|
249
355
|
try {
|
|
250
|
-
db
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
checks.push({
|
|
254
|
-
key: 'database_open',
|
|
255
|
-
ok: false,
|
|
256
|
-
label: 'Database access',
|
|
257
|
-
detail: 'Switchman could not open the project database',
|
|
258
|
-
});
|
|
259
|
-
nextSteps.push('Re-run `switchman init` if the project database looks corrupted.');
|
|
260
|
-
} finally {
|
|
261
|
-
try { db?.close(); } catch { /* no-op */ }
|
|
356
|
+
claimFiles(db, taskDocs, 'agent2', ['src/auth.js']);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
blockedClaimMessage = String(err.message || 'Claim blocked.');
|
|
262
359
|
}
|
|
263
|
-
|
|
360
|
+
claimFiles(db, taskDocs, 'agent2', ['docs/auth-flow.md']);
|
|
264
361
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
key: 'workspaces',
|
|
269
|
-
ok: workspaceReady,
|
|
270
|
-
label: 'Agent workspaces',
|
|
271
|
-
detail: workspaceReady
|
|
272
|
-
? `${agentWorkspaces.length} agent workspace(s) registered`
|
|
273
|
-
: 'No agent workspaces are registered yet',
|
|
274
|
-
});
|
|
275
|
-
if (!workspaceReady) {
|
|
276
|
-
nextSteps.push('Run `switchman setup --agents 3` to create agent workspaces.');
|
|
277
|
-
}
|
|
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);
|
|
278
365
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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;
|
|
288
446
|
}
|
|
447
|
+
}
|
|
289
448
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.');
|
|
299
461
|
}
|
|
462
|
+
return normalized;
|
|
463
|
+
}
|
|
300
464
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
label: 'Claude guide',
|
|
306
|
-
detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
|
|
307
|
-
});
|
|
308
|
-
if (!claudeGuideExists) {
|
|
309
|
-
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.`);
|
|
310
469
|
}
|
|
311
470
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
? 'Windsurf shared MCP config is installed'
|
|
319
|
-
: 'Windsurf shared MCP config is optional and not installed',
|
|
320
|
-
});
|
|
321
|
-
if (!windsurfConfigExists) {
|
|
322
|
-
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;
|
|
323
477
|
}
|
|
324
478
|
|
|
325
|
-
const
|
|
479
|
+
const recentEvents = listMergeQueueEvents(db, item.id, { limit: 5 });
|
|
326
480
|
return {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
path: entry.path,
|
|
333
|
-
branch: entry.branch,
|
|
334
|
-
})),
|
|
335
|
-
suggested_commands: [
|
|
336
|
-
'switchman status --watch',
|
|
337
|
-
'switchman task add "Your first task" --priority 8',
|
|
338
|
-
'switchman gate ci',
|
|
339
|
-
...nextSteps.some((step) => step.includes('Windsurf')) ? ['switchman mcp install --windsurf'] : [],
|
|
340
|
-
],
|
|
341
|
-
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,
|
|
342
486
|
};
|
|
343
487
|
}
|
|
344
488
|
|
|
345
|
-
function
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
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}\`.`;
|
|
350
493
|
}
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
for (const step of report.next_steps) {
|
|
355
|
-
console.log(` - ${step}`);
|
|
356
|
-
}
|
|
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.`;
|
|
357
497
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
for (const command of report.suggested_commands.slice(0, 4)) {
|
|
361
|
-
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.`;
|
|
362
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.';
|
|
363
508
|
}
|
|
364
509
|
|
|
365
|
-
function
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
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
|
+
});
|
|
385
531
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
missing_expected_source_changes: 'missing expected source changes',
|
|
402
|
-
objective_not_evidenced: 'task objective not evidenced',
|
|
403
|
-
no_changes_detected: 'no changes detected',
|
|
404
|
-
task_execution_timeout: 'task execution timed out',
|
|
405
|
-
task_failed: 'task failed',
|
|
406
|
-
agent_command_failed: 'agent command failed',
|
|
407
|
-
rejected: 'rejected',
|
|
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
|
+
),
|
|
408
547
|
};
|
|
409
|
-
return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
|
|
410
548
|
}
|
|
411
549
|
|
|
412
|
-
function
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
550
|
+
function buildStaleTaskExplainReport(db, taskId) {
|
|
551
|
+
const task = getTask(db, taskId);
|
|
552
|
+
if (!task) {
|
|
553
|
+
throw new Error(`Task ${taskId} does not exist.`);
|
|
554
|
+
}
|
|
555
|
+
|
|
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,
|
|
428
583
|
};
|
|
429
|
-
return actions[reasonCode] || null;
|
|
430
584
|
}
|
|
431
585
|
|
|
432
|
-
function
|
|
433
|
-
const
|
|
434
|
-
.split('\n')
|
|
435
|
-
.map((line) => line.trim())
|
|
436
|
-
.filter(Boolean)
|
|
437
|
-
.reverse()
|
|
438
|
-
.find((line) => line.startsWith('FAILED: '));
|
|
439
|
-
if (!failureLine) return null;
|
|
440
|
-
const failureText = failureLine.slice('FAILED: '.length);
|
|
441
|
-
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
586
|
+
function normalizeDependencyInvalidation(item) {
|
|
587
|
+
const details = item.details || {};
|
|
442
588
|
return {
|
|
443
|
-
|
|
444
|
-
|
|
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'}`,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
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 || '')
|
|
1710
|
+
.split('\n')
|
|
1711
|
+
.map((line) => line.trim())
|
|
1712
|
+
.filter(Boolean)
|
|
1713
|
+
.reverse()
|
|
1714
|
+
.find((line) => line.startsWith('FAILED: '));
|
|
1715
|
+
if (!failureLine) return null;
|
|
1716
|
+
const failureText = failureLine.slice('FAILED: '.length);
|
|
1717
|
+
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
1718
|
+
return {
|
|
1719
|
+
reason_code: reasonMatch ? reasonMatch[1] : null,
|
|
1720
|
+
summary: reasonMatch ? reasonMatch[2] : failureText,
|
|
445
1721
|
};
|
|
446
1722
|
}
|
|
447
1723
|
|
|
@@ -491,6 +1767,7 @@ function commandForFailedTask(task, failure) {
|
|
|
491
1767
|
}
|
|
492
1768
|
|
|
493
1769
|
function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
|
|
1770
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
494
1771
|
const failedTasks = tasks
|
|
495
1772
|
.filter((task) => task.status === 'failed')
|
|
496
1773
|
.map((task) => {
|
|
@@ -540,6 +1817,7 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
540
1817
|
next_step: 'review the overlapping branches before merge',
|
|
541
1818
|
}));
|
|
542
1819
|
|
|
1820
|
+
const staleClusters = buildStaleClusters(aiGate.dependency_invalidations || []);
|
|
543
1821
|
const attention = [
|
|
544
1822
|
...staleLeases.map((lease) => ({
|
|
545
1823
|
kind: 'stale_lease',
|
|
@@ -627,14 +1905,17 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
627
1905
|
});
|
|
628
1906
|
}
|
|
629
1907
|
|
|
630
|
-
for (const
|
|
1908
|
+
for (const cluster of staleClusters) {
|
|
631
1909
|
attention.push({
|
|
632
1910
|
kind: 'dependency_invalidation',
|
|
633
|
-
title:
|
|
634
|
-
detail:
|
|
635
|
-
next_step:
|
|
636
|
-
command:
|
|
637
|
-
severity:
|
|
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,
|
|
638
1919
|
});
|
|
639
1920
|
}
|
|
640
1921
|
|
|
@@ -644,6 +1925,16 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
644
1925
|
? 'warn'
|
|
645
1926
|
: 'healthy';
|
|
646
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
|
+
|
|
647
1938
|
return {
|
|
648
1939
|
repo_root: repoRoot,
|
|
649
1940
|
health,
|
|
@@ -683,8 +1974,10 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
683
1974
|
ai_gate_status: aiGate.status,
|
|
684
1975
|
boundary_validations: aiGate.boundary_validations || [],
|
|
685
1976
|
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
1977
|
+
stale_clusters: staleClusters,
|
|
686
1978
|
compliance: scanReport.complianceSummary,
|
|
687
1979
|
semantic_conflicts: scanReport.semanticConflicts || [],
|
|
1980
|
+
policy_state: repoPolicyState,
|
|
688
1981
|
},
|
|
689
1982
|
next_steps: attention.length > 0
|
|
690
1983
|
? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
|
|
@@ -752,6 +2045,20 @@ function buildUnifiedStatusReport({
|
|
|
752
2045
|
...(queueItems.length > 0 ? ['switchman queue status'] : []),
|
|
753
2046
|
...(queueSummary.next ? ['switchman queue run'] : []),
|
|
754
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'];
|
|
755
2062
|
|
|
756
2063
|
return {
|
|
757
2064
|
generated_at: new Date().toISOString(),
|
|
@@ -765,6 +2072,8 @@ function buildUnifiedStatusReport({
|
|
|
765
2072
|
? 'Repo needs attention before more work or merge.'
|
|
766
2073
|
: attention.some((item) => item.severity === 'warn')
|
|
767
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.'
|
|
768
2077
|
: 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
|
|
769
2078
|
lease_policy: leasePolicy,
|
|
770
2079
|
counts: {
|
|
@@ -788,10 +2097,10 @@ function buildUnifiedStatusReport({
|
|
|
788
2097
|
file_path: claim.file_path,
|
|
789
2098
|
})),
|
|
790
2099
|
next_steps: [...new Set([
|
|
791
|
-
...doctorReport.next_steps,
|
|
2100
|
+
...(attention.length > 0 ? doctorReport.next_steps : defaultNextSteps),
|
|
792
2101
|
...queueAttention.map((item) => item.next_step),
|
|
793
2102
|
])].slice(0, 6),
|
|
794
|
-
suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
|
|
2103
|
+
suggested_commands: [...new Set(attention.length > 0 ? suggestedCommands : defaultSuggestedCommands)].slice(0, 6),
|
|
795
2104
|
};
|
|
796
2105
|
}
|
|
797
2106
|
|
|
@@ -860,7 +2169,15 @@ function renderUnifiedStatusReport(report) {
|
|
|
860
2169
|
? ('title' in focusItem
|
|
861
2170
|
? `${focusItem.title}${focusItem.detail ? ` ${chalk.dim(`• ${focusItem.detail}`)}` : ''}`
|
|
862
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.'
|
|
863
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.';
|
|
864
2181
|
const queueLoad = queueCounts.queued + queueCounts.retrying + queueCounts.merging + queueCounts.blocked;
|
|
865
2182
|
const landingLabel = report.merge_readiness.ci_gate_ok ? 'ready' : 'hold';
|
|
866
2183
|
|
|
@@ -890,8 +2207,14 @@ function renderUnifiedStatusReport(report) {
|
|
|
890
2207
|
{ label: 'merging', value: queueCounts.merging, color: chalk.blue },
|
|
891
2208
|
{ label: 'merged', value: queueCounts.merged, color: chalk.green },
|
|
892
2209
|
]));
|
|
893
|
-
console.log(`${chalk.bold('
|
|
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}`);
|
|
894
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
|
+
}
|
|
895
2218
|
|
|
896
2219
|
const runningLines = report.active_work.length > 0
|
|
897
2220
|
? report.active_work.slice(0, 5).map((item) => {
|
|
@@ -931,8 +2254,14 @@ function renderUnifiedStatusReport(report) {
|
|
|
931
2254
|
const queueLines = report.queue.items.length > 0
|
|
932
2255
|
? [
|
|
933
2256
|
...(report.queue.summary.next
|
|
934
|
-
? [
|
|
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
|
+
]
|
|
935
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 || '')}`),
|
|
936
2265
|
...report.queue.items
|
|
937
2266
|
.filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
|
|
938
2267
|
.slice(0, 4)
|
|
@@ -945,6 +2274,35 @@ function renderUnifiedStatusReport(report) {
|
|
|
945
2274
|
]
|
|
946
2275
|
: [chalk.dim('No queued merges.')];
|
|
947
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
|
+
|
|
948
2306
|
const nextActionLines = [
|
|
949
2307
|
...(report.next_up.length > 0
|
|
950
2308
|
? report.next_up.map((task) => `${renderChip('NEXT', `p${task.priority}`, chalk.green)} ${task.title} ${chalk.dim(task.id)}`)
|
|
@@ -957,6 +2315,8 @@ function renderUnifiedStatusReport(report) {
|
|
|
957
2315
|
renderPanel('Running now', runningLines, chalk.cyan),
|
|
958
2316
|
renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
|
|
959
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),
|
|
960
2320
|
renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
|
|
961
2321
|
renderPanel('Next action', nextActionLines, chalk.green),
|
|
962
2322
|
];
|
|
@@ -1071,6 +2431,7 @@ program
|
|
|
1071
2431
|
program.showHelpAfterError('(run with --help for usage examples)');
|
|
1072
2432
|
program.addHelpText('after', `
|
|
1073
2433
|
Start here:
|
|
2434
|
+
switchman demo
|
|
1074
2435
|
switchman setup --agents 5
|
|
1075
2436
|
switchman status --watch
|
|
1076
2437
|
switchman gate ci
|
|
@@ -1085,6 +2446,50 @@ Docs:
|
|
|
1085
2446
|
docs/setup-cursor.md
|
|
1086
2447
|
`);
|
|
1087
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
|
+
|
|
1088
2493
|
// ── init ──────────────────────────────────────────────────────────────────────
|
|
1089
2494
|
|
|
1090
2495
|
program
|
|
@@ -1133,7 +2538,7 @@ Examples:
|
|
|
1133
2538
|
switchman setup --agents 5
|
|
1134
2539
|
switchman setup --agents 3 --prefix team
|
|
1135
2540
|
`)
|
|
1136
|
-
.action((opts) => {
|
|
2541
|
+
.action(async (opts) => {
|
|
1137
2542
|
const agentCount = parseInt(opts.agents);
|
|
1138
2543
|
|
|
1139
2544
|
if (isNaN(agentCount) || agentCount < 1 || agentCount > 10) {
|
|
@@ -1208,15 +2613,19 @@ Examples:
|
|
|
1208
2613
|
|
|
1209
2614
|
console.log('');
|
|
1210
2615
|
console.log(chalk.bold('Next steps:'));
|
|
1211
|
-
console.log(` 1. Add
|
|
2616
|
+
console.log(` 1. Add a first task:`);
|
|
1212
2617
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
1213
|
-
console.log(` 2. Open Claude Code or Cursor in
|
|
1214
|
-
console.log(` 3.
|
|
1215
|
-
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')}`);
|
|
1216
2621
|
console.log('');
|
|
1217
2622
|
|
|
1218
2623
|
const verification = collectSetupVerification(repoRoot);
|
|
1219
2624
|
renderSetupVerification(verification, { compact: true });
|
|
2625
|
+
await maybeCaptureTelemetry('setup_completed', {
|
|
2626
|
+
agent_count: agentCount,
|
|
2627
|
+
verification_ok: verification.ok,
|
|
2628
|
+
});
|
|
1220
2629
|
|
|
1221
2630
|
} catch (err) {
|
|
1222
2631
|
spinner.fail(err.message);
|
|
@@ -1236,7 +2645,7 @@ Examples:
|
|
|
1236
2645
|
|
|
1237
2646
|
Use this after setup or whenever editor/config wiring feels off.
|
|
1238
2647
|
`)
|
|
1239
|
-
.action((opts) => {
|
|
2648
|
+
.action(async (opts) => {
|
|
1240
2649
|
const repoRoot = getRepo();
|
|
1241
2650
|
const report = collectSetupVerification(repoRoot, { homeDir: opts.home || null });
|
|
1242
2651
|
|
|
@@ -1247,6 +2656,10 @@ Use this after setup or whenever editor/config wiring feels off.
|
|
|
1247
2656
|
}
|
|
1248
2657
|
|
|
1249
2658
|
renderSetupVerification(report);
|
|
2659
|
+
await maybeCaptureTelemetry(report.ok ? 'verify_setup_passed' : 'verify_setup_failed', {
|
|
2660
|
+
check_count: report.checks.length,
|
|
2661
|
+
next_step_count: report.next_steps.length,
|
|
2662
|
+
}, { homeDir: opts.home || null });
|
|
1250
2663
|
if (!report.ok) process.exitCode = 1;
|
|
1251
2664
|
});
|
|
1252
2665
|
|
|
@@ -1254,6 +2667,101 @@ Use this after setup or whenever editor/config wiring feels off.
|
|
|
1254
2667
|
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
1255
2668
|
|
|
1256
2669
|
const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
|
|
2670
|
+
const telemetryCmd = program.command('telemetry').description('Control anonymous opt-in telemetry for Switchman');
|
|
2671
|
+
|
|
2672
|
+
telemetryCmd
|
|
2673
|
+
.command('status')
|
|
2674
|
+
.description('Show whether telemetry is enabled and where events would be sent')
|
|
2675
|
+
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2676
|
+
.option('--json', 'Output raw JSON')
|
|
2677
|
+
.action((opts) => {
|
|
2678
|
+
const config = loadTelemetryConfig(opts.home || undefined);
|
|
2679
|
+
const runtime = getTelemetryRuntimeConfig();
|
|
2680
|
+
const payload = {
|
|
2681
|
+
enabled: config.telemetry_enabled === true,
|
|
2682
|
+
configured: Boolean(runtime.apiKey) && !runtime.disabled,
|
|
2683
|
+
install_id: config.telemetry_install_id,
|
|
2684
|
+
destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
|
|
2685
|
+
config_path: getTelemetryConfigPath(opts.home || undefined),
|
|
2686
|
+
};
|
|
2687
|
+
|
|
2688
|
+
if (opts.json) {
|
|
2689
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
|
|
2694
|
+
console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
|
|
2695
|
+
console.log(`Config file: ${chalk.dim(payload.config_path)}`);
|
|
2696
|
+
if (payload.install_id) {
|
|
2697
|
+
console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
|
|
2701
|
+
telemetryCmd
|
|
2702
|
+
.command('enable')
|
|
2703
|
+
.description('Enable anonymous telemetry for setup and operator workflows')
|
|
2704
|
+
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2705
|
+
.action((opts) => {
|
|
2706
|
+
const runtime = getTelemetryRuntimeConfig();
|
|
2707
|
+
if (!runtime.apiKey || runtime.disabled) {
|
|
2708
|
+
printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
|
|
2709
|
+
process.exitCode = 1;
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
const result = enableTelemetry(opts.home || undefined);
|
|
2713
|
+
console.log(`${chalk.green('✓')} Telemetry enabled`);
|
|
2714
|
+
console.log(` ${chalk.dim(result.path)}`);
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
telemetryCmd
|
|
2718
|
+
.command('disable')
|
|
2719
|
+
.description('Disable anonymous telemetry')
|
|
2720
|
+
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2721
|
+
.action((opts) => {
|
|
2722
|
+
const result = disableTelemetry(opts.home || undefined);
|
|
2723
|
+
console.log(`${chalk.green('✓')} Telemetry disabled`);
|
|
2724
|
+
console.log(` ${chalk.dim(result.path)}`);
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
telemetryCmd
|
|
2728
|
+
.command('test')
|
|
2729
|
+
.description('Send one test telemetry event and report whether delivery succeeded')
|
|
2730
|
+
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2731
|
+
.option('--json', 'Output raw JSON')
|
|
2732
|
+
.action(async (opts) => {
|
|
2733
|
+
const result = await sendTelemetryEvent('telemetry_test', {
|
|
2734
|
+
app_version: program.version(),
|
|
2735
|
+
os: process.platform,
|
|
2736
|
+
node_version: process.version,
|
|
2737
|
+
source: 'switchman-cli-test',
|
|
2738
|
+
}, { homeDir: opts.home || undefined });
|
|
2739
|
+
|
|
2740
|
+
if (opts.json) {
|
|
2741
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2742
|
+
if (!result.ok) process.exitCode = 1;
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
if (result.ok) {
|
|
2747
|
+
console.log(`${chalk.green('✓')} Telemetry test event delivered`);
|
|
2748
|
+
console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
|
|
2749
|
+
if (result.status) {
|
|
2750
|
+
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
2751
|
+
}
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
printErrorWithNext(`Telemetry test failed (${result.reason || 'unknown_error'}).`, 'switchman telemetry status');
|
|
2756
|
+
console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
|
|
2757
|
+
if (result.status) {
|
|
2758
|
+
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
2759
|
+
}
|
|
2760
|
+
if (result.error) {
|
|
2761
|
+
console.log(` ${chalk.dim('error:')} ${result.error}`);
|
|
2762
|
+
}
|
|
2763
|
+
process.exitCode = 1;
|
|
2764
|
+
});
|
|
1257
2765
|
|
|
1258
2766
|
mcpCmd
|
|
1259
2767
|
.command('install')
|
|
@@ -1370,6 +2878,67 @@ taskCmd
|
|
|
1370
2878
|
}
|
|
1371
2879
|
});
|
|
1372
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
|
+
|
|
1373
2942
|
taskCmd
|
|
1374
2943
|
.command('done <taskId>')
|
|
1375
2944
|
.description('Mark a task as complete and release all file claims')
|
|
@@ -1457,8 +3026,13 @@ Examples:
|
|
|
1457
3026
|
switchman queue add feature/auth-hardening
|
|
1458
3027
|
switchman queue add --worktree agent2
|
|
1459
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.
|
|
1460
3034
|
`)
|
|
1461
|
-
.action((branch, opts) => {
|
|
3035
|
+
.action(async (branch, opts) => {
|
|
1462
3036
|
const repoRoot = getRepo();
|
|
1463
3037
|
const db = getDb(repoRoot);
|
|
1464
3038
|
|
|
@@ -1478,13 +3052,29 @@ Examples:
|
|
|
1478
3052
|
submittedBy: opts.submittedBy || null,
|
|
1479
3053
|
};
|
|
1480
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
|
+
});
|
|
1481
3064
|
payload = {
|
|
1482
3065
|
sourceType: 'pipeline',
|
|
1483
|
-
sourceRef:
|
|
3066
|
+
sourceRef: landingTarget.branch,
|
|
1484
3067
|
sourcePipelineId: opts.pipeline,
|
|
3068
|
+
sourceWorktree: landingTarget.worktree || null,
|
|
1485
3069
|
targetBranch: opts.target,
|
|
1486
3070
|
maxRetries: opts.maxRetries,
|
|
1487
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,
|
|
1488
3078
|
};
|
|
1489
3079
|
} else if (branch) {
|
|
1490
3080
|
payload = {
|
|
@@ -1509,6 +3099,9 @@ Examples:
|
|
|
1509
3099
|
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
1510
3100
|
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
1511
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
|
+
}
|
|
1512
3105
|
} catch (err) {
|
|
1513
3106
|
db.close();
|
|
1514
3107
|
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
@@ -1540,7 +3133,9 @@ queueCmd
|
|
|
1540
3133
|
for (const item of items) {
|
|
1541
3134
|
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
1542
3135
|
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
1543
|
-
|
|
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}`);
|
|
1544
3139
|
if (item.last_error_summary) {
|
|
1545
3140
|
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1546
3141
|
}
|
|
@@ -1571,7 +3166,7 @@ What it helps you answer:
|
|
|
1571
3166
|
const repoRoot = getRepo();
|
|
1572
3167
|
const db = getDb(repoRoot);
|
|
1573
3168
|
const items = listMergeQueue(db);
|
|
1574
|
-
const summary = buildQueueStatusSummary(items);
|
|
3169
|
+
const summary = buildQueueStatusSummary(items, { db, repoRoot });
|
|
1575
3170
|
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
1576
3171
|
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
1577
3172
|
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
@@ -1582,9 +3177,14 @@ What it helps you answer:
|
|
|
1582
3177
|
return;
|
|
1583
3178
|
}
|
|
1584
3179
|
|
|
1585
|
-
const queueHealth = summary.counts.blocked > 0
|
|
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';
|
|
1586
3185
|
const queueHealthColor = colorForHealth(queueHealth);
|
|
1587
|
-
const
|
|
3186
|
+
const retryingItems = items.filter((item) => item.status === 'retrying');
|
|
3187
|
+
const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
|
|
1588
3188
|
const focusLine = focus
|
|
1589
3189
|
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
1590
3190
|
: 'Nothing waiting. Landing queue is clear.';
|
|
@@ -1596,6 +3196,9 @@ What it helps you answer:
|
|
|
1596
3196
|
console.log(renderSignalStrip([
|
|
1597
3197
|
renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
|
|
1598
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),
|
|
1599
3202
|
renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1600
3203
|
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
1601
3204
|
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
@@ -1610,11 +3213,23 @@ What it helps you answer:
|
|
|
1610
3213
|
|
|
1611
3214
|
const queueFocusLines = summary.next
|
|
1612
3215
|
? [
|
|
1613
|
-
`${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}`)}`,
|
|
1614
|
-
` ${chalk.
|
|
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'}`,
|
|
1615
3220
|
]
|
|
1616
3221
|
: [chalk.dim('No queued landing work right now.')];
|
|
1617
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
|
+
|
|
1618
3233
|
const queueBlockedLines = summary.blocked.length > 0
|
|
1619
3234
|
? summary.blocked.slice(0, 4).flatMap((item) => {
|
|
1620
3235
|
const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
@@ -1624,13 +3239,14 @@ What it helps you answer:
|
|
|
1624
3239
|
})
|
|
1625
3240
|
: [chalk.green('Nothing blocked.')];
|
|
1626
3241
|
|
|
1627
|
-
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
|
|
1628
3243
|
? items
|
|
1629
|
-
.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
3244
|
+
.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
1630
3245
|
.slice(0, 4)
|
|
1631
3246
|
.flatMap((item) => {
|
|
1632
|
-
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}`];
|
|
1633
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}`);
|
|
1634
3250
|
return lines;
|
|
1635
3251
|
})
|
|
1636
3252
|
: [chalk.green('No in-flight queue items right now.')];
|
|
@@ -1641,9 +3257,23 @@ What it helps you answer:
|
|
|
1641
3257
|
...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
|
|
1642
3258
|
];
|
|
1643
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
|
+
|
|
1644
3271
|
console.log('');
|
|
1645
3272
|
for (const block of [
|
|
1646
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),
|
|
1647
3277
|
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1648
3278
|
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
1649
3279
|
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
@@ -1664,6 +3294,8 @@ queueCmd
|
|
|
1664
3294
|
.command('run')
|
|
1665
3295
|
.description('Process landing-queue items one at a time')
|
|
1666
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')
|
|
1667
3299
|
.option('--target <branch>', 'Default target branch', 'main')
|
|
1668
3300
|
.option('--watch', 'Keep polling for new queue items')
|
|
1669
3301
|
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
@@ -1672,6 +3304,7 @@ queueCmd
|
|
|
1672
3304
|
.addHelpText('after', `
|
|
1673
3305
|
Examples:
|
|
1674
3306
|
switchman queue run
|
|
3307
|
+
switchman queue run --follow-plan --merge-budget 2
|
|
1675
3308
|
switchman queue run --watch
|
|
1676
3309
|
switchman queue run --watch --watch-interval-ms 1000
|
|
1677
3310
|
`)
|
|
@@ -1680,12 +3313,21 @@ Examples:
|
|
|
1680
3313
|
|
|
1681
3314
|
try {
|
|
1682
3315
|
const watch = Boolean(opts.watch);
|
|
3316
|
+
const followPlan = Boolean(opts.followPlan);
|
|
1683
3317
|
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
1684
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;
|
|
1685
3322
|
const aggregate = {
|
|
1686
3323
|
processed: [],
|
|
1687
3324
|
cycles: 0,
|
|
1688
3325
|
watch,
|
|
3326
|
+
execution_policy: {
|
|
3327
|
+
follow_plan: followPlan,
|
|
3328
|
+
merge_budget: mergeBudget,
|
|
3329
|
+
merged_count: 0,
|
|
3330
|
+
},
|
|
1689
3331
|
};
|
|
1690
3332
|
|
|
1691
3333
|
while (true) {
|
|
@@ -1693,87 +3335,437 @@ Examples:
|
|
|
1693
3335
|
const result = await runMergeQueue(db, repoRoot, {
|
|
1694
3336
|
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
1695
3337
|
targetBranch: opts.target || 'main',
|
|
3338
|
+
followPlan,
|
|
3339
|
+
mergeBudget,
|
|
1696
3340
|
});
|
|
1697
3341
|
db.close();
|
|
1698
3342
|
|
|
1699
3343
|
aggregate.processed.push(...result.processed);
|
|
1700
3344
|
aggregate.summary = result.summary;
|
|
3345
|
+
aggregate.deferred = result.deferred || aggregate.deferred || null;
|
|
3346
|
+
aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
|
|
1701
3347
|
aggregate.cycles += 1;
|
|
1702
3348
|
|
|
1703
3349
|
if (!watch) break;
|
|
1704
3350
|
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
3351
|
+
if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
|
|
1705
3352
|
if (result.processed.length === 0) {
|
|
1706
3353
|
sleepSync(watchIntervalMs);
|
|
1707
3354
|
}
|
|
1708
3355
|
}
|
|
1709
3356
|
|
|
1710
3357
|
if (opts.json) {
|
|
1711
|
-
console.log(JSON.stringify(aggregate, null, 2));
|
|
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();
|
|
3563
|
+
|
|
3564
|
+
if (opts.json) {
|
|
3565
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1712
3566
|
return;
|
|
1713
3567
|
}
|
|
1714
3568
|
|
|
1715
|
-
|
|
1716
|
-
|
|
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}`);
|
|
1717
3573
|
return;
|
|
1718
3574
|
}
|
|
1719
3575
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
console.log(
|
|
1724
|
-
console.log(`
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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}`);
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
|
|
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}`);
|
|
1729
3589
|
}
|
|
1730
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
|
+
}
|
|
1731
3596
|
} catch (err) {
|
|
1732
|
-
|
|
3597
|
+
db.close();
|
|
3598
|
+
printErrorWithNext(err.message, 'switchman status');
|
|
1733
3599
|
process.exitCode = 1;
|
|
1734
3600
|
}
|
|
1735
3601
|
});
|
|
1736
3602
|
|
|
1737
|
-
|
|
1738
|
-
.command('
|
|
1739
|
-
.description('
|
|
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')
|
|
1740
3607
|
.option('--json', 'Output raw JSON')
|
|
1741
|
-
.action((
|
|
3608
|
+
.action((taskId, opts) => {
|
|
1742
3609
|
const repoRoot = getRepo();
|
|
1743
3610
|
const db = getDb(repoRoot);
|
|
1744
|
-
const item = retryMergeQueueItem(db, itemId);
|
|
1745
|
-
db.close();
|
|
1746
3611
|
|
|
1747
|
-
|
|
1748
|
-
|
|
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');
|
|
1749
3658
|
process.exitCode = 1;
|
|
1750
|
-
return;
|
|
1751
3659
|
}
|
|
3660
|
+
});
|
|
1752
3661
|
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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);
|
|
1757
3669
|
|
|
1758
|
-
|
|
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
|
+
}
|
|
1759
3714
|
});
|
|
1760
3715
|
|
|
1761
|
-
|
|
1762
|
-
.command('
|
|
1763
|
-
.description('
|
|
1764
|
-
.
|
|
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) => {
|
|
1765
3721
|
const repoRoot = getRepo();
|
|
1766
3722
|
const db = getDb(repoRoot);
|
|
1767
|
-
const item = removeMergeQueueItem(db, itemId);
|
|
1768
|
-
db.close();
|
|
1769
3723
|
|
|
1770
|
-
|
|
1771
|
-
|
|
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}`);
|
|
1772
3767
|
process.exitCode = 1;
|
|
1773
|
-
return;
|
|
1774
3768
|
}
|
|
1775
|
-
|
|
1776
|
-
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
1777
3769
|
});
|
|
1778
3770
|
|
|
1779
3771
|
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
@@ -1784,6 +3776,7 @@ Examples:
|
|
|
1784
3776
|
switchman pipeline start "Harden auth API permissions"
|
|
1785
3777
|
switchman pipeline exec pipe-123 "/path/to/agent-command"
|
|
1786
3778
|
switchman pipeline status pipe-123
|
|
3779
|
+
switchman pipeline land pipe-123
|
|
1787
3780
|
`);
|
|
1788
3781
|
|
|
1789
3782
|
pipelineCmd
|
|
@@ -1836,10 +3829,33 @@ Examples:
|
|
|
1836
3829
|
|
|
1837
3830
|
try {
|
|
1838
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), []);
|
|
1839
3850
|
db.close();
|
|
1840
3851
|
|
|
1841
3852
|
if (opts.json) {
|
|
1842
|
-
console.log(JSON.stringify(
|
|
3853
|
+
console.log(JSON.stringify({
|
|
3854
|
+
...result,
|
|
3855
|
+
landing_branch: landing,
|
|
3856
|
+
landing_error: landingError,
|
|
3857
|
+
policy_state: policyState,
|
|
3858
|
+
}, null, 2));
|
|
1843
3859
|
return;
|
|
1844
3860
|
}
|
|
1845
3861
|
|
|
@@ -1858,6 +3874,7 @@ Examples:
|
|
|
1858
3874
|
const focusLine = focusTask
|
|
1859
3875
|
? `${focusTask.title} ${chalk.dim(focusTask.id)}`
|
|
1860
3876
|
: 'No pipeline tasks found.';
|
|
3877
|
+
const landingLabel = buildLandingStateLabel(landing);
|
|
1861
3878
|
|
|
1862
3879
|
console.log('');
|
|
1863
3880
|
console.log(pipelineHealthColor('='.repeat(72)));
|
|
@@ -1871,6 +3888,11 @@ Examples:
|
|
|
1871
3888
|
renderChip('failed', result.counts.failed, result.counts.failed > 0 ? chalk.red : chalk.green),
|
|
1872
3889
|
]));
|
|
1873
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
|
+
}
|
|
1874
3896
|
|
|
1875
3897
|
const runningLines = result.tasks.filter((task) => task.status === 'in_progress').slice(0, 4).map((task) => {
|
|
1876
3898
|
const worktree = task.worktree || task.suggested_worktree || 'unassigned';
|
|
@@ -1886,49 +3908,298 @@ Examples:
|
|
|
1886
3908
|
const reasonLabel = humanizeReasonCode(task.failure.reason_code);
|
|
1887
3909
|
lines.push(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
|
|
1888
3910
|
}
|
|
1889
|
-
if (task.next_action) lines.push(` ${chalk.yellow('next:')} ${task.next_action}`);
|
|
1890
|
-
return lines;
|
|
1891
|
-
});
|
|
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
|
+
}
|
|
4138
|
+
|
|
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
|
+
}
|
|
1892
4151
|
|
|
1893
|
-
const
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
4152
|
+
const result = materializePipelineLandingBranch(db, repoRoot, pipelineId, {
|
|
4153
|
+
baseBranch: opts.base,
|
|
4154
|
+
landingBranch: opts.branch || null,
|
|
4155
|
+
requireCompleted: true,
|
|
4156
|
+
refresh: Boolean(opts.refresh),
|
|
1897
4157
|
});
|
|
4158
|
+
db.close();
|
|
1898
4159
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
];
|
|
4160
|
+
if (opts.json) {
|
|
4161
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4162
|
+
return;
|
|
4163
|
+
}
|
|
1904
4164
|
|
|
1905
|
-
console.log('');
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
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`);
|
|
1914
4174
|
}
|
|
4175
|
+
console.log(` ${chalk.yellow('next:')} switchman queue add ${result.branch}`);
|
|
1915
4176
|
} catch (err) {
|
|
1916
4177
|
db.close();
|
|
1917
|
-
|
|
4178
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
1918
4179
|
process.exitCode = 1;
|
|
1919
4180
|
}
|
|
1920
4181
|
});
|
|
1921
4182
|
|
|
1922
4183
|
pipelineCmd
|
|
1923
|
-
.command('
|
|
1924
|
-
.description('
|
|
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')
|
|
1925
4190
|
.option('--json', 'Output raw JSON')
|
|
1926
|
-
.action(async (pipelineId, opts) => {
|
|
4191
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
1927
4192
|
const repoRoot = getRepo();
|
|
1928
4193
|
const db = getDb(repoRoot);
|
|
1929
4194
|
|
|
1930
4195
|
try {
|
|
1931
|
-
const result = await
|
|
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
|
+
});
|
|
1932
4203
|
db.close();
|
|
1933
4204
|
|
|
1934
4205
|
if (opts.json) {
|
|
@@ -1936,7 +4207,12 @@ pipelineCmd
|
|
|
1936
4207
|
return;
|
|
1937
4208
|
}
|
|
1938
4209
|
|
|
1939
|
-
console.log(result.
|
|
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
|
+
}
|
|
1940
4216
|
} catch (err) {
|
|
1941
4217
|
db.close();
|
|
1942
4218
|
console.error(chalk.red(err.message));
|
|
@@ -1945,15 +4221,28 @@ pipelineCmd
|
|
|
1945
4221
|
});
|
|
1946
4222
|
|
|
1947
4223
|
pipelineCmd
|
|
1948
|
-
.command('
|
|
1949
|
-
.description('
|
|
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')
|
|
1950
4230
|
.option('--json', 'Output raw JSON')
|
|
1951
4231
|
.action(async (pipelineId, outputDir, opts) => {
|
|
1952
4232
|
const repoRoot = getRepo();
|
|
1953
4233
|
const db = getDb(repoRoot);
|
|
4234
|
+
const prNumber = opts.pr || (opts.prFromEnv ? resolvePrNumberFromEnv() : null);
|
|
1954
4235
|
|
|
1955
4236
|
try {
|
|
1956
|
-
|
|
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
|
+
});
|
|
1957
4246
|
db.close();
|
|
1958
4247
|
|
|
1959
4248
|
if (opts.json) {
|
|
@@ -1961,11 +4250,12 @@ pipelineCmd
|
|
|
1961
4250
|
return;
|
|
1962
4251
|
}
|
|
1963
4252
|
|
|
1964
|
-
console.log(`${chalk.green('✓')}
|
|
1965
|
-
console.log(` ${chalk.dim(result.
|
|
1966
|
-
console.log(` ${chalk.dim('
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
+
}
|
|
1969
4259
|
} catch (err) {
|
|
1970
4260
|
db.close();
|
|
1971
4261
|
console.error(chalk.red(err.message));
|
|
@@ -1974,37 +4264,86 @@ pipelineCmd
|
|
|
1974
4264
|
});
|
|
1975
4265
|
|
|
1976
4266
|
pipelineCmd
|
|
1977
|
-
.command('
|
|
1978
|
-
.description('
|
|
1979
|
-
.option('--
|
|
1980
|
-
.option('--
|
|
1981
|
-
.option('--
|
|
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')
|
|
1982
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')
|
|
1983
4278
|
.option('--json', 'Output raw JSON')
|
|
1984
4279
|
.action(async (pipelineId, outputDir, opts) => {
|
|
1985
4280
|
const repoRoot = getRepo();
|
|
1986
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);
|
|
1987
4285
|
|
|
1988
4286
|
try {
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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,
|
|
1993
4319
|
ghCommand: opts.ghCommand,
|
|
1994
4320
|
outputDir: outputDir || null,
|
|
4321
|
+
updateExisting: true,
|
|
1995
4322
|
});
|
|
1996
4323
|
db.close();
|
|
1997
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
|
+
|
|
1998
4334
|
if (opts.json) {
|
|
1999
4335
|
console.log(JSON.stringify(result, null, 2));
|
|
2000
4336
|
return;
|
|
2001
4337
|
}
|
|
2002
4338
|
|
|
2003
|
-
console.log(`${chalk.green('✓')}
|
|
2004
|
-
console.log(` ${chalk.dim('
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
console.log(` ${chalk.dim(
|
|
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`);
|
|
2008
4347
|
}
|
|
2009
4348
|
} catch (err) {
|
|
2010
4349
|
db.close();
|
|
@@ -2058,6 +4397,53 @@ pipelineCmd
|
|
|
2058
4397
|
}
|
|
2059
4398
|
});
|
|
2060
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
|
+
|
|
2061
4447
|
pipelineCmd
|
|
2062
4448
|
.command('review <pipelineId>')
|
|
2063
4449
|
.description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
|
|
@@ -2834,11 +5220,57 @@ Use this first when the repo feels stuck.
|
|
|
2834
5220
|
if (opts.json) break;
|
|
2835
5221
|
sleepSync(watchIntervalMs);
|
|
2836
5222
|
}
|
|
5223
|
+
|
|
5224
|
+
if (watch) {
|
|
5225
|
+
await maybeCaptureTelemetry('status_watch_used', {
|
|
5226
|
+
cycles,
|
|
5227
|
+
interval_ms: watchIntervalMs,
|
|
5228
|
+
});
|
|
5229
|
+
}
|
|
5230
|
+
});
|
|
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
|
+
}
|
|
2837
5268
|
});
|
|
2838
5269
|
|
|
2839
5270
|
program
|
|
2840
5271
|
.command('doctor')
|
|
2841
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')
|
|
2842
5274
|
.option('--json', 'Output raw JSON')
|
|
2843
5275
|
.addHelpText('after', `
|
|
2844
5276
|
Plain English:
|
|
@@ -2851,6 +5283,7 @@ Examples:
|
|
|
2851
5283
|
.action(async (opts) => {
|
|
2852
5284
|
const repoRoot = getRepo();
|
|
2853
5285
|
const db = getDb(repoRoot);
|
|
5286
|
+
const repairResult = opts.repair ? repairRepoState(db, repoRoot) : null;
|
|
2854
5287
|
const tasks = listTasks(db);
|
|
2855
5288
|
const activeLeases = listLeases(db, 'active');
|
|
2856
5289
|
const staleLeases = getStaleLeases(db);
|
|
@@ -2868,10 +5301,19 @@ Examples:
|
|
|
2868
5301
|
db.close();
|
|
2869
5302
|
|
|
2870
5303
|
if (opts.json) {
|
|
2871
|
-
console.log(JSON.stringify(report, null, 2));
|
|
5304
|
+
console.log(JSON.stringify(opts.repair ? { ...report, repair: repairResult } : report, null, 2));
|
|
2872
5305
|
return;
|
|
2873
5306
|
}
|
|
2874
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
|
+
|
|
2875
5317
|
const doctorColor = colorForHealth(report.health);
|
|
2876
5318
|
const blockedCount = report.attention.filter((item) => item.severity === 'block').length;
|
|
2877
5319
|
const warningCount = report.attention.filter((item) => item.severity !== 'block').length;
|
|
@@ -2922,6 +5364,19 @@ Examples:
|
|
|
2922
5364
|
})
|
|
2923
5365
|
: [chalk.green('Nothing urgent.')];
|
|
2924
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
|
+
|
|
2925
5380
|
const nextStepLines = [
|
|
2926
5381
|
...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
|
|
2927
5382
|
'',
|
|
@@ -2933,6 +5388,7 @@ Examples:
|
|
|
2933
5388
|
for (const block of [
|
|
2934
5389
|
renderPanel('Running now', runningLines, chalk.cyan),
|
|
2935
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),
|
|
2936
5392
|
renderPanel('Recommended next steps', nextStepLines, chalk.green),
|
|
2937
5393
|
]) {
|
|
2938
5394
|
for (const line of block) console.log(line);
|
|
@@ -2952,6 +5408,39 @@ Examples:
|
|
|
2952
5408
|
|
|
2953
5409
|
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
2954
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
|
+
|
|
2955
5444
|
auditCmd
|
|
2956
5445
|
.command('verify')
|
|
2957
5446
|
.description('Verify the audit log hash chain and project signatures')
|
|
@@ -3145,6 +5634,15 @@ gateCmd
|
|
|
3145
5634
|
}
|
|
3146
5635
|
}
|
|
3147
5636
|
|
|
5637
|
+
await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
|
|
5638
|
+
worktree_count: report.worktrees.length,
|
|
5639
|
+
unclaimed_change_count: result.unclaimed_changes.length,
|
|
5640
|
+
file_conflict_count: result.file_conflicts.length,
|
|
5641
|
+
ownership_conflict_count: result.ownership_conflicts.length,
|
|
5642
|
+
semantic_conflict_count: result.semantic_conflicts.length,
|
|
5643
|
+
branch_conflict_count: result.branch_conflicts.length,
|
|
5644
|
+
});
|
|
5645
|
+
|
|
3148
5646
|
if (!ok) process.exitCode = 1;
|
|
3149
5647
|
});
|
|
3150
5648
|
|
|
@@ -3500,7 +5998,7 @@ monitorCmd
|
|
|
3500
5998
|
|
|
3501
5999
|
// ── policy ───────────────────────────────────────────────────────────────────
|
|
3502
6000
|
|
|
3503
|
-
const policyCmd = program.command('policy').description('Manage enforcement policy
|
|
6001
|
+
const policyCmd = program.command('policy').description('Manage enforcement and change-governance policy');
|
|
3504
6002
|
|
|
3505
6003
|
policyCmd
|
|
3506
6004
|
.command('init')
|
|
@@ -3517,4 +6015,133 @@ policyCmd
|
|
|
3517
6015
|
console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
|
|
3518
6016
|
});
|
|
3519
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
|
+
|
|
3520
6147
|
program.parse();
|