@colin4k1024/tsp 2.5.1 → 2.5.2

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 (42) hide show
  1. package/commands/dashboard.md +105 -0
  2. package/commands/goal.md +142 -0
  3. package/commands/heartbeat.md +129 -0
  4. package/commands/triage.md +108 -0
  5. package/hooks/harness-context-monitor.js +4 -0
  6. package/hooks/hooks.json +23 -23
  7. package/package.json +1 -1
  8. package/schemas/goal.schema.json +172 -0
  9. package/scripts/hooks/session-start-goal-resume.js +95 -0
  10. package/scripts/hooks/suggest-compact.js +81 -19
  11. package/scripts/lib/blame-attribution.js +210 -0
  12. package/scripts/lib/completion-oracle.js +351 -0
  13. package/scripts/lib/heartbeat-scheduler.js +265 -0
  14. package/scripts/lib/wave-cost-advisor.js +155 -0
  15. package/skills/goal-convergence/SKILL.md +150 -0
  16. package/skills/goframe-v2/examples/practices/quick-demo/manifest/config/config.yaml +14 -14
  17. package/skills/loop-heartbeat/SKILL.md +120 -0
  18. package/skills/mcp-connector-bridge/SKILL.md +132 -0
  19. package/skills/repo-scan/SKILL.md +63 -63
  20. package/skills/rework-loop/SKILL.md +131 -0
  21. package/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/build_platform_artifacts.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/install_platform.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/langfuse_trace.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/query_audit_logs.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/scan_leaked_keys.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/team_skills_platform.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/team_skills_platform.cpython-313.pyc +0 -0
  29. package/scripts/__pycache__/validate_library.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/validate_workflow_state.cpython-311.pyc +0 -0
  31. package/scripts/evolution/__pycache__/__init__.cpython-311.pyc +0 -0
  32. package/scripts/evolution/__pycache__/store.cpython-311.pyc +0 -0
  33. package/scripts/hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  34. package/scripts/hooks/__pycache__/mcp_health_check.cpython-311.pyc +0 -0
  35. package/scripts/hooks/__pycache__/observe.cpython-311.pyc +0 -0
  36. package/scripts/hooks/__pycache__/session_end.cpython-311.pyc +0 -0
  37. package/scripts/hooks/__pycache__/session_start.cpython-311.pyc +0 -0
  38. package/scripts/lib/__pycache__/audit_logger.cpython-311.pyc +0 -0
  39. package/scripts/lib/__pycache__/audit_query.cpython-311.pyc +0 -0
  40. package/scripts/lib/__pycache__/hook_contract.cpython-311.pyc +0 -0
  41. package/scripts/lib/__pycache__/memory_store.cpython-311.pyc +0 -0
  42. package/scripts/lib/__pycache__/utils.cpython-311.pyc +0 -0
@@ -0,0 +1,172 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "ECC Goal Definition",
4
+ "description": "Schema for goal-oriented autonomous loop with external completion oracle",
5
+ "type": "object",
6
+ "properties": {
7
+ "goalId": {
8
+ "type": "string",
9
+ "pattern": "^goal-[a-z0-9]{8}$",
10
+ "description": "Unique goal identifier"
11
+ },
12
+ "objective": {
13
+ "type": "string",
14
+ "minLength": 5,
15
+ "description": "Natural language description of the goal"
16
+ },
17
+ "stoppingConditions": {
18
+ "type": "array",
19
+ "minItems": 1,
20
+ "items": {
21
+ "type": "object",
22
+ "properties": {
23
+ "type": {
24
+ "type": "string",
25
+ "enum": ["test_pass", "lint_clean", "coverage_threshold", "build_pass", "custom_command"],
26
+ "description": "Type of stopping condition"
27
+ },
28
+ "command": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "description": "Shell command to evaluate the condition"
32
+ },
33
+ "threshold": {
34
+ "type": "number",
35
+ "description": "Numeric threshold for the condition (e.g., coverage percentage)"
36
+ },
37
+ "description": {
38
+ "type": "string",
39
+ "description": "Human-readable description of what this condition checks"
40
+ }
41
+ },
42
+ "required": ["type", "command"],
43
+ "additionalProperties": false
44
+ }
45
+ },
46
+ "budget": {
47
+ "type": "object",
48
+ "properties": {
49
+ "maxIterations": {
50
+ "type": "integer",
51
+ "minimum": 1,
52
+ "maximum": 100,
53
+ "default": 15,
54
+ "description": "Maximum maker iterations before escalation"
55
+ },
56
+ "maxDuration": {
57
+ "type": "string",
58
+ "pattern": "^[0-9]+(m|h)$",
59
+ "default": "2h",
60
+ "description": "Maximum wall-clock time (e.g., '30m', '2h')"
61
+ },
62
+ "maxDollars": {
63
+ "type": "number",
64
+ "minimum": 0.1,
65
+ "default": 10,
66
+ "description": "Maximum cost in USD before pausing"
67
+ }
68
+ },
69
+ "additionalProperties": false
70
+ },
71
+ "oracle": {
72
+ "type": "object",
73
+ "properties": {
74
+ "model": {
75
+ "type": "string",
76
+ "default": "haiku",
77
+ "description": "Model to use for completion verification (must differ from maker)"
78
+ },
79
+ "allowedTools": {
80
+ "type": "array",
81
+ "items": { "type": "string" },
82
+ "default": ["Read", "Bash"],
83
+ "description": "Tools the oracle can use (read-only by convention)"
84
+ },
85
+ "prompt": {
86
+ "type": "string",
87
+ "description": "Custom oracle prompt override (optional)"
88
+ }
89
+ },
90
+ "additionalProperties": false
91
+ },
92
+ "state": {
93
+ "type": "string",
94
+ "enum": ["active", "paused", "converged", "escalated", "failed"],
95
+ "default": "active",
96
+ "description": "Current goal lifecycle state"
97
+ },
98
+ "currentIteration": {
99
+ "type": "integer",
100
+ "minimum": 0,
101
+ "default": 0
102
+ },
103
+ "createdAt": {
104
+ "type": "string",
105
+ "format": "date-time"
106
+ },
107
+ "updatedAt": {
108
+ "type": "string",
109
+ "format": "date-time"
110
+ },
111
+ "history": {
112
+ "type": "array",
113
+ "items": {
114
+ "type": "object",
115
+ "properties": {
116
+ "iteration": {
117
+ "type": "integer",
118
+ "minimum": 0
119
+ },
120
+ "makerSummary": {
121
+ "type": "string",
122
+ "description": "Brief summary of what the maker did this iteration"
123
+ },
124
+ "oracleVerdict": {
125
+ "type": "string",
126
+ "enum": ["pass", "fail", "partial"],
127
+ "description": "Oracle's assessment of stopping conditions"
128
+ },
129
+ "failReasons": {
130
+ "type": "array",
131
+ "items": { "type": "string" },
132
+ "description": "Specific reasons for failure"
133
+ },
134
+ "nextHint": {
135
+ "type": "string",
136
+ "description": "Oracle's guidance for the next iteration"
137
+ },
138
+ "timestamp": {
139
+ "type": "string",
140
+ "format": "date-time"
141
+ },
142
+ "costDollars": {
143
+ "type": "number",
144
+ "description": "Cost of this iteration"
145
+ }
146
+ },
147
+ "required": ["iteration", "oracleVerdict", "timestamp"],
148
+ "additionalProperties": false
149
+ }
150
+ },
151
+ "escalation": {
152
+ "type": "object",
153
+ "properties": {
154
+ "reason": {
155
+ "type": "string",
156
+ "enum": ["budget_exhausted", "repeated_failure", "oracle_uncertain", "manual"],
157
+ "description": "Why the goal was escalated"
158
+ },
159
+ "details": {
160
+ "type": "string"
161
+ },
162
+ "escalatedAt": {
163
+ "type": "string",
164
+ "format": "date-time"
165
+ }
166
+ },
167
+ "additionalProperties": false
168
+ }
169
+ },
170
+ "required": ["goalId", "objective", "stoppingConditions", "state"],
171
+ "additionalProperties": false
172
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * session-start-goal-resume.js
6
+ *
7
+ * SessionStart hook that checks for active goals and injects a resume reminder.
8
+ *
9
+ * Part of the Loop Engineering upgrade (Phase 1.3: Inter-session state bridge).
10
+ * When active goals exist from a previous session, this hook adds context to the
11
+ * session start output so the user knows they can resume with `/goal resume`.
12
+ *
13
+ * Behavior:
14
+ * 1. Scans ~/.claude/goals/ for goal files with state "active" or "paused"
15
+ * 2. If found, appends a summary to the hook output
16
+ * 3. Non-blocking: failures are silently ignored
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ function getGoalsDir() {
23
+ const home = process.env.HOME || process.env.USERPROFILE || '';
24
+ return path.join(home, '.claude', 'goals');
25
+ }
26
+
27
+ function scanActiveGoals() {
28
+ const dir = getGoalsDir();
29
+ if (!fs.existsSync(dir)) return [];
30
+
31
+ try {
32
+ return fs.readdirSync(dir)
33
+ .filter(f => f.endsWith('.json'))
34
+ .map(f => {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ })
41
+ .filter(g => g && (g.state === 'active' || g.state === 'paused'))
42
+ .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ function formatGoalSummary(goal) {
49
+ const stateIcon = goal.state === 'active' ? '▶' : '⏸';
50
+ const progress = `${goal.currentIteration}/${goal.budget?.maxIterations || 15}`;
51
+ const lastHint = goal.history?.length > 0
52
+ ? goal.history[goal.history.length - 1].nextHint || ''
53
+ : '';
54
+ return ` ${stateIcon} [${goal.goalId}] "${goal.objective}" (iter ${progress})${lastHint ? `\n Last hint: ${lastHint.slice(0, 120)}` : ''}`;
55
+ }
56
+
57
+ function main() {
58
+ let raw = '';
59
+ try {
60
+ raw = fs.readFileSync(0, 'utf8');
61
+ } catch {
62
+ // stdin may not be available
63
+ }
64
+
65
+ const activeGoals = scanActiveGoals();
66
+
67
+ if (activeGoals.length === 0) {
68
+ process.stdout.write(raw);
69
+ return;
70
+ }
71
+
72
+ // Parse the input event and add goal context
73
+ let event;
74
+ try {
75
+ event = JSON.parse(raw);
76
+ } catch {
77
+ process.stdout.write(raw);
78
+ return;
79
+ }
80
+
81
+ const goalSummary = activeGoals.map(formatGoalSummary).join('\n');
82
+ const message = `\n[Loop Engineering] ${activeGoals.length} active goal(s) from previous session:\n${goalSummary}\n\nResume with: /goal resume\n`;
83
+
84
+ // Inject into session context if possible
85
+ if (event && typeof event === 'object') {
86
+ if (!event.additionalContext) {
87
+ event.additionalContext = '';
88
+ }
89
+ event.additionalContext += message;
90
+ }
91
+
92
+ process.stdout.write(JSON.stringify(event));
93
+ }
94
+
95
+ main();
@@ -16,12 +16,13 @@ const crypto = require('crypto');
16
16
  const AUTO_COMPACT_BUFFER_PCT = 16.5;
17
17
  const DEFAULT_CONTEXT_LIMIT = 200000;
18
18
  const DEFAULT_DEBOUNCE_CALLS = 8;
19
- const STALE_BRIDGE_SECONDS = 60;
19
+ const STALE_BRIDGE_SECONDS = 120;
20
20
 
21
21
  const URGENCY = [
22
22
  [95, 'critical'],
23
23
  [85, 'high'],
24
24
  [70, 'medium'],
25
+ [65, 'advisory'],
25
26
  [0, 'low'],
26
27
  ];
27
28
 
@@ -64,8 +65,11 @@ function sessionKey(data) {
64
65
  return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(-64) || 'default';
65
66
  }
66
67
 
68
+ // Use PID + cwd hash to differentiate sessions in the same directory
69
+ const pidKey = `pid-${process.pid}`;
67
70
  const cwd = data.cwd || process.cwd();
68
- return crypto.createHash('sha256').update(String(cwd)).digest('hex').slice(0, 16);
71
+ const cwdHash = crypto.createHash('sha256').update(String(cwd)).digest('hex').slice(0, 16);
72
+ return `${pidKey}-${cwdHash}`;
69
73
  }
70
74
 
71
75
  function readBridgeMetrics(sessionId) {
@@ -95,7 +99,30 @@ function readBridgeMetrics(sessionId) {
95
99
 
96
100
  function resolveContextMetrics(data) {
97
101
  const contextLimit = toNumber(process.env.CLAUDE_CONTEXT_LIMIT) || DEFAULT_CONTEXT_LIMIT;
98
- const cw = data.context_window && typeof data.context_window === 'object' ? data.context_window : {};
102
+ let cw = {};
103
+ if (data.context_window && typeof data.context_window === 'object') {
104
+ cw = data.context_window;
105
+ } else if (data.context_window != null) {
106
+ // Malformed: context_window is present but not an object.
107
+ // Attempt to extract a numeric value (some Claude Code versions
108
+ // may pass context_size as a bare number).
109
+ const numericValue = toNumber(data.context_window);
110
+ if (numericValue != null && numericValue > 0) {
111
+ const usagePct = clampPct((numericValue / contextLimit) * 100);
112
+ return {
113
+ usagePct,
114
+ remainingPct: null,
115
+ contextLimit,
116
+ contextSize: numericValue,
117
+ source: 'stdin.context_window_raw',
118
+ };
119
+ }
120
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
121
+ process.stderr.write(
122
+ `[strategic-compact] context_window is ${typeof data.context_window}: ${JSON.stringify(data.context_window).slice(0, 100)}\n`
123
+ );
124
+ }
125
+ }
99
126
 
100
127
  const stdinUsed = toNumber(cw.used_percentage);
101
128
  if (stdinUsed != null) {
@@ -215,12 +242,6 @@ function shouldEmit(sessionId, urgency, usagePct) {
215
242
  const debounceCalls =
216
243
  toNumber(process.env.STRATEGIC_COMPACT_DEBOUNCE_CALLS) || DEFAULT_DEBOUNCE_CALLS;
217
244
  const statePath = path.join(os.tmpdir(), `harness-strategic-compact-${sessionId}.json`);
218
- const nextState = {
219
- lastUrgency: urgency,
220
- lastUsagePct: usagePct,
221
- callsSinceEmit: 0,
222
- updatedAt: new Date().toISOString(),
223
- };
224
245
 
225
246
  try {
226
247
  let previous = null;
@@ -229,22 +250,38 @@ function shouldEmit(sessionId, urgency, usagePct) {
229
250
  }
230
251
 
231
252
  const callsSinceEmit = (previous?.callsSinceEmit || 0) + 1;
232
- const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
253
+ const severityOrder = { low: 0, advisory: 1, medium: 2, high: 3, critical: 4 };
233
254
  const escalated = severityOrder[urgency] > severityOrder[previous?.lastUrgency || 'low'];
234
255
  const usageJumped = usagePct - (previous?.lastUsagePct || 0) >= 8;
235
256
  const repeatDue = callsSinceEmit >= debounceCalls;
236
257
 
237
258
  if (!previous || escalated || usageJumped || repeatDue) {
238
- fs.writeFileSync(statePath, JSON.stringify(nextState));
259
+ fs.writeFileSync(statePath, JSON.stringify({
260
+ lastUrgency: urgency,
261
+ lastUsagePct: usagePct,
262
+ callsSinceEmit: 0,
263
+ updatedAt: new Date().toISOString(),
264
+ }));
239
265
  return true;
240
266
  }
241
267
 
242
- fs.writeFileSync(statePath, JSON.stringify({
243
- ...nextState,
244
- callsSinceEmit,
245
- lastUrgency: previous.lastUrgency || urgency,
246
- lastUsagePct: previous.lastUsagePct || usagePct,
247
- }));
268
+ // Only write if callsSinceEmit actually changed
269
+ if (callsSinceEmit > 1) {
270
+ fs.writeFileSync(statePath, JSON.stringify({
271
+ lastUrgency: previous.lastUrgency || urgency,
272
+ lastUsagePct: previous.lastUsagePct || usagePct,
273
+ callsSinceEmit,
274
+ updatedAt: new Date().toISOString(),
275
+ }));
276
+ }
277
+
278
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
279
+ process.stderr.write(
280
+ `[strategic-compact] debounce suppressed: callsSinceEmit=${callsSinceEmit}, ` +
281
+ `urgency=${urgency}, lastUrgency=${previous?.lastUrgency}\n`
282
+ );
283
+ }
284
+
248
285
  return false;
249
286
  } catch (_) {
250
287
  return true;
@@ -254,6 +291,7 @@ function shouldEmit(sessionId, urgency, usagePct) {
254
291
  function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggestions, source }) {
255
292
  const remainingPart = remainingPct == null ? '' : ` | remaining: ${clampPct(remainingPct)}%`;
256
293
  const actionByUrgency = {
294
+ advisory: 'Context usage is approaching the compaction threshold. Be mindful of context budget; avoid starting large new explorations.',
257
295
  medium: 'Finish the current small step, then run `/compact` before more broad reading or implementation.',
258
296
  high: 'Stop new exploration, preserve decisions/todos/validation results, and ask the user to run `/compact`.',
259
297
  critical: 'Context is nearly exhausted. Do not start new tool chains; ask the user to run `/compact` now.',
@@ -273,14 +311,38 @@ function buildHookOutput(rawInput) {
273
311
  try {
274
312
  data = JSON.parse(rawInput || '{}');
275
313
  } catch (_) {
314
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
315
+ process.stderr.write('[strategic-compact] failed to parse stdin JSON\n');
316
+ }
276
317
  return null;
277
318
  }
278
319
 
279
320
  const metrics = resolveContextMetrics(data);
280
- if (!metrics) return null;
321
+ if (!metrics) {
322
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
323
+ const sessionId = sessionKey(data);
324
+ const bridgePath = path.join(os.tmpdir(), `harness-ctx-${sessionId}.json`);
325
+ const bridgeExists = fs.existsSync(bridgePath);
326
+ process.stderr.write(
327
+ `[strategic-compact] no metrics available. ` +
328
+ `context_window=${JSON.stringify(data.context_window || 'missing')}, ` +
329
+ `session=${sessionId}, bridge_exists=${bridgeExists}, ` +
330
+ `env_size=${process.env.CLAUDE_CONTEXT_SIZE || 'unset'}, ` +
331
+ `env_limit=${process.env.CLAUDE_CONTEXT_LIMIT || 'unset'}\n`
332
+ );
333
+ }
334
+ return null;
335
+ }
281
336
 
282
337
  const urgency = getUrgency(metrics.usagePct);
283
- if (urgency === 'low') return null;
338
+ if (urgency === 'low') {
339
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
340
+ process.stderr.write(
341
+ `[strategic-compact] below threshold: ${metrics.usagePct}% (source: ${metrics.source})\n`
342
+ );
343
+ }
344
+ return null;
345
+ }
284
346
 
285
347
  const sessionId = sessionKey(data);
286
348
  if (!shouldEmit(sessionId, urgency, metrics.usagePct)) return null;
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * blame-attribution.js
5
+ *
6
+ * Maps goal iteration failures to specific code changes using git blame/diff.
7
+ * Produces targeted rework briefs that constrain the next iteration's scope.
8
+ */
9
+
10
+ const { execSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+
14
+ const MAX_REWORK_ATTEMPTS = 3;
15
+
16
+ function parseFailureLocations(failOutput) {
17
+ const locations = [];
18
+ const lines = failOutput.split('\n');
19
+
20
+ for (const line of lines) {
21
+ // Match common test failure patterns:
22
+ // "FAIL src/auth/refresh.test.ts"
23
+ // "● Auth > should refresh tokens"
24
+ // "at Object.<anonymous> (src/auth/refresh.ts:23:5)"
25
+ // "src/auth/refresh.ts(23,5): error TS2345"
26
+ const fileLineMatch = line.match(/([a-zA-Z0-9_\-./]+\.[a-z]{1,4})[:(](\d+)/);
27
+ if (fileLineMatch) {
28
+ const filePath = fileLineMatch[1];
29
+ const lineNumber = parseInt(fileLineMatch[2], 10);
30
+ if (!filePath.includes('node_modules') && !filePath.includes('.git')) {
31
+ locations.push({ file: filePath, line: lineNumber, context: line.trim() });
32
+ }
33
+ }
34
+
35
+ // Match "FAIL" prefixed paths
36
+ const failMatch = line.match(/FAIL\s+(.+\.[a-z]{1,4})/);
37
+ if (failMatch) {
38
+ locations.push({ file: failMatch[1].trim(), line: null, context: line.trim() });
39
+ }
40
+ }
41
+
42
+ // Deduplicate by file
43
+ const seen = new Set();
44
+ return locations.filter(loc => {
45
+ const key = `${loc.file}:${loc.line || 0}`;
46
+ if (seen.has(key)) return false;
47
+ seen.add(key);
48
+ return true;
49
+ });
50
+ }
51
+
52
+ function getChangedFiles(cwd) {
53
+ try {
54
+ const output = execSync('git diff --name-only HEAD 2>/dev/null || git diff --name-only', {
55
+ encoding: 'utf-8',
56
+ cwd,
57
+ timeout: 10000,
58
+ }).trim();
59
+ return output ? output.split('\n').filter(Boolean) : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function getFileDiff(filePath, cwd) {
66
+ try {
67
+ return execSync(`git diff HEAD -- "${filePath}" 2>/dev/null || git diff -- "${filePath}"`, {
68
+ encoding: 'utf-8',
69
+ cwd,
70
+ timeout: 10000,
71
+ }).trim();
72
+ } catch {
73
+ return '';
74
+ }
75
+ }
76
+
77
+ function intersectFailuresWithChanges(failureLocations, changedFiles) {
78
+ return failureLocations.filter(loc => {
79
+ const normalizedFail = loc.file.replace(/^\.\//, '');
80
+ return changedFiles.some(changed => {
81
+ const normalizedChanged = changed.replace(/^\.\//, '');
82
+ return normalizedFail === normalizedChanged ||
83
+ normalizedFail.endsWith(normalizedChanged) ||
84
+ normalizedChanged.endsWith(normalizedFail);
85
+ });
86
+ });
87
+ }
88
+
89
+ function buildReworkBrief(goal, oracleResult, blameResult) {
90
+ const iteration = goal.currentIteration;
91
+ const failingFiles = blameResult.blamedLocations.map(l => l.file);
92
+ const uniqueFiles = [...new Set(failingFiles)];
93
+
94
+ const brief = [
95
+ '## REWORK BRIEF',
96
+ '',
97
+ `**Goal:** ${goal.objective}`,
98
+ `**Iteration:** ${iteration} (rework attempt ${blameResult.attemptCount} for this scope)`,
99
+ '',
100
+ '**Failing evidence:**',
101
+ ...blameResult.blamedLocations.map(l =>
102
+ `- ${l.file}${l.line ? ':' + l.line : ''} — ${l.context.slice(0, 100)}`
103
+ ),
104
+ '',
105
+ '**Root cause (blame):**',
106
+ ...blameResult.changedFiles.slice(0, 5).map(f => `- Changed: ${f}`),
107
+ '',
108
+ '**Constraint:**',
109
+ ...uniqueFiles.map(f => `- ONLY modify: ${f}`),
110
+ '- DO NOT touch test files unless they are the source of the bug',
111
+ '- DO NOT expand scope beyond the blamed files',
112
+ '',
113
+ ];
114
+
115
+ if (oracleResult.nextHint) {
116
+ brief.push('**Oracle hint:**', oracleResult.nextHint, '');
117
+ }
118
+
119
+ return brief.join('\n');
120
+ }
121
+
122
+ function analyzeBlame(goal, oracleResult, cwd) {
123
+ const failOutput = (oracleResult.reasons || []).join('\n') +
124
+ '\n' + (oracleResult.conditionResults || [])
125
+ .filter(r => !r.passed)
126
+ .map(r => r.output)
127
+ .join('\n');
128
+
129
+ const failureLocations = parseFailureLocations(failOutput);
130
+ const changedFiles = getChangedFiles(cwd);
131
+ const blamedLocations = intersectFailuresWithChanges(failureLocations, changedFiles);
132
+
133
+ // Determine if escalation is needed
134
+ const shouldEscalate = blamedLocations.length === 0 && failureLocations.length > 0;
135
+
136
+ return {
137
+ failureLocations,
138
+ changedFiles,
139
+ blamedLocations: blamedLocations.length > 0 ? blamedLocations : failureLocations.slice(0, 5),
140
+ intersection: blamedLocations.length > 0,
141
+ shouldEscalate,
142
+ escalationReason: shouldEscalate ? 'no_blame_intersection' : null,
143
+ attemptCount: 1, // caller should update from tracking store
144
+ };
145
+ }
146
+
147
+ // Rework tracking
148
+
149
+ function getReworkTrackingPath() {
150
+ const home = process.env.HOME || process.env.USERPROFILE || '';
151
+ return path.join(home, '.claude', 'rework-tracking.json');
152
+ }
153
+
154
+ function loadReworkTracking() {
155
+ const trackPath = getReworkTrackingPath();
156
+ if (!fs.existsSync(trackPath)) return {};
157
+ try {
158
+ return JSON.parse(fs.readFileSync(trackPath, 'utf-8'));
159
+ } catch {
160
+ return {};
161
+ }
162
+ }
163
+
164
+ function saveReworkTracking(tracking) {
165
+ const trackPath = getReworkTrackingPath();
166
+ const dir = path.dirname(trackPath);
167
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
168
+ fs.writeFileSync(trackPath, JSON.stringify(tracking, null, 2), 'utf-8');
169
+ }
170
+
171
+ function recordReworkAttempt(filePath, outcome) {
172
+ const tracking = loadReworkTracking();
173
+ if (!tracking[filePath]) {
174
+ tracking[filePath] = { attempts: 0, outcomes: [], lastAttempt: null, totalCost: 0 };
175
+ }
176
+ tracking[filePath].attempts += 1;
177
+ tracking[filePath].outcomes.push(outcome);
178
+ tracking[filePath].lastAttempt = new Date().toISOString();
179
+ saveReworkTracking(tracking);
180
+ return tracking[filePath];
181
+ }
182
+
183
+ function shouldEscalateFile(filePath) {
184
+ const tracking = loadReworkTracking();
185
+ const record = tracking[filePath];
186
+ if (!record) return false;
187
+ return record.attempts >= MAX_REWORK_ATTEMPTS;
188
+ }
189
+
190
+ function getPersistentTroubleSpots() {
191
+ const tracking = loadReworkTracking();
192
+ return Object.entries(tracking)
193
+ .filter(([, record]) => record.attempts >= MAX_REWORK_ATTEMPTS)
194
+ .map(([file, record]) => ({ file, ...record }));
195
+ }
196
+
197
+ module.exports = {
198
+ MAX_REWORK_ATTEMPTS,
199
+ parseFailureLocations,
200
+ getChangedFiles,
201
+ getFileDiff,
202
+ intersectFailuresWithChanges,
203
+ buildReworkBrief,
204
+ analyzeBlame,
205
+ loadReworkTracking,
206
+ saveReworkTracking,
207
+ recordReworkAttempt,
208
+ shouldEscalateFile,
209
+ getPersistentTroubleSpots,
210
+ };