switchman-dev 0.1.8 → 0.1.9
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 +118 -303
- package/package.json +1 -1
- package/src/cli/commands/audit.js +77 -0
- package/src/cli/commands/claude.js +37 -0
- package/src/cli/commands/gate.js +278 -0
- package/src/cli/commands/lease.js +256 -0
- package/src/cli/commands/mcp.js +45 -0
- package/src/cli/commands/monitor.js +191 -0
- package/src/cli/commands/queue.js +549 -0
- package/src/cli/commands/task.js +248 -0
- package/src/cli/commands/telemetry.js +108 -0
- package/src/cli/commands/worktree.js +85 -0
- package/src/cli/index.js +780 -1697
- package/src.zip +0 -0
- package/tests.zip +0 -0
package/src/cli/index.js
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
import { Help, program } from 'commander';
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
import ora from 'ora';
|
|
22
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
22
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
23
23
|
import { tmpdir } from 'os';
|
|
24
24
|
import { dirname, join, posix } from 'path';
|
|
25
|
-
import { execSync, spawn } from 'child_process';
|
|
25
|
+
import { execSync, spawn, spawnSync } from 'child_process';
|
|
26
26
|
|
|
27
27
|
import { cleanupCrashedLandingTempWorktrees, createGitWorktree, findRepoRoot, getWorktreeBranch, getWorktreeChangedFiles, gitAssessBranchFreshness, gitBranchExists, listGitWorktrees } from '../core/git.js';
|
|
28
28
|
import { matchesPathPatterns } from '../core/ignore.js';
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
|
|
40
40
|
upsertTaskSpec,
|
|
41
41
|
listAuditEvents, pruneDatabaseMaintenance, verifyAuditTrail,
|
|
42
|
+
getLeaseExecutionContext, getActiveLeaseForTask,
|
|
42
43
|
} from '../core/db.js';
|
|
43
44
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
44
45
|
import { ensureProjectLocalMcpGitExcludes, getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
@@ -64,6 +65,16 @@ import {
|
|
|
64
65
|
import { checkLicence, clearCredentials, FREE_AGENT_LIMIT, getRetentionDaysForCurrentPlan, loginWithGitHub, PRO_PAGE_URL, readCredentials } from '../core/licence.js';
|
|
65
66
|
import { homedir } from 'os';
|
|
66
67
|
import { cleanupOldSyncEvents, pullActiveTeamMembers, pullTeamState, pushSyncEvent } from '../core/sync.js';
|
|
68
|
+
import { registerClaudeCommands } from './commands/claude.js';
|
|
69
|
+
import { registerMcpCommands } from './commands/mcp.js';
|
|
70
|
+
import { registerAuditCommands } from './commands/audit.js';
|
|
71
|
+
import { registerGateCommands } from './commands/gate.js';
|
|
72
|
+
import { registerLeaseCommands } from './commands/lease.js';
|
|
73
|
+
import { registerMonitorCommands } from './commands/monitor.js';
|
|
74
|
+
import { registerQueueCommands } from './commands/queue.js';
|
|
75
|
+
import { registerTaskCommands } from './commands/task.js';
|
|
76
|
+
import { registerTelemetryCommands } from './commands/telemetry.js';
|
|
77
|
+
import { registerWorktreeCommands } from './commands/worktree.js';
|
|
67
78
|
|
|
68
79
|
const originalProcessEmit = process.emit.bind(process);
|
|
69
80
|
process.emit = function patchedProcessEmit(event, ...args) {
|
|
@@ -200,7 +211,7 @@ function summarizeRecentCommitContext(branchGoal, subjects) {
|
|
|
200
211
|
return `${effectiveCount} recent ${topicLabel}commit${effectiveCount === 1 ? '' : 's'}`;
|
|
201
212
|
}
|
|
202
213
|
|
|
203
|
-
function collectPlanContext(repoRoot, explicitGoal = null) {
|
|
214
|
+
function collectPlanContext(repoRoot, explicitGoal = null, issueContext = null) {
|
|
204
215
|
const planningFiles = ['CLAUDE.md', 'ROADMAP.md', 'tasks.md', 'TASKS.md', 'TODO.md', 'README.md']
|
|
205
216
|
.map((fileName) => readPlanningFile(repoRoot, fileName))
|
|
206
217
|
.filter(Boolean);
|
|
@@ -217,14 +228,19 @@ function collectPlanContext(repoRoot, explicitGoal = null) {
|
|
|
217
228
|
|| planningByName.get('README.md')
|
|
218
229
|
|| null;
|
|
219
230
|
const planningSignal = preferredPlanningFile ? extractMarkdownSignal(preferredPlanningFile.text) : null;
|
|
220
|
-
const title = capitalizeSentence(explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
|
|
231
|
+
const title = capitalizeSentence(issueContext?.title || explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
|
|
221
232
|
const descriptionParts = [];
|
|
233
|
+
if (issueContext?.description) descriptionParts.push(issueContext.description);
|
|
222
234
|
if (preferredPlanningFile?.text) descriptionParts.push(preferredPlanningFile.text);
|
|
223
235
|
if (recentCommitSubjects.length > 0) descriptionParts.push(`Recent git history summary: ${recentCommitSubjects.slice(0, 3).join('; ')}.`);
|
|
224
236
|
const description = descriptionParts.join('\n\n').trim() || null;
|
|
225
237
|
|
|
226
238
|
const found = [];
|
|
227
239
|
const used = [];
|
|
240
|
+
if (issueContext?.number) {
|
|
241
|
+
found.push(`issue #${issueContext.number} "${issueContext.title}"`);
|
|
242
|
+
used.push(`GitHub issue #${issueContext.number}`);
|
|
243
|
+
}
|
|
228
244
|
if (explicitGoal) {
|
|
229
245
|
used.push('explicit goal');
|
|
230
246
|
}
|
|
@@ -250,6 +266,123 @@ function collectPlanContext(repoRoot, explicitGoal = null) {
|
|
|
250
266
|
};
|
|
251
267
|
}
|
|
252
268
|
|
|
269
|
+
function fetchGitHubIssueContext(repoRoot, issueNumber, ghCommand = 'gh') {
|
|
270
|
+
const normalizedIssueNumber = String(issueNumber || '').trim();
|
|
271
|
+
if (!normalizedIssueNumber) {
|
|
272
|
+
throw new Error('A GitHub issue number is required.');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = spawnSync(ghCommand, [
|
|
276
|
+
'issue',
|
|
277
|
+
'view',
|
|
278
|
+
normalizedIssueNumber,
|
|
279
|
+
'--json',
|
|
280
|
+
'title,body,comments',
|
|
281
|
+
], {
|
|
282
|
+
cwd: repoRoot,
|
|
283
|
+
encoding: 'utf8',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (result.error) {
|
|
287
|
+
throw new Error(`Could not run ${ghCommand} to read issue #${normalizedIssueNumber}. ${result.error.message}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status !== 0) {
|
|
291
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
292
|
+
throw new Error(output || `gh issue view failed for issue #${normalizedIssueNumber}. Make sure gh is installed and authenticated.`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let issue;
|
|
296
|
+
try {
|
|
297
|
+
issue = JSON.parse(result.stdout || '{}');
|
|
298
|
+
} catch {
|
|
299
|
+
throw new Error(`Could not parse GitHub issue #${normalizedIssueNumber}.`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const comments = Array.isArray(issue.comments)
|
|
303
|
+
? issue.comments.map((entry) => entry?.body || '').filter(Boolean)
|
|
304
|
+
: [];
|
|
305
|
+
const descriptionParts = [
|
|
306
|
+
`GitHub issue #${normalizedIssueNumber}: ${issue.title || 'Untitled issue'}`,
|
|
307
|
+
issue.body || '',
|
|
308
|
+
comments.length > 0 ? `Comments:\n${comments.join('\n---\n')}` : '',
|
|
309
|
+
].filter(Boolean);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
number: normalizedIssueNumber,
|
|
313
|
+
title: issue.title || `Issue #${normalizedIssueNumber}`,
|
|
314
|
+
description: descriptionParts.join('\n\n'),
|
|
315
|
+
comment_count: comments.length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildPlanningCommentBody(context, plannedTasks) {
|
|
320
|
+
const lines = [
|
|
321
|
+
'## Switchman plan summary',
|
|
322
|
+
'',
|
|
323
|
+
`Planned from: **${context.title}**`,
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
if (context.used.length > 0) {
|
|
327
|
+
lines.push(`Context used: ${context.used.join(', ')}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lines.push('');
|
|
331
|
+
lines.push('Proposed parallel tasks:');
|
|
332
|
+
lines.push('');
|
|
333
|
+
|
|
334
|
+
plannedTasks.forEach((task, index) => {
|
|
335
|
+
const worktreeLabel = task.suggested_worktree || 'unassigned';
|
|
336
|
+
lines.push(`${index + 1}. ${task.title} (${worktreeLabel})`);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
lines.push('');
|
|
340
|
+
lines.push('_Generated by Switchman._');
|
|
341
|
+
return `${lines.join('\n')}\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function postPlanningSummaryComment(repoRoot, {
|
|
345
|
+
ghCommand = 'gh',
|
|
346
|
+
issueNumber = null,
|
|
347
|
+
prNumber = null,
|
|
348
|
+
body,
|
|
349
|
+
}) {
|
|
350
|
+
const targetNumber = prNumber || issueNumber;
|
|
351
|
+
if (!targetNumber) {
|
|
352
|
+
throw new Error('A GitHub issue or pull request number is required to post a planning summary.');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'switchman-plan-comment-'));
|
|
356
|
+
const bodyPath = join(tempDir, 'plan-summary.md');
|
|
357
|
+
writeFileSync(bodyPath, body, 'utf8');
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const args = prNumber
|
|
361
|
+
? ['pr', 'comment', String(prNumber), '--body-file', bodyPath]
|
|
362
|
+
: ['issue', 'comment', String(issueNumber), '--body-file', bodyPath];
|
|
363
|
+
const result = spawnSync(ghCommand, args, {
|
|
364
|
+
cwd: repoRoot,
|
|
365
|
+
encoding: 'utf8',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (result.error) {
|
|
369
|
+
throw new Error(`Could not run ${ghCommand} to post the planning summary. ${result.error.message}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (result.status !== 0) {
|
|
373
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
374
|
+
throw new Error(output || `gh ${prNumber ? 'pr' : 'issue'} comment failed.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
target_type: prNumber ? 'pr' : 'issue',
|
|
379
|
+
target_number: String(targetNumber),
|
|
380
|
+
};
|
|
381
|
+
} finally {
|
|
382
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
253
386
|
function resolvePlanningWorktrees(repoRoot, db = null) {
|
|
254
387
|
if (db) {
|
|
255
388
|
const registered = listWorktrees(db)
|
|
@@ -1465,6 +1598,184 @@ function printRepairSummary(report, {
|
|
|
1465
1598
|
}
|
|
1466
1599
|
}
|
|
1467
1600
|
|
|
1601
|
+
function summarizeRecoveredTaskState({
|
|
1602
|
+
task,
|
|
1603
|
+
lease = null,
|
|
1604
|
+
worktree = null,
|
|
1605
|
+
changedFiles = [],
|
|
1606
|
+
claims = [],
|
|
1607
|
+
boundaryValidation = null,
|
|
1608
|
+
auditEvents = [],
|
|
1609
|
+
recoveryKind,
|
|
1610
|
+
staleAfterMinutes = null,
|
|
1611
|
+
}) {
|
|
1612
|
+
const observedWrites = auditEvents.filter((event) => event.event_type === 'write_observed');
|
|
1613
|
+
const latestAuditEvent = auditEvents[0] || null;
|
|
1614
|
+
const nextAction = worktree?.path
|
|
1615
|
+
? `cd "${worktree.path}" && git status`
|
|
1616
|
+
: `switchman task retry ${task.id}`;
|
|
1617
|
+
|
|
1618
|
+
return {
|
|
1619
|
+
kind: recoveryKind,
|
|
1620
|
+
task_id: task.id,
|
|
1621
|
+
task_title: task.title,
|
|
1622
|
+
worktree: worktree?.name || lease?.worktree || task.worktree || null,
|
|
1623
|
+
worktree_path: worktree?.path || null,
|
|
1624
|
+
lease_id: lease?.id || null,
|
|
1625
|
+
agent: lease?.agent || task.agent || null,
|
|
1626
|
+
stale_after_minutes: staleAfterMinutes,
|
|
1627
|
+
changed_files: changedFiles,
|
|
1628
|
+
claimed_files: claims,
|
|
1629
|
+
observed_write_count: observedWrites.length,
|
|
1630
|
+
latest_audit_event: latestAuditEvent ? {
|
|
1631
|
+
event_type: latestAuditEvent.event_type,
|
|
1632
|
+
status: latestAuditEvent.status,
|
|
1633
|
+
created_at: latestAuditEvent.created_at,
|
|
1634
|
+
reason_code: latestAuditEvent.reason_code || null,
|
|
1635
|
+
} : null,
|
|
1636
|
+
boundary_validation: boundaryValidation ? {
|
|
1637
|
+
status: boundaryValidation.status,
|
|
1638
|
+
missing_task_types: boundaryValidation.missing_task_types || [],
|
|
1639
|
+
} : null,
|
|
1640
|
+
progress_summary: changedFiles.length > 0
|
|
1641
|
+
? `Observed uncommitted changes in ${changedFiles.length} file(s).`
|
|
1642
|
+
: claims.length > 0
|
|
1643
|
+
? `Lease held ${claims.length} active claim(s) before recovery.`
|
|
1644
|
+
: observedWrites.length > 0
|
|
1645
|
+
? `Observed ${observedWrites.length} governed write event(s) before recovery.`
|
|
1646
|
+
: 'No uncommitted changes were detected at recovery time.',
|
|
1647
|
+
next_action: nextAction,
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function buildRecoverReport(db, repoRoot, { staleAfterMinutes = null, reason = 'operator recover' } = {}) {
|
|
1652
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
1653
|
+
const staleMinutes = staleAfterMinutes
|
|
1654
|
+
? Number.parseInt(staleAfterMinutes, 10)
|
|
1655
|
+
: leasePolicy.stale_after_minutes;
|
|
1656
|
+
const worktreeMap = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
|
|
1657
|
+
const staleLeases = getStaleLeases(db, staleMinutes);
|
|
1658
|
+
const staleTaskIds = new Set(staleLeases.map((lease) => lease.task_id));
|
|
1659
|
+
const staleLeaseSummaries = staleLeases.map((lease) => {
|
|
1660
|
+
const execution = getLeaseExecutionContext(db, lease.id);
|
|
1661
|
+
const worktree = worktreeMap.get(lease.worktree) || execution?.worktree || null;
|
|
1662
|
+
const changedFiles = worktree?.path ? getWorktreeChangedFiles(worktree.path, repoRoot) : [];
|
|
1663
|
+
const claims = execution?.claims?.map((claim) => claim.file_path) || [];
|
|
1664
|
+
const boundaryValidation = getBoundaryValidationState(db, lease.id);
|
|
1665
|
+
const auditEvents = listAuditEvents(db, { taskId: lease.task_id, limit: 10 });
|
|
1666
|
+
return summarizeRecoveredTaskState({
|
|
1667
|
+
task: execution?.task || getTask(db, lease.task_id),
|
|
1668
|
+
lease,
|
|
1669
|
+
worktree,
|
|
1670
|
+
changedFiles,
|
|
1671
|
+
claims,
|
|
1672
|
+
boundaryValidation,
|
|
1673
|
+
auditEvents,
|
|
1674
|
+
recoveryKind: 'stale_lease',
|
|
1675
|
+
staleAfterMinutes: staleMinutes,
|
|
1676
|
+
});
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
const strandedTasks = listTasks(db, 'in_progress')
|
|
1680
|
+
.filter((task) => !staleTaskIds.has(task.id))
|
|
1681
|
+
.filter((task) => !getActiveLeaseForTask(db, task.id));
|
|
1682
|
+
const strandedTaskSummaries = strandedTasks.map((task) => {
|
|
1683
|
+
const worktree = task.worktree ? worktreeMap.get(task.worktree) || null : null;
|
|
1684
|
+
const changedFiles = worktree?.path ? getWorktreeChangedFiles(worktree.path, repoRoot) : [];
|
|
1685
|
+
const auditEvents = listAuditEvents(db, { taskId: task.id, limit: 10 });
|
|
1686
|
+
return summarizeRecoveredTaskState({
|
|
1687
|
+
task,
|
|
1688
|
+
worktree,
|
|
1689
|
+
changedFiles,
|
|
1690
|
+
claims: [],
|
|
1691
|
+
boundaryValidation: null,
|
|
1692
|
+
auditEvents,
|
|
1693
|
+
recoveryKind: 'stranded_task',
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
const expiredLeases = staleLeases.length > 0
|
|
1698
|
+
? reapStaleLeases(db, staleMinutes, { requeueTask: leasePolicy.requeue_task_on_reap })
|
|
1699
|
+
: [];
|
|
1700
|
+
const retriedTasks = strandedTasks
|
|
1701
|
+
.map((task) => retryTask(db, task.id, reason))
|
|
1702
|
+
.filter(Boolean);
|
|
1703
|
+
const repair = repairRepoState(db, repoRoot);
|
|
1704
|
+
|
|
1705
|
+
return {
|
|
1706
|
+
stale_after_minutes: staleMinutes,
|
|
1707
|
+
requeue_task_on_reap: leasePolicy.requeue_task_on_reap,
|
|
1708
|
+
stale_leases: staleLeaseSummaries.map((item) => ({
|
|
1709
|
+
...item,
|
|
1710
|
+
recovered_to: leasePolicy.requeue_task_on_reap ? 'pending' : 'failed',
|
|
1711
|
+
})),
|
|
1712
|
+
stranded_tasks: strandedTaskSummaries.map((item) => ({
|
|
1713
|
+
...item,
|
|
1714
|
+
recovered_to: 'pending',
|
|
1715
|
+
})),
|
|
1716
|
+
repair,
|
|
1717
|
+
recovered: {
|
|
1718
|
+
stale_leases: expiredLeases.length,
|
|
1719
|
+
stranded_tasks: retriedTasks.length,
|
|
1720
|
+
repo_actions: repair.actions.length,
|
|
1721
|
+
},
|
|
1722
|
+
next_steps: [
|
|
1723
|
+
...(staleLeaseSummaries.length > 0 || strandedTaskSummaries.length > 0
|
|
1724
|
+
? ['switchman status', 'switchman task list --status pending']
|
|
1725
|
+
: []),
|
|
1726
|
+
...(repair.next_action ? [repair.next_action] : []),
|
|
1727
|
+
].filter((value, index, all) => all.indexOf(value) === index),
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function printRecoverSummary(report) {
|
|
1732
|
+
const totalRecovered = report.recovered.stale_leases + report.recovered.stranded_tasks;
|
|
1733
|
+
console.log(totalRecovered > 0 || report.repair.repaired
|
|
1734
|
+
? `${chalk.green('✓')} Recovered abandoned work and repaired safe interrupted state`
|
|
1735
|
+
: `${chalk.green('✓')} No abandoned work needed recovery`);
|
|
1736
|
+
|
|
1737
|
+
console.log(` ${chalk.dim('stale lease threshold:')} ${report.stale_after_minutes} minute(s)`);
|
|
1738
|
+
console.log(` ${chalk.dim('requeue on reap:')} ${report.requeue_task_on_reap ? 'on' : 'off'}`);
|
|
1739
|
+
console.log(` ${chalk.green('recovered stale leases:')} ${report.recovered.stale_leases}`);
|
|
1740
|
+
console.log(` ${chalk.green('recovered stranded tasks:')} ${report.recovered.stranded_tasks}`);
|
|
1741
|
+
console.log(` ${chalk.green('repo repair actions:')} ${report.recovered.repo_actions}`);
|
|
1742
|
+
|
|
1743
|
+
const recoveredItems = [...report.stale_leases, ...report.stranded_tasks];
|
|
1744
|
+
if (recoveredItems.length > 0) {
|
|
1745
|
+
console.log('');
|
|
1746
|
+
console.log(chalk.bold('Recovered work:'));
|
|
1747
|
+
for (const item of recoveredItems) {
|
|
1748
|
+
console.log(` ${chalk.cyan(item.worktree || 'unknown')} ${chalk.dim(item.task_id)} ${chalk.bold(item.task_title)}`);
|
|
1749
|
+
console.log(` ${chalk.dim('type:')} ${item.kind === 'stale_lease' ? 'stale lease' : 'stranded in-progress task'}${item.lease_id ? ` ${chalk.dim('lease:')} ${item.lease_id}` : ''}`);
|
|
1750
|
+
console.log(` ${chalk.dim('summary:')} ${item.progress_summary}`);
|
|
1751
|
+
if (item.changed_files.length > 0) {
|
|
1752
|
+
console.log(` ${chalk.dim('changed:')} ${item.changed_files.slice(0, 5).join(', ')}${item.changed_files.length > 5 ? ` ${chalk.dim(`+${item.changed_files.length - 5} more`)}` : ''}`);
|
|
1753
|
+
}
|
|
1754
|
+
if (item.claimed_files.length > 0) {
|
|
1755
|
+
console.log(` ${chalk.dim('claimed:')} ${item.claimed_files.slice(0, 5).join(', ')}${item.claimed_files.length > 5 ? ` ${chalk.dim(`+${item.claimed_files.length - 5} more`)}` : ''}`);
|
|
1756
|
+
}
|
|
1757
|
+
if (item.boundary_validation) {
|
|
1758
|
+
console.log(` ${chalk.dim('validation:')} ${item.boundary_validation.status}${item.boundary_validation.missing_task_types.length > 0 ? ` ${chalk.dim(`missing ${item.boundary_validation.missing_task_types.join(', ')}`)}` : ''}`);
|
|
1759
|
+
}
|
|
1760
|
+
console.log(` ${chalk.yellow('inspect:')} ${item.next_action}`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
console.log('');
|
|
1765
|
+
printRepairSummary(report.repair, {
|
|
1766
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state during recovery`,
|
|
1767
|
+
noRepairHeading: `${chalk.green('✓')} No extra repo repair action was needed during recovery`,
|
|
1768
|
+
limit: 6,
|
|
1769
|
+
});
|
|
1770
|
+
if (report.next_steps.length > 0) {
|
|
1771
|
+
console.log('');
|
|
1772
|
+
console.log(chalk.bold('Next steps:'));
|
|
1773
|
+
for (const step of report.next_steps) {
|
|
1774
|
+
console.log(` ${chalk.cyan(step)}`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1468
1779
|
function repairRepoState(db, repoRoot) {
|
|
1469
1780
|
const actions = [];
|
|
1470
1781
|
const warnings = [];
|
|
@@ -1758,7 +2069,7 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
|
|
|
1758
2069
|
detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
|
|
1759
2070
|
});
|
|
1760
2071
|
if (!claudeGuideExists) {
|
|
1761
|
-
nextSteps.push('If you use Claude Code,
|
|
2072
|
+
nextSteps.push('If you use Claude Code, run `switchman claude refresh` to generate a repo-aware `CLAUDE.md`.');
|
|
1762
2073
|
}
|
|
1763
2074
|
|
|
1764
2075
|
const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
|
|
@@ -1794,6 +2105,238 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
|
|
|
1794
2105
|
};
|
|
1795
2106
|
}
|
|
1796
2107
|
|
|
2108
|
+
function readJsonFileIfExists(filePath) {
|
|
2109
|
+
if (!existsSync(filePath)) return null;
|
|
2110
|
+
try {
|
|
2111
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
2112
|
+
} catch {
|
|
2113
|
+
return null;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function detectClaudeGuideContext(repoRoot) {
|
|
2118
|
+
const packageJson = readJsonFileIfExists(join(repoRoot, 'package.json'));
|
|
2119
|
+
const hasTsconfig = existsSync(join(repoRoot, 'tsconfig.json'));
|
|
2120
|
+
const hasPyproject = existsSync(join(repoRoot, 'pyproject.toml'));
|
|
2121
|
+
const hasRequirements = existsSync(join(repoRoot, 'requirements.txt'));
|
|
2122
|
+
const hasGoMod = existsSync(join(repoRoot, 'go.mod'));
|
|
2123
|
+
const hasCargo = existsSync(join(repoRoot, 'Cargo.toml'));
|
|
2124
|
+
const hasGemfile = existsSync(join(repoRoot, 'Gemfile'));
|
|
2125
|
+
|
|
2126
|
+
const stack = [];
|
|
2127
|
+
if (packageJson) stack.push(hasTsconfig ? 'Node.js + TypeScript' : 'Node.js');
|
|
2128
|
+
if (!packageJson && hasTsconfig) stack.push('TypeScript');
|
|
2129
|
+
if (hasPyproject || hasRequirements) stack.push('Python');
|
|
2130
|
+
if (hasGoMod) stack.push('Go');
|
|
2131
|
+
if (hasCargo) stack.push('Rust');
|
|
2132
|
+
if (hasGemfile) stack.push('Ruby');
|
|
2133
|
+
if (stack.length === 0) stack.push('general-purpose codebase');
|
|
2134
|
+
|
|
2135
|
+
const packageManager = existsSync(join(repoRoot, 'pnpm-lock.yaml'))
|
|
2136
|
+
? 'pnpm'
|
|
2137
|
+
: existsSync(join(repoRoot, 'yarn.lock'))
|
|
2138
|
+
? 'yarn'
|
|
2139
|
+
: 'npm';
|
|
2140
|
+
|
|
2141
|
+
const scripts = packageJson?.scripts || {};
|
|
2142
|
+
const packageRunner = packageManager === 'pnpm'
|
|
2143
|
+
? 'pnpm'
|
|
2144
|
+
: packageManager === 'yarn'
|
|
2145
|
+
? 'yarn'
|
|
2146
|
+
: 'npm run';
|
|
2147
|
+
const testCommand = typeof scripts.test === 'string' && scripts.test.trim() && !scripts.test.includes('no test specified')
|
|
2148
|
+
? `${packageRunner}${packageManager === 'yarn' ? ' test' : ' test'}`
|
|
2149
|
+
: hasPyproject || hasRequirements
|
|
2150
|
+
? 'pytest'
|
|
2151
|
+
: hasGoMod
|
|
2152
|
+
? 'go test ./...'
|
|
2153
|
+
: hasCargo
|
|
2154
|
+
? 'cargo test'
|
|
2155
|
+
: null;
|
|
2156
|
+
const buildCommand = typeof scripts.build === 'string' && scripts.build.trim()
|
|
2157
|
+
? `${packageRunner}${packageManager === 'yarn' ? ' build' : ' build'}`
|
|
2158
|
+
: hasGoMod
|
|
2159
|
+
? 'go build ./...'
|
|
2160
|
+
: hasCargo
|
|
2161
|
+
? 'cargo build'
|
|
2162
|
+
: null;
|
|
2163
|
+
const lintCommand = typeof scripts.lint === 'string' && scripts.lint.trim()
|
|
2164
|
+
? `${packageRunner}${packageManager === 'yarn' ? ' lint' : ' lint'}`
|
|
2165
|
+
: null;
|
|
2166
|
+
|
|
2167
|
+
const importantPaths = [
|
|
2168
|
+
existsSync(join(repoRoot, 'src')) ? 'src/' : null,
|
|
2169
|
+
existsSync(join(repoRoot, 'app')) ? 'app/' : null,
|
|
2170
|
+
existsSync(join(repoRoot, 'lib')) ? 'lib/' : null,
|
|
2171
|
+
existsSync(join(repoRoot, 'tests')) ? 'tests/' : null,
|
|
2172
|
+
existsSync(join(repoRoot, 'test')) ? 'test/' : null,
|
|
2173
|
+
existsSync(join(repoRoot, 'docs')) ? 'docs/' : null,
|
|
2174
|
+
existsSync(join(repoRoot, 'packages')) ? 'packages/' : null,
|
|
2175
|
+
].filter(Boolean);
|
|
2176
|
+
|
|
2177
|
+
const conventions = [];
|
|
2178
|
+
if (existsSync(join(repoRoot, '.mcp.json'))) conventions.push('project-local MCP config is already wired');
|
|
2179
|
+
if (existsSync(join(repoRoot, '.cursor', 'mcp.json'))) conventions.push('Cursor MCP config is present');
|
|
2180
|
+
if (existsSync(join(repoRoot, '.git', 'info', 'exclude'))) conventions.push('repo-local git excludes can hide MCP noise from merges');
|
|
2181
|
+
|
|
2182
|
+
return {
|
|
2183
|
+
stack,
|
|
2184
|
+
packageManager,
|
|
2185
|
+
testCommand,
|
|
2186
|
+
buildCommand,
|
|
2187
|
+
lintCommand,
|
|
2188
|
+
importantPaths: importantPaths.slice(0, 5),
|
|
2189
|
+
conventions,
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function renderClaudeGuide(repoRoot) {
|
|
2194
|
+
const context = detectClaudeGuideContext(repoRoot);
|
|
2195
|
+
const stackSummary = formatHumanList(context.stack);
|
|
2196
|
+
const importantPaths = context.importantPaths.length > 0
|
|
2197
|
+
? context.importantPaths.join(', ')
|
|
2198
|
+
: 'the repo root';
|
|
2199
|
+
const preferredCommands = [
|
|
2200
|
+
context.testCommand ? `- Tests: \`${context.testCommand}\`` : null,
|
|
2201
|
+
context.buildCommand ? `- Build: \`${context.buildCommand}\`` : null,
|
|
2202
|
+
context.lintCommand ? `- Lint: \`${context.lintCommand}\`` : null,
|
|
2203
|
+
].filter(Boolean);
|
|
2204
|
+
const conventions = context.conventions.length > 0
|
|
2205
|
+
? context.conventions.map((item) => `- ${item}`)
|
|
2206
|
+
: ['- follow the existing file layout and naming conventions already in the repo'];
|
|
2207
|
+
|
|
2208
|
+
return `# Switchman Agent Instructions
|
|
2209
|
+
|
|
2210
|
+
This repository uses **Switchman** to coordinate parallel AI coding agents.
|
|
2211
|
+
You MUST follow these instructions every session to avoid conflicting with other agents.
|
|
2212
|
+
|
|
2213
|
+
You must use the Switchman MCP tools for coordination. Do not read from or write to \`.switchman/switchman.db\` directly, and do not bypass Switchman by issuing raw SQLite queries.
|
|
2214
|
+
|
|
2215
|
+
---
|
|
2216
|
+
|
|
2217
|
+
## Repo profile
|
|
2218
|
+
|
|
2219
|
+
- Stack: ${stackSummary}
|
|
2220
|
+
- Important paths: ${importantPaths}
|
|
2221
|
+
${preferredCommands.length > 0 ? preferredCommands.join('\n') : '- Commands: follow the repo-specific test/build scripts before marking work complete'}
|
|
2222
|
+
|
|
2223
|
+
## Existing conventions
|
|
2224
|
+
|
|
2225
|
+
${conventions.join('\n')}
|
|
2226
|
+
|
|
2227
|
+
When you make changes, preserve the current code style, folder structure, and script usage that already exist in this repo.
|
|
2228
|
+
|
|
2229
|
+
---
|
|
2230
|
+
|
|
2231
|
+
## Your worktree
|
|
2232
|
+
|
|
2233
|
+
Find your worktree name by running:
|
|
2234
|
+
\`\`\`bash
|
|
2235
|
+
git worktree list
|
|
2236
|
+
\`\`\`
|
|
2237
|
+
The path that matches your current directory is your worktree. Use the last path segment as your worktree name (e.g. \`/projects/myapp-feature-auth\` → \`feature-auth\`). The main repo root is always named \`main\`.
|
|
2238
|
+
|
|
2239
|
+
---
|
|
2240
|
+
|
|
2241
|
+
## Required workflow — follow this every session
|
|
2242
|
+
|
|
2243
|
+
### 1. Start of session — get your task
|
|
2244
|
+
|
|
2245
|
+
Call the \`switchman_task_next\` MCP tool with your worktree name:
|
|
2246
|
+
\`\`\`
|
|
2247
|
+
switchman_task_next({ worktree: "<your-worktree-name>", agent: "claude-code" })
|
|
2248
|
+
\`\`\`
|
|
2249
|
+
|
|
2250
|
+
- If \`task\` is \`null\` — the queue is empty. Ask the user what to work on, or stop.
|
|
2251
|
+
- If you receive a task — note the \`task.id\`. You'll need it in the next steps.
|
|
2252
|
+
- If the \`switchman_*\` tools are unavailable, stop and tell the user the MCP server is not connected. Do not fall back to direct SQLite access.
|
|
2253
|
+
|
|
2254
|
+
### 2. Before editing any files — claim them
|
|
2255
|
+
|
|
2256
|
+
Call \`switchman_task_claim\` with every file you plan to edit **before you edit them**:
|
|
2257
|
+
\`\`\`
|
|
2258
|
+
switchman_task_claim({
|
|
2259
|
+
task_id: "<task-id>",
|
|
2260
|
+
worktree: "<your-worktree-name>",
|
|
2261
|
+
files: ["src/auth/login.js", "tests/auth.test.js"]
|
|
2262
|
+
})
|
|
2263
|
+
\`\`\`
|
|
2264
|
+
|
|
2265
|
+
- If \`safe_to_proceed\` is \`false\` — there are conflicts. Do NOT edit those files.
|
|
2266
|
+
Read the \`conflicts\` array to see which worktrees own them, then either:
|
|
2267
|
+
- Choose different files that accomplish the same goal
|
|
2268
|
+
- Ask the user how to proceed
|
|
2269
|
+
|
|
2270
|
+
- If \`safe_to_proceed\` is \`true\` — you are clear to edit.
|
|
2271
|
+
|
|
2272
|
+
### 3. Do the work
|
|
2273
|
+
|
|
2274
|
+
Implement the task. Make commits as normal. Other agents will avoid your claimed files.
|
|
2275
|
+
|
|
2276
|
+
If you discover mid-task that you need to edit additional files, call \`switchman_task_claim\` again for those files before editing them.
|
|
2277
|
+
|
|
2278
|
+
When MCP write tools are available, prefer the Switchman enforcement gateway over native file writes:
|
|
2279
|
+
\`\`\`text
|
|
2280
|
+
switchman_write_file(...)
|
|
2281
|
+
switchman_append_file(...)
|
|
2282
|
+
switchman_make_directory(...)
|
|
2283
|
+
switchman_move_path(...)
|
|
2284
|
+
switchman_remove_path(...)
|
|
2285
|
+
\`\`\`
|
|
2286
|
+
|
|
2287
|
+
These tools validate your active lease and claimed paths before changing the filesystem. Use native file writes only when the Switchman write tools are unavailable and you have already claimed the path.
|
|
2288
|
+
|
|
2289
|
+
### 4. Before marking work complete
|
|
2290
|
+
|
|
2291
|
+
Run the repo's most relevant verification commands for the files you changed.
|
|
2292
|
+
${context.testCommand ? `At minimum, prefer \`${context.testCommand}\` when the task changes behavior or tests.\n` : ''}${context.lintCommand ? `Use \`${context.lintCommand}\` when the repo relies on linting before merge.\n` : ''}${context.buildCommand ? `Use \`${context.buildCommand}\` when the repo has a meaningful build step.\n` : ''}If the correct command is unclear, inspect the existing scripts and match the repo's normal workflow.
|
|
2293
|
+
|
|
2294
|
+
### 5. End of session — mark complete or failed
|
|
2295
|
+
|
|
2296
|
+
**On success:**
|
|
2297
|
+
\`\`\`
|
|
2298
|
+
switchman_task_done({ task_id: "<task-id>" })
|
|
2299
|
+
\`\`\`
|
|
2300
|
+
|
|
2301
|
+
**On failure (can't complete the task):**
|
|
2302
|
+
\`\`\`
|
|
2303
|
+
switchman_task_fail({ task_id: "<task-id>", reason: "Brief explanation of what blocked you" })
|
|
2304
|
+
\`\`\`
|
|
2305
|
+
|
|
2306
|
+
Always call one of these before ending your session. Released file claims allow other agents to proceed.
|
|
2307
|
+
|
|
2308
|
+
---
|
|
2309
|
+
|
|
2310
|
+
## Checking system state
|
|
2311
|
+
|
|
2312
|
+
To see what other agents are doing:
|
|
2313
|
+
\`\`\`
|
|
2314
|
+
switchman_status()
|
|
2315
|
+
\`\`\`
|
|
2316
|
+
|
|
2317
|
+
To recover abandoned work or stale sessions:
|
|
2318
|
+
\`\`\`
|
|
2319
|
+
switchman_recover()
|
|
2320
|
+
\`\`\`
|
|
2321
|
+
|
|
2322
|
+
To scan for conflicts before merge:
|
|
2323
|
+
\`\`\`
|
|
2324
|
+
switchman_scan()
|
|
2325
|
+
\`\`\`
|
|
2326
|
+
|
|
2327
|
+
---
|
|
2328
|
+
|
|
2329
|
+
## Rules
|
|
2330
|
+
|
|
2331
|
+
1. **Always claim files before editing them** — not after.
|
|
2332
|
+
2. **Always call \`switchman_task_done\` or \`switchman_task_fail\` at end of session** — never leave tasks as \`in_progress\` when you stop.
|
|
2333
|
+
3. **If \`safe_to_proceed\` is false, do not edit the conflicting files** — coordinate first.
|
|
2334
|
+
4. **Do not claim files you don't need** — over-claiming blocks other agents unnecessarily.
|
|
2335
|
+
5. **One task per session** — complete or fail your current task before taking another.
|
|
2336
|
+
6. **Never query or mutate the Switchman SQLite database directly** — use MCP tools only.
|
|
2337
|
+
`;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1797
2340
|
function renderSetupVerification(report, { compact = false } = {}) {
|
|
1798
2341
|
console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
|
|
1799
2342
|
for (const check of report.checks) {
|
|
@@ -2911,6 +3454,7 @@ program
|
|
|
2911
3454
|
program.showHelpAfterError('(run with --help for usage examples)');
|
|
2912
3455
|
const ROOT_HELP_COMMANDS = new Set([
|
|
2913
3456
|
'advanced',
|
|
3457
|
+
'claude',
|
|
2914
3458
|
'demo',
|
|
2915
3459
|
'setup',
|
|
2916
3460
|
'verify-setup',
|
|
@@ -2919,6 +3463,7 @@ const ROOT_HELP_COMMANDS = new Set([
|
|
|
2919
3463
|
'plan',
|
|
2920
3464
|
'task',
|
|
2921
3465
|
'status',
|
|
3466
|
+
'recover',
|
|
2922
3467
|
'merge',
|
|
2923
3468
|
'repair',
|
|
2924
3469
|
'help',
|
|
@@ -2936,13 +3481,16 @@ Start here:
|
|
|
2936
3481
|
switchman setup --agents 3
|
|
2937
3482
|
switchman task add "Your task" --priority 8
|
|
2938
3483
|
switchman status --watch
|
|
3484
|
+
switchman recover
|
|
2939
3485
|
switchman gate ci && switchman queue run
|
|
2940
3486
|
|
|
2941
3487
|
For you (the operator):
|
|
2942
3488
|
switchman demo
|
|
2943
3489
|
switchman setup
|
|
3490
|
+
switchman claude refresh
|
|
2944
3491
|
switchman task add
|
|
2945
3492
|
switchman status
|
|
3493
|
+
switchman recover
|
|
2946
3494
|
switchman merge
|
|
2947
3495
|
switchman repair
|
|
2948
3496
|
switchman upgrade
|
|
@@ -3257,156 +3805,53 @@ Use this after setup or whenever editor/config wiring feels off.
|
|
|
3257
3805
|
}, { homeDir: opts.home || null });
|
|
3258
3806
|
if (!report.ok) process.exitCode = 1;
|
|
3259
3807
|
});
|
|
3808
|
+
registerClaudeCommands(program, {
|
|
3809
|
+
chalk,
|
|
3810
|
+
existsSync,
|
|
3811
|
+
getRepo,
|
|
3812
|
+
join,
|
|
3813
|
+
renderClaudeGuide,
|
|
3814
|
+
writeFileSync,
|
|
3815
|
+
});
|
|
3260
3816
|
|
|
3261
3817
|
|
|
3262
3818
|
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
3263
3819
|
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
|
|
3280
|
-
config_path: getTelemetryConfigPath(opts.home || undefined),
|
|
3281
|
-
};
|
|
3282
|
-
|
|
3283
|
-
if (opts.json) {
|
|
3284
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
3285
|
-
return;
|
|
3286
|
-
}
|
|
3287
|
-
|
|
3288
|
-
console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
|
|
3289
|
-
console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
|
|
3290
|
-
console.log(`Config file: ${chalk.dim(payload.config_path)}`);
|
|
3291
|
-
if (payload.install_id) {
|
|
3292
|
-
console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
|
|
3293
|
-
}
|
|
3294
|
-
});
|
|
3295
|
-
|
|
3296
|
-
telemetryCmd
|
|
3297
|
-
.command('enable')
|
|
3298
|
-
.description('Enable anonymous telemetry for setup and operator workflows')
|
|
3299
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
3300
|
-
.action((opts) => {
|
|
3301
|
-
const runtime = getTelemetryRuntimeConfig();
|
|
3302
|
-
if (!runtime.apiKey || runtime.disabled) {
|
|
3303
|
-
printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
|
|
3304
|
-
process.exitCode = 1;
|
|
3305
|
-
return;
|
|
3306
|
-
}
|
|
3307
|
-
const result = enableTelemetry(opts.home || undefined);
|
|
3308
|
-
console.log(`${chalk.green('✓')} Telemetry enabled`);
|
|
3309
|
-
console.log(` ${chalk.dim(result.path)}`);
|
|
3310
|
-
});
|
|
3311
|
-
|
|
3312
|
-
telemetryCmd
|
|
3313
|
-
.command('disable')
|
|
3314
|
-
.description('Disable anonymous telemetry')
|
|
3315
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
3316
|
-
.action((opts) => {
|
|
3317
|
-
const result = disableTelemetry(opts.home || undefined);
|
|
3318
|
-
console.log(`${chalk.green('✓')} Telemetry disabled`);
|
|
3319
|
-
console.log(` ${chalk.dim(result.path)}`);
|
|
3320
|
-
});
|
|
3321
|
-
|
|
3322
|
-
telemetryCmd
|
|
3323
|
-
.command('test')
|
|
3324
|
-
.description('Send one test telemetry event and report whether delivery succeeded')
|
|
3325
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
3326
|
-
.option('--json', 'Output raw JSON')
|
|
3327
|
-
.action(async (opts) => {
|
|
3328
|
-
const result = await sendTelemetryEvent('telemetry_test', {
|
|
3329
|
-
app_version: program.version(),
|
|
3330
|
-
os: process.platform,
|
|
3331
|
-
node_version: process.version,
|
|
3332
|
-
source: 'switchman-cli-test',
|
|
3333
|
-
}, { homeDir: opts.home || undefined });
|
|
3334
|
-
|
|
3335
|
-
if (opts.json) {
|
|
3336
|
-
console.log(JSON.stringify(result, null, 2));
|
|
3337
|
-
if (!result.ok) process.exitCode = 1;
|
|
3338
|
-
return;
|
|
3339
|
-
}
|
|
3340
|
-
|
|
3341
|
-
if (result.ok) {
|
|
3342
|
-
console.log(`${chalk.green('✓')} Telemetry test event delivered`);
|
|
3343
|
-
console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
|
|
3344
|
-
if (result.status) {
|
|
3345
|
-
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
3346
|
-
}
|
|
3347
|
-
return;
|
|
3348
|
-
}
|
|
3349
|
-
|
|
3350
|
-
printErrorWithNext(`Telemetry test failed (${result.reason || 'unknown_error'}).`, 'switchman telemetry status');
|
|
3351
|
-
console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
|
|
3352
|
-
if (result.status) {
|
|
3353
|
-
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
3354
|
-
}
|
|
3355
|
-
if (result.error) {
|
|
3356
|
-
console.log(` ${chalk.dim('error:')} ${result.error}`);
|
|
3357
|
-
}
|
|
3358
|
-
process.exitCode = 1;
|
|
3359
|
-
});
|
|
3360
|
-
|
|
3361
|
-
mcpCmd
|
|
3362
|
-
.command('install')
|
|
3363
|
-
.description('Install editor-specific MCP config for Switchman')
|
|
3364
|
-
.option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
|
|
3365
|
-
.option('--home <path>', 'Override the home directory for config writes (useful for testing)')
|
|
3366
|
-
.option('--json', 'Output raw JSON')
|
|
3367
|
-
.addHelpText('after', `
|
|
3368
|
-
Examples:
|
|
3369
|
-
switchman mcp install --windsurf
|
|
3370
|
-
switchman mcp install --windsurf --json
|
|
3371
|
-
`)
|
|
3372
|
-
.action((opts) => {
|
|
3373
|
-
if (!opts.windsurf) {
|
|
3374
|
-
console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
|
|
3375
|
-
process.exitCode = 1;
|
|
3376
|
-
return;
|
|
3377
|
-
}
|
|
3378
|
-
|
|
3379
|
-
const result = upsertWindsurfMcpConfig(opts.home);
|
|
3380
|
-
|
|
3381
|
-
if (opts.json) {
|
|
3382
|
-
console.log(JSON.stringify({
|
|
3383
|
-
editor: 'windsurf',
|
|
3384
|
-
path: result.path,
|
|
3385
|
-
created: result.created,
|
|
3386
|
-
changed: result.changed,
|
|
3387
|
-
}, null, 2));
|
|
3388
|
-
return;
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
|
|
3392
|
-
console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
|
|
3393
|
-
console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
|
|
3394
|
-
console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
|
|
3395
|
-
});
|
|
3820
|
+
registerMcpCommands(program, {
|
|
3821
|
+
chalk,
|
|
3822
|
+
getWindsurfMcpConfigPath,
|
|
3823
|
+
upsertWindsurfMcpConfig,
|
|
3824
|
+
});
|
|
3825
|
+
registerTelemetryCommands(program, {
|
|
3826
|
+
chalk,
|
|
3827
|
+
disableTelemetry,
|
|
3828
|
+
enableTelemetry,
|
|
3829
|
+
getTelemetryConfigPath,
|
|
3830
|
+
getTelemetryRuntimeConfig,
|
|
3831
|
+
loadTelemetryConfig,
|
|
3832
|
+
printErrorWithNext,
|
|
3833
|
+
sendTelemetryEvent,
|
|
3834
|
+
});
|
|
3396
3835
|
|
|
3397
3836
|
|
|
3398
3837
|
// ── plan ──────────────────────────────────────────────────────────────────────
|
|
3399
3838
|
|
|
3400
3839
|
program
|
|
3401
3840
|
.command('plan [goal]')
|
|
3402
|
-
.description('Pro: suggest a parallel task plan from an explicit goal')
|
|
3841
|
+
.description('Pro: suggest a parallel task plan from an explicit goal or GitHub issue')
|
|
3842
|
+
.option('--issue <number>', 'Read planning context from a GitHub issue via gh')
|
|
3843
|
+
.option('--pr <number>', 'Post the resulting plan summary to a GitHub pull request')
|
|
3844
|
+
.option('--comment', 'Post a GitHub comment with the created plan summary after --apply')
|
|
3845
|
+
.option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
|
|
3403
3846
|
.option('--apply', 'Create the suggested tasks in Switchman')
|
|
3404
3847
|
.option('--max-tasks <n>', 'Maximum number of suggested tasks', '6')
|
|
3405
3848
|
.option('--json', 'Output raw JSON')
|
|
3406
3849
|
.addHelpText('after', `
|
|
3407
3850
|
Examples:
|
|
3408
3851
|
switchman plan "Add authentication"
|
|
3852
|
+
switchman plan --issue 47
|
|
3409
3853
|
switchman plan "Add authentication" --apply
|
|
3854
|
+
switchman plan --issue 47 --apply --comment
|
|
3410
3855
|
`)
|
|
3411
3856
|
.action(async (goal, opts) => {
|
|
3412
3857
|
const repoRoot = getRepo();
|
|
@@ -3424,17 +3869,48 @@ Examples:
|
|
|
3424
3869
|
return;
|
|
3425
3870
|
}
|
|
3426
3871
|
|
|
3427
|
-
|
|
3872
|
+
let issueContext = null;
|
|
3873
|
+
if (opts.issue) {
|
|
3874
|
+
try {
|
|
3875
|
+
issueContext = fetchGitHubIssueContext(repoRoot, opts.issue, opts.ghCommand);
|
|
3876
|
+
} catch (err) {
|
|
3877
|
+
printErrorWithNext(err.message, 'switchman plan "Add authentication"');
|
|
3878
|
+
process.exitCode = 1;
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
if ((!goal || !goal.trim()) && !issueContext) {
|
|
3428
3884
|
console.log('');
|
|
3429
3885
|
console.log(chalk.yellow(' ⚠ AI planning currently requires an explicit goal.'));
|
|
3430
3886
|
console.log(` ${chalk.dim('Try:')} ${chalk.cyan('switchman plan "Add authentication"')}`);
|
|
3887
|
+
console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan --issue 47')}`);
|
|
3431
3888
|
console.log(` ${chalk.dim('Then:')} ${chalk.cyan('switchman plan "Add authentication" --apply')}`);
|
|
3432
3889
|
console.log('');
|
|
3433
3890
|
process.exitCode = 1;
|
|
3434
3891
|
return;
|
|
3435
3892
|
}
|
|
3436
3893
|
|
|
3437
|
-
|
|
3894
|
+
if (opts.comment && !opts.apply) {
|
|
3895
|
+
console.log('');
|
|
3896
|
+
console.log(chalk.yellow(' ⚠ GitHub plan comments are only posted after task creation.'));
|
|
3897
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman plan --apply --comment')}`);
|
|
3898
|
+
console.log('');
|
|
3899
|
+
process.exitCode = 1;
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
if (opts.comment && !opts.pr && !issueContext) {
|
|
3904
|
+
console.log('');
|
|
3905
|
+
console.log(chalk.yellow(' ⚠ Choose where to post the plan summary.'));
|
|
3906
|
+
console.log(` ${chalk.dim('Use:')} ${chalk.cyan('switchman plan --issue 47 --apply --comment')}`);
|
|
3907
|
+
console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan "Add authentication" --pr 123 --apply --comment')}`);
|
|
3908
|
+
console.log('');
|
|
3909
|
+
process.exitCode = 1;
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
const context = collectPlanContext(repoRoot, goal || null, issueContext);
|
|
3438
3914
|
const planningWorktrees = resolvePlanningWorktrees(repoRoot, db);
|
|
3439
3915
|
const pipelineId = `plan-${slugifyValue(context.title)}-${Date.now().toString(36)}`;
|
|
3440
3916
|
const plannedTasks = planPipelineTasks({
|
|
@@ -3514,6 +3990,19 @@ Examples:
|
|
|
3514
3990
|
|
|
3515
3991
|
console.log('');
|
|
3516
3992
|
console.log(`${chalk.green('✓')} Planned ${plannedTasks.length} task(s) from repo context.`);
|
|
3993
|
+
if (opts.comment) {
|
|
3994
|
+
const commentBody = buildPlanningCommentBody(context, plannedTasks);
|
|
3995
|
+
const commentResult = postPlanningSummaryComment(repoRoot, {
|
|
3996
|
+
ghCommand: opts.ghCommand,
|
|
3997
|
+
issueNumber: issueContext?.number || null,
|
|
3998
|
+
prNumber: opts.pr || null,
|
|
3999
|
+
body: commentBody,
|
|
4000
|
+
});
|
|
4001
|
+
const targetLabel = commentResult.target_type === 'pr'
|
|
4002
|
+
? `PR #${commentResult.target_number}`
|
|
4003
|
+
: `issue #${commentResult.target_number}`;
|
|
4004
|
+
console.log(` ${chalk.green('✓')} Posted plan summary to ${chalk.cyan(targetLabel)}.`);
|
|
4005
|
+
}
|
|
3517
4006
|
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman status --watch')}`);
|
|
3518
4007
|
} finally {
|
|
3519
4008
|
db?.close();
|
|
@@ -3523,677 +4012,56 @@ Examples:
|
|
|
3523
4012
|
|
|
3524
4013
|
// ── task ──────────────────────────────────────────────────────────────────────
|
|
3525
4014
|
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
description: opts.description,
|
|
3547
|
-
priority: parseInt(opts.priority),
|
|
3548
|
-
});
|
|
3549
|
-
db.close();
|
|
3550
|
-
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
3551
|
-
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
3552
|
-
pushSyncEvent('task_added', { task_id: taskId, title, priority: parseInt(opts.priority) }).catch(() => {});
|
|
3553
|
-
console.log(` ${chalk.dim(title)}`);
|
|
3554
|
-
if (scopeWarning) {
|
|
3555
|
-
console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
|
|
3556
|
-
console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
|
|
3557
|
-
console.log(chalk.cyan(` try: ${scopeWarning.command}`));
|
|
3558
|
-
}
|
|
3559
|
-
});
|
|
3560
|
-
|
|
3561
|
-
taskCmd
|
|
3562
|
-
.command('list')
|
|
3563
|
-
.description('List all tasks')
|
|
3564
|
-
.option('-s, --status <status>', 'Filter by status (pending|in_progress|done|failed)')
|
|
3565
|
-
.action((opts) => {
|
|
3566
|
-
const repoRoot = getRepo();
|
|
3567
|
-
const db = getDb(repoRoot);
|
|
3568
|
-
const tasks = listTasks(db, opts.status);
|
|
3569
|
-
db.close();
|
|
3570
|
-
|
|
3571
|
-
if (!tasks.length) {
|
|
3572
|
-
console.log(chalk.dim('No tasks found.'));
|
|
3573
|
-
return;
|
|
3574
|
-
}
|
|
3575
|
-
|
|
3576
|
-
console.log('');
|
|
3577
|
-
for (const t of tasks) {
|
|
3578
|
-
const badge = statusBadge(t.status);
|
|
3579
|
-
const worktree = t.worktree ? chalk.cyan(t.worktree) : chalk.dim('unassigned');
|
|
3580
|
-
console.log(`${badge} ${chalk.bold(t.title)}`);
|
|
3581
|
-
console.log(` ${chalk.dim('id:')} ${t.id} ${chalk.dim('worktree:')} ${worktree} ${chalk.dim('priority:')} ${t.priority}`);
|
|
3582
|
-
if (t.description) console.log(` ${chalk.dim(t.description)}`);
|
|
3583
|
-
console.log('');
|
|
3584
|
-
}
|
|
3585
|
-
});
|
|
3586
|
-
|
|
3587
|
-
taskCmd
|
|
3588
|
-
.command('assign <taskId> <worktree>')
|
|
3589
|
-
.description('Assign a task to a workspace (compatibility shim for lease acquire)')
|
|
3590
|
-
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
3591
|
-
.action((taskId, worktree, opts) => {
|
|
3592
|
-
const repoRoot = getRepo();
|
|
3593
|
-
const db = getDb(repoRoot);
|
|
3594
|
-
const lease = startTaskLease(db, taskId, worktree, opts.agent);
|
|
3595
|
-
db.close();
|
|
3596
|
-
if (lease) {
|
|
3597
|
-
console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
|
|
3598
|
-
} else {
|
|
3599
|
-
console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
|
|
3600
|
-
}
|
|
3601
|
-
});
|
|
3602
|
-
|
|
3603
|
-
taskCmd
|
|
3604
|
-
.command('retry <taskId>')
|
|
3605
|
-
.description('Return a failed or stale completed task to pending so it can be revalidated')
|
|
3606
|
-
.option('--reason <text>', 'Reason to record for the retry')
|
|
3607
|
-
.option('--json', 'Output raw JSON')
|
|
3608
|
-
.action((taskId, opts) => {
|
|
3609
|
-
const repoRoot = getRepo();
|
|
3610
|
-
const db = getDb(repoRoot);
|
|
3611
|
-
const task = retryTask(db, taskId, opts.reason || 'manual retry');
|
|
3612
|
-
db.close();
|
|
3613
|
-
|
|
3614
|
-
if (!task) {
|
|
3615
|
-
printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
|
|
3616
|
-
process.exitCode = 1;
|
|
3617
|
-
return;
|
|
3618
|
-
}
|
|
3619
|
-
|
|
3620
|
-
if (opts.json) {
|
|
3621
|
-
console.log(JSON.stringify(task, null, 2));
|
|
3622
|
-
return;
|
|
3623
|
-
}
|
|
3624
|
-
|
|
3625
|
-
console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
|
|
3626
|
-
pushSyncEvent('task_retried', { task_id: task.id, title: task.title, reason: opts.reason || 'manual retry' }).catch(() => {});
|
|
3627
|
-
console.log(` ${chalk.dim('title:')} ${task.title}`);
|
|
3628
|
-
console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
|
|
3629
|
-
});
|
|
3630
|
-
|
|
3631
|
-
taskCmd
|
|
3632
|
-
.command('retry-stale')
|
|
3633
|
-
.description('Return all currently stale tasks to pending so they can be revalidated together')
|
|
3634
|
-
.option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
|
|
3635
|
-
.option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
|
|
3636
|
-
.option('--json', 'Output raw JSON')
|
|
3637
|
-
.action((opts) => {
|
|
3638
|
-
const repoRoot = getRepo();
|
|
3639
|
-
const db = getDb(repoRoot);
|
|
3640
|
-
const result = retryStaleTasks(db, {
|
|
3641
|
-
pipelineId: opts.pipeline || null,
|
|
3642
|
-
reason: opts.reason,
|
|
3643
|
-
});
|
|
3644
|
-
db.close();
|
|
3645
|
-
|
|
3646
|
-
if (opts.json) {
|
|
3647
|
-
console.log(JSON.stringify(result, null, 2));
|
|
3648
|
-
return;
|
|
3649
|
-
}
|
|
3650
|
-
|
|
3651
|
-
if (result.retried.length === 0) {
|
|
3652
|
-
const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
|
|
3653
|
-
console.log(chalk.dim(`No stale tasks to retry${scope}.`));
|
|
3654
|
-
return;
|
|
3655
|
-
}
|
|
3656
|
-
|
|
3657
|
-
console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
|
|
3658
|
-
pushSyncEvent('task_retried', {
|
|
3659
|
-
pipeline_id: result.pipeline_id || null,
|
|
3660
|
-
task_count: result.retried.length,
|
|
3661
|
-
task_ids: result.retried.map((task) => task.id),
|
|
3662
|
-
reason: opts.reason,
|
|
3663
|
-
}).catch(() => {});
|
|
3664
|
-
if (result.pipeline_id) {
|
|
3665
|
-
console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
|
|
3666
|
-
}
|
|
3667
|
-
console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
|
|
3668
|
-
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
3669
|
-
});
|
|
3670
|
-
|
|
3671
|
-
taskCmd
|
|
3672
|
-
.command('done <taskId>')
|
|
3673
|
-
.description('Mark a task as complete and release all file claims')
|
|
3674
|
-
.action((taskId) => {
|
|
3675
|
-
const repoRoot = getRepo();
|
|
3676
|
-
try {
|
|
3677
|
-
const result = completeTaskWithRetries(repoRoot, taskId);
|
|
3678
|
-
if (result?.status === 'already_done') {
|
|
3679
|
-
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} was already marked done — no new changes were recorded`);
|
|
3680
|
-
return;
|
|
3681
|
-
}
|
|
3682
|
-
if (result?.status === 'failed') {
|
|
3683
|
-
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is currently failed — retry it before marking it done again`);
|
|
3684
|
-
return;
|
|
3685
|
-
}
|
|
3686
|
-
if (result?.status === 'not_in_progress') {
|
|
3687
|
-
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is not currently in progress — start a lease before marking it done`);
|
|
3688
|
-
return;
|
|
3689
|
-
}
|
|
3690
|
-
if (result?.status === 'no_active_lease') {
|
|
3691
|
-
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} has no active lease — reacquire the task before marking it done`);
|
|
3692
|
-
return;
|
|
3693
|
-
}
|
|
3694
|
-
console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
|
|
3695
|
-
pushSyncEvent('task_done', { task_id: taskId }).catch(() => {});
|
|
3696
|
-
} catch (err) {
|
|
3697
|
-
console.error(chalk.red(err.message));
|
|
3698
|
-
process.exitCode = 1;
|
|
3699
|
-
}
|
|
3700
|
-
});
|
|
3701
|
-
|
|
3702
|
-
taskCmd
|
|
3703
|
-
.command('fail <taskId> [reason]')
|
|
3704
|
-
.description('Mark a task as failed')
|
|
3705
|
-
.action((taskId, reason) => {
|
|
3706
|
-
const repoRoot = getRepo();
|
|
3707
|
-
const db = getDb(repoRoot);
|
|
3708
|
-
failTask(db, taskId, reason);
|
|
3709
|
-
releaseFileClaims(db, taskId);
|
|
3710
|
-
db.close();
|
|
3711
|
-
console.log(`${chalk.red('✗')} Task ${chalk.cyan(taskId)} marked failed`);
|
|
3712
|
-
pushSyncEvent('task_failed', { task_id: taskId, reason: reason || null }).catch(() => {});
|
|
3713
|
-
});
|
|
3714
|
-
|
|
3715
|
-
taskCmd
|
|
3716
|
-
.command('next')
|
|
3717
|
-
.description('Get the next pending task quickly (use `lease next` for the full workflow)')
|
|
3718
|
-
.option('--json', 'Output as JSON')
|
|
3719
|
-
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
3720
|
-
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
3721
|
-
.addHelpText('after', `
|
|
3722
|
-
Examples:
|
|
3723
|
-
switchman task next
|
|
3724
|
-
switchman task next --json
|
|
3725
|
-
`)
|
|
3726
|
-
.action((opts) => {
|
|
3727
|
-
const repoRoot = getRepo();
|
|
3728
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
3729
|
-
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
3730
|
-
|
|
3731
|
-
if (!task) {
|
|
3732
|
-
if (opts.json) console.log(JSON.stringify({ task: null }));
|
|
3733
|
-
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
3734
|
-
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
3735
|
-
return;
|
|
3736
|
-
}
|
|
3737
|
-
|
|
3738
|
-
if (!lease) {
|
|
3739
|
-
if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
|
|
3740
|
-
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
3741
|
-
return;
|
|
3742
|
-
}
|
|
3743
|
-
|
|
3744
|
-
if (opts.json) {
|
|
3745
|
-
console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
|
|
3746
|
-
} else {
|
|
3747
|
-
console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
|
|
3748
|
-
console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('lease:')} ${chalk.dim(lease.id)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
3749
|
-
}
|
|
3750
|
-
});
|
|
4015
|
+
registerTaskCommands(program, {
|
|
4016
|
+
acquireNextTaskLeaseWithRetries,
|
|
4017
|
+
analyzeTaskScope,
|
|
4018
|
+
chalk,
|
|
4019
|
+
completeTaskWithRetries,
|
|
4020
|
+
createTask,
|
|
4021
|
+
failTask,
|
|
4022
|
+
getCurrentWorktreeName,
|
|
4023
|
+
getDb,
|
|
4024
|
+
getRepo,
|
|
4025
|
+
listTasks,
|
|
4026
|
+
printErrorWithNext,
|
|
4027
|
+
pushSyncEvent,
|
|
4028
|
+
releaseFileClaims,
|
|
4029
|
+
retryStaleTasks,
|
|
4030
|
+
retryTask,
|
|
4031
|
+
startTaskLease,
|
|
4032
|
+
statusBadge,
|
|
4033
|
+
taskJsonWithLease,
|
|
4034
|
+
});
|
|
3751
4035
|
|
|
3752
4036
|
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
3753
4037
|
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
`)
|
|
3782
|
-
.action(async (branch, opts) => {
|
|
3783
|
-
const repoRoot = getRepo();
|
|
3784
|
-
const db = getDb(repoRoot);
|
|
3785
|
-
|
|
3786
|
-
try {
|
|
3787
|
-
let payload;
|
|
3788
|
-
if (opts.worktree) {
|
|
3789
|
-
const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
|
|
3790
|
-
if (!worktree) {
|
|
3791
|
-
throw new Error(`Workspace ${opts.worktree} is not registered.`);
|
|
3792
|
-
}
|
|
3793
|
-
payload = {
|
|
3794
|
-
sourceType: 'worktree',
|
|
3795
|
-
sourceRef: worktree.branch,
|
|
3796
|
-
sourceWorktree: worktree.name,
|
|
3797
|
-
targetBranch: opts.target,
|
|
3798
|
-
maxRetries: opts.maxRetries,
|
|
3799
|
-
submittedBy: opts.submittedBy || null,
|
|
3800
|
-
};
|
|
3801
|
-
} else if (opts.pipeline) {
|
|
3802
|
-
const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
|
|
3803
|
-
if (!policyGate.ok) {
|
|
3804
|
-
throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
|
|
3805
|
-
}
|
|
3806
|
-
const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
|
|
3807
|
-
baseBranch: opts.target || 'main',
|
|
3808
|
-
requireCompleted: true,
|
|
3809
|
-
allowCurrentBranchFallback: false,
|
|
3810
|
-
});
|
|
3811
|
-
payload = {
|
|
3812
|
-
sourceType: 'pipeline',
|
|
3813
|
-
sourceRef: landingTarget.branch,
|
|
3814
|
-
sourcePipelineId: opts.pipeline,
|
|
3815
|
-
sourceWorktree: landingTarget.worktree || null,
|
|
3816
|
-
targetBranch: opts.target,
|
|
3817
|
-
maxRetries: opts.maxRetries,
|
|
3818
|
-
submittedBy: opts.submittedBy || null,
|
|
3819
|
-
eventDetails: policyGate.override_applied
|
|
3820
|
-
? {
|
|
3821
|
-
policy_override_summary: policyGate.override_summary,
|
|
3822
|
-
overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
|
|
3823
|
-
}
|
|
3824
|
-
: null,
|
|
3825
|
-
};
|
|
3826
|
-
} else if (branch) {
|
|
3827
|
-
payload = {
|
|
3828
|
-
sourceType: 'branch',
|
|
3829
|
-
sourceRef: branch,
|
|
3830
|
-
targetBranch: opts.target,
|
|
3831
|
-
maxRetries: opts.maxRetries,
|
|
3832
|
-
submittedBy: opts.submittedBy || null,
|
|
3833
|
-
};
|
|
3834
|
-
} else {
|
|
3835
|
-
throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
|
|
3836
|
-
}
|
|
3837
|
-
|
|
3838
|
-
const result = enqueueMergeItem(db, payload);
|
|
3839
|
-
db.close();
|
|
3840
|
-
pushSyncEvent('queue_added', {
|
|
3841
|
-
item_id: result.id,
|
|
3842
|
-
source_type: result.source_type,
|
|
3843
|
-
source_ref: result.source_ref,
|
|
3844
|
-
source_worktree: result.source_worktree || null,
|
|
3845
|
-
target_branch: result.target_branch,
|
|
3846
|
-
}, { worktree: result.source_worktree || null }).catch(() => {});
|
|
3847
|
-
|
|
3848
|
-
if (opts.json) {
|
|
3849
|
-
console.log(JSON.stringify(result, null, 2));
|
|
3850
|
-
return;
|
|
3851
|
-
}
|
|
3852
|
-
|
|
3853
|
-
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
3854
|
-
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
3855
|
-
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
3856
|
-
if (payload.eventDetails?.policy_override_summary) {
|
|
3857
|
-
console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
|
|
3858
|
-
}
|
|
3859
|
-
} catch (err) {
|
|
3860
|
-
db.close();
|
|
3861
|
-
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
3862
|
-
process.exitCode = 1;
|
|
3863
|
-
}
|
|
3864
|
-
});
|
|
3865
|
-
|
|
3866
|
-
queueCmd
|
|
3867
|
-
.command('list')
|
|
3868
|
-
.description('List merge queue items')
|
|
3869
|
-
.option('--status <status>', 'Filter by queue status')
|
|
3870
|
-
.option('--json', 'Output raw JSON')
|
|
3871
|
-
.action((opts) => {
|
|
3872
|
-
const repoRoot = getRepo();
|
|
3873
|
-
const db = getDb(repoRoot);
|
|
3874
|
-
const items = listMergeQueue(db, { status: opts.status || null });
|
|
3875
|
-
db.close();
|
|
3876
|
-
|
|
3877
|
-
if (opts.json) {
|
|
3878
|
-
console.log(JSON.stringify(items, null, 2));
|
|
3879
|
-
return;
|
|
3880
|
-
}
|
|
3881
|
-
|
|
3882
|
-
if (items.length === 0) {
|
|
3883
|
-
console.log(chalk.dim('Merge queue is empty.'));
|
|
3884
|
-
return;
|
|
3885
|
-
}
|
|
3886
|
-
|
|
3887
|
-
for (const item of items) {
|
|
3888
|
-
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
3889
|
-
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
3890
|
-
const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
|
|
3891
|
-
const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
|
|
3892
|
-
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
|
|
3893
|
-
if (item.last_error_summary) {
|
|
3894
|
-
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
3895
|
-
}
|
|
3896
|
-
if (item.next_action) {
|
|
3897
|
-
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
3898
|
-
}
|
|
3899
|
-
}
|
|
3900
|
-
});
|
|
3901
|
-
|
|
3902
|
-
queueCmd
|
|
3903
|
-
.command('status')
|
|
3904
|
-
.description('Show an operator-friendly merge queue summary')
|
|
3905
|
-
.option('--json', 'Output raw JSON')
|
|
3906
|
-
.addHelpText('after', `
|
|
3907
|
-
Plain English:
|
|
3908
|
-
Use this when finished branches are waiting to land and you want one safe queue view.
|
|
3909
|
-
|
|
3910
|
-
Examples:
|
|
3911
|
-
switchman queue status
|
|
3912
|
-
switchman queue status --json
|
|
3913
|
-
|
|
3914
|
-
What it helps you answer:
|
|
3915
|
-
- what lands next
|
|
3916
|
-
- what is blocked
|
|
3917
|
-
- what command should I run now
|
|
3918
|
-
`)
|
|
3919
|
-
.action((opts) => {
|
|
3920
|
-
const repoRoot = getRepo();
|
|
3921
|
-
const db = getDb(repoRoot);
|
|
3922
|
-
const items = listMergeQueue(db);
|
|
3923
|
-
const summary = buildQueueStatusSummary(items, { db, repoRoot });
|
|
3924
|
-
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
3925
|
-
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
3926
|
-
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
3927
|
-
db.close();
|
|
3928
|
-
|
|
3929
|
-
if (opts.json) {
|
|
3930
|
-
console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
|
|
3931
|
-
return;
|
|
3932
|
-
}
|
|
3933
|
-
|
|
3934
|
-
if (items.length === 0) {
|
|
3935
|
-
console.log('');
|
|
3936
|
-
console.log(chalk.bold('switchman queue status'));
|
|
3937
|
-
console.log('');
|
|
3938
|
-
console.log('Queue is empty.');
|
|
3939
|
-
console.log(`Add finished work with: ${chalk.cyan('switchman queue add --worktree agent1')}`);
|
|
3940
|
-
return;
|
|
3941
|
-
}
|
|
3942
|
-
|
|
3943
|
-
const queueHealth = summary.counts.blocked > 0
|
|
3944
|
-
? 'block'
|
|
3945
|
-
: summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
|
|
3946
|
-
? 'warn'
|
|
3947
|
-
: 'healthy';
|
|
3948
|
-
const queueHealthColor = colorForHealth(queueHealth);
|
|
3949
|
-
const retryingItems = items.filter((item) => item.status === 'retrying');
|
|
3950
|
-
const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
|
|
3951
|
-
const focusLine = focus
|
|
3952
|
-
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
3953
|
-
: 'Nothing waiting. Landing queue is clear.';
|
|
3954
|
-
|
|
3955
|
-
console.log('');
|
|
3956
|
-
console.log(queueHealthColor('='.repeat(72)));
|
|
3957
|
-
console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
|
|
3958
|
-
console.log(queueHealthColor('='.repeat(72)));
|
|
3959
|
-
console.log(renderSignalStrip([
|
|
3960
|
-
renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
|
|
3961
|
-
renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
|
|
3962
|
-
renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
|
|
3963
|
-
renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
|
|
3964
|
-
renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
|
|
3965
|
-
renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
3966
|
-
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
3967
|
-
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
3968
|
-
]));
|
|
3969
|
-
console.log(renderMetricRow([
|
|
3970
|
-
{ label: 'items', value: items.length, color: chalk.white },
|
|
3971
|
-
{ label: 'validating', value: summary.counts.validating, color: chalk.blue },
|
|
3972
|
-
{ label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
|
|
3973
|
-
{ label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
|
|
3974
|
-
]));
|
|
3975
|
-
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
3976
|
-
|
|
3977
|
-
const queueFocusLines = summary.next
|
|
3978
|
-
? [
|
|
3979
|
-
`${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
|
|
3980
|
-
...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
|
|
3981
|
-
...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
|
|
3982
|
-
` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
|
|
3983
|
-
]
|
|
3984
|
-
: [chalk.dim('No queued landing work right now.')];
|
|
3985
|
-
|
|
3986
|
-
const queueHeldBackLines = summary.held_back.length > 0
|
|
3987
|
-
? summary.held_back.flatMap((item) => {
|
|
3988
|
-
const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
|
|
3989
|
-
if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
|
|
3990
|
-
if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
|
|
3991
|
-
if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
|
|
3992
|
-
return lines;
|
|
3993
|
-
})
|
|
3994
|
-
: [chalk.green('Nothing significant is being held back.')];
|
|
3995
|
-
|
|
3996
|
-
const queueBlockedLines = summary.blocked.length > 0
|
|
3997
|
-
? summary.blocked.slice(0, 4).flatMap((item) => {
|
|
3998
|
-
const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
3999
|
-
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
4000
|
-
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4001
|
-
return lines;
|
|
4002
|
-
})
|
|
4003
|
-
: [chalk.green('Nothing blocked.')];
|
|
4004
|
-
|
|
4005
|
-
const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
|
|
4006
|
-
? items
|
|
4007
|
-
.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
4008
|
-
.slice(0, 4)
|
|
4009
|
-
.flatMap((item) => {
|
|
4010
|
-
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
4011
|
-
if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
|
|
4012
|
-
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4013
|
-
return lines;
|
|
4014
|
-
})
|
|
4015
|
-
: [chalk.green('No in-flight queue items right now.')];
|
|
4016
|
-
|
|
4017
|
-
const queueCommandLines = [
|
|
4018
|
-
`${chalk.cyan('$')} switchman queue run`,
|
|
4019
|
-
`${chalk.cyan('$')} switchman queue status --json`,
|
|
4020
|
-
...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
|
|
4021
|
-
];
|
|
4022
|
-
|
|
4023
|
-
const queuePlanLines = [
|
|
4024
|
-
...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4025
|
-
...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4026
|
-
...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4027
|
-
...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4028
|
-
...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4029
|
-
];
|
|
4030
|
-
const queueSequenceLines = summary.recommended_sequence?.length > 0
|
|
4031
|
-
? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
|
|
4032
|
-
: [chalk.green('No recommended sequence beyond the current landing focus.')];
|
|
4033
|
-
|
|
4034
|
-
console.log('');
|
|
4035
|
-
for (const block of [
|
|
4036
|
-
renderPanel('Landing focus', queueFocusLines, chalk.green),
|
|
4037
|
-
renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
|
|
4038
|
-
renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
|
|
4039
|
-
renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
|
|
4040
|
-
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
4041
|
-
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
4042
|
-
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
4043
|
-
]) {
|
|
4044
|
-
for (const line of block) console.log(line);
|
|
4045
|
-
console.log('');
|
|
4046
|
-
}
|
|
4047
|
-
|
|
4048
|
-
if (recentEvents.length > 0) {
|
|
4049
|
-
console.log(chalk.bold('Recent Queue Events:'));
|
|
4050
|
-
for (const event of recentEvents) {
|
|
4051
|
-
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
4052
|
-
}
|
|
4053
|
-
}
|
|
4054
|
-
});
|
|
4055
|
-
|
|
4056
|
-
queueCmd
|
|
4057
|
-
.command('run')
|
|
4058
|
-
.description('Process landing-queue items one at a time')
|
|
4059
|
-
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
4060
|
-
.option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
|
|
4061
|
-
.option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
|
|
4062
|
-
.option('--target <branch>', 'Default target branch', 'main')
|
|
4063
|
-
.option('--watch', 'Keep polling for new queue items')
|
|
4064
|
-
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
4065
|
-
.option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
|
|
4066
|
-
.option('--json', 'Output raw JSON')
|
|
4067
|
-
.addHelpText('after', `
|
|
4068
|
-
Examples:
|
|
4069
|
-
switchman queue run
|
|
4070
|
-
switchman queue run --follow-plan --merge-budget 2
|
|
4071
|
-
switchman queue run --watch
|
|
4072
|
-
switchman queue run --watch --watch-interval-ms 1000
|
|
4073
|
-
`)
|
|
4074
|
-
.action(async (opts) => {
|
|
4075
|
-
const repoRoot = getRepo();
|
|
4076
|
-
|
|
4077
|
-
try {
|
|
4078
|
-
const watch = Boolean(opts.watch);
|
|
4079
|
-
const followPlan = Boolean(opts.followPlan);
|
|
4080
|
-
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
4081
|
-
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
4082
|
-
const mergeBudget = opts.mergeBudget !== undefined
|
|
4083
|
-
? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
|
|
4084
|
-
: null;
|
|
4085
|
-
const aggregate = {
|
|
4086
|
-
processed: [],
|
|
4087
|
-
cycles: 0,
|
|
4088
|
-
watch,
|
|
4089
|
-
execution_policy: {
|
|
4090
|
-
follow_plan: followPlan,
|
|
4091
|
-
merge_budget: mergeBudget,
|
|
4092
|
-
merged_count: 0,
|
|
4093
|
-
},
|
|
4094
|
-
};
|
|
4095
|
-
|
|
4096
|
-
while (true) {
|
|
4097
|
-
const db = getDb(repoRoot);
|
|
4098
|
-
const result = await runMergeQueue(db, repoRoot, {
|
|
4099
|
-
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
4100
|
-
targetBranch: opts.target || 'main',
|
|
4101
|
-
followPlan,
|
|
4102
|
-
mergeBudget,
|
|
4103
|
-
});
|
|
4104
|
-
db.close();
|
|
4105
|
-
|
|
4106
|
-
aggregate.processed.push(...result.processed);
|
|
4107
|
-
aggregate.summary = result.summary;
|
|
4108
|
-
aggregate.deferred = result.deferred || aggregate.deferred || null;
|
|
4109
|
-
aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
|
|
4110
|
-
aggregate.cycles += 1;
|
|
4111
|
-
|
|
4112
|
-
if (!watch) break;
|
|
4113
|
-
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
4114
|
-
if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
|
|
4115
|
-
if (result.processed.length === 0) {
|
|
4116
|
-
sleepSync(watchIntervalMs);
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
|
|
4120
|
-
if (opts.json) {
|
|
4121
|
-
console.log(JSON.stringify(aggregate, null, 2));
|
|
4122
|
-
return;
|
|
4123
|
-
}
|
|
4124
|
-
|
|
4125
|
-
if (aggregate.processed.length === 0) {
|
|
4126
|
-
const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
|
|
4127
|
-
if (deferredFocus?.recommendation?.action) {
|
|
4128
|
-
console.log(chalk.yellow('No landing candidate is ready to run right now.'));
|
|
4129
|
-
console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
|
|
4130
|
-
if (followPlan) {
|
|
4131
|
-
console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
|
|
4132
|
-
}
|
|
4133
|
-
if (deferredFocus.recommendation?.summary) {
|
|
4134
|
-
console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
|
|
4135
|
-
}
|
|
4136
|
-
if (deferredFocus.recommendation?.command) {
|
|
4137
|
-
console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
|
|
4138
|
-
}
|
|
4139
|
-
} else {
|
|
4140
|
-
console.log(chalk.dim('No queued merge items.'));
|
|
4141
|
-
}
|
|
4142
|
-
await maybeCaptureTelemetry('queue_used', {
|
|
4143
|
-
watch,
|
|
4144
|
-
cycles: aggregate.cycles,
|
|
4145
|
-
processed_count: 0,
|
|
4146
|
-
merged_count: 0,
|
|
4147
|
-
blocked_count: 0,
|
|
4148
|
-
});
|
|
4149
|
-
return;
|
|
4150
|
-
}
|
|
4151
|
-
|
|
4152
|
-
for (const entry of aggregate.processed) {
|
|
4153
|
-
const item = entry.item;
|
|
4154
|
-
if (entry.status === 'merged') {
|
|
4155
|
-
pushSyncEvent('queue_merged', {
|
|
4156
|
-
item_id: item.id,
|
|
4157
|
-
source_type: item.source_type,
|
|
4158
|
-
source_ref: item.source_ref,
|
|
4159
|
-
source_worktree: item.source_worktree || null,
|
|
4160
|
-
target_branch: item.target_branch,
|
|
4161
|
-
merged_commit: item.merged_commit || null,
|
|
4162
|
-
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
4163
|
-
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
4164
|
-
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
4165
|
-
} else {
|
|
4166
|
-
pushSyncEvent('queue_blocked', {
|
|
4167
|
-
item_id: item.id,
|
|
4168
|
-
source_type: item.source_type,
|
|
4169
|
-
source_ref: item.source_ref,
|
|
4170
|
-
source_worktree: item.source_worktree || null,
|
|
4171
|
-
target_branch: item.target_branch,
|
|
4172
|
-
error_code: item.last_error_code || null,
|
|
4173
|
-
error_summary: item.last_error_summary || null,
|
|
4174
|
-
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
4175
|
-
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
4176
|
-
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
4177
|
-
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4178
|
-
}
|
|
4179
|
-
}
|
|
4180
|
-
|
|
4181
|
-
if (aggregate.execution_policy.follow_plan) {
|
|
4182
|
-
console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
|
|
4183
|
-
}
|
|
4184
|
-
|
|
4185
|
-
await maybeCaptureTelemetry('queue_used', {
|
|
4186
|
-
watch,
|
|
4187
|
-
cycles: aggregate.cycles,
|
|
4188
|
-
processed_count: aggregate.processed.length,
|
|
4189
|
-
merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
|
|
4190
|
-
blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
|
|
4191
|
-
});
|
|
4192
|
-
} catch (err) {
|
|
4193
|
-
console.error(chalk.red(err.message));
|
|
4194
|
-
process.exitCode = 1;
|
|
4195
|
-
}
|
|
4196
|
-
});
|
|
4038
|
+
registerQueueCommands(program, {
|
|
4039
|
+
buildQueueStatusSummary,
|
|
4040
|
+
chalk,
|
|
4041
|
+
colorForHealth,
|
|
4042
|
+
escalateMergeQueueItem,
|
|
4043
|
+
enqueueMergeItem,
|
|
4044
|
+
evaluatePipelinePolicyGate,
|
|
4045
|
+
getDb,
|
|
4046
|
+
getRepo,
|
|
4047
|
+
healthLabel,
|
|
4048
|
+
listMergeQueue,
|
|
4049
|
+
listMergeQueueEvents,
|
|
4050
|
+
listWorktrees,
|
|
4051
|
+
maybeCaptureTelemetry,
|
|
4052
|
+
preparePipelineLandingTarget,
|
|
4053
|
+
printErrorWithNext,
|
|
4054
|
+
pushSyncEvent,
|
|
4055
|
+
renderChip,
|
|
4056
|
+
renderMetricRow,
|
|
4057
|
+
renderPanel,
|
|
4058
|
+
renderSignalStrip,
|
|
4059
|
+
removeMergeQueueItem,
|
|
4060
|
+
retryMergeQueueItem,
|
|
4061
|
+
runMergeQueue,
|
|
4062
|
+
sleepSync,
|
|
4063
|
+
statusBadge,
|
|
4064
|
+
});
|
|
4197
4065
|
|
|
4198
4066
|
program
|
|
4199
4067
|
.command('merge')
|
|
@@ -4331,82 +4199,6 @@ Examples:
|
|
|
4331
4199
|
}
|
|
4332
4200
|
});
|
|
4333
4201
|
|
|
4334
|
-
queueCmd
|
|
4335
|
-
.command('retry <itemId>')
|
|
4336
|
-
.description('Retry a blocked merge queue item')
|
|
4337
|
-
.option('--json', 'Output raw JSON')
|
|
4338
|
-
.action((itemId, opts) => {
|
|
4339
|
-
const repoRoot = getRepo();
|
|
4340
|
-
const db = getDb(repoRoot);
|
|
4341
|
-
const item = retryMergeQueueItem(db, itemId);
|
|
4342
|
-
db.close();
|
|
4343
|
-
|
|
4344
|
-
if (!item) {
|
|
4345
|
-
printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
|
|
4346
|
-
process.exitCode = 1;
|
|
4347
|
-
return;
|
|
4348
|
-
}
|
|
4349
|
-
|
|
4350
|
-
if (opts.json) {
|
|
4351
|
-
console.log(JSON.stringify(item, null, 2));
|
|
4352
|
-
return;
|
|
4353
|
-
}
|
|
4354
|
-
|
|
4355
|
-
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
4356
|
-
});
|
|
4357
|
-
|
|
4358
|
-
queueCmd
|
|
4359
|
-
.command('escalate <itemId>')
|
|
4360
|
-
.description('Mark a queue item as needing explicit operator review before landing')
|
|
4361
|
-
.option('--reason <text>', 'Why this item is being escalated')
|
|
4362
|
-
.option('--json', 'Output raw JSON')
|
|
4363
|
-
.action((itemId, opts) => {
|
|
4364
|
-
const repoRoot = getRepo();
|
|
4365
|
-
const db = getDb(repoRoot);
|
|
4366
|
-
const item = escalateMergeQueueItem(db, itemId, {
|
|
4367
|
-
summary: opts.reason || null,
|
|
4368
|
-
nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
|
|
4369
|
-
});
|
|
4370
|
-
db.close();
|
|
4371
|
-
|
|
4372
|
-
if (!item) {
|
|
4373
|
-
printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
|
|
4374
|
-
process.exitCode = 1;
|
|
4375
|
-
return;
|
|
4376
|
-
}
|
|
4377
|
-
|
|
4378
|
-
if (opts.json) {
|
|
4379
|
-
console.log(JSON.stringify(item, null, 2));
|
|
4380
|
-
return;
|
|
4381
|
-
}
|
|
4382
|
-
|
|
4383
|
-
console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
|
|
4384
|
-
if (item.last_error_summary) {
|
|
4385
|
-
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
4386
|
-
}
|
|
4387
|
-
if (item.next_action) {
|
|
4388
|
-
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4389
|
-
}
|
|
4390
|
-
});
|
|
4391
|
-
|
|
4392
|
-
queueCmd
|
|
4393
|
-
.command('remove <itemId>')
|
|
4394
|
-
.description('Remove a merge queue item')
|
|
4395
|
-
.action((itemId) => {
|
|
4396
|
-
const repoRoot = getRepo();
|
|
4397
|
-
const db = getDb(repoRoot);
|
|
4398
|
-
const item = removeMergeQueueItem(db, itemId);
|
|
4399
|
-
db.close();
|
|
4400
|
-
|
|
4401
|
-
if (!item) {
|
|
4402
|
-
printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
|
|
4403
|
-
process.exitCode = 1;
|
|
4404
|
-
return;
|
|
4405
|
-
}
|
|
4406
|
-
|
|
4407
|
-
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
4408
|
-
});
|
|
4409
|
-
|
|
4410
4202
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
4411
4203
|
|
|
4412
4204
|
const explainCmd = program.command('explain').description('Explain why Switchman blocked something and what to do next');
|
|
@@ -5451,317 +5243,37 @@ Examples:
|
|
|
5451
5243
|
|
|
5452
5244
|
// ── lease ────────────────────────────────────────────────────────────────────
|
|
5453
5245
|
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
Examples:
|
|
5472
|
-
switchman lease acquire task-123 agent2
|
|
5473
|
-
switchman lease acquire task-123 agent2 --agent cursor
|
|
5474
|
-
`)
|
|
5475
|
-
.action((taskId, worktree, opts) => {
|
|
5476
|
-
const repoRoot = getRepo();
|
|
5477
|
-
const db = getDb(repoRoot);
|
|
5478
|
-
const task = getTask(db, taskId);
|
|
5479
|
-
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
5480
|
-
db.close();
|
|
5481
|
-
|
|
5482
|
-
if (!lease || !task) {
|
|
5483
|
-
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
5484
|
-
else printErrorWithNext('Could not start a work session. The task may not exist or may already be in progress.', 'switchman task list --status pending');
|
|
5485
|
-
process.exitCode = 1;
|
|
5486
|
-
return;
|
|
5487
|
-
}
|
|
5488
|
-
|
|
5489
|
-
if (opts.json) {
|
|
5490
|
-
console.log(JSON.stringify({
|
|
5491
|
-
lease,
|
|
5492
|
-
task: taskJsonWithLease(task, worktree, lease).task,
|
|
5493
|
-
}, null, 2));
|
|
5494
|
-
return;
|
|
5495
|
-
}
|
|
5496
|
-
|
|
5497
|
-
console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
|
|
5498
|
-
console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
|
|
5499
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
|
|
5500
|
-
});
|
|
5501
|
-
|
|
5502
|
-
leaseCmd
|
|
5503
|
-
.command('next')
|
|
5504
|
-
.description('Start the next pending task and open a tracked work session for it')
|
|
5505
|
-
.option('--json', 'Output as JSON')
|
|
5506
|
-
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
5507
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
5508
|
-
.addHelpText('after', `
|
|
5509
|
-
Examples:
|
|
5510
|
-
switchman lease next
|
|
5511
|
-
switchman lease next --json
|
|
5512
|
-
switchman lease next --worktree agent2 --agent cursor
|
|
5513
|
-
`)
|
|
5514
|
-
.action((opts) => {
|
|
5515
|
-
const repoRoot = getRepo();
|
|
5516
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
5517
|
-
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
5518
|
-
|
|
5519
|
-
if (!task) {
|
|
5520
|
-
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
5521
|
-
else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
|
|
5522
|
-
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
5523
|
-
return;
|
|
5524
|
-
}
|
|
5525
|
-
|
|
5526
|
-
if (!lease) {
|
|
5527
|
-
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
5528
|
-
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
5529
|
-
return;
|
|
5530
|
-
}
|
|
5531
|
-
|
|
5532
|
-
if (opts.json) {
|
|
5533
|
-
console.log(JSON.stringify({
|
|
5534
|
-
lease,
|
|
5535
|
-
...taskJsonWithLease(task, worktreeName, lease),
|
|
5536
|
-
}, null, 2));
|
|
5537
|
-
return;
|
|
5538
|
-
}
|
|
5539
|
-
|
|
5540
|
-
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
5541
|
-
pushSyncEvent('lease_acquired', { task_id: task.id, title: task.title }, { worktree: worktreeName }).catch(() => {});
|
|
5542
|
-
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
5543
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
5544
|
-
});
|
|
5545
|
-
|
|
5546
|
-
leaseCmd
|
|
5547
|
-
.command('list')
|
|
5548
|
-
.description('List leases, newest first')
|
|
5549
|
-
.option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
|
|
5550
|
-
.action((opts) => {
|
|
5551
|
-
const repoRoot = getRepo();
|
|
5552
|
-
const db = getDb(repoRoot);
|
|
5553
|
-
const leases = listLeases(db, opts.status);
|
|
5554
|
-
db.close();
|
|
5555
|
-
|
|
5556
|
-
if (!leases.length) {
|
|
5557
|
-
console.log(chalk.dim('No leases found.'));
|
|
5558
|
-
return;
|
|
5559
|
-
}
|
|
5560
|
-
|
|
5561
|
-
console.log('');
|
|
5562
|
-
for (const lease of leases) {
|
|
5563
|
-
console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
|
|
5564
|
-
console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
|
|
5565
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
|
|
5566
|
-
console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
|
|
5567
|
-
if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
|
|
5568
|
-
console.log('');
|
|
5569
|
-
}
|
|
5570
|
-
});
|
|
5571
|
-
|
|
5572
|
-
leaseCmd
|
|
5573
|
-
.command('heartbeat <leaseId>')
|
|
5574
|
-
.description('Refresh the heartbeat timestamp for an active lease')
|
|
5575
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
5576
|
-
.option('--json', 'Output as JSON')
|
|
5577
|
-
.action((leaseId, opts) => {
|
|
5578
|
-
const repoRoot = getRepo();
|
|
5579
|
-
const db = getDb(repoRoot);
|
|
5580
|
-
const lease = heartbeatLease(db, leaseId, opts.agent || null);
|
|
5581
|
-
db.close();
|
|
5582
|
-
|
|
5583
|
-
if (!lease) {
|
|
5584
|
-
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
5585
|
-
else printErrorWithNext(`No active work session found for ${leaseId}.`, 'switchman lease list --status active');
|
|
5586
|
-
process.exitCode = 1;
|
|
5587
|
-
return;
|
|
5588
|
-
}
|
|
5589
|
-
|
|
5590
|
-
if (opts.json) {
|
|
5591
|
-
console.log(JSON.stringify({ lease }, null, 2));
|
|
5592
|
-
return;
|
|
5593
|
-
}
|
|
5594
|
-
|
|
5595
|
-
console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
|
|
5596
|
-
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
5597
|
-
});
|
|
5598
|
-
|
|
5599
|
-
leaseCmd
|
|
5600
|
-
.command('reap')
|
|
5601
|
-
.description('Clean up abandoned work sessions and release their file locks')
|
|
5602
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
5603
|
-
.option('--json', 'Output as JSON')
|
|
5604
|
-
.addHelpText('after', `
|
|
5605
|
-
Examples:
|
|
5606
|
-
switchman lease reap
|
|
5607
|
-
switchman lease reap --stale-after-minutes 20
|
|
5608
|
-
`)
|
|
5609
|
-
.action((opts) => {
|
|
5610
|
-
const repoRoot = getRepo();
|
|
5611
|
-
const db = getDb(repoRoot);
|
|
5612
|
-
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
5613
|
-
const staleAfterMinutes = opts.staleAfterMinutes
|
|
5614
|
-
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
5615
|
-
: leasePolicy.stale_after_minutes;
|
|
5616
|
-
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
5617
|
-
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
5618
|
-
});
|
|
5619
|
-
db.close();
|
|
5620
|
-
|
|
5621
|
-
if (opts.json) {
|
|
5622
|
-
console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
|
|
5623
|
-
return;
|
|
5624
|
-
}
|
|
5625
|
-
|
|
5626
|
-
if (!expired.length) {
|
|
5627
|
-
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
5628
|
-
return;
|
|
5629
|
-
}
|
|
5630
|
-
|
|
5631
|
-
console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
|
|
5632
|
-
for (const lease of expired) {
|
|
5633
|
-
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
5634
|
-
}
|
|
5635
|
-
});
|
|
5636
|
-
|
|
5637
|
-
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
5638
|
-
|
|
5639
|
-
leasePolicyCmd
|
|
5640
|
-
.command('set')
|
|
5641
|
-
.description('Persist a stale-lease policy for this repo')
|
|
5642
|
-
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
5643
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
5644
|
-
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
5645
|
-
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
5646
|
-
.option('--json', 'Output as JSON')
|
|
5647
|
-
.action((opts) => {
|
|
5648
|
-
const repoRoot = getRepo();
|
|
5649
|
-
const current = loadLeasePolicy(repoRoot);
|
|
5650
|
-
const next = {
|
|
5651
|
-
...current,
|
|
5652
|
-
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
5653
|
-
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
5654
|
-
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
5655
|
-
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
5656
|
-
};
|
|
5657
|
-
const path = writeLeasePolicy(repoRoot, next);
|
|
5658
|
-
const saved = loadLeasePolicy(repoRoot);
|
|
5659
|
-
|
|
5660
|
-
if (opts.json) {
|
|
5661
|
-
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
5662
|
-
return;
|
|
5663
|
-
}
|
|
5664
|
-
|
|
5665
|
-
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
5666
|
-
console.log(` ${chalk.dim(path)}`);
|
|
5667
|
-
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
5668
|
-
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
5669
|
-
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
5670
|
-
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
5671
|
-
});
|
|
5672
|
-
|
|
5673
|
-
leasePolicyCmd
|
|
5674
|
-
.description('Show the active stale-lease policy for this repo')
|
|
5675
|
-
.option('--json', 'Output as JSON')
|
|
5676
|
-
.action((opts) => {
|
|
5677
|
-
const repoRoot = getRepo();
|
|
5678
|
-
const policy = loadLeasePolicy(repoRoot);
|
|
5679
|
-
if (opts.json) {
|
|
5680
|
-
console.log(JSON.stringify({ policy }, null, 2));
|
|
5681
|
-
return;
|
|
5682
|
-
}
|
|
5683
|
-
|
|
5684
|
-
console.log(chalk.bold('Lease policy'));
|
|
5685
|
-
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
|
|
5686
|
-
console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
|
|
5687
|
-
console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
|
|
5688
|
-
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
|
|
5689
|
-
});
|
|
5246
|
+
registerLeaseCommands(program, {
|
|
5247
|
+
acquireNextTaskLeaseWithRetries,
|
|
5248
|
+
chalk,
|
|
5249
|
+
getCurrentWorktreeName,
|
|
5250
|
+
getDb,
|
|
5251
|
+
getRepo,
|
|
5252
|
+
getTask,
|
|
5253
|
+
heartbeatLease,
|
|
5254
|
+
listLeases,
|
|
5255
|
+
loadLeasePolicy,
|
|
5256
|
+
pushSyncEvent,
|
|
5257
|
+
reapStaleLeases,
|
|
5258
|
+
startTaskLease,
|
|
5259
|
+
statusBadge,
|
|
5260
|
+
taskJsonWithLease,
|
|
5261
|
+
writeLeasePolicy,
|
|
5262
|
+
});
|
|
5690
5263
|
|
|
5691
5264
|
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
5692
5265
|
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
wtCmd
|
|
5705
|
-
.command('add <name> <path> <branch>')
|
|
5706
|
-
.description('Register a workspace with Switchman')
|
|
5707
|
-
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
5708
|
-
.action((name, path, branch, opts) => {
|
|
5709
|
-
const repoRoot = getRepo();
|
|
5710
|
-
const db = getDb(repoRoot);
|
|
5711
|
-
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
5712
|
-
db.close();
|
|
5713
|
-
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
5714
|
-
});
|
|
5715
|
-
|
|
5716
|
-
wtCmd
|
|
5717
|
-
.command('list')
|
|
5718
|
-
.description('List all registered workspaces')
|
|
5719
|
-
.action(() => {
|
|
5720
|
-
const repoRoot = getRepo();
|
|
5721
|
-
const db = getDb(repoRoot);
|
|
5722
|
-
const worktrees = listWorktrees(db);
|
|
5723
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
5724
|
-
|
|
5725
|
-
if (!worktrees.length && !gitWorktrees.length) {
|
|
5726
|
-
db.close();
|
|
5727
|
-
console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
|
|
5728
|
-
return;
|
|
5729
|
-
}
|
|
5730
|
-
|
|
5731
|
-
// Show git worktrees (source of truth) annotated with db info
|
|
5732
|
-
const complianceReport = evaluateRepoCompliance(db, repoRoot, gitWorktrees);
|
|
5733
|
-
console.log('');
|
|
5734
|
-
console.log(chalk.bold('Git Worktrees:'));
|
|
5735
|
-
for (const wt of gitWorktrees) {
|
|
5736
|
-
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
5737
|
-
const complianceInfo = complianceReport.worktreeCompliance.find((entry) => entry.worktree === wt.name) || null;
|
|
5738
|
-
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
5739
|
-
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
5740
|
-
const compliance = complianceInfo?.compliance_state ? statusBadge(complianceInfo.compliance_state) : dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
5741
|
-
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
5742
|
-
console.log(` ${chalk.dim(wt.path)}`);
|
|
5743
|
-
if ((complianceInfo?.unclaimed_changed_files || []).length > 0) {
|
|
5744
|
-
console.log(` ${chalk.red('files:')} ${complianceInfo.unclaimed_changed_files.slice(0, 5).join(', ')}${complianceInfo.unclaimed_changed_files.length > 5 ? ` ${chalk.dim(`+${complianceInfo.unclaimed_changed_files.length - 5} more`)}` : ''}`);
|
|
5745
|
-
}
|
|
5746
|
-
}
|
|
5747
|
-
console.log('');
|
|
5748
|
-
db.close();
|
|
5749
|
-
});
|
|
5750
|
-
|
|
5751
|
-
wtCmd
|
|
5752
|
-
.command('sync')
|
|
5753
|
-
.description('Sync Git workspaces into the Switchman database')
|
|
5754
|
-
.action(() => {
|
|
5755
|
-
const repoRoot = getRepo();
|
|
5756
|
-
const db = getDb(repoRoot);
|
|
5757
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
5758
|
-
for (const wt of gitWorktrees) {
|
|
5759
|
-
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
5760
|
-
}
|
|
5761
|
-
db.close();
|
|
5762
|
-
installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
5763
|
-
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
5764
|
-
});
|
|
5266
|
+
registerWorktreeCommands(program, {
|
|
5267
|
+
chalk,
|
|
5268
|
+
evaluateRepoCompliance,
|
|
5269
|
+
getDb,
|
|
5270
|
+
getRepo,
|
|
5271
|
+
installMcpConfig,
|
|
5272
|
+
listGitWorktrees,
|
|
5273
|
+
listWorktrees,
|
|
5274
|
+
registerWorktree,
|
|
5275
|
+
statusBadge,
|
|
5276
|
+
});
|
|
5765
5277
|
|
|
5766
5278
|
// ── claim ──────────────────────────────────────────────────────────────────────
|
|
5767
5279
|
|
|
@@ -6183,11 +5695,47 @@ Use this first when the repo feels stuck.
|
|
|
6183
5695
|
sleepSync(watchIntervalMs);
|
|
6184
5696
|
}
|
|
6185
5697
|
|
|
6186
|
-
if (watch) {
|
|
6187
|
-
await maybeCaptureTelemetry('status_watch_used', {
|
|
6188
|
-
cycles,
|
|
6189
|
-
interval_ms: watchIntervalMs,
|
|
5698
|
+
if (watch) {
|
|
5699
|
+
await maybeCaptureTelemetry('status_watch_used', {
|
|
5700
|
+
cycles,
|
|
5701
|
+
interval_ms: watchIntervalMs,
|
|
5702
|
+
});
|
|
5703
|
+
}
|
|
5704
|
+
});
|
|
5705
|
+
|
|
5706
|
+
program
|
|
5707
|
+
.command('recover')
|
|
5708
|
+
.description('Recover abandoned agent work, repair safe interrupted state, and point at the right checkpoint')
|
|
5709
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for stale lease recovery')
|
|
5710
|
+
.option('--json', 'Output raw JSON')
|
|
5711
|
+
.addHelpText('after', `
|
|
5712
|
+
Examples:
|
|
5713
|
+
switchman recover
|
|
5714
|
+
switchman recover --stale-after-minutes 20
|
|
5715
|
+
switchman recover --json
|
|
5716
|
+
|
|
5717
|
+
Use this when an agent crashed, a worktree was abandoned mid-task, or the repo feels stuck after interrupted work.
|
|
5718
|
+
`)
|
|
5719
|
+
.action((opts) => {
|
|
5720
|
+
const repoRoot = getRepo();
|
|
5721
|
+
const db = getDb(repoRoot);
|
|
5722
|
+
|
|
5723
|
+
try {
|
|
5724
|
+
const report = buildRecoverReport(db, repoRoot, {
|
|
5725
|
+
staleAfterMinutes: opts.staleAfterMinutes || null,
|
|
6190
5726
|
});
|
|
5727
|
+
db.close();
|
|
5728
|
+
|
|
5729
|
+
if (opts.json) {
|
|
5730
|
+
console.log(JSON.stringify(report, null, 2));
|
|
5731
|
+
return;
|
|
5732
|
+
}
|
|
5733
|
+
|
|
5734
|
+
printRecoverSummary(report);
|
|
5735
|
+
} catch (err) {
|
|
5736
|
+
db.close();
|
|
5737
|
+
printErrorWithNext(err.message, 'switchman status');
|
|
5738
|
+
process.exitCode = 1;
|
|
6191
5739
|
}
|
|
6192
5740
|
});
|
|
6193
5741
|
|
|
@@ -6362,334 +5910,29 @@ Examples:
|
|
|
6362
5910
|
|
|
6363
5911
|
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
6364
5912
|
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
auditCmd._switchmanAdvanced = true;
|
|
6375
|
-
|
|
6376
|
-
auditCmd
|
|
6377
|
-
.command('change <pipelineId>')
|
|
6378
|
-
.description('Show a signed, operator-friendly history for one pipeline')
|
|
6379
|
-
.option('--json', 'Output raw JSON')
|
|
6380
|
-
.action((pipelineId, options) => {
|
|
6381
|
-
const repoRoot = getRepo();
|
|
6382
|
-
const db = getDb(repoRoot);
|
|
6383
|
-
|
|
6384
|
-
try {
|
|
6385
|
-
const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
|
|
6386
|
-
db.close();
|
|
6387
|
-
|
|
6388
|
-
if (options.json) {
|
|
6389
|
-
console.log(JSON.stringify(report, null, 2));
|
|
6390
|
-
return;
|
|
6391
|
-
}
|
|
6392
|
-
|
|
6393
|
-
console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
|
|
6394
|
-
console.log(` ${chalk.dim('title:')} ${report.title}`);
|
|
6395
|
-
console.log(` ${chalk.dim('events:')} ${report.events.length}`);
|
|
6396
|
-
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
6397
|
-
for (const event of report.events.slice(-20)) {
|
|
6398
|
-
const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
|
|
6399
|
-
console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
|
|
6400
|
-
console.log(` ${event.summary}`);
|
|
6401
|
-
}
|
|
6402
|
-
} catch (err) {
|
|
6403
|
-
db.close();
|
|
6404
|
-
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
6405
|
-
process.exitCode = 1;
|
|
6406
|
-
}
|
|
6407
|
-
});
|
|
6408
|
-
|
|
6409
|
-
auditCmd
|
|
6410
|
-
.command('verify')
|
|
6411
|
-
.description('Verify the audit log hash chain and project signatures')
|
|
6412
|
-
.option('--json', 'Output verification details as JSON')
|
|
6413
|
-
.action((options) => {
|
|
6414
|
-
const repo = getRepo();
|
|
6415
|
-
const db = getDb(repo);
|
|
6416
|
-
const result = verifyAuditTrail(db);
|
|
6417
|
-
|
|
6418
|
-
if (options.json) {
|
|
6419
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6420
|
-
process.exit(result.ok ? 0 : 1);
|
|
6421
|
-
}
|
|
6422
|
-
|
|
6423
|
-
if (result.ok) {
|
|
6424
|
-
console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
|
|
6425
|
-
return;
|
|
6426
|
-
}
|
|
6427
|
-
|
|
6428
|
-
console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
|
|
6429
|
-
for (const failure of result.failures.slice(0, 10)) {
|
|
6430
|
-
const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
|
|
6431
|
-
console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
|
|
6432
|
-
}
|
|
6433
|
-
if (result.failures.length > 10) {
|
|
6434
|
-
console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
|
|
6435
|
-
}
|
|
6436
|
-
process.exit(1);
|
|
6437
|
-
});
|
|
6438
|
-
|
|
6439
|
-
gateCmd
|
|
6440
|
-
.command('commit')
|
|
6441
|
-
.description('Validate current worktree changes against the active lease and claims')
|
|
6442
|
-
.option('--json', 'Output raw JSON')
|
|
6443
|
-
.action((opts) => {
|
|
6444
|
-
const repoRoot = getRepo();
|
|
6445
|
-
const db = getDb(repoRoot);
|
|
6446
|
-
const result = runCommitGate(db, repoRoot);
|
|
6447
|
-
db.close();
|
|
6448
|
-
|
|
6449
|
-
if (opts.json) {
|
|
6450
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6451
|
-
} else if (result.ok) {
|
|
6452
|
-
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
6453
|
-
} else {
|
|
6454
|
-
console.log(chalk.red(`✗ ${result.summary}`));
|
|
6455
|
-
for (const violation of result.violations) {
|
|
6456
|
-
const label = violation.file || '(worktree)';
|
|
6457
|
-
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
6458
|
-
}
|
|
6459
|
-
}
|
|
6460
|
-
|
|
6461
|
-
if (!result.ok) process.exitCode = 1;
|
|
6462
|
-
});
|
|
6463
|
-
|
|
6464
|
-
gateCmd
|
|
6465
|
-
.command('merge')
|
|
6466
|
-
.description('Validate current worktree changes before recording a merge commit')
|
|
6467
|
-
.option('--json', 'Output raw JSON')
|
|
6468
|
-
.action((opts) => {
|
|
6469
|
-
const repoRoot = getRepo();
|
|
6470
|
-
const db = getDb(repoRoot);
|
|
6471
|
-
const result = runCommitGate(db, repoRoot);
|
|
6472
|
-
db.close();
|
|
6473
|
-
|
|
6474
|
-
if (opts.json) {
|
|
6475
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6476
|
-
} else if (result.ok) {
|
|
6477
|
-
console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
|
|
6478
|
-
} else {
|
|
6479
|
-
console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
|
|
6480
|
-
for (const violation of result.violations) {
|
|
6481
|
-
const label = violation.file || '(worktree)';
|
|
6482
|
-
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
6483
|
-
}
|
|
6484
|
-
}
|
|
6485
|
-
|
|
6486
|
-
if (!result.ok) process.exitCode = 1;
|
|
6487
|
-
});
|
|
6488
|
-
|
|
6489
|
-
gateCmd
|
|
6490
|
-
.command('install')
|
|
6491
|
-
.description('Install git hooks that run the Switchman commit and merge gates')
|
|
6492
|
-
.action(() => {
|
|
6493
|
-
const repoRoot = getRepo();
|
|
6494
|
-
const hookPaths = installGateHooks(repoRoot);
|
|
6495
|
-
console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
|
|
6496
|
-
console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
|
|
6497
|
-
});
|
|
6498
|
-
|
|
6499
|
-
gateCmd
|
|
6500
|
-
.command('ci')
|
|
6501
|
-
.description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
|
|
6502
|
-
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
6503
|
-
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
6504
|
-
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
6505
|
-
.option('--json', 'Output raw JSON')
|
|
6506
|
-
.action(async (opts) => {
|
|
6507
|
-
const repoRoot = getRepo();
|
|
6508
|
-
const db = getDb(repoRoot);
|
|
6509
|
-
const report = await scanAllWorktrees(db, repoRoot);
|
|
6510
|
-
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
6511
|
-
db.close();
|
|
6512
|
-
|
|
6513
|
-
const ok = report.conflicts.length === 0
|
|
6514
|
-
&& report.fileConflicts.length === 0
|
|
6515
|
-
&& (report.ownershipConflicts?.length || 0) === 0
|
|
6516
|
-
&& (report.semanticConflicts?.length || 0) === 0
|
|
6517
|
-
&& report.unclaimedChanges.length === 0
|
|
6518
|
-
&& report.complianceSummary.non_compliant === 0
|
|
6519
|
-
&& report.complianceSummary.stale === 0
|
|
6520
|
-
&& aiGate.status !== 'blocked'
|
|
6521
|
-
&& (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
|
|
6522
|
-
|
|
6523
|
-
const result = {
|
|
6524
|
-
ok,
|
|
6525
|
-
summary: ok
|
|
6526
|
-
? `Repo gate passed for ${report.worktrees.length} worktree(s).`
|
|
6527
|
-
: 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
|
|
6528
|
-
compliance: report.complianceSummary,
|
|
6529
|
-
unclaimed_changes: report.unclaimedChanges,
|
|
6530
|
-
file_conflicts: report.fileConflicts,
|
|
6531
|
-
ownership_conflicts: report.ownershipConflicts || [],
|
|
6532
|
-
semantic_conflicts: report.semanticConflicts || [],
|
|
6533
|
-
branch_conflicts: report.conflicts,
|
|
6534
|
-
ai_gate_status: aiGate.status,
|
|
6535
|
-
boundary_validations: aiGate.boundary_validations || [],
|
|
6536
|
-
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
6537
|
-
};
|
|
6538
|
-
|
|
6539
|
-
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
6540
|
-
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
6541
|
-
writeGitHubCiStatus({
|
|
6542
|
-
result,
|
|
6543
|
-
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
6544
|
-
outputPath: githubTargets.outputPath,
|
|
6545
|
-
});
|
|
6546
|
-
}
|
|
6547
|
-
|
|
6548
|
-
if (opts.json) {
|
|
6549
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6550
|
-
} else if (ok) {
|
|
6551
|
-
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
6552
|
-
} else {
|
|
6553
|
-
console.log(chalk.red(`✗ ${result.summary}`));
|
|
6554
|
-
if (result.unclaimed_changes.length > 0) {
|
|
6555
|
-
console.log(chalk.bold(' Unclaimed changes:'));
|
|
6556
|
-
for (const entry of result.unclaimed_changes) {
|
|
6557
|
-
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
|
|
6558
|
-
}
|
|
6559
|
-
}
|
|
6560
|
-
if (result.file_conflicts.length > 0) {
|
|
6561
|
-
console.log(chalk.bold(' File conflicts:'));
|
|
6562
|
-
for (const conflict of result.file_conflicts) {
|
|
6563
|
-
console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
|
|
6564
|
-
}
|
|
6565
|
-
}
|
|
6566
|
-
if (result.ownership_conflicts.length > 0) {
|
|
6567
|
-
console.log(chalk.bold(' Ownership conflicts:'));
|
|
6568
|
-
for (const conflict of result.ownership_conflicts) {
|
|
6569
|
-
if (conflict.type === 'subsystem_overlap') {
|
|
6570
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
|
|
6571
|
-
} else {
|
|
6572
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
|
|
6573
|
-
}
|
|
6574
|
-
}
|
|
6575
|
-
}
|
|
6576
|
-
if (result.semantic_conflicts.length > 0) {
|
|
6577
|
-
console.log(chalk.bold(' Semantic conflicts:'));
|
|
6578
|
-
for (const conflict of result.semantic_conflicts) {
|
|
6579
|
-
console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
6580
|
-
}
|
|
6581
|
-
}
|
|
6582
|
-
if (result.branch_conflicts.length > 0) {
|
|
6583
|
-
console.log(chalk.bold(' Branch conflicts:'));
|
|
6584
|
-
for (const conflict of result.branch_conflicts) {
|
|
6585
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
|
|
6586
|
-
}
|
|
6587
|
-
}
|
|
6588
|
-
if (result.boundary_validations.length > 0) {
|
|
6589
|
-
console.log(chalk.bold(' Boundary validations:'));
|
|
6590
|
-
for (const validation of result.boundary_validations) {
|
|
6591
|
-
console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
6592
|
-
}
|
|
6593
|
-
}
|
|
6594
|
-
if (result.dependency_invalidations.length > 0) {
|
|
6595
|
-
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
6596
|
-
for (const invalidation of result.dependency_invalidations) {
|
|
6597
|
-
console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
|
|
6598
|
-
}
|
|
6599
|
-
}
|
|
6600
|
-
}
|
|
6601
|
-
|
|
6602
|
-
await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
|
|
6603
|
-
worktree_count: report.worktrees.length,
|
|
6604
|
-
unclaimed_change_count: result.unclaimed_changes.length,
|
|
6605
|
-
file_conflict_count: result.file_conflicts.length,
|
|
6606
|
-
ownership_conflict_count: result.ownership_conflicts.length,
|
|
6607
|
-
semantic_conflict_count: result.semantic_conflicts.length,
|
|
6608
|
-
branch_conflict_count: result.branch_conflicts.length,
|
|
6609
|
-
});
|
|
6610
|
-
|
|
6611
|
-
if (!ok) process.exitCode = 1;
|
|
6612
|
-
});
|
|
6613
|
-
|
|
6614
|
-
gateCmd
|
|
6615
|
-
.command('install-ci')
|
|
6616
|
-
.description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
|
|
6617
|
-
.option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
|
|
6618
|
-
.action((opts) => {
|
|
6619
|
-
const repoRoot = getRepo();
|
|
6620
|
-
const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
|
|
6621
|
-
console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
|
|
6622
|
-
});
|
|
6623
|
-
|
|
6624
|
-
gateCmd
|
|
6625
|
-
.command('ai')
|
|
6626
|
-
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
6627
|
-
.option('--json', 'Output raw JSON')
|
|
6628
|
-
.action(async (opts) => {
|
|
6629
|
-
const repoRoot = getRepo();
|
|
6630
|
-
const db = getDb(repoRoot);
|
|
6631
|
-
const result = await runAiMergeGate(db, repoRoot);
|
|
6632
|
-
db.close();
|
|
6633
|
-
|
|
6634
|
-
if (opts.json) {
|
|
6635
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6636
|
-
} else {
|
|
6637
|
-
const badge = result.status === 'pass'
|
|
6638
|
-
? chalk.green('PASS')
|
|
6639
|
-
: result.status === 'warn'
|
|
6640
|
-
? chalk.yellow('WARN')
|
|
6641
|
-
: chalk.red('BLOCK');
|
|
6642
|
-
console.log(`${badge} ${result.summary}`);
|
|
6643
|
-
|
|
6644
|
-
const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
|
|
6645
|
-
if (riskyPairs.length > 0) {
|
|
6646
|
-
console.log(chalk.bold(' Risky pairs:'));
|
|
6647
|
-
for (const pair of riskyPairs) {
|
|
6648
|
-
console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
|
|
6649
|
-
for (const reason of pair.reasons.slice(0, 3)) {
|
|
6650
|
-
console.log(` ${chalk.yellow(reason)}`);
|
|
6651
|
-
}
|
|
6652
|
-
}
|
|
6653
|
-
}
|
|
6654
|
-
|
|
6655
|
-
if ((result.boundary_validations?.length || 0) > 0) {
|
|
6656
|
-
console.log(chalk.bold(' Boundary validations:'));
|
|
6657
|
-
for (const validation of result.boundary_validations.slice(0, 5)) {
|
|
6658
|
-
console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
6659
|
-
if (validation.rationale?.[0]) {
|
|
6660
|
-
console.log(` ${chalk.yellow(validation.rationale[0])}`);
|
|
6661
|
-
}
|
|
6662
|
-
}
|
|
6663
|
-
}
|
|
6664
|
-
|
|
6665
|
-
if ((result.dependency_invalidations?.length || 0) > 0) {
|
|
6666
|
-
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
6667
|
-
for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
|
|
6668
|
-
console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
|
|
6669
|
-
}
|
|
6670
|
-
}
|
|
6671
|
-
|
|
6672
|
-
if ((result.semantic_conflicts?.length || 0) > 0) {
|
|
6673
|
-
console.log(chalk.bold(' Semantic conflicts:'));
|
|
6674
|
-
for (const conflict of result.semantic_conflicts.slice(0, 5)) {
|
|
6675
|
-
console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
6676
|
-
}
|
|
6677
|
-
}
|
|
6678
|
-
|
|
6679
|
-
const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
|
|
6680
|
-
if (riskyWorktrees.length > 0) {
|
|
6681
|
-
console.log(chalk.bold(' Worktree signals:'));
|
|
6682
|
-
for (const worktree of riskyWorktrees) {
|
|
6683
|
-
console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
|
|
6684
|
-
for (const finding of worktree.findings.slice(0, 2)) {
|
|
6685
|
-
console.log(` ${chalk.yellow(finding)}`);
|
|
6686
|
-
}
|
|
6687
|
-
}
|
|
6688
|
-
}
|
|
6689
|
-
}
|
|
5913
|
+
registerAuditCommands(program, {
|
|
5914
|
+
buildPipelineHistoryReport,
|
|
5915
|
+
chalk,
|
|
5916
|
+
getDb,
|
|
5917
|
+
getRepo,
|
|
5918
|
+
printErrorWithNext,
|
|
5919
|
+
statusBadge,
|
|
5920
|
+
verifyAuditTrail,
|
|
5921
|
+
});
|
|
6690
5922
|
|
|
6691
|
-
|
|
6692
|
-
|
|
5923
|
+
registerGateCommands(program, {
|
|
5924
|
+
chalk,
|
|
5925
|
+
getDb,
|
|
5926
|
+
getRepo,
|
|
5927
|
+
installGateHooks,
|
|
5928
|
+
installGitHubActionsWorkflow,
|
|
5929
|
+
maybeCaptureTelemetry,
|
|
5930
|
+
resolveGitHubOutputTargets,
|
|
5931
|
+
runAiMergeGate,
|
|
5932
|
+
runCommitGate,
|
|
5933
|
+
scanAllWorktrees,
|
|
5934
|
+
writeGitHubCiStatus,
|
|
5935
|
+
});
|
|
6693
5936
|
|
|
6694
5937
|
const semanticCmd = program
|
|
6695
5938
|
.command('semantic')
|
|
@@ -6792,180 +6035,20 @@ objectCmd
|
|
|
6792
6035
|
|
|
6793
6036
|
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
6794
6037
|
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
.
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
if (opts.json) {
|
|
6811
|
-
console.log(JSON.stringify(result, null, 2));
|
|
6812
|
-
return;
|
|
6813
|
-
}
|
|
6814
|
-
|
|
6815
|
-
if (result.events.length === 0) {
|
|
6816
|
-
console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
|
|
6817
|
-
return;
|
|
6818
|
-
}
|
|
6819
|
-
|
|
6820
|
-
console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
|
|
6821
|
-
for (const event of result.events) {
|
|
6822
|
-
renderMonitorEvent(event);
|
|
6823
|
-
}
|
|
6824
|
-
});
|
|
6825
|
-
|
|
6826
|
-
monitorCmd
|
|
6827
|
-
.command('watch')
|
|
6828
|
-
.description('Poll workspaces continuously and log observed file changes')
|
|
6829
|
-
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
6830
|
-
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
6831
|
-
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
6832
|
-
.action(async (opts) => {
|
|
6833
|
-
const repoRoot = getRepo();
|
|
6834
|
-
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
6835
|
-
|
|
6836
|
-
if (!Number.isFinite(intervalMs) || intervalMs < 100) {
|
|
6837
|
-
console.error(chalk.red('--interval-ms must be at least 100'));
|
|
6838
|
-
process.exit(1);
|
|
6839
|
-
}
|
|
6840
|
-
|
|
6841
|
-
console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
6842
|
-
|
|
6843
|
-
let stopped = false;
|
|
6844
|
-
const stop = () => {
|
|
6845
|
-
stopped = true;
|
|
6846
|
-
process.stdout.write('\n');
|
|
6847
|
-
if (opts.daemonized) {
|
|
6848
|
-
clearMonitorState(repoRoot);
|
|
6849
|
-
}
|
|
6850
|
-
};
|
|
6851
|
-
process.on('SIGINT', stop);
|
|
6852
|
-
process.on('SIGTERM', stop);
|
|
6853
|
-
|
|
6854
|
-
while (!stopped) {
|
|
6855
|
-
const db = getDb(repoRoot);
|
|
6856
|
-
const worktrees = resolveMonitoredWorktrees(db, repoRoot);
|
|
6857
|
-
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
6858
|
-
db.close();
|
|
6859
|
-
|
|
6860
|
-
for (const event of result.events) {
|
|
6861
|
-
renderMonitorEvent(event);
|
|
6862
|
-
}
|
|
6863
|
-
|
|
6864
|
-
if (stopped) break;
|
|
6865
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
|
|
6866
|
-
}
|
|
6867
|
-
|
|
6868
|
-
console.log(chalk.dim('Stopped worktree monitor.'));
|
|
6869
|
-
});
|
|
6870
|
-
|
|
6871
|
-
monitorCmd
|
|
6872
|
-
.command('start')
|
|
6873
|
-
.description('Start the worktree monitor as a background process')
|
|
6874
|
-
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
6875
|
-
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
6876
|
-
.action((opts) => {
|
|
6877
|
-
const repoRoot = getRepo();
|
|
6878
|
-
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
6879
|
-
const state = startBackgroundMonitor(repoRoot, {
|
|
6880
|
-
intervalMs,
|
|
6881
|
-
quarantine: Boolean(opts.quarantine),
|
|
6882
|
-
});
|
|
6883
|
-
|
|
6884
|
-
if (state.already_running) {
|
|
6885
|
-
console.log(chalk.yellow(`Monitor already running with pid ${state.state.pid}`));
|
|
6886
|
-
return;
|
|
6887
|
-
}
|
|
6888
|
-
|
|
6889
|
-
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(state.state.pid))}`);
|
|
6890
|
-
console.log(`${chalk.dim('State:')} ${state.state_path}`);
|
|
6891
|
-
});
|
|
6892
|
-
|
|
6893
|
-
monitorCmd
|
|
6894
|
-
.command('stop')
|
|
6895
|
-
.description('Stop the background worktree monitor')
|
|
6896
|
-
.action(() => {
|
|
6897
|
-
const repoRoot = getRepo();
|
|
6898
|
-
const state = readMonitorState(repoRoot);
|
|
6899
|
-
|
|
6900
|
-
if (!state) {
|
|
6901
|
-
console.log(chalk.dim('Monitor is not running.'));
|
|
6902
|
-
return;
|
|
6903
|
-
}
|
|
6904
|
-
|
|
6905
|
-
if (!isProcessRunning(state.pid)) {
|
|
6906
|
-
clearMonitorState(repoRoot);
|
|
6907
|
-
console.log(chalk.dim('Monitor state was stale and has been cleared.'));
|
|
6908
|
-
return;
|
|
6909
|
-
}
|
|
6910
|
-
|
|
6911
|
-
process.kill(state.pid, 'SIGTERM');
|
|
6912
|
-
clearMonitorState(repoRoot);
|
|
6913
|
-
console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
|
|
6914
|
-
});
|
|
6915
|
-
|
|
6916
|
-
monitorCmd
|
|
6917
|
-
.command('status')
|
|
6918
|
-
.description('Show background monitor process status')
|
|
6919
|
-
.action(() => {
|
|
6920
|
-
const repoRoot = getRepo();
|
|
6921
|
-
const state = readMonitorState(repoRoot);
|
|
6922
|
-
|
|
6923
|
-
if (!state) {
|
|
6924
|
-
console.log(chalk.dim('Monitor is not running.'));
|
|
6925
|
-
return;
|
|
6926
|
-
}
|
|
6927
|
-
|
|
6928
|
-
const running = isProcessRunning(state.pid);
|
|
6929
|
-
if (!running) {
|
|
6930
|
-
clearMonitorState(repoRoot);
|
|
6931
|
-
console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
|
|
6932
|
-
return;
|
|
6933
|
-
}
|
|
6934
|
-
|
|
6935
|
-
console.log(`${chalk.green('✓')} Monitor running`);
|
|
6936
|
-
console.log(` ${chalk.dim('pid')} ${state.pid}`);
|
|
6937
|
-
console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
|
|
6938
|
-
console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
|
|
6939
|
-
console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
|
|
6940
|
-
});
|
|
6941
|
-
|
|
6942
|
-
program
|
|
6943
|
-
.command('watch')
|
|
6944
|
-
.description('Watch worktrees for direct writes and rogue edits in real time')
|
|
6945
|
-
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
6946
|
-
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
6947
|
-
.action(async (opts) => {
|
|
6948
|
-
const repoRoot = getRepo();
|
|
6949
|
-
const child = spawn(process.execPath, [
|
|
6950
|
-
process.argv[1],
|
|
6951
|
-
'monitor',
|
|
6952
|
-
'watch',
|
|
6953
|
-
'--interval-ms',
|
|
6954
|
-
String(opts.intervalMs || '2000'),
|
|
6955
|
-
...(opts.quarantine ? ['--quarantine'] : []),
|
|
6956
|
-
], {
|
|
6957
|
-
cwd: repoRoot,
|
|
6958
|
-
stdio: 'inherit',
|
|
6959
|
-
});
|
|
6960
|
-
|
|
6961
|
-
await new Promise((resolve, reject) => {
|
|
6962
|
-
child.on('exit', (code) => {
|
|
6963
|
-
process.exitCode = code ?? 0;
|
|
6964
|
-
resolve();
|
|
6965
|
-
});
|
|
6966
|
-
child.on('error', reject);
|
|
6967
|
-
});
|
|
6968
|
-
});
|
|
6038
|
+
registerMonitorCommands(program, {
|
|
6039
|
+
chalk,
|
|
6040
|
+
clearMonitorState,
|
|
6041
|
+
getDb,
|
|
6042
|
+
getRepo,
|
|
6043
|
+
isProcessRunning,
|
|
6044
|
+
monitorWorktreesOnce,
|
|
6045
|
+
processExecPath: process.execPath,
|
|
6046
|
+
readMonitorState,
|
|
6047
|
+
renderMonitorEvent,
|
|
6048
|
+
resolveMonitoredWorktrees,
|
|
6049
|
+
spawn,
|
|
6050
|
+
startBackgroundMonitor,
|
|
6051
|
+
});
|
|
6969
6052
|
|
|
6970
6053
|
// ── policy ───────────────────────────────────────────────────────────────────
|
|
6971
6054
|
|