switchman-dev 0.1.3 → 0.1.5
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/README.md +93 -495
- package/examples/README.md +18 -0
- package/examples/demo.sh +124 -0
- package/examples/setup.sh +13 -6
- package/examples/taskapi/.cursor/mcp.json +8 -0
- package/examples/taskapi/.mcp.json +8 -0
- package/examples/taskapi/src/middleware/auth.js +4 -0
- package/examples/taskapi/src/middleware/validate.js +4 -0
- package/examples/taskapi/src/routes/tasks.js +4 -0
- package/examples/taskapi/src/server.js +4 -0
- package/examples/walkthrough.sh +16 -9
- package/package.json +3 -3
- package/src/cli/index.js +1331 -274
- package/src/core/db.js +252 -2
- package/src/core/git.js +74 -1
- package/src/core/ignore.js +2 -0
- package/src/core/mcp.js +39 -10
- package/src/core/outcome.js +48 -11
- package/src/core/policy.js +49 -0
- package/src/core/queue.js +225 -0
package/src/cli/index.js
CHANGED
|
@@ -7,18 +7,19 @@
|
|
|
7
7
|
* switchman init - Initialize in current repo
|
|
8
8
|
* switchman task add - Add a task to the queue
|
|
9
9
|
* switchman task list - List all tasks
|
|
10
|
-
* switchman task assign - Assign task to a
|
|
10
|
+
* switchman task assign - Assign task to a workspace
|
|
11
11
|
* switchman task done - Mark task complete
|
|
12
|
-
* switchman worktree add - Register a
|
|
13
|
-
* switchman worktree list - List registered
|
|
14
|
-
* switchman scan - Scan for conflicts across
|
|
12
|
+
* switchman worktree add - Register a workspace
|
|
13
|
+
* switchman worktree list - List registered workspaces
|
|
14
|
+
* switchman scan - Scan for conflicts across workspaces
|
|
15
15
|
* switchman claim - Claim files for a task
|
|
16
|
-
* switchman status - Show
|
|
16
|
+
* switchman status - Show the repo dashboard
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { program } from 'commander';
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
import ora from 'ora';
|
|
22
|
+
import { existsSync } from 'fs';
|
|
22
23
|
import { join } from 'path';
|
|
23
24
|
import { execSync, spawn } from 'child_process';
|
|
24
25
|
|
|
@@ -29,20 +30,23 @@ import {
|
|
|
29
30
|
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
30
31
|
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
31
32
|
registerWorktree, listWorktrees,
|
|
33
|
+
enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
|
|
32
34
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
|
|
33
35
|
verifyAuditTrail,
|
|
34
36
|
} from '../core/db.js';
|
|
35
37
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
36
|
-
import {
|
|
38
|
+
import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
37
39
|
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
38
40
|
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
39
41
|
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
40
42
|
import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
|
|
41
43
|
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
|
|
42
44
|
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';
|
|
43
47
|
|
|
44
48
|
function installMcpConfig(targetDirs) {
|
|
45
|
-
return targetDirs.
|
|
49
|
+
return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -80,6 +84,14 @@ function statusBadge(status) {
|
|
|
80
84
|
observed: chalk.yellow,
|
|
81
85
|
non_compliant: chalk.red,
|
|
82
86
|
stale: chalk.red,
|
|
87
|
+
queued: chalk.yellow,
|
|
88
|
+
validating: chalk.blue,
|
|
89
|
+
rebasing: chalk.blue,
|
|
90
|
+
retrying: chalk.yellow,
|
|
91
|
+
blocked: chalk.red,
|
|
92
|
+
merging: chalk.blue,
|
|
93
|
+
merged: chalk.green,
|
|
94
|
+
canceled: chalk.gray,
|
|
83
95
|
};
|
|
84
96
|
return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
|
|
85
97
|
}
|
|
@@ -117,10 +129,239 @@ function printTable(rows, columns) {
|
|
|
117
129
|
}
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
function padRight(value, width) {
|
|
133
|
+
return String(value).padEnd(width);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stripAnsi(text) {
|
|
137
|
+
return String(text).replace(/\x1B\[[0-9;]*m/g, '');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function colorForHealth(health) {
|
|
141
|
+
if (health === 'healthy') return chalk.green;
|
|
142
|
+
if (health === 'warn') return chalk.yellow;
|
|
143
|
+
return chalk.red;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function healthLabel(health) {
|
|
147
|
+
if (health === 'healthy') return 'HEALTHY';
|
|
148
|
+
if (health === 'warn') return 'ATTENTION';
|
|
149
|
+
return 'BLOCKED';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderPanel(title, lines, color = chalk.cyan) {
|
|
153
|
+
const content = lines.length > 0 ? lines : [chalk.dim('No items.')];
|
|
154
|
+
const width = Math.max(
|
|
155
|
+
stripAnsi(title).length + 2,
|
|
156
|
+
...content.map((line) => stripAnsi(line).length),
|
|
157
|
+
);
|
|
158
|
+
const top = color(`+${'-'.repeat(width + 2)}+`);
|
|
159
|
+
const titleLine = color(`| ${padRight(title, width)} |`);
|
|
160
|
+
const body = content.map((line) => `| ${padRight(line, width)} |`);
|
|
161
|
+
const bottom = color(`+${'-'.repeat(width + 2)}+`);
|
|
162
|
+
return [top, titleLine, top, ...body, bottom];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderMetricRow(metrics) {
|
|
166
|
+
return metrics.map(({ label, value, color = chalk.white }) => `${chalk.dim(label)} ${color(String(value))}`).join(chalk.dim(' | '));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderMiniBar(items) {
|
|
170
|
+
if (!items.length) return chalk.dim('none');
|
|
171
|
+
return items.map(({ label, value, color = chalk.white }) => `${color('■')} ${label}:${value}`).join(chalk.dim(' '));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderChip(label, value, color = chalk.white) {
|
|
175
|
+
return color(`[${label}:${value}]`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderSignalStrip(signals) {
|
|
179
|
+
return signals.join(chalk.dim(' '));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatClockTime(isoString) {
|
|
183
|
+
if (!isoString) return null;
|
|
184
|
+
const date = new Date(isoString);
|
|
185
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
186
|
+
return date.toLocaleTimeString('en-GB', {
|
|
187
|
+
hour: '2-digit',
|
|
188
|
+
minute: '2-digit',
|
|
189
|
+
second: '2-digit',
|
|
190
|
+
hour12: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildWatchSignature(report) {
|
|
195
|
+
return JSON.stringify({
|
|
196
|
+
health: report.health,
|
|
197
|
+
summary: report.summary,
|
|
198
|
+
counts: report.counts,
|
|
199
|
+
active_work: report.active_work,
|
|
200
|
+
attention: report.attention,
|
|
201
|
+
queue_summary: report.queue?.summary || null,
|
|
202
|
+
next_up: report.next_up || null,
|
|
203
|
+
next_steps: report.next_steps,
|
|
204
|
+
suggested_commands: report.suggested_commands,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatRelativePolicy(policy) {
|
|
209
|
+
return `stale ${policy.stale_after_minutes}m • heartbeat ${policy.heartbeat_interval_seconds}s • auto-reap ${policy.reap_on_status_check ? 'on' : 'off'}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
120
212
|
function sleepSync(ms) {
|
|
121
213
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
122
214
|
}
|
|
123
215
|
|
|
216
|
+
function boolBadge(ok) {
|
|
217
|
+
return ok ? chalk.green('OK ') : chalk.yellow('CHECK');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printErrorWithNext(message, nextCommand = null) {
|
|
221
|
+
console.error(chalk.red(message));
|
|
222
|
+
if (nextCommand) {
|
|
223
|
+
console.error(`${chalk.yellow('next:')} ${nextCommand}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
|
|
228
|
+
const dbPath = join(repoRoot, '.switchman', 'switchman.db');
|
|
229
|
+
const rootMcpPath = join(repoRoot, '.mcp.json');
|
|
230
|
+
const cursorMcpPath = join(repoRoot, '.cursor', 'mcp.json');
|
|
231
|
+
const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
|
|
232
|
+
const checks = [];
|
|
233
|
+
const nextSteps = [];
|
|
234
|
+
let workspaces = [];
|
|
235
|
+
let db = null;
|
|
236
|
+
|
|
237
|
+
const dbExists = existsSync(dbPath);
|
|
238
|
+
checks.push({
|
|
239
|
+
key: 'database',
|
|
240
|
+
ok: dbExists,
|
|
241
|
+
label: 'Project database',
|
|
242
|
+
detail: dbExists ? '.switchman/switchman.db is ready' : 'Switchman database is missing',
|
|
243
|
+
});
|
|
244
|
+
if (!dbExists) {
|
|
245
|
+
nextSteps.push('Run `switchman init` or `switchman setup --agents 3` in this repo.');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (dbExists) {
|
|
249
|
+
try {
|
|
250
|
+
db = getDb(repoRoot);
|
|
251
|
+
workspaces = listWorktrees(db);
|
|
252
|
+
} catch {
|
|
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 */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const agentWorkspaces = workspaces.filter((entry) => entry.name !== 'main');
|
|
266
|
+
const workspaceReady = agentWorkspaces.length > 0;
|
|
267
|
+
checks.push({
|
|
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
|
+
}
|
|
278
|
+
|
|
279
|
+
const rootMcpExists = existsSync(rootMcpPath);
|
|
280
|
+
checks.push({
|
|
281
|
+
key: 'claude_mcp',
|
|
282
|
+
ok: rootMcpExists,
|
|
283
|
+
label: 'Claude Code MCP',
|
|
284
|
+
detail: rootMcpExists ? '.mcp.json is present in the repo root' : '.mcp.json is missing from the repo root',
|
|
285
|
+
});
|
|
286
|
+
if (!rootMcpExists) {
|
|
287
|
+
nextSteps.push('Re-run `switchman setup --agents 3` to restore the repo-local MCP config.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const cursorMcpExists = existsSync(cursorMcpPath);
|
|
291
|
+
checks.push({
|
|
292
|
+
key: 'cursor_mcp',
|
|
293
|
+
ok: cursorMcpExists,
|
|
294
|
+
label: 'Cursor MCP',
|
|
295
|
+
detail: cursorMcpExists ? '.cursor/mcp.json is present in the repo root' : '.cursor/mcp.json is missing from the repo root',
|
|
296
|
+
});
|
|
297
|
+
if (!cursorMcpExists) {
|
|
298
|
+
nextSteps.push('Re-run `switchman setup --agents 3` if you want Cursor to attach automatically.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const claudeGuideExists = existsSync(claudeGuidePath);
|
|
302
|
+
checks.push({
|
|
303
|
+
key: 'claude_md',
|
|
304
|
+
ok: claudeGuideExists,
|
|
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.');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
|
|
313
|
+
checks.push({
|
|
314
|
+
key: 'windsurf_mcp',
|
|
315
|
+
ok: windsurfConfigExists,
|
|
316
|
+
label: 'Windsurf MCP',
|
|
317
|
+
detail: windsurfConfigExists
|
|
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.');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const ok = checks.every((item) => item.ok || ['claude_md', 'windsurf_mcp'].includes(item.key));
|
|
326
|
+
return {
|
|
327
|
+
ok,
|
|
328
|
+
repo_root: repoRoot,
|
|
329
|
+
checks,
|
|
330
|
+
workspaces: workspaces.map((entry) => ({
|
|
331
|
+
name: entry.name,
|
|
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),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function renderSetupVerification(report, { compact = false } = {}) {
|
|
346
|
+
console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
|
|
347
|
+
for (const check of report.checks) {
|
|
348
|
+
const badge = boolBadge(check.ok);
|
|
349
|
+
console.log(` ${badge} ${check.label} ${chalk.dim(`— ${check.detail}`)}`);
|
|
350
|
+
}
|
|
351
|
+
if (report.next_steps.length > 0) {
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(chalk.bold('Fix next:'));
|
|
354
|
+
for (const step of report.next_steps) {
|
|
355
|
+
console.log(` - ${step}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
console.log('');
|
|
359
|
+
console.log(chalk.bold('Try next:'));
|
|
360
|
+
for (const command of report.suggested_commands.slice(0, 4)) {
|
|
361
|
+
console.log(` ${chalk.cyan(command)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
124
365
|
function summarizeLeaseScope(db, lease) {
|
|
125
366
|
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
126
367
|
const pathScopes = reservations
|
|
@@ -454,6 +695,303 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
454
695
|
};
|
|
455
696
|
}
|
|
456
697
|
|
|
698
|
+
function buildUnifiedStatusReport({
|
|
699
|
+
repoRoot,
|
|
700
|
+
leasePolicy,
|
|
701
|
+
tasks,
|
|
702
|
+
claims,
|
|
703
|
+
doctorReport,
|
|
704
|
+
queueItems,
|
|
705
|
+
queueSummary,
|
|
706
|
+
recentQueueEvents,
|
|
707
|
+
}) {
|
|
708
|
+
const queueAttention = [
|
|
709
|
+
...queueItems
|
|
710
|
+
.filter((item) => item.status === 'blocked')
|
|
711
|
+
.map((item) => ({
|
|
712
|
+
kind: 'queue_blocked',
|
|
713
|
+
title: `${item.id} is blocked from landing`,
|
|
714
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
715
|
+
next_step: item.next_action || `Run \`switchman queue retry ${item.id}\` after fixing the branch state.`,
|
|
716
|
+
command: item.next_action?.includes('queue retry') ? `switchman queue retry ${item.id}` : 'switchman queue status',
|
|
717
|
+
severity: 'block',
|
|
718
|
+
})),
|
|
719
|
+
...queueItems
|
|
720
|
+
.filter((item) => item.status === 'retrying')
|
|
721
|
+
.map((item) => ({
|
|
722
|
+
kind: 'queue_retrying',
|
|
723
|
+
title: `${item.id} is waiting for another landing attempt`,
|
|
724
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
725
|
+
next_step: item.next_action || 'Run `switchman queue run` again to continue landing queued work.',
|
|
726
|
+
command: 'switchman queue run',
|
|
727
|
+
severity: 'warn',
|
|
728
|
+
})),
|
|
729
|
+
];
|
|
730
|
+
|
|
731
|
+
const attention = [...doctorReport.attention, ...queueAttention];
|
|
732
|
+
const nextUp = tasks
|
|
733
|
+
.filter((task) => task.status === 'pending')
|
|
734
|
+
.sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0))
|
|
735
|
+
.slice(0, 3)
|
|
736
|
+
.map((task) => ({
|
|
737
|
+
id: task.id,
|
|
738
|
+
title: task.title,
|
|
739
|
+
priority: task.priority,
|
|
740
|
+
}));
|
|
741
|
+
const failedTasks = tasks
|
|
742
|
+
.filter((task) => task.status === 'failed')
|
|
743
|
+
.slice(0, 5)
|
|
744
|
+
.map((task) => ({
|
|
745
|
+
id: task.id,
|
|
746
|
+
title: task.title,
|
|
747
|
+
failure: latestTaskFailure(task),
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
const suggestedCommands = [
|
|
751
|
+
...doctorReport.suggested_commands,
|
|
752
|
+
...(queueItems.length > 0 ? ['switchman queue status'] : []),
|
|
753
|
+
...(queueSummary.next ? ['switchman queue run'] : []),
|
|
754
|
+
].filter(Boolean);
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
generated_at: new Date().toISOString(),
|
|
758
|
+
repo_root: repoRoot,
|
|
759
|
+
health: attention.some((item) => item.severity === 'block')
|
|
760
|
+
? 'block'
|
|
761
|
+
: attention.some((item) => item.severity === 'warn')
|
|
762
|
+
? 'warn'
|
|
763
|
+
: doctorReport.health,
|
|
764
|
+
summary: attention.some((item) => item.severity === 'block')
|
|
765
|
+
? 'Repo needs attention before more work or merge.'
|
|
766
|
+
: attention.some((item) => item.severity === 'warn')
|
|
767
|
+
? 'Repo is running, but a few items need review.'
|
|
768
|
+
: 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
|
|
769
|
+
lease_policy: leasePolicy,
|
|
770
|
+
counts: {
|
|
771
|
+
...doctorReport.counts,
|
|
772
|
+
queue: queueSummary.counts,
|
|
773
|
+
active_claims: claims.length,
|
|
774
|
+
},
|
|
775
|
+
active_work: doctorReport.active_work,
|
|
776
|
+
attention,
|
|
777
|
+
next_up: nextUp,
|
|
778
|
+
failed_tasks: failedTasks,
|
|
779
|
+
queue: {
|
|
780
|
+
items: queueItems,
|
|
781
|
+
summary: queueSummary,
|
|
782
|
+
recent_events: recentQueueEvents,
|
|
783
|
+
},
|
|
784
|
+
merge_readiness: doctorReport.merge_readiness,
|
|
785
|
+
claims: claims.map((claim) => ({
|
|
786
|
+
worktree: claim.worktree,
|
|
787
|
+
task_id: claim.task_id,
|
|
788
|
+
file_path: claim.file_path,
|
|
789
|
+
})),
|
|
790
|
+
next_steps: [...new Set([
|
|
791
|
+
...doctorReport.next_steps,
|
|
792
|
+
...queueAttention.map((item) => item.next_step),
|
|
793
|
+
])].slice(0, 6),
|
|
794
|
+
suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function collectStatusSnapshot(repoRoot) {
|
|
799
|
+
const db = getDb(repoRoot);
|
|
800
|
+
try {
|
|
801
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
802
|
+
|
|
803
|
+
if (leasePolicy.reap_on_status_check) {
|
|
804
|
+
reapStaleLeases(db, leasePolicy.stale_after_minutes, {
|
|
805
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const tasks = listTasks(db);
|
|
810
|
+
const activeLeases = listLeases(db, 'active');
|
|
811
|
+
const staleLeases = getStaleLeases(db, leasePolicy.stale_after_minutes);
|
|
812
|
+
const claims = getActiveFileClaims(db);
|
|
813
|
+
const queueItems = listMergeQueue(db);
|
|
814
|
+
const queueSummary = buildQueueStatusSummary(queueItems);
|
|
815
|
+
const recentQueueEvents = queueItems
|
|
816
|
+
.slice(0, 5)
|
|
817
|
+
.flatMap((item) => listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })))
|
|
818
|
+
.sort((a, b) => b.id - a.id)
|
|
819
|
+
.slice(0, 8);
|
|
820
|
+
const scanReport = await scanAllWorktrees(db, repoRoot);
|
|
821
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
822
|
+
const doctorReport = buildDoctorReport({
|
|
823
|
+
db,
|
|
824
|
+
repoRoot,
|
|
825
|
+
tasks,
|
|
826
|
+
activeLeases,
|
|
827
|
+
staleLeases,
|
|
828
|
+
scanReport,
|
|
829
|
+
aiGate,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
return buildUnifiedStatusReport({
|
|
833
|
+
repoRoot,
|
|
834
|
+
leasePolicy,
|
|
835
|
+
tasks,
|
|
836
|
+
claims,
|
|
837
|
+
doctorReport,
|
|
838
|
+
queueItems,
|
|
839
|
+
queueSummary,
|
|
840
|
+
recentQueueEvents,
|
|
841
|
+
});
|
|
842
|
+
} finally {
|
|
843
|
+
db.close();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function renderUnifiedStatusReport(report) {
|
|
848
|
+
const healthColor = colorForHealth(report.health);
|
|
849
|
+
const badge = healthColor(healthLabel(report.health));
|
|
850
|
+
const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
|
|
851
|
+
const queueCounts = report.counts.queue;
|
|
852
|
+
const blockedCount = report.attention.filter((item) => item.severity === 'block').length;
|
|
853
|
+
const warningCount = report.attention.filter((item) => item.severity !== 'block').length;
|
|
854
|
+
const focusItem = blockedCount > 0
|
|
855
|
+
? report.attention.find((item) => item.severity === 'block')
|
|
856
|
+
: warningCount > 0
|
|
857
|
+
? report.attention.find((item) => item.severity !== 'block')
|
|
858
|
+
: report.next_up[0];
|
|
859
|
+
const focusLine = focusItem
|
|
860
|
+
? ('title' in focusItem
|
|
861
|
+
? `${focusItem.title}${focusItem.detail ? ` ${chalk.dim(`• ${focusItem.detail}`)}` : ''}`
|
|
862
|
+
: `${focusItem.title} ${chalk.dim(focusItem.id)}`)
|
|
863
|
+
: 'Nothing urgent. Safe to keep parallel work moving.';
|
|
864
|
+
const queueLoad = queueCounts.queued + queueCounts.retrying + queueCounts.merging + queueCounts.blocked;
|
|
865
|
+
const landingLabel = report.merge_readiness.ci_gate_ok ? 'ready' : 'hold';
|
|
866
|
+
|
|
867
|
+
console.log('');
|
|
868
|
+
console.log(healthColor('='.repeat(72)));
|
|
869
|
+
console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• mission control for parallel agents')}`);
|
|
870
|
+
console.log(`${chalk.dim(report.repo_root)}`);
|
|
871
|
+
console.log(`${chalk.dim(report.summary)}`);
|
|
872
|
+
console.log(healthColor('='.repeat(72)));
|
|
873
|
+
console.log(renderSignalStrip([
|
|
874
|
+
renderChip('health', healthLabel(report.health), healthColor),
|
|
875
|
+
renderChip('blocked', blockedCount, blockedCount > 0 ? chalk.red : chalk.green),
|
|
876
|
+
renderChip('watch', warningCount, warningCount > 0 ? chalk.yellow : chalk.green),
|
|
877
|
+
renderChip('landing', landingLabel, mergeColor),
|
|
878
|
+
renderChip('queue', queueLoad, queueLoad > 0 ? chalk.blue : chalk.green),
|
|
879
|
+
]));
|
|
880
|
+
console.log(renderMetricRow([
|
|
881
|
+
{ label: 'tasks', value: `${report.counts.pending}/${report.counts.in_progress}/${report.counts.done}/${report.counts.failed}`, color: chalk.white },
|
|
882
|
+
{ label: 'leases', value: `${report.counts.active_leases} active`, color: chalk.blue },
|
|
883
|
+
{ label: 'claims', value: report.counts.active_claims, color: chalk.cyan },
|
|
884
|
+
{ label: 'merge', value: report.merge_readiness.ci_gate_ok ? 'clear' : 'blocked', color: mergeColor },
|
|
885
|
+
]));
|
|
886
|
+
console.log(renderMiniBar([
|
|
887
|
+
{ label: 'queued', value: queueCounts.queued, color: chalk.yellow },
|
|
888
|
+
{ label: 'retrying', value: queueCounts.retrying, color: chalk.yellow },
|
|
889
|
+
{ label: 'blocked', value: queueCounts.blocked, color: chalk.red },
|
|
890
|
+
{ label: 'merging', value: queueCounts.merging, color: chalk.blue },
|
|
891
|
+
{ label: 'merged', value: queueCounts.merged, color: chalk.green },
|
|
892
|
+
]));
|
|
893
|
+
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
894
|
+
console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
|
|
895
|
+
|
|
896
|
+
const runningLines = report.active_work.length > 0
|
|
897
|
+
? report.active_work.slice(0, 5).map((item) => {
|
|
898
|
+
const boundary = item.boundary_validation
|
|
899
|
+
? ` ${renderChip('validation', item.boundary_validation.status, item.boundary_validation.status === 'accepted' ? chalk.green : chalk.yellow)}`
|
|
900
|
+
: '';
|
|
901
|
+
const stale = (item.dependency_invalidations?.length || 0) > 0
|
|
902
|
+
? ` ${renderChip('stale', item.dependency_invalidations.length, chalk.yellow)}`
|
|
903
|
+
: '';
|
|
904
|
+
return `${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${boundary}${stale}`;
|
|
905
|
+
})
|
|
906
|
+
: [chalk.dim('Nothing active right now.')];
|
|
907
|
+
|
|
908
|
+
const blockedItems = report.attention.filter((item) => item.severity === 'block');
|
|
909
|
+
const warningItems = report.attention.filter((item) => item.severity !== 'block');
|
|
910
|
+
|
|
911
|
+
const blockedLines = blockedItems.length > 0
|
|
912
|
+
? blockedItems.slice(0, 4).flatMap((item) => {
|
|
913
|
+
const lines = [`${renderChip('BLOCKED', item.kind || 'item', chalk.red)} ${item.title}`];
|
|
914
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
915
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
916
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
917
|
+
return lines;
|
|
918
|
+
})
|
|
919
|
+
: [chalk.green('Nothing blocked.')];
|
|
920
|
+
|
|
921
|
+
const warningLines = warningItems.length > 0
|
|
922
|
+
? warningItems.slice(0, 4).flatMap((item) => {
|
|
923
|
+
const lines = [`${renderChip('WATCH', item.kind || 'item', chalk.yellow)} ${item.title}`];
|
|
924
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
925
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
926
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
927
|
+
return lines;
|
|
928
|
+
})
|
|
929
|
+
: [chalk.green('Nothing warning-worthy right now.')];
|
|
930
|
+
|
|
931
|
+
const queueLines = report.queue.items.length > 0
|
|
932
|
+
? [
|
|
933
|
+
...(report.queue.summary.next
|
|
934
|
+
? [`${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}`)}`]
|
|
935
|
+
: []),
|
|
936
|
+
...report.queue.items
|
|
937
|
+
.filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
|
|
938
|
+
.slice(0, 4)
|
|
939
|
+
.flatMap((item) => {
|
|
940
|
+
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'blocked' ? chalk.red : item.status === 'retrying' ? chalk.yellow : chalk.blue)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
941
|
+
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
942
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
943
|
+
return lines;
|
|
944
|
+
}),
|
|
945
|
+
]
|
|
946
|
+
: [chalk.dim('No queued merges.')];
|
|
947
|
+
|
|
948
|
+
const nextActionLines = [
|
|
949
|
+
...(report.next_up.length > 0
|
|
950
|
+
? report.next_up.map((task) => `${renderChip('NEXT', `p${task.priority}`, chalk.green)} ${task.title} ${chalk.dim(task.id)}`)
|
|
951
|
+
: [chalk.dim('No pending tasks waiting right now.')]),
|
|
952
|
+
'',
|
|
953
|
+
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
954
|
+
];
|
|
955
|
+
|
|
956
|
+
const panelBlocks = [
|
|
957
|
+
renderPanel('Running now', runningLines, chalk.cyan),
|
|
958
|
+
renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
|
|
959
|
+
renderPanel('Warnings', warningLines, warningItems.length > 0 ? chalk.yellow : chalk.green),
|
|
960
|
+
renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
|
|
961
|
+
renderPanel('Next action', nextActionLines, chalk.green),
|
|
962
|
+
];
|
|
963
|
+
|
|
964
|
+
console.log('');
|
|
965
|
+
for (const block of panelBlocks) {
|
|
966
|
+
for (const line of block) console.log(line);
|
|
967
|
+
console.log('');
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (report.failed_tasks.length > 0) {
|
|
971
|
+
console.log(chalk.bold('Recent failed tasks:'));
|
|
972
|
+
for (const task of report.failed_tasks) {
|
|
973
|
+
const reason = humanizeReasonCode(task.failure?.reason_code);
|
|
974
|
+
const summary = task.failure?.summary || 'unknown failure';
|
|
975
|
+
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
976
|
+
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${reason})`)}`);
|
|
977
|
+
}
|
|
978
|
+
console.log('');
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (report.queue.recent_events.length > 0) {
|
|
982
|
+
console.log(chalk.bold('Recent queue events:'));
|
|
983
|
+
for (const event of report.queue.recent_events.slice(0, 5)) {
|
|
984
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
985
|
+
}
|
|
986
|
+
console.log('');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
console.log(chalk.bold('Recommended next steps:'));
|
|
990
|
+
for (const step of report.next_steps) {
|
|
991
|
+
console.log(` - ${step}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
457
995
|
function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
|
|
458
996
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
459
997
|
try {
|
|
@@ -530,6 +1068,23 @@ program
|
|
|
530
1068
|
.description('Conflict-aware task coordinator for parallel AI coding agents')
|
|
531
1069
|
.version('0.1.0');
|
|
532
1070
|
|
|
1071
|
+
program.showHelpAfterError('(run with --help for usage examples)');
|
|
1072
|
+
program.addHelpText('after', `
|
|
1073
|
+
Start here:
|
|
1074
|
+
switchman setup --agents 5
|
|
1075
|
+
switchman status --watch
|
|
1076
|
+
switchman gate ci
|
|
1077
|
+
|
|
1078
|
+
Most useful commands:
|
|
1079
|
+
switchman task add "Implement auth helper" --priority 9
|
|
1080
|
+
switchman lease next --json
|
|
1081
|
+
switchman queue run --watch
|
|
1082
|
+
|
|
1083
|
+
Docs:
|
|
1084
|
+
README.md
|
|
1085
|
+
docs/setup-cursor.md
|
|
1086
|
+
`);
|
|
1087
|
+
|
|
533
1088
|
// ── init ──────────────────────────────────────────────────────────────────────
|
|
534
1089
|
|
|
535
1090
|
program
|
|
@@ -570,9 +1125,14 @@ program
|
|
|
570
1125
|
|
|
571
1126
|
program
|
|
572
1127
|
.command('setup')
|
|
573
|
-
.description('One-command setup: create agent
|
|
574
|
-
.option('-a, --agents <n>', 'Number of agent
|
|
1128
|
+
.description('One-command setup: create agent workspaces and initialise Switchman')
|
|
1129
|
+
.option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
|
|
575
1130
|
.option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
|
|
1131
|
+
.addHelpText('after', `
|
|
1132
|
+
Examples:
|
|
1133
|
+
switchman setup --agents 5
|
|
1134
|
+
switchman setup --agents 3 --prefix team
|
|
1135
|
+
`)
|
|
576
1136
|
.action((opts) => {
|
|
577
1137
|
const agentCount = parseInt(opts.agents);
|
|
578
1138
|
|
|
@@ -592,7 +1152,7 @@ program
|
|
|
592
1152
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
593
1153
|
});
|
|
594
1154
|
} catch {
|
|
595
|
-
spinner.fail('Your repo needs at least one commit before
|
|
1155
|
+
spinner.fail('Your repo needs at least one commit before agent workspaces can be created.');
|
|
596
1156
|
console.log(chalk.dim(' Run: git commit --allow-empty -m "init" then try again'));
|
|
597
1157
|
process.exit(1);
|
|
598
1158
|
}
|
|
@@ -600,7 +1160,7 @@ program
|
|
|
600
1160
|
// Init the switchman database
|
|
601
1161
|
const db = initDb(repoRoot);
|
|
602
1162
|
|
|
603
|
-
// Create one worktree per agent
|
|
1163
|
+
// Create one workspace (git worktree) per agent
|
|
604
1164
|
const created = [];
|
|
605
1165
|
for (let i = 1; i <= agentCount; i++) {
|
|
606
1166
|
const name = `agent${i}`;
|
|
@@ -650,21 +1210,97 @@ program
|
|
|
650
1210
|
console.log(chalk.bold('Next steps:'));
|
|
651
1211
|
console.log(` 1. Add your tasks:`);
|
|
652
1212
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
653
|
-
console.log(` 2. Open Claude Code in each folder above — the local
|
|
1213
|
+
console.log(` 2. Open Claude Code or Cursor in each folder above — the local MCP config will attach Switchman automatically`);
|
|
654
1214
|
console.log(` 3. Check status at any time:`);
|
|
655
1215
|
console.log(` ${chalk.cyan('switchman status')}`);
|
|
656
1216
|
console.log('');
|
|
657
1217
|
|
|
1218
|
+
const verification = collectSetupVerification(repoRoot);
|
|
1219
|
+
renderSetupVerification(verification, { compact: true });
|
|
1220
|
+
|
|
658
1221
|
} catch (err) {
|
|
659
1222
|
spinner.fail(err.message);
|
|
660
1223
|
process.exit(1);
|
|
661
1224
|
}
|
|
662
1225
|
});
|
|
663
1226
|
|
|
1227
|
+
program
|
|
1228
|
+
.command('verify-setup')
|
|
1229
|
+
.description('Check whether this repo is ready for a smooth first Switchman run')
|
|
1230
|
+
.option('--json', 'Output raw JSON')
|
|
1231
|
+
.option('--home <path>', 'Override the home directory for editor config checks')
|
|
1232
|
+
.addHelpText('after', `
|
|
1233
|
+
Examples:
|
|
1234
|
+
switchman verify-setup
|
|
1235
|
+
switchman verify-setup --json
|
|
1236
|
+
|
|
1237
|
+
Use this after setup or whenever editor/config wiring feels off.
|
|
1238
|
+
`)
|
|
1239
|
+
.action((opts) => {
|
|
1240
|
+
const repoRoot = getRepo();
|
|
1241
|
+
const report = collectSetupVerification(repoRoot, { homeDir: opts.home || null });
|
|
1242
|
+
|
|
1243
|
+
if (opts.json) {
|
|
1244
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1245
|
+
if (!report.ok) process.exitCode = 1;
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
renderSetupVerification(report);
|
|
1250
|
+
if (!report.ok) process.exitCode = 1;
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
1255
|
+
|
|
1256
|
+
const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
|
|
1257
|
+
|
|
1258
|
+
mcpCmd
|
|
1259
|
+
.command('install')
|
|
1260
|
+
.description('Install editor-specific MCP config for Switchman')
|
|
1261
|
+
.option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
|
|
1262
|
+
.option('--home <path>', 'Override the home directory for config writes (useful for testing)')
|
|
1263
|
+
.option('--json', 'Output raw JSON')
|
|
1264
|
+
.addHelpText('after', `
|
|
1265
|
+
Examples:
|
|
1266
|
+
switchman mcp install --windsurf
|
|
1267
|
+
switchman mcp install --windsurf --json
|
|
1268
|
+
`)
|
|
1269
|
+
.action((opts) => {
|
|
1270
|
+
if (!opts.windsurf) {
|
|
1271
|
+
console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
|
|
1272
|
+
process.exitCode = 1;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const result = upsertWindsurfMcpConfig(opts.home);
|
|
1277
|
+
|
|
1278
|
+
if (opts.json) {
|
|
1279
|
+
console.log(JSON.stringify({
|
|
1280
|
+
editor: 'windsurf',
|
|
1281
|
+
path: result.path,
|
|
1282
|
+
created: result.created,
|
|
1283
|
+
changed: result.changed,
|
|
1284
|
+
}, null, 2));
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
|
|
1289
|
+
console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
|
|
1290
|
+
console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
|
|
1291
|
+
console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
664
1294
|
|
|
665
1295
|
// ── task ──────────────────────────────────────────────────────────────────────
|
|
666
1296
|
|
|
667
|
-
const taskCmd = program.command('task').description('Manage the task
|
|
1297
|
+
const taskCmd = program.command('task').description('Manage the task list');
|
|
1298
|
+
taskCmd.addHelpText('after', `
|
|
1299
|
+
Examples:
|
|
1300
|
+
switchman task add "Fix login bug" --priority 8
|
|
1301
|
+
switchman task list --status pending
|
|
1302
|
+
switchman task done task-123
|
|
1303
|
+
`);
|
|
668
1304
|
|
|
669
1305
|
taskCmd
|
|
670
1306
|
.command('add <title>')
|
|
@@ -720,7 +1356,7 @@ taskCmd
|
|
|
720
1356
|
|
|
721
1357
|
taskCmd
|
|
722
1358
|
.command('assign <taskId> <worktree>')
|
|
723
|
-
.description('Assign a task to a
|
|
1359
|
+
.description('Assign a task to a workspace (compatibility shim for lease acquire)')
|
|
724
1360
|
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
725
1361
|
.action((taskId, worktree, opts) => {
|
|
726
1362
|
const repoRoot = getRepo();
|
|
@@ -762,10 +1398,15 @@ taskCmd
|
|
|
762
1398
|
|
|
763
1399
|
taskCmd
|
|
764
1400
|
.command('next')
|
|
765
|
-
.description('Get
|
|
1401
|
+
.description('Get the next pending task quickly (use `lease next` for the full workflow)')
|
|
766
1402
|
.option('--json', 'Output as JSON')
|
|
767
|
-
.option('--worktree <name>', '
|
|
1403
|
+
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
768
1404
|
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
1405
|
+
.addHelpText('after', `
|
|
1406
|
+
Examples:
|
|
1407
|
+
switchman task next
|
|
1408
|
+
switchman task next --json
|
|
1409
|
+
`)
|
|
769
1410
|
.action((opts) => {
|
|
770
1411
|
const repoRoot = getRepo();
|
|
771
1412
|
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
@@ -792,9 +1433,358 @@ taskCmd
|
|
|
792
1433
|
}
|
|
793
1434
|
});
|
|
794
1435
|
|
|
1436
|
+
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
1437
|
+
|
|
1438
|
+
const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
|
|
1439
|
+
queueCmd.addHelpText('after', `
|
|
1440
|
+
Examples:
|
|
1441
|
+
switchman queue add --worktree agent1
|
|
1442
|
+
switchman queue status
|
|
1443
|
+
switchman queue run --watch
|
|
1444
|
+
`);
|
|
1445
|
+
|
|
1446
|
+
queueCmd
|
|
1447
|
+
.command('add [branch]')
|
|
1448
|
+
.description('Add a branch, workspace, or pipeline to the landing queue')
|
|
1449
|
+
.option('--worktree <name>', 'Queue a registered workspace by name')
|
|
1450
|
+
.option('--pipeline <pipelineId>', 'Queue a pipeline by id')
|
|
1451
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
1452
|
+
.option('--max-retries <n>', 'Maximum automatic retries', '1')
|
|
1453
|
+
.option('--submitted-by <name>', 'Operator or automation name')
|
|
1454
|
+
.option('--json', 'Output raw JSON')
|
|
1455
|
+
.addHelpText('after', `
|
|
1456
|
+
Examples:
|
|
1457
|
+
switchman queue add feature/auth-hardening
|
|
1458
|
+
switchman queue add --worktree agent2
|
|
1459
|
+
switchman queue add --pipeline pipe-123
|
|
1460
|
+
`)
|
|
1461
|
+
.action((branch, opts) => {
|
|
1462
|
+
const repoRoot = getRepo();
|
|
1463
|
+
const db = getDb(repoRoot);
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
let payload;
|
|
1467
|
+
if (opts.worktree) {
|
|
1468
|
+
const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
|
|
1469
|
+
if (!worktree) {
|
|
1470
|
+
throw new Error(`Workspace ${opts.worktree} is not registered.`);
|
|
1471
|
+
}
|
|
1472
|
+
payload = {
|
|
1473
|
+
sourceType: 'worktree',
|
|
1474
|
+
sourceRef: worktree.branch,
|
|
1475
|
+
sourceWorktree: worktree.name,
|
|
1476
|
+
targetBranch: opts.target,
|
|
1477
|
+
maxRetries: opts.maxRetries,
|
|
1478
|
+
submittedBy: opts.submittedBy || null,
|
|
1479
|
+
};
|
|
1480
|
+
} else if (opts.pipeline) {
|
|
1481
|
+
payload = {
|
|
1482
|
+
sourceType: 'pipeline',
|
|
1483
|
+
sourceRef: opts.pipeline,
|
|
1484
|
+
sourcePipelineId: opts.pipeline,
|
|
1485
|
+
targetBranch: opts.target,
|
|
1486
|
+
maxRetries: opts.maxRetries,
|
|
1487
|
+
submittedBy: opts.submittedBy || null,
|
|
1488
|
+
};
|
|
1489
|
+
} else if (branch) {
|
|
1490
|
+
payload = {
|
|
1491
|
+
sourceType: 'branch',
|
|
1492
|
+
sourceRef: branch,
|
|
1493
|
+
targetBranch: opts.target,
|
|
1494
|
+
maxRetries: opts.maxRetries,
|
|
1495
|
+
submittedBy: opts.submittedBy || null,
|
|
1496
|
+
};
|
|
1497
|
+
} else {
|
|
1498
|
+
throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const result = enqueueMergeItem(db, payload);
|
|
1502
|
+
db.close();
|
|
1503
|
+
|
|
1504
|
+
if (opts.json) {
|
|
1505
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
1510
|
+
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
1511
|
+
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
db.close();
|
|
1514
|
+
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
1515
|
+
process.exitCode = 1;
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
queueCmd
|
|
1520
|
+
.command('list')
|
|
1521
|
+
.description('List merge queue items')
|
|
1522
|
+
.option('--status <status>', 'Filter by queue status')
|
|
1523
|
+
.option('--json', 'Output raw JSON')
|
|
1524
|
+
.action((opts) => {
|
|
1525
|
+
const repoRoot = getRepo();
|
|
1526
|
+
const db = getDb(repoRoot);
|
|
1527
|
+
const items = listMergeQueue(db, { status: opts.status || null });
|
|
1528
|
+
db.close();
|
|
1529
|
+
|
|
1530
|
+
if (opts.json) {
|
|
1531
|
+
console.log(JSON.stringify(items, null, 2));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (items.length === 0) {
|
|
1536
|
+
console.log(chalk.dim('Merge queue is empty.'));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
for (const item of items) {
|
|
1541
|
+
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
1542
|
+
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
1543
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}`);
|
|
1544
|
+
if (item.last_error_summary) {
|
|
1545
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1546
|
+
}
|
|
1547
|
+
if (item.next_action) {
|
|
1548
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
queueCmd
|
|
1554
|
+
.command('status')
|
|
1555
|
+
.description('Show an operator-friendly merge queue summary')
|
|
1556
|
+
.option('--json', 'Output raw JSON')
|
|
1557
|
+
.addHelpText('after', `
|
|
1558
|
+
Plain English:
|
|
1559
|
+
Use this when finished branches are waiting to land and you want one safe queue view.
|
|
1560
|
+
|
|
1561
|
+
Examples:
|
|
1562
|
+
switchman queue status
|
|
1563
|
+
switchman queue status --json
|
|
1564
|
+
|
|
1565
|
+
What it helps you answer:
|
|
1566
|
+
- what lands next
|
|
1567
|
+
- what is blocked
|
|
1568
|
+
- what command should I run now
|
|
1569
|
+
`)
|
|
1570
|
+
.action((opts) => {
|
|
1571
|
+
const repoRoot = getRepo();
|
|
1572
|
+
const db = getDb(repoRoot);
|
|
1573
|
+
const items = listMergeQueue(db);
|
|
1574
|
+
const summary = buildQueueStatusSummary(items);
|
|
1575
|
+
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
1576
|
+
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
1577
|
+
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
1578
|
+
db.close();
|
|
1579
|
+
|
|
1580
|
+
if (opts.json) {
|
|
1581
|
+
console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const queueHealth = summary.counts.blocked > 0 ? 'block' : summary.counts.retrying > 0 ? 'warn' : 'healthy';
|
|
1586
|
+
const queueHealthColor = colorForHealth(queueHealth);
|
|
1587
|
+
const focus = summary.blocked[0] || summary.retrying[0] || summary.next || null;
|
|
1588
|
+
const focusLine = focus
|
|
1589
|
+
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
1590
|
+
: 'Nothing waiting. Landing queue is clear.';
|
|
1591
|
+
|
|
1592
|
+
console.log('');
|
|
1593
|
+
console.log(queueHealthColor('='.repeat(72)));
|
|
1594
|
+
console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
|
|
1595
|
+
console.log(queueHealthColor('='.repeat(72)));
|
|
1596
|
+
console.log(renderSignalStrip([
|
|
1597
|
+
renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
|
|
1598
|
+
renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
|
|
1599
|
+
renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1600
|
+
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
1601
|
+
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
1602
|
+
]));
|
|
1603
|
+
console.log(renderMetricRow([
|
|
1604
|
+
{ label: 'items', value: items.length, color: chalk.white },
|
|
1605
|
+
{ label: 'validating', value: summary.counts.validating, color: chalk.blue },
|
|
1606
|
+
{ label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
|
|
1607
|
+
{ label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
|
|
1608
|
+
]));
|
|
1609
|
+
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
1610
|
+
|
|
1611
|
+
const queueFocusLines = summary.next
|
|
1612
|
+
? [
|
|
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.yellow('run:')} switchman queue run`,
|
|
1615
|
+
]
|
|
1616
|
+
: [chalk.dim('No queued landing work right now.')];
|
|
1617
|
+
|
|
1618
|
+
const queueBlockedLines = summary.blocked.length > 0
|
|
1619
|
+
? summary.blocked.slice(0, 4).flatMap((item) => {
|
|
1620
|
+
const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
1621
|
+
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1622
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1623
|
+
return lines;
|
|
1624
|
+
})
|
|
1625
|
+
: [chalk.green('Nothing blocked.')];
|
|
1626
|
+
|
|
1627
|
+
const queueWatchLines = items.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
|
|
1628
|
+
? items
|
|
1629
|
+
.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
1630
|
+
.slice(0, 4)
|
|
1631
|
+
.flatMap((item) => {
|
|
1632
|
+
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' ? chalk.yellow : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
1633
|
+
if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
|
|
1634
|
+
return lines;
|
|
1635
|
+
})
|
|
1636
|
+
: [chalk.green('No in-flight queue items right now.')];
|
|
1637
|
+
|
|
1638
|
+
const queueCommandLines = [
|
|
1639
|
+
`${chalk.cyan('$')} switchman queue run`,
|
|
1640
|
+
`${chalk.cyan('$')} switchman queue status --json`,
|
|
1641
|
+
...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
|
|
1642
|
+
];
|
|
1643
|
+
|
|
1644
|
+
console.log('');
|
|
1645
|
+
for (const block of [
|
|
1646
|
+
renderPanel('Landing focus', queueFocusLines, chalk.green),
|
|
1647
|
+
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1648
|
+
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
1649
|
+
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
1650
|
+
]) {
|
|
1651
|
+
for (const line of block) console.log(line);
|
|
1652
|
+
console.log('');
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (recentEvents.length > 0) {
|
|
1656
|
+
console.log(chalk.bold('Recent Queue Events:'));
|
|
1657
|
+
for (const event of recentEvents) {
|
|
1658
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
queueCmd
|
|
1664
|
+
.command('run')
|
|
1665
|
+
.description('Process landing-queue items one at a time')
|
|
1666
|
+
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
1667
|
+
.option('--target <branch>', 'Default target branch', 'main')
|
|
1668
|
+
.option('--watch', 'Keep polling for new queue items')
|
|
1669
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
1670
|
+
.option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
|
|
1671
|
+
.option('--json', 'Output raw JSON')
|
|
1672
|
+
.addHelpText('after', `
|
|
1673
|
+
Examples:
|
|
1674
|
+
switchman queue run
|
|
1675
|
+
switchman queue run --watch
|
|
1676
|
+
switchman queue run --watch --watch-interval-ms 1000
|
|
1677
|
+
`)
|
|
1678
|
+
.action(async (opts) => {
|
|
1679
|
+
const repoRoot = getRepo();
|
|
1680
|
+
|
|
1681
|
+
try {
|
|
1682
|
+
const watch = Boolean(opts.watch);
|
|
1683
|
+
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
1684
|
+
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
1685
|
+
const aggregate = {
|
|
1686
|
+
processed: [],
|
|
1687
|
+
cycles: 0,
|
|
1688
|
+
watch,
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
while (true) {
|
|
1692
|
+
const db = getDb(repoRoot);
|
|
1693
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
1694
|
+
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
1695
|
+
targetBranch: opts.target || 'main',
|
|
1696
|
+
});
|
|
1697
|
+
db.close();
|
|
1698
|
+
|
|
1699
|
+
aggregate.processed.push(...result.processed);
|
|
1700
|
+
aggregate.summary = result.summary;
|
|
1701
|
+
aggregate.cycles += 1;
|
|
1702
|
+
|
|
1703
|
+
if (!watch) break;
|
|
1704
|
+
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
1705
|
+
if (result.processed.length === 0) {
|
|
1706
|
+
sleepSync(watchIntervalMs);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (opts.json) {
|
|
1711
|
+
console.log(JSON.stringify(aggregate, null, 2));
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (aggregate.processed.length === 0) {
|
|
1716
|
+
console.log(chalk.dim('No queued merge items.'));
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
for (const entry of aggregate.processed) {
|
|
1721
|
+
const item = entry.item;
|
|
1722
|
+
if (entry.status === 'merged') {
|
|
1723
|
+
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
1724
|
+
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
1725
|
+
} else {
|
|
1726
|
+
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
1727
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1728
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
console.error(chalk.red(err.message));
|
|
1733
|
+
process.exitCode = 1;
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
queueCmd
|
|
1738
|
+
.command('retry <itemId>')
|
|
1739
|
+
.description('Retry a blocked merge queue item')
|
|
1740
|
+
.option('--json', 'Output raw JSON')
|
|
1741
|
+
.action((itemId, opts) => {
|
|
1742
|
+
const repoRoot = getRepo();
|
|
1743
|
+
const db = getDb(repoRoot);
|
|
1744
|
+
const item = retryMergeQueueItem(db, itemId);
|
|
1745
|
+
db.close();
|
|
1746
|
+
|
|
1747
|
+
if (!item) {
|
|
1748
|
+
printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
|
|
1749
|
+
process.exitCode = 1;
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
if (opts.json) {
|
|
1754
|
+
console.log(JSON.stringify(item, null, 2));
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
queueCmd
|
|
1762
|
+
.command('remove <itemId>')
|
|
1763
|
+
.description('Remove a merge queue item')
|
|
1764
|
+
.action((itemId) => {
|
|
1765
|
+
const repoRoot = getRepo();
|
|
1766
|
+
const db = getDb(repoRoot);
|
|
1767
|
+
const item = removeMergeQueueItem(db, itemId);
|
|
1768
|
+
db.close();
|
|
1769
|
+
|
|
1770
|
+
if (!item) {
|
|
1771
|
+
printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
|
|
1772
|
+
process.exitCode = 1;
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
1777
|
+
});
|
|
1778
|
+
|
|
795
1779
|
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
796
1780
|
|
|
797
1781
|
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
1782
|
+
pipelineCmd.addHelpText('after', `
|
|
1783
|
+
Examples:
|
|
1784
|
+
switchman pipeline start "Harden auth API permissions"
|
|
1785
|
+
switchman pipeline exec pipe-123 "/path/to/agent-command"
|
|
1786
|
+
switchman pipeline status pipe-123
|
|
1787
|
+
`);
|
|
798
1788
|
|
|
799
1789
|
pipelineCmd
|
|
800
1790
|
.command('start <title>')
|
|
@@ -832,6 +1822,14 @@ pipelineCmd
|
|
|
832
1822
|
.command('status <pipelineId>')
|
|
833
1823
|
.description('Show task status for a pipeline')
|
|
834
1824
|
.option('--json', 'Output raw JSON')
|
|
1825
|
+
.addHelpText('after', `
|
|
1826
|
+
Plain English:
|
|
1827
|
+
Use this when one goal has been split into several tasks and you want to see what is running, stuck, or next.
|
|
1828
|
+
|
|
1829
|
+
Examples:
|
|
1830
|
+
switchman pipeline status pipe-123
|
|
1831
|
+
switchman pipeline status pipe-123 --json
|
|
1832
|
+
`)
|
|
835
1833
|
.action((pipelineId, opts) => {
|
|
836
1834
|
const repoRoot = getRepo();
|
|
837
1835
|
const db = getDb(repoRoot);
|
|
@@ -845,20 +1843,74 @@ pipelineCmd
|
|
|
845
1843
|
return;
|
|
846
1844
|
}
|
|
847
1845
|
|
|
1846
|
+
const pipelineHealth = result.status === 'blocked'
|
|
1847
|
+
? 'block'
|
|
1848
|
+
: result.counts.failed > 0
|
|
1849
|
+
? 'warn'
|
|
1850
|
+
: result.counts.in_progress > 0
|
|
1851
|
+
? 'warn'
|
|
1852
|
+
: 'healthy';
|
|
1853
|
+
const pipelineHealthColor = colorForHealth(pipelineHealth);
|
|
1854
|
+
const failedTask = result.tasks.find((task) => task.status === 'failed');
|
|
1855
|
+
const runningTask = result.tasks.find((task) => task.status === 'in_progress');
|
|
1856
|
+
const nextPendingTask = result.tasks.find((task) => task.status === 'pending');
|
|
1857
|
+
const focusTask = failedTask || runningTask || nextPendingTask || result.tasks[0] || null;
|
|
1858
|
+
const focusLine = focusTask
|
|
1859
|
+
? `${focusTask.title} ${chalk.dim(focusTask.id)}`
|
|
1860
|
+
: 'No pipeline tasks found.';
|
|
1861
|
+
|
|
1862
|
+
console.log('');
|
|
1863
|
+
console.log(pipelineHealthColor('='.repeat(72)));
|
|
1864
|
+
console.log(`${pipelineHealthColor(healthLabel(pipelineHealth))} ${chalk.bold('switchman pipeline status')} ${chalk.dim('• pipeline mission control')}`);
|
|
848
1865
|
console.log(`${chalk.bold(result.title)} ${chalk.dim(result.pipeline_id)}`);
|
|
849
|
-
console.log(
|
|
850
|
-
|
|
1866
|
+
console.log(pipelineHealthColor('='.repeat(72)));
|
|
1867
|
+
console.log(renderSignalStrip([
|
|
1868
|
+
renderChip('done', result.counts.done, result.counts.done > 0 ? chalk.green : chalk.white),
|
|
1869
|
+
renderChip('running', result.counts.in_progress, result.counts.in_progress > 0 ? chalk.blue : chalk.green),
|
|
1870
|
+
renderChip('pending', result.counts.pending, result.counts.pending > 0 ? chalk.yellow : chalk.green),
|
|
1871
|
+
renderChip('failed', result.counts.failed, result.counts.failed > 0 ? chalk.red : chalk.green),
|
|
1872
|
+
]));
|
|
1873
|
+
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
1874
|
+
|
|
1875
|
+
const runningLines = result.tasks.filter((task) => task.status === 'in_progress').slice(0, 4).map((task) => {
|
|
851
1876
|
const worktree = task.worktree || task.suggested_worktree || 'unassigned';
|
|
852
1877
|
const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
|
|
853
1878
|
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
854
|
-
|
|
1879
|
+
return `${chalk.cyan(worktree)} -> ${task.title}${type} ${chalk.dim(task.id)}${blocked}`;
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
const blockedLines = result.tasks.filter((task) => task.status === 'failed').slice(0, 4).flatMap((task) => {
|
|
1883
|
+
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
1884
|
+
const lines = [`${renderChip('BLOCKED', task.id, chalk.red)} ${task.title}${type}`];
|
|
855
1885
|
if (task.failure?.summary) {
|
|
856
1886
|
const reasonLabel = humanizeReasonCode(task.failure.reason_code);
|
|
857
|
-
|
|
858
|
-
}
|
|
859
|
-
if (task.next_action) {
|
|
860
|
-
console.log(` ${chalk.yellow('next:')} ${task.next_action}`);
|
|
1887
|
+
lines.push(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
|
|
861
1888
|
}
|
|
1889
|
+
if (task.next_action) lines.push(` ${chalk.yellow('next:')} ${task.next_action}`);
|
|
1890
|
+
return lines;
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
const nextLines = result.tasks.filter((task) => task.status === 'pending').slice(0, 4).map((task) => {
|
|
1894
|
+
const worktree = task.suggested_worktree || task.worktree || 'unassigned';
|
|
1895
|
+
const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
|
|
1896
|
+
return `${renderChip('NEXT', task.id, chalk.green)} ${task.title} ${chalk.dim(worktree)}${blocked}`;
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
const commandLines = [
|
|
1900
|
+
`${chalk.cyan('$')} switchman pipeline exec ${result.pipeline_id} "/path/to/agent-command"`,
|
|
1901
|
+
`${chalk.cyan('$')} switchman pipeline pr ${result.pipeline_id}`,
|
|
1902
|
+
...(result.counts.failed > 0 ? [`${chalk.cyan('$')} switchman pipeline status ${result.pipeline_id}`] : []),
|
|
1903
|
+
];
|
|
1904
|
+
|
|
1905
|
+
console.log('');
|
|
1906
|
+
for (const block of [
|
|
1907
|
+
renderPanel('Running now', runningLines.length > 0 ? runningLines : [chalk.dim('No tasks are actively running.')], runningLines.length > 0 ? chalk.cyan : chalk.green),
|
|
1908
|
+
renderPanel('Blocked', blockedLines.length > 0 ? blockedLines : [chalk.green('Nothing blocked.')], blockedLines.length > 0 ? chalk.red : chalk.green),
|
|
1909
|
+
renderPanel('Next up', nextLines.length > 0 ? nextLines : [chalk.dim('No pending tasks left.')], chalk.green),
|
|
1910
|
+
renderPanel('Next commands', commandLines, chalk.cyan),
|
|
1911
|
+
]) {
|
|
1912
|
+
for (const line of block) console.log(line);
|
|
1913
|
+
console.log('');
|
|
862
1914
|
}
|
|
863
1915
|
} catch (err) {
|
|
864
1916
|
db.close();
|
|
@@ -1048,6 +2100,14 @@ pipelineCmd
|
|
|
1048
2100
|
.option('--retry-backoff-ms <ms>', 'Base backoff in milliseconds between retry attempts', '0')
|
|
1049
2101
|
.option('--timeout-ms <ms>', 'Default command timeout in milliseconds when a task spec does not provide one', '0')
|
|
1050
2102
|
.option('--json', 'Output raw JSON')
|
|
2103
|
+
.addHelpText('after', `
|
|
2104
|
+
Plain English:
|
|
2105
|
+
pipeline = one goal, broken into smaller safe tasks
|
|
2106
|
+
|
|
2107
|
+
Examples:
|
|
2108
|
+
switchman pipeline exec pipe-123 "/path/to/agent-command"
|
|
2109
|
+
switchman pipeline exec pipe-123 "npm test"
|
|
2110
|
+
`)
|
|
1051
2111
|
.action(async (pipelineId, agentCommand, opts) => {
|
|
1052
2112
|
const repoRoot = getRepo();
|
|
1053
2113
|
const db = getDb(repoRoot);
|
|
@@ -1081,20 +2141,34 @@ pipelineCmd
|
|
|
1081
2141
|
console.log(chalk.dim(result.pr.markdown.split('\n')[0]));
|
|
1082
2142
|
} catch (err) {
|
|
1083
2143
|
db.close();
|
|
1084
|
-
|
|
2144
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
1085
2145
|
process.exitCode = 1;
|
|
1086
2146
|
}
|
|
1087
2147
|
});
|
|
1088
2148
|
|
|
1089
2149
|
// ── lease ────────────────────────────────────────────────────────────────────
|
|
1090
2150
|
|
|
1091
|
-
const leaseCmd = program.command('lease').description('Manage active work
|
|
2151
|
+
const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
|
|
2152
|
+
leaseCmd.addHelpText('after', `
|
|
2153
|
+
Plain English:
|
|
2154
|
+
lease = a task currently checked out by an agent
|
|
2155
|
+
|
|
2156
|
+
Examples:
|
|
2157
|
+
switchman lease next --json
|
|
2158
|
+
switchman lease heartbeat lease-123
|
|
2159
|
+
switchman lease reap
|
|
2160
|
+
`);
|
|
1092
2161
|
|
|
1093
2162
|
leaseCmd
|
|
1094
2163
|
.command('acquire <taskId> <worktree>')
|
|
1095
|
-
.description('
|
|
2164
|
+
.description('Start a tracked work session for a specific pending task')
|
|
1096
2165
|
.option('--agent <name>', 'Agent identifier for logging')
|
|
1097
2166
|
.option('--json', 'Output as JSON')
|
|
2167
|
+
.addHelpText('after', `
|
|
2168
|
+
Examples:
|
|
2169
|
+
switchman lease acquire task-123 agent2
|
|
2170
|
+
switchman lease acquire task-123 agent2 --agent cursor
|
|
2171
|
+
`)
|
|
1098
2172
|
.action((taskId, worktree, opts) => {
|
|
1099
2173
|
const repoRoot = getRepo();
|
|
1100
2174
|
const db = getDb(repoRoot);
|
|
@@ -1104,7 +2178,7 @@ leaseCmd
|
|
|
1104
2178
|
|
|
1105
2179
|
if (!lease || !task) {
|
|
1106
2180
|
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
1107
|
-
else
|
|
2181
|
+
else printErrorWithNext('Could not start a work session. The task may not exist or may already be in progress.', 'switchman task list --status pending');
|
|
1108
2182
|
process.exitCode = 1;
|
|
1109
2183
|
return;
|
|
1110
2184
|
}
|
|
@@ -1124,10 +2198,16 @@ leaseCmd
|
|
|
1124
2198
|
|
|
1125
2199
|
leaseCmd
|
|
1126
2200
|
.command('next')
|
|
1127
|
-
.description('
|
|
2201
|
+
.description('Start the next pending task and open a tracked work session for it')
|
|
1128
2202
|
.option('--json', 'Output as JSON')
|
|
1129
|
-
.option('--worktree <name>', '
|
|
2203
|
+
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
1130
2204
|
.option('--agent <name>', 'Agent identifier for logging')
|
|
2205
|
+
.addHelpText('after', `
|
|
2206
|
+
Examples:
|
|
2207
|
+
switchman lease next
|
|
2208
|
+
switchman lease next --json
|
|
2209
|
+
switchman lease next --worktree agent2 --agent cursor
|
|
2210
|
+
`)
|
|
1131
2211
|
.action((opts) => {
|
|
1132
2212
|
const repoRoot = getRepo();
|
|
1133
2213
|
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
@@ -1135,7 +2215,7 @@ leaseCmd
|
|
|
1135
2215
|
|
|
1136
2216
|
if (!task) {
|
|
1137
2217
|
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
1138
|
-
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
2218
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
|
|
1139
2219
|
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
1140
2220
|
return;
|
|
1141
2221
|
}
|
|
@@ -1198,7 +2278,7 @@ leaseCmd
|
|
|
1198
2278
|
|
|
1199
2279
|
if (!lease) {
|
|
1200
2280
|
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
1201
|
-
else
|
|
2281
|
+
else printErrorWithNext(`No active work session found for ${leaseId}.`, 'switchman lease list --status active');
|
|
1202
2282
|
process.exitCode = 1;
|
|
1203
2283
|
return;
|
|
1204
2284
|
}
|
|
@@ -1214,14 +2294,24 @@ leaseCmd
|
|
|
1214
2294
|
|
|
1215
2295
|
leaseCmd
|
|
1216
2296
|
.command('reap')
|
|
1217
|
-
.description('
|
|
1218
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness'
|
|
2297
|
+
.description('Clean up abandoned work sessions and release their file locks')
|
|
2298
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
1219
2299
|
.option('--json', 'Output as JSON')
|
|
2300
|
+
.addHelpText('after', `
|
|
2301
|
+
Examples:
|
|
2302
|
+
switchman lease reap
|
|
2303
|
+
switchman lease reap --stale-after-minutes 20
|
|
2304
|
+
`)
|
|
1220
2305
|
.action((opts) => {
|
|
1221
2306
|
const repoRoot = getRepo();
|
|
1222
2307
|
const db = getDb(repoRoot);
|
|
1223
|
-
const
|
|
1224
|
-
const
|
|
2308
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
2309
|
+
const staleAfterMinutes = opts.staleAfterMinutes
|
|
2310
|
+
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
2311
|
+
: leasePolicy.stale_after_minutes;
|
|
2312
|
+
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
2313
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
2314
|
+
});
|
|
1225
2315
|
db.close();
|
|
1226
2316
|
|
|
1227
2317
|
if (opts.json) {
|
|
@@ -1240,13 +2330,76 @@ leaseCmd
|
|
|
1240
2330
|
}
|
|
1241
2331
|
});
|
|
1242
2332
|
|
|
2333
|
+
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
2334
|
+
|
|
2335
|
+
leasePolicyCmd
|
|
2336
|
+
.command('set')
|
|
2337
|
+
.description('Persist a stale-lease policy for this repo')
|
|
2338
|
+
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
2339
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
2340
|
+
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
2341
|
+
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
2342
|
+
.option('--json', 'Output as JSON')
|
|
2343
|
+
.action((opts) => {
|
|
2344
|
+
const repoRoot = getRepo();
|
|
2345
|
+
const current = loadLeasePolicy(repoRoot);
|
|
2346
|
+
const next = {
|
|
2347
|
+
...current,
|
|
2348
|
+
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
2349
|
+
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
2350
|
+
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
2351
|
+
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
2352
|
+
};
|
|
2353
|
+
const path = writeLeasePolicy(repoRoot, next);
|
|
2354
|
+
const saved = loadLeasePolicy(repoRoot);
|
|
2355
|
+
|
|
2356
|
+
if (opts.json) {
|
|
2357
|
+
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
2362
|
+
console.log(` ${chalk.dim(path)}`);
|
|
2363
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
2364
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
2365
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
2366
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
leasePolicyCmd
|
|
2370
|
+
.description('Show the active stale-lease policy for this repo')
|
|
2371
|
+
.option('--json', 'Output as JSON')
|
|
2372
|
+
.action((opts) => {
|
|
2373
|
+
const repoRoot = getRepo();
|
|
2374
|
+
const policy = loadLeasePolicy(repoRoot);
|
|
2375
|
+
if (opts.json) {
|
|
2376
|
+
console.log(JSON.stringify({ policy }, null, 2));
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
console.log(chalk.bold('Lease policy'));
|
|
2381
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
|
|
2382
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
|
|
2383
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
|
|
2384
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
|
|
2385
|
+
});
|
|
2386
|
+
|
|
1243
2387
|
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
1244
2388
|
|
|
1245
|
-
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
2389
|
+
const wtCmd = program.command('worktree').alias('workspace').description('Manage registered workspaces (Git worktrees)');
|
|
2390
|
+
wtCmd.addHelpText('after', `
|
|
2391
|
+
Plain English:
|
|
2392
|
+
worktree = the Git feature behind each agent workspace
|
|
2393
|
+
|
|
2394
|
+
Examples:
|
|
2395
|
+
switchman worktree list
|
|
2396
|
+
switchman workspace list
|
|
2397
|
+
switchman worktree sync
|
|
2398
|
+
`);
|
|
1246
2399
|
|
|
1247
2400
|
wtCmd
|
|
1248
2401
|
.command('add <name> <path> <branch>')
|
|
1249
|
-
.description('Register a
|
|
2402
|
+
.description('Register a workspace with Switchman')
|
|
1250
2403
|
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
1251
2404
|
.action((name, path, branch, opts) => {
|
|
1252
2405
|
const repoRoot = getRepo();
|
|
@@ -1258,7 +2411,7 @@ wtCmd
|
|
|
1258
2411
|
|
|
1259
2412
|
wtCmd
|
|
1260
2413
|
.command('list')
|
|
1261
|
-
.description('List all registered
|
|
2414
|
+
.description('List all registered workspaces')
|
|
1262
2415
|
.action(() => {
|
|
1263
2416
|
const repoRoot = getRepo();
|
|
1264
2417
|
const db = getDb(repoRoot);
|
|
@@ -1267,7 +2420,7 @@ wtCmd
|
|
|
1267
2420
|
db.close();
|
|
1268
2421
|
|
|
1269
2422
|
if (!worktrees.length && !gitWorktrees.length) {
|
|
1270
|
-
console.log(chalk.dim('No
|
|
2423
|
+
console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
|
|
1271
2424
|
return;
|
|
1272
2425
|
}
|
|
1273
2426
|
|
|
@@ -1287,7 +2440,7 @@ wtCmd
|
|
|
1287
2440
|
|
|
1288
2441
|
wtCmd
|
|
1289
2442
|
.command('sync')
|
|
1290
|
-
.description('Sync
|
|
2443
|
+
.description('Sync Git workspaces into the Switchman database')
|
|
1291
2444
|
.action(() => {
|
|
1292
2445
|
const repoRoot = getRepo();
|
|
1293
2446
|
const db = getDb(repoRoot);
|
|
@@ -1304,12 +2457,20 @@ wtCmd
|
|
|
1304
2457
|
|
|
1305
2458
|
program
|
|
1306
2459
|
.command('claim <taskId> <worktree> [files...]')
|
|
1307
|
-
.description('
|
|
2460
|
+
.description('Lock files for a task before editing')
|
|
1308
2461
|
.option('--agent <name>', 'Agent name')
|
|
1309
2462
|
.option('--force', 'Claim even if conflicts exist')
|
|
2463
|
+
.addHelpText('after', `
|
|
2464
|
+
Examples:
|
|
2465
|
+
switchman claim task-123 agent2 src/auth.js src/server.js
|
|
2466
|
+
switchman claim task-123 agent2 src/auth.js --agent cursor
|
|
2467
|
+
|
|
2468
|
+
Use this before editing files in a shared repo.
|
|
2469
|
+
`)
|
|
1310
2470
|
.action((taskId, worktree, files, opts) => {
|
|
1311
2471
|
if (!files.length) {
|
|
1312
|
-
console.log(chalk.yellow('No files specified.
|
|
2472
|
+
console.log(chalk.yellow('No files specified.'));
|
|
2473
|
+
console.log(`${chalk.yellow('next:')} switchman claim <taskId> <workspace> file1 file2`);
|
|
1313
2474
|
return;
|
|
1314
2475
|
}
|
|
1315
2476
|
const repoRoot = getRepo();
|
|
@@ -1323,7 +2484,8 @@ program
|
|
|
1323
2484
|
for (const c of conflicts) {
|
|
1324
2485
|
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
1325
2486
|
}
|
|
1326
|
-
console.log(chalk.dim('\nUse --force to claim anyway, or
|
|
2487
|
+
console.log(chalk.dim('\nUse --force to claim anyway, or pick different files first.'));
|
|
2488
|
+
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
1327
2489
|
process.exitCode = 1;
|
|
1328
2490
|
return;
|
|
1329
2491
|
}
|
|
@@ -1332,7 +2494,7 @@ program
|
|
|
1332
2494
|
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
1333
2495
|
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
1334
2496
|
} catch (err) {
|
|
1335
|
-
|
|
2497
|
+
printErrorWithNext(err.message, 'switchman task list --status in_progress');
|
|
1336
2498
|
process.exitCode = 1;
|
|
1337
2499
|
} finally {
|
|
1338
2500
|
db.close();
|
|
@@ -1501,13 +2663,19 @@ program
|
|
|
1501
2663
|
|
|
1502
2664
|
program
|
|
1503
2665
|
.command('scan')
|
|
1504
|
-
.description('Scan all
|
|
2666
|
+
.description('Scan all workspaces for conflicts')
|
|
1505
2667
|
.option('--json', 'Output raw JSON')
|
|
1506
2668
|
.option('--quiet', 'Only show conflicts')
|
|
2669
|
+
.addHelpText('after', `
|
|
2670
|
+
Examples:
|
|
2671
|
+
switchman scan
|
|
2672
|
+
switchman scan --quiet
|
|
2673
|
+
switchman scan --json
|
|
2674
|
+
`)
|
|
1507
2675
|
.action(async (opts) => {
|
|
1508
2676
|
const repoRoot = getRepo();
|
|
1509
2677
|
const db = getDb(repoRoot);
|
|
1510
|
-
const spinner = ora('Scanning
|
|
2678
|
+
const spinner = ora('Scanning workspaces for conflicts...').start();
|
|
1511
2679
|
|
|
1512
2680
|
try {
|
|
1513
2681
|
const report = await scanAllWorktrees(db, repoRoot);
|
|
@@ -1600,7 +2768,7 @@ program
|
|
|
1600
2768
|
|
|
1601
2769
|
// All clear
|
|
1602
2770
|
if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
|
|
1603
|
-
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length}
|
|
2771
|
+
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} workspace(s)`));
|
|
1604
2772
|
}
|
|
1605
2773
|
|
|
1606
2774
|
} catch (err) {
|
|
@@ -1614,200 +2782,72 @@ program
|
|
|
1614
2782
|
|
|
1615
2783
|
program
|
|
1616
2784
|
.command('status')
|
|
1617
|
-
.description('Show
|
|
1618
|
-
.
|
|
2785
|
+
.description('Show one dashboard view of what is running, blocked, and ready next')
|
|
2786
|
+
.option('--json', 'Output raw JSON')
|
|
2787
|
+
.option('--watch', 'Keep refreshing status in the terminal')
|
|
2788
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
|
|
2789
|
+
.option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
|
|
2790
|
+
.addHelpText('after', `
|
|
2791
|
+
Examples:
|
|
2792
|
+
switchman status
|
|
2793
|
+
switchman status --watch
|
|
2794
|
+
switchman status --json
|
|
2795
|
+
|
|
2796
|
+
Use this first when the repo feels stuck.
|
|
2797
|
+
`)
|
|
2798
|
+
.action(async (opts) => {
|
|
1619
2799
|
const repoRoot = getRepo();
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
console.log('');
|
|
2800
|
+
const watch = Boolean(opts.watch);
|
|
2801
|
+
const watchIntervalMs = Math.max(100, Number.parseInt(opts.watchIntervalMs, 10) || 2000);
|
|
2802
|
+
const maxCycles = Math.max(0, Number.parseInt(opts.maxCycles, 10) || 0);
|
|
2803
|
+
let cycles = 0;
|
|
2804
|
+
let lastSignature = null;
|
|
1626
2805
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1631
|
-
const done = tasks.filter(t => t.status === 'done');
|
|
1632
|
-
const failed = tasks.filter(t => t.status === 'failed');
|
|
1633
|
-
const activeLeases = listLeases(db, 'active');
|
|
1634
|
-
const staleLeases = getStaleLeases(db);
|
|
1635
|
-
|
|
1636
|
-
console.log(chalk.bold('Tasks:'));
|
|
1637
|
-
console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
|
|
1638
|
-
console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
|
|
1639
|
-
console.log(` ${chalk.green('Done')} ${done.length}`);
|
|
1640
|
-
console.log(` ${chalk.red('Failed')} ${failed.length}`);
|
|
1641
|
-
|
|
1642
|
-
if (activeLeases.length > 0) {
|
|
1643
|
-
console.log('');
|
|
1644
|
-
console.log(chalk.bold('Active Leases:'));
|
|
1645
|
-
for (const lease of activeLeases) {
|
|
1646
|
-
const agent = lease.agent ? ` ${chalk.dim(`agent:${lease.agent}`)}` : '';
|
|
1647
|
-
const scope = summarizeLeaseScope(db, lease);
|
|
1648
|
-
const boundaryValidation = getBoundaryValidationState(db, lease.id);
|
|
1649
|
-
const dependencyInvalidations = listDependencyInvalidations(db, { affectedTaskId: lease.task_id });
|
|
1650
|
-
const boundary = boundaryValidation ? ` ${chalk.dim(`validation:${boundaryValidation.status}`)}` : '';
|
|
1651
|
-
const staleMarker = dependencyInvalidations.length > 0 ? ` ${chalk.dim(`stale:${dependencyInvalidations.length}`)}` : '';
|
|
1652
|
-
console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(`task:${lease.task_id}`)}${agent}${scope ? ` ${chalk.dim(scope)}` : ''}${boundary}${staleMarker}`);
|
|
2806
|
+
while (true) {
|
|
2807
|
+
if (watch && process.stdout.isTTY && !opts.json) {
|
|
2808
|
+
console.clear();
|
|
1653
2809
|
}
|
|
1654
|
-
} else if (inProgress.length > 0) {
|
|
1655
|
-
console.log('');
|
|
1656
|
-
console.log(chalk.bold('In-Progress Tasks Without Lease:'));
|
|
1657
|
-
for (const t of inProgress) {
|
|
1658
|
-
console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
2810
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
console.log(chalk.bold('Stale Leases:'));
|
|
1665
|
-
for (const lease of staleLeases) {
|
|
1666
|
-
console.log(` ${chalk.red(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
if (pending.length > 0) {
|
|
1671
|
-
console.log('');
|
|
1672
|
-
console.log(chalk.bold('Next Up:'));
|
|
1673
|
-
const next = pending.slice(0, 3);
|
|
1674
|
-
for (const t of next) {
|
|
1675
|
-
console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
if (failed.length > 0) {
|
|
1680
|
-
console.log('');
|
|
1681
|
-
console.log(chalk.bold('Failed Tasks:'));
|
|
1682
|
-
for (const task of failed.slice(0, 5)) {
|
|
1683
|
-
const failureLine = String(task.description || '')
|
|
1684
|
-
.split('\n')
|
|
1685
|
-
.map((line) => line.trim())
|
|
1686
|
-
.filter(Boolean)
|
|
1687
|
-
.reverse()
|
|
1688
|
-
.find((line) => line.startsWith('FAILED: '));
|
|
1689
|
-
const failureText = failureLine ? failureLine.slice('FAILED: '.length) : 'unknown failure';
|
|
1690
|
-
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
1691
|
-
const reasonCode = reasonMatch ? reasonMatch[1] : null;
|
|
1692
|
-
const summary = reasonMatch ? reasonMatch[2] : failureText;
|
|
1693
|
-
const nextStep = nextStepForReason(reasonCode);
|
|
1694
|
-
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
1695
|
-
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${humanizeReasonCode(reasonCode)})`)}`);
|
|
1696
|
-
if (nextStep) {
|
|
1697
|
-
console.log(` ${chalk.yellow('next:')} ${nextStep}`);
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// File Claims
|
|
1703
|
-
const claims = getActiveFileClaims(db);
|
|
1704
|
-
if (claims.length > 0) {
|
|
1705
|
-
console.log('');
|
|
1706
|
-
console.log(chalk.bold(`Active File Claims (${claims.length}):`));
|
|
1707
|
-
const byWorktree = {};
|
|
1708
|
-
for (const c of claims) {
|
|
1709
|
-
if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
|
|
1710
|
-
byWorktree[c.worktree].push(c.file_path);
|
|
1711
|
-
}
|
|
1712
|
-
for (const [wt, files] of Object.entries(byWorktree)) {
|
|
1713
|
-
console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
2811
|
+
const report = await collectStatusSnapshot(repoRoot);
|
|
2812
|
+
cycles += 1;
|
|
1716
2813
|
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
const spinner = ora('Running conflict scan...').start();
|
|
1720
|
-
try {
|
|
1721
|
-
const report = await scanAllWorktrees(db, repoRoot);
|
|
1722
|
-
spinner.stop();
|
|
1723
|
-
|
|
1724
|
-
const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
|
|
1725
|
-
if (totalConflicts === 0) {
|
|
1726
|
-
console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
|
|
2814
|
+
if (opts.json) {
|
|
2815
|
+
console.log(JSON.stringify(watch ? { ...report, watch: true, cycles } : report, null, 2));
|
|
1727
2816
|
} else {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
console.log(chalk.bold('Unclaimed Changed Paths:'));
|
|
1741
|
-
for (const entry of report.unclaimedChanges) {
|
|
1742
|
-
const reasonCode = entry.reasons?.[0]?.reason_code || null;
|
|
1743
|
-
const nextStep = nextStepForReason(reasonCode);
|
|
1744
|
-
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}`);
|
|
1745
|
-
console.log(` ${chalk.dim(humanizeReasonCode(reasonCode))}${nextStep ? ` — ${nextStep}` : ''}`);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
if ((report.ownershipConflicts?.length || 0) > 0) {
|
|
1750
|
-
console.log('');
|
|
1751
|
-
console.log(chalk.bold('Ownership Boundary Overlaps:'));
|
|
1752
|
-
for (const conflict of report.ownershipConflicts.slice(0, 5)) {
|
|
1753
|
-
if (conflict.type === 'subsystem_overlap') {
|
|
1754
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(`subsystem:${conflict.subsystemTag}`)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)}`);
|
|
1755
|
-
} else {
|
|
1756
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.scopeA)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(conflict.scopeB)}`);
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
if ((report.semanticConflicts?.length || 0) > 0) {
|
|
1762
|
-
console.log('');
|
|
1763
|
-
console.log(chalk.bold('Semantic Overlaps:'));
|
|
1764
|
-
for (const conflict of report.semanticConflicts.slice(0, 5)) {
|
|
1765
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.object_name)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(`${conflict.fileA} ↔ ${conflict.fileB}`)}`);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
const staleInvalidations = listDependencyInvalidations(db, { status: 'stale' });
|
|
1770
|
-
if (staleInvalidations.length > 0) {
|
|
1771
|
-
console.log('');
|
|
1772
|
-
console.log(chalk.bold('Stale For Revalidation:'));
|
|
1773
|
-
for (const invalidation of staleInvalidations.slice(0, 5)) {
|
|
1774
|
-
const staleArea = invalidation.reason_type === 'subsystem_overlap'
|
|
1775
|
-
? `subsystem:${invalidation.subsystem_tag}`
|
|
1776
|
-
: `${invalidation.source_scope_pattern} ↔ ${invalidation.affected_scope_pattern}`;
|
|
1777
|
-
console.log(` ${chalk.cyan(invalidation.affected_worktree || 'unknown')}: ${chalk.dim(staleArea)} ${chalk.dim('because')} ${chalk.cyan(invalidation.source_worktree || 'unknown')} changed it`);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
if (report.commitGateFailures.length > 0) {
|
|
1782
|
-
console.log('');
|
|
1783
|
-
console.log(chalk.bold('Recent Commit Gate Failures:'));
|
|
1784
|
-
for (const failure of report.commitGateFailures.slice(0, 5)) {
|
|
1785
|
-
console.log(` ${chalk.red(failure.worktree || 'unknown')} ${chalk.dim(humanizeReasonCode(failure.reason_code || 'rejected'))} ${chalk.dim(failure.created_at)}`);
|
|
2817
|
+
renderUnifiedStatusReport(report);
|
|
2818
|
+
if (watch) {
|
|
2819
|
+
const signature = buildWatchSignature(report);
|
|
2820
|
+
const watchState = lastSignature === null
|
|
2821
|
+
? chalk.cyan('baseline snapshot')
|
|
2822
|
+
: signature === lastSignature
|
|
2823
|
+
? chalk.dim('no repo changes since last refresh')
|
|
2824
|
+
: chalk.green('change detected');
|
|
2825
|
+
const updatedAt = formatClockTime(report.generated_at);
|
|
2826
|
+
lastSignature = signature;
|
|
2827
|
+
console.log('');
|
|
2828
|
+
console.log(chalk.dim(`Live watch • updated ${updatedAt || 'just now'} • ${watchState}${maxCycles > 0 ? ` • cycle ${cycles}/${maxCycles}` : ''} • refresh ${watchIntervalMs}ms`));
|
|
1786
2829
|
}
|
|
1787
2830
|
}
|
|
1788
2831
|
|
|
1789
|
-
if (
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
console.log(` ${chalk.red(event.event_type)} ${chalk.cyan(event.worktree || 'repo')} ${chalk.dim(humanizeReasonCode(event.reason_code || event.status))}`);
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
} catch {
|
|
1797
|
-
spinner.stop();
|
|
1798
|
-
console.log(chalk.dim('Could not run conflict scan'));
|
|
2832
|
+
if (!watch) break;
|
|
2833
|
+
if (maxCycles > 0 && cycles >= maxCycles) break;
|
|
2834
|
+
if (opts.json) break;
|
|
2835
|
+
sleepSync(watchIntervalMs);
|
|
1799
2836
|
}
|
|
1800
|
-
|
|
1801
|
-
db.close();
|
|
1802
|
-
console.log('');
|
|
1803
|
-
console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
1804
|
-
console.log('');
|
|
1805
2837
|
});
|
|
1806
2838
|
|
|
1807
2839
|
program
|
|
1808
2840
|
.command('doctor')
|
|
1809
2841
|
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
|
|
1810
2842
|
.option('--json', 'Output raw JSON')
|
|
2843
|
+
.addHelpText('after', `
|
|
2844
|
+
Plain English:
|
|
2845
|
+
Use this when the repo feels risky, noisy, or stuck and you want the health summary plus exact next moves.
|
|
2846
|
+
|
|
2847
|
+
Examples:
|
|
2848
|
+
switchman doctor
|
|
2849
|
+
switchman doctor --json
|
|
2850
|
+
`)
|
|
1811
2851
|
.action(async (opts) => {
|
|
1812
2852
|
const repoRoot = getRepo();
|
|
1813
2853
|
const db = getDb(repoRoot);
|
|
@@ -1832,66 +2872,83 @@ program
|
|
|
1832
2872
|
return;
|
|
1833
2873
|
}
|
|
1834
2874
|
|
|
1835
|
-
const
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
console.log('');
|
|
1843
|
-
|
|
1844
|
-
console.log(chalk.bold('At a glance:'));
|
|
1845
|
-
console.log(` ${chalk.dim('tasks')} ${report.counts.pending} pending, ${report.counts.in_progress} in progress, ${report.counts.done} done, ${report.counts.failed} failed`);
|
|
1846
|
-
console.log(` ${chalk.dim('leases')} ${report.counts.active_leases} active, ${report.counts.stale_leases} stale`);
|
|
1847
|
-
console.log(` ${chalk.dim('merge')} CI ${report.merge_readiness.ci_gate_ok ? chalk.green('clear') : chalk.red('blocked')} AI ${report.merge_readiness.ai_gate_status}`);
|
|
2875
|
+
const doctorColor = colorForHealth(report.health);
|
|
2876
|
+
const blockedCount = report.attention.filter((item) => item.severity === 'block').length;
|
|
2877
|
+
const warningCount = report.attention.filter((item) => item.severity !== 'block').length;
|
|
2878
|
+
const focusItem = report.attention[0] || report.active_work[0] || null;
|
|
2879
|
+
const focusLine = focusItem
|
|
2880
|
+
? `${focusItem.title || focusItem.task_title}${focusItem.detail ? ` ${chalk.dim(`• ${focusItem.detail}`)}` : ''}`
|
|
2881
|
+
: 'Nothing urgent. Repo health looks steady.';
|
|
1848
2882
|
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2883
|
+
console.log('');
|
|
2884
|
+
console.log(doctorColor('='.repeat(72)));
|
|
2885
|
+
console.log(`${doctorColor(healthLabel(report.health))} ${chalk.bold('switchman doctor')} ${chalk.dim('• repo health mission control')}`);
|
|
2886
|
+
console.log(chalk.dim(repoRoot));
|
|
2887
|
+
console.log(chalk.dim(report.summary));
|
|
2888
|
+
console.log(doctorColor('='.repeat(72)));
|
|
2889
|
+
console.log(renderSignalStrip([
|
|
2890
|
+
renderChip('blocked', blockedCount, blockedCount > 0 ? chalk.red : chalk.green),
|
|
2891
|
+
renderChip('watch', warningCount, warningCount > 0 ? chalk.yellow : chalk.green),
|
|
2892
|
+
renderChip('leases', report.counts.active_leases, report.counts.active_leases > 0 ? chalk.blue : chalk.green),
|
|
2893
|
+
renderChip('stale', report.counts.stale_leases, report.counts.stale_leases > 0 ? chalk.red : chalk.green),
|
|
2894
|
+
renderChip('merge', report.merge_readiness.ci_gate_ok ? 'clear' : 'hold', report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red),
|
|
2895
|
+
]));
|
|
2896
|
+
console.log(renderMetricRow([
|
|
2897
|
+
{ label: 'tasks', value: `${report.counts.pending}/${report.counts.in_progress}/${report.counts.done}/${report.counts.failed}`, color: chalk.white },
|
|
2898
|
+
{ label: 'AI gate', value: report.merge_readiness.ai_gate_status, color: report.merge_readiness.ai_gate_status === 'blocked' ? chalk.red : report.merge_readiness.ai_gate_status === 'warn' ? chalk.yellow : chalk.green },
|
|
2899
|
+
]));
|
|
2900
|
+
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
2901
|
+
|
|
2902
|
+
const runningLines = report.active_work.length > 0
|
|
2903
|
+
? report.active_work.slice(0, 5).map((item) => {
|
|
1853
2904
|
const leaseId = activeLeases.find((lease) => lease.task_id === item.task_id && lease.worktree === item.worktree)?.id || null;
|
|
1854
2905
|
const boundary = item.boundary_validation
|
|
1855
|
-
? ` ${
|
|
2906
|
+
? ` ${renderChip('validation', item.boundary_validation.status, item.boundary_validation.status === 'accepted' ? chalk.green : chalk.yellow)}`
|
|
1856
2907
|
: '';
|
|
1857
2908
|
const stale = (item.dependency_invalidations?.length || 0) > 0
|
|
1858
|
-
? ` ${
|
|
2909
|
+
? ` ${renderChip('stale', item.dependency_invalidations.length, chalk.yellow)}`
|
|
1859
2910
|
: '';
|
|
1860
|
-
|
|
1861
|
-
}
|
|
1862
|
-
|
|
2911
|
+
return `${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${leaseId ? ` ${chalk.dim(`lease:${leaseId}`)}` : ''}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${boundary}${stale}`;
|
|
2912
|
+
})
|
|
2913
|
+
: [chalk.dim('Nothing active right now.')];
|
|
2914
|
+
|
|
2915
|
+
const attentionLines = report.attention.length > 0
|
|
2916
|
+
? report.attention.slice(0, 6).flatMap((item) => {
|
|
2917
|
+
const lines = [`${item.severity === 'block' ? renderChip('BLOCKED', item.kind || 'item', chalk.red) : renderChip('WATCH', item.kind || 'item', chalk.yellow)} ${item.title}`];
|
|
2918
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
2919
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
2920
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
2921
|
+
return lines;
|
|
2922
|
+
})
|
|
2923
|
+
: [chalk.green('Nothing urgent.')];
|
|
2924
|
+
|
|
2925
|
+
const nextStepLines = [
|
|
2926
|
+
...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
|
|
2927
|
+
'',
|
|
2928
|
+
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
2929
|
+
];
|
|
1863
2930
|
|
|
1864
2931
|
console.log('');
|
|
1865
2932
|
console.log(chalk.bold('Attention now:'));
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
if (item.detail) console.log(` ${chalk.dim(item.detail)}`);
|
|
1873
|
-
console.log(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
1874
|
-
if (item.command) console.log(` ${chalk.cyan('run:')} ${item.command}`);
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
console.log('');
|
|
1879
|
-
console.log(chalk.bold('Recommended next steps:'));
|
|
1880
|
-
for (const step of report.next_steps) {
|
|
1881
|
-
console.log(` - ${step}`);
|
|
1882
|
-
}
|
|
1883
|
-
if (report.suggested_commands.length > 0) {
|
|
2933
|
+
for (const block of [
|
|
2934
|
+
renderPanel('Running now', runningLines, chalk.cyan),
|
|
2935
|
+
renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
|
|
2936
|
+
renderPanel('Recommended next steps', nextStepLines, chalk.green),
|
|
2937
|
+
]) {
|
|
2938
|
+
for (const line of block) console.log(line);
|
|
1884
2939
|
console.log('');
|
|
1885
|
-
console.log(chalk.bold('Suggested commands:'));
|
|
1886
|
-
for (const command of report.suggested_commands) {
|
|
1887
|
-
console.log(` ${chalk.cyan(command)}`);
|
|
1888
|
-
}
|
|
1889
2940
|
}
|
|
1890
2941
|
});
|
|
1891
2942
|
|
|
1892
2943
|
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
1893
2944
|
|
|
1894
|
-
const gateCmd = program.command('gate').description('
|
|
2945
|
+
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
2946
|
+
gateCmd.addHelpText('after', `
|
|
2947
|
+
Examples:
|
|
2948
|
+
switchman gate ci
|
|
2949
|
+
switchman gate ai
|
|
2950
|
+
switchman gate install-ci
|
|
2951
|
+
`);
|
|
1895
2952
|
|
|
1896
2953
|
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
1897
2954
|
|
|
@@ -2103,7 +3160,7 @@ gateCmd
|
|
|
2103
3160
|
|
|
2104
3161
|
gateCmd
|
|
2105
3162
|
.command('ai')
|
|
2106
|
-
.description('Run the AI-style merge
|
|
3163
|
+
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
2107
3164
|
.option('--json', 'Output raw JSON')
|
|
2108
3165
|
.action(async (opts) => {
|
|
2109
3166
|
const repoRoot = getRepo();
|
|
@@ -2270,7 +3327,7 @@ objectCmd
|
|
|
2270
3327
|
|
|
2271
3328
|
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
2272
3329
|
|
|
2273
|
-
const monitorCmd = program.command('monitor').description('Observe
|
|
3330
|
+
const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
|
|
2274
3331
|
|
|
2275
3332
|
monitorCmd
|
|
2276
3333
|
.command('once')
|
|
@@ -2304,7 +3361,7 @@ monitorCmd
|
|
|
2304
3361
|
|
|
2305
3362
|
monitorCmd
|
|
2306
3363
|
.command('watch')
|
|
2307
|
-
.description('Poll
|
|
3364
|
+
.description('Poll workspaces continuously and log observed file changes')
|
|
2308
3365
|
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2309
3366
|
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2310
3367
|
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
@@ -2317,7 +3374,7 @@ monitorCmd
|
|
|
2317
3374
|
process.exit(1);
|
|
2318
3375
|
}
|
|
2319
3376
|
|
|
2320
|
-
console.log(chalk.cyan(`Watching
|
|
3377
|
+
console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
2321
3378
|
|
|
2322
3379
|
let stopped = false;
|
|
2323
3380
|
const stop = () => {
|