@sienklogic/plan-build-run 2.61.1 → 2.62.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.
@@ -28,6 +28,165 @@ const UNIQUE_FILE_MILESTONE = 10; // warn every 10 unique files
28
28
  const CHAR_MILESTONE = 50000; // warn every 50k chars
29
29
  const LARGE_FILE_THRESHOLD = 5000; // warn if single read > 5k chars
30
30
 
31
+ /**
32
+ * Core event processing logic for track-context-budget.
33
+ * Extracted so it can be called directly by hook-server.js (HTTP mode)
34
+ * or via stdin in command mode.
35
+ *
36
+ * @param {Object} data - Hook event data (tool_input, tool_output, etc.)
37
+ * @param {string} planningDir - Absolute path to .planning/ directory
38
+ * @param {Object} [opts] - Optional overrides
39
+ * @param {string} [opts.pluginRoot] - Override for CLAUDE_PLUGIN_ROOT
40
+ * @returns {{ additionalContext: string }|null} Warning output or null
41
+ */
42
+ function processEvent(data, planningDir, opts) {
43
+ const filePath = data.tool_input?.file_path || '';
44
+ if (!filePath) {
45
+ return null;
46
+ }
47
+
48
+ // Skip plugin-internal files — these are loaded by the plugin system,
49
+ // not by the orchestrator, so they shouldn't count against context budget
50
+ const pluginRoot = (opts && opts.pluginRoot != null) ? opts.pluginRoot : (process.env.CLAUDE_PLUGIN_ROOT || '');
51
+ if (pluginRoot) {
52
+ const normalizedFile = path.resolve(filePath);
53
+ const normalizedPlugin = path.resolve(pluginRoot);
54
+ if (normalizedFile.startsWith(normalizedPlugin + path.sep) || normalizedFile === normalizedPlugin) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Estimate chars read from actual output or limit, with a conservative default.
60
+ // Previous default of 80k (2000 lines × 40 chars) caused every read to cross
61
+ // the 50k milestone, flooding logs with warnings on every single Read call.
62
+ const limit = data.tool_input?.limit;
63
+ const estimatedChars = limit ? limit * 40 : 8000;
64
+ // Use actual output length if available
65
+ const actualChars = data.tool_output ? String(data.tool_output).length : estimatedChars;
66
+
67
+ const trackerPath = path.join(planningDir, '.context-tracker');
68
+ const skillPath = path.join(planningDir, '.active-skill');
69
+
70
+ // Check if active skill changed (reset tracker)
71
+ const currentSkill = readFileSafe(skillPath);
72
+ let tracker = loadTracker(trackerPath);
73
+
74
+ if (tracker.skill !== currentSkill) {
75
+ tracker = { skill: currentSkill, reads: 0, total_chars: 0, files: [] };
76
+ } else if (tracker.files.length > 200) {
77
+ logHook('track-context-budget', 'PostToolUse', 'warn', {
78
+ reason: 'tracker reset at 200 files',
79
+ reads: tracker.reads,
80
+ total_chars: tracker.total_chars,
81
+ unique_files: tracker.files.length,
82
+ });
83
+ const prevCharsTotal = tracker.total_chars;
84
+ const resetWarning = {
85
+ additionalContext: `[Context Budget] Tracker reset: ${tracker.files.length} unique files read (~${Math.round(tracker.total_chars / 1000)}k chars). File list cleared but char total preserved. Consider delegating remaining work to a Task() subagent.`
86
+ };
87
+ tracker = { skill: currentSkill, reads: 0, total_chars: prevCharsTotal, files: [] };
88
+ // Save reset tracker before returning the warning
89
+ try {
90
+ const tmpPath = trackerPath + '.' + process.pid;
91
+ fs.writeFileSync(tmpPath, JSON.stringify(tracker), 'utf8');
92
+ fs.renameSync(tmpPath, trackerPath);
93
+ } catch (_e) {
94
+ try { fs.unlinkSync(trackerPath + '.' + process.pid); } catch (_e2) { /* best-effort cleanup */ }
95
+ }
96
+ return resetWarning;
97
+ }
98
+
99
+ // Update tracker
100
+ const prevFileCount = tracker.files.length;
101
+ tracker.reads += 1;
102
+ tracker.total_chars += actualChars;
103
+ if (!tracker.files.includes(filePath)) {
104
+ tracker.files.push(filePath);
105
+ }
106
+
107
+ // Save tracker (atomic write to avoid corruption from concurrent hooks)
108
+ try {
109
+ const tmpPath = trackerPath + '.' + process.pid;
110
+ fs.writeFileSync(tmpPath, JSON.stringify(tracker), 'utf8');
111
+ fs.renameSync(tmpPath, trackerPath);
112
+ } catch (_e) {
113
+ // Best-effort — clean up temp file if rename failed
114
+ try { fs.unlinkSync(trackerPath + '.' + process.pid); } catch (_e2) { /* best-effort cleanup */ }
115
+ }
116
+
117
+ // Check bridge file for tier-based context warnings
118
+ const bridgeTier = checkBridge(planningDir);
119
+ if (bridgeTier) {
120
+ // Bridge is fresh and providing tier warnings — skip heuristic milestones
121
+ // (the bridge's context-bridge.js already handles tier debounce)
122
+ logHook('track-context-budget', 'PostToolUse', 'bridge-active', {
123
+ reads: tracker.reads,
124
+ total_chars: tracker.total_chars,
125
+ tier: bridgeTier,
126
+ });
127
+ return null;
128
+ }
129
+
130
+ // Check thresholds — only warn at milestone crossings, not every read
131
+ const warnings = [];
132
+
133
+ // Milestone: unique files read crosses a multiple of UNIQUE_FILE_MILESTONE
134
+ const curUniqueFiles = tracker.files.length;
135
+ if (curUniqueFiles >= UNIQUE_FILE_MILESTONE &&
136
+ Math.floor(curUniqueFiles / UNIQUE_FILE_MILESTONE) > Math.floor(prevFileCount / UNIQUE_FILE_MILESTONE)) {
137
+ warnings.push(`${curUniqueFiles} unique files read (milestone: every ${UNIQUE_FILE_MILESTONE})`);
138
+ }
139
+
140
+ // Milestone: total chars crosses a multiple of CHAR_MILESTONE
141
+ const prevChars = tracker.total_chars - actualChars;
142
+ if (tracker.total_chars >= CHAR_MILESTONE &&
143
+ Math.floor(tracker.total_chars / CHAR_MILESTONE) > Math.floor(prevChars / CHAR_MILESTONE)) {
144
+ const kChars = Math.round(tracker.total_chars / 1000);
145
+ warnings.push(`~${kChars}k chars read (milestone: every ${CHAR_MILESTONE / 1000}k)`);
146
+ }
147
+
148
+ // Single large file warning
149
+ if (actualChars >= LARGE_FILE_THRESHOLD) {
150
+ const kChars = Math.round(actualChars / 1000);
151
+ warnings.push(`large file read (~${kChars}k chars): ${path.basename(filePath)}`);
152
+ }
153
+
154
+ if (warnings.length > 0) {
155
+ logHook('track-context-budget', 'PostToolUse', 'warn', {
156
+ reads: tracker.reads,
157
+ total_chars: tracker.total_chars,
158
+ unique_files: tracker.files.length,
159
+ });
160
+
161
+ return {
162
+ additionalContext: `[Context Budget Warning] ${warnings.join(', ')}. ${tracker.files.length} unique files read. Consider delegating remaining reads to a Task() subagent to protect orchestrator context.`
163
+ };
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * HTTP handler for hook-server.js.
171
+ * Called directly instead of spawning a subprocess.
172
+ *
173
+ * @param {Object} reqBody - Full hook request body { event, tool, data, planningDir, cache }
174
+ * @param {Object} _cache - Server in-memory cache (unused by this handler)
175
+ * @returns {{ additionalContext: string }|null}
176
+ */
177
+ function handleHttp(reqBody, _cache) {
178
+ try {
179
+ const planningDir = reqBody.planningDir;
180
+ const data = reqBody.data || {};
181
+ if (!planningDir || !fs.existsSync(planningDir)) {
182
+ return null;
183
+ }
184
+ return processEvent(data, planningDir, {});
185
+ } catch (_e) {
186
+ return null;
187
+ }
188
+ }
189
+
31
190
  function main() {
32
191
  let input = '';
33
192
 
@@ -42,121 +201,12 @@ function main() {
42
201
  }
43
202
 
44
203
  const data = JSON.parse(input);
45
- const filePath = data.tool_input?.file_path || '';
46
- if (!filePath) {
47
- process.exit(0);
48
- }
49
-
50
- // Skip plugin-internal files — these are loaded by the plugin system,
51
- // not by the orchestrator, so they shouldn't count against context budget
52
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || '';
53
- if (pluginRoot) {
54
- const normalizedFile = path.resolve(filePath);
55
- const normalizedPlugin = path.resolve(pluginRoot);
56
- if (normalizedFile.startsWith(normalizedPlugin + path.sep) || normalizedFile === normalizedPlugin) {
57
- process.exit(0);
58
- }
59
- }
60
-
61
- // Estimate chars read from actual output or limit, with a conservative default.
62
- // Previous default of 80k (2000 lines × 40 chars) caused every read to cross
63
- // the 50k milestone, flooding logs with warnings on every single Read call.
64
- const limit = data.tool_input?.limit;
65
- const estimatedChars = limit ? limit * 40 : 8000;
66
- // Use actual output length if available
67
- const actualChars = data.tool_output ? String(data.tool_output).length : estimatedChars;
68
-
69
- const trackerPath = path.join(planningDir, '.context-tracker');
70
- const skillPath = path.join(planningDir, '.active-skill');
71
-
72
- // Check if active skill changed (reset tracker)
73
- const currentSkill = readFileSafe(skillPath);
74
- let tracker = loadTracker(trackerPath);
75
-
76
- if (tracker.skill !== currentSkill) {
77
- tracker = { skill: currentSkill, reads: 0, total_chars: 0, files: [] };
78
- } else if (tracker.files.length > 200) {
79
- logHook('track-context-budget', 'PostToolUse', 'warn', {
80
- reason: 'tracker reset at 200 files',
81
- reads: tracker.reads,
82
- total_chars: tracker.total_chars,
83
- unique_files: tracker.files.length,
84
- });
85
- const prevCharsTotal = tracker.total_chars;
86
- // Emit user-visible warning before resetting (was previously silent)
87
- const resetWarning = {
88
- additionalContext: `[Context Budget] Tracker reset: ${tracker.files.length} unique files read (~${Math.round(tracker.total_chars / 1000)}k chars). File list cleared but char total preserved. Consider delegating remaining work to a Task() subagent.`
89
- };
90
- process.stdout.write(JSON.stringify(resetWarning));
91
- tracker = { skill: currentSkill, reads: 0, total_chars: prevCharsTotal, files: [] };
92
- }
93
-
94
- // Update tracker
95
- const prevFileCount = tracker.files.length;
96
- tracker.reads += 1;
97
- tracker.total_chars += actualChars;
98
- if (!tracker.files.includes(filePath)) {
99
- tracker.files.push(filePath);
100
- }
101
-
102
- // Save tracker (atomic write to avoid corruption from concurrent hooks)
103
- try {
104
- const tmpPath = trackerPath + '.' + process.pid;
105
- fs.writeFileSync(tmpPath, JSON.stringify(tracker), 'utf8');
106
- fs.renameSync(tmpPath, trackerPath);
107
- } catch (_e) {
108
- // Best-effort — clean up temp file if rename failed
109
- try { fs.unlinkSync(trackerPath + '.' + process.pid); } catch (_e2) { /* best-effort cleanup */ }
110
- }
111
-
112
- // Check bridge file for tier-based context warnings
113
- const bridgeTier = checkBridge(planningDir);
114
- if (bridgeTier) {
115
- // Bridge is fresh and providing tier warnings — skip heuristic milestones
116
- // (the bridge's context-bridge.js already handles tier debounce)
117
- logHook('track-context-budget', 'PostToolUse', 'bridge-active', {
118
- reads: tracker.reads,
119
- total_chars: tracker.total_chars,
120
- tier: bridgeTier,
121
- });
122
- process.exit(0);
123
- }
124
-
125
- // Check thresholds — only warn at milestone crossings, not every read
126
- const warnings = [];
127
-
128
- // Milestone: unique files read crosses a multiple of UNIQUE_FILE_MILESTONE
129
- const curUniqueFiles = tracker.files.length;
130
- if (curUniqueFiles >= UNIQUE_FILE_MILESTONE &&
131
- Math.floor(curUniqueFiles / UNIQUE_FILE_MILESTONE) > Math.floor(prevFileCount / UNIQUE_FILE_MILESTONE)) {
132
- warnings.push(`${curUniqueFiles} unique files read (milestone: every ${UNIQUE_FILE_MILESTONE})`);
133
- }
134
-
135
- // Milestone: total chars crosses a multiple of CHAR_MILESTONE
136
- const prevChars = tracker.total_chars - actualChars;
137
- if (tracker.total_chars >= CHAR_MILESTONE &&
138
- Math.floor(tracker.total_chars / CHAR_MILESTONE) > Math.floor(prevChars / CHAR_MILESTONE)) {
139
- const kChars = Math.round(tracker.total_chars / 1000);
140
- warnings.push(`~${kChars}k chars read (milestone: every ${CHAR_MILESTONE / 1000}k)`);
141
- }
142
-
143
- // Single large file warning
144
- if (actualChars >= LARGE_FILE_THRESHOLD) {
145
- const kChars = Math.round(actualChars / 1000);
146
- warnings.push(`large file read (~${kChars}k chars): ${path.basename(filePath)}`);
147
- }
204
+ const result = processEvent(data, planningDir, {});
148
205
 
149
- if (warnings.length > 0) {
150
- logHook('track-context-budget', 'PostToolUse', 'warn', {
151
- reads: tracker.reads,
152
- total_chars: tracker.total_chars,
153
- unique_files: tracker.files.length,
154
- });
155
-
156
- const output = {
157
- additionalContext: `[Context Budget Warning] ${warnings.join(', ')}. ${tracker.files.length} unique files read. Consider delegating remaining reads to a Task() subagent to protect orchestrator context.`
158
- };
159
- process.stdout.write(JSON.stringify(output));
206
+ if (result) {
207
+ // In command mode: write reset warnings that were previously written inline
208
+ // processEvent returns them all, so just output here
209
+ process.stdout.write(JSON.stringify(result));
160
210
  }
161
211
 
162
212
  process.exit(0);
@@ -214,6 +264,6 @@ function checkBridge(planningDir) {
214
264
  }
215
265
  }
216
266
 
217
- module.exports = { checkBridge, BRIDGE_STALENESS_MS };
267
+ module.exports = { checkBridge, BRIDGE_STALENESS_MS, processEvent, handleHttp };
218
268
 
219
269
  if (require.main === module || process.argv[1] === __filename) { main(); }
@@ -89,5 +89,53 @@ initialized: ${new Date().toISOString()}
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * handleHttp — hook-server.js interface.
94
+ * reqBody = { event, tool, data, planningDir, cache }
95
+ * Returns { additionalContext: "..." } or null. Never calls process.exit().
96
+ */
97
+ function handleHttp(reqBody) {
98
+ try {
99
+ const data = (reqBody && reqBody.data) || {};
100
+ const worktreePath = data.worktree_path || process.env.PBR_PROJECT_ROOT || process.cwd();
101
+ const parentRoot = data.project_root || process.env.PBR_PROJECT_ROOT || process.cwd();
102
+
103
+ const planningDir = path.join(worktreePath, '.planning');
104
+ const parentPlanningDir = path.join(parentRoot, '.planning');
105
+
106
+ if (!fs.existsSync(parentPlanningDir)) {
107
+ logHook('worktree-create', 'WorktreeCreate', 'skip-no-parent', { worktree_path: worktreePath });
108
+ return null;
109
+ }
110
+
111
+ if (fs.existsSync(planningDir)) {
112
+ logHook('worktree-create', 'WorktreeCreate', 'skip-exists', { worktree_path: worktreePath });
113
+ return null;
114
+ }
115
+
116
+ fs.mkdirSync(planningDir, { recursive: true });
117
+ fs.mkdirSync(path.join(planningDir, 'logs'), { recursive: true });
118
+
119
+ const stateMd = `# STATE\n\n## Current Position\nphase: (none)\nstatus: Worktree initialized — run /pbr:resume or /pbr:status for project state.\n\n## Source\nparent: ${parentRoot}\ninitialized: ${new Date().toISOString()}\n`;
120
+ fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateMd, 'utf8');
121
+
122
+ try {
123
+ const srcConfig = path.join(parentPlanningDir, 'config.json');
124
+ const destConfig = path.join(planningDir, 'config.json');
125
+ if (fs.existsSync(srcConfig)) fs.copyFileSync(srcConfig, destConfig);
126
+ } catch (_e) { /* non-fatal */ }
127
+
128
+ logHook('worktree-create', 'WorktreeCreate', 'initialized', {
129
+ worktree_path: worktreePath,
130
+ parent_root: parentRoot
131
+ });
132
+
133
+ return { additionalContext: '[Plan-Build-Run] Worktree .planning/ initialized. Run /pbr:status to see project state.' };
134
+ } catch (err) {
135
+ logHook('worktree-create', 'WorktreeCreate', 'error', { error: err.message });
136
+ return null;
137
+ }
138
+ }
139
+
92
140
  if (require.main === module || process.argv[1] === __filename) { main(); }
93
- module.exports = { main };
141
+ module.exports = { main, handleHttp };
@@ -79,5 +79,50 @@ function main() {
79
79
  }
80
80
  }
81
81
 
82
+ /**
83
+ * handleHttp — hook-server.js interface.
84
+ * reqBody = { event, tool, data, planningDir, cache }
85
+ * Returns null. Never calls process.exit().
86
+ */
87
+ function handleHttp(reqBody) {
88
+ try {
89
+ const data = (reqBody && reqBody.data) || {};
90
+ const worktreePath = data.worktree_path || process.env.PBR_PROJECT_ROOT || process.cwd();
91
+ const planningDir = path.join(worktreePath, '.planning');
92
+
93
+ if (!fs.existsSync(planningDir)) {
94
+ logHook('worktree-remove', 'WorktreeRemove', 'skip-no-planning', { worktree_path: worktreePath });
95
+ return null;
96
+ }
97
+
98
+ const stateMdPath = path.join(planningDir, 'STATE.md');
99
+ if (fs.existsSync(stateMdPath)) {
100
+ const stateContent = fs.readFileSync(stateMdPath, 'utf8');
101
+ if (!stateContent.includes('parent:')) {
102
+ logHook('worktree-remove', 'WorktreeRemove', 'skip-not-worktree', { worktree_path: worktreePath });
103
+ return null;
104
+ }
105
+ }
106
+
107
+ const sessionFiles = ['.session.json', '.session-start', '.active-skill', '.auto-next'];
108
+ for (const filename of sessionFiles) {
109
+ try {
110
+ const filePath = path.join(planningDir, filename);
111
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
112
+ } catch (_e) { /* non-fatal */ }
113
+ }
114
+
115
+ logHook('worktree-remove', 'WorktreeRemove', 'cleaned', {
116
+ worktree_path: worktreePath,
117
+ files_removed: ['session files']
118
+ });
119
+
120
+ return null;
121
+ } catch (err) {
122
+ logHook('worktree-remove', 'WorktreeRemove', 'error', { error: err.message });
123
+ return null;
124
+ }
125
+ }
126
+
82
127
  if (require.main === module || process.argv[1] === __filename) { main(); }
83
- module.exports = { main };
128
+ module.exports = { main, handleHttp };