@weldr/runr 0.3.0

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. package/templates/prompts/reviewer.md +77 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * File collision detection for parallel runs.
3
+ *
4
+ * Two-stage collision prevention:
5
+ * 1. Pre-PLAN: Coarse check on allowlist patterns (warn-only)
6
+ * 2. Post-PLAN: Precise check on files_expected (STOP by default)
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import picomatch from 'picomatch';
11
+ import { getRunsRoot } from '../store/runs-root.js';
12
+ /**
13
+ * Get all active (non-stopped) runs from the runs directory.
14
+ */
15
+ export function getActiveRuns(repoPath, excludeRunId) {
16
+ const runsRoot = getRunsRoot(repoPath);
17
+ if (!fs.existsSync(runsRoot)) {
18
+ return [];
19
+ }
20
+ const runDirs = fs.readdirSync(runsRoot, { withFileTypes: true })
21
+ .filter(d => d.isDirectory())
22
+ .map(d => d.name);
23
+ const activeRuns = [];
24
+ for (const runId of runDirs) {
25
+ if (runId === excludeRunId)
26
+ continue;
27
+ const statePath = path.join(runsRoot, runId, 'state.json');
28
+ if (!fs.existsSync(statePath))
29
+ continue;
30
+ try {
31
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
32
+ const state = JSON.parse(stateRaw);
33
+ // Only include running runs (not STOPPED)
34
+ if (state.phase === 'STOPPED')
35
+ continue;
36
+ // Extract predicted touch files from milestones
37
+ const predictedTouchFiles = extractPredictedTouchFiles(state);
38
+ activeRuns.push({
39
+ runId,
40
+ phase: state.phase,
41
+ allowlist: state.scope_lock?.allowlist ?? [],
42
+ predictedTouchFiles,
43
+ updatedAt: state.updated_at ?? ''
44
+ });
45
+ }
46
+ catch {
47
+ // Skip runs with invalid state
48
+ }
49
+ }
50
+ return activeRuns;
51
+ }
52
+ /**
53
+ * Extract union of all files_expected from milestones.
54
+ */
55
+ function extractPredictedTouchFiles(state) {
56
+ const files = new Set();
57
+ for (const milestone of state.milestones) {
58
+ for (const file of milestone.files_expected ?? []) {
59
+ files.add(file);
60
+ }
61
+ }
62
+ return Array.from(files);
63
+ }
64
+ /**
65
+ * Check if two allowlist patterns could overlap.
66
+ * Uses picomatch to test pattern intersection.
67
+ */
68
+ export function patternsOverlap(pattern1, pattern2) {
69
+ // If patterns are identical, they definitely overlap
70
+ if (pattern1 === pattern2)
71
+ return true;
72
+ // Extract base paths (before glob characters)
73
+ const base1 = getPatternBase(pattern1);
74
+ const base2 = getPatternBase(pattern2);
75
+ // If one base is a prefix of the other, they could overlap
76
+ if (base1.startsWith(base2) || base2.startsWith(base1)) {
77
+ return true;
78
+ }
79
+ // Check if pattern1 could match pattern2's base or vice versa
80
+ const matcher1 = picomatch(pattern1);
81
+ const matcher2 = picomatch(pattern2);
82
+ // Test representative paths
83
+ if (matcher1(base2) || matcher2(base1)) {
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * Get the non-glob prefix of a pattern.
90
+ */
91
+ function getPatternBase(pattern) {
92
+ const globIndex = pattern.search(/[*?[\]]/);
93
+ if (globIndex === -1)
94
+ return pattern;
95
+ const base = pattern.slice(0, globIndex);
96
+ // Remove trailing slash if present
97
+ return base.replace(/\/$/, '');
98
+ }
99
+ /**
100
+ * Stage 1: Check for allowlist pattern overlaps (coarse, warn-only).
101
+ */
102
+ export function checkAllowlistOverlaps(newAllowlist, activeRuns) {
103
+ const overlaps = [];
104
+ for (const run of activeRuns) {
105
+ const overlappingPatterns = [];
106
+ for (const newPattern of newAllowlist) {
107
+ for (const existingPattern of run.allowlist) {
108
+ if (patternsOverlap(newPattern, existingPattern)) {
109
+ overlappingPatterns.push(`${newPattern} ∩ ${existingPattern}`);
110
+ }
111
+ }
112
+ }
113
+ if (overlappingPatterns.length > 0) {
114
+ overlaps.push({
115
+ runId: run.runId,
116
+ overlappingPatterns: [...new Set(overlappingPatterns)]
117
+ });
118
+ }
119
+ }
120
+ return overlaps;
121
+ }
122
+ /**
123
+ * Stage 2: Check for exact file collisions (precise, STOP by default).
124
+ */
125
+ export function checkFileCollisions(newTouchFiles, activeRuns) {
126
+ const collisions = [];
127
+ const newFilesSet = new Set(newTouchFiles);
128
+ for (const run of activeRuns) {
129
+ const collidingFiles = [];
130
+ for (const file of run.predictedTouchFiles) {
131
+ if (newFilesSet.has(file)) {
132
+ collidingFiles.push(file);
133
+ }
134
+ }
135
+ if (collidingFiles.length > 0) {
136
+ collisions.push({
137
+ runId: run.runId,
138
+ collidingFiles,
139
+ phase: run.phase,
140
+ updatedAt: run.updatedAt
141
+ });
142
+ }
143
+ }
144
+ return collisions;
145
+ }
146
+ /**
147
+ * Full collision check (both stages).
148
+ */
149
+ export function checkCollisions(newAllowlist, newTouchFiles, activeRuns) {
150
+ const allowlistOverlaps = checkAllowlistOverlaps(newAllowlist, activeRuns);
151
+ const fileCollisions = checkFileCollisions(newTouchFiles, activeRuns);
152
+ return {
153
+ hasCollision: fileCollisions.length > 0,
154
+ allowlistOverlaps,
155
+ fileCollisions
156
+ };
157
+ }
158
+ /**
159
+ * Format collision warning for console output.
160
+ */
161
+ export function formatAllowlistWarning(overlaps) {
162
+ if (overlaps.length === 0)
163
+ return '';
164
+ const lines = [
165
+ 'WARNING: Allowlist overlap detected with active runs:',
166
+ ''
167
+ ];
168
+ for (const overlap of overlaps) {
169
+ lines.push(` Run ${overlap.runId}:`);
170
+ for (const pattern of overlap.overlappingPatterns.slice(0, 5)) {
171
+ lines.push(` - ${pattern}`);
172
+ }
173
+ if (overlap.overlappingPatterns.length > 5) {
174
+ lines.push(` ... and ${overlap.overlappingPatterns.length - 5} more`);
175
+ }
176
+ }
177
+ lines.push('');
178
+ lines.push('Consider waiting for active runs or use --force-parallel to proceed.');
179
+ return lines.join('\n');
180
+ }
181
+ /**
182
+ * Format age from ISO timestamp to human-readable string.
183
+ */
184
+ function formatAge(isoTimestamp) {
185
+ if (!isoTimestamp)
186
+ return '';
187
+ const diffMs = Date.now() - new Date(isoTimestamp).getTime();
188
+ if (diffMs < 0)
189
+ return '';
190
+ const minutes = Math.floor(diffMs / 60000);
191
+ if (minutes < 60)
192
+ return `${minutes}m`;
193
+ const hours = Math.floor(minutes / 60);
194
+ if (hours < 24)
195
+ return `${hours}h`;
196
+ const days = Math.floor(hours / 24);
197
+ return `${days}d`;
198
+ }
199
+ /**
200
+ * Format collision error for console output.
201
+ */
202
+ export function formatFileCollisionError(collisions) {
203
+ if (collisions.length === 0)
204
+ return '';
205
+ const lines = [
206
+ 'ERROR: This run will stop to avoid merge conflicts.',
207
+ ''
208
+ ];
209
+ for (const collision of collisions) {
210
+ const ageStr = formatAge(collision.updatedAt);
211
+ const phaseStr = collision.phase ? `, ${collision.phase}` : '';
212
+ const contextStr = ageStr || phaseStr ? ` (${[collision.phase, ageStr].filter(Boolean).join(', ')})` : '';
213
+ const fileCount = collision.collidingFiles.length;
214
+ const showCount = Math.min(3, fileCount);
215
+ lines.push(`Conflicts with: ${collision.runId}${contextStr}`);
216
+ lines.push(` ${fileCount} file${fileCount > 1 ? 's' : ''} overlap${showCount < fileCount ? ` (showing ${showCount})` : ''}:`);
217
+ for (const file of collision.collidingFiles.slice(0, showCount)) {
218
+ lines.push(` - ${file}`);
219
+ }
220
+ }
221
+ lines.push('');
222
+ lines.push('Options:');
223
+ lines.push(` 1. Wait for run ${collisions[0].runId} to complete (recommended)`);
224
+ lines.push(' 2. Re-run with --force-parallel (may require manual merge resolution)');
225
+ return lines.join('\n');
226
+ }
227
+ /**
228
+ * Get collision summary for status display.
229
+ * Returns diagnostic labels: 'none', 'allowlist' (pattern overlap), 'collision' (file conflict).
230
+ */
231
+ export function getCollisionRisk(runAllowlist, runTouchFiles, activeRuns) {
232
+ const result = checkCollisions(runAllowlist, runTouchFiles, activeRuns);
233
+ if (result.fileCollisions.length > 0) {
234
+ return 'collision';
235
+ }
236
+ if (result.allowlistOverlaps.length > 0) {
237
+ return 'allowlist';
238
+ }
239
+ return 'none';
240
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Evidence gating for counter-evidence claims.
3
+ *
4
+ * When an implementer claims "no_changes_needed", it must provide
5
+ * machine-checkable evidence to prove the claim. This prevents
6
+ * false certainty and "it said it was done but wasn't" failures.
7
+ */
8
+ import picomatch from 'picomatch';
9
+ /**
10
+ * Validate that no_changes_needed claims have sufficient evidence.
11
+ *
12
+ * Requirements (at least one must be satisfied):
13
+ * - files_checked: at least 1 file, all within scope allowlist
14
+ * - grep_output: non-empty string (max 8KB enforced by schema)
15
+ * - commands_run: at least 1 command with exit_code === 0
16
+ *
17
+ * @param evidence - The evidence object from implementer output
18
+ * @param allowlist - Scope allowlist patterns for file validation
19
+ * @returns Validation result with ok flag and any errors
20
+ */
21
+ export function validateNoChangesEvidence(evidence, allowlist) {
22
+ const errors = [];
23
+ if (!evidence) {
24
+ return {
25
+ ok: false,
26
+ errors: ['No evidence provided for no_changes_needed claim']
27
+ };
28
+ }
29
+ // Check files_checked
30
+ const filesChecked = evidence.files_checked ?? [];
31
+ if (filesChecked.length > 0) {
32
+ // Validate all files are within scope
33
+ const matchers = allowlist.map(pattern => picomatch(pattern));
34
+ const outOfScope = filesChecked.filter(file => !matchers.some(match => match(file)));
35
+ if (outOfScope.length > 0) {
36
+ errors.push(`files_checked contains paths outside scope: ${outOfScope.slice(0, 3).join(', ')}${outOfScope.length > 3 ? ` (+${outOfScope.length - 3} more)` : ''}`);
37
+ }
38
+ else {
39
+ return {
40
+ ok: true,
41
+ errors: [],
42
+ satisfied_by: 'files_checked'
43
+ };
44
+ }
45
+ }
46
+ // Check grep_output
47
+ const grepOutput = evidence.grep_output ?? '';
48
+ if (grepOutput.trim().length > 0) {
49
+ return {
50
+ ok: true,
51
+ errors: [],
52
+ satisfied_by: 'grep_output'
53
+ };
54
+ }
55
+ // Check commands_run
56
+ const commandsRun = evidence.commands_run ?? [];
57
+ if (commandsRun.length > 0) {
58
+ const allPassed = commandsRun.every(cmd => cmd.exit_code === 0);
59
+ if (allPassed) {
60
+ return {
61
+ ok: true,
62
+ errors: [],
63
+ satisfied_by: 'commands_run'
64
+ };
65
+ }
66
+ else {
67
+ const failedCommands = commandsRun.filter(cmd => cmd.exit_code !== 0);
68
+ errors.push(`commands_run contains failed commands: ${failedCommands.map(c => `${c.command} (exit ${c.exit_code})`).slice(0, 2).join(', ')}`);
69
+ }
70
+ }
71
+ // None of the evidence types were sufficient
72
+ if (errors.length === 0) {
73
+ errors.push('Evidence must include at least one of: files_checked (non-empty), grep_output (non-empty), or commands_run (with exit_code 0)');
74
+ }
75
+ return {
76
+ ok: false,
77
+ errors
78
+ };
79
+ }
80
+ /**
81
+ * Format evidence validation errors for stop memo.
82
+ */
83
+ export function formatEvidenceErrors(result) {
84
+ if (result.ok)
85
+ return '';
86
+ const lines = [
87
+ 'Insufficient evidence for "no_changes_needed" claim.',
88
+ '',
89
+ 'Errors:',
90
+ ...result.errors.map(e => ` - ${e}`),
91
+ '',
92
+ 'Required: At least one of:',
93
+ ' - files_checked: array of file paths that were inspected (must be within scope)',
94
+ ' - grep_output: output from grep/search showing the feature already exists',
95
+ ' - commands_run: commands executed with exit_code 0 proving no changes needed'
96
+ ];
97
+ return lines.join('\n');
98
+ }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs';
2
+ export function loadTaskFile(taskPath) {
3
+ return fs.readFileSync(taskPath, 'utf-8');
4
+ }
5
+ export function buildMilestonesFromTask(taskText) {
6
+ const firstLine = taskText
7
+ .split('\n')
8
+ .map((line) => line.trim())
9
+ .find((line) => line.length > 0);
10
+ const goal = firstLine ? firstLine : 'Implement task requirements';
11
+ return [
12
+ {
13
+ goal,
14
+ done_checks: ['Core changes implemented', 'Tier 0 checks pass'],
15
+ risk_level: 'medium'
16
+ }
17
+ ];
18
+ }