switchman-dev 0.1.1 → 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.
@@ -0,0 +1,508 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, readdirSync, statSync } from 'fs';
3
+ import { basename, join } from 'path';
4
+
5
+ const DOMAIN_RULES = [
6
+ { 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
+ { key: 'api', regex: /\b(api|endpoint|route|graphql|rest|handler)\b/i, source: ['src/api/**', 'app/api/**', 'server/api/**', 'routes/**'] },
8
+ { key: 'schema', regex: /\b(schema|migration|database|db|sql|prisma)\b/i, source: ['db/**', 'database/**', 'migrations/**', 'prisma/**', 'schema/**', 'src/db/**'] },
9
+ { 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/**'] },
11
+ { key: 'ui', regex: /\b(ui|ux|frontend|component|screen|page|layout)\b/i, source: ['src/components/**', 'src/ui/**', 'app/**', 'client/**'] },
12
+ { key: 'infra', regex: /\b(deploy|infra|infrastructure|build|pipeline|docker|kubernetes|terraform)\b/i, source: ['infra/**', '.github/**', 'docker/**', 'scripts/**'] },
13
+ { key: 'docs', regex: /\b(docs?|readme|documentation|integration notes)\b/i, source: ['docs/**', 'README.md'] },
14
+ ];
15
+
16
+ function uniq(values) {
17
+ return [...new Set(values)];
18
+ }
19
+
20
+ function isTestPath(filePath) {
21
+ return /(^|\/)(__tests__|tests?|spec|specs)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
22
+ }
23
+
24
+ function isDocsPath(filePath) {
25
+ return /(^|\/)(docs?|readme)(\/|$)|(^|\/)README(\.[^.]+)?$/i.test(filePath);
26
+ }
27
+
28
+ function isSourcePath(filePath) {
29
+ return !isTestPath(filePath) && !isDocsPath(filePath);
30
+ }
31
+
32
+ function stripGlobSuffix(pathPattern) {
33
+ return String(pathPattern || '').replace(/\/\*\*$/, '');
34
+ }
35
+
36
+ function extractChecklistItems(description) {
37
+ if (!description) return [];
38
+ return description
39
+ .split('\n')
40
+ .map((line) => line.match(/^\s*(?:[-*]|\d+\.)\s+(?:\[[ xX]\]\s*)?(.*\S)\s*$/)?.[1] || null)
41
+ .filter(Boolean);
42
+ }
43
+
44
+ function detectDomains(text) {
45
+ const matches = DOMAIN_RULES
46
+ .filter((rule) => rule.regex.test(text))
47
+ .map((rule) => rule.key);
48
+ return matches.length > 0 ? matches : ['general'];
49
+ }
50
+
51
+ function safeReadDir(rootPath) {
52
+ try {
53
+ return readdirSync(rootPath);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function walkRepoFiles(rootPath, currentPath = '', depth = 0, maxDepth = 4) {
60
+ if (!rootPath || depth > maxDepth) return [];
61
+ const absolutePath = currentPath ? join(rootPath, currentPath) : rootPath;
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(absolutePath);
65
+ } catch {
66
+ return [];
67
+ }
68
+
69
+ const files = [];
70
+ for (const entry of entries) {
71
+ if (entry === '.git' || entry === '.switchman' || entry === 'node_modules') continue;
72
+ const relativePath = currentPath ? join(currentPath, entry) : entry;
73
+ const entryPath = join(rootPath, relativePath);
74
+ let stats;
75
+ try {
76
+ stats = statSync(entryPath);
77
+ } catch {
78
+ continue;
79
+ }
80
+
81
+ if (stats.isDirectory()) {
82
+ files.push(...walkRepoFiles(rootPath, relativePath, depth + 1, maxDepth));
83
+ } else if (stats.isFile()) {
84
+ files.push(relativePath);
85
+ }
86
+ }
87
+
88
+ return files;
89
+ }
90
+
91
+ function listRepoFiles(repoRoot) {
92
+ if (!repoRoot) return [];
93
+ try {
94
+ const output = execSync('git ls-files', {
95
+ cwd: repoRoot,
96
+ encoding: 'utf8',
97
+ stdio: ['pipe', 'pipe', 'pipe'],
98
+ }).trim();
99
+ const trackedFiles = output.split('\n').filter(Boolean);
100
+ if (trackedFiles.length > 0) return trackedFiles;
101
+ } catch {
102
+ // Fall back to a shallow filesystem walk for non-git fixtures.
103
+ }
104
+ return walkRepoFiles(repoRoot);
105
+ }
106
+
107
+ function buildRepoContext(repoRoot) {
108
+ if (!repoRoot) {
109
+ return {
110
+ repo_root: null,
111
+ files: [],
112
+ lower_files: [],
113
+ test_roots: [],
114
+ docs_roots: [],
115
+ domain_roots: {},
116
+ package_roots: [],
117
+ };
118
+ }
119
+
120
+ const files = listRepoFiles(repoRoot);
121
+ const lowerFiles = files.map((filePath) => filePath.toLowerCase());
122
+ const testRoots = ['tests', '__tests__', 'test', 'spec', 'specs'].filter((root) => existsSync(join(repoRoot, root)));
123
+ const docsRoots = uniq([
124
+ ...(existsSync(join(repoRoot, 'docs')) ? ['docs'] : []),
125
+ ...(existsSync(join(repoRoot, 'README.md')) ? ['README.md'] : []),
126
+ ]);
127
+ const domainRoots = Object.fromEntries(DOMAIN_RULES.map((rule) => {
128
+ const existingRoots = uniq(rule.source
129
+ .map(stripGlobSuffix)
130
+ .filter((root) => existsSync(join(repoRoot, root))));
131
+ return [rule.key, existingRoots];
132
+ }));
133
+ const packageRoots = safeReadDir(join(repoRoot, 'packages'))
134
+ .map((name) => `packages/${name}`)
135
+ .filter((packagePath) => existsSync(join(repoRoot, packagePath)));
136
+
137
+ return {
138
+ repo_root: repoRoot,
139
+ files,
140
+ lower_files: lowerFiles,
141
+ test_roots: testRoots,
142
+ docs_roots: docsRoots,
143
+ domain_roots: domainRoots,
144
+ package_roots: packageRoots,
145
+ };
146
+ }
147
+
148
+ function deriveSubtaskTitles(title, description) {
149
+ const checklistItems = extractChecklistItems(description);
150
+ if (checklistItems.length > 0) return checklistItems;
151
+
152
+ const text = `${title}\n${description || ''}`.toLowerCase();
153
+ const subtasks = [];
154
+ 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);
159
+
160
+ if (docsOnly) {
161
+ return [`Update docs for: ${title}`];
162
+ }
163
+
164
+ subtasks.push(`Implement: ${title}`);
165
+
166
+ if (!/\b(test|spec)\b/.test(text)) {
167
+ subtasks.push(`Add or update tests for: ${title}`);
168
+ }
169
+
170
+ if (/\b(api|public|config|migration|schema|docs?|readme)\b/.test(text)) {
171
+ subtasks.push(`Update integration notes for: ${title}`);
172
+ }
173
+
174
+ if (highRisk && domains.some((domain) => ['auth', 'payments', 'schema', 'config'].includes(domain))) {
175
+ subtasks.push(`Review safety constraints for: ${title}`);
176
+ }
177
+
178
+ return subtasks;
179
+ }
180
+
181
+ function inferRiskLevel(text) {
182
+ if (/\b(auth|payment|schema|migration|security|permission|billing)\b/.test(text)) return 'high';
183
+ if (/\b(api|config|deploy|build|infra)\b/.test(text)) return 'medium';
184
+ return 'low';
185
+ }
186
+
187
+ function summarizeRelevantPaths(filePaths = []) {
188
+ const summarized = [];
189
+ for (const filePath of filePaths) {
190
+ const segments = filePath.split('/');
191
+ if (segments.length >= 2) {
192
+ summarized.push(`${segments[0]}/${segments[1]}/**`);
193
+ } else {
194
+ summarized.push(filePath);
195
+ }
196
+ }
197
+ return uniq(summarized);
198
+ }
199
+
200
+ function inferRelevantRepoFiles(repoContext, objectiveKeywords = [], domains = [], taskType = 'implementation') {
201
+ if (!repoContext || objectiveKeywords.length === 0) return [];
202
+
203
+ const candidates = repoContext.files
204
+ .filter((filePath) => {
205
+ if (taskType === 'tests') return isTestPath(filePath);
206
+ if (taskType === 'docs') return isDocsPath(filePath);
207
+ if (taskType === 'governance') return isDocsPath(filePath) || /^\.github\//.test(filePath) || /^\.switchman\//.test(filePath);
208
+ return isSourcePath(filePath);
209
+ })
210
+ .map((filePath) => {
211
+ const lower = filePath.toLowerCase();
212
+ const basenameLower = basename(filePath).toLowerCase();
213
+ const keywordHits = objectiveKeywords.filter((keyword) => lower.includes(keyword) || basenameLower.includes(keyword));
214
+ const domainHits = domains.filter((domain) => domain !== 'general' && lower.includes(domain));
215
+ return {
216
+ filePath,
217
+ score: (keywordHits.length * 3) + (domainHits.length * 2),
218
+ };
219
+ })
220
+ .filter((entry) => entry.score > 0)
221
+ .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
222
+ .slice(0, 8);
223
+
224
+ return candidates.map((entry) => entry.filePath);
225
+ }
226
+
227
+ function inferAllowedPaths(taskType, domains = ['general'], repoContext = null, objectiveKeywords = []) {
228
+ const sourceRoots = uniq(domains.flatMap((domain) =>
229
+ repoContext?.domain_roots?.[domain]?.length > 0
230
+ ? repoContext.domain_roots[domain].map((root) => `${root}/**`)
231
+ : (DOMAIN_RULES.find((rule) => rule.key === domain)?.source || []),
232
+ ));
233
+ const relevantPaths = summarizeRelevantPaths(inferRelevantRepoFiles(repoContext, objectiveKeywords, domains, taskType));
234
+
235
+ if (taskType === 'tests') {
236
+ return uniq([
237
+ ...(repoContext?.test_roots?.length > 0 ? repoContext.test_roots.map((root) => `${root}/**`) : ['tests/**', '__tests__/**', 'spec/**', 'specs/**', 'test/**']),
238
+ ...domains.filter((domain) => domain !== 'general').flatMap((domain) => [
239
+ `tests/${domain}/**`,
240
+ `__tests__/${domain}/**`,
241
+ `spec/${domain}/**`,
242
+ ]),
243
+ ...relevantPaths,
244
+ ]);
245
+ }
246
+ if (taskType === 'docs') {
247
+ return uniq([
248
+ ...(repoContext?.docs_roots?.length > 0
249
+ ? repoContext.docs_roots.flatMap((root) => root === 'README.md' ? ['README.md'] : [`${root}/**`])
250
+ : ['docs/**', 'README.md', 'README/**']),
251
+ ...domains.map((domain) => `docs/${domain}/**`),
252
+ ...relevantPaths,
253
+ ]);
254
+ }
255
+ if (taskType === 'governance') {
256
+ return uniq([
257
+ '.switchman/**',
258
+ '.github/**',
259
+ 'docs/**',
260
+ 'README.md',
261
+ ...sourceRoots,
262
+ ...(repoContext?.test_roots?.length > 0 ? repoContext.test_roots.map((root) => `${root}/**`) : ['tests/**']),
263
+ ]);
264
+ }
265
+ if (relevantPaths.length > 0) {
266
+ return uniq([...relevantPaths, ...sourceRoots]);
267
+ }
268
+ return sourceRoots.length > 0
269
+ ? sourceRoots
270
+ : [
271
+ 'src/**',
272
+ 'app/**',
273
+ 'lib/**',
274
+ 'server/**',
275
+ 'client/**',
276
+ ...(repoContext?.package_roots?.length > 0 ? repoContext.package_roots.map((root) => `${root}/**`) : ['packages/**']),
277
+ ];
278
+ }
279
+
280
+ function inferExpectedOutputTypes(taskType) {
281
+ if (taskType === 'tests') return ['tests'];
282
+ if (taskType === 'docs') return ['docs'];
283
+ if (taskType === 'governance') return ['config', 'docs'];
284
+ return ['source'];
285
+ }
286
+
287
+ function buildPrimaryOutputPath({ taskType, pipelineId, taskId }) {
288
+ if (taskType === 'governance') {
289
+ return `docs/reviews/${pipelineId}/${taskId}.md`;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ function inferTaskType(title) {
295
+ if (/^Add or update tests/.test(title) || /\btests?\b/i.test(title)) return 'tests';
296
+ if (/^Update integration notes/.test(title) || /\bdocs?|readme|integration notes\b/i.test(title)) return 'docs';
297
+ if (/^Review |^Govern /.test(title)) return 'governance';
298
+ return 'implementation';
299
+ }
300
+
301
+ function buildExecutionPolicy({ taskType, riskLevel }) {
302
+ const policy = {
303
+ timeout_ms: 45000,
304
+ max_retries: 1,
305
+ retry_backoff_ms: 500,
306
+ };
307
+
308
+ if (taskType === 'docs') {
309
+ policy.timeout_ms = 15000;
310
+ policy.max_retries = 0;
311
+ policy.retry_backoff_ms = 0;
312
+ } else if (taskType === 'tests') {
313
+ policy.timeout_ms = 30000;
314
+ policy.max_retries = 1;
315
+ policy.retry_backoff_ms = 250;
316
+ } else if (taskType === 'governance') {
317
+ policy.timeout_ms = 20000;
318
+ policy.max_retries = 0;
319
+ policy.retry_backoff_ms = 0;
320
+ }
321
+
322
+ if (riskLevel === 'medium') {
323
+ policy.timeout_ms = Math.max(policy.timeout_ms, 60000);
324
+ policy.retry_backoff_ms = Math.max(policy.retry_backoff_ms, 1000);
325
+ }
326
+
327
+ if (riskLevel === 'high') {
328
+ policy.timeout_ms = Math.max(policy.timeout_ms, 90000);
329
+ policy.max_retries = Math.min(policy.max_retries, 1);
330
+ policy.retry_backoff_ms = Math.max(policy.retry_backoff_ms, 1500);
331
+ }
332
+
333
+ return policy;
334
+ }
335
+
336
+ function buildSuccessCriteria({ taskType, allowedPaths, dependencies }) {
337
+ const criteria = [`stay within task scope: ${allowedPaths.join(', ')}`];
338
+ if (dependencies.length > 0) {
339
+ criteria.push(`wait for dependencies: ${dependencies.join(', ')}`);
340
+ }
341
+ if (taskType === 'tests') criteria.push('change at least one test file');
342
+ if (taskType === 'docs') criteria.push('change at least one docs or README file');
343
+ if (taskType === 'implementation') criteria.push('change at least one source file');
344
+ if (taskType === 'governance') criteria.push('produce a governed follow-up or policy change');
345
+ return criteria;
346
+ }
347
+
348
+ function buildRequiredDeliverables({ taskType, riskLevel, domains }) {
349
+ const deliverables = [];
350
+
351
+ if (taskType === 'implementation') {
352
+ deliverables.push('source');
353
+ } else if (taskType === 'tests') {
354
+ deliverables.push('tests');
355
+ } else if (taskType === 'docs') {
356
+ deliverables.push('docs');
357
+ } else if (taskType === 'governance') {
358
+ deliverables.push('docs');
359
+ }
360
+
361
+ return uniq(deliverables);
362
+ }
363
+
364
+ function buildFollowupDeliverables({ taskType, riskLevel, domains }) {
365
+ const deliverables = [];
366
+
367
+ if (taskType === 'implementation') {
368
+ if (riskLevel === 'high') {
369
+ deliverables.push('tests');
370
+ }
371
+ if (domains.some((domain) => ['api', 'schema', 'config'].includes(domain))) {
372
+ deliverables.push('docs');
373
+ }
374
+ }
375
+
376
+ return uniq(deliverables);
377
+ }
378
+
379
+ function buildValidationRules({ taskType, riskLevel, domains }) {
380
+ if (taskType !== 'implementation') {
381
+ return {
382
+ enforcement: 'none',
383
+ required_completed_task_types: [],
384
+ rationale: [],
385
+ };
386
+ }
387
+
388
+ const requiredCompletedTaskTypes = [];
389
+ const rationale = [];
390
+
391
+ if (riskLevel === 'high') {
392
+ requiredCompletedTaskTypes.push('tests');
393
+ rationale.push('high-risk implementation must be backed by completed test work');
394
+ }
395
+
396
+ if (domains.some((domain) => ['auth', 'payments', 'schema', 'config'].includes(domain))) {
397
+ requiredCompletedTaskTypes.push('governance');
398
+ rationale.push('sensitive ownership boundaries require completed governance review');
399
+ }
400
+
401
+ if (domains.some((domain) => ['api', 'schema', 'config'].includes(domain))) {
402
+ requiredCompletedTaskTypes.push('docs');
403
+ rationale.push('public or shared boundaries require updated docs or integration notes');
404
+ }
405
+
406
+ const enforcement = domains.some((domain) => ['auth', 'payments', 'schema'].includes(domain))
407
+ ? 'blocked'
408
+ : (requiredCompletedTaskTypes.length > 0 ? 'warn' : 'none');
409
+
410
+ return {
411
+ enforcement,
412
+ required_completed_task_types: uniq(requiredCompletedTaskTypes),
413
+ rationale,
414
+ };
415
+ }
416
+
417
+ function extractObjectiveKeywords(title, domains = []) {
418
+ const rawWords = String(title || '')
419
+ .toLowerCase()
420
+ .replace(/[^a-z0-9\s]/g, ' ')
421
+ .split(/\s+/)
422
+ .filter(Boolean);
423
+ const stopWords = new Set([
424
+ 'add', 'and', 'for', 'from', 'the', 'with', 'into', 'onto', 'update', 'implement', 'review',
425
+ 'safety', 'constraints', 'notes', 'docs', 'documentation', 'tests', 'test', 'or', 'of', 'to',
426
+ ]);
427
+ return uniq([
428
+ ...domains,
429
+ ...rawWords.filter((word) => word.length >= 4 && !stopWords.has(word)),
430
+ ]).slice(0, 8);
431
+ }
432
+
433
+ export function buildTaskSpec({ pipelineId, taskId, title, issueTitle, issueDescription = null, suggestedWorktree = null, dependencies = [], repoContext = null }) {
434
+ const taskType = inferTaskType(title);
435
+ const text = `${issueTitle}\n${issueDescription || ''}\n${title}`.toLowerCase();
436
+ const domains = detectDomains(text);
437
+ const objectiveKeywords = extractObjectiveKeywords(title, domains);
438
+ const riskLevel = inferRiskLevel(text);
439
+ const primaryOutputPath = buildPrimaryOutputPath({ taskType, pipelineId, taskId });
440
+ const allowedPaths = uniq([
441
+ ...inferAllowedPaths(taskType, domains, repoContext, objectiveKeywords),
442
+ ...(primaryOutputPath ? [primaryOutputPath] : []),
443
+ ]);
444
+ const expectedOutputTypes = inferExpectedOutputTypes(taskType);
445
+
446
+ return {
447
+ pipeline_id: pipelineId,
448
+ task_id: taskId,
449
+ task_type: taskType,
450
+ objective: title,
451
+ issue_title: issueTitle,
452
+ suggested_worktree: suggestedWorktree,
453
+ dependencies,
454
+ subsystem_tags: domains,
455
+ objective_keywords: objectiveKeywords,
456
+ primary_output_path: primaryOutputPath,
457
+ allowed_paths: allowedPaths,
458
+ expected_output_types: expectedOutputTypes,
459
+ required_deliverables: buildRequiredDeliverables({ taskType, riskLevel, domains }),
460
+ followup_deliverables: buildFollowupDeliverables({ taskType, riskLevel, domains }),
461
+ validation_rules: buildValidationRules({ taskType, riskLevel, domains }),
462
+ success_criteria: buildSuccessCriteria({ taskType, allowedPaths, dependencies }),
463
+ risk_level: riskLevel,
464
+ execution_policy: buildExecutionPolicy({ taskType, riskLevel }),
465
+ };
466
+ }
467
+
468
+ export function planPipelineTasks({ pipelineId, title, description = null, worktrees = [], maxTasks = 5, repoRoot = null }) {
469
+ const subtaskTitles = deriveSubtaskTitles(title, description).slice(0, maxTasks);
470
+ const repoContext = buildRepoContext(repoRoot);
471
+ let implementationTaskId = null;
472
+
473
+ return subtaskTitles.map((subtaskTitle, index) => {
474
+ const suggestedWorktree = worktrees.length > 0 ? worktrees[index % worktrees.length].name : null;
475
+ const taskId = `${pipelineId}-${String(index + 1).padStart(2, '0')}`;
476
+ const dependencies = [];
477
+ const taskType = inferTaskType(subtaskTitle);
478
+
479
+ if (implementationTaskId && (taskType === 'tests' || taskType === 'docs')) {
480
+ dependencies.push(implementationTaskId);
481
+ }
482
+
483
+ const taskSpec = buildTaskSpec({
484
+ pipelineId,
485
+ taskId,
486
+ title: subtaskTitle,
487
+ issueTitle: title,
488
+ issueDescription: description,
489
+ suggestedWorktree,
490
+ dependencies,
491
+ repoContext,
492
+ });
493
+
494
+ const task = {
495
+ id: taskId,
496
+ title: subtaskTitle,
497
+ suggested_worktree: suggestedWorktree,
498
+ dependencies,
499
+ task_spec: taskSpec,
500
+ };
501
+
502
+ if (taskSpec.task_type === 'implementation' && !implementationTaskId) {
503
+ implementationTaskId = taskId;
504
+ }
505
+
506
+ return task;
507
+ });
508
+ }