@sienklogic/plan-build-run 2.29.0 → 2.31.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 +32 -0
- package/dashboard/public/css/explorer.css +458 -0
- package/dashboard/public/css/timeline.css +240 -0
- package/dashboard/src/components/Layout.tsx +4 -0
- package/dashboard/src/components/explorer/ExplorerPage.tsx +57 -0
- package/dashboard/src/components/explorer/tabs/AuditsTab.tsx +29 -0
- package/dashboard/src/components/explorer/tabs/MilestonesTab.tsx +84 -0
- package/dashboard/src/components/explorer/tabs/NotesTab.tsx +32 -0
- package/dashboard/src/components/explorer/tabs/PhasesTab.tsx +204 -0
- package/dashboard/src/components/explorer/tabs/QuickTab.tsx +40 -0
- package/dashboard/src/components/explorer/tabs/RequirementsTab.tsx +45 -0
- package/dashboard/src/components/explorer/tabs/ResearchTab.tsx +47 -0
- package/dashboard/src/components/explorer/tabs/TodosTab.tsx +161 -0
- package/dashboard/src/components/settings/ConfigEditor.tsx +399 -0
- package/dashboard/src/components/settings/SettingsPage.tsx +44 -0
- package/dashboard/src/components/timeline/AnalyticsPanel.tsx +99 -0
- package/dashboard/src/components/timeline/DependencyGraph.tsx +23 -0
- package/dashboard/src/components/timeline/TimelinePage.tsx +124 -0
- package/dashboard/src/index.tsx +4 -0
- package/dashboard/src/routes/explorer.routes.tsx +226 -0
- package/dashboard/src/routes/timeline.routes.tsx +50 -0
- package/dashboard/src/services/analytics.service.d.ts +24 -0
- package/dashboard/src/services/audit.service.d.ts +14 -0
- package/dashboard/src/services/local-llm-metrics.service.d.ts +26 -0
- package/dashboard/src/services/milestone.service.d.ts +35 -0
- package/dashboard/src/services/notes.service.d.ts +14 -0
- package/dashboard/src/services/phase.service.d.ts +59 -0
- package/dashboard/src/services/quick.service.d.ts +15 -0
- package/dashboard/src/services/requirements.service.d.ts +19 -0
- package/dashboard/src/services/research.service.d.ts +27 -0
- package/dashboard/src/services/roadmap.service.d.ts +23 -0
- package/dashboard/src/services/timeline.service.d.ts +20 -0
- package/dashboard/src/services/timeline.service.js +174 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/config-schema.json +12 -0
- package/plugins/pbr/scripts/context-budget-check.js +4 -1
- package/plugins/pbr/scripts/enforce-pbr-workflow.js +218 -0
- package/plugins/pbr/scripts/pre-bash-dispatch.js +7 -0
- package/plugins/pbr/scripts/pre-write-dispatch.js +30 -18
- package/plugins/pbr/scripts/progress-tracker.js +1 -1
- package/plugins/pbr/scripts/validate-task.js +6 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { TTLCache } from '../utils/cache.js';
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCb);
|
|
8
|
+
|
|
9
|
+
export const cache = new TTLCache(30_000); // 30s TTL
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a git command in the given directory, returning stdout.
|
|
13
|
+
* Returns empty string on failure.
|
|
14
|
+
*/
|
|
15
|
+
async function git(projectDir, args) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFile('git', args, {
|
|
18
|
+
cwd: projectDir,
|
|
19
|
+
maxBuffer: 10 * 1024 * 1024
|
|
20
|
+
});
|
|
21
|
+
return stdout;
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Aggregate timeline events from git commits, todo completions, and STATE.md
|
|
29
|
+
* phase transitions into a unified chronological array.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} projectDir - Absolute path to the project root
|
|
32
|
+
* @param {{ types?: string[], phase?: string, dateFrom?: string, dateTo?: string }} filters
|
|
33
|
+
* @returns {Promise<Array>}
|
|
34
|
+
*/
|
|
35
|
+
export async function getTimelineEvents(projectDir, filters = {}) {
|
|
36
|
+
const cacheKey = `timeline:${projectDir}:${JSON.stringify(filters)}`;
|
|
37
|
+
const cached = cache.get(cacheKey);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : null;
|
|
41
|
+
const dateTo = filters.dateTo ? new Date(filters.dateTo + 'T23:59:59Z') : null;
|
|
42
|
+
|
|
43
|
+
// --- Git commits ---
|
|
44
|
+
let commitEvents = [];
|
|
45
|
+
const logOutput = await git(projectDir, [
|
|
46
|
+
'log', '--all', '--format=%H|%aI|%s|%an'
|
|
47
|
+
]);
|
|
48
|
+
if (logOutput.trim()) {
|
|
49
|
+
for (const line of logOutput.trim().split('\n')) {
|
|
50
|
+
const parts = line.split('|');
|
|
51
|
+
if (parts.length < 4) continue;
|
|
52
|
+
const [id, isoDate, subject, ...authorParts] = parts;
|
|
53
|
+
const author = authorParts.join('|');
|
|
54
|
+
const date = new Date(isoDate);
|
|
55
|
+
if (isNaN(date.getTime())) continue;
|
|
56
|
+
|
|
57
|
+
// Phase filter: keep only commits whose subject matches scope pattern for that phase
|
|
58
|
+
if (filters.phase) {
|
|
59
|
+
const phaseNum = String(filters.phase).padStart(2, '0');
|
|
60
|
+
const scopeRe = new RegExp(`\\(${phaseNum}-`);
|
|
61
|
+
if (!scopeRe.test(subject)) continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (dateFrom && date < dateFrom) continue;
|
|
65
|
+
if (dateTo && date > dateTo) continue;
|
|
66
|
+
|
|
67
|
+
commitEvents.push({ type: 'commit', id, date, title: subject, author });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Todo completions ---
|
|
72
|
+
let todoEvents = [];
|
|
73
|
+
try {
|
|
74
|
+
const doneDir = join(projectDir, '.planning', 'todos', 'done');
|
|
75
|
+
const entries = await readdir(doneDir, { withFileTypes: true });
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(join(doneDir, entry.name), 'utf-8');
|
|
80
|
+
// Parse frontmatter manually
|
|
81
|
+
const lines = raw.split(/\r?\n/);
|
|
82
|
+
if (lines[0] !== '---') continue;
|
|
83
|
+
const endIdx = lines.indexOf('---', 1);
|
|
84
|
+
if (endIdx === -1) continue;
|
|
85
|
+
const fmLines = lines.slice(1, endIdx);
|
|
86
|
+
let title = '';
|
|
87
|
+
let completedAt = '';
|
|
88
|
+
for (const fmLine of fmLines) {
|
|
89
|
+
const titleMatch = fmLine.match(/^title:\s*['"]?(.+?)['"]?\s*$/);
|
|
90
|
+
if (titleMatch) title = titleMatch[1];
|
|
91
|
+
const completedMatch = fmLine.match(/^completed_at:\s*['"]?(.+?)['"]?\s*$/);
|
|
92
|
+
if (completedMatch) completedAt = completedMatch[1];
|
|
93
|
+
}
|
|
94
|
+
if (!completedAt) continue;
|
|
95
|
+
const date = new Date(completedAt);
|
|
96
|
+
if (isNaN(date.getTime())) continue;
|
|
97
|
+
|
|
98
|
+
if (dateFrom && date < dateFrom) continue;
|
|
99
|
+
if (dateTo && date > dateTo) continue;
|
|
100
|
+
|
|
101
|
+
todoEvents.push({
|
|
102
|
+
type: 'todo-completion',
|
|
103
|
+
id: entry.name,
|
|
104
|
+
date,
|
|
105
|
+
title: title || entry.name.replace(/^\d{3}-/, '').replace(/\.md$/, '').replace(/-/g, ' ')
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
// skip unreadable files
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.code !== 'ENOENT') {
|
|
113
|
+
// Non-ENOENT errors: skip silently for robustness
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Phase transitions from STATE.md ---
|
|
118
|
+
let phaseEvents = [];
|
|
119
|
+
try {
|
|
120
|
+
const statePath = join(projectDir, '.planning', 'STATE.md');
|
|
121
|
+
const raw = await readFile(statePath, 'utf-8');
|
|
122
|
+
const lines = raw.split(/\r?\n/);
|
|
123
|
+
|
|
124
|
+
// Best-effort: extract current phase and last updated
|
|
125
|
+
let currentPhase = '';
|
|
126
|
+
let currentStatus = '';
|
|
127
|
+
let lastUpdated = '';
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const phaseMatch = line.match(/\*\*Current phase:\*\*\s*(.+)/);
|
|
131
|
+
if (phaseMatch) currentPhase = phaseMatch[1].trim();
|
|
132
|
+
|
|
133
|
+
const statusMatch = line.match(/\*\*Status:\*\*\s*(.+)/);
|
|
134
|
+
if (statusMatch) currentStatus = statusMatch[1].trim();
|
|
135
|
+
|
|
136
|
+
const updatedMatch = line.match(/\*\*Last updated:\*\*\s*(.+)/);
|
|
137
|
+
if (updatedMatch) lastUpdated = updatedMatch[1].trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (lastUpdated) {
|
|
141
|
+
const date = new Date(lastUpdated);
|
|
142
|
+
if (!isNaN(date.getTime())) {
|
|
143
|
+
const title = currentPhase
|
|
144
|
+
? `Phase ${currentPhase} — ${currentStatus || 'active'}`
|
|
145
|
+
: `Status: ${currentStatus || 'active'}`;
|
|
146
|
+
|
|
147
|
+
if ((!dateFrom || date >= dateFrom) && (!dateTo || date <= dateTo)) {
|
|
148
|
+
phaseEvents.push({
|
|
149
|
+
type: 'phase-transition',
|
|
150
|
+
id: 'state-current',
|
|
151
|
+
date,
|
|
152
|
+
title
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.code !== 'ENOENT') {
|
|
159
|
+
// Non-ENOENT: skip silently
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Merge and sort descending (newest first)
|
|
164
|
+
let events = [...commitEvents, ...todoEvents, ...phaseEvents];
|
|
165
|
+
events.sort((a, b) => b.date - a.date);
|
|
166
|
+
|
|
167
|
+
// Apply types filter
|
|
168
|
+
if (filters.types && filters.types.length > 0) {
|
|
169
|
+
events = events.filter(e => filters.types.includes(e.type));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cache.set(cacheKey, events);
|
|
173
|
+
return events;
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
3
|
"displayName": "Plan-Build-Run",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.31.0",
|
|
5
5
|
"description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "SienkLogic",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
3
|
"displayName": "Plan-Build-Run",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.31.0",
|
|
5
5
|
"description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "SienkLogic",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.0",
|
|
4
4
|
"description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SienkLogic",
|
|
@@ -254,6 +254,18 @@
|
|
|
254
254
|
},
|
|
255
255
|
"additionalProperties": false
|
|
256
256
|
},
|
|
257
|
+
"workflow": {
|
|
258
|
+
"type": "object",
|
|
259
|
+
"properties": {
|
|
260
|
+
"enforce_pbr_skills": {
|
|
261
|
+
"type": "string",
|
|
262
|
+
"enum": ["advisory", "block", "off"],
|
|
263
|
+
"default": "advisory",
|
|
264
|
+
"description": "Enforcement level for PBR workflow compliance"
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"additionalProperties": false
|
|
268
|
+
},
|
|
257
269
|
"local_llm": {
|
|
258
270
|
"type": "object",
|
|
259
271
|
"properties": {
|
|
@@ -268,7 +268,10 @@ function readConfigHighlights(planningDir) {
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
function buildRecoveryContext(activeOp, roadmapSummary, currentPlan, configHighlights, recentErrors, recentAgents) {
|
|
271
|
-
const parts = [
|
|
271
|
+
const parts = [
|
|
272
|
+
'[Post-Compaction Recovery] Context was auto-compacted. Key state preserved:',
|
|
273
|
+
'[PBR WORKFLOW REQUIRED — Route all work through PBR commands]\n- Fix a bug or small task → /pbr:quick\n- Plan a feature → /pbr:plan N\n- Build from a plan → /pbr:build N\n- Explore or research → /pbr:explore\n- Freeform request → /pbr:do\n- Do NOT write source code or spawn generic agents without an active PBR skill.\n- Use PBR agents (pbr:researcher, pbr:executor, etc.) not Explore/general-purpose.'
|
|
274
|
+
];
|
|
272
275
|
|
|
273
276
|
if (activeOp) parts.push(`Active operation: ${activeOp}`);
|
|
274
277
|
if (currentPlan) parts.push(`Current plan: ${currentPlan}`);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PBR workflow enforcement checks.
|
|
5
|
+
*
|
|
6
|
+
* Provides advisory and blocking checks that ensure PBR workflow is
|
|
7
|
+
* followed even when the user doesn't explicitly invoke /pbr:* commands.
|
|
8
|
+
*
|
|
9
|
+
* Functions:
|
|
10
|
+
* loadEnforcementConfig(planningDir) — reads config.json for enforcement level
|
|
11
|
+
* checkUnmanagedSourceWrite(data) — PreToolUse Write|Edit: warns/blocks unmanaged source writes
|
|
12
|
+
* checkNonPbrAgent(data) — PreToolUse Task: advises using pbr:* agents
|
|
13
|
+
* checkUnmanagedCommit(data) — PreToolUse Bash: advises git commits to use /pbr:quick
|
|
14
|
+
*
|
|
15
|
+
* All functions return null for pass, or { exitCode, output } for action.
|
|
16
|
+
* checkNonPbrAgent always returns advisory (exitCode 0) — never blocks Task().
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { logHook } = require('./hook-logger');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load the enforcement configuration from .planning/config.json.
|
|
27
|
+
* Also checks for .planning/.native-mode which bypasses all enforcement.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} planningDir - absolute path to the .planning directory
|
|
30
|
+
* @returns {{ level: "advisory"|"block"|"off" }}
|
|
31
|
+
*/
|
|
32
|
+
function loadEnforcementConfig(planningDir) {
|
|
33
|
+
// .native-mode bypass takes precedence over all config settings
|
|
34
|
+
const nativeModeFile = path.join(planningDir, '.native-mode');
|
|
35
|
+
if (fs.existsSync(nativeModeFile)) {
|
|
36
|
+
return { level: 'off' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const configPath = path.join(planningDir, 'config.json');
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
42
|
+
const level = parsed.workflow && parsed.workflow.enforce_pbr_skills;
|
|
43
|
+
if (level === 'advisory' || level === 'block' || level === 'off') {
|
|
44
|
+
return { level };
|
|
45
|
+
}
|
|
46
|
+
} catch (_e) {
|
|
47
|
+
// No config or parse error — use default
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { level: 'advisory' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* PreToolUse Write|Edit: warns or blocks source file writes that happen
|
|
55
|
+
* without an active PBR skill.
|
|
56
|
+
*
|
|
57
|
+
* Skip conditions:
|
|
58
|
+
* - No .planning/ directory (not a PBR project)
|
|
59
|
+
* - .planning/.active-skill exists (PBR skill is managing the session)
|
|
60
|
+
* - Target file is inside .planning/ (planning files are always OK)
|
|
61
|
+
* - Enforcement level is "off"
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} data - parsed hook input from Claude Code
|
|
64
|
+
* @returns {null|{ exitCode: number, output: Object }}
|
|
65
|
+
*/
|
|
66
|
+
function checkUnmanagedSourceWrite(data) {
|
|
67
|
+
const filePath = data.tool_input && (data.tool_input.file_path || data.tool_input.path);
|
|
68
|
+
if (!filePath) return null;
|
|
69
|
+
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
const planningDir = path.join(cwd, '.planning');
|
|
72
|
+
|
|
73
|
+
// Skip if not a PBR project
|
|
74
|
+
if (!fs.existsSync(planningDir)) return null;
|
|
75
|
+
|
|
76
|
+
// Skip if a PBR skill is active
|
|
77
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
78
|
+
if (fs.existsSync(activeSkillFile)) return null;
|
|
79
|
+
|
|
80
|
+
// Skip if writing inside .planning/
|
|
81
|
+
const normalizedFile = filePath.replace(/\\/g, '/');
|
|
82
|
+
const normalizedPlanning = planningDir.replace(/\\/g, '/');
|
|
83
|
+
if (normalizedFile.startsWith(normalizedPlanning)) return null;
|
|
84
|
+
|
|
85
|
+
// Also check with resolved symlinks (macOS /var → /private/var)
|
|
86
|
+
try {
|
|
87
|
+
const resolvedPlanning = fs.realpathSync(planningDir).replace(/\\/g, '/');
|
|
88
|
+
if (normalizedFile.startsWith(resolvedPlanning)) return null;
|
|
89
|
+
} catch (_e) { /* not resolvable */ }
|
|
90
|
+
|
|
91
|
+
const config = loadEnforcementConfig(planningDir);
|
|
92
|
+
if (config.level === 'off') return null;
|
|
93
|
+
|
|
94
|
+
const message =
|
|
95
|
+
'PBR workflow required: You are editing source code without an active PBR skill. ' +
|
|
96
|
+
'Route this work through a PBR command: /pbr:quick (small fix), /pbr:build (planned work), ' +
|
|
97
|
+
'or /pbr:do (auto-route). Set workflow.enforce_pbr_skills: off in config to disable.';
|
|
98
|
+
|
|
99
|
+
if (config.level === 'block') {
|
|
100
|
+
logHook('enforce-pbr-workflow', 'PreToolUse', 'block', { file: path.basename(filePath), level: 'block' });
|
|
101
|
+
return {
|
|
102
|
+
exitCode: 2,
|
|
103
|
+
output: { decision: 'block', reason: message }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// advisory (default)
|
|
108
|
+
logHook('enforce-pbr-workflow', 'PreToolUse', 'advisory', { file: path.basename(filePath), level: 'advisory' });
|
|
109
|
+
return {
|
|
110
|
+
exitCode: 0,
|
|
111
|
+
output: { additionalContext: '[pbr] ' + message }
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Agent type → PBR agent mapping for advisory messages.
|
|
117
|
+
*/
|
|
118
|
+
const AGENT_MAPPING = {
|
|
119
|
+
'Explore': 'pbr:researcher or pbr:codebase-mapper',
|
|
120
|
+
'general-purpose': 'pbr:general',
|
|
121
|
+
'Plan': 'pbr:planner',
|
|
122
|
+
'Bash': 'pbr:executor'
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* PreToolUse Task: advises using pbr:* agents instead of generic agents.
|
|
127
|
+
*
|
|
128
|
+
* Skip conditions:
|
|
129
|
+
* - No .planning/ directory (not a PBR project)
|
|
130
|
+
* - subagent_type starts with "pbr:" (already using PBR agent)
|
|
131
|
+
* - subagent_type is missing/empty (can't determine type)
|
|
132
|
+
* - Enforcement level is "off"
|
|
133
|
+
*
|
|
134
|
+
* NOTE: This function NEVER blocks Task() — blocking is too disruptive.
|
|
135
|
+
* It always returns an advisory (exitCode 0) or null.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} data - parsed hook input from Claude Code
|
|
138
|
+
* @returns {null|{ exitCode: 0, output: Object }}
|
|
139
|
+
*/
|
|
140
|
+
function checkNonPbrAgent(data) {
|
|
141
|
+
const subagentType = data.tool_input && data.tool_input.subagent_type;
|
|
142
|
+
if (!subagentType || typeof subagentType !== 'string') return null;
|
|
143
|
+
|
|
144
|
+
// Already using a PBR agent
|
|
145
|
+
if (subagentType.startsWith('pbr:')) return null;
|
|
146
|
+
|
|
147
|
+
const cwd = process.cwd();
|
|
148
|
+
const planningDir = path.join(cwd, '.planning');
|
|
149
|
+
|
|
150
|
+
// Skip if not a PBR project
|
|
151
|
+
if (!fs.existsSync(planningDir)) return null;
|
|
152
|
+
|
|
153
|
+
const config = loadEnforcementConfig(planningDir);
|
|
154
|
+
if (config.level === 'off') return null;
|
|
155
|
+
|
|
156
|
+
const suggestion = AGENT_MAPPING[subagentType] || 'a pbr:* agent (e.g., pbr:researcher, pbr:general, pbr:executor)';
|
|
157
|
+
const message =
|
|
158
|
+
`PBR workflow advisory: spawning generic agent "${subagentType}" without PBR routing. ` +
|
|
159
|
+
`Use ${suggestion} instead to maintain audit logging and workflow context. ` +
|
|
160
|
+
'PBR agents are auto-loaded via subagent_type — no extra setup needed.';
|
|
161
|
+
|
|
162
|
+
logHook('enforce-pbr-workflow', 'PreToolUse', 'advisory', { agentType: subagentType, suggestion });
|
|
163
|
+
return {
|
|
164
|
+
exitCode: 0,
|
|
165
|
+
output: { additionalContext: '[pbr] ' + message }
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* PreToolUse Bash: advises using /pbr:quick when git commit is run without
|
|
171
|
+
* an active PBR skill.
|
|
172
|
+
*
|
|
173
|
+
* Skip conditions:
|
|
174
|
+
* - No .planning/ directory (not a PBR project)
|
|
175
|
+
* - .planning/.active-skill exists (PBR skill is managing the session)
|
|
176
|
+
* - Command is not a git commit
|
|
177
|
+
* - Enforcement level is "off"
|
|
178
|
+
*
|
|
179
|
+
* @param {Object} data - parsed hook input from Claude Code
|
|
180
|
+
* @returns {null|{ exitCode: 0, output: Object }}
|
|
181
|
+
*/
|
|
182
|
+
function checkUnmanagedCommit(data) {
|
|
183
|
+
const command = data.tool_input && data.tool_input.command;
|
|
184
|
+
if (!command || typeof command !== 'string') return null;
|
|
185
|
+
|
|
186
|
+
// Only check git commit commands
|
|
187
|
+
if (!/\bgit\s+commit\b/.test(command)) return null;
|
|
188
|
+
|
|
189
|
+
const cwd = process.cwd();
|
|
190
|
+
const planningDir = path.join(cwd, '.planning');
|
|
191
|
+
|
|
192
|
+
// Skip if not a PBR project
|
|
193
|
+
if (!fs.existsSync(planningDir)) return null;
|
|
194
|
+
|
|
195
|
+
// Skip if a PBR skill is active
|
|
196
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
197
|
+
if (fs.existsSync(activeSkillFile)) return null;
|
|
198
|
+
|
|
199
|
+
const config = loadEnforcementConfig(planningDir);
|
|
200
|
+
if (config.level === 'off') return null;
|
|
201
|
+
|
|
202
|
+
const message =
|
|
203
|
+
'Committing without PBR tracking. Use /pbr:quick to track this work. ' +
|
|
204
|
+
'PBR quick tasks create atomic commits with proper scope and audit trail.';
|
|
205
|
+
|
|
206
|
+
logHook('enforce-pbr-workflow', 'PreToolUse', 'advisory', { check: 'unmanaged-commit' });
|
|
207
|
+
return {
|
|
208
|
+
exitCode: 0,
|
|
209
|
+
output: { additionalContext: '[pbr] ' + message }
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
loadEnforcementConfig,
|
|
215
|
+
checkUnmanagedSourceWrite,
|
|
216
|
+
checkNonPbrAgent,
|
|
217
|
+
checkUnmanagedCommit
|
|
218
|
+
};
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
const { logHook } = require('./hook-logger');
|
|
50
50
|
const { checkDangerous } = require('./check-dangerous-commands');
|
|
51
51
|
const { checkCommit, enrichCommitLlm } = require('./validate-commit');
|
|
52
|
+
const { checkUnmanagedCommit } = require('./enforce-pbr-workflow');
|
|
52
53
|
|
|
53
54
|
function main() {
|
|
54
55
|
let input = '';
|
|
@@ -97,6 +98,12 @@ function main() {
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
// Unmanaged commit advisory — warn when git commit runs without PBR skill
|
|
102
|
+
const unmanagedCommitResult = checkUnmanagedCommit(data);
|
|
103
|
+
if (unmanagedCommitResult) {
|
|
104
|
+
warnings.push(unmanagedCommitResult.output.additionalContext);
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
// LLM commit semantic classification — advisory only
|
|
101
108
|
const llmAdvisory = await enrichCommitLlm(data);
|
|
102
109
|
if (llmAdvisory) {
|
|
@@ -3,33 +3,35 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* PreToolUse dispatcher for Write|Edit hooks.
|
|
5
5
|
*
|
|
6
|
-
* Consolidates
|
|
7
|
-
*
|
|
8
|
-
* and running all checks sequentially.
|
|
6
|
+
* Consolidates all PreToolUse Write|Edit checks into a single process,
|
|
7
|
+
* reading stdin once and running all checks sequentially.
|
|
9
8
|
*
|
|
10
9
|
* ── Dispatch Order & Rationale ──────────────────────────────────
|
|
11
10
|
*
|
|
12
|
-
* 1.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. enforce-pbr-workflow — Warns or blocks source code writes that
|
|
12
|
+
* happen without an active PBR skill. Runs first because this is
|
|
13
|
+
* the most fundamental enforcement: if no PBR skill is managing
|
|
14
|
+
* the session, all other skill-specific checks are moot.
|
|
15
|
+
* Can block (exit 2) or advise (exit 0). Config-driven.
|
|
17
16
|
*
|
|
18
|
-
* 2. check-
|
|
17
|
+
* 2. check-agent-state-write — Blocks subagents from writing STATE.md
|
|
18
|
+
* directly (except pbr:general). Runs before skill-specific checks
|
|
19
|
+
* because agent isolation is a hard invariant. Can block (exit 2).
|
|
20
|
+
*
|
|
21
|
+
* 3. check-skill-workflow — Enforces planning-phase rules (e.g. no
|
|
22
|
+
* code writes during the plan phase). Can block (exit 2).
|
|
23
|
+
*
|
|
24
|
+
* 4. check-summary-gate — Blocks STATE.md status advancement
|
|
19
25
|
* (to built/verified/complete) unless a SUMMARY file exists for
|
|
20
|
-
* the current phase.
|
|
21
|
-
* appears complete but has no build receipt. Can block (exit 2).
|
|
26
|
+
* the current phase. Can block (exit 2).
|
|
22
27
|
*
|
|
23
|
-
*
|
|
24
|
-
* outside the current phase directory.
|
|
25
|
-
* we know the write is allowed by workflow rules, we need to
|
|
26
|
-
* verify it's scoped to the correct phase. Can block (exit 2)
|
|
28
|
+
* 5. check-phase-boundary — Guards against writes that target files
|
|
29
|
+
* outside the current phase directory. Can block (exit 2)
|
|
27
30
|
* or warn (exit 0 with message).
|
|
28
31
|
*
|
|
29
|
-
*
|
|
32
|
+
* 6. check-doc-sprawl — Prevents creation of new .md/.txt files
|
|
30
33
|
* outside a known allowlist (when enabled in config). Runs last
|
|
31
|
-
* because it's the most granular check
|
|
32
|
-
* documentation files, not all writes. Can block (exit 2).
|
|
34
|
+
* because it's the most granular check. Can block (exit 2).
|
|
33
35
|
*
|
|
34
36
|
* ── Short-Circuit Behavior ──────────────────────────────────────
|
|
35
37
|
*
|
|
@@ -62,6 +64,7 @@ const { checkWorkflow } = require('./check-skill-workflow');
|
|
|
62
64
|
const { checkSummaryGate } = require('./check-summary-gate');
|
|
63
65
|
const { checkBoundary } = require('./check-phase-boundary');
|
|
64
66
|
const { checkDocSprawl } = require('./check-doc-sprawl');
|
|
67
|
+
const { checkUnmanagedSourceWrite } = require('./enforce-pbr-workflow');
|
|
65
68
|
|
|
66
69
|
function main() {
|
|
67
70
|
let input = '';
|
|
@@ -72,6 +75,15 @@ function main() {
|
|
|
72
75
|
try {
|
|
73
76
|
const data = JSON.parse(input);
|
|
74
77
|
|
|
78
|
+
// Unmanaged source write check — runs first as the most fundamental
|
|
79
|
+
// workflow enforcement: warn/block when source code is edited without
|
|
80
|
+
// an active PBR skill managing the session.
|
|
81
|
+
const unmanagedResult = checkUnmanagedSourceWrite(data);
|
|
82
|
+
if (unmanagedResult) {
|
|
83
|
+
process.stdout.write(JSON.stringify(unmanagedResult.output));
|
|
84
|
+
process.exit(unmanagedResult.exitCode || 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
// Agent STATE.md write blocker — most fundamental check
|
|
76
88
|
const agentResult = checkAgentStateWrite(data);
|
|
77
89
|
if (agentResult) {
|
|
@@ -262,7 +262,7 @@ function buildContext(planningDir, stateFile) {
|
|
|
262
262
|
parts.push(`\n${hookHealth}`);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
parts.push('\
|
|
265
|
+
parts.push('\n[PBR WORKFLOW REQUIRED — Route all work through PBR commands]\n- Fix a bug or small task → /pbr:quick\n- Plan a feature → /pbr:plan N\n- Build from a plan → /pbr:build N\n- Explore or research → /pbr:explore\n- Freeform request → /pbr:do\n- Do NOT write source code or spawn generic agents without an active PBR skill.\n- Use PBR agents (pbr:researcher, pbr:executor, etc.) not Explore/general-purpose.');
|
|
266
266
|
|
|
267
267
|
return parts.join('\n');
|
|
268
268
|
}
|
|
@@ -22,6 +22,7 @@ const path = require('path');
|
|
|
22
22
|
const { logHook } = require('./hook-logger');
|
|
23
23
|
const { resolveConfig } = require('./local-llm/health');
|
|
24
24
|
const { validateTask: llmValidateTask } = require('./local-llm/operations/validate-task');
|
|
25
|
+
const { checkNonPbrAgent } = require('./enforce-pbr-workflow');
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Load and resolve the local_llm config block from .planning/config.json.
|
|
@@ -48,7 +49,9 @@ const KNOWN_AGENTS = [
|
|
|
48
49
|
'debugger',
|
|
49
50
|
'codebase-mapper',
|
|
50
51
|
'synthesizer',
|
|
51
|
-
'general'
|
|
52
|
+
'general',
|
|
53
|
+
'audit',
|
|
54
|
+
'dev-sync'
|
|
52
55
|
];
|
|
53
56
|
|
|
54
57
|
const MAX_DESCRIPTION_LENGTH = 100;
|
|
@@ -800,6 +803,8 @@ function main() {
|
|
|
800
803
|
if (debuggerWarning) warnings.push(debuggerWarning);
|
|
801
804
|
const activeSkillWarning = checkActiveSkillIntegrity(data);
|
|
802
805
|
if (activeSkillWarning) warnings.push(activeSkillWarning);
|
|
806
|
+
const nonPbrAgentResult = checkNonPbrAgent(data);
|
|
807
|
+
if (nonPbrAgentResult) warnings.push(nonPbrAgentResult.output.additionalContext);
|
|
803
808
|
|
|
804
809
|
// LLM task coherence check — advisory only
|
|
805
810
|
try {
|