@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.
- package/CHANGELOG.md +51 -0
- package/README.md +62 -13
- package/dashboard/package.json +1 -1
- package/dashboard/public/css/layout.css +128 -21
- package/dashboard/public/css/status-colors.css +14 -2
- package/dashboard/public/css/tokens.css +36 -0
- package/dashboard/src/middleware/current-phase.js +2 -1
- package/dashboard/src/routes/events.routes.js +49 -0
- package/dashboard/src/routes/pages.routes.js +250 -1
- package/dashboard/src/services/config.service.js +140 -0
- package/dashboard/src/services/dashboard.service.js +156 -11
- package/dashboard/src/services/log.service.js +105 -0
- package/dashboard/src/services/notes.service.js +16 -0
- package/dashboard/src/services/phase.service.js +58 -9
- package/dashboard/src/services/requirements.service.js +130 -0
- package/dashboard/src/services/research.service.js +137 -0
- package/dashboard/src/services/todo.service.js +30 -0
- package/dashboard/src/views/config.ejs +5 -0
- package/dashboard/src/views/logs.ejs +3 -0
- package/dashboard/src/views/note-detail.ejs +3 -0
- package/dashboard/src/views/partials/activity-feed.ejs +12 -0
- package/dashboard/src/views/partials/config-content.ejs +196 -0
- package/dashboard/src/views/partials/dashboard-content.ejs +71 -46
- package/dashboard/src/views/partials/log-entries-content.ejs +17 -0
- package/dashboard/src/views/partials/logs-content.ejs +131 -0
- package/dashboard/src/views/partials/note-detail-content.ejs +22 -0
- package/dashboard/src/views/partials/notes-content.ejs +7 -1
- package/dashboard/src/views/partials/phase-content.ejs +181 -146
- package/dashboard/src/views/partials/phase-timeline.ejs +16 -0
- package/dashboard/src/views/partials/requirements-content.ejs +44 -0
- package/dashboard/src/views/partials/research-content.ejs +49 -0
- package/dashboard/src/views/partials/research-detail-content.ejs +23 -0
- package/dashboard/src/views/partials/sidebar.ejs +63 -26
- package/dashboard/src/views/partials/todos-done-content.ejs +44 -0
- package/dashboard/src/views/requirements.ejs +3 -0
- package/dashboard/src/views/research-detail.ejs +3 -0
- package/dashboard/src/views/research.ejs +3 -0
- package/dashboard/src/views/todos-done.ejs +3 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/agents/dev-sync.agent.md +114 -0
- package/plugins/copilot-pbr/hooks/hooks.json +12 -0
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/dev-sync.md +113 -0
- package/plugins/cursor-pbr/hooks/hooks.json +10 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/dev-sync.md +120 -0
- package/plugins/pbr/hooks/hooks.json +10 -0
- package/plugins/pbr/scripts/config-schema.json +4 -1
- package/plugins/pbr/scripts/local-llm/health.js +4 -1
- package/plugins/pbr/scripts/local-llm/operations/classify-commit.js +68 -0
- package/plugins/pbr/scripts/local-llm/operations/classify-file-intent.js +73 -0
- package/plugins/pbr/scripts/local-llm/operations/triage-test-output.js +72 -0
- package/plugins/pbr/scripts/post-bash-triage.js +132 -0
- package/plugins/pbr/scripts/post-write-dispatch.js +44 -0
- package/plugins/pbr/scripts/pre-bash-dispatch.js +17 -11
- package/plugins/pbr/scripts/status-line.js +50 -5
- 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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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(); }
|