@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.
- package/commands/dashboard.md +105 -0
- package/commands/goal.md +142 -0
- package/commands/heartbeat.md +129 -0
- package/commands/triage.md +108 -0
- package/hooks/harness-context-monitor.js +4 -0
- package/hooks/hooks.json +23 -23
- package/package.json +1 -1
- package/schemas/goal.schema.json +172 -0
- package/scripts/hooks/session-start-goal-resume.js +95 -0
- package/scripts/hooks/suggest-compact.js +81 -19
- package/scripts/lib/blame-attribution.js +210 -0
- package/scripts/lib/completion-oracle.js +351 -0
- package/scripts/lib/heartbeat-scheduler.js +265 -0
- package/scripts/lib/wave-cost-advisor.js +155 -0
- package/skills/goal-convergence/SKILL.md +150 -0
- package/skills/goframe-v2/examples/practices/quick-demo/manifest/config/config.yaml +14 -14
- package/skills/loop-heartbeat/SKILL.md +120 -0
- package/skills/mcp-connector-bridge/SKILL.md +132 -0
- package/skills/repo-scan/SKILL.md +63 -63
- package/skills/rework-loop/SKILL.md +131 -0
- package/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_platform_artifacts.cpython-311.pyc +0 -0
- package/scripts/__pycache__/install_platform.cpython-311.pyc +0 -0
- package/scripts/__pycache__/langfuse_trace.cpython-311.pyc +0 -0
- package/scripts/__pycache__/query_audit_logs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scan_leaked_keys.cpython-311.pyc +0 -0
- package/scripts/__pycache__/team_skills_platform.cpython-311.pyc +0 -0
- package/scripts/__pycache__/team_skills_platform.cpython-313.pyc +0 -0
- package/scripts/__pycache__/validate_library.cpython-311.pyc +0 -0
- package/scripts/__pycache__/validate_workflow_state.cpython-311.pyc +0 -0
- package/scripts/evolution/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/evolution/__pycache__/store.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/mcp_health_check.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/observe.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/session_end.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/session_start.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/audit_logger.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/audit_query.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/hook_contract.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/memory_store.cpython-311.pyc +0 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
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')
|
|
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
|
+
};
|