switchman-dev 0.1.2 → 0.1.4
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 +95 -205
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +3 -3
- package/src/cli/index.js +2517 -331
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1669 -28
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +108 -5
- package/src/core/ignore.js +49 -0
- package/src/core/mcp.js +76 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +190 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/policy.js +49 -0
- package/src/core/queue.js +225 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +321 -1
package/src/cli/index.js
CHANGED
|
@@ -7,35 +7,46 @@
|
|
|
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 {
|
|
23
|
-
import {
|
|
24
|
-
import { fileURLToPath } from 'url';
|
|
25
|
-
import { execSync } from 'child_process';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { execSync, spawn } from 'child_process';
|
|
26
24
|
|
|
27
25
|
import { findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
|
|
28
26
|
import {
|
|
29
27
|
initDb, openDb,
|
|
30
28
|
DEFAULT_STALE_LEASE_MINUTES,
|
|
31
|
-
createTask, startTaskLease, completeTask, failTask, listTasks, getTask, getNextPendingTask,
|
|
32
|
-
listLeases, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
29
|
+
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
30
|
+
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
33
31
|
registerWorktree, listWorktrees,
|
|
32
|
+
enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
|
|
34
33
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
|
|
34
|
+
verifyAuditTrail,
|
|
35
35
|
} from '../core/db.js';
|
|
36
36
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
38
|
+
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
39
|
+
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
40
|
+
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
41
|
+
import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
|
|
42
|
+
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
|
|
43
|
+
import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
|
|
44
|
+
import { buildQueueStatusSummary, runMergeQueue } from '../core/queue.js';
|
|
45
|
+
import { DEFAULT_LEASE_POLICY, loadLeasePolicy, writeLeasePolicy } from '../core/policy.js';
|
|
46
|
+
|
|
47
|
+
function installMcpConfig(targetDirs) {
|
|
48
|
+
return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
|
|
49
|
+
}
|
|
39
50
|
|
|
40
51
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
41
52
|
|
|
@@ -68,6 +79,18 @@ function statusBadge(status) {
|
|
|
68
79
|
expired: chalk.red,
|
|
69
80
|
idle: chalk.gray,
|
|
70
81
|
busy: chalk.blue,
|
|
82
|
+
managed: chalk.green,
|
|
83
|
+
observed: chalk.yellow,
|
|
84
|
+
non_compliant: chalk.red,
|
|
85
|
+
stale: chalk.red,
|
|
86
|
+
queued: chalk.yellow,
|
|
87
|
+
validating: chalk.blue,
|
|
88
|
+
rebasing: chalk.blue,
|
|
89
|
+
retrying: chalk.yellow,
|
|
90
|
+
blocked: chalk.red,
|
|
91
|
+
merging: chalk.blue,
|
|
92
|
+
merged: chalk.green,
|
|
93
|
+
canceled: chalk.gray,
|
|
71
94
|
};
|
|
72
95
|
return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
|
|
73
96
|
}
|
|
@@ -105,6 +128,730 @@ function printTable(rows, columns) {
|
|
|
105
128
|
}
|
|
106
129
|
}
|
|
107
130
|
|
|
131
|
+
function padRight(value, width) {
|
|
132
|
+
return String(value).padEnd(width);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stripAnsi(text) {
|
|
136
|
+
return String(text).replace(/\x1B\[[0-9;]*m/g, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function colorForHealth(health) {
|
|
140
|
+
if (health === 'healthy') return chalk.green;
|
|
141
|
+
if (health === 'warn') return chalk.yellow;
|
|
142
|
+
return chalk.red;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function healthLabel(health) {
|
|
146
|
+
if (health === 'healthy') return 'HEALTHY';
|
|
147
|
+
if (health === 'warn') return 'ATTENTION';
|
|
148
|
+
return 'BLOCKED';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderPanel(title, lines, color = chalk.cyan) {
|
|
152
|
+
const content = lines.length > 0 ? lines : [chalk.dim('No items.')];
|
|
153
|
+
const width = Math.max(
|
|
154
|
+
stripAnsi(title).length + 2,
|
|
155
|
+
...content.map((line) => stripAnsi(line).length),
|
|
156
|
+
);
|
|
157
|
+
const top = color(`+${'-'.repeat(width + 2)}+`);
|
|
158
|
+
const titleLine = color(`| ${padRight(title, width)} |`);
|
|
159
|
+
const body = content.map((line) => `| ${padRight(line, width)} |`);
|
|
160
|
+
const bottom = color(`+${'-'.repeat(width + 2)}+`);
|
|
161
|
+
return [top, titleLine, top, ...body, bottom];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderMetricRow(metrics) {
|
|
165
|
+
return metrics.map(({ label, value, color = chalk.white }) => `${chalk.dim(label)} ${color(String(value))}`).join(chalk.dim(' | '));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderMiniBar(items) {
|
|
169
|
+
if (!items.length) return chalk.dim('none');
|
|
170
|
+
return items.map(({ label, value, color = chalk.white }) => `${color('■')} ${label}:${value}`).join(chalk.dim(' '));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatRelativePolicy(policy) {
|
|
174
|
+
return `stale ${policy.stale_after_minutes}m • heartbeat ${policy.heartbeat_interval_seconds}s • auto-reap ${policy.reap_on_status_check ? 'on' : 'off'}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sleepSync(ms) {
|
|
178
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function summarizeLeaseScope(db, lease) {
|
|
182
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
183
|
+
const pathScopes = reservations
|
|
184
|
+
.filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
|
|
185
|
+
.map((reservation) => reservation.scope_pattern);
|
|
186
|
+
if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
|
|
187
|
+
if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
|
|
188
|
+
|
|
189
|
+
const subsystemScopes = reservations
|
|
190
|
+
.filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
|
|
191
|
+
.map((reservation) => reservation.subsystem_tag);
|
|
192
|
+
if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
|
|
193
|
+
if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isBusyError(err) {
|
|
198
|
+
const message = String(err?.message || '').toLowerCase();
|
|
199
|
+
return message.includes('database is locked') || message.includes('sqlite_busy');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function humanizeReasonCode(reasonCode) {
|
|
203
|
+
const labels = {
|
|
204
|
+
no_active_lease: 'no active lease',
|
|
205
|
+
lease_expired: 'lease expired',
|
|
206
|
+
worktree_mismatch: 'wrong worktree',
|
|
207
|
+
path_not_claimed: 'path not claimed',
|
|
208
|
+
path_claimed_by_other_lease: 'claimed by another lease',
|
|
209
|
+
path_scoped_by_other_lease: 'scoped by another lease',
|
|
210
|
+
path_within_task_scope: 'within task scope',
|
|
211
|
+
policy_exception_required: 'policy exception required',
|
|
212
|
+
policy_exception_allowed: 'policy exception allowed',
|
|
213
|
+
changes_outside_claims: 'changed files outside claims',
|
|
214
|
+
changes_outside_task_scope: 'changed files outside task scope',
|
|
215
|
+
missing_expected_tests: 'missing expected tests',
|
|
216
|
+
missing_expected_docs: 'missing expected docs',
|
|
217
|
+
missing_expected_source_changes: 'missing expected source changes',
|
|
218
|
+
objective_not_evidenced: 'task objective not evidenced',
|
|
219
|
+
no_changes_detected: 'no changes detected',
|
|
220
|
+
task_execution_timeout: 'task execution timed out',
|
|
221
|
+
task_failed: 'task failed',
|
|
222
|
+
agent_command_failed: 'agent command failed',
|
|
223
|
+
rejected: 'rejected',
|
|
224
|
+
};
|
|
225
|
+
return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function nextStepForReason(reasonCode) {
|
|
229
|
+
const actions = {
|
|
230
|
+
no_active_lease: 'reacquire the task or lease before writing',
|
|
231
|
+
lease_expired: 'refresh or reacquire the lease, then retry',
|
|
232
|
+
worktree_mismatch: 'run the task from the assigned worktree',
|
|
233
|
+
path_not_claimed: 'claim the file before editing it',
|
|
234
|
+
path_claimed_by_other_lease: 'wait for the other task or pick a different file',
|
|
235
|
+
changes_outside_claims: 'claim all edited files or narrow the task scope',
|
|
236
|
+
changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
|
|
237
|
+
missing_expected_tests: 'add test coverage before rerunning',
|
|
238
|
+
missing_expected_docs: 'add the expected docs change before rerunning',
|
|
239
|
+
missing_expected_source_changes: 'make a source change inside the task scope',
|
|
240
|
+
objective_not_evidenced: 'align the output more closely to the task objective',
|
|
241
|
+
no_changes_detected: 'produce a tracked change or close the task differently',
|
|
242
|
+
task_execution_timeout: 'raise the timeout or reduce task size',
|
|
243
|
+
agent_command_failed: 'inspect stderr/stdout and rerun the agent',
|
|
244
|
+
};
|
|
245
|
+
return actions[reasonCode] || null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function latestTaskFailure(task) {
|
|
249
|
+
const failureLine = String(task.description || '')
|
|
250
|
+
.split('\n')
|
|
251
|
+
.map((line) => line.trim())
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.reverse()
|
|
254
|
+
.find((line) => line.startsWith('FAILED: '));
|
|
255
|
+
if (!failureLine) return null;
|
|
256
|
+
const failureText = failureLine.slice('FAILED: '.length);
|
|
257
|
+
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
258
|
+
return {
|
|
259
|
+
reason_code: reasonMatch ? reasonMatch[1] : null,
|
|
260
|
+
summary: reasonMatch ? reasonMatch[2] : failureText,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function analyzeTaskScope(title, description = '') {
|
|
265
|
+
const text = `${title}\n${description}`.toLowerCase();
|
|
266
|
+
const broadPatterns = [
|
|
267
|
+
/\brefactor\b/,
|
|
268
|
+
/\bwhole repo\b/,
|
|
269
|
+
/\bentire repo\b/,
|
|
270
|
+
/\bacross the repo\b/,
|
|
271
|
+
/\bacross the codebase\b/,
|
|
272
|
+
/\bmultiple modules\b/,
|
|
273
|
+
/\ball routes\b/,
|
|
274
|
+
/\bevery route\b/,
|
|
275
|
+
/\ball files\b/,
|
|
276
|
+
/\bevery file\b/,
|
|
277
|
+
/\brename\b.*\bacross\b/,
|
|
278
|
+
/\bsweep(ing)?\b/,
|
|
279
|
+
/\bglobal\b/,
|
|
280
|
+
/\bwide\b/,
|
|
281
|
+
/\blarge\b/,
|
|
282
|
+
];
|
|
283
|
+
const matches = broadPatterns.filter((pattern) => pattern.test(text));
|
|
284
|
+
if (matches.length === 0) return null;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
level: 'warn',
|
|
288
|
+
summary: 'This task looks broad and may fan out across many files or shared areas.',
|
|
289
|
+
next_step: 'Split it into smaller tasks or use `switchman pipeline start` so Switchman can plan and govern the work explicitly.',
|
|
290
|
+
command: `switchman pipeline start "${title.replace(/"/g, '\\"')}"`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function commandForFailedTask(task, failure) {
|
|
295
|
+
if (!task?.id) return null;
|
|
296
|
+
switch (failure?.reason_code) {
|
|
297
|
+
case 'changes_outside_task_scope':
|
|
298
|
+
case 'objective_not_evidenced':
|
|
299
|
+
case 'missing_expected_tests':
|
|
300
|
+
case 'missing_expected_docs':
|
|
301
|
+
case 'missing_expected_source_changes':
|
|
302
|
+
case 'no_changes_detected':
|
|
303
|
+
return `switchman pipeline status ${task.id.split('-').slice(0, -1).join('-')}`;
|
|
304
|
+
default:
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
|
|
310
|
+
const failedTasks = tasks
|
|
311
|
+
.filter((task) => task.status === 'failed')
|
|
312
|
+
.map((task) => {
|
|
313
|
+
const failure = latestTaskFailure(task);
|
|
314
|
+
return {
|
|
315
|
+
id: task.id,
|
|
316
|
+
title: task.title,
|
|
317
|
+
worktree: task.worktree || null,
|
|
318
|
+
reason_code: failure?.reason_code || null,
|
|
319
|
+
summary: failure?.summary || 'task failed without a recorded summary',
|
|
320
|
+
next_step: nextStepForReason(failure?.reason_code) || 'inspect the task output and rerun with a narrower scope',
|
|
321
|
+
command: commandForFailedTask(task, failure),
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => ({
|
|
326
|
+
worktree: entry.worktree,
|
|
327
|
+
files: entry.files,
|
|
328
|
+
reason_code: entry.reasons?.[0]?.reason_code || null,
|
|
329
|
+
next_step: nextStepForReason(entry.reasons?.[0]?.reason_code) || 'inspect the changed files and bring them back under Switchman claims',
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
|
|
333
|
+
file: conflict.file,
|
|
334
|
+
worktrees: conflict.worktrees,
|
|
335
|
+
next_step: 'let one task finish first or re-scope the conflicting work',
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
const ownershipConflicts = (scanReport.ownershipConflicts || []).map((conflict) => ({
|
|
339
|
+
type: conflict.type,
|
|
340
|
+
worktree_a: conflict.worktreeA,
|
|
341
|
+
worktree_b: conflict.worktreeB,
|
|
342
|
+
subsystem_tag: conflict.subsystemTag || null,
|
|
343
|
+
scope_a: conflict.scopeA || null,
|
|
344
|
+
scope_b: conflict.scopeB || null,
|
|
345
|
+
next_step: 'split the task scopes or serialize work across the shared ownership boundary',
|
|
346
|
+
}));
|
|
347
|
+
const semanticConflicts = (scanReport.semanticConflicts || []).map((conflict) => ({
|
|
348
|
+
...conflict,
|
|
349
|
+
next_step: 'review the overlapping exported object or split the work across different boundaries',
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
const branchConflicts = scanReport.conflicts.map((conflict) => ({
|
|
353
|
+
worktree_a: conflict.worktreeA,
|
|
354
|
+
worktree_b: conflict.worktreeB,
|
|
355
|
+
files: conflict.conflictingFiles,
|
|
356
|
+
next_step: 'review the overlapping branches before merge',
|
|
357
|
+
}));
|
|
358
|
+
|
|
359
|
+
const attention = [
|
|
360
|
+
...staleLeases.map((lease) => ({
|
|
361
|
+
kind: 'stale_lease',
|
|
362
|
+
title: `${lease.worktree} lost its active heartbeat`,
|
|
363
|
+
detail: lease.task_title,
|
|
364
|
+
next_step: 'run `switchman lease reap` to return the task to pending',
|
|
365
|
+
command: 'switchman lease reap',
|
|
366
|
+
severity: 'block',
|
|
367
|
+
})),
|
|
368
|
+
...failedTasks.map((task) => ({
|
|
369
|
+
kind: 'failed_task',
|
|
370
|
+
title: task.title,
|
|
371
|
+
detail: task.summary,
|
|
372
|
+
next_step: task.next_step,
|
|
373
|
+
command: task.command,
|
|
374
|
+
severity: 'warn',
|
|
375
|
+
})),
|
|
376
|
+
...blockedWorktrees.map((entry) => ({
|
|
377
|
+
kind: 'unmanaged_changes',
|
|
378
|
+
title: `${entry.worktree} has unmanaged changed files`,
|
|
379
|
+
detail: `${entry.files.slice(0, 3).join(', ')}${entry.files.length > 3 ? ` +${entry.files.length - 3} more` : ''}`,
|
|
380
|
+
next_step: entry.next_step,
|
|
381
|
+
command: 'switchman scan',
|
|
382
|
+
severity: 'block',
|
|
383
|
+
})),
|
|
384
|
+
...fileConflicts.map((conflict) => ({
|
|
385
|
+
kind: 'file_conflict',
|
|
386
|
+
title: `${conflict.file} is being edited in multiple worktrees`,
|
|
387
|
+
detail: conflict.worktrees.join(', '),
|
|
388
|
+
next_step: conflict.next_step,
|
|
389
|
+
command: 'switchman scan',
|
|
390
|
+
severity: 'block',
|
|
391
|
+
})),
|
|
392
|
+
...ownershipConflicts.map((conflict) => ({
|
|
393
|
+
kind: 'ownership_conflict',
|
|
394
|
+
title: conflict.type === 'subsystem_overlap'
|
|
395
|
+
? `${conflict.worktree_a} and ${conflict.worktree_b} share subsystem ownership`
|
|
396
|
+
: `${conflict.worktree_a} and ${conflict.worktree_b} share scoped ownership`,
|
|
397
|
+
detail: conflict.type === 'subsystem_overlap'
|
|
398
|
+
? `subsystem:${conflict.subsystem_tag}`
|
|
399
|
+
: `${conflict.scope_a} ↔ ${conflict.scope_b}`,
|
|
400
|
+
next_step: conflict.next_step,
|
|
401
|
+
command: 'switchman scan',
|
|
402
|
+
severity: 'block',
|
|
403
|
+
})),
|
|
404
|
+
...semanticConflicts.map((conflict) => ({
|
|
405
|
+
kind: 'semantic_conflict',
|
|
406
|
+
title: conflict.type === 'semantic_object_overlap'
|
|
407
|
+
? `${conflict.worktreeA} and ${conflict.worktreeB} changed the same exported object`
|
|
408
|
+
: `${conflict.worktreeA} and ${conflict.worktreeB} changed semantically similar objects`,
|
|
409
|
+
detail: `${conflict.object_name} (${conflict.fileA} ↔ ${conflict.fileB})`,
|
|
410
|
+
next_step: conflict.next_step,
|
|
411
|
+
command: 'switchman gate ai',
|
|
412
|
+
severity: conflict.severity === 'blocked' ? 'block' : 'warn',
|
|
413
|
+
})),
|
|
414
|
+
...branchConflicts.map((conflict) => ({
|
|
415
|
+
kind: 'branch_conflict',
|
|
416
|
+
title: `${conflict.worktree_a} and ${conflict.worktree_b} have merge risk`,
|
|
417
|
+
detail: `${conflict.files.slice(0, 3).join(', ')}${conflict.files.length > 3 ? ` +${conflict.files.length - 3} more` : ''}`,
|
|
418
|
+
next_step: conflict.next_step,
|
|
419
|
+
command: 'switchman gate ai',
|
|
420
|
+
severity: 'block',
|
|
421
|
+
})),
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
if (aiGate.status === 'warn' || aiGate.status === 'blocked') {
|
|
425
|
+
attention.push({
|
|
426
|
+
kind: 'ai_merge_gate',
|
|
427
|
+
title: aiGate.status === 'blocked' ? 'AI merge gate blocked the repo' : 'AI merge gate wants manual review',
|
|
428
|
+
detail: aiGate.summary,
|
|
429
|
+
next_step: 'run `switchman gate ai` and review the risky worktree pairs',
|
|
430
|
+
command: 'switchman gate ai',
|
|
431
|
+
severity: aiGate.status === 'blocked' ? 'block' : 'warn',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (const validation of aiGate.boundary_validations || []) {
|
|
436
|
+
attention.push({
|
|
437
|
+
kind: 'boundary_validation',
|
|
438
|
+
title: validation.summary,
|
|
439
|
+
detail: validation.rationale?.[0] || `missing ${validation.missing_task_types.join(', ')}`,
|
|
440
|
+
next_step: 'complete the missing validation work before merge',
|
|
441
|
+
command: validation.pipeline_id ? `switchman pipeline status ${validation.pipeline_id}` : 'switchman gate ai',
|
|
442
|
+
severity: validation.severity === 'blocked' ? 'block' : 'warn',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const invalidation of aiGate.dependency_invalidations || []) {
|
|
447
|
+
attention.push({
|
|
448
|
+
kind: 'dependency_invalidation',
|
|
449
|
+
title: invalidation.summary,
|
|
450
|
+
detail: `${invalidation.source_worktree || 'unknown'} -> ${invalidation.affected_worktree || 'unknown'} (${invalidation.stale_area})`,
|
|
451
|
+
next_step: 'rerun or re-review the stale task before merge',
|
|
452
|
+
command: invalidation.affected_pipeline_id ? `switchman pipeline status ${invalidation.affected_pipeline_id}` : 'switchman gate ai',
|
|
453
|
+
severity: invalidation.severity === 'blocked' ? 'block' : 'warn',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const health = attention.some((item) => item.severity === 'block')
|
|
458
|
+
? 'block'
|
|
459
|
+
: attention.some((item) => item.severity === 'warn')
|
|
460
|
+
? 'warn'
|
|
461
|
+
: 'healthy';
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
repo_root: repoRoot,
|
|
465
|
+
health,
|
|
466
|
+
summary: health === 'healthy'
|
|
467
|
+
? 'Repo looks healthy. Agents are coordinated and merge checks are clear.'
|
|
468
|
+
: health === 'warn'
|
|
469
|
+
? 'Repo is running, but there are issues that need review before merge.'
|
|
470
|
+
: 'Repo needs attention before more work or merge.',
|
|
471
|
+
counts: {
|
|
472
|
+
pending: tasks.filter((task) => task.status === 'pending').length,
|
|
473
|
+
in_progress: tasks.filter((task) => task.status === 'in_progress').length,
|
|
474
|
+
done: tasks.filter((task) => task.status === 'done').length,
|
|
475
|
+
failed: failedTasks.length,
|
|
476
|
+
active_leases: activeLeases.length,
|
|
477
|
+
stale_leases: staleLeases.length,
|
|
478
|
+
},
|
|
479
|
+
active_work: activeLeases.map((lease) => ({
|
|
480
|
+
worktree: lease.worktree,
|
|
481
|
+
task_id: lease.task_id,
|
|
482
|
+
task_title: lease.task_title,
|
|
483
|
+
heartbeat_at: lease.heartbeat_at,
|
|
484
|
+
scope_summary: summarizeLeaseScope(db, lease),
|
|
485
|
+
boundary_validation: getBoundaryValidationState(db, lease.id),
|
|
486
|
+
dependency_invalidations: listDependencyInvalidations(db, { affectedTaskId: lease.task_id }),
|
|
487
|
+
})),
|
|
488
|
+
attention,
|
|
489
|
+
merge_readiness: {
|
|
490
|
+
ci_gate_ok: scanReport.conflicts.length === 0
|
|
491
|
+
&& scanReport.fileConflicts.length === 0
|
|
492
|
+
&& (scanReport.ownershipConflicts?.length || 0) === 0
|
|
493
|
+
&& (scanReport.semanticConflicts?.length || 0) === 0
|
|
494
|
+
&& scanReport.unclaimedChanges.length === 0
|
|
495
|
+
&& scanReport.complianceSummary.non_compliant === 0
|
|
496
|
+
&& scanReport.complianceSummary.stale === 0
|
|
497
|
+
&& aiGate.status !== 'blocked'
|
|
498
|
+
&& (aiGate.dependency_invalidations || []).filter((item) => item.severity === 'blocked').length === 0,
|
|
499
|
+
ai_gate_status: aiGate.status,
|
|
500
|
+
boundary_validations: aiGate.boundary_validations || [],
|
|
501
|
+
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
502
|
+
compliance: scanReport.complianceSummary,
|
|
503
|
+
semantic_conflicts: scanReport.semanticConflicts || [],
|
|
504
|
+
},
|
|
505
|
+
next_steps: attention.length > 0
|
|
506
|
+
? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
|
|
507
|
+
: ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'],
|
|
508
|
+
suggested_commands: attention.length > 0
|
|
509
|
+
? [...new Set(attention.map((item) => item.command).filter(Boolean))].slice(0, 5)
|
|
510
|
+
: ['switchman gate ci', 'switchman scan'],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function buildUnifiedStatusReport({
|
|
515
|
+
repoRoot,
|
|
516
|
+
leasePolicy,
|
|
517
|
+
tasks,
|
|
518
|
+
claims,
|
|
519
|
+
doctorReport,
|
|
520
|
+
queueItems,
|
|
521
|
+
queueSummary,
|
|
522
|
+
recentQueueEvents,
|
|
523
|
+
}) {
|
|
524
|
+
const queueAttention = [
|
|
525
|
+
...queueItems
|
|
526
|
+
.filter((item) => item.status === 'blocked')
|
|
527
|
+
.map((item) => ({
|
|
528
|
+
kind: 'queue_blocked',
|
|
529
|
+
title: `${item.id} is blocked from landing`,
|
|
530
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
531
|
+
next_step: item.next_action || `Run \`switchman queue retry ${item.id}\` after fixing the branch state.`,
|
|
532
|
+
command: item.next_action?.includes('queue retry') ? `switchman queue retry ${item.id}` : 'switchman queue status',
|
|
533
|
+
severity: 'block',
|
|
534
|
+
})),
|
|
535
|
+
...queueItems
|
|
536
|
+
.filter((item) => item.status === 'retrying')
|
|
537
|
+
.map((item) => ({
|
|
538
|
+
kind: 'queue_retrying',
|
|
539
|
+
title: `${item.id} is waiting for another landing attempt`,
|
|
540
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
541
|
+
next_step: item.next_action || 'Run `switchman queue run` again to continue landing queued work.',
|
|
542
|
+
command: 'switchman queue run',
|
|
543
|
+
severity: 'warn',
|
|
544
|
+
})),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const attention = [...doctorReport.attention, ...queueAttention];
|
|
548
|
+
const nextUp = tasks
|
|
549
|
+
.filter((task) => task.status === 'pending')
|
|
550
|
+
.sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0))
|
|
551
|
+
.slice(0, 3)
|
|
552
|
+
.map((task) => ({
|
|
553
|
+
id: task.id,
|
|
554
|
+
title: task.title,
|
|
555
|
+
priority: task.priority,
|
|
556
|
+
}));
|
|
557
|
+
const failedTasks = tasks
|
|
558
|
+
.filter((task) => task.status === 'failed')
|
|
559
|
+
.slice(0, 5)
|
|
560
|
+
.map((task) => ({
|
|
561
|
+
id: task.id,
|
|
562
|
+
title: task.title,
|
|
563
|
+
failure: latestTaskFailure(task),
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
const suggestedCommands = [
|
|
567
|
+
...doctorReport.suggested_commands,
|
|
568
|
+
...(queueItems.length > 0 ? ['switchman queue status'] : []),
|
|
569
|
+
...(queueSummary.next ? ['switchman queue run'] : []),
|
|
570
|
+
].filter(Boolean);
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
generated_at: new Date().toISOString(),
|
|
574
|
+
repo_root: repoRoot,
|
|
575
|
+
health: attention.some((item) => item.severity === 'block')
|
|
576
|
+
? 'block'
|
|
577
|
+
: attention.some((item) => item.severity === 'warn')
|
|
578
|
+
? 'warn'
|
|
579
|
+
: doctorReport.health,
|
|
580
|
+
summary: attention.some((item) => item.severity === 'block')
|
|
581
|
+
? 'Repo needs attention before more work or merge.'
|
|
582
|
+
: attention.some((item) => item.severity === 'warn')
|
|
583
|
+
? 'Repo is running, but a few items need review.'
|
|
584
|
+
: 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
|
|
585
|
+
lease_policy: leasePolicy,
|
|
586
|
+
counts: {
|
|
587
|
+
...doctorReport.counts,
|
|
588
|
+
queue: queueSummary.counts,
|
|
589
|
+
active_claims: claims.length,
|
|
590
|
+
},
|
|
591
|
+
active_work: doctorReport.active_work,
|
|
592
|
+
attention,
|
|
593
|
+
next_up: nextUp,
|
|
594
|
+
failed_tasks: failedTasks,
|
|
595
|
+
queue: {
|
|
596
|
+
items: queueItems,
|
|
597
|
+
summary: queueSummary,
|
|
598
|
+
recent_events: recentQueueEvents,
|
|
599
|
+
},
|
|
600
|
+
merge_readiness: doctorReport.merge_readiness,
|
|
601
|
+
claims: claims.map((claim) => ({
|
|
602
|
+
worktree: claim.worktree,
|
|
603
|
+
task_id: claim.task_id,
|
|
604
|
+
file_path: claim.file_path,
|
|
605
|
+
})),
|
|
606
|
+
next_steps: [...new Set([
|
|
607
|
+
...doctorReport.next_steps,
|
|
608
|
+
...queueAttention.map((item) => item.next_step),
|
|
609
|
+
])].slice(0, 6),
|
|
610
|
+
suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function collectStatusSnapshot(repoRoot) {
|
|
615
|
+
const db = getDb(repoRoot);
|
|
616
|
+
try {
|
|
617
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
618
|
+
|
|
619
|
+
if (leasePolicy.reap_on_status_check) {
|
|
620
|
+
reapStaleLeases(db, leasePolicy.stale_after_minutes, {
|
|
621
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const tasks = listTasks(db);
|
|
626
|
+
const activeLeases = listLeases(db, 'active');
|
|
627
|
+
const staleLeases = getStaleLeases(db, leasePolicy.stale_after_minutes);
|
|
628
|
+
const claims = getActiveFileClaims(db);
|
|
629
|
+
const queueItems = listMergeQueue(db);
|
|
630
|
+
const queueSummary = buildQueueStatusSummary(queueItems);
|
|
631
|
+
const recentQueueEvents = queueItems
|
|
632
|
+
.slice(0, 5)
|
|
633
|
+
.flatMap((item) => listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })))
|
|
634
|
+
.sort((a, b) => b.id - a.id)
|
|
635
|
+
.slice(0, 8);
|
|
636
|
+
const scanReport = await scanAllWorktrees(db, repoRoot);
|
|
637
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
638
|
+
const doctorReport = buildDoctorReport({
|
|
639
|
+
db,
|
|
640
|
+
repoRoot,
|
|
641
|
+
tasks,
|
|
642
|
+
activeLeases,
|
|
643
|
+
staleLeases,
|
|
644
|
+
scanReport,
|
|
645
|
+
aiGate,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return buildUnifiedStatusReport({
|
|
649
|
+
repoRoot,
|
|
650
|
+
leasePolicy,
|
|
651
|
+
tasks,
|
|
652
|
+
claims,
|
|
653
|
+
doctorReport,
|
|
654
|
+
queueItems,
|
|
655
|
+
queueSummary,
|
|
656
|
+
recentQueueEvents,
|
|
657
|
+
});
|
|
658
|
+
} finally {
|
|
659
|
+
db.close();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function renderUnifiedStatusReport(report) {
|
|
664
|
+
const healthColor = colorForHealth(report.health);
|
|
665
|
+
const badge = healthColor(healthLabel(report.health));
|
|
666
|
+
const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
|
|
667
|
+
const queueCounts = report.counts.queue;
|
|
668
|
+
|
|
669
|
+
console.log('');
|
|
670
|
+
console.log(healthColor('='.repeat(64)));
|
|
671
|
+
console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• user-centred repo overview')}`);
|
|
672
|
+
console.log(`${chalk.dim(report.repo_root)}`);
|
|
673
|
+
console.log(`${chalk.dim(report.summary)}`);
|
|
674
|
+
console.log(healthColor('='.repeat(64)));
|
|
675
|
+
console.log(renderMetricRow([
|
|
676
|
+
{ label: 'tasks', value: `${report.counts.pending}/${report.counts.in_progress}/${report.counts.done}/${report.counts.failed}`, color: chalk.white },
|
|
677
|
+
{ label: 'leases', value: `${report.counts.active_leases} active`, color: chalk.blue },
|
|
678
|
+
{ label: 'claims', value: report.counts.active_claims, color: chalk.cyan },
|
|
679
|
+
{ label: 'merge', value: report.merge_readiness.ci_gate_ok ? 'clear' : 'blocked', color: mergeColor },
|
|
680
|
+
]));
|
|
681
|
+
console.log(renderMiniBar([
|
|
682
|
+
{ label: 'queued', value: queueCounts.queued, color: chalk.yellow },
|
|
683
|
+
{ label: 'retrying', value: queueCounts.retrying, color: chalk.yellow },
|
|
684
|
+
{ label: 'blocked', value: queueCounts.blocked, color: chalk.red },
|
|
685
|
+
{ label: 'merging', value: queueCounts.merging, color: chalk.blue },
|
|
686
|
+
{ label: 'merged', value: queueCounts.merged, color: chalk.green },
|
|
687
|
+
]));
|
|
688
|
+
console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
|
|
689
|
+
|
|
690
|
+
const runningLines = report.active_work.length > 0
|
|
691
|
+
? report.active_work.slice(0, 5).map((item) => {
|
|
692
|
+
const boundary = item.boundary_validation ? ` validation:${item.boundary_validation.status}` : '';
|
|
693
|
+
const stale = (item.dependency_invalidations?.length || 0) > 0 ? ` stale:${item.dependency_invalidations.length}` : '';
|
|
694
|
+
return `${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${chalk.dim(boundary)}${chalk.dim(stale)}`;
|
|
695
|
+
})
|
|
696
|
+
: [chalk.dim('Nothing active right now.')];
|
|
697
|
+
|
|
698
|
+
const blockedItems = report.attention.filter((item) => item.severity === 'block');
|
|
699
|
+
const warningItems = report.attention.filter((item) => item.severity !== 'block');
|
|
700
|
+
|
|
701
|
+
const blockedLines = blockedItems.length > 0
|
|
702
|
+
? blockedItems.slice(0, 4).flatMap((item) => {
|
|
703
|
+
const lines = [`${chalk.red('BLOCK')} ${item.title}`];
|
|
704
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
705
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
706
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
707
|
+
return lines;
|
|
708
|
+
})
|
|
709
|
+
: [chalk.green('Nothing blocked.')];
|
|
710
|
+
|
|
711
|
+
const warningLines = warningItems.length > 0
|
|
712
|
+
? warningItems.slice(0, 4).flatMap((item) => {
|
|
713
|
+
const tone = chalk.yellow('WARN ');
|
|
714
|
+
const lines = [`${tone} ${item.title}`];
|
|
715
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
716
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
717
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
718
|
+
return lines;
|
|
719
|
+
})
|
|
720
|
+
: [chalk.green('Nothing warning-worthy right now.')];
|
|
721
|
+
|
|
722
|
+
const queueLines = report.queue.items.length > 0
|
|
723
|
+
? [
|
|
724
|
+
...(report.queue.summary.next
|
|
725
|
+
? [`${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}`)}`]
|
|
726
|
+
: []),
|
|
727
|
+
...report.queue.items
|
|
728
|
+
.filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
|
|
729
|
+
.slice(0, 4)
|
|
730
|
+
.flatMap((item) => {
|
|
731
|
+
const lines = [`${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
732
|
+
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
733
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
734
|
+
return lines;
|
|
735
|
+
}),
|
|
736
|
+
]
|
|
737
|
+
: [chalk.dim('No queued merges.')];
|
|
738
|
+
|
|
739
|
+
const nextActionLines = [
|
|
740
|
+
...(report.next_up.length > 0
|
|
741
|
+
? report.next_up.map((task) => `[p${task.priority}] ${task.title} ${chalk.dim(task.id)}`)
|
|
742
|
+
: [chalk.dim('No pending tasks waiting right now.')]),
|
|
743
|
+
'',
|
|
744
|
+
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
const panelBlocks = [
|
|
748
|
+
renderPanel('Running now', runningLines, chalk.cyan),
|
|
749
|
+
renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
|
|
750
|
+
renderPanel('Warnings', warningLines, warningItems.length > 0 ? chalk.yellow : chalk.green),
|
|
751
|
+
renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
|
|
752
|
+
renderPanel('Next action', nextActionLines, chalk.green),
|
|
753
|
+
];
|
|
754
|
+
|
|
755
|
+
console.log('');
|
|
756
|
+
for (const block of panelBlocks) {
|
|
757
|
+
for (const line of block) console.log(line);
|
|
758
|
+
console.log('');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (report.failed_tasks.length > 0) {
|
|
762
|
+
console.log(chalk.bold('Recent failed tasks:'));
|
|
763
|
+
for (const task of report.failed_tasks) {
|
|
764
|
+
const reason = humanizeReasonCode(task.failure?.reason_code);
|
|
765
|
+
const summary = task.failure?.summary || 'unknown failure';
|
|
766
|
+
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
767
|
+
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${reason})`)}`);
|
|
768
|
+
}
|
|
769
|
+
console.log('');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (report.queue.recent_events.length > 0) {
|
|
773
|
+
console.log(chalk.bold('Recent queue events:'));
|
|
774
|
+
for (const event of report.queue.recent_events.slice(0, 5)) {
|
|
775
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
776
|
+
}
|
|
777
|
+
console.log('');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log(chalk.bold('Recommended next steps:'));
|
|
781
|
+
for (const step of report.next_steps) {
|
|
782
|
+
console.log(` - ${step}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
|
|
787
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
788
|
+
try {
|
|
789
|
+
const task = getNextPendingTask(db);
|
|
790
|
+
if (!task) {
|
|
791
|
+
return { task: null, lease: null, exhausted: true };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const lease = startTaskLease(db, task.id, worktreeName, agent || null);
|
|
795
|
+
if (lease) {
|
|
796
|
+
return { task, lease, exhausted: false };
|
|
797
|
+
}
|
|
798
|
+
} catch (err) {
|
|
799
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (attempt < attempts) {
|
|
805
|
+
sleepSync(75 * attempt);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return { task: null, lease: null, exhausted: false };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, agent, attempts = 20) {
|
|
813
|
+
let lastError = null;
|
|
814
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
815
|
+
let db = null;
|
|
816
|
+
try {
|
|
817
|
+
db = openDb(repoRoot);
|
|
818
|
+
const result = acquireNextTaskLease(db, worktreeName, agent, attempts);
|
|
819
|
+
db.close();
|
|
820
|
+
return result;
|
|
821
|
+
} catch (err) {
|
|
822
|
+
lastError = err;
|
|
823
|
+
try { db?.close(); } catch { /* no-op */ }
|
|
824
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
825
|
+
throw err;
|
|
826
|
+
}
|
|
827
|
+
sleepSync(100 * attempt);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
throw lastError;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
834
|
+
let lastError = null;
|
|
835
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
836
|
+
let db = null;
|
|
837
|
+
try {
|
|
838
|
+
db = openDb(repoRoot);
|
|
839
|
+
completeTask(db, taskId);
|
|
840
|
+
releaseFileClaims(db, taskId);
|
|
841
|
+
db.close();
|
|
842
|
+
return;
|
|
843
|
+
} catch (err) {
|
|
844
|
+
lastError = err;
|
|
845
|
+
try { db?.close(); } catch { /* no-op */ }
|
|
846
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
sleepSync(100 * attempt);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
throw lastError;
|
|
853
|
+
}
|
|
854
|
+
|
|
108
855
|
// ─── Program ──────────────────────────────────────────────────────────────────
|
|
109
856
|
|
|
110
857
|
program
|
|
@@ -129,10 +876,13 @@ program
|
|
|
129
876
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
130
877
|
}
|
|
131
878
|
|
|
879
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
880
|
+
|
|
132
881
|
db.close();
|
|
133
882
|
spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
|
|
134
883
|
console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
|
|
135
884
|
console.log(chalk.dim(` Database: .switchman/switchman.db`));
|
|
885
|
+
console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
|
|
136
886
|
console.log('');
|
|
137
887
|
console.log(`Next steps:`);
|
|
138
888
|
console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
|
|
@@ -149,8 +899,8 @@ program
|
|
|
149
899
|
|
|
150
900
|
program
|
|
151
901
|
.command('setup')
|
|
152
|
-
.description('One-command setup: create agent
|
|
153
|
-
.option('-a, --agents <n>', 'Number of agent
|
|
902
|
+
.description('One-command setup: create agent workspaces and initialise Switchman')
|
|
903
|
+
.option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
|
|
154
904
|
.option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
|
|
155
905
|
.action((opts) => {
|
|
156
906
|
const agentCount = parseInt(opts.agents);
|
|
@@ -171,7 +921,7 @@ program
|
|
|
171
921
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
172
922
|
});
|
|
173
923
|
} catch {
|
|
174
|
-
spinner.fail('Your repo needs at least one commit before
|
|
924
|
+
spinner.fail('Your repo needs at least one commit before agent workspaces can be created.');
|
|
175
925
|
console.log(chalk.dim(' Run: git commit --allow-empty -m "init" then try again'));
|
|
176
926
|
process.exit(1);
|
|
177
927
|
}
|
|
@@ -179,7 +929,7 @@ program
|
|
|
179
929
|
// Init the switchman database
|
|
180
930
|
const db = initDb(repoRoot);
|
|
181
931
|
|
|
182
|
-
// Create one worktree per agent
|
|
932
|
+
// Create one workspace (git worktree) per agent
|
|
183
933
|
const created = [];
|
|
184
934
|
for (let i = 1; i <= agentCount; i++) {
|
|
185
935
|
const name = `agent${i}`;
|
|
@@ -204,6 +954,8 @@ program
|
|
|
204
954
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
205
955
|
}
|
|
206
956
|
|
|
957
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
|
|
958
|
+
|
|
207
959
|
db.close();
|
|
208
960
|
|
|
209
961
|
const label = agentCount === 1 ? 'workspace' : 'workspaces';
|
|
@@ -216,11 +968,18 @@ program
|
|
|
216
968
|
console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
|
|
217
969
|
}
|
|
218
970
|
|
|
971
|
+
console.log('');
|
|
972
|
+
console.log(chalk.bold('MCP config:'));
|
|
973
|
+
for (const result of mcpConfigWrites) {
|
|
974
|
+
const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
|
|
975
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
|
|
976
|
+
}
|
|
977
|
+
|
|
219
978
|
console.log('');
|
|
220
979
|
console.log(chalk.bold('Next steps:'));
|
|
221
980
|
console.log(` 1. Add your tasks:`);
|
|
222
981
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
223
|
-
console.log(` 2. Open Claude Code in each folder above —
|
|
982
|
+
console.log(` 2. Open Claude Code or Cursor in each folder above — the local MCP config will attach Switchman automatically`);
|
|
224
983
|
console.log(` 3. Check status at any time:`);
|
|
225
984
|
console.log(` ${chalk.cyan('switchman status')}`);
|
|
226
985
|
console.log('');
|
|
@@ -232,9 +991,45 @@ program
|
|
|
232
991
|
});
|
|
233
992
|
|
|
234
993
|
|
|
994
|
+
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
995
|
+
|
|
996
|
+
const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
|
|
997
|
+
|
|
998
|
+
mcpCmd
|
|
999
|
+
.command('install')
|
|
1000
|
+
.description('Install editor-specific MCP config for Switchman')
|
|
1001
|
+
.option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
|
|
1002
|
+
.option('--home <path>', 'Override the home directory for config writes (useful for testing)')
|
|
1003
|
+
.option('--json', 'Output raw JSON')
|
|
1004
|
+
.action((opts) => {
|
|
1005
|
+
if (!opts.windsurf) {
|
|
1006
|
+
console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
|
|
1007
|
+
process.exitCode = 1;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const result = upsertWindsurfMcpConfig(opts.home);
|
|
1012
|
+
|
|
1013
|
+
if (opts.json) {
|
|
1014
|
+
console.log(JSON.stringify({
|
|
1015
|
+
editor: 'windsurf',
|
|
1016
|
+
path: result.path,
|
|
1017
|
+
created: result.created,
|
|
1018
|
+
changed: result.changed,
|
|
1019
|
+
}, null, 2));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
|
|
1024
|
+
console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
|
|
1025
|
+
console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
|
|
1026
|
+
console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
|
|
235
1030
|
// ── task ──────────────────────────────────────────────────────────────────────
|
|
236
1031
|
|
|
237
|
-
const taskCmd = program.command('task').description('Manage the task
|
|
1032
|
+
const taskCmd = program.command('task').description('Manage the task list');
|
|
238
1033
|
|
|
239
1034
|
taskCmd
|
|
240
1035
|
.command('add <title>')
|
|
@@ -252,8 +1047,14 @@ taskCmd
|
|
|
252
1047
|
priority: parseInt(opts.priority),
|
|
253
1048
|
});
|
|
254
1049
|
db.close();
|
|
1050
|
+
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
255
1051
|
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
256
1052
|
console.log(` ${chalk.dim(title)}`);
|
|
1053
|
+
if (scopeWarning) {
|
|
1054
|
+
console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
|
|
1055
|
+
console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
|
|
1056
|
+
console.log(chalk.cyan(` try: ${scopeWarning.command}`));
|
|
1057
|
+
}
|
|
257
1058
|
});
|
|
258
1059
|
|
|
259
1060
|
taskCmd
|
|
@@ -284,7 +1085,7 @@ taskCmd
|
|
|
284
1085
|
|
|
285
1086
|
taskCmd
|
|
286
1087
|
.command('assign <taskId> <worktree>')
|
|
287
|
-
.description('Assign a task to a
|
|
1088
|
+
.description('Assign a task to a workspace (compatibility shim for lease acquire)')
|
|
288
1089
|
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
289
1090
|
.action((taskId, worktree, opts) => {
|
|
290
1091
|
const repoRoot = getRepo();
|
|
@@ -303,11 +1104,13 @@ taskCmd
|
|
|
303
1104
|
.description('Mark a task as complete and release all file claims')
|
|
304
1105
|
.action((taskId) => {
|
|
305
1106
|
const repoRoot = getRepo();
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1107
|
+
try {
|
|
1108
|
+
completeTaskWithRetries(repoRoot, taskId);
|
|
1109
|
+
console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
console.error(chalk.red(err.message));
|
|
1112
|
+
process.exitCode = 1;
|
|
1113
|
+
}
|
|
311
1114
|
});
|
|
312
1115
|
|
|
313
1116
|
taskCmd
|
|
@@ -326,27 +1129,21 @@ taskCmd
|
|
|
326
1129
|
.command('next')
|
|
327
1130
|
.description('Get and assign the next pending task (compatibility shim for lease next)')
|
|
328
1131
|
.option('--json', 'Output as JSON')
|
|
329
|
-
.option('--worktree <name>', '
|
|
1132
|
+
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
330
1133
|
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
331
1134
|
.action((opts) => {
|
|
332
1135
|
const repoRoot = getRepo();
|
|
333
|
-
const
|
|
334
|
-
const task =
|
|
1136
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
1137
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
335
1138
|
|
|
336
1139
|
if (!task) {
|
|
337
|
-
db.close();
|
|
338
1140
|
if (opts.json) console.log(JSON.stringify({ task: null }));
|
|
339
|
-
else console.log(chalk.dim('No pending tasks.'));
|
|
1141
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
1142
|
+
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
340
1143
|
return;
|
|
341
1144
|
}
|
|
342
1145
|
|
|
343
|
-
// Determine worktree name: explicit flag, or derive from cwd
|
|
344
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
345
|
-
const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
|
|
346
|
-
db.close();
|
|
347
|
-
|
|
348
1146
|
if (!lease) {
|
|
349
|
-
// Race condition: another agent grabbed it between get and assign
|
|
350
1147
|
if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
|
|
351
1148
|
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
352
1149
|
return;
|
|
@@ -360,442 +1157,1831 @@ taskCmd
|
|
|
360
1157
|
}
|
|
361
1158
|
});
|
|
362
1159
|
|
|
363
|
-
// ──
|
|
1160
|
+
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
364
1161
|
|
|
365
|
-
const
|
|
1162
|
+
const queueCmd = program.command('queue').description('Land finished work safely back onto main, one item at a time');
|
|
366
1163
|
|
|
367
|
-
|
|
368
|
-
.command('
|
|
369
|
-
.description('
|
|
370
|
-
.option('--
|
|
371
|
-
.option('--
|
|
372
|
-
.
|
|
1164
|
+
queueCmd
|
|
1165
|
+
.command('add [branch]')
|
|
1166
|
+
.description('Add a branch, workspace, or pipeline to the landing queue')
|
|
1167
|
+
.option('--worktree <name>', 'Queue a registered workspace by name')
|
|
1168
|
+
.option('--pipeline <pipelineId>', 'Queue a pipeline by id')
|
|
1169
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
1170
|
+
.option('--max-retries <n>', 'Maximum automatic retries', '1')
|
|
1171
|
+
.option('--submitted-by <name>', 'Operator or automation name')
|
|
1172
|
+
.option('--json', 'Output raw JSON')
|
|
1173
|
+
.action((branch, opts) => {
|
|
373
1174
|
const repoRoot = getRepo();
|
|
374
1175
|
const db = getDb(repoRoot);
|
|
375
|
-
const task = getTask(db, taskId);
|
|
376
|
-
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
377
|
-
db.close();
|
|
378
1176
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1177
|
+
try {
|
|
1178
|
+
let payload;
|
|
1179
|
+
if (opts.worktree) {
|
|
1180
|
+
const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
|
|
1181
|
+
if (!worktree) {
|
|
1182
|
+
throw new Error(`Worktree ${opts.worktree} is not registered.`);
|
|
1183
|
+
}
|
|
1184
|
+
payload = {
|
|
1185
|
+
sourceType: 'worktree',
|
|
1186
|
+
sourceRef: worktree.branch,
|
|
1187
|
+
sourceWorktree: worktree.name,
|
|
1188
|
+
targetBranch: opts.target,
|
|
1189
|
+
maxRetries: opts.maxRetries,
|
|
1190
|
+
submittedBy: opts.submittedBy || null,
|
|
1191
|
+
};
|
|
1192
|
+
} else if (opts.pipeline) {
|
|
1193
|
+
payload = {
|
|
1194
|
+
sourceType: 'pipeline',
|
|
1195
|
+
sourceRef: opts.pipeline,
|
|
1196
|
+
sourcePipelineId: opts.pipeline,
|
|
1197
|
+
targetBranch: opts.target,
|
|
1198
|
+
maxRetries: opts.maxRetries,
|
|
1199
|
+
submittedBy: opts.submittedBy || null,
|
|
1200
|
+
};
|
|
1201
|
+
} else if (branch) {
|
|
1202
|
+
payload = {
|
|
1203
|
+
sourceType: 'branch',
|
|
1204
|
+
sourceRef: branch,
|
|
1205
|
+
targetBranch: opts.target,
|
|
1206
|
+
maxRetries: opts.maxRetries,
|
|
1207
|
+
submittedBy: opts.submittedBy || null,
|
|
1208
|
+
};
|
|
1209
|
+
} else {
|
|
1210
|
+
throw new Error('Provide a branch, --worktree, or --pipeline.');
|
|
1211
|
+
}
|
|
385
1212
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
lease,
|
|
389
|
-
task: taskJsonWithLease(task, worktree, lease).task,
|
|
390
|
-
}, null, 2));
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
1213
|
+
const result = enqueueMergeItem(db, payload);
|
|
1214
|
+
db.close();
|
|
393
1215
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1216
|
+
if (opts.json) {
|
|
1217
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
1222
|
+
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
1223
|
+
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
db.close();
|
|
1226
|
+
console.error(chalk.red(err.message));
|
|
1227
|
+
process.exitCode = 1;
|
|
1228
|
+
}
|
|
397
1229
|
});
|
|
398
1230
|
|
|
399
|
-
|
|
400
|
-
.command('
|
|
401
|
-
.description('
|
|
402
|
-
.option('--
|
|
403
|
-
.option('--
|
|
404
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
1231
|
+
queueCmd
|
|
1232
|
+
.command('list')
|
|
1233
|
+
.description('List merge queue items')
|
|
1234
|
+
.option('--status <status>', 'Filter by queue status')
|
|
1235
|
+
.option('--json', 'Output raw JSON')
|
|
405
1236
|
.action((opts) => {
|
|
406
1237
|
const repoRoot = getRepo();
|
|
407
1238
|
const db = getDb(repoRoot);
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
if (!task) {
|
|
411
|
-
db.close();
|
|
412
|
-
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
413
|
-
else console.log(chalk.dim('No pending tasks.'));
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
418
|
-
const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
|
|
1239
|
+
const items = listMergeQueue(db, { status: opts.status || null });
|
|
419
1240
|
db.close();
|
|
420
1241
|
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
1242
|
+
if (opts.json) {
|
|
1243
|
+
console.log(JSON.stringify(items, null, 2));
|
|
424
1244
|
return;
|
|
425
1245
|
}
|
|
426
1246
|
|
|
427
|
-
if (
|
|
428
|
-
console.log(
|
|
429
|
-
lease,
|
|
430
|
-
...taskJsonWithLease(task, worktreeName, lease),
|
|
431
|
-
}, null, 2));
|
|
1247
|
+
if (items.length === 0) {
|
|
1248
|
+
console.log(chalk.dim('Merge queue is empty.'));
|
|
432
1249
|
return;
|
|
433
1250
|
}
|
|
434
1251
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1252
|
+
for (const item of items) {
|
|
1253
|
+
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
1254
|
+
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
1255
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}`);
|
|
1256
|
+
if (item.last_error_summary) {
|
|
1257
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1258
|
+
}
|
|
1259
|
+
if (item.next_action) {
|
|
1260
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
438
1263
|
});
|
|
439
1264
|
|
|
440
|
-
|
|
441
|
-
.command('
|
|
442
|
-
.description('
|
|
443
|
-
.option('
|
|
1265
|
+
queueCmd
|
|
1266
|
+
.command('status')
|
|
1267
|
+
.description('Show an operator-friendly merge queue summary')
|
|
1268
|
+
.option('--json', 'Output raw JSON')
|
|
444
1269
|
.action((opts) => {
|
|
445
1270
|
const repoRoot = getRepo();
|
|
446
1271
|
const db = getDb(repoRoot);
|
|
447
|
-
const
|
|
1272
|
+
const items = listMergeQueue(db);
|
|
1273
|
+
const summary = buildQueueStatusSummary(items);
|
|
1274
|
+
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
1275
|
+
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
1276
|
+
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
448
1277
|
db.close();
|
|
449
1278
|
|
|
450
|
-
if (
|
|
451
|
-
console.log(
|
|
1279
|
+
if (opts.json) {
|
|
1280
|
+
console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
|
|
452
1281
|
return;
|
|
453
1282
|
}
|
|
454
1283
|
|
|
455
|
-
console.log(
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
console.log(` ${chalk.dim('
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
console.log('');
|
|
1284
|
+
console.log(`Queue: ${items.length} item(s)`);
|
|
1285
|
+
console.log(` ${chalk.dim('queued')} ${summary.counts.queued} ${chalk.dim('validating')} ${summary.counts.validating} ${chalk.dim('rebasing')} ${summary.counts.rebasing} ${chalk.dim('merging')} ${summary.counts.merging} ${chalk.dim('retrying')} ${summary.counts.retrying} ${chalk.dim('blocked')} ${summary.counts.blocked} ${chalk.dim('merged')} ${summary.counts.merged}`);
|
|
1286
|
+
if (summary.next) {
|
|
1287
|
+
console.log(` ${chalk.dim('next:')} ${summary.next.id} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}`);
|
|
1288
|
+
}
|
|
1289
|
+
for (const item of summary.blocked) {
|
|
1290
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`);
|
|
1291
|
+
if (item.last_error_summary) console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1292
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1293
|
+
}
|
|
1294
|
+
if (recentEvents.length > 0) {
|
|
1295
|
+
console.log('');
|
|
1296
|
+
console.log(chalk.bold('Recent Queue Events:'));
|
|
1297
|
+
for (const event of recentEvents) {
|
|
1298
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
1299
|
+
}
|
|
463
1300
|
}
|
|
464
1301
|
});
|
|
465
1302
|
|
|
466
|
-
|
|
467
|
-
.command('
|
|
468
|
-
.description('
|
|
469
|
-
.option('--
|
|
470
|
-
.option('--
|
|
471
|
-
.
|
|
1303
|
+
queueCmd
|
|
1304
|
+
.command('run')
|
|
1305
|
+
.description('Process queued merge items serially')
|
|
1306
|
+
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
1307
|
+
.option('--target <branch>', 'Default target branch', 'main')
|
|
1308
|
+
.option('--watch', 'Keep polling for new queue items')
|
|
1309
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
1310
|
+
.option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
|
|
1311
|
+
.option('--json', 'Output raw JSON')
|
|
1312
|
+
.action(async (opts) => {
|
|
1313
|
+
const repoRoot = getRepo();
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
const watch = Boolean(opts.watch);
|
|
1317
|
+
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
1318
|
+
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
1319
|
+
const aggregate = {
|
|
1320
|
+
processed: [],
|
|
1321
|
+
cycles: 0,
|
|
1322
|
+
watch,
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
while (true) {
|
|
1326
|
+
const db = getDb(repoRoot);
|
|
1327
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
1328
|
+
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
1329
|
+
targetBranch: opts.target || 'main',
|
|
1330
|
+
});
|
|
1331
|
+
db.close();
|
|
1332
|
+
|
|
1333
|
+
aggregate.processed.push(...result.processed);
|
|
1334
|
+
aggregate.summary = result.summary;
|
|
1335
|
+
aggregate.cycles += 1;
|
|
1336
|
+
|
|
1337
|
+
if (!watch) break;
|
|
1338
|
+
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
1339
|
+
if (result.processed.length === 0) {
|
|
1340
|
+
sleepSync(watchIntervalMs);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (opts.json) {
|
|
1345
|
+
console.log(JSON.stringify(aggregate, null, 2));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (aggregate.processed.length === 0) {
|
|
1350
|
+
console.log(chalk.dim('No queued merge items.'));
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
for (const entry of aggregate.processed) {
|
|
1355
|
+
const item = entry.item;
|
|
1356
|
+
if (entry.status === 'merged') {
|
|
1357
|
+
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
1358
|
+
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
1359
|
+
} else {
|
|
1360
|
+
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
1361
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1362
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
console.error(chalk.red(err.message));
|
|
1367
|
+
process.exitCode = 1;
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
queueCmd
|
|
1372
|
+
.command('retry <itemId>')
|
|
1373
|
+
.description('Retry a blocked merge queue item')
|
|
1374
|
+
.option('--json', 'Output raw JSON')
|
|
1375
|
+
.action((itemId, opts) => {
|
|
472
1376
|
const repoRoot = getRepo();
|
|
473
1377
|
const db = getDb(repoRoot);
|
|
474
|
-
const
|
|
1378
|
+
const item = retryMergeQueueItem(db, itemId);
|
|
475
1379
|
db.close();
|
|
476
1380
|
|
|
477
|
-
if (!
|
|
478
|
-
|
|
479
|
-
else console.log(chalk.red(`No active lease found for ${leaseId}`));
|
|
1381
|
+
if (!item) {
|
|
1382
|
+
console.error(chalk.red(`Queue item ${itemId} is not retryable.`));
|
|
480
1383
|
process.exitCode = 1;
|
|
481
1384
|
return;
|
|
482
1385
|
}
|
|
483
1386
|
|
|
484
1387
|
if (opts.json) {
|
|
485
|
-
console.log(JSON.stringify(
|
|
1388
|
+
console.log(JSON.stringify(item, null, 2));
|
|
486
1389
|
return;
|
|
487
1390
|
}
|
|
488
1391
|
|
|
489
|
-
console.log(`${chalk.green('✓')}
|
|
490
|
-
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
1392
|
+
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
491
1393
|
});
|
|
492
1394
|
|
|
493
|
-
|
|
494
|
-
.command('
|
|
495
|
-
.description('
|
|
496
|
-
.
|
|
497
|
-
.option('--json', 'Output as JSON')
|
|
498
|
-
.action((opts) => {
|
|
1395
|
+
queueCmd
|
|
1396
|
+
.command('remove <itemId>')
|
|
1397
|
+
.description('Remove a merge queue item')
|
|
1398
|
+
.action((itemId) => {
|
|
499
1399
|
const repoRoot = getRepo();
|
|
500
1400
|
const db = getDb(repoRoot);
|
|
501
|
-
const
|
|
502
|
-
const expired = reapStaleLeases(db, staleAfterMinutes);
|
|
1401
|
+
const item = removeMergeQueueItem(db, itemId);
|
|
503
1402
|
db.close();
|
|
504
1403
|
|
|
505
|
-
if (
|
|
506
|
-
console.
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (!expired.length) {
|
|
511
|
-
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
1404
|
+
if (!item) {
|
|
1405
|
+
console.error(chalk.red(`Queue item ${itemId} does not exist.`));
|
|
1406
|
+
process.exitCode = 1;
|
|
512
1407
|
return;
|
|
513
1408
|
}
|
|
514
1409
|
|
|
515
|
-
console.log(`${chalk.green('✓')}
|
|
516
|
-
for (const lease of expired) {
|
|
517
|
-
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
518
|
-
}
|
|
1410
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
519
1411
|
});
|
|
520
1412
|
|
|
521
|
-
// ──
|
|
522
|
-
|
|
523
|
-
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
1413
|
+
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
524
1414
|
|
|
525
|
-
|
|
526
|
-
.command('add <name> <path> <branch>')
|
|
527
|
-
.description('Register a worktree with switchman')
|
|
528
|
-
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
529
|
-
.action((name, path, branch, opts) => {
|
|
530
|
-
const repoRoot = getRepo();
|
|
531
|
-
const db = getDb(repoRoot);
|
|
532
|
-
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
533
|
-
db.close();
|
|
534
|
-
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
535
|
-
});
|
|
1415
|
+
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
536
1416
|
|
|
537
|
-
|
|
538
|
-
.command('
|
|
539
|
-
.description('
|
|
540
|
-
.
|
|
1417
|
+
pipelineCmd
|
|
1418
|
+
.command('start <title>')
|
|
1419
|
+
.description('Create a pipeline from one issue title and split it into execution subtasks')
|
|
1420
|
+
.option('-d, --description <desc>', 'Issue description or markdown checklist')
|
|
1421
|
+
.option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
|
|
1422
|
+
.option('--id <id>', 'Custom pipeline ID')
|
|
1423
|
+
.option('--json', 'Output raw JSON')
|
|
1424
|
+
.action((title, opts) => {
|
|
541
1425
|
const repoRoot = getRepo();
|
|
542
1426
|
const db = getDb(repoRoot);
|
|
543
|
-
const
|
|
544
|
-
|
|
1427
|
+
const result = startPipeline(db, {
|
|
1428
|
+
title,
|
|
1429
|
+
description: opts.description || null,
|
|
1430
|
+
priority: Number.parseInt(opts.priority, 10),
|
|
1431
|
+
pipelineId: opts.id || null,
|
|
1432
|
+
});
|
|
545
1433
|
db.close();
|
|
546
1434
|
|
|
547
|
-
if (
|
|
548
|
-
console.log(
|
|
1435
|
+
if (opts.json) {
|
|
1436
|
+
console.log(JSON.stringify(result, null, 2));
|
|
549
1437
|
return;
|
|
550
1438
|
}
|
|
551
1439
|
|
|
552
|
-
|
|
553
|
-
console.log(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
559
|
-
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
560
|
-
console.log(` ${chalk.dim(wt.path)}`);
|
|
1440
|
+
console.log(`${chalk.green('✓')} Pipeline created ${chalk.cyan(result.pipeline_id)}`);
|
|
1441
|
+
console.log(` ${chalk.bold(result.title)}`);
|
|
1442
|
+
for (const task of result.tasks) {
|
|
1443
|
+
const suggested = task.suggested_worktree ? ` ${chalk.dim(`→ ${task.suggested_worktree}`)}` : '';
|
|
1444
|
+
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
1445
|
+
console.log(` ${chalk.cyan(task.id)} ${task.title}${type}${suggested}`);
|
|
561
1446
|
}
|
|
562
|
-
console.log('');
|
|
563
1447
|
});
|
|
564
1448
|
|
|
565
|
-
|
|
566
|
-
.command('
|
|
567
|
-
.description('
|
|
568
|
-
.
|
|
1449
|
+
pipelineCmd
|
|
1450
|
+
.command('status <pipelineId>')
|
|
1451
|
+
.description('Show task status for a pipeline')
|
|
1452
|
+
.option('--json', 'Output raw JSON')
|
|
1453
|
+
.action((pipelineId, opts) => {
|
|
569
1454
|
const repoRoot = getRepo();
|
|
570
1455
|
const db = getDb(repoRoot);
|
|
571
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
572
|
-
for (const wt of gitWorktrees) {
|
|
573
|
-
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
574
|
-
}
|
|
575
|
-
db.close();
|
|
576
|
-
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
577
|
-
});
|
|
578
1456
|
|
|
579
|
-
|
|
1457
|
+
try {
|
|
1458
|
+
const result = getPipelineStatus(db, pipelineId);
|
|
1459
|
+
db.close();
|
|
580
1460
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1461
|
+
if (opts.json) {
|
|
1462
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
console.log(`${chalk.bold(result.title)} ${chalk.dim(result.pipeline_id)}`);
|
|
1467
|
+
console.log(` ${chalk.dim('done')} ${result.counts.done} ${chalk.dim('in_progress')} ${result.counts.in_progress} ${chalk.dim('pending')} ${result.counts.pending} ${chalk.dim('failed')} ${result.counts.failed}`);
|
|
1468
|
+
for (const task of result.tasks) {
|
|
1469
|
+
const worktree = task.worktree || task.suggested_worktree || 'unassigned';
|
|
1470
|
+
const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
|
|
1471
|
+
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
1472
|
+
console.log(` ${statusBadge(task.status)} ${task.id} ${task.title}${type} ${chalk.dim(worktree)}${blocked}`);
|
|
1473
|
+
if (task.failure?.summary) {
|
|
1474
|
+
const reasonLabel = humanizeReasonCode(task.failure.reason_code);
|
|
1475
|
+
console.log(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
|
|
1476
|
+
}
|
|
1477
|
+
if (task.next_action) {
|
|
1478
|
+
console.log(` ${chalk.yellow('next:')} ${task.next_action}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
db.close();
|
|
1483
|
+
console.error(chalk.red(err.message));
|
|
1484
|
+
process.exitCode = 1;
|
|
590
1485
|
}
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
pipelineCmd
|
|
1489
|
+
.command('pr <pipelineId>')
|
|
1490
|
+
.description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
|
|
1491
|
+
.option('--json', 'Output raw JSON')
|
|
1492
|
+
.action(async (pipelineId, opts) => {
|
|
591
1493
|
const repoRoot = getRepo();
|
|
592
1494
|
const db = getDb(repoRoot);
|
|
593
1495
|
|
|
594
1496
|
try {
|
|
595
|
-
const
|
|
1497
|
+
const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
|
|
1498
|
+
db.close();
|
|
596
1499
|
|
|
597
|
-
if (
|
|
598
|
-
console.log(
|
|
599
|
-
for (const c of conflicts) {
|
|
600
|
-
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
601
|
-
}
|
|
602
|
-
console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
|
|
1500
|
+
if (opts.json) {
|
|
1501
|
+
console.log(JSON.stringify(result, null, 2));
|
|
603
1502
|
return;
|
|
604
1503
|
}
|
|
605
1504
|
|
|
606
|
-
|
|
607
|
-
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
608
|
-
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
1505
|
+
console.log(result.markdown);
|
|
609
1506
|
} catch (err) {
|
|
1507
|
+
db.close();
|
|
610
1508
|
console.error(chalk.red(err.message));
|
|
611
1509
|
process.exitCode = 1;
|
|
612
|
-
} finally {
|
|
613
|
-
db.close();
|
|
614
1510
|
}
|
|
615
1511
|
});
|
|
616
1512
|
|
|
617
|
-
|
|
618
|
-
.command('
|
|
619
|
-
.description('
|
|
620
|
-
.
|
|
1513
|
+
pipelineCmd
|
|
1514
|
+
.command('bundle <pipelineId> [outputDir]')
|
|
1515
|
+
.description('Export a reviewer-ready PR bundle for a pipeline to disk')
|
|
1516
|
+
.option('--json', 'Output raw JSON')
|
|
1517
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
621
1518
|
const repoRoot = getRepo();
|
|
622
1519
|
const db = getDb(repoRoot);
|
|
623
|
-
releaseFileClaims(db, taskId);
|
|
624
|
-
db.close();
|
|
625
|
-
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
626
|
-
});
|
|
627
1520
|
|
|
628
|
-
|
|
1521
|
+
try {
|
|
1522
|
+
const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
|
|
1523
|
+
db.close();
|
|
629
1524
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1525
|
+
if (opts.json) {
|
|
1526
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
|
|
1531
|
+
console.log(` ${chalk.dim(result.output_dir)}`);
|
|
1532
|
+
console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
|
|
1533
|
+
console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
|
|
1534
|
+
console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
db.close();
|
|
1537
|
+
console.error(chalk.red(err.message));
|
|
1538
|
+
process.exitCode = 1;
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
pipelineCmd
|
|
1543
|
+
.command('publish <pipelineId> [outputDir]')
|
|
1544
|
+
.description('Create a hosted GitHub pull request for a pipeline using gh')
|
|
1545
|
+
.option('--base <branch>', 'Base branch for the pull request', 'main')
|
|
1546
|
+
.option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
|
|
1547
|
+
.option('--draft', 'Create the pull request as a draft')
|
|
1548
|
+
.option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
|
|
633
1549
|
.option('--json', 'Output raw JSON')
|
|
634
|
-
.
|
|
635
|
-
.action(async (opts) => {
|
|
1550
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
636
1551
|
const repoRoot = getRepo();
|
|
637
1552
|
const db = getDb(repoRoot);
|
|
638
|
-
const spinner = ora('Scanning worktrees for conflicts...').start();
|
|
639
1553
|
|
|
640
1554
|
try {
|
|
641
|
-
const
|
|
1555
|
+
const result = await publishPipelinePr(db, repoRoot, pipelineId, {
|
|
1556
|
+
baseBranch: opts.base,
|
|
1557
|
+
headBranch: opts.head || null,
|
|
1558
|
+
draft: Boolean(opts.draft),
|
|
1559
|
+
ghCommand: opts.ghCommand,
|
|
1560
|
+
outputDir: outputDir || null,
|
|
1561
|
+
});
|
|
642
1562
|
db.close();
|
|
643
|
-
spinner.stop();
|
|
644
1563
|
|
|
645
1564
|
if (opts.json) {
|
|
646
|
-
console.log(JSON.stringify(
|
|
1565
|
+
console.log(JSON.stringify(result, null, 2));
|
|
647
1566
|
return;
|
|
648
1567
|
}
|
|
649
1568
|
|
|
650
|
-
console.log('');
|
|
651
|
-
console.log(chalk.
|
|
652
|
-
console.log(chalk.dim(
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
// Worktrees summary
|
|
656
|
-
if (!opts.quiet) {
|
|
657
|
-
console.log(chalk.bold('Worktrees:'));
|
|
658
|
-
for (const wt of report.worktrees) {
|
|
659
|
-
const files = report.fileMap?.[wt.name] || [];
|
|
660
|
-
console.log(` ${chalk.cyan(wt.name.padEnd(20))} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
|
|
661
|
-
}
|
|
662
|
-
console.log('');
|
|
1569
|
+
console.log(`${chalk.green('✓')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
|
|
1570
|
+
console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
|
|
1571
|
+
console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
|
|
1572
|
+
if (result.output) {
|
|
1573
|
+
console.log(` ${chalk.dim(result.output)}`);
|
|
663
1574
|
}
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
db.close();
|
|
1577
|
+
console.error(chalk.red(err.message));
|
|
1578
|
+
process.exitCode = 1;
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
664
1581
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1582
|
+
pipelineCmd
|
|
1583
|
+
.command('run <pipelineId> [agentCommand...]')
|
|
1584
|
+
.description('Dispatch pending pipeline tasks onto available worktrees and optionally launch an agent command in each one')
|
|
1585
|
+
.option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
|
|
1586
|
+
.option('--detached', 'Launch agent commands as detached background processes')
|
|
1587
|
+
.option('--json', 'Output raw JSON')
|
|
1588
|
+
.action((pipelineId, agentCommand, opts) => {
|
|
1589
|
+
const repoRoot = getRepo();
|
|
1590
|
+
const db = getDb(repoRoot);
|
|
674
1591
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
console.log('');
|
|
1592
|
+
try {
|
|
1593
|
+
const result = runPipeline(db, repoRoot, {
|
|
1594
|
+
pipelineId,
|
|
1595
|
+
agentCommand,
|
|
1596
|
+
agentName: opts.agent,
|
|
1597
|
+
detached: Boolean(opts.detached),
|
|
1598
|
+
});
|
|
1599
|
+
db.close();
|
|
1600
|
+
|
|
1601
|
+
if (opts.json) {
|
|
1602
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1603
|
+
return;
|
|
688
1604
|
}
|
|
689
1605
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1606
|
+
if (result.assigned.length === 0) {
|
|
1607
|
+
console.log(chalk.dim('No pending pipeline tasks were assigned. All worktrees may already be busy.'));
|
|
1608
|
+
return;
|
|
693
1609
|
}
|
|
694
1610
|
|
|
1611
|
+
console.log(`${chalk.green('✓')} Dispatched ${result.assigned.length} pipeline task(s)`);
|
|
1612
|
+
for (const assignment of result.assigned) {
|
|
1613
|
+
const launch = result.launched.find((item) => item.task_id === assignment.task_id);
|
|
1614
|
+
const launchInfo = launch ? ` ${chalk.dim(`pid=${launch.pid}`)}` : '';
|
|
1615
|
+
console.log(` ${chalk.cyan(assignment.task_id)} → ${chalk.cyan(assignment.worktree)} ${chalk.dim(assignment.lease_id)}${launchInfo}`);
|
|
1616
|
+
}
|
|
1617
|
+
if (result.remaining_pending > 0) {
|
|
1618
|
+
console.log(chalk.dim(`${result.remaining_pending} pipeline task(s) remain pending due to unavailable worktrees.`));
|
|
1619
|
+
}
|
|
695
1620
|
} catch (err) {
|
|
696
|
-
spinner.fail(err.message);
|
|
697
1621
|
db.close();
|
|
698
|
-
|
|
1622
|
+
console.error(chalk.red(err.message));
|
|
1623
|
+
process.exitCode = 1;
|
|
699
1624
|
}
|
|
700
1625
|
});
|
|
701
1626
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
.
|
|
706
|
-
.
|
|
707
|
-
.action(async () => {
|
|
1627
|
+
pipelineCmd
|
|
1628
|
+
.command('review <pipelineId>')
|
|
1629
|
+
.description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
|
|
1630
|
+
.option('--json', 'Output raw JSON')
|
|
1631
|
+
.action(async (pipelineId, opts) => {
|
|
708
1632
|
const repoRoot = getRepo();
|
|
709
1633
|
const db = getDb(repoRoot);
|
|
710
1634
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
console.log('');
|
|
1635
|
+
try {
|
|
1636
|
+
const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
|
|
1637
|
+
db.close();
|
|
715
1638
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const done = tasks.filter(t => t.status === 'done');
|
|
721
|
-
const failed = tasks.filter(t => t.status === 'failed');
|
|
722
|
-
const activeLeases = listLeases(db, 'active');
|
|
723
|
-
const staleLeases = getStaleLeases(db);
|
|
1639
|
+
if (opts.json) {
|
|
1640
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
724
1643
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
console.log(` ${chalk.red('Failed')} ${failed.length}`);
|
|
1644
|
+
if (result.created_count === 0) {
|
|
1645
|
+
console.log(chalk.dim('No follow-up tasks were created. The pipeline gates did not surface new actionable items.'));
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
730
1648
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
for (const lease of activeLeases) {
|
|
735
|
-
console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)}`);
|
|
1649
|
+
console.log(`${chalk.green('✓')} Created ${result.created_count} follow-up task(s)`);
|
|
1650
|
+
for (const task of result.created) {
|
|
1651
|
+
console.log(` ${chalk.cyan(task.id)} ${task.title}`);
|
|
736
1652
|
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
console.
|
|
740
|
-
|
|
741
|
-
|
|
1653
|
+
} catch (err) {
|
|
1654
|
+
db.close();
|
|
1655
|
+
console.error(chalk.red(err.message));
|
|
1656
|
+
process.exitCode = 1;
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
pipelineCmd
|
|
1661
|
+
.command('exec <pipelineId> [agentCommand...]')
|
|
1662
|
+
.description('Run a bounded autonomous loop: dispatch, execute, review, and stop when ready or blocked')
|
|
1663
|
+
.option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
|
|
1664
|
+
.option('--max-iterations <n>', 'Maximum execution/review iterations', '3')
|
|
1665
|
+
.option('--max-retries <n>', 'Retry a failed pipeline task up to this many times', '1')
|
|
1666
|
+
.option('--retry-backoff-ms <ms>', 'Base backoff in milliseconds between retry attempts', '0')
|
|
1667
|
+
.option('--timeout-ms <ms>', 'Default command timeout in milliseconds when a task spec does not provide one', '0')
|
|
1668
|
+
.option('--json', 'Output raw JSON')
|
|
1669
|
+
.action(async (pipelineId, agentCommand, opts) => {
|
|
1670
|
+
const repoRoot = getRepo();
|
|
1671
|
+
const db = getDb(repoRoot);
|
|
1672
|
+
|
|
1673
|
+
try {
|
|
1674
|
+
const result = await executePipeline(db, repoRoot, {
|
|
1675
|
+
pipelineId,
|
|
1676
|
+
agentCommand,
|
|
1677
|
+
agentName: opts.agent,
|
|
1678
|
+
maxIterations: Number.parseInt(opts.maxIterations, 10),
|
|
1679
|
+
maxRetries: Number.parseInt(opts.maxRetries, 10),
|
|
1680
|
+
retryBackoffMs: Number.parseInt(opts.retryBackoffMs, 10),
|
|
1681
|
+
timeoutMs: Number.parseInt(opts.timeoutMs, 10),
|
|
1682
|
+
});
|
|
1683
|
+
db.close();
|
|
1684
|
+
|
|
1685
|
+
if (opts.json) {
|
|
1686
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const badge = result.status === 'ready'
|
|
1691
|
+
? chalk.green('READY')
|
|
1692
|
+
: result.status === 'blocked'
|
|
1693
|
+
? chalk.red('BLOCKED')
|
|
1694
|
+
: chalk.yellow('MAX');
|
|
1695
|
+
console.log(`${badge} Pipeline ${chalk.cyan(result.pipeline_id)} ${chalk.dim(result.status)}`);
|
|
1696
|
+
for (const iteration of result.iterations) {
|
|
1697
|
+
console.log(` iter ${iteration.iteration}: resumed=${iteration.resumed_retries} dispatched=${iteration.dispatched} executed=${iteration.executed} retries=${iteration.retries_scheduled} followups=${iteration.followups_created} ai=${iteration.ai_gate_status} ready=${iteration.ready}`);
|
|
1698
|
+
}
|
|
1699
|
+
console.log(chalk.dim(result.pr.markdown.split('\n')[0]));
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
db.close();
|
|
1702
|
+
console.error(chalk.red(err.message));
|
|
1703
|
+
process.exitCode = 1;
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// ── lease ────────────────────────────────────────────────────────────────────
|
|
1708
|
+
|
|
1709
|
+
const leaseCmd = program.command('lease').description('Manage active work leases');
|
|
1710
|
+
|
|
1711
|
+
leaseCmd
|
|
1712
|
+
.command('acquire <taskId> <worktree>')
|
|
1713
|
+
.description('Acquire a lease for a pending task')
|
|
1714
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1715
|
+
.option('--json', 'Output as JSON')
|
|
1716
|
+
.action((taskId, worktree, opts) => {
|
|
1717
|
+
const repoRoot = getRepo();
|
|
1718
|
+
const db = getDb(repoRoot);
|
|
1719
|
+
const task = getTask(db, taskId);
|
|
1720
|
+
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
1721
|
+
db.close();
|
|
1722
|
+
|
|
1723
|
+
if (!lease || !task) {
|
|
1724
|
+
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
1725
|
+
else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
|
|
1726
|
+
process.exitCode = 1;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (opts.json) {
|
|
1731
|
+
console.log(JSON.stringify({
|
|
1732
|
+
lease,
|
|
1733
|
+
task: taskJsonWithLease(task, worktree, lease).task,
|
|
1734
|
+
}, null, 2));
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
|
|
1739
|
+
console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
|
|
1740
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
leaseCmd
|
|
1744
|
+
.command('next')
|
|
1745
|
+
.description('Claim the next pending task and acquire its lease')
|
|
1746
|
+
.option('--json', 'Output as JSON')
|
|
1747
|
+
.option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
|
|
1748
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1749
|
+
.action((opts) => {
|
|
1750
|
+
const repoRoot = getRepo();
|
|
1751
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
1752
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
1753
|
+
|
|
1754
|
+
if (!task) {
|
|
1755
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
1756
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
1757
|
+
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if (!lease) {
|
|
1762
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
1763
|
+
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (opts.json) {
|
|
1768
|
+
console.log(JSON.stringify({
|
|
1769
|
+
lease,
|
|
1770
|
+
...taskJsonWithLease(task, worktreeName, lease),
|
|
1771
|
+
}, null, 2));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
1776
|
+
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
1777
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
leaseCmd
|
|
1781
|
+
.command('list')
|
|
1782
|
+
.description('List leases, newest first')
|
|
1783
|
+
.option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
|
|
1784
|
+
.action((opts) => {
|
|
1785
|
+
const repoRoot = getRepo();
|
|
1786
|
+
const db = getDb(repoRoot);
|
|
1787
|
+
const leases = listLeases(db, opts.status);
|
|
1788
|
+
db.close();
|
|
1789
|
+
|
|
1790
|
+
if (!leases.length) {
|
|
1791
|
+
console.log(chalk.dim('No leases found.'));
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
console.log('');
|
|
1796
|
+
for (const lease of leases) {
|
|
1797
|
+
console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
|
|
1798
|
+
console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
|
|
1799
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
|
|
1800
|
+
console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
|
|
1801
|
+
if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
|
|
1802
|
+
console.log('');
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
leaseCmd
|
|
1807
|
+
.command('heartbeat <leaseId>')
|
|
1808
|
+
.description('Refresh the heartbeat timestamp for an active lease')
|
|
1809
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1810
|
+
.option('--json', 'Output as JSON')
|
|
1811
|
+
.action((leaseId, opts) => {
|
|
1812
|
+
const repoRoot = getRepo();
|
|
1813
|
+
const db = getDb(repoRoot);
|
|
1814
|
+
const lease = heartbeatLease(db, leaseId, opts.agent || null);
|
|
1815
|
+
db.close();
|
|
1816
|
+
|
|
1817
|
+
if (!lease) {
|
|
1818
|
+
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
1819
|
+
else console.log(chalk.red(`No active lease found for ${leaseId}`));
|
|
1820
|
+
process.exitCode = 1;
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (opts.json) {
|
|
1825
|
+
console.log(JSON.stringify({ lease }, null, 2));
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
|
|
1830
|
+
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
leaseCmd
|
|
1834
|
+
.command('reap')
|
|
1835
|
+
.description('Expire stale leases, release their claims, and return their tasks to pending')
|
|
1836
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
1837
|
+
.option('--json', 'Output as JSON')
|
|
1838
|
+
.action((opts) => {
|
|
1839
|
+
const repoRoot = getRepo();
|
|
1840
|
+
const db = getDb(repoRoot);
|
|
1841
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
1842
|
+
const staleAfterMinutes = opts.staleAfterMinutes
|
|
1843
|
+
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
1844
|
+
: leasePolicy.stale_after_minutes;
|
|
1845
|
+
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
1846
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
1847
|
+
});
|
|
1848
|
+
db.close();
|
|
1849
|
+
|
|
1850
|
+
if (opts.json) {
|
|
1851
|
+
console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (!expired.length) {
|
|
1856
|
+
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
|
|
1861
|
+
for (const lease of expired) {
|
|
1862
|
+
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
1867
|
+
|
|
1868
|
+
leasePolicyCmd
|
|
1869
|
+
.command('set')
|
|
1870
|
+
.description('Persist a stale-lease policy for this repo')
|
|
1871
|
+
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
1872
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
1873
|
+
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
1874
|
+
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
1875
|
+
.option('--json', 'Output as JSON')
|
|
1876
|
+
.action((opts) => {
|
|
1877
|
+
const repoRoot = getRepo();
|
|
1878
|
+
const current = loadLeasePolicy(repoRoot);
|
|
1879
|
+
const next = {
|
|
1880
|
+
...current,
|
|
1881
|
+
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
1882
|
+
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
1883
|
+
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
1884
|
+
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
1885
|
+
};
|
|
1886
|
+
const path = writeLeasePolicy(repoRoot, next);
|
|
1887
|
+
const saved = loadLeasePolicy(repoRoot);
|
|
1888
|
+
|
|
1889
|
+
if (opts.json) {
|
|
1890
|
+
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
1895
|
+
console.log(` ${chalk.dim(path)}`);
|
|
1896
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
1897
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
1898
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
1899
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
leasePolicyCmd
|
|
1903
|
+
.description('Show the active stale-lease policy for this repo')
|
|
1904
|
+
.option('--json', 'Output as JSON')
|
|
1905
|
+
.action((opts) => {
|
|
1906
|
+
const repoRoot = getRepo();
|
|
1907
|
+
const policy = loadLeasePolicy(repoRoot);
|
|
1908
|
+
if (opts.json) {
|
|
1909
|
+
console.log(JSON.stringify({ policy }, null, 2));
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
console.log(chalk.bold('Lease policy'));
|
|
1914
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
|
|
1915
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
|
|
1916
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
|
|
1917
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
1921
|
+
|
|
1922
|
+
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
1923
|
+
|
|
1924
|
+
wtCmd
|
|
1925
|
+
.command('add <name> <path> <branch>')
|
|
1926
|
+
.description('Register a worktree with switchman')
|
|
1927
|
+
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
1928
|
+
.action((name, path, branch, opts) => {
|
|
1929
|
+
const repoRoot = getRepo();
|
|
1930
|
+
const db = getDb(repoRoot);
|
|
1931
|
+
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
1932
|
+
db.close();
|
|
1933
|
+
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
wtCmd
|
|
1937
|
+
.command('list')
|
|
1938
|
+
.description('List all registered worktrees')
|
|
1939
|
+
.action(() => {
|
|
1940
|
+
const repoRoot = getRepo();
|
|
1941
|
+
const db = getDb(repoRoot);
|
|
1942
|
+
const worktrees = listWorktrees(db);
|
|
1943
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1944
|
+
db.close();
|
|
1945
|
+
|
|
1946
|
+
if (!worktrees.length && !gitWorktrees.length) {
|
|
1947
|
+
console.log(chalk.dim('No worktrees found.'));
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Show git worktrees (source of truth) annotated with db info
|
|
1952
|
+
console.log('');
|
|
1953
|
+
console.log(chalk.bold('Git Worktrees:'));
|
|
1954
|
+
for (const wt of gitWorktrees) {
|
|
1955
|
+
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
1956
|
+
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
1957
|
+
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
1958
|
+
const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
1959
|
+
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
1960
|
+
console.log(` ${chalk.dim(wt.path)}`);
|
|
1961
|
+
}
|
|
1962
|
+
console.log('');
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
wtCmd
|
|
1966
|
+
.command('sync')
|
|
1967
|
+
.description('Sync git worktrees into the switchman database')
|
|
1968
|
+
.action(() => {
|
|
1969
|
+
const repoRoot = getRepo();
|
|
1970
|
+
const db = getDb(repoRoot);
|
|
1971
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1972
|
+
for (const wt of gitWorktrees) {
|
|
1973
|
+
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
1974
|
+
}
|
|
1975
|
+
db.close();
|
|
1976
|
+
installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
1977
|
+
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
// ── claim ──────────────────────────────────────────────────────────────────────
|
|
1981
|
+
|
|
1982
|
+
program
|
|
1983
|
+
.command('claim <taskId> <worktree> [files...]')
|
|
1984
|
+
.description('Claim files for a task (warns if conflicts exist)')
|
|
1985
|
+
.option('--agent <name>', 'Agent name')
|
|
1986
|
+
.option('--force', 'Claim even if conflicts exist')
|
|
1987
|
+
.action((taskId, worktree, files, opts) => {
|
|
1988
|
+
if (!files.length) {
|
|
1989
|
+
console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const repoRoot = getRepo();
|
|
1993
|
+
const db = getDb(repoRoot);
|
|
1994
|
+
|
|
1995
|
+
try {
|
|
1996
|
+
const conflicts = checkFileConflicts(db, files, worktree);
|
|
1997
|
+
|
|
1998
|
+
if (conflicts.length > 0 && !opts.force) {
|
|
1999
|
+
console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
|
|
2000
|
+
for (const c of conflicts) {
|
|
2001
|
+
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
2002
|
+
}
|
|
2003
|
+
console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
|
|
2004
|
+
process.exitCode = 1;
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
const lease = claimFiles(db, taskId, worktree, files, opts.agent);
|
|
2009
|
+
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
2010
|
+
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
console.error(chalk.red(err.message));
|
|
2013
|
+
process.exitCode = 1;
|
|
2014
|
+
} finally {
|
|
2015
|
+
db.close();
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
program
|
|
2020
|
+
.command('release <taskId>')
|
|
2021
|
+
.description('Release all file claims for a task')
|
|
2022
|
+
.action((taskId) => {
|
|
2023
|
+
const repoRoot = getRepo();
|
|
2024
|
+
const db = getDb(repoRoot);
|
|
2025
|
+
releaseFileClaims(db, taskId);
|
|
2026
|
+
db.close();
|
|
2027
|
+
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
program
|
|
2031
|
+
.command('write <leaseId> <path>')
|
|
2032
|
+
.description('Write a file through the Switchman enforcement gateway')
|
|
2033
|
+
.requiredOption('--text <content>', 'Replacement file content')
|
|
2034
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2035
|
+
.action((leaseId, path, opts) => {
|
|
2036
|
+
const repoRoot = getRepo();
|
|
2037
|
+
const db = getDb(repoRoot);
|
|
2038
|
+
const result = gatewayWriteFile(db, repoRoot, {
|
|
2039
|
+
leaseId,
|
|
2040
|
+
path,
|
|
2041
|
+
content: opts.text,
|
|
2042
|
+
worktree: opts.worktree || null,
|
|
2043
|
+
});
|
|
2044
|
+
db.close();
|
|
2045
|
+
|
|
2046
|
+
if (!result.ok) {
|
|
2047
|
+
console.log(chalk.red(`✗ Write denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
2048
|
+
process.exitCode = 1;
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
console.log(`${chalk.green('✓')} Wrote ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
program
|
|
2056
|
+
.command('rm <leaseId> <path>')
|
|
2057
|
+
.description('Remove a file or directory through the Switchman enforcement gateway')
|
|
2058
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2059
|
+
.action((leaseId, path, opts) => {
|
|
2060
|
+
const repoRoot = getRepo();
|
|
2061
|
+
const db = getDb(repoRoot);
|
|
2062
|
+
const result = gatewayRemovePath(db, repoRoot, {
|
|
2063
|
+
leaseId,
|
|
2064
|
+
path,
|
|
2065
|
+
worktree: opts.worktree || null,
|
|
2066
|
+
});
|
|
2067
|
+
db.close();
|
|
2068
|
+
|
|
2069
|
+
if (!result.ok) {
|
|
2070
|
+
console.log(chalk.red(`✗ Remove denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
2071
|
+
process.exitCode = 1;
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
program
|
|
2079
|
+
.command('append <leaseId> <path>')
|
|
2080
|
+
.description('Append to a file through the Switchman enforcement gateway')
|
|
2081
|
+
.requiredOption('--text <content>', 'Content to append')
|
|
2082
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2083
|
+
.action((leaseId, path, opts) => {
|
|
2084
|
+
const repoRoot = getRepo();
|
|
2085
|
+
const db = getDb(repoRoot);
|
|
2086
|
+
const result = gatewayAppendFile(db, repoRoot, {
|
|
2087
|
+
leaseId,
|
|
2088
|
+
path,
|
|
2089
|
+
content: opts.text,
|
|
2090
|
+
worktree: opts.worktree || null,
|
|
2091
|
+
});
|
|
2092
|
+
db.close();
|
|
2093
|
+
|
|
2094
|
+
if (!result.ok) {
|
|
2095
|
+
console.log(chalk.red(`✗ Append denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
2096
|
+
process.exitCode = 1;
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
console.log(`${chalk.green('✓')} Appended to ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
program
|
|
2104
|
+
.command('mv <leaseId> <sourcePath> <destinationPath>')
|
|
2105
|
+
.description('Move a file through the Switchman enforcement gateway')
|
|
2106
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2107
|
+
.action((leaseId, sourcePath, destinationPath, opts) => {
|
|
2108
|
+
const repoRoot = getRepo();
|
|
2109
|
+
const db = getDb(repoRoot);
|
|
2110
|
+
const result = gatewayMovePath(db, repoRoot, {
|
|
2111
|
+
leaseId,
|
|
2112
|
+
sourcePath,
|
|
2113
|
+
destinationPath,
|
|
2114
|
+
worktree: opts.worktree || null,
|
|
2115
|
+
});
|
|
2116
|
+
db.close();
|
|
2117
|
+
|
|
2118
|
+
if (!result.ok) {
|
|
2119
|
+
console.log(chalk.red(`✗ Move denied for ${chalk.cyan(result.file_path || destinationPath)} ${chalk.dim(result.reason_code)}`));
|
|
2120
|
+
process.exitCode = 1;
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
console.log(`${chalk.green('✓')} Moved ${chalk.cyan(result.source_path)} → ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
program
|
|
2128
|
+
.command('mkdir <leaseId> <path>')
|
|
2129
|
+
.description('Create a directory through the Switchman enforcement gateway')
|
|
2130
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2131
|
+
.action((leaseId, path, opts) => {
|
|
2132
|
+
const repoRoot = getRepo();
|
|
2133
|
+
const db = getDb(repoRoot);
|
|
2134
|
+
const result = gatewayMakeDirectory(db, repoRoot, {
|
|
2135
|
+
leaseId,
|
|
2136
|
+
path,
|
|
2137
|
+
worktree: opts.worktree || null,
|
|
2138
|
+
});
|
|
2139
|
+
db.close();
|
|
2140
|
+
|
|
2141
|
+
if (!result.ok) {
|
|
2142
|
+
console.log(chalk.red(`✗ Mkdir denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
2143
|
+
process.exitCode = 1;
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
console.log(`${chalk.green('✓')} Created ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
program
|
|
2151
|
+
.command('wrap <leaseId> <command...>')
|
|
2152
|
+
.description('Launch a CLI tool under an active Switchman lease with enforcement context env vars')
|
|
2153
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
2154
|
+
.option('--cwd <path>', 'Override working directory for the wrapped command')
|
|
2155
|
+
.action((leaseId, commandParts, opts) => {
|
|
2156
|
+
const repoRoot = getRepo();
|
|
2157
|
+
const db = getDb(repoRoot);
|
|
2158
|
+
const [command, ...args] = commandParts;
|
|
2159
|
+
const result = runWrappedCommand(db, repoRoot, {
|
|
2160
|
+
leaseId,
|
|
2161
|
+
command,
|
|
2162
|
+
args,
|
|
2163
|
+
worktree: opts.worktree || null,
|
|
2164
|
+
cwd: opts.cwd || null,
|
|
2165
|
+
});
|
|
2166
|
+
db.close();
|
|
2167
|
+
|
|
2168
|
+
if (!result.ok) {
|
|
2169
|
+
console.log(chalk.red(`✗ Wrapped command denied ${chalk.dim(result.reason_code || 'wrapped_command_failed')}`));
|
|
2170
|
+
process.exitCode = 1;
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
console.log(`${chalk.green('✓')} Wrapped command completed under lease ${chalk.dim(result.lease_id)}`);
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
// ── scan ───────────────────────────────────────────────────────────────────────
|
|
2178
|
+
|
|
2179
|
+
program
|
|
2180
|
+
.command('scan')
|
|
2181
|
+
.description('Scan all workspaces for conflicts')
|
|
2182
|
+
.option('--json', 'Output raw JSON')
|
|
2183
|
+
.option('--quiet', 'Only show conflicts')
|
|
2184
|
+
.action(async (opts) => {
|
|
2185
|
+
const repoRoot = getRepo();
|
|
2186
|
+
const db = getDb(repoRoot);
|
|
2187
|
+
const spinner = ora('Scanning workspaces for conflicts...').start();
|
|
2188
|
+
|
|
2189
|
+
try {
|
|
2190
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
2191
|
+
db.close();
|
|
2192
|
+
spinner.stop();
|
|
2193
|
+
|
|
2194
|
+
if (opts.json) {
|
|
2195
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
console.log('');
|
|
2200
|
+
console.log(chalk.bold(`Conflict Scan Report`));
|
|
2201
|
+
console.log(chalk.dim(`${report.scannedAt}`));
|
|
2202
|
+
console.log('');
|
|
2203
|
+
|
|
2204
|
+
// Worktrees summary
|
|
2205
|
+
if (!opts.quiet) {
|
|
2206
|
+
console.log(chalk.bold('Worktrees:'));
|
|
2207
|
+
for (const wt of report.worktrees) {
|
|
2208
|
+
const files = report.fileMap?.[wt.name] || [];
|
|
2209
|
+
const compliance = report.worktreeCompliance?.find((entry) => entry.worktree === wt.name)?.compliance_state || wt.compliance_state || 'observed';
|
|
2210
|
+
console.log(` ${chalk.cyan(wt.name.padEnd(20))} ${statusBadge(compliance)} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
|
|
2211
|
+
}
|
|
2212
|
+
console.log('');
|
|
742
2213
|
}
|
|
2214
|
+
|
|
2215
|
+
// File-level overlaps (uncommitted)
|
|
2216
|
+
if (report.fileConflicts.length > 0) {
|
|
2217
|
+
console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
|
|
2218
|
+
for (const fc of report.fileConflicts) {
|
|
2219
|
+
console.log(` ${chalk.yellow(fc.file)}`);
|
|
2220
|
+
console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
|
|
2221
|
+
}
|
|
2222
|
+
console.log('');
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if ((report.ownershipConflicts?.length || 0) > 0) {
|
|
2226
|
+
console.log(chalk.yellow(`⚠ Ownership boundary overlaps detected:`));
|
|
2227
|
+
for (const conflict of report.ownershipConflicts) {
|
|
2228
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
2229
|
+
console.log(` ${chalk.yellow(`subsystem:${conflict.subsystemTag}`)}`);
|
|
2230
|
+
console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
2231
|
+
} else {
|
|
2232
|
+
console.log(` ${chalk.yellow(conflict.scopeA)}`);
|
|
2233
|
+
console.log(` ${chalk.dim('overlaps with:')} ${conflict.scopeB}`);
|
|
2234
|
+
console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
console.log('');
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if ((report.semanticConflicts?.length || 0) > 0) {
|
|
2241
|
+
console.log(chalk.yellow(`⚠ Semantic overlaps detected:`));
|
|
2242
|
+
for (const conflict of report.semanticConflicts) {
|
|
2243
|
+
console.log(` ${chalk.yellow(conflict.object_name)}`);
|
|
2244
|
+
console.log(` ${chalk.dim('changed by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
2245
|
+
console.log(` ${chalk.dim('files:')} ${conflict.fileA} ↔ ${conflict.fileB}`);
|
|
2246
|
+
}
|
|
2247
|
+
console.log('');
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// Branch-level conflicts
|
|
2251
|
+
if (report.conflicts.length > 0) {
|
|
2252
|
+
console.log(chalk.red(`✗ Branch conflicts detected:`));
|
|
2253
|
+
for (const c of report.conflicts) {
|
|
2254
|
+
const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
|
|
2255
|
+
console.log(` ${icon}`);
|
|
2256
|
+
console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
|
|
2257
|
+
if (c.conflictingFiles.length) {
|
|
2258
|
+
console.log(` Conflicting files:`);
|
|
2259
|
+
c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
console.log('');
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (report.unclaimedChanges.length > 0) {
|
|
2266
|
+
console.log(chalk.red(`✗ Unclaimed or unmanaged changed files detected:`));
|
|
2267
|
+
for (const entry of report.unclaimedChanges) {
|
|
2268
|
+
console.log(` ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.lease_id || 'no active lease')}`);
|
|
2269
|
+
entry.files.forEach((file) => {
|
|
2270
|
+
const reason = entry.reasons.find((item) => item.file === file)?.reason_code || 'path_not_claimed';
|
|
2271
|
+
const nextStep = nextStepForReason(reason);
|
|
2272
|
+
console.log(` ${chalk.yellow(file)} ${chalk.dim(humanizeReasonCode(reason))}${nextStep ? ` ${chalk.dim(`— ${nextStep}`)}` : ''}`);
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
console.log('');
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// All clear
|
|
2279
|
+
if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
|
|
2280
|
+
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} workspace(s)`));
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
spinner.fail(err.message);
|
|
2285
|
+
db.close();
|
|
2286
|
+
process.exit(1);
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
// ── status ─────────────────────────────────────────────────────────────────────
|
|
2291
|
+
|
|
2292
|
+
program
|
|
2293
|
+
.command('status')
|
|
2294
|
+
.description('Show one dashboard view of what is running, blocked, and ready next')
|
|
2295
|
+
.option('--json', 'Output raw JSON')
|
|
2296
|
+
.option('--watch', 'Keep refreshing status in the terminal')
|
|
2297
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
|
|
2298
|
+
.option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
|
|
2299
|
+
.action(async (opts) => {
|
|
2300
|
+
const repoRoot = getRepo();
|
|
2301
|
+
const watch = Boolean(opts.watch);
|
|
2302
|
+
const watchIntervalMs = Math.max(100, Number.parseInt(opts.watchIntervalMs, 10) || 2000);
|
|
2303
|
+
const maxCycles = Math.max(0, Number.parseInt(opts.maxCycles, 10) || 0);
|
|
2304
|
+
let cycles = 0;
|
|
2305
|
+
|
|
2306
|
+
while (true) {
|
|
2307
|
+
if (watch && process.stdout.isTTY && !opts.json) {
|
|
2308
|
+
console.clear();
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const report = await collectStatusSnapshot(repoRoot);
|
|
2312
|
+
cycles += 1;
|
|
2313
|
+
|
|
2314
|
+
if (opts.json) {
|
|
2315
|
+
console.log(JSON.stringify(watch ? { ...report, watch: true, cycles } : report, null, 2));
|
|
2316
|
+
} else {
|
|
2317
|
+
renderUnifiedStatusReport(report);
|
|
2318
|
+
if (watch) {
|
|
2319
|
+
console.log('');
|
|
2320
|
+
console.log(chalk.dim(`Watching every ${watchIntervalMs}ms${maxCycles > 0 ? ` • cycle ${cycles}/${maxCycles}` : ''}`));
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
if (!watch) break;
|
|
2325
|
+
if (maxCycles > 0 && cycles >= maxCycles) break;
|
|
2326
|
+
if (opts.json) break;
|
|
2327
|
+
sleepSync(watchIntervalMs);
|
|
2328
|
+
}
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
program
|
|
2332
|
+
.command('doctor')
|
|
2333
|
+
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
|
|
2334
|
+
.option('--json', 'Output raw JSON')
|
|
2335
|
+
.action(async (opts) => {
|
|
2336
|
+
const repoRoot = getRepo();
|
|
2337
|
+
const db = getDb(repoRoot);
|
|
2338
|
+
const tasks = listTasks(db);
|
|
2339
|
+
const activeLeases = listLeases(db, 'active');
|
|
2340
|
+
const staleLeases = getStaleLeases(db);
|
|
2341
|
+
const scanReport = await scanAllWorktrees(db, repoRoot);
|
|
2342
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
2343
|
+
const report = buildDoctorReport({
|
|
2344
|
+
db,
|
|
2345
|
+
repoRoot,
|
|
2346
|
+
tasks,
|
|
2347
|
+
activeLeases,
|
|
2348
|
+
staleLeases,
|
|
2349
|
+
scanReport,
|
|
2350
|
+
aiGate,
|
|
2351
|
+
});
|
|
2352
|
+
db.close();
|
|
2353
|
+
|
|
2354
|
+
if (opts.json) {
|
|
2355
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2356
|
+
return;
|
|
743
2357
|
}
|
|
744
2358
|
|
|
745
|
-
|
|
2359
|
+
const badge = report.health === 'healthy'
|
|
2360
|
+
? chalk.green('HEALTHY')
|
|
2361
|
+
: report.health === 'warn'
|
|
2362
|
+
? chalk.yellow('ATTENTION')
|
|
2363
|
+
: chalk.red('BLOCKED');
|
|
2364
|
+
console.log(`${badge} ${report.summary}`);
|
|
2365
|
+
console.log(chalk.dim(repoRoot));
|
|
2366
|
+
console.log('');
|
|
2367
|
+
|
|
2368
|
+
console.log(chalk.bold('At a glance:'));
|
|
2369
|
+
console.log(` ${chalk.dim('tasks')} ${report.counts.pending} pending, ${report.counts.in_progress} in progress, ${report.counts.done} done, ${report.counts.failed} failed`);
|
|
2370
|
+
console.log(` ${chalk.dim('leases')} ${report.counts.active_leases} active, ${report.counts.stale_leases} stale`);
|
|
2371
|
+
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}`);
|
|
2372
|
+
|
|
2373
|
+
if (report.active_work.length > 0) {
|
|
746
2374
|
console.log('');
|
|
747
|
-
console.log(chalk.bold('
|
|
748
|
-
for (const
|
|
749
|
-
|
|
2375
|
+
console.log(chalk.bold('Running now:'));
|
|
2376
|
+
for (const item of report.active_work.slice(0, 5)) {
|
|
2377
|
+
const leaseId = activeLeases.find((lease) => lease.task_id === item.task_id && lease.worktree === item.worktree)?.id || null;
|
|
2378
|
+
const boundary = item.boundary_validation
|
|
2379
|
+
? ` ${chalk.dim(`validation:${item.boundary_validation.status}`)}`
|
|
2380
|
+
: '';
|
|
2381
|
+
const stale = (item.dependency_invalidations?.length || 0) > 0
|
|
2382
|
+
? ` ${chalk.dim(`stale:${item.dependency_invalidations.length}`)}`
|
|
2383
|
+
: '';
|
|
2384
|
+
console.log(` ${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}`);
|
|
750
2385
|
}
|
|
751
2386
|
}
|
|
752
2387
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
2388
|
+
console.log('');
|
|
2389
|
+
console.log(chalk.bold('Attention now:'));
|
|
2390
|
+
if (report.attention.length === 0) {
|
|
2391
|
+
console.log(` ${chalk.green('Nothing urgent.')}`);
|
|
2392
|
+
} else {
|
|
2393
|
+
for (const item of report.attention.slice(0, 6)) {
|
|
2394
|
+
const itemBadge = item.severity === 'block' ? chalk.red('block') : chalk.yellow('warn ');
|
|
2395
|
+
console.log(` ${itemBadge} ${item.title}`);
|
|
2396
|
+
if (item.detail) console.log(` ${chalk.dim(item.detail)}`);
|
|
2397
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
2398
|
+
if (item.command) console.log(` ${chalk.cyan('run:')} ${item.command}`);
|
|
759
2399
|
}
|
|
760
2400
|
}
|
|
761
2401
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
2402
|
+
console.log('');
|
|
2403
|
+
console.log(chalk.bold('Recommended next steps:'));
|
|
2404
|
+
for (const step of report.next_steps) {
|
|
2405
|
+
console.log(` - ${step}`);
|
|
2406
|
+
}
|
|
2407
|
+
if (report.suggested_commands.length > 0) {
|
|
765
2408
|
console.log('');
|
|
766
|
-
console.log(chalk.bold(
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
|
|
770
|
-
byWorktree[c.worktree].push(c.file_path);
|
|
2409
|
+
console.log(chalk.bold('Suggested commands:'));
|
|
2410
|
+
for (const command of report.suggested_commands) {
|
|
2411
|
+
console.log(` ${chalk.cyan(command)}`);
|
|
771
2412
|
}
|
|
772
|
-
|
|
773
|
-
|
|
2413
|
+
}
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
2417
|
+
|
|
2418
|
+
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
2419
|
+
|
|
2420
|
+
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
2421
|
+
|
|
2422
|
+
auditCmd
|
|
2423
|
+
.command('verify')
|
|
2424
|
+
.description('Verify the audit log hash chain and project signatures')
|
|
2425
|
+
.option('--json', 'Output verification details as JSON')
|
|
2426
|
+
.action((options) => {
|
|
2427
|
+
const repo = getRepo();
|
|
2428
|
+
const db = getDb(repo);
|
|
2429
|
+
const result = verifyAuditTrail(db);
|
|
2430
|
+
|
|
2431
|
+
if (options.json) {
|
|
2432
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2433
|
+
process.exit(result.ok ? 0 : 1);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
if (result.ok) {
|
|
2437
|
+
console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
|
|
2442
|
+
for (const failure of result.failures.slice(0, 10)) {
|
|
2443
|
+
const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
|
|
2444
|
+
console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
|
|
2445
|
+
}
|
|
2446
|
+
if (result.failures.length > 10) {
|
|
2447
|
+
console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
|
|
2448
|
+
}
|
|
2449
|
+
process.exit(1);
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
gateCmd
|
|
2453
|
+
.command('commit')
|
|
2454
|
+
.description('Validate current worktree changes against the active lease and claims')
|
|
2455
|
+
.option('--json', 'Output raw JSON')
|
|
2456
|
+
.action((opts) => {
|
|
2457
|
+
const repoRoot = getRepo();
|
|
2458
|
+
const db = getDb(repoRoot);
|
|
2459
|
+
const result = runCommitGate(db, repoRoot);
|
|
2460
|
+
db.close();
|
|
2461
|
+
|
|
2462
|
+
if (opts.json) {
|
|
2463
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2464
|
+
} else if (result.ok) {
|
|
2465
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
2466
|
+
} else {
|
|
2467
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
2468
|
+
for (const violation of result.violations) {
|
|
2469
|
+
const label = violation.file || '(worktree)';
|
|
2470
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
774
2471
|
}
|
|
775
2472
|
}
|
|
776
2473
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const spinner = ora('Running conflict scan...').start();
|
|
780
|
-
try {
|
|
781
|
-
const report = await scanAllWorktrees(db, repoRoot);
|
|
782
|
-
spinner.stop();
|
|
2474
|
+
if (!result.ok) process.exitCode = 1;
|
|
2475
|
+
});
|
|
783
2476
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
2477
|
+
gateCmd
|
|
2478
|
+
.command('merge')
|
|
2479
|
+
.description('Validate current worktree changes before recording a merge commit')
|
|
2480
|
+
.option('--json', 'Output raw JSON')
|
|
2481
|
+
.action((opts) => {
|
|
2482
|
+
const repoRoot = getRepo();
|
|
2483
|
+
const db = getDb(repoRoot);
|
|
2484
|
+
const result = runCommitGate(db, repoRoot);
|
|
2485
|
+
db.close();
|
|
2486
|
+
|
|
2487
|
+
if (opts.json) {
|
|
2488
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2489
|
+
} else if (result.ok) {
|
|
2490
|
+
console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
|
|
2491
|
+
} else {
|
|
2492
|
+
console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
|
|
2493
|
+
for (const violation of result.violations) {
|
|
2494
|
+
const label = violation.file || '(worktree)';
|
|
2495
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (!result.ok) process.exitCode = 1;
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
gateCmd
|
|
2503
|
+
.command('install')
|
|
2504
|
+
.description('Install git hooks that run the Switchman commit and merge gates')
|
|
2505
|
+
.action(() => {
|
|
2506
|
+
const repoRoot = getRepo();
|
|
2507
|
+
const hookPaths = installGateHooks(repoRoot);
|
|
2508
|
+
console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
|
|
2509
|
+
console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
gateCmd
|
|
2513
|
+
.command('ci')
|
|
2514
|
+
.description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
|
|
2515
|
+
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
2516
|
+
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
2517
|
+
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
2518
|
+
.option('--json', 'Output raw JSON')
|
|
2519
|
+
.action(async (opts) => {
|
|
2520
|
+
const repoRoot = getRepo();
|
|
2521
|
+
const db = getDb(repoRoot);
|
|
2522
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
2523
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
2524
|
+
db.close();
|
|
2525
|
+
|
|
2526
|
+
const ok = report.conflicts.length === 0
|
|
2527
|
+
&& report.fileConflicts.length === 0
|
|
2528
|
+
&& (report.ownershipConflicts?.length || 0) === 0
|
|
2529
|
+
&& (report.semanticConflicts?.length || 0) === 0
|
|
2530
|
+
&& report.unclaimedChanges.length === 0
|
|
2531
|
+
&& report.complianceSummary.non_compliant === 0
|
|
2532
|
+
&& report.complianceSummary.stale === 0
|
|
2533
|
+
&& aiGate.status !== 'blocked'
|
|
2534
|
+
&& (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
|
|
2535
|
+
|
|
2536
|
+
const result = {
|
|
2537
|
+
ok,
|
|
2538
|
+
summary: ok
|
|
2539
|
+
? `Repo gate passed for ${report.worktrees.length} worktree(s).`
|
|
2540
|
+
: 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
|
|
2541
|
+
compliance: report.complianceSummary,
|
|
2542
|
+
unclaimed_changes: report.unclaimedChanges,
|
|
2543
|
+
file_conflicts: report.fileConflicts,
|
|
2544
|
+
ownership_conflicts: report.ownershipConflicts || [],
|
|
2545
|
+
semantic_conflicts: report.semanticConflicts || [],
|
|
2546
|
+
branch_conflicts: report.conflicts,
|
|
2547
|
+
ai_gate_status: aiGate.status,
|
|
2548
|
+
boundary_validations: aiGate.boundary_validations || [],
|
|
2549
|
+
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
2553
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
2554
|
+
writeGitHubCiStatus({
|
|
2555
|
+
result,
|
|
2556
|
+
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
2557
|
+
outputPath: githubTargets.outputPath,
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
if (opts.json) {
|
|
2562
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2563
|
+
} else if (ok) {
|
|
2564
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
2565
|
+
} else {
|
|
2566
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
2567
|
+
if (result.unclaimed_changes.length > 0) {
|
|
2568
|
+
console.log(chalk.bold(' Unclaimed changes:'));
|
|
2569
|
+
for (const entry of result.unclaimed_changes) {
|
|
2570
|
+
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (result.file_conflicts.length > 0) {
|
|
2574
|
+
console.log(chalk.bold(' File conflicts:'));
|
|
2575
|
+
for (const conflict of result.file_conflicts) {
|
|
2576
|
+
console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
if (result.ownership_conflicts.length > 0) {
|
|
2580
|
+
console.log(chalk.bold(' Ownership conflicts:'));
|
|
2581
|
+
for (const conflict of result.ownership_conflicts) {
|
|
2582
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
2583
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
|
|
2584
|
+
} else {
|
|
2585
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
if (result.semantic_conflicts.length > 0) {
|
|
2590
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
2591
|
+
for (const conflict of result.semantic_conflicts) {
|
|
2592
|
+
console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
if (result.branch_conflicts.length > 0) {
|
|
2596
|
+
console.log(chalk.bold(' Branch conflicts:'));
|
|
2597
|
+
for (const conflict of result.branch_conflicts) {
|
|
2598
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (result.boundary_validations.length > 0) {
|
|
2602
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
2603
|
+
for (const validation of result.boundary_validations) {
|
|
2604
|
+
console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (result.dependency_invalidations.length > 0) {
|
|
2608
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
2609
|
+
for (const invalidation of result.dependency_invalidations) {
|
|
2610
|
+
console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
|
|
2611
|
+
}
|
|
789
2612
|
}
|
|
790
|
-
} catch {
|
|
791
|
-
spinner.stop();
|
|
792
|
-
console.log(chalk.dim('Could not run conflict scan'));
|
|
793
2613
|
}
|
|
794
2614
|
|
|
2615
|
+
if (!ok) process.exitCode = 1;
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
gateCmd
|
|
2619
|
+
.command('install-ci')
|
|
2620
|
+
.description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
|
|
2621
|
+
.option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
|
|
2622
|
+
.action((opts) => {
|
|
2623
|
+
const repoRoot = getRepo();
|
|
2624
|
+
const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
|
|
2625
|
+
console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2628
|
+
gateCmd
|
|
2629
|
+
.command('ai')
|
|
2630
|
+
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
2631
|
+
.option('--json', 'Output raw JSON')
|
|
2632
|
+
.action(async (opts) => {
|
|
2633
|
+
const repoRoot = getRepo();
|
|
2634
|
+
const db = getDb(repoRoot);
|
|
2635
|
+
const result = await runAiMergeGate(db, repoRoot);
|
|
795
2636
|
db.close();
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
2637
|
+
|
|
2638
|
+
if (opts.json) {
|
|
2639
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2640
|
+
} else {
|
|
2641
|
+
const badge = result.status === 'pass'
|
|
2642
|
+
? chalk.green('PASS')
|
|
2643
|
+
: result.status === 'warn'
|
|
2644
|
+
? chalk.yellow('WARN')
|
|
2645
|
+
: chalk.red('BLOCK');
|
|
2646
|
+
console.log(`${badge} ${result.summary}`);
|
|
2647
|
+
|
|
2648
|
+
const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
|
|
2649
|
+
if (riskyPairs.length > 0) {
|
|
2650
|
+
console.log(chalk.bold(' Risky pairs:'));
|
|
2651
|
+
for (const pair of riskyPairs) {
|
|
2652
|
+
console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
|
|
2653
|
+
for (const reason of pair.reasons.slice(0, 3)) {
|
|
2654
|
+
console.log(` ${chalk.yellow(reason)}`);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
if ((result.boundary_validations?.length || 0) > 0) {
|
|
2660
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
2661
|
+
for (const validation of result.boundary_validations.slice(0, 5)) {
|
|
2662
|
+
console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
2663
|
+
if (validation.rationale?.[0]) {
|
|
2664
|
+
console.log(` ${chalk.yellow(validation.rationale[0])}`);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
if ((result.dependency_invalidations?.length || 0) > 0) {
|
|
2670
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
2671
|
+
for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
|
|
2672
|
+
console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if ((result.semantic_conflicts?.length || 0) > 0) {
|
|
2677
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
2678
|
+
for (const conflict of result.semantic_conflicts.slice(0, 5)) {
|
|
2679
|
+
console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
|
|
2684
|
+
if (riskyWorktrees.length > 0) {
|
|
2685
|
+
console.log(chalk.bold(' Worktree signals:'));
|
|
2686
|
+
for (const worktree of riskyWorktrees) {
|
|
2687
|
+
console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
|
|
2688
|
+
for (const finding of worktree.findings.slice(0, 2)) {
|
|
2689
|
+
console.log(` ${chalk.yellow(finding)}`);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
if (result.status === 'blocked') process.exitCode = 1;
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
const semanticCmd = program
|
|
2699
|
+
.command('semantic')
|
|
2700
|
+
.description('Inspect or materialize the derived semantic code-object view');
|
|
2701
|
+
|
|
2702
|
+
semanticCmd
|
|
2703
|
+
.command('materialize')
|
|
2704
|
+
.description('Write a deterministic semantic index artifact to .switchman/semantic-index.json')
|
|
2705
|
+
.action(() => {
|
|
2706
|
+
const repoRoot = getRepo();
|
|
2707
|
+
const db = getDb(repoRoot);
|
|
2708
|
+
const worktrees = listWorktrees(db);
|
|
2709
|
+
const result = materializeSemanticIndex(repoRoot, { worktrees });
|
|
2710
|
+
db.close();
|
|
2711
|
+
console.log(`${chalk.green('✓')} Wrote semantic index to ${chalk.cyan(result.output_path)}`);
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
const objectCmd = program
|
|
2715
|
+
.command('object')
|
|
2716
|
+
.description('Experimental object-source mode backed by canonical exported code objects');
|
|
2717
|
+
|
|
2718
|
+
objectCmd
|
|
2719
|
+
.command('import')
|
|
2720
|
+
.description('Import exported code objects from tracked source files into the canonical object store')
|
|
2721
|
+
.option('--json', 'Output raw JSON')
|
|
2722
|
+
.action((opts) => {
|
|
2723
|
+
const repoRoot = getRepo();
|
|
2724
|
+
const db = getDb(repoRoot);
|
|
2725
|
+
const objects = importCodeObjectsToStore(db, repoRoot);
|
|
2726
|
+
db.close();
|
|
2727
|
+
if (opts.json) {
|
|
2728
|
+
console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
console.log(`${chalk.green('✓')} Imported ${objects.length} code object(s) into the canonical store`);
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
objectCmd
|
|
2735
|
+
.command('list')
|
|
2736
|
+
.description('List canonical code objects currently stored in Switchman')
|
|
2737
|
+
.option('--json', 'Output raw JSON')
|
|
2738
|
+
.action((opts) => {
|
|
2739
|
+
const repoRoot = getRepo();
|
|
2740
|
+
const db = getDb(repoRoot);
|
|
2741
|
+
const objects = listCodeObjects(db);
|
|
2742
|
+
db.close();
|
|
2743
|
+
if (opts.json) {
|
|
2744
|
+
console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
if (objects.length === 0) {
|
|
2748
|
+
console.log(chalk.dim('No canonical code objects stored yet. Run `switchman object import` first.'));
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
for (const object of objects) {
|
|
2752
|
+
console.log(`${chalk.cyan(object.object_id)} ${chalk.dim(`${object.file_path} ${object.kind}`)}`);
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
objectCmd
|
|
2757
|
+
.command('update <objectId>')
|
|
2758
|
+
.description('Update the canonical source text for a stored code object')
|
|
2759
|
+
.requiredOption('--text <source>', 'Replacement exported source text')
|
|
2760
|
+
.option('--json', 'Output raw JSON')
|
|
2761
|
+
.action((objectId, opts) => {
|
|
2762
|
+
const repoRoot = getRepo();
|
|
2763
|
+
const db = getDb(repoRoot);
|
|
2764
|
+
const object = updateCodeObjectSource(db, objectId, opts.text);
|
|
2765
|
+
db.close();
|
|
2766
|
+
if (!object) {
|
|
2767
|
+
console.error(chalk.red(`Unknown code object: ${objectId}`));
|
|
2768
|
+
process.exitCode = 1;
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (opts.json) {
|
|
2772
|
+
console.log(JSON.stringify({ object }, null, 2));
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
console.log(`${chalk.green('✓')} Updated ${chalk.cyan(object.object_id)} in the canonical object store`);
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
objectCmd
|
|
2779
|
+
.command('materialize')
|
|
2780
|
+
.description('Materialize source files from the canonical object store')
|
|
2781
|
+
.option('--output-root <path>', 'Alternate root directory to write materialized files into')
|
|
2782
|
+
.option('--json', 'Output raw JSON')
|
|
2783
|
+
.action((opts) => {
|
|
2784
|
+
const repoRoot = getRepo();
|
|
2785
|
+
const db = getDb(repoRoot);
|
|
2786
|
+
const result = materializeCodeObjects(db, repoRoot, { outputRoot: opts.outputRoot || repoRoot });
|
|
2787
|
+
db.close();
|
|
2788
|
+
if (opts.json) {
|
|
2789
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
console.log(`${chalk.green('✓')} Materialized ${result.file_count} file(s) from the canonical object store`);
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
2796
|
+
|
|
2797
|
+
const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
|
|
2798
|
+
|
|
2799
|
+
monitorCmd
|
|
2800
|
+
.command('once')
|
|
2801
|
+
.description('Capture one monitoring pass and log observed file changes')
|
|
2802
|
+
.option('--json', 'Output raw JSON')
|
|
2803
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2804
|
+
.action((opts) => {
|
|
2805
|
+
const repoRoot = getRepo();
|
|
2806
|
+
const db = getDb(repoRoot);
|
|
2807
|
+
const worktrees = listGitWorktrees(repoRoot);
|
|
2808
|
+
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
2809
|
+
db.close();
|
|
2810
|
+
|
|
2811
|
+
if (opts.json) {
|
|
2812
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
if (result.events.length === 0) {
|
|
2817
|
+
console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
|
|
2822
|
+
for (const event of result.events) {
|
|
2823
|
+
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
2824
|
+
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
2825
|
+
console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
monitorCmd
|
|
2830
|
+
.command('watch')
|
|
2831
|
+
.description('Poll workspaces continuously and log observed file changes')
|
|
2832
|
+
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2833
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2834
|
+
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
2835
|
+
.action(async (opts) => {
|
|
2836
|
+
const repoRoot = getRepo();
|
|
2837
|
+
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
2838
|
+
|
|
2839
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 100) {
|
|
2840
|
+
console.error(chalk.red('--interval-ms must be at least 100'));
|
|
2841
|
+
process.exit(1);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
2845
|
+
|
|
2846
|
+
let stopped = false;
|
|
2847
|
+
const stop = () => {
|
|
2848
|
+
stopped = true;
|
|
2849
|
+
process.stdout.write('\n');
|
|
2850
|
+
if (opts.daemonized) {
|
|
2851
|
+
clearMonitorState(repoRoot);
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
process.on('SIGINT', stop);
|
|
2855
|
+
process.on('SIGTERM', stop);
|
|
2856
|
+
|
|
2857
|
+
while (!stopped) {
|
|
2858
|
+
const db = getDb(repoRoot);
|
|
2859
|
+
const worktrees = listGitWorktrees(repoRoot);
|
|
2860
|
+
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
2861
|
+
db.close();
|
|
2862
|
+
|
|
2863
|
+
for (const event of result.events) {
|
|
2864
|
+
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
2865
|
+
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
2866
|
+
console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
if (stopped) break;
|
|
2870
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
console.log(chalk.dim('Stopped worktree monitor.'));
|
|
2874
|
+
});
|
|
2875
|
+
|
|
2876
|
+
monitorCmd
|
|
2877
|
+
.command('start')
|
|
2878
|
+
.description('Start the worktree monitor as a background process')
|
|
2879
|
+
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2880
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2881
|
+
.action((opts) => {
|
|
2882
|
+
const repoRoot = getRepo();
|
|
2883
|
+
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
2884
|
+
const existingState = readMonitorState(repoRoot);
|
|
2885
|
+
|
|
2886
|
+
if (existingState && isProcessRunning(existingState.pid)) {
|
|
2887
|
+
console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
const logPath = join(repoRoot, '.switchman', 'monitor.log');
|
|
2892
|
+
const child = spawn(process.execPath, [
|
|
2893
|
+
process.argv[1],
|
|
2894
|
+
'monitor',
|
|
2895
|
+
'watch',
|
|
2896
|
+
'--interval-ms',
|
|
2897
|
+
String(intervalMs),
|
|
2898
|
+
...(opts.quarantine ? ['--quarantine'] : []),
|
|
2899
|
+
'--daemonized',
|
|
2900
|
+
], {
|
|
2901
|
+
cwd: repoRoot,
|
|
2902
|
+
detached: true,
|
|
2903
|
+
stdio: 'ignore',
|
|
2904
|
+
});
|
|
2905
|
+
child.unref();
|
|
2906
|
+
|
|
2907
|
+
const statePath = writeMonitorState(repoRoot, {
|
|
2908
|
+
pid: child.pid,
|
|
2909
|
+
interval_ms: intervalMs,
|
|
2910
|
+
quarantine: Boolean(opts.quarantine),
|
|
2911
|
+
log_path: logPath,
|
|
2912
|
+
started_at: new Date().toISOString(),
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
|
|
2916
|
+
console.log(`${chalk.dim('State:')} ${statePath}`);
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
monitorCmd
|
|
2920
|
+
.command('stop')
|
|
2921
|
+
.description('Stop the background worktree monitor')
|
|
2922
|
+
.action(() => {
|
|
2923
|
+
const repoRoot = getRepo();
|
|
2924
|
+
const state = readMonitorState(repoRoot);
|
|
2925
|
+
|
|
2926
|
+
if (!state) {
|
|
2927
|
+
console.log(chalk.dim('Monitor is not running.'));
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
if (!isProcessRunning(state.pid)) {
|
|
2932
|
+
clearMonitorState(repoRoot);
|
|
2933
|
+
console.log(chalk.dim('Monitor state was stale and has been cleared.'));
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
process.kill(state.pid, 'SIGTERM');
|
|
2938
|
+
clearMonitorState(repoRoot);
|
|
2939
|
+
console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
monitorCmd
|
|
2943
|
+
.command('status')
|
|
2944
|
+
.description('Show background monitor process status')
|
|
2945
|
+
.action(() => {
|
|
2946
|
+
const repoRoot = getRepo();
|
|
2947
|
+
const state = readMonitorState(repoRoot);
|
|
2948
|
+
|
|
2949
|
+
if (!state) {
|
|
2950
|
+
console.log(chalk.dim('Monitor is not running.'));
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
const running = isProcessRunning(state.pid);
|
|
2955
|
+
if (!running) {
|
|
2956
|
+
clearMonitorState(repoRoot);
|
|
2957
|
+
console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
console.log(`${chalk.green('✓')} Monitor running`);
|
|
2962
|
+
console.log(` ${chalk.dim('pid')} ${state.pid}`);
|
|
2963
|
+
console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
|
|
2964
|
+
console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
|
|
2965
|
+
console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
|
|
2966
|
+
});
|
|
2967
|
+
|
|
2968
|
+
// ── policy ───────────────────────────────────────────────────────────────────
|
|
2969
|
+
|
|
2970
|
+
const policyCmd = program.command('policy').description('Manage enforcement policy exceptions');
|
|
2971
|
+
|
|
2972
|
+
policyCmd
|
|
2973
|
+
.command('init')
|
|
2974
|
+
.description('Write a starter enforcement policy file for generated-path exceptions')
|
|
2975
|
+
.action(() => {
|
|
2976
|
+
const repoRoot = getRepo();
|
|
2977
|
+
const policyPath = writeEnforcementPolicy(repoRoot, {
|
|
2978
|
+
allowed_generated_paths: [
|
|
2979
|
+
'dist/**',
|
|
2980
|
+
'build/**',
|
|
2981
|
+
'coverage/**',
|
|
2982
|
+
],
|
|
2983
|
+
});
|
|
2984
|
+
console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
|
|
799
2985
|
});
|
|
800
2986
|
|
|
801
2987
|
program.parse();
|