kaizen-agent 1.7.8 → 1.8.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/LICENSE +21 -21
- package/README.md +694 -658
- package/agents/kaizen-builder.md +82 -77
- package/agents/kaizen-planner.md +250 -250
- package/agents/kaizen-reviewer.md +127 -127
- package/cli/board.test.ts +55 -0
- package/cli/dashboard.ts +1188 -1187
- package/cli/install.ts +76 -3
- package/cli/publish-gh.sh +27 -27
- package/cli/release.sh +45 -45
- package/cli/settings.ts +7 -3
- package/cli/sources.test.ts +494 -0
- package/cli/sources.ts +323 -0
- package/cli/state.test.ts +422 -197
- package/cli/state.ts +989 -700
- package/cli/web.ts +182 -11
- package/commands/kaizen-abort.md +5 -5
- package/commands/kaizen-approve.md +5 -5
- package/commands/kaizen-auto.md +6 -6
- package/commands/kaizen-backlog.md +5 -5
- package/commands/kaizen-config.md +5 -5
- package/commands/kaizen-full.md +5 -5
- package/commands/kaizen-gather.md +5 -0
- package/commands/kaizen-init.md +5 -5
- package/commands/kaizen-install.md +5 -5
- package/commands/kaizen-lite.md +5 -5
- package/commands/kaizen-plan.md +6 -6
- package/commands/kaizen-reject.md +6 -6
- package/commands/kaizen-review.md +6 -6
- package/commands/kaizen-run.md +5 -5
- package/commands/kaizen-status.md +5 -5
- package/package.json +3 -1
- package/skills/kaizen/README.md +283 -281
- package/skills/kaizen/SKILL.md +334 -440
- package/skills/kaizen/adapters.md +84 -77
- package/skills/kaizen/config.default.yml +181 -167
- package/skills/kaizen/gather.md +50 -0
- package/skills/kaizen/install.md +42 -0
- package/skills/kaizen/settings.md +52 -0
- package/skills/kaizen/spec-build.md +131 -119
- package/skills/kaizen/spec-main.md +109 -104
- package/skills/kaizen/spec-plan.md +201 -201
- package/skills/kaizen/spec-review.md +121 -121
- package/skills/kaizen/spec.md +154 -153
- package/skills/kaizen/status.md +43 -0
- package/skills/kaizen-help/SKILL.md +131 -129
- package/web/board.html +295 -126
package/cli/state.ts
CHANGED
|
@@ -1,700 +1,989 @@
|
|
|
1
|
-
// Everything kaizen knows about a project's state, read from and written to the
|
|
2
|
-
// `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
|
|
3
|
-
// board (`web.ts`) both import this and draw it their own way.
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
5
|
-
import { join, dirname } from "node:path";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{ name: "
|
|
16
|
-
{ name: "
|
|
17
|
-
{ name: "
|
|
18
|
-
{ name: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
export const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
export
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
export function
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
run.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
export function
|
|
555
|
-
const
|
|
556
|
-
if (!existsSync(
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
export function
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
};
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return
|
|
684
|
-
} catch (err: any) {
|
|
685
|
-
return
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1
|
+
// Everything kaizen knows about a project's state, read from and written to the
|
|
2
|
+
// `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
|
|
3
|
+
// board (`web.ts`) both import this and draw it their own way.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
9
|
+
// symlink (/home -> var/home) would never compare equal to it.
|
|
10
|
+
export const home = (() => { try { return realpathSync(homedir()); } catch { return homedir(); } })();
|
|
11
|
+
|
|
12
|
+
export type Run = { id: string; stage: string; awaiting: string | null; moved: number; runner?: string; agent?: string };
|
|
13
|
+
|
|
14
|
+
export const KNOWN_AGENTS = [
|
|
15
|
+
{ name: "Claude Code", dir: ".claude", cmd: "claude" },
|
|
16
|
+
{ name: "Antigravity", dir: ".agents", cmd: "agy" },
|
|
17
|
+
{ name: "Codex", dir: ".codex", cmd: "codex" },
|
|
18
|
+
{ name: "OpenCode", dir: ".opencode", cmd: "opencode" },
|
|
19
|
+
{ name: "Gemini CLI", dir: ".gemini", cmd: "gemini" },
|
|
20
|
+
{ name: "Cursor", dir: ".cursor", cmd: "cursor" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
|
|
24
|
+
const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
|
|
25
|
+
? join(projectDir, ".kaizen", "config.yml")
|
|
26
|
+
: join(home, ".kaizen", "config.yml");
|
|
27
|
+
if (existsSync(cfgFile)) {
|
|
28
|
+
const text = readFileSync(cfgFile, "utf8");
|
|
29
|
+
const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
|
|
30
|
+
if (m && m[1] && m[1] !== "auto") {
|
|
31
|
+
const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
|
|
32
|
+
if (found && Bun.which(found.cmd)) return found;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const a of KNOWN_AGENTS) {
|
|
37
|
+
if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
|
|
38
|
+
}
|
|
39
|
+
if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
|
|
40
|
+
for (const a of KNOWN_AGENTS) {
|
|
41
|
+
if (Bun.which(a.cmd)) return a;
|
|
42
|
+
}
|
|
43
|
+
return { name: "Claude Code", cmd: "claude" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Flags that pin the launched session's model and effort, from the config chain:
|
|
47
|
+
// agent:
|
|
48
|
+
// codex: { model: gpt-5, effort: low }
|
|
49
|
+
// Each tool spells the flag its own way; a tool not listed here takes none.
|
|
50
|
+
const MODEL_FLAGS: Record<string, (m: string) => string[]> = {
|
|
51
|
+
claude: (m) => ["--model", m], agy: (m) => ["--model", m], codex: (m) => ["-m", m],
|
|
52
|
+
gemini: (m) => ["-m", m], opencode: (m) => ["-m", m],
|
|
53
|
+
};
|
|
54
|
+
const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
|
|
55
|
+
claude: (e) => ["--effort", e], agy: (e) => ["--effort", e],
|
|
56
|
+
codex: (e) => ["-c", `model_reasoning_effort=${e}`],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
|
|
60
|
+
if (configText === undefined) {
|
|
61
|
+
const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
|
|
62
|
+
configText = f ? readFileSync(f, "utf8") : "";
|
|
63
|
+
}
|
|
64
|
+
// ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
|
|
65
|
+
const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
|
|
66
|
+
if (!block) return [];
|
|
67
|
+
const get = (k: string) => new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
|
|
68
|
+
const model = get("model"), effort = get("effort");
|
|
69
|
+
return [
|
|
70
|
+
...(model && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](model) : []),
|
|
71
|
+
...(effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](effort) : []),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One argument as a POSIX shell reads it literally: a prompt carries quotes, `$`,
|
|
76
|
+
// backticks and, with notes, newlines.
|
|
77
|
+
const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
78
|
+
|
|
79
|
+
// Bun.which reads the PATH the process started with unless handed the current one.
|
|
80
|
+
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
|
|
81
|
+
|
|
82
|
+
// Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
|
|
83
|
+
// throws on a missing binary, so the path is resolved first.
|
|
84
|
+
function answers(cmd: string[]): boolean {
|
|
85
|
+
const bin = onPath(cmd[0]);
|
|
86
|
+
if (!bin) return false;
|
|
87
|
+
try {
|
|
88
|
+
return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000 }).exitCode === 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
|
|
95
|
+
const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
|
|
96
|
+
|
|
97
|
+
// Windows has no xdg anything, and none of the terminals below exist there.
|
|
98
|
+
// PowerShell is what a Windows user has open anyway. The command reaches it as
|
|
99
|
+
// -EncodedCommand (base64 of UTF-16LE): a prompt with notes has newlines, which
|
|
100
|
+
// end a cmd.exe command line, and semicolons, which Windows Terminal splits its
|
|
101
|
+
// own arguments on -- neither survives as a plain -Command string. Windows
|
|
102
|
+
// Terminal hosts the shell when installed; otherwise `start` gives it a window.
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
105
|
+
const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
|
|
106
|
+
const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
|
|
107
|
+
const enc = Buffer.from(ps, "utf16le").toString("base64");
|
|
108
|
+
if (Bun.which("wt.exe")) {
|
|
109
|
+
return { cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
|
|
110
|
+
}
|
|
111
|
+
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
112
|
+
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
113
|
+
return {
|
|
114
|
+
cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
|
|
115
|
+
detached: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Someone working inside a multiplexer wants the run there, not in a stray window.
|
|
120
|
+
// The server is asked each time: the board outlives the multiplexer it was started
|
|
121
|
+
// in, so $TMUX or $HERDR_ENV only says what was running then. herdr has no "new tab
|
|
122
|
+
// running this", and what it sends a pane is typed into an interactive shell, which
|
|
123
|
+
// eats tabs and quotes its own way -- so the line goes into a one-shot script and
|
|
124
|
+
// only `sh <path>` is typed.
|
|
125
|
+
// ponytail: the mktemp path is typed unquoted; quote it if a TMPDIR with spaces shows up.
|
|
126
|
+
const line = fullCmd.map(shq).join(" ");
|
|
127
|
+
if (answers(["herdr", "workspace", "list"])) {
|
|
128
|
+
const script = `f=$(mktemp) && printf 'rm -f "$0"\\nexec %s\\n' "$2" > "$f" && p=$(herdr tab create --cwd "$1" --label kaizen --focus | sed -n 's/.*"pane_id":"\\([^"]*\\)".*/\\1/p') && [ -n "$p" ] && herdr pane run "$p" "sh $f"`;
|
|
129
|
+
return { cmd: ["sh", "-c", script, "sh", cwd, line], detached: false };
|
|
130
|
+
}
|
|
131
|
+
if (answers(["tmux", "has-session"])) {
|
|
132
|
+
// Several arguments are run as they are; one would go through the default-shell.
|
|
133
|
+
return { cmd: ["tmux", "new-window", "-c", cwd, ...fullCmd], detached: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
|
|
137
|
+
const term = process.env.TERMINAL;
|
|
138
|
+
if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
|
|
139
|
+
if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
140
|
+
if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
141
|
+
if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
|
|
142
|
+
return { cmd: [term, "-e", ...fullCmd], detached: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hasDisplay && Bun.which("xdg-terminal-exec")) {
|
|
146
|
+
return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (hasDisplay) {
|
|
150
|
+
if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
|
|
151
|
+
if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
152
|
+
if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
153
|
+
if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
|
|
154
|
+
if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
|
|
155
|
+
if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
|
|
156
|
+
if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
|
|
157
|
+
if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
|
|
158
|
+
// One argument after -e is handed to the login shell, which may not be a POSIX one.
|
|
159
|
+
if (Bun.which("xterm")) return { cmd: ["xterm", "-e", "sh", "-c", 'cd "$1" && shift && exec "$@"', "sh", cwd, ...fullCmd], detached: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (process.platform === "darwin") {
|
|
163
|
+
// Terminal types what it is given into the login shell, so, as with herdr, the
|
|
164
|
+
// line goes into a one-shot script and only `sh <path>` is typed.
|
|
165
|
+
const script = `f=$(mktemp) && printf 'rm -f "$0"\\ncd %s && exec %s\\n' "$1" "$2" > "$f" && osascript -e "tell application \\"Terminal\\" to do script \\"sh $f\\"" -e 'tell application "Terminal" to activate'`;
|
|
166
|
+
return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The list of projects kaizen knows about. Appended to as kaizen sets one up or is
|
|
173
|
+
// opened inside one, and seeded by the Find projects action. Plain lines so it can be
|
|
174
|
+
// read and corrected in an editor; blanks and # lines ignored.
|
|
175
|
+
export const REGISTRY = join(home, ".kaizen", "projects");
|
|
176
|
+
|
|
177
|
+
// Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
|
|
178
|
+
// and a search that only knows $HOME finds none of them. Every letter that answers is
|
|
179
|
+
// a root worth walking; elsewhere there is one filesystem and $HOME is where work
|
|
180
|
+
// lives, so walking / would cost minutes to find nothing.
|
|
181
|
+
export function searchRoots(extra: string[] = []): string[] {
|
|
182
|
+
const roots = [home, ...extra];
|
|
183
|
+
if (process.platform === "win32") {
|
|
184
|
+
for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
185
|
+
const drive = `${letter}:\\`;
|
|
186
|
+
try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return roots.filter((d, i, all) => d && all.indexOf(d) === i);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function knownProjects(): string[] {
|
|
193
|
+
if (!existsSync(REGISTRY)) return [];
|
|
194
|
+
return readFileSync(REGISTRY, "utf8").split("\n")
|
|
195
|
+
.map((l) => l.trim())
|
|
196
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
200
|
+
// dashboard to stop opening.
|
|
201
|
+
export function remember(projectDir: string) {
|
|
202
|
+
try {
|
|
203
|
+
if (knownProjects().includes(projectDir)) return;
|
|
204
|
+
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
205
|
+
appendFileSync(REGISTRY, projectDir + "\n");
|
|
206
|
+
} catch { /* nothing here is worth an error */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const SKIP = new Set([
|
|
210
|
+
"windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
|
|
211
|
+
"system volume information", "appdata", "node_modules", "recovery", "perflogs",
|
|
212
|
+
"library", "applications", "onedrive", "onedrivetemp",
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
// Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
|
|
216
|
+
// the skips are the directories that make one slow: node_modules, and anything dotted
|
|
217
|
+
// (which includes .git, and every state directory that is not a project of its own).
|
|
218
|
+
export function findProjects(root: string, depth = 4): string[] {
|
|
219
|
+
const found: string[] = [];
|
|
220
|
+
const walk = (dir: string, left: number) => {
|
|
221
|
+
let entries: string[];
|
|
222
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
223
|
+
// $HOME holds ~/.kaizen, the global state, which is not a project and must not
|
|
224
|
+
// stop the walk before it has looked at anything.
|
|
225
|
+
// A project can hold projects of its own -- a monorepo, or a folder of them --
|
|
226
|
+
// so finding one is not a reason to stop looking underneath it.
|
|
227
|
+
if (dir !== root && entries.includes(".kaizen")) found.push(dir);
|
|
228
|
+
if (left === 0) return;
|
|
229
|
+
for (const name of entries) {
|
|
230
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
231
|
+
// Walking a whole drive means walking Windows itself otherwise: tens of
|
|
232
|
+
// thousands of directories that have never held a project.
|
|
233
|
+
if (SKIP.has(name.toLowerCase())) continue;
|
|
234
|
+
try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
|
|
235
|
+
catch { /* unreadable or vanished mid-walk */ }
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(root, depth);
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function locate() {
|
|
243
|
+
let dir = process.cwd();
|
|
244
|
+
for (;;) {
|
|
245
|
+
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
246
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
247
|
+
const up = dirname(dir);
|
|
248
|
+
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
249
|
+
dir = up;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function readRuns(state: string): Run[] {
|
|
254
|
+
const dir = join(state, "runs");
|
|
255
|
+
if (!existsSync(dir)) return [];
|
|
256
|
+
// readdir order is the filesystem's, not the alphabet's; ids start with the date,
|
|
257
|
+
// so sorting is what makes "newest first" true everywhere.
|
|
258
|
+
return readdirSync(dir).sort().flatMap((id) => {
|
|
259
|
+
try {
|
|
260
|
+
const file = join(dir, id, "state.json");
|
|
261
|
+
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
262
|
+
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
263
|
+
// own mtime is the only evidence that anything is still working on it.
|
|
264
|
+
let moved = 0;
|
|
265
|
+
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
266
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
|
|
267
|
+
} catch { return []; }
|
|
268
|
+
}).reverse();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The finished runs beyond the newest `keep`, oldest first; with `apply`, deleted.
|
|
272
|
+
// A run still in plan, build or review is never a candidate, whatever its age. A
|
|
273
|
+
// done run's open backlog items move to the orphanage first; an abandoned run's are
|
|
274
|
+
// not open work and go with it.
|
|
275
|
+
export function pruneRuns(state: string, keep: number, apply = false): { id: string; rescued: number }[] {
|
|
276
|
+
const finished = readRuns(state).filter((r) => r.stage === "done" || r.stage === "abandoned");
|
|
277
|
+
return finished.slice(Math.max(0, keep)).reverse().map((r) => {
|
|
278
|
+
const dir = join(state, "runs", r.id);
|
|
279
|
+
const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
|
|
280
|
+
if (apply) {
|
|
281
|
+
const orphanage = join(state, "backlog.md");
|
|
282
|
+
const gap = existsSync(orphanage) && !readFileSync(orphanage, "utf8").endsWith("\n") ? "\n" : "";
|
|
283
|
+
if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
|
|
284
|
+
rmSync(dir, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
return { id: r.id, rescued: open.length };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// True when the last fix of the run's fix loop was never rechecked: the highest
|
|
291
|
+
// NN-fix.md in 05-iterations/ has no NN-recheck.md beside it.
|
|
292
|
+
export function unrechecked(state: string, id: string) {
|
|
293
|
+
const dir = join(state, "runs", id, "05-iterations");
|
|
294
|
+
if (!existsSync(dir)) return false;
|
|
295
|
+
const files = readdirSync(dir);
|
|
296
|
+
const last = files.filter((f) => /^\d+-fix\.md$/.test(f)).sort().pop();
|
|
297
|
+
return !!last && !files.includes(last.replace("-fix.md", "-recheck.md"));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Open items are the ones worth a number; done and rejected stay as record.
|
|
301
|
+
export function backlog(state: string) {
|
|
302
|
+
const files = [join(state, "backlog.md")];
|
|
303
|
+
const runs = join(state, "runs");
|
|
304
|
+
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
305
|
+
let n = 0;
|
|
306
|
+
for (const f of files) {
|
|
307
|
+
if (!existsSync(f)) continue;
|
|
308
|
+
n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
|
|
309
|
+
}
|
|
310
|
+
return n;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function version(repo: string) {
|
|
314
|
+
try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
|
|
315
|
+
}
|
|
316
|
+
// The global state directory is not a project and has no parent worth naming.
|
|
317
|
+
export function label(stateDir: string) {
|
|
318
|
+
return stateDir === join(home, ".kaizen") ? "no project" : tilde(dirname(stateDir));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
322
|
+
|
|
323
|
+
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
324
|
+
export function section(file: string, n: number) {
|
|
325
|
+
if (!existsSync(file)) return [];
|
|
326
|
+
return readFileSync(file, "utf8").split("\n")
|
|
327
|
+
.map((l) => l.trim())
|
|
328
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
329
|
+
.slice(0, n);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function readFindings(file: string) {
|
|
333
|
+
if (!existsSync(file)) return [];
|
|
334
|
+
return readFileSync(file, "utf8").split("\n")
|
|
335
|
+
.map((l) => l.trim())
|
|
336
|
+
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The review's numbered findings, under its Findings heading when it has one. The
|
|
340
|
+
// number is the reviewer's own, which is how a fix names a finding. `done` is what
|
|
341
|
+
// the run's backlog says was fixed or rejected, by number or by the finding's text,
|
|
342
|
+
// or what a recheck in the fix loop called closed before the backlog was written.
|
|
343
|
+
// ponytail: a recheck is prose, matched as "Finding <n> — closed"; one that numbers
|
|
344
|
+
// its own findings instead of the review's would close the wrong line here.
|
|
345
|
+
export function reviewFindings(state: string, id: string) {
|
|
346
|
+
const base = join(state, "runs", id);
|
|
347
|
+
const read = (f: string) => {
|
|
348
|
+
const p = join(base, f);
|
|
349
|
+
return existsSync(p) ? readFileSync(p, "utf8").split("\n").map((l) => l.trim()) : [];
|
|
350
|
+
};
|
|
351
|
+
let lines = read("04-review.md");
|
|
352
|
+
const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
|
|
353
|
+
if (at >= 0) {
|
|
354
|
+
// Sub-headings group findings; only a heading at the section's own level ends it.
|
|
355
|
+
const depth = (l: string) => /^#*/.exec(l)![0].length;
|
|
356
|
+
const end = lines.findIndex((l, i) => i > at && depth(l) > 0 && depth(l) <= depth(lines[at]!));
|
|
357
|
+
lines = lines.slice(at + 1, end < 0 ? undefined : end);
|
|
358
|
+
}
|
|
359
|
+
const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
|
|
360
|
+
const iter = join(base, "05-iterations");
|
|
361
|
+
const rechecked = new Set((existsSync(iter) ? readdirSync(iter) : []).filter((f) => f.endsWith("-recheck.md"))
|
|
362
|
+
.flatMap((f) => [...readFileSync(join(iter, f), "utf8").matchAll(/finding\s+(\d+)\W{1,8}closed/gi)].map((m) => Number(m[1]))));
|
|
363
|
+
return lines.flatMap((l) => {
|
|
364
|
+
const m = /^(\d+)\.\s+(.+)$/.exec(l);
|
|
365
|
+
if (!m) return [];
|
|
366
|
+
const n = Number(m[1]);
|
|
367
|
+
return [{ n, text: m[2]!, done: rechecked.has(n) || closed.some((c) => c.startsWith(`${n}. `) || c.includes(m[2]!)) }];
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The numbers a fix request may name: every one an open finding of this review, or
|
|
372
|
+
// null. One that is not refuses the lot, so a stale page never fixes half of what it showed.
|
|
373
|
+
export function pickFindings(state: string, id: string, nums: unknown): number[] | null {
|
|
374
|
+
const want = new Set<unknown>(Array.isArray(nums) ? nums : []);
|
|
375
|
+
const got = reviewFindings(state, id).filter((f) => !f.done && want.has(f.n)).map((f) => f.n);
|
|
376
|
+
return got.length && got.length === want.size ? got : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// What the agent is launched with to fix picked findings: the run and the numbers,
|
|
380
|
+
// nothing else. launchRun puts `/kaizen ` in front.
|
|
381
|
+
export function fixPrompt(id: string, nums: number[]) {
|
|
382
|
+
return `run ${id}: fix finding${nums.length === 1 ? "" : "s"} ${nums.join(", ")} from its 04-review.md and recheck. `
|
|
383
|
+
+ `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
387
|
+
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
388
|
+
// severity and the place as columns and leave the prose to speak for itself.
|
|
389
|
+
export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
|
|
390
|
+
|
|
391
|
+
export function parseItem(raw: string): Item {
|
|
392
|
+
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
393
|
+
if (m) {
|
|
394
|
+
const where = m[1]!.trim().split("/").pop()!;
|
|
395
|
+
return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
|
|
396
|
+
}
|
|
397
|
+
const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
398
|
+
if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
|
|
399
|
+
return { severity: null, where: null, text: tidy(raw), raw };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// One readable sentence: no markdown, and the fix belongs in the full view. The
|
|
403
|
+
// first word is left alone -- capitalising it turns request_for_id into
|
|
404
|
+
// Request_for_id, which is a different identifier.
|
|
405
|
+
export function tidy(text: string) {
|
|
406
|
+
return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function readBacklog(file: string) {
|
|
410
|
+
if (!existsSync(file)) return [];
|
|
411
|
+
return readFileSync(file, "utf8").split("\n")
|
|
412
|
+
.map((l) => l.trim())
|
|
413
|
+
.filter((l) => /^-\s*open:/.test(l))
|
|
414
|
+
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function allBacklog(state: string) {
|
|
418
|
+
const out: { run: string; items: string[] }[] = [];
|
|
419
|
+
const runs = join(state, "runs");
|
|
420
|
+
if (existsSync(runs)) {
|
|
421
|
+
for (const id of readdirSync(runs).sort().reverse()) {
|
|
422
|
+
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
423
|
+
if (items.length) out.push({ run: id, items });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const orphan = readBacklog(join(state, "backlog.md"));
|
|
427
|
+
if (orphan.length) out.push({ run: "Orphaned", items: orphan });
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------- board data
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------- board data
|
|
434
|
+
|
|
435
|
+
export type Card = {
|
|
436
|
+
kind: "idea" | "run";
|
|
437
|
+
id?: string; // runs only: the directory name
|
|
438
|
+
status: string; // what the legend calls this card
|
|
439
|
+
text: string;
|
|
440
|
+
state: string;
|
|
441
|
+
where: string;
|
|
442
|
+
column: number;
|
|
443
|
+
awaiting: string | null;
|
|
444
|
+
dim: boolean;
|
|
445
|
+
moved?: number; // runs only: state.json mtime
|
|
446
|
+
title?: string; // runs only: first line of the request
|
|
447
|
+
archived: boolean; // hidden from the board unless asked for
|
|
448
|
+
tags?: string[]; // runs only: runner, agent, "unrechecked"
|
|
449
|
+
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// A run is called running on the evidence that it moved recently; there is no PID
|
|
453
|
+
// to ask. Anything in flight and older than this is stalled, not running -- the
|
|
454
|
+
// difference the board exists to show.
|
|
455
|
+
|
|
456
|
+
// The verbatim request of every run, normalised, so an idea that has become a run
|
|
457
|
+
// can be retired from the board.
|
|
458
|
+
// ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
|
|
459
|
+
// cache by mtime if a project ever has hundreds.
|
|
460
|
+
export function startedRuns(state: string): string[] {
|
|
461
|
+
const dir = join(state, "runs");
|
|
462
|
+
if (!existsSync(dir)) return [];
|
|
463
|
+
const out: string[] = [];
|
|
464
|
+
for (const id of readdirSync(dir)) {
|
|
465
|
+
try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
|
|
466
|
+
catch { /* no request file, or unreadable */ }
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function normalise(text: string) {
|
|
472
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export const RUNNING_WITHIN_MS = 30 * 60_000;
|
|
476
|
+
|
|
477
|
+
// The status is what the legend lists; how it is drawn is each UI's business, so
|
|
478
|
+
// nothing here knows about colour.
|
|
479
|
+
export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
|
|
480
|
+
|
|
481
|
+
export function statusOf(r: Run, now: number): Status {
|
|
482
|
+
if (r.stage === "abandoned") return "abandoned";
|
|
483
|
+
if (r.awaiting) return "waiting";
|
|
484
|
+
if (r.stage === "done") return "done";
|
|
485
|
+
return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A stage this build has never heard of still belongs somewhere visible. What the
|
|
489
|
+
// run is waiting on says where it really is (a run awaiting the review approval is
|
|
490
|
+
// in Review whatever its stage word), and PLANNING is the honest guess otherwise:
|
|
491
|
+
// the run has started and has not finished.
|
|
492
|
+
export function columnOf(stage: string, awaiting: string | null = null) {
|
|
493
|
+
if (stage === "build") return 2;
|
|
494
|
+
if (stage === "review") return 3;
|
|
495
|
+
if (stage === "done" || stage === "abandoned") return 4;
|
|
496
|
+
if (awaiting === "approvals.review" || awaiting === "findings") return 3;
|
|
497
|
+
if (awaiting === "approvals.each_file") return 2;
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
502
|
+
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
503
|
+
export type InboxLine = { status: string; text: string; notes?: string };
|
|
504
|
+
|
|
505
|
+
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
506
|
+
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
507
|
+
export function readInbox(state: string): InboxLine[] {
|
|
508
|
+
const f = join(state, "inbox.md");
|
|
509
|
+
if (!existsSync(f)) return [];
|
|
510
|
+
const out: InboxLine[] = [];
|
|
511
|
+
for (const raw of readFileSync(f, "utf8").split("\n")) {
|
|
512
|
+
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
513
|
+
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
514
|
+
// An indented line belongs to the bullet above it; anything else is noise.
|
|
515
|
+
const last = out[out.length - 1];
|
|
516
|
+
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Whole-file rewrite. The board is the only writer today.
|
|
522
|
+
// ponytail: single writer assumed; if a kaizen stage ever appends here too, this
|
|
523
|
+
// needs a lock or an append-only journal.
|
|
524
|
+
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
525
|
+
mkdirSync(state, { recursive: true });
|
|
526
|
+
const body = [
|
|
527
|
+
"# Inbox",
|
|
528
|
+
"",
|
|
529
|
+
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
530
|
+
"",
|
|
531
|
+
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
532
|
+
];
|
|
533
|
+
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function noteLines(notes?: string) {
|
|
537
|
+
return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The request the agent is launched with: the text, then the notes as the rest of the
|
|
541
|
+
// request, so they land verbatim in 00-request.md and the planner reads them.
|
|
542
|
+
export function requestOf(text: string, notes?: string, state?: string) {
|
|
543
|
+
let n = (notes ?? "").trim();
|
|
544
|
+
if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
|
|
545
|
+
return n ? `${text}\n\n${n}` : text;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Free text beside a run -- what the user learned after the idea became a run.
|
|
549
|
+
// Its own file, never state.json: that shape is the resume contract.
|
|
550
|
+
export function readNotes(state: string, id: string) {
|
|
551
|
+
try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
555
|
+
const dir = join(state, "runs", id);
|
|
556
|
+
if (!existsSync(join(dir, "state.json"))) return "no such run";
|
|
557
|
+
writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
|
|
562
|
+
// lines indented. Text before the first stamp is one unstamped entry, so notes
|
|
563
|
+
// written by hand still read.
|
|
564
|
+
export type Note = { at: string; text: string };
|
|
565
|
+
|
|
566
|
+
export function parseNotes(src: string): Note[] {
|
|
567
|
+
const out: Note[] = [];
|
|
568
|
+
for (const raw of src.split("\n")) {
|
|
569
|
+
const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
|
|
570
|
+
if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
|
|
571
|
+
const last = out[out.length - 1];
|
|
572
|
+
if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
|
|
573
|
+
else if (raw.trim()) out.push({ at: "", text: raw });
|
|
574
|
+
}
|
|
575
|
+
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function stamp(now = new Date()) {
|
|
579
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
580
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function formatNote(text: string, at = stamp()) {
|
|
584
|
+
return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
|
|
588
|
+
// bullet. Attachments arrive as markdown links already written into text.
|
|
589
|
+
export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
|
|
590
|
+
if (!text.trim()) return "empty note";
|
|
591
|
+
if ("id" in target) {
|
|
592
|
+
if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
|
|
593
|
+
appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const lines = readInbox(state);
|
|
597
|
+
const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
|
|
598
|
+
if (at < 0) return "no such idea";
|
|
599
|
+
const prev = lines[at]!.notes;
|
|
600
|
+
lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
|
|
601
|
+
writeInbox(state, lines);
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
|
|
606
|
+
// <state>/attachments/ for an idea. Returns the path relative to the state dir,
|
|
607
|
+
// which is what the note links and what /file serves.
|
|
608
|
+
// ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
|
|
609
|
+
export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
|
|
610
|
+
const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
|
|
611
|
+
const rel = id ? join("runs", id, "attachments") : "attachments";
|
|
612
|
+
mkdirSync(join(state, rel), { recursive: true });
|
|
613
|
+
const file = `${Date.now().toString(36)}-${safe}`;
|
|
614
|
+
writeFileSync(join(state, rel, file), bytes);
|
|
615
|
+
return join(rel, file);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// What a card is worth interrupting someone for, in the words both UIs send: a run
|
|
619
|
+
// that wants a decision, or one that finished. Null for everything else. The second
|
|
620
|
+
// line is the project, since a notification outlives the board that raised it.
|
|
621
|
+
const NEED: Record<string, string> = {
|
|
622
|
+
"approvals.plan": "plan needs your approval",
|
|
623
|
+
"approvals.review": "review needs your approval",
|
|
624
|
+
"approvals.each_file": "an edit needs your approval",
|
|
625
|
+
findings: "pick the findings to fix",
|
|
626
|
+
};
|
|
627
|
+
export function notice(c: Card): string | null {
|
|
628
|
+
if (c.kind !== "run") return null;
|
|
629
|
+
const what = c.awaiting ? NEED[c.awaiting] ?? `waiting on ${c.awaiting}` : c.status === "done" ? "done" : null;
|
|
630
|
+
return what && `${c.text} — ${what}\n${c.where}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// When a notice is worth sending, for both UIs: each keeps one of these, and the
|
|
634
|
+
// board page is served this very function, so it must stand on its own -- no
|
|
635
|
+
// imports, no outer names. A run never seen before says nothing, which covers first
|
|
636
|
+
// load and a run arriving already waiting. A `done` within a minute of the run
|
|
637
|
+
// leaving a wait was finished by the person's own decision, so that one stays quiet.
|
|
638
|
+
// ponytail: a fixed minute; an approval whose last writes take longer still pings.
|
|
639
|
+
export function notifier() {
|
|
640
|
+
const said = new Map<string, string | null>(), left = new Map<string, number>();
|
|
641
|
+
return (key: string, say: string | null, done: boolean, now: number) => {
|
|
642
|
+
const before = said.get(key);
|
|
643
|
+
said.set(key, say);
|
|
644
|
+
if (before && say !== before) left.set(key, now);
|
|
645
|
+
return before !== undefined && !!say && say !== before && !(done && now - (left.get(key) ?? -Infinity) < 60_000);
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export const LOGO = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
|
|
650
|
+
|
|
651
|
+
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
652
|
+
export function notifyArgs(body: string) {
|
|
653
|
+
return ["notify-send", "-a", "kaizen", ...(existsSync(LOGO) ? ["-i", LOGO] : []), "kaizen", body];
|
|
654
|
+
}
|
|
655
|
+
export function notify(body: string) {
|
|
656
|
+
try {
|
|
657
|
+
if (!Bun.which("notify-send")) return;
|
|
658
|
+
Bun.spawn(notifyArgs(body), {
|
|
659
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
|
|
660
|
+
}).unref();
|
|
661
|
+
} catch { /* notifications are not worth an exception */ }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Run ids carry the date they started; the list is already in date order.
|
|
665
|
+
export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
|
|
666
|
+
|
|
667
|
+
// The only run state the board writes, and only this transition: stage becomes
|
|
668
|
+
// abandoned, awaiting clears. Every other field is preserved, so a run abandoned
|
|
669
|
+
// here still resumes and reads like one abandoned by /kaizen abort.
|
|
670
|
+
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
671
|
+
const file = join(st, "runs", id, "state.json");
|
|
672
|
+
try {
|
|
673
|
+
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
674
|
+
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
675
|
+
run.stage = "abandoned";
|
|
676
|
+
run.awaiting = null;
|
|
677
|
+
run.updated = new Date().toISOString();
|
|
678
|
+
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
679
|
+
// The reason belongs in the record, not in state.json, whose shape is the
|
|
680
|
+
// resume contract every kaizen tool reads.
|
|
681
|
+
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
682
|
+
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
683
|
+
return null;
|
|
684
|
+
} catch (err: any) {
|
|
685
|
+
return err?.message ?? String(err);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function replaceIdea(state: string, text: string, next: InboxLine | null) {
|
|
690
|
+
const lines = readInbox(state);
|
|
691
|
+
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
692
|
+
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
693
|
+
if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
|
|
694
|
+
writeInbox(state, lines);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Archived runs stay exactly where they are on disk -- run directories are never
|
|
698
|
+
// moved or renamed, that is the resume contract -- and are listed here instead.
|
|
699
|
+
// One id per line; the board hides them unless asked for the archive.
|
|
700
|
+
export function readArchive(state: string): Set<string> {
|
|
701
|
+
const f = join(state, "archive.md");
|
|
702
|
+
if (!existsSync(f)) return new Set();
|
|
703
|
+
return new Set(readFileSync(f, "utf8").split("\n")
|
|
704
|
+
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
705
|
+
.filter((x): x is string => !!x));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function writeArchive(state: string, ids: Set<string>) {
|
|
709
|
+
mkdirSync(state, { recursive: true });
|
|
710
|
+
const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
|
|
711
|
+
...[...ids].sort().map((id) => `- ${id}`)];
|
|
712
|
+
writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function setArchived(state: string, id: string, archived: boolean) {
|
|
716
|
+
const ids = readArchive(state);
|
|
717
|
+
if (archived) ids.add(id); else ids.delete(id);
|
|
718
|
+
writeArchive(state, ids);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// The request's first real line: not a heading, not a label ending in a colon,
|
|
722
|
+
// quote marker dropped. Long enough to recognise the run, short enough for a card.
|
|
723
|
+
export function requestTitle(text: string) {
|
|
724
|
+
const line = text.split("\n")
|
|
725
|
+
.map((l) => l.replace(/^>\s*/, "").trim())
|
|
726
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
727
|
+
.slice(0, 6)
|
|
728
|
+
.find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
|
|
729
|
+
return line.length > 140 ? line.slice(0, 139) + "…" : line;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Every card on the board, for the state dirs asked for. Retirement of an idea is
|
|
733
|
+
// checked against every known project's runs, not just these: an idea in the global
|
|
734
|
+
// inbox becomes a run in whichever project the agent was started in.
|
|
735
|
+
export function boardCards(states: string[], now: number): Card[] {
|
|
736
|
+
const next: Card[] = [];
|
|
737
|
+
// One read per request file; both the retirement check and the card title come
|
|
738
|
+
// from the same text.
|
|
739
|
+
// ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
|
|
740
|
+
// project ever has hundreds.
|
|
741
|
+
const bodies = new Map<string, string>();
|
|
742
|
+
const body = (st: string, id: string) => {
|
|
743
|
+
const f = join(st, "runs", id, "00-request.md");
|
|
744
|
+
if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
|
|
745
|
+
return bodies.get(f)!;
|
|
746
|
+
};
|
|
747
|
+
const requests: string[] = [];
|
|
748
|
+
// Each request line on its own, quote and list markers stripped: what an idea
|
|
749
|
+
// nobody launched from the board is matched against.
|
|
750
|
+
const lines: string[] = [];
|
|
751
|
+
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
752
|
+
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
753
|
+
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b) continue;
|
|
754
|
+
requests.push(normalise(b));
|
|
755
|
+
for (const l of b.split("\n")) lines.push(normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (const st of states) {
|
|
759
|
+
const where = label(st);
|
|
760
|
+
const archive = readArchive(st);
|
|
761
|
+
for (const it of readInbox(st)) {
|
|
762
|
+
if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
|
|
763
|
+
// Retired once any run's request contains this text -- whoever started it.
|
|
764
|
+
// Checking `started` items only would leave every idea acted on outside the
|
|
765
|
+
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
766
|
+
// the common way a run begins.
|
|
767
|
+
// An idea that was launched only has to appear somewhere in a request. One
|
|
768
|
+
// that was not must be what a request line says (after any `/kaizen lite`
|
|
769
|
+
// prefix): a short idea like "test" is a substring of half the requests
|
|
770
|
+
// ever written, and would vanish the moment it was added.
|
|
771
|
+
const t = normalise(it.text);
|
|
772
|
+
if (it.status === "started" ? requests.some((r) => r.includes(t)) : lines.some((l) => l === t || l.endsWith(" " + t))) continue;
|
|
773
|
+
next.push(it.status === "started"
|
|
774
|
+
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
775
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
776
|
+
}
|
|
777
|
+
for (const r of readRuns(st)) {
|
|
778
|
+
next.push({
|
|
779
|
+
kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
|
|
780
|
+
title: requestTitle(body(st, r.id)),
|
|
781
|
+
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
782
|
+
dim: r.stage === "abandoned",
|
|
783
|
+
archived: archive.has(r.id),
|
|
784
|
+
tags: [r.runner, r.agent, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
|
|
785
|
+
notes: readNotes(st, r.id) || undefined,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Done reads newest first: by `moved`, the time each card prints as its age.
|
|
790
|
+
// Sorted into the slots the done cards already hold, so no other card moves.
|
|
791
|
+
const done = next.filter((k) => k.column === 4).sort((a, b) => (b.moved ?? 0) - (a.moved ?? 0));
|
|
792
|
+
let i = 0;
|
|
793
|
+
return next.map((k) => (k.column === 4 ? done[i++]! : k));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
797
|
+
// launch the same way and only differ in how they tell the user.
|
|
798
|
+
export type Launch =
|
|
799
|
+
| { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
|
|
800
|
+
| { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
|
|
801
|
+
|
|
802
|
+
export function projectOf(from: string) {
|
|
803
|
+
return from === join(home, ".kaizen")
|
|
804
|
+
? process.cwd()
|
|
805
|
+
: (from.endsWith(".kaizen") ? dirname(from) : from);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
|
|
809
|
+
const bin = [agentCmd, ...flags].join(" ");
|
|
810
|
+
// Pasted into whatever shell the user has. fish reads \' and \\ inside single
|
|
811
|
+
// quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
|
|
812
|
+
const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
|
|
813
|
+
return process.platform === "win32"
|
|
814
|
+
? `cd '${projectDir}'; & ${bin} '${prompt.replace(/'/g, "''")}'`
|
|
815
|
+
: `cd ${q(projectDir)} && ${bin} ${q(prompt)}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function launchRun(it: Item, from: string): Launch {
|
|
819
|
+
const projectDir = projectOf(from);
|
|
820
|
+
// The run will be born here; the board must know the dir or the idea never retires.
|
|
821
|
+
remember(projectDir);
|
|
822
|
+
const agent = detectDefaultAgent(projectDir);
|
|
823
|
+
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
824
|
+
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
825
|
+
const flags = agentFlags(projectDir, agent.cmd);
|
|
826
|
+
const fullCmd = [agentBin, ...flags, prompt];
|
|
827
|
+
const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
|
|
828
|
+
|
|
829
|
+
const term = findTerminal(projectDir, fullCmd);
|
|
830
|
+
if (!term) {
|
|
831
|
+
return {
|
|
832
|
+
ok: false, agent, prompt, projectDir, manual,
|
|
833
|
+
why: process.platform === "darwin"
|
|
834
|
+
? "Terminal.app could not be driven from here."
|
|
835
|
+
: "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const proc = Bun.spawn(term.cmd, {
|
|
840
|
+
cwd: projectDir,
|
|
841
|
+
stdin: "ignore",
|
|
842
|
+
stdout: "ignore",
|
|
843
|
+
stderr: "ignore",
|
|
844
|
+
detached: term.detached,
|
|
845
|
+
});
|
|
846
|
+
if (term.detached) proc.unref();
|
|
847
|
+
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
848
|
+
} catch (err: any) {
|
|
849
|
+
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Whoever holds the board's port, by the OS's own accounting.
|
|
854
|
+
export async function pidOnPort(port: number): Promise<number | null> {
|
|
855
|
+
try {
|
|
856
|
+
if (process.platform === "win32") {
|
|
857
|
+
const out = await Bun.$`netstat -ano -p tcp`.text();
|
|
858
|
+
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
|
|
859
|
+
return m ? Number(m[1]) : null;
|
|
860
|
+
}
|
|
861
|
+
const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
|
|
862
|
+
return Number(out.trim().split("\n")[0]) || null;
|
|
863
|
+
} catch { return null; }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---- git, for the board's commit and push. Always argv, never a shell: the commit
|
|
867
|
+
// message is whatever the user typed. A credential prompt has no terminal to appear
|
|
868
|
+
// in, so git is told not to ask and is given a minute at most.
|
|
869
|
+
const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000 });
|
|
870
|
+
const gitDone = (code: number | null, stdout: string, stderr: string) => {
|
|
871
|
+
const out = stdout.trimEnd(), err = stderr.trim();
|
|
872
|
+
return { ok: code === 0, out, why: (err || out).split("\n").pop() || "git did not finish" };
|
|
873
|
+
};
|
|
874
|
+
const NO_GIT = { ok: false, out: "", why: "git is not installed" };
|
|
875
|
+
function git(dir: string, ...args: string[]) {
|
|
876
|
+
// spawnSync throws when git is not installed; to the board that is just "no repo".
|
|
877
|
+
let r;
|
|
878
|
+
try { r = Bun.spawnSync(["git", "-C", dir, ...args], gitOpts()); } catch { return NO_GIT; }
|
|
879
|
+
return gitDone(r.exitCode, r.stdout.toString(), r.stderr.toString());
|
|
880
|
+
}
|
|
881
|
+
// The same, without holding the server: for the push, the one call that waits on a network.
|
|
882
|
+
async function gitAsync(dir: string, ...args: string[]) {
|
|
883
|
+
try {
|
|
884
|
+
const p = Bun.spawn(["git", "-C", dir, ...args], { ...gitOpts(), stdout: "pipe", stderr: "pipe" });
|
|
885
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
|
886
|
+
return gitDone(code, out, err);
|
|
887
|
+
} catch { return NO_GIT; }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[] };
|
|
891
|
+
|
|
892
|
+
// A project the board can commit for: a git work tree with somewhere to push.
|
|
893
|
+
export function gitStatus(projectDir: string): GitStatus {
|
|
894
|
+
if (!git(projectDir, "rev-parse", "--is-inside-work-tree").ok) return { repo: false };
|
|
895
|
+
const remote = git(projectDir, "remote").out.split("\n")[0] ?? "";
|
|
896
|
+
if (!remote) return { repo: false };
|
|
897
|
+
// Every untracked file on its own line: a new directory is never committed whole.
|
|
898
|
+
const st = git(projectDir, "status", "--porcelain", "--untracked-files=all").out;
|
|
899
|
+
// No upstream yet means nothing of this branch is on the remote: any commit is ahead.
|
|
900
|
+
const up = git(projectDir, "rev-list", "--count", "@{u}..HEAD");
|
|
901
|
+
const ahead = up.ok ? Number(up.out) : git(projectDir, "rev-parse", "HEAD").ok ? 1 : 0;
|
|
902
|
+
return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// The path of a `git status --porcelain` line; for a rename, the new one.
|
|
906
|
+
// ponytail: only the surrounding quotes are undone, so a name git escapes (non-ASCII,
|
|
907
|
+
// a quote, a backslash) never matches a report; read `status -z` if that comes up.
|
|
908
|
+
const statusPath = (line: string) => line.slice(3).split(" -> ").pop()!.replace(/^"|"$/g, "");
|
|
909
|
+
|
|
910
|
+
// What a run reported changing: 03-impl.md and its fix rounds, and when the newest
|
|
911
|
+
// of them was written.
|
|
912
|
+
function runReport(state: string, id: string) {
|
|
913
|
+
const base = join(state, "runs", id), its = join(base, "05-iterations");
|
|
914
|
+
const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
|
|
915
|
+
let text = "", at = 0;
|
|
916
|
+
for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readFileSync(f, "utf8") + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
|
|
917
|
+
return { text, at };
|
|
918
|
+
}
|
|
919
|
+
const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
|
|
920
|
+
|
|
921
|
+
// The project's status narrowed to one run: a changed file is the run's when its
|
|
922
|
+
// report names the path in backticks and no run that reported later names it too,
|
|
923
|
+
// so a finished run is not offered the work of the ones after it. `others` counts
|
|
924
|
+
// the changed files left for other runs. `shared` says which of this run's files
|
|
925
|
+
// also hold an earlier run's work: one that named the file and reported after the
|
|
926
|
+
// file was last committed. A file is committed whole, so the user is told whose.
|
|
927
|
+
// ponytail: runs in the same file are named, not separated; that takes a worktree
|
|
928
|
+
// per run or a saved patch per run.
|
|
929
|
+
export function runGit(state: string, id: string): GitStatus {
|
|
930
|
+
const dir = dirname(state), s = gitStatus(dir);
|
|
931
|
+
if (!s.repo) return s;
|
|
932
|
+
const mine = runReport(state, id);
|
|
933
|
+
let ids: string[] = [];
|
|
934
|
+
try { ids = readdirSync(join(state, "runs")); } catch {}
|
|
935
|
+
const rest = ids.filter((o) => o !== id).map((o) => ({ id: o, ...runReport(state, o) }));
|
|
936
|
+
const files = s.files.filter((l) => {
|
|
937
|
+
const p = statusPath(l);
|
|
938
|
+
return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
|
|
939
|
+
});
|
|
940
|
+
const shared = files.flatMap((l) => {
|
|
941
|
+
const p = statusPath(l);
|
|
942
|
+
const since = Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
|
|
943
|
+
const who = rest.filter((r) => r.at > since && names(r.text, p)).map((r) => short(r.id));
|
|
944
|
+
return who.length ? [`${p}: ${who.join(", ")}`] : [];
|
|
945
|
+
});
|
|
946
|
+
return { ...s, files, others: s.files.length - files.length, shared };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// What the board did for a run, kept beside the run and not in state.json, whose
|
|
950
|
+
// mtime is the liveness signal.
|
|
951
|
+
export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
|
|
952
|
+
try {
|
|
953
|
+
const t = readFileSync(join(state, "runs", id, "07-commit.md"), "utf8");
|
|
954
|
+
return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
|
|
955
|
+
} catch { return null; }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// A Conventional Commits subject: every commit the board makes says what kind it is.
|
|
959
|
+
const COMMIT_TYPES = ["feat", "fix", "refactor", "perf", "docs", "test", "build", "ci", "chore", "style", "revert"];
|
|
960
|
+
const COMMIT_TYPE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\([^)\\n]+\\))?!?: \\S`);
|
|
961
|
+
|
|
962
|
+
// Commits the run's changed files (`runGit`) when there are any, then pushes. The record
|
|
963
|
+
// is written after the commit and again after the push, so a push that failed reads
|
|
964
|
+
// as committed and not pushed. `shown` is the file list the user confirmed: anything
|
|
965
|
+
// that changed since is refused, not swept in unseen.
|
|
966
|
+
export async function commitPush(state: string, id: string, message: string, shown: string[]): Promise<{ ok: true; sha: string } | { ok: false; why: string }> {
|
|
967
|
+
const dir = dirname(state);
|
|
968
|
+
const s = runGit(state, id);
|
|
969
|
+
if (!s.repo) return { ok: false, why: "This project has no git remote." };
|
|
970
|
+
if (s.files.join("\n") !== shown.join("\n")) return { ok: false, why: "The changed files are no longer the ones shown. Look at the list again." };
|
|
971
|
+
if (s.files.length) {
|
|
972
|
+
if (!message.trim()) return { ok: false, why: "A commit message is required." };
|
|
973
|
+
if (!COMMIT_TYPE.test(message.trim())) return { ok: false, why: `Start the message with a type (${COMMIT_TYPES.join(", ")}), then a colon. For example, fix(web): what changed.` };
|
|
974
|
+
// By path, both times: a file staged by hand for another run stays out of this commit.
|
|
975
|
+
// Literal, so a file named `a*.txt` is that file and not a pattern.
|
|
976
|
+
const paths = s.files.map(statusPath), lit = "--literal-pathspecs";
|
|
977
|
+
for (const args of [[lit, "add", "-A", "--", ...paths], [lit, "commit", "-m", message.trim(), "--", ...paths]]) {
|
|
978
|
+
const r = git(dir, ...args);
|
|
979
|
+
if (!r.ok) return { ok: false, why: r.why };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const sha = git(dir, "rev-parse", "--short", "HEAD").out;
|
|
983
|
+
const record = (pushed: boolean) => writeFileSync(join(state, "runs", id, "07-commit.md"), `commit: ${sha}\npushed: ${pushed ? "yes" : "no"}\n`);
|
|
984
|
+
record(false);
|
|
985
|
+
const p = await (git(dir, "rev-parse", "--abbrev-ref", "@{u}").ok ? gitAsync(dir, "push") : gitAsync(dir, "push", "-u", s.remote, "HEAD"));
|
|
986
|
+
if (!p.ok) return { ok: false, why: `${sha} is committed but not pushed: ${p.why}` };
|
|
987
|
+
record(true);
|
|
988
|
+
return { ok: true, sha };
|
|
989
|
+
}
|