ostacky 0.6.2 → 0.7.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 +9 -10
- package/assets/agents/ostacky.md +244 -62
- package/assets/commands/install-stack.md +62 -5
- package/assets/mcp/ostacky-controller/index.js +1341 -989
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/skills/brainstorming/SKILL.md +8 -8
- package/assets/skills/execution-mode-evaluation/SKILL.md +8 -8
- package/assets/skills/graceful-degradation/SKILL.md +3 -3
- package/assets/skills/openspec-apply-change/SKILL.md +3 -3
- package/assets/skills/openspec-propose/SKILL.md +2 -2
- package/assets/skills/review/SKILL.md +2 -2
- package/assets/skills/subagent-driven-development/SKILL.md +8 -8
- package/dist/cli.js +77 -53
- package/manifest.json +44 -51
- package/package.json +1 -1
- package/assets/skills/writing-plans/SKILL.md +0 -149
|
@@ -1,989 +1,1341 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Ostacky Controller — MCP Server
|
|
5
|
-
*
|
|
6
|
-
* Máquina de estados persistida para Ostacky. Valida transiciones,
|
|
7
|
-
* consume decisiones, autoriza side effects y persiste snapshots.
|
|
8
|
-
*
|
|
9
|
-
* Usage: node .opencode/mcp/ostacky-controller/index.js
|
|
10
|
-
*
|
|
11
|
-
* Environment:
|
|
12
|
-
* OSTACKY_STATE_PATH — Ruta al archivo de estado JSON (default: .opencode/ostacky-state.json)
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { McpServer } from '@modelcontextprotocol/server';
|
|
16
|
-
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
17
|
-
import * as z from 'zod/v4';
|
|
18
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
19
|
-
import { dirname, basename, join, resolve } from 'node:path';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
{ via: '
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{ via: '
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{ via: '
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{ via: '
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{ via: '
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{ via: '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{ via: '
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{ via: '
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{ via: '
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{ via: '
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
{ via: '
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{ via: '
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{ via: '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{ via: '
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
this.#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
#
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
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
|
-
this.#
|
|
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
|
-
this.#
|
|
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
|
-
this.#
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
this.#
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
};
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
this.#
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
this.#
|
|
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
|
-
|
|
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
|
-
return
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
);
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
)
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ostacky Controller — MCP Server
|
|
5
|
+
*
|
|
6
|
+
* Máquina de estados persistida para Ostacky. Valida transiciones,
|
|
7
|
+
* consume decisiones, autoriza side effects y persiste snapshots.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node .opencode/mcp/ostacky-controller/index.js
|
|
10
|
+
*
|
|
11
|
+
* Environment:
|
|
12
|
+
* OSTACKY_STATE_PATH — Ruta al archivo de estado JSON (default: .opencode/ostacky-state.json)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
17
|
+
import * as z from 'zod/v4';
|
|
18
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
|
19
|
+
import { dirname, basename, join, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// --- Constants (Fase 5.5 — headroom generoso) ---
|
|
22
|
+
const MAX_TASKS = 100;
|
|
23
|
+
const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
|
|
24
|
+
const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
|
|
25
|
+
const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
|
|
26
|
+
|
|
27
|
+
// --- Transition table ---
|
|
28
|
+
const TRANSITIONS = {
|
|
29
|
+
INTERPRETATION_PENDING: [
|
|
30
|
+
{ via: 'request_clarification', to: 'CLARIFICATION_PENDING' },
|
|
31
|
+
{ via: 'proceed_to_discovery', to: 'DISCOVERY' },
|
|
32
|
+
{ via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
|
|
33
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
34
|
+
],
|
|
35
|
+
CLARIFICATION_PENDING: [
|
|
36
|
+
{ via: 'record_clarification', to: 'DISCOVERY' },
|
|
37
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
38
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
39
|
+
],
|
|
40
|
+
DISCOVERY: [
|
|
41
|
+
{ via: 'record_discovery', to: 'LEVEL_RESOLVED' },
|
|
42
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
43
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
44
|
+
],
|
|
45
|
+
LEVEL_RESOLVED: [
|
|
46
|
+
{ via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
|
|
47
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
48
|
+
],
|
|
49
|
+
ROUTE_DECISION_PENDING: [
|
|
50
|
+
{ via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
|
|
51
|
+
{ via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
|
|
52
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
53
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
54
|
+
],
|
|
55
|
+
SPECIFICATION: [
|
|
56
|
+
{ via: 'spec_complete', to: 'EXECUTION_ANALYSIS' },
|
|
57
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
58
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
59
|
+
],
|
|
60
|
+
EXECUTION_ANALYSIS: [
|
|
61
|
+
{ via: 'record_execution_analysis', to: 'EXECUTION_DECISION_PENDING' },
|
|
62
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
63
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
64
|
+
],
|
|
65
|
+
EXECUTION_DECISION_PENDING: [
|
|
66
|
+
{ via: 'consume_execution_decision', to: 'EXECUTING_INLINE', mode: 'INLINE' },
|
|
67
|
+
{ via: 'consume_execution_decision', to: 'EXECUTING_SUBAGENTS', mode: 'SUBAGENT_DRIVEN' },
|
|
68
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
69
|
+
{ via: 'abandon', to: 'BLOCKED' },
|
|
70
|
+
],
|
|
71
|
+
EXECUTING_INLINE: [
|
|
72
|
+
{ via: 'implementation_complete', to: 'SYNC' },
|
|
73
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
74
|
+
],
|
|
75
|
+
EXECUTING_SUBAGENTS: [
|
|
76
|
+
{ via: 'implementation_complete', to: 'SYNC' },
|
|
77
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
78
|
+
],
|
|
79
|
+
BLOCKED: [
|
|
80
|
+
{ via: 'replan', to: 'INTERPRETATION_PENDING' },
|
|
81
|
+
{ via: 'abandon', to: 'DONE' },
|
|
82
|
+
],
|
|
83
|
+
SYNC: [
|
|
84
|
+
{ via: 'sync_complete', to: 'DONE' },
|
|
85
|
+
{ via: 'block', to: 'BLOCKED' },
|
|
86
|
+
],
|
|
87
|
+
DONE: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// --- O4: Pre-computed transition cache (O(1) lookup) ---
|
|
91
|
+
const ALLOWED_TRANSITIONS = Object.freeze(
|
|
92
|
+
Object.fromEntries(
|
|
93
|
+
Object.entries(TRANSITIONS).map(([state, transitions]) => [
|
|
94
|
+
state,
|
|
95
|
+
new Map(transitions.map((t) => [`${t.via}:${t.choice || t.mode || ''}`, t.to])),
|
|
96
|
+
])
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Safe JSON.stringify that won't throw on circular references.
|
|
102
|
+
*
|
|
103
|
+
* Uses WeakSet (not Map/Set) so:
|
|
104
|
+
* - Object references are tracked without preventing GC
|
|
105
|
+
* - Nested non-cyclic objects are still serialized fully
|
|
106
|
+
* - Symbol keys are silently dropped (JSON limitation, not a bug)
|
|
107
|
+
* - Functions are dropped (JSON limitation)
|
|
108
|
+
* - Returns "[Unstringifiable: ...]" on hard failures (BigInt, etc.)
|
|
109
|
+
*/
|
|
110
|
+
function safeJsonStringify(obj, pretty = false) {
|
|
111
|
+
const seen = new WeakSet();
|
|
112
|
+
try {
|
|
113
|
+
return JSON.stringify(
|
|
114
|
+
obj,
|
|
115
|
+
(key, value) => {
|
|
116
|
+
if (typeof value === 'object' && value !== null) {
|
|
117
|
+
if (seen.has(value)) return '[Circular]';
|
|
118
|
+
seen.add(value);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
},
|
|
122
|
+
pretty ? 2 : undefined
|
|
123
|
+
);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return `[Unstringifiable: ${e.message}]`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function log(event, data) {
|
|
130
|
+
const ts = new Date().toISOString();
|
|
131
|
+
const payload = data ? ` ${safeJsonStringify(data)}` : '';
|
|
132
|
+
console.error(`[${ts}] ${event}${payload}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cleans up stale .tmp.* and .lock.* files from a previous crash.
|
|
137
|
+
*/
|
|
138
|
+
function cleanupTmpFiles(statePath) {
|
|
139
|
+
if (!statePath) return;
|
|
140
|
+
const dir = dirname(statePath);
|
|
141
|
+
const name = basename(statePath);
|
|
142
|
+
try {
|
|
143
|
+
for (const entry of readdirSync(dir)) {
|
|
144
|
+
if (entry.startsWith(name + '.tmp.') || entry.startsWith(name + '.lock')) {
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(join(dir, entry));
|
|
147
|
+
} catch {
|
|
148
|
+
/* best-effort */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
/* directory may not exist yet */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- O6: Fast fingerprint (mtime + size) ---
|
|
158
|
+
function fastFingerprint(filePath) {
|
|
159
|
+
try {
|
|
160
|
+
const stat = statSync(filePath);
|
|
161
|
+
return `${stat.mtimeMs}-${stat.size}`;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const STATES = Object.freeze({
|
|
168
|
+
INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
|
|
169
|
+
CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
|
|
170
|
+
DISCOVERY: 'DISCOVERY',
|
|
171
|
+
LEVEL_RESOLVED: 'LEVEL_RESOLVED',
|
|
172
|
+
ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
|
|
173
|
+
SPECIFICATION: 'SPECIFICATION',
|
|
174
|
+
EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
|
|
175
|
+
EXECUTION_DECISION_PENDING: 'EXECUTION_DECISION_PENDING',
|
|
176
|
+
EXECUTING_INLINE: 'EXECUTING_INLINE',
|
|
177
|
+
EXECUTING_SUBAGENTS: 'EXECUTING_SUBAGENTS',
|
|
178
|
+
SYNC: 'SYNC',
|
|
179
|
+
DONE: 'DONE',
|
|
180
|
+
BLOCKED: 'BLOCKED',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const DEFAULT_STATE = Object.freeze({
|
|
184
|
+
state: STATES.INTERPRETATION_PENDING,
|
|
185
|
+
revision: 0,
|
|
186
|
+
requestId: null,
|
|
187
|
+
changeId: null,
|
|
188
|
+
routeDecisionId: null,
|
|
189
|
+
routeChoice: null,
|
|
190
|
+
level: null,
|
|
191
|
+
executionDecisionId: null,
|
|
192
|
+
executionMode: null,
|
|
193
|
+
snapshots: { codegraph: null, execution: null },
|
|
194
|
+
tasks: {},
|
|
195
|
+
fileFingerprints: {},
|
|
196
|
+
error: null,
|
|
197
|
+
lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
class OstackyController {
|
|
201
|
+
#statePath;
|
|
202
|
+
#state;
|
|
203
|
+
#loaded;
|
|
204
|
+
#degraded = false;
|
|
205
|
+
#consecutiveFailures = 0;
|
|
206
|
+
#auditBuffer = [];
|
|
207
|
+
#lockPath;
|
|
208
|
+
#lockPidPath;
|
|
209
|
+
#lockHeartbeatPath;
|
|
210
|
+
#lockMaxAttempts = 10; // overridable via opts for fast tests
|
|
211
|
+
|
|
212
|
+
constructor(opts = {}) {
|
|
213
|
+
this.#statePath = opts.statePath;
|
|
214
|
+
this.#lockPath = opts.statePath ? opts.statePath + '.lock' : null;
|
|
215
|
+
this.#lockPidPath = opts.statePath ? opts.statePath + '.lock.pid' : null;
|
|
216
|
+
this.#lockHeartbeatPath = opts.statePath ? opts.statePath + '.lock.timestamp' : null;
|
|
217
|
+
if (typeof opts.lockMaxAttempts === 'number' && opts.lockMaxAttempts > 0) {
|
|
218
|
+
this.#lockMaxAttempts = opts.lockMaxAttempts;
|
|
219
|
+
}
|
|
220
|
+
if (opts.initialState) {
|
|
221
|
+
this.#state = { ...DEFAULT_STATE, ...opts.initialState };
|
|
222
|
+
this.#loaded = true;
|
|
223
|
+
} else {
|
|
224
|
+
this.#state = null;
|
|
225
|
+
this.#loaded = false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Returns whether the controller is in degraded mode.
|
|
231
|
+
*/
|
|
232
|
+
get degraded() {
|
|
233
|
+
return this.#degraded;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validates that a parsed state object has the required fields and valid values.
|
|
238
|
+
* Returns null if valid, or an error message if invalid.
|
|
239
|
+
*/
|
|
240
|
+
#validateState(parsed) {
|
|
241
|
+
if (typeof parsed !== 'object' || parsed === null) return 'State is not an object';
|
|
242
|
+
if (typeof parsed.state !== 'string') return 'Missing or invalid "state" field';
|
|
243
|
+
if (!STATES[parsed.state]) return `Unknown state: "${parsed.state}"`;
|
|
244
|
+
if (typeof parsed.revision !== 'number') return 'Missing or invalid "revision" field';
|
|
245
|
+
if (parsed.revision < 0) return `Invalid revision: ${parsed.revision}`;
|
|
246
|
+
return null; // valid
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- 3.4: State file locking ---
|
|
250
|
+
#acquireLock() {
|
|
251
|
+
if (!this.#lockPath) return true;
|
|
252
|
+
// Allow tests to shorten retry loops via opts.lockMaxAttempts
|
|
253
|
+
const maxAttempts = this.#lockMaxAttempts;
|
|
254
|
+
const lockTimeout = 5000;
|
|
255
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
256
|
+
try {
|
|
257
|
+
writeFileSync(this.#lockPidPath, String(process.pid), 'utf8');
|
|
258
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
259
|
+
// Check if lock is stale (>30s without heartbeat)
|
|
260
|
+
try {
|
|
261
|
+
const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
262
|
+
const lockAge = Date.now() - parseInt(lockContent, 10);
|
|
263
|
+
if (lockAge > 30000) {
|
|
264
|
+
const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
265
|
+
try {
|
|
266
|
+
process.kill(parseInt(lockPid, 10), 0); // check if PID alive
|
|
267
|
+
// PID exists but lock is stale — wait briefly then force
|
|
268
|
+
const waitStart = Date.now();
|
|
269
|
+
while (Date.now() - waitStart < 10000) {
|
|
270
|
+
/* spin wait */
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// PID doesn't exist — force release
|
|
274
|
+
}
|
|
275
|
+
this.#releaseLock();
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Can't read heartbeat — assume stale
|
|
280
|
+
this.#releaseLock();
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
// Lock held by another process — wait and retry
|
|
286
|
+
const waitMs = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
287
|
+
const waitStart = Date.now();
|
|
288
|
+
while (Date.now() - waitStart < waitMs) {
|
|
289
|
+
/* spin wait */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
log('warn:lock_acquire_failed', { attempts: maxAttempts });
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#releaseLock() {
|
|
298
|
+
if (!this.#lockPath) return;
|
|
299
|
+
try {
|
|
300
|
+
unlinkSync(this.#lockPidPath);
|
|
301
|
+
} catch {
|
|
302
|
+
/* best-effort */
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
unlinkSync(this.#lockHeartbeatPath);
|
|
306
|
+
} catch {
|
|
307
|
+
/* best-effort */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#heartbeatLock() {
|
|
312
|
+
if (!this.#lockHeartbeatPath) return;
|
|
313
|
+
try {
|
|
314
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
315
|
+
} catch {
|
|
316
|
+
/* best-effort */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#load() {
|
|
321
|
+
if (this.#loaded) return;
|
|
322
|
+
if (!this.#statePath) {
|
|
323
|
+
this.#state = { ...DEFAULT_STATE };
|
|
324
|
+
this.#loaded = true;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Try primary state file
|
|
328
|
+
try {
|
|
329
|
+
const raw = readFileSync(this.#statePath, 'utf8');
|
|
330
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
|
|
331
|
+
const parsed = JSON.parse(raw);
|
|
332
|
+
const validationError = this.#validateState(parsed);
|
|
333
|
+
if (validationError) throw new Error(`State validation failed: ${validationError}`);
|
|
334
|
+
this.#state = { ...DEFAULT_STATE, ...parsed };
|
|
335
|
+
this.#loaded = true;
|
|
336
|
+
return;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
log('warn:load_primary_failed', { error: err.message });
|
|
339
|
+
}
|
|
340
|
+
// Fallback: try .backup
|
|
341
|
+
const backupPath = this.#statePath + '.backup';
|
|
342
|
+
try {
|
|
343
|
+
const raw = readFileSync(backupPath, 'utf8');
|
|
344
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
345
|
+
const parsed = JSON.parse(raw);
|
|
346
|
+
const validationError = this.#validateState(parsed);
|
|
347
|
+
if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
|
|
348
|
+
this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
|
|
349
|
+
log('warn:state_restored_from_backup');
|
|
350
|
+
this.#loaded = true;
|
|
351
|
+
return;
|
|
352
|
+
} catch (backupErr) {
|
|
353
|
+
// No backup either — set error state instead of silent reset
|
|
354
|
+
this.#state = {
|
|
355
|
+
...DEFAULT_STATE,
|
|
356
|
+
error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
|
|
357
|
+
};
|
|
358
|
+
log('warn:state_reset', { error: backupErr.message });
|
|
359
|
+
}
|
|
360
|
+
this.#loaded = true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#persist() {
|
|
364
|
+
if (!this.#statePath) return;
|
|
365
|
+
|
|
366
|
+
const dir = dirname(this.#statePath);
|
|
367
|
+
try {
|
|
368
|
+
mkdirSync(dir, { recursive: true });
|
|
369
|
+
} catch (err) {
|
|
370
|
+
// mkdir failures also count toward degraded mode
|
|
371
|
+
this.#consecutiveFailures++;
|
|
372
|
+
log('error:persist_mkdir_failed', { consecutive: this.#consecutiveFailures, error: err.message });
|
|
373
|
+
if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
|
|
374
|
+
this.#enterDegradedMode(`mkdir_failures: ${this.#consecutiveFailures} consecutive: ${err.message}`);
|
|
375
|
+
}
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// 3.4: Acquire lock before writing
|
|
381
|
+
const lockAcquired = this.#acquireLock();
|
|
382
|
+
if (!lockAcquired) {
|
|
383
|
+
log('warn:persist_skipped_lock', { state: this.#state.state });
|
|
384
|
+
throw new Error('Could not acquire state file lock');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let serialized = safeJsonStringify(this.#state, true);
|
|
388
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
389
|
+
log('warn:state_oversized', { size: serialized.length });
|
|
390
|
+
const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
|
|
391
|
+
serialized = safeJsonStringify(trimmed, true);
|
|
392
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
393
|
+
log('error:state_too_large_even_after_trim');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
this.#state.snapshots = { codegraph: null, execution: null };
|
|
397
|
+
}
|
|
398
|
+
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
399
|
+
writeFileSync(tmp, serialized, 'utf8');
|
|
400
|
+
renameSync(tmp, this.#statePath);
|
|
401
|
+
try {
|
|
402
|
+
const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
|
|
403
|
+
writeFileSync(backupTmp, serialized, 'utf8');
|
|
404
|
+
renameSync(backupTmp, this.#statePath + '.backup');
|
|
405
|
+
} catch {
|
|
406
|
+
/* backup is best-effort */
|
|
407
|
+
}
|
|
408
|
+
// B1: persist success → reset failure counter
|
|
409
|
+
if (this.#consecutiveFailures > 0) {
|
|
410
|
+
log('info:persist_recovered', { after: this.#consecutiveFailures });
|
|
411
|
+
}
|
|
412
|
+
this.#consecutiveFailures = 0;
|
|
413
|
+
} catch (err) {
|
|
414
|
+
// B1: persist failure → increment counter, auto-degrade if threshold reached
|
|
415
|
+
this.#consecutiveFailures++;
|
|
416
|
+
log('error:persist_failed', { consecutive: this.#consecutiveFailures, error: err.message });
|
|
417
|
+
if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
|
|
418
|
+
this.#enterDegradedMode(`persistence_failures: ${this.#consecutiveFailures} consecutive persists: ${err.message}`);
|
|
419
|
+
}
|
|
420
|
+
throw err;
|
|
421
|
+
} finally {
|
|
422
|
+
this.#releaseLock();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Trims old completed tasks when we exceed MAX_TASKS.
|
|
428
|
+
*
|
|
429
|
+
* Invariant: entries in `state.tasks` are kept sorted by `completedAt` descending
|
|
430
|
+
* (most recent first) as a side-effect of insertion order. We slice(0, MAX_TASKS)
|
|
431
|
+
* to keep the newest MAX_TASKS entries and discard older ones.
|
|
432
|
+
*/
|
|
433
|
+
|
|
434
|
+
#trimTasks() {
|
|
435
|
+
if (!this.#state.tasks) return;
|
|
436
|
+
const entries = Object.entries(this.#state.tasks);
|
|
437
|
+
if (entries.length <= MAX_TASKS) return;
|
|
438
|
+
// Sort by completedAt (desc), keep newest MAX_TASKS
|
|
439
|
+
entries.sort((a, b) => {
|
|
440
|
+
const da = a[1].completedAt || '';
|
|
441
|
+
const db = b[1].completedAt || '';
|
|
442
|
+
return db.localeCompare(da);
|
|
443
|
+
});
|
|
444
|
+
const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
|
|
445
|
+
this.#state.tasks = trimmed;
|
|
446
|
+
log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#transition(to, changes = {}) {
|
|
450
|
+
this.#state.revision++;
|
|
451
|
+
this.#state.state = to;
|
|
452
|
+
Object.assign(this.#state, changes);
|
|
453
|
+
this.#persist();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// --- O4: O(1) transition lookup via pre-computed cache ---
|
|
457
|
+
#isAllowedTransition(from, via, choiceOrMode) {
|
|
458
|
+
const key = `${via}:${choiceOrMode || ''}`;
|
|
459
|
+
return ALLOWED_TRANSITIONS[from]?.get(key) || null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- O5: Batched audit trail ---
|
|
463
|
+
#audit(phase, decision, reasoning) {
|
|
464
|
+
this.#auditBuffer.push({ ts: Date.now(), phase, decision, reasoning });
|
|
465
|
+
if (this.#auditBuffer.length >= 10 || phase === 'DONE') {
|
|
466
|
+
this.#flushAudit();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#flushAudit() {
|
|
471
|
+
if (this.#auditBuffer.length === 0) return;
|
|
472
|
+
if (!this.#state.audit) this.#state.audit = [];
|
|
473
|
+
this.#state.audit.push(...this.#auditBuffer);
|
|
474
|
+
if (this.#state.audit.length > 100) {
|
|
475
|
+
this.#state.audit = this.#state.audit.slice(-100);
|
|
476
|
+
}
|
|
477
|
+
this.#auditBuffer = [];
|
|
478
|
+
// O1: Skip persist for trivial Level 0 requests (non-terminal states).
|
|
479
|
+
// Final persist still happens via #transition() and on DONE/BLOCKED via setupGracefulShutdown.
|
|
480
|
+
if (this.#state.level === '0' && this.#state.state !== 'DONE' && this.#state.state !== 'BLOCKED') {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.#persist();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --- 3.5: Enriched error with available transitions ---
|
|
487
|
+
#makeError(message, attemptedTransition) {
|
|
488
|
+
const available = (TRANSITIONS[this.#state?.state] || []).map((t) => {
|
|
489
|
+
let desc = t.via;
|
|
490
|
+
if (t.choice) desc += ` (choice=${t.choice})`;
|
|
491
|
+
if (t.mode) desc += ` (mode=${t.mode})`;
|
|
492
|
+
return desc;
|
|
493
|
+
});
|
|
494
|
+
return {
|
|
495
|
+
error: message,
|
|
496
|
+
current_state: this.#state?.state || 'UNKNOWN',
|
|
497
|
+
attempted_transition: attemptedTransition || null,
|
|
498
|
+
available_transitions: available,
|
|
499
|
+
suggestion: this.#suggestRecovery(this.#state?.state, attemptedTransition),
|
|
500
|
+
timestamp: new Date().toISOString(),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#suggestRecovery(state, transition) {
|
|
505
|
+
const suggestions = {
|
|
506
|
+
INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
|
|
507
|
+
CLARIFICATION_PENDING: 'Answer the clarification question, then call record_clarification.',
|
|
508
|
+
DISCOVERY: 'Call record_discovery with a level classification.',
|
|
509
|
+
LEVEL_RESOLVED: 'Call proceed_to_route to move to route decision.',
|
|
510
|
+
ROUTE_DECISION_PENDING: 'Call consume_route_decision with SPEC or DIRECT.',
|
|
511
|
+
SPECIFICATION: 'Call spec_complete when specification is done.',
|
|
512
|
+
EXECUTION_ANALYSIS: 'Call record_execution_analysis with a snapshot.',
|
|
513
|
+
EXECUTION_DECISION_PENDING: 'Call consume_execution_decision with INLINE or SUBAGENT_DRIVEN.',
|
|
514
|
+
EXECUTING_INLINE: 'Call implementation_complete when done.',
|
|
515
|
+
EXECUTING_SUBAGENTS: 'Call implementation_complete when done.',
|
|
516
|
+
BLOCKED: 'Call replan to restart, or abandon to stop.',
|
|
517
|
+
SYNC: 'Call sync_complete to finish.',
|
|
518
|
+
DONE: 'Session complete. Call start_request for a new session.',
|
|
519
|
+
};
|
|
520
|
+
return suggestions[state] || `Unexpected state: ${state}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- 3.3: Degraded mode ---
|
|
524
|
+
#enterDegradedMode(reason) {
|
|
525
|
+
this.#degraded = true;
|
|
526
|
+
log('degraded_mode_activated', { reason, state: this.#state?.state });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#exitDegradedMode() {
|
|
530
|
+
if (!this.#degraded) return;
|
|
531
|
+
this.#degraded = false;
|
|
532
|
+
this.#consecutiveFailures = 0;
|
|
533
|
+
log('degraded_mode_exited', { state: this.#state?.state });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Core transitions ---
|
|
537
|
+
|
|
538
|
+
async startRequest({ requestId, changeId } = {}) {
|
|
539
|
+
this.#load();
|
|
540
|
+
if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
|
|
541
|
+
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
542
|
+
}
|
|
543
|
+
this.#transition('INTERPRETATION_PENDING', {
|
|
544
|
+
requestId: requestId || 'req-' + Date.now(),
|
|
545
|
+
changeId: changeId || null,
|
|
546
|
+
routeDecisionId: null,
|
|
547
|
+
routeChoice: null,
|
|
548
|
+
executionDecisionId: null,
|
|
549
|
+
executionMode: null,
|
|
550
|
+
snapshots: { codegraph: null, execution: null },
|
|
551
|
+
tasks: {},
|
|
552
|
+
fileFingerprints: {},
|
|
553
|
+
error: null,
|
|
554
|
+
});
|
|
555
|
+
this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
556
|
+
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async requestClarification({ question } = {}) {
|
|
560
|
+
this.#load();
|
|
561
|
+
const to = this.#isAllowedTransition(this.#state.state, 'request_clarification');
|
|
562
|
+
if (!to) return this.#makeError(`Cannot request clarification from state ${this.#state.state}`, 'request_clarification');
|
|
563
|
+
this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
564
|
+
this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
565
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async recordClarification() {
|
|
569
|
+
this.#load();
|
|
570
|
+
const to = this.#isAllowedTransition(this.#state.state, 'record_clarification');
|
|
571
|
+
if (!to) return this.#makeError(`Cannot record clarification from state ${this.#state.state}`, 'record_clarification');
|
|
572
|
+
this.#transition(to, { error: null });
|
|
573
|
+
this.#audit('DISCOVERY', 'record_clarification');
|
|
574
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// --- O3: Compressed CodeGraph snapshots ---
|
|
578
|
+
#compressCodegraphSnapshot(fullResult) {
|
|
579
|
+
if (!fullResult || typeof fullResult !== 'object') return fullResult;
|
|
580
|
+
// If it's already compressed (has our marker), return as-is
|
|
581
|
+
if (fullResult._compressed) return fullResult;
|
|
582
|
+
return {
|
|
583
|
+
_compressed: true,
|
|
584
|
+
symbols: (fullResult.symbols || []).map((s) => ({
|
|
585
|
+
name: s.name,
|
|
586
|
+
kind: s.kind,
|
|
587
|
+
file: s.file,
|
|
588
|
+
})),
|
|
589
|
+
blastRadius: fullResult.blastRadius || null,
|
|
590
|
+
fileCount: (fullResult.files || []).length,
|
|
591
|
+
callPaths: fullResult.callPaths?.map((p) => p.map((s) => s.name || s)) || null,
|
|
592
|
+
timestamp: Date.now(),
|
|
593
|
+
// Intentionally NOT including: fullResult.source (too large)
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
|
|
598
|
+
this.#load();
|
|
599
|
+
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
600
|
+
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
601
|
+
|
|
602
|
+
// O3: Compress snapshot before persisting
|
|
603
|
+
const compressedSnapshot = snapshot ? this.#compressCodegraphSnapshot(snapshot) : this.#state.snapshots.codegraph;
|
|
604
|
+
const snapshotJson = compressedSnapshot ? safeJsonStringify(compressedSnapshot) : '';
|
|
605
|
+
if (snapshotJson.length > MAX_SNAPSHOT_JSON_LENGTH) {
|
|
606
|
+
return this.#makeError(
|
|
607
|
+
`Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes (compressed: ${snapshotJson.length})`,
|
|
608
|
+
'record_discovery'
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
613
|
+
this.#transition(to, {
|
|
614
|
+
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
615
|
+
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
616
|
+
level, // O1: persist level for conditional persistence
|
|
617
|
+
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
618
|
+
});
|
|
619
|
+
this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
620
|
+
return {
|
|
621
|
+
state: this.#state.state,
|
|
622
|
+
revision: this.#state.revision,
|
|
623
|
+
level,
|
|
624
|
+
routeDecisionId: this.#state.routeDecisionId,
|
|
625
|
+
defaultChoice,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async proceedToRoute() {
|
|
630
|
+
this.#load();
|
|
631
|
+
const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
|
|
632
|
+
if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
|
|
633
|
+
this.#transition(to);
|
|
634
|
+
this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
635
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async abandon({ reason } = {}) {
|
|
639
|
+
this.#load();
|
|
640
|
+
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
641
|
+
if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
|
|
642
|
+
this.#transition(to, { error: reason || 'Abandoned' });
|
|
643
|
+
this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
644
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
648
|
+
this.#load();
|
|
649
|
+
if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
|
|
650
|
+
return this.#makeError(`Cannot consume route decision from state ${this.#state.state}`, 'consume_route_decision');
|
|
651
|
+
}
|
|
652
|
+
if (this.#state.routeDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_route_decision');
|
|
653
|
+
const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
|
|
654
|
+
if (!to) return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
|
|
655
|
+
this.#transition(to, { routeChoice: choice });
|
|
656
|
+
this.#audit(to, 'consume_route_decision', `choice=${choice}`);
|
|
657
|
+
return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async specComplete() {
|
|
661
|
+
this.#load();
|
|
662
|
+
const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
|
|
663
|
+
if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
|
|
664
|
+
this.#transition(to);
|
|
665
|
+
this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
666
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
|
|
670
|
+
this.#load();
|
|
671
|
+
const to = this.#isAllowedTransition(this.#state.state, 'record_execution_analysis');
|
|
672
|
+
if (!to) return this.#makeError(`Cannot record execution analysis from state ${this.#state.state}`, 'record_execution_analysis');
|
|
673
|
+
if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
|
|
674
|
+
return this.#makeError(
|
|
675
|
+
`Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes`,
|
|
676
|
+
'record_execution_analysis'
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
this.#transition(to, {
|
|
680
|
+
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
681
|
+
executionMode: null,
|
|
682
|
+
snapshots: { ...this.#state.snapshots, execution: snapshot || null },
|
|
683
|
+
});
|
|
684
|
+
this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
685
|
+
return {
|
|
686
|
+
state: this.#state.state,
|
|
687
|
+
revision: this.#state.revision,
|
|
688
|
+
executionDecisionId: this.#state.executionDecisionId,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
693
|
+
this.#load();
|
|
694
|
+
if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
|
|
695
|
+
return this.#makeError(`Cannot consume execution decision from state ${this.#state.state}`, 'consume_execution_decision');
|
|
696
|
+
}
|
|
697
|
+
if (this.#state.executionDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_execution_decision');
|
|
698
|
+
const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
|
|
699
|
+
if (!to) return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
|
|
700
|
+
this.#transition(to, { executionMode: mode });
|
|
701
|
+
this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
|
|
702
|
+
return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async implementationComplete() {
|
|
706
|
+
this.#load();
|
|
707
|
+
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
708
|
+
if (!to) return this.#makeError(`Cannot complete implementation from state ${this.#state.state}`, 'implementation_complete');
|
|
709
|
+
this.#transition(to);
|
|
710
|
+
this.#audit('SYNC', 'implementation_complete');
|
|
711
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async syncComplete() {
|
|
715
|
+
this.#load();
|
|
716
|
+
const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
|
|
717
|
+
if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
|
|
718
|
+
this.#transition(to);
|
|
719
|
+
this.#audit('DONE', 'sync_complete');
|
|
720
|
+
// Flush remaining audit entries
|
|
721
|
+
this.#flushAudit();
|
|
722
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async block({ reason } = {}) {
|
|
726
|
+
this.#load();
|
|
727
|
+
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
728
|
+
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
729
|
+
this.#transition(to, { error: reason || 'Blocked' });
|
|
730
|
+
this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
731
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async replan({ reason } = {}) {
|
|
735
|
+
this.#load();
|
|
736
|
+
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
737
|
+
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
738
|
+
this.#transition(to, {
|
|
739
|
+
error: reason || null,
|
|
740
|
+
routeDecisionId: null,
|
|
741
|
+
routeChoice: null,
|
|
742
|
+
executionDecisionId: null,
|
|
743
|
+
executionMode: null,
|
|
744
|
+
snapshots: { codegraph: null, execution: null },
|
|
745
|
+
tasks: {},
|
|
746
|
+
fileFingerprints: {},
|
|
747
|
+
});
|
|
748
|
+
this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
749
|
+
return { state: this.#state.state, revision: this.#state.revision };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// --- B2: Handoff persistence for cross-session continuity ---
|
|
753
|
+
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
754
|
+
this.#load();
|
|
755
|
+
if (!summary || typeof summary !== 'string') {
|
|
756
|
+
return this.#makeError('summary is required and must be a string', 'set_handoff');
|
|
757
|
+
}
|
|
758
|
+
this.#state.lastHandoff = {
|
|
759
|
+
ts: Date.now(),
|
|
760
|
+
summary,
|
|
761
|
+
nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
|
|
762
|
+
pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
|
|
763
|
+
};
|
|
764
|
+
this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
765
|
+
this.#persist();
|
|
766
|
+
return { ok: true, lastHandoff: this.#state.lastHandoff };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async getHandoff() {
|
|
770
|
+
this.#load();
|
|
771
|
+
return this.#state.lastHandoff;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async clearHandoff() {
|
|
775
|
+
this.#load();
|
|
776
|
+
const prev = this.#state.lastHandoff;
|
|
777
|
+
this.#state.lastHandoff = null;
|
|
778
|
+
this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
|
|
779
|
+
this.#persist();
|
|
780
|
+
return { ok: true, cleared: prev };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async getState() {
|
|
784
|
+
this.#load();
|
|
785
|
+
return structuredClone(this.#state);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async getTasks() {
|
|
789
|
+
this.#load();
|
|
790
|
+
return { ...this.#state.tasks };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async getAvailableTransitions() {
|
|
794
|
+
this.#load();
|
|
795
|
+
return {
|
|
796
|
+
currentState: this.#state.state,
|
|
797
|
+
transitions: TRANSITIONS[this.#state.state] || [],
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// --- O6: Validate edit with fast fingerprint ---
|
|
802
|
+
async validateEdit({ oldString, newString, content, taskId } = {}) {
|
|
803
|
+
this.#load();
|
|
804
|
+
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
805
|
+
return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
|
|
806
|
+
}
|
|
807
|
+
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
808
|
+
return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
|
|
809
|
+
}
|
|
810
|
+
if (oldString.length === 0) {
|
|
811
|
+
return { outcome: 'CONFLICT', reason: 'oldString cannot be empty' };
|
|
812
|
+
}
|
|
813
|
+
// Trivial idempotency: identical strings → nothing to do
|
|
814
|
+
if (oldString === newString) {
|
|
815
|
+
return { outcome: 'ALREADY_APPLIED', taskId, reason: 'oldString and newString are identical' };
|
|
816
|
+
}
|
|
817
|
+
// Count occurrences of oldString in content
|
|
818
|
+
let oldCount = 0;
|
|
819
|
+
let idx = 0;
|
|
820
|
+
while ((idx = content.indexOf(oldString, idx)) !== -1) {
|
|
821
|
+
oldCount++;
|
|
822
|
+
idx += oldString.length;
|
|
823
|
+
}
|
|
824
|
+
// If oldString not found, check if newString is already present (edit was already applied)
|
|
825
|
+
if (oldCount === 0) {
|
|
826
|
+
if (content.includes(newString)) {
|
|
827
|
+
return {
|
|
828
|
+
outcome: 'ALREADY_APPLIED',
|
|
829
|
+
taskId,
|
|
830
|
+
reason: 'oldString not found but newString is present — edit was already applied',
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return { outcome: 'CONFLICT', reason: 'oldString not found in content — file was modified externally' };
|
|
834
|
+
}
|
|
835
|
+
if (oldCount > 1) {
|
|
836
|
+
return {
|
|
837
|
+
outcome: 'CONFLICT',
|
|
838
|
+
reason: `oldString found ${oldCount} times — need more context to disambiguate`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
// oldString found exactly once → safe to replace
|
|
842
|
+
return { outcome: 'EDITABLE', taskId };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Marks a task as completed and records a file fingerprint.
|
|
847
|
+
* Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.
|
|
848
|
+
*/
|
|
849
|
+
async completeTask({ taskId, filePath, fileHash } = {}) {
|
|
850
|
+
this.#load();
|
|
851
|
+
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
852
|
+
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
853
|
+
}
|
|
854
|
+
if (!taskId) return { error: 'taskId is required' };
|
|
855
|
+
if (!this.#state.tasks) this.#state.tasks = {};
|
|
856
|
+
|
|
857
|
+
// O6: Use fast fingerprint if no hash provided
|
|
858
|
+
const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
|
|
859
|
+
|
|
860
|
+
this.#state.tasks[taskId] = {
|
|
861
|
+
status: 'COMPLETED',
|
|
862
|
+
completedAt: new Date().toISOString(),
|
|
863
|
+
filePath: filePath || null,
|
|
864
|
+
fileHash: effectiveHash,
|
|
865
|
+
};
|
|
866
|
+
if (filePath && effectiveHash) {
|
|
867
|
+
if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
|
|
868
|
+
this.#state.fileFingerprints[filePath] = effectiveHash;
|
|
869
|
+
}
|
|
870
|
+
this.#trimTasks();
|
|
871
|
+
this.#persist();
|
|
872
|
+
this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
|
|
873
|
+
return {
|
|
874
|
+
taskId,
|
|
875
|
+
status: 'COMPLETED',
|
|
876
|
+
totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
|
|
877
|
+
.length,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Public flush — force-persists current state to disk.
|
|
883
|
+
* Used by graceful shutdown (private fields not accessible from outside).
|
|
884
|
+
*/
|
|
885
|
+
flush() {
|
|
886
|
+
this.#flushAudit();
|
|
887
|
+
this.#persist();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const statePath = resolve(process.env.OSTACKY_STATE_PATH || join(process.cwd(), '.opencode', 'ostacky-state.json'));
|
|
892
|
+
const controller = new OstackyController({ statePath });
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Wraps an async tool handler to ALWAYS return a response (even on error).
|
|
896
|
+
* Without this, an unhandled exception in any tool handler leaves the LLM
|
|
897
|
+
* waiting forever — the root cause of agent freezes.
|
|
898
|
+
*/
|
|
899
|
+
function safeHandler(fn) {
|
|
900
|
+
return async (params) => {
|
|
901
|
+
try {
|
|
902
|
+
const result = await fn(params);
|
|
903
|
+
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
904
|
+
} catch (error) {
|
|
905
|
+
log('tool:error', {
|
|
906
|
+
name: fn.name || 'anonymous',
|
|
907
|
+
error: error.message,
|
|
908
|
+
stack: error.stack,
|
|
909
|
+
});
|
|
910
|
+
return {
|
|
911
|
+
content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
|
|
912
|
+
isError: true,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const server = new McpServer({
|
|
919
|
+
name: 'ostacky-controller',
|
|
920
|
+
version: '0.7.0',
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
server.registerTool(
|
|
924
|
+
'start_request',
|
|
925
|
+
{
|
|
926
|
+
description:
|
|
927
|
+
'Start or reset a new request. Can be called from ANY state — resets state machine. Call this first.',
|
|
928
|
+
inputSchema: z.object({
|
|
929
|
+
requestId: z.string().optional().describe('Unique request ID'),
|
|
930
|
+
changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
|
|
931
|
+
}),
|
|
932
|
+
},
|
|
933
|
+
safeHandler(async ({ requestId, changeId }) => {
|
|
934
|
+
log('tool:start_request');
|
|
935
|
+
return await controller.startRequest({ requestId, changeId });
|
|
936
|
+
})
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
server.registerTool(
|
|
940
|
+
'request_clarification',
|
|
941
|
+
{
|
|
942
|
+
description: 'Pause execution to ask the user for clarification. Use when the request is too vague to classify. Transitions to CLARIFICATION_PENDING — you MUST stop and wait for user response.',
|
|
943
|
+
inputSchema: z.object({
|
|
944
|
+
question: z.string().optional().describe('The clarification question'),
|
|
945
|
+
}),
|
|
946
|
+
},
|
|
947
|
+
safeHandler(async ({ question }) => {
|
|
948
|
+
log('tool:request_clarification');
|
|
949
|
+
return await controller.requestClarification({ question });
|
|
950
|
+
})
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
server.registerTool(
|
|
954
|
+
'record_clarification',
|
|
955
|
+
{
|
|
956
|
+
description: 'Record that clarification was answered. Transitions to DISCOVERY.',
|
|
957
|
+
inputSchema: z.object({}),
|
|
958
|
+
},
|
|
959
|
+
safeHandler(async () => {
|
|
960
|
+
log('tool:record_clarification');
|
|
961
|
+
return await controller.recordClarification();
|
|
962
|
+
})
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
server.registerTool(
|
|
966
|
+
'record_discovery',
|
|
967
|
+
{
|
|
968
|
+
description:
|
|
969
|
+
'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
|
|
970
|
+
inputSchema: z.object({
|
|
971
|
+
level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
|
|
972
|
+
routeDecisionId: z.string().optional().describe('Unique route decision ID'),
|
|
973
|
+
snapshot: z.any().optional().describe('Optional CodeGraph snapshot (auto-compressed)'),
|
|
974
|
+
}),
|
|
975
|
+
},
|
|
976
|
+
safeHandler(async ({ level, routeDecisionId, snapshot }) => {
|
|
977
|
+
log('tool:record_discovery', { level });
|
|
978
|
+
return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
server.registerTool(
|
|
983
|
+
'consume_route_decision',
|
|
984
|
+
{
|
|
985
|
+
description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
|
|
986
|
+
inputSchema: z.object({
|
|
987
|
+
decisionId: z.string().describe('Route decision ID from record_discovery'),
|
|
988
|
+
choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
|
|
989
|
+
}),
|
|
990
|
+
},
|
|
991
|
+
safeHandler(async ({ decisionId, choice }) => {
|
|
992
|
+
log('tool:consume_route_decision', { choice });
|
|
993
|
+
return await controller.consumeRouteDecision({ decisionId, choice });
|
|
994
|
+
})
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
server.registerTool(
|
|
998
|
+
'spec_complete',
|
|
999
|
+
{
|
|
1000
|
+
description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
|
|
1001
|
+
inputSchema: z.object({}),
|
|
1002
|
+
},
|
|
1003
|
+
safeHandler(async () => {
|
|
1004
|
+
log('tool:spec_complete');
|
|
1005
|
+
return await controller.specComplete();
|
|
1006
|
+
})
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
server.registerTool(
|
|
1010
|
+
'record_execution_analysis',
|
|
1011
|
+
{
|
|
1012
|
+
description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
|
|
1013
|
+
inputSchema: z.object({
|
|
1014
|
+
executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
|
|
1015
|
+
snapshot: z.any().optional().describe('Execution analysis snapshot'),
|
|
1016
|
+
}),
|
|
1017
|
+
},
|
|
1018
|
+
safeHandler(async ({ executionDecisionId, snapshot }) => {
|
|
1019
|
+
log('tool:record_execution_analysis');
|
|
1020
|
+
return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
|
|
1021
|
+
})
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
server.registerTool(
|
|
1025
|
+
'consume_execution_decision',
|
|
1026
|
+
{
|
|
1027
|
+
description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
|
|
1028
|
+
inputSchema: z.object({
|
|
1029
|
+
decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
|
|
1030
|
+
mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
|
|
1031
|
+
}),
|
|
1032
|
+
},
|
|
1033
|
+
safeHandler(async ({ decisionId, mode }) => {
|
|
1034
|
+
log('tool:consume_execution_decision', { mode });
|
|
1035
|
+
return await controller.consumeExecutionDecision({ decisionId, mode });
|
|
1036
|
+
})
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
server.registerTool(
|
|
1040
|
+
'implementation_complete',
|
|
1041
|
+
{
|
|
1042
|
+
description: 'Mark implementation as complete. Transitions to SYNC.',
|
|
1043
|
+
inputSchema: z.object({}),
|
|
1044
|
+
},
|
|
1045
|
+
safeHandler(async () => {
|
|
1046
|
+
log('tool:implementation_complete');
|
|
1047
|
+
return await controller.implementationComplete();
|
|
1048
|
+
})
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
server.registerTool(
|
|
1052
|
+
'sync_complete',
|
|
1053
|
+
{
|
|
1054
|
+
description: 'Mark sync as complete. Transitions to DONE.',
|
|
1055
|
+
inputSchema: z.object({}),
|
|
1056
|
+
},
|
|
1057
|
+
safeHandler(async () => {
|
|
1058
|
+
log('tool:sync_complete');
|
|
1059
|
+
return await controller.syncComplete();
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
server.registerTool(
|
|
1064
|
+
'block',
|
|
1065
|
+
{
|
|
1066
|
+
description: 'Transition to BLOCKED state with an optional reason.',
|
|
1067
|
+
inputSchema: z.object({
|
|
1068
|
+
reason: z.string().optional().describe('Reason for blocking'),
|
|
1069
|
+
}),
|
|
1070
|
+
},
|
|
1071
|
+
safeHandler(async ({ reason }) => {
|
|
1072
|
+
log('tool:block');
|
|
1073
|
+
return await controller.block({ reason });
|
|
1074
|
+
})
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
server.registerTool(
|
|
1078
|
+
'replan',
|
|
1079
|
+
{
|
|
1080
|
+
description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
|
|
1081
|
+
inputSchema: z.object({
|
|
1082
|
+
reason: z.string().optional().describe('Reason for replanning'),
|
|
1083
|
+
}),
|
|
1084
|
+
},
|
|
1085
|
+
safeHandler(async ({ reason }) => {
|
|
1086
|
+
log('tool:replan');
|
|
1087
|
+
return await controller.replan({ reason });
|
|
1088
|
+
})
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
server.registerTool(
|
|
1092
|
+
'proceed_to_route',
|
|
1093
|
+
{
|
|
1094
|
+
description: 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed. Only valid from LEVEL_RESOLVED — call this after asking the user about the route decision.',
|
|
1095
|
+
inputSchema: z.object({}),
|
|
1096
|
+
},
|
|
1097
|
+
safeHandler(async () => {
|
|
1098
|
+
log('tool:proceed_to_route');
|
|
1099
|
+
return await controller.proceedToRoute();
|
|
1100
|
+
})
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
server.registerTool(
|
|
1104
|
+
'abandon',
|
|
1105
|
+
{
|
|
1106
|
+
description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
|
|
1107
|
+
inputSchema: z.object({
|
|
1108
|
+
reason: z.string().optional().describe('Reason for abandoning'),
|
|
1109
|
+
}),
|
|
1110
|
+
},
|
|
1111
|
+
safeHandler(async ({ reason }) => {
|
|
1112
|
+
log('tool:abandon');
|
|
1113
|
+
return await controller.abandon({ reason });
|
|
1114
|
+
})
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
server.registerTool(
|
|
1118
|
+
'ping',
|
|
1119
|
+
{
|
|
1120
|
+
description:
|
|
1121
|
+
'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
|
|
1122
|
+
inputSchema: z.object({}),
|
|
1123
|
+
},
|
|
1124
|
+
safeHandler(async () => {
|
|
1125
|
+
return {
|
|
1126
|
+
pong: true,
|
|
1127
|
+
degraded: controller.degraded,
|
|
1128
|
+
state: await controller.getState().then((s) => ({
|
|
1129
|
+
state: s.state,
|
|
1130
|
+
revision: s.revision,
|
|
1131
|
+
requestId: s.requestId,
|
|
1132
|
+
})),
|
|
1133
|
+
};
|
|
1134
|
+
})
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
server.registerTool(
|
|
1138
|
+
'get_state',
|
|
1139
|
+
{
|
|
1140
|
+
description: 'Get the current controller state (reads persistent store).',
|
|
1141
|
+
inputSchema: z.object({}),
|
|
1142
|
+
},
|
|
1143
|
+
safeHandler(async () => {
|
|
1144
|
+
return await controller.getState();
|
|
1145
|
+
})
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
server.registerTool(
|
|
1149
|
+
'get_tasks',
|
|
1150
|
+
{
|
|
1151
|
+
description: 'Get current task states.',
|
|
1152
|
+
inputSchema: z.object({}),
|
|
1153
|
+
},
|
|
1154
|
+
safeHandler(async () => {
|
|
1155
|
+
return await controller.getTasks();
|
|
1156
|
+
})
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
server.registerTool(
|
|
1160
|
+
'get_available_transitions',
|
|
1161
|
+
{
|
|
1162
|
+
description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
|
|
1163
|
+
inputSchema: z.object({}),
|
|
1164
|
+
},
|
|
1165
|
+
safeHandler(async () => {
|
|
1166
|
+
return await controller.getAvailableTransitions();
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
server.registerTool(
|
|
1171
|
+
'set_handoff',
|
|
1172
|
+
{
|
|
1173
|
+
description: 'Save handoff context for the next session. Call at session end if interrupted or before a context switch. Persists to controller state.',
|
|
1174
|
+
inputSchema: z.object({
|
|
1175
|
+
summary: z.string().describe('What we were working on (1-3 sentences)'),
|
|
1176
|
+
nextSteps: z.array(z.string()).optional().describe('Concrete next actions'),
|
|
1177
|
+
pendingTasks: z.array(z.string()).optional().describe('Task IDs or descriptions of pending work'),
|
|
1178
|
+
}),
|
|
1179
|
+
},
|
|
1180
|
+
safeHandler(async ({ summary, nextSteps, pendingTasks }) => {
|
|
1181
|
+
return await controller.setHandoff({ summary, nextSteps, pendingTasks });
|
|
1182
|
+
})
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
server.registerTool(
|
|
1186
|
+
'get_handoff',
|
|
1187
|
+
{
|
|
1188
|
+
description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
|
|
1189
|
+
inputSchema: z.object({}),
|
|
1190
|
+
},
|
|
1191
|
+
safeHandler(async () => {
|
|
1192
|
+
return await controller.getHandoff();
|
|
1193
|
+
})
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
server.registerTool(
|
|
1197
|
+
'clear_handoff',
|
|
1198
|
+
{
|
|
1199
|
+
description: 'Mark handoff as consumed after the agent has loaded the context.',
|
|
1200
|
+
inputSchema: z.object({}),
|
|
1201
|
+
},
|
|
1202
|
+
safeHandler(async () => {
|
|
1203
|
+
return await controller.clearHandoff();
|
|
1204
|
+
})
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
server.registerTool(
|
|
1208
|
+
'check_pending_state',
|
|
1209
|
+
{
|
|
1210
|
+
description:
|
|
1211
|
+
'Check if agent is in a pending state waiting for user input. ' +
|
|
1212
|
+
'MUST be called before ANY tool call when controller is available. ' +
|
|
1213
|
+
'Returns ALLOW or BLOCKED with reason. ' +
|
|
1214
|
+
'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
|
|
1215
|
+
'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
|
|
1216
|
+
inputSchema: z.object({}),
|
|
1217
|
+
},
|
|
1218
|
+
safeHandler(async () => {
|
|
1219
|
+
const state = await controller.getState();
|
|
1220
|
+
const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
|
|
1221
|
+
if (pendingStates.includes(state.state)) {
|
|
1222
|
+
return {
|
|
1223
|
+
status: 'BLOCKED',
|
|
1224
|
+
state: state.state,
|
|
1225
|
+
revision: state.revision,
|
|
1226
|
+
reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
|
|
1227
|
+
degraded: controller.degraded,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
|
|
1231
|
+
})
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
server.registerTool(
|
|
1235
|
+
'validate_edit',
|
|
1236
|
+
{
|
|
1237
|
+
description:
|
|
1238
|
+
'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
|
|
1239
|
+
'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
|
|
1240
|
+
'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
|
|
1241
|
+
inputSchema: z.object({
|
|
1242
|
+
oldString: z.string().describe('The exact string to find in content (must be unique).'),
|
|
1243
|
+
newString: z.string().describe('The replacement string.'),
|
|
1244
|
+
content: z
|
|
1245
|
+
.string()
|
|
1246
|
+
.describe(
|
|
1247
|
+
'REQUIRED — The full file content. ' +
|
|
1248
|
+
'You MUST read the file first with the Read tool, then pass the complete content here. ' +
|
|
1249
|
+
'Example: call Read on the file, store the output, then call validate_edit with that content. ' +
|
|
1250
|
+
'Without this parameter, validate_edit will fail.'
|
|
1251
|
+
),
|
|
1252
|
+
taskId: z.string().optional().describe('Optional task ID for tracking.'),
|
|
1253
|
+
}),
|
|
1254
|
+
},
|
|
1255
|
+
safeHandler(async ({ oldString, newString, content, taskId }) => {
|
|
1256
|
+
log('tool:validate_edit', {
|
|
1257
|
+
taskId,
|
|
1258
|
+
oldLen: oldString?.length,
|
|
1259
|
+
newLen: newString?.length,
|
|
1260
|
+
hasContent: !!content,
|
|
1261
|
+
});
|
|
1262
|
+
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1263
|
+
return {
|
|
1264
|
+
outcome: 'CONFLICT',
|
|
1265
|
+
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
return await controller.validateEdit({ oldString, newString, content, taskId });
|
|
1269
|
+
})
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
server.registerTool(
|
|
1273
|
+
'complete_task',
|
|
1274
|
+
{
|
|
1275
|
+
description:
|
|
1276
|
+
'Mark a task as completed and optionally record a file fingerprint. ' +
|
|
1277
|
+
'Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.',
|
|
1278
|
+
inputSchema: z.object({
|
|
1279
|
+
taskId: z.string().describe('The task ID to mark as completed.'),
|
|
1280
|
+
filePath: z.string().optional().describe('Optional file path that was modified.'),
|
|
1281
|
+
fileHash: z.string().optional().describe('Optional SHA-256 or fast fingerprint of the file after modification.'),
|
|
1282
|
+
}),
|
|
1283
|
+
},
|
|
1284
|
+
safeHandler(async ({ taskId, filePath, fileHash }) => {
|
|
1285
|
+
log('tool:complete_task', { taskId, filePath });
|
|
1286
|
+
return await controller.completeTask({ taskId, filePath, fileHash });
|
|
1287
|
+
})
|
|
1288
|
+
);
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Graceful shutdown: clean up tmp/lock files and flush state.
|
|
1292
|
+
*/
|
|
1293
|
+
function setupGracefulShutdown(ctrl) {
|
|
1294
|
+
const shutdown = (signal) => {
|
|
1295
|
+
log('shutdown', { signal });
|
|
1296
|
+
// Final persist attempt (flush via public method, sync inside)
|
|
1297
|
+
try {
|
|
1298
|
+
if (ctrl) ctrl.flush();
|
|
1299
|
+
} catch {
|
|
1300
|
+
/* best-effort */
|
|
1301
|
+
}
|
|
1302
|
+
// Clean up own tmp and lock files
|
|
1303
|
+
try {
|
|
1304
|
+
cleanupTmpFiles(statePath);
|
|
1305
|
+
} catch {
|
|
1306
|
+
/* best-effort */
|
|
1307
|
+
}
|
|
1308
|
+
process.exit(signal === 'SIGINT' ? 130 : 0);
|
|
1309
|
+
};
|
|
1310
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1311
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1312
|
+
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
|
1313
|
+
process.on('SIGPIPE', () => shutdown('SIGPIPE'));
|
|
1314
|
+
// Prevent unhandled rejections from silently killing the server
|
|
1315
|
+
process.on('unhandledRejection', (reason) => {
|
|
1316
|
+
log('unhandled_rejection', { reason: String(reason) });
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function main() {
|
|
1321
|
+
log('Starting ostacky-controller MCP v0.7.0...');
|
|
1322
|
+
log('State path:', { path: statePath });
|
|
1323
|
+
// Clean up stale tmp/lock files from previous runs
|
|
1324
|
+
cleanupTmpFiles(statePath);
|
|
1325
|
+
setupGracefulShutdown(controller);
|
|
1326
|
+
const transport = new StdioServerTransport();
|
|
1327
|
+
await server.connect(transport);
|
|
1328
|
+
log('ostacky-controller connected and ready');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const isDirectRun =
|
|
1332
|
+
process.argv[1] && (process.argv[1].endsWith('/index.js') || process.argv[1].endsWith('\\index.js'));
|
|
1333
|
+
|
|
1334
|
+
if (isDirectRun) {
|
|
1335
|
+
main().catch((error) => {
|
|
1336
|
+
console.error('Fatal error:', error);
|
|
1337
|
+
process.exit(1);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
export { OstackyController };
|