switchman-dev 0.1.2 → 0.1.4

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,190 @@
1
+ import { getActiveFileClaims, getLeaseExecutionContext, getTask, getTaskSpec, getWorktree, touchBoundaryValidationState } from './db.js';
2
+ import { getWorktreeChangedFiles } from './git.js';
3
+ import { matchesPathPatterns } from './ignore.js';
4
+
5
+ function isTestPath(filePath) {
6
+ return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
7
+ }
8
+
9
+ function isDocsPath(filePath) {
10
+ return /(^|\/)(docs?|readme)(\/|$)|(^|\/)README(\.[^.]+)?$/i.test(filePath);
11
+ }
12
+
13
+ function isSourcePath(filePath) {
14
+ return /(^|\/)(src|app|lib|server|client)(\/|$)/i.test(filePath) && !isTestPath(filePath);
15
+ }
16
+
17
+ function fileMatchesKeyword(filePath, keyword) {
18
+ const normalizedPath = String(filePath || '').toLowerCase();
19
+ const normalizedKeyword = String(keyword || '').toLowerCase();
20
+ return normalizedKeyword.length >= 3 && normalizedPath.includes(normalizedKeyword);
21
+ }
22
+
23
+ function resolveExecution(db, { taskId = null, leaseId = null } = {}) {
24
+ if (leaseId) {
25
+ const execution = getLeaseExecutionContext(db, leaseId);
26
+ if (!execution?.task) {
27
+ return { task: null, taskSpec: null, worktree: null, leaseId };
28
+ }
29
+ return {
30
+ task: execution.task,
31
+ taskSpec: execution.task_spec,
32
+ worktree: execution.worktree,
33
+ leaseId: execution.lease?.id || leaseId,
34
+ };
35
+ }
36
+
37
+ if (!taskId) {
38
+ return { task: null, taskSpec: null, worktree: null, leaseId: null };
39
+ }
40
+
41
+ const task = getTask(db, taskId);
42
+ return {
43
+ task,
44
+ taskSpec: task ? getTaskSpec(db, taskId) : null,
45
+ worktree: task?.worktree ? getWorktree(db, task.worktree) : null,
46
+ leaseId: null,
47
+ };
48
+ }
49
+
50
+ export function evaluateTaskOutcome(db, repoRoot, { taskId = null, leaseId = null } = {}) {
51
+ const execution = resolveExecution(db, { taskId, leaseId });
52
+ const task = execution.task;
53
+ const taskSpec = execution.taskSpec;
54
+
55
+ if (!task || !task.worktree) {
56
+ return {
57
+ status: 'failed',
58
+ reason_code: taskId || leaseId ? 'task_not_assigned' : 'task_identity_required',
59
+ changed_files: [],
60
+ findings: [taskId || leaseId ? 'task has no assigned worktree' : 'task outcome requires a taskId or leaseId'],
61
+ };
62
+ }
63
+
64
+ const worktree = execution.worktree;
65
+ if (!worktree) {
66
+ return {
67
+ status: 'failed',
68
+ reason_code: 'worktree_missing',
69
+ changed_files: [],
70
+ findings: ['assigned worktree is not registered'],
71
+ };
72
+ }
73
+
74
+ const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
75
+ const activeClaims = getActiveFileClaims(db)
76
+ .filter((claim) => claim.task_id === task.id && claim.worktree === task.worktree)
77
+ .map((claim) => claim.file_path);
78
+ const changedOutsideClaims = changedFiles.filter((filePath) => !activeClaims.includes(filePath));
79
+ const changedInsideClaims = changedFiles.filter((filePath) => activeClaims.includes(filePath));
80
+ const allowedPaths = taskSpec?.allowed_paths || [];
81
+ const changedOutsideTaskScope = allowedPaths.length > 0
82
+ ? changedFiles.filter((filePath) => !matchesPathPatterns(filePath, allowedPaths))
83
+ : [];
84
+ const expectedOutputTypes = taskSpec?.expected_output_types || [];
85
+ const requiredDeliverables = taskSpec?.required_deliverables || [];
86
+ const objectiveKeywords = taskSpec?.objective_keywords || [];
87
+ const findings = [];
88
+
89
+ if (changedFiles.length === 0) {
90
+ findings.push('command exited successfully but produced no tracked file changes');
91
+ return {
92
+ status: 'needs_followup',
93
+ reason_code: 'no_changes_detected',
94
+ changed_files: changedFiles,
95
+ findings,
96
+ };
97
+ }
98
+
99
+ if (activeClaims.length > 0 && changedOutsideClaims.length > 0) {
100
+ findings.push(`changed files outside claimed scope: ${changedOutsideClaims.join(', ')}`);
101
+ return {
102
+ status: 'needs_followup',
103
+ reason_code: 'changes_outside_claims',
104
+ changed_files: changedFiles,
105
+ findings,
106
+ };
107
+ }
108
+
109
+ if (changedOutsideTaskScope.length > 0) {
110
+ findings.push(`changed files outside task scope: ${changedOutsideTaskScope.join(', ')}`);
111
+ return {
112
+ status: 'needs_followup',
113
+ reason_code: 'changes_outside_task_scope',
114
+ changed_files: changedFiles,
115
+ findings,
116
+ };
117
+ }
118
+
119
+ const title = String(task.title || '').toLowerCase();
120
+ const expectsTests = expectedOutputTypes.includes('tests') || title.includes('test');
121
+ const expectsDocs = expectedOutputTypes.includes('docs') || title.includes('docs') || title.includes('readme') || title.includes('integration notes');
122
+ const expectsSource = expectedOutputTypes.includes('source') || title.startsWith('implement:') || title.includes('implement');
123
+ const changedTestFiles = changedFiles.filter(isTestPath);
124
+ const changedDocsFiles = changedFiles.filter(isDocsPath);
125
+ const changedSourceFiles = changedFiles.filter(isSourcePath);
126
+
127
+ if ((expectsTests || requiredDeliverables.includes('tests')) && changedTestFiles.length === 0) {
128
+ findings.push('task looks like a test task but no test files changed');
129
+ return {
130
+ status: 'needs_followup',
131
+ reason_code: 'missing_expected_tests',
132
+ changed_files: changedFiles,
133
+ findings,
134
+ };
135
+ }
136
+
137
+ if ((expectsDocs || requiredDeliverables.includes('docs')) && changedDocsFiles.length === 0) {
138
+ findings.push('task looks like a docs task but no docs files changed');
139
+ return {
140
+ status: 'needs_followup',
141
+ reason_code: 'missing_expected_docs',
142
+ changed_files: changedFiles,
143
+ findings,
144
+ };
145
+ }
146
+
147
+ if ((expectsSource || requiredDeliverables.includes('source')) && changedSourceFiles.length === 0) {
148
+ findings.push('implementation task finished without source-file changes');
149
+ return {
150
+ status: 'needs_followup',
151
+ reason_code: 'missing_expected_source_changes',
152
+ changed_files: changedFiles,
153
+ findings,
154
+ };
155
+ }
156
+
157
+ const matchedObjectiveKeywords = objectiveKeywords.filter((keyword) =>
158
+ changedFiles.some((filePath) => fileMatchesKeyword(filePath, keyword)),
159
+ );
160
+ const minimumKeywordMatches = Math.min(1, objectiveKeywords.length);
161
+
162
+ if (objectiveKeywords.length > 0 && matchedObjectiveKeywords.length < minimumKeywordMatches) {
163
+ findings.push(`changed files do not clearly satisfy task objective keywords: ${objectiveKeywords.join(', ')}`);
164
+ return {
165
+ status: 'needs_followup',
166
+ reason_code: 'objective_not_evidenced',
167
+ changed_files: changedFiles,
168
+ task_id: task.id,
169
+ lease_id: execution.leaseId,
170
+ findings,
171
+ };
172
+ }
173
+
174
+ const result = {
175
+ status: 'accepted',
176
+ reason_code: null,
177
+ changed_files: changedFiles,
178
+ task_id: task.id,
179
+ lease_id: execution.leaseId,
180
+ task_spec: taskSpec,
181
+ claimed_files: activeClaims,
182
+ findings: changedInsideClaims.length > 0 ? ['changes stayed within claimed scope'] : [],
183
+ };
184
+
185
+ if (execution.leaseId) {
186
+ touchBoundaryValidationState(db, execution.leaseId, 'task_outcome_accepted');
187
+ }
188
+
189
+ return result;
190
+ }