switchman-dev 0.1.2 → 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 +301 -7
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +1 -1
- package/src/cli/index.js +1699 -37
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1417 -26
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +34 -4
- 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 +321 -1
package/src/cli/index.js
CHANGED
|
@@ -19,23 +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,
|
|
31
|
-
createTask, startTaskLease, completeTask, failTask, listTasks, getTask, getNextPendingTask,
|
|
32
|
-
listLeases, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
29
|
+
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
30
|
+
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
33
31
|
registerWorktree, listWorktrees,
|
|
34
32
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
|
|
33
|
+
verifyAuditTrail,
|
|
35
34
|
} from '../core/db.js';
|
|
36
35
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
47
|
|
|
40
48
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
41
49
|
|
|
@@ -68,6 +76,10 @@ function statusBadge(status) {
|
|
|
68
76
|
expired: chalk.red,
|
|
69
77
|
idle: chalk.gray,
|
|
70
78
|
busy: chalk.blue,
|
|
79
|
+
managed: chalk.green,
|
|
80
|
+
observed: chalk.yellow,
|
|
81
|
+
non_compliant: chalk.red,
|
|
82
|
+
stale: chalk.red,
|
|
71
83
|
};
|
|
72
84
|
return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
|
|
73
85
|
}
|
|
@@ -105,6 +117,412 @@ function printTable(rows, columns) {
|
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
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
|
+
|
|
108
526
|
// ─── Program ──────────────────────────────────────────────────────────────────
|
|
109
527
|
|
|
110
528
|
program
|
|
@@ -129,10 +547,13 @@ program
|
|
|
129
547
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
130
548
|
}
|
|
131
549
|
|
|
550
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
551
|
+
|
|
132
552
|
db.close();
|
|
133
553
|
spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
|
|
134
554
|
console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
|
|
135
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`));
|
|
136
557
|
console.log('');
|
|
137
558
|
console.log(`Next steps:`);
|
|
138
559
|
console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
|
|
@@ -204,6 +625,8 @@ program
|
|
|
204
625
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
205
626
|
}
|
|
206
627
|
|
|
628
|
+
const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
|
|
629
|
+
|
|
207
630
|
db.close();
|
|
208
631
|
|
|
209
632
|
const label = agentCount === 1 ? 'workspace' : 'workspaces';
|
|
@@ -216,11 +639,18 @@ program
|
|
|
216
639
|
console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
|
|
217
640
|
}
|
|
218
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
|
+
|
|
219
649
|
console.log('');
|
|
220
650
|
console.log(chalk.bold('Next steps:'));
|
|
221
651
|
console.log(` 1. Add your tasks:`);
|
|
222
652
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
223
|
-
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`);
|
|
224
654
|
console.log(` 3. Check status at any time:`);
|
|
225
655
|
console.log(` ${chalk.cyan('switchman status')}`);
|
|
226
656
|
console.log('');
|
|
@@ -252,8 +682,14 @@ taskCmd
|
|
|
252
682
|
priority: parseInt(opts.priority),
|
|
253
683
|
});
|
|
254
684
|
db.close();
|
|
685
|
+
const scopeWarning = analyzeTaskScope(title, opts.description || '');
|
|
255
686
|
console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
|
|
256
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
|
+
}
|
|
257
693
|
});
|
|
258
694
|
|
|
259
695
|
taskCmd
|
|
@@ -303,11 +739,13 @@ taskCmd
|
|
|
303
739
|
.description('Mark a task as complete and release all file claims')
|
|
304
740
|
.action((taskId) => {
|
|
305
741
|
const repoRoot = getRepo();
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
+
}
|
|
311
749
|
});
|
|
312
750
|
|
|
313
751
|
taskCmd
|
|
@@ -330,23 +768,17 @@ taskCmd
|
|
|
330
768
|
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
331
769
|
.action((opts) => {
|
|
332
770
|
const repoRoot = getRepo();
|
|
333
|
-
const
|
|
334
|
-
const task =
|
|
771
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
772
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
335
773
|
|
|
336
774
|
if (!task) {
|
|
337
|
-
db.close();
|
|
338
775
|
if (opts.json) console.log(JSON.stringify({ task: null }));
|
|
339
|
-
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.'));
|
|
340
778
|
return;
|
|
341
779
|
}
|
|
342
780
|
|
|
343
|
-
// Determine worktree name: explicit flag, or derive from cwd
|
|
344
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
345
|
-
const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
|
|
346
|
-
db.close();
|
|
347
|
-
|
|
348
781
|
if (!lease) {
|
|
349
|
-
// Race condition: another agent grabbed it between get and assign
|
|
350
782
|
if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
|
|
351
783
|
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
352
784
|
return;
|
|
@@ -360,6 +792,300 @@ taskCmd
|
|
|
360
792
|
}
|
|
361
793
|
});
|
|
362
794
|
|
|
795
|
+
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
798
|
+
|
|
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) => {
|
|
807
|
+
const repoRoot = getRepo();
|
|
808
|
+
const db = getDb(repoRoot);
|
|
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
|
+
});
|
|
815
|
+
db.close();
|
|
816
|
+
|
|
817
|
+
if (opts.json) {
|
|
818
|
+
console.log(JSON.stringify(result, null, 2));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
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}`);
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
pipelineCmd
|
|
832
|
+
.command('status <pipelineId>')
|
|
833
|
+
.description('Show task status for a pipeline')
|
|
834
|
+
.option('--json', 'Output raw JSON')
|
|
835
|
+
.action((pipelineId, opts) => {
|
|
836
|
+
const repoRoot = getRepo();
|
|
837
|
+
const db = getDb(repoRoot);
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const result = getPipelineStatus(db, pipelineId);
|
|
841
|
+
db.close();
|
|
842
|
+
|
|
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;
|
|
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) => {
|
|
875
|
+
const repoRoot = getRepo();
|
|
876
|
+
const db = getDb(repoRoot);
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
|
|
880
|
+
db.close();
|
|
881
|
+
|
|
882
|
+
if (opts.json) {
|
|
883
|
+
console.log(JSON.stringify(result, null, 2));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
console.log(result.markdown);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
db.close();
|
|
890
|
+
console.error(chalk.red(err.message));
|
|
891
|
+
process.exitCode = 1;
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
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) => {
|
|
900
|
+
const repoRoot = getRepo();
|
|
901
|
+
const db = getDb(repoRoot);
|
|
902
|
+
|
|
903
|
+
try {
|
|
904
|
+
const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
|
|
905
|
+
db.close();
|
|
906
|
+
|
|
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')
|
|
931
|
+
.option('--json', 'Output raw JSON')
|
|
932
|
+
.action(async (pipelineId, outputDir, opts) => {
|
|
933
|
+
const repoRoot = getRepo();
|
|
934
|
+
const db = getDb(repoRoot);
|
|
935
|
+
|
|
936
|
+
try {
|
|
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
|
+
});
|
|
944
|
+
db.close();
|
|
945
|
+
|
|
946
|
+
if (opts.json) {
|
|
947
|
+
console.log(JSON.stringify(result, null, 2));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
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)}`);
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
db.close();
|
|
959
|
+
console.error(chalk.red(err.message));
|
|
960
|
+
process.exitCode = 1;
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
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);
|
|
973
|
+
|
|
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;
|
|
986
|
+
}
|
|
987
|
+
|
|
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;
|
|
991
|
+
}
|
|
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
|
+
}
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
db.close();
|
|
1004
|
+
console.error(chalk.red(err.message));
|
|
1005
|
+
process.exitCode = 1;
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
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) => {
|
|
1014
|
+
const repoRoot = getRepo();
|
|
1015
|
+
const db = getDb(repoRoot);
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
|
|
1019
|
+
db.close();
|
|
1020
|
+
|
|
1021
|
+
if (opts.json) {
|
|
1022
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
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
|
+
}
|
|
1030
|
+
|
|
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}`);
|
|
1034
|
+
}
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
db.close();
|
|
1037
|
+
console.error(chalk.red(err.message));
|
|
1038
|
+
process.exitCode = 1;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
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}`);
|
|
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;
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
|
|
363
1089
|
// ── lease ────────────────────────────────────────────────────────────────────
|
|
364
1090
|
|
|
365
1091
|
const leaseCmd = program.command('lease').description('Manage active work leases');
|
|
@@ -404,20 +1130,16 @@ leaseCmd
|
|
|
404
1130
|
.option('--agent <name>', 'Agent identifier for logging')
|
|
405
1131
|
.action((opts) => {
|
|
406
1132
|
const repoRoot = getRepo();
|
|
407
|
-
const
|
|
408
|
-
const task =
|
|
1133
|
+
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
1134
|
+
const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
|
|
409
1135
|
|
|
410
1136
|
if (!task) {
|
|
411
|
-
db.close();
|
|
412
1137
|
if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
|
|
413
|
-
else console.log(chalk.dim('No pending tasks.'));
|
|
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.'));
|
|
414
1140
|
return;
|
|
415
1141
|
}
|
|
416
1142
|
|
|
417
|
-
const worktreeName = getCurrentWorktreeName(opts.worktree);
|
|
418
|
-
const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
|
|
419
|
-
db.close();
|
|
420
|
-
|
|
421
1143
|
if (!lease) {
|
|
422
1144
|
if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
|
|
423
1145
|
else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
|
|
@@ -556,7 +1278,8 @@ wtCmd
|
|
|
556
1278
|
const dbInfo = worktrees.find(d => d.path === wt.path);
|
|
557
1279
|
const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
|
|
558
1280
|
const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
|
|
559
|
-
|
|
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}`);
|
|
560
1283
|
console.log(` ${chalk.dim(wt.path)}`);
|
|
561
1284
|
}
|
|
562
1285
|
console.log('');
|
|
@@ -573,6 +1296,7 @@ wtCmd
|
|
|
573
1296
|
registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
|
|
574
1297
|
}
|
|
575
1298
|
db.close();
|
|
1299
|
+
installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
|
|
576
1300
|
console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
|
|
577
1301
|
});
|
|
578
1302
|
|
|
@@ -600,6 +1324,7 @@ program
|
|
|
600
1324
|
console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
|
|
601
1325
|
}
|
|
602
1326
|
console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
|
|
1327
|
+
process.exitCode = 1;
|
|
603
1328
|
return;
|
|
604
1329
|
}
|
|
605
1330
|
|
|
@@ -625,6 +1350,153 @@ program
|
|
|
625
1350
|
console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
|
|
626
1351
|
});
|
|
627
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
|
+
|
|
628
1500
|
// ── scan ───────────────────────────────────────────────────────────────────────
|
|
629
1501
|
|
|
630
1502
|
program
|
|
@@ -657,7 +1529,8 @@ program
|
|
|
657
1529
|
console.log(chalk.bold('Worktrees:'));
|
|
658
1530
|
for (const wt of report.worktrees) {
|
|
659
1531
|
const files = report.fileMap?.[wt.name] || [];
|
|
660
|
-
|
|
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)')}`);
|
|
661
1534
|
}
|
|
662
1535
|
console.log('');
|
|
663
1536
|
}
|
|
@@ -672,6 +1545,31 @@ program
|
|
|
672
1545
|
console.log('');
|
|
673
1546
|
}
|
|
674
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
|
+
|
|
675
1573
|
// Branch-level conflicts
|
|
676
1574
|
if (report.conflicts.length > 0) {
|
|
677
1575
|
console.log(chalk.red(`✗ Branch conflicts detected:`));
|
|
@@ -687,8 +1585,21 @@ program
|
|
|
687
1585
|
console.log('');
|
|
688
1586
|
}
|
|
689
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
|
+
|
|
690
1601
|
// All clear
|
|
691
|
-
if (report.conflicts.length === 0 && report.fileConflicts.length === 0) {
|
|
1602
|
+
if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
|
|
692
1603
|
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
|
|
693
1604
|
}
|
|
694
1605
|
|
|
@@ -732,7 +1643,13 @@ program
|
|
|
732
1643
|
console.log('');
|
|
733
1644
|
console.log(chalk.bold('Active Leases:'));
|
|
734
1645
|
for (const lease of activeLeases) {
|
|
735
|
-
|
|
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}`);
|
|
736
1653
|
}
|
|
737
1654
|
} else if (inProgress.length > 0) {
|
|
738
1655
|
console.log('');
|
|
@@ -759,6 +1676,29 @@ program
|
|
|
759
1676
|
}
|
|
760
1677
|
}
|
|
761
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
|
+
|
|
762
1702
|
// File Claims
|
|
763
1703
|
const claims = getActiveFileClaims(db);
|
|
764
1704
|
if (claims.length > 0) {
|
|
@@ -781,12 +1721,78 @@ program
|
|
|
781
1721
|
const report = await scanAllWorktrees(db, repoRoot);
|
|
782
1722
|
spinner.stop();
|
|
783
1723
|
|
|
784
|
-
const totalConflicts = report.conflicts.length + report.fileConflicts.length;
|
|
1724
|
+
const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
|
|
785
1725
|
if (totalConflicts === 0) {
|
|
786
1726
|
console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
|
|
787
1727
|
} else {
|
|
788
1728
|
console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
|
|
789
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
|
+
}
|
|
790
1796
|
} catch {
|
|
791
1797
|
spinner.stop();
|
|
792
1798
|
console.log(chalk.dim('Could not run conflict scan'));
|
|
@@ -798,4 +1804,660 @@ program
|
|
|
798
1804
|
console.log('');
|
|
799
1805
|
});
|
|
800
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)}`);
|
|
2461
|
+
});
|
|
2462
|
+
|
|
801
2463
|
program.parse();
|