switchman-dev 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +124 -195
- package/examples/README.md +9 -2
- package/package.json +6 -1
- package/src/cli/commands/audit.js +77 -0
- package/src/cli/commands/claude.js +37 -0
- package/src/cli/commands/gate.js +278 -0
- package/src/cli/commands/lease.js +256 -0
- package/src/cli/commands/mcp.js +45 -0
- package/src/cli/commands/monitor.js +191 -0
- package/src/cli/commands/queue.js +549 -0
- package/src/cli/commands/task.js +248 -0
- package/src/cli/commands/telemetry.js +108 -0
- package/src/cli/commands/worktree.js +85 -0
- package/src/cli/index.js +2072 -1649
- package/src/core/ci.js +1 -1
- package/src/core/db.js +143 -21
- package/src/core/enforcement.js +122 -10
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +5 -3
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +66 -35
- package/src/core/planner.js +10 -6
- package/src/core/policy.js +1 -1
- package/src/core/queue.js +11 -2
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/src.zip +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function registerAuditCommands(program, {
|
|
2
|
+
buildPipelineHistoryReport,
|
|
3
|
+
chalk,
|
|
4
|
+
getDb,
|
|
5
|
+
getRepo,
|
|
6
|
+
printErrorWithNext,
|
|
7
|
+
statusBadge,
|
|
8
|
+
verifyAuditTrail,
|
|
9
|
+
}) {
|
|
10
|
+
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
11
|
+
auditCmd._switchmanAdvanced = true;
|
|
12
|
+
|
|
13
|
+
auditCmd
|
|
14
|
+
.command('change <pipelineId>')
|
|
15
|
+
.description('Show a signed, operator-friendly history for one pipeline')
|
|
16
|
+
.option('--json', 'Output raw JSON')
|
|
17
|
+
.action((pipelineId, options) => {
|
|
18
|
+
const repoRoot = getRepo();
|
|
19
|
+
const db = getDb(repoRoot);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
|
|
23
|
+
db.close();
|
|
24
|
+
|
|
25
|
+
if (options.json) {
|
|
26
|
+
console.log(JSON.stringify(report, null, 2));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
|
|
31
|
+
console.log(` ${chalk.dim('title:')} ${report.title}`);
|
|
32
|
+
console.log(` ${chalk.dim('events:')} ${report.events.length}`);
|
|
33
|
+
console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
|
|
34
|
+
for (const event of report.events.slice(-20)) {
|
|
35
|
+
const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
|
|
36
|
+
console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
|
|
37
|
+
console.log(` ${event.summary}`);
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
db.close();
|
|
41
|
+
printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
auditCmd
|
|
47
|
+
.command('verify')
|
|
48
|
+
.description('Verify the audit log hash chain and project signatures')
|
|
49
|
+
.option('--json', 'Output verification details as JSON')
|
|
50
|
+
.action((options) => {
|
|
51
|
+
const repo = getRepo();
|
|
52
|
+
const db = getDb(repo);
|
|
53
|
+
const result = verifyAuditTrail(db);
|
|
54
|
+
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify(result, null, 2));
|
|
57
|
+
process.exit(result.ok ? 0 : 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
|
|
66
|
+
for (const failure of result.failures.slice(0, 10)) {
|
|
67
|
+
const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
|
|
68
|
+
console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (result.failures.length > 10) {
|
|
71
|
+
console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
|
|
72
|
+
}
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return auditCmd;
|
|
77
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function registerClaudeCommands(program, {
|
|
2
|
+
chalk,
|
|
3
|
+
existsSync,
|
|
4
|
+
getRepo,
|
|
5
|
+
join,
|
|
6
|
+
renderClaudeGuide,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
}) {
|
|
9
|
+
const claudeCmd = program.command('claude').description('Generate or refresh Claude Code instructions for this repo');
|
|
10
|
+
|
|
11
|
+
claudeCmd
|
|
12
|
+
.command('refresh')
|
|
13
|
+
.description('Generate a repo-aware CLAUDE.md for this repository')
|
|
14
|
+
.option('--print', 'Print the generated guide instead of writing CLAUDE.md')
|
|
15
|
+
.addHelpText('after', `
|
|
16
|
+
Examples:
|
|
17
|
+
switchman claude refresh
|
|
18
|
+
switchman claude refresh --print
|
|
19
|
+
`)
|
|
20
|
+
.action((opts) => {
|
|
21
|
+
const repoRoot = getRepo();
|
|
22
|
+
const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
|
|
23
|
+
const content = renderClaudeGuide(repoRoot);
|
|
24
|
+
|
|
25
|
+
if (opts.print) {
|
|
26
|
+
process.stdout.write(content);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const existed = existsSync(claudeGuidePath);
|
|
31
|
+
writeFileSync(claudeGuidePath, content, 'utf8');
|
|
32
|
+
console.log(`${chalk.green('✓')} ${existed ? 'Refreshed' : 'Created'} ${chalk.cyan(claudeGuidePath)}`);
|
|
33
|
+
console.log(` ${chalk.dim('next:')} ${chalk.cyan('switchman verify-setup')}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return claudeCmd;
|
|
37
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
export function registerGateCommands(program, {
|
|
2
|
+
chalk,
|
|
3
|
+
getDb,
|
|
4
|
+
getRepo,
|
|
5
|
+
installGateHooks,
|
|
6
|
+
installGitHubActionsWorkflow,
|
|
7
|
+
maybeCaptureTelemetry,
|
|
8
|
+
resolveGitHubOutputTargets,
|
|
9
|
+
runAiMergeGate,
|
|
10
|
+
runCommitGate,
|
|
11
|
+
scanAllWorktrees,
|
|
12
|
+
writeGitHubCiStatus,
|
|
13
|
+
}) {
|
|
14
|
+
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
15
|
+
gateCmd.addHelpText('after', `
|
|
16
|
+
Examples:
|
|
17
|
+
switchman gate ci
|
|
18
|
+
switchman gate ai
|
|
19
|
+
switchman gate install-ci
|
|
20
|
+
`);
|
|
21
|
+
|
|
22
|
+
gateCmd
|
|
23
|
+
.command('commit')
|
|
24
|
+
.description('Validate current worktree changes against the active lease and claims')
|
|
25
|
+
.option('--json', 'Output raw JSON')
|
|
26
|
+
.action((opts) => {
|
|
27
|
+
const repoRoot = getRepo();
|
|
28
|
+
const db = getDb(repoRoot);
|
|
29
|
+
const result = runCommitGate(db, repoRoot);
|
|
30
|
+
db.close();
|
|
31
|
+
|
|
32
|
+
if (opts.json) {
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
} else if (result.ok) {
|
|
35
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
36
|
+
} else {
|
|
37
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
38
|
+
for (const violation of result.violations) {
|
|
39
|
+
const label = violation.file || '(worktree)';
|
|
40
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!result.ok) process.exitCode = 1;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
gateCmd
|
|
48
|
+
.command('merge')
|
|
49
|
+
.description('Validate current worktree changes before recording a merge commit')
|
|
50
|
+
.option('--json', 'Output raw JSON')
|
|
51
|
+
.action((opts) => {
|
|
52
|
+
const repoRoot = getRepo();
|
|
53
|
+
const db = getDb(repoRoot);
|
|
54
|
+
const result = runCommitGate(db, repoRoot);
|
|
55
|
+
db.close();
|
|
56
|
+
|
|
57
|
+
if (opts.json) {
|
|
58
|
+
console.log(JSON.stringify(result, null, 2));
|
|
59
|
+
} else if (result.ok) {
|
|
60
|
+
console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
|
|
61
|
+
} else {
|
|
62
|
+
console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
|
|
63
|
+
for (const violation of result.violations) {
|
|
64
|
+
const label = violation.file || '(worktree)';
|
|
65
|
+
console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!result.ok) process.exitCode = 1;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
gateCmd
|
|
73
|
+
.command('install')
|
|
74
|
+
.description('Install git hooks that run the Switchman commit and merge gates')
|
|
75
|
+
.action(() => {
|
|
76
|
+
const repoRoot = getRepo();
|
|
77
|
+
const hookPaths = installGateHooks(repoRoot);
|
|
78
|
+
console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
|
|
79
|
+
console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
gateCmd
|
|
83
|
+
.command('ci')
|
|
84
|
+
.description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
|
|
85
|
+
.option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
|
|
86
|
+
.option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
|
|
87
|
+
.option('--github-output <path>', 'Path to write GitHub Actions outputs')
|
|
88
|
+
.option('--json', 'Output raw JSON')
|
|
89
|
+
.action(async (opts) => {
|
|
90
|
+
const repoRoot = getRepo();
|
|
91
|
+
const db = getDb(repoRoot);
|
|
92
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
93
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
94
|
+
db.close();
|
|
95
|
+
|
|
96
|
+
const ok = report.conflicts.length === 0
|
|
97
|
+
&& report.fileConflicts.length === 0
|
|
98
|
+
&& (report.ownershipConflicts?.length || 0) === 0
|
|
99
|
+
&& (report.semanticConflicts?.length || 0) === 0
|
|
100
|
+
&& report.unclaimedChanges.length === 0
|
|
101
|
+
&& report.complianceSummary.non_compliant === 0
|
|
102
|
+
&& report.complianceSummary.stale === 0
|
|
103
|
+
&& aiGate.status !== 'blocked'
|
|
104
|
+
&& (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
|
|
105
|
+
|
|
106
|
+
const result = {
|
|
107
|
+
ok,
|
|
108
|
+
summary: ok
|
|
109
|
+
? `Repo gate passed for ${report.worktrees.length} worktree(s).`
|
|
110
|
+
: 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
|
|
111
|
+
compliance: report.complianceSummary,
|
|
112
|
+
unclaimed_changes: report.unclaimedChanges,
|
|
113
|
+
file_conflicts: report.fileConflicts,
|
|
114
|
+
ownership_conflicts: report.ownershipConflicts || [],
|
|
115
|
+
semantic_conflicts: report.semanticConflicts || [],
|
|
116
|
+
branch_conflicts: report.conflicts,
|
|
117
|
+
ai_gate_status: aiGate.status,
|
|
118
|
+
boundary_validations: aiGate.boundary_validations || [],
|
|
119
|
+
dependency_invalidations: aiGate.dependency_invalidations || [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const githubTargets = resolveGitHubOutputTargets(opts);
|
|
123
|
+
if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
|
|
124
|
+
writeGitHubCiStatus({
|
|
125
|
+
result,
|
|
126
|
+
stepSummaryPath: githubTargets.stepSummaryPath,
|
|
127
|
+
outputPath: githubTargets.outputPath,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
console.log(JSON.stringify(result, null, 2));
|
|
133
|
+
} else if (ok) {
|
|
134
|
+
console.log(`${chalk.green('✓')} ${result.summary}`);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.red(`✗ ${result.summary}`));
|
|
137
|
+
if (result.unclaimed_changes.length > 0) {
|
|
138
|
+
console.log(chalk.bold(' Unclaimed changes:'));
|
|
139
|
+
for (const entry of result.unclaimed_changes) {
|
|
140
|
+
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (result.file_conflicts.length > 0) {
|
|
144
|
+
console.log(chalk.bold(' File conflicts:'));
|
|
145
|
+
for (const conflict of result.file_conflicts) {
|
|
146
|
+
console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (result.ownership_conflicts.length > 0) {
|
|
150
|
+
console.log(chalk.bold(' Ownership conflicts:'));
|
|
151
|
+
for (const conflict of result.ownership_conflicts) {
|
|
152
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
153
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (result.semantic_conflicts.length > 0) {
|
|
160
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
161
|
+
for (const conflict of result.semantic_conflicts) {
|
|
162
|
+
console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (result.branch_conflicts.length > 0) {
|
|
166
|
+
console.log(chalk.bold(' Branch conflicts:'));
|
|
167
|
+
for (const conflict of result.branch_conflicts) {
|
|
168
|
+
console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (result.boundary_validations.length > 0) {
|
|
172
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
173
|
+
for (const validation of result.boundary_validations) {
|
|
174
|
+
console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (result.dependency_invalidations.length > 0) {
|
|
178
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
179
|
+
for (const invalidation of result.dependency_invalidations) {
|
|
180
|
+
console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
|
|
186
|
+
worktree_count: report.worktrees.length,
|
|
187
|
+
unclaimed_change_count: result.unclaimed_changes.length,
|
|
188
|
+
file_conflict_count: result.file_conflicts.length,
|
|
189
|
+
ownership_conflict_count: result.ownership_conflicts.length,
|
|
190
|
+
semantic_conflict_count: result.semantic_conflicts.length,
|
|
191
|
+
branch_conflict_count: result.branch_conflicts.length,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!ok) process.exitCode = 1;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
gateCmd
|
|
198
|
+
.command('install-ci')
|
|
199
|
+
.description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
|
|
200
|
+
.option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
|
|
201
|
+
.action((opts) => {
|
|
202
|
+
const repoRoot = getRepo();
|
|
203
|
+
const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
|
|
204
|
+
console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
gateCmd
|
|
208
|
+
.command('ai')
|
|
209
|
+
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
210
|
+
.option('--json', 'Output raw JSON')
|
|
211
|
+
.action(async (opts) => {
|
|
212
|
+
const repoRoot = getRepo();
|
|
213
|
+
const db = getDb(repoRoot);
|
|
214
|
+
const result = await runAiMergeGate(db, repoRoot);
|
|
215
|
+
db.close();
|
|
216
|
+
|
|
217
|
+
if (opts.json) {
|
|
218
|
+
console.log(JSON.stringify(result, null, 2));
|
|
219
|
+
} else {
|
|
220
|
+
const badge = result.status === 'pass'
|
|
221
|
+
? chalk.green('PASS')
|
|
222
|
+
: result.status === 'warn'
|
|
223
|
+
? chalk.yellow('WARN')
|
|
224
|
+
: chalk.red('BLOCK');
|
|
225
|
+
console.log(`${badge} ${result.summary}`);
|
|
226
|
+
|
|
227
|
+
const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
|
|
228
|
+
if (riskyPairs.length > 0) {
|
|
229
|
+
console.log(chalk.bold(' Risky pairs:'));
|
|
230
|
+
for (const pair of riskyPairs) {
|
|
231
|
+
console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
|
|
232
|
+
for (const reason of pair.reasons.slice(0, 3)) {
|
|
233
|
+
console.log(` ${chalk.yellow(reason)}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if ((result.boundary_validations?.length || 0) > 0) {
|
|
239
|
+
console.log(chalk.bold(' Boundary validations:'));
|
|
240
|
+
for (const validation of result.boundary_validations.slice(0, 5)) {
|
|
241
|
+
console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
|
|
242
|
+
if (validation.rationale?.[0]) {
|
|
243
|
+
console.log(` ${chalk.yellow(validation.rationale[0])}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ((result.dependency_invalidations?.length || 0) > 0) {
|
|
249
|
+
console.log(chalk.bold(' Stale dependency invalidations:'));
|
|
250
|
+
for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
|
|
251
|
+
console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if ((result.semantic_conflicts?.length || 0) > 0) {
|
|
256
|
+
console.log(chalk.bold(' Semantic conflicts:'));
|
|
257
|
+
for (const conflict of result.semantic_conflicts.slice(0, 5)) {
|
|
258
|
+
console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
|
|
263
|
+
if (riskyWorktrees.length > 0) {
|
|
264
|
+
console.log(chalk.bold(' Worktree signals:'));
|
|
265
|
+
for (const worktree of riskyWorktrees) {
|
|
266
|
+
console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
|
|
267
|
+
for (const finding of worktree.findings.slice(0, 2)) {
|
|
268
|
+
console.log(` ${chalk.yellow(finding)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (result.status === 'blocked') process.exitCode = 1;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return gateCmd;
|
|
278
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
export function registerLeaseCommands(program, {
|
|
2
|
+
acquireNextTaskLeaseWithRetries,
|
|
3
|
+
chalk,
|
|
4
|
+
getCurrentWorktreeName,
|
|
5
|
+
getDb,
|
|
6
|
+
getRepo,
|
|
7
|
+
getTask,
|
|
8
|
+
heartbeatLease,
|
|
9
|
+
listLeases,
|
|
10
|
+
loadLeasePolicy,
|
|
11
|
+
pushSyncEvent,
|
|
12
|
+
reapStaleLeases,
|
|
13
|
+
startTaskLease,
|
|
14
|
+
statusBadge,
|
|
15
|
+
taskJsonWithLease,
|
|
16
|
+
writeLeasePolicy,
|
|
17
|
+
}) {
|
|
18
|
+
const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
|
|
19
|
+
leaseCmd.addHelpText('after', `
|
|
20
|
+
Plain English:
|
|
21
|
+
lease = a task currently checked out by an agent
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
switchman lease next --json
|
|
25
|
+
switchman lease heartbeat lease-123
|
|
26
|
+
switchman lease reap
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
leaseCmd
|
|
30
|
+
.command('acquire <taskId> <worktree>')
|
|
31
|
+
.description('Start a tracked work session for a specific pending task')
|
|
32
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
33
|
+
.option('--json', 'Output as JSON')
|
|
34
|
+
.addHelpText('after', `
|
|
35
|
+
Examples:
|
|
36
|
+
switchman lease acquire task-123 agent2
|
|
37
|
+
switchman lease acquire task-123 agent2 --agent cursor
|
|
38
|
+
`)
|
|
39
|
+
.action((taskId, worktree, opts) => {
|
|
40
|
+
const repoRoot = getRepo();
|
|
41
|
+
const db = getDb(repoRoot);
|
|
42
|
+
const task = getTask(db, taskId);
|
|
43
|
+
const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
|
|
44
|
+
db.close();
|
|
45
|
+
|
|
46
|
+
if (!lease || !task) {
|
|
47
|
+
if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
|
|
48
|
+
else console.log(chalk.red('Could not start a work session. The task may not exist or may already be in progress.'));
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.json) {
|
|
54
|
+
console.log(JSON.stringify({
|
|
55
|
+
lease,
|
|
56
|
+
task: taskJsonWithLease(task, worktree, lease).task,
|
|
57
|
+
}, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
|
|
62
|
+
console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
|
|
63
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
leaseCmd
|
|
67
|
+
.command('next')
|
|
68
|
+
.description('Start the next pending task and open a tracked work session for it')
|
|
69
|
+
.option('--json', 'Output as JSON')
|
|
70
|
+
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
71
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
72
|
+
.addHelpText('after', `
|
|
73
|
+
Examples:
|
|
74
|
+
switchman lease next
|
|
75
|
+
switchman lease next --json
|
|
76
|
+
switchman lease next --worktree agent2 --agent cursor
|
|
77
|
+
`)
|
|
78
|
+
.action((opts) => {
|
|
79
|
+
const repoRoot = getRepo();
|
|
80
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
81
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
82
|
+
|
|
83
|
+
if (!task) {
|
|
84
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
85
|
+
else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
|
|
86
|
+
else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!lease) {
|
|
91
|
+
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
92
|
+
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify({
|
|
98
|
+
lease,
|
|
99
|
+
...taskJsonWithLease(task, worktreeName, lease),
|
|
100
|
+
}, null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
|
|
105
|
+
pushSyncEvent('lease_acquired', { task_id: task.id, title: task.title }, { worktree: worktreeName }).catch(() => {});
|
|
106
|
+
console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
|
|
107
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
leaseCmd
|
|
111
|
+
.command('list')
|
|
112
|
+
.description('List leases, newest first')
|
|
113
|
+
.option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
|
|
114
|
+
.action((opts) => {
|
|
115
|
+
const repoRoot = getRepo();
|
|
116
|
+
const db = getDb(repoRoot);
|
|
117
|
+
const leases = listLeases(db, opts.status);
|
|
118
|
+
db.close();
|
|
119
|
+
|
|
120
|
+
if (!leases.length) {
|
|
121
|
+
console.log(chalk.dim('No leases found.'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('');
|
|
126
|
+
for (const lease of leases) {
|
|
127
|
+
console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
|
|
128
|
+
console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
|
|
129
|
+
console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
|
|
130
|
+
console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
|
|
131
|
+
if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
|
|
132
|
+
console.log('');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
leaseCmd
|
|
137
|
+
.command('heartbeat <leaseId>')
|
|
138
|
+
.description('Refresh the heartbeat timestamp for an active lease')
|
|
139
|
+
.option('--agent <name>', 'Agent identifier for logging')
|
|
140
|
+
.option('--json', 'Output as JSON')
|
|
141
|
+
.action((leaseId, opts) => {
|
|
142
|
+
const repoRoot = getRepo();
|
|
143
|
+
const db = getDb(repoRoot);
|
|
144
|
+
const lease = heartbeatLease(db, leaseId, opts.agent || null);
|
|
145
|
+
db.close();
|
|
146
|
+
|
|
147
|
+
if (!lease) {
|
|
148
|
+
if (opts.json) console.log(JSON.stringify({ lease: null }));
|
|
149
|
+
else console.log(chalk.red(`No active work session found for ${leaseId}.`));
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (opts.json) {
|
|
155
|
+
console.log(JSON.stringify({ lease }, null, 2));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
|
|
160
|
+
console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
leaseCmd
|
|
164
|
+
.command('reap')
|
|
165
|
+
.description('Clean up abandoned work sessions and release their file locks')
|
|
166
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
167
|
+
.option('--json', 'Output as JSON')
|
|
168
|
+
.addHelpText('after', `
|
|
169
|
+
Examples:
|
|
170
|
+
switchman lease reap
|
|
171
|
+
switchman lease reap --stale-after-minutes 20
|
|
172
|
+
`)
|
|
173
|
+
.action((opts) => {
|
|
174
|
+
const repoRoot = getRepo();
|
|
175
|
+
const db = getDb(repoRoot);
|
|
176
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
177
|
+
const staleAfterMinutes = opts.staleAfterMinutes
|
|
178
|
+
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
179
|
+
: leasePolicy.stale_after_minutes;
|
|
180
|
+
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
181
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
182
|
+
});
|
|
183
|
+
db.close();
|
|
184
|
+
|
|
185
|
+
if (opts.json) {
|
|
186
|
+
console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!expired.length) {
|
|
191
|
+
console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
|
|
196
|
+
for (const lease of expired) {
|
|
197
|
+
console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
202
|
+
|
|
203
|
+
leasePolicyCmd
|
|
204
|
+
.command('set')
|
|
205
|
+
.description('Persist a stale-lease policy for this repo')
|
|
206
|
+
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
207
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
208
|
+
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
209
|
+
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
210
|
+
.option('--json', 'Output as JSON')
|
|
211
|
+
.action((opts) => {
|
|
212
|
+
const repoRoot = getRepo();
|
|
213
|
+
const current = loadLeasePolicy(repoRoot);
|
|
214
|
+
const next = {
|
|
215
|
+
...current,
|
|
216
|
+
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
217
|
+
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
218
|
+
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
219
|
+
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
220
|
+
};
|
|
221
|
+
const path = writeLeasePolicy(repoRoot, next);
|
|
222
|
+
const saved = loadLeasePolicy(repoRoot);
|
|
223
|
+
|
|
224
|
+
if (opts.json) {
|
|
225
|
+
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
230
|
+
console.log(` ${chalk.dim(path)}`);
|
|
231
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
232
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
233
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
234
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
leasePolicyCmd
|
|
238
|
+
.description('Show the active stale-lease policy for this repo')
|
|
239
|
+
.option('--json', 'Output as JSON')
|
|
240
|
+
.action((opts) => {
|
|
241
|
+
const repoRoot = getRepo();
|
|
242
|
+
const policy = loadLeasePolicy(repoRoot);
|
|
243
|
+
if (opts.json) {
|
|
244
|
+
console.log(JSON.stringify({ policy }, null, 2));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(chalk.bold('Lease policy'));
|
|
249
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
|
|
250
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
|
|
251
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
|
|
252
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return leaseCmd;
|
|
256
|
+
}
|