@polymorphism-tech/morph-spec 7.2.0 → 7.3.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/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-proposal.md +1 -1
- package/framework/hooks/README.md +1 -1
- package/framework/hooks/claude-code/core/validate-completion.js +136 -136
- package/framework/phases.json +1 -1
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/templates/README.md +708 -709
- package/framework/templates/REGISTRY.json +1 -20
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/proposal.md +171 -171
- package/framework/templates/feature/recap.md +145 -145
- package/package.json +1 -1
- package/src/commands/project/doctor.js +941 -946
- package/src/commands/project/status.js +373 -376
- package/src/commands/validation/validate-feature.js +315 -287
- package/src/core/state/state-manager.js +900 -934
- package/src/dashboard/app/App.jsx +29 -31
- package/src/dashboard/app/components/Sidebar.jsx +44 -45
- package/src/dashboard/scanner/index.js +20 -21
- package/src/lib/context/context-builder.js +3 -3
- package/src/lib/generators/context-generator.js +2 -3
- package/src/lib/generators/mandate-generator.js +48 -92
- package/src/lib/generators/metadata-extractor.js +385 -386
- package/src/lib/generators/recap-generator.js +285 -285
- package/src/lib/validators/content/content-validator.js +89 -286
- package/src/lib/validators/content/index.js +0 -1
- package/framework/templates/feature/tasks.md +0 -199
- package/framework/workflows/README.md +0 -1041
- package/framework/workflows/configs/fast-track.json +0 -46
- package/framework/workflows/configs/full.json +0 -64
- package/framework/workflows/configs/quick.json +0 -32
- package/framework/workflows/configs/standard.json +0 -57
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
- package/framework/workflows/docs/design-impl.md +0 -37
- package/framework/workflows/docs/enforcement-pipeline.md +0 -668
- package/framework/workflows/docs/fast-track.md +0 -29
- package/framework/workflows/docs/full-morph.md +0 -69
- package/framework/workflows/docs/standard.md +0 -49
- package/framework/workflows/docs/ui-refresh.md +0 -39
- package/src/commands/state/index.js +0 -7
- package/src/core/workflows/workflow-detector.js +0 -586
- package/src/core/workflows/workflow-router.js +0 -204
- package/src/dashboard/app/pages/Workflows.jsx +0 -346
- package/src/dashboard/scanner/workflows-scanner.js +0 -18
- package/src/lib/phase-chain/phase-validator.js +0 -359
- package/src/lib/validators/content/cross-phase-validator.js +0 -281
- package/src/lib/validators/spec-validator.js +0 -312
|
@@ -1,934 +1,900 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MORPH-SPEC State Manager Library
|
|
3
|
-
*
|
|
4
|
-
* Manages state.json for tracking features, progress, agents, and checkpoints.
|
|
5
|
-
* Used both by CLI commands and internal automation.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync } from 'fs';
|
|
9
|
-
import { join, dirname } from 'path';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
* @param {string}
|
|
51
|
-
* @
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
* @
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
state.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
delete feature.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
* @param {
|
|
215
|
-
* @
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @param {string}
|
|
515
|
-
* @param {string}
|
|
516
|
-
* @returns {
|
|
517
|
-
*/
|
|
518
|
-
function
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
state.features[featureName].
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
*
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
*
|
|
850
|
-
* @param {string}
|
|
851
|
-
* @returns {
|
|
852
|
-
*/
|
|
853
|
-
export function
|
|
854
|
-
const state = loadState();
|
|
855
|
-
const feature = state.features[featureName];
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
return null;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* Update timing fields on a specific agent in the latest dispatch entry.
|
|
906
|
-
* Fail-open: returns silently if feature/dispatch/agent not found.
|
|
907
|
-
*
|
|
908
|
-
* @param {string} featureName
|
|
909
|
-
* @param {string} agentId - Agent ID to update
|
|
910
|
-
* @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
|
|
911
|
-
*/
|
|
912
|
-
export function updateDispatchAgentTiming(featureName, agentId, updates) {
|
|
913
|
-
const state = loadState();
|
|
914
|
-
const feature = state.features?.[featureName];
|
|
915
|
-
if (!feature) return;
|
|
916
|
-
|
|
917
|
-
const log = feature.dispatchLog;
|
|
918
|
-
if (!Array.isArray(log) || log.length === 0) return;
|
|
919
|
-
|
|
920
|
-
const latest = log[log.length - 1];
|
|
921
|
-
const agent = (latest.agents || []).find(a => a.id === agentId);
|
|
922
|
-
if (!agent) return;
|
|
923
|
-
|
|
924
|
-
Object.assign(agent, updates);
|
|
925
|
-
|
|
926
|
-
// If all agents now have completedAt, set entry.completedAt to the latest
|
|
927
|
-
const allComplete = (latest.agents || []).every(a => a.completedAt);
|
|
928
|
-
if (allComplete && latest.agents.length > 0) {
|
|
929
|
-
const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
|
|
930
|
-
latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
saveState(state);
|
|
934
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MORPH-SPEC State Manager Library
|
|
3
|
+
*
|
|
4
|
+
* Manages state.json for tracking features, progress, agents, and checkpoints.
|
|
5
|
+
* Used both by CLI commands and internal automation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { getAllOutputPaths, getOutputPath } from '../paths/output-schema.js';
|
|
11
|
+
|
|
12
|
+
const STATE_FILE_NAME = '.morph/state.json';
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Core Functions
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the state file path (looks in current working directory)
|
|
20
|
+
*/
|
|
21
|
+
export function getStatePath() {
|
|
22
|
+
return join(process.cwd(), STATE_FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Derive current phase from filesystem — checks for phase folders in descending order.
|
|
26
|
+
* Returns the phase corresponding to the highest-numbered folder present.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} featurePath - Absolute path to .morph/features/{feature}/
|
|
29
|
+
* @returns {string} Phase name: 'review' | 'implement' | 'plan' | 'uiux' | 'proposal'
|
|
30
|
+
*/
|
|
31
|
+
export function derivePhase(featurePath) {
|
|
32
|
+
const phaseMap = [
|
|
33
|
+
['4-review', 'review'],
|
|
34
|
+
['3-implement', 'implement'],
|
|
35
|
+
['2-plan', 'plan'],
|
|
36
|
+
['1-design', 'uiux'],
|
|
37
|
+
['0-proposal', 'proposal'],
|
|
38
|
+
];
|
|
39
|
+
for (const [folder, phase] of phaseMap) {
|
|
40
|
+
if (existsSync(join(featurePath, folder))) return phase;
|
|
41
|
+
}
|
|
42
|
+
return 'proposal';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Derive output existence from filesystem — checks if each output file exists at its expected path.
|
|
47
|
+
* Returns an object matching the old outputs shape for backwards-compatible display.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} featureName - Feature name
|
|
50
|
+
* @param {string} [baseDir] - Project base dir (defaults to cwd)
|
|
51
|
+
* @returns {Object} Map of outputType -> { created: boolean, path: string }
|
|
52
|
+
*/
|
|
53
|
+
export function deriveOutputs(featureName, baseDir = process.cwd()) {
|
|
54
|
+
const outputPaths = getAllOutputPaths(featureName);
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const [type, { path: relPath }] of Object.entries(outputPaths)) {
|
|
57
|
+
const absPath = join(baseDir, relPath);
|
|
58
|
+
result[type] = { created: existsSync(absPath), path: relPath };
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if state file exists
|
|
65
|
+
*/
|
|
66
|
+
export function stateExists() {
|
|
67
|
+
return existsSync(getStatePath());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load state from disk
|
|
72
|
+
* @param {boolean} throwOnError - If false, returns null instead of throwing
|
|
73
|
+
* @returns {Object|null} State object or null
|
|
74
|
+
*/
|
|
75
|
+
export function loadState(throwOnError = true) {
|
|
76
|
+
const statePath = getStatePath();
|
|
77
|
+
|
|
78
|
+
if (!existsSync(statePath)) {
|
|
79
|
+
if (throwOnError) {
|
|
80
|
+
throw new Error(`State file not found: ${statePath}\nRun 'morph-spec init' to initialize MORPH in this project.`);
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const content = readFileSync(statePath, 'utf8');
|
|
87
|
+
const state = JSON.parse(content);
|
|
88
|
+
|
|
89
|
+
// Migrate v3.0.0 → v4.0.0 (new directory layout)
|
|
90
|
+
if (state.version === '3.0.0') {
|
|
91
|
+
state.version = '4.0.0';
|
|
92
|
+
for (const [name, feature] of Object.entries(state.features || {})) {
|
|
93
|
+
if (!feature.outputs) continue;
|
|
94
|
+
const outputPathMap = getAllOutputPaths(name);
|
|
95
|
+
for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
|
|
96
|
+
if (feature.outputs[key]) {
|
|
97
|
+
feature.outputs[key].path = newPath;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Migrate v4.x -> v5.0.0: remove outputs and phase (now derived from
|
|
105
|
+
// filesystem) and workflow/workflowDetection (v5 has fixed phases, no
|
|
106
|
+
// workflow selection).
|
|
107
|
+
if (state.version && state.version.startsWith('4.')) {
|
|
108
|
+
state.version = '5.0.0';
|
|
109
|
+
for (const feature of Object.values(state.features || {})) {
|
|
110
|
+
delete feature.outputs;
|
|
111
|
+
delete feature.phase;
|
|
112
|
+
delete feature.workflow;
|
|
113
|
+
delete feature.workflowDetection;
|
|
114
|
+
}
|
|
115
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return state;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (throwOnError) {
|
|
121
|
+
throw new Error(`Failed to parse state.json: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Save state to disk
|
|
129
|
+
* @param {Object} state - State object to save
|
|
130
|
+
*/
|
|
131
|
+
export function saveState(state) {
|
|
132
|
+
state.metadata = state.metadata || {};
|
|
133
|
+
state.metadata.lastUpdated = new Date().toISOString();
|
|
134
|
+
|
|
135
|
+
const statePath = getStatePath();
|
|
136
|
+
const stateDir = dirname(statePath);
|
|
137
|
+
|
|
138
|
+
// Ensure .morph directory exists
|
|
139
|
+
if (!existsSync(stateDir)) {
|
|
140
|
+
mkdirSync(stateDir, { recursive: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Atomic write: write to a per-process temp file then rename.
|
|
144
|
+
// Per-process name prevents two concurrent CLI invocations (e.g. `task done && task start`)
|
|
145
|
+
// from clobbering each other's temp file on Windows.
|
|
146
|
+
const tmpPath = `${statePath}.tmp.${process.pid}`;
|
|
147
|
+
writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
|
|
148
|
+
|
|
149
|
+
// Retry rename on EPERM — Windows holds a brief exclusive lock on the target
|
|
150
|
+
// when another process is reading it; a short backoff resolves the contention.
|
|
151
|
+
const MAX_RETRIES = 5;
|
|
152
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
153
|
+
try {
|
|
154
|
+
renameSync(tmpPath, statePath);
|
|
155
|
+
return;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
|
|
158
|
+
// Synchronous sleep: CPU-friendly, works on Node.js main thread (v9.4+)
|
|
159
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
160
|
+
} else {
|
|
161
|
+
try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// File Locking (BUG-002)
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
const LOCK_DIR_NAME = '.morph/state.json.lock';
|
|
173
|
+
const LOCK_MAX_RETRIES = 5;
|
|
174
|
+
const LOCK_BASE_MS = 100;
|
|
175
|
+
const LOCK_STALE_MS = 30_000;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wrap a read-modify-write cycle on state.json with a mkdir-based mutex.
|
|
179
|
+
* @param {Function} fn - Async function to execute while holding the lock
|
|
180
|
+
* @returns {Promise<*>} Return value of fn
|
|
181
|
+
*/
|
|
182
|
+
export async function withStateLock(fn) {
|
|
183
|
+
const lockPath = join(process.cwd(), LOCK_DIR_NAME);
|
|
184
|
+
|
|
185
|
+
for (let attempt = 0; attempt <= LOCK_MAX_RETRIES; attempt++) {
|
|
186
|
+
try {
|
|
187
|
+
mkdirSync(lockPath);
|
|
188
|
+
break;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err.code !== 'EEXIST') throw err;
|
|
191
|
+
try {
|
|
192
|
+
const lockStat = statSync(lockPath);
|
|
193
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
194
|
+
try { rmdirSync(lockPath); } catch { }
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
} catch { }
|
|
198
|
+
if (attempt === LOCK_MAX_RETRIES) {
|
|
199
|
+
throw new Error('Could not acquire state lock after retries');
|
|
200
|
+
}
|
|
201
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_BASE_MS * (2 ** attempt));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
return await fn();
|
|
207
|
+
} finally {
|
|
208
|
+
try { rmdirSync(lockPath); } catch { }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Initialize new state file
|
|
214
|
+
* @param {Object} options - Options
|
|
215
|
+
* @param {boolean} options.force - Overwrite existing file
|
|
216
|
+
* @param {string} options.projectName - Project name
|
|
217
|
+
* @param {string} options.projectType - Project type (e.g., 'dotnet-nextjs')
|
|
218
|
+
* @returns {Object} Initial state
|
|
219
|
+
*/
|
|
220
|
+
export function initState(options = {}) {
|
|
221
|
+
const { force = false, projectName = '{PROJECT_NAME}', projectType = 'dotnet-nextjs' } = options;
|
|
222
|
+
|
|
223
|
+
if (stateExists() && !force) {
|
|
224
|
+
throw new Error('State file already exists. Use force=true to overwrite.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const initialState = {
|
|
228
|
+
version: "5.0.0",
|
|
229
|
+
project: {
|
|
230
|
+
name: projectName,
|
|
231
|
+
type: projectType,
|
|
232
|
+
createdAt: new Date().toISOString(),
|
|
233
|
+
updatedAt: new Date().toISOString()
|
|
234
|
+
},
|
|
235
|
+
features: {},
|
|
236
|
+
threads: {},
|
|
237
|
+
metadata: {
|
|
238
|
+
totalFeatures: 0,
|
|
239
|
+
completedFeatures: 0,
|
|
240
|
+
totalTimeSpent: 0,
|
|
241
|
+
lastUpdated: new Date().toISOString()
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
saveState(initialState);
|
|
246
|
+
return initialState;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// Feature Operations
|
|
251
|
+
// ============================================================================
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get feature from state
|
|
255
|
+
* @param {string} featureName - Feature name
|
|
256
|
+
* @returns {Object|null} Feature object or null
|
|
257
|
+
*/
|
|
258
|
+
export function getFeature(featureName) {
|
|
259
|
+
const state = loadState();
|
|
260
|
+
return state.features[featureName] || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create or get feature with default structure.
|
|
265
|
+
* v5: every feature follows the fixed phase sequence
|
|
266
|
+
* (proposal → uiux → plan → implement → review). No workflow selection.
|
|
267
|
+
* @param {string} featureName - Feature name
|
|
268
|
+
* @returns {Promise<Object>} Feature object
|
|
269
|
+
*/
|
|
270
|
+
async function ensureFeature(featureName) {
|
|
271
|
+
const state = loadState();
|
|
272
|
+
|
|
273
|
+
if (!state.features[featureName]) {
|
|
274
|
+
state.features[featureName] = {
|
|
275
|
+
status: "draft",
|
|
276
|
+
createdAt: new Date().toISOString(),
|
|
277
|
+
updatedAt: new Date().toISOString(),
|
|
278
|
+
activeAgents: [],
|
|
279
|
+
approvalGates: {
|
|
280
|
+
proposal: { approved: false, timestamp: null, approvedBy: null },
|
|
281
|
+
uiux: { approved: false, timestamp: null, approvedBy: null },
|
|
282
|
+
plan: { approved: false, timestamp: null, approvedBy: null },
|
|
283
|
+
review: { approved: false, timestamp: null, approvedBy: null }
|
|
284
|
+
},
|
|
285
|
+
tasks: {
|
|
286
|
+
total: 0,
|
|
287
|
+
completed: 0,
|
|
288
|
+
inProgress: 0,
|
|
289
|
+
pending: 0
|
|
290
|
+
},
|
|
291
|
+
checkpoints: [],
|
|
292
|
+
dispatchLog: [],
|
|
293
|
+
trustConfig: {},
|
|
294
|
+
contextBundles: [],
|
|
295
|
+
fileChanges: []
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
state.metadata.totalFeatures++;
|
|
299
|
+
saveState(state);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return state.features[featureName];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Update feature property (supports nested keys like "tasks.completed")
|
|
307
|
+
* @param {string} featureName - Feature name
|
|
308
|
+
* @param {string} key - Property key (supports dot notation)
|
|
309
|
+
* @param {any} value - Value to set
|
|
310
|
+
*/
|
|
311
|
+
export async function updateFeature(featureName, key, value) {
|
|
312
|
+
await ensureFeature(featureName);
|
|
313
|
+
const state = loadState(); // Load AFTER ensuring feature exists
|
|
314
|
+
|
|
315
|
+
const keys = key.split('.');
|
|
316
|
+
let target = state.features[featureName];
|
|
317
|
+
|
|
318
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
319
|
+
if (!target[keys[i]]) {
|
|
320
|
+
target[keys[i]] = {};
|
|
321
|
+
}
|
|
322
|
+
target = target[keys[i]];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const finalKey = keys[keys.length - 1];
|
|
326
|
+
target[finalKey] = value;
|
|
327
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
328
|
+
|
|
329
|
+
saveState(state);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Update multiple feature properties at once
|
|
334
|
+
* @param {string} featureName - Feature name
|
|
335
|
+
* @param {Object} updates - Object with key-value pairs to update
|
|
336
|
+
*/
|
|
337
|
+
export async function updateFeatureMultiple(featureName, updates) {
|
|
338
|
+
await ensureFeature(featureName);
|
|
339
|
+
const state = loadState(); // Load AFTER ensuring feature exists
|
|
340
|
+
|
|
341
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
342
|
+
const keys = key.split('.');
|
|
343
|
+
let target = state.features[featureName];
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
346
|
+
if (!target[keys[i]]) {
|
|
347
|
+
target[keys[i]] = {};
|
|
348
|
+
}
|
|
349
|
+
target = target[keys[i]];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const finalKey = keys[keys.length - 1];
|
|
353
|
+
target[finalKey] = value;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
357
|
+
saveState(state);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Add checkpoint to feature
|
|
362
|
+
* @param {string} featureName - Feature name
|
|
363
|
+
* @param {string} note - Checkpoint note
|
|
364
|
+
* @returns {Object} Checkpoint object
|
|
365
|
+
*/
|
|
366
|
+
export async function addCheckpoint(featureName, note) {
|
|
367
|
+
await ensureFeature(featureName);
|
|
368
|
+
const state = loadState();
|
|
369
|
+
const feature = state.features[featureName];
|
|
370
|
+
|
|
371
|
+
const checkpoint = {
|
|
372
|
+
passed: true,
|
|
373
|
+
checkpointNum: feature.checkpoints.length + 1,
|
|
374
|
+
timestamp: new Date().toISOString(),
|
|
375
|
+
phase: feature.phase || 'proposal',
|
|
376
|
+
results: [],
|
|
377
|
+
summary: {
|
|
378
|
+
note: note,
|
|
379
|
+
completedTasks: feature.tasks.completed
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
feature.checkpoints.push(checkpoint);
|
|
384
|
+
feature.updatedAt = new Date().toISOString();
|
|
385
|
+
|
|
386
|
+
saveState(state);
|
|
387
|
+
return checkpoint;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Add agent to feature
|
|
392
|
+
* @param {string} featureName - Feature name
|
|
393
|
+
* @param {string} agentId - Agent ID
|
|
394
|
+
* @returns {boolean} True if added, false if already exists
|
|
395
|
+
*/
|
|
396
|
+
export async function addAgent(featureName, agentId) {
|
|
397
|
+
await ensureFeature(featureName);
|
|
398
|
+
const state = loadState(); // Load AFTER ensuring feature exists
|
|
399
|
+
|
|
400
|
+
if (!state.features[featureName].activeAgents.includes(agentId)) {
|
|
401
|
+
state.features[featureName].activeAgents.push(agentId);
|
|
402
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
403
|
+
saveState(state);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Remove agent from feature
|
|
412
|
+
* @param {string} featureName - Feature name
|
|
413
|
+
* @param {string} agentId - Agent ID
|
|
414
|
+
* @returns {boolean} True if removed, false if not found
|
|
415
|
+
*/
|
|
416
|
+
export function removeAgent(featureName, agentId) {
|
|
417
|
+
const state = loadState();
|
|
418
|
+
|
|
419
|
+
if (!state.features[featureName]) {
|
|
420
|
+
throw new Error(`Feature '${featureName}' not found.`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const index = state.features[featureName].activeAgents.indexOf(agentId);
|
|
424
|
+
if (index > -1) {
|
|
425
|
+
state.features[featureName].activeAgents.splice(index, 1);
|
|
426
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
427
|
+
saveState(state);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Log a dispatch event to state.json.
|
|
436
|
+
* Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
|
|
437
|
+
*
|
|
438
|
+
* @param {string} featureName - Feature name
|
|
439
|
+
* @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
|
|
440
|
+
*/
|
|
441
|
+
export function logDispatch(featureName, entry) {
|
|
442
|
+
const state = loadState();
|
|
443
|
+
const feature = state.features?.[featureName];
|
|
444
|
+
if (!feature) return;
|
|
445
|
+
|
|
446
|
+
if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
|
|
447
|
+
feature.dispatchLog.push(entry);
|
|
448
|
+
|
|
449
|
+
// Cap at 50 entries
|
|
450
|
+
if (feature.dispatchLog.length > 50) {
|
|
451
|
+
feature.dispatchLog = feature.dispatchLog.slice(-50);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
saveState(state);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
|
|
459
|
+
* Supports both kebab-case and camelCase input formats for all output types
|
|
460
|
+
* @param {string} type - Output type (e.g., 'ui-design-system', 'stack-scan', or 'uiDesignSystem')
|
|
461
|
+
* @returns {string} Normalized type in camelCase
|
|
462
|
+
*/
|
|
463
|
+
function normalizeOutputType(type) {
|
|
464
|
+
// If string doesn't contain hyphens, assume it's already camelCase
|
|
465
|
+
if (!type.includes('-')) {
|
|
466
|
+
return type;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Convert kebab-case to camelCase automatically
|
|
470
|
+
// Example: 'ui-design-system' → 'uiDesignSystem'
|
|
471
|
+
// Example: 'stack-scan' → 'stackScan'
|
|
472
|
+
const camelCase = type.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
473
|
+
|
|
474
|
+
return camelCase;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Calculate Levenshtein distance between two strings
|
|
479
|
+
* Used for fuzzy matching and typo detection
|
|
480
|
+
* @param {string} a - First string
|
|
481
|
+
* @param {string} b - Second string
|
|
482
|
+
* @returns {number} Edit distance
|
|
483
|
+
*/
|
|
484
|
+
function levenshteinDistance(a, b) {
|
|
485
|
+
const matrix = [];
|
|
486
|
+
|
|
487
|
+
for (let i = 0; i <= b.length; i++) {
|
|
488
|
+
matrix[i] = [i];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
for (let j = 0; j <= a.length; j++) {
|
|
492
|
+
matrix[0][j] = j;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (let i = 1; i <= b.length; i++) {
|
|
496
|
+
for (let j = 1; j <= a.length; j++) {
|
|
497
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
498
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
499
|
+
} else {
|
|
500
|
+
matrix[i][j] = Math.min(
|
|
501
|
+
matrix[i - 1][j - 1] + 1, // substitution
|
|
502
|
+
matrix[i][j - 1] + 1, // insertion
|
|
503
|
+
matrix[i - 1][j] + 1 // deletion
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return matrix[b.length][a.length];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Find closest matching output type for error suggestions
|
|
514
|
+
* @param {string} inputType - The invalid input type
|
|
515
|
+
* @param {Array<string>} validTypes - List of valid output types
|
|
516
|
+
* @returns {string|null} Closest match or null
|
|
517
|
+
*/
|
|
518
|
+
function findClosestMatch(inputType, validTypes) {
|
|
519
|
+
let closestMatch = null;
|
|
520
|
+
let minDistance = Infinity;
|
|
521
|
+
|
|
522
|
+
const normalizedInput = inputType.toLowerCase();
|
|
523
|
+
|
|
524
|
+
for (const validType of validTypes) {
|
|
525
|
+
const distance = levenshteinDistance(normalizedInput, validType.toLowerCase());
|
|
526
|
+
|
|
527
|
+
// Consider it a match if distance is less than 3 (likely typo)
|
|
528
|
+
// or if one string contains the other (partial match)
|
|
529
|
+
if (distance < minDistance && distance <= 3) {
|
|
530
|
+
minDistance = distance;
|
|
531
|
+
closestMatch = validType;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Also check for partial matches
|
|
535
|
+
if (validType.toLowerCase().includes(normalizedInput) ||
|
|
536
|
+
normalizedInput.includes(validType.toLowerCase())) {
|
|
537
|
+
if (distance < minDistance) {
|
|
538
|
+
minDistance = distance;
|
|
539
|
+
closestMatch = validType;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Only suggest if similarity is reasonable
|
|
545
|
+
return minDistance <= 3 ? closestMatch : null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Mark output as created
|
|
550
|
+
* @param {string} featureName - Feature name
|
|
551
|
+
* @param {string} outputType - Output type (proposal, spec, mandate, etc.)
|
|
552
|
+
*/
|
|
553
|
+
export async function markOutput(featureName, outputType) {
|
|
554
|
+
await ensureFeature(featureName);
|
|
555
|
+
const state = loadState();
|
|
556
|
+
|
|
557
|
+
const normalized = normalizeOutputType(outputType);
|
|
558
|
+
|
|
559
|
+
// Initialize outputs on demand (not pre-stored)
|
|
560
|
+
if (!state.features[featureName].outputs) {
|
|
561
|
+
state.features[featureName].outputs = getAllOutputPaths(featureName);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!state.features[featureName].outputs[normalized]) {
|
|
565
|
+
// Try to find closest match for better error message
|
|
566
|
+
const validCamelCaseTypes = Object.keys(state.features[featureName].outputs);
|
|
567
|
+
const suggestion = findClosestMatch(outputType, validCamelCaseTypes);
|
|
568
|
+
|
|
569
|
+
let errorMsg = `Output type '${outputType}' not valid.`;
|
|
570
|
+
|
|
571
|
+
if (suggestion) {
|
|
572
|
+
errorMsg += `\n\nDid you mean '${suggestion}'?`;
|
|
573
|
+
// If the suggestion has a kebab-case alternative, show it too
|
|
574
|
+
const kebabAlternative = {
|
|
575
|
+
'uiDesignSystem': 'ui-design-system',
|
|
576
|
+
'uiMockups': 'ui-mockups',
|
|
577
|
+
'uiComponents': 'ui-components',
|
|
578
|
+
'uiFlows': 'ui-flows'
|
|
579
|
+
}[suggestion];
|
|
580
|
+
if (kebabAlternative) {
|
|
581
|
+
errorMsg += ` (also accepts '${kebabAlternative}')`;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
errorMsg += `\n\nValid types:\n`;
|
|
586
|
+
errorMsg += ` ${validCamelCaseTypes.join(', ')}\n`;
|
|
587
|
+
errorMsg += `\nNote: kebab-case is also accepted (e.g., 'ui-design-system', 'stack-scan')`;
|
|
588
|
+
|
|
589
|
+
throw new Error(errorMsg);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
state.features[featureName].outputs[normalized].created = true;
|
|
593
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
594
|
+
|
|
595
|
+
// Track the file change automatically
|
|
596
|
+
const outputPath = state.features[featureName].outputs[normalized].path;
|
|
597
|
+
if (outputPath) {
|
|
598
|
+
if (!Array.isArray(state.features[featureName].fileChanges)) {
|
|
599
|
+
state.features[featureName].fileChanges = [];
|
|
600
|
+
}
|
|
601
|
+
state.features[featureName].fileChanges.push({
|
|
602
|
+
path: outputPath,
|
|
603
|
+
action: 'created',
|
|
604
|
+
phase: state.features[featureName].phase,
|
|
605
|
+
timestamp: new Date().toISOString()
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
saveState(state);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Track a file change for a feature
|
|
614
|
+
* @param {string} featureName - Feature name
|
|
615
|
+
* @param {string} filePath - Path of the changed file
|
|
616
|
+
* @param {string} action - 'created' | 'modified' | 'deleted'
|
|
617
|
+
* @param {string} phase - Phase during which the change occurred
|
|
618
|
+
*/
|
|
619
|
+
export async function trackFileChange(featureName, filePath, action, phase) {
|
|
620
|
+
await ensureFeature(featureName);
|
|
621
|
+
const state = loadState();
|
|
622
|
+
|
|
623
|
+
if (!Array.isArray(state.features[featureName].fileChanges)) {
|
|
624
|
+
state.features[featureName].fileChanges = [];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
state.features[featureName].fileChanges.push({
|
|
628
|
+
path: filePath,
|
|
629
|
+
action,
|
|
630
|
+
phase: phase || state.features[featureName].phase || 'proposal',
|
|
631
|
+
timestamp: new Date().toISOString()
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
635
|
+
saveState(state);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Get file changes for a feature, optionally grouped by phase
|
|
640
|
+
* @param {string} featureName - Feature name
|
|
641
|
+
* @param {Object} options - Options
|
|
642
|
+
* @param {boolean} options.groupByPhase - Group changes by phase
|
|
643
|
+
* @returns {Array|Object} File changes
|
|
644
|
+
*/
|
|
645
|
+
export function getFileChanges(featureName, options = {}) {
|
|
646
|
+
const state = loadState();
|
|
647
|
+
const feature = state.features[featureName];
|
|
648
|
+
|
|
649
|
+
if (!feature) {
|
|
650
|
+
throw new Error(`Feature '${featureName}' not found.`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const changes = feature.fileChanges || [];
|
|
654
|
+
|
|
655
|
+
if (options.groupByPhase) {
|
|
656
|
+
const grouped = {};
|
|
657
|
+
for (const change of changes) {
|
|
658
|
+
const phase = change.phase || 'unknown';
|
|
659
|
+
if (!grouped[phase]) grouped[phase] = [];
|
|
660
|
+
grouped[phase].push(change);
|
|
661
|
+
}
|
|
662
|
+
return grouped;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return changes;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Skip a phase and record it
|
|
670
|
+
* @param {string} featureName - Feature name
|
|
671
|
+
* @param {string} phase - Phase to skip
|
|
672
|
+
* @param {string} reason - Reason for skipping
|
|
673
|
+
*/
|
|
674
|
+
export async function skipPhase(featureName, phase, reason = '') {
|
|
675
|
+
await ensureFeature(featureName);
|
|
676
|
+
const state = loadState();
|
|
677
|
+
|
|
678
|
+
if (!state.features[featureName].skippedPhases) {
|
|
679
|
+
state.features[featureName].skippedPhases = [];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Don't add duplicates
|
|
683
|
+
const existing = state.features[featureName].skippedPhases.find(s => s.phase === phase);
|
|
684
|
+
if (!existing) {
|
|
685
|
+
state.features[featureName].skippedPhases.push({
|
|
686
|
+
phase,
|
|
687
|
+
reason,
|
|
688
|
+
timestamp: new Date().toISOString()
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
state.features[featureName].updatedAt = new Date().toISOString();
|
|
693
|
+
saveState(state);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Get skipped phases for a feature
|
|
698
|
+
* @param {string} featureName - Feature name
|
|
699
|
+
* @returns {Array} Array of skipped phases
|
|
700
|
+
*/
|
|
701
|
+
export function getSkippedPhases(featureName) {
|
|
702
|
+
const state = loadState();
|
|
703
|
+
if (!state.features[featureName]) {
|
|
704
|
+
return [];
|
|
705
|
+
}
|
|
706
|
+
return state.features[featureName].skippedPhases || [];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* List all features
|
|
711
|
+
* @returns {Array} Array of [featureName, featureObject] tuples
|
|
712
|
+
*/
|
|
713
|
+
export function listFeatures() {
|
|
714
|
+
const state = loadState();
|
|
715
|
+
return Object.entries(state.features);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Get project summary
|
|
720
|
+
* @returns {Object} Summary with metadata
|
|
721
|
+
*/
|
|
722
|
+
export function getSummary() {
|
|
723
|
+
const state = loadState();
|
|
724
|
+
return {
|
|
725
|
+
project: state.project,
|
|
726
|
+
metadata: state.metadata,
|
|
727
|
+
featuresCount: Object.keys(state.features).length
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ============================================================================
|
|
732
|
+
// Approval Gates Operations
|
|
733
|
+
// ============================================================================
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Set approval gate status
|
|
737
|
+
* @param {string} featureName - Feature name
|
|
738
|
+
* @param {string} gate - Gate name (proposal, uiux, plan, review)
|
|
739
|
+
* @param {boolean} approved - Approval status
|
|
740
|
+
* @param {Object} metadata - Additional metadata (approvedBy, reason, etc.)
|
|
741
|
+
*/
|
|
742
|
+
export async function setApprovalGate(featureName, gate, approved, metadata = {}) {
|
|
743
|
+
const state = loadState();
|
|
744
|
+
const feature = await ensureFeature(featureName);
|
|
745
|
+
|
|
746
|
+
if (!feature.approvalGates) {
|
|
747
|
+
feature.approvalGates = {
|
|
748
|
+
proposal: { approved: false, timestamp: null, approvedBy: null },
|
|
749
|
+
uiux: { approved: false, timestamp: null, approvedBy: null },
|
|
750
|
+
plan: { approved: false, timestamp: null, approvedBy: null },
|
|
751
|
+
review: { approved: false, timestamp: null, approvedBy: null }
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
feature.approvalGates[gate] = {
|
|
756
|
+
approved,
|
|
757
|
+
timestamp: metadata.approvedAt || metadata.rejectedAt || new Date().toISOString(),
|
|
758
|
+
approvedBy: metadata.approvedBy || metadata.rejectedBy || null,
|
|
759
|
+
...metadata
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
state.features[featureName] = feature;
|
|
763
|
+
saveState(state);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Get approval gate status
|
|
768
|
+
* @param {string} featureName - Feature name
|
|
769
|
+
* @param {string} gate - Gate name
|
|
770
|
+
* @returns {Object|null} Gate object or null
|
|
771
|
+
*/
|
|
772
|
+
export function getApprovalGate(featureName, gate) {
|
|
773
|
+
const state = loadState();
|
|
774
|
+
const feature = state.features[featureName];
|
|
775
|
+
|
|
776
|
+
if (!feature || !feature.approvalGates) {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return feature.approvalGates[gate] || null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Check if feature is pending approval
|
|
785
|
+
* @param {string} featureName - Feature name
|
|
786
|
+
* @returns {boolean} True if any gate is pending approval
|
|
787
|
+
*/
|
|
788
|
+
export function isPendingApproval(featureName) {
|
|
789
|
+
const state = loadState();
|
|
790
|
+
const feature = state.features[featureName];
|
|
791
|
+
|
|
792
|
+
if (!feature || !feature.approvalGates) {
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const currentPhase = feature.phase || 'proposal';
|
|
797
|
+
|
|
798
|
+
// Check if current phase has an approval gate
|
|
799
|
+
const phaseGateMap = {
|
|
800
|
+
'plan': 'plan',
|
|
801
|
+
'review': 'review',
|
|
802
|
+
'uiux': 'uiux'
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const relevantGate = phaseGateMap[currentPhase];
|
|
806
|
+
if (!relevantGate) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const gate = feature.approvalGates[relevantGate];
|
|
811
|
+
return gate && !gate.approved;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Get approval history for a feature
|
|
816
|
+
* @param {string} featureName - Feature name
|
|
817
|
+
* @returns {Array} Array of approval events
|
|
818
|
+
*/
|
|
819
|
+
export function getApprovalHistory(featureName) {
|
|
820
|
+
const state = loadState();
|
|
821
|
+
const feature = state.features[featureName];
|
|
822
|
+
|
|
823
|
+
if (!feature || !feature.approvalGates) {
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const history = [];
|
|
828
|
+
|
|
829
|
+
Object.entries(feature.approvalGates).forEach(([gate, data]) => {
|
|
830
|
+
if (data.timestamp) {
|
|
831
|
+
history.push({
|
|
832
|
+
gate,
|
|
833
|
+
approved: data.approved,
|
|
834
|
+
timestamp: data.timestamp,
|
|
835
|
+
approvedBy: data.approvedBy || data.rejectedBy,
|
|
836
|
+
reason: data.reason
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Sort by timestamp
|
|
842
|
+
return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Find which agent was assigned a specific task in the latest dispatch.
|
|
847
|
+
* Searches only the last dispatchLog entry.
|
|
848
|
+
*
|
|
849
|
+
* @param {string} featureName
|
|
850
|
+
* @param {string} taskId
|
|
851
|
+
* @returns {string|null} Agent ID or null if not found
|
|
852
|
+
*/
|
|
853
|
+
export function resolveAgentForTask(featureName, taskId) {
|
|
854
|
+
const state = loadState();
|
|
855
|
+
const feature = state.features?.[featureName];
|
|
856
|
+
if (!feature) return null;
|
|
857
|
+
|
|
858
|
+
const log = feature.dispatchLog;
|
|
859
|
+
if (!Array.isArray(log) || log.length === 0) return null;
|
|
860
|
+
|
|
861
|
+
const latest = log[log.length - 1];
|
|
862
|
+
for (const agent of latest.agents || []) {
|
|
863
|
+
if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
|
|
864
|
+
return agent.id;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Update timing fields on a specific agent in the latest dispatch entry.
|
|
872
|
+
* Fail-open: returns silently if feature/dispatch/agent not found.
|
|
873
|
+
*
|
|
874
|
+
* @param {string} featureName
|
|
875
|
+
* @param {string} agentId - Agent ID to update
|
|
876
|
+
* @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
|
|
877
|
+
*/
|
|
878
|
+
export function updateDispatchAgentTiming(featureName, agentId, updates) {
|
|
879
|
+
const state = loadState();
|
|
880
|
+
const feature = state.features?.[featureName];
|
|
881
|
+
if (!feature) return;
|
|
882
|
+
|
|
883
|
+
const log = feature.dispatchLog;
|
|
884
|
+
if (!Array.isArray(log) || log.length === 0) return;
|
|
885
|
+
|
|
886
|
+
const latest = log[log.length - 1];
|
|
887
|
+
const agent = (latest.agents || []).find(a => a.id === agentId);
|
|
888
|
+
if (!agent) return;
|
|
889
|
+
|
|
890
|
+
Object.assign(agent, updates);
|
|
891
|
+
|
|
892
|
+
// If all agents now have completedAt, set entry.completedAt to the latest
|
|
893
|
+
const allComplete = (latest.agents || []).every(a => a.completedAt);
|
|
894
|
+
if (allComplete && latest.agents.length > 0) {
|
|
895
|
+
const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
|
|
896
|
+
latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
saveState(state);
|
|
900
|
+
}
|