switchman-dev 0.1.6 → 0.1.8
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 +296 -15
- package/examples/README.md +37 -2
- package/package.json +6 -1
- package/src/cli/index.js +3939 -130
- package/src/core/ci.js +205 -1
- package/src/core/db.js +963 -45
- package/src/core/enforcement.js +140 -15
- package/src/core/git.js +286 -1
- 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 +22 -5
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +2459 -88
- package/src/core/planner.js +35 -11
- package/src/core/policy.js +106 -1
- package/src/core/queue.js +654 -29
- package/src/core/semantic.js +71 -5
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/tests.zip +0 -0
package/src/cli/index.js
CHANGED
|
@@ -16,34 +16,41 @@
|
|
|
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 } from 'fs';
|
|
23
|
-
import {
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
23
|
+
import { tmpdir } from 'os';
|
|
24
|
+
import { dirname, join, posix } from 'path';
|
|
24
25
|
import { execSync, spawn } from 'child_process';
|
|
25
26
|
|
|
26
|
-
import { findRepoRoot,
|
|
27
|
+
import { cleanupCrashedLandingTempWorktrees, createGitWorktree, findRepoRoot, getWorktreeBranch, getWorktreeChangedFiles, gitAssessBranchFreshness, gitBranchExists, listGitWorktrees } from '../core/git.js';
|
|
28
|
+
import { matchesPathPatterns } from '../core/ignore.js';
|
|
27
29
|
import {
|
|
28
30
|
initDb, openDb,
|
|
29
31
|
DEFAULT_STALE_LEASE_MINUTES,
|
|
30
32
|
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
31
33
|
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
32
|
-
registerWorktree, listWorktrees,
|
|
33
|
-
enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
registerWorktree, listWorktrees, updateWorktreeStatus,
|
|
35
|
+
enqueueMergeItem, escalateMergeQueueItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
|
|
36
|
+
markMergeQueueState,
|
|
37
|
+
createPolicyOverride, listPolicyOverrides, revokePolicyOverride,
|
|
38
|
+
finishOperationJournalEntry, listOperationJournal, listTempResources, updateTempResource,
|
|
39
|
+
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
|
|
40
|
+
upsertTaskSpec,
|
|
41
|
+
listAuditEvents, pruneDatabaseMaintenance, verifyAuditTrail,
|
|
36
42
|
} from '../core/db.js';
|
|
37
43
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
38
|
-
import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
39
|
-
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
44
|
+
import { ensureProjectLocalMcpGitExcludes, getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
45
|
+
import { evaluateRepoCompliance, gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
40
46
|
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
41
47
|
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
42
|
-
import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
|
|
43
|
-
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
|
|
48
|
+
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';
|
|
49
|
+
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus, writeGitHubPipelineLandingStatus } from '../core/ci.js';
|
|
44
50
|
import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
|
|
45
|
-
import { buildQueueStatusSummary, runMergeQueue } from '../core/queue.js';
|
|
46
|
-
import { DEFAULT_LEASE_POLICY, loadLeasePolicy, writeLeasePolicy } from '../core/policy.js';
|
|
51
|
+
import { buildQueueStatusSummary, evaluateQueueRepoGate, resolveQueueSource, runMergeQueue } from '../core/queue.js';
|
|
52
|
+
import { DEFAULT_CHANGE_POLICY, DEFAULT_LEASE_POLICY, getChangePolicyPath, loadChangePolicy, loadLeasePolicy, writeChangePolicy, writeLeasePolicy } from '../core/policy.js';
|
|
53
|
+
import { planPipelineTasks } from '../core/planner.js';
|
|
47
54
|
import {
|
|
48
55
|
captureTelemetryEvent,
|
|
49
56
|
disableTelemetry,
|
|
@@ -54,6 +61,20 @@ import {
|
|
|
54
61
|
maybePromptForTelemetry,
|
|
55
62
|
sendTelemetryEvent,
|
|
56
63
|
} from '../core/telemetry.js';
|
|
64
|
+
import { checkLicence, clearCredentials, FREE_AGENT_LIMIT, getRetentionDaysForCurrentPlan, loginWithGitHub, PRO_PAGE_URL, readCredentials } from '../core/licence.js';
|
|
65
|
+
import { homedir } from 'os';
|
|
66
|
+
import { cleanupOldSyncEvents, pullActiveTeamMembers, pullTeamState, pushSyncEvent } from '../core/sync.js';
|
|
67
|
+
|
|
68
|
+
const originalProcessEmit = process.emit.bind(process);
|
|
69
|
+
process.emit = function patchedProcessEmit(event, ...args) {
|
|
70
|
+
if (event === 'warning') {
|
|
71
|
+
const [warning] = args;
|
|
72
|
+
if (warning?.name === 'ExperimentalWarning' && warning?.message?.includes('SQLite')) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return originalProcessEmit(event, ...args);
|
|
77
|
+
};
|
|
57
78
|
|
|
58
79
|
function installMcpConfig(targetDirs) {
|
|
59
80
|
return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
|
|
@@ -79,6 +100,225 @@ function getDb(repoRoot) {
|
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
|
|
103
|
+
function getOptionalDb(repoRoot) {
|
|
104
|
+
try {
|
|
105
|
+
return openDb(repoRoot);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function slugifyValue(value) {
|
|
112
|
+
return String(value || '')
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
115
|
+
.replace(/^-+|-+$/g, '')
|
|
116
|
+
.slice(0, 40) || 'plan';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function capitalizeSentence(value) {
|
|
120
|
+
const text = String(value || '').trim();
|
|
121
|
+
if (!text) return text;
|
|
122
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatHumanList(values = []) {
|
|
126
|
+
if (values.length === 0) return '';
|
|
127
|
+
if (values.length === 1) return values[0];
|
|
128
|
+
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
|
129
|
+
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readPlanningFile(repoRoot, fileName, maxChars = 1200) {
|
|
133
|
+
const filePath = join(repoRoot, fileName);
|
|
134
|
+
if (!existsSync(filePath)) return null;
|
|
135
|
+
try {
|
|
136
|
+
const text = readFileSync(filePath, 'utf8').trim();
|
|
137
|
+
if (!text) return null;
|
|
138
|
+
return {
|
|
139
|
+
file: fileName,
|
|
140
|
+
text: text.slice(0, maxChars),
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractMarkdownSignal(text) {
|
|
148
|
+
const lines = String(text || '')
|
|
149
|
+
.split('\n')
|
|
150
|
+
.map((line) => line.trim())
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s+/, '').trim();
|
|
154
|
+
if (!normalized) continue;
|
|
155
|
+
if (/^switchman\b/i.test(normalized)) continue;
|
|
156
|
+
return normalized;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function deriveGoalFromBranch(branchName) {
|
|
162
|
+
const raw = String(branchName || '').replace(/^refs\/heads\//, '').trim();
|
|
163
|
+
if (!raw || ['main', 'master', 'trunk', 'develop', 'development'].includes(raw)) return null;
|
|
164
|
+
const tail = raw.split('/').pop() || raw;
|
|
165
|
+
const tokens = tail
|
|
166
|
+
.replace(/^\d+[-_]?/, '')
|
|
167
|
+
.split(/[-_]/)
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.filter((token) => !['feature', 'feat', 'fix', 'bugfix', 'chore', 'task', 'issue', 'story', 'work'].includes(token.toLowerCase()));
|
|
170
|
+
if (tokens.length === 0) return null;
|
|
171
|
+
return capitalizeSentence(tokens.join(' '));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getRecentCommitSubjects(repoRoot, limit = 6) {
|
|
175
|
+
try {
|
|
176
|
+
return execSync(`git log --pretty=%s -n ${limit}`, {
|
|
177
|
+
cwd: repoRoot,
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
180
|
+
}).trim().split('\n').map((line) => line.trim()).filter(Boolean);
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function summarizeRecentCommitContext(branchGoal, subjects) {
|
|
187
|
+
if (!subjects.length) return null;
|
|
188
|
+
const topicWords = String(branchGoal || '')
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.split(/\s+/)
|
|
191
|
+
.filter((word) => word.length >= 4);
|
|
192
|
+
const relatedCount = topicWords.length > 0
|
|
193
|
+
? subjects.filter((subject) => {
|
|
194
|
+
const lower = subject.toLowerCase();
|
|
195
|
+
return topicWords.some((word) => lower.includes(word));
|
|
196
|
+
}).length
|
|
197
|
+
: 0;
|
|
198
|
+
const effectiveCount = relatedCount > 0 ? relatedCount : Math.min(subjects.length, 3);
|
|
199
|
+
const topicLabel = relatedCount > 0 && topicWords.length > 0 ? `${topicWords[0]}-related ` : '';
|
|
200
|
+
return `${effectiveCount} recent ${topicLabel}commit${effectiveCount === 1 ? '' : 's'}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectPlanContext(repoRoot, explicitGoal = null) {
|
|
204
|
+
const planningFiles = ['CLAUDE.md', 'ROADMAP.md', 'tasks.md', 'TASKS.md', 'TODO.md', 'README.md']
|
|
205
|
+
.map((fileName) => readPlanningFile(repoRoot, fileName))
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
const planningByName = new Map(planningFiles.map((entry) => [entry.file, entry]));
|
|
208
|
+
const branch = getWorktreeBranch(process.cwd()) || null;
|
|
209
|
+
const branchGoal = deriveGoalFromBranch(branch);
|
|
210
|
+
const recentCommitSubjects = getRecentCommitSubjects(repoRoot, 6);
|
|
211
|
+
const recentCommitSummary = summarizeRecentCommitContext(branchGoal, recentCommitSubjects);
|
|
212
|
+
const preferredPlanningFile = planningByName.get('CLAUDE.md')
|
|
213
|
+
|| planningByName.get('tasks.md')
|
|
214
|
+
|| planningByName.get('TASKS.md')
|
|
215
|
+
|| planningByName.get('ROADMAP.md')
|
|
216
|
+
|| planningByName.get('TODO.md')
|
|
217
|
+
|| planningByName.get('README.md')
|
|
218
|
+
|| null;
|
|
219
|
+
const planningSignal = preferredPlanningFile ? extractMarkdownSignal(preferredPlanningFile.text) : null;
|
|
220
|
+
const title = capitalizeSentence(explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
|
|
221
|
+
const descriptionParts = [];
|
|
222
|
+
if (preferredPlanningFile?.text) descriptionParts.push(preferredPlanningFile.text);
|
|
223
|
+
if (recentCommitSubjects.length > 0) descriptionParts.push(`Recent git history summary: ${recentCommitSubjects.slice(0, 3).join('; ')}.`);
|
|
224
|
+
const description = descriptionParts.join('\n\n').trim() || null;
|
|
225
|
+
|
|
226
|
+
const found = [];
|
|
227
|
+
const used = [];
|
|
228
|
+
if (explicitGoal) {
|
|
229
|
+
used.push('explicit goal');
|
|
230
|
+
}
|
|
231
|
+
if (branch) {
|
|
232
|
+
found.push(`branch ${branch}`);
|
|
233
|
+
if (branchGoal) used.push('branch name');
|
|
234
|
+
}
|
|
235
|
+
if (preferredPlanningFile?.file) {
|
|
236
|
+
found.push(preferredPlanningFile.file);
|
|
237
|
+
used.push(preferredPlanningFile.file);
|
|
238
|
+
}
|
|
239
|
+
if (recentCommitSummary) {
|
|
240
|
+
found.push(recentCommitSummary);
|
|
241
|
+
used.push('recent git history');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
branch,
|
|
246
|
+
title,
|
|
247
|
+
description,
|
|
248
|
+
found,
|
|
249
|
+
used: [...new Set(used)],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolvePlanningWorktrees(repoRoot, db = null) {
|
|
254
|
+
if (db) {
|
|
255
|
+
const registered = listWorktrees(db)
|
|
256
|
+
.filter((worktree) => worktree.name !== 'main' && worktree.status !== 'missing')
|
|
257
|
+
.map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch }));
|
|
258
|
+
if (registered.length > 0) return registered;
|
|
259
|
+
}
|
|
260
|
+
return listGitWorktrees(repoRoot)
|
|
261
|
+
.filter((worktree) => !worktree.isMain)
|
|
262
|
+
.map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch || null }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function planTaskPriority(taskSpec = null) {
|
|
266
|
+
const taskType = taskSpec?.task_type || 'implementation';
|
|
267
|
+
if (taskType === 'implementation') return 8;
|
|
268
|
+
if (taskType === 'tests') return 7;
|
|
269
|
+
if (taskType === 'docs') return 6;
|
|
270
|
+
if (taskType === 'governance') return 6;
|
|
271
|
+
return 5;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolvePrNumberFromEnv(env = process.env) {
|
|
275
|
+
if (env.SWITCHMAN_PR_NUMBER) return String(env.SWITCHMAN_PR_NUMBER);
|
|
276
|
+
if (env.GITHUB_PR_NUMBER) return String(env.GITHUB_PR_NUMBER);
|
|
277
|
+
|
|
278
|
+
if (env.GITHUB_EVENT_PATH && existsSync(env.GITHUB_EVENT_PATH)) {
|
|
279
|
+
try {
|
|
280
|
+
const payload = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
281
|
+
const prNumber = payload.pull_request?.number || payload.issue?.number || null;
|
|
282
|
+
if (prNumber) return String(prNumber);
|
|
283
|
+
} catch {
|
|
284
|
+
// Ignore malformed GitHub event payloads.
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resolveBranchFromEnv(env = process.env) {
|
|
292
|
+
return env.SWITCHMAN_BRANCH
|
|
293
|
+
|| env.GITHUB_HEAD_REF
|
|
294
|
+
|| env.GITHUB_REF_NAME
|
|
295
|
+
|| null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function retryStaleTasks(db, { pipelineId = null, reason = 'bulk stale retry' } = {}) {
|
|
299
|
+
const invalidations = listDependencyInvalidations(db, { pipelineId });
|
|
300
|
+
const staleTaskIds = [...new Set(invalidations.map((item) => item.affected_task_id).filter(Boolean))];
|
|
301
|
+
const retried = [];
|
|
302
|
+
const skipped = [];
|
|
303
|
+
|
|
304
|
+
for (const taskId of staleTaskIds) {
|
|
305
|
+
const task = retryTask(db, taskId, reason);
|
|
306
|
+
if (task) {
|
|
307
|
+
retried.push(task);
|
|
308
|
+
} else {
|
|
309
|
+
skipped.push(taskId);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
pipeline_id: pipelineId,
|
|
315
|
+
stale_task_ids: staleTaskIds,
|
|
316
|
+
retried,
|
|
317
|
+
skipped,
|
|
318
|
+
invalidation_count: invalidations.length,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
82
322
|
function statusBadge(status) {
|
|
83
323
|
const colors = {
|
|
84
324
|
pending: chalk.yellow,
|
|
@@ -223,15 +463,1203 @@ function sleepSync(ms) {
|
|
|
223
463
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
224
464
|
}
|
|
225
465
|
|
|
226
|
-
function boolBadge(ok) {
|
|
227
|
-
return ok ? chalk.green('OK ') : chalk.yellow('CHECK');
|
|
466
|
+
function boolBadge(ok) {
|
|
467
|
+
return ok ? chalk.green('OK ') : chalk.yellow('CHECK');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function printErrorWithNext(message, nextCommand = null) {
|
|
471
|
+
console.error(chalk.red(message));
|
|
472
|
+
if (nextCommand) {
|
|
473
|
+
console.error(`${chalk.yellow('next:')} ${nextCommand}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function writeDemoFile(filePath, contents) {
|
|
478
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
479
|
+
writeFileSync(filePath, contents);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function gitCommitAll(worktreePath, message) {
|
|
483
|
+
execSync('git add .', {
|
|
484
|
+
cwd: worktreePath,
|
|
485
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
486
|
+
});
|
|
487
|
+
execSync(`git -c user.email="demo@switchman.dev" -c user.name="Switchman Demo" commit -m ${JSON.stringify(message)}`, {
|
|
488
|
+
cwd: worktreePath,
|
|
489
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function runDemoScenario({ repoPath = null, cleanup = false } = {}) {
|
|
494
|
+
const repoDir = repoPath || join(tmpdir(), `switchman-demo-${Date.now()}`);
|
|
495
|
+
mkdirSync(repoDir, { recursive: true });
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
execSync('git init -b main', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
499
|
+
execSync('git config user.email "demo@switchman.dev"', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
500
|
+
execSync('git config user.name "Switchman Demo"', { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
501
|
+
|
|
502
|
+
writeDemoFile(join(repoDir, 'README.md'), '# Switchman demo repo\n');
|
|
503
|
+
writeDemoFile(join(repoDir, 'src', 'index.js'), 'export function ready() {\n return true;\n}\n');
|
|
504
|
+
writeDemoFile(join(repoDir, 'docs', 'overview.md'), '# Demo\n');
|
|
505
|
+
gitCommitAll(repoDir, 'Initial demo repo');
|
|
506
|
+
|
|
507
|
+
const db = initDb(repoDir);
|
|
508
|
+
registerWorktree(db, { name: 'main', path: repoDir, branch: 'main' });
|
|
509
|
+
|
|
510
|
+
const agent1Path = createGitWorktree(repoDir, 'agent1', 'switchman/demo-agent1');
|
|
511
|
+
const agent2Path = createGitWorktree(repoDir, 'agent2', 'switchman/demo-agent2');
|
|
512
|
+
registerWorktree(db, { name: 'agent1', path: agent1Path, branch: 'switchman/demo-agent1' });
|
|
513
|
+
registerWorktree(db, { name: 'agent2', path: agent2Path, branch: 'switchman/demo-agent2' });
|
|
514
|
+
|
|
515
|
+
const taskAuth = createTask(db, {
|
|
516
|
+
id: 'demo-01',
|
|
517
|
+
title: 'Add auth helper',
|
|
518
|
+
priority: 9,
|
|
519
|
+
});
|
|
520
|
+
const taskDocs = createTask(db, {
|
|
521
|
+
id: 'demo-02',
|
|
522
|
+
title: 'Document auth flow',
|
|
523
|
+
priority: 8,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const lease1 = startTaskLease(db, taskAuth, 'agent1');
|
|
527
|
+
claimFiles(db, taskAuth, 'agent1', ['src/auth.js']);
|
|
528
|
+
|
|
529
|
+
const lease2 = startTaskLease(db, taskDocs, 'agent2');
|
|
530
|
+
let blockedClaimMessage = null;
|
|
531
|
+
try {
|
|
532
|
+
claimFiles(db, taskDocs, 'agent2', ['src/auth.js']);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
blockedClaimMessage = String(err.message || 'Claim blocked.');
|
|
535
|
+
}
|
|
536
|
+
claimFiles(db, taskDocs, 'agent2', ['docs/auth-flow.md']);
|
|
537
|
+
|
|
538
|
+
writeDemoFile(join(agent1Path, 'src', 'auth.js'), 'export function authHeader(token) {\n return `Bearer ${token}`;\n}\n');
|
|
539
|
+
gitCommitAll(agent1Path, 'Add auth helper');
|
|
540
|
+
completeTask(db, taskAuth);
|
|
541
|
+
|
|
542
|
+
writeDemoFile(join(agent2Path, 'docs', 'auth-flow.md'), '# Auth flow\n\n- claims stop overlap early\n');
|
|
543
|
+
gitCommitAll(agent2Path, 'Document auth flow');
|
|
544
|
+
completeTask(db, taskDocs);
|
|
545
|
+
|
|
546
|
+
enqueueMergeItem(db, {
|
|
547
|
+
sourceType: 'worktree',
|
|
548
|
+
sourceRef: 'agent1',
|
|
549
|
+
sourceWorktree: 'agent1',
|
|
550
|
+
targetBranch: 'main',
|
|
551
|
+
});
|
|
552
|
+
enqueueMergeItem(db, {
|
|
553
|
+
sourceType: 'worktree',
|
|
554
|
+
sourceRef: 'agent2',
|
|
555
|
+
sourceWorktree: 'agent2',
|
|
556
|
+
targetBranch: 'main',
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const queueRun = await runMergeQueue(db, repoDir, {
|
|
560
|
+
maxItems: 2,
|
|
561
|
+
targetBranch: 'main',
|
|
562
|
+
});
|
|
563
|
+
const queueItems = listMergeQueue(db);
|
|
564
|
+
const gateReport = await scanAllWorktrees(db, repoDir);
|
|
565
|
+
const aiGate = await runAiMergeGate(db, repoDir);
|
|
566
|
+
|
|
567
|
+
const result = {
|
|
568
|
+
repo_path: repoDir,
|
|
569
|
+
worktrees: listWorktrees(db).map((worktree) => ({
|
|
570
|
+
name: worktree.name,
|
|
571
|
+
path: worktree.path,
|
|
572
|
+
branch: worktree.branch,
|
|
573
|
+
})),
|
|
574
|
+
tasks: listTasks(db).map((task) => ({
|
|
575
|
+
id: task.id,
|
|
576
|
+
title: task.title,
|
|
577
|
+
status: task.status,
|
|
578
|
+
})),
|
|
579
|
+
overlap_demo: {
|
|
580
|
+
blocked_path: 'src/auth.js',
|
|
581
|
+
blocked_message: blockedClaimMessage,
|
|
582
|
+
safe_path: 'docs/auth-flow.md',
|
|
583
|
+
leases: [lease1.id, lease2.id],
|
|
584
|
+
},
|
|
585
|
+
queue: {
|
|
586
|
+
processed: queueRun.processed.map((entry) => ({
|
|
587
|
+
status: entry.status,
|
|
588
|
+
item_id: entry.item?.id || null,
|
|
589
|
+
source_ref: entry.item?.source_ref || null,
|
|
590
|
+
})),
|
|
591
|
+
final_items: queueItems.map((item) => ({
|
|
592
|
+
id: item.id,
|
|
593
|
+
status: item.status,
|
|
594
|
+
source_ref: item.source_ref,
|
|
595
|
+
})),
|
|
596
|
+
},
|
|
597
|
+
final_gate: {
|
|
598
|
+
ok: gateReport.conflicts.length === 0
|
|
599
|
+
&& gateReport.fileConflicts.length === 0
|
|
600
|
+
&& gateReport.unclaimedChanges.length === 0
|
|
601
|
+
&& gateReport.complianceSummary.non_compliant === 0
|
|
602
|
+
&& aiGate.status !== 'blocked',
|
|
603
|
+
ai_gate_status: aiGate.status,
|
|
604
|
+
},
|
|
605
|
+
next_steps: [
|
|
606
|
+
`cd ${repoDir}`,
|
|
607
|
+
'switchman status',
|
|
608
|
+
'switchman queue status',
|
|
609
|
+
],
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
db.close();
|
|
613
|
+
if (cleanup) {
|
|
614
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
} catch (err) {
|
|
618
|
+
if (cleanup) {
|
|
619
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
620
|
+
}
|
|
621
|
+
throw err;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function normalizeCliRepoPath(targetPath) {
|
|
626
|
+
const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
|
|
627
|
+
const normalized = posix.normalize(rawPath.replace(/^\.\/+/, ''));
|
|
628
|
+
if (
|
|
629
|
+
normalized === '' ||
|
|
630
|
+
normalized === '.' ||
|
|
631
|
+
normalized === '..' ||
|
|
632
|
+
normalized.startsWith('../') ||
|
|
633
|
+
rawPath.startsWith('/') ||
|
|
634
|
+
/^[A-Za-z]:\//.test(rawPath)
|
|
635
|
+
) {
|
|
636
|
+
throw new Error('Target path must point to a file inside the repository.');
|
|
637
|
+
}
|
|
638
|
+
return normalized;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function buildQueueExplainReport(db, repoRoot, itemId) {
|
|
642
|
+
const item = getMergeQueueItem(db, itemId);
|
|
643
|
+
if (!item) {
|
|
644
|
+
throw new Error(`Queue item ${itemId} does not exist.`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let resolved = null;
|
|
648
|
+
let resolutionError = null;
|
|
649
|
+
try {
|
|
650
|
+
resolved = resolveQueueSource(db, repoRoot, item);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
resolutionError = err.message;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const recentEvents = listMergeQueueEvents(db, item.id, { limit: 5 });
|
|
656
|
+
return {
|
|
657
|
+
item,
|
|
658
|
+
resolved_source: resolved,
|
|
659
|
+
resolution_error: resolutionError,
|
|
660
|
+
next_action: item.next_action || inferQueueExplainNextAction(item, resolved, resolutionError),
|
|
661
|
+
recent_events: recentEvents,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function inferQueueExplainNextAction(item, resolved, resolutionError) {
|
|
666
|
+
if (item.status === 'blocked' && item.next_action) return item.next_action;
|
|
667
|
+
if (item.status === 'blocked' && item.last_error_code === 'source_missing') {
|
|
668
|
+
return `Recreate the source branch, then run \`switchman queue retry ${item.id}\`.`;
|
|
669
|
+
}
|
|
670
|
+
if (resolutionError) return 'Fix the source resolution issue, then re-run `switchman explain queue <itemId>` or queue a branch/worktree explicitly.';
|
|
671
|
+
if (item.status === 'retrying' && item.backoff_until) {
|
|
672
|
+
return item.next_action || `Wait until ${item.backoff_until}, or run \`switchman queue retry ${item.id}\` to retry sooner.`;
|
|
673
|
+
}
|
|
674
|
+
if (item.status === 'wave_blocked') {
|
|
675
|
+
return item.next_action || `Run \`switchman explain queue ${item.id}\` to review the shared stale wave, then revalidate the affected pipelines together.`;
|
|
676
|
+
}
|
|
677
|
+
if (item.status === 'escalated') {
|
|
678
|
+
return item.next_action || `Run \`switchman explain queue ${item.id}\` to review the landing risk, then \`switchman queue retry ${item.id}\` when it is ready again.`;
|
|
679
|
+
}
|
|
680
|
+
if (item.status === 'queued' || item.status === 'retrying') return 'Run `switchman queue run` to continue landing queued work.';
|
|
681
|
+
if (item.status === 'merged') return 'No action needed.';
|
|
682
|
+
if (resolved?.pipeline_id) return `Run \`switchman pipeline status ${resolved.pipeline_id}\` to inspect the pipeline state.`;
|
|
683
|
+
return 'Run `switchman queue status` to inspect the landing queue.';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function buildClaimExplainReport(db, filePath) {
|
|
687
|
+
const normalizedPath = normalizeCliRepoPath(filePath);
|
|
688
|
+
const activeClaims = getActiveFileClaims(db);
|
|
689
|
+
const directClaims = activeClaims.filter((claim) => claim.file_path === normalizedPath);
|
|
690
|
+
const activeLeases = listLeases(db, 'active');
|
|
691
|
+
const scopeOwners = activeLeases.flatMap((lease) => {
|
|
692
|
+
const taskSpec = getTaskSpec(db, lease.task_id);
|
|
693
|
+
const patterns = taskSpec?.allowed_paths || [];
|
|
694
|
+
if (!patterns.some((pattern) => matchesPathPatterns(normalizedPath, [pattern]))) {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
return [{
|
|
698
|
+
lease_id: lease.id,
|
|
699
|
+
task_id: lease.task_id,
|
|
700
|
+
task_title: lease.task_title,
|
|
701
|
+
worktree: lease.worktree,
|
|
702
|
+
agent: lease.agent || null,
|
|
703
|
+
ownership_type: 'scope',
|
|
704
|
+
allowed_paths: patterns,
|
|
705
|
+
}];
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
file_path: normalizedPath,
|
|
710
|
+
claims: directClaims.map((claim) => ({
|
|
711
|
+
lease_id: claim.lease_id,
|
|
712
|
+
task_id: claim.task_id,
|
|
713
|
+
task_title: claim.task_title,
|
|
714
|
+
task_status: claim.task_status,
|
|
715
|
+
worktree: claim.worktree,
|
|
716
|
+
agent: claim.agent || null,
|
|
717
|
+
ownership_type: 'claim',
|
|
718
|
+
heartbeat_at: claim.lease_heartbeat_at || null,
|
|
719
|
+
})),
|
|
720
|
+
scope_owners: scopeOwners.filter((owner, index, all) =>
|
|
721
|
+
all.findIndex((candidate) => candidate.lease_id === owner.lease_id) === index,
|
|
722
|
+
),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function buildStaleTaskExplainReport(db, taskId) {
|
|
727
|
+
const task = getTask(db, taskId);
|
|
728
|
+
if (!task) {
|
|
729
|
+
throw new Error(`Task ${taskId} does not exist.`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const invalidations = listDependencyInvalidations(db, { affectedTaskId: taskId });
|
|
733
|
+
return {
|
|
734
|
+
task,
|
|
735
|
+
invalidations: invalidations.map((item) => ({
|
|
736
|
+
...item,
|
|
737
|
+
details: item.details || {},
|
|
738
|
+
revalidation_set: item.details?.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
|
|
739
|
+
stale_area: item.reason_type === 'subsystem_overlap'
|
|
740
|
+
? `subsystem:${item.subsystem_tag}`
|
|
741
|
+
: item.reason_type === 'semantic_contract_drift'
|
|
742
|
+
? `contract:${(item.details?.contract_names || []).join('|') || 'unknown'}`
|
|
743
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
744
|
+
? `object:${(item.details?.object_names || []).join('|') || 'unknown'}`
|
|
745
|
+
: item.reason_type === 'shared_module_drift'
|
|
746
|
+
? `module:${(item.details?.module_paths || []).join('|') || 'unknown'}`
|
|
747
|
+
: `${item.source_scope_pattern} ↔ ${item.affected_scope_pattern}`,
|
|
748
|
+
summary: item.reason_type === 'semantic_contract_drift'
|
|
749
|
+
? `${item.details?.source_task_title || item.source_task_id} changed shared contract ${(item.details?.contract_names || []).join(', ') || 'unknown'}`
|
|
750
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
751
|
+
? `${item.details?.source_task_title || item.source_task_id} changed shared exported object ${(item.details?.object_names || []).join(', ') || 'unknown'}`
|
|
752
|
+
: item.reason_type === 'shared_module_drift'
|
|
753
|
+
? `${item.details?.source_task_title || item.source_task_id} changed shared module ${(item.details?.module_paths || []).join(', ') || 'unknown'} used by ${(item.details?.dependent_files || []).join(', ') || item.affected_task_id}`
|
|
754
|
+
: `${item.details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
|
|
755
|
+
})),
|
|
756
|
+
next_action: invalidations.length > 0
|
|
757
|
+
? `switchman task retry ${taskId}`
|
|
758
|
+
: null,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function normalizeDependencyInvalidation(item) {
|
|
763
|
+
const details = item.details || {};
|
|
764
|
+
return {
|
|
765
|
+
...item,
|
|
766
|
+
severity: item.severity || details.severity || (item.reason_type === 'semantic_contract_drift' ? 'blocked' : 'warn'),
|
|
767
|
+
details,
|
|
768
|
+
revalidation_set: details.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
|
|
769
|
+
stale_area: item.reason_type === 'subsystem_overlap'
|
|
770
|
+
? `subsystem:${item.subsystem_tag}`
|
|
771
|
+
: item.reason_type === 'semantic_contract_drift'
|
|
772
|
+
? `contract:${(details.contract_names || []).join('|') || 'unknown'}`
|
|
773
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
774
|
+
? `object:${(details.object_names || []).join('|') || 'unknown'}`
|
|
775
|
+
: item.reason_type === 'shared_module_drift'
|
|
776
|
+
? `module:${(details.module_paths || []).join('|') || 'unknown'}`
|
|
777
|
+
: `${item.source_scope_pattern} ↔ ${item.affected_scope_pattern}`,
|
|
778
|
+
summary: item.reason_type === 'semantic_contract_drift'
|
|
779
|
+
? `${details?.source_task_title || item.source_task_id} changed shared contract ${(details.contract_names || []).join(', ') || 'unknown'}`
|
|
780
|
+
: item.reason_type === 'semantic_object_overlap'
|
|
781
|
+
? `${details?.source_task_title || item.source_task_id} changed shared exported object ${(details.object_names || []).join(', ') || 'unknown'}`
|
|
782
|
+
: item.reason_type === 'shared_module_drift'
|
|
783
|
+
? `${details?.source_task_title || item.source_task_id} changed shared module ${(details.module_paths || []).join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || item.affected_task_id}`
|
|
784
|
+
: `${details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function buildStaleClusters(invalidations = []) {
|
|
789
|
+
const clusters = new Map();
|
|
790
|
+
for (const invalidation of invalidations.map(normalizeDependencyInvalidation)) {
|
|
791
|
+
const clusterKey = invalidation.affected_pipeline_id
|
|
792
|
+
? `pipeline:${invalidation.affected_pipeline_id}`
|
|
793
|
+
: `task:${invalidation.affected_task_id}`;
|
|
794
|
+
if (!clusters.has(clusterKey)) {
|
|
795
|
+
clusters.set(clusterKey, {
|
|
796
|
+
key: clusterKey,
|
|
797
|
+
affected_pipeline_id: invalidation.affected_pipeline_id || null,
|
|
798
|
+
affected_task_ids: new Set(),
|
|
799
|
+
source_task_ids: new Set(),
|
|
800
|
+
source_task_titles: new Set(),
|
|
801
|
+
source_worktrees: new Set(),
|
|
802
|
+
affected_worktrees: new Set(),
|
|
803
|
+
stale_areas: new Set(),
|
|
804
|
+
revalidation_sets: new Set(),
|
|
805
|
+
dependent_files: new Set(),
|
|
806
|
+
dependent_areas: new Set(),
|
|
807
|
+
module_paths: new Set(),
|
|
808
|
+
invalidations: [],
|
|
809
|
+
severity: 'warn',
|
|
810
|
+
highest_affected_priority: 0,
|
|
811
|
+
highest_source_priority: 0,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
const cluster = clusters.get(clusterKey);
|
|
815
|
+
cluster.invalidations.push(invalidation);
|
|
816
|
+
cluster.affected_task_ids.add(invalidation.affected_task_id);
|
|
817
|
+
if (invalidation.source_task_id) cluster.source_task_ids.add(invalidation.source_task_id);
|
|
818
|
+
if (invalidation.details?.source_task_title) cluster.source_task_titles.add(invalidation.details.source_task_title);
|
|
819
|
+
if (invalidation.source_worktree) cluster.source_worktrees.add(invalidation.source_worktree);
|
|
820
|
+
if (invalidation.affected_worktree) cluster.affected_worktrees.add(invalidation.affected_worktree);
|
|
821
|
+
cluster.stale_areas.add(invalidation.stale_area);
|
|
822
|
+
if (invalidation.revalidation_set) cluster.revalidation_sets.add(invalidation.revalidation_set);
|
|
823
|
+
for (const filePath of invalidation.details?.dependent_files || []) cluster.dependent_files.add(filePath);
|
|
824
|
+
for (const area of invalidation.details?.dependent_areas || []) cluster.dependent_areas.add(area);
|
|
825
|
+
for (const modulePath of invalidation.details?.module_paths || []) cluster.module_paths.add(modulePath);
|
|
826
|
+
if (invalidation.severity === 'blocked') cluster.severity = 'block';
|
|
827
|
+
cluster.highest_affected_priority = Math.max(cluster.highest_affected_priority, Number(invalidation.details?.affected_task_priority || 0));
|
|
828
|
+
cluster.highest_source_priority = Math.max(cluster.highest_source_priority, Number(invalidation.details?.source_task_priority || 0));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const clusterEntries = [...clusters.values()]
|
|
832
|
+
.map((cluster) => {
|
|
833
|
+
const affectedTaskIds = [...cluster.affected_task_ids];
|
|
834
|
+
const sourceTaskTitles = [...cluster.source_task_titles];
|
|
835
|
+
const staleAreas = [...cluster.stale_areas];
|
|
836
|
+
const sourceWorktrees = [...cluster.source_worktrees];
|
|
837
|
+
const affectedWorktrees = [...cluster.affected_worktrees];
|
|
838
|
+
return {
|
|
839
|
+
key: cluster.key,
|
|
840
|
+
affected_pipeline_id: cluster.affected_pipeline_id,
|
|
841
|
+
affected_task_ids: affectedTaskIds,
|
|
842
|
+
invalidation_count: cluster.invalidations.length,
|
|
843
|
+
source_task_ids: [...cluster.source_task_ids],
|
|
844
|
+
source_pipeline_ids: [...new Set(cluster.invalidations.map((item) => item.source_pipeline_id).filter(Boolean))],
|
|
845
|
+
source_task_titles: sourceTaskTitles,
|
|
846
|
+
source_worktrees: sourceWorktrees,
|
|
847
|
+
affected_worktrees: affectedWorktrees,
|
|
848
|
+
stale_areas: staleAreas,
|
|
849
|
+
revalidation_sets: [...cluster.revalidation_sets],
|
|
850
|
+
dependent_files: [...cluster.dependent_files],
|
|
851
|
+
dependent_areas: [...cluster.dependent_areas],
|
|
852
|
+
module_paths: [...cluster.module_paths],
|
|
853
|
+
revalidation_set_type: cluster.revalidation_sets.has('contract')
|
|
854
|
+
? 'contract'
|
|
855
|
+
: cluster.revalidation_sets.has('shared_module')
|
|
856
|
+
? 'shared_module'
|
|
857
|
+
: cluster.revalidation_sets.has('semantic_object')
|
|
858
|
+
? 'semantic_object'
|
|
859
|
+
: cluster.revalidation_sets.has('subsystem')
|
|
860
|
+
? 'subsystem'
|
|
861
|
+
: 'scope',
|
|
862
|
+
rerun_priority: cluster.severity === 'block'
|
|
863
|
+
? (cluster.revalidation_sets.has('contract') || cluster.highest_affected_priority >= 8 ? 'urgent' : 'high')
|
|
864
|
+
: cluster.revalidation_sets.has('shared_module') && cluster.dependent_files.size >= 3
|
|
865
|
+
? 'high'
|
|
866
|
+
: cluster.highest_affected_priority >= 8
|
|
867
|
+
? 'high'
|
|
868
|
+
: cluster.highest_affected_priority >= 5
|
|
869
|
+
? 'medium'
|
|
870
|
+
: 'low',
|
|
871
|
+
rerun_priority_score: (cluster.severity === 'block' ? 100 : 0)
|
|
872
|
+
+ (cluster.revalidation_sets.has('contract') ? 30 : cluster.revalidation_sets.has('shared_module') ? 20 : cluster.revalidation_sets.has('semantic_object') ? 15 : 0)
|
|
873
|
+
+ (cluster.highest_affected_priority * 3)
|
|
874
|
+
+ (cluster.dependent_files.size * 4)
|
|
875
|
+
+ (cluster.dependent_areas.size * 2)
|
|
876
|
+
+ cluster.module_paths.size
|
|
877
|
+
+ cluster.invalidations.length,
|
|
878
|
+
rerun_breadth_score: (cluster.dependent_files.size * 4) + (cluster.dependent_areas.size * 2) + cluster.module_paths.size,
|
|
879
|
+
highest_affected_priority: cluster.highest_affected_priority,
|
|
880
|
+
highest_source_priority: cluster.highest_source_priority,
|
|
881
|
+
severity: cluster.severity,
|
|
882
|
+
invalidations: cluster.invalidations,
|
|
883
|
+
title: cluster.affected_pipeline_id
|
|
884
|
+
? `Pipeline ${cluster.affected_pipeline_id} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`
|
|
885
|
+
: `${affectedTaskIds[0]} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`,
|
|
886
|
+
detail: `${sourceTaskTitles[0] || cluster.invalidations[0]?.source_task_id || 'unknown source'} -> ${affectedWorktrees.join(', ') || 'unknown target'} (${staleAreas.join(', ')})`,
|
|
887
|
+
next_step: cluster.revalidation_sets.has('contract')
|
|
888
|
+
? (cluster.affected_pipeline_id
|
|
889
|
+
? 'retry the stale pipeline tasks together so the affected contract can be revalidated before merge'
|
|
890
|
+
: 'retry the stale task so the affected contract can be revalidated before merge')
|
|
891
|
+
: cluster.revalidation_sets.has('shared_module')
|
|
892
|
+
? (cluster.affected_pipeline_id
|
|
893
|
+
? 'retry the stale pipeline tasks together so dependent shared-module work can be revalidated before merge'
|
|
894
|
+
: 'retry the stale task so its shared-module dependency can be revalidated before merge')
|
|
895
|
+
: cluster.affected_pipeline_id
|
|
896
|
+
? 'retry the stale pipeline tasks together so the whole cluster can be revalidated before merge'
|
|
897
|
+
: 'retry the stale task so it can be revalidated before merge',
|
|
898
|
+
command: cluster.affected_pipeline_id
|
|
899
|
+
? `switchman task retry-stale --pipeline ${cluster.affected_pipeline_id}`
|
|
900
|
+
: `switchman task retry ${affectedTaskIds[0]}`,
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const causeGroups = new Map();
|
|
905
|
+
for (const cluster of clusterEntries) {
|
|
906
|
+
const primary = cluster.invalidations[0] || {};
|
|
907
|
+
const details = primary.details || {};
|
|
908
|
+
const causeKey = cluster.revalidation_set_type === 'contract'
|
|
909
|
+
? `contract:${(details.contract_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
910
|
+
: cluster.revalidation_set_type === 'shared_module'
|
|
911
|
+
? `shared_module:${(details.module_paths || cluster.module_paths || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
912
|
+
: cluster.revalidation_set_type === 'semantic_object'
|
|
913
|
+
? `semantic_object:${(details.object_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
|
|
914
|
+
: `dependency:${cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`;
|
|
915
|
+
if (!causeGroups.has(causeKey)) causeGroups.set(causeKey, []);
|
|
916
|
+
causeGroups.get(causeKey).push(cluster);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
for (const [causeKey, relatedClusters] of causeGroups.entries()) {
|
|
920
|
+
const relatedPipelines = [...new Set(relatedClusters.map((cluster) => cluster.affected_pipeline_id).filter(Boolean))];
|
|
921
|
+
const primary = relatedClusters[0];
|
|
922
|
+
const details = primary.invalidations[0]?.details || {};
|
|
923
|
+
const causeSummary = primary.revalidation_set_type === 'contract'
|
|
924
|
+
? `shared contract drift in ${(details.contract_names || []).join(', ') || 'unknown contract'}`
|
|
925
|
+
: primary.revalidation_set_type === 'shared_module'
|
|
926
|
+
? `shared module drift in ${(details.module_paths || primary.module_paths || []).join(', ') || 'unknown module'}`
|
|
927
|
+
: primary.revalidation_set_type === 'semantic_object'
|
|
928
|
+
? `shared exported object drift in ${(details.object_names || []).join(', ') || 'unknown object'}`
|
|
929
|
+
: `shared dependency drift across ${primary.stale_areas.join(', ')}`;
|
|
930
|
+
for (let index = 0; index < relatedClusters.length; index += 1) {
|
|
931
|
+
relatedClusters[index].causal_group_id = `cause-${causeKey}`;
|
|
932
|
+
relatedClusters[index].causal_group_size = relatedClusters.length;
|
|
933
|
+
relatedClusters[index].causal_group_rank = index + 1;
|
|
934
|
+
relatedClusters[index].causal_group_summary = causeSummary;
|
|
935
|
+
relatedClusters[index].related_affected_pipelines = relatedPipelines;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return clusterEntries.sort((a, b) =>
|
|
940
|
+
b.rerun_priority_score - a.rerun_priority_score
|
|
941
|
+
|| (a.severity === 'block' ? -1 : 1) - (b.severity === 'block' ? -1 : 1)
|
|
942
|
+
|| (a.revalidation_set_type === 'contract' ? -1 : 1) - (b.revalidation_set_type === 'contract' ? -1 : 1)
|
|
943
|
+
|| (a.revalidation_set_type === 'shared_module' ? -1 : 1) - (b.revalidation_set_type === 'shared_module' ? -1 : 1)
|
|
944
|
+
|| b.invalidation_count - a.invalidation_count
|
|
945
|
+
|| String(a.affected_pipeline_id || a.affected_task_ids[0]).localeCompare(String(b.affected_pipeline_id || b.affected_task_ids[0])));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function buildStalePipelineExplainReport(db, pipelineId) {
|
|
949
|
+
const invalidations = listDependencyInvalidations(db, { pipelineId });
|
|
950
|
+
const staleClusters = buildStaleClusters(invalidations)
|
|
951
|
+
.filter((cluster) => cluster.affected_pipeline_id === pipelineId);
|
|
952
|
+
return {
|
|
953
|
+
pipeline_id: pipelineId,
|
|
954
|
+
invalidations: invalidations.map(normalizeDependencyInvalidation),
|
|
955
|
+
stale_clusters: staleClusters,
|
|
956
|
+
next_action: staleClusters.length > 0
|
|
957
|
+
? `switchman task retry-stale --pipeline ${pipelineId}`
|
|
958
|
+
: null,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function parseEventDetails(details) {
|
|
963
|
+
try {
|
|
964
|
+
return JSON.parse(details || '{}');
|
|
965
|
+
} catch {
|
|
966
|
+
return {};
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function pipelineOwnsAuditEvent(event, pipelineId) {
|
|
971
|
+
if (event.task_id?.startsWith(`${pipelineId}-`)) return true;
|
|
972
|
+
const details = parseEventDetails(event.details);
|
|
973
|
+
if (details.pipeline_id === pipelineId) return true;
|
|
974
|
+
if (details.source_pipeline_id === pipelineId) return true;
|
|
975
|
+
if (Array.isArray(details.task_ids) && details.task_ids.some((taskId) => String(taskId).startsWith(`${pipelineId}-`))) {
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function fallbackEventLabel(eventType) {
|
|
982
|
+
return String(eventType || 'event')
|
|
983
|
+
.split('_')
|
|
984
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
985
|
+
.join(' ');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function summarizePipelineAuditHistoryEvent(event, pipelineId) {
|
|
989
|
+
const details = parseEventDetails(event.details);
|
|
990
|
+
const defaultNextAction = `switchman pipeline status ${pipelineId}`;
|
|
991
|
+
|
|
992
|
+
switch (event.event_type) {
|
|
993
|
+
case 'pipeline_created':
|
|
994
|
+
return {
|
|
995
|
+
label: 'Pipeline created',
|
|
996
|
+
summary: `Created pipeline "${details.title || pipelineId}" with ${(details.task_ids || []).length} planned task${(details.task_ids || []).length === 1 ? '' : 's'}.`,
|
|
997
|
+
next_action: defaultNextAction,
|
|
998
|
+
};
|
|
999
|
+
case 'task_completed':
|
|
1000
|
+
return {
|
|
1001
|
+
label: 'Task completed',
|
|
1002
|
+
summary: `Completed ${event.task_id}.`,
|
|
1003
|
+
next_action: defaultNextAction,
|
|
1004
|
+
};
|
|
1005
|
+
case 'task_failed':
|
|
1006
|
+
return {
|
|
1007
|
+
label: 'Task failed',
|
|
1008
|
+
summary: `Failed ${event.task_id}${event.reason_code ? ` because ${humanizeReasonCode(event.reason_code)}` : ''}.`,
|
|
1009
|
+
next_action: defaultNextAction,
|
|
1010
|
+
};
|
|
1011
|
+
case 'task_retried':
|
|
1012
|
+
case 'pipeline_task_retry_scheduled':
|
|
1013
|
+
return {
|
|
1014
|
+
label: 'Task retry scheduled',
|
|
1015
|
+
summary: `Scheduled a retry for ${event.task_id}${details.retry_attempt ? ` (attempt ${details.retry_attempt})` : ''}.`,
|
|
1016
|
+
next_action: defaultNextAction,
|
|
1017
|
+
};
|
|
1018
|
+
case 'dependency_invalidations_updated':
|
|
1019
|
+
{
|
|
1020
|
+
const reasonTypes = details.reason_types || [];
|
|
1021
|
+
const revalidationSets = details.revalidation_sets || [];
|
|
1022
|
+
return {
|
|
1023
|
+
label: 'Stale work detected',
|
|
1024
|
+
summary: `Marked stale work after ${details.source_task_title || details.source_task_id || 'an upstream task'} changed a shared boundary${revalidationSets.length > 0 ? ` across ${revalidationSets.join(', ')} revalidation` : reasonTypes.length > 0 ? ` across ${reasonTypes.join(', ')}` : ''}.`,
|
|
1025
|
+
next_action: details.affected_pipeline_id
|
|
1026
|
+
? `switchman explain stale --pipeline ${details.affected_pipeline_id}`
|
|
1027
|
+
: defaultNextAction,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
case 'boundary_validation_state':
|
|
1031
|
+
return {
|
|
1032
|
+
label: 'Boundary validation updated',
|
|
1033
|
+
summary: details.summary || 'Updated boundary validation state for the pipeline.',
|
|
1034
|
+
next_action: defaultNextAction,
|
|
1035
|
+
};
|
|
1036
|
+
case 'pipeline_followups_created':
|
|
1037
|
+
return {
|
|
1038
|
+
label: 'Follow-up work created',
|
|
1039
|
+
summary: `Created ${(details.created_task_ids || []).length} follow-up task${(details.created_task_ids || []).length === 1 ? '' : 's'} for review or validation.`,
|
|
1040
|
+
next_action: `switchman pipeline review ${pipelineId}`,
|
|
1041
|
+
};
|
|
1042
|
+
case 'pipeline_pr_summary':
|
|
1043
|
+
return {
|
|
1044
|
+
label: 'PR summary built',
|
|
1045
|
+
summary: 'Built the reviewer-facing pipeline summary.',
|
|
1046
|
+
next_action: `switchman pipeline sync-pr ${pipelineId} --pr-from-env`,
|
|
1047
|
+
};
|
|
1048
|
+
case 'pipeline_pr_bundle_exported':
|
|
1049
|
+
return {
|
|
1050
|
+
label: 'PR bundle exported',
|
|
1051
|
+
summary: 'Exported PR and landing artifacts for CI or review.',
|
|
1052
|
+
next_action: `switchman pipeline sync-pr ${pipelineId} --pr-from-env`,
|
|
1053
|
+
};
|
|
1054
|
+
case 'pipeline_pr_commented':
|
|
1055
|
+
return {
|
|
1056
|
+
label: 'PR comment updated',
|
|
1057
|
+
summary: `Updated PR #${details.pr_number || 'unknown'} with the latest pipeline status.`,
|
|
1058
|
+
next_action: defaultNextAction,
|
|
1059
|
+
};
|
|
1060
|
+
case 'pipeline_pr_synced':
|
|
1061
|
+
return {
|
|
1062
|
+
label: 'PR sync completed',
|
|
1063
|
+
summary: `Synced PR #${details.pr_number || 'unknown'} with bundle artifacts, comment, and CI outputs.`,
|
|
1064
|
+
next_action: defaultNextAction,
|
|
1065
|
+
};
|
|
1066
|
+
case 'pipeline_pr_published':
|
|
1067
|
+
return {
|
|
1068
|
+
label: 'PR published',
|
|
1069
|
+
summary: `Published pipeline PR${details.pr_number ? ` #${details.pr_number}` : ''}.`,
|
|
1070
|
+
next_action: defaultNextAction,
|
|
1071
|
+
};
|
|
1072
|
+
case 'pipeline_landing_branch_materialized':
|
|
1073
|
+
return {
|
|
1074
|
+
label: event.status === 'allowed' ? 'Landing branch assembled' : 'Landing branch failed',
|
|
1075
|
+
summary: event.status === 'allowed'
|
|
1076
|
+
? `Materialized synthetic landing branch ${details.branch || 'unknown'} from ${(details.component_branches || []).length} component branch${(details.component_branches || []).length === 1 ? '' : 'es'}.`
|
|
1077
|
+
: `Failed to materialize the landing branch${details.failed_branch ? ` while merging ${details.failed_branch}` : ''}.`,
|
|
1078
|
+
next_action: details.next_action || `switchman explain landing ${pipelineId}`,
|
|
1079
|
+
};
|
|
1080
|
+
case 'pipeline_landing_recovery_prepared':
|
|
1081
|
+
return {
|
|
1082
|
+
label: 'Landing recovery prepared',
|
|
1083
|
+
summary: `Prepared a recovery worktree${details.recovery_path ? ` at ${details.recovery_path}` : ''} for the landing branch.`,
|
|
1084
|
+
next_action: details.inspect_command || `switchman pipeline land ${pipelineId} --recover`,
|
|
1085
|
+
};
|
|
1086
|
+
case 'pipeline_landing_recovery_resumed':
|
|
1087
|
+
return {
|
|
1088
|
+
label: 'Landing recovery resumed',
|
|
1089
|
+
summary: 'Recorded a manually resolved landing branch and marked it ready to queue again.',
|
|
1090
|
+
next_action: details.resume_command || `switchman queue add --pipeline ${pipelineId}`,
|
|
1091
|
+
};
|
|
1092
|
+
case 'pipeline_landing_recovery_cleared':
|
|
1093
|
+
return {
|
|
1094
|
+
label: 'Landing recovery cleaned up',
|
|
1095
|
+
summary: `Cleared the recorded landing recovery worktree${details.recovery_path ? ` at ${details.recovery_path}` : ''}.`,
|
|
1096
|
+
next_action: defaultNextAction,
|
|
1097
|
+
};
|
|
1098
|
+
default:
|
|
1099
|
+
return {
|
|
1100
|
+
label: fallbackEventLabel(event.event_type),
|
|
1101
|
+
summary: details.summary || fallbackEventLabel(event.event_type),
|
|
1102
|
+
next_action: defaultNextAction,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function summarizePipelineQueueHistoryEvent(item, event) {
|
|
1108
|
+
const details = parseEventDetails(event.details);
|
|
1109
|
+
|
|
1110
|
+
switch (event.event_type) {
|
|
1111
|
+
case 'merge_queue_enqueued':
|
|
1112
|
+
return {
|
|
1113
|
+
label: 'Queued for landing',
|
|
1114
|
+
summary: `Queued ${item.id} to land ${item.source_ref} onto ${item.target_branch}.${details.policy_override_summary ? ` ${details.policy_override_summary}` : ''}`,
|
|
1115
|
+
next_action: 'switchman queue status',
|
|
1116
|
+
};
|
|
1117
|
+
case 'merge_queue_started':
|
|
1118
|
+
return {
|
|
1119
|
+
label: 'Queue processing started',
|
|
1120
|
+
summary: `Started validating queue item ${item.id}.`,
|
|
1121
|
+
next_action: 'switchman queue status',
|
|
1122
|
+
};
|
|
1123
|
+
case 'merge_queue_retried':
|
|
1124
|
+
return {
|
|
1125
|
+
label: 'Queue item retried',
|
|
1126
|
+
summary: `Moved ${item.id} back into the landing queue for another attempt.`,
|
|
1127
|
+
next_action: 'switchman queue status',
|
|
1128
|
+
};
|
|
1129
|
+
case 'merge_queue_state_changed':
|
|
1130
|
+
return {
|
|
1131
|
+
label: `Queue ${event.status || 'updated'}`,
|
|
1132
|
+
summary: details.last_error_summary
|
|
1133
|
+
|| (event.status === 'merged'
|
|
1134
|
+
? `Merged ${item.id}${details.merged_commit ? ` at ${String(details.merged_commit).slice(0, 12)}` : ''}.`
|
|
1135
|
+
: `Updated ${item.id} to ${event.status || 'unknown'}.`),
|
|
1136
|
+
next_action: details.next_action || item.next_action || `switchman explain queue ${item.id}`,
|
|
1137
|
+
};
|
|
1138
|
+
default:
|
|
1139
|
+
return {
|
|
1140
|
+
label: fallbackEventLabel(event.event_type),
|
|
1141
|
+
summary: fallbackEventLabel(event.event_type),
|
|
1142
|
+
next_action: item.next_action || `switchman explain queue ${item.id}`,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function buildPipelineHistoryReport(db, repoRoot, pipelineId) {
|
|
1148
|
+
const status = getPipelineStatus(db, pipelineId);
|
|
1149
|
+
let landing;
|
|
1150
|
+
try {
|
|
1151
|
+
landing = getPipelineLandingExplainReport(db, repoRoot, pipelineId);
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
landing = {
|
|
1154
|
+
pipeline_id: pipelineId,
|
|
1155
|
+
landing: {
|
|
1156
|
+
branch: null,
|
|
1157
|
+
strategy: 'unresolved',
|
|
1158
|
+
synthetic: false,
|
|
1159
|
+
stale: false,
|
|
1160
|
+
stale_reasons: [],
|
|
1161
|
+
last_failure: {
|
|
1162
|
+
reason_code: 'landing_not_ready',
|
|
1163
|
+
summary: String(err.message || 'Landing branch is not ready yet.'),
|
|
1164
|
+
},
|
|
1165
|
+
last_recovery: null,
|
|
1166
|
+
},
|
|
1167
|
+
next_action: `switchman pipeline status ${pipelineId}`,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
const staleClusters = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }))
|
|
1171
|
+
.filter((cluster) => cluster.affected_pipeline_id === pipelineId);
|
|
1172
|
+
const queueItems = listMergeQueue(db)
|
|
1173
|
+
.filter((item) => item.source_pipeline_id === pipelineId)
|
|
1174
|
+
.map((item) => ({
|
|
1175
|
+
...item,
|
|
1176
|
+
recent_events: listMergeQueueEvents(db, item.id, { limit: 20 }),
|
|
1177
|
+
}));
|
|
1178
|
+
const auditEvents = listAuditEvents(db, { limit: 2000 })
|
|
1179
|
+
.filter((event) => pipelineOwnsAuditEvent(event, pipelineId));
|
|
1180
|
+
|
|
1181
|
+
const events = [
|
|
1182
|
+
...auditEvents.map((event) => {
|
|
1183
|
+
const described = summarizePipelineAuditHistoryEvent(event, pipelineId);
|
|
1184
|
+
return {
|
|
1185
|
+
source: 'audit',
|
|
1186
|
+
id: `audit:${event.id}`,
|
|
1187
|
+
created_at: event.created_at,
|
|
1188
|
+
event_type: event.event_type,
|
|
1189
|
+
status: event.status,
|
|
1190
|
+
reason_code: event.reason_code || null,
|
|
1191
|
+
task_id: event.task_id || null,
|
|
1192
|
+
...described,
|
|
1193
|
+
};
|
|
1194
|
+
}),
|
|
1195
|
+
...queueItems.flatMap((item) => item.recent_events.map((event) => {
|
|
1196
|
+
const described = summarizePipelineQueueHistoryEvent(item, event);
|
|
1197
|
+
return {
|
|
1198
|
+
source: 'queue',
|
|
1199
|
+
id: `queue:${item.id}:${event.id}`,
|
|
1200
|
+
created_at: event.created_at,
|
|
1201
|
+
event_type: event.event_type,
|
|
1202
|
+
status: event.status || item.status,
|
|
1203
|
+
reason_code: null,
|
|
1204
|
+
task_id: null,
|
|
1205
|
+
queue_item_id: item.id,
|
|
1206
|
+
...described,
|
|
1207
|
+
};
|
|
1208
|
+
})),
|
|
1209
|
+
].sort((a, b) => {
|
|
1210
|
+
const timeCompare = String(a.created_at || '').localeCompare(String(b.created_at || ''));
|
|
1211
|
+
if (timeCompare !== 0) return timeCompare;
|
|
1212
|
+
return a.id.localeCompare(b.id);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
const blockedQueueItem = queueItems.find((item) => item.status === 'blocked');
|
|
1216
|
+
const nextAction = staleClusters[0]?.command
|
|
1217
|
+
|| blockedQueueItem?.next_action
|
|
1218
|
+
|| landing.next_action
|
|
1219
|
+
|| `switchman pipeline status ${pipelineId}`;
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
pipeline_id: pipelineId,
|
|
1223
|
+
title: status.title,
|
|
1224
|
+
description: status.description,
|
|
1225
|
+
counts: status.counts,
|
|
1226
|
+
current: {
|
|
1227
|
+
stale_clusters: staleClusters,
|
|
1228
|
+
queue_items: queueItems.map((item) => ({
|
|
1229
|
+
id: item.id,
|
|
1230
|
+
status: item.status,
|
|
1231
|
+
target_branch: item.target_branch,
|
|
1232
|
+
last_error_code: item.last_error_code || null,
|
|
1233
|
+
last_error_summary: item.last_error_summary || null,
|
|
1234
|
+
next_action: item.next_action || null,
|
|
1235
|
+
})),
|
|
1236
|
+
landing: {
|
|
1237
|
+
branch: landing.landing.branch,
|
|
1238
|
+
strategy: landing.landing.strategy,
|
|
1239
|
+
synthetic: landing.landing.synthetic,
|
|
1240
|
+
stale: landing.landing.stale,
|
|
1241
|
+
stale_reasons: landing.landing.stale_reasons,
|
|
1242
|
+
last_failure: landing.landing.last_failure,
|
|
1243
|
+
last_recovery: landing.landing.last_recovery,
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
events,
|
|
1247
|
+
next_action: nextAction,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function collectKnownPipelineIds(db) {
|
|
1252
|
+
return [...new Set(
|
|
1253
|
+
listTasks(db)
|
|
1254
|
+
.map((task) => getTaskSpec(db, task.id)?.pipeline_id || null)
|
|
1255
|
+
.filter(Boolean),
|
|
1256
|
+
)].sort();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function reconcileWorktreeState(db, repoRoot) {
|
|
1260
|
+
const actions = [];
|
|
1261
|
+
const dbWorktrees = listWorktrees(db);
|
|
1262
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1263
|
+
|
|
1264
|
+
const dbByPath = new Map(dbWorktrees.map((worktree) => [worktree.path, worktree]));
|
|
1265
|
+
const dbByName = new Map(dbWorktrees.map((worktree) => [worktree.name, worktree]));
|
|
1266
|
+
const gitByPath = new Map(gitWorktrees.map((worktree) => [worktree.path, worktree]));
|
|
1267
|
+
|
|
1268
|
+
for (const gitWorktree of gitWorktrees) {
|
|
1269
|
+
const dbMatch = dbByPath.get(gitWorktree.path) || dbByName.get(gitWorktree.name) || null;
|
|
1270
|
+
if (!dbMatch) {
|
|
1271
|
+
registerWorktree(db, {
|
|
1272
|
+
name: gitWorktree.name,
|
|
1273
|
+
path: gitWorktree.path,
|
|
1274
|
+
branch: gitWorktree.branch || 'unknown',
|
|
1275
|
+
agent: null,
|
|
1276
|
+
});
|
|
1277
|
+
actions.push({
|
|
1278
|
+
kind: 'git_worktree_registered',
|
|
1279
|
+
worktree: gitWorktree.name,
|
|
1280
|
+
path: gitWorktree.path,
|
|
1281
|
+
branch: gitWorktree.branch || 'unknown',
|
|
1282
|
+
});
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (dbMatch.path !== gitWorktree.path || dbMatch.branch !== (gitWorktree.branch || dbMatch.branch) || dbMatch.status === 'missing') {
|
|
1287
|
+
registerWorktree(db, {
|
|
1288
|
+
name: dbMatch.name,
|
|
1289
|
+
path: gitWorktree.path,
|
|
1290
|
+
branch: gitWorktree.branch || dbMatch.branch || 'unknown',
|
|
1291
|
+
agent: dbMatch.agent,
|
|
1292
|
+
});
|
|
1293
|
+
actions.push({
|
|
1294
|
+
kind: 'db_worktree_reconciled',
|
|
1295
|
+
worktree: dbMatch.name,
|
|
1296
|
+
path: gitWorktree.path,
|
|
1297
|
+
branch: gitWorktree.branch || dbMatch.branch || 'unknown',
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
for (const dbWorktree of dbWorktrees) {
|
|
1303
|
+
if (!gitByPath.has(dbWorktree.path) && dbWorktree.status !== 'missing') {
|
|
1304
|
+
updateWorktreeStatus(db, dbWorktree.name, 'missing');
|
|
1305
|
+
actions.push({
|
|
1306
|
+
kind: 'db_worktree_marked_missing',
|
|
1307
|
+
worktree: dbWorktree.name,
|
|
1308
|
+
path: dbWorktree.path,
|
|
1309
|
+
branch: dbWorktree.branch,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
return actions;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function reconcileTrackedTempResources(db, repoRoot) {
|
|
1318
|
+
const actions = [];
|
|
1319
|
+
const warnings = [];
|
|
1320
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1321
|
+
const gitPaths = new Set(gitWorktrees.map((worktree) => worktree.path));
|
|
1322
|
+
const resources = listTempResources(db, { limit: 500 }).filter((resource) => resource.status !== 'released');
|
|
1323
|
+
|
|
1324
|
+
for (const resource of resources) {
|
|
1325
|
+
const exists = existsSync(resource.path);
|
|
1326
|
+
const trackedByGit = gitPaths.has(resource.path);
|
|
1327
|
+
|
|
1328
|
+
if (resource.resource_type === 'landing_temp_worktree') {
|
|
1329
|
+
if (!exists && !trackedByGit) {
|
|
1330
|
+
updateTempResource(db, resource.id, {
|
|
1331
|
+
status: 'abandoned',
|
|
1332
|
+
details: JSON.stringify({
|
|
1333
|
+
repaired_by: 'switchman repair',
|
|
1334
|
+
reason: 'temp_worktree_missing_after_interruption',
|
|
1335
|
+
path: resource.path,
|
|
1336
|
+
}),
|
|
1337
|
+
});
|
|
1338
|
+
actions.push({
|
|
1339
|
+
kind: 'temp_resource_reconciled',
|
|
1340
|
+
resource_id: resource.id,
|
|
1341
|
+
resource_type: resource.resource_type,
|
|
1342
|
+
path: resource.path,
|
|
1343
|
+
status: 'abandoned',
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (resource.resource_type === 'landing_recovery_worktree') {
|
|
1350
|
+
if (!exists && !trackedByGit) {
|
|
1351
|
+
updateTempResource(db, resource.id, {
|
|
1352
|
+
status: 'abandoned',
|
|
1353
|
+
details: JSON.stringify({
|
|
1354
|
+
repaired_by: 'switchman repair',
|
|
1355
|
+
reason: 'recovery_worktree_missing',
|
|
1356
|
+
path: resource.path,
|
|
1357
|
+
}),
|
|
1358
|
+
});
|
|
1359
|
+
actions.push({
|
|
1360
|
+
kind: 'temp_resource_reconciled',
|
|
1361
|
+
resource_id: resource.id,
|
|
1362
|
+
resource_type: resource.resource_type,
|
|
1363
|
+
path: resource.path,
|
|
1364
|
+
status: 'abandoned',
|
|
1365
|
+
});
|
|
1366
|
+
} else if (exists && !trackedByGit) {
|
|
1367
|
+
warnings.push({
|
|
1368
|
+
kind: 'temp_resource_manual_review',
|
|
1369
|
+
resource_id: resource.id,
|
|
1370
|
+
resource_type: resource.resource_type,
|
|
1371
|
+
path: resource.path,
|
|
1372
|
+
status: resource.status,
|
|
1373
|
+
next_action: `Inspect ${resource.path} and either re-register it or clean it up with switchman pipeline land ${resource.scope_id} --cleanup ${JSON.stringify(resource.path)}`,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return { actions, warnings };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function summarizeRepairReport(actions = [], warnings = [], notes = []) {
|
|
1383
|
+
return {
|
|
1384
|
+
auto_fixed: actions,
|
|
1385
|
+
manual_review: warnings,
|
|
1386
|
+
skipped: [],
|
|
1387
|
+
notes,
|
|
1388
|
+
counts: {
|
|
1389
|
+
auto_fixed: actions.length,
|
|
1390
|
+
manual_review: warnings.length,
|
|
1391
|
+
skipped: 0,
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function renderRepairLine(action) {
|
|
1397
|
+
if (action.kind === 'git_worktree_registered') {
|
|
1398
|
+
return `${chalk.dim('registered git worktree:')} ${action.worktree} ${action.path}`;
|
|
1399
|
+
}
|
|
1400
|
+
if (action.kind === 'db_worktree_reconciled') {
|
|
1401
|
+
return `${chalk.dim('reconciled db worktree:')} ${action.worktree} ${action.path}`;
|
|
1402
|
+
}
|
|
1403
|
+
if (action.kind === 'db_worktree_marked_missing') {
|
|
1404
|
+
return `${chalk.dim('marked missing db worktree:')} ${action.worktree} ${action.path}`;
|
|
1405
|
+
}
|
|
1406
|
+
if (action.kind === 'queue_item_blocked_missing_worktree') {
|
|
1407
|
+
return `${chalk.dim('blocked queue item with missing worktree:')} ${action.queue_item_id} ${action.worktree}`;
|
|
1408
|
+
}
|
|
1409
|
+
if (action.kind === 'stale_temp_worktree_removed') {
|
|
1410
|
+
return `${chalk.dim('removed stale temp landing worktree:')} ${action.path}`;
|
|
1411
|
+
}
|
|
1412
|
+
if (action.kind === 'stale_temp_worktree_pruned') {
|
|
1413
|
+
return `${chalk.dim('pruned stale temp landing metadata:')} ${action.path}`;
|
|
1414
|
+
}
|
|
1415
|
+
if (action.kind === 'journal_operation_repaired') {
|
|
1416
|
+
return `${chalk.dim('closed interrupted operation:')} ${action.operation_type} ${action.scope_type}:${action.scope_id}`;
|
|
1417
|
+
}
|
|
1418
|
+
if (action.kind === 'queue_item_reset') {
|
|
1419
|
+
return `${chalk.dim('queue reset:')} ${action.queue_item_id} ${action.previous_status} -> ${action.status}`;
|
|
1420
|
+
}
|
|
1421
|
+
if (action.kind === 'pipeline_repaired') {
|
|
1422
|
+
return `${chalk.dim('pipeline repair:')} ${action.pipeline_id}`;
|
|
1423
|
+
}
|
|
1424
|
+
if (action.kind === 'temp_resource_reconciled') {
|
|
1425
|
+
return `${chalk.dim('reconciled tracked temp resource:')} ${action.resource_type} ${action.path} -> ${action.status}`;
|
|
1426
|
+
}
|
|
1427
|
+
return `${chalk.dim(action.kind + ':')} ${JSON.stringify(action)}`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function renderRepairWarningLine(warning) {
|
|
1431
|
+
if (warning.kind === 'temp_resource_manual_review') {
|
|
1432
|
+
return `${chalk.yellow('manual review:')} ${warning.resource_type} ${warning.path}`;
|
|
1433
|
+
}
|
|
1434
|
+
return `${chalk.yellow('manual review:')} ${warning.kind}`;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function printRepairSummary(report, {
|
|
1438
|
+
repairedHeading,
|
|
1439
|
+
noRepairHeading,
|
|
1440
|
+
limit = null,
|
|
1441
|
+
} = {}) {
|
|
1442
|
+
const autoFixed = report.summary?.auto_fixed || report.actions || [];
|
|
1443
|
+
const manualReview = report.summary?.manual_review || report.warnings || [];
|
|
1444
|
+
const skipped = report.summary?.skipped || [];
|
|
1445
|
+
const limitedAutoFixed = limit == null ? autoFixed : autoFixed.slice(0, limit);
|
|
1446
|
+
const limitedManualReview = limit == null ? manualReview : manualReview.slice(0, limit);
|
|
1447
|
+
const limitedSkipped = limit == null ? skipped : skipped.slice(0, limit);
|
|
1448
|
+
|
|
1449
|
+
console.log(report.repaired ? repairedHeading : noRepairHeading);
|
|
1450
|
+
for (const note of report.notes || []) {
|
|
1451
|
+
console.log(` ${chalk.dim(note)}`);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
console.log(` ${chalk.green('auto-fixed:')} ${autoFixed.length}`);
|
|
1455
|
+
for (const action of limitedAutoFixed) {
|
|
1456
|
+
console.log(` ${renderRepairLine(action)}`);
|
|
1457
|
+
}
|
|
1458
|
+
console.log(` ${chalk.yellow('manual review:')} ${manualReview.length}`);
|
|
1459
|
+
for (const warning of limitedManualReview) {
|
|
1460
|
+
console.log(` ${renderRepairWarningLine(warning)}`);
|
|
1461
|
+
}
|
|
1462
|
+
console.log(` ${chalk.dim('skipped:')} ${skipped.length}`);
|
|
1463
|
+
for (const item of limitedSkipped) {
|
|
1464
|
+
console.log(` ${chalk.dim(JSON.stringify(item))}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function repairRepoState(db, repoRoot) {
|
|
1469
|
+
const actions = [];
|
|
1470
|
+
const warnings = [];
|
|
1471
|
+
const notes = [];
|
|
1472
|
+
const repairedQueueItems = new Set();
|
|
1473
|
+
for (const action of reconcileWorktreeState(db, repoRoot)) {
|
|
1474
|
+
actions.push(action);
|
|
1475
|
+
}
|
|
1476
|
+
const tempLandingCleanup = cleanupCrashedLandingTempWorktrees(repoRoot);
|
|
1477
|
+
for (const action of tempLandingCleanup.actions) {
|
|
1478
|
+
actions.push(action);
|
|
1479
|
+
}
|
|
1480
|
+
const tempResourceReconciliation = reconcileTrackedTempResources(db, repoRoot);
|
|
1481
|
+
for (const action of tempResourceReconciliation.actions) {
|
|
1482
|
+
actions.push(action);
|
|
1483
|
+
}
|
|
1484
|
+
for (const warning of tempResourceReconciliation.warnings) {
|
|
1485
|
+
warnings.push(warning);
|
|
1486
|
+
}
|
|
1487
|
+
const queueItems = listMergeQueue(db);
|
|
1488
|
+
const runningQueueOperations = listOperationJournal(db, { scopeType: 'queue_item', status: 'running', limit: 200 });
|
|
1489
|
+
|
|
1490
|
+
for (const operation of runningQueueOperations) {
|
|
1491
|
+
const item = getMergeQueueItem(db, operation.scope_id);
|
|
1492
|
+
if (!item) {
|
|
1493
|
+
finishOperationJournalEntry(db, operation.id, {
|
|
1494
|
+
status: 'repaired',
|
|
1495
|
+
details: JSON.stringify({
|
|
1496
|
+
repaired_by: 'switchman repair',
|
|
1497
|
+
summary: 'Queue item no longer exists; interrupted journal entry was cleared.',
|
|
1498
|
+
}),
|
|
1499
|
+
});
|
|
1500
|
+
actions.push({
|
|
1501
|
+
kind: 'journal_operation_repaired',
|
|
1502
|
+
operation_id: operation.id,
|
|
1503
|
+
operation_type: operation.operation_type,
|
|
1504
|
+
scope_type: operation.scope_type,
|
|
1505
|
+
scope_id: operation.scope_id,
|
|
1506
|
+
});
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (['validating', 'rebasing', 'merging'].includes(item.status)) {
|
|
1511
|
+
const repaired = markMergeQueueState(db, item.id, {
|
|
1512
|
+
status: 'retrying',
|
|
1513
|
+
lastErrorCode: 'interrupted_queue_run',
|
|
1514
|
+
lastErrorSummary: `Queue item ${item.id} was interrupted during ${operation.operation_type} and has been reset to retrying.`,
|
|
1515
|
+
nextAction: 'Run `switchman queue run` to resume landing.',
|
|
1516
|
+
});
|
|
1517
|
+
finishOperationJournalEntry(db, operation.id, {
|
|
1518
|
+
status: 'repaired',
|
|
1519
|
+
details: JSON.stringify({
|
|
1520
|
+
repaired_by: 'switchman repair',
|
|
1521
|
+
queue_item_id: item.id,
|
|
1522
|
+
previous_status: item.status,
|
|
1523
|
+
status: repaired.status,
|
|
1524
|
+
}),
|
|
1525
|
+
});
|
|
1526
|
+
repairedQueueItems.add(item.id);
|
|
1527
|
+
actions.push({
|
|
1528
|
+
kind: 'queue_item_reset',
|
|
1529
|
+
queue_item_id: repaired.id,
|
|
1530
|
+
previous_status: item.status,
|
|
1531
|
+
status: repaired.status,
|
|
1532
|
+
next_action: repaired.next_action,
|
|
1533
|
+
});
|
|
1534
|
+
actions.push({
|
|
1535
|
+
kind: 'journal_operation_repaired',
|
|
1536
|
+
operation_id: operation.id,
|
|
1537
|
+
operation_type: operation.operation_type,
|
|
1538
|
+
scope_type: operation.scope_type,
|
|
1539
|
+
scope_id: operation.scope_id,
|
|
1540
|
+
});
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (!['running', 'queued', 'retrying'].includes(item.status)) {
|
|
1545
|
+
finishOperationJournalEntry(db, operation.id, {
|
|
1546
|
+
status: 'repaired',
|
|
1547
|
+
details: JSON.stringify({
|
|
1548
|
+
repaired_by: 'switchman repair',
|
|
1549
|
+
queue_item_id: item.id,
|
|
1550
|
+
summary: `Queue item is already ${item.status}; stale running journal entry was cleared.`,
|
|
1551
|
+
}),
|
|
1552
|
+
});
|
|
1553
|
+
actions.push({
|
|
1554
|
+
kind: 'journal_operation_repaired',
|
|
1555
|
+
operation_id: operation.id,
|
|
1556
|
+
operation_type: operation.operation_type,
|
|
1557
|
+
scope_type: operation.scope_type,
|
|
1558
|
+
scope_id: operation.scope_id,
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const interruptedQueueItems = queueItems.filter((item) => ['validating', 'rebasing', 'merging'].includes(item.status) && !repairedQueueItems.has(item.id));
|
|
1564
|
+
|
|
1565
|
+
for (const item of interruptedQueueItems) {
|
|
1566
|
+
const repaired = markMergeQueueState(db, item.id, {
|
|
1567
|
+
status: 'retrying',
|
|
1568
|
+
lastErrorCode: 'interrupted_queue_run',
|
|
1569
|
+
lastErrorSummary: `Queue item ${item.id} was left in ${item.status} and has been reset to retrying.`,
|
|
1570
|
+
nextAction: 'Run `switchman queue run` to resume landing.',
|
|
1571
|
+
});
|
|
1572
|
+
actions.push({
|
|
1573
|
+
kind: 'queue_item_reset',
|
|
1574
|
+
queue_item_id: repaired.id,
|
|
1575
|
+
previous_status: item.status,
|
|
1576
|
+
status: repaired.status,
|
|
1577
|
+
next_action: repaired.next_action,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const reconciledWorktrees = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
|
|
1582
|
+
for (const item of queueItems.filter((entry) => ['queued', 'retrying'].includes(entry.status) && entry.source_type === 'worktree')) {
|
|
1583
|
+
const worktree = reconciledWorktrees.get(item.source_worktree || item.source_ref) || null;
|
|
1584
|
+
if (!worktree || worktree.status === 'missing') {
|
|
1585
|
+
const blocked = markMergeQueueState(db, item.id, {
|
|
1586
|
+
status: 'blocked',
|
|
1587
|
+
lastErrorCode: 'source_worktree_missing',
|
|
1588
|
+
lastErrorSummary: `Queued worktree ${item.source_worktree || item.source_ref} is no longer available.`,
|
|
1589
|
+
nextAction: `Restore or re-register ${item.source_worktree || item.source_ref}, then run \`switchman queue retry ${item.id}\`.`,
|
|
1590
|
+
});
|
|
1591
|
+
actions.push({
|
|
1592
|
+
kind: 'queue_item_blocked_missing_worktree',
|
|
1593
|
+
queue_item_id: blocked.id,
|
|
1594
|
+
worktree: item.source_worktree || item.source_ref,
|
|
1595
|
+
next_action: blocked.next_action,
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const pipelineIds = [...new Set([
|
|
1601
|
+
...collectKnownPipelineIds(db),
|
|
1602
|
+
...queueItems.map((item) => item.source_pipeline_id).filter(Boolean),
|
|
1603
|
+
])];
|
|
1604
|
+
const runningPipelineOperations = listOperationJournal(db, { scopeType: 'pipeline', status: 'running', limit: 200 });
|
|
1605
|
+
|
|
1606
|
+
for (const pipelineId of pipelineIds) {
|
|
1607
|
+
const repaired = repairPipelineState(db, repoRoot, pipelineId);
|
|
1608
|
+
if (!repaired.repaired) continue;
|
|
1609
|
+
actions.push({
|
|
1610
|
+
kind: 'pipeline_repaired',
|
|
1611
|
+
pipeline_id: pipelineId,
|
|
1612
|
+
actions: repaired.actions,
|
|
1613
|
+
next_action: repaired.next_action,
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
for (const operation of runningPipelineOperations.filter((entry) => entry.scope_id === pipelineId)) {
|
|
1617
|
+
finishOperationJournalEntry(db, operation.id, {
|
|
1618
|
+
status: 'repaired',
|
|
1619
|
+
details: JSON.stringify({
|
|
1620
|
+
repaired_by: 'switchman repair',
|
|
1621
|
+
pipeline_id: pipelineId,
|
|
1622
|
+
repair_actions: repaired.actions.map((action) => action.kind),
|
|
1623
|
+
}),
|
|
1624
|
+
});
|
|
1625
|
+
actions.push({
|
|
1626
|
+
kind: 'journal_operation_repaired',
|
|
1627
|
+
operation_id: operation.id,
|
|
1628
|
+
operation_type: operation.operation_type,
|
|
1629
|
+
scope_type: operation.scope_type,
|
|
1630
|
+
scope_id: operation.scope_id,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (actions.length === 0) {
|
|
1636
|
+
notes.push('No safe repair action was needed.');
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const summary = summarizeRepairReport(actions, warnings, notes);
|
|
1640
|
+
|
|
1641
|
+
return {
|
|
1642
|
+
repaired: actions.length > 0,
|
|
1643
|
+
actions,
|
|
1644
|
+
warnings,
|
|
1645
|
+
summary,
|
|
1646
|
+
notes,
|
|
1647
|
+
next_action: warnings[0]?.next_action || (interruptedQueueItems.length > 0 ? 'switchman queue run' : 'switchman status'),
|
|
1648
|
+
};
|
|
228
1649
|
}
|
|
229
1650
|
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
1651
|
+
function buildLandingStateLabel(landing) {
|
|
1652
|
+
if (!landing) return null;
|
|
1653
|
+
if (!landing.synthetic) {
|
|
1654
|
+
return `${landing.branch} ${chalk.dim('(single branch)')}`;
|
|
234
1655
|
}
|
|
1656
|
+
if (!landing.last_materialized) {
|
|
1657
|
+
return `${landing.branch} ${chalk.yellow('(not created yet)')}`;
|
|
1658
|
+
}
|
|
1659
|
+
if (landing.stale) {
|
|
1660
|
+
return `${landing.branch} ${chalk.red('(stale)')}`;
|
|
1661
|
+
}
|
|
1662
|
+
return `${landing.branch} ${chalk.green('(current)')}`;
|
|
235
1663
|
}
|
|
236
1664
|
|
|
237
1665
|
async function maybeCaptureTelemetry(event, properties = {}, { homeDir = null } = {}) {
|
|
@@ -515,6 +1943,7 @@ function commandForFailedTask(task, failure) {
|
|
|
515
1943
|
}
|
|
516
1944
|
|
|
517
1945
|
function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
|
|
1946
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
518
1947
|
const failedTasks = tasks
|
|
519
1948
|
.filter((task) => task.status === 'failed')
|
|
520
1949
|
.map((task) => {
|
|
@@ -530,12 +1959,24 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
530
1959
|
};
|
|
531
1960
|
});
|
|
532
1961
|
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1962
|
+
const worktreeByName = new Map((scanReport.worktrees || []).map((worktree) => [worktree.name, worktree]));
|
|
1963
|
+
const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => {
|
|
1964
|
+
const worktreeInfo = worktreeByName.get(entry.worktree) || null;
|
|
1965
|
+
const reasonCode = entry.reasons?.[0]?.reason_code || null;
|
|
1966
|
+
const isDirtyWorktree = reasonCode === 'no_active_lease';
|
|
1967
|
+
return {
|
|
1968
|
+
worktree: entry.worktree,
|
|
1969
|
+
path: worktreeInfo?.path || null,
|
|
1970
|
+
files: entry.files,
|
|
1971
|
+
reason_code: reasonCode,
|
|
1972
|
+
next_step: isDirtyWorktree
|
|
1973
|
+
? 'commit or discard the changed files in that worktree, then rescan before continuing'
|
|
1974
|
+
: (nextStepForReason(reasonCode) || 'inspect the changed files and bring them back under Switchman claims'),
|
|
1975
|
+
command: worktreeInfo?.path
|
|
1976
|
+
? `cd ${JSON.stringify(worktreeInfo.path)} && git status`
|
|
1977
|
+
: 'switchman scan',
|
|
1978
|
+
};
|
|
1979
|
+
});
|
|
539
1980
|
|
|
540
1981
|
const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
|
|
541
1982
|
file: conflict.file,
|
|
@@ -564,6 +2005,7 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
564
2005
|
next_step: 'review the overlapping branches before merge',
|
|
565
2006
|
}));
|
|
566
2007
|
|
|
2008
|
+
const staleClusters = buildStaleClusters(aiGate.dependency_invalidations || []);
|
|
567
2009
|
const attention = [
|
|
568
2010
|
...staleLeases.map((lease) => ({
|
|
569
2011
|
kind: 'stale_lease',
|
|
@@ -584,9 +2026,9 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
584
2026
|
...blockedWorktrees.map((entry) => ({
|
|
585
2027
|
kind: 'unmanaged_changes',
|
|
586
2028
|
title: `${entry.worktree} has unmanaged changed files`,
|
|
587
|
-
detail: `${entry.files.slice(0,
|
|
2029
|
+
detail: `${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}${entry.path ? ` • ${entry.path}` : ''}`,
|
|
588
2030
|
next_step: entry.next_step,
|
|
589
|
-
command:
|
|
2031
|
+
command: entry.command,
|
|
590
2032
|
severity: 'block',
|
|
591
2033
|
})),
|
|
592
2034
|
...fileConflicts.map((conflict) => ({
|
|
@@ -651,14 +2093,17 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
651
2093
|
});
|
|
652
2094
|
}
|
|
653
2095
|
|
|
654
|
-
for (const
|
|
2096
|
+
for (const cluster of staleClusters) {
|
|
655
2097
|
attention.push({
|
|
656
2098
|
kind: 'dependency_invalidation',
|
|
657
|
-
title:
|
|
658
|
-
detail:
|
|
659
|
-
next_step:
|
|
660
|
-
command:
|
|
661
|
-
severity:
|
|
2099
|
+
title: cluster.title,
|
|
2100
|
+
detail: cluster.detail,
|
|
2101
|
+
next_step: cluster.next_step,
|
|
2102
|
+
command: cluster.command,
|
|
2103
|
+
severity: cluster.severity,
|
|
2104
|
+
affected_pipeline_id: cluster.affected_pipeline_id,
|
|
2105
|
+
affected_task_ids: cluster.affected_task_ids,
|
|
2106
|
+
invalidation_count: cluster.invalidation_count,
|
|
662
2107
|
});
|
|
663
2108
|
}
|
|
664
2109
|
|
|
@@ -668,6 +2113,16 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
668
2113
|
? 'warn'
|
|
669
2114
|
: 'healthy';
|
|
670
2115
|
|
|
2116
|
+
const repoPolicyState = summarizePipelinePolicyState(db, {
|
|
2117
|
+
tasks,
|
|
2118
|
+
counts: {
|
|
2119
|
+
done: tasks.filter((task) => task.status === 'done').length,
|
|
2120
|
+
in_progress: tasks.filter((task) => task.status === 'in_progress').length,
|
|
2121
|
+
pending: tasks.filter((task) => task.status === 'pending').length,
|
|
2122
|
+
failed: tasks.filter((task) => task.status === 'failed').length,
|
|
2123
|
+
},
|
|
2124
|
+
}, changePolicy, aiGate.boundary_validations || []);
|
|
2125
|
+
|
|
671
2126
|
return {
|
|
672
2127
|
repo_root: repoRoot,
|
|
673
2128
|
health,
|
|
@@ -707,8 +2162,10 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
707
2162
|
ai_gate_status: aiGate.status,
|
|
708
2163
|
boundary_validations: aiGate.boundary_validations || [],
|
|
709
2164
|
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
2165
|
+
stale_clusters: staleClusters,
|
|
710
2166
|
compliance: scanReport.complianceSummary,
|
|
711
2167
|
semantic_conflicts: scanReport.semanticConflicts || [],
|
|
2168
|
+
policy_state: repoPolicyState,
|
|
712
2169
|
},
|
|
713
2170
|
next_steps: attention.length > 0
|
|
714
2171
|
? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
|
|
@@ -728,6 +2185,7 @@ function buildUnifiedStatusReport({
|
|
|
728
2185
|
queueItems,
|
|
729
2186
|
queueSummary,
|
|
730
2187
|
recentQueueEvents,
|
|
2188
|
+
retentionDays = 7,
|
|
731
2189
|
}) {
|
|
732
2190
|
const queueAttention = [
|
|
733
2191
|
...queueItems
|
|
@@ -776,6 +2234,20 @@ function buildUnifiedStatusReport({
|
|
|
776
2234
|
...(queueItems.length > 0 ? ['switchman queue status'] : []),
|
|
777
2235
|
...(queueSummary.next ? ['switchman queue run'] : []),
|
|
778
2236
|
].filter(Boolean);
|
|
2237
|
+
const isFirstRunReady = tasks.length === 0
|
|
2238
|
+
&& doctorReport.active_work.length === 0
|
|
2239
|
+
&& queueItems.length === 0
|
|
2240
|
+
&& claims.length === 0;
|
|
2241
|
+
const defaultNextSteps = isFirstRunReady
|
|
2242
|
+
? [
|
|
2243
|
+
'add a first task with `switchman task add "Your first task" --priority 8`',
|
|
2244
|
+
'keep `switchman status --watch` open while agents start work',
|
|
2245
|
+
'run `switchman demo` if you want the shortest proof before using a real repo',
|
|
2246
|
+
]
|
|
2247
|
+
: ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'];
|
|
2248
|
+
const defaultSuggestedCommands = isFirstRunReady
|
|
2249
|
+
? ['switchman task add "Your first task" --priority 8', 'switchman status --watch', 'switchman demo']
|
|
2250
|
+
: ['switchman gate ci', 'switchman scan'];
|
|
779
2251
|
|
|
780
2252
|
return {
|
|
781
2253
|
generated_at: new Date().toISOString(),
|
|
@@ -789,6 +2261,8 @@ function buildUnifiedStatusReport({
|
|
|
789
2261
|
? 'Repo needs attention before more work or merge.'
|
|
790
2262
|
: attention.some((item) => item.severity === 'warn')
|
|
791
2263
|
? 'Repo is running, but a few items need review.'
|
|
2264
|
+
: isFirstRunReady
|
|
2265
|
+
? 'Switchman is set up and ready. Add a task or run the demo to start.'
|
|
792
2266
|
: 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
|
|
793
2267
|
lease_policy: leasePolicy,
|
|
794
2268
|
counts: {
|
|
@@ -812,10 +2286,11 @@ function buildUnifiedStatusReport({
|
|
|
812
2286
|
file_path: claim.file_path,
|
|
813
2287
|
})),
|
|
814
2288
|
next_steps: [...new Set([
|
|
815
|
-
...doctorReport.next_steps,
|
|
2289
|
+
...(attention.length > 0 ? doctorReport.next_steps : defaultNextSteps),
|
|
816
2290
|
...queueAttention.map((item) => item.next_step),
|
|
817
2291
|
])].slice(0, 6),
|
|
818
|
-
suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
|
|
2292
|
+
suggested_commands: [...new Set(attention.length > 0 ? suggestedCommands : defaultSuggestedCommands)].slice(0, 6),
|
|
2293
|
+
retention_days: retentionDays,
|
|
819
2294
|
};
|
|
820
2295
|
}
|
|
821
2296
|
|
|
@@ -823,6 +2298,9 @@ async function collectStatusSnapshot(repoRoot) {
|
|
|
823
2298
|
const db = getDb(repoRoot);
|
|
824
2299
|
try {
|
|
825
2300
|
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
2301
|
+
const retentionDays = await getRetentionDaysForCurrentPlan();
|
|
2302
|
+
pruneDatabaseMaintenance(db, { retentionDays });
|
|
2303
|
+
cleanupOldSyncEvents({ retentionDays }).catch(() => {});
|
|
826
2304
|
|
|
827
2305
|
if (leasePolicy.reap_on_status_check) {
|
|
828
2306
|
reapStaleLeases(db, leasePolicy.stale_after_minutes, {
|
|
@@ -862,13 +2340,40 @@ async function collectStatusSnapshot(repoRoot) {
|
|
|
862
2340
|
queueItems,
|
|
863
2341
|
queueSummary,
|
|
864
2342
|
recentQueueEvents,
|
|
2343
|
+
retentionDays,
|
|
865
2344
|
});
|
|
866
2345
|
} finally {
|
|
867
2346
|
db.close();
|
|
868
2347
|
}
|
|
869
2348
|
}
|
|
870
2349
|
|
|
871
|
-
function
|
|
2350
|
+
function summarizeTeamCoordinationState(events = [], myUserId = null) {
|
|
2351
|
+
const visibleEvents = events.filter((event) => event.user_id !== myUserId);
|
|
2352
|
+
if (visibleEvents.length === 0) {
|
|
2353
|
+
return {
|
|
2354
|
+
members: 0,
|
|
2355
|
+
queue_events: 0,
|
|
2356
|
+
lease_events: 0,
|
|
2357
|
+
claim_events: 0,
|
|
2358
|
+
latest_queue_event: null,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const activeMembers = new Set(visibleEvents.map((event) => event.user_id || `${event.payload?.email || 'unknown'}:${event.worktree || 'unknown'}`));
|
|
2363
|
+
const queueEvents = visibleEvents.filter((event) => ['queue_added', 'queue_merged', 'queue_blocked'].includes(event.event_type));
|
|
2364
|
+
const leaseEvents = visibleEvents.filter((event) => ['lease_acquired', 'task_done', 'task_failed', 'task_retried'].includes(event.event_type));
|
|
2365
|
+
const claimEvents = visibleEvents.filter((event) => ['claim_added', 'claim_released'].includes(event.event_type));
|
|
2366
|
+
|
|
2367
|
+
return {
|
|
2368
|
+
members: activeMembers.size,
|
|
2369
|
+
queue_events: queueEvents.length,
|
|
2370
|
+
lease_events: leaseEvents.length,
|
|
2371
|
+
claim_events: claimEvents.length,
|
|
2372
|
+
latest_queue_event: queueEvents[0] || null,
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function renderUnifiedStatusReport(report, { teamActivity = [], teamSummary = null } = {}) {
|
|
872
2377
|
const healthColor = colorForHealth(report.health);
|
|
873
2378
|
const badge = healthColor(healthLabel(report.health));
|
|
874
2379
|
const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
|
|
@@ -884,7 +2389,15 @@ function renderUnifiedStatusReport(report) {
|
|
|
884
2389
|
? ('title' in focusItem
|
|
885
2390
|
? `${focusItem.title}${focusItem.detail ? ` ${chalk.dim(`• ${focusItem.detail}`)}` : ''}`
|
|
886
2391
|
: `${focusItem.title} ${chalk.dim(focusItem.id)}`)
|
|
2392
|
+
: report.counts.pending === 0 && report.counts.in_progress === 0 && report.queue.items.length === 0
|
|
2393
|
+
? 'Nothing active yet. Add a task or run the demo to start.'
|
|
887
2394
|
: 'Nothing urgent. Safe to keep parallel work moving.';
|
|
2395
|
+
const primaryCommand = ('command' in (focusItem || {}) && focusItem?.command)
|
|
2396
|
+
? focusItem.command
|
|
2397
|
+
: report.suggested_commands[0] || 'switchman status --watch';
|
|
2398
|
+
const nextStepLine = ('next_step' in (focusItem || {}) && focusItem?.next_step)
|
|
2399
|
+
? focusItem.next_step
|
|
2400
|
+
: report.next_steps[0] || 'Keep work moving and check back here if anything blocks.';
|
|
888
2401
|
const queueLoad = queueCounts.queued + queueCounts.retrying + queueCounts.merging + queueCounts.blocked;
|
|
889
2402
|
const landingLabel = report.merge_readiness.ci_gate_ok ? 'ready' : 'hold';
|
|
890
2403
|
|
|
@@ -914,8 +2427,47 @@ function renderUnifiedStatusReport(report) {
|
|
|
914
2427
|
{ label: 'merging', value: queueCounts.merging, color: chalk.blue },
|
|
915
2428
|
{ label: 'merged', value: queueCounts.merged, color: chalk.green },
|
|
916
2429
|
]));
|
|
917
|
-
console.log(`${chalk.bold('
|
|
2430
|
+
console.log(`${chalk.bold('Now:')} ${report.summary}`);
|
|
2431
|
+
console.log(`${chalk.bold('Attention:')} ${focusLine}`);
|
|
2432
|
+
console.log(`${chalk.bold('Run next:')} ${chalk.cyan(primaryCommand)}`);
|
|
2433
|
+
console.log(`${chalk.dim('why:')} ${nextStepLine}`);
|
|
918
2434
|
console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
|
|
2435
|
+
console.log(chalk.dim(`history retention: ${report.retention_days || 7} days`));
|
|
2436
|
+
if (report.merge_readiness.policy_state?.active) {
|
|
2437
|
+
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'}`));
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// ── Team activity (Pro cloud sync) ──────────────────────────────────────────
|
|
2441
|
+
if (teamSummary && teamSummary.members > 0) {
|
|
2442
|
+
console.log('');
|
|
2443
|
+
console.log(chalk.bold('Shared cloud state:'));
|
|
2444
|
+
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}`);
|
|
2445
|
+
if (teamSummary.latest_queue_event) {
|
|
2446
|
+
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());
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
if (teamActivity.length > 0) {
|
|
2450
|
+
console.log('');
|
|
2451
|
+
console.log(chalk.bold('Team activity:'));
|
|
2452
|
+
for (const member of teamActivity) {
|
|
2453
|
+
const email = member.payload?.email ?? chalk.dim(member.user_id?.slice(0, 8) ?? 'unknown');
|
|
2454
|
+
const worktree = chalk.cyan(member.worktree ?? 'unknown');
|
|
2455
|
+
const eventLabel = {
|
|
2456
|
+
task_added: 'added a task',
|
|
2457
|
+
task_done: 'completed a task',
|
|
2458
|
+
task_failed: 'failed a task',
|
|
2459
|
+
task_retried: 'retried a task',
|
|
2460
|
+
lease_acquired: `working on: ${chalk.dim(member.payload?.title ?? '')}`,
|
|
2461
|
+
claim_added: `claimed ${chalk.dim(member.payload?.file_count ?? 0)} file(s)`,
|
|
2462
|
+
claim_released: 'released file claims',
|
|
2463
|
+
queue_added: `queued ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
2464
|
+
queue_merged: `landed ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
2465
|
+
queue_blocked: `blocked ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
|
|
2466
|
+
status_ping: 'active',
|
|
2467
|
+
}[member.event_type] ?? member.event_type;
|
|
2468
|
+
console.log(` ${chalk.dim('○')} ${email} · ${worktree} · ${eventLabel}`);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
919
2471
|
|
|
920
2472
|
const runningLines = report.active_work.length > 0
|
|
921
2473
|
? report.active_work.slice(0, 5).map((item) => {
|
|
@@ -931,6 +2483,27 @@ function renderUnifiedStatusReport(report) {
|
|
|
931
2483
|
|
|
932
2484
|
const blockedItems = report.attention.filter((item) => item.severity === 'block');
|
|
933
2485
|
const warningItems = report.attention.filter((item) => item.severity !== 'block');
|
|
2486
|
+
const isQuietEmptyState = report.active_work.length === 0
|
|
2487
|
+
&& blockedItems.length === 0
|
|
2488
|
+
&& warningItems.length === 0
|
|
2489
|
+
&& report.queue.items.length === 0
|
|
2490
|
+
&& report.next_up.length === 0
|
|
2491
|
+
&& report.failed_tasks.length === 0;
|
|
2492
|
+
|
|
2493
|
+
if (isQuietEmptyState) {
|
|
2494
|
+
console.log('');
|
|
2495
|
+
console.log(healthColor('='.repeat(72)));
|
|
2496
|
+
console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• mission control for parallel agents')}`);
|
|
2497
|
+
console.log(`${chalk.dim(report.repo_root)}`);
|
|
2498
|
+
console.log(`${chalk.dim(report.summary)}`);
|
|
2499
|
+
console.log(healthColor('='.repeat(72)));
|
|
2500
|
+
console.log('');
|
|
2501
|
+
console.log(chalk.green('Nothing is running yet.'));
|
|
2502
|
+
console.log(`Add work with: ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
2503
|
+
console.log(`Or prove the flow in 30 seconds with: ${chalk.cyan('switchman demo')}`);
|
|
2504
|
+
console.log('');
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
934
2507
|
|
|
935
2508
|
const blockedLines = blockedItems.length > 0
|
|
936
2509
|
? blockedItems.slice(0, 4).flatMap((item) => {
|
|
@@ -955,8 +2528,14 @@ function renderUnifiedStatusReport(report) {
|
|
|
955
2528
|
const queueLines = report.queue.items.length > 0
|
|
956
2529
|
? [
|
|
957
2530
|
...(report.queue.summary.next
|
|
958
|
-
? [
|
|
2531
|
+
? [
|
|
2532
|
+
`${chalk.dim('next:')} ${report.queue.summary.next.id} ${report.queue.summary.next.source_type}:${report.queue.summary.next.source_ref} ${chalk.dim(`retries:${report.queue.summary.next.retry_count}/${report.queue.summary.next.max_retries}`)}${report.queue.summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${report.queue.summary.next.queue_assessment.goal_priority}`)}` : ''}${report.queue.summary.next.queue_assessment?.integration_risk && report.queue.summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${report.queue.summary.next.queue_assessment.integration_risk}`)}` : ''}`,
|
|
2533
|
+
...(report.queue.summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${report.queue.summary.next.recommendation.summary}`] : []),
|
|
2534
|
+
]
|
|
959
2535
|
: []),
|
|
2536
|
+
...report.queue.summary.held_back
|
|
2537
|
+
.slice(0, 2)
|
|
2538
|
+
.map((item) => ` ${chalk.dim(item.recommendation?.action === 'escalate' ? 'escalate:' : 'hold:')} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(item.recommendation?.summary || item.queue_assessment?.reason || '')}`),
|
|
960
2539
|
...report.queue.items
|
|
961
2540
|
.filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
|
|
962
2541
|
.slice(0, 4)
|
|
@@ -969,6 +2548,35 @@ function renderUnifiedStatusReport(report) {
|
|
|
969
2548
|
]
|
|
970
2549
|
: [chalk.dim('No queued merges.')];
|
|
971
2550
|
|
|
2551
|
+
const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
|
|
2552
|
+
? report.merge_readiness.stale_clusters.slice(0, 4).flatMap((cluster) => {
|
|
2553
|
+
const lines = [`${renderChip(cluster.severity === 'block' ? 'STALE' : 'WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], cluster.severity === 'block' ? chalk.red : chalk.yellow)} ${cluster.title}`];
|
|
2554
|
+
lines.push(` ${chalk.dim(cluster.detail)}`);
|
|
2555
|
+
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(', ')}`)}` : ''}`);
|
|
2556
|
+
lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
|
|
2557
|
+
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}`)}` : ''}`);
|
|
2558
|
+
lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
|
|
2559
|
+
lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
|
|
2560
|
+
return lines;
|
|
2561
|
+
})
|
|
2562
|
+
: [chalk.green('No stale dependency clusters.')];
|
|
2563
|
+
|
|
2564
|
+
const policyLines = report.merge_readiness.policy_state?.active
|
|
2565
|
+
? [
|
|
2566
|
+
`${renderChip(report.merge_readiness.policy_state.enforcement.toUpperCase(), report.merge_readiness.policy_state.domains.join(','), report.merge_readiness.policy_state.enforcement === 'blocked' ? chalk.red : chalk.yellow)} ${report.merge_readiness.policy_state.summary}`,
|
|
2567
|
+
` ${chalk.dim('required:')} ${report.merge_readiness.policy_state.required_task_types.join(', ') || 'none'}`,
|
|
2568
|
+
` ${chalk.dim('missing:')} ${report.merge_readiness.policy_state.missing_task_types.join(', ') || 'none'}`,
|
|
2569
|
+
` ${chalk.dim('overridden:')} ${report.merge_readiness.policy_state.overridden_task_types.join(', ') || 'none'}`,
|
|
2570
|
+
...report.merge_readiness.policy_state.requirement_status
|
|
2571
|
+
.filter((requirement) => requirement.evidence.length > 0)
|
|
2572
|
+
.slice(0, 3)
|
|
2573
|
+
.map((requirement) => ` ${chalk.dim(`${requirement.task_type}:`)} ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path})` : entry.task_id).join(', ')}`),
|
|
2574
|
+
...report.merge_readiness.policy_state.overrides
|
|
2575
|
+
.slice(0, 3)
|
|
2576
|
+
.map((entry) => ` ${chalk.dim(`override ${entry.id}:`)} ${(entry.task_types || []).join(', ') || 'all'} by ${entry.approved_by || 'unknown'}`),
|
|
2577
|
+
]
|
|
2578
|
+
: [chalk.green('No explicit change policy requirements are active.')];
|
|
2579
|
+
|
|
972
2580
|
const nextActionLines = [
|
|
973
2581
|
...(report.next_up.length > 0
|
|
974
2582
|
? report.next_up.map((task) => `${renderChip('NEXT', `p${task.priority}`, chalk.green)} ${task.title} ${chalk.dim(task.id)}`)
|
|
@@ -981,6 +2589,8 @@ function renderUnifiedStatusReport(report) {
|
|
|
981
2589
|
renderPanel('Running now', runningLines, chalk.cyan),
|
|
982
2590
|
renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
|
|
983
2591
|
renderPanel('Warnings', warningLines, warningItems.length > 0 ? chalk.yellow : chalk.green),
|
|
2592
|
+
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)),
|
|
2593
|
+
renderPanel('Policy', policyLines, report.merge_readiness.policy_state?.active && report.merge_readiness.policy_state.missing_task_types.length > 0 ? chalk.red : chalk.green),
|
|
984
2594
|
renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
|
|
985
2595
|
renderPanel('Next action', nextActionLines, chalk.green),
|
|
986
2596
|
];
|
|
@@ -1069,10 +2679,12 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
|
1069
2679
|
let db = null;
|
|
1070
2680
|
try {
|
|
1071
2681
|
db = openDb(repoRoot);
|
|
1072
|
-
completeTask(db, taskId);
|
|
1073
|
-
|
|
2682
|
+
const result = completeTask(db, taskId);
|
|
2683
|
+
if (result?.status === 'completed') {
|
|
2684
|
+
releaseFileClaims(db, taskId);
|
|
2685
|
+
}
|
|
1074
2686
|
db.close();
|
|
1075
|
-
return;
|
|
2687
|
+
return result;
|
|
1076
2688
|
} catch (err) {
|
|
1077
2689
|
lastError = err;
|
|
1078
2690
|
try { db?.close(); } catch { /* no-op */ }
|
|
@@ -1085,6 +2697,210 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
|
1085
2697
|
throw lastError;
|
|
1086
2698
|
}
|
|
1087
2699
|
|
|
2700
|
+
function startBackgroundMonitor(repoRoot, { intervalMs = 2000, quarantine = false } = {}) {
|
|
2701
|
+
const existingState = readMonitorState(repoRoot);
|
|
2702
|
+
if (existingState && isProcessRunning(existingState.pid)) {
|
|
2703
|
+
return {
|
|
2704
|
+
already_running: true,
|
|
2705
|
+
state: existingState,
|
|
2706
|
+
state_path: getMonitorStatePath(repoRoot),
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
const logPath = join(repoRoot, '.switchman', 'monitor.log');
|
|
2711
|
+
const child = spawn(process.execPath, [
|
|
2712
|
+
process.argv[1],
|
|
2713
|
+
'monitor',
|
|
2714
|
+
'watch',
|
|
2715
|
+
'--interval-ms',
|
|
2716
|
+
String(intervalMs),
|
|
2717
|
+
...(quarantine ? ['--quarantine'] : []),
|
|
2718
|
+
'--daemonized',
|
|
2719
|
+
], {
|
|
2720
|
+
cwd: repoRoot,
|
|
2721
|
+
detached: true,
|
|
2722
|
+
stdio: 'ignore',
|
|
2723
|
+
});
|
|
2724
|
+
child.unref();
|
|
2725
|
+
|
|
2726
|
+
const statePath = writeMonitorState(repoRoot, {
|
|
2727
|
+
pid: child.pid,
|
|
2728
|
+
interval_ms: intervalMs,
|
|
2729
|
+
quarantine: Boolean(quarantine),
|
|
2730
|
+
log_path: logPath,
|
|
2731
|
+
started_at: new Date().toISOString(),
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
return {
|
|
2735
|
+
already_running: false,
|
|
2736
|
+
state: readMonitorState(repoRoot),
|
|
2737
|
+
state_path: statePath,
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function renderMonitorEvent(event) {
|
|
2742
|
+
const ownerText = event.owner_worktree
|
|
2743
|
+
? `${event.owner_worktree}${event.owner_task_id ? ` (${event.owner_task_id})` : ''}`
|
|
2744
|
+
: null;
|
|
2745
|
+
const claimCommand = event.task_id
|
|
2746
|
+
? `switchman claim ${event.task_id} ${event.worktree} ${event.file_path}`
|
|
2747
|
+
: null;
|
|
2748
|
+
|
|
2749
|
+
if (event.status === 'denied') {
|
|
2750
|
+
console.log(`${chalk.yellow('⚠')} ${chalk.cyan(event.worktree)} modified ${chalk.yellow(event.file_path)} without governed ownership`);
|
|
2751
|
+
if (ownerText) {
|
|
2752
|
+
console.log(` ${chalk.dim('Owned by:')} ${chalk.cyan(ownerText)}${event.owner_task_title ? ` ${chalk.dim(`— ${event.owner_task_title}`)}` : ''}`);
|
|
2753
|
+
}
|
|
2754
|
+
if (claimCommand) {
|
|
2755
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan(claimCommand)}`);
|
|
2756
|
+
}
|
|
2757
|
+
console.log(` ${chalk.dim('Or:')} ${chalk.cyan('switchman status')} ${chalk.dim('to inspect current claims and blockers')}`);
|
|
2758
|
+
if (event.enforcement_action) {
|
|
2759
|
+
console.log(` ${chalk.dim('Action:')} ${event.enforcement_action}`);
|
|
2760
|
+
}
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
const ownerSuffix = ownerText ? ` ${chalk.dim(`(${ownerText})`)}` : '';
|
|
2765
|
+
console.log(`${chalk.green('✓')} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${ownerSuffix}`);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function resolveMonitoredWorktrees(db, repoRoot) {
|
|
2769
|
+
const registeredByPath = new Map(
|
|
2770
|
+
listWorktrees(db)
|
|
2771
|
+
.filter((worktree) => worktree.path)
|
|
2772
|
+
.map((worktree) => [worktree.path, worktree])
|
|
2773
|
+
);
|
|
2774
|
+
|
|
2775
|
+
return listGitWorktrees(repoRoot).map((worktree) => {
|
|
2776
|
+
const registered = registeredByPath.get(worktree.path);
|
|
2777
|
+
if (!registered) return worktree;
|
|
2778
|
+
return {
|
|
2779
|
+
...worktree,
|
|
2780
|
+
name: registered.name,
|
|
2781
|
+
path: registered.path || worktree.path,
|
|
2782
|
+
branch: registered.branch || worktree.branch,
|
|
2783
|
+
};
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
function discoverMergeCandidates(db, repoRoot, { targetBranch = 'main' } = {}) {
|
|
2788
|
+
const worktrees = listWorktrees(db).filter((worktree) => worktree.name !== 'main');
|
|
2789
|
+
const activeLeases = new Set(listLeases(db, 'active').map((lease) => lease.worktree));
|
|
2790
|
+
const tasks = listTasks(db);
|
|
2791
|
+
const queueItems = listMergeQueue(db).filter((item) => item.status !== 'merged');
|
|
2792
|
+
const alreadyQueued = new Set(queueItems.map((item) => item.source_worktree).filter(Boolean));
|
|
2793
|
+
|
|
2794
|
+
const eligible = [];
|
|
2795
|
+
const blocked = [];
|
|
2796
|
+
const skipped = [];
|
|
2797
|
+
|
|
2798
|
+
for (const worktree of worktrees) {
|
|
2799
|
+
const doneTasks = tasks.filter((task) => task.worktree === worktree.name && task.status === 'done');
|
|
2800
|
+
if (doneTasks.length === 0) {
|
|
2801
|
+
skipped.push({
|
|
2802
|
+
worktree: worktree.name,
|
|
2803
|
+
branch: worktree.branch,
|
|
2804
|
+
reason: 'no_completed_tasks',
|
|
2805
|
+
summary: 'no completed tasks are assigned to this worktree yet',
|
|
2806
|
+
command: `switchman task list --status done`,
|
|
2807
|
+
});
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
if (!worktree.branch || worktree.branch === targetBranch) {
|
|
2812
|
+
skipped.push({
|
|
2813
|
+
worktree: worktree.name,
|
|
2814
|
+
branch: worktree.branch || null,
|
|
2815
|
+
reason: 'no_merge_branch',
|
|
2816
|
+
summary: `worktree is on ${targetBranch} already`,
|
|
2817
|
+
command: `switchman worktree list`,
|
|
2818
|
+
});
|
|
2819
|
+
continue;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
if (!gitBranchExists(repoRoot, worktree.branch)) {
|
|
2823
|
+
blocked.push({
|
|
2824
|
+
worktree: worktree.name,
|
|
2825
|
+
branch: worktree.branch,
|
|
2826
|
+
reason: 'missing_branch',
|
|
2827
|
+
summary: `branch ${worktree.branch} is not available in git`,
|
|
2828
|
+
command: `switchman worktree sync`,
|
|
2829
|
+
});
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
if (activeLeases.has(worktree.name)) {
|
|
2834
|
+
blocked.push({
|
|
2835
|
+
worktree: worktree.name,
|
|
2836
|
+
branch: worktree.branch,
|
|
2837
|
+
reason: 'active_lease',
|
|
2838
|
+
summary: 'an active lease is still running in this worktree',
|
|
2839
|
+
command: `switchman status`,
|
|
2840
|
+
});
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
if (alreadyQueued.has(worktree.name)) {
|
|
2845
|
+
skipped.push({
|
|
2846
|
+
worktree: worktree.name,
|
|
2847
|
+
branch: worktree.branch,
|
|
2848
|
+
reason: 'already_queued',
|
|
2849
|
+
summary: 'worktree is already in the landing queue',
|
|
2850
|
+
command: `switchman queue status`,
|
|
2851
|
+
});
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const dirtyFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
|
|
2856
|
+
if (dirtyFiles.length > 0) {
|
|
2857
|
+
blocked.push({
|
|
2858
|
+
worktree: worktree.name,
|
|
2859
|
+
branch: worktree.branch,
|
|
2860
|
+
path: worktree.path,
|
|
2861
|
+
files: dirtyFiles,
|
|
2862
|
+
reason: 'dirty_worktree',
|
|
2863
|
+
summary: `worktree has uncommitted changes: ${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ` +${dirtyFiles.length - 5} more` : ''}`,
|
|
2864
|
+
command: `cd ${JSON.stringify(worktree.path)} && git status`,
|
|
2865
|
+
});
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const freshness = gitAssessBranchFreshness(repoRoot, targetBranch, worktree.branch);
|
|
2870
|
+
eligible.push({
|
|
2871
|
+
worktree: worktree.name,
|
|
2872
|
+
branch: worktree.branch,
|
|
2873
|
+
path: worktree.path,
|
|
2874
|
+
done_task_count: doneTasks.length,
|
|
2875
|
+
done_task_titles: doneTasks.slice(0, 3).map((task) => task.title),
|
|
2876
|
+
freshness,
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
return { eligible, blocked, skipped, queue_items: queueItems };
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function printMergeDiscovery(discovery) {
|
|
2884
|
+
console.log('');
|
|
2885
|
+
console.log(chalk.bold(`Checking ${discovery.eligible.length + discovery.blocked.length + discovery.skipped.length} worktree(s)...`));
|
|
2886
|
+
|
|
2887
|
+
for (const entry of discovery.eligible) {
|
|
2888
|
+
const freshness = entry.freshness?.state && entry.freshness.state !== 'unknown'
|
|
2889
|
+
? ` ${chalk.dim(`(${entry.freshness.state})`)}`
|
|
2890
|
+
: '';
|
|
2891
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch)}${freshness}`);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
for (const entry of discovery.blocked) {
|
|
2895
|
+
console.log(` ${chalk.yellow('!')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
for (const entry of discovery.skipped) {
|
|
2899
|
+
if (entry.reason === 'no_completed_tasks') continue;
|
|
2900
|
+
console.log(` ${chalk.dim('·')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
1088
2904
|
// ─── Program ──────────────────────────────────────────────────────────────────
|
|
1089
2905
|
|
|
1090
2906
|
program
|
|
@@ -1093,22 +2909,131 @@ program
|
|
|
1093
2909
|
.version('0.1.0');
|
|
1094
2910
|
|
|
1095
2911
|
program.showHelpAfterError('(run with --help for usage examples)');
|
|
2912
|
+
const ROOT_HELP_COMMANDS = new Set([
|
|
2913
|
+
'advanced',
|
|
2914
|
+
'demo',
|
|
2915
|
+
'setup',
|
|
2916
|
+
'verify-setup',
|
|
2917
|
+
'login',
|
|
2918
|
+
'upgrade',
|
|
2919
|
+
'plan',
|
|
2920
|
+
'task',
|
|
2921
|
+
'status',
|
|
2922
|
+
'merge',
|
|
2923
|
+
'repair',
|
|
2924
|
+
'help',
|
|
2925
|
+
]);
|
|
2926
|
+
program.configureHelp({
|
|
2927
|
+
visibleCommands(cmd) {
|
|
2928
|
+
const commands = Help.prototype.visibleCommands.call(this, cmd);
|
|
2929
|
+
if (cmd.parent) return commands;
|
|
2930
|
+
return commands.filter((command) => ROOT_HELP_COMMANDS.has(command.name()) && !command._switchmanAdvanced);
|
|
2931
|
+
},
|
|
2932
|
+
});
|
|
1096
2933
|
program.addHelpText('after', `
|
|
1097
2934
|
Start here:
|
|
1098
|
-
switchman
|
|
2935
|
+
switchman demo
|
|
2936
|
+
switchman setup --agents 3
|
|
2937
|
+
switchman task add "Your task" --priority 8
|
|
1099
2938
|
switchman status --watch
|
|
1100
|
-
switchman gate ci
|
|
2939
|
+
switchman gate ci && switchman queue run
|
|
1101
2940
|
|
|
1102
|
-
|
|
1103
|
-
switchman
|
|
1104
|
-
switchman
|
|
1105
|
-
switchman
|
|
2941
|
+
For you (the operator):
|
|
2942
|
+
switchman demo
|
|
2943
|
+
switchman setup
|
|
2944
|
+
switchman task add
|
|
2945
|
+
switchman status
|
|
2946
|
+
switchman merge
|
|
2947
|
+
switchman repair
|
|
2948
|
+
switchman upgrade
|
|
2949
|
+
switchman login
|
|
2950
|
+
switchman plan "Add authentication" (Pro)
|
|
2951
|
+
|
|
2952
|
+
For your agents (via CLAUDE.md or MCP):
|
|
2953
|
+
switchman lease next
|
|
2954
|
+
switchman claim
|
|
2955
|
+
switchman task done
|
|
2956
|
+
switchman write
|
|
2957
|
+
switchman wrap
|
|
1106
2958
|
|
|
1107
2959
|
Docs:
|
|
1108
2960
|
README.md
|
|
1109
|
-
docs/setup-
|
|
2961
|
+
docs/setup-claude-code.md
|
|
2962
|
+
|
|
2963
|
+
Power tools:
|
|
2964
|
+
switchman advanced --help
|
|
1110
2965
|
`);
|
|
1111
2966
|
|
|
2967
|
+
const advancedCmd = program
|
|
2968
|
+
.command('advanced')
|
|
2969
|
+
.description('Show advanced, experimental, and power-user command groups')
|
|
2970
|
+
.addHelpText('after', `
|
|
2971
|
+
Advanced operator commands:
|
|
2972
|
+
switchman pipeline <...>
|
|
2973
|
+
switchman audit <...>
|
|
2974
|
+
switchman policy <...>
|
|
2975
|
+
switchman monitor <...>
|
|
2976
|
+
switchman repair
|
|
2977
|
+
|
|
2978
|
+
Experimental commands:
|
|
2979
|
+
switchman semantic <...>
|
|
2980
|
+
switchman object <...>
|
|
2981
|
+
|
|
2982
|
+
Compatibility aliases:
|
|
2983
|
+
switchman doctor
|
|
2984
|
+
|
|
2985
|
+
Tip:
|
|
2986
|
+
The main help keeps the day-one workflow small on purpose.
|
|
2987
|
+
`)
|
|
2988
|
+
.action(() => {
|
|
2989
|
+
advancedCmd.outputHelp();
|
|
2990
|
+
});
|
|
2991
|
+
advancedCmd._switchmanAdvanced = false;
|
|
2992
|
+
|
|
2993
|
+
program
|
|
2994
|
+
.command('demo')
|
|
2995
|
+
.description('Create a throwaway repo that proves overlapping claims are blocked and safe landing works')
|
|
2996
|
+
.option('--path <dir>', 'Directory to create the demo repo in')
|
|
2997
|
+
.option('--cleanup', 'Delete the demo repo after the run finishes')
|
|
2998
|
+
.option('--json', 'Output raw JSON')
|
|
2999
|
+
.addHelpText('after', `
|
|
3000
|
+
Examples:
|
|
3001
|
+
switchman demo
|
|
3002
|
+
switchman demo --path /tmp/switchman-demo
|
|
3003
|
+
`)
|
|
3004
|
+
.action(async (opts) => {
|
|
3005
|
+
try {
|
|
3006
|
+
const result = await runDemoScenario({
|
|
3007
|
+
repoPath: opts.path || null,
|
|
3008
|
+
cleanup: Boolean(opts.cleanup),
|
|
3009
|
+
});
|
|
3010
|
+
|
|
3011
|
+
if (opts.json) {
|
|
3012
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
console.log(`${chalk.green('✓')} Demo repo ready`);
|
|
3017
|
+
console.log(` ${chalk.dim('path:')} ${result.repo_path}`);
|
|
3018
|
+
console.log(` ${chalk.dim('proof:')} agent2 was blocked from ${chalk.cyan(result.overlap_demo.blocked_path)}`);
|
|
3019
|
+
console.log(` ${chalk.dim('safe reroute:')} agent2 claimed ${chalk.cyan(result.overlap_demo.safe_path)} instead`);
|
|
3020
|
+
console.log(` ${chalk.dim('landing:')} ${result.queue.processed.filter((entry) => entry.status === 'merged').length} queue item(s) merged safely`);
|
|
3021
|
+
console.log(` ${chalk.dim('final gate:')} ${result.final_gate.ok ? chalk.green('clean') : chalk.red('attention needed')}`);
|
|
3022
|
+
console.log('');
|
|
3023
|
+
console.log(chalk.bold('What to do next:'));
|
|
3024
|
+
for (const step of result.next_steps) {
|
|
3025
|
+
console.log(` ${chalk.cyan(step)}`);
|
|
3026
|
+
}
|
|
3027
|
+
if (!opts.cleanup) {
|
|
3028
|
+
console.log('');
|
|
3029
|
+
console.log(chalk.dim('The demo repo stays on disk so you can inspect it, record it, or keep experimenting.'));
|
|
3030
|
+
}
|
|
3031
|
+
} catch (err) {
|
|
3032
|
+
printErrorWithNext(err.message, 'switchman demo --json');
|
|
3033
|
+
process.exitCode = 1;
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
|
|
1112
3037
|
// ── init ──────────────────────────────────────────────────────────────────────
|
|
1113
3038
|
|
|
1114
3039
|
program
|
|
@@ -1127,12 +3052,16 @@ program
|
|
|
1127
3052
|
}
|
|
1128
3053
|
|
|
1129
3054
|
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
3055
|
+
const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
|
|
1130
3056
|
|
|
1131
3057
|
db.close();
|
|
1132
3058
|
spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
|
|
1133
3059
|
console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
|
|
1134
3060
|
console.log(chalk.dim(` Database: .switchman/switchman.db`));
|
|
1135
3061
|
console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
|
|
3062
|
+
if (mcpExclude.managed) {
|
|
3063
|
+
console.log(chalk.dim(` MCP excludes: ${mcpExclude.changed ? 'updated' : 'already set'} in .git/info/exclude`));
|
|
3064
|
+
}
|
|
1136
3065
|
console.log('');
|
|
1137
3066
|
console.log(`Next steps:`);
|
|
1138
3067
|
console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
|
|
@@ -1152,6 +3081,8 @@ program
|
|
|
1152
3081
|
.description('One-command setup: create agent workspaces and initialise Switchman')
|
|
1153
3082
|
.option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
|
|
1154
3083
|
.option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
|
|
3084
|
+
.option('--no-monitor', 'Do not start the background rogue-edit monitor')
|
|
3085
|
+
.option('--monitor-interval-ms <ms>', 'Polling interval for the background monitor', '2000')
|
|
1155
3086
|
.addHelpText('after', `
|
|
1156
3087
|
Examples:
|
|
1157
3088
|
switchman setup --agents 5
|
|
@@ -1165,6 +3096,24 @@ Examples:
|
|
|
1165
3096
|
process.exit(1);
|
|
1166
3097
|
}
|
|
1167
3098
|
|
|
3099
|
+
if (agentCount > FREE_AGENT_LIMIT) {
|
|
3100
|
+
const licence = await checkLicence();
|
|
3101
|
+
if (!licence.valid) {
|
|
3102
|
+
console.log('');
|
|
3103
|
+
console.log(chalk.yellow(` ⚠ Free tier supports up to ${FREE_AGENT_LIMIT} agents.`));
|
|
3104
|
+
console.log('');
|
|
3105
|
+
console.log(` You requested ${chalk.cyan(agentCount)} agents — that requires ${chalk.bold('Switchman Pro')}.`);
|
|
3106
|
+
console.log('');
|
|
3107
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
3108
|
+
console.log(` ${chalk.dim('Or run: ')} ${chalk.cyan('switchman upgrade')}`);
|
|
3109
|
+
console.log('');
|
|
3110
|
+
process.exit(1);
|
|
3111
|
+
}
|
|
3112
|
+
if (licence.offline) {
|
|
3113
|
+
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)`));
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
1168
3117
|
const repoRoot = getRepo();
|
|
1169
3118
|
const spinner = ora('Setting up Switchman...').start();
|
|
1170
3119
|
|
|
@@ -1210,6 +3159,12 @@ Examples:
|
|
|
1210
3159
|
}
|
|
1211
3160
|
|
|
1212
3161
|
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
|
|
3162
|
+
const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
|
|
3163
|
+
|
|
3164
|
+
const monitorIntervalMs = Math.max(100, Number.parseInt(opts.monitorIntervalMs, 10) || 2000);
|
|
3165
|
+
const monitorState = opts.monitor
|
|
3166
|
+
? startBackgroundMonitor(repoRoot, { intervalMs: monitorIntervalMs, quarantine: false })
|
|
3167
|
+
: null;
|
|
1213
3168
|
|
|
1214
3169
|
db.close();
|
|
1215
3170
|
|
|
@@ -1229,14 +3184,35 @@ Examples:
|
|
|
1229
3184
|
const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
|
|
1230
3185
|
console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
|
|
1231
3186
|
}
|
|
3187
|
+
if (mcpExclude.managed) {
|
|
3188
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(mcpExclude.path)} ${chalk.dim(`(${mcpExclude.changed ? 'updated' : 'unchanged'})`)}`);
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
if (opts.monitor) {
|
|
3192
|
+
console.log('');
|
|
3193
|
+
console.log(chalk.bold('Monitor:'));
|
|
3194
|
+
if (monitorState?.already_running) {
|
|
3195
|
+
console.log(` ${chalk.green('✓')} Background rogue-edit monitor already running ${chalk.dim(`(pid ${monitorState.state.pid})`)}`);
|
|
3196
|
+
} else {
|
|
3197
|
+
console.log(` ${chalk.green('✓')} Started background rogue-edit monitor ${chalk.dim(`(pid ${monitorState?.state?.pid ?? 'unknown'})`)}`);
|
|
3198
|
+
}
|
|
3199
|
+
console.log(` ${chalk.dim('interval:')} ${monitorIntervalMs}ms`);
|
|
3200
|
+
}
|
|
1232
3201
|
|
|
1233
3202
|
console.log('');
|
|
1234
3203
|
console.log(chalk.bold('Next steps:'));
|
|
1235
|
-
console.log(` 1. Add
|
|
3204
|
+
console.log(` 1. Add a first task:`);
|
|
1236
3205
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
1237
|
-
console.log(` 2. Open Claude Code or Cursor in
|
|
1238
|
-
console.log(` 3.
|
|
1239
|
-
console.log(` ${chalk.cyan('switchman status')}`);
|
|
3206
|
+
console.log(` 2. Open Claude Code or Cursor in the workspaces above — the local MCP config will attach Switchman automatically`);
|
|
3207
|
+
console.log(` 3. Keep the repo dashboard open while work starts:`);
|
|
3208
|
+
console.log(` ${chalk.cyan('switchman status --watch')}`);
|
|
3209
|
+
console.log(` 4. Run the final check and land finished work:`);
|
|
3210
|
+
console.log(` ${chalk.cyan('switchman gate ci')}`);
|
|
3211
|
+
console.log(` ${chalk.cyan('switchman queue run')}`);
|
|
3212
|
+
if (opts.monitor) {
|
|
3213
|
+
console.log(` 5. Watch for rogue edits or direct writes in real time:`);
|
|
3214
|
+
console.log(` ${chalk.cyan('switchman monitor status')}`);
|
|
3215
|
+
}
|
|
1240
3216
|
console.log('');
|
|
1241
3217
|
|
|
1242
3218
|
const verification = collectSetupVerification(repoRoot);
|
|
@@ -1419,6 +3395,132 @@ Examples:
|
|
|
1419
3395
|
});
|
|
1420
3396
|
|
|
1421
3397
|
|
|
3398
|
+
// ── plan ──────────────────────────────────────────────────────────────────────
|
|
3399
|
+
|
|
3400
|
+
program
|
|
3401
|
+
.command('plan [goal]')
|
|
3402
|
+
.description('Pro: suggest a parallel task plan from an explicit goal')
|
|
3403
|
+
.option('--apply', 'Create the suggested tasks in Switchman')
|
|
3404
|
+
.option('--max-tasks <n>', 'Maximum number of suggested tasks', '6')
|
|
3405
|
+
.option('--json', 'Output raw JSON')
|
|
3406
|
+
.addHelpText('after', `
|
|
3407
|
+
Examples:
|
|
3408
|
+
switchman plan "Add authentication"
|
|
3409
|
+
switchman plan "Add authentication" --apply
|
|
3410
|
+
`)
|
|
3411
|
+
.action(async (goal, opts) => {
|
|
3412
|
+
const repoRoot = getRepo();
|
|
3413
|
+
const db = getOptionalDb(repoRoot);
|
|
3414
|
+
|
|
3415
|
+
try {
|
|
3416
|
+
const licence = await checkLicence();
|
|
3417
|
+
if (!licence.valid) {
|
|
3418
|
+
console.log('');
|
|
3419
|
+
console.log(chalk.yellow(' ⚠ AI planning requires Switchman Pro.'));
|
|
3420
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
3421
|
+
console.log(` ${chalk.dim('Or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
3422
|
+
console.log('');
|
|
3423
|
+
process.exitCode = 1;
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
if (!goal || !goal.trim()) {
|
|
3428
|
+
console.log('');
|
|
3429
|
+
console.log(chalk.yellow(' ⚠ AI planning currently requires an explicit goal.'));
|
|
3430
|
+
console.log(` ${chalk.dim('Try:')} ${chalk.cyan('switchman plan "Add authentication"')}`);
|
|
3431
|
+
console.log(` ${chalk.dim('Then:')} ${chalk.cyan('switchman plan "Add authentication" --apply')}`);
|
|
3432
|
+
console.log('');
|
|
3433
|
+
process.exitCode = 1;
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
const context = collectPlanContext(repoRoot, goal || null);
|
|
3438
|
+
const planningWorktrees = resolvePlanningWorktrees(repoRoot, db);
|
|
3439
|
+
const pipelineId = `plan-${slugifyValue(context.title)}-${Date.now().toString(36)}`;
|
|
3440
|
+
const plannedTasks = planPipelineTasks({
|
|
3441
|
+
pipelineId,
|
|
3442
|
+
title: context.title,
|
|
3443
|
+
description: context.description,
|
|
3444
|
+
worktrees: planningWorktrees,
|
|
3445
|
+
maxTasks: Math.max(1, parseInt(opts.maxTasks, 10) || 6),
|
|
3446
|
+
repoRoot,
|
|
3447
|
+
});
|
|
3448
|
+
|
|
3449
|
+
if (opts.json) {
|
|
3450
|
+
const payload = {
|
|
3451
|
+
title: context.title,
|
|
3452
|
+
context: {
|
|
3453
|
+
found: context.found,
|
|
3454
|
+
used: context.used,
|
|
3455
|
+
branch: context.branch,
|
|
3456
|
+
},
|
|
3457
|
+
planned_tasks: plannedTasks.map((task) => ({
|
|
3458
|
+
id: task.id,
|
|
3459
|
+
title: task.title,
|
|
3460
|
+
suggested_worktree: task.suggested_worktree || null,
|
|
3461
|
+
task_type: task.task_spec?.task_type || null,
|
|
3462
|
+
dependencies: task.dependencies || [],
|
|
3463
|
+
})),
|
|
3464
|
+
apply_ready: Boolean(db),
|
|
3465
|
+
};
|
|
3466
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
console.log(chalk.bold('Reading repo context...'));
|
|
3471
|
+
if (context.found.length > 0) {
|
|
3472
|
+
console.log(`${chalk.dim('Found:')} ${context.found.join(', ')}`);
|
|
3473
|
+
} else {
|
|
3474
|
+
console.log(chalk.dim('Found: local repo context only'));
|
|
3475
|
+
}
|
|
3476
|
+
console.log('');
|
|
3477
|
+
console.log(`${chalk.bold('Suggested plan based on:')} ${context.used.length > 0 ? formatHumanList(context.used) : 'available repo context'}`);
|
|
3478
|
+
console.log('');
|
|
3479
|
+
|
|
3480
|
+
plannedTasks.forEach((task, index) => {
|
|
3481
|
+
const worktreeLabel = task.suggested_worktree ? chalk.cyan(task.suggested_worktree) : chalk.dim('unassigned');
|
|
3482
|
+
console.log(` ${chalk.green('✓')} ${chalk.bold(`${index + 1}.`)} ${task.title} ${chalk.dim('→')} ${worktreeLabel}`);
|
|
3483
|
+
});
|
|
3484
|
+
|
|
3485
|
+
if (!opts.apply) {
|
|
3486
|
+
console.log('');
|
|
3487
|
+
if (!db) {
|
|
3488
|
+
console.log(chalk.dim('Preview only — run `switchman setup --agents 3` first if you want Switchman to create and track these tasks.'));
|
|
3489
|
+
} else {
|
|
3490
|
+
console.log(chalk.dim('Preview only — rerun with `switchman plan --apply` to create these tasks.'));
|
|
3491
|
+
}
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
if (!db) {
|
|
3496
|
+
console.log('');
|
|
3497
|
+
console.log(`${chalk.red('✗')} Switchman is not set up in this repo yet.`);
|
|
3498
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman setup --agents 3')}`);
|
|
3499
|
+
process.exitCode = 1;
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
console.log('');
|
|
3504
|
+
for (const task of plannedTasks) {
|
|
3505
|
+
createTask(db, {
|
|
3506
|
+
id: task.id,
|
|
3507
|
+
title: task.title,
|
|
3508
|
+
description: `Planned from: ${context.title}`,
|
|
3509
|
+
priority: planTaskPriority(task.task_spec),
|
|
3510
|
+
});
|
|
3511
|
+
upsertTaskSpec(db, task.id, task.task_spec);
|
|
3512
|
+
console.log(` ${chalk.green('✓')} Created ${chalk.cyan(task.id)} ${chalk.dim(task.title)}`);
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
console.log('');
|
|
3516
|
+
console.log(`${chalk.green('✓')} Planned ${plannedTasks.length} task(s) from repo context.`);
|
|
3517
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman status --watch')}`);
|
|
3518
|
+
} finally {
|
|
3519
|
+
db?.close();
|
|
3520
|
+
}
|
|
3521
|
+
});
|
|
3522
|
+
|
|
3523
|
+
|
|
1422
3524
|
// ── task ──────────────────────────────────────────────────────────────────────
|
|
1423
3525
|
|
|
1424
3526
|
const taskCmd = program.command('task').description('Manage the task list');
|
|
@@ -1447,6 +3549,7 @@ taskCmd
|
|
|
1447
3549
|
db.close();
|
|
1448
3550
|
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
1449
3551
|
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
3552
|
+
pushSyncEvent('task_added', { task_id: taskId, title, priority: parseInt(opts.priority) }).catch(() => {});
|
|
1450
3553
|
console.log(` ${chalk.dim(title)}`);
|
|
1451
3554
|
if (scopeWarning) {
|
|
1452
3555
|
console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
|
|
@@ -1497,14 +3600,99 @@ taskCmd
|
|
|
1497
3600
|
}
|
|
1498
3601
|
});
|
|
1499
3602
|
|
|
3603
|
+
taskCmd
|
|
3604
|
+
.command('retry <taskId>')
|
|
3605
|
+
.description('Return a failed or stale completed task to pending so it can be revalidated')
|
|
3606
|
+
.option('--reason <text>', 'Reason to record for the retry')
|
|
3607
|
+
.option('--json', 'Output raw JSON')
|
|
3608
|
+
.action((taskId, opts) => {
|
|
3609
|
+
const repoRoot = getRepo();
|
|
3610
|
+
const db = getDb(repoRoot);
|
|
3611
|
+
const task = retryTask(db, taskId, opts.reason || 'manual retry');
|
|
3612
|
+
db.close();
|
|
3613
|
+
|
|
3614
|
+
if (!task) {
|
|
3615
|
+
printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
|
|
3616
|
+
process.exitCode = 1;
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
if (opts.json) {
|
|
3621
|
+
console.log(JSON.stringify(task, null, 2));
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
|
|
3626
|
+
pushSyncEvent('task_retried', { task_id: task.id, title: task.title, reason: opts.reason || 'manual retry' }).catch(() => {});
|
|
3627
|
+
console.log(` ${chalk.dim('title:')} ${task.title}`);
|
|
3628
|
+
console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3631
|
+
taskCmd
|
|
3632
|
+
.command('retry-stale')
|
|
3633
|
+
.description('Return all currently stale tasks to pending so they can be revalidated together')
|
|
3634
|
+
.option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
|
|
3635
|
+
.option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
|
|
3636
|
+
.option('--json', 'Output raw JSON')
|
|
3637
|
+
.action((opts) => {
|
|
3638
|
+
const repoRoot = getRepo();
|
|
3639
|
+
const db = getDb(repoRoot);
|
|
3640
|
+
const result = retryStaleTasks(db, {
|
|
3641
|
+
pipelineId: opts.pipeline || null,
|
|
3642
|
+
reason: opts.reason,
|
|
3643
|
+
});
|
|
3644
|
+
db.close();
|
|
3645
|
+
|
|
3646
|
+
if (opts.json) {
|
|
3647
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
if (result.retried.length === 0) {
|
|
3652
|
+
const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
|
|
3653
|
+
console.log(chalk.dim(`No stale tasks to retry${scope}.`));
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
|
|
3658
|
+
pushSyncEvent('task_retried', {
|
|
3659
|
+
pipeline_id: result.pipeline_id || null,
|
|
3660
|
+
task_count: result.retried.length,
|
|
3661
|
+
task_ids: result.retried.map((task) => task.id),
|
|
3662
|
+
reason: opts.reason,
|
|
3663
|
+
}).catch(() => {});
|
|
3664
|
+
if (result.pipeline_id) {
|
|
3665
|
+
console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
|
|
3666
|
+
}
|
|
3667
|
+
console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
|
|
3668
|
+
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
3669
|
+
});
|
|
3670
|
+
|
|
1500
3671
|
taskCmd
|
|
1501
3672
|
.command('done <taskId>')
|
|
1502
3673
|
.description('Mark a task as complete and release all file claims')
|
|
1503
3674
|
.action((taskId) => {
|
|
1504
3675
|
const repoRoot = getRepo();
|
|
1505
3676
|
try {
|
|
1506
|
-
completeTaskWithRetries(repoRoot, taskId);
|
|
3677
|
+
const result = completeTaskWithRetries(repoRoot, taskId);
|
|
3678
|
+
if (result?.status === 'already_done') {
|
|
3679
|
+
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} was already marked done — no new changes were recorded`);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
if (result?.status === 'failed') {
|
|
3683
|
+
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is currently failed — retry it before marking it done again`);
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
if (result?.status === 'not_in_progress') {
|
|
3687
|
+
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is not currently in progress — start a lease before marking it done`);
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
if (result?.status === 'no_active_lease') {
|
|
3691
|
+
console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} has no active lease — reacquire the task before marking it done`);
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
1507
3694
|
console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
|
|
3695
|
+
pushSyncEvent('task_done', { task_id: taskId }).catch(() => {});
|
|
1508
3696
|
} catch (err) {
|
|
1509
3697
|
console.error(chalk.red(err.message));
|
|
1510
3698
|
process.exitCode = 1;
|
|
@@ -1521,6 +3709,7 @@ taskCmd
|
|
|
1521
3709
|
releaseFileClaims(db, taskId);
|
|
1522
3710
|
db.close();
|
|
1523
3711
|
console.log(`${chalk.red('✗')} Task ${chalk.cyan(taskId)} marked failed`);
|
|
3712
|
+
pushSyncEvent('task_failed', { task_id: taskId, reason: reason || null }).catch(() => {});
|
|
1524
3713
|
});
|
|
1525
3714
|
|
|
1526
3715
|
taskCmd
|
|
@@ -1584,8 +3773,13 @@ Examples:
|
|
|
1584
3773
|
switchman queue add feature/auth-hardening
|
|
1585
3774
|
switchman queue add --worktree agent2
|
|
1586
3775
|
switchman queue add --pipeline pipe-123
|
|
3776
|
+
|
|
3777
|
+
Pipeline landing rule:
|
|
3778
|
+
switchman queue add --pipeline <id>
|
|
3779
|
+
lands the pipeline's inferred landing branch.
|
|
3780
|
+
If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
|
|
1587
3781
|
`)
|
|
1588
|
-
.action((branch, opts) => {
|
|
3782
|
+
.action(async (branch, opts) => {
|
|
1589
3783
|
const repoRoot = getRepo();
|
|
1590
3784
|
const db = getDb(repoRoot);
|
|
1591
3785
|
|
|
@@ -1605,13 +3799,29 @@ Examples:
|
|
|
1605
3799
|
submittedBy: opts.submittedBy || null,
|
|
1606
3800
|
};
|
|
1607
3801
|
} else if (opts.pipeline) {
|
|
3802
|
+
const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
|
|
3803
|
+
if (!policyGate.ok) {
|
|
3804
|
+
throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
|
|
3805
|
+
}
|
|
3806
|
+
const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
|
|
3807
|
+
baseBranch: opts.target || 'main',
|
|
3808
|
+
requireCompleted: true,
|
|
3809
|
+
allowCurrentBranchFallback: false,
|
|
3810
|
+
});
|
|
1608
3811
|
payload = {
|
|
1609
3812
|
sourceType: 'pipeline',
|
|
1610
|
-
sourceRef:
|
|
3813
|
+
sourceRef: landingTarget.branch,
|
|
1611
3814
|
sourcePipelineId: opts.pipeline,
|
|
3815
|
+
sourceWorktree: landingTarget.worktree || null,
|
|
1612
3816
|
targetBranch: opts.target,
|
|
1613
3817
|
maxRetries: opts.maxRetries,
|
|
1614
3818
|
submittedBy: opts.submittedBy || null,
|
|
3819
|
+
eventDetails: policyGate.override_applied
|
|
3820
|
+
? {
|
|
3821
|
+
policy_override_summary: policyGate.override_summary,
|
|
3822
|
+
overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
|
|
3823
|
+
}
|
|
3824
|
+
: null,
|
|
1615
3825
|
};
|
|
1616
3826
|
} else if (branch) {
|
|
1617
3827
|
payload = {
|
|
@@ -1627,6 +3837,13 @@ Examples:
|
|
|
1627
3837
|
|
|
1628
3838
|
const result = enqueueMergeItem(db, payload);
|
|
1629
3839
|
db.close();
|
|
3840
|
+
pushSyncEvent('queue_added', {
|
|
3841
|
+
item_id: result.id,
|
|
3842
|
+
source_type: result.source_type,
|
|
3843
|
+
source_ref: result.source_ref,
|
|
3844
|
+
source_worktree: result.source_worktree || null,
|
|
3845
|
+
target_branch: result.target_branch,
|
|
3846
|
+
}, { worktree: result.source_worktree || null }).catch(() => {});
|
|
1630
3847
|
|
|
1631
3848
|
if (opts.json) {
|
|
1632
3849
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -1636,6 +3853,9 @@ Examples:
|
|
|
1636
3853
|
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
1637
3854
|
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
1638
3855
|
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
3856
|
+
if (payload.eventDetails?.policy_override_summary) {
|
|
3857
|
+
console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
|
|
3858
|
+
}
|
|
1639
3859
|
} catch (err) {
|
|
1640
3860
|
db.close();
|
|
1641
3861
|
printErrorWithNext(err.message, 'switchman queue add --help');
|
|
@@ -1667,7 +3887,9 @@ queueCmd
|
|
|
1667
3887
|
for (const item of items) {
|
|
1668
3888
|
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
1669
3889
|
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
1670
|
-
|
|
3890
|
+
const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
|
|
3891
|
+
const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
|
|
3892
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
|
|
1671
3893
|
if (item.last_error_summary) {
|
|
1672
3894
|
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1673
3895
|
}
|
|
@@ -1698,7 +3920,7 @@ What it helps you answer:
|
|
|
1698
3920
|
const repoRoot = getRepo();
|
|
1699
3921
|
const db = getDb(repoRoot);
|
|
1700
3922
|
const items = listMergeQueue(db);
|
|
1701
|
-
const summary = buildQueueStatusSummary(items);
|
|
3923
|
+
const summary = buildQueueStatusSummary(items, { db, repoRoot });
|
|
1702
3924
|
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
1703
3925
|
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
1704
3926
|
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
@@ -1709,9 +3931,23 @@ What it helps you answer:
|
|
|
1709
3931
|
return;
|
|
1710
3932
|
}
|
|
1711
3933
|
|
|
1712
|
-
|
|
3934
|
+
if (items.length === 0) {
|
|
3935
|
+
console.log('');
|
|
3936
|
+
console.log(chalk.bold('switchman queue status'));
|
|
3937
|
+
console.log('');
|
|
3938
|
+
console.log('Queue is empty.');
|
|
3939
|
+
console.log(`Add finished work with: ${chalk.cyan('switchman queue add --worktree agent1')}`);
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
const queueHealth = summary.counts.blocked > 0
|
|
3944
|
+
? 'block'
|
|
3945
|
+
: summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
|
|
3946
|
+
? 'warn'
|
|
3947
|
+
: 'healthy';
|
|
1713
3948
|
const queueHealthColor = colorForHealth(queueHealth);
|
|
1714
|
-
const
|
|
3949
|
+
const retryingItems = items.filter((item) => item.status === 'retrying');
|
|
3950
|
+
const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
|
|
1715
3951
|
const focusLine = focus
|
|
1716
3952
|
? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
|
|
1717
3953
|
: 'Nothing waiting. Landing queue is clear.';
|
|
@@ -1723,6 +3959,9 @@ What it helps you answer:
|
|
|
1723
3959
|
console.log(renderSignalStrip([
|
|
1724
3960
|
renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
|
|
1725
3961
|
renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
|
|
3962
|
+
renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
|
|
3963
|
+
renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
|
|
3964
|
+
renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
|
|
1726
3965
|
renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1727
3966
|
renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
|
|
1728
3967
|
renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
|
|
@@ -1737,11 +3976,23 @@ What it helps you answer:
|
|
|
1737
3976
|
|
|
1738
3977
|
const queueFocusLines = summary.next
|
|
1739
3978
|
? [
|
|
1740
|
-
`${renderChip('NEXT', summary.next.id, chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}`,
|
|
1741
|
-
` ${chalk.
|
|
3979
|
+
`${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
|
|
3980
|
+
...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
|
|
3981
|
+
...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
|
|
3982
|
+
` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
|
|
1742
3983
|
]
|
|
1743
3984
|
: [chalk.dim('No queued landing work right now.')];
|
|
1744
3985
|
|
|
3986
|
+
const queueHeldBackLines = summary.held_back.length > 0
|
|
3987
|
+
? summary.held_back.flatMap((item) => {
|
|
3988
|
+
const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
|
|
3989
|
+
if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
|
|
3990
|
+
if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
|
|
3991
|
+
if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
|
|
3992
|
+
return lines;
|
|
3993
|
+
})
|
|
3994
|
+
: [chalk.green('Nothing significant is being held back.')];
|
|
3995
|
+
|
|
1745
3996
|
const queueBlockedLines = summary.blocked.length > 0
|
|
1746
3997
|
? summary.blocked.slice(0, 4).flatMap((item) => {
|
|
1747
3998
|
const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
@@ -1751,13 +4002,14 @@ What it helps you answer:
|
|
|
1751
4002
|
})
|
|
1752
4003
|
: [chalk.green('Nothing blocked.')];
|
|
1753
4004
|
|
|
1754
|
-
const queueWatchLines = items.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
|
|
4005
|
+
const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
|
|
1755
4006
|
? items
|
|
1756
|
-
.filter((item) => ['retrying', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
4007
|
+
.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
|
|
1757
4008
|
.slice(0, 4)
|
|
1758
4009
|
.flatMap((item) => {
|
|
1759
|
-
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' ? chalk.yellow : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
4010
|
+
const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
|
|
1760
4011
|
if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
|
|
4012
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1761
4013
|
return lines;
|
|
1762
4014
|
})
|
|
1763
4015
|
: [chalk.green('No in-flight queue items right now.')];
|
|
@@ -1768,9 +4020,23 @@ What it helps you answer:
|
|
|
1768
4020
|
...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
|
|
1769
4021
|
];
|
|
1770
4022
|
|
|
4023
|
+
const queuePlanLines = [
|
|
4024
|
+
...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4025
|
+
...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4026
|
+
...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4027
|
+
...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4028
|
+
...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
|
|
4029
|
+
];
|
|
4030
|
+
const queueSequenceLines = summary.recommended_sequence?.length > 0
|
|
4031
|
+
? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
|
|
4032
|
+
: [chalk.green('No recommended sequence beyond the current landing focus.')];
|
|
4033
|
+
|
|
1771
4034
|
console.log('');
|
|
1772
4035
|
for (const block of [
|
|
1773
4036
|
renderPanel('Landing focus', queueFocusLines, chalk.green),
|
|
4037
|
+
renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
|
|
4038
|
+
renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
|
|
4039
|
+
renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
|
|
1774
4040
|
renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
|
|
1775
4041
|
renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
|
|
1776
4042
|
renderPanel('Next commands', queueCommandLines, chalk.cyan),
|
|
@@ -1791,6 +4057,8 @@ queueCmd
|
|
|
1791
4057
|
.command('run')
|
|
1792
4058
|
.description('Process landing-queue items one at a time')
|
|
1793
4059
|
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
4060
|
+
.option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
|
|
4061
|
+
.option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
|
|
1794
4062
|
.option('--target <branch>', 'Default target branch', 'main')
|
|
1795
4063
|
.option('--watch', 'Keep polling for new queue items')
|
|
1796
4064
|
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
@@ -1799,6 +4067,7 @@ queueCmd
|
|
|
1799
4067
|
.addHelpText('after', `
|
|
1800
4068
|
Examples:
|
|
1801
4069
|
switchman queue run
|
|
4070
|
+
switchman queue run --follow-plan --merge-budget 2
|
|
1802
4071
|
switchman queue run --watch
|
|
1803
4072
|
switchman queue run --watch --watch-interval-ms 1000
|
|
1804
4073
|
`)
|
|
@@ -1807,12 +4076,21 @@ Examples:
|
|
|
1807
4076
|
|
|
1808
4077
|
try {
|
|
1809
4078
|
const watch = Boolean(opts.watch);
|
|
4079
|
+
const followPlan = Boolean(opts.followPlan);
|
|
1810
4080
|
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
1811
4081
|
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
4082
|
+
const mergeBudget = opts.mergeBudget !== undefined
|
|
4083
|
+
? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
|
|
4084
|
+
: null;
|
|
1812
4085
|
const aggregate = {
|
|
1813
4086
|
processed: [],
|
|
1814
4087
|
cycles: 0,
|
|
1815
4088
|
watch,
|
|
4089
|
+
execution_policy: {
|
|
4090
|
+
follow_plan: followPlan,
|
|
4091
|
+
merge_budget: mergeBudget,
|
|
4092
|
+
merged_count: 0,
|
|
4093
|
+
},
|
|
1816
4094
|
};
|
|
1817
4095
|
|
|
1818
4096
|
while (true) {
|
|
@@ -1820,15 +4098,20 @@ Examples:
|
|
|
1820
4098
|
const result = await runMergeQueue(db, repoRoot, {
|
|
1821
4099
|
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
1822
4100
|
targetBranch: opts.target || 'main',
|
|
4101
|
+
followPlan,
|
|
4102
|
+
mergeBudget,
|
|
1823
4103
|
});
|
|
1824
4104
|
db.close();
|
|
1825
4105
|
|
|
1826
4106
|
aggregate.processed.push(...result.processed);
|
|
1827
4107
|
aggregate.summary = result.summary;
|
|
4108
|
+
aggregate.deferred = result.deferred || aggregate.deferred || null;
|
|
4109
|
+
aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
|
|
1828
4110
|
aggregate.cycles += 1;
|
|
1829
4111
|
|
|
1830
4112
|
if (!watch) break;
|
|
1831
4113
|
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
4114
|
+
if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
|
|
1832
4115
|
if (result.processed.length === 0) {
|
|
1833
4116
|
sleepSync(watchIntervalMs);
|
|
1834
4117
|
}
|
|
@@ -1840,7 +4123,22 @@ Examples:
|
|
|
1840
4123
|
}
|
|
1841
4124
|
|
|
1842
4125
|
if (aggregate.processed.length === 0) {
|
|
1843
|
-
|
|
4126
|
+
const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
|
|
4127
|
+
if (deferredFocus?.recommendation?.action) {
|
|
4128
|
+
console.log(chalk.yellow('No landing candidate is ready to run right now.'));
|
|
4129
|
+
console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
|
|
4130
|
+
if (followPlan) {
|
|
4131
|
+
console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
|
|
4132
|
+
}
|
|
4133
|
+
if (deferredFocus.recommendation?.summary) {
|
|
4134
|
+
console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
|
|
4135
|
+
}
|
|
4136
|
+
if (deferredFocus.recommendation?.command) {
|
|
4137
|
+
console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
|
|
4138
|
+
}
|
|
4139
|
+
} else {
|
|
4140
|
+
console.log(chalk.dim('No queued merge items.'));
|
|
4141
|
+
}
|
|
1844
4142
|
await maybeCaptureTelemetry('queue_used', {
|
|
1845
4143
|
watch,
|
|
1846
4144
|
cycles: aggregate.cycles,
|
|
@@ -1854,15 +4152,36 @@ Examples:
|
|
|
1854
4152
|
for (const entry of aggregate.processed) {
|
|
1855
4153
|
const item = entry.item;
|
|
1856
4154
|
if (entry.status === 'merged') {
|
|
4155
|
+
pushSyncEvent('queue_merged', {
|
|
4156
|
+
item_id: item.id,
|
|
4157
|
+
source_type: item.source_type,
|
|
4158
|
+
source_ref: item.source_ref,
|
|
4159
|
+
source_worktree: item.source_worktree || null,
|
|
4160
|
+
target_branch: item.target_branch,
|
|
4161
|
+
merged_commit: item.merged_commit || null,
|
|
4162
|
+
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
1857
4163
|
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
1858
4164
|
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
1859
4165
|
} else {
|
|
4166
|
+
pushSyncEvent('queue_blocked', {
|
|
4167
|
+
item_id: item.id,
|
|
4168
|
+
source_type: item.source_type,
|
|
4169
|
+
source_ref: item.source_ref,
|
|
4170
|
+
source_worktree: item.source_worktree || null,
|
|
4171
|
+
target_branch: item.target_branch,
|
|
4172
|
+
error_code: item.last_error_code || null,
|
|
4173
|
+
error_summary: item.last_error_summary || null,
|
|
4174
|
+
}, { worktree: item.source_worktree || null }).catch(() => {});
|
|
1860
4175
|
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
1861
4176
|
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1862
4177
|
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1863
4178
|
}
|
|
1864
4179
|
}
|
|
1865
4180
|
|
|
4181
|
+
if (aggregate.execution_policy.follow_plan) {
|
|
4182
|
+
console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
|
|
4183
|
+
}
|
|
4184
|
+
|
|
1866
4185
|
await maybeCaptureTelemetry('queue_used', {
|
|
1867
4186
|
watch,
|
|
1868
4187
|
cycles: aggregate.cycles,
|
|
@@ -1876,6 +4195,142 @@ Examples:
|
|
|
1876
4195
|
}
|
|
1877
4196
|
});
|
|
1878
4197
|
|
|
4198
|
+
program
|
|
4199
|
+
.command('merge')
|
|
4200
|
+
.description('Queue finished worktrees and land safe work through one guided front door')
|
|
4201
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
4202
|
+
.option('--dry-run', 'Preview mergeable work without queueing or landing anything')
|
|
4203
|
+
.option('--json', 'Output raw JSON')
|
|
4204
|
+
.addHelpText('after', `
|
|
4205
|
+
Examples:
|
|
4206
|
+
switchman merge
|
|
4207
|
+
switchman merge --dry-run
|
|
4208
|
+
switchman merge --target release
|
|
4209
|
+
`)
|
|
4210
|
+
.action(async (opts) => {
|
|
4211
|
+
const repoRoot = getRepo();
|
|
4212
|
+
const db = getDb(repoRoot);
|
|
4213
|
+
|
|
4214
|
+
try {
|
|
4215
|
+
const discovery = discoverMergeCandidates(db, repoRoot, { targetBranch: opts.target || 'main' });
|
|
4216
|
+
const queued = [];
|
|
4217
|
+
|
|
4218
|
+
for (const entry of discovery.eligible) {
|
|
4219
|
+
queued.push(enqueueMergeItem(db, {
|
|
4220
|
+
sourceType: 'worktree',
|
|
4221
|
+
sourceRef: entry.branch,
|
|
4222
|
+
sourceWorktree: entry.worktree,
|
|
4223
|
+
targetBranch: opts.target || 'main',
|
|
4224
|
+
submittedBy: 'switchman merge',
|
|
4225
|
+
}));
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
const queueItems = listMergeQueue(db);
|
|
4229
|
+
const summary = buildQueueStatusSummary(queueItems, { db, repoRoot });
|
|
4230
|
+
const mergeOrder = summary.recommended_sequence
|
|
4231
|
+
.filter((item) => ['land_now', 'prepare_next'].includes(item.lane))
|
|
4232
|
+
.map((item) => item.source_ref);
|
|
4233
|
+
|
|
4234
|
+
if (opts.json) {
|
|
4235
|
+
if (opts.dryRun) {
|
|
4236
|
+
console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, dry_run: true }, null, 2));
|
|
4237
|
+
db.close();
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
const gate = await evaluateQueueRepoGate(db, repoRoot);
|
|
4242
|
+
const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
|
|
4243
|
+
const result = gate.ok
|
|
4244
|
+
? await runMergeQueue(db, repoRoot, {
|
|
4245
|
+
targetBranch: opts.target || 'main',
|
|
4246
|
+
maxItems: Math.max(1, runnableCount),
|
|
4247
|
+
mergeBudget: Math.max(1, runnableCount),
|
|
4248
|
+
followPlan: false,
|
|
4249
|
+
})
|
|
4250
|
+
: null;
|
|
4251
|
+
console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, gate, result }, null, 2));
|
|
4252
|
+
db.close();
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
printMergeDiscovery(discovery);
|
|
4257
|
+
|
|
4258
|
+
if (discovery.blocked.length > 0) {
|
|
4259
|
+
console.log('');
|
|
4260
|
+
console.log(chalk.bold('Needs attention before landing:'));
|
|
4261
|
+
for (const entry of discovery.blocked) {
|
|
4262
|
+
console.log(` ${chalk.yellow(entry.worktree)}: ${entry.summary}`);
|
|
4263
|
+
console.log(` ${chalk.dim('run:')} ${chalk.cyan(entry.command)}`);
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
if (mergeOrder.length > 0) {
|
|
4268
|
+
console.log('');
|
|
4269
|
+
console.log(`${chalk.bold('Merge order:')} ${mergeOrder.join(' → ')}`);
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
if (opts.dryRun) {
|
|
4273
|
+
console.log('');
|
|
4274
|
+
console.log(chalk.dim('Dry run only — nothing was landed.'));
|
|
4275
|
+
db.close();
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
if (discovery.eligible.length === 0) {
|
|
4280
|
+
console.log('');
|
|
4281
|
+
console.log(chalk.dim('No finished worktrees are ready to land yet.'));
|
|
4282
|
+
console.log(`${chalk.yellow('next:')} ${chalk.cyan('switchman status')}`);
|
|
4283
|
+
db.close();
|
|
4284
|
+
return;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
const gate = await evaluateQueueRepoGate(db, repoRoot);
|
|
4288
|
+
if (!gate.ok) {
|
|
4289
|
+
console.log('');
|
|
4290
|
+
console.log(`${chalk.red('✗')} Merge gate blocked landing`);
|
|
4291
|
+
console.log(` ${chalk.dim(gate.summary)}`);
|
|
4292
|
+
console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman gate ci')}`);
|
|
4293
|
+
db.close();
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
console.log('');
|
|
4298
|
+
const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
|
|
4299
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
4300
|
+
targetBranch: opts.target || 'main',
|
|
4301
|
+
maxItems: Math.max(1, runnableCount),
|
|
4302
|
+
mergeBudget: Math.max(1, runnableCount),
|
|
4303
|
+
followPlan: false,
|
|
4304
|
+
});
|
|
4305
|
+
|
|
4306
|
+
const merged = result.processed.filter((item) => item.status === 'merged');
|
|
4307
|
+
for (const mergedItem of merged) {
|
|
4308
|
+
console.log(` ${chalk.green('✓')} Landed ${chalk.cyan(mergedItem.item.source_ref)} into ${chalk.bold(mergedItem.item.target_branch)}`);
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
if (merged.length > 0 && !result.deferred && result.processed.every((item) => item.status === 'merged')) {
|
|
4312
|
+
console.log('');
|
|
4313
|
+
console.log(`${chalk.green('✓')} Done. ${merged.length} worktree(s) landed cleanly.`);
|
|
4314
|
+
db.close();
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
const blocked = result.processed.find((item) => item.status !== 'merged')?.item || result.deferred || null;
|
|
4319
|
+
if (blocked) {
|
|
4320
|
+
console.log('');
|
|
4321
|
+
console.log(`${chalk.yellow('!')} Landing stopped at ${chalk.cyan(blocked.source_ref)}`);
|
|
4322
|
+
if (blocked.last_error_summary) console.log(` ${chalk.dim(blocked.last_error_summary)}`);
|
|
4323
|
+
if (blocked.next_action) console.log(` ${chalk.yellow('next:')} ${blocked.next_action}`);
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
db.close();
|
|
4327
|
+
} catch (err) {
|
|
4328
|
+
db.close();
|
|
4329
|
+
console.error(chalk.red(err.message));
|
|
4330
|
+
process.exitCode = 1;
|
|
4331
|
+
}
|
|
4332
|
+
});
|
|
4333
|
+
|
|
1879
4334
|
queueCmd
|
|
1880
4335
|
.command('retry <itemId>')
|
|
1881
4336
|
.description('Retry a blocked merge queue item')
|
|
@@ -1900,6 +4355,40 @@ queueCmd
|
|
|
1900
4355
|
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
1901
4356
|
});
|
|
1902
4357
|
|
|
4358
|
+
queueCmd
|
|
4359
|
+
.command('escalate <itemId>')
|
|
4360
|
+
.description('Mark a queue item as needing explicit operator review before landing')
|
|
4361
|
+
.option('--reason <text>', 'Why this item is being escalated')
|
|
4362
|
+
.option('--json', 'Output raw JSON')
|
|
4363
|
+
.action((itemId, opts) => {
|
|
4364
|
+
const repoRoot = getRepo();
|
|
4365
|
+
const db = getDb(repoRoot);
|
|
4366
|
+
const item = escalateMergeQueueItem(db, itemId, {
|
|
4367
|
+
summary: opts.reason || null,
|
|
4368
|
+
nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
|
|
4369
|
+
});
|
|
4370
|
+
db.close();
|
|
4371
|
+
|
|
4372
|
+
if (!item) {
|
|
4373
|
+
printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
|
|
4374
|
+
process.exitCode = 1;
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
if (opts.json) {
|
|
4379
|
+
console.log(JSON.stringify(item, null, 2));
|
|
4380
|
+
return;
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
|
|
4384
|
+
if (item.last_error_summary) {
|
|
4385
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
4386
|
+
}
|
|
4387
|
+
if (item.next_action) {
|
|
4388
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
4389
|
+
}
|
|
4390
|
+
});
|
|
4391
|
+
|
|
1903
4392
|
queueCmd
|
|
1904
4393
|
.command('remove <itemId>')
|
|
1905
4394
|
.description('Remove a merge queue item')
|
|
@@ -1909,23 +4398,302 @@ queueCmd
|
|
|
1909
4398
|
const item = removeMergeQueueItem(db, itemId);
|
|
1910
4399
|
db.close();
|
|
1911
4400
|
|
|
1912
|
-
if (!item) {
|
|
1913
|
-
printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
|
|
4401
|
+
if (!item) {
|
|
4402
|
+
printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
|
|
4403
|
+
process.exitCode = 1;
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
4408
|
+
});
|
|
4409
|
+
|
|
4410
|
+
// ── explain ───────────────────────────────────────────────────────────────────
|
|
4411
|
+
|
|
4412
|
+
const explainCmd = program.command('explain').description('Explain why Switchman blocked something and what to do next');
|
|
4413
|
+
explainCmd.addHelpText('after', `
|
|
4414
|
+
Examples:
|
|
4415
|
+
switchman explain queue mq-123
|
|
4416
|
+
switchman explain claim src/auth/login.js
|
|
4417
|
+
switchman explain history pipe-123
|
|
4418
|
+
`);
|
|
4419
|
+
|
|
4420
|
+
explainCmd
|
|
4421
|
+
.command('queue <itemId>')
|
|
4422
|
+
.description('Explain one landing-queue item in plain English')
|
|
4423
|
+
.option('--json', 'Output raw JSON')
|
|
4424
|
+
.action((itemId, opts) => {
|
|
4425
|
+
const repoRoot = getRepo();
|
|
4426
|
+
const db = getDb(repoRoot);
|
|
4427
|
+
|
|
4428
|
+
try {
|
|
4429
|
+
const report = buildQueueExplainReport(db, repoRoot, itemId);
|
|
4430
|
+
db.close();
|
|
4431
|
+
|
|
4432
|
+
if (opts.json) {
|
|
4433
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
console.log(chalk.bold(`Queue item ${report.item.id}`));
|
|
4438
|
+
console.log(` ${chalk.dim('status:')} ${statusBadge(report.item.status)}`.trim());
|
|
4439
|
+
console.log(` ${chalk.dim('source:')} ${report.item.source_type} ${report.item.source_ref}`);
|
|
4440
|
+
console.log(` ${chalk.dim('target:')} ${report.item.target_branch}`);
|
|
4441
|
+
if (report.resolved_source) {
|
|
4442
|
+
console.log(` ${chalk.dim('resolved branch:')} ${chalk.cyan(report.resolved_source.branch)}`);
|
|
4443
|
+
if (report.resolved_source.worktree) {
|
|
4444
|
+
console.log(` ${chalk.dim('resolved worktree:')} ${chalk.cyan(report.resolved_source.worktree)}`);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
if (report.resolution_error) {
|
|
4448
|
+
console.log(` ${chalk.red('why:')} ${report.resolution_error}`);
|
|
4449
|
+
} else if (report.item.last_error_summary) {
|
|
4450
|
+
console.log(` ${chalk.red('why:')} ${report.item.last_error_summary}`);
|
|
4451
|
+
} else {
|
|
4452
|
+
console.log(` ${chalk.dim('why:')} waiting to land`);
|
|
4453
|
+
}
|
|
4454
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
4455
|
+
if (report.recent_events.length > 0) {
|
|
4456
|
+
console.log(chalk.bold('\nRecent events'));
|
|
4457
|
+
for (const event of report.recent_events) {
|
|
4458
|
+
console.log(` ${chalk.dim(event.created_at)} ${event.event_type}${event.status ? ` ${statusBadge(event.status).trim()}` : ''}`);
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
} catch (err) {
|
|
4462
|
+
db.close();
|
|
4463
|
+
printErrorWithNext(err.message, 'switchman queue status');
|
|
4464
|
+
process.exitCode = 1;
|
|
4465
|
+
}
|
|
4466
|
+
});
|
|
4467
|
+
|
|
4468
|
+
explainCmd
|
|
4469
|
+
.command('claim <path>')
|
|
4470
|
+
.description('Explain who currently owns a file path')
|
|
4471
|
+
.option('--json', 'Output raw JSON')
|
|
4472
|
+
.action((filePath, opts) => {
|
|
4473
|
+
const repoRoot = getRepo();
|
|
4474
|
+
const db = getDb(repoRoot);
|
|
4475
|
+
|
|
4476
|
+
try {
|
|
4477
|
+
const report = buildClaimExplainReport(db, filePath);
|
|
4478
|
+
db.close();
|
|
4479
|
+
|
|
4480
|
+
if (opts.json) {
|
|
4481
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
console.log(chalk.bold(`Claim status for ${report.file_path}`));
|
|
4486
|
+
if (report.claims.length === 0 && report.scope_owners.length === 0) {
|
|
4487
|
+
console.log(` ${chalk.green('status:')} unowned`);
|
|
4488
|
+
console.log(` ${chalk.yellow('next:')} switchman claim <taskId> <workspace> ${report.file_path}`);
|
|
4489
|
+
return;
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
if (report.claims.length > 0) {
|
|
4493
|
+
console.log(` ${chalk.red('explicit claim:')}`);
|
|
4494
|
+
for (const claim of report.claims) {
|
|
4495
|
+
console.log(` ${chalk.cyan(claim.worktree)} task:${claim.task_id} ${chalk.dim(`lease:${claim.lease_id}`)}`);
|
|
4496
|
+
console.log(` ${chalk.dim('title:')} ${claim.task_title}`);
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
if (report.scope_owners.length > 0) {
|
|
4501
|
+
console.log(` ${chalk.yellow('task scope owner:')}`);
|
|
4502
|
+
for (const owner of report.scope_owners) {
|
|
4503
|
+
console.log(` ${chalk.cyan(owner.worktree)} task:${owner.task_id} ${chalk.dim(`lease:${owner.lease_id}`)}`);
|
|
4504
|
+
console.log(` ${chalk.dim('title:')} ${owner.task_title}`);
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
const blockingOwner = report.claims[0] || report.scope_owners[0];
|
|
4509
|
+
if (blockingOwner) {
|
|
4510
|
+
console.log(` ${chalk.yellow('next:')} inspect ${chalk.cyan(blockingOwner.worktree)} or choose a different file before claiming this path`);
|
|
4511
|
+
}
|
|
4512
|
+
} catch (err) {
|
|
4513
|
+
db.close();
|
|
4514
|
+
printErrorWithNext(err.message, 'switchman status');
|
|
4515
|
+
process.exitCode = 1;
|
|
4516
|
+
}
|
|
4517
|
+
});
|
|
4518
|
+
|
|
4519
|
+
explainCmd
|
|
4520
|
+
.command('stale [taskId]')
|
|
4521
|
+
.description('Explain why a task or pipeline is stale and how to revalidate it')
|
|
4522
|
+
.option('--pipeline <pipelineId>', 'Explain stale invalidations for a whole pipeline')
|
|
4523
|
+
.option('--json', 'Output raw JSON')
|
|
4524
|
+
.action((taskId, opts) => {
|
|
4525
|
+
const repoRoot = getRepo();
|
|
4526
|
+
const db = getDb(repoRoot);
|
|
4527
|
+
|
|
4528
|
+
try {
|
|
4529
|
+
if (!opts.pipeline && !taskId) {
|
|
4530
|
+
throw new Error('Pass a task id or `--pipeline <id>`.');
|
|
4531
|
+
}
|
|
4532
|
+
const report = opts.pipeline
|
|
4533
|
+
? buildStalePipelineExplainReport(db, opts.pipeline)
|
|
4534
|
+
: buildStaleTaskExplainReport(db, taskId);
|
|
4535
|
+
db.close();
|
|
4536
|
+
|
|
4537
|
+
if (opts.json) {
|
|
4538
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
if (opts.pipeline) {
|
|
4543
|
+
console.log(chalk.bold(`Stale status for pipeline ${report.pipeline_id}`));
|
|
4544
|
+
if (report.stale_clusters.length === 0) {
|
|
4545
|
+
console.log(` ${chalk.green('state:')} no active dependency invalidations`);
|
|
4546
|
+
return;
|
|
4547
|
+
}
|
|
4548
|
+
for (const cluster of report.stale_clusters) {
|
|
4549
|
+
console.log(` ${cluster.severity === 'block' ? chalk.red('why:') : chalk.yellow('why:')} ${cluster.title}`);
|
|
4550
|
+
console.log(` ${chalk.dim('source worktrees:')} ${cluster.source_worktrees.join(', ') || 'unknown'}`);
|
|
4551
|
+
console.log(` ${chalk.dim('affected tasks:')} ${cluster.affected_task_ids.join(', ')}`);
|
|
4552
|
+
console.log(` ${chalk.dim('stale areas:')} ${cluster.stale_areas.join(', ')}`);
|
|
4553
|
+
}
|
|
4554
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
console.log(chalk.bold(`Stale status for ${report.task.id}`));
|
|
4559
|
+
console.log(` ${chalk.dim('title:')} ${report.task.title}`);
|
|
4560
|
+
console.log(` ${chalk.dim('status:')} ${statusBadge(report.task.status)}`.trim());
|
|
4561
|
+
if (report.invalidations.length === 0) {
|
|
4562
|
+
console.log(` ${chalk.green('state:')} no active dependency invalidations`);
|
|
4563
|
+
return;
|
|
4564
|
+
}
|
|
4565
|
+
for (const invalidation of report.invalidations) {
|
|
4566
|
+
console.log(` ${chalk.red('why:')} ${invalidation.summary}`);
|
|
4567
|
+
console.log(` ${chalk.dim('source task:')} ${invalidation.source_task_id}`);
|
|
4568
|
+
console.log(` ${chalk.dim('stale area:')} ${invalidation.stale_area}`);
|
|
4569
|
+
}
|
|
4570
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
4571
|
+
} catch (err) {
|
|
4572
|
+
db.close();
|
|
4573
|
+
printErrorWithNext(err.message, opts.pipeline ? 'switchman doctor' : 'switchman doctor');
|
|
4574
|
+
process.exitCode = 1;
|
|
4575
|
+
}
|
|
4576
|
+
});
|
|
4577
|
+
|
|
4578
|
+
explainCmd
|
|
4579
|
+
.command('history <pipelineId>')
|
|
4580
|
+
.description('Explain the recent change timeline for one pipeline')
|
|
4581
|
+
.option('--json', 'Output raw JSON')
|
|
4582
|
+
.action((pipelineId, opts) => {
|
|
4583
|
+
const repoRoot = getRepo();
|
|
4584
|
+
const db = getDb(repoRoot);
|
|
4585
|
+
|
|
4586
|
+
try {
|
|
4587
|
+
const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
|
|
4588
|
+
db.close();
|
|
4589
|
+
|
|
4590
|
+
if (opts.json) {
|
|
4591
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4592
|
+
return;
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
console.log(chalk.bold(`History for pipeline ${report.pipeline_id}`));
|
|
4596
|
+
console.log(` ${chalk.dim('title:')} ${report.title}`);
|
|
4597
|
+
console.log(` ${chalk.dim('tasks:')} pending ${report.counts.pending} • in progress ${report.counts.in_progress} • done ${report.counts.done} • failed ${report.counts.failed}`);
|
|
4598
|
+
if (report.current.queue_items.length > 0) {
|
|
4599
|
+
const queueSummary = report.current.queue_items
|
|
4600
|
+
.map((item) => `${item.id} ${item.status}`)
|
|
4601
|
+
.join(', ');
|
|
4602
|
+
console.log(` ${chalk.dim('queue:')} ${queueSummary}`);
|
|
4603
|
+
}
|
|
4604
|
+
if (report.current.stale_clusters.length > 0) {
|
|
4605
|
+
console.log(` ${chalk.red('stale:')} ${report.current.stale_clusters[0].title}`);
|
|
4606
|
+
}
|
|
4607
|
+
if (report.current.landing.last_failure) {
|
|
4608
|
+
console.log(` ${chalk.red('landing:')} ${humanizeReasonCode(report.current.landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`);
|
|
4609
|
+
} else if (report.current.landing.stale) {
|
|
4610
|
+
console.log(` ${chalk.yellow('landing:')} synthetic landing branch is stale`);
|
|
4611
|
+
} else {
|
|
4612
|
+
console.log(` ${chalk.dim('landing:')} ${report.current.landing.branch} (${report.current.landing.strategy})`);
|
|
4613
|
+
}
|
|
4614
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
4615
|
+
|
|
4616
|
+
console.log(chalk.bold('\nTimeline'));
|
|
4617
|
+
for (const event of report.events.slice(-20)) {
|
|
4618
|
+
const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
|
|
4619
|
+
console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
|
|
4620
|
+
console.log(` ${event.summary}`);
|
|
4621
|
+
if (event.next_action) {
|
|
4622
|
+
console.log(` ${chalk.dim(`next: ${event.next_action}`)}`);
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
} catch (err) {
|
|
4626
|
+
db.close();
|
|
4627
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
4628
|
+
process.exitCode = 1;
|
|
4629
|
+
}
|
|
4630
|
+
});
|
|
4631
|
+
|
|
4632
|
+
explainCmd
|
|
4633
|
+
.command('landing <pipelineId>')
|
|
4634
|
+
.description('Explain the current landing branch state for a pipeline')
|
|
4635
|
+
.option('--json', 'Output raw JSON')
|
|
4636
|
+
.action((pipelineId, opts) => {
|
|
4637
|
+
const repoRoot = getRepo();
|
|
4638
|
+
const db = getDb(repoRoot);
|
|
4639
|
+
|
|
4640
|
+
try {
|
|
4641
|
+
const report = getPipelineLandingExplainReport(db, repoRoot, pipelineId);
|
|
4642
|
+
db.close();
|
|
4643
|
+
|
|
4644
|
+
if (opts.json) {
|
|
4645
|
+
console.log(JSON.stringify(report, null, 2));
|
|
4646
|
+
return;
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
console.log(chalk.bold(`Landing status for ${report.pipeline_id}`));
|
|
4650
|
+
console.log(` ${chalk.dim('branch:')} ${report.landing.branch}`);
|
|
4651
|
+
console.log(` ${chalk.dim('strategy:')} ${report.landing.strategy}`);
|
|
4652
|
+
if (report.landing.last_failure) {
|
|
4653
|
+
console.log(` ${chalk.red('failure:')} ${humanizeReasonCode(report.landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`);
|
|
4654
|
+
if (report.landing.last_failure.failed_branch) {
|
|
4655
|
+
console.log(` ${chalk.dim('failed branch:')} ${report.landing.last_failure.failed_branch}`);
|
|
4656
|
+
}
|
|
4657
|
+
if (report.landing.last_failure.conflicting_files?.length > 0) {
|
|
4658
|
+
console.log(` ${chalk.dim('conflicts:')} ${report.landing.last_failure.conflicting_files.join(', ')}`);
|
|
4659
|
+
}
|
|
4660
|
+
if (report.landing.last_failure.output) {
|
|
4661
|
+
console.log(` ${chalk.dim('details:')} ${report.landing.last_failure.output.split('\n')[0]}`);
|
|
4662
|
+
}
|
|
4663
|
+
} else if (report.landing.last_recovery?.recovery_path) {
|
|
4664
|
+
console.log(` ${chalk.dim('recovery path:')} ${report.landing.last_recovery.recovery_path}`);
|
|
4665
|
+
if (report.landing.last_recovery.state?.status) {
|
|
4666
|
+
console.log(` ${chalk.dim('recovery state:')} ${report.landing.last_recovery.state.status}`);
|
|
4667
|
+
}
|
|
4668
|
+
} else if (report.landing.last_materialized?.head_commit) {
|
|
4669
|
+
console.log(` ${chalk.green('head:')} ${report.landing.last_materialized.head_commit.slice(0, 12)}`);
|
|
4670
|
+
} else if (report.landing.stale_reasons.length > 0) {
|
|
4671
|
+
for (const reason of report.landing.stale_reasons) {
|
|
4672
|
+
console.log(` ${chalk.red('why:')} ${reason.summary}`);
|
|
4673
|
+
}
|
|
4674
|
+
} else if (report.landing.last_materialized) {
|
|
4675
|
+
console.log(` ${chalk.green('state:')} landing branch is current`);
|
|
4676
|
+
} else {
|
|
4677
|
+
console.log(` ${chalk.yellow('state:')} landing branch has not been materialized yet`);
|
|
4678
|
+
}
|
|
4679
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
4680
|
+
} catch (err) {
|
|
4681
|
+
db.close();
|
|
4682
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
1914
4683
|
process.exitCode = 1;
|
|
1915
|
-
return;
|
|
1916
4684
|
}
|
|
1917
|
-
|
|
1918
|
-
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
1919
4685
|
});
|
|
1920
4686
|
|
|
1921
4687
|
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
1922
4688
|
|
|
1923
4689
|
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
4690
|
+
pipelineCmd._switchmanAdvanced = true;
|
|
1924
4691
|
pipelineCmd.addHelpText('after', `
|
|
1925
4692
|
Examples:
|
|
1926
4693
|
switchman pipeline start "Harden auth API permissions"
|
|
1927
4694
|
switchman pipeline exec pipe-123 "/path/to/agent-command"
|
|
1928
4695
|
switchman pipeline status pipe-123
|
|
4696
|
+
switchman pipeline land pipe-123
|
|
1929
4697
|
`);
|
|
1930
4698
|
|
|
1931
4699
|
pipelineCmd
|
|
@@ -1978,10 +4746,33 @@ Examples:
|
|
|
1978
4746
|
|
|
1979
4747
|
try {
|
|
1980
4748
|
const result = getPipelineStatus(db, pipelineId);
|
|
4749
|
+
let landing;
|
|
4750
|
+
let landingError = null;
|
|
4751
|
+
try {
|
|
4752
|
+
landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
|
|
4753
|
+
requireCompleted: false,
|
|
4754
|
+
});
|
|
4755
|
+
} catch (err) {
|
|
4756
|
+
landingError = String(err.message || 'Landing branch is not ready yet.');
|
|
4757
|
+
landing = {
|
|
4758
|
+
branch: null,
|
|
4759
|
+
synthetic: false,
|
|
4760
|
+
stale: false,
|
|
4761
|
+
stale_reasons: [],
|
|
4762
|
+
last_failure: null,
|
|
4763
|
+
last_recovery: null,
|
|
4764
|
+
};
|
|
4765
|
+
}
|
|
4766
|
+
const policyState = summarizePipelinePolicyState(db, result, loadChangePolicy(repoRoot), []);
|
|
1981
4767
|
db.close();
|
|
1982
4768
|
|
|
1983
4769
|
if (opts.json) {
|
|
1984
|
-
console.log(JSON.stringify(
|
|
4770
|
+
console.log(JSON.stringify({
|
|
4771
|
+
...result,
|
|
4772
|
+
landing_branch: landing,
|
|
4773
|
+
landing_error: landingError,
|
|
4774
|
+
policy_state: policyState,
|
|
4775
|
+
}, null, 2));
|
|
1985
4776
|
return;
|
|
1986
4777
|
}
|
|
1987
4778
|
|
|
@@ -2000,6 +4791,7 @@ Examples:
|
|
|
2000
4791
|
const focusLine = focusTask
|
|
2001
4792
|
? `${focusTask.title} ${chalk.dim(focusTask.id)}`
|
|
2002
4793
|
: 'No pipeline tasks found.';
|
|
4794
|
+
const landingLabel = buildLandingStateLabel(landing);
|
|
2003
4795
|
|
|
2004
4796
|
console.log('');
|
|
2005
4797
|
console.log(pipelineHealthColor('='.repeat(72)));
|
|
@@ -2013,6 +4805,11 @@ Examples:
|
|
|
2013
4805
|
renderChip('failed', result.counts.failed, result.counts.failed > 0 ? chalk.red : chalk.green),
|
|
2014
4806
|
]));
|
|
2015
4807
|
console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
|
|
4808
|
+
if (landingLabel) {
|
|
4809
|
+
console.log(`${chalk.bold('Landing:')} ${landingLabel}`);
|
|
4810
|
+
} else if (landingError) {
|
|
4811
|
+
console.log(`${chalk.bold('Landing:')} ${chalk.yellow('not ready yet')} ${chalk.dim(landingError)}`);
|
|
4812
|
+
}
|
|
2016
4813
|
|
|
2017
4814
|
const runningLines = result.tasks.filter((task) => task.status === 'in_progress').slice(0, 4).map((task) => {
|
|
2018
4815
|
const worktree = task.worktree || task.suggested_worktree || 'unassigned';
|
|
@@ -2038,9 +4835,55 @@ Examples:
|
|
|
2038
4835
|
return `${renderChip('NEXT', task.id, chalk.green)} ${task.title} ${chalk.dim(worktree)}${blocked}`;
|
|
2039
4836
|
});
|
|
2040
4837
|
|
|
4838
|
+
const landingLines = landing.synthetic
|
|
4839
|
+
? [
|
|
4840
|
+
`${renderChip(landing.stale ? 'STALE' : 'LAND', landing.branch, landing.stale ? chalk.red : chalk.green)} ${chalk.dim(`base ${landing.base_branch}`)}`,
|
|
4841
|
+
...(landing.last_failure
|
|
4842
|
+
? [
|
|
4843
|
+
` ${chalk.red('failure:')} ${humanizeReasonCode(landing.last_failure.reason_code || 'landing_branch_materialization_failed')}`,
|
|
4844
|
+
...(landing.last_failure.failed_branch ? [` ${chalk.dim('failed branch:')} ${landing.last_failure.failed_branch}`] : []),
|
|
4845
|
+
]
|
|
4846
|
+
: []),
|
|
4847
|
+
...(landing.last_recovery?.state?.status
|
|
4848
|
+
? [
|
|
4849
|
+
` ${chalk.dim('recovery:')} ${landing.last_recovery.state.status} ${landing.last_recovery.recovery_path}`,
|
|
4850
|
+
]
|
|
4851
|
+
: []),
|
|
4852
|
+
...(landing.stale_reasons.length > 0
|
|
4853
|
+
? landing.stale_reasons.slice(0, 3).map((reason) => ` ${chalk.red('why:')} ${reason.summary}`)
|
|
4854
|
+
: [landing.last_materialized
|
|
4855
|
+
? ` ${chalk.green('state:')} ready to queue`
|
|
4856
|
+
: ` ${chalk.yellow('next:')} switchman pipeline land ${result.pipeline_id}`]),
|
|
4857
|
+
(landing.last_failure?.command
|
|
4858
|
+
? ` ${chalk.yellow('next:')} ${landing.last_failure.command}`
|
|
4859
|
+
: landing.stale
|
|
4860
|
+
? ` ${chalk.yellow('next:')} switchman pipeline land ${result.pipeline_id} --refresh`
|
|
4861
|
+
: ` ${chalk.yellow('next:')} switchman queue add --pipeline ${result.pipeline_id}`),
|
|
4862
|
+
]
|
|
4863
|
+
: [];
|
|
4864
|
+
|
|
4865
|
+
const policyLines = policyState.active
|
|
4866
|
+
? [
|
|
4867
|
+
`${renderChip(policyState.enforcement.toUpperCase(), policyState.domains.join(','), policyState.enforcement === 'blocked' ? chalk.red : chalk.yellow)} ${policyState.summary}`,
|
|
4868
|
+
` ${chalk.dim('required:')} ${policyState.required_task_types.join(', ') || 'none'}`,
|
|
4869
|
+
` ${chalk.dim('satisfied:')} ${policyState.satisfied_task_types.join(', ') || 'none'}`,
|
|
4870
|
+
` ${chalk.dim('missing:')} ${policyState.missing_task_types.join(', ') || 'none'}`,
|
|
4871
|
+
` ${chalk.dim('overridden:')} ${policyState.overridden_task_types.join(', ') || 'none'}`,
|
|
4872
|
+
...policyState.requirement_status
|
|
4873
|
+
.filter((requirement) => requirement.evidence.length > 0)
|
|
4874
|
+
.slice(0, 4)
|
|
4875
|
+
.map((requirement) => ` ${chalk.dim(`${requirement.task_type}:`)} ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path})` : entry.task_id).join(', ')}`),
|
|
4876
|
+
...policyState.overrides
|
|
4877
|
+
.slice(0, 3)
|
|
4878
|
+
.map((entry) => ` ${chalk.dim(`override ${entry.id}:`)} ${(entry.task_types || []).join(', ') || 'all'} by ${entry.approved_by || 'unknown'}`),
|
|
4879
|
+
]
|
|
4880
|
+
: [chalk.green('No explicit change policy requirements are active for this pipeline.')];
|
|
4881
|
+
|
|
2041
4882
|
const commandLines = [
|
|
2042
4883
|
`${chalk.cyan('$')} switchman pipeline exec ${result.pipeline_id} "/path/to/agent-command"`,
|
|
2043
4884
|
`${chalk.cyan('$')} switchman pipeline pr ${result.pipeline_id}`,
|
|
4885
|
+
...(landing.last_failure?.command ? [`${chalk.cyan('$')} ${landing.last_failure.command}`] : []),
|
|
4886
|
+
...(landing.synthetic && landing.stale ? [`${chalk.cyan('$')} switchman pipeline land ${result.pipeline_id} --refresh`] : []),
|
|
2044
4887
|
...(result.counts.failed > 0 ? [`${chalk.cyan('$')} switchman pipeline status ${result.pipeline_id}`] : []),
|
|
2045
4888
|
];
|
|
2046
4889
|
|
|
@@ -2049,6 +4892,8 @@ Examples:
|
|
|
2049
4892
|
renderPanel('Running now', runningLines.length > 0 ? runningLines : [chalk.dim('No tasks are actively running.')], runningLines.length > 0 ? chalk.cyan : chalk.green),
|
|
2050
4893
|
renderPanel('Blocked', blockedLines.length > 0 ? blockedLines : [chalk.green('Nothing blocked.')], blockedLines.length > 0 ? chalk.red : chalk.green),
|
|
2051
4894
|
renderPanel('Next up', nextLines.length > 0 ? nextLines : [chalk.dim('No pending tasks left.')], chalk.green),
|
|
4895
|
+
renderPanel('Policy', policyLines, policyState.active ? (policyState.missing_task_types.length > 0 ? chalk.red : chalk.green) : chalk.green),
|
|
4896
|
+
...(landing.synthetic ? [renderPanel('Landing branch', landingLines, landing.stale ? chalk.red : chalk.cyan)] : []),
|
|
2052
4897
|
renderPanel('Next commands', commandLines, chalk.cyan),
|
|
2053
4898
|
]) {
|
|
2054
4899
|
for (const line of block) console.log(line);
|
|
@@ -2089,6 +4934,9 @@ pipelineCmd
|
|
|
2089
4934
|
pipelineCmd
|
|
2090
4935
|
.command('bundle <pipelineId> [outputDir]')
|
|
2091
4936
|
.description('Export a reviewer-ready PR bundle for a pipeline to disk')
|
|
4937
|
+
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
4938
|
+
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
4939
|
+
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
2092
4940
|
.option('--json', 'Output raw JSON')
|
|
2093
4941
|
.action(async (pipelineId, outputDir, opts) => {
|
|
2094
4942
|
const repoRoot = getRepo();
|
|
@@ -2098,6 +4946,15 @@ pipelineCmd
|
|
|
2098
4946
|
const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
|
|
2099
4947
|
db.close();
|
|
2100
4948
|
|
|
4949
|
+
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
4950
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
4951
|
+
writeGitHubPipelineLandingStatus({
|
|
4952
|
+
result: result.landing_summary,
|
|
4953
|
+
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
4954
|
+
outputPath: githubTargets.outputPath,
|
|
4955
|
+
});
|
|
4956
|
+
}
|
|
4957
|
+
|
|
2101
4958
|
if (opts.json) {
|
|
2102
4959
|
console.log(JSON.stringify(result, null, 2));
|
|
2103
4960
|
return;
|
|
@@ -2108,6 +4965,8 @@ pipelineCmd
|
|
|
2108
4965
|
console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
|
|
2109
4966
|
console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
|
|
2110
4967
|
console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
|
|
4968
|
+
console.log(` ${chalk.dim('landing json:')} ${result.files.landing_summary_json}`);
|
|
4969
|
+
console.log(` ${chalk.dim('landing md:')} ${result.files.landing_summary_markdown}`);
|
|
2111
4970
|
} catch (err) {
|
|
2112
4971
|
db.close();
|
|
2113
4972
|
console.error(chalk.red(err.message));
|
|
@@ -2115,6 +4974,129 @@ pipelineCmd
|
|
|
2115
4974
|
}
|
|
2116
4975
|
});
|
|
2117
4976
|
|
|
4977
|
+
pipelineCmd
|
|
4978
|
+
.command('land <pipelineId>')
|
|
4979
|
+
.description('Create or refresh one landing branch for a completed pipeline')
|
|
4980
|
+
.option('--base <branch>', 'Base branch for the landing branch', 'main')
|
|
4981
|
+
.option('--branch <branch>', 'Custom landing branch name')
|
|
4982
|
+
.option('--refresh', 'Rebuild the landing branch when a source branch or base branch has moved')
|
|
4983
|
+
.option('--recover', 'Create a recovery worktree for an unresolved landing merge conflict')
|
|
4984
|
+
.option('--replace-recovery', 'Replace an existing recovery worktree when creating a new one')
|
|
4985
|
+
.option('--resume [path]', 'Validate a resolved recovery worktree and mark the landing branch ready again')
|
|
4986
|
+
.option('--cleanup [path]', 'Remove a recorded recovery worktree after it is resolved or abandoned')
|
|
4987
|
+
.option('--json', 'Output raw JSON')
|
|
4988
|
+
.action((pipelineId, opts) => {
|
|
4989
|
+
const repoRoot = getRepo();
|
|
4990
|
+
const db = getDb(repoRoot);
|
|
4991
|
+
|
|
4992
|
+
try {
|
|
4993
|
+
const selectedModes = [opts.refresh, opts.recover, Boolean(opts.resume), Boolean(opts.cleanup)].filter(Boolean).length;
|
|
4994
|
+
if (selectedModes > 1) {
|
|
4995
|
+
throw new Error('Choose only one of --refresh, --recover, --resume, or --cleanup.');
|
|
4996
|
+
}
|
|
4997
|
+
if (opts.recover) {
|
|
4998
|
+
const result = preparePipelineLandingRecovery(db, repoRoot, pipelineId, {
|
|
4999
|
+
baseBranch: opts.base,
|
|
5000
|
+
landingBranch: opts.branch || null,
|
|
5001
|
+
replaceExisting: Boolean(opts.replaceRecovery),
|
|
5002
|
+
});
|
|
5003
|
+
db.close();
|
|
5004
|
+
|
|
5005
|
+
if (opts.json) {
|
|
5006
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5007
|
+
return;
|
|
5008
|
+
}
|
|
5009
|
+
|
|
5010
|
+
console.log(`${chalk.green('✓')} ${result.reused_existing ? 'Recovery worktree already ready for' : 'Recovery worktree ready for'} ${chalk.cyan(result.pipeline_id)}`);
|
|
5011
|
+
console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
|
|
5012
|
+
console.log(` ${chalk.dim('path:')} ${result.recovery_path}`);
|
|
5013
|
+
if (result.reused_existing) {
|
|
5014
|
+
console.log(` ${chalk.dim('state:')} reusing existing recovery worktree`);
|
|
5015
|
+
}
|
|
5016
|
+
console.log(` ${chalk.dim('blocked by:')} ${result.failed_branch}`);
|
|
5017
|
+
if (result.conflicting_files.length > 0) {
|
|
5018
|
+
console.log(` ${chalk.dim('conflicts:')} ${result.conflicting_files.join(', ')}`);
|
|
5019
|
+
}
|
|
5020
|
+
console.log(` ${chalk.yellow('inspect:')} ${result.inspect_command}`);
|
|
5021
|
+
console.log(` ${chalk.yellow('after resolving + commit:')} ${result.resume_command}`);
|
|
5022
|
+
return;
|
|
5023
|
+
}
|
|
5024
|
+
if (opts.cleanup) {
|
|
5025
|
+
const result = cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
|
|
5026
|
+
baseBranch: opts.base,
|
|
5027
|
+
landingBranch: opts.branch || null,
|
|
5028
|
+
recoveryPath: typeof opts.cleanup === 'string' ? opts.cleanup : null,
|
|
5029
|
+
});
|
|
5030
|
+
db.close();
|
|
5031
|
+
|
|
5032
|
+
if (opts.json) {
|
|
5033
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5034
|
+
return;
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
console.log(`${chalk.green('✓')} Recovery worktree cleared for ${chalk.cyan(result.pipeline_id)}`);
|
|
5038
|
+
console.log(` ${chalk.dim('path:')} ${result.recovery_path}`);
|
|
5039
|
+
console.log(` ${chalk.dim('removed:')} ${result.removed ? 'yes' : 'no'}`);
|
|
5040
|
+
console.log(` ${chalk.yellow('next:')} switchman explain landing ${result.pipeline_id}`);
|
|
5041
|
+
return;
|
|
5042
|
+
}
|
|
5043
|
+
if (opts.resume) {
|
|
5044
|
+
const result = resumePipelineLandingRecovery(db, repoRoot, pipelineId, {
|
|
5045
|
+
baseBranch: opts.base,
|
|
5046
|
+
landingBranch: opts.branch || null,
|
|
5047
|
+
recoveryPath: typeof opts.resume === 'string' ? opts.resume : null,
|
|
5048
|
+
});
|
|
5049
|
+
db.close();
|
|
5050
|
+
|
|
5051
|
+
if (opts.json) {
|
|
5052
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5053
|
+
return;
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
console.log(`${chalk.green('✓')} ${result.already_resumed ? 'Landing recovery already resumed for' : 'Landing recovery resumed for'} ${chalk.cyan(result.pipeline_id)}`);
|
|
5057
|
+
console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
|
|
5058
|
+
console.log(` ${chalk.dim('head:')} ${result.head_commit}`);
|
|
5059
|
+
if (result.recovery_path) {
|
|
5060
|
+
console.log(` ${chalk.dim('recovery path:')} ${result.recovery_path}`);
|
|
5061
|
+
}
|
|
5062
|
+
if (result.already_resumed) {
|
|
5063
|
+
console.log(` ${chalk.dim('state:')} already aligned and ready to queue`);
|
|
5064
|
+
}
|
|
5065
|
+
console.log(` ${chalk.yellow('next:')} ${result.resume_command}`);
|
|
5066
|
+
return;
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
const result = materializePipelineLandingBranch(db, repoRoot, pipelineId, {
|
|
5070
|
+
baseBranch: opts.base,
|
|
5071
|
+
landingBranch: opts.branch || null,
|
|
5072
|
+
requireCompleted: true,
|
|
5073
|
+
refresh: Boolean(opts.refresh),
|
|
5074
|
+
});
|
|
5075
|
+
db.close();
|
|
5076
|
+
|
|
5077
|
+
if (opts.json) {
|
|
5078
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5079
|
+
return;
|
|
5080
|
+
}
|
|
5081
|
+
|
|
5082
|
+
console.log(`${chalk.green('✓')} Landing branch ready for ${chalk.cyan(result.pipeline_id)}`);
|
|
5083
|
+
console.log(` ${chalk.dim('branch:')} ${chalk.cyan(result.branch)}`);
|
|
5084
|
+
console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
|
|
5085
|
+
console.log(` ${chalk.dim('strategy:')} ${result.strategy}`);
|
|
5086
|
+
console.log(` ${chalk.dim('components:')} ${result.component_branches.join(', ')}`);
|
|
5087
|
+
if (result.reused_existing) {
|
|
5088
|
+
console.log(` ${chalk.dim('state:')} already current`);
|
|
5089
|
+
} else if (result.refreshed) {
|
|
5090
|
+
console.log(` ${chalk.dim('state:')} refreshed`);
|
|
5091
|
+
}
|
|
5092
|
+
console.log(` ${chalk.yellow('next:')} switchman queue add ${result.branch}`);
|
|
5093
|
+
} catch (err) {
|
|
5094
|
+
db.close();
|
|
5095
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
5096
|
+
process.exitCode = 1;
|
|
5097
|
+
}
|
|
5098
|
+
});
|
|
5099
|
+
|
|
2118
5100
|
pipelineCmd
|
|
2119
5101
|
.command('publish <pipelineId> [outputDir]')
|
|
2120
5102
|
.description('Create a hosted GitHub pull request for a pipeline using gh')
|
|
@@ -2155,6 +5137,138 @@ pipelineCmd
|
|
|
2155
5137
|
}
|
|
2156
5138
|
});
|
|
2157
5139
|
|
|
5140
|
+
pipelineCmd
|
|
5141
|
+
.command('comment <pipelineId> [outputDir]')
|
|
5142
|
+
.description('Post or update a GitHub PR comment with the pipeline landing summary')
|
|
5143
|
+
.option('--pr <number>', 'Pull request number to comment on')
|
|
5144
|
+
.option('--pr-from-env', 'Read the pull request number from GitHub Actions environment variables')
|
|
5145
|
+
.option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
|
|
5146
|
+
.option('--update-existing', 'Edit the last comment from this user instead of creating a new one')
|
|
5147
|
+
.option('--json', 'Output raw JSON')
|
|
5148
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
5149
|
+
const repoRoot = getRepo();
|
|
5150
|
+
const db = getDb(repoRoot);
|
|
5151
|
+
const prNumber = opts.pr || (opts.prFromEnv ? resolvePrNumberFromEnv() : null);
|
|
5152
|
+
|
|
5153
|
+
try {
|
|
5154
|
+
if (!prNumber) {
|
|
5155
|
+
throw new Error('A pull request number is required. Pass `--pr <number>` or `--pr-from-env`.');
|
|
5156
|
+
}
|
|
5157
|
+
const result = await commentPipelinePr(db, repoRoot, pipelineId, {
|
|
5158
|
+
prNumber,
|
|
5159
|
+
ghCommand: opts.ghCommand,
|
|
5160
|
+
outputDir: outputDir || null,
|
|
5161
|
+
updateExisting: Boolean(opts.updateExisting),
|
|
5162
|
+
});
|
|
5163
|
+
db.close();
|
|
5164
|
+
|
|
5165
|
+
if (opts.json) {
|
|
5166
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5167
|
+
return;
|
|
5168
|
+
}
|
|
5169
|
+
|
|
5170
|
+
console.log(`${chalk.green('✓')} Posted pipeline comment for ${chalk.cyan(result.pipeline_id)}`);
|
|
5171
|
+
console.log(` ${chalk.dim('pr:')} #${result.pr_number}`);
|
|
5172
|
+
console.log(` ${chalk.dim('body:')} ${result.bundle.files.landing_summary_markdown}`);
|
|
5173
|
+
if (result.updated_existing) {
|
|
5174
|
+
console.log(` ${chalk.dim('mode:')} update existing comment`);
|
|
5175
|
+
}
|
|
5176
|
+
} catch (err) {
|
|
5177
|
+
db.close();
|
|
5178
|
+
console.error(chalk.red(err.message));
|
|
5179
|
+
process.exitCode = 1;
|
|
5180
|
+
}
|
|
5181
|
+
});
|
|
5182
|
+
|
|
5183
|
+
pipelineCmd
|
|
5184
|
+
.command('sync-pr [pipelineId] [outputDir]')
|
|
5185
|
+
.description('Build PR artifacts, emit GitHub outputs, and update the PR comment in one command')
|
|
5186
|
+
.option('--pr <number>', 'Pull request number to comment on')
|
|
5187
|
+
.option('--pr-from-env', 'Read the pull request number from GitHub Actions environment variables')
|
|
5188
|
+
.option('--pipeline-from-env', 'Infer the pipeline id from the current GitHub branch context')
|
|
5189
|
+
.option('--skip-missing-pipeline', 'Exit successfully when no matching pipeline can be inferred')
|
|
5190
|
+
.option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
|
|
5191
|
+
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
5192
|
+
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
5193
|
+
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
5194
|
+
.option('--no-comment', 'Skip updating the PR comment')
|
|
5195
|
+
.option('--json', 'Output raw JSON')
|
|
5196
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
5197
|
+
const repoRoot = getRepo();
|
|
5198
|
+
const db = getDb(repoRoot);
|
|
5199
|
+
const branchFromEnv = opts.pipelineFromEnv ? resolveBranchFromEnv() : null;
|
|
5200
|
+
const resolvedPipelineId = pipelineId || (branchFromEnv ? inferPipelineIdFromBranch(db, branchFromEnv) : null);
|
|
5201
|
+
const prNumber = opts.pr || (opts.prFromEnv ? resolvePrNumberFromEnv() : null);
|
|
5202
|
+
|
|
5203
|
+
try {
|
|
5204
|
+
if (!resolvedPipelineId) {
|
|
5205
|
+
if (!opts.skipMissingPipeline) {
|
|
5206
|
+
throw new Error(opts.pipelineFromEnv
|
|
5207
|
+
? `Could not infer a pipeline from branch ${branchFromEnv || 'unknown'}. Pass a pipeline id explicitly or use a branch that maps to one Switchman pipeline.`
|
|
5208
|
+
: 'A pipeline id is required. Pass one explicitly or use `--pipeline-from-env`.');
|
|
5209
|
+
}
|
|
5210
|
+
const skipped = {
|
|
5211
|
+
skipped: true,
|
|
5212
|
+
reason: 'no_pipeline_inferred',
|
|
5213
|
+
branch: branchFromEnv,
|
|
5214
|
+
next_action: 'Run `switchman pipeline status <pipelineId>` locally to confirm the pipeline id, then rerun sync-pr with that id.',
|
|
5215
|
+
};
|
|
5216
|
+
db.close();
|
|
5217
|
+
if (opts.json) {
|
|
5218
|
+
console.log(JSON.stringify(skipped, null, 2));
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
console.log(`${chalk.green('✓')} No pipeline sync needed`);
|
|
5222
|
+
if (branchFromEnv) {
|
|
5223
|
+
console.log(` ${chalk.dim('branch:')} ${branchFromEnv}`);
|
|
5224
|
+
}
|
|
5225
|
+
console.log(` ${chalk.dim('reason:')} no matching Switchman pipeline was inferred`);
|
|
5226
|
+
console.log(` ${chalk.yellow('next:')} ${skipped.next_action}`);
|
|
5227
|
+
return;
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
if (opts.comment !== false && !prNumber) {
|
|
5231
|
+
throw new Error('A pull request number is required for comment sync. Pass `--pr <number>`, `--pr-from-env`, or `--no-comment`.');
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
const result = await syncPipelinePr(db, repoRoot, resolvedPipelineId, {
|
|
5235
|
+
prNumber: opts.comment === false ? null : prNumber,
|
|
5236
|
+
ghCommand: opts.ghCommand,
|
|
5237
|
+
outputDir: outputDir || null,
|
|
5238
|
+
updateExisting: true,
|
|
5239
|
+
});
|
|
5240
|
+
db.close();
|
|
5241
|
+
|
|
5242
|
+
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
5243
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
5244
|
+
writeGitHubPipelineLandingStatus({
|
|
5245
|
+
result: result.bundle.landing_summary,
|
|
5246
|
+
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
5247
|
+
outputPath: githubTargets.outputPath,
|
|
5248
|
+
});
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
if (opts.json) {
|
|
5252
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5253
|
+
return;
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
console.log(`${chalk.green('✓')} Synced PR artifacts for ${chalk.cyan(result.pipeline_id)}`);
|
|
5257
|
+
console.log(` ${chalk.dim('bundle:')} ${result.bundle.output_dir}`);
|
|
5258
|
+
if (result.comment) {
|
|
5259
|
+
console.log(` ${chalk.dim('pr:')} #${result.comment.pr_number}`);
|
|
5260
|
+
console.log(` ${chalk.dim('comment:')} updated existing`);
|
|
5261
|
+
}
|
|
5262
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
5263
|
+
console.log(` ${chalk.dim('github:')} wrote PR check artifacts`);
|
|
5264
|
+
}
|
|
5265
|
+
} catch (err) {
|
|
5266
|
+
db.close();
|
|
5267
|
+
console.error(chalk.red(err.message));
|
|
5268
|
+
process.exitCode = 1;
|
|
5269
|
+
}
|
|
5270
|
+
});
|
|
5271
|
+
|
|
2158
5272
|
pipelineCmd
|
|
2159
5273
|
.command('run <pipelineId> [agentCommand...]')
|
|
2160
5274
|
.description('Dispatch pending pipeline tasks onto available worktrees and optionally launch an agent command in each one')
|
|
@@ -2200,6 +5314,53 @@ pipelineCmd
|
|
|
2200
5314
|
}
|
|
2201
5315
|
});
|
|
2202
5316
|
|
|
5317
|
+
pipelineCmd
|
|
5318
|
+
.command('repair <pipelineId>')
|
|
5319
|
+
.description('Safely repair interrupted landing state for one pipeline')
|
|
5320
|
+
.option('--base <branch>', 'Base branch for landing repair checks', 'main')
|
|
5321
|
+
.option('--branch <branch>', 'Custom landing branch name')
|
|
5322
|
+
.option('--json', 'Output raw JSON')
|
|
5323
|
+
.action((pipelineId, opts) => {
|
|
5324
|
+
const repoRoot = getRepo();
|
|
5325
|
+
const db = getDb(repoRoot);
|
|
5326
|
+
|
|
5327
|
+
try {
|
|
5328
|
+
const result = repairPipelineState(db, repoRoot, pipelineId, {
|
|
5329
|
+
baseBranch: opts.base,
|
|
5330
|
+
landingBranch: opts.branch || null,
|
|
5331
|
+
});
|
|
5332
|
+
db.close();
|
|
5333
|
+
|
|
5334
|
+
if (opts.json) {
|
|
5335
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5336
|
+
return;
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
if (!result.repaired) {
|
|
5340
|
+
console.log(`${chalk.green('✓')} No repair action needed for ${chalk.cyan(result.pipeline_id)}`);
|
|
5341
|
+
for (const note of result.notes) {
|
|
5342
|
+
console.log(` ${chalk.dim(note)}`);
|
|
5343
|
+
}
|
|
5344
|
+
console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
|
|
5345
|
+
return;
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
console.log(`${chalk.green('✓')} Repaired ${chalk.cyan(result.pipeline_id)}`);
|
|
5349
|
+
for (const action of result.actions) {
|
|
5350
|
+
if (action.kind === 'recovery_state_cleared') {
|
|
5351
|
+
console.log(` ${chalk.dim('cleared recovery record:')} ${action.recovery_path}`);
|
|
5352
|
+
} else if (action.kind === 'landing_branch_refreshed') {
|
|
5353
|
+
console.log(` ${chalk.dim('refreshed landing branch:')} ${action.branch}${action.head_commit ? ` ${chalk.dim(action.head_commit.slice(0, 12))}` : ''}`);
|
|
5354
|
+
}
|
|
5355
|
+
}
|
|
5356
|
+
console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
|
|
5357
|
+
} catch (err) {
|
|
5358
|
+
db.close();
|
|
5359
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
5360
|
+
process.exitCode = 1;
|
|
5361
|
+
}
|
|
5362
|
+
});
|
|
5363
|
+
|
|
2203
5364
|
pipelineCmd
|
|
2204
5365
|
.command('review <pipelineId>')
|
|
2205
5366
|
.description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
|
|
@@ -2377,6 +5538,7 @@ Examples:
|
|
|
2377
5538
|
}
|
|
2378
5539
|
|
|
2379
5540
|
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
5541
|
+
pushSyncEvent('lease_acquired', { task_id: task.id, title: task.title }, { worktree: worktreeName }).catch(() => {});
|
|
2380
5542
|
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
2381
5543
|
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
2382
5544
|
});
|
|
@@ -2559,25 +5721,31 @@ wtCmd
|
|
|
2559
5721
|
const db = getDb(repoRoot);
|
|
2560
5722
|
const worktrees = listWorktrees(db);
|
|
2561
5723
|
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
2562
|
-
db.close();
|
|
2563
5724
|
|
|
2564
5725
|
if (!worktrees.length && !gitWorktrees.length) {
|
|
5726
|
+
db.close();
|
|
2565
5727
|
console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
|
|
2566
5728
|
return;
|
|
2567
5729
|
}
|
|
2568
5730
|
|
|
2569
5731
|
// Show git worktrees (source of truth) annotated with db info
|
|
5732
|
+
const complianceReport = evaluateRepoCompliance(db, repoRoot, gitWorktrees);
|
|
2570
5733
|
console.log('');
|
|
2571
5734
|
console.log(chalk.bold('Git Worktrees:'));
|
|
2572
5735
|
for (const wt of gitWorktrees) {
|
|
2573
5736
|
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
5737
|
+
const complianceInfo = complianceReport.worktreeCompliance.find((entry) => entry.worktree === wt.name) || null;
|
|
2574
5738
|
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
2575
5739
|
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
2576
|
-
const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
5740
|
+
const compliance = complianceInfo?.compliance_state ? statusBadge(complianceInfo.compliance_state) : dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
2577
5741
|
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
2578
5742
|
console.log(` ${chalk.dim(wt.path)}`);
|
|
5743
|
+
if ((complianceInfo?.unclaimed_changed_files || []).length > 0) {
|
|
5744
|
+
console.log(` ${chalk.red('files:')} ${complianceInfo.unclaimed_changed_files.slice(0, 5).join(', ')}${complianceInfo.unclaimed_changed_files.length > 5 ? ` ${chalk.dim(`+${complianceInfo.unclaimed_changed_files.length - 5} more`)}` : ''}`);
|
|
5745
|
+
}
|
|
2579
5746
|
}
|
|
2580
5747
|
console.log('');
|
|
5748
|
+
db.close();
|
|
2581
5749
|
});
|
|
2582
5750
|
|
|
2583
5751
|
wtCmd
|
|
@@ -2601,13 +5769,14 @@ program
|
|
|
2601
5769
|
.command('claim <taskId> <worktree> [files...]')
|
|
2602
5770
|
.description('Lock files for a task before editing')
|
|
2603
5771
|
.option('--agent <name>', 'Agent name')
|
|
2604
|
-
.option('--force', '
|
|
5772
|
+
.option('--force', 'Emergency override for manual recovery when a conflicting claim is known to be stale or wrong')
|
|
2605
5773
|
.addHelpText('after', `
|
|
2606
5774
|
Examples:
|
|
2607
5775
|
switchman claim task-123 agent2 src/auth.js src/server.js
|
|
2608
5776
|
switchman claim task-123 agent2 src/auth.js --agent cursor
|
|
2609
5777
|
|
|
2610
5778
|
Use this before editing files in a shared repo.
|
|
5779
|
+
Only use --force for operator-led recovery after checking switchman status or switchman explain claim <path>.
|
|
2611
5780
|
`)
|
|
2612
5781
|
.action((taskId, worktree, files, opts) => {
|
|
2613
5782
|
if (!files.length) {
|
|
@@ -2626,7 +5795,7 @@ Use this before editing files in a shared repo.
|
|
|
2626
5795
|
for (const c of conflicts) {
|
|
2627
5796
|
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
2628
5797
|
}
|
|
2629
|
-
console.log(chalk.dim('\
|
|
5798
|
+
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.'));
|
|
2630
5799
|
console.log(`${chalk.yellow('next:')} switchman status`);
|
|
2631
5800
|
process.exitCode = 1;
|
|
2632
5801
|
return;
|
|
@@ -2634,6 +5803,12 @@ Use this before editing files in a shared repo.
|
|
|
2634
5803
|
|
|
2635
5804
|
const lease = claimFiles(db, taskId, worktree, files, opts.agent);
|
|
2636
5805
|
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
5806
|
+
pushSyncEvent('claim_added', {
|
|
5807
|
+
task_id: taskId,
|
|
5808
|
+
lease_id: lease.id,
|
|
5809
|
+
file_count: files.length,
|
|
5810
|
+
files: files.slice(0, 10),
|
|
5811
|
+
}, { worktree }).catch(() => {});
|
|
2637
5812
|
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
2638
5813
|
} catch (err) {
|
|
2639
5814
|
printErrorWithNext(err.message, 'switchman task list --status in_progress');
|
|
@@ -2649,9 +5824,11 @@ program
|
|
|
2649
5824
|
.action((taskId) => {
|
|
2650
5825
|
const repoRoot = getRepo();
|
|
2651
5826
|
const db = getDb(repoRoot);
|
|
5827
|
+
const task = getTask(db, taskId);
|
|
2652
5828
|
releaseFileClaims(db, taskId);
|
|
2653
5829
|
db.close();
|
|
2654
5830
|
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
5831
|
+
pushSyncEvent('claim_released', { task_id: taskId }, { worktree: task?.worktree || null }).catch(() => {});
|
|
2655
5832
|
});
|
|
2656
5833
|
|
|
2657
5834
|
program
|
|
@@ -2927,12 +6104,14 @@ program
|
|
|
2927
6104
|
.description('Show one dashboard view of what is running, blocked, and ready next')
|
|
2928
6105
|
.option('--json', 'Output raw JSON')
|
|
2929
6106
|
.option('--watch', 'Keep refreshing status in the terminal')
|
|
6107
|
+
.option('--repair', 'Repair safe interrupted queue and pipeline state before rendering status')
|
|
2930
6108
|
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
|
|
2931
6109
|
.option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
|
|
2932
6110
|
.addHelpText('after', `
|
|
2933
6111
|
Examples:
|
|
2934
6112
|
switchman status
|
|
2935
6113
|
switchman status --watch
|
|
6114
|
+
switchman status --repair
|
|
2936
6115
|
switchman status --json
|
|
2937
6116
|
|
|
2938
6117
|
Use this first when the repo feels stuck.
|
|
@@ -2950,13 +6129,40 @@ Use this first when the repo feels stuck.
|
|
|
2950
6129
|
console.clear();
|
|
2951
6130
|
}
|
|
2952
6131
|
|
|
6132
|
+
let repairResult = null;
|
|
6133
|
+
if (opts.repair) {
|
|
6134
|
+
const repairDb = getDb(repoRoot);
|
|
6135
|
+
try {
|
|
6136
|
+
repairResult = repairRepoState(repairDb, repoRoot);
|
|
6137
|
+
} finally {
|
|
6138
|
+
repairDb.close();
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
6141
|
+
|
|
2953
6142
|
const report = await collectStatusSnapshot(repoRoot);
|
|
6143
|
+
const [teamActivity, teamState] = await Promise.all([
|
|
6144
|
+
pullActiveTeamMembers(),
|
|
6145
|
+
pullTeamState(),
|
|
6146
|
+
]);
|
|
6147
|
+
const myUserId = readCredentials()?.user_id;
|
|
6148
|
+
const otherMembers = teamActivity.filter(e => e.user_id !== myUserId);
|
|
6149
|
+
const teamSummary = summarizeTeamCoordinationState(teamState, myUserId);
|
|
2954
6150
|
cycles += 1;
|
|
2955
6151
|
|
|
2956
6152
|
if (opts.json) {
|
|
2957
|
-
|
|
6153
|
+
const payload = watch ? { ...report, watch: true, cycles } : report;
|
|
6154
|
+
const withTeam = { ...payload, team_sync: { summary: teamSummary, recent_events: teamState.filter((event) => event.user_id !== myUserId).slice(0, 25) } };
|
|
6155
|
+
console.log(JSON.stringify(opts.repair ? { ...withTeam, repair: repairResult } : withTeam, null, 2));
|
|
2958
6156
|
} else {
|
|
2959
|
-
|
|
6157
|
+
if (opts.repair && repairResult) {
|
|
6158
|
+
printRepairSummary(repairResult, {
|
|
6159
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state before rendering status`,
|
|
6160
|
+
noRepairHeading: `${chalk.green('✓')} No repo repair action needed before rendering status`,
|
|
6161
|
+
limit: 6,
|
|
6162
|
+
});
|
|
6163
|
+
console.log('');
|
|
6164
|
+
}
|
|
6165
|
+
renderUnifiedStatusReport(report, { teamActivity: otherMembers, teamSummary });
|
|
2960
6166
|
if (watch) {
|
|
2961
6167
|
const signature = buildWatchSignature(report);
|
|
2962
6168
|
const watchState = lastSignature === null
|
|
@@ -2986,8 +6192,49 @@ Use this first when the repo feels stuck.
|
|
|
2986
6192
|
});
|
|
2987
6193
|
|
|
2988
6194
|
program
|
|
6195
|
+
.command('repair')
|
|
6196
|
+
.description('Repair safe interrupted queue and pipeline state across the repo')
|
|
6197
|
+
.option('--json', 'Output raw JSON')
|
|
6198
|
+
.action((opts) => {
|
|
6199
|
+
const repoRoot = getRepo();
|
|
6200
|
+
const db = getDb(repoRoot);
|
|
6201
|
+
|
|
6202
|
+
try {
|
|
6203
|
+
const result = repairRepoState(db, repoRoot);
|
|
6204
|
+
db.close();
|
|
6205
|
+
|
|
6206
|
+
if (opts.json) {
|
|
6207
|
+
console.log(JSON.stringify(result, null, 2));
|
|
6208
|
+
return;
|
|
6209
|
+
}
|
|
6210
|
+
|
|
6211
|
+
if (!result.repaired) {
|
|
6212
|
+
printRepairSummary(result, {
|
|
6213
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state`,
|
|
6214
|
+
noRepairHeading: `${chalk.green('✓')} No repo repair action needed`,
|
|
6215
|
+
});
|
|
6216
|
+
console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
|
|
6217
|
+
return;
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6220
|
+
printRepairSummary(result, {
|
|
6221
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state`,
|
|
6222
|
+
noRepairHeading: `${chalk.green('✓')} No repo repair action needed`,
|
|
6223
|
+
});
|
|
6224
|
+
console.log(` ${chalk.yellow('next:')} ${result.next_action}`);
|
|
6225
|
+
} catch (err) {
|
|
6226
|
+
db.close();
|
|
6227
|
+
console.error(chalk.red(err.message));
|
|
6228
|
+
process.exitCode = 1;
|
|
6229
|
+
}
|
|
6230
|
+
});
|
|
6231
|
+
|
|
6232
|
+
const doctorCmd = program
|
|
2989
6233
|
.command('doctor')
|
|
2990
|
-
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
|
|
6234
|
+
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next');
|
|
6235
|
+
doctorCmd._switchmanAdvanced = true;
|
|
6236
|
+
doctorCmd
|
|
6237
|
+
.option('--repair', 'Repair safe interrupted queue and pipeline state before reporting health')
|
|
2991
6238
|
.option('--json', 'Output raw JSON')
|
|
2992
6239
|
.addHelpText('after', `
|
|
2993
6240
|
Plain English:
|
|
@@ -3000,6 +6247,7 @@ Examples:
|
|
|
3000
6247
|
.action(async (opts) => {
|
|
3001
6248
|
const repoRoot = getRepo();
|
|
3002
6249
|
const db = getDb(repoRoot);
|
|
6250
|
+
const repairResult = opts.repair ? repairRepoState(db, repoRoot) : null;
|
|
3003
6251
|
const tasks = listTasks(db);
|
|
3004
6252
|
const activeLeases = listLeases(db, 'active');
|
|
3005
6253
|
const staleLeases = getStaleLeases(db);
|
|
@@ -3017,10 +6265,19 @@ Examples:
|
|
|
3017
6265
|
db.close();
|
|
3018
6266
|
|
|
3019
6267
|
if (opts.json) {
|
|
3020
|
-
console.log(JSON.stringify(report, null, 2));
|
|
6268
|
+
console.log(JSON.stringify(opts.repair ? { ...report, repair: repairResult } : report, null, 2));
|
|
3021
6269
|
return;
|
|
3022
6270
|
}
|
|
3023
6271
|
|
|
6272
|
+
if (opts.repair) {
|
|
6273
|
+
printRepairSummary(repairResult, {
|
|
6274
|
+
repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state before running doctor`,
|
|
6275
|
+
noRepairHeading: `${chalk.green('✓')} No repo repair action needed before doctor`,
|
|
6276
|
+
limit: 6,
|
|
6277
|
+
});
|
|
6278
|
+
console.log('');
|
|
6279
|
+
}
|
|
6280
|
+
|
|
3024
6281
|
const doctorColor = colorForHealth(report.health);
|
|
3025
6282
|
const blockedCount = report.attention.filter((item) => item.severity === 'block').length;
|
|
3026
6283
|
const warningCount = report.attention.filter((item) => item.severity !== 'block').length;
|
|
@@ -3071,6 +6328,19 @@ Examples:
|
|
|
3071
6328
|
})
|
|
3072
6329
|
: [chalk.green('Nothing urgent.')];
|
|
3073
6330
|
|
|
6331
|
+
const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
|
|
6332
|
+
? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
|
|
6333
|
+
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}`];
|
|
6334
|
+
lines.push(` ${chalk.dim(cluster.detail)}`);
|
|
6335
|
+
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(', ')}`)}` : ''}`);
|
|
6336
|
+
lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
|
|
6337
|
+
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}`)}` : ''}`);
|
|
6338
|
+
lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
|
|
6339
|
+
lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
|
|
6340
|
+
return lines;
|
|
6341
|
+
})
|
|
6342
|
+
: [chalk.green('No stale dependency clusters.')];
|
|
6343
|
+
|
|
3074
6344
|
const nextStepLines = [
|
|
3075
6345
|
...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
|
|
3076
6346
|
'',
|
|
@@ -3082,6 +6352,7 @@ Examples:
|
|
|
3082
6352
|
for (const block of [
|
|
3083
6353
|
renderPanel('Running now', runningLines, chalk.cyan),
|
|
3084
6354
|
renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
|
|
6355
|
+
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),
|
|
3085
6356
|
renderPanel('Recommended next steps', nextStepLines, chalk.green),
|
|
3086
6357
|
]) {
|
|
3087
6358
|
for (const line of block) console.log(line);
|
|
@@ -3089,18 +6360,52 @@ Examples:
|
|
|
3089
6360
|
}
|
|
3090
6361
|
});
|
|
3091
6362
|
|
|
3092
|
-
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
3093
|
-
|
|
3094
|
-
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
3095
|
-
gateCmd.addHelpText('after', `
|
|
3096
|
-
Examples:
|
|
3097
|
-
switchman gate ci
|
|
3098
|
-
switchman gate ai
|
|
3099
|
-
switchman gate install-ci
|
|
3100
|
-
`);
|
|
3101
|
-
|
|
3102
|
-
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
3103
|
-
|
|
6363
|
+
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
6364
|
+
|
|
6365
|
+
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
6366
|
+
gateCmd.addHelpText('after', `
|
|
6367
|
+
Examples:
|
|
6368
|
+
switchman gate ci
|
|
6369
|
+
switchman gate ai
|
|
6370
|
+
switchman gate install-ci
|
|
6371
|
+
`);
|
|
6372
|
+
|
|
6373
|
+
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
6374
|
+
auditCmd._switchmanAdvanced = true;
|
|
6375
|
+
|
|
6376
|
+
auditCmd
|
|
6377
|
+
.command('change <pipelineId>')
|
|
6378
|
+
.description('Show a signed, operator-friendly history for one pipeline')
|
|
6379
|
+
.option('--json', 'Output raw JSON')
|
|
6380
|
+
.action((pipelineId, options) => {
|
|
6381
|
+
const repoRoot = getRepo();
|
|
6382
|
+
const db = getDb(repoRoot);
|
|
6383
|
+
|
|
6384
|
+
try {
|
|
6385
|
+
const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
|
|
6386
|
+
db.close();
|
|
6387
|
+
|
|
6388
|
+
if (options.json) {
|
|
6389
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6390
|
+
return;
|
|
6391
|
+
}
|
|
6392
|
+
|
|
6393
|
+
console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
|
|
6394
|
+
console.log(` ${chalk.dim('title:')} ${report.title}`);
|
|
6395
|
+
console.log(` ${chalk.dim('events:')} ${report.events.length}`);
|
|
6396
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
6397
|
+
for (const event of report.events.slice(-20)) {
|
|
6398
|
+
const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
|
|
6399
|
+
console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
|
|
6400
|
+
console.log(` ${event.summary}`);
|
|
6401
|
+
}
|
|
6402
|
+
} catch (err) {
|
|
6403
|
+
db.close();
|
|
6404
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
6405
|
+
process.exitCode = 1;
|
|
6406
|
+
}
|
|
6407
|
+
});
|
|
6408
|
+
|
|
3104
6409
|
auditCmd
|
|
3105
6410
|
.command('verify')
|
|
3106
6411
|
.description('Verify the audit log hash chain and project signatures')
|
|
@@ -3389,6 +6694,7 @@ gateCmd
|
|
|
3389
6694
|
const semanticCmd = program
|
|
3390
6695
|
.command('semantic')
|
|
3391
6696
|
.description('Inspect or materialize the derived semantic code-object view');
|
|
6697
|
+
semanticCmd._switchmanAdvanced = true;
|
|
3392
6698
|
|
|
3393
6699
|
semanticCmd
|
|
3394
6700
|
.command('materialize')
|
|
@@ -3405,6 +6711,7 @@ semanticCmd
|
|
|
3405
6711
|
const objectCmd = program
|
|
3406
6712
|
.command('object')
|
|
3407
6713
|
.description('Experimental object-source mode backed by canonical exported code objects');
|
|
6714
|
+
objectCmd._switchmanAdvanced = true;
|
|
3408
6715
|
|
|
3409
6716
|
objectCmd
|
|
3410
6717
|
.command('import')
|
|
@@ -3486,6 +6793,7 @@ objectCmd
|
|
|
3486
6793
|
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
3487
6794
|
|
|
3488
6795
|
const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
|
|
6796
|
+
monitorCmd._switchmanAdvanced = true;
|
|
3489
6797
|
|
|
3490
6798
|
monitorCmd
|
|
3491
6799
|
.command('once')
|
|
@@ -3495,7 +6803,7 @@ monitorCmd
|
|
|
3495
6803
|
.action((opts) => {
|
|
3496
6804
|
const repoRoot = getRepo();
|
|
3497
6805
|
const db = getDb(repoRoot);
|
|
3498
|
-
const worktrees =
|
|
6806
|
+
const worktrees = resolveMonitoredWorktrees(db, repoRoot);
|
|
3499
6807
|
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
3500
6808
|
db.close();
|
|
3501
6809
|
|
|
@@ -3511,9 +6819,7 @@ monitorCmd
|
|
|
3511
6819
|
|
|
3512
6820
|
console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
|
|
3513
6821
|
for (const event of result.events) {
|
|
3514
|
-
|
|
3515
|
-
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
3516
|
-
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}`);
|
|
6822
|
+
renderMonitorEvent(event);
|
|
3517
6823
|
}
|
|
3518
6824
|
});
|
|
3519
6825
|
|
|
@@ -3547,14 +6853,12 @@ monitorCmd
|
|
|
3547
6853
|
|
|
3548
6854
|
while (!stopped) {
|
|
3549
6855
|
const db = getDb(repoRoot);
|
|
3550
|
-
const worktrees =
|
|
6856
|
+
const worktrees = resolveMonitoredWorktrees(db, repoRoot);
|
|
3551
6857
|
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
3552
6858
|
db.close();
|
|
3553
6859
|
|
|
3554
6860
|
for (const event of result.events) {
|
|
3555
|
-
|
|
3556
|
-
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
3557
|
-
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}`);
|
|
6861
|
+
renderMonitorEvent(event);
|
|
3558
6862
|
}
|
|
3559
6863
|
|
|
3560
6864
|
if (stopped) break;
|
|
@@ -3572,39 +6876,18 @@ monitorCmd
|
|
|
3572
6876
|
.action((opts) => {
|
|
3573
6877
|
const repoRoot = getRepo();
|
|
3574
6878
|
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
3575
|
-
const
|
|
6879
|
+
const state = startBackgroundMonitor(repoRoot, {
|
|
6880
|
+
intervalMs,
|
|
6881
|
+
quarantine: Boolean(opts.quarantine),
|
|
6882
|
+
});
|
|
3576
6883
|
|
|
3577
|
-
if (
|
|
3578
|
-
console.log(chalk.yellow(`Monitor already running with pid ${
|
|
6884
|
+
if (state.already_running) {
|
|
6885
|
+
console.log(chalk.yellow(`Monitor already running with pid ${state.state.pid}`));
|
|
3579
6886
|
return;
|
|
3580
6887
|
}
|
|
3581
6888
|
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
process.argv[1],
|
|
3585
|
-
'monitor',
|
|
3586
|
-
'watch',
|
|
3587
|
-
'--interval-ms',
|
|
3588
|
-
String(intervalMs),
|
|
3589
|
-
...(opts.quarantine ? ['--quarantine'] : []),
|
|
3590
|
-
'--daemonized',
|
|
3591
|
-
], {
|
|
3592
|
-
cwd: repoRoot,
|
|
3593
|
-
detached: true,
|
|
3594
|
-
stdio: 'ignore',
|
|
3595
|
-
});
|
|
3596
|
-
child.unref();
|
|
3597
|
-
|
|
3598
|
-
const statePath = writeMonitorState(repoRoot, {
|
|
3599
|
-
pid: child.pid,
|
|
3600
|
-
interval_ms: intervalMs,
|
|
3601
|
-
quarantine: Boolean(opts.quarantine),
|
|
3602
|
-
log_path: logPath,
|
|
3603
|
-
started_at: new Date().toISOString(),
|
|
3604
|
-
});
|
|
3605
|
-
|
|
3606
|
-
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
|
|
3607
|
-
console.log(`${chalk.dim('State:')} ${statePath}`);
|
|
6889
|
+
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(state.state.pid))}`);
|
|
6890
|
+
console.log(`${chalk.dim('State:')} ${state.state_path}`);
|
|
3608
6891
|
});
|
|
3609
6892
|
|
|
3610
6893
|
monitorCmd
|
|
@@ -3656,9 +6939,38 @@ monitorCmd
|
|
|
3656
6939
|
console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
|
|
3657
6940
|
});
|
|
3658
6941
|
|
|
6942
|
+
program
|
|
6943
|
+
.command('watch')
|
|
6944
|
+
.description('Watch worktrees for direct writes and rogue edits in real time')
|
|
6945
|
+
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
6946
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
6947
|
+
.action(async (opts) => {
|
|
6948
|
+
const repoRoot = getRepo();
|
|
6949
|
+
const child = spawn(process.execPath, [
|
|
6950
|
+
process.argv[1],
|
|
6951
|
+
'monitor',
|
|
6952
|
+
'watch',
|
|
6953
|
+
'--interval-ms',
|
|
6954
|
+
String(opts.intervalMs || '2000'),
|
|
6955
|
+
...(opts.quarantine ? ['--quarantine'] : []),
|
|
6956
|
+
], {
|
|
6957
|
+
cwd: repoRoot,
|
|
6958
|
+
stdio: 'inherit',
|
|
6959
|
+
});
|
|
6960
|
+
|
|
6961
|
+
await new Promise((resolve, reject) => {
|
|
6962
|
+
child.on('exit', (code) => {
|
|
6963
|
+
process.exitCode = code ?? 0;
|
|
6964
|
+
resolve();
|
|
6965
|
+
});
|
|
6966
|
+
child.on('error', reject);
|
|
6967
|
+
});
|
|
6968
|
+
});
|
|
6969
|
+
|
|
3659
6970
|
// ── policy ───────────────────────────────────────────────────────────────────
|
|
3660
6971
|
|
|
3661
|
-
const policyCmd = program.command('policy').description('Manage enforcement policy
|
|
6972
|
+
const policyCmd = program.command('policy').description('Manage enforcement and change-governance policy');
|
|
6973
|
+
policyCmd._switchmanAdvanced = true;
|
|
3662
6974
|
|
|
3663
6975
|
policyCmd
|
|
3664
6976
|
.command('init')
|
|
@@ -3675,4 +6987,501 @@ policyCmd
|
|
|
3675
6987
|
console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
|
|
3676
6988
|
});
|
|
3677
6989
|
|
|
6990
|
+
policyCmd
|
|
6991
|
+
.command('init-change')
|
|
6992
|
+
.description('Write a starter change policy file for governed domains like auth, payments, and schema')
|
|
6993
|
+
.action(() => {
|
|
6994
|
+
const repoRoot = getRepo();
|
|
6995
|
+
const policyPath = writeChangePolicy(repoRoot, DEFAULT_CHANGE_POLICY);
|
|
6996
|
+
console.log(`${chalk.green('✓')} Wrote change policy to ${chalk.cyan(policyPath)}`);
|
|
6997
|
+
});
|
|
6998
|
+
|
|
6999
|
+
policyCmd
|
|
7000
|
+
.command('show-change')
|
|
7001
|
+
.description('Show the active change policy for this repo')
|
|
7002
|
+
.option('--json', 'Output raw JSON')
|
|
7003
|
+
.action((opts) => {
|
|
7004
|
+
const repoRoot = getRepo();
|
|
7005
|
+
const policy = loadChangePolicy(repoRoot);
|
|
7006
|
+
const policyPath = getChangePolicyPath(repoRoot);
|
|
7007
|
+
|
|
7008
|
+
if (opts.json) {
|
|
7009
|
+
console.log(JSON.stringify({ path: policyPath, policy }, null, 2));
|
|
7010
|
+
return;
|
|
7011
|
+
}
|
|
7012
|
+
|
|
7013
|
+
console.log(chalk.bold('Change policy'));
|
|
7014
|
+
console.log(` ${chalk.dim('path:')} ${policyPath}`);
|
|
7015
|
+
for (const [domain, rule] of Object.entries(policy.domain_rules || {})) {
|
|
7016
|
+
console.log(` ${chalk.cyan(domain)} ${chalk.dim(rule.enforcement)}`);
|
|
7017
|
+
console.log(` ${chalk.dim('requires:')} ${(rule.required_completed_task_types || []).join(', ') || 'none'}`);
|
|
7018
|
+
}
|
|
7019
|
+
});
|
|
7020
|
+
|
|
7021
|
+
policyCmd
|
|
7022
|
+
.command('override <pipelineId>')
|
|
7023
|
+
.description('Record a policy override for one pipeline requirement or task type')
|
|
7024
|
+
.requiredOption('--task-types <types>', 'Comma-separated task types to override, e.g. tests,governance')
|
|
7025
|
+
.requiredOption('--reason <text>', 'Why this override is being granted')
|
|
7026
|
+
.option('--by <actor>', 'Who approved the override', 'operator')
|
|
7027
|
+
.option('--json', 'Output raw JSON')
|
|
7028
|
+
.action((pipelineId, opts) => {
|
|
7029
|
+
const repoRoot = getRepo();
|
|
7030
|
+
const db = getDb(repoRoot);
|
|
7031
|
+
const taskTypes = String(opts.taskTypes || '')
|
|
7032
|
+
.split(',')
|
|
7033
|
+
.map((value) => value.trim())
|
|
7034
|
+
.filter(Boolean);
|
|
7035
|
+
|
|
7036
|
+
if (taskTypes.length === 0) {
|
|
7037
|
+
db.close();
|
|
7038
|
+
printErrorWithNext('At least one task type is required for a policy override.', 'switchman policy override <pipelineId> --task-types tests --reason "why"');
|
|
7039
|
+
process.exit(1);
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
const override = createPolicyOverride(db, {
|
|
7043
|
+
pipelineId,
|
|
7044
|
+
taskTypes,
|
|
7045
|
+
requirementKeys: taskTypes.map((taskType) => `completed_task_type:${taskType}`),
|
|
7046
|
+
reason: opts.reason,
|
|
7047
|
+
approvedBy: opts.by || null,
|
|
7048
|
+
});
|
|
7049
|
+
db.close();
|
|
7050
|
+
|
|
7051
|
+
if (opts.json) {
|
|
7052
|
+
console.log(JSON.stringify({ override }, null, 2));
|
|
7053
|
+
return;
|
|
7054
|
+
}
|
|
7055
|
+
|
|
7056
|
+
console.log(`${chalk.yellow('!')} Policy override ${chalk.cyan(override.id)} recorded for ${chalk.cyan(pipelineId)}`);
|
|
7057
|
+
console.log(` ${chalk.dim('task types:')} ${taskTypes.join(', ')}`);
|
|
7058
|
+
console.log(` ${chalk.dim('approved by:')} ${opts.by || 'operator'}`);
|
|
7059
|
+
console.log(` ${chalk.dim('reason:')} ${opts.reason}`);
|
|
7060
|
+
console.log(` ${chalk.dim('next:')} switchman pipeline status ${pipelineId}`);
|
|
7061
|
+
});
|
|
7062
|
+
|
|
7063
|
+
policyCmd
|
|
7064
|
+
.command('revoke <overrideId>')
|
|
7065
|
+
.description('Revoke a previously recorded policy override')
|
|
7066
|
+
.option('--reason <text>', 'Why the override is being revoked')
|
|
7067
|
+
.option('--by <actor>', 'Who revoked the override', 'operator')
|
|
7068
|
+
.option('--json', 'Output raw JSON')
|
|
7069
|
+
.action((overrideId, opts) => {
|
|
7070
|
+
const repoRoot = getRepo();
|
|
7071
|
+
const db = getDb(repoRoot);
|
|
7072
|
+
const override = revokePolicyOverride(db, overrideId, {
|
|
7073
|
+
revokedBy: opts.by || null,
|
|
7074
|
+
reason: opts.reason || null,
|
|
7075
|
+
});
|
|
7076
|
+
db.close();
|
|
7077
|
+
|
|
7078
|
+
if (opts.json) {
|
|
7079
|
+
console.log(JSON.stringify({ override }, null, 2));
|
|
7080
|
+
return;
|
|
7081
|
+
}
|
|
7082
|
+
|
|
7083
|
+
console.log(`${chalk.green('✓')} Policy override ${chalk.cyan(override.id)} revoked`);
|
|
7084
|
+
console.log(` ${chalk.dim('pipeline:')} ${override.pipeline_id}`);
|
|
7085
|
+
console.log(` ${chalk.dim('revoked by:')} ${opts.by || 'operator'}`);
|
|
7086
|
+
if (opts.reason) {
|
|
7087
|
+
console.log(` ${chalk.dim('reason:')} ${opts.reason}`);
|
|
7088
|
+
}
|
|
7089
|
+
});
|
|
7090
|
+
|
|
7091
|
+
policyCmd
|
|
7092
|
+
.command('list-overrides <pipelineId>')
|
|
7093
|
+
.description('Show policy overrides recorded for a pipeline')
|
|
7094
|
+
.option('--json', 'Output raw JSON')
|
|
7095
|
+
.action((pipelineId, opts) => {
|
|
7096
|
+
const repoRoot = getRepo();
|
|
7097
|
+
const db = getDb(repoRoot);
|
|
7098
|
+
const overrides = listPolicyOverrides(db, { pipelineId, limit: 100 });
|
|
7099
|
+
db.close();
|
|
7100
|
+
|
|
7101
|
+
if (opts.json) {
|
|
7102
|
+
console.log(JSON.stringify({ pipeline_id: pipelineId, overrides }, null, 2));
|
|
7103
|
+
return;
|
|
7104
|
+
}
|
|
7105
|
+
|
|
7106
|
+
console.log(chalk.bold(`Policy overrides for ${pipelineId}`));
|
|
7107
|
+
if (overrides.length === 0) {
|
|
7108
|
+
console.log(` ${chalk.green('No overrides recorded.')}`);
|
|
7109
|
+
return;
|
|
7110
|
+
}
|
|
7111
|
+
for (const entry of overrides) {
|
|
7112
|
+
console.log(` ${chalk.cyan(entry.id)} ${chalk.dim(entry.status)}`);
|
|
7113
|
+
console.log(` ${chalk.dim('task types:')} ${(entry.task_types || []).join(', ') || 'none'}`);
|
|
7114
|
+
console.log(` ${chalk.dim('approved by:')} ${entry.approved_by || 'unknown'}`);
|
|
7115
|
+
console.log(` ${chalk.dim('reason:')} ${entry.reason}`);
|
|
7116
|
+
}
|
|
7117
|
+
});
|
|
7118
|
+
|
|
7119
|
+
|
|
7120
|
+
|
|
7121
|
+
// ── login ──────────────────────────────────────────────────────────────────────
|
|
7122
|
+
|
|
7123
|
+
program
|
|
7124
|
+
.command('login')
|
|
7125
|
+
.description('Sign in with GitHub to activate Switchman Pro')
|
|
7126
|
+
.option('--invite <token>', 'Join a team with an invite token')
|
|
7127
|
+
.option('--status', 'Show current login status')
|
|
7128
|
+
.addHelpText('after', `
|
|
7129
|
+
Examples:
|
|
7130
|
+
switchman login
|
|
7131
|
+
switchman login --status
|
|
7132
|
+
switchman login --invite tk_8f3a2c
|
|
7133
|
+
`)
|
|
7134
|
+
.action(async (opts) => {
|
|
7135
|
+
// Show current status
|
|
7136
|
+
if (opts.status) {
|
|
7137
|
+
const creds = readCredentials();
|
|
7138
|
+
if (!creds?.access_token) {
|
|
7139
|
+
console.log('');
|
|
7140
|
+
console.log(` ${chalk.dim('Status:')} Not logged in`);
|
|
7141
|
+
console.log(` ${chalk.dim('Run: ')} ${chalk.cyan('switchman login')}`);
|
|
7142
|
+
console.log('');
|
|
7143
|
+
return;
|
|
7144
|
+
}
|
|
7145
|
+
|
|
7146
|
+
const licence = await checkLicence();
|
|
7147
|
+
console.log('');
|
|
7148
|
+
if (licence.valid) {
|
|
7149
|
+
console.log(` ${chalk.green('✓')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')}`);
|
|
7150
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
7151
|
+
if (licence.current_period_end) {
|
|
7152
|
+
console.log(` ${chalk.dim('Renews:')} ${new Date(licence.current_period_end).toLocaleDateString()}`);
|
|
7153
|
+
}
|
|
7154
|
+
if (licence.offline) {
|
|
7155
|
+
console.log(` ${chalk.dim('(offline cache)')}`);
|
|
7156
|
+
}
|
|
7157
|
+
} else {
|
|
7158
|
+
console.log(` ${chalk.yellow('⚠')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')} but no active Pro licence`);
|
|
7159
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
7160
|
+
}
|
|
7161
|
+
console.log('');
|
|
7162
|
+
return;
|
|
7163
|
+
}
|
|
7164
|
+
|
|
7165
|
+
// Handle --invite token
|
|
7166
|
+
if (opts.invite) {
|
|
7167
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
7168
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
7169
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
7170
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
7171
|
+
|
|
7172
|
+
const creds = readCredentials();
|
|
7173
|
+
if (!creds?.access_token) {
|
|
7174
|
+
console.log('');
|
|
7175
|
+
console.log(chalk.yellow(' You need to sign in first before accepting an invite.'));
|
|
7176
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')} ${chalk.dim('then try again with --invite')}`);
|
|
7177
|
+
console.log('');
|
|
7178
|
+
process.exit(1);
|
|
7179
|
+
}
|
|
7180
|
+
|
|
7181
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
7182
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
7183
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
7184
|
+
});
|
|
7185
|
+
|
|
7186
|
+
const { data: { user } } = await sb.auth.getUser();
|
|
7187
|
+
if (!user) {
|
|
7188
|
+
console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
|
|
7189
|
+
process.exit(1);
|
|
7190
|
+
}
|
|
7191
|
+
|
|
7192
|
+
// Look up the invite
|
|
7193
|
+
const { data: invite, error: inviteError } = await sb
|
|
7194
|
+
.from('team_invites')
|
|
7195
|
+
.select('id, team_id, email, accepted')
|
|
7196
|
+
.eq('token', opts.invite)
|
|
7197
|
+
.maybeSingle();
|
|
7198
|
+
|
|
7199
|
+
if (inviteError || !invite) {
|
|
7200
|
+
console.log('');
|
|
7201
|
+
console.log(chalk.red(' ✗ Invite token not found or already used.'));
|
|
7202
|
+
console.log(` ${chalk.dim('Ask your teammate to send a new invite.')}`);
|
|
7203
|
+
console.log('');
|
|
7204
|
+
process.exit(1);
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
if (invite.accepted) {
|
|
7208
|
+
console.log('');
|
|
7209
|
+
console.log(chalk.yellow(' ⚠ This invite has already been accepted.'));
|
|
7210
|
+
console.log('');
|
|
7211
|
+
process.exit(1);
|
|
7212
|
+
}
|
|
7213
|
+
|
|
7214
|
+
// Add user to the team
|
|
7215
|
+
const { error: memberError } = await sb
|
|
7216
|
+
.from('team_members')
|
|
7217
|
+
.insert({ team_id: invite.team_id, user_id: user.id, role: 'member' });
|
|
7218
|
+
|
|
7219
|
+
if (memberError && !memberError.message.includes('duplicate')) {
|
|
7220
|
+
console.log(chalk.red(` ✗ Could not join team: ${memberError.message}`));
|
|
7221
|
+
process.exit(1);
|
|
7222
|
+
}
|
|
7223
|
+
|
|
7224
|
+
// Mark invite as accepted
|
|
7225
|
+
await sb
|
|
7226
|
+
.from('team_invites')
|
|
7227
|
+
.update({ accepted: true })
|
|
7228
|
+
.eq('id', invite.id);
|
|
7229
|
+
|
|
7230
|
+
console.log('');
|
|
7231
|
+
console.log(` ${chalk.green('✓')} Joined the team successfully`);
|
|
7232
|
+
console.log(` ${chalk.dim('Your agents now share coordination with your teammates.')}`);
|
|
7233
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman status')} ${chalk.dim('to see the shared view.')}`);
|
|
7234
|
+
console.log('');
|
|
7235
|
+
return;
|
|
7236
|
+
}
|
|
7237
|
+
|
|
7238
|
+
// Already logged in?
|
|
7239
|
+
const existing = readCredentials();
|
|
7240
|
+
if (existing?.access_token) {
|
|
7241
|
+
const licence = await checkLicence();
|
|
7242
|
+
if (licence.valid) {
|
|
7243
|
+
console.log('');
|
|
7244
|
+
console.log(` ${chalk.green('✓')} Already logged in as ${chalk.cyan(existing.email ?? 'unknown')}`);
|
|
7245
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
7246
|
+
console.log(` ${chalk.dim('Run')} ${chalk.cyan('switchman login --status')} ${chalk.dim('to see full details')}`);
|
|
7247
|
+
console.log('');
|
|
7248
|
+
return;
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
|
|
7252
|
+
console.log('');
|
|
7253
|
+
console.log(chalk.bold(' Switchman Pro — sign in with GitHub'));
|
|
7254
|
+
console.log('');
|
|
7255
|
+
|
|
7256
|
+
const spinner = ora('Waiting for GitHub sign-in...').start();
|
|
7257
|
+
spinner.stop();
|
|
7258
|
+
|
|
7259
|
+
const result = await loginWithGitHub();
|
|
7260
|
+
|
|
7261
|
+
if (!result.success) {
|
|
7262
|
+
console.log(` ${chalk.red('✗')} Sign in failed: ${result.error ?? 'unknown error'}`);
|
|
7263
|
+
console.log(` ${chalk.dim('Try again or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
7264
|
+
console.log('');
|
|
7265
|
+
process.exit(1);
|
|
7266
|
+
}
|
|
7267
|
+
|
|
7268
|
+
// Verify the licence
|
|
7269
|
+
const licence = await checkLicence();
|
|
7270
|
+
|
|
7271
|
+
console.log(` ${chalk.green('✓')} Signed in as ${chalk.cyan(result.email ?? 'unknown')}`);
|
|
7272
|
+
|
|
7273
|
+
if (licence.valid) {
|
|
7274
|
+
console.log(` ${chalk.green('✓')} Switchman Pro active`);
|
|
7275
|
+
console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
|
|
7276
|
+
console.log('');
|
|
7277
|
+
console.log(` ${chalk.dim('Credentials saved · valid 24h · 7-day offline grace')}`);
|
|
7278
|
+
console.log('');
|
|
7279
|
+
console.log(` Run ${chalk.cyan('switchman setup --agents 10')} to use unlimited agents.`);
|
|
7280
|
+
} else {
|
|
7281
|
+
console.log(` ${chalk.yellow('⚠')} No active Pro licence found`);
|
|
7282
|
+
console.log('');
|
|
7283
|
+
console.log(` If you just paid, it may take a moment to activate.`);
|
|
7284
|
+
console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
|
|
7285
|
+
}
|
|
7286
|
+
|
|
7287
|
+
console.log('');
|
|
7288
|
+
});
|
|
7289
|
+
|
|
7290
|
+
|
|
7291
|
+
// ── logout ─────────────────────────────────────────────────────────────────────
|
|
7292
|
+
|
|
7293
|
+
program
|
|
7294
|
+
.command('logout')
|
|
7295
|
+
.description('Sign out and remove saved credentials')
|
|
7296
|
+
.action(() => {
|
|
7297
|
+
clearCredentials();
|
|
7298
|
+
console.log('');
|
|
7299
|
+
console.log(` ${chalk.green('✓')} Signed out — credentials removed`);
|
|
7300
|
+
console.log('');
|
|
7301
|
+
});
|
|
7302
|
+
|
|
7303
|
+
|
|
7304
|
+
// ── upgrade ────────────────────────────────────────────────────────────────────
|
|
7305
|
+
|
|
7306
|
+
program
|
|
7307
|
+
.command('upgrade')
|
|
7308
|
+
.description('Open the Switchman Pro page in your browser')
|
|
7309
|
+
.action(async () => {
|
|
7310
|
+
console.log('');
|
|
7311
|
+
console.log(` Opening ${chalk.cyan(PRO_PAGE_URL)}...`);
|
|
7312
|
+
console.log('');
|
|
7313
|
+
const { default: open } = await import('open');
|
|
7314
|
+
await open(PRO_PAGE_URL);
|
|
7315
|
+
});
|
|
7316
|
+
|
|
7317
|
+
// ── team ───────────────────────────────────────────────────────────────────────
|
|
7318
|
+
|
|
7319
|
+
const teamCmd = program
|
|
7320
|
+
.command('team')
|
|
7321
|
+
.description('Manage your Switchman Pro team');
|
|
7322
|
+
|
|
7323
|
+
teamCmd
|
|
7324
|
+
.command('invite <email>')
|
|
7325
|
+
.description('Invite a teammate to your shared coordination')
|
|
7326
|
+
.addHelpText('after', `
|
|
7327
|
+
Examples:
|
|
7328
|
+
switchman team invite alice@example.com
|
|
7329
|
+
`)
|
|
7330
|
+
.action(async (email) => {
|
|
7331
|
+
const licence = await checkLicence();
|
|
7332
|
+
if (!licence.valid) {
|
|
7333
|
+
console.log('');
|
|
7334
|
+
console.log(chalk.yellow(' ⚠ Team invites require Switchman Pro.'));
|
|
7335
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
7336
|
+
console.log('');
|
|
7337
|
+
process.exit(1);
|
|
7338
|
+
}
|
|
7339
|
+
|
|
7340
|
+
const repoRoot = getRepo();
|
|
7341
|
+
const creds = readCredentials();
|
|
7342
|
+
if (!creds?.access_token) {
|
|
7343
|
+
console.log('');
|
|
7344
|
+
console.log(chalk.yellow(' ⚠ You need to be logged in.'));
|
|
7345
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')}`);
|
|
7346
|
+
console.log('');
|
|
7347
|
+
process.exit(1);
|
|
7348
|
+
}
|
|
7349
|
+
|
|
7350
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
7351
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
7352
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
7353
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
7354
|
+
|
|
7355
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
7356
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
7357
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
7358
|
+
});
|
|
7359
|
+
|
|
7360
|
+
const { data: { user } } = await sb.auth.getUser();
|
|
7361
|
+
if (!user) {
|
|
7362
|
+
console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
|
|
7363
|
+
process.exit(1);
|
|
7364
|
+
}
|
|
7365
|
+
|
|
7366
|
+
// Get or create team
|
|
7367
|
+
let teamId;
|
|
7368
|
+
const { data: membership } = await sb
|
|
7369
|
+
.from('team_members')
|
|
7370
|
+
.select('team_id')
|
|
7371
|
+
.eq('user_id', user.id)
|
|
7372
|
+
.maybeSingle();
|
|
7373
|
+
|
|
7374
|
+
if (membership?.team_id) {
|
|
7375
|
+
teamId = membership.team_id;
|
|
7376
|
+
} else {
|
|
7377
|
+
// Create a new team
|
|
7378
|
+
const { data: team, error: teamError } = await sb
|
|
7379
|
+
.from('teams')
|
|
7380
|
+
.insert({ owner_id: user.id, name: 'My Team' })
|
|
7381
|
+
.select('id')
|
|
7382
|
+
.single();
|
|
7383
|
+
|
|
7384
|
+
if (teamError) {
|
|
7385
|
+
console.log(chalk.red(` ✗ Could not create team: ${teamError.message}`));
|
|
7386
|
+
process.exit(1);
|
|
7387
|
+
}
|
|
7388
|
+
|
|
7389
|
+
teamId = team.id;
|
|
7390
|
+
|
|
7391
|
+
// Add the owner as a member
|
|
7392
|
+
await sb.from('team_members').insert({
|
|
7393
|
+
team_id: teamId,
|
|
7394
|
+
user_id: user.id,
|
|
7395
|
+
role: 'owner',
|
|
7396
|
+
});
|
|
7397
|
+
}
|
|
7398
|
+
|
|
7399
|
+
// Create the invite
|
|
7400
|
+
const { data: invite, error: inviteError } = await sb
|
|
7401
|
+
.from('team_invites')
|
|
7402
|
+
.insert({
|
|
7403
|
+
team_id: teamId,
|
|
7404
|
+
invited_by: user.id,
|
|
7405
|
+
email,
|
|
7406
|
+
})
|
|
7407
|
+
.select('token')
|
|
7408
|
+
.single();
|
|
7409
|
+
|
|
7410
|
+
if (inviteError) {
|
|
7411
|
+
console.log(chalk.red(` ✗ Could not create invite: ${inviteError.message}`));
|
|
7412
|
+
process.exit(1);
|
|
7413
|
+
}
|
|
7414
|
+
|
|
7415
|
+
console.log('');
|
|
7416
|
+
console.log(` ${chalk.green('✓')} Invite created for ${chalk.cyan(email)}`);
|
|
7417
|
+
console.log('');
|
|
7418
|
+
console.log(` They can join with:`);
|
|
7419
|
+
console.log(` ${chalk.cyan(`switchman login --invite ${invite.token}`)}`);
|
|
7420
|
+
console.log('');
|
|
7421
|
+
});
|
|
7422
|
+
|
|
7423
|
+
teamCmd
|
|
7424
|
+
.command('list')
|
|
7425
|
+
.description('List your team members')
|
|
7426
|
+
.action(async () => {
|
|
7427
|
+
const licence = await checkLicence();
|
|
7428
|
+
if (!licence.valid) {
|
|
7429
|
+
console.log('');
|
|
7430
|
+
console.log(chalk.yellow(' ⚠ Team features require Switchman Pro.'));
|
|
7431
|
+
console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
|
|
7432
|
+
console.log('');
|
|
7433
|
+
process.exit(1);
|
|
7434
|
+
}
|
|
7435
|
+
|
|
7436
|
+
const creds = readCredentials();
|
|
7437
|
+
if (!creds?.access_token) {
|
|
7438
|
+
console.log(chalk.red(' ✗ Not logged in. Run: switchman login'));
|
|
7439
|
+
process.exit(1);
|
|
7440
|
+
}
|
|
7441
|
+
|
|
7442
|
+
const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
7443
|
+
?? 'https://afilbolhlkiingnsupgr.supabase.co';
|
|
7444
|
+
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
7445
|
+
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
7446
|
+
|
|
7447
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
7448
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
7449
|
+
global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
|
|
7450
|
+
});
|
|
7451
|
+
|
|
7452
|
+
const { data: membership } = await sb
|
|
7453
|
+
.from('team_members')
|
|
7454
|
+
.select('team_id')
|
|
7455
|
+
.eq('user_id', (await sb.auth.getUser()).data.user?.id)
|
|
7456
|
+
.maybeSingle();
|
|
7457
|
+
|
|
7458
|
+
if (!membership?.team_id) {
|
|
7459
|
+
console.log('');
|
|
7460
|
+
console.log(` ${chalk.dim('No team yet. Invite someone with:')} ${chalk.cyan('switchman team invite <email>')}`);
|
|
7461
|
+
console.log('');
|
|
7462
|
+
return;
|
|
7463
|
+
}
|
|
7464
|
+
|
|
7465
|
+
const { data: members } = await sb
|
|
7466
|
+
.from('team_members')
|
|
7467
|
+
.select('user_id, role, joined_at')
|
|
7468
|
+
.eq('team_id', membership.team_id);
|
|
7469
|
+
|
|
7470
|
+
const { data: invites } = await sb
|
|
7471
|
+
.from('team_invites')
|
|
7472
|
+
.select('email, token, accepted, created_at')
|
|
7473
|
+
.eq('team_id', membership.team_id)
|
|
7474
|
+
.eq('accepted', false);
|
|
7475
|
+
|
|
7476
|
+
console.log('');
|
|
7477
|
+
for (const m of members ?? []) {
|
|
7478
|
+
const roleLabel = m.role === 'owner' ? chalk.dim('(owner)') : chalk.dim('(member)');
|
|
7479
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(m.user_id.slice(0, 8))} ${roleLabel}`);
|
|
7480
|
+
}
|
|
7481
|
+
for (const inv of invites ?? []) {
|
|
7482
|
+
console.log(` ${chalk.dim('○')} ${chalk.cyan(inv.email)} ${chalk.dim('(invited)')}`);
|
|
7483
|
+
}
|
|
7484
|
+
console.log('');
|
|
7485
|
+
});
|
|
7486
|
+
|
|
3678
7487
|
program.parse();
|