@sienklogic/plan-build-run 2.31.0 → 2.32.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/settings.css +314 -0
- package/dashboard/src/components/Layout.tsx +1 -0
- package/dashboard/src/components/settings/LogEntryList.tsx +108 -0
- package/dashboard/src/components/settings/LogFileList.tsx +36 -0
- package/dashboard/src/components/settings/LogViewer.tsx +129 -0
- package/dashboard/src/components/settings/SettingsPage.tsx +1 -1
- package/dashboard/src/index.tsx +2 -0
- package/dashboard/src/routes/settings.routes.tsx +231 -0
- package/dashboard/src/services/config.service.d.ts +9 -0
- package/dashboard/src/services/log.service.d.ts +13 -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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to Plan-Build-Run will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.32.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.31.0...plan-build-run-v2.32.0) (2026-02-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **42-01:** create settings routes, CSS, wire into app, and add config.service.d.ts ([3c4f953](https://github.com/SienkLogic/plan-build-run/commit/3c4f9531de93acc4e47b4ab6a94f962972c3f6ab))
|
|
14
|
+
* **42-02:** add log viewer routes (page, entries, SSE tail) and CSS ([cbcb47c](https://github.com/SienkLogic/plan-build-run/commit/cbcb47ce31b2e06481e329bfd01597bcc644a4a8))
|
|
15
|
+
* **42-02:** add LogFileList, LogEntryList, and LogViewer components ([3101c12](https://github.com/SienkLogic/plan-build-run/commit/3101c129dbf5e6535b720188be2a49a583008303))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **42-02:** move beforeunload cleanup to addEventListener for JSX compatibility ([e582a51](https://github.com/SienkLogic/plan-build-run/commit/e582a51ef628e7596bb42e352dbf8df861d67e07))
|
|
21
|
+
|
|
8
22
|
## [2.31.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.30.0...plan-build-run-v2.31.0) (2026-02-24)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* Settings page styles */
|
|
2
|
+
|
|
3
|
+
/* Tab bar */
|
|
4
|
+
.settings-tabs {
|
|
5
|
+
display: flex;
|
|
6
|
+
gap: var(--size-2);
|
|
7
|
+
border-bottom: 2px solid var(--surface-3, #e2e8f0);
|
|
8
|
+
margin-bottom: var(--size-5);
|
|
9
|
+
padding-bottom: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Mode toggle (Form / Raw JSON) */
|
|
13
|
+
.config-mode-toggle {
|
|
14
|
+
display: flex;
|
|
15
|
+
gap: var(--size-2);
|
|
16
|
+
margin-bottom: var(--size-4);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Tab buttons — shared by settings-tabs and config-mode-toggle */
|
|
20
|
+
.tab-btn {
|
|
21
|
+
background: none;
|
|
22
|
+
border: none;
|
|
23
|
+
border-bottom: 2px solid transparent;
|
|
24
|
+
padding: var(--size-2) var(--size-4);
|
|
25
|
+
font-size: var(--font-size-1);
|
|
26
|
+
font-weight: 500;
|
|
27
|
+
color: var(--text-2, #64748b);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
30
|
+
margin-bottom: -2px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.tab-btn:hover {
|
|
34
|
+
color: var(--text-1, #1e293b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.tab-btn.active {
|
|
38
|
+
color: var(--link, #2563eb);
|
|
39
|
+
border-bottom-color: var(--link, #2563eb);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Config section card */
|
|
43
|
+
.config-section {
|
|
44
|
+
border: 1px solid var(--surface-3, #e2e8f0);
|
|
45
|
+
border-radius: var(--radius-2);
|
|
46
|
+
padding: var(--size-4);
|
|
47
|
+
margin-bottom: var(--size-4);
|
|
48
|
+
background: var(--surface-1, #f8fafc);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.config-section--nested {
|
|
52
|
+
margin-top: var(--size-4);
|
|
53
|
+
background: var(--surface-2, #f1f5f9);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Section heading */
|
|
57
|
+
.config-section__title {
|
|
58
|
+
font-size: var(--font-size-1);
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
color: var(--text-1, #1e293b);
|
|
61
|
+
margin: 0 0 var(--size-3) 0;
|
|
62
|
+
text-transform: uppercase;
|
|
63
|
+
letter-spacing: 0.05em;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Individual field row */
|
|
67
|
+
.config-field {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: var(--size-3);
|
|
71
|
+
padding: var(--size-1) 0;
|
|
72
|
+
font-size: var(--font-size-1);
|
|
73
|
+
color: var(--text-1, #1e293b);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.config-field--check {
|
|
78
|
+
flex-direction: row;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.config-field__label {
|
|
82
|
+
min-width: 12rem;
|
|
83
|
+
color: var(--text-2, #64748b);
|
|
84
|
+
font-size: var(--font-size-0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.config-field input[type='text'],
|
|
88
|
+
.config-field input[type='number'],
|
|
89
|
+
.config-field select {
|
|
90
|
+
flex: 1;
|
|
91
|
+
padding: var(--size-1) var(--size-2);
|
|
92
|
+
border: 1px solid var(--surface-3, #e2e8f0);
|
|
93
|
+
border-radius: var(--radius-1);
|
|
94
|
+
font-size: var(--font-size-0);
|
|
95
|
+
background: var(--surface-1, #fff);
|
|
96
|
+
color: var(--text-1, #1e293b);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.config-field input[readonly] {
|
|
100
|
+
opacity: 0.6;
|
|
101
|
+
cursor: not-allowed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.config-field input[type='checkbox'] {
|
|
105
|
+
width: 1rem;
|
|
106
|
+
height: 1rem;
|
|
107
|
+
accent-color: var(--link, #2563eb);
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Raw JSON textarea */
|
|
112
|
+
.config-raw-json {
|
|
113
|
+
width: 100%;
|
|
114
|
+
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
|
115
|
+
font-size: var(--font-size-0);
|
|
116
|
+
padding: var(--size-3);
|
|
117
|
+
border: 1px solid var(--surface-3, #e2e8f0);
|
|
118
|
+
border-radius: var(--radius-2);
|
|
119
|
+
background: var(--surface-1, #f8fafc);
|
|
120
|
+
color: var(--text-1, #1e293b);
|
|
121
|
+
resize: vertical;
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Config actions row */
|
|
126
|
+
.config-actions {
|
|
127
|
+
display: flex;
|
|
128
|
+
justify-content: flex-end;
|
|
129
|
+
margin-bottom: var(--size-3);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Inline feedback */
|
|
133
|
+
.config-feedback {
|
|
134
|
+
min-height: 1.5rem;
|
|
135
|
+
margin-top: var(--size-2);
|
|
136
|
+
font-size: var(--font-size-0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.feedback--success {
|
|
140
|
+
color: var(--green-7, #15803d);
|
|
141
|
+
font-weight: 500;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.feedback--error {
|
|
145
|
+
color: var(--red-7, #b91c1c);
|
|
146
|
+
font-weight: 500;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Empty state */
|
|
150
|
+
.config-empty {
|
|
151
|
+
font-size: var(--font-size-0);
|
|
152
|
+
color: var(--text-2, #64748b);
|
|
153
|
+
font-style: italic;
|
|
154
|
+
margin: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Primary button (consistent with other pages) */
|
|
158
|
+
.btn--primary {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: var(--size-2);
|
|
162
|
+
padding: var(--size-2) var(--size-5);
|
|
163
|
+
background: var(--link, #2563eb);
|
|
164
|
+
color: #fff;
|
|
165
|
+
border: none;
|
|
166
|
+
border-radius: var(--radius-2);
|
|
167
|
+
font-size: var(--font-size-1);
|
|
168
|
+
font-weight: 500;
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
transition: background 0.15s ease;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.btn--primary:hover {
|
|
174
|
+
background: var(--blue-7, #1d4ed8);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Dark theme overrides */
|
|
178
|
+
[data-theme='dark'] .config-section {
|
|
179
|
+
background: var(--surface-2, #1e2535);
|
|
180
|
+
border-color: var(--surface-3, #2d3748);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
[data-theme='dark'] .config-section--nested {
|
|
184
|
+
background: var(--surface-1, #161e2d);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
[data-theme='dark'] .config-raw-json,
|
|
188
|
+
[data-theme='dark'] .config-field input[type='text'],
|
|
189
|
+
[data-theme='dark'] .config-field input[type='number'],
|
|
190
|
+
[data-theme='dark'] .config-field select {
|
|
191
|
+
background: var(--surface-2, #1e2535);
|
|
192
|
+
border-color: var(--surface-3, #2d3748);
|
|
193
|
+
color: var(--text-1, #e2e8f0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ── Log Viewer ─────────────────────────────────────── */
|
|
197
|
+
.log-viewer-layout {
|
|
198
|
+
display: grid;
|
|
199
|
+
grid-template-columns: 220px 1fr;
|
|
200
|
+
gap: var(--size-4);
|
|
201
|
+
height: calc(100vh - var(--size-12));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.log-file-sidebar {
|
|
205
|
+
border-right: 1px solid var(--surface-3);
|
|
206
|
+
padding-right: var(--size-3);
|
|
207
|
+
overflow-y: auto;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.log-file-list {
|
|
211
|
+
list-style: none;
|
|
212
|
+
padding: 0;
|
|
213
|
+
margin: 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.log-file-item {
|
|
217
|
+
display: block;
|
|
218
|
+
padding: var(--size-2) var(--size-3);
|
|
219
|
+
border-radius: var(--radius-2);
|
|
220
|
+
text-decoration: none;
|
|
221
|
+
color: var(--text-2);
|
|
222
|
+
font-size: var(--font-size-0);
|
|
223
|
+
margin-block: 2px;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.log-file-item:hover { background: var(--surface-2); }
|
|
227
|
+
.log-file-item--active { background: var(--surface-3); color: var(--text-1); font-weight: 500; }
|
|
228
|
+
|
|
229
|
+
.log-file-name { display: block; font-family: var(--font-mono); font-size: 0.75rem; }
|
|
230
|
+
.log-file-size { color: var(--text-2); }
|
|
231
|
+
.log-file-date { color: var(--text-2); }
|
|
232
|
+
|
|
233
|
+
.log-main { overflow-y: auto; padding-right: var(--size-2); }
|
|
234
|
+
|
|
235
|
+
.log-filters {
|
|
236
|
+
display: flex;
|
|
237
|
+
gap: var(--size-3);
|
|
238
|
+
align-items: center;
|
|
239
|
+
margin-bottom: var(--size-3);
|
|
240
|
+
padding: var(--size-2) var(--size-3);
|
|
241
|
+
background: var(--surface-2);
|
|
242
|
+
border-radius: var(--radius-2);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.log-table {
|
|
246
|
+
width: 100%;
|
|
247
|
+
border-collapse: collapse;
|
|
248
|
+
font-size: var(--font-size-0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.log-table th,
|
|
252
|
+
.log-table td {
|
|
253
|
+
text-align: left;
|
|
254
|
+
padding: var(--size-1) var(--size-2);
|
|
255
|
+
border-bottom: 1px solid var(--surface-3);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.log-table th { color: var(--text-2); font-weight: 500; }
|
|
259
|
+
|
|
260
|
+
.log-badge {
|
|
261
|
+
display: inline-block;
|
|
262
|
+
padding: 1px 6px;
|
|
263
|
+
border-radius: var(--radius-round);
|
|
264
|
+
font-size: 0.7rem;
|
|
265
|
+
font-weight: 600;
|
|
266
|
+
text-transform: uppercase;
|
|
267
|
+
background: var(--surface-3);
|
|
268
|
+
color: var(--text-2);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.log-badge--error { background: var(--red-2); color: var(--red-9); }
|
|
272
|
+
.log-badge--warn { background: var(--yellow-2); color: var(--yellow-9); }
|
|
273
|
+
.log-badge--info { background: var(--blue-2); color: var(--blue-9); }
|
|
274
|
+
.log-badge--hook { background: var(--violet-2); color: var(--violet-9); }
|
|
275
|
+
.log-badge--task { background: var(--green-2); color: var(--green-9); }
|
|
276
|
+
.log-badge--agent { background: var(--cyan-2); color: var(--cyan-9); }
|
|
277
|
+
|
|
278
|
+
.log-pagination {
|
|
279
|
+
display: flex;
|
|
280
|
+
gap: var(--size-2);
|
|
281
|
+
align-items: center;
|
|
282
|
+
padding: var(--size-2) 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.log-tail-indicator {
|
|
286
|
+
display: flex;
|
|
287
|
+
align-items: center;
|
|
288
|
+
gap: var(--size-2);
|
|
289
|
+
font-size: var(--font-size-0);
|
|
290
|
+
color: var(--text-2);
|
|
291
|
+
margin-bottom: var(--size-2);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.tail-dot {
|
|
295
|
+
width: 8px;
|
|
296
|
+
height: 8px;
|
|
297
|
+
border-radius: 50%;
|
|
298
|
+
background: var(--surface-3);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.tail-dot--active {
|
|
302
|
+
background: var(--green-6);
|
|
303
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@keyframes pulse {
|
|
307
|
+
0%, 100% { opacity: 1; }
|
|
308
|
+
50% { opacity: 0.4; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.log-tail-entries { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-2); }
|
|
312
|
+
.log-tail-row { padding: 2px 0; border-bottom: 1px solid var(--surface-2); }
|
|
313
|
+
.log-empty, .log-prompt { color: var(--text-2); padding: var(--size-4); }
|
|
314
|
+
.log-summary { color: var(--text-2); font-size: var(--font-size-0); margin-bottom: var(--size-2); }
|
|
@@ -37,6 +37,7 @@ export function Layout({ title, children, currentView }: LayoutProps) {
|
|
|
37
37
|
<link rel="stylesheet" href="/css/command-center.css" />
|
|
38
38
|
<link rel="stylesheet" href="/css/explorer.css" />
|
|
39
39
|
<link rel="stylesheet" href="/css/timeline.css" />
|
|
40
|
+
<link rel="stylesheet" href="/css/settings.css" />
|
|
40
41
|
|
|
41
42
|
{/* Prevent flash of wrong theme */}
|
|
42
43
|
{html`<script>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
interface LogEntryListProps {
|
|
2
|
+
entries: object[];
|
|
3
|
+
total: number;
|
|
4
|
+
page: number;
|
|
5
|
+
pageSize: number;
|
|
6
|
+
file: string;
|
|
7
|
+
typeFilter: string;
|
|
8
|
+
q: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sanitizeType(t: unknown): string {
|
|
12
|
+
if (typeof t !== 'string' || !t) return 'unknown';
|
|
13
|
+
return t.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getTimestamp(entry: Record<string, unknown>): string {
|
|
17
|
+
const raw = entry['timestamp'] ?? entry['ts'] ?? entry['time'];
|
|
18
|
+
if (!raw) return '—';
|
|
19
|
+
try {
|
|
20
|
+
return new Date(raw as string).toLocaleString();
|
|
21
|
+
} catch {
|
|
22
|
+
return String(raw);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getSummary(entry: Record<string, unknown>): string {
|
|
27
|
+
const { type: _type, timestamp: _ts, ts: _ts2, time: _time, ...rest } = entry;
|
|
28
|
+
const str = JSON.stringify(rest);
|
|
29
|
+
return str.length > 120 ? str.slice(0, 117) + '...' : str;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function LogEntryRow({ entry }: { entry: object }) {
|
|
33
|
+
const e = entry as Record<string, unknown>;
|
|
34
|
+
const type = typeof e['type'] === 'string' ? e['type'] : '';
|
|
35
|
+
const badge = sanitizeType(type);
|
|
36
|
+
const timestamp = getTimestamp(e);
|
|
37
|
+
const summary = getSummary(e);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<tr>
|
|
41
|
+
<td>
|
|
42
|
+
<span class={`log-badge log-badge--${badge}`}>{type || '—'}</span>
|
|
43
|
+
</td>
|
|
44
|
+
<td>{timestamp}</td>
|
|
45
|
+
<td>{summary}</td>
|
|
46
|
+
</tr>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function LogEntryList({ entries, total, page, pageSize, file, typeFilter, q }: LogEntryListProps) {
|
|
51
|
+
const start = (page - 1) * pageSize + 1;
|
|
52
|
+
const end = Math.min(page * pageSize, total);
|
|
53
|
+
const encodedFile = encodeURIComponent(file);
|
|
54
|
+
const encodedQ = encodeURIComponent(q);
|
|
55
|
+
const encodedType = encodeURIComponent(typeFilter);
|
|
56
|
+
|
|
57
|
+
const baseQuery = `file=${encodedFile}&typeFilter=${encodedType}&q=${encodedQ}`;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div id="log-entries">
|
|
61
|
+
<p class="log-summary">
|
|
62
|
+
{total === 0
|
|
63
|
+
? 'No entries found.'
|
|
64
|
+
: `Showing ${start}–${end} of ${total} entries`}
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
{entries.length > 0 && (
|
|
68
|
+
<table class="log-table">
|
|
69
|
+
<thead>
|
|
70
|
+
<tr>
|
|
71
|
+
<th>Type</th>
|
|
72
|
+
<th>Timestamp</th>
|
|
73
|
+
<th>Message / Data</th>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
{entries.map((entry, i) => (
|
|
78
|
+
<LogEntryRow key={i} entry={entry} />
|
|
79
|
+
))}
|
|
80
|
+
</tbody>
|
|
81
|
+
</table>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div class="log-pagination">
|
|
85
|
+
<button
|
|
86
|
+
hx-get={`/api/settings/logs/entries?${baseQuery}&page=${page - 1}`}
|
|
87
|
+
hx-target="#log-entries"
|
|
88
|
+
hx-swap="outerHTML"
|
|
89
|
+
disabled={page <= 1}
|
|
90
|
+
>
|
|
91
|
+
Prev
|
|
92
|
+
</button>
|
|
93
|
+
<span>{page}</span>
|
|
94
|
+
<button
|
|
95
|
+
hx-get={`/api/settings/logs/entries?${baseQuery}&page=${page + 1}`}
|
|
96
|
+
hx-target="#log-entries"
|
|
97
|
+
hx-swap="outerHTML"
|
|
98
|
+
disabled={end >= total}
|
|
99
|
+
>
|
|
100
|
+
Next
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Alias used by route for HTMX partial responses
|
|
108
|
+
export const LogEntriesFragment = LogEntryList;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface LogFileListProps {
|
|
2
|
+
files: Array<{ name: string; size: number; modified: string }>;
|
|
3
|
+
selectedFile?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function formatBytes(n: number): string {
|
|
7
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
8
|
+
return `${n} B`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LogFileList({ files, selectedFile }: LogFileListProps) {
|
|
12
|
+
if (files.length === 0) {
|
|
13
|
+
return <p class="log-empty">No log files found in .planning/logs/</p>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<ul class="log-file-list">
|
|
18
|
+
{files.map((file) => {
|
|
19
|
+
const isSelected = file.name === selectedFile;
|
|
20
|
+
const date = new Date(file.modified).toLocaleDateString();
|
|
21
|
+
return (
|
|
22
|
+
<li key={file.name}>
|
|
23
|
+
<a
|
|
24
|
+
href={`/settings/logs?file=${encodeURIComponent(file.name)}`}
|
|
25
|
+
class={`log-file-item${isSelected ? ' log-file-item--active' : ''}`}
|
|
26
|
+
>
|
|
27
|
+
<span class="log-file-name">{file.name}</span>
|
|
28
|
+
<span class="log-file-size">{formatBytes(file.size)}</span>
|
|
29
|
+
<span class="log-file-date">{date}</span>
|
|
30
|
+
</a>
|
|
31
|
+
</li>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</ul>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { html } from 'hono/html';
|
|
2
|
+
import { LogFileList } from './LogFileList';
|
|
3
|
+
import { LogEntryList } from './LogEntryList';
|
|
4
|
+
|
|
5
|
+
interface LogViewerProps {
|
|
6
|
+
files: Array<{ name: string; size: number; modified: string }>;
|
|
7
|
+
selectedFile?: string;
|
|
8
|
+
entries?: object[];
|
|
9
|
+
total?: number;
|
|
10
|
+
page?: number;
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
typeFilter?: string;
|
|
13
|
+
q?: string;
|
|
14
|
+
isLatest?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function LogViewer({
|
|
18
|
+
files,
|
|
19
|
+
selectedFile,
|
|
20
|
+
entries = [],
|
|
21
|
+
total = 0,
|
|
22
|
+
page = 1,
|
|
23
|
+
pageSize = 50,
|
|
24
|
+
typeFilter = '',
|
|
25
|
+
q = '',
|
|
26
|
+
isLatest = false,
|
|
27
|
+
}: LogViewerProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div class="log-viewer-layout">
|
|
30
|
+
<aside class="log-file-sidebar">
|
|
31
|
+
<h2 class="config-section__title">Log Files</h2>
|
|
32
|
+
<LogFileList files={files} selectedFile={selectedFile} />
|
|
33
|
+
</aside>
|
|
34
|
+
|
|
35
|
+
<section class="log-main">
|
|
36
|
+
{!selectedFile ? (
|
|
37
|
+
<p class="log-prompt">Select a log file to view entries.</p>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
<form
|
|
41
|
+
class="log-filters"
|
|
42
|
+
hx-get="/api/settings/logs/entries"
|
|
43
|
+
hx-target="#log-entries"
|
|
44
|
+
hx-swap="outerHTML"
|
|
45
|
+
hx-trigger="change, input delay:300ms from:[name=q]"
|
|
46
|
+
>
|
|
47
|
+
<input type="hidden" name="file" value={selectedFile} />
|
|
48
|
+
<input type="hidden" name="page" value="1" />
|
|
49
|
+
|
|
50
|
+
<select name="typeFilter">
|
|
51
|
+
<option value="" selected={typeFilter === ''}>All types</option>
|
|
52
|
+
<option value="hook" selected={typeFilter === 'hook'}>hook</option>
|
|
53
|
+
<option value="task" selected={typeFilter === 'task'}>task</option>
|
|
54
|
+
<option value="agent" selected={typeFilter === 'agent'}>agent</option>
|
|
55
|
+
<option value="error" selected={typeFilter === 'error'}>error</option>
|
|
56
|
+
<option value="info" selected={typeFilter === 'info'}>info</option>
|
|
57
|
+
<option value="warn" selected={typeFilter === 'warn'}>warn</option>
|
|
58
|
+
<option value="debug" selected={typeFilter === 'debug'}>debug</option>
|
|
59
|
+
</select>
|
|
60
|
+
|
|
61
|
+
<input
|
|
62
|
+
type="text"
|
|
63
|
+
name="q"
|
|
64
|
+
placeholder="Search entries..."
|
|
65
|
+
value={q || ''}
|
|
66
|
+
/>
|
|
67
|
+
</form>
|
|
68
|
+
|
|
69
|
+
<div id="log-entries-container">
|
|
70
|
+
<LogEntryList
|
|
71
|
+
entries={entries}
|
|
72
|
+
total={total}
|
|
73
|
+
page={page}
|
|
74
|
+
pageSize={pageSize}
|
|
75
|
+
file={selectedFile}
|
|
76
|
+
typeFilter={typeFilter}
|
|
77
|
+
q={q}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{isLatest && (
|
|
82
|
+
<>
|
|
83
|
+
<div
|
|
84
|
+
class="log-tail-indicator"
|
|
85
|
+
x-data={`logTail({ file: '${selectedFile}' })`}
|
|
86
|
+
x-init="start()"
|
|
87
|
+
>
|
|
88
|
+
<span class="tail-dot" x-bind:class="{ 'tail-dot--active': connected }"></span>
|
|
89
|
+
<span x-text="connected ? 'Live' : 'Connecting...'"></span>
|
|
90
|
+
<span class="tail-count" x-text="newCount + ' new entries'"></span>
|
|
91
|
+
</div>
|
|
92
|
+
<div id="log-tail-entries" class="log-tail-entries"></div>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
{html`<script>
|
|
100
|
+
function logTail({ file }) {
|
|
101
|
+
return {
|
|
102
|
+
connected: false,
|
|
103
|
+
newCount: 0,
|
|
104
|
+
es: null,
|
|
105
|
+
start() {
|
|
106
|
+
this.es = new EventSource('/api/settings/logs/tail?file=' + encodeURIComponent(file));
|
|
107
|
+
this.es.addEventListener('log-entry', (e) => {
|
|
108
|
+
this.newCount++;
|
|
109
|
+
const container = document.getElementById('log-tail-entries');
|
|
110
|
+
if (!container) return;
|
|
111
|
+
const row = document.createElement('div');
|
|
112
|
+
row.className = 'log-tail-row';
|
|
113
|
+
try {
|
|
114
|
+
const entry = JSON.parse(e.data);
|
|
115
|
+
row.textContent = JSON.stringify(entry);
|
|
116
|
+
} catch { row.textContent = e.data; }
|
|
117
|
+
container.prepend(row);
|
|
118
|
+
});
|
|
119
|
+
this.es.onopen = () => { this.connected = true; };
|
|
120
|
+
this.es.onerror = () => { this.connected = false; };
|
|
121
|
+
window.addEventListener('beforeunload', () => this.stop());
|
|
122
|
+
},
|
|
123
|
+
stop() { if (this.es) this.es.close(); }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
</script>`}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
package/dashboard/src/index.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { indexRouter } from './routes/index.routes';
|
|
|
9
9
|
import { commandCenterRouter } from './routes/command-center.routes';
|
|
10
10
|
import { explorerRouter } from './routes/explorer.routes';
|
|
11
11
|
import { timelineRouter } from './routes/timeline.routes';
|
|
12
|
+
import { settingsRouter } from './routes/settings.routes';
|
|
12
13
|
import { sseHandler } from './sse-handler';
|
|
13
14
|
import { startWatcher } from './watcher-setup';
|
|
14
15
|
import { currentPhaseMiddleware } from './middleware/current-phase';
|
|
@@ -72,6 +73,7 @@ function createApp(config: ServerConfig) {
|
|
|
72
73
|
app.route('/api/command-center', commandCenterRouter);
|
|
73
74
|
app.route('/', explorerRouter);
|
|
74
75
|
app.route('/', timelineRouter);
|
|
76
|
+
app.route('/', settingsRouter);
|
|
75
77
|
|
|
76
78
|
// SSE endpoint — real streamSSE handler with multi-client broadcast
|
|
77
79
|
app.get('/api/events/stream', sseHandler);
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { streamSSE } from 'hono/streaming';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Layout } from '../components/Layout';
|
|
5
|
+
import { SettingsPage } from '../components/settings/SettingsPage';
|
|
6
|
+
import { LogViewer } from '../components/settings/LogViewer';
|
|
7
|
+
import { LogEntriesFragment } from '../components/settings/LogEntryList';
|
|
8
|
+
import { readConfig, writeConfig, mergeDefaults } from '../services/config.service.js';
|
|
9
|
+
import { listLogFiles, readLogPage, tailLogFile } from '../services/log.service.js';
|
|
10
|
+
|
|
11
|
+
type Env = { Variables: { projectDir: string } };
|
|
12
|
+
const router = new Hono<Env>();
|
|
13
|
+
|
|
14
|
+
// GET /settings — full page
|
|
15
|
+
router.get('/settings', async (c) => {
|
|
16
|
+
const projectDir = c.get('projectDir');
|
|
17
|
+
const raw = await readConfig(projectDir).catch(() => null);
|
|
18
|
+
const config = mergeDefaults(raw || {});
|
|
19
|
+
const isHtmx = c.req.header('HX-Request');
|
|
20
|
+
const content = <SettingsPage config={config} activeTab="config" />;
|
|
21
|
+
if (isHtmx) return c.html(content);
|
|
22
|
+
return c.html(<Layout title="Settings" currentView="settings">{content}</Layout>);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// GET /api/settings/config — return current config as JSON
|
|
26
|
+
router.get('/api/settings/config', async (c) => {
|
|
27
|
+
const projectDir = c.get('projectDir');
|
|
28
|
+
const raw = await readConfig(projectDir).catch(() => null);
|
|
29
|
+
const config = mergeDefaults(raw || {});
|
|
30
|
+
return c.json(config);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// POST /api/settings/config — save config (form-encoded or raw JSON textarea)
|
|
34
|
+
router.post('/api/settings/config', async (c) => {
|
|
35
|
+
const projectDir = c.get('projectDir');
|
|
36
|
+
try {
|
|
37
|
+
const contentType = c.req.header('content-type') || '';
|
|
38
|
+
let newConfig: object;
|
|
39
|
+
|
|
40
|
+
if (contentType.includes('application/json')) {
|
|
41
|
+
// Raw JSON mode via JSON body
|
|
42
|
+
newConfig = await c.req.json();
|
|
43
|
+
} else {
|
|
44
|
+
// Form mode or raw textarea: check for rawJson field
|
|
45
|
+
const formData = await c.req.parseBody();
|
|
46
|
+
|
|
47
|
+
if (typeof formData['rawJson'] === 'string') {
|
|
48
|
+
// Raw JSON textarea submitted as form field
|
|
49
|
+
newConfig = JSON.parse(formData['rawJson']);
|
|
50
|
+
} else {
|
|
51
|
+
// Standard form mode: reconstruct nested object from flat dotted keys
|
|
52
|
+
const current = await readConfig(projectDir).catch(() => null);
|
|
53
|
+
newConfig = mergeDefaults(current || {});
|
|
54
|
+
|
|
55
|
+
// Apply form fields — dotted keys map to nested paths
|
|
56
|
+
for (const [key, value] of Object.entries(formData)) {
|
|
57
|
+
setNestedValue(newConfig as Record<string, unknown>, key, value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Checkboxes: unchecked fields are absent from form data; set them to false
|
|
61
|
+
const boolPaths = getBoolPaths(newConfig);
|
|
62
|
+
for (const path of boolPaths) {
|
|
63
|
+
if (!(path in formData)) {
|
|
64
|
+
setNestedValue(newConfig as Record<string, unknown>, path, false);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Coerce number fields
|
|
69
|
+
coerceNumbers(newConfig as Record<string, unknown>);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await writeConfig(projectDir, newConfig);
|
|
74
|
+
return c.html('<span class="feedback--success">Saved successfully.</span>');
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
return c.html(`<span class="feedback--error">Save failed: ${msg}</span>`, 400);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// GET /settings/logs — log viewer page
|
|
82
|
+
router.get('/settings/logs', async (c) => {
|
|
83
|
+
const projectDir = c.get('projectDir');
|
|
84
|
+
const { file, page, typeFilter, q } = c.req.query();
|
|
85
|
+
const files = await listLogFiles(projectDir).catch(() => []);
|
|
86
|
+
const selectedFile = file || (files[0]?.name ?? '');
|
|
87
|
+
const isLatest = files.length > 0 && selectedFile === files[0].name;
|
|
88
|
+
|
|
89
|
+
let entries: object[] = [];
|
|
90
|
+
let total = 0;
|
|
91
|
+
let pageNum = parseInt(page || '1', 10);
|
|
92
|
+
const pageSize = 50;
|
|
93
|
+
|
|
94
|
+
if (selectedFile) {
|
|
95
|
+
const filePath = join(projectDir, '.planning', 'logs', selectedFile);
|
|
96
|
+
const result = await readLogPage(filePath, {
|
|
97
|
+
page: pageNum,
|
|
98
|
+
pageSize,
|
|
99
|
+
typeFilter: typeFilter || '',
|
|
100
|
+
q: q || '',
|
|
101
|
+
}).catch(() => ({ entries: [], total: 0, page: pageNum, pageSize }));
|
|
102
|
+
entries = result.entries;
|
|
103
|
+
total = result.total;
|
|
104
|
+
pageNum = result.page;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isHtmx = c.req.header('HX-Request');
|
|
108
|
+
const content = (
|
|
109
|
+
<LogViewer
|
|
110
|
+
files={files}
|
|
111
|
+
selectedFile={selectedFile}
|
|
112
|
+
entries={entries}
|
|
113
|
+
total={total}
|
|
114
|
+
page={pageNum}
|
|
115
|
+
pageSize={pageSize}
|
|
116
|
+
typeFilter={typeFilter || ''}
|
|
117
|
+
q={q || ''}
|
|
118
|
+
isLatest={isLatest}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
if (isHtmx) return c.html(content);
|
|
122
|
+
return c.html(<Layout title="Log Viewer" currentView="settings">{content}</Layout>);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// GET /api/settings/logs/entries — HTMX partial: paginated entries
|
|
126
|
+
router.get('/api/settings/logs/entries', async (c) => {
|
|
127
|
+
const projectDir = c.get('projectDir');
|
|
128
|
+
const { file, page, typeFilter, q } = c.req.query();
|
|
129
|
+
if (!file) return c.html('<p class="log-empty">No file specified.</p>');
|
|
130
|
+
|
|
131
|
+
const filePath = join(projectDir, '.planning', 'logs', file);
|
|
132
|
+
const pageNum = parseInt(page || '1', 10);
|
|
133
|
+
const result = await readLogPage(filePath, {
|
|
134
|
+
page: pageNum,
|
|
135
|
+
pageSize: 50,
|
|
136
|
+
typeFilter: typeFilter || '',
|
|
137
|
+
q: q || '',
|
|
138
|
+
}).catch(() => ({ entries: [], total: 0, page: pageNum, pageSize: 50 }));
|
|
139
|
+
|
|
140
|
+
return c.html(
|
|
141
|
+
<LogEntriesFragment
|
|
142
|
+
entries={result.entries}
|
|
143
|
+
total={result.total}
|
|
144
|
+
page={result.page}
|
|
145
|
+
pageSize={result.pageSize}
|
|
146
|
+
file={file}
|
|
147
|
+
typeFilter={typeFilter || ''}
|
|
148
|
+
q={q || ''}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// GET /api/settings/logs/tail — SSE: stream new log entries
|
|
154
|
+
router.get('/api/settings/logs/tail', async (c) => {
|
|
155
|
+
const projectDir = c.get('projectDir');
|
|
156
|
+
const { file } = c.req.query();
|
|
157
|
+
if (!file) return c.json({ error: 'file param required' }, 400);
|
|
158
|
+
|
|
159
|
+
const filePath = join(projectDir, '.planning', 'logs', file);
|
|
160
|
+
return streamSSE(c, async (stream) => {
|
|
161
|
+
const stop = await tailLogFile(filePath, (entry) => {
|
|
162
|
+
stream.writeSSE({
|
|
163
|
+
event: 'log-entry',
|
|
164
|
+
data: JSON.stringify(entry),
|
|
165
|
+
}).catch(() => {});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await new Promise<void>((resolve) => {
|
|
169
|
+
stream.onAbort(() => {
|
|
170
|
+
stop();
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export { router as settingsRouter };
|
|
178
|
+
|
|
179
|
+
/** Set a value at a dotted path on an object, creating intermediate objects as needed. */
|
|
180
|
+
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown) {
|
|
181
|
+
const parts = path.split('.');
|
|
182
|
+
let cur: Record<string, unknown> = obj;
|
|
183
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
184
|
+
if (typeof cur[parts[i]] !== 'object' || cur[parts[i]] === null) {
|
|
185
|
+
cur[parts[i]] = {};
|
|
186
|
+
}
|
|
187
|
+
cur = cur[parts[i]] as Record<string, unknown>;
|
|
188
|
+
}
|
|
189
|
+
cur[parts[parts.length - 1]] = value === 'on' ? true : value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Collect all dotted paths to boolean fields in the config object. */
|
|
193
|
+
function getBoolPaths(obj: unknown, prefix = ''): string[] {
|
|
194
|
+
const paths: string[] = [];
|
|
195
|
+
if (typeof obj !== 'object' || obj === null) return paths;
|
|
196
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
197
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
198
|
+
if (typeof v === 'boolean') paths.push(fullKey);
|
|
199
|
+
else if (typeof v === 'object' && v !== null) paths.push(...getBoolPaths(v, fullKey));
|
|
200
|
+
}
|
|
201
|
+
return paths;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Coerce known number fields from string to number after form parse. */
|
|
205
|
+
function coerceNumbers(config: Record<string, unknown>) {
|
|
206
|
+
const numberPaths = [
|
|
207
|
+
'parallelization.max_concurrent_agents',
|
|
208
|
+
'parallelization.min_plans_for_parallel',
|
|
209
|
+
'planning.max_tasks_per_plan',
|
|
210
|
+
'local_llm.timeout_ms',
|
|
211
|
+
'local_llm.max_retries',
|
|
212
|
+
'local_llm.metrics.frontier_token_rate',
|
|
213
|
+
'local_llm.advanced.confidence_threshold',
|
|
214
|
+
'local_llm.advanced.max_input_tokens',
|
|
215
|
+
'local_llm.advanced.num_ctx',
|
|
216
|
+
'local_llm.advanced.disable_after_failures',
|
|
217
|
+
];
|
|
218
|
+
for (const path of numberPaths) {
|
|
219
|
+
const parts = path.split('.');
|
|
220
|
+
let cur: Record<string, unknown> = config;
|
|
221
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
222
|
+
if (typeof cur[parts[i]] !== 'object') break;
|
|
223
|
+
cur = cur[parts[i]] as Record<string, unknown>;
|
|
224
|
+
}
|
|
225
|
+
const last = parts[parts.length - 1];
|
|
226
|
+
if (typeof cur[last] === 'string') {
|
|
227
|
+
const n = Number(cur[last]);
|
|
228
|
+
if (!isNaN(n)) cur[last] = n;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function getConfigDefaults(): Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export function mergeDefaults(incoming: Record<string, unknown>): Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export function readConfig(projectDir: string): Promise<Record<string, unknown> | null>;
|
|
6
|
+
|
|
7
|
+
export function validateConfig(config: Record<string, unknown>): void;
|
|
8
|
+
|
|
9
|
+
export function writeConfig(projectDir: string, config: object): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function listLogFiles(
|
|
2
|
+
projectDir: string
|
|
3
|
+
): Promise<Array<{ name: string; size: number; modified: string }>>;
|
|
4
|
+
|
|
5
|
+
export function readLogPage(
|
|
6
|
+
filePath: string,
|
|
7
|
+
opts?: { page?: number; pageSize?: number; typeFilter?: string; q?: string }
|
|
8
|
+
): Promise<{ entries: object[]; total: number; page: number; pageSize: number }>;
|
|
9
|
+
|
|
10
|
+
export function tailLogFile(
|
|
11
|
+
filePath: string,
|
|
12
|
+
onLine: (entry: object) => void
|
|
13
|
+
): Promise<() => void>;
|
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.32.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.32.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.32.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",
|