@sienklogic/plan-build-run 2.24.0 → 2.26.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 (58) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -13
  3. package/dashboard/package.json +1 -1
  4. package/dashboard/public/css/layout.css +128 -21
  5. package/dashboard/public/css/status-colors.css +14 -2
  6. package/dashboard/public/css/tokens.css +36 -0
  7. package/dashboard/src/middleware/current-phase.js +2 -1
  8. package/dashboard/src/routes/events.routes.js +49 -0
  9. package/dashboard/src/routes/pages.routes.js +250 -1
  10. package/dashboard/src/services/config.service.js +140 -0
  11. package/dashboard/src/services/dashboard.service.js +156 -11
  12. package/dashboard/src/services/log.service.js +105 -0
  13. package/dashboard/src/services/notes.service.js +16 -0
  14. package/dashboard/src/services/phase.service.js +58 -9
  15. package/dashboard/src/services/requirements.service.js +130 -0
  16. package/dashboard/src/services/research.service.js +137 -0
  17. package/dashboard/src/services/todo.service.js +30 -0
  18. package/dashboard/src/views/config.ejs +5 -0
  19. package/dashboard/src/views/logs.ejs +3 -0
  20. package/dashboard/src/views/note-detail.ejs +3 -0
  21. package/dashboard/src/views/partials/activity-feed.ejs +12 -0
  22. package/dashboard/src/views/partials/config-content.ejs +196 -0
  23. package/dashboard/src/views/partials/dashboard-content.ejs +71 -46
  24. package/dashboard/src/views/partials/log-entries-content.ejs +17 -0
  25. package/dashboard/src/views/partials/logs-content.ejs +131 -0
  26. package/dashboard/src/views/partials/note-detail-content.ejs +22 -0
  27. package/dashboard/src/views/partials/notes-content.ejs +7 -1
  28. package/dashboard/src/views/partials/phase-content.ejs +181 -146
  29. package/dashboard/src/views/partials/phase-timeline.ejs +16 -0
  30. package/dashboard/src/views/partials/requirements-content.ejs +44 -0
  31. package/dashboard/src/views/partials/research-content.ejs +49 -0
  32. package/dashboard/src/views/partials/research-detail-content.ejs +23 -0
  33. package/dashboard/src/views/partials/sidebar.ejs +63 -26
  34. package/dashboard/src/views/partials/todos-done-content.ejs +44 -0
  35. package/dashboard/src/views/requirements.ejs +3 -0
  36. package/dashboard/src/views/research-detail.ejs +3 -0
  37. package/dashboard/src/views/research.ejs +3 -0
  38. package/dashboard/src/views/todos-done.ejs +3 -0
  39. package/package.json +1 -1
  40. package/plugins/copilot-pbr/agents/dev-sync.agent.md +114 -0
  41. package/plugins/copilot-pbr/hooks/hooks.json +12 -0
  42. package/plugins/copilot-pbr/plugin.json +1 -1
  43. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  44. package/plugins/cursor-pbr/agents/dev-sync.md +113 -0
  45. package/plugins/cursor-pbr/hooks/hooks.json +10 -0
  46. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  47. package/plugins/pbr/agents/dev-sync.md +120 -0
  48. package/plugins/pbr/hooks/hooks.json +10 -0
  49. package/plugins/pbr/scripts/config-schema.json +4 -1
  50. package/plugins/pbr/scripts/local-llm/health.js +4 -1
  51. package/plugins/pbr/scripts/local-llm/operations/classify-commit.js +68 -0
  52. package/plugins/pbr/scripts/local-llm/operations/classify-file-intent.js +73 -0
  53. package/plugins/pbr/scripts/local-llm/operations/triage-test-output.js +72 -0
  54. package/plugins/pbr/scripts/post-bash-triage.js +132 -0
  55. package/plugins/pbr/scripts/post-write-dispatch.js +44 -0
  56. package/plugins/pbr/scripts/pre-bash-dispatch.js +17 -11
  57. package/plugins/pbr/scripts/status-line.js +50 -5
  58. package/plugins/pbr/scripts/validate-commit.js +66 -2
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const { complete, tryParseJSON, isDisabled } = require('../client');
4
+ const { logMetric } = require('../metrics');
5
+ const { route } = require('../router');
6
+
7
+ const VALID_FILE_TYPES = ['plan', 'state', 'code', 'test', 'config', 'docs', 'template', 'other'];
8
+ const VALID_INTENTS = ['create', 'update', 'fix', 'refactor', 'delete'];
9
+
10
+ /**
11
+ * Classifies the type and intent of a Write/Edit operation using the local LLM.
12
+ * Uses the file path and a short content snippet to determine what kind of file
13
+ * is being written and why, enabling smarter downstream dispatch.
14
+ *
15
+ * @param {object} config - resolved local_llm config block
16
+ * @param {string} planningDir - path to the .planning directory
17
+ * @param {string} filePath - the target file path
18
+ * @param {string} contentSnippet - first ~200 chars of the content being written
19
+ * @param {string} [sessionId] - optional session identifier for metrics
20
+ * @returns {Promise<{ file_type: string, intent: string, confidence: number, latency_ms: number, fallback_used: boolean }|null>}
21
+ */
22
+ async function classifyFileIntent(config, planningDir, filePath, contentSnippet, sessionId) {
23
+ if (!config.enabled || !config.features.file_intent_classification) return null;
24
+ if (isDisabled('file-intent', config.advanced.disable_after_failures)) return null;
25
+
26
+ const snippet = contentSnippet.length > 800 ? contentSnippet.slice(0, 800) : contentSnippet;
27
+
28
+ const prompt =
29
+ 'Classify this file write operation. Based on the file path and content snippet, determine: (1) file_type: plan (PLAN.md, ROADMAP.md, planning docs), state (STATE.md, status tracking), code (source code, scripts), test (test files), config (JSON/YAML config, package.json), docs (README, documentation), template (templates, EJS), other. (2) intent: create (new file), update (modify existing), fix (bug fix), refactor (restructure), delete (removing content). Respond with JSON: {"file_type": "<one of 8>", "intent": "<one of 5>", "confidence": 0.0-1.0}\n\nPath: ' +
30
+ filePath + '\nContent snippet:\n' + snippet;
31
+
32
+ try {
33
+ const result = await route(config, prompt, 'file-intent', (logprobs) =>
34
+ complete(config, prompt, 'file-intent', { logprobs })
35
+ );
36
+ if (result === null) return null;
37
+ const parsed = tryParseJSON(result.content);
38
+ if (!parsed.ok) return null;
39
+
40
+ const fileType = VALID_FILE_TYPES.includes(parsed.data.file_type)
41
+ ? parsed.data.file_type
42
+ : 'other';
43
+ const intent = VALID_INTENTS.includes(parsed.data.intent)
44
+ ? parsed.data.intent
45
+ : 'update';
46
+
47
+ const metricEntry = {
48
+ session_id: sessionId || 'unknown',
49
+ timestamp: new Date().toISOString(),
50
+ operation: 'file-intent',
51
+ model: config.model,
52
+ latency_ms: result.latency_ms,
53
+ tokens_used_local: result.tokens,
54
+ tokens_saved_frontier: 150,
55
+ result: fileType + '/' + intent,
56
+ fallback_used: false,
57
+ confidence: parsed.data.confidence || 0.9
58
+ };
59
+ logMetric(planningDir, metricEntry);
60
+
61
+ return {
62
+ file_type: fileType,
63
+ intent,
64
+ confidence: parsed.data.confidence || 0.9,
65
+ latency_ms: result.latency_ms,
66
+ fallback_used: false
67
+ };
68
+ } catch (_) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ module.exports = { classifyFileIntent, VALID_FILE_TYPES, VALID_INTENTS };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const { complete, tryParseJSON, isDisabled } = require('../client');
4
+ const { logMetric } = require('../metrics');
5
+ const { route } = require('../router');
6
+
7
+ const VALID_CATEGORIES = ['assertion', 'timeout', 'import', 'syntax', 'environment', 'runtime', 'unknown'];
8
+
9
+ /**
10
+ * Triages test failure output into a category using the local LLM.
11
+ * Classifies the failure type and extracts a file hint when possible,
12
+ * saving the frontier model from parsing raw test output.
13
+ *
14
+ * @param {object} config - resolved local_llm config block
15
+ * @param {string} planningDir - path to the .planning directory
16
+ * @param {string} testOutput - stderr/stdout from the test run (truncated by caller)
17
+ * @param {string} [testRunner] - optional runner identifier (jest, vitest, pytest, etc.)
18
+ * @param {string} [sessionId] - optional session identifier for metrics
19
+ * @returns {Promise<{ category: string, file_hint: string|null, confidence: number, latency_ms: number, fallback_used: boolean }|null>}
20
+ */
21
+ async function triageTestOutput(config, planningDir, testOutput, testRunner, sessionId) {
22
+ if (!config.enabled || !config.features.test_triage) return null;
23
+ if (isDisabled('test-triage', config.advanced.disable_after_failures)) return null;
24
+
25
+ const maxChars = (config.advanced.max_input_tokens || 1024) * 4;
26
+ const truncated = testOutput.length > maxChars ? testOutput.slice(0, maxChars) : testOutput;
27
+
28
+ const runnerHint = testRunner ? '\nTest runner: ' + testRunner : '';
29
+
30
+ const prompt =
31
+ 'Classify this test failure output into one category. Categories: assertion (expect/assert failed), timeout (test or operation timed out), import (module not found or import error), syntax (parse error or syntax issue), environment (missing env var, port conflict, permissions), runtime (uncaught exception, null reference, type error), unknown (none of the above). Also extract the most likely failing file and line if visible. Respond with JSON: {"category": "<one of 7>", "file_hint": "path:line or null", "confidence": 0.0-1.0}' +
32
+ runnerHint + '\n\nTest output:\n' + truncated;
33
+
34
+ try {
35
+ const result = await route(config, prompt, 'test-triage', (logprobs) =>
36
+ complete(config, prompt, 'test-triage', { logprobs })
37
+ );
38
+ if (result === null) return null;
39
+ const parsed = tryParseJSON(result.content);
40
+ if (!parsed.ok) return null;
41
+
42
+ const category = VALID_CATEGORIES.includes(parsed.data.category)
43
+ ? parsed.data.category
44
+ : 'unknown';
45
+
46
+ const metricEntry = {
47
+ session_id: sessionId || 'unknown',
48
+ timestamp: new Date().toISOString(),
49
+ operation: 'test-triage',
50
+ model: config.model,
51
+ latency_ms: result.latency_ms,
52
+ tokens_used_local: result.tokens,
53
+ tokens_saved_frontier: 250,
54
+ result: category,
55
+ fallback_used: false,
56
+ confidence: parsed.data.confidence || 0.9
57
+ };
58
+ logMetric(planningDir, metricEntry);
59
+
60
+ return {
61
+ category,
62
+ file_hint: parsed.data.file_hint || null,
63
+ confidence: parsed.data.confidence || 0.9,
64
+ latency_ms: result.latency_ms,
65
+ fallback_used: false
66
+ };
67
+ } catch (_) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ module.exports = { triageTestOutput, VALID_CATEGORIES };
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse hook for Bash: Triages test failure output using the local LLM.
5
+ *
6
+ * When a Bash command that looks like a test invocation exits with a non-zero
7
+ * exit code, this hook sends the output to the local LLM for classification.
8
+ * The triage result is returned as advisory context to help the frontier model
9
+ * focus on the right failure category without re-parsing raw test output.
10
+ *
11
+ * Exit codes:
12
+ * 0 = always (PostToolUse hook, never blocks)
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { logHook } = require('./hook-logger');
20
+ const { resolveConfig } = require('./local-llm/health');
21
+ const { triageTestOutput } = require('./local-llm/operations/triage-test-output');
22
+
23
+ const TEST_COMMAND_PATTERNS = [
24
+ /\bnpm\s+test\b/,
25
+ /\bnpx\s+jest\b/,
26
+ /\bnpx\s+vitest\b/,
27
+ /\bpytest\b/,
28
+ /\bmocha\b/,
29
+ /\bjest\b/,
30
+ /\bvitest\b/,
31
+ /\bcargo\s+test\b/,
32
+ /\bgo\s+test\b/,
33
+ /\bnpm\s+run\s+test/
34
+ ];
35
+
36
+ /**
37
+ * Detect the test runner from the command string.
38
+ * @param {string} command
39
+ * @returns {string|null}
40
+ */
41
+ function detectTestRunner(command) {
42
+ if (/jest/i.test(command)) return 'jest';
43
+ if (/vitest/i.test(command)) return 'vitest';
44
+ if (/pytest/i.test(command)) return 'pytest';
45
+ if (/mocha/i.test(command)) return 'mocha';
46
+ if (/cargo\s+test/i.test(command)) return 'cargo';
47
+ if (/go\s+test/i.test(command)) return 'go';
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Load and resolve the local_llm config block from .planning/config.json.
53
+ */
54
+ function loadLocalLlmConfig(cwd) {
55
+ try {
56
+ const configPath = path.join(cwd || process.cwd(), '.planning', 'config.json');
57
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
58
+ return resolveConfig(parsed.local_llm);
59
+ } catch (_e) {
60
+ return resolveConfig(undefined);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check if Bash output contains test failure and triage it.
66
+ * @param {object} data - parsed hook data
67
+ * @returns {Promise<{output: object}|null>}
68
+ */
69
+ async function checkTestTriage(data) {
70
+ const command = data.tool_input?.command || '';
71
+ const toolOutput = data.tool_output || '';
72
+ const exitCode = data.tool_exit_code;
73
+
74
+ // Only triage test commands that failed
75
+ if (exitCode === 0 || exitCode === undefined) return null;
76
+ if (!TEST_COMMAND_PATTERNS.some(p => p.test(command))) return null;
77
+ if (!toolOutput || toolOutput.length < 20) return null;
78
+
79
+ const cwd = process.cwd();
80
+ const llmConfig = loadLocalLlmConfig(cwd);
81
+ const planningDir = path.join(cwd, '.planning');
82
+ const testRunner = detectTestRunner(command);
83
+
84
+ // Truncate to last 2000 chars — test failures are usually at the end
85
+ const tail = toolOutput.length > 2000 ? toolOutput.slice(-2000) : toolOutput;
86
+
87
+ try {
88
+ const llmResult = await triageTestOutput(llmConfig, planningDir, tail, testRunner, data.session_id);
89
+ if (llmResult && llmResult.category && llmResult.category !== 'unknown') {
90
+ logHook('post-bash-triage', 'PostToolUse', 'triage', {
91
+ category: llmResult.category,
92
+ file_hint: llmResult.file_hint,
93
+ runner: testRunner
94
+ });
95
+
96
+ let msg = `[pbr] Test failure triage: ${llmResult.category}`;
97
+ if (llmResult.file_hint) {
98
+ msg += ` (likely: ${llmResult.file_hint})`;
99
+ }
100
+ msg += ` (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`;
101
+
102
+ return { output: { additionalContext: msg } };
103
+ }
104
+ } catch (_llmErr) {
105
+ // Never propagate LLM errors
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function main() {
112
+ let input = '';
113
+
114
+ process.stdin.setEncoding('utf8');
115
+ process.stdin.on('data', (chunk) => { input += chunk; });
116
+ process.stdin.on('end', async () => {
117
+ try {
118
+ const data = JSON.parse(input);
119
+ const result = await checkTestTriage(data);
120
+ if (result) {
121
+ process.stdout.write(JSON.stringify(result.output));
122
+ }
123
+ process.exit(0);
124
+ } catch (_e) {
125
+ // Don't block on errors
126
+ process.exit(0);
127
+ }
128
+ });
129
+ }
130
+
131
+ module.exports = { checkTestTriage, detectTestRunner };
132
+ if (require.main === module || process.argv[1] === __filename) { main(); }
@@ -18,10 +18,14 @@
18
18
  * 0 = always (PostToolUse hooks are advisory)
19
19
  */
20
20
 
21
+ const fs = require('fs');
22
+ const path = require('path');
21
23
  const { checkPlanWrite, checkStateWrite } = require('./check-plan-format');
22
24
  const { checkSync } = require('./check-roadmap-sync');
23
25
  const { checkStateSync } = require('./check-state-sync');
24
26
  const { checkQuality } = require('./post-write-quality');
27
+ const { resolveConfig } = require('./local-llm/health');
28
+ const { classifyFileIntent } = require('./local-llm/operations/classify-file-intent');
25
29
 
26
30
  // Conditionally import validateRoadmap (may not exist yet if PLAN-01 hasn't landed)
27
31
  let validateRoadmap;
@@ -117,6 +121,46 @@ function main() {
117
121
  process.exit(0);
118
122
  }
119
123
 
124
+ // LLM file intent classification — advisory enrichment for non-planning files
125
+ // Skipped for .planning/ files (already handled by plan format / state checks above)
126
+ const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
127
+ const normalizedPath = filePath.replace(/\\/g, '/');
128
+ if (filePath && !normalizedPath.includes('.planning/') && !normalizedPath.includes('.planning\\')) {
129
+ try {
130
+ const cwd = process.cwd();
131
+ const planningDir = path.join(cwd, '.planning');
132
+ const llmConfig = (() => {
133
+ try {
134
+ const configPath = path.join(planningDir, 'config.json');
135
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
136
+ return resolveConfig(parsed.local_llm);
137
+ } catch (_e) {
138
+ return resolveConfig(undefined);
139
+ }
140
+ })();
141
+
142
+ let contentSnippet = '';
143
+ try {
144
+ const content = data.tool_input?.content || data.tool_input?.new_string || '';
145
+ contentSnippet = content.slice(0, 400);
146
+ } catch (_e) {
147
+ // No content available
148
+ }
149
+
150
+ if (contentSnippet) {
151
+ const llmResult = await classifyFileIntent(llmConfig, planningDir, filePath, contentSnippet, data.session_id);
152
+ if (llmResult && llmResult.file_type) {
153
+ process.stdout.write(JSON.stringify({
154
+ additionalContext: `[pbr] File classified: ${llmResult.file_type}/${llmResult.intent} (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`
155
+ }));
156
+ process.exit(0);
157
+ }
158
+ }
159
+ } catch (_llmErr) {
160
+ // Never propagate LLM errors
161
+ }
162
+ }
163
+
120
164
  process.exit(0);
121
165
  } catch (_e) {
122
166
  // Don't block on parse errors
@@ -48,14 +48,14 @@
48
48
 
49
49
  const { logHook } = require('./hook-logger');
50
50
  const { checkDangerous } = require('./check-dangerous-commands');
51
- const { checkCommit } = require('./validate-commit');
51
+ const { checkCommit, enrichCommitLlm } = require('./validate-commit');
52
52
 
53
53
  function main() {
54
54
  let input = '';
55
55
 
56
56
  process.stdin.setEncoding('utf8');
57
57
  process.stdin.on('data', (chunk) => { input += chunk; });
58
- process.stdin.on('end', () => {
58
+ process.stdin.on('end', async () => {
59
59
  try {
60
60
  const data = JSON.parse(input);
61
61
 
@@ -77,9 +77,9 @@ function main() {
77
77
 
78
78
  // Soft warnings for risky-but-allowed commands
79
79
  const command = data.tool_input?.command || '';
80
- if (command) {
81
- const warnings = [];
80
+ const warnings = [];
82
81
 
82
+ if (command) {
83
83
  // Warn about npm publish / deploy commands
84
84
  if (/\bnpm\s+publish\b/.test(command)) {
85
85
  warnings.push('npm publish detected — ensure version is correct before publishing');
@@ -95,14 +95,20 @@ function main() {
95
95
  if (/\b(DROP|TRUNCATE|DELETE\s+FROM|ALTER\s+TABLE)\b/i.test(command)) {
96
96
  warnings.push('destructive database operation (DROP/TRUNCATE/DELETE/ALTER) — verify correct database is targeted and a backup exists');
97
97
  }
98
+ }
98
99
 
99
- if (warnings.length > 0) {
100
- process.stdout.write(JSON.stringify({
101
- decision: 'allow',
102
- additionalContext: `[pbr] Advisory: ${warnings.join('; ')}.`
103
- }));
104
- process.exit(0);
105
- }
100
+ // LLM commit semantic classification — advisory only
101
+ const llmAdvisory = await enrichCommitLlm(data);
102
+ if (llmAdvisory) {
103
+ warnings.push(llmAdvisory);
104
+ }
105
+
106
+ if (warnings.length > 0) {
107
+ process.stdout.write(JSON.stringify({
108
+ decision: 'allow',
109
+ additionalContext: `[pbr] Advisory: ${warnings.join('; ')}.`
110
+ }));
111
+ process.exit(0);
106
112
  }
107
113
 
108
114
  process.exit(0);
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const cp = require('child_process');
16
16
  const { logHook } = require('./hook-logger');
17
17
  const { configLoad } = require('./pbr-tools');
18
+ const llmMetricsModule = require('./local-llm/metrics');
18
19
 
19
20
  // ANSI color codes
20
21
  const c = {
@@ -36,7 +37,7 @@ const c = {
36
37
 
37
38
  // Default status_line config — works out of the box with zero config
38
39
  const DEFAULTS = {
39
- sections: ['phase', 'plan', 'status', 'git', 'context'],
40
+ sections: ['phase', 'plan', 'status', 'git', 'context', 'llm'],
40
41
  brand_text: '\u25C6 Plan-Build-Run',
41
42
  max_status_length: 50,
42
43
  context_bar: {
@@ -170,6 +171,17 @@ function formatDuration(ms) {
170
171
  return `${minutes}m`;
171
172
  }
172
173
 
174
+ /**
175
+ * Format a token count with K/M suffixes for compact display.
176
+ * @param {number} tokens
177
+ * @returns {string}
178
+ */
179
+ function formatTokens(tokens) {
180
+ if (tokens >= 1_000_000) return (tokens / 1_000_000).toFixed(1) + 'M';
181
+ if (tokens >= 1_000) return (tokens / 1_000).toFixed(1) + 'K';
182
+ return String(tokens);
183
+ }
184
+
173
185
  function main() {
174
186
  const stdinData = readStdin();
175
187
  const cwd = process.cwd();
@@ -184,7 +196,7 @@ function main() {
184
196
  const slConfig = loadStatusLineConfig(planningDir);
185
197
  const content = fs.readFileSync(stateFile, 'utf8');
186
198
  const ctxPercent = getContextPercent(stdinData);
187
- const status = buildStatusLine(content, ctxPercent, slConfig, stdinData);
199
+ const status = buildStatusLine(content, ctxPercent, slConfig, stdinData, planningDir);
188
200
 
189
201
  if (status) {
190
202
  process.stdout.write(status);
@@ -214,7 +226,7 @@ function parseFrontmatter(content) {
214
226
  return result;
215
227
  }
216
228
 
217
- function buildStatusLine(content, ctxPercent, cfg, stdinData) {
229
+ function buildStatusLine(content, ctxPercent, cfg, stdinData, planningDir) {
218
230
  const config = cfg || DEFAULTS;
219
231
  const sections = config.sections || DEFAULTS.sections;
220
232
  const brandText = config.brand_text || DEFAULTS.brand_text;
@@ -314,8 +326,41 @@ function buildStatusLine(content, ctxPercent, cfg, stdinData) {
314
326
 
315
327
  if (parts.length === 0) return null;
316
328
 
317
- return parts.join(` ${c.dim}\u2502${c.reset} `);
329
+ let output = parts.join(` ${c.dim}\u2502${c.reset} `);
330
+
331
+ // LLM offload section — renders on a second line below the main status
332
+ // Shows session stats + lifetime total when both are available
333
+ if (sections.includes('llm') && planningDir) {
334
+ try {
335
+ const lifetime = llmMetricsModule.computeLifetimeMetrics(planningDir);
336
+ if (lifetime && lifetime.total_calls > 0) {
337
+ // Try to get session-scoped metrics using duration from stdin
338
+ let sessionPart = '';
339
+ const durationMs = sd.cost && sd.cost.total_duration_ms;
340
+ if (durationMs != null && durationMs > 0) {
341
+ const sessionStart = new Date(Date.now() - durationMs);
342
+ const sessionEntries = llmMetricsModule.readSessionMetrics(planningDir, sessionStart);
343
+ const session = llmMetricsModule.summarizeMetrics(sessionEntries);
344
+ if (session.total_calls > 0) {
345
+ sessionPart = `${c.dim}${session.total_calls} calls${c.reset} ${c.dim}\u00B7${c.reset} ${c.green}${formatTokens(session.tokens_saved)} saved${c.reset}`;
346
+ }
347
+ }
348
+
349
+ const lifetimePart = `${c.dim}${formatTokens(lifetime.tokens_saved)} lifetime${c.reset}`;
350
+
351
+ if (sessionPart) {
352
+ output += `\n${c.green}Local LLM${c.reset} ${sessionPart} ${c.dim}\u2502${c.reset} ${lifetimePart}`;
353
+ } else {
354
+ output += `\n${c.green}Local LLM${c.reset} ${c.dim}${lifetime.total_calls} calls${c.reset} ${c.dim}\u00B7${c.reset} ${c.green}${formatTokens(lifetime.tokens_saved)} saved${c.reset}`;
355
+ }
356
+ }
357
+ } catch (_e) {
358
+ // No metrics available — skip silently
359
+ }
360
+ }
361
+
362
+ return output;
318
363
  }
319
364
 
320
365
  if (require.main === module || process.argv[1] === __filename) { main(); }
321
- module.exports = { buildStatusLine, buildContextBar, getContextPercent, getGitInfo, formatDuration, loadStatusLineConfig, parseFrontmatter, DEFAULTS };
366
+ module.exports = { buildStatusLine, buildContextBar, getContextPercent, getGitInfo, formatDuration, formatTokens, loadStatusLineConfig, parseFrontmatter, DEFAULTS };
@@ -17,10 +17,13 @@
17
17
  * 2 = invalid commit message format (blocks the tool)
18
18
  */
19
19
 
20
+ const fs = require('fs');
20
21
  const path = require('path');
21
22
  const { execSync } = require('child_process');
22
23
  const { logHook } = require('./hook-logger');
23
24
  const { logEvent } = require('./event-logger');
25
+ const { resolveConfig } = require('./local-llm/health');
26
+ const { classifyCommit } = require('./local-llm/operations/classify-commit');
24
27
 
25
28
  const VALID_TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'wip'];
26
29
 
@@ -149,12 +152,64 @@ function checkCommit(data) {
149
152
  return null;
150
153
  }
151
154
 
155
+ /**
156
+ * Load and resolve the local_llm config block from .planning/config.json.
157
+ * Returns a resolved config (always safe to use — disabled by default on error).
158
+ */
159
+ function loadLocalLlmConfig(cwd) {
160
+ try {
161
+ const configPath = path.join(cwd || process.cwd(), '.planning', 'config.json');
162
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
163
+ return resolveConfig(parsed.local_llm);
164
+ } catch (_e) {
165
+ return resolveConfig(undefined);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Async LLM enrichment for commit messages. Returns an advisory string or null.
171
+ * Called after checkCommit passes (valid format) to provide semantic classification.
172
+ *
173
+ * @param {object} data - parsed hook data
174
+ * @returns {Promise<string|null>}
175
+ */
176
+ async function enrichCommitLlm(data) {
177
+ try {
178
+ const command = data.tool_input?.command || '';
179
+ const message = extractCommitMessage(command);
180
+ if (!message) return null;
181
+
182
+ const cwd = process.cwd();
183
+ const llmConfig = loadLocalLlmConfig(cwd);
184
+ const planningDir = path.join(cwd, '.planning');
185
+
186
+ // Get staged files for scope validation
187
+ let stagedFiles = [];
188
+ try {
189
+ const output = execSync('git diff --cached --name-only', { encoding: 'utf8' });
190
+ stagedFiles = output.trim().split('\n').filter(Boolean);
191
+ } catch (_e) {
192
+ // Not in a git repo — skip staged files context
193
+ }
194
+
195
+ const llmResult = await classifyCommit(llmConfig, planningDir, message, stagedFiles, data.session_id);
196
+ if (llmResult && llmResult.classification !== 'correct') {
197
+ return 'LLM commit advisory: ' + llmResult.classification +
198
+ ' (confidence: ' + (llmResult.confidence * 100).toFixed(0) + '%)';
199
+ }
200
+ return null;
201
+ } catch (_llmErr) {
202
+ // Never propagate LLM errors
203
+ return null;
204
+ }
205
+ }
206
+
152
207
  function main() {
153
208
  let input = '';
154
209
 
155
210
  process.stdin.setEncoding('utf8');
156
211
  process.stdin.on('data', (chunk) => { input += chunk; });
157
- process.stdin.on('end', () => {
212
+ process.stdin.on('end', async () => {
158
213
  try {
159
214
  const data = JSON.parse(input);
160
215
  const result = checkCommit(data);
@@ -162,6 +217,15 @@ function main() {
162
217
  process.stdout.write(JSON.stringify(result.output));
163
218
  process.exit(result.exitCode);
164
219
  }
220
+
221
+ // LLM semantic classification — advisory only (after format validation passes)
222
+ const llmAdvisory = await enrichCommitLlm(data);
223
+ if (llmAdvisory) {
224
+ process.stdout.write(JSON.stringify({
225
+ additionalContext: '[pbr] ' + llmAdvisory
226
+ }));
227
+ }
228
+
165
229
  process.exit(0);
166
230
  } catch (_e) {
167
231
  // Parse error - don't block
@@ -197,5 +261,5 @@ function extractCommitMessage(command) {
197
261
  return null;
198
262
  }
199
263
 
200
- module.exports = { checkCommit };
264
+ module.exports = { checkCommit, enrichCommitLlm };
201
265
  if (require.main === module || process.argv[1] === __filename) { main(); }