@sienklogic/plan-build-run 2.30.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 +14 -0
- package/dashboard/public/css/timeline.css +240 -0
- package/dashboard/src/components/Layout.tsx +3 -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 +2 -0
- package/dashboard/src/routes/timeline.routes.tsx +50 -0
- package/dashboard/src/services/analytics.service.d.ts +24 -0
- package/dashboard/src/services/local-llm-metrics.service.d.ts +26 -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,44 @@
|
|
|
1
|
+
import { ConfigEditor } from './ConfigEditor';
|
|
2
|
+
|
|
3
|
+
interface SettingsPageProps {
|
|
4
|
+
config: object;
|
|
5
|
+
activeTab?: 'config' | 'logs';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SettingsPage({ config, activeTab }: SettingsPageProps) {
|
|
9
|
+
const initialTab = activeTab || 'config';
|
|
10
|
+
return (
|
|
11
|
+
<div class="settings" x-data={`{ tab: '${initialTab}' }`}>
|
|
12
|
+
<h1 class="page-title">Settings</h1>
|
|
13
|
+
|
|
14
|
+
<div class="settings-tabs" role="tablist">
|
|
15
|
+
<button
|
|
16
|
+
role="tab"
|
|
17
|
+
class="tab-btn"
|
|
18
|
+
x-on:click="tab = 'config'"
|
|
19
|
+
x-bind:class="{ active: tab === 'config' }"
|
|
20
|
+
x-bind:aria-selected="tab === 'config'"
|
|
21
|
+
>
|
|
22
|
+
Config
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
role="tab"
|
|
26
|
+
class="tab-btn"
|
|
27
|
+
x-on:click="tab = 'logs'"
|
|
28
|
+
x-bind:class="{ active: tab === 'logs' }"
|
|
29
|
+
x-bind:aria-selected="tab === 'logs'"
|
|
30
|
+
>
|
|
31
|
+
Logs
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div x-show="tab === 'config'">
|
|
36
|
+
<ConfigEditor config={config} />
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div x-show="tab === 'logs'" x-cloak>
|
|
40
|
+
<p>Log Viewer — <a href="/settings/logs">Open Log Viewer</a></p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
interface AnalyticsProps {
|
|
2
|
+
analytics: any;
|
|
3
|
+
llmMetrics: any | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function AnalyticsPanelFragment({ analytics, llmMetrics }: AnalyticsProps) {
|
|
7
|
+
const summary = analytics?.summary ?? {};
|
|
8
|
+
const phases: any[] = analytics?.phases ?? [];
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div class="analytics-panel">
|
|
12
|
+
{/* Git & Phase stats */}
|
|
13
|
+
<section class="analytics-panel__section">
|
|
14
|
+
<h2>Git & Phase Stats</h2>
|
|
15
|
+
<div class="analytics-panel__stats">
|
|
16
|
+
<div class="analytics-panel__stat">
|
|
17
|
+
<span class="analytics-panel__stat-value">{summary.totalCommits ?? 0}</span>
|
|
18
|
+
<span class="analytics-panel__stat-label">Total Commits</span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="analytics-panel__stat">
|
|
21
|
+
<span class="analytics-panel__stat-value">{summary.totalLinesChanged ?? 0}</span>
|
|
22
|
+
<span class="analytics-panel__stat-label">Lines Changed</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="analytics-panel__stat">
|
|
25
|
+
<span class="analytics-panel__stat-value">{summary.avgDuration ?? 'N/A'}</span>
|
|
26
|
+
<span class="analytics-panel__stat-label">Avg Phase Duration</span>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="analytics-panel__stat">
|
|
29
|
+
<span class="analytics-panel__stat-value">{summary.totalPhases ?? 0}</span>
|
|
30
|
+
<span class="analytics-panel__stat-label">Phases</span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
{phases.length > 0 ? (
|
|
34
|
+
<table class="analytics-panel__table">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr>
|
|
37
|
+
<th>Phase</th>
|
|
38
|
+
<th>Name</th>
|
|
39
|
+
<th>Commits</th>
|
|
40
|
+
<th>Lines Changed</th>
|
|
41
|
+
<th>Duration</th>
|
|
42
|
+
<th>Plans</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
{phases.map((p: any) => (
|
|
47
|
+
<tr key={p.phaseId}>
|
|
48
|
+
<td>{p.phaseId}</td>
|
|
49
|
+
<td>{p.phaseName}</td>
|
|
50
|
+
<td>{p.commitCount}</td>
|
|
51
|
+
<td>{p.linesChanged}</td>
|
|
52
|
+
<td>{p.duration ?? '—'}</td>
|
|
53
|
+
<td>{p.planCount}</td>
|
|
54
|
+
</tr>
|
|
55
|
+
))}
|
|
56
|
+
</tbody>
|
|
57
|
+
</table>
|
|
58
|
+
) : (
|
|
59
|
+
<p class="analytics-panel__empty">No phase data available.</p>
|
|
60
|
+
)}
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
{/* LLM Offload stats */}
|
|
64
|
+
<section class="analytics-panel__section">
|
|
65
|
+
<h2>LLM Offload Metrics</h2>
|
|
66
|
+
{llmMetrics === null ? (
|
|
67
|
+
<p class="analytics-panel__empty">No LLM metrics data — run local model integrations to populate.</p>
|
|
68
|
+
) : (
|
|
69
|
+
<div class="analytics-panel__stats">
|
|
70
|
+
<div class="analytics-panel__stat">
|
|
71
|
+
<span class="analytics-panel__stat-value">{llmMetrics.summary.total_calls}</span>
|
|
72
|
+
<span class="analytics-panel__stat-label">Total Calls</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="analytics-panel__stat">
|
|
75
|
+
<span class="analytics-panel__stat-value">{llmMetrics.summary.fallback_rate_pct}%</span>
|
|
76
|
+
<span class="analytics-panel__stat-label">Fallback Rate</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="analytics-panel__stat">
|
|
79
|
+
<span class="analytics-panel__stat-value">{llmMetrics.summary.tokens_saved}</span>
|
|
80
|
+
<span class="analytics-panel__stat-label">Tokens Saved</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="analytics-panel__stat">
|
|
83
|
+
<span class="analytics-panel__stat-value">${llmMetrics.summary.cost_saved_usd}</span>
|
|
84
|
+
<span class="analytics-panel__stat-label">Cost Saved</span>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</section>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function AnalyticsPanel() {
|
|
94
|
+
return (
|
|
95
|
+
<div id="analytics-panel" hx-get="/api/timeline/analytics" hx-trigger="intersect once" hx-swap="innerHTML">
|
|
96
|
+
<div class="analytics-panel__loading">Loading analytics...</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function DependencyGraphFragment({ mermaidDef }: { mermaidDef: string }) {
|
|
2
|
+
return (
|
|
3
|
+
<div class="dep-graph">
|
|
4
|
+
<div
|
|
5
|
+
class="dep-graph__container"
|
|
6
|
+
id="dep-graph-render"
|
|
7
|
+
x-data="{ rendered: false }"
|
|
8
|
+
x-init={`$nextTick(() => { mermaid.render('dep-graph-svg', $el.dataset.def).then(r => { $el.innerHTML = r.svg; rendered = true; }) })`}
|
|
9
|
+
data-def={mermaidDef}
|
|
10
|
+
>
|
|
11
|
+
<div class="dep-graph__loading" x-show="!rendered">Rendering graph...</div>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function DependencyGraph() {
|
|
18
|
+
return (
|
|
19
|
+
<div id="dep-graph-panel" hx-get="/api/timeline/dependency-graph" hx-trigger="intersect once" hx-swap="innerHTML">
|
|
20
|
+
<div class="dep-graph__loading">Loading dependency graph...</div>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { AnalyticsPanel } from './AnalyticsPanel';
|
|
2
|
+
import { DependencyGraph } from './DependencyGraph';
|
|
3
|
+
|
|
4
|
+
export function TimelinePage() {
|
|
5
|
+
return (
|
|
6
|
+
<div class="timeline" x-data="{ activeSection: 'events' }">
|
|
7
|
+
<h1 class="page-title">Timeline</h1>
|
|
8
|
+
<div class="timeline__section-tabs" role="tablist">
|
|
9
|
+
<button
|
|
10
|
+
role="tab"
|
|
11
|
+
x-bind:aria-selected="activeSection === 'events'"
|
|
12
|
+
x-on:click="activeSection = 'events'"
|
|
13
|
+
class="timeline__section-tab"
|
|
14
|
+
>
|
|
15
|
+
Event Stream
|
|
16
|
+
</button>
|
|
17
|
+
<button
|
|
18
|
+
role="tab"
|
|
19
|
+
x-bind:aria-selected="activeSection === 'analytics'"
|
|
20
|
+
x-on:click="activeSection = 'analytics'"
|
|
21
|
+
class="timeline__section-tab"
|
|
22
|
+
>
|
|
23
|
+
Analytics
|
|
24
|
+
</button>
|
|
25
|
+
<button
|
|
26
|
+
role="tab"
|
|
27
|
+
x-bind:aria-selected="activeSection === 'graph'"
|
|
28
|
+
x-on:click="activeSection = 'graph'"
|
|
29
|
+
class="timeline__section-tab"
|
|
30
|
+
>
|
|
31
|
+
Dependency Graph
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div x-show="activeSection === 'events'">
|
|
36
|
+
<div class="timeline__filters">
|
|
37
|
+
<label>
|
|
38
|
+
<input type="checkbox" name="types" value="commit" />
|
|
39
|
+
Commits
|
|
40
|
+
</label>
|
|
41
|
+
<label>
|
|
42
|
+
<input type="checkbox" name="types" value="phase-transition" />
|
|
43
|
+
Phase Transitions
|
|
44
|
+
</label>
|
|
45
|
+
<label>
|
|
46
|
+
<input type="checkbox" name="types" value="todo-completion" />
|
|
47
|
+
Todo Completions
|
|
48
|
+
</label>
|
|
49
|
+
|
|
50
|
+
<select name="phase">
|
|
51
|
+
<option value="">All phases</option>
|
|
52
|
+
</select>
|
|
53
|
+
|
|
54
|
+
<input type="date" name="dateFrom" aria-label="From date" />
|
|
55
|
+
<input type="date" name="dateTo" aria-label="To date" />
|
|
56
|
+
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
hx-get="/api/timeline/events"
|
|
60
|
+
hx-include="closest .timeline__filters"
|
|
61
|
+
hx-target="#timeline-stream"
|
|
62
|
+
hx-swap="innerHTML"
|
|
63
|
+
hx-indicator="#timeline-loading"
|
|
64
|
+
>
|
|
65
|
+
Apply Filters
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div id="timeline-loading" class="timeline__loading htmx-indicator">Refreshing...</div>
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
id="timeline-stream"
|
|
73
|
+
class="timeline__stream"
|
|
74
|
+
hx-get="/api/timeline/events"
|
|
75
|
+
hx-trigger="load"
|
|
76
|
+
hx-swap="innerHTML"
|
|
77
|
+
>
|
|
78
|
+
<div class="timeline__loading">Loading events...</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div x-show="activeSection === 'analytics'">
|
|
83
|
+
<AnalyticsPanel />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div x-show="activeSection === 'graph'">
|
|
87
|
+
<DependencyGraph />
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatDate(date: Date): string {
|
|
94
|
+
const y = date.getUTCFullYear();
|
|
95
|
+
const mo = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
96
|
+
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
97
|
+
const h = String(date.getUTCHours()).padStart(2, '0');
|
|
98
|
+
const mi = String(date.getUTCMinutes()).padStart(2, '0');
|
|
99
|
+
return `${y}-${mo}-${d} ${h}:${mi}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function EventStreamFragment({ events }: { events: any[] }) {
|
|
103
|
+
if (events.length === 0) {
|
|
104
|
+
return <p class="timeline__empty">No events match the current filters.</p>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<ol class="timeline__list">
|
|
109
|
+
{events.map((event) => (
|
|
110
|
+
<li key={event.id} class={`timeline__event timeline__event--${event.type}`}>
|
|
111
|
+
<span class="timeline__event-dot" aria-hidden="true"></span>
|
|
112
|
+
<time class="timeline__event-time" datetime={event.date.toISOString()}>
|
|
113
|
+
{formatDate(event.date)}
|
|
114
|
+
</time>
|
|
115
|
+
<span class="timeline__event-type">{event.type}</span>
|
|
116
|
+
<span class="timeline__event-title">{event.title}</span>
|
|
117
|
+
{event.author && (
|
|
118
|
+
<span class="timeline__event-author">{event.author}</span>
|
|
119
|
+
)}
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ol>
|
|
123
|
+
);
|
|
124
|
+
}
|
package/dashboard/src/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { Layout } from './components/Layout';
|
|
|
8
8
|
import { indexRouter } from './routes/index.routes';
|
|
9
9
|
import { commandCenterRouter } from './routes/command-center.routes';
|
|
10
10
|
import { explorerRouter } from './routes/explorer.routes';
|
|
11
|
+
import { timelineRouter } from './routes/timeline.routes';
|
|
11
12
|
import { sseHandler } from './sse-handler';
|
|
12
13
|
import { startWatcher } from './watcher-setup';
|
|
13
14
|
import { currentPhaseMiddleware } from './middleware/current-phase';
|
|
@@ -70,6 +71,7 @@ function createApp(config: ServerConfig) {
|
|
|
70
71
|
app.route('/', indexRouter);
|
|
71
72
|
app.route('/api/command-center', commandCenterRouter);
|
|
72
73
|
app.route('/', explorerRouter);
|
|
74
|
+
app.route('/', timelineRouter);
|
|
73
75
|
|
|
74
76
|
// SSE endpoint — real streamSSE handler with multi-client broadcast
|
|
75
77
|
app.get('/api/events/stream', sseHandler);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { Layout } from '../components/Layout';
|
|
3
|
+
import { TimelinePage, EventStreamFragment } from '../components/timeline/TimelinePage';
|
|
4
|
+
import { AnalyticsPanelFragment } from '../components/timeline/AnalyticsPanel';
|
|
5
|
+
import { DependencyGraphFragment } from '../components/timeline/DependencyGraph';
|
|
6
|
+
import { getTimelineEvents } from '../services/timeline.service.js';
|
|
7
|
+
import { getProjectAnalytics } from '../services/analytics.service.js';
|
|
8
|
+
import { getLlmMetrics } from '../services/local-llm-metrics.service.js';
|
|
9
|
+
import { generateDependencyMermaid } from '../services/roadmap.service.js';
|
|
10
|
+
|
|
11
|
+
type Env = { Variables: { projectDir: string } };
|
|
12
|
+
|
|
13
|
+
const router = new Hono<Env>();
|
|
14
|
+
|
|
15
|
+
router.get('/timeline', async (c) => {
|
|
16
|
+
const isHtmx = c.req.header('HX-Request');
|
|
17
|
+
const content = <TimelinePage />;
|
|
18
|
+
if (isHtmx) return c.html(content);
|
|
19
|
+
return c.html(<Layout title="Timeline" currentView="timeline">{content}</Layout>);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
router.get('/api/timeline/events', async (c) => {
|
|
23
|
+
const projectDir = c.get('projectDir');
|
|
24
|
+
const { types, phase, dateFrom, dateTo } = c.req.query();
|
|
25
|
+
const filters = {
|
|
26
|
+
types: types ? (Array.isArray(types) ? types : [types]) : [],
|
|
27
|
+
phase: phase || '',
|
|
28
|
+
dateFrom: dateFrom || '',
|
|
29
|
+
dateTo: dateTo || ''
|
|
30
|
+
};
|
|
31
|
+
const events = await getTimelineEvents(projectDir, filters).catch(() => []);
|
|
32
|
+
return c.html(<EventStreamFragment events={events} />);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.get('/api/timeline/analytics', async (c) => {
|
|
36
|
+
const projectDir = c.get('projectDir');
|
|
37
|
+
const [analytics, llmMetrics] = await Promise.all([
|
|
38
|
+
getProjectAnalytics(projectDir).catch(() => ({ phases: [], summary: {} })),
|
|
39
|
+
getLlmMetrics(projectDir).catch(() => null)
|
|
40
|
+
]);
|
|
41
|
+
return c.html(<AnalyticsPanelFragment analytics={analytics} llmMetrics={llmMetrics} />);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.get('/api/timeline/dependency-graph', async (c) => {
|
|
45
|
+
const projectDir = c.get('projectDir');
|
|
46
|
+
const mermaidDef = await generateDependencyMermaid(projectDir).catch(() => 'graph TD\n err["Could not load graph"]');
|
|
47
|
+
return c.html(<DependencyGraphFragment mermaidDef={mermaidDef} />);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export { router as timelineRouter };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface PhaseAnalytics {
|
|
2
|
+
phaseId: string;
|
|
3
|
+
phaseName: string;
|
|
4
|
+
commitCount: number;
|
|
5
|
+
duration: string | null;
|
|
6
|
+
planCount: number;
|
|
7
|
+
linesChanged: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AnalyticsSummary {
|
|
11
|
+
totalCommits: number;
|
|
12
|
+
totalPhases: number;
|
|
13
|
+
avgDuration: string;
|
|
14
|
+
totalLinesChanged: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProjectAnalytics {
|
|
18
|
+
phases: PhaseAnalytics[];
|
|
19
|
+
summary: AnalyticsSummary;
|
|
20
|
+
warning?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export declare const cache: any;
|
|
24
|
+
export declare function getProjectAnalytics(projectDir: string): Promise<ProjectAnalytics>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface LlmMetricsSummary {
|
|
2
|
+
total_calls: number;
|
|
3
|
+
fallback_count: number;
|
|
4
|
+
fallback_rate_pct: number;
|
|
5
|
+
avg_latency_ms: number;
|
|
6
|
+
tokens_saved: number;
|
|
7
|
+
cost_saved_usd: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LlmOperationMetrics {
|
|
11
|
+
operation: string;
|
|
12
|
+
calls: number;
|
|
13
|
+
fallbacks: number;
|
|
14
|
+
tokens_saved: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LlmMetrics {
|
|
18
|
+
summary: LlmMetricsSummary;
|
|
19
|
+
byOperation: LlmOperationMetrics[];
|
|
20
|
+
baseline: {
|
|
21
|
+
hook_invocations: number;
|
|
22
|
+
estimated_frontier_tokens_without_local: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export declare function getLlmMetrics(projectDir: string): Promise<LlmMetrics | null>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TTLCache } from '../utils/cache.js';
|
|
2
|
+
|
|
3
|
+
export interface TimelineEvent {
|
|
4
|
+
type: 'commit' | 'phase-transition' | 'todo-completion';
|
|
5
|
+
id: string;
|
|
6
|
+
date: Date;
|
|
7
|
+
title: string;
|
|
8
|
+
author?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TimelineFilters {
|
|
12
|
+
types?: string[];
|
|
13
|
+
phase?: string;
|
|
14
|
+
dateFrom?: string;
|
|
15
|
+
dateTo?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const cache: TTLCache;
|
|
19
|
+
|
|
20
|
+
export function getTimelineEvents(projectDir: string, filters?: TimelineFilters): Promise<TimelineEvent[]>;
|
|
@@ -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": {
|