@colin4k1024/tsp 2.5.1 → 2.5.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.
@@ -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) {
@@ -133,6 +160,27 @@ function resolveContextMetrics(data) {
133
160
  };
134
161
  }
135
162
 
163
+ // Transcript JSONL usage parsing (CCometixLine approach)
164
+ const transcriptPath = data.transcript_path;
165
+ const modelId = (data.model && data.model.id) || process.env.CLAUDE_MODEL || null;
166
+ if (transcriptPath && typeof transcriptPath === 'string') {
167
+ try {
168
+ const { resolveTranscriptMetrics } = require('../lib/transcript-usage');
169
+ const transcriptMetrics = resolveTranscriptMetrics(transcriptPath, modelId);
170
+ if (transcriptMetrics) {
171
+ return {
172
+ usagePct: clampPct(transcriptMetrics.usagePct),
173
+ remainingPct: null,
174
+ contextLimit: transcriptMetrics.contextLimit,
175
+ contextSize: transcriptMetrics.contextTokens,
176
+ source: 'transcript_usage',
177
+ };
178
+ }
179
+ } catch (_) {
180
+ // transcript parsing failed — fall through
181
+ }
182
+ }
183
+
136
184
  const bridge = readBridgeMetrics(sessionKey(data));
137
185
  if (bridge) {
138
186
  return {
@@ -142,6 +190,26 @@ function resolveContextMetrics(data) {
142
190
  };
143
191
  }
144
192
 
193
+ // Final fallback: estimate from transcript file size
194
+ if (transcriptPath && typeof transcriptPath === 'string') {
195
+ try {
196
+ const stat = fs.statSync(transcriptPath);
197
+ const estimatedTokens = Math.round(stat.size * 0.25);
198
+ if (estimatedTokens > 0) {
199
+ const usagePct = clampPct((estimatedTokens / contextLimit) * 100);
200
+ return {
201
+ usagePct,
202
+ remainingPct: null,
203
+ contextLimit,
204
+ contextSize: estimatedTokens,
205
+ source: 'transcript_size',
206
+ };
207
+ }
208
+ } catch (_) {
209
+ // transcript not accessible — fall through
210
+ }
211
+ }
212
+
145
213
  return null;
146
214
  }
147
215
 
@@ -215,12 +283,6 @@ function shouldEmit(sessionId, urgency, usagePct) {
215
283
  const debounceCalls =
216
284
  toNumber(process.env.STRATEGIC_COMPACT_DEBOUNCE_CALLS) || DEFAULT_DEBOUNCE_CALLS;
217
285
  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
286
 
225
287
  try {
226
288
  let previous = null;
@@ -229,22 +291,38 @@ function shouldEmit(sessionId, urgency, usagePct) {
229
291
  }
230
292
 
231
293
  const callsSinceEmit = (previous?.callsSinceEmit || 0) + 1;
232
- const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
294
+ const severityOrder = { low: 0, advisory: 1, medium: 2, high: 3, critical: 4 };
233
295
  const escalated = severityOrder[urgency] > severityOrder[previous?.lastUrgency || 'low'];
234
296
  const usageJumped = usagePct - (previous?.lastUsagePct || 0) >= 8;
235
297
  const repeatDue = callsSinceEmit >= debounceCalls;
236
298
 
237
299
  if (!previous || escalated || usageJumped || repeatDue) {
238
- fs.writeFileSync(statePath, JSON.stringify(nextState));
300
+ fs.writeFileSync(statePath, JSON.stringify({
301
+ lastUrgency: urgency,
302
+ lastUsagePct: usagePct,
303
+ callsSinceEmit: 0,
304
+ updatedAt: new Date().toISOString(),
305
+ }));
239
306
  return true;
240
307
  }
241
308
 
242
- fs.writeFileSync(statePath, JSON.stringify({
243
- ...nextState,
244
- callsSinceEmit,
245
- lastUrgency: previous.lastUrgency || urgency,
246
- lastUsagePct: previous.lastUsagePct || usagePct,
247
- }));
309
+ // Only write if callsSinceEmit actually changed
310
+ if (callsSinceEmit > 1) {
311
+ fs.writeFileSync(statePath, JSON.stringify({
312
+ lastUrgency: previous.lastUrgency || urgency,
313
+ lastUsagePct: previous.lastUsagePct || usagePct,
314
+ callsSinceEmit,
315
+ updatedAt: new Date().toISOString(),
316
+ }));
317
+ }
318
+
319
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
320
+ process.stderr.write(
321
+ `[strategic-compact] debounce suppressed: callsSinceEmit=${callsSinceEmit}, ` +
322
+ `urgency=${urgency}, lastUrgency=${previous?.lastUrgency}\n`
323
+ );
324
+ }
325
+
248
326
  return false;
249
327
  } catch (_) {
250
328
  return true;
@@ -254,6 +332,7 @@ function shouldEmit(sessionId, urgency, usagePct) {
254
332
  function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggestions, source }) {
255
333
  const remainingPart = remainingPct == null ? '' : ` | remaining: ${clampPct(remainingPct)}%`;
256
334
  const actionByUrgency = {
335
+ advisory: 'Context usage is approaching the compaction threshold. Be mindful of context budget; avoid starting large new explorations.',
257
336
  medium: 'Finish the current small step, then run `/compact` before more broad reading or implementation.',
258
337
  high: 'Stop new exploration, preserve decisions/todos/validation results, and ask the user to run `/compact`.',
259
338
  critical: 'Context is nearly exhausted. Do not start new tool chains; ask the user to run `/compact` now.',
@@ -273,14 +352,38 @@ function buildHookOutput(rawInput) {
273
352
  try {
274
353
  data = JSON.parse(rawInput || '{}');
275
354
  } catch (_) {
355
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
356
+ process.stderr.write('[strategic-compact] failed to parse stdin JSON\n');
357
+ }
276
358
  return null;
277
359
  }
278
360
 
279
361
  const metrics = resolveContextMetrics(data);
280
- if (!metrics) return null;
362
+ if (!metrics) {
363
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
364
+ const sessionId = sessionKey(data);
365
+ const bridgePath = path.join(os.tmpdir(), `harness-ctx-${sessionId}.json`);
366
+ const bridgeExists = fs.existsSync(bridgePath);
367
+ process.stderr.write(
368
+ `[strategic-compact] no metrics available. ` +
369
+ `context_window=${JSON.stringify(data.context_window || 'missing')}, ` +
370
+ `session=${sessionId}, bridge_exists=${bridgeExists}, ` +
371
+ `env_size=${process.env.CLAUDE_CONTEXT_SIZE || 'unset'}, ` +
372
+ `env_limit=${process.env.CLAUDE_CONTEXT_LIMIT || 'unset'}\n`
373
+ );
374
+ }
375
+ return null;
376
+ }
281
377
 
282
378
  const urgency = getUrgency(metrics.usagePct);
283
- if (urgency === 'low') return null;
379
+ if (urgency === 'low') {
380
+ if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
381
+ process.stderr.write(
382
+ `[strategic-compact] below threshold: ${metrics.usagePct}% (source: ${metrics.source})\n`
383
+ );
384
+ }
385
+ return null;
386
+ }
284
387
 
285
388
  const sessionId = sessionKey(data);
286
389
  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
+ };