@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 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
+ }
@@ -1,7 +1,7 @@
1
1
  import { ConfigEditor } from './ConfigEditor';
2
2
 
3
3
  interface SettingsPageProps {
4
- config: object;
4
+ config: Record<string, unknown>;
5
5
  activeTab?: 'config' | 'logs';
6
6
  }
7
7
 
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.31.0",
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.31.0",
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.31.0",
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",