@sienklogic/plan-build-run 2.33.0 → 2.34.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,28 @@ 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.34.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.33.1...plan-build-run-v2.34.0) (2026-02-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * **quick-011:** add mobile responsive sidebar with hamburger toggle ([2958c60](https://github.com/SienkLogic/plan-build-run/commit/2958c605a2d9c876d297872d8b823e560679e3da))
14
+ * **quick-011:** fix status badge data-status attrs and mermaid dark mode ([8359c8c](https://github.com/SienkLogic/plan-build-run/commit/8359c8c001e676d8f776ad274b52b4b251cefe5a))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **quick-009:** fix dashboard UX across milestones, timeline, logs, todos, and layout ([3529d9e](https://github.com/SienkLogic/plan-build-run/commit/3529d9ebad8bac7f58b0780107a36eadbd5f66e7))
20
+ * **quick-010:** use lockedFileUpdate for atomic writes, fix shell injection, add writeActiveSkill ([1cefb13](https://github.com/SienkLogic/plan-build-run/commit/1cefb132ff1aa1ab07be252695dec43f1397f689))
21
+ * **quick-011:** move hamburger button outside sidebar for correct fixed positioning ([65054c3](https://github.com/SienkLogic/plan-build-run/commit/65054c337c8adc812669976ae5c14d3c150c452f))
22
+
23
+ ## [2.33.1](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.33.0...plan-build-run-v2.33.1) (2026-02-25)
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **quick-008:** propagate block exit code from checkNonPbrAgent in validate-task dispatcher ([4e8fbf6](https://github.com/SienkLogic/plan-build-run/commit/4e8fbf6796789c2a73d879d32c748df89261a17b))
29
+
8
30
  ## [2.33.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.32.1...plan-build-run-v2.33.0) (2026-02-25)
9
31
 
10
32
 
@@ -418,6 +418,54 @@
418
418
  gap: var(--space-md);
419
419
  }
420
420
 
421
+ /* ---- Todo cards ---- */
422
+
423
+ .todo-card {
424
+ display: flex;
425
+ flex-direction: column;
426
+ gap: var(--space-xs);
427
+ background: var(--color-surface-raised);
428
+ border: 1px solid var(--color-border);
429
+ border-radius: var(--radius-md);
430
+ border-left-width: 4px;
431
+ padding: var(--space-sm) var(--space-md);
432
+ overflow: hidden;
433
+ }
434
+
435
+ .todo-card--high {
436
+ border-left-color: var(--color-error, #ef4444);
437
+ }
438
+
439
+ .todo-card--medium {
440
+ border-left-color: var(--color-warning, #f59e0b);
441
+ }
442
+
443
+ .todo-card--low {
444
+ border-left-color: var(--color-border);
445
+ }
446
+
447
+ .todo-card__main {
448
+ display: flex;
449
+ align-items: center;
450
+ gap: var(--space-sm);
451
+ }
452
+
453
+ .todo-card__main .explorer-item__title {
454
+ flex: 1;
455
+ }
456
+
457
+ .todo-card__meta {
458
+ display: flex;
459
+ gap: var(--space-md);
460
+ font-size: 0.8rem;
461
+ color: var(--color-text-dim);
462
+ padding-left: calc(var(--space-sm) + 60px);
463
+ }
464
+
465
+ .todo-card__meta:empty {
466
+ display: none;
467
+ }
468
+
421
469
  /* ---- Requirements traceability table ---- */
422
470
 
423
471
  .explorer-req-section {
@@ -103,13 +103,39 @@ ul, ol {
103
103
  z-index: 10;
104
104
  }
105
105
 
106
+ .sidebar__toggle {
107
+ display: none;
108
+ align-items: center;
109
+ justify-content: center;
110
+ position: fixed;
111
+ top: var(--space-sm);
112
+ left: var(--space-sm);
113
+ z-index: 20;
114
+ width: 2.5rem;
115
+ height: 2.5rem;
116
+ border: 1px solid var(--color-border);
117
+ border-radius: var(--radius-sm);
118
+ background: var(--color-surface-raised);
119
+ color: var(--color-text);
120
+ font-size: 1.25rem;
121
+ cursor: pointer;
122
+ }
123
+
124
+ body.sidebar-visible .main-content::before {
125
+ content: '';
126
+ position: fixed;
127
+ inset: 0;
128
+ background: rgba(0,0,0,0.4);
129
+ z-index: 5;
130
+ }
131
+
106
132
  .main-content {
107
133
  margin-left: var(--size-sidebar);
108
134
  flex: 1;
109
135
  padding: var(--space-lg) var(--space-xl);
110
136
  min-height: 100vh;
111
137
  background: var(--color-surface);
112
- max-width: 100%;
138
+ max-width: 1400px;
113
139
  }
114
140
 
115
141
  /* --- Sidebar Brand --- */
@@ -843,6 +869,23 @@ details li {
843
869
  min-width: 100px;
844
870
  font-size: 0.75rem;
845
871
  }
872
+
873
+ .sidebar {
874
+ transform: translateX(-100%);
875
+ transition: transform 0.2s ease;
876
+ }
877
+
878
+ .sidebar--open {
879
+ transform: translateX(0);
880
+ }
881
+
882
+ .sidebar__toggle {
883
+ display: flex;
884
+ }
885
+
886
+ .main-content {
887
+ margin-left: 0;
888
+ }
846
889
  }
847
890
 
848
891
  @media (max-width: 480px) {
@@ -3,17 +3,17 @@
3
3
  /* Tab bar */
4
4
  .settings-tabs {
5
5
  display: flex;
6
- gap: var(--size-2);
7
- border-bottom: 2px solid var(--surface-3, #e2e8f0);
8
- margin-bottom: var(--size-5);
6
+ gap: var(--space-sm);
7
+ border-bottom: 2px solid var(--color-border);
8
+ margin-bottom: var(--space-md);
9
9
  padding-bottom: 0;
10
10
  }
11
11
 
12
12
  /* Mode toggle (Form / Raw JSON) */
13
13
  .config-mode-toggle {
14
14
  display: flex;
15
- gap: var(--size-2);
16
- margin-bottom: var(--size-4);
15
+ gap: var(--space-sm);
16
+ margin-bottom: var(--space-md);
17
17
  }
18
18
 
19
19
  /* Tab buttons — shared by settings-tabs and config-mode-toggle */
@@ -21,44 +21,44 @@
21
21
  background: none;
22
22
  border: none;
23
23
  border-bottom: 2px solid transparent;
24
- padding: var(--size-2) var(--size-4);
25
- font-size: var(--font-size-1);
24
+ padding: var(--space-sm) var(--space-md);
25
+ font-size: 0.875rem;
26
26
  font-weight: 500;
27
- color: var(--text-2, #64748b);
27
+ color: var(--color-text-dim);
28
28
  cursor: pointer;
29
29
  transition: color 0.15s ease, border-color 0.15s ease;
30
30
  margin-bottom: -2px;
31
31
  }
32
32
 
33
33
  .tab-btn:hover {
34
- color: var(--text-1, #1e293b);
34
+ color: var(--color-text);
35
35
  }
36
36
 
37
37
  .tab-btn.active {
38
- color: var(--link, #2563eb);
39
- border-bottom-color: var(--link, #2563eb);
38
+ color: var(--color-accent);
39
+ border-bottom-color: var(--color-accent);
40
40
  }
41
41
 
42
42
  /* Config section card */
43
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);
44
+ border: 1px solid var(--color-border);
45
+ border-radius: var(--radius-sm);
46
+ padding: var(--space-md);
47
+ margin-bottom: var(--space-md);
48
+ background: var(--color-surface);
49
49
  }
50
50
 
51
51
  .config-section--nested {
52
- margin-top: var(--size-4);
53
- background: var(--surface-2, #f1f5f9);
52
+ margin-top: var(--space-md);
53
+ background: var(--color-surface-raised);
54
54
  }
55
55
 
56
56
  /* Section heading */
57
57
  .config-section__title {
58
- font-size: var(--font-size-1);
58
+ font-size: 0.875rem;
59
59
  font-weight: 600;
60
- color: var(--text-1, #1e293b);
61
- margin: 0 0 var(--size-3) 0;
60
+ color: var(--color-text);
61
+ margin: 0 0 var(--space-sm) 0;
62
62
  text-transform: uppercase;
63
63
  letter-spacing: 0.05em;
64
64
  }
@@ -67,10 +67,10 @@
67
67
  .config-field {
68
68
  display: flex;
69
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);
70
+ gap: var(--space-sm);
71
+ padding: var(--space-xs) 0;
72
+ font-size: 0.875rem;
73
+ color: var(--color-text);
74
74
  cursor: pointer;
75
75
  }
76
76
 
@@ -80,20 +80,20 @@
80
80
 
81
81
  .config-field__label {
82
82
  min-width: 12rem;
83
- color: var(--text-2, #64748b);
84
- font-size: var(--font-size-0);
83
+ color: var(--color-text-dim);
84
+ font-size: 0.8125rem;
85
85
  }
86
86
 
87
87
  .config-field input[type='text'],
88
88
  .config-field input[type='number'],
89
89
  .config-field select {
90
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);
91
+ padding: var(--space-xs) var(--space-sm);
92
+ border: 1px solid var(--color-border);
93
+ border-radius: var(--radius-sm);
94
+ font-size: 0.8125rem;
95
+ background: var(--color-surface);
96
+ color: var(--color-text);
97
97
  }
98
98
 
99
99
  .config-field input[readonly] {
@@ -104,20 +104,20 @@
104
104
  .config-field input[type='checkbox'] {
105
105
  width: 1rem;
106
106
  height: 1rem;
107
- accent-color: var(--link, #2563eb);
107
+ accent-color: var(--color-accent);
108
108
  cursor: pointer;
109
109
  }
110
110
 
111
111
  /* Raw JSON textarea */
112
112
  .config-raw-json {
113
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);
114
+ font-family: var(--font-mono);
115
+ font-size: 0.8125rem;
116
+ padding: var(--space-sm);
117
+ border: 1px solid var(--color-border);
118
+ border-radius: var(--radius-sm);
119
+ background: var(--color-surface);
120
+ color: var(--color-text);
121
121
  resize: vertical;
122
122
  box-sizing: border-box;
123
123
  }
@@ -126,30 +126,30 @@
126
126
  .config-actions {
127
127
  display: flex;
128
128
  justify-content: flex-end;
129
- margin-bottom: var(--size-3);
129
+ margin-bottom: var(--space-sm);
130
130
  }
131
131
 
132
132
  /* Inline feedback */
133
133
  .config-feedback {
134
134
  min-height: 1.5rem;
135
- margin-top: var(--size-2);
136
- font-size: var(--font-size-0);
135
+ margin-top: var(--space-sm);
136
+ font-size: 0.8125rem;
137
137
  }
138
138
 
139
139
  .feedback--success {
140
- color: var(--green-7, #15803d);
140
+ color: var(--status-complete);
141
141
  font-weight: 500;
142
142
  }
143
143
 
144
144
  .feedback--error {
145
- color: var(--red-7, #b91c1c);
145
+ color: var(--status-blocked);
146
146
  font-weight: 500;
147
147
  }
148
148
 
149
149
  /* Empty state */
150
150
  .config-empty {
151
- font-size: var(--font-size-0);
152
- color: var(--text-2, #64748b);
151
+ font-size: 0.8125rem;
152
+ color: var(--color-text-dim);
153
153
  font-style: italic;
154
154
  margin: 0;
155
155
  }
@@ -158,52 +158,33 @@
158
158
  .btn--primary {
159
159
  display: inline-flex;
160
160
  align-items: center;
161
- gap: var(--size-2);
162
- padding: var(--size-2) var(--size-5);
163
- background: var(--link, #2563eb);
161
+ gap: var(--space-sm);
162
+ padding: var(--space-sm) var(--space-md);
163
+ background: var(--color-accent);
164
164
  color: #fff;
165
165
  border: none;
166
- border-radius: var(--radius-2);
167
- font-size: var(--font-size-1);
166
+ border-radius: var(--radius-sm);
167
+ font-size: 0.875rem;
168
168
  font-weight: 500;
169
169
  cursor: pointer;
170
170
  transition: background 0.15s ease;
171
171
  }
172
172
 
173
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);
174
+ background: var(--color-accent-hover);
194
175
  }
195
176
 
196
177
  /* ── Log Viewer ─────────────────────────────────────── */
197
178
  .log-viewer-layout {
198
179
  display: grid;
199
180
  grid-template-columns: 220px 1fr;
200
- gap: var(--size-4);
201
- height: calc(100vh - var(--size-12));
181
+ gap: var(--space-md);
182
+ height: calc(100vh - var(--space-2xl));
202
183
  }
203
184
 
204
185
  .log-file-sidebar {
205
- border-right: 1px solid var(--surface-3);
206
- padding-right: var(--size-3);
186
+ border-right: 1px solid var(--color-border);
187
+ padding-right: var(--space-sm);
207
188
  overflow-y: auto;
208
189
  }
209
190
 
@@ -215,57 +196,74 @@
215
196
 
216
197
  .log-file-item {
217
198
  display: block;
218
- padding: var(--size-2) var(--size-3);
219
- border-radius: var(--radius-2);
199
+ padding: var(--space-sm);
200
+ border-radius: var(--radius-sm);
220
201
  text-decoration: none;
221
- color: var(--text-2);
222
- font-size: var(--font-size-0);
202
+ color: var(--color-text-dim);
203
+ font-size: 0.8125rem;
223
204
  margin-block: 2px;
224
205
  }
225
206
 
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; }
207
+ .log-file-item:hover { background: var(--color-surface-raised); }
208
+ .log-file-item--active { background: var(--color-border); color: var(--color-text); font-weight: 500; }
228
209
 
229
210
  .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); }
211
+ .log-file-size { color: var(--color-text-dim); }
212
+ .log-file-date { color: var(--color-text-dim); }
232
213
 
233
- .log-main { overflow-y: auto; padding-right: var(--size-2); }
214
+ .log-main { overflow-y: auto; padding-right: var(--space-sm); }
234
215
 
235
216
  .log-filters {
236
217
  display: flex;
237
- gap: var(--size-3);
218
+ gap: var(--space-sm);
238
219
  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);
220
+ margin-bottom: var(--space-sm);
221
+ padding: var(--space-sm);
222
+ background: var(--color-surface-raised);
223
+ border-radius: var(--radius-sm);
243
224
  }
244
225
 
245
226
  .log-table {
246
227
  width: 100%;
247
228
  border-collapse: collapse;
248
- font-size: var(--font-size-0);
229
+ font-size: 0.8125rem;
249
230
  }
250
231
 
251
232
  .log-table th,
252
233
  .log-table td {
253
234
  text-align: left;
254
- padding: var(--size-1) var(--size-2);
255
- border-bottom: 1px solid var(--surface-3);
235
+ padding: var(--space-xs) var(--space-sm);
236
+ border-bottom: 1px solid var(--color-border);
256
237
  }
257
238
 
258
- .log-table th { color: var(--text-2); font-weight: 500; }
239
+ .log-table th { color: var(--color-text-dim); font-weight: 500; }
240
+
241
+ .log-table th:first-child,
242
+ .log-table td:first-child {
243
+ width: 80px;
244
+ }
245
+
246
+ .log-table th:nth-child(2),
247
+ .log-table td:nth-child(2) {
248
+ width: 170px;
249
+ white-space: nowrap;
250
+ }
251
+
252
+ .log-table td:nth-child(3) {
253
+ font-family: var(--font-mono);
254
+ font-size: 0.8rem;
255
+ word-break: break-all;
256
+ }
259
257
 
260
258
  .log-badge {
261
259
  display: inline-block;
262
260
  padding: 1px 6px;
263
- border-radius: var(--radius-round);
261
+ border-radius: 999px;
264
262
  font-size: 0.7rem;
265
263
  font-weight: 600;
266
264
  text-transform: uppercase;
267
- background: var(--surface-3);
268
- color: var(--text-2);
265
+ background: var(--color-border);
266
+ color: var(--color-text-dim);
269
267
  }
270
268
 
271
269
  .log-badge--error { background: var(--red-2); color: var(--red-9); }
@@ -277,29 +275,29 @@
277
275
 
278
276
  .log-pagination {
279
277
  display: flex;
280
- gap: var(--size-2);
278
+ gap: var(--space-sm);
281
279
  align-items: center;
282
- padding: var(--size-2) 0;
280
+ padding: var(--space-sm) 0;
283
281
  }
284
282
 
285
283
  .log-tail-indicator {
286
284
  display: flex;
287
285
  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);
286
+ gap: var(--space-sm);
287
+ font-size: 0.8125rem;
288
+ color: var(--color-text-dim);
289
+ margin-bottom: var(--space-sm);
292
290
  }
293
291
 
294
292
  .tail-dot {
295
293
  width: 8px;
296
294
  height: 8px;
297
295
  border-radius: 50%;
298
- background: var(--surface-3);
296
+ background: var(--color-border);
299
297
  }
300
298
 
301
299
  .tail-dot--active {
302
- background: var(--green-6);
300
+ background: var(--status-complete);
303
301
  animation: pulse 1.5s ease-in-out infinite;
304
302
  }
305
303
 
@@ -308,7 +306,7 @@
308
306
  50% { opacity: 0.4; }
309
307
  }
310
308
 
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); }
309
+ .log-tail-entries { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-text-dim); }
310
+ .log-tail-row { padding: 2px 0; border-bottom: 1px solid var(--color-surface-raised); }
311
+ .log-empty, .log-prompt { color: var(--color-text-dim); padding: var(--space-md); }
312
+ .log-summary { color: var(--color-text-dim); font-size: 0.8125rem; margin-bottom: var(--space-sm); }
@@ -46,7 +46,7 @@
46
46
 
47
47
  .timeline__event {
48
48
  display: grid;
49
- grid-template-columns: 1rem 10rem 9rem 1fr auto;
49
+ grid-template-columns: 1rem auto 5rem 1fr auto;
50
50
  align-items: start;
51
51
  gap: var(--space-sm);
52
52
  padding: var(--space-sm) 0;
@@ -91,6 +91,7 @@
91
91
 
92
92
  .timeline__event-title {
93
93
  font-size: 0.9rem;
94
+ overflow-wrap: anywhere;
94
95
  }
95
96
 
96
97
  .timeline__event-author {
@@ -1,12 +1,26 @@
1
- // sidebar-toggle.js — Tabler vertical navbar toggle shim
2
- // Tabler's Bootstrap JS handles collapse via data-bs-toggle="collapse".
3
- // This file remains for the header button (#sidebar-toggle) which triggers
4
- // the .navbar-vertical collapse on mobile.
5
1
  document.addEventListener('DOMContentLoaded', function() {
6
2
  var toggle = document.getElementById('sidebar-toggle');
7
- var sidebarMenu = document.getElementById('sidebar-menu');
8
- if (!toggle || !sidebarMenu) return;
3
+ var sidebar = document.querySelector('.sidebar');
4
+ if (!toggle || !sidebar) return;
5
+
9
6
  toggle.addEventListener('click', function() {
10
- sidebarMenu.classList.toggle('show');
7
+ sidebar.classList.toggle('sidebar--open');
8
+ document.body.classList.toggle('sidebar-visible');
9
+ });
10
+
11
+ // Close sidebar when a nav link is clicked (mobile)
12
+ sidebar.querySelectorAll('.sidebar__nav-link').forEach(function(link) {
13
+ link.addEventListener('click', function() {
14
+ sidebar.classList.remove('sidebar--open');
15
+ document.body.classList.remove('sidebar-visible');
16
+ });
17
+ });
18
+
19
+ // Close sidebar when clicking the backdrop
20
+ document.addEventListener('click', function(e) {
21
+ if (!sidebar.classList.contains('sidebar--open')) return;
22
+ if (sidebar.contains(e.target) || toggle.contains(e.target)) return;
23
+ sidebar.classList.remove('sidebar--open');
24
+ document.body.classList.remove('sidebar-visible');
11
25
  });
12
26
  });
@@ -50,11 +50,27 @@ export function Layout({ title, children, currentView }: LayoutProps) {
50
50
  })();
51
51
  </script>`}
52
52
  <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js" defer></script>
53
- {html`<script>document.addEventListener('DOMContentLoaded', function() { mermaid.initialize({ startOnLoad: false, theme: 'neutral' }); });</script>`}
53
+ {html`<script>
54
+ document.addEventListener('DOMContentLoaded', function() {
55
+ function initMermaid() {
56
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
57
+ (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
58
+ mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'neutral' });
59
+ }
60
+ initMermaid();
61
+ new MutationObserver(function(mutations) {
62
+ mutations.forEach(function(m) { if (m.attributeName === 'data-theme') initMermaid(); });
63
+ }).observe(document.documentElement, { attributes: true });
64
+ });
65
+ </script>`}
54
66
  </head>
55
67
  <body>
56
68
  <a href="#main-content" class="skip-link">Skip to content</a>
57
69
 
70
+ <button id="sidebar-toggle" class="sidebar__toggle" type="button" aria-label="Toggle navigation">
71
+ <span aria-hidden="true">☰</span>
72
+ </button>
73
+
58
74
  <nav class="sidebar" aria-label="Main navigation">
59
75
  <div class="sidebar__brand">
60
76
  <span class="sidebar__brand-name">PBR</span>
@@ -88,6 +104,7 @@ export function Layout({ title, children, currentView }: LayoutProps) {
88
104
  </nav>
89
105
 
90
106
  <main id="main-content" class="main-content">
107
+ <div class="loading-bar" aria-hidden="true"></div>
91
108
  {children}
92
109
  </main>
93
110
 
@@ -98,6 +115,7 @@ export function Layout({ title, children, currentView }: LayoutProps) {
98
115
  {/* Local scripts */}
99
116
  <script src="/js/theme-toggle.js"></script>
100
117
  <script src="/js/sse-client.js" defer></script>
118
+ <script src="/js/sidebar-toggle.js" defer></script>
101
119
  </body>
102
120
  </html>
103
121
  );
@@ -1,6 +1,10 @@
1
1
  interface Milestone {
2
2
  version: string;
3
3
  name?: string;
4
+ goal?: string;
5
+ startPhase?: string | number;
6
+ endPhase?: string | number;
7
+ completed?: boolean;
4
8
  date?: string;
5
9
  duration?: string;
6
10
  }
@@ -27,9 +31,21 @@ export function MilestonesTab({ active, archived }: MilestonesTabProps) {
27
31
  {active.length === 0 && <p class="explorer__loading">No active milestones.</p>}
28
32
  <div class="explorer-list">
29
33
  {active.map((ms) => (
30
- <div class="explorer-item" key={ms.version}>
34
+ <div class="explorer-item" key={ms.name || ms.version}>
31
35
  <div class="explorer-item__header">
32
- <span class="explorer-item__title">v{ms.version}</span>
36
+ <span class="explorer-item__title">
37
+ {ms.name || `v${ms.version}`}
38
+ </span>
39
+ {ms.startPhase != null && ms.endPhase != null && (
40
+ <span class="explorer-item__meta">
41
+ Phase {ms.startPhase}–{ms.endPhase}
42
+ </span>
43
+ )}
44
+ {ms.goal && (
45
+ <span class="explorer-item__meta" style="flex:1; text-align:right; white-space:normal;">
46
+ {ms.goal}
47
+ </span>
48
+ )}
33
49
  <span class="explorer-badge explorer-badge--building">active</span>
34
50
  </div>
35
51
  </div>
@@ -74,16 +74,15 @@ export function TodoListFragment({ todos }: TodoListFragmentProps) {
74
74
  {todos.length === 0 && <li class="explorer__loading">No pending todos.</li>}
75
75
  {todos.map((todo) => (
76
76
  <li
77
- class="explorer-item"
77
+ class={`todo-card todo-card--${todo.priority}`}
78
78
  key={todo.id}
79
79
  x-show={`!search || '${todo.title.toLowerCase()}'.includes(search.toLowerCase())`}
80
80
  >
81
- <div class="explorer-item__header">
81
+ <div class="todo-card__main">
82
82
  <span class={`explorer-badge explorer-badge--${todo.priority}`}>{todo.priority}</span>
83
83
  <span class="explorer-item__title">{todo.title}</span>
84
- {todo.phase && <span class="explorer-item__meta">Phase {todo.phase}</span>}
85
84
  <button
86
- class="btn btn--danger btn--sm"
85
+ class="btn btn--ghost btn--sm"
87
86
  hx-post={`/api/explorer/todos/${todo.id}/complete`}
88
87
  hx-target="closest li"
89
88
  hx-swap="outerHTML"
@@ -92,6 +91,10 @@ export function TodoListFragment({ todos }: TodoListFragmentProps) {
92
91
  Done
93
92
  </button>
94
93
  </div>
94
+ <div class="todo-card__meta">
95
+ {todo.phase && <span>Phase {todo.phase}</span>}
96
+ {todo.created && <span>{new Date(todo.created).toLocaleDateString()}</span>}
97
+ </div>
95
98
  </li>
96
99
  ))}
97
100
  </ul>
@@ -25,7 +25,7 @@ export const CurrentPhaseCard: FC<CurrentPhaseCardProps> = ({
25
25
  <h2 class="card__title">
26
26
  Phase {currentPhase.id}: {currentPhase.name}
27
27
  </h2>
28
- <span class={`badge status-badge status-badge--${currentPhase.status}`}>
28
+ <span class={`badge status-badge status-badge--${currentPhase.status}`} data-status={currentPhase.status}>
29
29
  {currentPhase.status}
30
30
  </span>
31
31
  <p class="card__meta">Plans: {currentPhase.planStatus}</p>
@@ -26,7 +26,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
26
26
  <span class="status-header__phase">
27
27
  Phase {currentPhase.id}: {currentPhase.name}
28
28
  </span>
29
- <span class={`badge status-badge status-badge--${currentPhase.status}`}>
29
+ <span class={`badge status-badge status-badge--${currentPhase.status}`} data-status={currentPhase.status}>
30
30
  {currentPhase.status}
31
31
  </span>
32
32
  <div class="milestone-bar">
@@ -14,7 +14,7 @@ function sanitizeType(t: unknown): string {
14
14
  }
15
15
 
16
16
  function getTimestamp(entry: Record<string, unknown>): string {
17
- const raw = entry['timestamp'] ?? entry['ts'] ?? entry['time'];
17
+ const raw = entry['timestamp'] ?? entry['ts'] ?? entry['time'] ?? entry['start'] ?? entry['end'];
18
18
  if (!raw) return '—';
19
19
  try {
20
20
  return new Date(raw as string).toLocaleString();
@@ -23,15 +23,53 @@ function getTimestamp(entry: Record<string, unknown>): string {
23
23
  }
24
24
  }
25
25
 
26
+ function getType(entry: Record<string, unknown>): string {
27
+ if (typeof entry['type'] === 'string' && entry['type']) return entry['type'];
28
+ if (typeof entry['event'] === 'string' && entry['event']) return entry['event'];
29
+ if (typeof entry['hook'] === 'string' && entry['hook']) return 'hook';
30
+ if (typeof entry['action'] === 'string' && entry['action']) return entry['action'];
31
+ return '';
32
+ }
33
+
26
34
  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;
35
+ // Session log entry: has start/end/duration_minutes
36
+ if (entry['duration_minutes'] != null || (entry['start'] && entry['end'])) {
37
+ const parts: string[] = [];
38
+ if (entry['duration_minutes'] != null) parts.push(`Duration: ${entry['duration_minutes']}m`);
39
+ if (entry['reason']) parts.push(`Reason: ${entry['reason']}`);
40
+ if (entry['agents'] != null) parts.push(`Agents: ${entry['agents']}`);
41
+ if (parts.length > 0) return parts.join(', ');
42
+ }
43
+
44
+ // Hook log entry: has hook/script fields
45
+ if (entry['hook'] || entry['script']) {
46
+ const parts: string[] = [];
47
+ if (entry['hook']) parts.push(`Hook: ${entry['hook']}`);
48
+ if (entry['script']) parts.push(`Script: ${entry['script']}`);
49
+ if (entry['result']) parts.push(`Result: ${entry['result']}`);
50
+ if (parts.length > 0) return parts.join(', ');
51
+ }
52
+
53
+ // Event/action entry
54
+ if (entry['event'] || entry['action']) {
55
+ const parts: string[] = [];
56
+ if (entry['event']) parts.push(`Event: ${entry['event']}`);
57
+ if (entry['action']) parts.push(`Action: ${entry['action']}`);
58
+ if (parts.length > 0) return parts.join(', ');
59
+ }
60
+
61
+ // Fallback: first 3 meaningful key-value pairs
62
+ const skip = new Set(['type', 'timestamp', 'ts', 'time', 'start', 'end']);
63
+ const pairs = Object.entries(entry)
64
+ .filter(([k, v]) => !skip.has(k) && v != null && typeof v !== 'object')
65
+ .slice(0, 3)
66
+ .map(([k, v]) => `${k}: ${v}`);
67
+ return pairs.length > 0 ? pairs.join(', ') : '—';
30
68
  }
31
69
 
32
70
  export function LogEntryRow({ entry }: { entry: object }) {
33
71
  const e = entry as Record<string, unknown>;
34
- const type = typeof e['type'] === 'string' ? e['type'] : '';
72
+ const type = getType(e);
35
73
  const badge = sanitizeType(type);
36
74
  const timestamp = getTimestamp(e);
37
75
  const summary = getSummary(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.33.0",
3
+ "version": "2.34.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.33.0",
4
+ "version": "2.34.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.33.0",
4
+ "version": "2.34.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.33.0",
3
+ "version": "2.34.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",
@@ -24,7 +24,7 @@ const fs = require('fs');
24
24
  const path = require('path');
25
25
  const { logHook } = require('./hook-logger');
26
26
  const { logEvent } = require('./event-logger');
27
- const { atomicWrite } = require('./pbr-tools');
27
+ const { lockedFileUpdate } = require('./pbr-tools');
28
28
  const { resolveConfig } = require('./local-llm/health');
29
29
  const { classifyArtifact } = require('./local-llm/operations/classify-artifact');
30
30
 
@@ -464,7 +464,7 @@ function syncStateBody(content, filePath) {
464
464
  if (!needsFix) return null;
465
465
 
466
466
  try {
467
- atomicWrite(filePath, updated);
467
+ lockedFileUpdate(filePath, () => updated);
468
468
  logHook('check-plan-format', 'PostToolUse', 'body-sync', {
469
469
  fromPhase: bodyPhaseMatch[1], toPhase: fmPhase
470
470
  });
@@ -26,7 +26,7 @@ const fs = require('fs');
26
26
  const path = require('path');
27
27
  const { logHook } = require('./hook-logger');
28
28
  const { logEvent } = require('./event-logger');
29
- const { atomicWrite } = require('./pbr-tools');
29
+ const { lockedFileUpdate } = require('./pbr-tools');
30
30
 
31
31
  /**
32
32
  * Extract phase number from a phase directory name.
@@ -370,7 +370,7 @@ function checkStateSync(data) {
370
370
  } else {
371
371
  const updatedRoadmap = updateProgressTable(roadmapContent, phaseNum, plansComplete, newStatus, completedDate);
372
372
  if (updatedRoadmap !== roadmapContent) {
373
- atomicWrite(roadmapPath, updatedRoadmap);
373
+ lockedFileUpdate(roadmapPath, () => updatedRoadmap);
374
374
  messages.push(`ROADMAP.md: Phase ${phaseNum} → ${plansComplete} plans, ${newStatus}`);
375
375
  }
376
376
  }
@@ -410,7 +410,7 @@ function checkStateSync(data) {
410
410
 
411
411
  const updatedState = updateStatePosition(stateContent, stateUpdates);
412
412
  if (updatedState !== stateContent) {
413
- atomicWrite(statePath, updatedState);
413
+ lockedFileUpdate(statePath, () => updatedState);
414
414
  messages.push(`STATE.md: ${artifacts.completeSummaries}/${artifacts.plans} plans, ${overallPct}%`);
415
415
  }
416
416
  } catch (e) {
@@ -455,7 +455,7 @@ function checkStateSync(data) {
455
455
  } else {
456
456
  const updatedRoadmap = updateProgressTable(roadmapContent, phaseNum, plansComplete, roadmapStatus, completedDate);
457
457
  if (updatedRoadmap !== roadmapContent) {
458
- atomicWrite(roadmapPath, updatedRoadmap);
458
+ lockedFileUpdate(roadmapPath, () => updatedRoadmap);
459
459
  messages.push(`ROADMAP.md: Phase ${phaseNum} → ${roadmapStatus}`);
460
460
  }
461
461
  }
@@ -493,7 +493,7 @@ function checkStateSync(data) {
493
493
 
494
494
  const updatedState = updateStatePosition(stateContent, stateUpdates);
495
495
  if (updatedState !== stateContent) {
496
- atomicWrite(statePath, updatedState);
496
+ lockedFileUpdate(statePath, () => updatedState);
497
497
  messages.push(`STATE.md: ${stateStatus}, ${overallPct}%`);
498
498
  }
499
499
  } catch (e) {
@@ -543,7 +543,7 @@ function checkStateSync(data) {
543
543
  const plansComplete = `${artifacts.completeSummaries}/${artifacts.plans}`;
544
544
  const updatedRoadmap = updateProgressTable(roadmapContent, phaseNum, plansComplete, 'Planning', null);
545
545
  if (updatedRoadmap !== roadmapContent) {
546
- atomicWrite(roadmapPath, updatedRoadmap);
546
+ lockedFileUpdate(roadmapPath, () => updatedRoadmap);
547
547
  messages.push(`ROADMAP.md: Phase ${phaseNum} → Planning`);
548
548
  }
549
549
  }
@@ -21,7 +21,7 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { logHook } = require('./hook-logger');
23
23
  const { logEvent } = require('./event-logger');
24
- const { atomicWrite, configLoad, tailLines } = require('./pbr-tools');
24
+ const { configLoad, tailLines, lockedFileUpdate } = require('./pbr-tools');
25
25
 
26
26
  function main() {
27
27
  const cwd = process.cwd();
@@ -87,7 +87,7 @@ function main() {
87
87
  content = content.trimEnd() + `\n\n${continuityHeader}\n${continuityContent}\n`;
88
88
  }
89
89
 
90
- atomicWrite(stateFile, content);
90
+ lockedFileUpdate(stateFile, () => content);
91
91
 
92
92
  // Output additionalContext for post-compaction recovery
93
93
  const recoveryContext = buildRecoveryContext(activeOp, roadmapSummary, currentPlan, configHighlights, recentErrors, recentAgents);
@@ -1491,5 +1491,80 @@ function atomicWrite(filePath, content) {
1491
1491
  }
1492
1492
  }
1493
1493
 
1494
+ /**
1495
+ * Write .active-skill with OS-level mutual exclusion.
1496
+ * Uses a .active-skill.lock file with exclusive create (O_EXCL) to prevent
1497
+ * two sessions from racing on the same file.
1498
+ *
1499
+ * @param {string} planningDir - Path to .planning/ directory
1500
+ * @param {string} skillName - Skill name to write
1501
+ * @returns {{success: boolean, warning?: string}} Result
1502
+ */
1503
+ function writeActiveSkill(planningDir, skillName) {
1504
+ const skillFile = path.join(planningDir, '.active-skill');
1505
+ const lockFile = skillFile + '.lock';
1506
+ const staleThresholdMs = 60 * 60 * 1000; // 60 minutes
1507
+
1508
+ let lockFd = null;
1509
+ try {
1510
+ // Try exclusive create of lock file
1511
+ lockFd = fs.openSync(lockFile, 'wx');
1512
+ fs.writeSync(lockFd, `${process.pid}`);
1513
+ fs.closeSync(lockFd);
1514
+ lockFd = null;
1515
+
1516
+ // Check for existing .active-skill from another session
1517
+ let warning = null;
1518
+ if (fs.existsSync(skillFile)) {
1519
+ try {
1520
+ const stats = fs.statSync(skillFile);
1521
+ const ageMs = Date.now() - stats.mtimeMs;
1522
+ if (ageMs < staleThresholdMs) {
1523
+ const existing = fs.readFileSync(skillFile, 'utf8').trim();
1524
+ warning = `.active-skill already set to "${existing}" (${Math.round(ageMs / 60000)}min ago). Overwriting — possible concurrent session.`;
1525
+ }
1526
+ } catch (_e) {
1527
+ // File disappeared between exists and stat — fine
1528
+ }
1529
+ }
1530
+
1531
+ // Write the skill name
1532
+ fs.writeFileSync(skillFile, skillName, 'utf8');
1533
+
1534
+ // Release lock
1535
+ try { fs.unlinkSync(lockFile); } catch (_e) { /* best effort */ }
1536
+
1537
+ return { success: true, warning };
1538
+ } catch (e) {
1539
+ // Close fd if still open
1540
+ try { if (lockFd !== null) fs.closeSync(lockFd); } catch (_e) { /* ignore */ }
1541
+
1542
+ if (e.code === 'EEXIST') {
1543
+ // Lock held by another process — check staleness
1544
+ try {
1545
+ const lockStats = fs.statSync(lockFile);
1546
+ const lockAgeMs = Date.now() - lockStats.mtimeMs;
1547
+ if (lockAgeMs > staleThresholdMs) {
1548
+ // Stale lock — force remove and retry once
1549
+ fs.unlinkSync(lockFile);
1550
+ return writeActiveSkill(planningDir, skillName);
1551
+ }
1552
+ } catch (_statErr) {
1553
+ // Lock disappeared — retry once
1554
+ return writeActiveSkill(planningDir, skillName);
1555
+ }
1556
+ return { success: false, warning: `.active-skill.lock held by another process. Another PBR session may be active.` };
1557
+ }
1558
+
1559
+ // Other error — write without lock as fallback
1560
+ try {
1561
+ fs.writeFileSync(skillFile, skillName, 'utf8');
1562
+ return { success: true, warning: `Lock failed (${e.code}), wrote without lock` };
1563
+ } catch (writeErr) {
1564
+ return { success: false, warning: `Failed to write .active-skill: ${writeErr.message}` };
1565
+ }
1566
+ }
1567
+ }
1568
+
1494
1569
  if (require.main === module || process.argv[1] === __filename) { main().catch(err => { process.stderr.write(err.message + '\n'); process.exit(1); }); }
1495
- module.exports = { parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition };
1570
+ module.exports = { parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill };
@@ -20,7 +20,7 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
- const { execSync } = require('child_process');
23
+ const { execFileSync } = require('child_process');
24
24
  const { logHook } = require('./hook-logger');
25
25
 
26
26
  const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
@@ -132,7 +132,7 @@ function runPrettier(filePath, cwd) {
132
132
  if (!bin) return null;
133
133
 
134
134
  try {
135
- execSync(`"${bin}" --write "${filePath}"`, {
135
+ execFileSync(bin, ['--write', filePath], {
136
136
  cwd,
137
137
  timeout: 15000,
138
138
  stdio: 'pipe',
@@ -154,7 +154,7 @@ function runTypeCheck(filePath, cwd) {
154
154
  if (!fs.existsSync(path.join(cwd, 'tsconfig.json'))) return null;
155
155
 
156
156
  try {
157
- execSync(`"${bin}" --noEmit`, {
157
+ execFileSync(bin, ['--noEmit'], {
158
158
  cwd,
159
159
  timeout: 30000,
160
160
  stdio: 'pipe',
@@ -795,6 +795,14 @@ function main() {
795
795
  return;
796
796
  }
797
797
 
798
+ // Blocking/advisory gate: non-PBR agent enforcement
799
+ const nonPbrAgentResult = checkNonPbrAgent(data);
800
+ if (nonPbrAgentResult && nonPbrAgentResult.exitCode === 2) {
801
+ process.stdout.write(JSON.stringify(nonPbrAgentResult.output));
802
+ process.exit(2);
803
+ return;
804
+ }
805
+
798
806
  // Advisory warnings
799
807
  const warnings = checkTask(data);
800
808
  const manifestWarning = checkCheckpointManifest(data);
@@ -803,7 +811,6 @@ function main() {
803
811
  if (debuggerWarning) warnings.push(debuggerWarning);
804
812
  const activeSkillWarning = checkActiveSkillIntegrity(data);
805
813
  if (activeSkillWarning) warnings.push(activeSkillWarning);
806
- const nonPbrAgentResult = checkNonPbrAgent(data);
807
814
  if (nonPbrAgentResult) warnings.push(nonPbrAgentResult.output.additionalContext);
808
815
 
809
816
  // LLM task coherence check — advisory only