switchman-dev 0.1.5 → 0.1.7
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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/README.md +173 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2941 -314
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
- package/src/core/telemetry.js +210 -0
package/src/core/planner.js
CHANGED
|
@@ -2,6 +2,8 @@ import { execSync } from 'child_process';
|
|
|
2
2
|
import { existsSync, readdirSync, statSync } from 'fs';
|
|
3
3
|
import { basename, join } from 'path';
|
|
4
4
|
|
|
5
|
+
import { loadChangePolicy } from './policy.js';
|
|
6
|
+
|
|
5
7
|
const DOMAIN_RULES = [
|
|
6
8
|
{ key: 'auth', regex: /\b(auth|login|session|oauth|permission|rbac|token)\b/i, source: ['src/auth/**', 'app/auth/**', 'lib/auth/**', 'server/auth/**', 'client/auth/**'] },
|
|
7
9
|
{ key: 'api', regex: /\b(api|endpoint|route|graphql|rest|handler)\b/i, source: ['src/api/**', 'app/api/**', 'server/api/**', 'routes/**'] },
|
|
@@ -376,7 +378,7 @@ function buildFollowupDeliverables({ taskType, riskLevel, domains }) {
|
|
|
376
378
|
return uniq(deliverables);
|
|
377
379
|
}
|
|
378
380
|
|
|
379
|
-
function buildValidationRules({ taskType, riskLevel, domains }) {
|
|
381
|
+
function buildValidationRules({ taskType, riskLevel, domains, changePolicy = null }) {
|
|
380
382
|
if (taskType !== 'implementation') {
|
|
381
383
|
return {
|
|
382
384
|
enforcement: 'none',
|
|
@@ -403,14 +405,30 @@ function buildValidationRules({ taskType, riskLevel, domains }) {
|
|
|
403
405
|
rationale.push('public or shared boundaries require updated docs or integration notes');
|
|
404
406
|
}
|
|
405
407
|
|
|
406
|
-
const
|
|
408
|
+
const matchedPolicyRules = domains
|
|
409
|
+
.map((domain) => changePolicy?.domain_rules?.[domain] || null)
|
|
410
|
+
.filter(Boolean);
|
|
411
|
+
for (const rule of matchedPolicyRules) {
|
|
412
|
+
requiredCompletedTaskTypes.push(...(rule.required_completed_task_types || []));
|
|
413
|
+
rationale.push(...(rule.rationale || []));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const enforcementRank = { none: 0, warn: 1, blocked: 2 };
|
|
417
|
+
const defaultEnforcement = domains.some((domain) => ['auth', 'payments', 'schema'].includes(domain))
|
|
407
418
|
? 'blocked'
|
|
408
419
|
: (requiredCompletedTaskTypes.length > 0 ? 'warn' : 'none');
|
|
420
|
+
const policyEnforcement = matchedPolicyRules
|
|
421
|
+
.map((rule) => rule.enforcement || 'none')
|
|
422
|
+
.reduce((highest, current) =>
|
|
423
|
+
enforcementRank[current] > enforcementRank[highest] ? current : highest, 'none');
|
|
424
|
+
const enforcement = enforcementRank[policyEnforcement] > enforcementRank[defaultEnforcement]
|
|
425
|
+
? policyEnforcement
|
|
426
|
+
: defaultEnforcement;
|
|
409
427
|
|
|
410
428
|
return {
|
|
411
429
|
enforcement,
|
|
412
430
|
required_completed_task_types: uniq(requiredCompletedTaskTypes),
|
|
413
|
-
rationale,
|
|
431
|
+
rationale: uniq(rationale),
|
|
414
432
|
};
|
|
415
433
|
}
|
|
416
434
|
|
|
@@ -430,7 +448,7 @@ function extractObjectiveKeywords(title, domains = []) {
|
|
|
430
448
|
]).slice(0, 8);
|
|
431
449
|
}
|
|
432
450
|
|
|
433
|
-
export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDescription = null, suggestedWorktree = null, dependencies = [], repoContext = null }) {
|
|
451
|
+
export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDescription = null, suggestedWorktree = null, dependencies = [], repoContext = null, changePolicy = null }) {
|
|
434
452
|
const taskType = inferTaskType(title);
|
|
435
453
|
const text = `${issueTitle}\n${issueDescription || ''}\n${title}`.toLowerCase();
|
|
436
454
|
const domains = detectDomains(text);
|
|
@@ -458,7 +476,7 @@ export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDesc
|
|
|
458
476
|
expected_output_types: expectedOutputTypes,
|
|
459
477
|
required_deliverables: buildRequiredDeliverables({ taskType, riskLevel, domains }),
|
|
460
478
|
followup_deliverables: buildFollowupDeliverables({ taskType, riskLevel, domains }),
|
|
461
|
-
validation_rules: buildValidationRules({ taskType, riskLevel, domains }),
|
|
479
|
+
validation_rules: buildValidationRules({ taskType, riskLevel, domains, changePolicy }),
|
|
462
480
|
success_criteria: buildSuccessCriteria({ taskType, allowedPaths, dependencies }),
|
|
463
481
|
risk_level: riskLevel,
|
|
464
482
|
execution_policy: buildExecutionPolicy({ taskType, riskLevel }),
|
|
@@ -468,6 +486,7 @@ export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDesc
|
|
|
468
486
|
export function planPipelineTasks({ pipelineId, title, description = null, worktrees = [], maxTasks = 5, repoRoot = null }) {
|
|
469
487
|
const subtaskTitles = deriveSubtaskTitles(title, description).slice(0, maxTasks);
|
|
470
488
|
const repoContext = buildRepoContext(repoRoot);
|
|
489
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
471
490
|
let implementationTaskId = null;
|
|
472
491
|
|
|
473
492
|
return subtaskTitles.map((subtaskTitle, index) => {
|
|
@@ -489,6 +508,7 @@ export function planPipelineTasks({ pipelineId, title, description = null, workt
|
|
|
489
508
|
suggestedWorktree,
|
|
490
509
|
dependencies,
|
|
491
510
|
repoContext,
|
|
511
|
+
changePolicy,
|
|
492
512
|
});
|
|
493
513
|
|
|
494
514
|
const task = {
|
package/src/core/policy.js
CHANGED
|
@@ -8,10 +8,52 @@ export const DEFAULT_LEASE_POLICY = {
|
|
|
8
8
|
requeue_task_on_reap: true,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
export const DEFAULT_CHANGE_POLICY = {
|
|
12
|
+
domain_rules: {
|
|
13
|
+
auth: {
|
|
14
|
+
required_completed_task_types: ['tests', 'governance'],
|
|
15
|
+
enforcement: 'blocked',
|
|
16
|
+
rationale: [
|
|
17
|
+
'auth changes require completed tests before landing',
|
|
18
|
+
'auth changes require completed governance review before landing',
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
payments: {
|
|
22
|
+
required_completed_task_types: ['tests', 'governance'],
|
|
23
|
+
enforcement: 'blocked',
|
|
24
|
+
rationale: [
|
|
25
|
+
'payments changes require completed tests before landing',
|
|
26
|
+
'payments changes require completed governance review before landing',
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
schema: {
|
|
30
|
+
required_completed_task_types: ['tests', 'governance', 'docs'],
|
|
31
|
+
enforcement: 'blocked',
|
|
32
|
+
rationale: [
|
|
33
|
+
'schema changes require completed tests before landing',
|
|
34
|
+
'schema changes require completed governance review before landing',
|
|
35
|
+
'schema changes require completed docs or migration notes before landing',
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
config: {
|
|
39
|
+
required_completed_task_types: ['docs', 'governance'],
|
|
40
|
+
enforcement: 'warn',
|
|
41
|
+
rationale: [
|
|
42
|
+
'shared config changes should include updated docs or runbooks',
|
|
43
|
+
'shared config changes should include governance review',
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
11
49
|
export function getLeasePolicyPath(repoRoot) {
|
|
12
50
|
return join(repoRoot, '.switchman', 'lease-policy.json');
|
|
13
51
|
}
|
|
14
52
|
|
|
53
|
+
export function getChangePolicyPath(repoRoot) {
|
|
54
|
+
return join(repoRoot, '.switchman', 'change-policy.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
export function loadLeasePolicy(repoRoot) {
|
|
16
58
|
const policyPath = getLeasePolicyPath(repoRoot);
|
|
17
59
|
if (!existsSync(policyPath)) {
|
|
@@ -47,3 +89,66 @@ export function writeLeasePolicy(repoRoot, policy = {}) {
|
|
|
47
89
|
writeFileSync(policyPath, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
48
90
|
return policyPath;
|
|
49
91
|
}
|
|
92
|
+
|
|
93
|
+
function normalizeDomainRule(rule = {}, fallback = {}) {
|
|
94
|
+
const requiredCompletedTaskTypes = Array.isArray(rule.required_completed_task_types)
|
|
95
|
+
? rule.required_completed_task_types.filter(Boolean)
|
|
96
|
+
: (fallback.required_completed_task_types || []);
|
|
97
|
+
const enforcement = ['none', 'warn', 'blocked'].includes(rule.enforcement)
|
|
98
|
+
? rule.enforcement
|
|
99
|
+
: (fallback.enforcement || 'none');
|
|
100
|
+
const rationale = Array.isArray(rule.rationale)
|
|
101
|
+
? rule.rationale.filter(Boolean)
|
|
102
|
+
: (fallback.rationale || []);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
required_completed_task_types: [...new Set(requiredCompletedTaskTypes)],
|
|
106
|
+
enforcement,
|
|
107
|
+
rationale,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadChangePolicy(repoRoot) {
|
|
112
|
+
const policyPath = getChangePolicyPath(repoRoot);
|
|
113
|
+
if (!existsSync(policyPath)) {
|
|
114
|
+
return JSON.parse(JSON.stringify(DEFAULT_CHANGE_POLICY));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
119
|
+
const rules = {
|
|
120
|
+
...DEFAULT_CHANGE_POLICY.domain_rules,
|
|
121
|
+
...(parsed?.domain_rules || {}),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
domain_rules: Object.fromEntries(
|
|
126
|
+
Object.entries(rules).map(([domain, rule]) => [
|
|
127
|
+
domain,
|
|
128
|
+
normalizeDomainRule(rule, DEFAULT_CHANGE_POLICY.domain_rules[domain] || {}),
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return JSON.parse(JSON.stringify(DEFAULT_CHANGE_POLICY));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeChangePolicy(repoRoot, policy = {}) {
|
|
138
|
+
const policyPath = getChangePolicyPath(repoRoot);
|
|
139
|
+
mkdirSync(dirname(policyPath), { recursive: true });
|
|
140
|
+
const mergedRules = {
|
|
141
|
+
...DEFAULT_CHANGE_POLICY.domain_rules,
|
|
142
|
+
...(policy?.domain_rules || {}),
|
|
143
|
+
};
|
|
144
|
+
const normalized = {
|
|
145
|
+
domain_rules: Object.fromEntries(
|
|
146
|
+
Object.entries(mergedRules).map(([domain, rule]) => [
|
|
147
|
+
domain,
|
|
148
|
+
normalizeDomainRule(rule, DEFAULT_CHANGE_POLICY.domain_rules[domain] || {}),
|
|
149
|
+
]),
|
|
150
|
+
),
|
|
151
|
+
};
|
|
152
|
+
writeFileSync(policyPath, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
153
|
+
return policyPath;
|
|
154
|
+
}
|