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.
@@ -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 enforcement = domains.some((domain) => ['auth', 'payments', 'schema'].includes(domain))
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 = {
@@ -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
+ }