sillyspec 3.25.8 → 3.25.11
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/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +160 -159
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +208 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +215 -34
- package/src/init.js +7 -14
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +72 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +6 -3
- package/src/progress/stage-machine.js +61 -65
- package/src/progress/step-store.js +62 -67
- package/src/progress.js +155 -233
- package/src/quicklog.js +13 -5
- package/src/run/command.js +65 -37
- package/src/run/complete-handlers.js +1005 -889
- package/src/run/complete.js +115 -208
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +289 -17
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +648 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +389 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +13 -9
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +9 -6
- package/src/stages/execute.js +61 -20
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +1 -1
- package/src/stages/scan.js +9 -3
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +12 -7
- package/src/sync.js +4 -4
- package/src/task-review.js +30 -1
- package/src/verify-postcheck.js +933 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +85 -68
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
package/src/run/shared.js
CHANGED
|
@@ -1,600 +1,648 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* run.js 共享纯工具(W6 Step1 从 run.js 抽出)。
|
|
3
|
-
*
|
|
4
|
-
* 安全锚:run.js 始终是 barrel。这些函数搬至此模块后,run.js import 回来;
|
|
5
|
-
* parsePorcelainPath + auditQuickCompletion 被 test 直接 import,run.js 必须 re-export。
|
|
6
|
-
*
|
|
7
|
-
* 路径修正(相对 src/run/):
|
|
8
|
-
* - resolvePromptIncludes 的 templates/prompts 在仓库根 → __dirname 上两层
|
|
9
|
-
* - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
|
|
10
|
-
* - safeGit
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { fileURLToPath } from 'node:url'
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
}
|
|
515
|
-
|
|
516
|
-
//
|
|
517
|
-
if (
|
|
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
|
-
result.
|
|
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
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1
|
+
/**
|
|
2
|
+
* run.js 共享纯工具(W6 Step1 从 run.js 抽出)。
|
|
3
|
+
*
|
|
4
|
+
* 安全锚:run.js 始终是 barrel。这些函数搬至此模块后,run.js import 回来;
|
|
5
|
+
* parsePorcelainPath + auditQuickCompletion 被 test 直接 import,run.js 必须 re-export。
|
|
6
|
+
*
|
|
7
|
+
* 路径修正(相对 src/run/):
|
|
8
|
+
* - resolvePromptIncludes 的 templates/prompts 在仓库根 → __dirname 上两层
|
|
9
|
+
* - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
|
|
10
|
+
* - safeGit 已收口到 src/git-helper.js(单一公共 git 调用入口,数组形式不经 shell),
|
|
11
|
+
* 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
|
|
12
|
+
*/
|
|
13
|
+
import { basename, join, resolve, dirname, sep } from 'node:path'
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
// safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
|
|
18
|
+
// re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
|
|
19
|
+
import { safeGit } from '../git-helper.js'
|
|
20
|
+
export { safeGit }
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
|
|
24
|
+
// ── Wait State Constants ──(W6 Step3 从 run.js 搬入:prompt.js outputStep 与 run.js wait-detection 共用)
|
|
25
|
+
// 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
|
|
26
|
+
export const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
|
|
27
|
+
|
|
28
|
+
// ── did-you-mean(命令级 + flag 级 typo 建议,零依赖)──
|
|
29
|
+
// 历史:agent 打错命令名/flag 名只报「未知」+整屏列表,要自己捞正确拼写。
|
|
30
|
+
export function levenshtein(a, b) {
|
|
31
|
+
const m = String(a).length, n = String(b).length
|
|
32
|
+
if (!m) return n; if (!n) return m
|
|
33
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i)
|
|
34
|
+
for (let i = 1; i <= m; i++) {
|
|
35
|
+
const cur = [i]
|
|
36
|
+
for (let j = 1; j <= n; j++) {
|
|
37
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
38
|
+
}
|
|
39
|
+
prev = cur
|
|
40
|
+
}
|
|
41
|
+
return prev[n]
|
|
42
|
+
}
|
|
43
|
+
export function didYouMean(input, candidates) {
|
|
44
|
+
if (!input) return null
|
|
45
|
+
let best = null, bestD = Infinity
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
const d = levenshtein(String(input).toLowerCase(), String(c).toLowerCase())
|
|
48
|
+
if (d < bestD) { bestD = d; best = c }
|
|
49
|
+
}
|
|
50
|
+
return (best !== null && bestD <= Math.max(1, Math.floor(String(input).length / 2))) ? best : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 变更名路径穿越消毒(F6)──
|
|
54
|
+
// change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
|
|
55
|
+
// 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
|
|
56
|
+
// 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
|
|
57
|
+
export function assertSafeChangeName(name, label = '变更名') {
|
|
58
|
+
if (name == null) return
|
|
59
|
+
const s = String(name)
|
|
60
|
+
if (s === '') throw new Error(`${label}不能为空`)
|
|
61
|
+
// 含路径分隔符 或 父目录穿越段 → 拒绝(Windows 下 \\ 也算)
|
|
62
|
+
if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
|
|
63
|
+
throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
|
|
64
|
+
}
|
|
65
|
+
if (!/^[\w.\-]+$/.test(s)) {
|
|
66
|
+
throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
import { buildExecuteSteps } from '../stages/execute.js'
|
|
71
|
+
import { buildPlanSteps } from '../stages/plan.js'
|
|
72
|
+
import { stageRegistry } from '../stages/index.js'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
|
|
76
|
+
* 把 stage step prompt 里 self-contained 的大块抽到外部模板,CLI 端组装时注入——
|
|
77
|
+
* agent 收到的仍是自包含 prompt 字符串,无需自己 Read。单次替换(不递归);
|
|
78
|
+
* 模板缺失则保留占位符并 warn,便于发现配置错误。
|
|
79
|
+
*/
|
|
80
|
+
export function resolvePromptIncludes(text) {
|
|
81
|
+
return text.replace(/\{\{include:\s*([\w.-]+)\s*\}\}/g, (match, name) => {
|
|
82
|
+
// shared.js 在 src/run/,templates/prompts 在仓库根 → 上两层
|
|
83
|
+
const tplPath = join(__dirname, '..', '..', 'templates', 'prompts', `${name}.md`)
|
|
84
|
+
if (!existsSync(tplPath)) {
|
|
85
|
+
console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
|
|
86
|
+
return match
|
|
87
|
+
}
|
|
88
|
+
return readFileSync(tplPath, 'utf8')
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 找 .sillyspec 祖先目录:用户指定 specDir 优先,否则从 cwd 向上找。
|
|
94
|
+
* @param {string} cwd
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
|
|
97
|
+
* @returns {string} 规范目录的绝对路径
|
|
98
|
+
*/
|
|
99
|
+
export function resolveSpecDir(cwd, opts = {}) {
|
|
100
|
+
if (opts.specDir) return resolve(opts.specDir)
|
|
101
|
+
let dir = resolve(cwd)
|
|
102
|
+
while (true) {
|
|
103
|
+
const candidate = join(dir, '.sillyspec')
|
|
104
|
+
if (existsSync(candidate)) return candidate
|
|
105
|
+
const parent = dirname(dir)
|
|
106
|
+
if (parent === dir) break
|
|
107
|
+
dir = parent
|
|
108
|
+
}
|
|
109
|
+
return join(resolve(cwd), '.sillyspec')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 枚举 cwd 祖先链(含自身)上所有 .sillyspec 目录的绝对路径,上界 = git root。
|
|
114
|
+
*
|
|
115
|
+
* 返回顺序:从 cwd 最近开始向上到 git root(含两端命中项)。单实例项目任意子目录恒返回 [自身]。
|
|
116
|
+
*
|
|
117
|
+
* 上界 = git root(monorepo 天然边界)。必须有限上界:一路数到文件系统根会撞 home 等
|
|
118
|
+
* 无关祖先的孤立 .sillyspec(home 下任何项目都被误报多实例)。非 git 仓库:不向上数,
|
|
119
|
+
* 只看 cwd 自身(≤1,永不误报)。
|
|
120
|
+
*
|
|
121
|
+
* 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
|
|
122
|
+
* 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
|
|
123
|
+
*/
|
|
124
|
+
export function ancestorSpecDirs(cwd) {
|
|
125
|
+
const resolved = resolve(cwd)
|
|
126
|
+
// 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
|
|
127
|
+
// 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
|
|
128
|
+
// 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
|
|
129
|
+
// (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
|
|
130
|
+
const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
|
|
131
|
+
let ceiling = topLevel ? resolve(topLevel) : null
|
|
132
|
+
const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
|
|
133
|
+
if (commonDir) {
|
|
134
|
+
const absCommonDir = resolve(resolved, commonDir)
|
|
135
|
+
if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
|
|
136
|
+
const mainRoot = dirname(absCommonDir)
|
|
137
|
+
// 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
|
|
138
|
+
if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const dirs = []
|
|
142
|
+
let dir = resolved
|
|
143
|
+
while (true) {
|
|
144
|
+
if (existsSync(join(dir, '.sillyspec'))) dirs.push(join(dir, '.sillyspec'))
|
|
145
|
+
if (!ceiling || resolve(dir) === resolve(ceiling)) break
|
|
146
|
+
const parent = dirname(dir)
|
|
147
|
+
if (parent === dir) break
|
|
148
|
+
dir = parent
|
|
149
|
+
}
|
|
150
|
+
return dirs
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 统计 cwd 祖先链上有多少个 .sillyspec 目录(= ancestorSpecDirs(cwd).length)。
|
|
155
|
+
*
|
|
156
|
+
* ≥2 个 = monorepo 多实例(子项目独立 .sillyspec + 根 .sillyspec 并存),resolveSpecDir 命中的
|
|
157
|
+
* 「最近」实例未必是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)→ 漂移风险。
|
|
158
|
+
* 单实例项目在任意子目录工作恒返回 1,不误报。
|
|
159
|
+
*/
|
|
160
|
+
export function countAncestorSpecDirs(cwd) {
|
|
161
|
+
return ancestorSpecDirs(cwd).length
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 在 cwd 祖先链的各 specBase 中查找 quick 会话 guard.json(坑 quick-cwd-drift-splits-specdir)。
|
|
166
|
+
*
|
|
167
|
+
* sessionId(quick-<uuid8>)全局唯一,但 guard.json 按 specBase 分散落盘
|
|
168
|
+
* (<specBase>/.runtime/quick-sessions/<sessionId>/guard.json)。cd 漂移到子项目后,当前 specBase
|
|
169
|
+
* 下读不到本 session 的 guard,但祖先链别处的 specBase(创建 session 的根)仍有它 → 据此定位
|
|
170
|
+
* session 真正归属,供 detectQuickSessionDrift 的 fail-fast 守卫判断。
|
|
171
|
+
*
|
|
172
|
+
* 遍历顺序 = ancestorSpecDirs(cwd 最近 → git root),返回第一个命中。
|
|
173
|
+
*
|
|
174
|
+
* @param {string} cwd
|
|
175
|
+
* @param {string} sessionId - quick-<uuid8>
|
|
176
|
+
* @returns {{ specBase: string, guard: object } | null}
|
|
177
|
+
*/
|
|
178
|
+
export function locateQuickSessionGuard(cwd, sessionId) {
|
|
179
|
+
if (!sessionId) return null
|
|
180
|
+
for (const specBase of ancestorSpecDirs(cwd)) {
|
|
181
|
+
const guardFile = join(specBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
|
|
182
|
+
if (existsSync(guardFile)) {
|
|
183
|
+
try {
|
|
184
|
+
const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
185
|
+
return { specBase, guard }
|
|
186
|
+
} catch {
|
|
187
|
+
// guard.json 损坏:当作不存在,继续找下一个(不阻断主流程)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 检测 quick 会话是否跨 specDir 漂移(坑 quick-cwd-drift-splits-specdir)。
|
|
196
|
+
*
|
|
197
|
+
* quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),故 quick
|
|
198
|
+
* 漂移除 countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落
|
|
199
|
+
* 子项目、根 session 停滞)。本函数补这个口:
|
|
200
|
+
* - 当前 specBase 已有本 session guard → null(同一 specDir,无漂移)
|
|
201
|
+
* - 当前 specBase 没有,但祖先链别处有同 sessionId guard → 判漂移,返回引导信息供调用方 fail-fast
|
|
202
|
+
* - 别处也没有 → null(真·新会话首次启动,guard 尚未建,放行 —— 不误伤子项目主动启新 quick)
|
|
203
|
+
*
|
|
204
|
+
* 风格仿 validateChangeExists:null = 通过,对象 = 失败(含 message)。
|
|
205
|
+
*
|
|
206
|
+
* @param {string} cwd
|
|
207
|
+
* @param {string} currentSpecBase - 当前命中的 specBase
|
|
208
|
+
* @param {string} sessionId - quick-<uuid8>
|
|
209
|
+
* @returns {{ realSpecBase: string, message: string } | null}
|
|
210
|
+
*/
|
|
211
|
+
export function detectQuickSessionDrift(cwd, currentSpecBase, sessionId) {
|
|
212
|
+
if (!sessionId || !currentSpecBase) return null
|
|
213
|
+
// 当前 specBase 已有本 session guard → 同一 specDir,无漂移
|
|
214
|
+
const currentGuard = join(currentSpecBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
|
|
215
|
+
if (existsSync(currentGuard)) return null
|
|
216
|
+
// 当前没有 → 看祖先链别处有没有同 sessionId 的 guard
|
|
217
|
+
const located = locateQuickSessionGuard(cwd, sessionId)
|
|
218
|
+
if (!located) return null // 别处也没有 = 真·新会话首次启动,放行
|
|
219
|
+
return {
|
|
220
|
+
realSpecBase: located.specBase,
|
|
221
|
+
message: `quick 会话 "${sessionId}" 创建于 ${located.specBase},当前 cwd 解析到 ${currentSpecBase}(跨 specDir 漂移)。` +
|
|
222
|
+
`继续会把 progress/artifact/QUICKLOG 写到错误的 spec,与原会话分裂。` +
|
|
223
|
+
`排查:① cd 回 ${dirname(located.specBase)}(原 spec 所属项目根)再跑;② 或显式 --spec-dir ${located.specBase} 指定原 spec。`,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 检测 specBase 是否命中 worktree 内 checkout 出来的 .sillyspec 副本(坑 worktree-execute-spec-drift)。
|
|
229
|
+
*
|
|
230
|
+
* worktree 根 = <mainRepo>/.sillyspec/.runtime/worktrees/<changeName>/(worktree.js WORKTREES_REL)。
|
|
231
|
+
* worktree 是主仓完整 checkout,若 .sillyspec/changes/ 被跟踪 → 副本里 change 目录真实存在,
|
|
232
|
+
* validateChangeExists 被骗放行 → 进度/产出写进副本,与主仓 .sillyspec 分裂(副本随工作树清理丢失)。
|
|
233
|
+
*
|
|
234
|
+
* 判据:specBase 路径含 `.sillyspec/.runtime/worktrees/<seg>/.sillyspec`。主仓 specBase=<mainRepo>/.sillyspec
|
|
235
|
+
* 无此段 → null,零误伤。返回 null=通过;对象=漂移(含 mainSpecBase/changeName/message),风格仿
|
|
236
|
+
* validateChangeExists。覆盖 plan/execute/verify/archive(同 Set)——都要求 change 已存在、都会被副本骗过。
|
|
237
|
+
* 平台模式/--spec-dir 已明确指定,由调用方跳过。
|
|
238
|
+
*/
|
|
239
|
+
export function detectWorktreeSpecDrift(specBase) {
|
|
240
|
+
if (!specBase) return null
|
|
241
|
+
const seg = resolve(specBase).split(sep)
|
|
242
|
+
// specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
|
|
243
|
+
// <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
|
|
244
|
+
// 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
|
|
245
|
+
if (seg[seg.length - 1] !== '.sillyspec') return null
|
|
246
|
+
for (let i = 0; i + 3 < seg.length; i++) {
|
|
247
|
+
if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
|
|
248
|
+
const changeName = seg[i + 3]
|
|
249
|
+
const mainSpec = seg.slice(0, i + 1).join(sep) // <mainRepo>/.sillyspec
|
|
250
|
+
return {
|
|
251
|
+
changeName,
|
|
252
|
+
mainSpecBase: mainSpec,
|
|
253
|
+
message: `当前 spec 命中 worktree 副本(${specBase})——这是 ${changeName} 隔离工作树内 checkout 出来的 .sillyspec,不是主仓 spec。\n` +
|
|
254
|
+
` 在此跑 plan/execute/verify/archive 会把进度与产出写到副本,与主仓 .sillyspec 分裂(副本随工作树清理而丢失)。\n` +
|
|
255
|
+
` 排查:① cd 回 ${dirname(mainSpec)}(主仓根)再跑;② 或 --spec-dir ${mainSpec} 显式指定主仓 spec。`,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
|
|
264
|
+
*
|
|
265
|
+
* 优先级:平台模式 runtimeRoot > drift 锚点 specDriftAnchor > 本地 specBase/.runtime。
|
|
266
|
+
* specDriftAnchor 仅在此处消费,不参与平台 sentinel(specRoot||runtimeRoot)判定,
|
|
267
|
+
* 避免误跳 triggerSync(:286)/ checkApproval(:313)/ 平台渲染分支(D-02)。
|
|
268
|
+
*
|
|
269
|
+
* 历史:drift 守卫(command.js)命中时只重写本地 specBase/specRoot/specDir/pm(progress 落主仓),
|
|
270
|
+
* 但漏设 platformOpts 字段——下游 15 处 runtimeRoot 解析从 cwd(仍 worktree)重算 → execute-runs /
|
|
271
|
+
* stage-reviews 落 worktree,被 cleanup 整目录删。本函数 + specDriftAnchor 锚点堵此源头。
|
|
272
|
+
*
|
|
273
|
+
* @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
|
|
274
|
+
* @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
|
|
275
|
+
* @returns {string} .runtime 根目录绝对路径
|
|
276
|
+
*/
|
|
277
|
+
export function resolveRuntimeRoot(platformOpts, localSpecBase) {
|
|
278
|
+
if (platformOpts?.runtimeRoot) return platformOpts.runtimeRoot
|
|
279
|
+
if (platformOpts?.specDriftAnchor) return join(platformOpts.specDriftAnchor, '.runtime')
|
|
280
|
+
return join(localSpecBase, '.runtime')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* quick 会话目录(.runtime/quick-sessions)的单一解析入口(multi-agent-review Q4)。
|
|
285
|
+
*
|
|
286
|
+
* 历史 bug:stage.js 写 guard 用 `specBase/.runtime/quick-sessions/`,收尾 handleQuickStageCompletion
|
|
287
|
+
* 读/清理用 `resolveRuntimeRoot`(runtimeRoot 被设时返回 runtimeRoot)。两者在平台模式(runtimeRoot 与
|
|
288
|
+
* specBase/.runtime 不同)下分裂——guard 写到 specBase/.runtime、收尾从 runtimeRoot 读不到 → guard=null
|
|
289
|
+
* brownfield 分支整体跳过边界审计 + 兜底重分配 qlId + session 目录清错位置。
|
|
290
|
+
*
|
|
291
|
+
* 收敛到此函数后,写/读/清理三处共用同一解析(基于 resolveRuntimeRoot),任何平台/漂移模式都对齐。
|
|
292
|
+
*
|
|
293
|
+
* @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
|
|
294
|
+
* @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
|
|
295
|
+
* @returns {string} quick-sessions 目录绝对路径
|
|
296
|
+
*/
|
|
297
|
+
export function resolveQuickSessionsDir(platformOpts, localSpecBase) {
|
|
298
|
+
return join(resolveRuntimeRoot(platformOpts, localSpecBase), 'quick-sessions')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
|
|
303
|
+
*/
|
|
304
|
+
export function resolveChangeDir(cwd, progress, specDir = null) {
|
|
305
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
306
|
+
if (!existsSync(changesDir)) return null
|
|
307
|
+
|
|
308
|
+
// 1. 优先用 currentChange
|
|
309
|
+
if (progress.currentChange) {
|
|
310
|
+
const target = join(changesDir, progress.currentChange)
|
|
311
|
+
if (existsSync(target)) return target
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. fallback:唯一非 archive 目录
|
|
315
|
+
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
316
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
317
|
+
if (entries.length === 1) return join(changesDir, entries[0].name)
|
|
318
|
+
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
|
|
324
|
+
*/
|
|
325
|
+
// sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
|
|
326
|
+
// fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
|
|
327
|
+
// 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
|
|
328
|
+
const SYNC_TOTAL_TIMEOUT_MS = 8_000
|
|
329
|
+
|
|
330
|
+
export async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
331
|
+
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
|
332
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
|
|
333
|
+
try {
|
|
334
|
+
if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
|
|
335
|
+
// shared.js 在 src/run/,sync.js 在 src/ → 退一层
|
|
336
|
+
const syncMod = await import('../sync.js')
|
|
337
|
+
let timer
|
|
338
|
+
try {
|
|
339
|
+
await Promise.race([
|
|
340
|
+
syncMod.sync(changeName, cwd),
|
|
341
|
+
new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
|
|
342
|
+
])
|
|
343
|
+
} finally {
|
|
344
|
+
clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
|
|
345
|
+
}
|
|
346
|
+
} catch (e) {
|
|
347
|
+
// sync.js 不存在或同步失败,静默跳过
|
|
348
|
+
console.warn('⚠️ 同步失败:', e.message)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
|
|
354
|
+
* 平台模式走自己的链路,跳过;否则 await import sync.js。
|
|
355
|
+
* @returns {{ status: string, reason?: string } | null}
|
|
356
|
+
*/
|
|
357
|
+
export async function checkApproval(cwd, changeName, platformOpts = {}) {
|
|
358
|
+
// 平台模式不需要 CLI 内置审批检查
|
|
359
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
|
|
360
|
+
try {
|
|
361
|
+
// shared.js 在 src/run/,sync.js 在 src/ → 退一层
|
|
362
|
+
const syncMod = await import('../sync.js')
|
|
363
|
+
return await syncMod.checkApproval(changeName, cwd)
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 解析 git status --porcelain 单行 → 文件路径(去引号/处理 rename/归一化)。
|
|
371
|
+
* 注意:line.slice(3) —— porcelain 行前 2 字符是状态码 + 1 空格,路径从 index 3 开始。
|
|
372
|
+
*/
|
|
373
|
+
export function parsePorcelainPath(line) {
|
|
374
|
+
if (!line) return ''
|
|
375
|
+
let path = line.slice(3).trim()
|
|
376
|
+
if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
|
|
377
|
+
path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
378
|
+
}
|
|
379
|
+
const arrow = path.indexOf(' -> ')
|
|
380
|
+
if (arrow !== -1) path = path.slice(arrow + 4)
|
|
381
|
+
return path.replace(/\\/g, '/')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计 与 QUICKLOG「文件:」行回填
|
|
386
|
+
* 单源)。回填文件行时复用:review.changedFiles 含这些元数据时过滤掉,只留真实业务文件。
|
|
387
|
+
*
|
|
388
|
+
* 归类口径:
|
|
389
|
+
* - quicklog/.runtime/modules/_module-map/knowledge-uncategorized 等 quick 自身产物 → 元数据
|
|
390
|
+
* - .sillyspec/changes/:quick 自己没有该目录,其下文件要么是关联变更(reverse-sync),
|
|
391
|
+
* 要么是并发他者会话的工作。非关联变更目录整体视为元数据放行;关联变更文件不算(属本 quick 真实改动)
|
|
392
|
+
*
|
|
393
|
+
* 「并发工作 vs 偷建变更」的意图软判定留给 sillyhub,确定性校验只做路径归类。
|
|
394
|
+
*
|
|
395
|
+
* @param {string} p 文件路径(容错 \\ / 混用)
|
|
396
|
+
* @param {string[]} [linkedChanges] 关联变更名列表(影响 changes/ 归类)
|
|
397
|
+
* @returns {boolean} true=quick 元数据,应从业务文件列表过滤掉
|
|
398
|
+
*/
|
|
399
|
+
export function isQuickMetadata(p, linkedChanges = []) {
|
|
400
|
+
const normalizeGitPath = (x) => String(x).replace(/\\/g, '/')
|
|
401
|
+
const file = normalizeGitPath(p)
|
|
402
|
+
if (file.startsWith('.sillyspec/quicklog/')
|
|
403
|
+
|| file.startsWith('.sillyspec/.runtime/')
|
|
404
|
+
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
405
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
406
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
|
|
407
|
+
if (file.startsWith('.sillyspec/changes/')) {
|
|
408
|
+
const linkedChangeNames = new Set((Array.isArray(linkedChanges) ? linkedChanges : []).map(c => normalizeGitPath(c)))
|
|
409
|
+
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
410
|
+
// bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)或具体但非关联的变更 → 放行
|
|
411
|
+
if (!m || !linkedChangeNames.has(m[1])) return true
|
|
412
|
+
}
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* quick 完成审计:对比 baseline 与实际变更。
|
|
418
|
+
* @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number }}
|
|
419
|
+
*/
|
|
420
|
+
export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
421
|
+
const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
|
|
422
|
+
const { isConfirm } = options
|
|
423
|
+
// stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
|
|
424
|
+
// 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
|
|
425
|
+
// 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
|
|
426
|
+
const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0 }
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
// safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
|
|
430
|
+
// 外层 catch 吞成 warning(multi-agent-review Q3)。safeGit 返回 {value,error} 不抛;若仍有 error
|
|
431
|
+
// 说明真读不到 git 状态,保守阻断(审计无锚点不能放行),不静默降级。
|
|
432
|
+
// trim:false 必传:porcelain 首行前导空格是状态码一部分,trim 会削掉致 parsePorcelainPath 丢首字符。
|
|
433
|
+
const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
|
|
434
|
+
if (statusResult.error) {
|
|
435
|
+
result.reasons.push(`审计失败(git status): ${statusResult.error}`)
|
|
436
|
+
result.status = 'blocked'
|
|
437
|
+
return result
|
|
438
|
+
}
|
|
439
|
+
const gitStatus = statusResult.value || ''
|
|
440
|
+
// 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
|
|
441
|
+
const currentEntries = gitStatus.split('\n').filter(Boolean)
|
|
442
|
+
|
|
443
|
+
const normalizeGitPath = (p) => p.replace(/\\/g, '/')
|
|
444
|
+
// step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
|
|
445
|
+
// 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
|
|
446
|
+
// → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
|
|
447
|
+
// 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
|
|
448
|
+
// 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
|
|
449
|
+
const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
|
|
450
|
+
const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
|
|
451
|
+
const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
|
|
452
|
+
const isBaselineFile = (p) => {
|
|
453
|
+
const f = normalizeGitPath(p)
|
|
454
|
+
return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
|
|
455
|
+
}
|
|
456
|
+
const DANGEROUS_PATTERNS = [
|
|
457
|
+
'package.json',
|
|
458
|
+
'package-lock.json',
|
|
459
|
+
'yarn.lock',
|
|
460
|
+
'pnpm-lock.yaml',
|
|
461
|
+
'.eslintrc',
|
|
462
|
+
'tsconfig.json',
|
|
463
|
+
// SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
|
|
464
|
+
// 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
|
|
465
|
+
// (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
|
|
466
|
+
// 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
|
|
467
|
+
// barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
|
|
468
|
+
'src/run.js',
|
|
469
|
+
'src/run/',
|
|
470
|
+
'src/progress.js',
|
|
471
|
+
'src/progress/',
|
|
472
|
+
'src/db.js',
|
|
473
|
+
'src/stage-contract.js',
|
|
474
|
+
'src/stage-contract-spec.js',
|
|
475
|
+
'src/worktree.js',
|
|
476
|
+
'src/worktree-apply.js',
|
|
477
|
+
'src/hooks/',
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
for (const entry of currentEntries) {
|
|
481
|
+
const status = entry.slice(0, 2).trim()
|
|
482
|
+
const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
|
|
483
|
+
if (!file) continue
|
|
484
|
+
|
|
485
|
+
// 累计暂存计数:所有非 quick 元数据的未提交条目(含下方将被 baseline 跳过的前序残留)。
|
|
486
|
+
// 放在 isBaselineFile 跳过之前,确保 baseline 内文件也计入「累计暂存」。
|
|
487
|
+
if (!isQuickMetadata(file, guard.linkedChanges)) result.stagedTotal++
|
|
488
|
+
|
|
489
|
+
// 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
|
|
490
|
+
if (isBaselineFile(file)) continue
|
|
491
|
+
|
|
492
|
+
result.changedFiles.push(file)
|
|
493
|
+
if (status === 'D' || status === ' D') result.deletedFiles.push(file)
|
|
494
|
+
if (status === '??') result.newFiles.push(file)
|
|
495
|
+
|
|
496
|
+
// 检查是否命中 baseline protected files
|
|
497
|
+
if (baselineFiles.includes(file)) {
|
|
498
|
+
result.baselineHit.push(file)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 检查危险文件(除非 force-baseline)
|
|
502
|
+
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file, guard.linkedChanges) && !forceBaseline) {
|
|
503
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
|
|
507
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 检查 deleted files
|
|
512
|
+
for (const f of result.deletedFiles) {
|
|
513
|
+
result.reasons.push(`删除文件: ${f}`)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 检查 baseline hit(除非 force-baseline)
|
|
517
|
+
if (!forceBaseline) {
|
|
518
|
+
for (const f of result.baselineHit) {
|
|
519
|
+
result.reasons.push(`覆盖 baseline 文件: ${f}`)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 检查 new files(除非 allow-new)
|
|
524
|
+
if (!allowNew) {
|
|
525
|
+
for (const f of result.newFiles) {
|
|
526
|
+
if (!isQuickMetadata(f, guard.linkedChanges)) {
|
|
527
|
+
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 检查 allowedFiles 范围
|
|
533
|
+
if (allowedFiles.length > 0) {
|
|
534
|
+
for (const f of result.changedFiles) {
|
|
535
|
+
if (!allowedFiles.includes(f) && !isQuickMetadata(f, guard.linkedChanges)) {
|
|
536
|
+
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning。
|
|
542
|
+
// reasons 文案本就受这两个 flag 控制,但原判定直接看数组长度,致 flag 对 status 失效。)
|
|
543
|
+
if ((!forceBaseline && result.baselineHit.length > 0) || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
|
|
544
|
+
result.status = 'blocked'
|
|
545
|
+
} else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
|
|
546
|
+
result.status = 'warning'
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// quicklog 存在性检查
|
|
550
|
+
try {
|
|
551
|
+
const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
|
|
552
|
+
if (existsSync(quicklogDir)) {
|
|
553
|
+
const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
|
|
554
|
+
if (qlFiles.length === 0) {
|
|
555
|
+
result.reasons.push('quicklog 目录为空(无任务记录)')
|
|
556
|
+
if (result.status === 'safe') result.status = 'warning'
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
|
|
560
|
+
if (result.status === 'safe') result.status = 'warning'
|
|
561
|
+
}
|
|
562
|
+
} catch {}
|
|
563
|
+
|
|
564
|
+
// --confirm 模式:展示 diff 并等待确认
|
|
565
|
+
if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
|
|
566
|
+
console.log(`\n📋 quick 变更概览:`)
|
|
567
|
+
console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
|
|
568
|
+
if (result.changedFiles.length > 0) {
|
|
569
|
+
console.log(`\n 变更文件:`)
|
|
570
|
+
for (const f of result.changedFiles) {
|
|
571
|
+
const isBaseline = baselineFiles.includes(f)
|
|
572
|
+
const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
|
|
573
|
+
const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
|
|
574
|
+
console.log(` ${marker} ${f}`)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
console.log(`\n 状态: ${result.status.toUpperCase()}`)
|
|
578
|
+
if (result.reasons.length > 0) {
|
|
579
|
+
console.log(` 原因:`)
|
|
580
|
+
for (const r of result.reasons) {
|
|
581
|
+
console.log(` - ${r}`)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
585
|
+
console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
586
|
+
console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
587
|
+
console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
result.reasons.push(`审计失败: ${e.message}`)
|
|
591
|
+
result.status = 'warning'
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return result
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── Step 处理辅助(W6 Step7a 从 run.js 搬入:getStageSteps/formatWaitOptions 被 run.js + complete.js 共用)──
|
|
598
|
+
/**
|
|
599
|
+
* 格式化 waitOptions 为人类可读字符串
|
|
600
|
+
*/
|
|
601
|
+
export function formatWaitOptions(raw) {
|
|
602
|
+
if (!raw) return null
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(raw)
|
|
605
|
+
if (Array.isArray(parsed)) return parsed.join(', ')
|
|
606
|
+
return raw
|
|
607
|
+
} catch {
|
|
608
|
+
return raw
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 获取阶段的步骤定义(execute 需要动态构建)
|
|
615
|
+
*/
|
|
616
|
+
export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
617
|
+
if (stageName === 'execute') {
|
|
618
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
619
|
+
let planFile = null
|
|
620
|
+
let worktreePath = null
|
|
621
|
+
if (changeDir) {
|
|
622
|
+
const p = join(changeDir, 'plan.md')
|
|
623
|
+
if (existsSync(p)) planFile = p
|
|
624
|
+
// 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
|
|
625
|
+
// 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
|
|
626
|
+
// 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
|
|
627
|
+
try {
|
|
628
|
+
const changeName = basename(changeDir)
|
|
629
|
+
const { WorktreeManager } = await import('../worktree.js')
|
|
630
|
+
const wm = new WorktreeManager({ cwd })
|
|
631
|
+
const meta = wm.getMeta(changeName)
|
|
632
|
+
if (meta?.worktreePath && existsSync(meta.worktreePath)) {
|
|
633
|
+
worktreePath = meta.worktreePath
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
// 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return buildExecuteSteps(planFile, { worktreePath })
|
|
640
|
+
}
|
|
641
|
+
if (stageName === 'plan') {
|
|
642
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
643
|
+
return buildPlanSteps(changeDir)
|
|
644
|
+
}
|
|
645
|
+
const def = stageRegistry[stageName]
|
|
646
|
+
return def ? def.steps : null
|
|
647
|
+
}
|
|
648
|
+
|