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.
@@ -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(payment|billing|invoice|checkout|subscription|stripe)\b/i, source: ['src/payments/**', 'app/payments/**', 'lib/payments/**', 'server/payments/**'] },
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|payment|schema|migration|security|permission|billing)\b/.test(text);
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 (/\b(auth|payment|schema|migration|security|permission|billing)\b/.test(text)) return 'high';
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 enforcement = domains.some((domain) => ['auth', 'payments', 'schema'].includes(domain))
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 = {
@@ -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: false,
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
+ }