switchman-dev 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +296 -15
- package/examples/README.md +37 -2
- package/package.json +6 -1
- package/src/cli/index.js +3939 -130
- package/src/core/ci.js +205 -1
- package/src/core/db.js +963 -45
- package/src/core/enforcement.js +140 -15
- package/src/core/git.js +286 -1
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +22 -5
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +2459 -88
- package/src/core/planner.js +35 -11
- package/src/core/policy.js +106 -1
- package/src/core/queue.js +654 -29
- package/src/core/semantic.js +71 -5
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/tests.zip +0 -0
package/src/core/planner.js
CHANGED
|
@@ -2,12 +2,14 @@ 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/**'] },
|
|
8
10
|
{ key: 'schema', regex: /\b(schema|migration|database|db|sql|prisma)\b/i, source: ['db/**', 'database/**', 'migrations/**', 'prisma/**', 'schema/**', 'src/db/**'] },
|
|
9
11
|
{ key: 'config', regex: /\b(config|configuration|env|feature flag|settings?)\b/i, source: ['config/**', '.github/**', '.switchman/**', 'src/config/**'] },
|
|
10
|
-
{ key: 'payments', regex: /\b(
|
|
12
|
+
{ key: 'payments', regex: /\b(payments?|billing|invoice|checkout|subscription|stripe)\b/i, source: ['src/payments/**', 'app/payments/**', 'lib/payments/**', 'server/payments/**'] },
|
|
11
13
|
{ key: 'ui', regex: /\b(ui|ux|frontend|component|screen|page|layout)\b/i, source: ['src/components/**', 'src/ui/**', 'app/**', 'client/**'] },
|
|
12
14
|
{ key: 'infra', regex: /\b(deploy|infra|infrastructure|build|pipeline|docker|kubernetes|terraform)\b/i, source: ['infra/**', '.github/**', 'docker/**', 'scripts/**'] },
|
|
13
15
|
{ key: 'docs', regex: /\b(docs?|readme|documentation|integration notes)\b/i, source: ['docs/**', 'README.md'] },
|
|
@@ -152,10 +154,8 @@ function deriveSubtaskTitles(title, description) {
|
|
|
152
154
|
const text = `${title}\n${description || ''}`.toLowerCase();
|
|
153
155
|
const subtasks = [];
|
|
154
156
|
const domains = detectDomains(text);
|
|
155
|
-
const highRisk = /\b(auth|
|
|
156
|
-
|
|
157
|
-
const docsOnly = /\b(docs?|readme|documentation)\b/.test(text)
|
|
158
|
-
&& !/\b(api|auth|bug|feature|fix|refactor|schema|migration|config|build|test)\b/.test(text);
|
|
157
|
+
const highRisk = /\b(auth|payments?|schema|migration|security|permission|billing)\b/.test(text);
|
|
158
|
+
const docsOnly = isDocsOnlyRequest(text);
|
|
159
159
|
|
|
160
160
|
if (docsOnly) {
|
|
161
161
|
return [`Update docs for: ${title}`];
|
|
@@ -178,8 +178,14 @@ function deriveSubtaskTitles(title, description) {
|
|
|
178
178
|
return subtasks;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
function isDocsOnlyRequest(text) {
|
|
182
|
+
return /\b(docs?|readme|documentation)\b/.test(text)
|
|
183
|
+
&& !/\b(auth|bug|feature|fix|refactor|schema|migration|config|build|test|implement|route|handler|endpoint|model)\b/.test(text);
|
|
184
|
+
}
|
|
185
|
+
|
|
181
186
|
function inferRiskLevel(text) {
|
|
182
|
-
if (
|
|
187
|
+
if (isDocsOnlyRequest(text)) return 'low';
|
|
188
|
+
if (/\b(auth|payments?|schema|migration|security|permission|billing)\b/.test(text)) return 'high';
|
|
183
189
|
if (/\b(api|config|deploy|build|infra)\b/.test(text)) return 'medium';
|
|
184
190
|
return 'low';
|
|
185
191
|
}
|
|
@@ -376,7 +382,7 @@ function buildFollowupDeliverables({ taskType, riskLevel, domains }) {
|
|
|
376
382
|
return uniq(deliverables);
|
|
377
383
|
}
|
|
378
384
|
|
|
379
|
-
function buildValidationRules({ taskType, riskLevel, domains }) {
|
|
385
|
+
function buildValidationRules({ taskType, riskLevel, domains, changePolicy = null }) {
|
|
380
386
|
if (taskType !== 'implementation') {
|
|
381
387
|
return {
|
|
382
388
|
enforcement: 'none',
|
|
@@ -403,14 +409,30 @@ function buildValidationRules({ taskType, riskLevel, domains }) {
|
|
|
403
409
|
rationale.push('public or shared boundaries require updated docs or integration notes');
|
|
404
410
|
}
|
|
405
411
|
|
|
406
|
-
const
|
|
412
|
+
const matchedPolicyRules = domains
|
|
413
|
+
.map((domain) => changePolicy?.domain_rules?.[domain] || null)
|
|
414
|
+
.filter(Boolean);
|
|
415
|
+
for (const rule of matchedPolicyRules) {
|
|
416
|
+
requiredCompletedTaskTypes.push(...(rule.required_completed_task_types || []));
|
|
417
|
+
rationale.push(...(rule.rationale || []));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const enforcementRank = { none: 0, warn: 1, blocked: 2 };
|
|
421
|
+
const defaultEnforcement = domains.some((domain) => ['auth', 'payments', 'schema'].includes(domain))
|
|
407
422
|
? 'blocked'
|
|
408
423
|
: (requiredCompletedTaskTypes.length > 0 ? 'warn' : 'none');
|
|
424
|
+
const policyEnforcement = matchedPolicyRules
|
|
425
|
+
.map((rule) => rule.enforcement || 'none')
|
|
426
|
+
.reduce((highest, current) =>
|
|
427
|
+
enforcementRank[current] > enforcementRank[highest] ? current : highest, 'none');
|
|
428
|
+
const enforcement = enforcementRank[policyEnforcement] > enforcementRank[defaultEnforcement]
|
|
429
|
+
? policyEnforcement
|
|
430
|
+
: defaultEnforcement;
|
|
409
431
|
|
|
410
432
|
return {
|
|
411
433
|
enforcement,
|
|
412
434
|
required_completed_task_types: uniq(requiredCompletedTaskTypes),
|
|
413
|
-
rationale,
|
|
435
|
+
rationale: uniq(rationale),
|
|
414
436
|
};
|
|
415
437
|
}
|
|
416
438
|
|
|
@@ -430,7 +452,7 @@ function extractObjectiveKeywords(title, domains = []) {
|
|
|
430
452
|
]).slice(0, 8);
|
|
431
453
|
}
|
|
432
454
|
|
|
433
|
-
export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDescription = null, suggestedWorktree = null, dependencies = [], repoContext = null }) {
|
|
455
|
+
export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDescription = null, suggestedWorktree = null, dependencies = [], repoContext = null, changePolicy = null }) {
|
|
434
456
|
const taskType = inferTaskType(title);
|
|
435
457
|
const text = `${issueTitle}\n${issueDescription || ''}\n${title}`.toLowerCase();
|
|
436
458
|
const domains = detectDomains(text);
|
|
@@ -458,7 +480,7 @@ export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDesc
|
|
|
458
480
|
expected_output_types: expectedOutputTypes,
|
|
459
481
|
required_deliverables: buildRequiredDeliverables({ taskType, riskLevel, domains }),
|
|
460
482
|
followup_deliverables: buildFollowupDeliverables({ taskType, riskLevel, domains }),
|
|
461
|
-
validation_rules: buildValidationRules({ taskType, riskLevel, domains }),
|
|
483
|
+
validation_rules: buildValidationRules({ taskType, riskLevel, domains, changePolicy }),
|
|
462
484
|
success_criteria: buildSuccessCriteria({ taskType, allowedPaths, dependencies }),
|
|
463
485
|
risk_level: riskLevel,
|
|
464
486
|
execution_policy: buildExecutionPolicy({ taskType, riskLevel }),
|
|
@@ -468,6 +490,7 @@ export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDesc
|
|
|
468
490
|
export function planPipelineTasks({ pipelineId, title, description = null, worktrees = [], maxTasks = 5, repoRoot = null }) {
|
|
469
491
|
const subtaskTitles = deriveSubtaskTitles(title, description).slice(0, maxTasks);
|
|
470
492
|
const repoContext = buildRepoContext(repoRoot);
|
|
493
|
+
const changePolicy = loadChangePolicy(repoRoot);
|
|
471
494
|
let implementationTaskId = null;
|
|
472
495
|
|
|
473
496
|
return subtaskTitles.map((subtaskTitle, index) => {
|
|
@@ -489,6 +512,7 @@ export function planPipelineTasks({ pipelineId, title, description = null, workt
|
|
|
489
512
|
suggestedWorktree,
|
|
490
513
|
dependencies,
|
|
491
514
|
repoContext,
|
|
515
|
+
changePolicy,
|
|
492
516
|
});
|
|
493
517
|
|
|
494
518
|
const task = {
|
package/src/core/policy.js
CHANGED
|
@@ -4,14 +4,56 @@ import { dirname, join } from 'path';
|
|
|
4
4
|
export const DEFAULT_LEASE_POLICY = {
|
|
5
5
|
heartbeat_interval_seconds: 60,
|
|
6
6
|
stale_after_minutes: 15,
|
|
7
|
-
reap_on_status_check:
|
|
7
|
+
reap_on_status_check: true,
|
|
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
|
+
}
|