switchman-dev 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -16
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +1 -1
- package/src/cli/index.js +2077 -216
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1848 -73
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +42 -5
- package/src/core/ignore.js +47 -0
- package/src/core/mcp.js +47 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +153 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +491 -23
package/src/cli/index.js
CHANGED
|
@@ -19,22 +19,31 @@
|
|
|
19
19
|
import { program } from 'commander';
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
import ora from 'ora';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { fileURLToPath } from 'url';
|
|
25
|
-
import { execSync } from 'child_process';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { execSync, spawn } from 'child_process';
|
|
26
24
|
|
|
27
25
|
import { findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
|
|
28
26
|
import {
|
|
29
27
|
initDb, openDb,
|
|
30
|
-
|
|
28
|
+
DEFAULT_STALE_LEASE_MINUTES,
|
|
29
|
+
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
30
|
+
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
31
31
|
registerWorktree, listWorktrees,
|
|
32
32
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
|
|
33
|
-
|
|
33
|
+
verifyAuditTrail,
|
|
34
34
|
} from '../core/db.js';
|
|
35
35
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
import { upsertProjectMcpConfig } from '../core/mcp.js';
|
|
37
|
+
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
38
|
+
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
39
|
+
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
40
|
+
import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
|
|
41
|
+
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
|
|
42
|
+
import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
|
|
43
|
+
|
|
44
|
+
function installMcpConfig(targetDirs) {
|
|
45
|
+
return targetDirs.map((targetDir) => upsertProjectMcpConfig(targetDir));
|
|
46
|
+
}
|
|
38
47
|
|
|
39
48
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
40
49
|
|
|
@@ -60,14 +69,38 @@ function statusBadge(status) {
|
|
|
60
69
|
const colors = {
|
|
61
70
|
pending: chalk.yellow,
|
|
62
71
|
in_progress: chalk.blue,
|
|
72
|
+
active: chalk.blue,
|
|
73
|
+
completed: chalk.green,
|
|
63
74
|
done: chalk.green,
|
|
64
75
|
failed: chalk.red,
|
|
76
|
+
expired: chalk.red,
|
|
65
77
|
idle: chalk.gray,
|
|
66
78
|
busy: chalk.blue,
|
|
79
|
+
managed: chalk.green,
|
|
80
|
+
observed: chalk.yellow,
|
|
81
|
+
non_compliant: chalk.red,
|
|
82
|
+
stale: chalk.red,
|
|
67
83
|
};
|
|
68
84
|
return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
function getCurrentWorktreeName(explicitWorktree) {
|
|
88
|
+
return explicitWorktree || process.cwd().split('/').pop();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function taskJsonWithLease(task, worktree, lease) {
|
|
92
|
+
return {
|
|
93
|
+
task: {
|
|
94
|
+
...task,
|
|
95
|
+
worktree,
|
|
96
|
+
status: 'in_progress',
|
|
97
|
+
lease_id: lease?.id ?? null,
|
|
98
|
+
lease_status: lease?.status ?? null,
|
|
99
|
+
heartbeat_at: lease?.heartbeat_at ?? null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
function printTable(rows, columns) {
|
|
72
105
|
if (!rows.length) return;
|
|
73
106
|
const widths = columns.map(col =>
|
|
@@ -84,6 +117,412 @@ function printTable(rows, columns) {
|
|
|
84
117
|
}
|
|
85
118
|
}
|
|
86
119
|
|
|
120
|
+
function sleepSync(ms) {
|
|
121
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function summarizeLeaseScope(db, lease) {
|
|
125
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
126
|
+
const pathScopes = reservations
|
|
127
|
+
.filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
|
|
128
|
+
.map((reservation) => reservation.scope_pattern);
|
|
129
|
+
if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
|
|
130
|
+
if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
|
|
131
|
+
|
|
132
|
+
const subsystemScopes = reservations
|
|
133
|
+
.filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
|
|
134
|
+
.map((reservation) => reservation.subsystem_tag);
|
|
135
|
+
if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
|
|
136
|
+
if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isBusyError(err) {
|
|
141
|
+
const message = String(err?.message || '').toLowerCase();
|
|
142
|
+
return message.includes('database is locked') || message.includes('sqlite_busy');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function humanizeReasonCode(reasonCode) {
|
|
146
|
+
const labels = {
|
|
147
|
+
no_active_lease: 'no active lease',
|
|
148
|
+
lease_expired: 'lease expired',
|
|
149
|
+
worktree_mismatch: 'wrong worktree',
|
|
150
|
+
path_not_claimed: 'path not claimed',
|
|
151
|
+
path_claimed_by_other_lease: 'claimed by another lease',
|
|
152
|
+
path_scoped_by_other_lease: 'scoped by another lease',
|
|
153
|
+
path_within_task_scope: 'within task scope',
|
|
154
|
+
policy_exception_required: 'policy exception required',
|
|
155
|
+
policy_exception_allowed: 'policy exception allowed',
|
|
156
|
+
changes_outside_claims: 'changed files outside claims',
|
|
157
|
+
changes_outside_task_scope: 'changed files outside task scope',
|
|
158
|
+
missing_expected_tests: 'missing expected tests',
|
|
159
|
+
missing_expected_docs: 'missing expected docs',
|
|
160
|
+
missing_expected_source_changes: 'missing expected source changes',
|
|
161
|
+
objective_not_evidenced: 'task objective not evidenced',
|
|
162
|
+
no_changes_detected: 'no changes detected',
|
|
163
|
+
task_execution_timeout: 'task execution timed out',
|
|
164
|
+
task_failed: 'task failed',
|
|
165
|
+
agent_command_failed: 'agent command failed',
|
|
166
|
+
rejected: 'rejected',
|
|
167
|
+
};
|
|
168
|
+
return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nextStepForReason(reasonCode) {
|
|
172
|
+
const actions = {
|
|
173
|
+
no_active_lease: 'reacquire the task or lease before writing',
|
|
174
|
+
lease_expired: 'refresh or reacquire the lease, then retry',
|
|
175
|
+
worktree_mismatch: 'run the task from the assigned worktree',
|
|
176
|
+
path_not_claimed: 'claim the file before editing it',
|
|
177
|
+
path_claimed_by_other_lease: 'wait for the other task or pick a different file',
|
|
178
|
+
changes_outside_claims: 'claim all edited files or narrow the task scope',
|
|
179
|
+
changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
|
|
180
|
+
missing_expected_tests: 'add test coverage before rerunning',
|
|
181
|
+
missing_expected_docs: 'add the expected docs change before rerunning',
|
|
182
|
+
missing_expected_source_changes: 'make a source change inside the task scope',
|
|
183
|
+
objective_not_evidenced: 'align the output more closely to the task objective',
|
|
184
|
+
no_changes_detected: 'produce a tracked change or close the task differently',
|
|
185
|
+
task_execution_timeout: 'raise the timeout or reduce task size',
|
|
186
|
+
agent_command_failed: 'inspect stderr/stdout and rerun the agent',
|
|
187
|
+
};
|
|
188
|
+
return actions[reasonCode] || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function latestTaskFailure(task) {
|
|
192
|
+
const failureLine = String(task.description || '')
|
|
193
|
+
.split('\n')
|
|
194
|
+
.map((line) => line.trim())
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.reverse()
|
|
197
|
+
.find((line) => line.startsWith('FAILED: '));
|
|
198
|
+
if (!failureLine) return null;
|
|
199
|
+
const failureText = failureLine.slice('FAILED: '.length);
|
|
200
|
+
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
201
|
+
return {
|
|
202
|
+
reason_code: reasonMatch ? reasonMatch[1] : null,
|
|
203
|
+
summary: reasonMatch ? reasonMatch[2] : failureText,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function analyzeTaskScope(title, description = '') {
|
|
208
|
+
const text = `${title}\n${description}`.toLowerCase();
|
|
209
|
+
const broadPatterns = [
|
|
210
|
+
/\brefactor\b/,
|
|
211
|
+
/\bwhole repo\b/,
|
|
212
|
+
/\bentire repo\b/,
|
|
213
|
+
/\bacross the repo\b/,
|
|
214
|
+
/\bacross the codebase\b/,
|
|
215
|
+
/\bmultiple modules\b/,
|
|
216
|
+
/\ball routes\b/,
|
|
217
|
+
/\bevery route\b/,
|
|
218
|
+
/\ball files\b/,
|
|
219
|
+
/\bevery file\b/,
|
|
220
|
+
/\brename\b.*\bacross\b/,
|
|
221
|
+
/\bsweep(ing)?\b/,
|
|
222
|
+
/\bglobal\b/,
|
|
223
|
+
/\bwide\b/,
|
|
224
|
+
/\blarge\b/,
|
|
225
|
+
];
|
|
226
|
+
const matches = broadPatterns.filter((pattern) => pattern.test(text));
|
|
227
|
+
if (matches.length === 0) return null;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
level: 'warn',
|
|
231
|
+
summary: 'This task looks broad and may fan out across many files or shared areas.',
|
|
232
|
+
next_step: 'Split it into smaller tasks or use `switchman pipeline start` so Switchman can plan and govern the work explicitly.',
|
|
233
|
+
command: `switchman pipeline start "${title.replace(/"/g, '\\"')}"`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function commandForFailedTask(task, failure) {
|
|
238
|
+
if (!task?.id) return null;
|
|
239
|
+
switch (failure?.reason_code) {
|
|
240
|
+
case 'changes_outside_task_scope':
|
|
241
|
+
case 'objective_not_evidenced':
|
|
242
|
+
case 'missing_expected_tests':
|
|
243
|
+
case 'missing_expected_docs':
|
|
244
|
+
case 'missing_expected_source_changes':
|
|
245
|
+
case 'no_changes_detected':
|
|
246
|
+
return `switchman pipeline status ${task.id.split('-').slice(0, -1).join('-')}`;
|
|
247
|
+
default:
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
|
|
253
|
+
const failedTasks = tasks
|
|
254
|
+
.filter((task) => task.status === 'failed')
|
|
255
|
+
.map((task) => {
|
|
256
|
+
const failure = latestTaskFailure(task);
|
|
257
|
+
return {
|
|
258
|
+
id: task.id,
|
|
259
|
+
title: task.title,
|
|
260
|
+
worktree: task.worktree || null,
|
|
261
|
+
reason_code: failure?.reason_code || null,
|
|
262
|
+
summary: failure?.summary || 'task failed without a recorded summary',
|
|
263
|
+
next_step: nextStepForReason(failure?.reason_code) || 'inspect the task output and rerun with a narrower scope',
|
|
264
|
+
command: commandForFailedTask(task, failure),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => ({
|
|
269
|
+
worktree: entry.worktree,
|
|
270
|
+
files: entry.files,
|
|
271
|
+
reason_code: entry.reasons?.[0]?.reason_code || null,
|
|
272
|
+
next_step: nextStepForReason(entry.reasons?.[0]?.reason_code) || 'inspect the changed files and bring them back under Switchman claims',
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
|
|
276
|
+
file: conflict.file,
|
|
277
|
+
worktrees: conflict.worktrees,
|
|
278
|
+
next_step: 'let one task finish first or re-scope the conflicting work',
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
const ownershipConflicts = (scanReport.ownershipConflicts || []).map((conflict) => ({
|
|
282
|
+
type: conflict.type,
|
|
283
|
+
worktree_a: conflict.worktreeA,
|
|
284
|
+
worktree_b: conflict.worktreeB,
|
|
285
|
+
subsystem_tag: conflict.subsystemTag || null,
|
|
286
|
+
scope_a: conflict.scopeA || null,
|
|
287
|
+
scope_b: conflict.scopeB || null,
|
|
288
|
+
next_step: 'split the task scopes or serialize work across the shared ownership boundary',
|
|
289
|
+
}));
|
|
290
|
+
const semanticConflicts = (scanReport.semanticConflicts || []).map((conflict) => ({
|
|
291
|
+
...conflict,
|
|
292
|
+
next_step: 'review the overlapping exported object or split the work across different boundaries',
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
const branchConflicts = scanReport.conflicts.map((conflict) => ({
|
|
296
|
+
worktree_a: conflict.worktreeA,
|
|
297
|
+
worktree_b: conflict.worktreeB,
|
|
298
|
+
files: conflict.conflictingFiles,
|
|
299
|
+
next_step: 'review the overlapping branches before merge',
|
|
300
|
+
}));
|
|
301
|
+
|
|
302
|
+
const attention = [
|
|
303
|
+
...staleLeases.map((lease) => ({
|
|
304
|
+
kind: 'stale_lease',
|
|
305
|
+
title: `${lease.worktree} lost its active heartbeat`,
|
|
306
|
+
detail: lease.task_title,
|
|
307
|
+
next_step: 'run `switchman lease reap` to return the task to pending',
|
|
308
|
+
command: 'switchman lease reap',
|
|
309
|
+
severity: 'block',
|
|
310
|
+
})),
|
|
311
|
+
...failedTasks.map((task) => ({
|
|
312
|
+
kind: 'failed_task',
|
|
313
|
+
title: task.title,
|
|
314
|
+
detail: task.summary,
|
|
315
|
+
next_step: task.next_step,
|
|
316
|
+
command: task.command,
|
|
317
|
+
severity: 'warn',
|
|
318
|
+
})),
|
|
319
|
+
...blockedWorktrees.map((entry) => ({
|
|
320
|
+
kind: 'unmanaged_changes',
|
|
321
|
+
title: `${entry.worktree} has unmanaged changed files`,
|
|
322
|
+
detail: `${entry.files.slice(0, 3).join(', ')}${entry.files.length > 3 ? ` +${entry.files.length - 3} more` : ''}`,
|
|
323
|
+
next_step: entry.next_step,
|
|
324
|
+
command: 'switchman scan',
|
|
325
|
+
severity: 'block',
|
|
326
|
+
})),
|
|
327
|
+
...fileConflicts.map((conflict) => ({
|
|
328
|
+
kind: 'file_conflict',
|
|
329
|
+
title: `${conflict.file} is being edited in multiple worktrees`,
|
|
330
|
+
detail: conflict.worktrees.join(', '),
|
|
331
|
+
next_step: conflict.next_step,
|
|
332
|
+
command: 'switchman scan',
|
|
333
|
+
severity: 'block',
|
|
334
|
+
})),
|
|
335
|
+
...ownershipConflicts.map((conflict) => ({
|
|
336
|
+
kind: 'ownership_conflict',
|
|
337
|
+
title: conflict.type === 'subsystem_overlap'
|
|
338
|
+
? `${conflict.worktree_a} and ${conflict.worktree_b} share subsystem ownership`
|
|
339
|
+
: `${conflict.worktree_a} and ${conflict.worktree_b} share scoped ownership`,
|
|
340
|
+
detail: conflict.type === 'subsystem_overlap'
|
|
341
|
+
? `subsystem:${conflict.subsystem_tag}`
|
|
342
|
+
: `${conflict.scope_a} ↔ ${conflict.scope_b}`,
|
|
343
|
+
next_step: conflict.next_step,
|
|
344
|
+
command: 'switchman scan',
|
|
345
|
+
severity: 'block',
|
|
346
|
+
})),
|
|
347
|
+
...semanticConflicts.map((conflict) => ({
|
|
348
|
+
kind: 'semantic_conflict',
|
|
349
|
+
title: conflict.type === 'semantic_object_overlap'
|
|
350
|
+
? `${conflict.worktreeA} and ${conflict.worktreeB} changed the same exported object`
|
|
351
|
+
: `${conflict.worktreeA} and ${conflict.worktreeB} changed semantically similar objects`,
|
|
352
|
+
detail: `${conflict.object_name} (${conflict.fileA} ↔ ${conflict.fileB})`,
|
|
353
|
+
next_step: conflict.next_step,
|
|
354
|
+
command: 'switchman gate ai',
|
|
355
|
+
severity: conflict.severity === 'blocked' ? 'block' : 'warn',
|
|
356
|
+
})),
|
|
357
|
+
...branchConflicts.map((conflict) => ({
|
|
358
|
+
kind: 'branch_conflict',
|
|
359
|
+
title: `${conflict.worktree_a} and ${conflict.worktree_b} have merge risk`,
|
|
360
|
+
detail: `${conflict.files.slice(0, 3).join(', ')}${conflict.files.length > 3 ? ` +${conflict.files.length - 3} more` : ''}`,
|
|
361
|
+
next_step: conflict.next_step,
|
|
362
|
+
command: 'switchman gate ai',
|
|
363
|
+
severity: 'block',
|
|
364
|
+
})),
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
if (aiGate.status === 'warn' || aiGate.status === 'blocked') {
|
|
368
|
+
attention.push({
|
|
369
|
+
kind: 'ai_merge_gate',
|
|
370
|
+
title: aiGate.status === 'blocked' ? 'AI merge gate blocked the repo' : 'AI merge gate wants manual review',
|
|
371
|
+
detail: aiGate.summary,
|
|
372
|
+
next_step: 'run `switchman gate ai` and review the risky worktree pairs',
|
|
373
|
+
command: 'switchman gate ai',
|
|
374
|
+
severity: aiGate.status === 'blocked' ? 'block' : 'warn',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const validation of aiGate.boundary_validations || []) {
|
|
379
|
+
attention.push({
|
|
380
|
+
kind: 'boundary_validation',
|
|
381
|
+
title: validation.summary,
|
|
382
|
+
detail: validation.rationale?.[0] || `missing ${validation.missing_task_types.join(', ')}`,
|
|
383
|
+
next_step: 'complete the missing validation work before merge',
|
|
384
|
+
command: validation.pipeline_id ? `switchman pipeline status ${validation.pipeline_id}` : 'switchman gate ai',
|
|
385
|
+
severity: validation.severity === 'blocked' ? 'block' : 'warn',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const invalidation of aiGate.dependency_invalidations || []) {
|
|
390
|
+
attention.push({
|
|
391
|
+
kind: 'dependency_invalidation',
|
|
392
|
+
title: invalidation.summary,
|
|
393
|
+
detail: `${invalidation.source_worktree || 'unknown'} -> ${invalidation.affected_worktree || 'unknown'} (${invalidation.stale_area})`,
|
|
394
|
+
next_step: 'rerun or re-review the stale task before merge',
|
|
395
|
+
command: invalidation.affected_pipeline_id ? `switchman pipeline status ${invalidation.affected_pipeline_id}` : 'switchman gate ai',
|
|
396
|
+
severity: invalidation.severity === 'blocked' ? 'block' : 'warn',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const health = attention.some((item) => item.severity === 'block')
|
|
401
|
+
? 'block'
|
|
402
|
+
: attention.some((item) => item.severity === 'warn')
|
|
403
|
+
? 'warn'
|
|
404
|
+
: 'healthy';
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
repo_root: repoRoot,
|
|
408
|
+
health,
|
|
409
|
+
summary: health === 'healthy'
|
|
410
|
+
? 'Repo looks healthy. Agents are coordinated and merge checks are clear.'
|
|
411
|
+
: health === 'warn'
|
|
412
|
+
? 'Repo is running, but there are issues that need review before merge.'
|
|
413
|
+
: 'Repo needs attention before more work or merge.',
|
|
414
|
+
counts: {
|
|
415
|
+
pending: tasks.filter((task) => task.status === 'pending').length,
|
|
416
|
+
in_progress: tasks.filter((task) => task.status === 'in_progress').length,
|
|
417
|
+
done: tasks.filter((task) => task.status === 'done').length,
|
|
418
|
+
failed: failedTasks.length,
|
|
419
|
+
active_leases: activeLeases.length,
|
|
420
|
+
stale_leases: staleLeases.length,
|
|
421
|
+
},
|
|
422
|
+
active_work: activeLeases.map((lease) => ({
|
|
423
|
+
worktree: lease.worktree,
|
|
424
|
+
task_id: lease.task_id,
|
|
425
|
+
task_title: lease.task_title,
|
|
426
|
+
heartbeat_at: lease.heartbeat_at,
|
|
427
|
+
scope_summary: summarizeLeaseScope(db, lease),
|
|
428
|
+
boundary_validation: getBoundaryValidationState(db, lease.id),
|
|
429
|
+
dependency_invalidations: listDependencyInvalidations(db, { affectedTaskId: lease.task_id }),
|
|
430
|
+
})),
|
|
431
|
+
attention,
|
|
432
|
+
merge_readiness: {
|
|
433
|
+
ci_gate_ok: scanReport.conflicts.length === 0
|
|
434
|
+
&& scanReport.fileConflicts.length === 0
|
|
435
|
+
&& (scanReport.ownershipConflicts?.length || 0) === 0
|
|
436
|
+
&& (scanReport.semanticConflicts?.length || 0) === 0
|
|
437
|
+
&& scanReport.unclaimedChanges.length === 0
|
|
438
|
+
&& scanReport.complianceSummary.non_compliant === 0
|
|
439
|
+
&& scanReport.complianceSummary.stale === 0
|
|
440
|
+
&& aiGate.status !== 'blocked'
|
|
441
|
+
&& (aiGate.dependency_invalidations || []).filter((item) => item.severity === 'blocked').length === 0,
|
|
442
|
+
ai_gate_status: aiGate.status,
|
|
443
|
+
boundary_validations: aiGate.boundary_validations || [],
|
|
444
|
+
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
445
|
+
compliance: scanReport.complianceSummary,
|
|
446
|
+
semantic_conflicts: scanReport.semanticConflicts || [],
|
|
447
|
+
},
|
|
448
|
+
next_steps: attention.length > 0
|
|
449
|
+
? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
|
|
450
|
+
: ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'],
|
|
451
|
+
suggested_commands: attention.length > 0
|
|
452
|
+
? [...new Set(attention.map((item) => item.command).filter(Boolean))].slice(0, 5)
|
|
453
|
+
: ['switchman gate ci', 'switchman scan'],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
|
|
458
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
459
|
+
try {
|
|
460
|
+
const task = getNextPendingTask(db);
|
|
461
|
+
if (!task) {
|
|
462
|
+
return { task: null, lease: null, exhausted: true };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const lease = startTaskLease(db, task.id, worktreeName, agent || null);
|
|
466
|
+
if (lease) {
|
|
467
|
+
return { task, lease, exhausted: false };
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (attempt < attempts) {
|
|
476
|
+
sleepSync(75 * attempt);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { task: null, lease: null, exhausted: false };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, agent, attempts = 20) {
|
|
484
|
+
let lastError = null;
|
|
485
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
486
|
+
let db = null;
|
|
487
|
+
try {
|
|
488
|
+
db = openDb(repoRoot);
|
|
489
|
+
const result = acquireNextTaskLease(db, worktreeName, agent, attempts);
|
|
490
|
+
db.close();
|
|
491
|
+
return result;
|
|
492
|
+
} catch (err) {
|
|
493
|
+
lastError = err;
|
|
494
|
+
try { db?.close(); } catch { /* no-op */ }
|
|
495
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
sleepSync(100 * attempt);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
throw lastError;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
|
|
505
|
+
let lastError = null;
|
|
506
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
507
|
+
let db = null;
|
|
508
|
+
try {
|
|
509
|
+
db = openDb(repoRoot);
|
|
510
|
+
completeTask(db, taskId);
|
|
511
|
+
releaseFileClaims(db, taskId);
|
|
512
|
+
db.close();
|
|
513
|
+
return;
|
|
514
|
+
} catch (err) {
|
|
515
|
+
lastError = err;
|
|
516
|
+
try { db?.close(); } catch { /* no-op */ }
|
|
517
|
+
if (!isBusyError(err) || attempt === attempts) {
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
sleepSync(100 * attempt);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
throw lastError;
|
|
524
|
+
}
|
|
525
|
+
|
|
87
526
|
// ─── Program ──────────────────────────────────────────────────────────────────
|
|
88
527
|
|
|
89
528
|
program
|
|
@@ -108,10 +547,13 @@ program
|
|
|
108
547
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
109
548
|
}
|
|
110
549
|
|
|
550
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
551
|
+
|
|
111
552
|
db.close();
|
|
112
553
|
spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
|
|
113
554
|
console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
|
|
114
555
|
console.log(chalk.dim(` Database: .switchman/switchman.db`));
|
|
556
|
+
console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
|
|
115
557
|
console.log('');
|
|
116
558
|
console.log(`Next steps:`);
|
|
117
559
|
console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
|
|
@@ -183,6 +625,8 @@ program
|
|
|
183
625
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
184
626
|
}
|
|
185
627
|
|
|
628
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
|
|
629
|
+
|
|
186
630
|
db.close();
|
|
187
631
|
|
|
188
632
|
const label = agentCount === 1 ? 'workspace' : 'workspaces';
|
|
@@ -195,11 +639,18 @@ program
|
|
|
195
639
|
console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
|
|
196
640
|
}
|
|
197
641
|
|
|
642
|
+
console.log('');
|
|
643
|
+
console.log(chalk.bold('MCP config:'));
|
|
644
|
+
for (const result of mcpConfigWrites) {
|
|
645
|
+
const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
|
|
646
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
198
649
|
console.log('');
|
|
199
650
|
console.log(chalk.bold('Next steps:'));
|
|
200
651
|
console.log(` 1. Add your tasks:`);
|
|
201
652
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
202
|
-
console.log(` 2. Open Claude Code in each folder above —
|
|
653
|
+
console.log(` 2. Open Claude Code in each folder above — the local .mcp.json will attach Switchman automatically`);
|
|
203
654
|
console.log(` 3. Check status at any time:`);
|
|
204
655
|
console.log(` ${chalk.cyan('switchman status')}`);
|
|
205
656
|
console.log('');
|
|
@@ -231,8 +682,14 @@ taskCmd
|
|
|
231
682
|
priority: parseInt(opts.priority),
|
|
232
683
|
});
|
|
233
684
|
db.close();
|
|
685
|
+
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
234
686
|
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
235
687
|
console.log(` ${chalk.dim(title)}`);
|
|
688
|
+
if (scopeWarning) {
|
|
689
|
+
console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
|
|
690
|
+
console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
|
|
691
|
+
console.log(chalk.cyan(` try: ${scopeWarning.command}`));
|
|
692
|
+
}
|
|
236
693
|
});
|
|
237
694
|
|
|
238
695
|
taskCmd
|
|
@@ -263,15 +720,15 @@ taskCmd
|
|
|
263
720
|
|
|
264
721
|
taskCmd
|
|
265
722
|
.command('assign <taskId> <worktree>')
|
|
266
|
-
.description('Assign a task to a worktree')
|
|
723
|
+
.description('Assign a task to a worktree (compatibility shim for lease acquire)')
|
|
267
724
|
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
268
725
|
.action((taskId, worktree, opts) => {
|
|
269
726
|
const repoRoot = getRepo();
|
|
270
727
|
const db = getDb(repoRoot);
|
|
271
|
-
const
|
|
728
|
+
const lease = startTaskLease(db, taskId, worktree, opts.agent);
|
|
272
729
|
db.close();
|
|
273
|
-
if (
|
|
274
|
-
console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)}`);
|
|
730
|
+
if (lease) {
|
|
731
|
+
console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
|
|
275
732
|
} else {
|
|
276
733
|
console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
|
|
277
734
|
}
|
|
@@ -282,11 +739,13 @@ taskCmd
|
|
|
282
739
|
.description('Mark a task as complete and release all file claims')
|
|
283
740
|
.action((taskId) => {
|
|
284
741
|
const repoRoot = getRepo();
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
742
|
+
try {
|
|
743
|
+
completeTaskWithRetries(repoRoot, taskId);
|
|
744
|
+
console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
console.error(chalk.red(err.message));
|
|
747
|
+
process.exitCode = 1;
|
|
748
|
+
}
|
|
290
749
|
});
|
|
291
750
|
|
|
292
751
|
taskCmd
|
|
@@ -303,300 +762,1702 @@ taskCmd
|
|
|
303
762
|
|
|
304
763
|
taskCmd
|
|
305
764
|
.command('next')
|
|
306
|
-
.description('Get and assign the next pending task (for
|
|
765
|
+
.description('Get and assign the next pending task (compatibility shim for lease next)')
|
|
307
766
|
.option('--json', 'Output as JSON')
|
|
308
767
|
.option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
|
|
309
768
|
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
310
769
|
.action((opts) => {
|
|
311
770
|
const repoRoot = getRepo();
|
|
312
|
-
const
|
|
313
|
-
const task =
|
|
771
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
772
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
314
773
|
|
|
315
774
|
if (!task) {
|
|
316
|
-
db.close();
|
|
317
775
|
if (opts.json) console.log(JSON.stringify({ task: null }));
|
|
318
|
-
else console.log(chalk.dim('No pending tasks.'));
|
|
776
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
777
|
+
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
319
778
|
return;
|
|
320
779
|
}
|
|
321
780
|
|
|
322
|
-
|
|
323
|
-
const worktreeName = opts.worktree || process.cwd().split('/').pop();
|
|
324
|
-
const assigned = assignTask(db, task.id, worktreeName, opts.agent || null);
|
|
325
|
-
db.close();
|
|
326
|
-
|
|
327
|
-
if (!assigned) {
|
|
328
|
-
// Race condition: another agent grabbed it between get and assign
|
|
781
|
+
if (!lease) {
|
|
329
782
|
if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
|
|
330
783
|
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
331
784
|
return;
|
|
332
785
|
}
|
|
333
786
|
|
|
334
787
|
if (opts.json) {
|
|
335
|
-
console.log(JSON.stringify(
|
|
788
|
+
console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
|
|
336
789
|
} else {
|
|
337
790
|
console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
|
|
338
|
-
console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
791
|
+
console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('lease:')} ${chalk.dim(lease.id)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
339
792
|
}
|
|
340
793
|
});
|
|
341
794
|
|
|
342
|
-
// ──
|
|
343
|
-
|
|
344
|
-
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
795
|
+
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
345
796
|
|
|
346
|
-
|
|
347
|
-
.command('add <name> <path> <branch>')
|
|
348
|
-
.description('Register a worktree with switchman')
|
|
349
|
-
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
350
|
-
.action((name, path, branch, opts) => {
|
|
351
|
-
const repoRoot = getRepo();
|
|
352
|
-
const db = getDb(repoRoot);
|
|
353
|
-
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
354
|
-
db.close();
|
|
355
|
-
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
356
|
-
});
|
|
797
|
+
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
357
798
|
|
|
358
|
-
|
|
359
|
-
.command('
|
|
360
|
-
.description('
|
|
361
|
-
.
|
|
799
|
+
pipelineCmd
|
|
800
|
+
.command('start <title>')
|
|
801
|
+
.description('Create a pipeline from one issue title and split it into execution subtasks')
|
|
802
|
+
.option('-d, --description <desc>', 'Issue description or markdown checklist')
|
|
803
|
+
.option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
|
|
804
|
+
.option('--id <id>', 'Custom pipeline ID')
|
|
805
|
+
.option('--json', 'Output raw JSON')
|
|
806
|
+
.action((title, opts) => {
|
|
362
807
|
const repoRoot = getRepo();
|
|
363
808
|
const db = getDb(repoRoot);
|
|
364
|
-
const
|
|
365
|
-
|
|
809
|
+
const result = startPipeline(db, {
|
|
810
|
+
title,
|
|
811
|
+
description: opts.description || null,
|
|
812
|
+
priority: Number.parseInt(opts.priority, 10),
|
|
813
|
+
pipelineId: opts.id || null,
|
|
814
|
+
});
|
|
366
815
|
db.close();
|
|
367
816
|
|
|
368
|
-
if (
|
|
369
|
-
console.log(
|
|
817
|
+
if (opts.json) {
|
|
818
|
+
console.log(JSON.stringify(result, null, 2));
|
|
370
819
|
return;
|
|
371
820
|
}
|
|
372
821
|
|
|
373
|
-
|
|
374
|
-
console.log(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
380
|
-
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
381
|
-
console.log(` ${chalk.dim(wt.path)}`);
|
|
822
|
+
console.log(`${chalk.green('✓')} Pipeline created ${chalk.cyan(result.pipeline_id)}`);
|
|
823
|
+
console.log(` ${chalk.bold(result.title)}`);
|
|
824
|
+
for (const task of result.tasks) {
|
|
825
|
+
const suggested = task.suggested_worktree ? ` ${chalk.dim(`→ ${task.suggested_worktree}`)}` : '';
|
|
826
|
+
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
827
|
+
console.log(` ${chalk.cyan(task.id)} ${task.title}${type}${suggested}`);
|
|
382
828
|
}
|
|
383
|
-
console.log('');
|
|
384
829
|
});
|
|
385
830
|
|
|
386
|
-
|
|
387
|
-
.command('
|
|
388
|
-
.description('
|
|
389
|
-
.
|
|
831
|
+
pipelineCmd
|
|
832
|
+
.command('status <pipelineId>')
|
|
833
|
+
.description('Show task status for a pipeline')
|
|
834
|
+
.option('--json', 'Output raw JSON')
|
|
835
|
+
.action((pipelineId, opts) => {
|
|
390
836
|
const repoRoot = getRepo();
|
|
391
837
|
const db = getDb(repoRoot);
|
|
392
|
-
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
393
|
-
for (const wt of gitWorktrees) {
|
|
394
|
-
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
395
|
-
}
|
|
396
|
-
db.close();
|
|
397
|
-
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
398
|
-
});
|
|
399
838
|
|
|
400
|
-
|
|
839
|
+
try {
|
|
840
|
+
const result = getPipelineStatus(db, pipelineId);
|
|
841
|
+
db.close();
|
|
401
842
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
843
|
+
if (opts.json) {
|
|
844
|
+
console.log(JSON.stringify(result, null, 2));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
console.log(`${chalk.bold(result.title)} ${chalk.dim(result.pipeline_id)}`);
|
|
849
|
+
console.log(` ${chalk.dim('done')} ${result.counts.done} ${chalk.dim('in_progress')} ${result.counts.in_progress} ${chalk.dim('pending')} ${result.counts.pending} ${chalk.dim('failed')} ${result.counts.failed}`);
|
|
850
|
+
for (const task of result.tasks) {
|
|
851
|
+
const worktree = task.worktree || task.suggested_worktree || 'unassigned';
|
|
852
|
+
const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
|
|
853
|
+
const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
|
|
854
|
+
console.log(` ${statusBadge(task.status)} ${task.id} ${task.title}${type} ${chalk.dim(worktree)}${blocked}`);
|
|
855
|
+
if (task.failure?.summary) {
|
|
856
|
+
const reasonLabel = humanizeReasonCode(task.failure.reason_code);
|
|
857
|
+
console.log(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
|
|
858
|
+
}
|
|
859
|
+
if (task.next_action) {
|
|
860
|
+
console.log(` ${chalk.yellow('next:')} ${task.next_action}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
db.close();
|
|
865
|
+
console.error(chalk.red(err.message));
|
|
866
|
+
process.exitCode = 1;
|
|
411
867
|
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
pipelineCmd
|
|
871
|
+
.command('pr <pipelineId>')
|
|
872
|
+
.description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
|
|
873
|
+
.option('--json', 'Output raw JSON')
|
|
874
|
+
.action(async (pipelineId, opts) => {
|
|
412
875
|
const repoRoot = getRepo();
|
|
413
876
|
const db = getDb(repoRoot);
|
|
414
877
|
|
|
415
|
-
|
|
416
|
-
|
|
878
|
+
try {
|
|
879
|
+
const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
|
|
880
|
+
db.close();
|
|
417
881
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
882
|
+
if (opts.json) {
|
|
883
|
+
console.log(JSON.stringify(result, null, 2));
|
|
884
|
+
return;
|
|
422
885
|
}
|
|
423
|
-
|
|
886
|
+
|
|
887
|
+
console.log(result.markdown);
|
|
888
|
+
} catch (err) {
|
|
424
889
|
db.close();
|
|
425
|
-
|
|
890
|
+
console.error(chalk.red(err.message));
|
|
891
|
+
process.exitCode = 1;
|
|
426
892
|
}
|
|
427
|
-
|
|
428
|
-
claimFiles(db, taskId, worktree, files, opts.agent);
|
|
429
|
-
db.close();
|
|
430
|
-
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)}`);
|
|
431
|
-
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
432
893
|
});
|
|
433
894
|
|
|
434
|
-
|
|
435
|
-
.command('
|
|
436
|
-
.description('
|
|
437
|
-
.
|
|
895
|
+
pipelineCmd
|
|
896
|
+
.command('bundle <pipelineId> [outputDir]')
|
|
897
|
+
.description('Export a reviewer-ready PR bundle for a pipeline to disk')
|
|
898
|
+
.option('--json', 'Output raw JSON')
|
|
899
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
438
900
|
const repoRoot = getRepo();
|
|
439
901
|
const db = getDb(repoRoot);
|
|
440
|
-
releaseFileClaims(db, taskId);
|
|
441
|
-
db.close();
|
|
442
|
-
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
443
|
-
});
|
|
444
902
|
|
|
445
|
-
|
|
903
|
+
try {
|
|
904
|
+
const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
|
|
905
|
+
db.close();
|
|
446
906
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
907
|
+
if (opts.json) {
|
|
908
|
+
console.log(JSON.stringify(result, null, 2));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
|
|
913
|
+
console.log(` ${chalk.dim(result.output_dir)}`);
|
|
914
|
+
console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
|
|
915
|
+
console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
|
|
916
|
+
console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
db.close();
|
|
919
|
+
console.error(chalk.red(err.message));
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
pipelineCmd
|
|
925
|
+
.command('publish <pipelineId> [outputDir]')
|
|
926
|
+
.description('Create a hosted GitHub pull request for a pipeline using gh')
|
|
927
|
+
.option('--base <branch>', 'Base branch for the pull request', 'main')
|
|
928
|
+
.option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
|
|
929
|
+
.option('--draft', 'Create the pull request as a draft')
|
|
930
|
+
.option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
|
|
450
931
|
.option('--json', 'Output raw JSON')
|
|
451
|
-
.
|
|
452
|
-
.action(async (opts) => {
|
|
932
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
453
933
|
const repoRoot = getRepo();
|
|
454
934
|
const db = getDb(repoRoot);
|
|
455
|
-
const spinner = ora('Scanning worktrees for conflicts...').start();
|
|
456
935
|
|
|
457
936
|
try {
|
|
458
|
-
const
|
|
937
|
+
const result = await publishPipelinePr(db, repoRoot, pipelineId, {
|
|
938
|
+
baseBranch: opts.base,
|
|
939
|
+
headBranch: opts.head || null,
|
|
940
|
+
draft: Boolean(opts.draft),
|
|
941
|
+
ghCommand: opts.ghCommand,
|
|
942
|
+
outputDir: outputDir || null,
|
|
943
|
+
});
|
|
459
944
|
db.close();
|
|
460
|
-
spinner.stop();
|
|
461
945
|
|
|
462
946
|
if (opts.json) {
|
|
463
|
-
console.log(JSON.stringify(
|
|
947
|
+
console.log(JSON.stringify(result, null, 2));
|
|
464
948
|
return;
|
|
465
949
|
}
|
|
466
950
|
|
|
467
|
-
console.log('');
|
|
468
|
-
console.log(chalk.
|
|
469
|
-
console.log(chalk.dim(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
// Worktrees summary
|
|
473
|
-
if (!opts.quiet) {
|
|
474
|
-
console.log(chalk.bold('Worktrees:'));
|
|
475
|
-
for (const wt of report.worktrees) {
|
|
476
|
-
const files = report.fileMap?.[wt.name] || [];
|
|
477
|
-
console.log(` ${chalk.cyan(wt.name.padEnd(20))} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
|
|
478
|
-
}
|
|
479
|
-
console.log('');
|
|
951
|
+
console.log(`${chalk.green('✓')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
|
|
952
|
+
console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
|
|
953
|
+
console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
|
|
954
|
+
if (result.output) {
|
|
955
|
+
console.log(` ${chalk.dim(result.output)}`);
|
|
480
956
|
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
db.close();
|
|
959
|
+
console.error(chalk.red(err.message));
|
|
960
|
+
process.exitCode = 1;
|
|
961
|
+
}
|
|
962
|
+
});
|
|
481
963
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
964
|
+
pipelineCmd
|
|
965
|
+
.command('run <pipelineId> [agentCommand...]')
|
|
966
|
+
.description('Dispatch pending pipeline tasks onto available worktrees and optionally launch an agent command in each one')
|
|
967
|
+
.option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
|
|
968
|
+
.option('--detached', 'Launch agent commands as detached background processes')
|
|
969
|
+
.option('--json', 'Output raw JSON')
|
|
970
|
+
.action((pipelineId, agentCommand, opts) => {
|
|
971
|
+
const repoRoot = getRepo();
|
|
972
|
+
const db = getDb(repoRoot);
|
|
491
973
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
console.log('');
|
|
974
|
+
try {
|
|
975
|
+
const result = runPipeline(db, repoRoot, {
|
|
976
|
+
pipelineId,
|
|
977
|
+
agentCommand,
|
|
978
|
+
agentName: opts.agent,
|
|
979
|
+
detached: Boolean(opts.detached),
|
|
980
|
+
});
|
|
981
|
+
db.close();
|
|
982
|
+
|
|
983
|
+
if (opts.json) {
|
|
984
|
+
console.log(JSON.stringify(result, null, 2));
|
|
985
|
+
return;
|
|
505
986
|
}
|
|
506
987
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
988
|
+
if (result.assigned.length === 0) {
|
|
989
|
+
console.log(chalk.dim('No pending pipeline tasks were assigned. All worktrees may already be busy.'));
|
|
990
|
+
return;
|
|
510
991
|
}
|
|
511
992
|
|
|
993
|
+
console.log(`${chalk.green('✓')} Dispatched ${result.assigned.length} pipeline task(s)`);
|
|
994
|
+
for (const assignment of result.assigned) {
|
|
995
|
+
const launch = result.launched.find((item) => item.task_id === assignment.task_id);
|
|
996
|
+
const launchInfo = launch ? ` ${chalk.dim(`pid=${launch.pid}`)}` : '';
|
|
997
|
+
console.log(` ${chalk.cyan(assignment.task_id)} → ${chalk.cyan(assignment.worktree)} ${chalk.dim(assignment.lease_id)}${launchInfo}`);
|
|
998
|
+
}
|
|
999
|
+
if (result.remaining_pending > 0) {
|
|
1000
|
+
console.log(chalk.dim(`${result.remaining_pending} pipeline task(s) remain pending due to unavailable worktrees.`));
|
|
1001
|
+
}
|
|
512
1002
|
} catch (err) {
|
|
513
|
-
spinner.fail(err.message);
|
|
514
1003
|
db.close();
|
|
515
|
-
|
|
1004
|
+
console.error(chalk.red(err.message));
|
|
1005
|
+
process.exitCode = 1;
|
|
516
1006
|
}
|
|
517
1007
|
});
|
|
518
1008
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
.
|
|
523
|
-
.
|
|
524
|
-
.action(async () => {
|
|
1009
|
+
pipelineCmd
|
|
1010
|
+
.command('review <pipelineId>')
|
|
1011
|
+
.description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
|
|
1012
|
+
.option('--json', 'Output raw JSON')
|
|
1013
|
+
.action(async (pipelineId, opts) => {
|
|
525
1014
|
const repoRoot = getRepo();
|
|
526
1015
|
const db = getDb(repoRoot);
|
|
527
1016
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
console.log('');
|
|
1017
|
+
try {
|
|
1018
|
+
const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
|
|
1019
|
+
db.close();
|
|
532
1020
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const done = tasks.filter(t => t.status === 'done');
|
|
538
|
-
const failed = tasks.filter(t => t.status === 'failed');
|
|
1021
|
+
if (opts.json) {
|
|
1022
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
539
1025
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
console.log(` ${chalk.red('Failed')} ${failed.length}`);
|
|
1026
|
+
if (result.created_count === 0) {
|
|
1027
|
+
console.log(chalk.dim('No follow-up tasks were created. The pipeline gates did not surface new actionable items.'));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
545
1030
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
for (const t of inProgress) {
|
|
550
|
-
console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
|
|
1031
|
+
console.log(`${chalk.green('✓')} Created ${result.created_count} follow-up task(s)`);
|
|
1032
|
+
for (const task of result.created) {
|
|
1033
|
+
console.log(` ${chalk.cyan(task.id)} ${task.title}`);
|
|
551
1034
|
}
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
db.close();
|
|
1037
|
+
console.error(chalk.red(err.message));
|
|
1038
|
+
process.exitCode = 1;
|
|
552
1039
|
}
|
|
1040
|
+
});
|
|
553
1041
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1042
|
+
pipelineCmd
|
|
1043
|
+
.command('exec <pipelineId> [agentCommand...]')
|
|
1044
|
+
.description('Run a bounded autonomous loop: dispatch, execute, review, and stop when ready or blocked')
|
|
1045
|
+
.option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
|
|
1046
|
+
.option('--max-iterations <n>', 'Maximum execution/review iterations', '3')
|
|
1047
|
+
.option('--max-retries <n>', 'Retry a failed pipeline task up to this many times', '1')
|
|
1048
|
+
.option('--retry-backoff-ms <ms>', 'Base backoff in milliseconds between retry attempts', '0')
|
|
1049
|
+
.option('--timeout-ms <ms>', 'Default command timeout in milliseconds when a task spec does not provide one', '0')
|
|
1050
|
+
.option('--json', 'Output raw JSON')
|
|
1051
|
+
.action(async (pipelineId, agentCommand, opts) => {
|
|
1052
|
+
const repoRoot = getRepo();
|
|
1053
|
+
const db = getDb(repoRoot);
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
const result = await executePipeline(db, repoRoot, {
|
|
1057
|
+
pipelineId,
|
|
1058
|
+
agentCommand,
|
|
1059
|
+
agentName: opts.agent,
|
|
1060
|
+
maxIterations: Number.parseInt(opts.maxIterations, 10),
|
|
1061
|
+
maxRetries: Number.parseInt(opts.maxRetries, 10),
|
|
1062
|
+
retryBackoffMs: Number.parseInt(opts.retryBackoffMs, 10),
|
|
1063
|
+
timeoutMs: Number.parseInt(opts.timeoutMs, 10),
|
|
1064
|
+
});
|
|
1065
|
+
db.close();
|
|
1066
|
+
|
|
1067
|
+
if (opts.json) {
|
|
1068
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const badge = result.status === 'ready'
|
|
1073
|
+
? chalk.green('READY')
|
|
1074
|
+
: result.status === 'blocked'
|
|
1075
|
+
? chalk.red('BLOCKED')
|
|
1076
|
+
: chalk.yellow('MAX');
|
|
1077
|
+
console.log(`${badge} Pipeline ${chalk.cyan(result.pipeline_id)} ${chalk.dim(result.status)}`);
|
|
1078
|
+
for (const iteration of result.iterations) {
|
|
1079
|
+
console.log(` iter ${iteration.iteration}: resumed=${iteration.resumed_retries} dispatched=${iteration.dispatched} executed=${iteration.executed} retries=${iteration.retries_scheduled} followups=${iteration.followups_created} ai=${iteration.ai_gate_status} ready=${iteration.ready}`);
|
|
560
1080
|
}
|
|
1081
|
+
console.log(chalk.dim(result.pr.markdown.split('\n')[0]));
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
db.close();
|
|
1084
|
+
console.error(chalk.red(err.message));
|
|
1085
|
+
process.exitCode = 1;
|
|
561
1086
|
}
|
|
1087
|
+
});
|
|
562
1088
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1089
|
+
// ── lease ────────────────────────────────────────────────────────────────────
|
|
1090
|
+
|
|
1091
|
+
const leaseCmd = program.command('lease').description('Manage active work leases');
|
|
1092
|
+
|
|
1093
|
+
leaseCmd
|
|
1094
|
+
.command('acquire <taskId> <worktree>')
|
|
1095
|
+
.description('Acquire a lease for a pending task')
|
|
1096
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1097
|
+
.option('--json', 'Output as JSON')
|
|
1098
|
+
.action((taskId, worktree, opts) => {
|
|
1099
|
+
const repoRoot = getRepo();
|
|
1100
|
+
const db = getDb(repoRoot);
|
|
1101
|
+
const task = getTask(db, taskId);
|
|
1102
|
+
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
1103
|
+
db.close();
|
|
1104
|
+
|
|
1105
|
+
if (!lease || !task) {
|
|
1106
|
+
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
1107
|
+
else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
|
|
1108
|
+
process.exitCode = 1;
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (opts.json) {
|
|
1113
|
+
console.log(JSON.stringify({
|
|
1114
|
+
lease,
|
|
1115
|
+
task: taskJsonWithLease(task, worktree, lease).task,
|
|
1116
|
+
}, null, 2));
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
|
|
1121
|
+
console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
|
|
1122
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
leaseCmd
|
|
1126
|
+
.command('next')
|
|
1127
|
+
.description('Claim the next pending task and acquire its lease')
|
|
1128
|
+
.option('--json', 'Output as JSON')
|
|
1129
|
+
.option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
|
|
1130
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1131
|
+
.action((opts) => {
|
|
1132
|
+
const repoRoot = getRepo();
|
|
1133
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
1134
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
1135
|
+
|
|
1136
|
+
if (!task) {
|
|
1137
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
1138
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks.'));
|
|
1139
|
+
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (!lease) {
|
|
1144
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
1145
|
+
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (opts.json) {
|
|
1150
|
+
console.log(JSON.stringify({
|
|
1151
|
+
lease,
|
|
1152
|
+
...taskJsonWithLease(task, worktreeName, lease),
|
|
1153
|
+
}, null, 2));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
1158
|
+
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
1159
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
leaseCmd
|
|
1163
|
+
.command('list')
|
|
1164
|
+
.description('List leases, newest first')
|
|
1165
|
+
.option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
|
|
1166
|
+
.action((opts) => {
|
|
1167
|
+
const repoRoot = getRepo();
|
|
1168
|
+
const db = getDb(repoRoot);
|
|
1169
|
+
const leases = listLeases(db, opts.status);
|
|
1170
|
+
db.close();
|
|
1171
|
+
|
|
1172
|
+
if (!leases.length) {
|
|
1173
|
+
console.log(chalk.dim('No leases found.'));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
console.log('');
|
|
1178
|
+
for (const lease of leases) {
|
|
1179
|
+
console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
|
|
1180
|
+
console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
|
|
1181
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
|
|
1182
|
+
console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
|
|
1183
|
+
if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
|
|
566
1184
|
console.log('');
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
leaseCmd
|
|
1189
|
+
.command('heartbeat <leaseId>')
|
|
1190
|
+
.description('Refresh the heartbeat timestamp for an active lease')
|
|
1191
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
1192
|
+
.option('--json', 'Output as JSON')
|
|
1193
|
+
.action((leaseId, opts) => {
|
|
1194
|
+
const repoRoot = getRepo();
|
|
1195
|
+
const db = getDb(repoRoot);
|
|
1196
|
+
const lease = heartbeatLease(db, leaseId, opts.agent || null);
|
|
1197
|
+
db.close();
|
|
1198
|
+
|
|
1199
|
+
if (!lease) {
|
|
1200
|
+
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
1201
|
+
else console.log(chalk.red(`No active lease found for ${leaseId}`));
|
|
1202
|
+
process.exitCode = 1;
|
|
1203
|
+
return;
|
|
576
1204
|
}
|
|
577
1205
|
|
|
578
|
-
|
|
1206
|
+
if (opts.json) {
|
|
1207
|
+
console.log(JSON.stringify({ lease }, null, 2));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
|
|
1212
|
+
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
leaseCmd
|
|
1216
|
+
.command('reap')
|
|
1217
|
+
.description('Expire stale leases, release their claims, and return their tasks to pending')
|
|
1218
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness', String(DEFAULT_STALE_LEASE_MINUTES))
|
|
1219
|
+
.option('--json', 'Output as JSON')
|
|
1220
|
+
.action((opts) => {
|
|
1221
|
+
const repoRoot = getRepo();
|
|
1222
|
+
const db = getDb(repoRoot);
|
|
1223
|
+
const staleAfterMinutes = Number.parseInt(opts.staleAfterMinutes, 10);
|
|
1224
|
+
const expired = reapStaleLeases(db, staleAfterMinutes);
|
|
1225
|
+
db.close();
|
|
1226
|
+
|
|
1227
|
+
if (opts.json) {
|
|
1228
|
+
console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (!expired.length) {
|
|
1233
|
+
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
|
|
1238
|
+
for (const lease of expired) {
|
|
1239
|
+
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
1244
|
+
|
|
1245
|
+
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
1246
|
+
|
|
1247
|
+
wtCmd
|
|
1248
|
+
.command('add <name> <path> <branch>')
|
|
1249
|
+
.description('Register a worktree with switchman')
|
|
1250
|
+
.option('--agent <name>', 'Agent assigned to this worktree')
|
|
1251
|
+
.action((name, path, branch, opts) => {
|
|
1252
|
+
const repoRoot = getRepo();
|
|
1253
|
+
const db = getDb(repoRoot);
|
|
1254
|
+
registerWorktree(db, { name, path, branch, agent: opts.agent });
|
|
1255
|
+
db.close();
|
|
1256
|
+
console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
wtCmd
|
|
1260
|
+
.command('list')
|
|
1261
|
+
.description('List all registered worktrees')
|
|
1262
|
+
.action(() => {
|
|
1263
|
+
const repoRoot = getRepo();
|
|
1264
|
+
const db = getDb(repoRoot);
|
|
1265
|
+
const worktrees = listWorktrees(db);
|
|
1266
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1267
|
+
db.close();
|
|
1268
|
+
|
|
1269
|
+
if (!worktrees.length && !gitWorktrees.length) {
|
|
1270
|
+
console.log(chalk.dim('No worktrees found.'));
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Show git worktrees (source of truth) annotated with db info
|
|
579
1275
|
console.log('');
|
|
580
|
-
|
|
1276
|
+
console.log(chalk.bold('Git Worktrees:'));
|
|
1277
|
+
for (const wt of gitWorktrees) {
|
|
1278
|
+
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
1279
|
+
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
1280
|
+
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
1281
|
+
const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
|
|
1282
|
+
console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
|
|
1283
|
+
console.log(` ${chalk.dim(wt.path)}`);
|
|
1284
|
+
}
|
|
1285
|
+
console.log('');
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
wtCmd
|
|
1289
|
+
.command('sync')
|
|
1290
|
+
.description('Sync git worktrees into the switchman database')
|
|
1291
|
+
.action(() => {
|
|
1292
|
+
const repoRoot = getRepo();
|
|
1293
|
+
const db = getDb(repoRoot);
|
|
1294
|
+
const gitWorktrees = listGitWorktrees(repoRoot);
|
|
1295
|
+
for (const wt of gitWorktrees) {
|
|
1296
|
+
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
1297
|
+
}
|
|
1298
|
+
db.close();
|
|
1299
|
+
installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
1300
|
+
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// ── claim ──────────────────────────────────────────────────────────────────────
|
|
1304
|
+
|
|
1305
|
+
program
|
|
1306
|
+
.command('claim <taskId> <worktree> [files...]')
|
|
1307
|
+
.description('Claim files for a task (warns if conflicts exist)')
|
|
1308
|
+
.option('--agent <name>', 'Agent name')
|
|
1309
|
+
.option('--force', 'Claim even if conflicts exist')
|
|
1310
|
+
.action((taskId, worktree, files, opts) => {
|
|
1311
|
+
if (!files.length) {
|
|
1312
|
+
console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const repoRoot = getRepo();
|
|
1316
|
+
const db = getDb(repoRoot);
|
|
1317
|
+
|
|
581
1318
|
try {
|
|
582
|
-
const
|
|
583
|
-
spinner.stop();
|
|
1319
|
+
const conflicts = checkFileConflicts(db, files, worktree);
|
|
584
1320
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1321
|
+
if (conflicts.length > 0 && !opts.force) {
|
|
1322
|
+
console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
|
|
1323
|
+
for (const c of conflicts) {
|
|
1324
|
+
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
1325
|
+
}
|
|
1326
|
+
console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
|
|
1327
|
+
process.exitCode = 1;
|
|
1328
|
+
return;
|
|
590
1329
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
console.log(chalk.
|
|
1330
|
+
|
|
1331
|
+
const lease = claimFiles(db, taskId, worktree, files, opts.agent);
|
|
1332
|
+
console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
|
|
1333
|
+
files.forEach(f => console.log(` ${chalk.dim(f)}`));
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
console.error(chalk.red(err.message));
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
} finally {
|
|
1338
|
+
db.close();
|
|
594
1339
|
}
|
|
1340
|
+
});
|
|
595
1341
|
|
|
1342
|
+
program
|
|
1343
|
+
.command('release <taskId>')
|
|
1344
|
+
.description('Release all file claims for a task')
|
|
1345
|
+
.action((taskId) => {
|
|
1346
|
+
const repoRoot = getRepo();
|
|
1347
|
+
const db = getDb(repoRoot);
|
|
1348
|
+
releaseFileClaims(db, taskId);
|
|
596
1349
|
db.close();
|
|
597
|
-
console.log('');
|
|
598
|
-
|
|
599
|
-
|
|
1350
|
+
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
program
|
|
1354
|
+
.command('write <leaseId> <path>')
|
|
1355
|
+
.description('Write a file through the Switchman enforcement gateway')
|
|
1356
|
+
.requiredOption('--text <content>', 'Replacement file content')
|
|
1357
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1358
|
+
.action((leaseId, path, opts) => {
|
|
1359
|
+
const repoRoot = getRepo();
|
|
1360
|
+
const db = getDb(repoRoot);
|
|
1361
|
+
const result = gatewayWriteFile(db, repoRoot, {
|
|
1362
|
+
leaseId,
|
|
1363
|
+
path,
|
|
1364
|
+
content: opts.text,
|
|
1365
|
+
worktree: opts.worktree || null,
|
|
1366
|
+
});
|
|
1367
|
+
db.close();
|
|
1368
|
+
|
|
1369
|
+
if (!result.ok) {
|
|
1370
|
+
console.log(chalk.red(`✗ Write denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
1371
|
+
process.exitCode = 1;
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
console.log(`${chalk.green('✓')} Wrote ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
program
|
|
1379
|
+
.command('rm <leaseId> <path>')
|
|
1380
|
+
.description('Remove a file or directory through the Switchman enforcement gateway')
|
|
1381
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1382
|
+
.action((leaseId, path, opts) => {
|
|
1383
|
+
const repoRoot = getRepo();
|
|
1384
|
+
const db = getDb(repoRoot);
|
|
1385
|
+
const result = gatewayRemovePath(db, repoRoot, {
|
|
1386
|
+
leaseId,
|
|
1387
|
+
path,
|
|
1388
|
+
worktree: opts.worktree || null,
|
|
1389
|
+
});
|
|
1390
|
+
db.close();
|
|
1391
|
+
|
|
1392
|
+
if (!result.ok) {
|
|
1393
|
+
console.log(chalk.red(`✗ Remove denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
1394
|
+
process.exitCode = 1;
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
program
|
|
1402
|
+
.command('append <leaseId> <path>')
|
|
1403
|
+
.description('Append to a file through the Switchman enforcement gateway')
|
|
1404
|
+
.requiredOption('--text <content>', 'Content to append')
|
|
1405
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1406
|
+
.action((leaseId, path, opts) => {
|
|
1407
|
+
const repoRoot = getRepo();
|
|
1408
|
+
const db = getDb(repoRoot);
|
|
1409
|
+
const result = gatewayAppendFile(db, repoRoot, {
|
|
1410
|
+
leaseId,
|
|
1411
|
+
path,
|
|
1412
|
+
content: opts.text,
|
|
1413
|
+
worktree: opts.worktree || null,
|
|
1414
|
+
});
|
|
1415
|
+
db.close();
|
|
1416
|
+
|
|
1417
|
+
if (!result.ok) {
|
|
1418
|
+
console.log(chalk.red(`✗ Append denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
1419
|
+
process.exitCode = 1;
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
console.log(`${chalk.green('✓')} Appended to ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
program
|
|
1427
|
+
.command('mv <leaseId> <sourcePath> <destinationPath>')
|
|
1428
|
+
.description('Move a file through the Switchman enforcement gateway')
|
|
1429
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1430
|
+
.action((leaseId, sourcePath, destinationPath, opts) => {
|
|
1431
|
+
const repoRoot = getRepo();
|
|
1432
|
+
const db = getDb(repoRoot);
|
|
1433
|
+
const result = gatewayMovePath(db, repoRoot, {
|
|
1434
|
+
leaseId,
|
|
1435
|
+
sourcePath,
|
|
1436
|
+
destinationPath,
|
|
1437
|
+
worktree: opts.worktree || null,
|
|
1438
|
+
});
|
|
1439
|
+
db.close();
|
|
1440
|
+
|
|
1441
|
+
if (!result.ok) {
|
|
1442
|
+
console.log(chalk.red(`✗ Move denied for ${chalk.cyan(result.file_path || destinationPath)} ${chalk.dim(result.reason_code)}`));
|
|
1443
|
+
process.exitCode = 1;
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
console.log(`${chalk.green('✓')} Moved ${chalk.cyan(result.source_path)} → ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
program
|
|
1451
|
+
.command('mkdir <leaseId> <path>')
|
|
1452
|
+
.description('Create a directory through the Switchman enforcement gateway')
|
|
1453
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1454
|
+
.action((leaseId, path, opts) => {
|
|
1455
|
+
const repoRoot = getRepo();
|
|
1456
|
+
const db = getDb(repoRoot);
|
|
1457
|
+
const result = gatewayMakeDirectory(db, repoRoot, {
|
|
1458
|
+
leaseId,
|
|
1459
|
+
path,
|
|
1460
|
+
worktree: opts.worktree || null,
|
|
1461
|
+
});
|
|
1462
|
+
db.close();
|
|
1463
|
+
|
|
1464
|
+
if (!result.ok) {
|
|
1465
|
+
console.log(chalk.red(`✗ Mkdir denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
|
|
1466
|
+
process.exitCode = 1;
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
console.log(`${chalk.green('✓')} Created ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
program
|
|
1474
|
+
.command('wrap <leaseId> <command...>')
|
|
1475
|
+
.description('Launch a CLI tool under an active Switchman lease with enforcement context env vars')
|
|
1476
|
+
.option('--worktree <name>', 'Expected worktree for lease validation')
|
|
1477
|
+
.option('--cwd <path>', 'Override working directory for the wrapped command')
|
|
1478
|
+
.action((leaseId, commandParts, opts) => {
|
|
1479
|
+
const repoRoot = getRepo();
|
|
1480
|
+
const db = getDb(repoRoot);
|
|
1481
|
+
const [command, ...args] = commandParts;
|
|
1482
|
+
const result = runWrappedCommand(db, repoRoot, {
|
|
1483
|
+
leaseId,
|
|
1484
|
+
command,
|
|
1485
|
+
args,
|
|
1486
|
+
worktree: opts.worktree || null,
|
|
1487
|
+
cwd: opts.cwd || null,
|
|
1488
|
+
});
|
|
1489
|
+
db.close();
|
|
1490
|
+
|
|
1491
|
+
if (!result.ok) {
|
|
1492
|
+
console.log(chalk.red(`✗ Wrapped command denied ${chalk.dim(result.reason_code || 'wrapped_command_failed')}`));
|
|
1493
|
+
process.exitCode = 1;
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
console.log(`${chalk.green('✓')} Wrapped command completed under lease ${chalk.dim(result.lease_id)}`);
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
// ── scan ───────────────────────────────────────────────────────────────────────
|
|
1501
|
+
|
|
1502
|
+
program
|
|
1503
|
+
.command('scan')
|
|
1504
|
+
.description('Scan all worktrees for conflicts')
|
|
1505
|
+
.option('--json', 'Output raw JSON')
|
|
1506
|
+
.option('--quiet', 'Only show conflicts')
|
|
1507
|
+
.action(async (opts) => {
|
|
1508
|
+
const repoRoot = getRepo();
|
|
1509
|
+
const db = getDb(repoRoot);
|
|
1510
|
+
const spinner = ora('Scanning worktrees for conflicts...').start();
|
|
1511
|
+
|
|
1512
|
+
try {
|
|
1513
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
1514
|
+
db.close();
|
|
1515
|
+
spinner.stop();
|
|
1516
|
+
|
|
1517
|
+
if (opts.json) {
|
|
1518
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
console.log('');
|
|
1523
|
+
console.log(chalk.bold(`Conflict Scan Report`));
|
|
1524
|
+
console.log(chalk.dim(`${report.scannedAt}`));
|
|
1525
|
+
console.log('');
|
|
1526
|
+
|
|
1527
|
+
// Worktrees summary
|
|
1528
|
+
if (!opts.quiet) {
|
|
1529
|
+
console.log(chalk.bold('Worktrees:'));
|
|
1530
|
+
for (const wt of report.worktrees) {
|
|
1531
|
+
const files = report.fileMap?.[wt.name] || [];
|
|
1532
|
+
const compliance = report.worktreeCompliance?.find((entry) => entry.worktree === wt.name)?.compliance_state || wt.compliance_state || 'observed';
|
|
1533
|
+
console.log(` ${chalk.cyan(wt.name.padEnd(20))} ${statusBadge(compliance)} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
|
|
1534
|
+
}
|
|
1535
|
+
console.log('');
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// File-level overlaps (uncommitted)
|
|
1539
|
+
if (report.fileConflicts.length > 0) {
|
|
1540
|
+
console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
|
|
1541
|
+
for (const fc of report.fileConflicts) {
|
|
1542
|
+
console.log(` ${chalk.yellow(fc.file)}`);
|
|
1543
|
+
console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
|
|
1544
|
+
}
|
|
1545
|
+
console.log('');
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if ((report.ownershipConflicts?.length || 0) > 0) {
|
|
1549
|
+
console.log(chalk.yellow(`⚠ Ownership boundary overlaps detected:`));
|
|
1550
|
+
for (const conflict of report.ownershipConflicts) {
|
|
1551
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
1552
|
+
console.log(` ${chalk.yellow(`subsystem:${conflict.subsystemTag}`)}`);
|
|
1553
|
+
console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
1554
|
+
} else {
|
|
1555
|
+
console.log(` ${chalk.yellow(conflict.scopeA)}`);
|
|
1556
|
+
console.log(` ${chalk.dim('overlaps with:')} ${conflict.scopeB}`);
|
|
1557
|
+
console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
console.log('');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if ((report.semanticConflicts?.length || 0) > 0) {
|
|
1564
|
+
console.log(chalk.yellow(`⚠ Semantic overlaps detected:`));
|
|
1565
|
+
for (const conflict of report.semanticConflicts) {
|
|
1566
|
+
console.log(` ${chalk.yellow(conflict.object_name)}`);
|
|
1567
|
+
console.log(` ${chalk.dim('changed by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
|
|
1568
|
+
console.log(` ${chalk.dim('files:')} ${conflict.fileA} ↔ ${conflict.fileB}`);
|
|
1569
|
+
}
|
|
1570
|
+
console.log('');
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Branch-level conflicts
|
|
1574
|
+
if (report.conflicts.length > 0) {
|
|
1575
|
+
console.log(chalk.red(`✗ Branch conflicts detected:`));
|
|
1576
|
+
for (const c of report.conflicts) {
|
|
1577
|
+
const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
|
|
1578
|
+
console.log(` ${icon}`);
|
|
1579
|
+
console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
|
|
1580
|
+
if (c.conflictingFiles.length) {
|
|
1581
|
+
console.log(` Conflicting files:`);
|
|
1582
|
+
c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
console.log('');
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (report.unclaimedChanges.length > 0) {
|
|
1589
|
+
console.log(chalk.red(`✗ Unclaimed or unmanaged changed files detected:`));
|
|
1590
|
+
for (const entry of report.unclaimedChanges) {
|
|
1591
|
+
console.log(` ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.lease_id || 'no active lease')}`);
|
|
1592
|
+
entry.files.forEach((file) => {
|
|
1593
|
+
const reason = entry.reasons.find((item) => item.file === file)?.reason_code || 'path_not_claimed';
|
|
1594
|
+
const nextStep = nextStepForReason(reason);
|
|
1595
|
+
console.log(` ${chalk.yellow(file)} ${chalk.dim(humanizeReasonCode(reason))}${nextStep ? ` ${chalk.dim(`— ${nextStep}`)}` : ''}`);
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
console.log('');
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// All clear
|
|
1602
|
+
if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
|
|
1603
|
+
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
spinner.fail(err.message);
|
|
1608
|
+
db.close();
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// ── status ─────────────────────────────────────────────────────────────────────
|
|
1614
|
+
|
|
1615
|
+
program
|
|
1616
|
+
.command('status')
|
|
1617
|
+
.description('Show full system status: tasks, worktrees, claims, and conflicts')
|
|
1618
|
+
.action(async () => {
|
|
1619
|
+
const repoRoot = getRepo();
|
|
1620
|
+
const db = getDb(repoRoot);
|
|
1621
|
+
|
|
1622
|
+
console.log('');
|
|
1623
|
+
console.log(chalk.bold.cyan('━━━ switchman status ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
1624
|
+
console.log(chalk.dim(`Repo: ${repoRoot}`));
|
|
1625
|
+
console.log('');
|
|
1626
|
+
|
|
1627
|
+
// Tasks
|
|
1628
|
+
const tasks = listTasks(db);
|
|
1629
|
+
const pending = tasks.filter(t => t.status === 'pending');
|
|
1630
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1631
|
+
const done = tasks.filter(t => t.status === 'done');
|
|
1632
|
+
const failed = tasks.filter(t => t.status === 'failed');
|
|
1633
|
+
const activeLeases = listLeases(db, 'active');
|
|
1634
|
+
const staleLeases = getStaleLeases(db);
|
|
1635
|
+
|
|
1636
|
+
console.log(chalk.bold('Tasks:'));
|
|
1637
|
+
console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
|
|
1638
|
+
console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
|
|
1639
|
+
console.log(` ${chalk.green('Done')} ${done.length}`);
|
|
1640
|
+
console.log(` ${chalk.red('Failed')} ${failed.length}`);
|
|
1641
|
+
|
|
1642
|
+
if (activeLeases.length > 0) {
|
|
1643
|
+
console.log('');
|
|
1644
|
+
console.log(chalk.bold('Active Leases:'));
|
|
1645
|
+
for (const lease of activeLeases) {
|
|
1646
|
+
const agent = lease.agent ? ` ${chalk.dim(`agent:${lease.agent}`)}` : '';
|
|
1647
|
+
const scope = summarizeLeaseScope(db, lease);
|
|
1648
|
+
const boundaryValidation = getBoundaryValidationState(db, lease.id);
|
|
1649
|
+
const dependencyInvalidations = listDependencyInvalidations(db, { affectedTaskId: lease.task_id });
|
|
1650
|
+
const boundary = boundaryValidation ? ` ${chalk.dim(`validation:${boundaryValidation.status}`)}` : '';
|
|
1651
|
+
const staleMarker = dependencyInvalidations.length > 0 ? ` ${chalk.dim(`stale:${dependencyInvalidations.length}`)}` : '';
|
|
1652
|
+
console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(`task:${lease.task_id}`)}${agent}${scope ? ` ${chalk.dim(scope)}` : ''}${boundary}${staleMarker}`);
|
|
1653
|
+
}
|
|
1654
|
+
} else if (inProgress.length > 0) {
|
|
1655
|
+
console.log('');
|
|
1656
|
+
console.log(chalk.bold('In-Progress Tasks Without Lease:'));
|
|
1657
|
+
for (const t of inProgress) {
|
|
1658
|
+
console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if (staleLeases.length > 0) {
|
|
1663
|
+
console.log('');
|
|
1664
|
+
console.log(chalk.bold('Stale Leases:'));
|
|
1665
|
+
for (const lease of staleLeases) {
|
|
1666
|
+
console.log(` ${chalk.red(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
if (pending.length > 0) {
|
|
1671
|
+
console.log('');
|
|
1672
|
+
console.log(chalk.bold('Next Up:'));
|
|
1673
|
+
const next = pending.slice(0, 3);
|
|
1674
|
+
for (const t of next) {
|
|
1675
|
+
console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (failed.length > 0) {
|
|
1680
|
+
console.log('');
|
|
1681
|
+
console.log(chalk.bold('Failed Tasks:'));
|
|
1682
|
+
for (const task of failed.slice(0, 5)) {
|
|
1683
|
+
const failureLine = String(task.description || '')
|
|
1684
|
+
.split('\n')
|
|
1685
|
+
.map((line) => line.trim())
|
|
1686
|
+
.filter(Boolean)
|
|
1687
|
+
.reverse()
|
|
1688
|
+
.find((line) => line.startsWith('FAILED: '));
|
|
1689
|
+
const failureText = failureLine ? failureLine.slice('FAILED: '.length) : 'unknown failure';
|
|
1690
|
+
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
1691
|
+
const reasonCode = reasonMatch ? reasonMatch[1] : null;
|
|
1692
|
+
const summary = reasonMatch ? reasonMatch[2] : failureText;
|
|
1693
|
+
const nextStep = nextStepForReason(reasonCode);
|
|
1694
|
+
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
1695
|
+
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${humanizeReasonCode(reasonCode)})`)}`);
|
|
1696
|
+
if (nextStep) {
|
|
1697
|
+
console.log(` ${chalk.yellow('next:')} ${nextStep}`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// File Claims
|
|
1703
|
+
const claims = getActiveFileClaims(db);
|
|
1704
|
+
if (claims.length > 0) {
|
|
1705
|
+
console.log('');
|
|
1706
|
+
console.log(chalk.bold(`Active File Claims (${claims.length}):`));
|
|
1707
|
+
const byWorktree = {};
|
|
1708
|
+
for (const c of claims) {
|
|
1709
|
+
if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
|
|
1710
|
+
byWorktree[c.worktree].push(c.file_path);
|
|
1711
|
+
}
|
|
1712
|
+
for (const [wt, files] of Object.entries(byWorktree)) {
|
|
1713
|
+
console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Quick conflict scan
|
|
1718
|
+
console.log('');
|
|
1719
|
+
const spinner = ora('Running conflict scan...').start();
|
|
1720
|
+
try {
|
|
1721
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
1722
|
+
spinner.stop();
|
|
1723
|
+
|
|
1724
|
+
const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
|
|
1725
|
+
if (totalConflicts === 0) {
|
|
1726
|
+
console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
|
|
1727
|
+
} else {
|
|
1728
|
+
console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
console.log('');
|
|
1732
|
+
console.log(chalk.bold('Compliance:'));
|
|
1733
|
+
console.log(` ${chalk.green('Managed')} ${report.complianceSummary.managed}`);
|
|
1734
|
+
console.log(` ${chalk.yellow('Observed')} ${report.complianceSummary.observed}`);
|
|
1735
|
+
console.log(` ${chalk.red('Non-Compliant')} ${report.complianceSummary.non_compliant}`);
|
|
1736
|
+
console.log(` ${chalk.red('Stale')} ${report.complianceSummary.stale}`);
|
|
1737
|
+
|
|
1738
|
+
if (report.unclaimedChanges.length > 0) {
|
|
1739
|
+
console.log('');
|
|
1740
|
+
console.log(chalk.bold('Unclaimed Changed Paths:'));
|
|
1741
|
+
for (const entry of report.unclaimedChanges) {
|
|
1742
|
+
const reasonCode = entry.reasons?.[0]?.reason_code || null;
|
|
1743
|
+
const nextStep = nextStepForReason(reasonCode);
|
|
1744
|
+
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}`);
|
|
1745
|
+
console.log(` ${chalk.dim(humanizeReasonCode(reasonCode))}${nextStep ? ` — ${nextStep}` : ''}`);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
if ((report.ownershipConflicts?.length || 0) > 0) {
|
|
1750
|
+
console.log('');
|
|
1751
|
+
console.log(chalk.bold('Ownership Boundary Overlaps:'));
|
|
1752
|
+
for (const conflict of report.ownershipConflicts.slice(0, 5)) {
|
|
1753
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
1754
|
+
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(`subsystem:${conflict.subsystemTag}`)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)}`);
|
|
1755
|
+
} else {
|
|
1756
|
+
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.scopeA)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(conflict.scopeB)}`);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if ((report.semanticConflicts?.length || 0) > 0) {
|
|
1762
|
+
console.log('');
|
|
1763
|
+
console.log(chalk.bold('Semantic Overlaps:'));
|
|
1764
|
+
for (const conflict of report.semanticConflicts.slice(0, 5)) {
|
|
1765
|
+
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.object_name)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(`${conflict.fileA} ↔ ${conflict.fileB}`)}`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const staleInvalidations = listDependencyInvalidations(db, { status: 'stale' });
|
|
1770
|
+
if (staleInvalidations.length > 0) {
|
|
1771
|
+
console.log('');
|
|
1772
|
+
console.log(chalk.bold('Stale For Revalidation:'));
|
|
1773
|
+
for (const invalidation of staleInvalidations.slice(0, 5)) {
|
|
1774
|
+
const staleArea = invalidation.reason_type === 'subsystem_overlap'
|
|
1775
|
+
? `subsystem:${invalidation.subsystem_tag}`
|
|
1776
|
+
: `${invalidation.source_scope_pattern} ↔ ${invalidation.affected_scope_pattern}`;
|
|
1777
|
+
console.log(` ${chalk.cyan(invalidation.affected_worktree || 'unknown')}: ${chalk.dim(staleArea)} ${chalk.dim('because')} ${chalk.cyan(invalidation.source_worktree || 'unknown')} changed it`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (report.commitGateFailures.length > 0) {
|
|
1782
|
+
console.log('');
|
|
1783
|
+
console.log(chalk.bold('Recent Commit Gate Failures:'));
|
|
1784
|
+
for (const failure of report.commitGateFailures.slice(0, 5)) {
|
|
1785
|
+
console.log(` ${chalk.red(failure.worktree || 'unknown')} ${chalk.dim(humanizeReasonCode(failure.reason_code || 'rejected'))} ${chalk.dim(failure.created_at)}`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (report.deniedWrites.length > 0) {
|
|
1790
|
+
console.log('');
|
|
1791
|
+
console.log(chalk.bold('Recent Denied Events:'));
|
|
1792
|
+
for (const event of report.deniedWrites.slice(0, 5)) {
|
|
1793
|
+
console.log(` ${chalk.red(event.event_type)} ${chalk.cyan(event.worktree || 'repo')} ${chalk.dim(humanizeReasonCode(event.reason_code || event.status))}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
} catch {
|
|
1797
|
+
spinner.stop();
|
|
1798
|
+
console.log(chalk.dim('Could not run conflict scan'));
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
db.close();
|
|
1802
|
+
console.log('');
|
|
1803
|
+
console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
1804
|
+
console.log('');
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
program
|
|
1808
|
+
.command('doctor')
|
|
1809
|
+
.description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
|
|
1810
|
+
.option('--json', 'Output raw JSON')
|
|
1811
|
+
.action(async (opts) => {
|
|
1812
|
+
const repoRoot = getRepo();
|
|
1813
|
+
const db = getDb(repoRoot);
|
|
1814
|
+
const tasks = listTasks(db);
|
|
1815
|
+
const activeLeases = listLeases(db, 'active');
|
|
1816
|
+
const staleLeases = getStaleLeases(db);
|
|
1817
|
+
const scanReport = await scanAllWorktrees(db, repoRoot);
|
|
1818
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
1819
|
+
const report = buildDoctorReport({
|
|
1820
|
+
db,
|
|
1821
|
+
repoRoot,
|
|
1822
|
+
tasks,
|
|
1823
|
+
activeLeases,
|
|
1824
|
+
staleLeases,
|
|
1825
|
+
scanReport,
|
|
1826
|
+
aiGate,
|
|
1827
|
+
});
|
|
1828
|
+
db.close();
|
|
1829
|
+
|
|
1830
|
+
if (opts.json) {
|
|
1831
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const badge = report.health === 'healthy'
|
|
1836
|
+
? chalk.green('HEALTHY')
|
|
1837
|
+
: report.health === 'warn'
|
|
1838
|
+
? chalk.yellow('ATTENTION')
|
|
1839
|
+
: chalk.red('BLOCKED');
|
|
1840
|
+
console.log(`${badge} ${report.summary}`);
|
|
1841
|
+
console.log(chalk.dim(repoRoot));
|
|
1842
|
+
console.log('');
|
|
1843
|
+
|
|
1844
|
+
console.log(chalk.bold('At a glance:'));
|
|
1845
|
+
console.log(` ${chalk.dim('tasks')} ${report.counts.pending} pending, ${report.counts.in_progress} in progress, ${report.counts.done} done, ${report.counts.failed} failed`);
|
|
1846
|
+
console.log(` ${chalk.dim('leases')} ${report.counts.active_leases} active, ${report.counts.stale_leases} stale`);
|
|
1847
|
+
console.log(` ${chalk.dim('merge')} CI ${report.merge_readiness.ci_gate_ok ? chalk.green('clear') : chalk.red('blocked')} AI ${report.merge_readiness.ai_gate_status}`);
|
|
1848
|
+
|
|
1849
|
+
if (report.active_work.length > 0) {
|
|
1850
|
+
console.log('');
|
|
1851
|
+
console.log(chalk.bold('Running now:'));
|
|
1852
|
+
for (const item of report.active_work.slice(0, 5)) {
|
|
1853
|
+
const leaseId = activeLeases.find((lease) => lease.task_id === item.task_id && lease.worktree === item.worktree)?.id || null;
|
|
1854
|
+
const boundary = item.boundary_validation
|
|
1855
|
+
? ` ${chalk.dim(`validation:${item.boundary_validation.status}`)}`
|
|
1856
|
+
: '';
|
|
1857
|
+
const stale = (item.dependency_invalidations?.length || 0) > 0
|
|
1858
|
+
? ` ${chalk.dim(`stale:${item.dependency_invalidations.length}`)}`
|
|
1859
|
+
: '';
|
|
1860
|
+
console.log(` ${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${leaseId ? ` ${chalk.dim(`lease:${leaseId}`)}` : ''}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${boundary}${stale}`);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
console.log('');
|
|
1865
|
+
console.log(chalk.bold('Attention now:'));
|
|
1866
|
+
if (report.attention.length === 0) {
|
|
1867
|
+
console.log(` ${chalk.green('Nothing urgent.')}`);
|
|
1868
|
+
} else {
|
|
1869
|
+
for (const item of report.attention.slice(0, 6)) {
|
|
1870
|
+
const itemBadge = item.severity === 'block' ? chalk.red('block') : chalk.yellow('warn ');
|
|
1871
|
+
console.log(` ${itemBadge} ${item.title}`);
|
|
1872
|
+
if (item.detail) console.log(` ${chalk.dim(item.detail)}`);
|
|
1873
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
1874
|
+
if (item.command) console.log(` ${chalk.cyan('run:')} ${item.command}`);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
console.log('');
|
|
1879
|
+
console.log(chalk.bold('Recommended next steps:'));
|
|
1880
|
+
for (const step of report.next_steps) {
|
|
1881
|
+
console.log(` - ${step}`);
|
|
1882
|
+
}
|
|
1883
|
+
if (report.suggested_commands.length > 0) {
|
|
1884
|
+
console.log('');
|
|
1885
|
+
console.log(chalk.bold('Suggested commands:'));
|
|
1886
|
+
for (const command of report.suggested_commands) {
|
|
1887
|
+
console.log(` ${chalk.cyan(command)}`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
1893
|
+
|
|
1894
|
+
const gateCmd = program.command('gate').description('Enforcement and commit-gate helpers');
|
|
1895
|
+
|
|
1896
|
+
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
1897
|
+
|
|
1898
|
+
auditCmd
|
|
1899
|
+
.command('verify')
|
|
1900
|
+
.description('Verify the audit log hash chain and project signatures')
|
|
1901
|
+
.option('--json', 'Output verification details as JSON')
|
|
1902
|
+
.action((options) => {
|
|
1903
|
+
const repo = getRepo();
|
|
1904
|
+
const db = getDb(repo);
|
|
1905
|
+
const result = verifyAuditTrail(db);
|
|
1906
|
+
|
|
1907
|
+
if (options.json) {
|
|
1908
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1909
|
+
process.exit(result.ok ? 0 : 1);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
if (result.ok) {
|
|
1913
|
+
console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
|
|
1918
|
+
for (const failure of result.failures.slice(0, 10)) {
|
|
1919
|
+
const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
|
|
1920
|
+
console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
|
|
1921
|
+
}
|
|
1922
|
+
if (result.failures.length > 10) {
|
|
1923
|
+
console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
|
|
1924
|
+
}
|
|
1925
|
+
process.exit(1);
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
gateCmd
|
|
1929
|
+
.command('commit')
|
|
1930
|
+
.description('Validate current worktree changes against the active lease and claims')
|
|
1931
|
+
.option('--json', 'Output raw JSON')
|
|
1932
|
+
.action((opts) => {
|
|
1933
|
+
const repoRoot = getRepo();
|
|
1934
|
+
const db = getDb(repoRoot);
|
|
1935
|
+
const result = runCommitGate(db, repoRoot);
|
|
1936
|
+
db.close();
|
|
1937
|
+
|
|
1938
|
+
if (opts.json) {
|
|
1939
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1940
|
+
} else if (result.ok) {
|
|
1941
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
1942
|
+
} else {
|
|
1943
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
1944
|
+
for (const violation of result.violations) {
|
|
1945
|
+
const label = violation.file || '(worktree)';
|
|
1946
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (!result.ok) process.exitCode = 1;
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
gateCmd
|
|
1954
|
+
.command('merge')
|
|
1955
|
+
.description('Validate current worktree changes before recording a merge commit')
|
|
1956
|
+
.option('--json', 'Output raw JSON')
|
|
1957
|
+
.action((opts) => {
|
|
1958
|
+
const repoRoot = getRepo();
|
|
1959
|
+
const db = getDb(repoRoot);
|
|
1960
|
+
const result = runCommitGate(db, repoRoot);
|
|
1961
|
+
db.close();
|
|
1962
|
+
|
|
1963
|
+
if (opts.json) {
|
|
1964
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1965
|
+
} else if (result.ok) {
|
|
1966
|
+
console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
|
|
1967
|
+
} else {
|
|
1968
|
+
console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
|
|
1969
|
+
for (const violation of result.violations) {
|
|
1970
|
+
const label = violation.file || '(worktree)';
|
|
1971
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (!result.ok) process.exitCode = 1;
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
gateCmd
|
|
1979
|
+
.command('install')
|
|
1980
|
+
.description('Install git hooks that run the Switchman commit and merge gates')
|
|
1981
|
+
.action(() => {
|
|
1982
|
+
const repoRoot = getRepo();
|
|
1983
|
+
const hookPaths = installGateHooks(repoRoot);
|
|
1984
|
+
console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
|
|
1985
|
+
console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
gateCmd
|
|
1989
|
+
.command('ci')
|
|
1990
|
+
.description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
|
|
1991
|
+
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
1992
|
+
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
1993
|
+
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
1994
|
+
.option('--json', 'Output raw JSON')
|
|
1995
|
+
.action(async (opts) => {
|
|
1996
|
+
const repoRoot = getRepo();
|
|
1997
|
+
const db = getDb(repoRoot);
|
|
1998
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
1999
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
2000
|
+
db.close();
|
|
2001
|
+
|
|
2002
|
+
const ok = report.conflicts.length === 0
|
|
2003
|
+
&& report.fileConflicts.length === 0
|
|
2004
|
+
&& (report.ownershipConflicts?.length || 0) === 0
|
|
2005
|
+
&& (report.semanticConflicts?.length || 0) === 0
|
|
2006
|
+
&& report.unclaimedChanges.length === 0
|
|
2007
|
+
&& report.complianceSummary.non_compliant === 0
|
|
2008
|
+
&& report.complianceSummary.stale === 0
|
|
2009
|
+
&& aiGate.status !== 'blocked'
|
|
2010
|
+
&& (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
|
|
2011
|
+
|
|
2012
|
+
const result = {
|
|
2013
|
+
ok,
|
|
2014
|
+
summary: ok
|
|
2015
|
+
? `Repo gate passed for ${report.worktrees.length} worktree(s).`
|
|
2016
|
+
: 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
|
|
2017
|
+
compliance: report.complianceSummary,
|
|
2018
|
+
unclaimed_changes: report.unclaimedChanges,
|
|
2019
|
+
file_conflicts: report.fileConflicts,
|
|
2020
|
+
ownership_conflicts: report.ownershipConflicts || [],
|
|
2021
|
+
semantic_conflicts: report.semanticConflicts || [],
|
|
2022
|
+
branch_conflicts: report.conflicts,
|
|
2023
|
+
ai_gate_status: aiGate.status,
|
|
2024
|
+
boundary_validations: aiGate.boundary_validations || [],
|
|
2025
|
+
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
2029
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
2030
|
+
writeGitHubCiStatus({
|
|
2031
|
+
result,
|
|
2032
|
+
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
2033
|
+
outputPath: githubTargets.outputPath,
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
if (opts.json) {
|
|
2038
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2039
|
+
} else if (ok) {
|
|
2040
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
2041
|
+
} else {
|
|
2042
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
2043
|
+
if (result.unclaimed_changes.length > 0) {
|
|
2044
|
+
console.log(chalk.bold(' Unclaimed changes:'));
|
|
2045
|
+
for (const entry of result.unclaimed_changes) {
|
|
2046
|
+
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
if (result.file_conflicts.length > 0) {
|
|
2050
|
+
console.log(chalk.bold(' File conflicts:'));
|
|
2051
|
+
for (const conflict of result.file_conflicts) {
|
|
2052
|
+
console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
if (result.ownership_conflicts.length > 0) {
|
|
2056
|
+
console.log(chalk.bold(' Ownership conflicts:'));
|
|
2057
|
+
for (const conflict of result.ownership_conflicts) {
|
|
2058
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
2059
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
|
|
2060
|
+
} else {
|
|
2061
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
if (result.semantic_conflicts.length > 0) {
|
|
2066
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
2067
|
+
for (const conflict of result.semantic_conflicts) {
|
|
2068
|
+
console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
if (result.branch_conflicts.length > 0) {
|
|
2072
|
+
console.log(chalk.bold(' Branch conflicts:'));
|
|
2073
|
+
for (const conflict of result.branch_conflicts) {
|
|
2074
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
if (result.boundary_validations.length > 0) {
|
|
2078
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
2079
|
+
for (const validation of result.boundary_validations) {
|
|
2080
|
+
console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
if (result.dependency_invalidations.length > 0) {
|
|
2084
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
2085
|
+
for (const invalidation of result.dependency_invalidations) {
|
|
2086
|
+
console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
if (!ok) process.exitCode = 1;
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
gateCmd
|
|
2095
|
+
.command('install-ci')
|
|
2096
|
+
.description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
|
|
2097
|
+
.option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
|
|
2098
|
+
.action((opts) => {
|
|
2099
|
+
const repoRoot = getRepo();
|
|
2100
|
+
const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
|
|
2101
|
+
console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
gateCmd
|
|
2105
|
+
.command('ai')
|
|
2106
|
+
.description('Run the AI-style merge gate to assess semantic integration risk across worktrees')
|
|
2107
|
+
.option('--json', 'Output raw JSON')
|
|
2108
|
+
.action(async (opts) => {
|
|
2109
|
+
const repoRoot = getRepo();
|
|
2110
|
+
const db = getDb(repoRoot);
|
|
2111
|
+
const result = await runAiMergeGate(db, repoRoot);
|
|
2112
|
+
db.close();
|
|
2113
|
+
|
|
2114
|
+
if (opts.json) {
|
|
2115
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2116
|
+
} else {
|
|
2117
|
+
const badge = result.status === 'pass'
|
|
2118
|
+
? chalk.green('PASS')
|
|
2119
|
+
: result.status === 'warn'
|
|
2120
|
+
? chalk.yellow('WARN')
|
|
2121
|
+
: chalk.red('BLOCK');
|
|
2122
|
+
console.log(`${badge} ${result.summary}`);
|
|
2123
|
+
|
|
2124
|
+
const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
|
|
2125
|
+
if (riskyPairs.length > 0) {
|
|
2126
|
+
console.log(chalk.bold(' Risky pairs:'));
|
|
2127
|
+
for (const pair of riskyPairs) {
|
|
2128
|
+
console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
|
|
2129
|
+
for (const reason of pair.reasons.slice(0, 3)) {
|
|
2130
|
+
console.log(` ${chalk.yellow(reason)}`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
if ((result.boundary_validations?.length || 0) > 0) {
|
|
2136
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
2137
|
+
for (const validation of result.boundary_validations.slice(0, 5)) {
|
|
2138
|
+
console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
2139
|
+
if (validation.rationale?.[0]) {
|
|
2140
|
+
console.log(` ${chalk.yellow(validation.rationale[0])}`);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
if ((result.dependency_invalidations?.length || 0) > 0) {
|
|
2146
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
2147
|
+
for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
|
|
2148
|
+
console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
if ((result.semantic_conflicts?.length || 0) > 0) {
|
|
2153
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
2154
|
+
for (const conflict of result.semantic_conflicts.slice(0, 5)) {
|
|
2155
|
+
console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
|
|
2160
|
+
if (riskyWorktrees.length > 0) {
|
|
2161
|
+
console.log(chalk.bold(' Worktree signals:'));
|
|
2162
|
+
for (const worktree of riskyWorktrees) {
|
|
2163
|
+
console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
|
|
2164
|
+
for (const finding of worktree.findings.slice(0, 2)) {
|
|
2165
|
+
console.log(` ${chalk.yellow(finding)}`);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (result.status === 'blocked') process.exitCode = 1;
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
const semanticCmd = program
|
|
2175
|
+
.command('semantic')
|
|
2176
|
+
.description('Inspect or materialize the derived semantic code-object view');
|
|
2177
|
+
|
|
2178
|
+
semanticCmd
|
|
2179
|
+
.command('materialize')
|
|
2180
|
+
.description('Write a deterministic semantic index artifact to .switchman/semantic-index.json')
|
|
2181
|
+
.action(() => {
|
|
2182
|
+
const repoRoot = getRepo();
|
|
2183
|
+
const db = getDb(repoRoot);
|
|
2184
|
+
const worktrees = listWorktrees(db);
|
|
2185
|
+
const result = materializeSemanticIndex(repoRoot, { worktrees });
|
|
2186
|
+
db.close();
|
|
2187
|
+
console.log(`${chalk.green('✓')} Wrote semantic index to ${chalk.cyan(result.output_path)}`);
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
const objectCmd = program
|
|
2191
|
+
.command('object')
|
|
2192
|
+
.description('Experimental object-source mode backed by canonical exported code objects');
|
|
2193
|
+
|
|
2194
|
+
objectCmd
|
|
2195
|
+
.command('import')
|
|
2196
|
+
.description('Import exported code objects from tracked source files into the canonical object store')
|
|
2197
|
+
.option('--json', 'Output raw JSON')
|
|
2198
|
+
.action((opts) => {
|
|
2199
|
+
const repoRoot = getRepo();
|
|
2200
|
+
const db = getDb(repoRoot);
|
|
2201
|
+
const objects = importCodeObjectsToStore(db, repoRoot);
|
|
2202
|
+
db.close();
|
|
2203
|
+
if (opts.json) {
|
|
2204
|
+
console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
console.log(`${chalk.green('✓')} Imported ${objects.length} code object(s) into the canonical store`);
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
objectCmd
|
|
2211
|
+
.command('list')
|
|
2212
|
+
.description('List canonical code objects currently stored in Switchman')
|
|
2213
|
+
.option('--json', 'Output raw JSON')
|
|
2214
|
+
.action((opts) => {
|
|
2215
|
+
const repoRoot = getRepo();
|
|
2216
|
+
const db = getDb(repoRoot);
|
|
2217
|
+
const objects = listCodeObjects(db);
|
|
2218
|
+
db.close();
|
|
2219
|
+
if (opts.json) {
|
|
2220
|
+
console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (objects.length === 0) {
|
|
2224
|
+
console.log(chalk.dim('No canonical code objects stored yet. Run `switchman object import` first.'));
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
for (const object of objects) {
|
|
2228
|
+
console.log(`${chalk.cyan(object.object_id)} ${chalk.dim(`${object.file_path} ${object.kind}`)}`);
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
objectCmd
|
|
2233
|
+
.command('update <objectId>')
|
|
2234
|
+
.description('Update the canonical source text for a stored code object')
|
|
2235
|
+
.requiredOption('--text <source>', 'Replacement exported source text')
|
|
2236
|
+
.option('--json', 'Output raw JSON')
|
|
2237
|
+
.action((objectId, opts) => {
|
|
2238
|
+
const repoRoot = getRepo();
|
|
2239
|
+
const db = getDb(repoRoot);
|
|
2240
|
+
const object = updateCodeObjectSource(db, objectId, opts.text);
|
|
2241
|
+
db.close();
|
|
2242
|
+
if (!object) {
|
|
2243
|
+
console.error(chalk.red(`Unknown code object: ${objectId}`));
|
|
2244
|
+
process.exitCode = 1;
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (opts.json) {
|
|
2248
|
+
console.log(JSON.stringify({ object }, null, 2));
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
console.log(`${chalk.green('✓')} Updated ${chalk.cyan(object.object_id)} in the canonical object store`);
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
objectCmd
|
|
2255
|
+
.command('materialize')
|
|
2256
|
+
.description('Materialize source files from the canonical object store')
|
|
2257
|
+
.option('--output-root <path>', 'Alternate root directory to write materialized files into')
|
|
2258
|
+
.option('--json', 'Output raw JSON')
|
|
2259
|
+
.action((opts) => {
|
|
2260
|
+
const repoRoot = getRepo();
|
|
2261
|
+
const db = getDb(repoRoot);
|
|
2262
|
+
const result = materializeCodeObjects(db, repoRoot, { outputRoot: opts.outputRoot || repoRoot });
|
|
2263
|
+
db.close();
|
|
2264
|
+
if (opts.json) {
|
|
2265
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
console.log(`${chalk.green('✓')} Materialized ${result.file_count} file(s) from the canonical object store`);
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
2272
|
+
|
|
2273
|
+
const monitorCmd = program.command('monitor').description('Observe worktrees for runtime file mutations');
|
|
2274
|
+
|
|
2275
|
+
monitorCmd
|
|
2276
|
+
.command('once')
|
|
2277
|
+
.description('Capture one monitoring pass and log observed file changes')
|
|
2278
|
+
.option('--json', 'Output raw JSON')
|
|
2279
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2280
|
+
.action((opts) => {
|
|
2281
|
+
const repoRoot = getRepo();
|
|
2282
|
+
const db = getDb(repoRoot);
|
|
2283
|
+
const worktrees = listGitWorktrees(repoRoot);
|
|
2284
|
+
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
2285
|
+
db.close();
|
|
2286
|
+
|
|
2287
|
+
if (opts.json) {
|
|
2288
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (result.events.length === 0) {
|
|
2293
|
+
console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
|
|
2298
|
+
for (const event of result.events) {
|
|
2299
|
+
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
2300
|
+
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
2301
|
+
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}`);
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
monitorCmd
|
|
2306
|
+
.command('watch')
|
|
2307
|
+
.description('Poll worktrees continuously and log observed file changes')
|
|
2308
|
+
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2309
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2310
|
+
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
2311
|
+
.action(async (opts) => {
|
|
2312
|
+
const repoRoot = getRepo();
|
|
2313
|
+
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
2314
|
+
|
|
2315
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 100) {
|
|
2316
|
+
console.error(chalk.red('--interval-ms must be at least 100'));
|
|
2317
|
+
process.exit(1);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
console.log(chalk.cyan(`Watching worktrees every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
2321
|
+
|
|
2322
|
+
let stopped = false;
|
|
2323
|
+
const stop = () => {
|
|
2324
|
+
stopped = true;
|
|
2325
|
+
process.stdout.write('\n');
|
|
2326
|
+
if (opts.daemonized) {
|
|
2327
|
+
clearMonitorState(repoRoot);
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
process.on('SIGINT', stop);
|
|
2331
|
+
process.on('SIGTERM', stop);
|
|
2332
|
+
|
|
2333
|
+
while (!stopped) {
|
|
2334
|
+
const db = getDb(repoRoot);
|
|
2335
|
+
const worktrees = listGitWorktrees(repoRoot);
|
|
2336
|
+
const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
|
|
2337
|
+
db.close();
|
|
2338
|
+
|
|
2339
|
+
for (const event of result.events) {
|
|
2340
|
+
const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
|
|
2341
|
+
const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
|
|
2342
|
+
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}`);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
if (stopped) break;
|
|
2346
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
console.log(chalk.dim('Stopped worktree monitor.'));
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
monitorCmd
|
|
2353
|
+
.command('start')
|
|
2354
|
+
.description('Start the worktree monitor as a background process')
|
|
2355
|
+
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2356
|
+
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2357
|
+
.action((opts) => {
|
|
2358
|
+
const repoRoot = getRepo();
|
|
2359
|
+
const intervalMs = Number.parseInt(opts.intervalMs, 10);
|
|
2360
|
+
const existingState = readMonitorState(repoRoot);
|
|
2361
|
+
|
|
2362
|
+
if (existingState && isProcessRunning(existingState.pid)) {
|
|
2363
|
+
console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
const logPath = join(repoRoot, '.switchman', 'monitor.log');
|
|
2368
|
+
const child = spawn(process.execPath, [
|
|
2369
|
+
process.argv[1],
|
|
2370
|
+
'monitor',
|
|
2371
|
+
'watch',
|
|
2372
|
+
'--interval-ms',
|
|
2373
|
+
String(intervalMs),
|
|
2374
|
+
...(opts.quarantine ? ['--quarantine'] : []),
|
|
2375
|
+
'--daemonized',
|
|
2376
|
+
], {
|
|
2377
|
+
cwd: repoRoot,
|
|
2378
|
+
detached: true,
|
|
2379
|
+
stdio: 'ignore',
|
|
2380
|
+
});
|
|
2381
|
+
child.unref();
|
|
2382
|
+
|
|
2383
|
+
const statePath = writeMonitorState(repoRoot, {
|
|
2384
|
+
pid: child.pid,
|
|
2385
|
+
interval_ms: intervalMs,
|
|
2386
|
+
quarantine: Boolean(opts.quarantine),
|
|
2387
|
+
log_path: logPath,
|
|
2388
|
+
started_at: new Date().toISOString(),
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
|
|
2392
|
+
console.log(`${chalk.dim('State:')} ${statePath}`);
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
monitorCmd
|
|
2396
|
+
.command('stop')
|
|
2397
|
+
.description('Stop the background worktree monitor')
|
|
2398
|
+
.action(() => {
|
|
2399
|
+
const repoRoot = getRepo();
|
|
2400
|
+
const state = readMonitorState(repoRoot);
|
|
2401
|
+
|
|
2402
|
+
if (!state) {
|
|
2403
|
+
console.log(chalk.dim('Monitor is not running.'));
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
if (!isProcessRunning(state.pid)) {
|
|
2408
|
+
clearMonitorState(repoRoot);
|
|
2409
|
+
console.log(chalk.dim('Monitor state was stale and has been cleared.'));
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
process.kill(state.pid, 'SIGTERM');
|
|
2414
|
+
clearMonitorState(repoRoot);
|
|
2415
|
+
console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
monitorCmd
|
|
2419
|
+
.command('status')
|
|
2420
|
+
.description('Show background monitor process status')
|
|
2421
|
+
.action(() => {
|
|
2422
|
+
const repoRoot = getRepo();
|
|
2423
|
+
const state = readMonitorState(repoRoot);
|
|
2424
|
+
|
|
2425
|
+
if (!state) {
|
|
2426
|
+
console.log(chalk.dim('Monitor is not running.'));
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
const running = isProcessRunning(state.pid);
|
|
2431
|
+
if (!running) {
|
|
2432
|
+
clearMonitorState(repoRoot);
|
|
2433
|
+
console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
console.log(`${chalk.green('✓')} Monitor running`);
|
|
2438
|
+
console.log(` ${chalk.dim('pid')} ${state.pid}`);
|
|
2439
|
+
console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
|
|
2440
|
+
console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
|
|
2441
|
+
console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
// ── policy ───────────────────────────────────────────────────────────────────
|
|
2445
|
+
|
|
2446
|
+
const policyCmd = program.command('policy').description('Manage enforcement policy exceptions');
|
|
2447
|
+
|
|
2448
|
+
policyCmd
|
|
2449
|
+
.command('init')
|
|
2450
|
+
.description('Write a starter enforcement policy file for generated-path exceptions')
|
|
2451
|
+
.action(() => {
|
|
2452
|
+
const repoRoot = getRepo();
|
|
2453
|
+
const policyPath = writeEnforcementPolicy(repoRoot, {
|
|
2454
|
+
allowed_generated_paths: [
|
|
2455
|
+
'dist/**',
|
|
2456
|
+
'build/**',
|
|
2457
|
+
'coverage/**',
|
|
2458
|
+
],
|
|
2459
|
+
});
|
|
2460
|
+
console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
|
|
600
2461
|
});
|
|
601
2462
|
|
|
602
|
-
program.parse();
|
|
2463
|
+
program.parse();
|