switchman-dev 0.1.7 → 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/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +124 -195
- package/examples/README.md +9 -2
- package/package.json +6 -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 +2072 -1649
- package/src/core/ci.js +1 -1
- package/src/core/db.js +143 -21
- package/src/core/enforcement.js +122 -10
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +5 -3
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +66 -35
- package/src/core/planner.js +10 -6
- package/src/core/policy.js +1 -1
- package/src/core/queue.js +11 -2
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/src.zip +0 -0
package/src/cli/index.js
CHANGED
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
* switchman status - Show the repo dashboard
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { program } from 'commander';
|
|
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
|
-
import { cleanupCrashedLandingTempWorktrees, findRepoRoot,
|
|
27
|
+
import { cleanupCrashedLandingTempWorktrees, createGitWorktree, findRepoRoot, getWorktreeBranch, getWorktreeChangedFiles, gitAssessBranchFreshness, gitBranchExists, listGitWorktrees } from '../core/git.js';
|
|
28
28
|
import { matchesPathPatterns } from '../core/ignore.js';
|
|
29
29
|
import {
|
|
30
30
|
initDb, openDb,
|
|
@@ -37,18 +37,21 @@ import {
|
|
|
37
37
|
createPolicyOverride, listPolicyOverrides, revokePolicyOverride,
|
|
38
38
|
finishOperationJournalEntry, listOperationJournal, listTempResources, updateTempResource,
|
|
39
39
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
|
|
40
|
-
|
|
40
|
+
upsertTaskSpec,
|
|
41
|
+
listAuditEvents, pruneDatabaseMaintenance, verifyAuditTrail,
|
|
42
|
+
getLeaseExecutionContext, getActiveLeaseForTask,
|
|
41
43
|
} from '../core/db.js';
|
|
42
44
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
43
|
-
import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
44
|
-
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
45
|
+
import { ensureProjectLocalMcpGitExcludes, getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
46
|
+
import { evaluateRepoCompliance, gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
45
47
|
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
46
48
|
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
47
49
|
import { buildPipelinePrSummary, cleanupPipelineLandingRecovery, commentPipelinePr, createPipelineFollowupTasks, evaluatePipelinePolicyGate, executePipeline, exportPipelinePrBundle, getPipelineLandingBranchStatus, getPipelineLandingExplainReport, getPipelineStatus, inferPipelineIdFromBranch, materializePipelineLandingBranch, preparePipelineLandingRecovery, preparePipelineLandingTarget, publishPipelinePr, repairPipelineState, resumePipelineLandingRecovery, runPipeline, startPipeline, summarizePipelinePolicyState, syncPipelinePr } from '../core/pipeline.js';
|
|
48
50
|
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus, writeGitHubPipelineLandingStatus } from '../core/ci.js';
|
|
49
51
|
import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
|
|
50
|
-
import { buildQueueStatusSummary, resolveQueueSource, runMergeQueue } from '../core/queue.js';
|
|
52
|
+
import { buildQueueStatusSummary, evaluateQueueRepoGate, resolveQueueSource, runMergeQueue } from '../core/queue.js';
|
|
51
53
|
import { DEFAULT_CHANGE_POLICY, DEFAULT_LEASE_POLICY, getChangePolicyPath, loadChangePolicy, loadLeasePolicy, writeChangePolicy, writeLeasePolicy } from '../core/policy.js';
|
|
54
|
+
import { planPipelineTasks } from '../core/planner.js';
|
|
52
55
|
import {
|
|
53
56
|
captureTelemetryEvent,
|
|
54
57
|
disableTelemetry,
|
|
@@ -59,6 +62,19 @@ import {
|
|
|
59
62
|
maybePromptForTelemetry,
|
|
60
63
|
sendTelemetryEvent,
|
|
61
64
|
} from '../core/telemetry.js';
|
|
65
|
+
import { checkLicence, clearCredentials, FREE_AGENT_LIMIT, getRetentionDaysForCurrentPlan, loginWithGitHub, PRO_PAGE_URL, readCredentials } from '../core/licence.js';
|
|
66
|
+
import { homedir } from 'os';
|
|
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';
|
|
62
78
|
|
|
63
79
|
const originalProcessEmit = process.emit.bind(process);
|
|
64
80
|
process.emit = function patchedProcessEmit(event, ...args) {
|
|
@@ -95,6 +111,299 @@ function getDb(repoRoot) {
|
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
function getOptionalDb(repoRoot) {
|
|
115
|
+
try {
|
|
116
|
+
return openDb(repoRoot);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function slugifyValue(value) {
|
|
123
|
+
return String(value || '')
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
126
|
+
.replace(/^-+|-+$/g, '')
|
|
127
|
+
.slice(0, 40) || 'plan';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function capitalizeSentence(value) {
|
|
131
|
+
const text = String(value || '').trim();
|
|
132
|
+
if (!text) return text;
|
|
133
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatHumanList(values = []) {
|
|
137
|
+
if (values.length === 0) return '';
|
|
138
|
+
if (values.length === 1) return values[0];
|
|
139
|
+
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
|
140
|
+
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readPlanningFile(repoRoot, fileName, maxChars = 1200) {
|
|
144
|
+
const filePath = join(repoRoot, fileName);
|
|
145
|
+
if (!existsSync(filePath)) return null;
|
|
146
|
+
try {
|
|
147
|
+
const text = readFileSync(filePath, 'utf8').trim();
|
|
148
|
+
if (!text) return null;
|
|
149
|
+
return {
|
|
150
|
+
file: fileName,
|
|
151
|
+
text: text.slice(0, maxChars),
|
|
152
|
+
};
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function extractMarkdownSignal(text) {
|
|
159
|
+
const lines = String(text || '')
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s+/, '').trim();
|
|
165
|
+
if (!normalized) continue;
|
|
166
|
+
if (/^switchman\b/i.test(normalized)) continue;
|
|
167
|
+
return normalized;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function deriveGoalFromBranch(branchName) {
|
|
173
|
+
const raw = String(branchName || '').replace(/^refs\/heads\//, '').trim();
|
|
174
|
+
if (!raw || ['main', 'master', 'trunk', 'develop', 'development'].includes(raw)) return null;
|
|
175
|
+
const tail = raw.split('/').pop() || raw;
|
|
176
|
+
const tokens = tail
|
|
177
|
+
.replace(/^\d+[-_]?/, '')
|
|
178
|
+
.split(/[-_]/)
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.filter((token) => !['feature', 'feat', 'fix', 'bugfix', 'chore', 'task', 'issue', 'story', 'work'].includes(token.toLowerCase()));
|
|
181
|
+
if (tokens.length === 0) return null;
|
|
182
|
+
return capitalizeSentence(tokens.join(' '));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getRecentCommitSubjects(repoRoot, limit = 6) {
|
|
186
|
+
try {
|
|
187
|
+
return execSync(`git log --pretty=%s -n ${limit}`, {
|
|
188
|
+
cwd: repoRoot,
|
|
189
|
+
encoding: 'utf8',
|
|
190
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
191
|
+
}).trim().split('\n').map((line) => line.trim()).filter(Boolean);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function summarizeRecentCommitContext(branchGoal, subjects) {
|
|
198
|
+
if (!subjects.length) return null;
|
|
199
|
+
const topicWords = String(branchGoal || '')
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.split(/\s+/)
|
|
202
|
+
.filter((word) => word.length >= 4);
|
|
203
|
+
const relatedCount = topicWords.length > 0
|
|
204
|
+
? subjects.filter((subject) => {
|
|
205
|
+
const lower = subject.toLowerCase();
|
|
206
|
+
return topicWords.some((word) => lower.includes(word));
|
|
207
|
+
}).length
|
|
208
|
+
: 0;
|
|
209
|
+
const effectiveCount = relatedCount > 0 ? relatedCount : Math.min(subjects.length, 3);
|
|
210
|
+
const topicLabel = relatedCount > 0 && topicWords.length > 0 ? `${topicWords[0]}-related ` : '';
|
|
211
|
+
return `${effectiveCount} recent ${topicLabel}commit${effectiveCount === 1 ? '' : 's'}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function collectPlanContext(repoRoot, explicitGoal = null, issueContext = null) {
|
|
215
|
+
const planningFiles = ['CLAUDE.md', 'ROADMAP.md', 'tasks.md', 'TASKS.md', 'TODO.md', 'README.md']
|
|
216
|
+
.map((fileName) => readPlanningFile(repoRoot, fileName))
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
const planningByName = new Map(planningFiles.map((entry) => [entry.file, entry]));
|
|
219
|
+
const branch = getWorktreeBranch(process.cwd()) || null;
|
|
220
|
+
const branchGoal = deriveGoalFromBranch(branch);
|
|
221
|
+
const recentCommitSubjects = getRecentCommitSubjects(repoRoot, 6);
|
|
222
|
+
const recentCommitSummary = summarizeRecentCommitContext(branchGoal, recentCommitSubjects);
|
|
223
|
+
const preferredPlanningFile = planningByName.get('CLAUDE.md')
|
|
224
|
+
|| planningByName.get('tasks.md')
|
|
225
|
+
|| planningByName.get('TASKS.md')
|
|
226
|
+
|| planningByName.get('ROADMAP.md')
|
|
227
|
+
|| planningByName.get('TODO.md')
|
|
228
|
+
|| planningByName.get('README.md')
|
|
229
|
+
|| null;
|
|
230
|
+
const planningSignal = preferredPlanningFile ? extractMarkdownSignal(preferredPlanningFile.text) : null;
|
|
231
|
+
const title = capitalizeSentence(issueContext?.title || explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
|
|
232
|
+
const descriptionParts = [];
|
|
233
|
+
if (issueContext?.description) descriptionParts.push(issueContext.description);
|
|
234
|
+
if (preferredPlanningFile?.text) descriptionParts.push(preferredPlanningFile.text);
|
|
235
|
+
if (recentCommitSubjects.length > 0) descriptionParts.push(`Recent git history summary: ${recentCommitSubjects.slice(0, 3).join('; ')}.`);
|
|
236
|
+
const description = descriptionParts.join('\n\n').trim() || null;
|
|
237
|
+
|
|
238
|
+
const found = [];
|
|
239
|
+
const used = [];
|
|
240
|
+
if (issueContext?.number) {
|
|
241
|
+
found.push(`issue #${issueContext.number} "${issueContext.title}"`);
|
|
242
|
+
used.push(`GitHub issue #${issueContext.number}`);
|
|
243
|
+
}
|
|
244
|
+
if (explicitGoal) {
|
|
245
|
+
used.push('explicit goal');
|
|
246
|
+
}
|
|
247
|
+
if (branch) {
|
|
248
|
+
found.push(`branch ${branch}`);
|
|
249
|
+
if (branchGoal) used.push('branch name');
|
|
250
|
+
}
|
|
251
|
+
if (preferredPlanningFile?.file) {
|
|
252
|
+
found.push(preferredPlanningFile.file);
|
|
253
|
+
used.push(preferredPlanningFile.file);
|
|
254
|
+
}
|
|
255
|
+
if (recentCommitSummary) {
|
|
256
|
+
found.push(recentCommitSummary);
|
|
257
|
+
used.push('recent git history');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
branch,
|
|
262
|
+
title,
|
|
263
|
+
description,
|
|
264
|
+
found,
|
|
265
|
+
used: [...new Set(used)],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
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
|
+
|
|
386
|
+
function resolvePlanningWorktrees(repoRoot, db = null) {
|
|
387
|
+
if (db) {
|
|
388
|
+
const registered = listWorktrees(db)
|
|
389
|
+
.filter((worktree) => worktree.name !== 'main' && worktree.status !== 'missing')
|
|
390
|
+
.map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch }));
|
|
391
|
+
if (registered.length > 0) return registered;
|
|
392
|
+
}
|
|
393
|
+
return listGitWorktrees(repoRoot)
|
|
394
|
+
.filter((worktree) => !worktree.isMain)
|
|
395
|
+
.map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch || null }));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function planTaskPriority(taskSpec = null) {
|
|
399
|
+
const taskType = taskSpec?.task_type || 'implementation';
|
|
400
|
+
if (taskType === 'implementation') return 8;
|
|
401
|
+
if (taskType === 'tests') return 7;
|
|
402
|
+
if (taskType === 'docs') return 6;
|
|
403
|
+
if (taskType === 'governance') return 6;
|
|
404
|
+
return 5;
|
|
405
|
+
}
|
|
406
|
+
|
|
98
407
|
function resolvePrNumberFromEnv(env = process.env) {
|
|
99
408
|
if (env.SWITCHMAN_PR_NUMBER) return String(env.SWITCHMAN_PR_NUMBER);
|
|
100
409
|
if (env.GITHUB_PR_NUMBER) return String(env.GITHUB_PR_NUMBER);
|
|
@@ -1289,6 +1598,184 @@ function printRepairSummary(report, {
|
|
|
1289
1598
|
}
|
|
1290
1599
|
}
|
|
1291
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
|
+
|
|
1292
1779
|
function repairRepoState(db, repoRoot) {
|
|
1293
1780
|
const actions = [];
|
|
1294
1781
|
const warnings = [];
|
|
@@ -1582,7 +2069,7 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
|
|
|
1582
2069
|
detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
|
|
1583
2070
|
});
|
|
1584
2071
|
if (!claudeGuideExists) {
|
|
1585
|
-
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`.');
|
|
1586
2073
|
}
|
|
1587
2074
|
|
|
1588
2075
|
const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
|
|
@@ -1618,6 +2105,238 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
|
|
|
1618
2105
|
};
|
|
1619
2106
|
}
|
|
1620
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
|
+
|
|
1621
2340
|
function renderSetupVerification(report, { compact = false } = {}) {
|
|
1622
2341
|
console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
|
|
1623
2342
|
for (const check of report.checks) {
|
|
@@ -1783,12 +2502,24 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
1783
2502
|
};
|
|
1784
2503
|
});
|
|
1785
2504
|
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2505
|
+
const worktreeByName = new Map((scanReport.worktrees || []).map((worktree) => [worktree.name, worktree]));
|
|
2506
|
+
const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => {
|
|
2507
|
+
const worktreeInfo = worktreeByName.get(entry.worktree) || null;
|
|
2508
|
+
const reasonCode = entry.reasons?.[0]?.reason_code || null;
|
|
2509
|
+
const isDirtyWorktree = reasonCode === 'no_active_lease';
|
|
2510
|
+
return {
|
|
2511
|
+
worktree: entry.worktree,
|
|
2512
|
+
path: worktreeInfo?.path || null,
|
|
2513
|
+
files: entry.files,
|
|
2514
|
+
reason_code: reasonCode,
|
|
2515
|
+
next_step: isDirtyWorktree
|
|
2516
|
+
? 'commit or discard the changed files in that worktree, then rescan before continuing'
|
|
2517
|
+
: (nextStepForReason(reasonCode) || 'inspect the changed files and bring them back under Switchman claims'),
|
|
2518
|
+
command: worktreeInfo?.path
|
|
2519
|
+
? `cd ${JSON.stringify(worktreeInfo.path)} && git status`
|
|
2520
|
+
: 'switchman scan',
|
|
2521
|
+
};
|
|
2522
|
+
});
|
|
1792
2523
|
|
|
1793
2524
|
const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
|
|
1794
2525
|
file: conflict.file,
|
|
@@ -1838,9 +2569,9 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
1838
2569
|
...blockedWorktrees.map((entry) => ({
|
|
1839
2570
|
kind: 'unmanaged_changes',
|
|
1840
2571
|
title: `${entry.worktree} has unmanaged changed files`,
|
|
1841
|
-
detail: `${entry.files.slice(0,
|
|
2572
|
+
detail: `${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}${entry.path ? ` • ${entry.path}` : ''}`,
|
|
1842
2573
|
next_step: entry.next_step,
|
|
1843
|
-
command:
|
|
2574
|
+
command: entry.command,
|
|
1844
2575
|
severity: 'block',
|
|
1845
2576
|
})),
|
|
1846
2577
|
...fileConflicts.map((conflict) => ({
|
|
@@ -1997,6 +2728,7 @@ function buildUnifiedStatusReport({
|
|
|
1997
2728
|
queueItems,
|
|
1998
2729
|
queueSummary,
|
|
1999
2730
|
recentQueueEvents,
|
|
2731
|
+
retentionDays = 7,
|
|
2000
2732
|
}) {
|
|
2001
2733
|
const queueAttention = [
|
|
2002
2734
|
...queueItems
|
|
@@ -2101,6 +2833,7 @@ function buildUnifiedStatusReport({
|
|
|
2101
2833
|
...queueAttention.map((item) => item.next_step),
|
|
2102
2834
|
])].slice(0, 6),
|
|
2103
2835
|
suggested_commands: [...new Set(attention.length > 0 ? suggestedCommands : defaultSuggestedCommands)].slice(0, 6),
|
|
2836
|
+
retention_days: retentionDays,
|
|
2104
2837
|
};
|
|
2105
2838
|
}
|
|
2106
2839
|
|
|
@@ -2108,6 +2841,9 @@ async function collectStatusSnapshot(repoRoot) {
|
|
|
2108
2841
|
const db = getDb(repoRoot);
|
|
2109
2842
|
try {
|
|
2110
2843
|
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
2844
|
+
const retentionDays = await getRetentionDaysForCurrentPlan();
|
|
2845
|
+
pruneDatabaseMaintenance(db, { retentionDays });
|
|
2846
|
+
cleanupOldSyncEvents({ retentionDays }).catch(() => {});
|
|
2111
2847
|
|
|
2112
2848
|
if (leasePolicy.reap_on_status_check) {
|
|
2113
2849
|
reapStaleLeases(db, leasePolicy.stale_after_minutes, {
|
|
@@ -2147,13 +2883,40 @@ async function collectStatusSnapshot(repoRoot) {
|
|
|
2147
2883
|
queueItems,
|
|
2148
2884
|
queueSummary,
|
|
2149
2885
|
recentQueueEvents,
|
|
2886
|
+
retentionDays,
|
|
2150
2887
|
});
|
|
2151
2888
|
} finally {
|
|
2152
2889
|
db.close();
|
|
2153
2890
|
}
|
|
2154
2891
|
}
|
|
2155
2892
|
|
|
2156
|
-
function
|
|
2893
|
+
function summarizeTeamCoordinationState(events = [], myUserId = null) {
|
|
2894
|
+
const visibleEvents = events.filter((event) => event.user_id !== myUserId);
|
|
2895
|
+
if (visibleEvents.length === 0) {
|
|
2896
|
+
return {
|
|
2897
|
+
members: 0,
|
|
2898
|
+
queue_events: 0,
|
|
2899
|
+
lease_events: 0,
|
|
2900
|
+
claim_events: 0,
|
|
2901
|
+
latest_queue_event: null,
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
const activeMembers = new Set(visibleEvents.map((event) => event.user_id || `${event.payload?.email || 'unknown'}:${event.worktree || 'unknown'}`));
|
|
2906
|
+
const queueEvents = visibleEvents.filter((event) => ['queue_added', 'queue_merged', 'queue_blocked'].includes(event.event_type));
|
|
2907
|
+
const leaseEvents = visibleEvents.filter((event) => ['lease_acquired', 'task_done', 'task_failed', 'task_retried'].includes(event.event_type));
|
|
2908
|
+
const claimEvents = visibleEvents.filter((event) => ['claim_added', 'claim_released'].includes(event.event_type));
|
|
2909
|
+
|
|
2910
|
+
return {
|
|
2911
|
+
members: activeMembers.size,
|
|
2912
|
+
queue_events: queueEvents.length,
|
|
2913
|
+
lease_events: leaseEvents.length,
|
|
2914
|
+
claim_events: claimEvents.length,
|
|
2915
|
+
latest_queue_event: queueEvents[0] || null,
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
function renderUnifiedStatusReport(report, { teamActivity = [], teamSummary = null } = {}) {
|
|
2157
2920
|
const healthColor = colorForHealth(report.health);
|
|
2158
2921
|
const badge = healthColor(healthLabel(report.health));
|
|
2159
2922
|
const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
|
|
@@ -2212,10 +2975,43 @@ function renderUnifiedStatusReport(report) {
|
|
|
2212
2975
|
console.log(`${chalk.bold('Run next:')} ${chalk.cyan(primaryCommand)}`);
|
|
2213
2976
|
console.log(`${chalk.dim('why:')} ${nextStepLine}`);
|
|
2214
2977
|
console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
|
|
2978
|
+
console.log(chalk.dim(`history retention: ${report.retention_days || 7} days`));
|
|
2215
2979
|
if (report.merge_readiness.policy_state?.active) {
|
|
2216
2980
|
console.log(chalk.dim(`change policy: ${report.merge_readiness.policy_state.domains.join(', ')} • ${report.merge_readiness.policy_state.enforcement} • missing ${report.merge_readiness.policy_state.missing_task_types.join(', ') || 'none'}`));
|
|
2217
2981
|
}
|
|
2218
2982
|
|
|
2983
|
+
// ── Team activity (Pro cloud sync) ──────────────────────────────────────────
|
|
2984
|
+
if (teamSummary && teamSummary.members > 0) {
|
|
2985
|
+
console.log('');
|
|
2986
|
+
console.log(chalk.bold('Shared cloud state:'));
|
|
2987
|
+
console.log(` ${chalk.dim('members:')} ${teamSummary.members} ${chalk.dim('leases:')} ${teamSummary.lease_events} ${chalk.dim('claims:')} ${teamSummary.claim_events} ${chalk.dim('queue:')} ${teamSummary.queue_events}`);
|
|
2988
|
+
if (teamSummary.latest_queue_event) {
|
|
2989
|
+
console.log(` ${chalk.dim('latest queue event:')} ${chalk.cyan(teamSummary.latest_queue_event.event_type)} ${chalk.dim(teamSummary.latest_queue_event.payload?.source_ref || teamSummary.latest_queue_event.payload?.item_id || '')}`.trim());
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
if (teamActivity.length > 0) {
|
|
2993
|
+
console.log('');
|
|
2994
|
+
console.log(chalk.bold('Team activity:'));
|
|
2995
|
+
for (const member of teamActivity) {
|
|
2996
|
+
const email = member.payload?.email ?? chalk.dim(member.user_id?.slice(0, 8) ?? 'unknown');
|
|
2997
|
+
const worktree = chalk.cyan(member.worktree ?? 'unknown');
|
|
2998
|
+
const eventLabel = {
|
|
2999
|
+
task_added: 'added a task',
|
|
3000
|
+
task_done: 'completed a task',
|
|
3001
|
+
task_failed: 'failed a task',
|
|
3002
|
+
task_retried: 'retried a task',
|
|
3003
|
+
lease_acquired: `working on: ${chalk.dim(member.payload?.title ?? '')}`,
|
|
3004
|
+
claim_added: `claimed ${chalk.dim(member.payload?.file_count ?? 0)} file(s)`,
|
|
3005
|
+
claim_released: 'released file claims',
|
|
3006
|
+
queue_added: `queued ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
3007
|
+
queue_merged: `landed ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
3008
|
+
queue_blocked: `blocked ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
3009
|
+
status_ping: 'active',
|
|
3010
|
+
}[member.event_type] ?? member.event_type;
|
|
3011
|
+
console.log(` ${chalk.dim('○')} ${email} · ${worktree} · ${eventLabel}`);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
|
|
2219
3015
|
const runningLines = report.active_work.length > 0
|
|
2220
3016
|
? report.active_work.slice(0, 5).map((item) => {
|
|
2221
3017
|
const boundary = item.boundary_validation
|
|
@@ -2230,6 +3026,27 @@ function renderUnifiedStatusReport(report) {
|
|
|
2230
3026
|
|
|
2231
3027
|
const blockedItems = report.attention.filter((item) => item.severity === 'block');
|
|
2232
3028
|
const warningItems = report.attention.filter((item) => item.severity !== 'block');
|
|
3029
|
+
const isQuietEmptyState = report.active_work.length === 0
|
|
3030
|
+
&& blockedItems.length === 0
|
|
3031
|
+
&& warningItems.length === 0
|
|
3032
|
+
&& report.queue.items.length === 0
|
|
3033
|
+
&& report.next_up.length === 0
|
|
3034
|
+
&& report.failed_tasks.length === 0;
|
|
3035
|
+
|
|
3036
|
+
if (isQuietEmptyState) {
|
|
3037
|
+
console.log('');
|
|
3038
|
+
console.log(healthColor('='.repeat(72)));
|
|
3039
|
+
console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• mission control for parallel agents')}`);
|
|
3040
|
+
console.log(`${chalk.dim(report.repo_root)}`);
|
|
3041
|
+
console.log(`${chalk.dim(report.summary)}`);
|
|
3042
|
+
console.log(healthColor('='.repeat(72)));
|
|
3043
|
+
console.log('');
|
|
3044
|
+
console.log(chalk.green('Nothing is running yet.'));
|
|
3045
|
+
console.log(`Add work with: ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
3046
|
+
console.log(`Or prove the flow in 30 seconds with: ${chalk.cyan('switchman demo')}`);
|
|
3047
|
+
console.log('');
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
2233
3050
|
|
|
2234
3051
|
const blockedLines = blockedItems.length > 0
|
|
2235
3052
|
? blockedItems.slice(0, 4).flatMap((item) => {
|
|
@@ -2405,10 +3222,12 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
|
2405
3222
|
let db = null;
|
|
2406
3223
|
try {
|
|
2407
3224
|
db = openDb(repoRoot);
|
|
2408
|
-
completeTask(db, taskId);
|
|
2409
|
-
|
|
3225
|
+
const result = completeTask(db, taskId);
|
|
3226
|
+
if (result?.status === 'completed') {
|
|
3227
|
+
releaseFileClaims(db, taskId);
|
|
3228
|
+
}
|
|
2410
3229
|
db.close();
|
|
2411
|
-
return;
|
|
3230
|
+
return result;
|
|
2412
3231
|
} catch (err) {
|
|
2413
3232
|
lastError = err;
|
|
2414
3233
|
try { db?.close(); } catch { /* no-op */ }
|
|
@@ -2421,38 +3240,311 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
|
2421
3240
|
throw lastError;
|
|
2422
3241
|
}
|
|
2423
3242
|
|
|
2424
|
-
|
|
3243
|
+
function startBackgroundMonitor(repoRoot, { intervalMs = 2000, quarantine = false } = {}) {
|
|
3244
|
+
const existingState = readMonitorState(repoRoot);
|
|
3245
|
+
if (existingState && isProcessRunning(existingState.pid)) {
|
|
3246
|
+
return {
|
|
3247
|
+
already_running: true,
|
|
3248
|
+
state: existingState,
|
|
3249
|
+
state_path: getMonitorStatePath(repoRoot),
|
|
3250
|
+
};
|
|
3251
|
+
}
|
|
2425
3252
|
|
|
2426
|
-
|
|
2427
|
-
.
|
|
2428
|
-
|
|
2429
|
-
|
|
3253
|
+
const logPath = join(repoRoot, '.switchman', 'monitor.log');
|
|
3254
|
+
const child = spawn(process.execPath, [
|
|
3255
|
+
process.argv[1],
|
|
3256
|
+
'monitor',
|
|
3257
|
+
'watch',
|
|
3258
|
+
'--interval-ms',
|
|
3259
|
+
String(intervalMs),
|
|
3260
|
+
...(quarantine ? ['--quarantine'] : []),
|
|
3261
|
+
'--daemonized',
|
|
3262
|
+
], {
|
|
3263
|
+
cwd: repoRoot,
|
|
3264
|
+
detached: true,
|
|
3265
|
+
stdio: 'ignore',
|
|
3266
|
+
});
|
|
3267
|
+
child.unref();
|
|
3268
|
+
|
|
3269
|
+
const statePath = writeMonitorState(repoRoot, {
|
|
3270
|
+
pid: child.pid,
|
|
3271
|
+
interval_ms: intervalMs,
|
|
3272
|
+
quarantine: Boolean(quarantine),
|
|
3273
|
+
log_path: logPath,
|
|
3274
|
+
started_at: new Date().toISOString(),
|
|
3275
|
+
});
|
|
2430
3276
|
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
switchman gate ci
|
|
3277
|
+
return {
|
|
3278
|
+
already_running: false,
|
|
3279
|
+
state: readMonitorState(repoRoot),
|
|
3280
|
+
state_path: statePath,
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
2438
3283
|
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
3284
|
+
function renderMonitorEvent(event) {
|
|
3285
|
+
const ownerText = event.owner_worktree
|
|
3286
|
+
? `${event.owner_worktree}${event.owner_task_id ? ` (${event.owner_task_id})` : ''}`
|
|
3287
|
+
: null;
|
|
3288
|
+
const claimCommand = event.task_id
|
|
3289
|
+
? `switchman claim ${event.task_id} ${event.worktree} ${event.file_path}`
|
|
3290
|
+
: null;
|
|
2443
3291
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
`);
|
|
3292
|
+
if (event.status === 'denied') {
|
|
3293
|
+
console.log(`${chalk.yellow('⚠')} ${chalk.cyan(event.worktree)} modified ${chalk.yellow(event.file_path)} without governed ownership`);
|
|
3294
|
+
if (ownerText) {
|
|
3295
|
+
console.log(` ${chalk.dim('Owned by:')} ${chalk.cyan(ownerText)}${event.owner_task_title ? ` ${chalk.dim(`— ${event.owner_task_title}`)}` : ''}`);
|
|
3296
|
+
}
|
|
3297
|
+
if (claimCommand) {
|
|
3298
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan(claimCommand)}`);
|
|
3299
|
+
}
|
|
3300
|
+
console.log(` ${chalk.dim('Or:')} ${chalk.cyan('switchman status')} ${chalk.dim('to inspect current claims and blockers')}`);
|
|
3301
|
+
if (event.enforcement_action) {
|
|
3302
|
+
console.log(` ${chalk.dim('Action:')} ${event.enforcement_action}`);
|
|
3303
|
+
}
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
2448
3306
|
|
|
2449
|
-
|
|
2450
|
-
.
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
3307
|
+
const ownerSuffix = ownerText ? ` ${chalk.dim(`(${ownerText})`)}` : '';
|
|
3308
|
+
console.log(`${chalk.green('✓')} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${ownerSuffix}`);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
function resolveMonitoredWorktrees(db, repoRoot) {
|
|
3312
|
+
const registeredByPath = new Map(
|
|
3313
|
+
listWorktrees(db)
|
|
3314
|
+
.filter((worktree) => worktree.path)
|
|
3315
|
+
.map((worktree) => [worktree.path, worktree])
|
|
3316
|
+
);
|
|
3317
|
+
|
|
3318
|
+
return listGitWorktrees(repoRoot).map((worktree) => {
|
|
3319
|
+
const registered = registeredByPath.get(worktree.path);
|
|
3320
|
+
if (!registered) return worktree;
|
|
3321
|
+
return {
|
|
3322
|
+
...worktree,
|
|
3323
|
+
name: registered.name,
|
|
3324
|
+
path: registered.path || worktree.path,
|
|
3325
|
+
branch: registered.branch || worktree.branch,
|
|
3326
|
+
};
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
function discoverMergeCandidates(db, repoRoot, { targetBranch = 'main' } = {}) {
|
|
3331
|
+
const worktrees = listWorktrees(db).filter((worktree) => worktree.name !== 'main');
|
|
3332
|
+
const activeLeases = new Set(listLeases(db, 'active').map((lease) => lease.worktree));
|
|
3333
|
+
const tasks = listTasks(db);
|
|
3334
|
+
const queueItems = listMergeQueue(db).filter((item) => item.status !== 'merged');
|
|
3335
|
+
const alreadyQueued = new Set(queueItems.map((item) => item.source_worktree).filter(Boolean));
|
|
3336
|
+
|
|
3337
|
+
const eligible = [];
|
|
3338
|
+
const blocked = [];
|
|
3339
|
+
const skipped = [];
|
|
3340
|
+
|
|
3341
|
+
for (const worktree of worktrees) {
|
|
3342
|
+
const doneTasks = tasks.filter((task) => task.worktree === worktree.name && task.status === 'done');
|
|
3343
|
+
if (doneTasks.length === 0) {
|
|
3344
|
+
skipped.push({
|
|
3345
|
+
worktree: worktree.name,
|
|
3346
|
+
branch: worktree.branch,
|
|
3347
|
+
reason: 'no_completed_tasks',
|
|
3348
|
+
summary: 'no completed tasks are assigned to this worktree yet',
|
|
3349
|
+
command: `switchman task list --status done`,
|
|
3350
|
+
});
|
|
3351
|
+
continue;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
if (!worktree.branch || worktree.branch === targetBranch) {
|
|
3355
|
+
skipped.push({
|
|
3356
|
+
worktree: worktree.name,
|
|
3357
|
+
branch: worktree.branch || null,
|
|
3358
|
+
reason: 'no_merge_branch',
|
|
3359
|
+
summary: `worktree is on ${targetBranch} already`,
|
|
3360
|
+
command: `switchman worktree list`,
|
|
3361
|
+
});
|
|
3362
|
+
continue;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
if (!gitBranchExists(repoRoot, worktree.branch)) {
|
|
3366
|
+
blocked.push({
|
|
3367
|
+
worktree: worktree.name,
|
|
3368
|
+
branch: worktree.branch,
|
|
3369
|
+
reason: 'missing_branch',
|
|
3370
|
+
summary: `branch ${worktree.branch} is not available in git`,
|
|
3371
|
+
command: `switchman worktree sync`,
|
|
3372
|
+
});
|
|
3373
|
+
continue;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
if (activeLeases.has(worktree.name)) {
|
|
3377
|
+
blocked.push({
|
|
3378
|
+
worktree: worktree.name,
|
|
3379
|
+
branch: worktree.branch,
|
|
3380
|
+
reason: 'active_lease',
|
|
3381
|
+
summary: 'an active lease is still running in this worktree',
|
|
3382
|
+
command: `switchman status`,
|
|
3383
|
+
});
|
|
3384
|
+
continue;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
if (alreadyQueued.has(worktree.name)) {
|
|
3388
|
+
skipped.push({
|
|
3389
|
+
worktree: worktree.name,
|
|
3390
|
+
branch: worktree.branch,
|
|
3391
|
+
reason: 'already_queued',
|
|
3392
|
+
summary: 'worktree is already in the landing queue',
|
|
3393
|
+
command: `switchman queue status`,
|
|
3394
|
+
});
|
|
3395
|
+
continue;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
const dirtyFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
|
|
3399
|
+
if (dirtyFiles.length > 0) {
|
|
3400
|
+
blocked.push({
|
|
3401
|
+
worktree: worktree.name,
|
|
3402
|
+
branch: worktree.branch,
|
|
3403
|
+
path: worktree.path,
|
|
3404
|
+
files: dirtyFiles,
|
|
3405
|
+
reason: 'dirty_worktree',
|
|
3406
|
+
summary: `worktree has uncommitted changes: ${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ` +${dirtyFiles.length - 5} more` : ''}`,
|
|
3407
|
+
command: `cd ${JSON.stringify(worktree.path)} && git status`,
|
|
3408
|
+
});
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
const freshness = gitAssessBranchFreshness(repoRoot, targetBranch, worktree.branch);
|
|
3413
|
+
eligible.push({
|
|
3414
|
+
worktree: worktree.name,
|
|
3415
|
+
branch: worktree.branch,
|
|
3416
|
+
path: worktree.path,
|
|
3417
|
+
done_task_count: doneTasks.length,
|
|
3418
|
+
done_task_titles: doneTasks.slice(0, 3).map((task) => task.title),
|
|
3419
|
+
freshness,
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
return { eligible, blocked, skipped, queue_items: queueItems };
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
function printMergeDiscovery(discovery) {
|
|
3427
|
+
console.log('');
|
|
3428
|
+
console.log(chalk.bold(`Checking ${discovery.eligible.length + discovery.blocked.length + discovery.skipped.length} worktree(s)...`));
|
|
3429
|
+
|
|
3430
|
+
for (const entry of discovery.eligible) {
|
|
3431
|
+
const freshness = entry.freshness?.state && entry.freshness.state !== 'unknown'
|
|
3432
|
+
? ` ${chalk.dim(`(${entry.freshness.state})`)}`
|
|
3433
|
+
: '';
|
|
3434
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch)}${freshness}`);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
for (const entry of discovery.blocked) {
|
|
3438
|
+
console.log(` ${chalk.yellow('!')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
for (const entry of discovery.skipped) {
|
|
3442
|
+
if (entry.reason === 'no_completed_tasks') continue;
|
|
3443
|
+
console.log(` ${chalk.dim('·')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
// ─── Program ──────────────────────────────────────────────────────────────────
|
|
3448
|
+
|
|
3449
|
+
program
|
|
3450
|
+
.name('switchman')
|
|
3451
|
+
.description('Conflict-aware task coordinator for parallel AI coding agents')
|
|
3452
|
+
.version('0.1.0');
|
|
3453
|
+
|
|
3454
|
+
program.showHelpAfterError('(run with --help for usage examples)');
|
|
3455
|
+
const ROOT_HELP_COMMANDS = new Set([
|
|
3456
|
+
'advanced',
|
|
3457
|
+
'claude',
|
|
3458
|
+
'demo',
|
|
3459
|
+
'setup',
|
|
3460
|
+
'verify-setup',
|
|
3461
|
+
'login',
|
|
3462
|
+
'upgrade',
|
|
3463
|
+
'plan',
|
|
3464
|
+
'task',
|
|
3465
|
+
'status',
|
|
3466
|
+
'recover',
|
|
3467
|
+
'merge',
|
|
3468
|
+
'repair',
|
|
3469
|
+
'help',
|
|
3470
|
+
]);
|
|
3471
|
+
program.configureHelp({
|
|
3472
|
+
visibleCommands(cmd) {
|
|
3473
|
+
const commands = Help.prototype.visibleCommands.call(this, cmd);
|
|
3474
|
+
if (cmd.parent) return commands;
|
|
3475
|
+
return commands.filter((command) => ROOT_HELP_COMMANDS.has(command.name()) && !command._switchmanAdvanced);
|
|
3476
|
+
},
|
|
3477
|
+
});
|
|
3478
|
+
program.addHelpText('after', `
|
|
3479
|
+
Start here:
|
|
3480
|
+
switchman demo
|
|
3481
|
+
switchman setup --agents 3
|
|
3482
|
+
switchman task add "Your task" --priority 8
|
|
3483
|
+
switchman status --watch
|
|
3484
|
+
switchman recover
|
|
3485
|
+
switchman gate ci && switchman queue run
|
|
3486
|
+
|
|
3487
|
+
For you (the operator):
|
|
3488
|
+
switchman demo
|
|
3489
|
+
switchman setup
|
|
3490
|
+
switchman claude refresh
|
|
3491
|
+
switchman task add
|
|
3492
|
+
switchman status
|
|
3493
|
+
switchman recover
|
|
3494
|
+
switchman merge
|
|
3495
|
+
switchman repair
|
|
3496
|
+
switchman upgrade
|
|
3497
|
+
switchman login
|
|
3498
|
+
switchman plan "Add authentication" (Pro)
|
|
3499
|
+
|
|
3500
|
+
For your agents (via CLAUDE.md or MCP):
|
|
3501
|
+
switchman lease next
|
|
3502
|
+
switchman claim
|
|
3503
|
+
switchman task done
|
|
3504
|
+
switchman write
|
|
3505
|
+
switchman wrap
|
|
3506
|
+
|
|
3507
|
+
Docs:
|
|
3508
|
+
README.md
|
|
3509
|
+
docs/setup-claude-code.md
|
|
3510
|
+
|
|
3511
|
+
Power tools:
|
|
3512
|
+
switchman advanced --help
|
|
3513
|
+
`);
|
|
3514
|
+
|
|
3515
|
+
const advancedCmd = program
|
|
3516
|
+
.command('advanced')
|
|
3517
|
+
.description('Show advanced, experimental, and power-user command groups')
|
|
3518
|
+
.addHelpText('after', `
|
|
3519
|
+
Advanced operator commands:
|
|
3520
|
+
switchman pipeline <...>
|
|
3521
|
+
switchman audit <...>
|
|
3522
|
+
switchman policy <...>
|
|
3523
|
+
switchman monitor <...>
|
|
3524
|
+
switchman repair
|
|
3525
|
+
|
|
3526
|
+
Experimental commands:
|
|
3527
|
+
switchman semantic <...>
|
|
3528
|
+
switchman object <...>
|
|
3529
|
+
|
|
3530
|
+
Compatibility aliases:
|
|
3531
|
+
switchman doctor
|
|
3532
|
+
|
|
3533
|
+
Tip:
|
|
3534
|
+
The main help keeps the day-one workflow small on purpose.
|
|
3535
|
+
`)
|
|
3536
|
+
.action(() => {
|
|
3537
|
+
advancedCmd.outputHelp();
|
|
3538
|
+
});
|
|
3539
|
+
advancedCmd._switchmanAdvanced = false;
|
|
3540
|
+
|
|
3541
|
+
program
|
|
3542
|
+
.command('demo')
|
|
3543
|
+
.description('Create a throwaway repo that proves overlapping claims are blocked and safe landing works')
|
|
3544
|
+
.option('--path <dir>', 'Directory to create the demo repo in')
|
|
3545
|
+
.option('--cleanup', 'Delete the demo repo after the run finishes')
|
|
3546
|
+
.option('--json', 'Output raw JSON')
|
|
3547
|
+
.addHelpText('after', `
|
|
2456
3548
|
Examples:
|
|
2457
3549
|
switchman demo
|
|
2458
3550
|
switchman demo --path /tmp/switchman-demo
|
|
@@ -2508,12 +3600,16 @@ program
|
|
|
2508
3600
|
}
|
|
2509
3601
|
|
|
2510
3602
|
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
3603
|
+
const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
|
|
2511
3604
|
|
|
2512
3605
|
db.close();
|
|
2513
3606
|
spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
|
|
2514
3607
|
console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
|
|
2515
3608
|
console.log(chalk.dim(` Database: .switchman/switchman.db`));
|
|
2516
3609
|
console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
|
|
3610
|
+
if (mcpExclude.managed) {
|
|
3611
|
+
console.log(chalk.dim(` MCP excludes: ${mcpExclude.changed ? 'updated' : 'already set'} in .git/info/exclude`));
|
|
3612
|
+
}
|
|
2517
3613
|
console.log('');
|
|
2518
3614
|
console.log(`Next steps:`);
|
|
2519
3615
|
console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
|
|
@@ -2533,6 +3629,8 @@ program
|
|
|
2533
3629
|
.description('One-command setup: create agent workspaces and initialise Switchman')
|
|
2534
3630
|
.option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
|
|
2535
3631
|
.option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
|
|
3632
|
+
.option('--no-monitor', 'Do not start the background rogue-edit monitor')
|
|
3633
|
+
.option('--monitor-interval-ms <ms>', 'Polling interval for the background monitor', '2000')
|
|
2536
3634
|
.addHelpText('after', `
|
|
2537
3635
|
Examples:
|
|
2538
3636
|
switchman setup --agents 5
|
|
@@ -2546,6 +3644,24 @@ Examples:
|
|
|
2546
3644
|
process.exit(1);
|
|
2547
3645
|
}
|
|
2548
3646
|
|
|
3647
|
+
if (agentCount > FREE_AGENT_LIMIT) {
|
|
3648
|
+
const licence = await checkLicence();
|
|
3649
|
+
if (!licence.valid) {
|
|
3650
|
+
console.log('');
|
|
3651
|
+
console.log(chalk.yellow(` ⚠ Free tier supports up to ${FREE_AGENT_LIMIT} agents.`));
|
|
3652
|
+
console.log('');
|
|
3653
|
+
console.log(` You requested ${chalk.cyan(agentCount)} agents — that requires ${chalk.bold('Switchman Pro')}.`);
|
|
3654
|
+
console.log('');
|
|
3655
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
3656
|
+
console.log(` ${chalk.dim('Or run: ')} ${chalk.cyan('switchman upgrade')}`);
|
|
3657
|
+
console.log('');
|
|
3658
|
+
process.exit(1);
|
|
3659
|
+
}
|
|
3660
|
+
if (licence.offline) {
|
|
3661
|
+
console.log(chalk.dim(` Pro licence verified (offline cache · ${Math.ceil((7 * 24 * 60 * 60 * 1000 - (Date.now() - (licence.cached_at ?? 0))) / (24 * 60 * 60 * 1000))}d remaining)`));
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
|
|
2549
3665
|
const repoRoot = getRepo();
|
|
2550
3666
|
const spinner = ora('Setting up Switchman...').start();
|
|
2551
3667
|
|
|
@@ -2591,6 +3707,12 @@ Examples:
|
|
|
2591
3707
|
}
|
|
2592
3708
|
|
|
2593
3709
|
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
|
|
3710
|
+
const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
|
|
3711
|
+
|
|
3712
|
+
const monitorIntervalMs = Math.max(100, Number.parseInt(opts.monitorIntervalMs, 10) || 2000);
|
|
3713
|
+
const monitorState = opts.monitor
|
|
3714
|
+
? startBackgroundMonitor(repoRoot, { intervalMs: monitorIntervalMs, quarantine: false })
|
|
3715
|
+
: null;
|
|
2594
3716
|
|
|
2595
3717
|
db.close();
|
|
2596
3718
|
|
|
@@ -2610,6 +3732,20 @@ Examples:
|
|
|
2610
3732
|
const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
|
|
2611
3733
|
console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
|
|
2612
3734
|
}
|
|
3735
|
+
if (mcpExclude.managed) {
|
|
3736
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(mcpExclude.path)} ${chalk.dim(`(${mcpExclude.changed ? 'updated' : 'unchanged'})`)}`);
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
if (opts.monitor) {
|
|
3740
|
+
console.log('');
|
|
3741
|
+
console.log(chalk.bold('Monitor:'));
|
|
3742
|
+
if (monitorState?.already_running) {
|
|
3743
|
+
console.log(` ${chalk.green('✓')} Background rogue-edit monitor already running ${chalk.dim(`(pid ${monitorState.state.pid})`)}`);
|
|
3744
|
+
} else {
|
|
3745
|
+
console.log(` ${chalk.green('✓')} Started background rogue-edit monitor ${chalk.dim(`(pid ${monitorState?.state?.pid ?? 'unknown'})`)}`);
|
|
3746
|
+
}
|
|
3747
|
+
console.log(` ${chalk.dim('interval:')} ${monitorIntervalMs}ms`);
|
|
3748
|
+
}
|
|
2613
3749
|
|
|
2614
3750
|
console.log('');
|
|
2615
3751
|
console.log(chalk.bold('Next steps:'));
|
|
@@ -2618,6 +3754,13 @@ Examples:
|
|
|
2618
3754
|
console.log(` 2. Open Claude Code or Cursor in the workspaces above — the local MCP config will attach Switchman automatically`);
|
|
2619
3755
|
console.log(` 3. Keep the repo dashboard open while work starts:`);
|
|
2620
3756
|
console.log(` ${chalk.cyan('switchman status --watch')}`);
|
|
3757
|
+
console.log(` 4. Run the final check and land finished work:`);
|
|
3758
|
+
console.log(` ${chalk.cyan('switchman gate ci')}`);
|
|
3759
|
+
console.log(` ${chalk.cyan('switchman queue run')}`);
|
|
3760
|
+
if (opts.monitor) {
|
|
3761
|
+
console.log(` 5. Watch for rogue edits or direct writes in real time:`);
|
|
3762
|
+
console.log(` ${chalk.cyan('switchman monitor status')}`);
|
|
3763
|
+
}
|
|
2621
3764
|
console.log('');
|
|
2622
3765
|
|
|
2623
3766
|
const verification = collectSetupVerification(repoRoot);
|
|
@@ -2662,833 +3805,398 @@ Use this after setup or whenever editor/config wiring feels off.
|
|
|
2662
3805
|
}, { homeDir: opts.home || null });
|
|
2663
3806
|
if (!report.ok) process.exitCode = 1;
|
|
2664
3807
|
});
|
|
3808
|
+
registerClaudeCommands(program, {
|
|
3809
|
+
chalk,
|
|
3810
|
+
existsSync,
|
|
3811
|
+
getRepo,
|
|
3812
|
+
join,
|
|
3813
|
+
renderClaudeGuide,
|
|
3814
|
+
writeFileSync,
|
|
3815
|
+
});
|
|
2665
3816
|
|
|
2666
3817
|
|
|
2667
3818
|
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
2668
3819
|
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
|
|
2685
|
-
config_path: getTelemetryConfigPath(opts.home || undefined),
|
|
2686
|
-
};
|
|
2687
|
-
|
|
2688
|
-
if (opts.json) {
|
|
2689
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
2690
|
-
return;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
|
|
2694
|
-
console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
|
|
2695
|
-
console.log(`Config file: ${chalk.dim(payload.config_path)}`);
|
|
2696
|
-
if (payload.install_id) {
|
|
2697
|
-
console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
|
|
2698
|
-
}
|
|
2699
|
-
});
|
|
2700
|
-
|
|
2701
|
-
telemetryCmd
|
|
2702
|
-
.command('enable')
|
|
2703
|
-
.description('Enable anonymous telemetry for setup and operator workflows')
|
|
2704
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2705
|
-
.action((opts) => {
|
|
2706
|
-
const runtime = getTelemetryRuntimeConfig();
|
|
2707
|
-
if (!runtime.apiKey || runtime.disabled) {
|
|
2708
|
-
printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
|
|
2709
|
-
process.exitCode = 1;
|
|
2710
|
-
return;
|
|
2711
|
-
}
|
|
2712
|
-
const result = enableTelemetry(opts.home || undefined);
|
|
2713
|
-
console.log(`${chalk.green('✓')} Telemetry enabled`);
|
|
2714
|
-
console.log(` ${chalk.dim(result.path)}`);
|
|
2715
|
-
});
|
|
2716
|
-
|
|
2717
|
-
telemetryCmd
|
|
2718
|
-
.command('disable')
|
|
2719
|
-
.description('Disable anonymous telemetry')
|
|
2720
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2721
|
-
.action((opts) => {
|
|
2722
|
-
const result = disableTelemetry(opts.home || undefined);
|
|
2723
|
-
console.log(`${chalk.green('✓')} Telemetry disabled`);
|
|
2724
|
-
console.log(` ${chalk.dim(result.path)}`);
|
|
2725
|
-
});
|
|
2726
|
-
|
|
2727
|
-
telemetryCmd
|
|
2728
|
-
.command('test')
|
|
2729
|
-
.description('Send one test telemetry event and report whether delivery succeeded')
|
|
2730
|
-
.option('--home <path>', 'Override the home directory for telemetry config')
|
|
2731
|
-
.option('--json', 'Output raw JSON')
|
|
2732
|
-
.action(async (opts) => {
|
|
2733
|
-
const result = await sendTelemetryEvent('telemetry_test', {
|
|
2734
|
-
app_version: program.version(),
|
|
2735
|
-
os: process.platform,
|
|
2736
|
-
node_version: process.version,
|
|
2737
|
-
source: 'switchman-cli-test',
|
|
2738
|
-
}, { homeDir: opts.home || undefined });
|
|
2739
|
-
|
|
2740
|
-
if (opts.json) {
|
|
2741
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2742
|
-
if (!result.ok) process.exitCode = 1;
|
|
2743
|
-
return;
|
|
2744
|
-
}
|
|
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
|
+
});
|
|
2745
3835
|
|
|
2746
|
-
if (result.ok) {
|
|
2747
|
-
console.log(`${chalk.green('✓')} Telemetry test event delivered`);
|
|
2748
|
-
console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
|
|
2749
|
-
if (result.status) {
|
|
2750
|
-
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
2751
|
-
}
|
|
2752
|
-
return;
|
|
2753
|
-
}
|
|
2754
3836
|
|
|
2755
|
-
|
|
2756
|
-
console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
|
|
2757
|
-
if (result.status) {
|
|
2758
|
-
console.log(` ${chalk.dim('status:')} ${result.status}`);
|
|
2759
|
-
}
|
|
2760
|
-
if (result.error) {
|
|
2761
|
-
console.log(` ${chalk.dim('error:')} ${result.error}`);
|
|
2762
|
-
}
|
|
2763
|
-
process.exitCode = 1;
|
|
2764
|
-
});
|
|
3837
|
+
// ── plan ──────────────────────────────────────────────────────────────────────
|
|
2765
3838
|
|
|
2766
|
-
|
|
2767
|
-
.command('
|
|
2768
|
-
.description('
|
|
2769
|
-
.option('--
|
|
2770
|
-
.option('--
|
|
3839
|
+
program
|
|
3840
|
+
.command('plan [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')
|
|
3846
|
+
.option('--apply', 'Create the suggested tasks in Switchman')
|
|
3847
|
+
.option('--max-tasks <n>', 'Maximum number of suggested tasks', '6')
|
|
2771
3848
|
.option('--json', 'Output raw JSON')
|
|
2772
3849
|
.addHelpText('after', `
|
|
2773
3850
|
Examples:
|
|
2774
|
-
switchman
|
|
2775
|
-
switchman
|
|
3851
|
+
switchman plan "Add authentication"
|
|
3852
|
+
switchman plan --issue 47
|
|
3853
|
+
switchman plan "Add authentication" --apply
|
|
3854
|
+
switchman plan --issue 47 --apply --comment
|
|
2776
3855
|
`)
|
|
2777
|
-
.action((opts) => {
|
|
2778
|
-
if (!opts.windsurf) {
|
|
2779
|
-
console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
|
|
2780
|
-
process.exitCode = 1;
|
|
2781
|
-
return;
|
|
2782
|
-
}
|
|
2783
|
-
|
|
2784
|
-
const result = upsertWindsurfMcpConfig(opts.home);
|
|
2785
|
-
|
|
2786
|
-
if (opts.json) {
|
|
2787
|
-
console.log(JSON.stringify({
|
|
2788
|
-
editor: 'windsurf',
|
|
2789
|
-
path: result.path,
|
|
2790
|
-
created: result.created,
|
|
2791
|
-
changed: result.changed,
|
|
2792
|
-
}, null, 2));
|
|
2793
|
-
return;
|
|
2794
|
-
}
|
|
2795
|
-
|
|
2796
|
-
console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
|
|
2797
|
-
console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
|
|
2798
|
-
console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
|
|
2799
|
-
console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
|
|
2800
|
-
});
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
// ── task ──────────────────────────────────────────────────────────────────────
|
|
2804
|
-
|
|
2805
|
-
const taskCmd = program.command('task').description('Manage the task list');
|
|
2806
|
-
taskCmd.addHelpText('after', `
|
|
2807
|
-
Examples:
|
|
2808
|
-
switchman task add "Fix login bug" --priority 8
|
|
2809
|
-
switchman task list --status pending
|
|
2810
|
-
switchman task done task-123
|
|
2811
|
-
`);
|
|
2812
|
-
|
|
2813
|
-
taskCmd
|
|
2814
|
-
.command('add <title>')
|
|
2815
|
-
.description('Add a new task to the queue')
|
|
2816
|
-
.option('-d, --description <desc>', 'Task description')
|
|
2817
|
-
.option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
|
|
2818
|
-
.option('--id <id>', 'Custom task ID')
|
|
2819
|
-
.action((title, opts) => {
|
|
3856
|
+
.action(async (goal, opts) => {
|
|
2820
3857
|
const repoRoot = getRepo();
|
|
2821
|
-
const db =
|
|
2822
|
-
const taskId = createTask(db, {
|
|
2823
|
-
id: opts.id,
|
|
2824
|
-
title,
|
|
2825
|
-
description: opts.description,
|
|
2826
|
-
priority: parseInt(opts.priority),
|
|
2827
|
-
});
|
|
2828
|
-
db.close();
|
|
2829
|
-
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
2830
|
-
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
2831
|
-
console.log(` ${chalk.dim(title)}`);
|
|
2832
|
-
if (scopeWarning) {
|
|
2833
|
-
console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
|
|
2834
|
-
console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
|
|
2835
|
-
console.log(chalk.cyan(` try: ${scopeWarning.command}`));
|
|
2836
|
-
}
|
|
2837
|
-
});
|
|
2838
|
-
|
|
2839
|
-
taskCmd
|
|
2840
|
-
.command('list')
|
|
2841
|
-
.description('List all tasks')
|
|
2842
|
-
.option('-s, --status <status>', 'Filter by status (pending|in_progress|done|failed)')
|
|
2843
|
-
.action((opts) => {
|
|
2844
|
-
const repoRoot = getRepo();
|
|
2845
|
-
const db = getDb(repoRoot);
|
|
2846
|
-
const tasks = listTasks(db, opts.status);
|
|
2847
|
-
db.close();
|
|
2848
|
-
|
|
2849
|
-
if (!tasks.length) {
|
|
2850
|
-
console.log(chalk.dim('No tasks found.'));
|
|
2851
|
-
return;
|
|
2852
|
-
}
|
|
2853
|
-
|
|
2854
|
-
console.log('');
|
|
2855
|
-
for (const t of tasks) {
|
|
2856
|
-
const badge = statusBadge(t.status);
|
|
2857
|
-
const worktree = t.worktree ? chalk.cyan(t.worktree) : chalk.dim('unassigned');
|
|
2858
|
-
console.log(`${badge} ${chalk.bold(t.title)}`);
|
|
2859
|
-
console.log(` ${chalk.dim('id:')} ${t.id} ${chalk.dim('worktree:')} ${worktree} ${chalk.dim('priority:')} ${t.priority}`);
|
|
2860
|
-
if (t.description) console.log(` ${chalk.dim(t.description)}`);
|
|
2861
|
-
console.log('');
|
|
2862
|
-
}
|
|
2863
|
-
});
|
|
2864
|
-
|
|
2865
|
-
taskCmd
|
|
2866
|
-
.command('assign <taskId> <worktree>')
|
|
2867
|
-
.description('Assign a task to a workspace (compatibility shim for lease acquire)')
|
|
2868
|
-
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
2869
|
-
.action((taskId, worktree, opts) => {
|
|
2870
|
-
const repoRoot = getRepo();
|
|
2871
|
-
const db = getDb(repoRoot);
|
|
2872
|
-
const lease = startTaskLease(db, taskId, worktree, opts.agent);
|
|
2873
|
-
db.close();
|
|
2874
|
-
if (lease) {
|
|
2875
|
-
console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
|
|
2876
|
-
} else {
|
|
2877
|
-
console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
|
|
2878
|
-
}
|
|
2879
|
-
});
|
|
2880
|
-
|
|
2881
|
-
taskCmd
|
|
2882
|
-
.command('retry <taskId>')
|
|
2883
|
-
.description('Return a failed or stale completed task to pending so it can be revalidated')
|
|
2884
|
-
.option('--reason <text>', 'Reason to record for the retry')
|
|
2885
|
-
.option('--json', 'Output raw JSON')
|
|
2886
|
-
.action((taskId, opts) => {
|
|
2887
|
-
const repoRoot = getRepo();
|
|
2888
|
-
const db = getDb(repoRoot);
|
|
2889
|
-
const task = retryTask(db, taskId, opts.reason || 'manual retry');
|
|
2890
|
-
db.close();
|
|
2891
|
-
|
|
2892
|
-
if (!task) {
|
|
2893
|
-
printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
|
|
2894
|
-
process.exitCode = 1;
|
|
2895
|
-
return;
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
if (opts.json) {
|
|
2899
|
-
console.log(JSON.stringify(task, null, 2));
|
|
2900
|
-
return;
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
|
|
2904
|
-
console.log(` ${chalk.dim('title:')} ${task.title}`);
|
|
2905
|
-
console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
|
|
2906
|
-
});
|
|
2907
|
-
|
|
2908
|
-
taskCmd
|
|
2909
|
-
.command('retry-stale')
|
|
2910
|
-
.description('Return all currently stale tasks to pending so they can be revalidated together')
|
|
2911
|
-
.option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
|
|
2912
|
-
.option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
|
|
2913
|
-
.option('--json', 'Output raw JSON')
|
|
2914
|
-
.action((opts) => {
|
|
2915
|
-
const repoRoot = getRepo();
|
|
2916
|
-
const db = getDb(repoRoot);
|
|
2917
|
-
const result = retryStaleTasks(db, {
|
|
2918
|
-
pipelineId: opts.pipeline || null,
|
|
2919
|
-
reason: opts.reason,
|
|
2920
|
-
});
|
|
2921
|
-
db.close();
|
|
2922
|
-
|
|
2923
|
-
if (opts.json) {
|
|
2924
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2925
|
-
return;
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
if (result.retried.length === 0) {
|
|
2929
|
-
const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
|
|
2930
|
-
console.log(chalk.dim(`No stale tasks to retry${scope}.`));
|
|
2931
|
-
return;
|
|
2932
|
-
}
|
|
3858
|
+
const db = getOptionalDb(repoRoot);
|
|
2933
3859
|
|
|
2934
|
-
console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
|
|
2935
|
-
if (result.pipeline_id) {
|
|
2936
|
-
console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
|
|
2937
|
-
}
|
|
2938
|
-
console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
|
|
2939
|
-
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
2940
|
-
});
|
|
2941
|
-
|
|
2942
|
-
taskCmd
|
|
2943
|
-
.command('done <taskId>')
|
|
2944
|
-
.description('Mark a task as complete and release all file claims')
|
|
2945
|
-
.action((taskId) => {
|
|
2946
|
-
const repoRoot = getRepo();
|
|
2947
3860
|
try {
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
.description('Mark a task as failed')
|
|
2959
|
-
.action((taskId, reason) => {
|
|
2960
|
-
const repoRoot = getRepo();
|
|
2961
|
-
const db = getDb(repoRoot);
|
|
2962
|
-
failTask(db, taskId, reason);
|
|
2963
|
-
releaseFileClaims(db, taskId);
|
|
2964
|
-
db.close();
|
|
2965
|
-
console.log(`${chalk.red('✗')} Task ${chalk.cyan(taskId)} marked failed`);
|
|
2966
|
-
});
|
|
2967
|
-
|
|
2968
|
-
taskCmd
|
|
2969
|
-
.command('next')
|
|
2970
|
-
.description('Get the next pending task quickly (use `lease next` for the full workflow)')
|
|
2971
|
-
.option('--json', 'Output as JSON')
|
|
2972
|
-
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
2973
|
-
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
2974
|
-
.addHelpText('after', `
|
|
2975
|
-
Examples:
|
|
2976
|
-
switchman task next
|
|
2977
|
-
switchman task next --json
|
|
2978
|
-
`)
|
|
2979
|
-
.action((opts) => {
|
|
2980
|
-
const repoRoot = getRepo();
|
|
2981
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
2982
|
-
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
2983
|
-
|
|
2984
|
-
if (!task) {
|
|
2985
|
-
if (opts.json) console.log(JSON.stringify({ task: null }));
|
|
2986
|
-
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
2987
|
-
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
2988
|
-
return;
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2991
|
-
if (!lease) {
|
|
2992
|
-
if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
|
|
2993
|
-
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
2994
|
-
return;
|
|
2995
|
-
}
|
|
2996
|
-
|
|
2997
|
-
if (opts.json) {
|
|
2998
|
-
console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
|
|
2999
|
-
} else {
|
|
3000
|
-
console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
|
|
3001
|
-
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}`);
|
|
3002
|
-
}
|
|
3003
|
-
});
|
|
3004
|
-
|
|
3005
|
-
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
3006
|
-
|
|
3007
|
-
const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
|
|
3008
|
-
queueCmd.addHelpText('after', `
|
|
3009
|
-
Examples:
|
|
3010
|
-
switchman queue add --worktree agent1
|
|
3011
|
-
switchman queue status
|
|
3012
|
-
switchman queue run --watch
|
|
3013
|
-
`);
|
|
3014
|
-
|
|
3015
|
-
queueCmd
|
|
3016
|
-
.command('add [branch]')
|
|
3017
|
-
.description('Add a branch, workspace, or pipeline to the landing queue')
|
|
3018
|
-
.option('--worktree <name>', 'Queue a registered workspace by name')
|
|
3019
|
-
.option('--pipeline <pipelineId>', 'Queue a pipeline by id')
|
|
3020
|
-
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
3021
|
-
.option('--max-retries <n>', 'Maximum automatic retries', '1')
|
|
3022
|
-
.option('--submitted-by <name>', 'Operator or automation name')
|
|
3023
|
-
.option('--json', 'Output raw JSON')
|
|
3024
|
-
.addHelpText('after', `
|
|
3025
|
-
Examples:
|
|
3026
|
-
switchman queue add feature/auth-hardening
|
|
3027
|
-
switchman queue add --worktree agent2
|
|
3028
|
-
switchman queue add --pipeline pipe-123
|
|
3029
|
-
|
|
3030
|
-
Pipeline landing rule:
|
|
3031
|
-
switchman queue add --pipeline <id>
|
|
3032
|
-
lands the pipeline's inferred landing branch.
|
|
3033
|
-
If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
|
|
3034
|
-
`)
|
|
3035
|
-
.action(async (branch, opts) => {
|
|
3036
|
-
const repoRoot = getRepo();
|
|
3037
|
-
const db = getDb(repoRoot);
|
|
3861
|
+
const licence = await checkLicence();
|
|
3862
|
+
if (!licence.valid) {
|
|
3863
|
+
console.log('');
|
|
3864
|
+
console.log(chalk.yellow(' ⚠ AI planning requires Switchman Pro.'));
|
|
3865
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
3866
|
+
console.log(` ${chalk.dim('Or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
3867
|
+
console.log('');
|
|
3868
|
+
process.exitCode = 1;
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3038
3871
|
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
sourceType: 'worktree',
|
|
3048
|
-
sourceRef: worktree.branch,
|
|
3049
|
-
sourceWorktree: worktree.name,
|
|
3050
|
-
targetBranch: opts.target,
|
|
3051
|
-
maxRetries: opts.maxRetries,
|
|
3052
|
-
submittedBy: opts.submittedBy || null,
|
|
3053
|
-
};
|
|
3054
|
-
} else if (opts.pipeline) {
|
|
3055
|
-
const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
|
|
3056
|
-
if (!policyGate.ok) {
|
|
3057
|
-
throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
|
|
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;
|
|
3058
3880
|
}
|
|
3059
|
-
const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
|
|
3060
|
-
baseBranch: opts.target || 'main',
|
|
3061
|
-
requireCompleted: true,
|
|
3062
|
-
allowCurrentBranchFallback: false,
|
|
3063
|
-
});
|
|
3064
|
-
payload = {
|
|
3065
|
-
sourceType: 'pipeline',
|
|
3066
|
-
sourceRef: landingTarget.branch,
|
|
3067
|
-
sourcePipelineId: opts.pipeline,
|
|
3068
|
-
sourceWorktree: landingTarget.worktree || null,
|
|
3069
|
-
targetBranch: opts.target,
|
|
3070
|
-
maxRetries: opts.maxRetries,
|
|
3071
|
-
submittedBy: opts.submittedBy || null,
|
|
3072
|
-
eventDetails: policyGate.override_applied
|
|
3073
|
-
? {
|
|
3074
|
-
policy_override_summary: policyGate.override_summary,
|
|
3075
|
-
overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
|
|
3076
|
-
}
|
|
3077
|
-
: null,
|
|
3078
|
-
};
|
|
3079
|
-
} else if (branch) {
|
|
3080
|
-
payload = {
|
|
3081
|
-
sourceType: 'branch',
|
|
3082
|
-
sourceRef: branch,
|
|
3083
|
-
targetBranch: opts.target,
|
|
3084
|
-
maxRetries: opts.maxRetries,
|
|
3085
|
-
submittedBy: opts.submittedBy || null,
|
|
3086
|
-
};
|
|
3087
|
-
} else {
|
|
3088
|
-
throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
|
|
3089
3881
|
}
|
|
3090
3882
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
console.log(
|
|
3883
|
+
if ((!goal || !goal.trim()) && !issueContext) {
|
|
3884
|
+
console.log('');
|
|
3885
|
+
console.log(chalk.yellow(' ⚠ AI planning currently requires an explicit goal.'));
|
|
3886
|
+
console.log(` ${chalk.dim('Try:')} ${chalk.cyan('switchman plan "Add authentication"')}`);
|
|
3887
|
+
console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan --issue 47')}`);
|
|
3888
|
+
console.log(` ${chalk.dim('Then:')} ${chalk.cyan('switchman plan "Add authentication" --apply')}`);
|
|
3889
|
+
console.log('');
|
|
3890
|
+
process.exitCode = 1;
|
|
3096
3891
|
return;
|
|
3097
3892
|
}
|
|
3098
3893
|
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
console.log(
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
db.close();
|
|
3107
|
-
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
3108
|
-
process.exitCode = 1;
|
|
3109
|
-
}
|
|
3110
|
-
});
|
|
3111
|
-
|
|
3112
|
-
queueCmd
|
|
3113
|
-
.command('list')
|
|
3114
|
-
.description('List merge queue items')
|
|
3115
|
-
.option('--status <status>', 'Filter by queue status')
|
|
3116
|
-
.option('--json', 'Output raw JSON')
|
|
3117
|
-
.action((opts) => {
|
|
3118
|
-
const repoRoot = getRepo();
|
|
3119
|
-
const db = getDb(repoRoot);
|
|
3120
|
-
const items = listMergeQueue(db, { status: opts.status || null });
|
|
3121
|
-
db.close();
|
|
3122
|
-
|
|
3123
|
-
if (opts.json) {
|
|
3124
|
-
console.log(JSON.stringify(items, null, 2));
|
|
3125
|
-
return;
|
|
3126
|
-
}
|
|
3127
|
-
|
|
3128
|
-
if (items.length === 0) {
|
|
3129
|
-
console.log(chalk.dim('Merge queue is empty.'));
|
|
3130
|
-
return;
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
for (const item of items) {
|
|
3134
|
-
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
3135
|
-
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
3136
|
-
const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
|
|
3137
|
-
const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
|
|
3138
|
-
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
|
|
3139
|
-
if (item.last_error_summary) {
|
|
3140
|
-
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
3141
|
-
}
|
|
3142
|
-
if (item.next_action) {
|
|
3143
|
-
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
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;
|
|
3144
3901
|
}
|
|
3145
|
-
}
|
|
3146
|
-
});
|
|
3147
|
-
|
|
3148
|
-
queueCmd
|
|
3149
|
-
.command('status')
|
|
3150
|
-
.description('Show an operator-friendly merge queue summary')
|
|
3151
|
-
.option('--json', 'Output raw JSON')
|
|
3152
|
-
.addHelpText('after', `
|
|
3153
|
-
Plain English:
|
|
3154
|
-
Use this when finished branches are waiting to land and you want one safe queue view.
|
|
3155
|
-
|
|
3156
|
-
Examples:
|
|
3157
|
-
switchman queue status
|
|
3158
|
-
switchman queue status --json
|
|
3159
|
-
|
|
3160
|
-
What it helps you answer:
|
|
3161
|
-
- what lands next
|
|
3162
|
-
- what is blocked
|
|
3163
|
-
- what command should I run now
|
|
3164
|
-
`)
|
|
3165
|
-
.action((opts) => {
|
|
3166
|
-
const repoRoot = getRepo();
|
|
3167
|
-
const db = getDb(repoRoot);
|
|
3168
|
-
const items = listMergeQueue(db);
|
|
3169
|
-
const summary = buildQueueStatusSummary(items, { db, repoRoot });
|
|
3170
|
-
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
3171
|
-
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
3172
|
-
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
3173
|
-
db.close();
|
|
3174
3902
|
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
: 'healthy';
|
|
3185
|
-
const queueHealthColor = colorForHealth(queueHealth);
|
|
3186
|
-
const retryingItems = items.filter((item) => item.status === 'retrying');
|
|
3187
|
-
const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
|
|
3188
|
-
const focusLine = focus
|
|
3189
|
-
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
3190
|
-
: 'Nothing waiting. Landing queue is clear.';
|
|
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
|
+
}
|
|
3191
3912
|
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
3204
|
-
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
3205
|
-
]));
|
|
3206
|
-
console.log(renderMetricRow([
|
|
3207
|
-
{ label: 'items', value: items.length, color: chalk.white },
|
|
3208
|
-
{ label: 'validating', value: summary.counts.validating, color: chalk.blue },
|
|
3209
|
-
{ label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
|
|
3210
|
-
{ label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
|
|
3211
|
-
]));
|
|
3212
|
-
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
3913
|
+
const context = collectPlanContext(repoRoot, goal || null, issueContext);
|
|
3914
|
+
const planningWorktrees = resolvePlanningWorktrees(repoRoot, db);
|
|
3915
|
+
const pipelineId = `plan-${slugifyValue(context.title)}-${Date.now().toString(36)}`;
|
|
3916
|
+
const plannedTasks = planPipelineTasks({
|
|
3917
|
+
pipelineId,
|
|
3918
|
+
title: context.title,
|
|
3919
|
+
description: context.description,
|
|
3920
|
+
worktrees: planningWorktrees,
|
|
3921
|
+
maxTasks: Math.max(1, parseInt(opts.maxTasks, 10) || 6),
|
|
3922
|
+
repoRoot,
|
|
3923
|
+
});
|
|
3213
3924
|
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3925
|
+
if (opts.json) {
|
|
3926
|
+
const payload = {
|
|
3927
|
+
title: context.title,
|
|
3928
|
+
context: {
|
|
3929
|
+
found: context.found,
|
|
3930
|
+
used: context.used,
|
|
3931
|
+
branch: context.branch,
|
|
3932
|
+
},
|
|
3933
|
+
planned_tasks: plannedTasks.map((task) => ({
|
|
3934
|
+
id: task.id,
|
|
3935
|
+
title: task.title,
|
|
3936
|
+
suggested_worktree: task.suggested_worktree || null,
|
|
3937
|
+
task_type: task.task_spec?.task_type || null,
|
|
3938
|
+
dependencies: task.dependencies || [],
|
|
3939
|
+
})),
|
|
3940
|
+
apply_ready: Boolean(db),
|
|
3941
|
+
};
|
|
3942
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3232
3945
|
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3946
|
+
console.log(chalk.bold('Reading repo context...'));
|
|
3947
|
+
if (context.found.length > 0) {
|
|
3948
|
+
console.log(`${chalk.dim('Found:')} ${context.found.join(', ')}`);
|
|
3949
|
+
} else {
|
|
3950
|
+
console.log(chalk.dim('Found: local repo context only'));
|
|
3951
|
+
}
|
|
3952
|
+
console.log('');
|
|
3953
|
+
console.log(`${chalk.bold('Suggested plan based on:')} ${context.used.length > 0 ? formatHumanList(context.used) : 'available repo context'}`);
|
|
3954
|
+
console.log('');
|
|
3241
3955
|
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
.
|
|
3245
|
-
|
|
3246
|
-
.flatMap((item) => {
|
|
3247
|
-
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
3248
|
-
if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
|
|
3249
|
-
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
3250
|
-
return lines;
|
|
3251
|
-
})
|
|
3252
|
-
: [chalk.green('No in-flight queue items right now.')];
|
|
3956
|
+
plannedTasks.forEach((task, index) => {
|
|
3957
|
+
const worktreeLabel = task.suggested_worktree ? chalk.cyan(task.suggested_worktree) : chalk.dim('unassigned');
|
|
3958
|
+
console.log(` ${chalk.green('✓')} ${chalk.bold(`${index + 1}.`)} ${task.title} ${chalk.dim('→')} ${worktreeLabel}`);
|
|
3959
|
+
});
|
|
3253
3960
|
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3961
|
+
if (!opts.apply) {
|
|
3962
|
+
console.log('');
|
|
3963
|
+
if (!db) {
|
|
3964
|
+
console.log(chalk.dim('Preview only — run `switchman setup --agents 3` first if you want Switchman to create and track these tasks.'));
|
|
3965
|
+
} else {
|
|
3966
|
+
console.log(chalk.dim('Preview only — rerun with `switchman plan --apply` to create these tasks.'));
|
|
3967
|
+
}
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3259
3970
|
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
const queueSequenceLines = summary.recommended_sequence?.length > 0
|
|
3268
|
-
? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
|
|
3269
|
-
: [chalk.green('No recommended sequence beyond the current landing focus.')];
|
|
3971
|
+
if (!db) {
|
|
3972
|
+
console.log('');
|
|
3973
|
+
console.log(`${chalk.red('✗')} Switchman is not set up in this repo yet.`);
|
|
3974
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman setup --agents 3')}`);
|
|
3975
|
+
process.exitCode = 1;
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3270
3978
|
|
|
3271
|
-
console.log('');
|
|
3272
|
-
for (const block of [
|
|
3273
|
-
renderPanel('Landing focus', queueFocusLines, chalk.green),
|
|
3274
|
-
renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
|
|
3275
|
-
renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
|
|
3276
|
-
renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
|
|
3277
|
-
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
3278
|
-
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
3279
|
-
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
3280
|
-
]) {
|
|
3281
|
-
for (const line of block) console.log(line);
|
|
3282
3979
|
console.log('');
|
|
3283
|
-
|
|
3980
|
+
for (const task of plannedTasks) {
|
|
3981
|
+
createTask(db, {
|
|
3982
|
+
id: task.id,
|
|
3983
|
+
title: task.title,
|
|
3984
|
+
description: `Planned from: ${context.title}`,
|
|
3985
|
+
priority: planTaskPriority(task.task_spec),
|
|
3986
|
+
});
|
|
3987
|
+
upsertTaskSpec(db, task.id, task.task_spec);
|
|
3988
|
+
console.log(` ${chalk.green('✓')} Created ${chalk.cyan(task.id)} ${chalk.dim(task.title)}`);
|
|
3989
|
+
}
|
|
3284
3990
|
|
|
3285
|
-
|
|
3286
|
-
console.log(chalk.
|
|
3287
|
-
|
|
3288
|
-
|
|
3991
|
+
console.log('');
|
|
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)}.`);
|
|
3289
4005
|
}
|
|
4006
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman status --watch')}`);
|
|
4007
|
+
} finally {
|
|
4008
|
+
db?.close();
|
|
3290
4009
|
}
|
|
3291
4010
|
});
|
|
3292
4011
|
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
4012
|
+
|
|
4013
|
+
// ── task ──────────────────────────────────────────────────────────────────────
|
|
4014
|
+
|
|
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
|
+
});
|
|
4035
|
+
|
|
4036
|
+
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
4037
|
+
|
|
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
|
+
});
|
|
4065
|
+
|
|
4066
|
+
program
|
|
4067
|
+
.command('merge')
|
|
4068
|
+
.description('Queue finished worktrees and land safe work through one guided front door')
|
|
4069
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
4070
|
+
.option('--dry-run', 'Preview mergeable work without queueing or landing anything')
|
|
3303
4071
|
.option('--json', 'Output raw JSON')
|
|
3304
4072
|
.addHelpText('after', `
|
|
3305
4073
|
Examples:
|
|
3306
|
-
switchman
|
|
3307
|
-
switchman
|
|
3308
|
-
switchman
|
|
3309
|
-
switchman queue run --watch --watch-interval-ms 1000
|
|
4074
|
+
switchman merge
|
|
4075
|
+
switchman merge --dry-run
|
|
4076
|
+
switchman merge --target release
|
|
3310
4077
|
`)
|
|
3311
4078
|
.action(async (opts) => {
|
|
3312
4079
|
const repoRoot = getRepo();
|
|
4080
|
+
const db = getDb(repoRoot);
|
|
3313
4081
|
|
|
3314
4082
|
try {
|
|
3315
|
-
const
|
|
3316
|
-
const
|
|
3317
|
-
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
3318
|
-
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
3319
|
-
const mergeBudget = opts.mergeBudget !== undefined
|
|
3320
|
-
? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
|
|
3321
|
-
: null;
|
|
3322
|
-
const aggregate = {
|
|
3323
|
-
processed: [],
|
|
3324
|
-
cycles: 0,
|
|
3325
|
-
watch,
|
|
3326
|
-
execution_policy: {
|
|
3327
|
-
follow_plan: followPlan,
|
|
3328
|
-
merge_budget: mergeBudget,
|
|
3329
|
-
merged_count: 0,
|
|
3330
|
-
},
|
|
3331
|
-
};
|
|
4083
|
+
const discovery = discoverMergeCandidates(db, repoRoot, { targetBranch: opts.target || 'main' });
|
|
4084
|
+
const queued = [];
|
|
3332
4085
|
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
4086
|
+
for (const entry of discovery.eligible) {
|
|
4087
|
+
queued.push(enqueueMergeItem(db, {
|
|
4088
|
+
sourceType: 'worktree',
|
|
4089
|
+
sourceRef: entry.branch,
|
|
4090
|
+
sourceWorktree: entry.worktree,
|
|
3337
4091
|
targetBranch: opts.target || 'main',
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
});
|
|
3341
|
-
db.close();
|
|
3342
|
-
|
|
3343
|
-
aggregate.processed.push(...result.processed);
|
|
3344
|
-
aggregate.summary = result.summary;
|
|
3345
|
-
aggregate.deferred = result.deferred || aggregate.deferred || null;
|
|
3346
|
-
aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
|
|
3347
|
-
aggregate.cycles += 1;
|
|
3348
|
-
|
|
3349
|
-
if (!watch) break;
|
|
3350
|
-
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
3351
|
-
if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
|
|
3352
|
-
if (result.processed.length === 0) {
|
|
3353
|
-
sleepSync(watchIntervalMs);
|
|
3354
|
-
}
|
|
4092
|
+
submittedBy: 'switchman merge',
|
|
4093
|
+
}));
|
|
3355
4094
|
}
|
|
3356
4095
|
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
4096
|
+
const queueItems = listMergeQueue(db);
|
|
4097
|
+
const summary = buildQueueStatusSummary(queueItems, { db, repoRoot });
|
|
4098
|
+
const mergeOrder = summary.recommended_sequence
|
|
4099
|
+
.filter((item) => ['land_now', 'prepare_next'].includes(item.lane))
|
|
4100
|
+
.map((item) => item.source_ref);
|
|
3361
4101
|
|
|
3362
|
-
if (
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
if (followPlan) {
|
|
3368
|
-
console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
|
|
3369
|
-
}
|
|
3370
|
-
if (deferredFocus.recommendation?.summary) {
|
|
3371
|
-
console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
|
|
3372
|
-
}
|
|
3373
|
-
if (deferredFocus.recommendation?.command) {
|
|
3374
|
-
console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
|
|
3375
|
-
}
|
|
3376
|
-
} else {
|
|
3377
|
-
console.log(chalk.dim('No queued merge items.'));
|
|
4102
|
+
if (opts.json) {
|
|
4103
|
+
if (opts.dryRun) {
|
|
4104
|
+
console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, dry_run: true }, null, 2));
|
|
4105
|
+
db.close();
|
|
4106
|
+
return;
|
|
3378
4107
|
}
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
4108
|
+
|
|
4109
|
+
const gate = await evaluateQueueRepoGate(db, repoRoot);
|
|
4110
|
+
const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
|
|
4111
|
+
const result = gate.ok
|
|
4112
|
+
? await runMergeQueue(db, repoRoot, {
|
|
4113
|
+
targetBranch: opts.target || 'main',
|
|
4114
|
+
maxItems: Math.max(1, runnableCount),
|
|
4115
|
+
mergeBudget: Math.max(1, runnableCount),
|
|
4116
|
+
followPlan: false,
|
|
4117
|
+
})
|
|
4118
|
+
: null;
|
|
4119
|
+
console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, gate, result }, null, 2));
|
|
4120
|
+
db.close();
|
|
3386
4121
|
return;
|
|
3387
4122
|
}
|
|
3388
4123
|
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
console.log(
|
|
3396
|
-
console.log(`
|
|
3397
|
-
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4124
|
+
printMergeDiscovery(discovery);
|
|
4125
|
+
|
|
4126
|
+
if (discovery.blocked.length > 0) {
|
|
4127
|
+
console.log('');
|
|
4128
|
+
console.log(chalk.bold('Needs attention before landing:'));
|
|
4129
|
+
for (const entry of discovery.blocked) {
|
|
4130
|
+
console.log(` ${chalk.yellow(entry.worktree)}: ${entry.summary}`);
|
|
4131
|
+
console.log(` ${chalk.dim('run:')} ${chalk.cyan(entry.command)}`);
|
|
3398
4132
|
}
|
|
3399
4133
|
}
|
|
3400
4134
|
|
|
3401
|
-
if (
|
|
3402
|
-
console.log(
|
|
4135
|
+
if (mergeOrder.length > 0) {
|
|
4136
|
+
console.log('');
|
|
4137
|
+
console.log(`${chalk.bold('Merge order:')} ${mergeOrder.join(' → ')}`);
|
|
3403
4138
|
}
|
|
3404
4139
|
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
});
|
|
3412
|
-
} catch (err) {
|
|
3413
|
-
console.error(chalk.red(err.message));
|
|
3414
|
-
process.exitCode = 1;
|
|
3415
|
-
}
|
|
3416
|
-
});
|
|
3417
|
-
|
|
3418
|
-
queueCmd
|
|
3419
|
-
.command('retry <itemId>')
|
|
3420
|
-
.description('Retry a blocked merge queue item')
|
|
3421
|
-
.option('--json', 'Output raw JSON')
|
|
3422
|
-
.action((itemId, opts) => {
|
|
3423
|
-
const repoRoot = getRepo();
|
|
3424
|
-
const db = getDb(repoRoot);
|
|
3425
|
-
const item = retryMergeQueueItem(db, itemId);
|
|
3426
|
-
db.close();
|
|
3427
|
-
|
|
3428
|
-
if (!item) {
|
|
3429
|
-
printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
|
|
3430
|
-
process.exitCode = 1;
|
|
3431
|
-
return;
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
|
-
if (opts.json) {
|
|
3435
|
-
console.log(JSON.stringify(item, null, 2));
|
|
3436
|
-
return;
|
|
3437
|
-
}
|
|
4140
|
+
if (opts.dryRun) {
|
|
4141
|
+
console.log('');
|
|
4142
|
+
console.log(chalk.dim('Dry run only — nothing was landed.'));
|
|
4143
|
+
db.close();
|
|
4144
|
+
return;
|
|
4145
|
+
}
|
|
3438
4146
|
|
|
3439
|
-
|
|
3440
|
-
|
|
4147
|
+
if (discovery.eligible.length === 0) {
|
|
4148
|
+
console.log('');
|
|
4149
|
+
console.log(chalk.dim('No finished worktrees are ready to land yet.'));
|
|
4150
|
+
console.log(`${chalk.yellow('next:')} ${chalk.cyan('switchman status')}`);
|
|
4151
|
+
db.close();
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
3441
4154
|
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
.
|
|
3447
|
-
.
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
summary: opts.reason || null,
|
|
3452
|
-
nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
|
|
3453
|
-
});
|
|
3454
|
-
db.close();
|
|
4155
|
+
const gate = await evaluateQueueRepoGate(db, repoRoot);
|
|
4156
|
+
if (!gate.ok) {
|
|
4157
|
+
console.log('');
|
|
4158
|
+
console.log(`${chalk.red('✗')} Merge gate blocked landing`);
|
|
4159
|
+
console.log(` ${chalk.dim(gate.summary)}`);
|
|
4160
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman gate ci')}`);
|
|
4161
|
+
db.close();
|
|
4162
|
+
return;
|
|
4163
|
+
}
|
|
3455
4164
|
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
4165
|
+
console.log('');
|
|
4166
|
+
const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
|
|
4167
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
4168
|
+
targetBranch: opts.target || 'main',
|
|
4169
|
+
maxItems: Math.max(1, runnableCount),
|
|
4170
|
+
mergeBudget: Math.max(1, runnableCount),
|
|
4171
|
+
followPlan: false,
|
|
4172
|
+
});
|
|
3461
4173
|
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
4174
|
+
const merged = result.processed.filter((item) => item.status === 'merged');
|
|
4175
|
+
for (const mergedItem of merged) {
|
|
4176
|
+
console.log(` ${chalk.green('✓')} Landed ${chalk.cyan(mergedItem.item.source_ref)} into ${chalk.bold(mergedItem.item.target_branch)}`);
|
|
4177
|
+
}
|
|
3466
4178
|
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
}
|
|
3474
|
-
});
|
|
4179
|
+
if (merged.length > 0 && !result.deferred && result.processed.every((item) => item.status === 'merged')) {
|
|
4180
|
+
console.log('');
|
|
4181
|
+
console.log(`${chalk.green('✓')} Done. ${merged.length} worktree(s) landed cleanly.`);
|
|
4182
|
+
db.close();
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
3475
4185
|
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
db.close();
|
|
4186
|
+
const blocked = result.processed.find((item) => item.status !== 'merged')?.item || result.deferred || null;
|
|
4187
|
+
if (blocked) {
|
|
4188
|
+
console.log('');
|
|
4189
|
+
console.log(`${chalk.yellow('!')} Landing stopped at ${chalk.cyan(blocked.source_ref)}`);
|
|
4190
|
+
if (blocked.last_error_summary) console.log(` ${chalk.dim(blocked.last_error_summary)}`);
|
|
4191
|
+
if (blocked.next_action) console.log(` ${chalk.yellow('next:')} ${blocked.next_action}`);
|
|
4192
|
+
}
|
|
3484
4193
|
|
|
3485
|
-
|
|
3486
|
-
|
|
4194
|
+
db.close();
|
|
4195
|
+
} catch (err) {
|
|
4196
|
+
db.close();
|
|
4197
|
+
console.error(chalk.red(err.message));
|
|
3487
4198
|
process.exitCode = 1;
|
|
3488
|
-
return;
|
|
3489
4199
|
}
|
|
3490
|
-
|
|
3491
|
-
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
3492
4200
|
});
|
|
3493
4201
|
|
|
3494
4202
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
@@ -3771,6 +4479,7 @@ explainCmd
|
|
|
3771
4479
|
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
3772
4480
|
|
|
3773
4481
|
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
4482
|
+
pipelineCmd._switchmanAdvanced = true;
|
|
3774
4483
|
pipelineCmd.addHelpText('after', `
|
|
3775
4484
|
Examples:
|
|
3776
4485
|
switchman pipeline start "Harden auth API permissions"
|
|
@@ -4528,316 +5237,43 @@ Examples:
|
|
|
4528
5237
|
} catch (err) {
|
|
4529
5238
|
db.close();
|
|
4530
5239
|
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
4531
|
-
process.exitCode = 1;
|
|
4532
|
-
}
|
|
4533
|
-
});
|
|
4534
|
-
|
|
4535
|
-
// ── lease ────────────────────────────────────────────────────────────────────
|
|
4536
|
-
|
|
4537
|
-
const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
|
|
4538
|
-
leaseCmd.addHelpText('after', `
|
|
4539
|
-
Plain English:
|
|
4540
|
-
lease = a task currently checked out by an agent
|
|
4541
|
-
|
|
4542
|
-
Examples:
|
|
4543
|
-
switchman lease next --json
|
|
4544
|
-
switchman lease heartbeat lease-123
|
|
4545
|
-
switchman lease reap
|
|
4546
|
-
`);
|
|
4547
|
-
|
|
4548
|
-
leaseCmd
|
|
4549
|
-
.command('acquire <taskId> <worktree>')
|
|
4550
|
-
.description('Start a tracked work session for a specific pending task')
|
|
4551
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
4552
|
-
.option('--json', 'Output as JSON')
|
|
4553
|
-
.addHelpText('after', `
|
|
4554
|
-
Examples:
|
|
4555
|
-
switchman lease acquire task-123 agent2
|
|
4556
|
-
switchman lease acquire task-123 agent2 --agent cursor
|
|
4557
|
-
`)
|
|
4558
|
-
.action((taskId, worktree, opts) => {
|
|
4559
|
-
const repoRoot = getRepo();
|
|
4560
|
-
const db = getDb(repoRoot);
|
|
4561
|
-
const task = getTask(db, taskId);
|
|
4562
|
-
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
4563
|
-
db.close();
|
|
4564
|
-
|
|
4565
|
-
if (!lease || !task) {
|
|
4566
|
-
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
4567
|
-
else printErrorWithNext('Could not start a work session. The task may not exist or may already be in progress.', 'switchman task list --status pending');
|
|
4568
|
-
process.exitCode = 1;
|
|
4569
|
-
return;
|
|
4570
|
-
}
|
|
4571
|
-
|
|
4572
|
-
if (opts.json) {
|
|
4573
|
-
console.log(JSON.stringify({
|
|
4574
|
-
lease,
|
|
4575
|
-
task: taskJsonWithLease(task, worktree, lease).task,
|
|
4576
|
-
}, null, 2));
|
|
4577
|
-
return;
|
|
4578
|
-
}
|
|
4579
|
-
|
|
4580
|
-
console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
|
|
4581
|
-
console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
|
|
4582
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
|
|
4583
|
-
});
|
|
4584
|
-
|
|
4585
|
-
leaseCmd
|
|
4586
|
-
.command('next')
|
|
4587
|
-
.description('Start the next pending task and open a tracked work session for it')
|
|
4588
|
-
.option('--json', 'Output as JSON')
|
|
4589
|
-
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
4590
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
4591
|
-
.addHelpText('after', `
|
|
4592
|
-
Examples:
|
|
4593
|
-
switchman lease next
|
|
4594
|
-
switchman lease next --json
|
|
4595
|
-
switchman lease next --worktree agent2 --agent cursor
|
|
4596
|
-
`)
|
|
4597
|
-
.action((opts) => {
|
|
4598
|
-
const repoRoot = getRepo();
|
|
4599
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
4600
|
-
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
4601
|
-
|
|
4602
|
-
if (!task) {
|
|
4603
|
-
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
4604
|
-
else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
|
|
4605
|
-
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
4606
|
-
return;
|
|
4607
|
-
}
|
|
4608
|
-
|
|
4609
|
-
if (!lease) {
|
|
4610
|
-
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
4611
|
-
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
4612
|
-
return;
|
|
4613
|
-
}
|
|
4614
|
-
|
|
4615
|
-
if (opts.json) {
|
|
4616
|
-
console.log(JSON.stringify({
|
|
4617
|
-
lease,
|
|
4618
|
-
...taskJsonWithLease(task, worktreeName, lease),
|
|
4619
|
-
}, null, 2));
|
|
4620
|
-
return;
|
|
4621
|
-
}
|
|
4622
|
-
|
|
4623
|
-
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
4624
|
-
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
4625
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
4626
|
-
});
|
|
4627
|
-
|
|
4628
|
-
leaseCmd
|
|
4629
|
-
.command('list')
|
|
4630
|
-
.description('List leases, newest first')
|
|
4631
|
-
.option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
|
|
4632
|
-
.action((opts) => {
|
|
4633
|
-
const repoRoot = getRepo();
|
|
4634
|
-
const db = getDb(repoRoot);
|
|
4635
|
-
const leases = listLeases(db, opts.status);
|
|
4636
|
-
db.close();
|
|
4637
|
-
|
|
4638
|
-
if (!leases.length) {
|
|
4639
|
-
console.log(chalk.dim('No leases found.'));
|
|
4640
|
-
return;
|
|
4641
|
-
}
|
|
4642
|
-
|
|
4643
|
-
console.log('');
|
|
4644
|
-
for (const lease of leases) {
|
|
4645
|
-
console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
|
|
4646
|
-
console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
|
|
4647
|
-
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
|
|
4648
|
-
console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
|
|
4649
|
-
if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
|
|
4650
|
-
console.log('');
|
|
4651
|
-
}
|
|
4652
|
-
});
|
|
4653
|
-
|
|
4654
|
-
leaseCmd
|
|
4655
|
-
.command('heartbeat <leaseId>')
|
|
4656
|
-
.description('Refresh the heartbeat timestamp for an active lease')
|
|
4657
|
-
.option('--agent <name>', 'Agent identifier for logging')
|
|
4658
|
-
.option('--json', 'Output as JSON')
|
|
4659
|
-
.action((leaseId, opts) => {
|
|
4660
|
-
const repoRoot = getRepo();
|
|
4661
|
-
const db = getDb(repoRoot);
|
|
4662
|
-
const lease = heartbeatLease(db, leaseId, opts.agent || null);
|
|
4663
|
-
db.close();
|
|
4664
|
-
|
|
4665
|
-
if (!lease) {
|
|
4666
|
-
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
4667
|
-
else printErrorWithNext(`No active work session found for ${leaseId}.`, 'switchman lease list --status active');
|
|
4668
|
-
process.exitCode = 1;
|
|
4669
|
-
return;
|
|
4670
|
-
}
|
|
4671
|
-
|
|
4672
|
-
if (opts.json) {
|
|
4673
|
-
console.log(JSON.stringify({ lease }, null, 2));
|
|
4674
|
-
return;
|
|
4675
|
-
}
|
|
4676
|
-
|
|
4677
|
-
console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
|
|
4678
|
-
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
4679
|
-
});
|
|
4680
|
-
|
|
4681
|
-
leaseCmd
|
|
4682
|
-
.command('reap')
|
|
4683
|
-
.description('Clean up abandoned work sessions and release their file locks')
|
|
4684
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
4685
|
-
.option('--json', 'Output as JSON')
|
|
4686
|
-
.addHelpText('after', `
|
|
4687
|
-
Examples:
|
|
4688
|
-
switchman lease reap
|
|
4689
|
-
switchman lease reap --stale-after-minutes 20
|
|
4690
|
-
`)
|
|
4691
|
-
.action((opts) => {
|
|
4692
|
-
const repoRoot = getRepo();
|
|
4693
|
-
const db = getDb(repoRoot);
|
|
4694
|
-
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
4695
|
-
const staleAfterMinutes = opts.staleAfterMinutes
|
|
4696
|
-
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
4697
|
-
: leasePolicy.stale_after_minutes;
|
|
4698
|
-
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
4699
|
-
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
4700
|
-
});
|
|
4701
|
-
db.close();
|
|
4702
|
-
|
|
4703
|
-
if (opts.json) {
|
|
4704
|
-
console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
|
|
4705
|
-
return;
|
|
4706
|
-
}
|
|
4707
|
-
|
|
4708
|
-
if (!expired.length) {
|
|
4709
|
-
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
4710
|
-
return;
|
|
4711
|
-
}
|
|
4712
|
-
|
|
4713
|
-
console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
|
|
4714
|
-
for (const lease of expired) {
|
|
4715
|
-
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
4716
|
-
}
|
|
4717
|
-
});
|
|
4718
|
-
|
|
4719
|
-
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
4720
|
-
|
|
4721
|
-
leasePolicyCmd
|
|
4722
|
-
.command('set')
|
|
4723
|
-
.description('Persist a stale-lease policy for this repo')
|
|
4724
|
-
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
4725
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
4726
|
-
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
4727
|
-
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
4728
|
-
.option('--json', 'Output as JSON')
|
|
4729
|
-
.action((opts) => {
|
|
4730
|
-
const repoRoot = getRepo();
|
|
4731
|
-
const current = loadLeasePolicy(repoRoot);
|
|
4732
|
-
const next = {
|
|
4733
|
-
...current,
|
|
4734
|
-
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
4735
|
-
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
4736
|
-
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
4737
|
-
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
4738
|
-
};
|
|
4739
|
-
const path = writeLeasePolicy(repoRoot, next);
|
|
4740
|
-
const saved = loadLeasePolicy(repoRoot);
|
|
4741
|
-
|
|
4742
|
-
if (opts.json) {
|
|
4743
|
-
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
4744
|
-
return;
|
|
5240
|
+
process.exitCode = 1;
|
|
4745
5241
|
}
|
|
4746
|
-
|
|
4747
|
-
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
4748
|
-
console.log(` ${chalk.dim(path)}`);
|
|
4749
|
-
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
4750
|
-
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
4751
|
-
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
4752
|
-
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
4753
5242
|
});
|
|
4754
5243
|
|
|
4755
|
-
|
|
4756
|
-
.description('Show the active stale-lease policy for this repo')
|
|
4757
|
-
.option('--json', 'Output as JSON')
|
|
4758
|
-
.action((opts) => {
|
|
4759
|
-
const repoRoot = getRepo();
|
|
4760
|
-
const policy = loadLeasePolicy(repoRoot);
|
|
4761
|
-
if (opts.json) {
|
|
4762
|
-
console.log(JSON.stringify({ policy }, null, 2));
|
|
4763
|
-
return;
|
|
4764
|
-
}
|
|
5244
|
+
// ── lease ────────────────────────────────────────────────────────────────────
|
|
4765
5245
|
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
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
|
+
});
|
|
4772
5263
|
|
|
4773
5264
|
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
4774
5265
|
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
wtCmd
|
|
4787
|
-
.command('add <name> <path> <branch>')
|
|
4788
|
-
.description('Register a workspace with Switchman')
|
|
4789
|
-
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
4790
|
-
.action((name, path, branch, opts) => {
|
|
4791
|
-
const repoRoot = getRepo();
|
|
4792
|
-
const db = getDb(repoRoot);
|
|
4793
|
-
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
4794
|
-
db.close();
|
|
4795
|
-
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
4796
|
-
});
|
|
4797
|
-
|
|
4798
|
-
wtCmd
|
|
4799
|
-
.command('list')
|
|
4800
|
-
.description('List all registered workspaces')
|
|
4801
|
-
.action(() => {
|
|
4802
|
-
const repoRoot = getRepo();
|
|
4803
|
-
const db = getDb(repoRoot);
|
|
4804
|
-
const worktrees = listWorktrees(db);
|
|
4805
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
4806
|
-
db.close();
|
|
4807
|
-
|
|
4808
|
-
if (!worktrees.length && !gitWorktrees.length) {
|
|
4809
|
-
console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
|
|
4810
|
-
return;
|
|
4811
|
-
}
|
|
4812
|
-
|
|
4813
|
-
// Show git worktrees (source of truth) annotated with db info
|
|
4814
|
-
console.log('');
|
|
4815
|
-
console.log(chalk.bold('Git Worktrees:'));
|
|
4816
|
-
for (const wt of gitWorktrees) {
|
|
4817
|
-
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
4818
|
-
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
4819
|
-
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
4820
|
-
const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
4821
|
-
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
4822
|
-
console.log(` ${chalk.dim(wt.path)}`);
|
|
4823
|
-
}
|
|
4824
|
-
console.log('');
|
|
4825
|
-
});
|
|
4826
|
-
|
|
4827
|
-
wtCmd
|
|
4828
|
-
.command('sync')
|
|
4829
|
-
.description('Sync Git workspaces into the Switchman database')
|
|
4830
|
-
.action(() => {
|
|
4831
|
-
const repoRoot = getRepo();
|
|
4832
|
-
const db = getDb(repoRoot);
|
|
4833
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
4834
|
-
for (const wt of gitWorktrees) {
|
|
4835
|
-
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
4836
|
-
}
|
|
4837
|
-
db.close();
|
|
4838
|
-
installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
4839
|
-
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
4840
|
-
});
|
|
5266
|
+
registerWorktreeCommands(program, {
|
|
5267
|
+
chalk,
|
|
5268
|
+
evaluateRepoCompliance,
|
|
5269
|
+
getDb,
|
|
5270
|
+
getRepo,
|
|
5271
|
+
installMcpConfig,
|
|
5272
|
+
listGitWorktrees,
|
|
5273
|
+
listWorktrees,
|
|
5274
|
+
registerWorktree,
|
|
5275
|
+
statusBadge,
|
|
5276
|
+
});
|
|
4841
5277
|
|
|
4842
5278
|
// ── claim ──────────────────────────────────────────────────────────────────────
|
|
4843
5279
|
|
|
@@ -4845,13 +5281,14 @@ program
|
|
|
4845
5281
|
.command('claim <taskId> <worktree> [files...]')
|
|
4846
5282
|
.description('Lock files for a task before editing')
|
|
4847
5283
|
.option('--agent <name>', 'Agent name')
|
|
4848
|
-
.option('--force', '
|
|
5284
|
+
.option('--force', 'Emergency override for manual recovery when a conflicting claim is known to be stale or wrong')
|
|
4849
5285
|
.addHelpText('after', `
|
|
4850
5286
|
Examples:
|
|
4851
5287
|
switchman claim task-123 agent2 src/auth.js src/server.js
|
|
4852
5288
|
switchman claim task-123 agent2 src/auth.js --agent cursor
|
|
4853
5289
|
|
|
4854
5290
|
Use this before editing files in a shared repo.
|
|
5291
|
+
Only use --force for operator-led recovery after checking switchman status or switchman explain claim <path>.
|
|
4855
5292
|
`)
|
|
4856
5293
|
.action((taskId, worktree, files, opts) => {
|
|
4857
5294
|
if (!files.length) {
|
|
@@ -4870,7 +5307,7 @@ Use this before editing files in a shared repo.
|
|
|
4870
5307
|
for (const c of conflicts) {
|
|
4871
5308
|
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
4872
5309
|
}
|
|
4873
|
-
console.log(chalk.dim('\
|
|
5310
|
+
console.log(chalk.dim('\nDo not use --force as a shortcut. Check status or explain the claim first, then only override if the existing claim is known-bad.'));
|
|
4874
5311
|
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
4875
5312
|
process.exitCode = 1;
|
|
4876
5313
|
return;
|
|
@@ -4878,6 +5315,12 @@ Use this before editing files in a shared repo.
|
|
|
4878
5315
|
|
|
4879
5316
|
const lease = claimFiles(db, taskId, worktree, files, opts.agent);
|
|
4880
5317
|
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
5318
|
+
pushSyncEvent('claim_added', {
|
|
5319
|
+
task_id: taskId,
|
|
5320
|
+
lease_id: lease.id,
|
|
5321
|
+
file_count: files.length,
|
|
5322
|
+
files: files.slice(0, 10),
|
|
5323
|
+
}, { worktree }).catch(() => {});
|
|
4881
5324
|
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
4882
5325
|
} catch (err) {
|
|
4883
5326
|
printErrorWithNext(err.message, 'switchman task list --status in_progress');
|
|
@@ -4893,9 +5336,11 @@ program
|
|
|
4893
5336
|
.action((taskId) => {
|
|
4894
5337
|
const repoRoot = getRepo();
|
|
4895
5338
|
const db = getDb(repoRoot);
|
|
5339
|
+
const task = getTask(db, taskId);
|
|
4896
5340
|
releaseFileClaims(db, taskId);
|
|
4897
5341
|
db.close();
|
|
4898
5342
|
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
5343
|
+
pushSyncEvent('claim_released', { task_id: taskId }, { worktree: task?.worktree || null }).catch(() => {});
|
|
4899
5344
|
});
|
|
4900
5345
|
|
|
4901
5346
|
program
|
|
@@ -5171,12 +5616,14 @@ program
|
|
|
5171
5616
|
.description('Show one dashboard view of what is running, blocked, and ready next')
|
|
5172
5617
|
.option('--json', 'Output raw JSON')
|
|
5173
5618
|
.option('--watch', 'Keep refreshing status in the terminal')
|
|
5619
|
+
.option('--repair', 'Repair safe interrupted queue and pipeline state before rendering status')
|
|
5174
5620
|
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
|
|
5175
5621
|
.option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
|
|
5176
5622
|
.addHelpText('after', `
|
|
5177
5623
|
Examples:
|
|
5178
5624
|
switchman status
|
|
5179
5625
|
switchman status --watch
|
|
5626
|
+
switchman status --repair
|
|
5180
5627
|
switchman status --json
|
|
5181
5628
|
|
|
5182
5629
|
Use this first when the repo feels stuck.
|
|
@@ -5194,13 +5641,40 @@ Use this first when the repo feels stuck.
|
|
|
5194
5641
|
console.clear();
|
|
5195
5642
|
}
|
|
5196
5643
|
|
|
5644
|
+
let repairResult = null;
|
|
5645
|
+
if (opts.repair) {
|
|
5646
|
+
const repairDb = getDb(repoRoot);
|
|
5647
|
+
try {
|
|
5648
|
+
repairResult = repairRepoState(repairDb, repoRoot);
|
|
5649
|
+
} finally {
|
|
5650
|
+
repairDb.close();
|
|
5651
|
+
}
|
|
5652
|
+
}
|
|
5653
|
+
|
|
5197
5654
|
const report = await collectStatusSnapshot(repoRoot);
|
|
5655
|
+
const [teamActivity, teamState] = await Promise.all([
|
|
5656
|
+
pullActiveTeamMembers(),
|
|
5657
|
+
pullTeamState(),
|
|
5658
|
+
]);
|
|
5659
|
+
const myUserId = readCredentials()?.user_id;
|
|
5660
|
+
const otherMembers = teamActivity.filter(e => e.user_id !== myUserId);
|
|
5661
|
+
const teamSummary = summarizeTeamCoordinationState(teamState, myUserId);
|
|
5198
5662
|
cycles += 1;
|
|
5199
5663
|
|
|
5200
5664
|
if (opts.json) {
|
|
5201
|
-
|
|
5665
|
+
const payload = watch ? { ...report, watch: true, cycles } : report;
|
|
5666
|
+
const withTeam = { ...payload, team_sync: { summary: teamSummary, recent_events: teamState.filter((event) => event.user_id !== myUserId).slice(0, 25) } };
|
|
5667
|
+
console.log(JSON.stringify(opts.repair ? { ...withTeam, repair: repairResult } : withTeam, null, 2));
|
|
5202
5668
|
} else {
|
|
5203
|
-
|
|
5669
|
+
if (opts.repair && repairResult) {
|
|
5670
|
+
printRepairSummary(repairResult, {
|
|
5671
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state before rendering status`,
|
|
5672
|
+
noRepairHeading: `${chalk.green('✓')} No repo repair action needed before rendering status`,
|
|
5673
|
+
limit: 6,
|
|
5674
|
+
});
|
|
5675
|
+
console.log('');
|
|
5676
|
+
}
|
|
5677
|
+
renderUnifiedStatusReport(report, { teamActivity: otherMembers, teamSummary });
|
|
5204
5678
|
if (watch) {
|
|
5205
5679
|
const signature = buildWatchSignature(report);
|
|
5206
5680
|
const watchState = lastSignature === null
|
|
@@ -5229,6 +5703,42 @@ Use this first when the repo feels stuck.
|
|
|
5229
5703
|
}
|
|
5230
5704
|
});
|
|
5231
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,
|
|
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;
|
|
5739
|
+
}
|
|
5740
|
+
});
|
|
5741
|
+
|
|
5232
5742
|
program
|
|
5233
5743
|
.command('repair')
|
|
5234
5744
|
.description('Repair safe interrupted queue and pipeline state across the repo')
|
|
@@ -5267,9 +5777,11 @@ program
|
|
|
5267
5777
|
}
|
|
5268
5778
|
});
|
|
5269
5779
|
|
|
5270
|
-
program
|
|
5780
|
+
const doctorCmd = program
|
|
5271
5781
|
.command('doctor')
|
|
5272
|
-
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
|
|
5782
|
+
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next');
|
|
5783
|
+
doctorCmd._switchmanAdvanced = true;
|
|
5784
|
+
doctorCmd
|
|
5273
5785
|
.option('--repair', 'Repair safe interrupted queue and pipeline state before reporting health')
|
|
5274
5786
|
.option('--json', 'Output raw JSON')
|
|
5275
5787
|
.addHelpText('after', `
|
|
@@ -5358,377 +5870,74 @@ Examples:
|
|
|
5358
5870
|
? report.attention.slice(0, 6).flatMap((item) => {
|
|
5359
5871
|
const lines = [`${item.severity === 'block' ? renderChip('BLOCKED', item.kind || 'item', chalk.red) : renderChip('WATCH', item.kind || 'item', chalk.yellow)} ${item.title}`];
|
|
5360
5872
|
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
5361
|
-
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
5362
|
-
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
5363
|
-
return lines;
|
|
5364
|
-
})
|
|
5365
|
-
: [chalk.green('Nothing urgent.')];
|
|
5366
|
-
|
|
5367
|
-
const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
|
|
5368
|
-
? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
|
|
5369
|
-
const lines = [`${cluster.severity === 'block' ? renderChip('STALE', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.red) : renderChip('WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.yellow)} ${cluster.title}`];
|
|
5370
|
-
lines.push(` ${chalk.dim(cluster.detail)}`);
|
|
5371
|
-
if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
|
|
5372
|
-
lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
|
|
5373
|
-
lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
|
|
5374
|
-
lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
|
|
5375
|
-
lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
|
|
5376
|
-
return lines;
|
|
5377
|
-
})
|
|
5378
|
-
: [chalk.green('No stale dependency clusters.')];
|
|
5379
|
-
|
|
5380
|
-
const nextStepLines = [
|
|
5381
|
-
...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
|
|
5382
|
-
'',
|
|
5383
|
-
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
5384
|
-
];
|
|
5385
|
-
|
|
5386
|
-
console.log('');
|
|
5387
|
-
console.log(chalk.bold('Attention now:'));
|
|
5388
|
-
for (const block of [
|
|
5389
|
-
renderPanel('Running now', runningLines, chalk.cyan),
|
|
5390
|
-
renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
|
|
5391
|
-
renderPanel('Stale clusters', staleClusterLines, report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green),
|
|
5392
|
-
renderPanel('Recommended next steps', nextStepLines, chalk.green),
|
|
5393
|
-
]) {
|
|
5394
|
-
for (const line of block) console.log(line);
|
|
5395
|
-
console.log('');
|
|
5396
|
-
}
|
|
5397
|
-
});
|
|
5398
|
-
|
|
5399
|
-
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
5400
|
-
|
|
5401
|
-
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
5402
|
-
gateCmd.addHelpText('after', `
|
|
5403
|
-
Examples:
|
|
5404
|
-
switchman gate ci
|
|
5405
|
-
switchman gate ai
|
|
5406
|
-
switchman gate install-ci
|
|
5407
|
-
`);
|
|
5408
|
-
|
|
5409
|
-
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
5410
|
-
|
|
5411
|
-
auditCmd
|
|
5412
|
-
.command('change <pipelineId>')
|
|
5413
|
-
.description('Show a signed, operator-friendly history for one pipeline')
|
|
5414
|
-
.option('--json', 'Output raw JSON')
|
|
5415
|
-
.action((pipelineId, options) => {
|
|
5416
|
-
const repoRoot = getRepo();
|
|
5417
|
-
const db = getDb(repoRoot);
|
|
5418
|
-
|
|
5419
|
-
try {
|
|
5420
|
-
const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
|
|
5421
|
-
db.close();
|
|
5422
|
-
|
|
5423
|
-
if (options.json) {
|
|
5424
|
-
console.log(JSON.stringify(report, null, 2));
|
|
5425
|
-
return;
|
|
5426
|
-
}
|
|
5427
|
-
|
|
5428
|
-
console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
|
|
5429
|
-
console.log(` ${chalk.dim('title:')} ${report.title}`);
|
|
5430
|
-
console.log(` ${chalk.dim('events:')} ${report.events.length}`);
|
|
5431
|
-
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
5432
|
-
for (const event of report.events.slice(-20)) {
|
|
5433
|
-
const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
|
|
5434
|
-
console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
|
|
5435
|
-
console.log(` ${event.summary}`);
|
|
5436
|
-
}
|
|
5437
|
-
} catch (err) {
|
|
5438
|
-
db.close();
|
|
5439
|
-
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
5440
|
-
process.exitCode = 1;
|
|
5441
|
-
}
|
|
5442
|
-
});
|
|
5443
|
-
|
|
5444
|
-
auditCmd
|
|
5445
|
-
.command('verify')
|
|
5446
|
-
.description('Verify the audit log hash chain and project signatures')
|
|
5447
|
-
.option('--json', 'Output verification details as JSON')
|
|
5448
|
-
.action((options) => {
|
|
5449
|
-
const repo = getRepo();
|
|
5450
|
-
const db = getDb(repo);
|
|
5451
|
-
const result = verifyAuditTrail(db);
|
|
5452
|
-
|
|
5453
|
-
if (options.json) {
|
|
5454
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5455
|
-
process.exit(result.ok ? 0 : 1);
|
|
5456
|
-
}
|
|
5457
|
-
|
|
5458
|
-
if (result.ok) {
|
|
5459
|
-
console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
|
|
5460
|
-
return;
|
|
5461
|
-
}
|
|
5462
|
-
|
|
5463
|
-
console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
|
|
5464
|
-
for (const failure of result.failures.slice(0, 10)) {
|
|
5465
|
-
const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
|
|
5466
|
-
console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
|
|
5467
|
-
}
|
|
5468
|
-
if (result.failures.length > 10) {
|
|
5469
|
-
console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
|
|
5470
|
-
}
|
|
5471
|
-
process.exit(1);
|
|
5472
|
-
});
|
|
5473
|
-
|
|
5474
|
-
gateCmd
|
|
5475
|
-
.command('commit')
|
|
5476
|
-
.description('Validate current worktree changes against the active lease and claims')
|
|
5477
|
-
.option('--json', 'Output raw JSON')
|
|
5478
|
-
.action((opts) => {
|
|
5479
|
-
const repoRoot = getRepo();
|
|
5480
|
-
const db = getDb(repoRoot);
|
|
5481
|
-
const result = runCommitGate(db, repoRoot);
|
|
5482
|
-
db.close();
|
|
5483
|
-
|
|
5484
|
-
if (opts.json) {
|
|
5485
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5486
|
-
} else if (result.ok) {
|
|
5487
|
-
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
5488
|
-
} else {
|
|
5489
|
-
console.log(chalk.red(`✗ ${result.summary}`));
|
|
5490
|
-
for (const violation of result.violations) {
|
|
5491
|
-
const label = violation.file || '(worktree)';
|
|
5492
|
-
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
5493
|
-
}
|
|
5494
|
-
}
|
|
5495
|
-
|
|
5496
|
-
if (!result.ok) process.exitCode = 1;
|
|
5497
|
-
});
|
|
5498
|
-
|
|
5499
|
-
gateCmd
|
|
5500
|
-
.command('merge')
|
|
5501
|
-
.description('Validate current worktree changes before recording a merge commit')
|
|
5502
|
-
.option('--json', 'Output raw JSON')
|
|
5503
|
-
.action((opts) => {
|
|
5504
|
-
const repoRoot = getRepo();
|
|
5505
|
-
const db = getDb(repoRoot);
|
|
5506
|
-
const result = runCommitGate(db, repoRoot);
|
|
5507
|
-
db.close();
|
|
5508
|
-
|
|
5509
|
-
if (opts.json) {
|
|
5510
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5511
|
-
} else if (result.ok) {
|
|
5512
|
-
console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
|
|
5513
|
-
} else {
|
|
5514
|
-
console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
|
|
5515
|
-
for (const violation of result.violations) {
|
|
5516
|
-
const label = violation.file || '(worktree)';
|
|
5517
|
-
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
5518
|
-
}
|
|
5519
|
-
}
|
|
5520
|
-
|
|
5521
|
-
if (!result.ok) process.exitCode = 1;
|
|
5522
|
-
});
|
|
5523
|
-
|
|
5524
|
-
gateCmd
|
|
5525
|
-
.command('install')
|
|
5526
|
-
.description('Install git hooks that run the Switchman commit and merge gates')
|
|
5527
|
-
.action(() => {
|
|
5528
|
-
const repoRoot = getRepo();
|
|
5529
|
-
const hookPaths = installGateHooks(repoRoot);
|
|
5530
|
-
console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
|
|
5531
|
-
console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
|
|
5532
|
-
});
|
|
5533
|
-
|
|
5534
|
-
gateCmd
|
|
5535
|
-
.command('ci')
|
|
5536
|
-
.description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
|
|
5537
|
-
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
5538
|
-
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
5539
|
-
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
5540
|
-
.option('--json', 'Output raw JSON')
|
|
5541
|
-
.action(async (opts) => {
|
|
5542
|
-
const repoRoot = getRepo();
|
|
5543
|
-
const db = getDb(repoRoot);
|
|
5544
|
-
const report = await scanAllWorktrees(db, repoRoot);
|
|
5545
|
-
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
5546
|
-
db.close();
|
|
5547
|
-
|
|
5548
|
-
const ok = report.conflicts.length === 0
|
|
5549
|
-
&& report.fileConflicts.length === 0
|
|
5550
|
-
&& (report.ownershipConflicts?.length || 0) === 0
|
|
5551
|
-
&& (report.semanticConflicts?.length || 0) === 0
|
|
5552
|
-
&& report.unclaimedChanges.length === 0
|
|
5553
|
-
&& report.complianceSummary.non_compliant === 0
|
|
5554
|
-
&& report.complianceSummary.stale === 0
|
|
5555
|
-
&& aiGate.status !== 'blocked'
|
|
5556
|
-
&& (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
|
|
5557
|
-
|
|
5558
|
-
const result = {
|
|
5559
|
-
ok,
|
|
5560
|
-
summary: ok
|
|
5561
|
-
? `Repo gate passed for ${report.worktrees.length} worktree(s).`
|
|
5562
|
-
: 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
|
|
5563
|
-
compliance: report.complianceSummary,
|
|
5564
|
-
unclaimed_changes: report.unclaimedChanges,
|
|
5565
|
-
file_conflicts: report.fileConflicts,
|
|
5566
|
-
ownership_conflicts: report.ownershipConflicts || [],
|
|
5567
|
-
semantic_conflicts: report.semanticConflicts || [],
|
|
5568
|
-
branch_conflicts: report.conflicts,
|
|
5569
|
-
ai_gate_status: aiGate.status,
|
|
5570
|
-
boundary_validations: aiGate.boundary_validations || [],
|
|
5571
|
-
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
5572
|
-
};
|
|
5573
|
-
|
|
5574
|
-
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
5575
|
-
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
5576
|
-
writeGitHubCiStatus({
|
|
5577
|
-
result,
|
|
5578
|
-
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
5579
|
-
outputPath: githubTargets.outputPath,
|
|
5580
|
-
});
|
|
5581
|
-
}
|
|
5582
|
-
|
|
5583
|
-
if (opts.json) {
|
|
5584
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5585
|
-
} else if (ok) {
|
|
5586
|
-
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
5587
|
-
} else {
|
|
5588
|
-
console.log(chalk.red(`✗ ${result.summary}`));
|
|
5589
|
-
if (result.unclaimed_changes.length > 0) {
|
|
5590
|
-
console.log(chalk.bold(' Unclaimed changes:'));
|
|
5591
|
-
for (const entry of result.unclaimed_changes) {
|
|
5592
|
-
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
|
|
5593
|
-
}
|
|
5594
|
-
}
|
|
5595
|
-
if (result.file_conflicts.length > 0) {
|
|
5596
|
-
console.log(chalk.bold(' File conflicts:'));
|
|
5597
|
-
for (const conflict of result.file_conflicts) {
|
|
5598
|
-
console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
|
|
5599
|
-
}
|
|
5600
|
-
}
|
|
5601
|
-
if (result.ownership_conflicts.length > 0) {
|
|
5602
|
-
console.log(chalk.bold(' Ownership conflicts:'));
|
|
5603
|
-
for (const conflict of result.ownership_conflicts) {
|
|
5604
|
-
if (conflict.type === 'subsystem_overlap') {
|
|
5605
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
|
|
5606
|
-
} else {
|
|
5607
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
|
|
5608
|
-
}
|
|
5609
|
-
}
|
|
5610
|
-
}
|
|
5611
|
-
if (result.semantic_conflicts.length > 0) {
|
|
5612
|
-
console.log(chalk.bold(' Semantic conflicts:'));
|
|
5613
|
-
for (const conflict of result.semantic_conflicts) {
|
|
5614
|
-
console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
5615
|
-
}
|
|
5616
|
-
}
|
|
5617
|
-
if (result.branch_conflicts.length > 0) {
|
|
5618
|
-
console.log(chalk.bold(' Branch conflicts:'));
|
|
5619
|
-
for (const conflict of result.branch_conflicts) {
|
|
5620
|
-
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
|
|
5621
|
-
}
|
|
5622
|
-
}
|
|
5623
|
-
if (result.boundary_validations.length > 0) {
|
|
5624
|
-
console.log(chalk.bold(' Boundary validations:'));
|
|
5625
|
-
for (const validation of result.boundary_validations) {
|
|
5626
|
-
console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
5627
|
-
}
|
|
5628
|
-
}
|
|
5629
|
-
if (result.dependency_invalidations.length > 0) {
|
|
5630
|
-
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
5631
|
-
for (const invalidation of result.dependency_invalidations) {
|
|
5632
|
-
console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
|
|
5633
|
-
}
|
|
5634
|
-
}
|
|
5635
|
-
}
|
|
5636
|
-
|
|
5637
|
-
await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
|
|
5638
|
-
worktree_count: report.worktrees.length,
|
|
5639
|
-
unclaimed_change_count: result.unclaimed_changes.length,
|
|
5640
|
-
file_conflict_count: result.file_conflicts.length,
|
|
5641
|
-
ownership_conflict_count: result.ownership_conflicts.length,
|
|
5642
|
-
semantic_conflict_count: result.semantic_conflicts.length,
|
|
5643
|
-
branch_conflict_count: result.branch_conflicts.length,
|
|
5644
|
-
});
|
|
5645
|
-
|
|
5646
|
-
if (!ok) process.exitCode = 1;
|
|
5647
|
-
});
|
|
5648
|
-
|
|
5649
|
-
gateCmd
|
|
5650
|
-
.command('install-ci')
|
|
5651
|
-
.description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
|
|
5652
|
-
.option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
|
|
5653
|
-
.action((opts) => {
|
|
5654
|
-
const repoRoot = getRepo();
|
|
5655
|
-
const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
|
|
5656
|
-
console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
|
|
5657
|
-
});
|
|
5658
|
-
|
|
5659
|
-
gateCmd
|
|
5660
|
-
.command('ai')
|
|
5661
|
-
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
5662
|
-
.option('--json', 'Output raw JSON')
|
|
5663
|
-
.action(async (opts) => {
|
|
5664
|
-
const repoRoot = getRepo();
|
|
5665
|
-
const db = getDb(repoRoot);
|
|
5666
|
-
const result = await runAiMergeGate(db, repoRoot);
|
|
5667
|
-
db.close();
|
|
5668
|
-
|
|
5669
|
-
if (opts.json) {
|
|
5670
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5671
|
-
} else {
|
|
5672
|
-
const badge = result.status === 'pass'
|
|
5673
|
-
? chalk.green('PASS')
|
|
5674
|
-
: result.status === 'warn'
|
|
5675
|
-
? chalk.yellow('WARN')
|
|
5676
|
-
: chalk.red('BLOCK');
|
|
5677
|
-
console.log(`${badge} ${result.summary}`);
|
|
5678
|
-
|
|
5679
|
-
const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
|
|
5680
|
-
if (riskyPairs.length > 0) {
|
|
5681
|
-
console.log(chalk.bold(' Risky pairs:'));
|
|
5682
|
-
for (const pair of riskyPairs) {
|
|
5683
|
-
console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
|
|
5684
|
-
for (const reason of pair.reasons.slice(0, 3)) {
|
|
5685
|
-
console.log(` ${chalk.yellow(reason)}`);
|
|
5686
|
-
}
|
|
5687
|
-
}
|
|
5688
|
-
}
|
|
5689
|
-
|
|
5690
|
-
if ((result.boundary_validations?.length || 0) > 0) {
|
|
5691
|
-
console.log(chalk.bold(' Boundary validations:'));
|
|
5692
|
-
for (const validation of result.boundary_validations.slice(0, 5)) {
|
|
5693
|
-
console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
5694
|
-
if (validation.rationale?.[0]) {
|
|
5695
|
-
console.log(` ${chalk.yellow(validation.rationale[0])}`);
|
|
5696
|
-
}
|
|
5697
|
-
}
|
|
5698
|
-
}
|
|
5873
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
5874
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
5875
|
+
return lines;
|
|
5876
|
+
})
|
|
5877
|
+
: [chalk.green('Nothing urgent.')];
|
|
5699
5878
|
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
}
|
|
5705
|
-
|
|
5879
|
+
const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
|
|
5880
|
+
? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
|
|
5881
|
+
const lines = [`${cluster.severity === 'block' ? renderChip('STALE', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.red) : renderChip('WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.yellow)} ${cluster.title}`];
|
|
5882
|
+
lines.push(` ${chalk.dim(cluster.detail)}`);
|
|
5883
|
+
if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
|
|
5884
|
+
lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
|
|
5885
|
+
lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
|
|
5886
|
+
lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
|
|
5887
|
+
lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
|
|
5888
|
+
return lines;
|
|
5889
|
+
})
|
|
5890
|
+
: [chalk.green('No stale dependency clusters.')];
|
|
5706
5891
|
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
}
|
|
5892
|
+
const nextStepLines = [
|
|
5893
|
+
...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
|
|
5894
|
+
'',
|
|
5895
|
+
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
5896
|
+
];
|
|
5713
5897
|
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5898
|
+
console.log('');
|
|
5899
|
+
console.log(chalk.bold('Attention now:'));
|
|
5900
|
+
for (const block of [
|
|
5901
|
+
renderPanel('Running now', runningLines, chalk.cyan),
|
|
5902
|
+
renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
|
|
5903
|
+
renderPanel('Stale clusters', staleClusterLines, report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green),
|
|
5904
|
+
renderPanel('Recommended next steps', nextStepLines, chalk.green),
|
|
5905
|
+
]) {
|
|
5906
|
+
for (const line of block) console.log(line);
|
|
5907
|
+
console.log('');
|
|
5724
5908
|
}
|
|
5725
|
-
|
|
5726
|
-
if (result.status === 'blocked') process.exitCode = 1;
|
|
5727
5909
|
});
|
|
5728
5910
|
|
|
5911
|
+
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
5912
|
+
|
|
5913
|
+
registerAuditCommands(program, {
|
|
5914
|
+
buildPipelineHistoryReport,
|
|
5915
|
+
chalk,
|
|
5916
|
+
getDb,
|
|
5917
|
+
getRepo,
|
|
5918
|
+
printErrorWithNext,
|
|
5919
|
+
statusBadge,
|
|
5920
|
+
verifyAuditTrail,
|
|
5921
|
+
});
|
|
5922
|
+
|
|
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
|
+
});
|
|
5936
|
+
|
|
5729
5937
|
const semanticCmd = program
|
|
5730
5938
|
.command('semantic')
|
|
5731
5939
|
.description('Inspect or materialize the derived semantic code-object view');
|
|
5940
|
+
semanticCmd._switchmanAdvanced = true;
|
|
5732
5941
|
|
|
5733
5942
|
semanticCmd
|
|
5734
5943
|
.command('materialize')
|
|
@@ -5745,6 +5954,7 @@ semanticCmd
|
|
|
5745
5954
|
const objectCmd = program
|
|
5746
5955
|
.command('object')
|
|
5747
5956
|
.description('Experimental object-source mode backed by canonical exported code objects');
|
|
5957
|
+
objectCmd._switchmanAdvanced = true;
|
|
5748
5958
|
|
|
5749
5959
|
objectCmd
|
|
5750
5960
|
.command('import')
|
|
@@ -5825,180 +6035,25 @@ objectCmd
|
|
|
5825
6035
|
|
|
5826
6036
|
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
5827
6037
|
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
.
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
if (opts.json) {
|
|
5843
|
-
console.log(JSON.stringify(result, null, 2));
|
|
5844
|
-
return;
|
|
5845
|
-
}
|
|
5846
|
-
|
|
5847
|
-
if (result.events.length === 0) {
|
|
5848
|
-
console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
|
|
5849
|
-
return;
|
|
5850
|
-
}
|
|
5851
|
-
|
|
5852
|
-
console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
|
|
5853
|
-
for (const event of result.events) {
|
|
5854
|
-
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
5855
|
-
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
5856
|
-
console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
|
|
5857
|
-
}
|
|
5858
|
-
});
|
|
5859
|
-
|
|
5860
|
-
monitorCmd
|
|
5861
|
-
.command('watch')
|
|
5862
|
-
.description('Poll workspaces continuously and log observed file changes')
|
|
5863
|
-
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
5864
|
-
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
5865
|
-
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
5866
|
-
.action(async (opts) => {
|
|
5867
|
-
const repoRoot = getRepo();
|
|
5868
|
-
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
5869
|
-
|
|
5870
|
-
if (!Number.isFinite(intervalMs) || intervalMs < 100) {
|
|
5871
|
-
console.error(chalk.red('--interval-ms must be at least 100'));
|
|
5872
|
-
process.exit(1);
|
|
5873
|
-
}
|
|
5874
|
-
|
|
5875
|
-
console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
5876
|
-
|
|
5877
|
-
let stopped = false;
|
|
5878
|
-
const stop = () => {
|
|
5879
|
-
stopped = true;
|
|
5880
|
-
process.stdout.write('\n');
|
|
5881
|
-
if (opts.daemonized) {
|
|
5882
|
-
clearMonitorState(repoRoot);
|
|
5883
|
-
}
|
|
5884
|
-
};
|
|
5885
|
-
process.on('SIGINT', stop);
|
|
5886
|
-
process.on('SIGTERM', stop);
|
|
5887
|
-
|
|
5888
|
-
while (!stopped) {
|
|
5889
|
-
const db = getDb(repoRoot);
|
|
5890
|
-
const worktrees = listGitWorktrees(repoRoot);
|
|
5891
|
-
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
5892
|
-
db.close();
|
|
5893
|
-
|
|
5894
|
-
for (const event of result.events) {
|
|
5895
|
-
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
5896
|
-
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
5897
|
-
console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
|
|
5898
|
-
}
|
|
5899
|
-
|
|
5900
|
-
if (stopped) break;
|
|
5901
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
|
|
5902
|
-
}
|
|
5903
|
-
|
|
5904
|
-
console.log(chalk.dim('Stopped worktree monitor.'));
|
|
5905
|
-
});
|
|
5906
|
-
|
|
5907
|
-
monitorCmd
|
|
5908
|
-
.command('start')
|
|
5909
|
-
.description('Start the worktree monitor as a background process')
|
|
5910
|
-
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
5911
|
-
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
5912
|
-
.action((opts) => {
|
|
5913
|
-
const repoRoot = getRepo();
|
|
5914
|
-
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
5915
|
-
const existingState = readMonitorState(repoRoot);
|
|
5916
|
-
|
|
5917
|
-
if (existingState && isProcessRunning(existingState.pid)) {
|
|
5918
|
-
console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
|
|
5919
|
-
return;
|
|
5920
|
-
}
|
|
5921
|
-
|
|
5922
|
-
const logPath = join(repoRoot, '.switchman', 'monitor.log');
|
|
5923
|
-
const child = spawn(process.execPath, [
|
|
5924
|
-
process.argv[1],
|
|
5925
|
-
'monitor',
|
|
5926
|
-
'watch',
|
|
5927
|
-
'--interval-ms',
|
|
5928
|
-
String(intervalMs),
|
|
5929
|
-
...(opts.quarantine ? ['--quarantine'] : []),
|
|
5930
|
-
'--daemonized',
|
|
5931
|
-
], {
|
|
5932
|
-
cwd: repoRoot,
|
|
5933
|
-
detached: true,
|
|
5934
|
-
stdio: 'ignore',
|
|
5935
|
-
});
|
|
5936
|
-
child.unref();
|
|
5937
|
-
|
|
5938
|
-
const statePath = writeMonitorState(repoRoot, {
|
|
5939
|
-
pid: child.pid,
|
|
5940
|
-
interval_ms: intervalMs,
|
|
5941
|
-
quarantine: Boolean(opts.quarantine),
|
|
5942
|
-
log_path: logPath,
|
|
5943
|
-
started_at: new Date().toISOString(),
|
|
5944
|
-
});
|
|
5945
|
-
|
|
5946
|
-
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
|
|
5947
|
-
console.log(`${chalk.dim('State:')} ${statePath}`);
|
|
5948
|
-
});
|
|
5949
|
-
|
|
5950
|
-
monitorCmd
|
|
5951
|
-
.command('stop')
|
|
5952
|
-
.description('Stop the background worktree monitor')
|
|
5953
|
-
.action(() => {
|
|
5954
|
-
const repoRoot = getRepo();
|
|
5955
|
-
const state = readMonitorState(repoRoot);
|
|
5956
|
-
|
|
5957
|
-
if (!state) {
|
|
5958
|
-
console.log(chalk.dim('Monitor is not running.'));
|
|
5959
|
-
return;
|
|
5960
|
-
}
|
|
5961
|
-
|
|
5962
|
-
if (!isProcessRunning(state.pid)) {
|
|
5963
|
-
clearMonitorState(repoRoot);
|
|
5964
|
-
console.log(chalk.dim('Monitor state was stale and has been cleared.'));
|
|
5965
|
-
return;
|
|
5966
|
-
}
|
|
5967
|
-
|
|
5968
|
-
process.kill(state.pid, 'SIGTERM');
|
|
5969
|
-
clearMonitorState(repoRoot);
|
|
5970
|
-
console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
|
|
5971
|
-
});
|
|
5972
|
-
|
|
5973
|
-
monitorCmd
|
|
5974
|
-
.command('status')
|
|
5975
|
-
.description('Show background monitor process status')
|
|
5976
|
-
.action(() => {
|
|
5977
|
-
const repoRoot = getRepo();
|
|
5978
|
-
const state = readMonitorState(repoRoot);
|
|
5979
|
-
|
|
5980
|
-
if (!state) {
|
|
5981
|
-
console.log(chalk.dim('Monitor is not running.'));
|
|
5982
|
-
return;
|
|
5983
|
-
}
|
|
5984
|
-
|
|
5985
|
-
const running = isProcessRunning(state.pid);
|
|
5986
|
-
if (!running) {
|
|
5987
|
-
clearMonitorState(repoRoot);
|
|
5988
|
-
console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
|
|
5989
|
-
return;
|
|
5990
|
-
}
|
|
5991
|
-
|
|
5992
|
-
console.log(`${chalk.green('✓')} Monitor running`);
|
|
5993
|
-
console.log(` ${chalk.dim('pid')} ${state.pid}`);
|
|
5994
|
-
console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
|
|
5995
|
-
console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
|
|
5996
|
-
console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
|
|
5997
|
-
});
|
|
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
|
+
});
|
|
5998
6052
|
|
|
5999
6053
|
// ── policy ───────────────────────────────────────────────────────────────────
|
|
6000
6054
|
|
|
6001
6055
|
const policyCmd = program.command('policy').description('Manage enforcement and change-governance policy');
|
|
6056
|
+
policyCmd._switchmanAdvanced = true;
|
|
6002
6057
|
|
|
6003
6058
|
policyCmd
|
|
6004
6059
|
.command('init')
|
|
@@ -6144,4 +6199,372 @@ policyCmd
|
|
|
6144
6199
|
}
|
|
6145
6200
|
});
|
|
6146
6201
|
|
|
6202
|
+
|
|
6203
|
+
|
|
6204
|
+
// ── login ──────────────────────────────────────────────────────────────────────
|
|
6205
|
+
|
|
6206
|
+
program
|
|
6207
|
+
.command('login')
|
|
6208
|
+
.description('Sign in with GitHub to activate Switchman Pro')
|
|
6209
|
+
.option('--invite <token>', 'Join a team with an invite token')
|
|
6210
|
+
.option('--status', 'Show current login status')
|
|
6211
|
+
.addHelpText('after', `
|
|
6212
|
+
Examples:
|
|
6213
|
+
switchman login
|
|
6214
|
+
switchman login --status
|
|
6215
|
+
switchman login --invite tk_8f3a2c
|
|
6216
|
+
`)
|
|
6217
|
+
.action(async (opts) => {
|
|
6218
|
+
// Show current status
|
|
6219
|
+
if (opts.status) {
|
|
6220
|
+
const creds = readCredentials();
|
|
6221
|
+
if (!creds?.access_token) {
|
|
6222
|
+
console.log('');
|
|
6223
|
+
console.log(` ${chalk.dim('Status:')} Not logged in`);
|
|
6224
|
+
console.log(` ${chalk.dim('Run: ')} ${chalk.cyan('switchman login')}`);
|
|
6225
|
+
console.log('');
|
|
6226
|
+
return;
|
|
6227
|
+
}
|
|
6228
|
+
|
|
6229
|
+
const licence = await checkLicence();
|
|
6230
|
+
console.log('');
|
|
6231
|
+
if (licence.valid) {
|
|
6232
|
+
console.log(` ${chalk.green('✓')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')}`);
|
|
6233
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
6234
|
+
if (licence.current_period_end) {
|
|
6235
|
+
console.log(` ${chalk.dim('Renews:')} ${new Date(licence.current_period_end).toLocaleDateString()}`);
|
|
6236
|
+
}
|
|
6237
|
+
if (licence.offline) {
|
|
6238
|
+
console.log(` ${chalk.dim('(offline cache)')}`);
|
|
6239
|
+
}
|
|
6240
|
+
} else {
|
|
6241
|
+
console.log(` ${chalk.yellow('⚠')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')} but no active Pro licence`);
|
|
6242
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
6243
|
+
}
|
|
6244
|
+
console.log('');
|
|
6245
|
+
return;
|
|
6246
|
+
}
|
|
6247
|
+
|
|
6248
|
+
// Handle --invite token
|
|
6249
|
+
if (opts.invite) {
|
|
6250
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
6251
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
6252
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
6253
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
6254
|
+
|
|
6255
|
+
const creds = readCredentials();
|
|
6256
|
+
if (!creds?.access_token) {
|
|
6257
|
+
console.log('');
|
|
6258
|
+
console.log(chalk.yellow(' You need to sign in first before accepting an invite.'));
|
|
6259
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')} ${chalk.dim('then try again with --invite')}`);
|
|
6260
|
+
console.log('');
|
|
6261
|
+
process.exit(1);
|
|
6262
|
+
}
|
|
6263
|
+
|
|
6264
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
6265
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
6266
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
6267
|
+
});
|
|
6268
|
+
|
|
6269
|
+
const { data: { user } } = await sb.auth.getUser();
|
|
6270
|
+
if (!user) {
|
|
6271
|
+
console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
|
|
6272
|
+
process.exit(1);
|
|
6273
|
+
}
|
|
6274
|
+
|
|
6275
|
+
// Look up the invite
|
|
6276
|
+
const { data: invite, error: inviteError } = await sb
|
|
6277
|
+
.from('team_invites')
|
|
6278
|
+
.select('id, team_id, email, accepted')
|
|
6279
|
+
.eq('token', opts.invite)
|
|
6280
|
+
.maybeSingle();
|
|
6281
|
+
|
|
6282
|
+
if (inviteError || !invite) {
|
|
6283
|
+
console.log('');
|
|
6284
|
+
console.log(chalk.red(' ✗ Invite token not found or already used.'));
|
|
6285
|
+
console.log(` ${chalk.dim('Ask your teammate to send a new invite.')}`);
|
|
6286
|
+
console.log('');
|
|
6287
|
+
process.exit(1);
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
if (invite.accepted) {
|
|
6291
|
+
console.log('');
|
|
6292
|
+
console.log(chalk.yellow(' ⚠ This invite has already been accepted.'));
|
|
6293
|
+
console.log('');
|
|
6294
|
+
process.exit(1);
|
|
6295
|
+
}
|
|
6296
|
+
|
|
6297
|
+
// Add user to the team
|
|
6298
|
+
const { error: memberError } = await sb
|
|
6299
|
+
.from('team_members')
|
|
6300
|
+
.insert({ team_id: invite.team_id, user_id: user.id, role: 'member' });
|
|
6301
|
+
|
|
6302
|
+
if (memberError && !memberError.message.includes('duplicate')) {
|
|
6303
|
+
console.log(chalk.red(` ✗ Could not join team: ${memberError.message}`));
|
|
6304
|
+
process.exit(1);
|
|
6305
|
+
}
|
|
6306
|
+
|
|
6307
|
+
// Mark invite as accepted
|
|
6308
|
+
await sb
|
|
6309
|
+
.from('team_invites')
|
|
6310
|
+
.update({ accepted: true })
|
|
6311
|
+
.eq('id', invite.id);
|
|
6312
|
+
|
|
6313
|
+
console.log('');
|
|
6314
|
+
console.log(` ${chalk.green('✓')} Joined the team successfully`);
|
|
6315
|
+
console.log(` ${chalk.dim('Your agents now share coordination with your teammates.')}`);
|
|
6316
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman status')} ${chalk.dim('to see the shared view.')}`);
|
|
6317
|
+
console.log('');
|
|
6318
|
+
return;
|
|
6319
|
+
}
|
|
6320
|
+
|
|
6321
|
+
// Already logged in?
|
|
6322
|
+
const existing = readCredentials();
|
|
6323
|
+
if (existing?.access_token) {
|
|
6324
|
+
const licence = await checkLicence();
|
|
6325
|
+
if (licence.valid) {
|
|
6326
|
+
console.log('');
|
|
6327
|
+
console.log(` ${chalk.green('✓')} Already logged in as ${chalk.cyan(existing.email ?? 'unknown')}`);
|
|
6328
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
6329
|
+
console.log(` ${chalk.dim('Run')} ${chalk.cyan('switchman login --status')} ${chalk.dim('to see full details')}`);
|
|
6330
|
+
console.log('');
|
|
6331
|
+
return;
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
|
|
6335
|
+
console.log('');
|
|
6336
|
+
console.log(chalk.bold(' Switchman Pro — sign in with GitHub'));
|
|
6337
|
+
console.log('');
|
|
6338
|
+
|
|
6339
|
+
const spinner = ora('Waiting for GitHub sign-in...').start();
|
|
6340
|
+
spinner.stop();
|
|
6341
|
+
|
|
6342
|
+
const result = await loginWithGitHub();
|
|
6343
|
+
|
|
6344
|
+
if (!result.success) {
|
|
6345
|
+
console.log(` ${chalk.red('✗')} Sign in failed: ${result.error ?? 'unknown error'}`);
|
|
6346
|
+
console.log(` ${chalk.dim('Try again or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
6347
|
+
console.log('');
|
|
6348
|
+
process.exit(1);
|
|
6349
|
+
}
|
|
6350
|
+
|
|
6351
|
+
// Verify the licence
|
|
6352
|
+
const licence = await checkLicence();
|
|
6353
|
+
|
|
6354
|
+
console.log(` ${chalk.green('✓')} Signed in as ${chalk.cyan(result.email ?? 'unknown')}`);
|
|
6355
|
+
|
|
6356
|
+
if (licence.valid) {
|
|
6357
|
+
console.log(` ${chalk.green('✓')} Switchman Pro active`);
|
|
6358
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
6359
|
+
console.log('');
|
|
6360
|
+
console.log(` ${chalk.dim('Credentials saved · valid 24h · 7-day offline grace')}`);
|
|
6361
|
+
console.log('');
|
|
6362
|
+
console.log(` Run ${chalk.cyan('switchman setup --agents 10')} to use unlimited agents.`);
|
|
6363
|
+
} else {
|
|
6364
|
+
console.log(` ${chalk.yellow('⚠')} No active Pro licence found`);
|
|
6365
|
+
console.log('');
|
|
6366
|
+
console.log(` If you just paid, it may take a moment to activate.`);
|
|
6367
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
6368
|
+
}
|
|
6369
|
+
|
|
6370
|
+
console.log('');
|
|
6371
|
+
});
|
|
6372
|
+
|
|
6373
|
+
|
|
6374
|
+
// ── logout ─────────────────────────────────────────────────────────────────────
|
|
6375
|
+
|
|
6376
|
+
program
|
|
6377
|
+
.command('logout')
|
|
6378
|
+
.description('Sign out and remove saved credentials')
|
|
6379
|
+
.action(() => {
|
|
6380
|
+
clearCredentials();
|
|
6381
|
+
console.log('');
|
|
6382
|
+
console.log(` ${chalk.green('✓')} Signed out — credentials removed`);
|
|
6383
|
+
console.log('');
|
|
6384
|
+
});
|
|
6385
|
+
|
|
6386
|
+
|
|
6387
|
+
// ── upgrade ────────────────────────────────────────────────────────────────────
|
|
6388
|
+
|
|
6389
|
+
program
|
|
6390
|
+
.command('upgrade')
|
|
6391
|
+
.description('Open the Switchman Pro page in your browser')
|
|
6392
|
+
.action(async () => {
|
|
6393
|
+
console.log('');
|
|
6394
|
+
console.log(` Opening ${chalk.cyan(PRO_PAGE_URL)}...`);
|
|
6395
|
+
console.log('');
|
|
6396
|
+
const { default: open } = await import('open');
|
|
6397
|
+
await open(PRO_PAGE_URL);
|
|
6398
|
+
});
|
|
6399
|
+
|
|
6400
|
+
// ── team ───────────────────────────────────────────────────────────────────────
|
|
6401
|
+
|
|
6402
|
+
const teamCmd = program
|
|
6403
|
+
.command('team')
|
|
6404
|
+
.description('Manage your Switchman Pro team');
|
|
6405
|
+
|
|
6406
|
+
teamCmd
|
|
6407
|
+
.command('invite <email>')
|
|
6408
|
+
.description('Invite a teammate to your shared coordination')
|
|
6409
|
+
.addHelpText('after', `
|
|
6410
|
+
Examples:
|
|
6411
|
+
switchman team invite alice@example.com
|
|
6412
|
+
`)
|
|
6413
|
+
.action(async (email) => {
|
|
6414
|
+
const licence = await checkLicence();
|
|
6415
|
+
if (!licence.valid) {
|
|
6416
|
+
console.log('');
|
|
6417
|
+
console.log(chalk.yellow(' ⚠ Team invites require Switchman Pro.'));
|
|
6418
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
6419
|
+
console.log('');
|
|
6420
|
+
process.exit(1);
|
|
6421
|
+
}
|
|
6422
|
+
|
|
6423
|
+
const repoRoot = getRepo();
|
|
6424
|
+
const creds = readCredentials();
|
|
6425
|
+
if (!creds?.access_token) {
|
|
6426
|
+
console.log('');
|
|
6427
|
+
console.log(chalk.yellow(' ⚠ You need to be logged in.'));
|
|
6428
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')}`);
|
|
6429
|
+
console.log('');
|
|
6430
|
+
process.exit(1);
|
|
6431
|
+
}
|
|
6432
|
+
|
|
6433
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
6434
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
6435
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
6436
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
6437
|
+
|
|
6438
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
6439
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
6440
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
6441
|
+
});
|
|
6442
|
+
|
|
6443
|
+
const { data: { user } } = await sb.auth.getUser();
|
|
6444
|
+
if (!user) {
|
|
6445
|
+
console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
|
|
6446
|
+
process.exit(1);
|
|
6447
|
+
}
|
|
6448
|
+
|
|
6449
|
+
// Get or create team
|
|
6450
|
+
let teamId;
|
|
6451
|
+
const { data: membership } = await sb
|
|
6452
|
+
.from('team_members')
|
|
6453
|
+
.select('team_id')
|
|
6454
|
+
.eq('user_id', user.id)
|
|
6455
|
+
.maybeSingle();
|
|
6456
|
+
|
|
6457
|
+
if (membership?.team_id) {
|
|
6458
|
+
teamId = membership.team_id;
|
|
6459
|
+
} else {
|
|
6460
|
+
// Create a new team
|
|
6461
|
+
const { data: team, error: teamError } = await sb
|
|
6462
|
+
.from('teams')
|
|
6463
|
+
.insert({ owner_id: user.id, name: 'My Team' })
|
|
6464
|
+
.select('id')
|
|
6465
|
+
.single();
|
|
6466
|
+
|
|
6467
|
+
if (teamError) {
|
|
6468
|
+
console.log(chalk.red(` ✗ Could not create team: ${teamError.message}`));
|
|
6469
|
+
process.exit(1);
|
|
6470
|
+
}
|
|
6471
|
+
|
|
6472
|
+
teamId = team.id;
|
|
6473
|
+
|
|
6474
|
+
// Add the owner as a member
|
|
6475
|
+
await sb.from('team_members').insert({
|
|
6476
|
+
team_id: teamId,
|
|
6477
|
+
user_id: user.id,
|
|
6478
|
+
role: 'owner',
|
|
6479
|
+
});
|
|
6480
|
+
}
|
|
6481
|
+
|
|
6482
|
+
// Create the invite
|
|
6483
|
+
const { data: invite, error: inviteError } = await sb
|
|
6484
|
+
.from('team_invites')
|
|
6485
|
+
.insert({
|
|
6486
|
+
team_id: teamId,
|
|
6487
|
+
invited_by: user.id,
|
|
6488
|
+
email,
|
|
6489
|
+
})
|
|
6490
|
+
.select('token')
|
|
6491
|
+
.single();
|
|
6492
|
+
|
|
6493
|
+
if (inviteError) {
|
|
6494
|
+
console.log(chalk.red(` ✗ Could not create invite: ${inviteError.message}`));
|
|
6495
|
+
process.exit(1);
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6498
|
+
console.log('');
|
|
6499
|
+
console.log(` ${chalk.green('✓')} Invite created for ${chalk.cyan(email)}`);
|
|
6500
|
+
console.log('');
|
|
6501
|
+
console.log(` They can join with:`);
|
|
6502
|
+
console.log(` ${chalk.cyan(`switchman login --invite ${invite.token}`)}`);
|
|
6503
|
+
console.log('');
|
|
6504
|
+
});
|
|
6505
|
+
|
|
6506
|
+
teamCmd
|
|
6507
|
+
.command('list')
|
|
6508
|
+
.description('List your team members')
|
|
6509
|
+
.action(async () => {
|
|
6510
|
+
const licence = await checkLicence();
|
|
6511
|
+
if (!licence.valid) {
|
|
6512
|
+
console.log('');
|
|
6513
|
+
console.log(chalk.yellow(' ⚠ Team features require Switchman Pro.'));
|
|
6514
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
6515
|
+
console.log('');
|
|
6516
|
+
process.exit(1);
|
|
6517
|
+
}
|
|
6518
|
+
|
|
6519
|
+
const creds = readCredentials();
|
|
6520
|
+
if (!creds?.access_token) {
|
|
6521
|
+
console.log(chalk.red(' ✗ Not logged in. Run: switchman login'));
|
|
6522
|
+
process.exit(1);
|
|
6523
|
+
}
|
|
6524
|
+
|
|
6525
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
6526
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
6527
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
6528
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
6529
|
+
|
|
6530
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
6531
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
6532
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
6533
|
+
});
|
|
6534
|
+
|
|
6535
|
+
const { data: membership } = await sb
|
|
6536
|
+
.from('team_members')
|
|
6537
|
+
.select('team_id')
|
|
6538
|
+
.eq('user_id', (await sb.auth.getUser()).data.user?.id)
|
|
6539
|
+
.maybeSingle();
|
|
6540
|
+
|
|
6541
|
+
if (!membership?.team_id) {
|
|
6542
|
+
console.log('');
|
|
6543
|
+
console.log(` ${chalk.dim('No team yet. Invite someone with:')} ${chalk.cyan('switchman team invite <email>')}`);
|
|
6544
|
+
console.log('');
|
|
6545
|
+
return;
|
|
6546
|
+
}
|
|
6547
|
+
|
|
6548
|
+
const { data: members } = await sb
|
|
6549
|
+
.from('team_members')
|
|
6550
|
+
.select('user_id, role, joined_at')
|
|
6551
|
+
.eq('team_id', membership.team_id);
|
|
6552
|
+
|
|
6553
|
+
const { data: invites } = await sb
|
|
6554
|
+
.from('team_invites')
|
|
6555
|
+
.select('email, token, accepted, created_at')
|
|
6556
|
+
.eq('team_id', membership.team_id)
|
|
6557
|
+
.eq('accepted', false);
|
|
6558
|
+
|
|
6559
|
+
console.log('');
|
|
6560
|
+
for (const m of members ?? []) {
|
|
6561
|
+
const roleLabel = m.role === 'owner' ? chalk.dim('(owner)') : chalk.dim('(member)');
|
|
6562
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(m.user_id.slice(0, 8))} ${roleLabel}`);
|
|
6563
|
+
}
|
|
6564
|
+
for (const inv of invites ?? []) {
|
|
6565
|
+
console.log(` ${chalk.dim('○')} ${chalk.cyan(inv.email)} ${chalk.dim('(invited)')}`);
|
|
6566
|
+
}
|
|
6567
|
+
console.log('');
|
|
6568
|
+
});
|
|
6569
|
+
|
|
6147
6570
|
program.parse();
|