sillyspec 3.28.13 → 3.29.1
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/package.json +1 -1
- package/src/blast-surface.js +168 -0
- package/src/ceremony-tier.js +359 -0
- package/src/change-risk-profile.js +72 -199
- package/src/config-schema.js +22 -1
- package/src/decisions-io.js +32 -0
- package/src/design-facts.js +1 -0
- package/src/doctor-diagnostics.js +119 -1
- package/src/index.js +92 -5
- package/src/knowledge-stats.js +25 -0
- package/src/modules.js +28 -0
- package/src/prefill.js +491 -0
- package/src/probe7-anchor-check.js +8 -6
- package/src/quick-gate-profile.js +8 -4
- package/src/quick-session-owner.js +63 -0
- package/src/redlines.js +195 -0
- package/src/review-dispatch.js +473 -1
- package/src/review-material-pack.js +265 -0
- package/src/review-tier.js +185 -41
- package/src/run/archive-distill.js +6 -0
- package/src/run/command.js +158 -87
- package/src/run/complete-handlers.js +82 -1
- package/src/run/complete.js +90 -3
- package/src/run/gate-snapshot.js +30 -1
- package/src/run/gates.js +301 -19
- package/src/run/prompt.js +423 -21
- package/src/run/quick-audit.js +12 -2
- package/src/run/shared.js +36 -0
- package/src/run/verify-quality-scan.js +15 -7
- package/src/scope-audit.js +8 -1
- package/src/span-risk-surface.js +127 -0
- package/src/stage-contract-spec.js +2 -2
- package/src/stage-contract.js +102 -81
- package/src/stage-review.js +26 -2
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +35 -19
- package/src/stages/execute.js +11 -3
- package/src/stages/plan.js +45 -24
- package/src/stages/scan.js +1 -1
- package/src/stages/verify.js +7 -7
- package/src/taskcard.js +3 -0
- package/src/verify-postcheck.js +375 -20
- package/src/verify-probes.js +194 -10
- package/src/worktree-apply.js +1 -1
- package/src/worktree.js +1 -1
- package/templates/prompts/taskcard-rules.md +1 -0
- package/templates/prompts/verify-probes.md +1 -1
package/src/review-dispatch.js
CHANGED
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
21
|
-
import { join } from 'node:path'
|
|
21
|
+
import { join, dirname } from 'node:path'
|
|
22
22
|
import jsYaml from 'js-yaml'
|
|
23
23
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
24
24
|
import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
|
|
25
|
+
import { CEREMONY_TIERS } from './ceremony-tier.js'
|
|
26
|
+
import { withFileLock } from './quicklog.js'
|
|
25
27
|
import {
|
|
26
28
|
renderReviewJsonContract,
|
|
27
29
|
validateStageReviewSchema,
|
|
@@ -366,6 +368,476 @@ export async function runReviewDispatch(opts = {}) {
|
|
|
366
368
|
}
|
|
367
369
|
}
|
|
368
370
|
|
|
371
|
+
// ════════ 影子期派发(2026-09-18-ceremony-risk-pricing task-06 / FR-04 / D-007@v1 / R-04 / R-06)════════
|
|
372
|
+
// 依据:design.md「总体方案 Phase 3」+「风险登记 R-04/R-06」+ plan.md「Wave 4」+ 全局硬约束 2。
|
|
373
|
+
// 定位:S0/S1 轻档变更在明面轻仪主线外,后台静默派发重仪式独立评审**只记账不阻断**——
|
|
374
|
+
// 影子 verdict 任何取值不回流任何 gate / verify / archive 判定面,为轻仪转正攒对照数据证据。
|
|
375
|
+
//
|
|
376
|
+
// 隔离面双通道(R-06,缺一不可):
|
|
377
|
+
// ① 目录隔离——影子产物一律落 .runtime/stage-reviews-shadow/<change>-<stage>-<ts>/review.json,
|
|
378
|
+
// 与主线 .runtime/stage-reviews/<stage>-review-* 物理分离(目录名模式亦不匹配主线
|
|
379
|
+
// `${stage}-review-` 前缀扫描,双保险);
|
|
380
|
+
// ② marker 写入隔离——getLatestStageReviewRunId 优先读 current-stage-review-run-id-* marker
|
|
381
|
+
// (stage-review.js:377-390),本节任何代码**绝不调用 stageReviewMarkerPath / 绝不写主线
|
|
382
|
+
// marker**,影子 runId 独立生成(shadow- 前缀)不复用主线 marker ID。
|
|
383
|
+
//
|
|
384
|
+
// 通道复用口径:CLI 可自动派发的独立通道是 platform(agent-tool / host-mcp 是宿主侧通道,
|
|
385
|
+
// CLI 无法代为派发——缺 client / probe 不可用时按 skip 留痕,不渲染交互式降级指引防噪音)。
|
|
386
|
+
|
|
387
|
+
/** 影子命名空间目录名(挂在 runtimeRoot 下,随 .runtime 生命周期回收——design 对照账落盘契约) */
|
|
388
|
+
export const SHADOW_NAMESPACE_DIR = 'stage-reviews-shadow'
|
|
389
|
+
/** 影子派发覆盖的轻档(task-03 档位契约:S0/S1 轻仪、S2/S3 本就是重仪式无影子意义) */
|
|
390
|
+
const SHADOW_LIGHT_TIERS = ['S0', 'S1']
|
|
391
|
+
/** 转正判据样本目标(design Phase 3:N=10 轻档变更对照样本;计数逻辑消费方在 doctor 维度) */
|
|
392
|
+
export const SHADOW_PROMOTION_SAMPLE_TARGET = 10
|
|
393
|
+
/** skip 留痕上限(防高频 skip 无界膨胀;entries 是对照证据不截断) */
|
|
394
|
+
const SHADOW_LEDGER_MAX_SKIPS = 100
|
|
395
|
+
|
|
396
|
+
/** 影子对照账路径:{runtimeRoot}/stage-reviews-shadow/shadow-ledger.json(doctor 影子对照维度经 readShadowLedger 同源读取) */
|
|
397
|
+
function shadowLedgerPath(runtimeRoot) {
|
|
398
|
+
return join(runtimeRoot, SHADOW_NAMESPACE_DIR, 'shadow-ledger.json')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 读影子对照账(doctor 影子对照维度消费;best-effort 绝不抛)。
|
|
403
|
+
* @returns {{schemaVersion: number, entries: object[], skips: object[]}} 无/坏账 → 空账壳
|
|
404
|
+
*/
|
|
405
|
+
export function readShadowLedger(runtimeRoot) {
|
|
406
|
+
try {
|
|
407
|
+
const p = shadowLedgerPath(runtimeRoot)
|
|
408
|
+
if (!existsSync(p)) return { schemaVersion: 1, entries: [], skips: [] }
|
|
409
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'))
|
|
410
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { schemaVersion: 1, entries: [], skips: [] }
|
|
411
|
+
return {
|
|
412
|
+
schemaVersion: 1,
|
|
413
|
+
entries: Array.isArray(doc.entries) ? doc.entries : [],
|
|
414
|
+
skips: Array.isArray(doc.skips) ? doc.skips : [],
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
return { schemaVersion: 1, entries: [], skips: [] }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 对照账读改写(withFileLock + writeAtomicSync,多会话并发追加安全——对齐 gates.js 档位文件
|
|
423
|
+
* 锁口径)。mutator 直接改 ledger 对象;skips 段自动截尾。锁/写失败 warn 后吞(fire-and-forget
|
|
424
|
+
* 语义:影子链路任何失败只留日志,不向调用方抛)。
|
|
425
|
+
*/
|
|
426
|
+
async function mutateShadowLedger(runtimeRoot, mutator) {
|
|
427
|
+
const p = shadowLedgerPath(runtimeRoot)
|
|
428
|
+
try {
|
|
429
|
+
await withFileLock(p + '.lock', () => {
|
|
430
|
+
const ledger = readShadowLedger(runtimeRoot)
|
|
431
|
+
mutator(ledger)
|
|
432
|
+
ledger.skips = (Array.isArray(ledger.skips) ? ledger.skips : []).slice(-SHADOW_LEDGER_MAX_SKIPS)
|
|
433
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
434
|
+
writeAtomicSync(p, JSON.stringify(ledger, null, 2) + '\n')
|
|
435
|
+
return ledger
|
|
436
|
+
})
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.warn(`[sillyspec] 影子对照账写入失败(只记账不阻断): ${e && e.message ? e.message : String(e)}`)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** skip 留痕(前置不满足 / 平台通道不可用——acceptance「skip 留痕可查」落点) */
|
|
443
|
+
function appendShadowLedgerSkip(runtimeRoot, skip) {
|
|
444
|
+
return mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
445
|
+
ledger.skips.push({ at: new Date().toISOString(), ...skip })
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 读档位文件(task-03 契约消费:.runtime/ceremony-tier-<change>.json {tier,components,reasons,transitions[]})。
|
|
451
|
+
* gates.js readCeremonyTierDoc 未导出——本地镜像同一容错口径(缺失/坏 JSON/非对象/tier 不在
|
|
452
|
+
* S0~S3 序 → null,不修复不报错)。影子只读不写:升档判定与迁移记录归 gates 阶段门。
|
|
453
|
+
*/
|
|
454
|
+
function readCeremonyTierForShadow(runtimeRoot, changeName) {
|
|
455
|
+
try {
|
|
456
|
+
const p = join(runtimeRoot, `ceremony-tier-${changeName}.json`)
|
|
457
|
+
if (!existsSync(p)) return null
|
|
458
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'))
|
|
459
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null
|
|
460
|
+
return CEREMONY_TIERS.includes(doc.tier) ? doc : null
|
|
461
|
+
} catch {
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 影子 runId 独立生成(shadow- 前缀 + 毫秒尾——不与主线 review- 前缀 marker 格式互通) */
|
|
467
|
+
function generateShadowReviewRunId() {
|
|
468
|
+
const now = new Date()
|
|
469
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
470
|
+
return `shadow-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}-${String(now.getMilliseconds()).padStart(3, '0')}`
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── 影子在途记录(独立于主线 review-dispatch-<change>.json——互不触发对方幂等闸,
|
|
474
|
+
// stage-review.js:751 的主线在途提示内联读的也是主线文件名,不会被影子记录污染)──
|
|
475
|
+
|
|
476
|
+
function shadowRecordPath(specBase, changeName) {
|
|
477
|
+
return join(specBase, '.runtime', `review-dispatch-shadow-${changeName}.json`)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function readShadowRecord(specBase, changeName) {
|
|
481
|
+
try {
|
|
482
|
+
const p = shadowRecordPath(specBase, changeName)
|
|
483
|
+
if (!existsSync(p)) return null
|
|
484
|
+
const r = JSON.parse(readFileSync(p, 'utf8'))
|
|
485
|
+
return r && typeof r === 'object' && typeof r.missionId === 'string' ? r : null
|
|
486
|
+
} catch { return null }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function createShadowRecord(specBase, changeName, record) {
|
|
490
|
+
const p = shadowRecordPath(specBase, changeName)
|
|
491
|
+
mkdirSync(join(specBase, '.runtime'), { recursive: true })
|
|
492
|
+
try {
|
|
493
|
+
writeFileSync(p, JSON.stringify(record, null, 2) + '\n', { flag: 'wx' })
|
|
494
|
+
return { created: true }
|
|
495
|
+
} catch (e) {
|
|
496
|
+
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
497
|
+
return { created: false, existing: readShadowRecord(specBase, changeName) }
|
|
498
|
+
}
|
|
499
|
+
throw e
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function updateShadowRecord(specBase, changeName, patch) {
|
|
504
|
+
const cur = readShadowRecord(specBase, changeName)
|
|
505
|
+
if (!cur) return false
|
|
506
|
+
writeAtomicSync(shadowRecordPath(specBase, changeName), JSON.stringify({ ...cur, ...patch }, null, 2) + '\n')
|
|
507
|
+
return true
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function clearShadowRecord(specBase, changeName) {
|
|
511
|
+
const p = shadowRecordPath(specBase, changeName)
|
|
512
|
+
try { if (existsSync(p)) { rmSync(p); return true } return false } catch { return false }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── 对照账口径:catch = checklist result ∈ {fail, gap} 的项(与 collectSameStagePriorReview
|
|
516
|
+
// openFindings 同口径);漏检 = 影子 catch 项的主线侧文本不匹配项(主线 S0/S1 轻仪通常无
|
|
517
|
+
// review.json → caught 空 → 影子全部 catch 记为漏检——这正是要攒的证据)──────────────────
|
|
518
|
+
|
|
519
|
+
function normalizeShadowItemKey(item) {
|
|
520
|
+
return String(item || '').replace(/\s+/g, ' ').trim()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function extractCaughtItems(review) {
|
|
524
|
+
const out = []
|
|
525
|
+
for (const c of (Array.isArray(review && review.checklist) ? review.checklist : [])) {
|
|
526
|
+
if (!c || typeof c !== 'object' || !c.item) continue
|
|
527
|
+
if (c.result === 'fail' || c.result === 'gap') {
|
|
528
|
+
out.push({
|
|
529
|
+
item: String(c.item).replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
530
|
+
result: c.result === 'fail' ? 'fail' : 'gap',
|
|
531
|
+
note: c.note ? String(c.note).replace(/\s+/g, ' ').slice(0, 160) : '',
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return out
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 主线轻仪结论快照(派发时点定妆,此后主线改判不影响对照口径)。
|
|
540
|
+
* 有主线 review.json(轻档降级自审等场景)→ 采其 verdict + caught;无 → light-cli 形态
|
|
541
|
+
* (S0/S1 CLI 清单核验无 review.json,按零 catch 记账——漏检判据的保守侧)。
|
|
542
|
+
*/
|
|
543
|
+
async function collectMainlineLightConclusion(runtimeRoot, stage, changeName) {
|
|
544
|
+
try {
|
|
545
|
+
const { getLatestStageReviewRunId } = await import('./stage-review.js')
|
|
546
|
+
const runId = getLatestStageReviewRunId(runtimeRoot, stage, changeName)
|
|
547
|
+
if (runId) {
|
|
548
|
+
const rp = join(runtimeRoot, 'stage-reviews', `${stage}-${runId}`, 'review.json')
|
|
549
|
+
if (existsSync(rp)) {
|
|
550
|
+
const review = JSON.parse(readFileSync(rp, 'utf8'))
|
|
551
|
+
if (review && typeof review === 'object') {
|
|
552
|
+
const r0 = Array.isArray(review.reviewedFiles) ? String(review.reviewedFiles[0]) : ''
|
|
553
|
+
// cross-change 防护同 getLatestStageReviewRunId 口径;空 reviewedFiles 的存量容过
|
|
554
|
+
if (!r0 || r0.includes(`changes/${changeName}/`)) {
|
|
555
|
+
return {
|
|
556
|
+
source: 'review.json', reviewRunId: runId,
|
|
557
|
+
specVerdict: String(review.specVerdict || ''), qualityVerdict: String(review.qualityVerdict || ''),
|
|
558
|
+
caught: extractCaughtItems(review),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} catch { /* best-effort 快照,失败按 light-cli 记账 */ }
|
|
565
|
+
return {
|
|
566
|
+
source: 'light-cli', reviewRunId: null, specVerdict: '', qualityVerdict: '', caught: [],
|
|
567
|
+
note: 'S0/S1 轻仪主线(CLI 清单核验)无 review.json——按零 catch 记账',
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 落盘影子 stage review(R-06 隔离双通道的目录通道)。
|
|
573
|
+
*
|
|
574
|
+
* ⚠️ 与 persistStageReview 的三处刻意差异:①目录落 stage-reviews-shadow/<change>-<stage>-<ts>/
|
|
575
|
+
* (独立命名空间,目录名模式不匹配主线 `${stage}-review-` 前缀扫描);②reviewer 落款带
|
|
576
|
+
* shadow: true 标记;③**绝不触碰 stageReviewMarkerPath / 主线 marker**(marker 写入通道隔离)。
|
|
577
|
+
* schema 不过 → 不落 review.json(影子命名空间同样不放宽 schema),原始产物存 review-raw.json
|
|
578
|
+
* 供人工核对、errors 返回记账。
|
|
579
|
+
* @returns {{ok: boolean, reviewPath?: string, rawPath?: string, errors?: string[]}}
|
|
580
|
+
*/
|
|
581
|
+
function persistShadowStageReview({ runtimeRoot, stage, changeName, shadowRunId, reviewObj, missionId, mainDocPath }) {
|
|
582
|
+
const review = {
|
|
583
|
+
...reviewObj,
|
|
584
|
+
reviewer: {
|
|
585
|
+
channel: 'platform', shadow: true,
|
|
586
|
+
missionId: missionId || null,
|
|
587
|
+
model: (reviewObj && reviewObj.reviewer && reviewObj.reviewer.model) || null,
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
if (mainDocPath && existsSync(mainDocPath)) {
|
|
591
|
+
const h = computeDocHash(mainDocPath)
|
|
592
|
+
if (h) review.docHash = h
|
|
593
|
+
}
|
|
594
|
+
const schema = validateStageReviewSchema(review, stage)
|
|
595
|
+
const ts = String(shadowRunId || '').startsWith('shadow-') ? String(shadowRunId).slice('shadow-'.length) : String(shadowRunId || Date.now())
|
|
596
|
+
const dir = join(runtimeRoot, SHADOW_NAMESPACE_DIR, `${changeName}-${stage}-${ts}`)
|
|
597
|
+
mkdirSync(dir, { recursive: true })
|
|
598
|
+
if (!schema.ok) {
|
|
599
|
+
const rawPath = join(dir, 'review-raw.json')
|
|
600
|
+
writeAtomicSync(rawPath, JSON.stringify(reviewObj, null, 2) + '\n')
|
|
601
|
+
return { ok: false, rawPath, errors: schema.errors }
|
|
602
|
+
}
|
|
603
|
+
review.reviewedFiles = Array.isArray(review.reviewedFiles) && review.reviewedFiles.length > 0
|
|
604
|
+
? review.reviewedFiles
|
|
605
|
+
: [`changes/${changeName}/${STAGE_MAIN_DOC[stage] || 'design.md'}`]
|
|
606
|
+
const reviewPath = join(dir, 'review.json')
|
|
607
|
+
writeAtomicSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
|
|
608
|
+
return { ok: true, reviewPath }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 影子期派发总入口(task-06 落地;gates.js 阶段门接线归收尾批次——本函数只导出不挂载)。
|
|
613
|
+
*
|
|
614
|
+
* 调用协议(gates 阶段门 / task-07 接线用):
|
|
615
|
+
* await dispatchShadowReview({
|
|
616
|
+
* mode: 'create', // 'create' | 'status' | 'kill'(与 runReviewDispatch 同形)
|
|
617
|
+
* cwd, specBase, changeName, // 必填;stage(create 必填)∈ brainstorm|plan|execute
|
|
618
|
+
* client, // SillyHubMcpClient 实例(create/status 用;缺 → skip 留痕不报错)
|
|
619
|
+
* probe, // 可注入(默认真 probeSillyHub,测试传 stub)
|
|
620
|
+
* budgetUsd, stallMs, // 缺省读 readReviewDispatchConfig(local.yaml review_dispatch 段)
|
|
621
|
+
* mainline, // 可选:主线轻仪结论快照注入 {specVerdict, qualityVerdict, caught:[{item,result,note}]};
|
|
622
|
+
* // 缺省自动采(collectMainlineLightConclusion)
|
|
623
|
+
* })
|
|
624
|
+
* 建议挂点:阶段完成门升档检查点(escalateCeremonyTierAtGate)之后 fire-and-forget 调用
|
|
625
|
+
* (不 await 阻塞门判定亦可用——返回值只用于日志,任何 {ok:false}/{skipped} 形态都不是错误)。
|
|
626
|
+
*
|
|
627
|
+
* 前置校验两项硬条件(acceptance):① .runtime/ceremony-tier-<change>.json 当前档 ∈ {S0, S1};
|
|
628
|
+
* ② local.yaml ceremony.shadow ≠ false(task-05 readCeremonyLocalConfig 契约,缺省 true)。
|
|
629
|
+
* 不满足 → skip 留痕(对照账 skips 段)+ {ok:true, skipped:true},不报错不阻断主线。
|
|
630
|
+
*
|
|
631
|
+
* 只记账不阻断(FR-04):本函数永不抛(异常吞掉留 warning 日志);影子 verdict / 影子结论
|
|
632
|
+
* 不进入任何主线 gate、verify、archive 判定面——对照数据只落 stage-reviews-shadow/ 命名空间。
|
|
633
|
+
*
|
|
634
|
+
* @returns {Promise<{ok: boolean, shadow: true, ...}>}
|
|
635
|
+
*/
|
|
636
|
+
export async function dispatchShadowReview(opts = {}) {
|
|
637
|
+
const {
|
|
638
|
+
mode = 'create', cwd = process.cwd(), specBase, changeName, stage,
|
|
639
|
+
client, probe, budgetUsd, stallMs, mainline,
|
|
640
|
+
} = opts
|
|
641
|
+
try {
|
|
642
|
+
if (!specBase || !changeName) return { ok: false, shadow: true, reason: '缺少 specBase/changeName' }
|
|
643
|
+
const cfg = readReviewDispatchConfig(cwd)
|
|
644
|
+
const budget = typeof budgetUsd === 'number' && budgetUsd > 0 ? budgetUsd : cfg.budget_usd
|
|
645
|
+
const stall = typeof stallMs === 'number' && stallMs > 0 ? stallMs : cfg.stall_ms
|
|
646
|
+
const runtimeRoot = join(specBase, '.runtime')
|
|
647
|
+
|
|
648
|
+
if (mode === 'kill') {
|
|
649
|
+
const rec = readShadowRecord(specBase, changeName)
|
|
650
|
+
if (rec) {
|
|
651
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
652
|
+
const e = ledger.entries.find((x) => x && x.shadowRunId === rec.shadowRunId)
|
|
653
|
+
if (e && e.state !== 'completed') {
|
|
654
|
+
e.state = 'failed'
|
|
655
|
+
e.failReason = `人工 kill(影子在途放弃——平台 mission ${rec.missionId} 处置权在人)`
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
clearShadowRecord(specBase, changeName)
|
|
660
|
+
return { ok: true, shadow: true, killedLocally: true, platformHint: rec ? `影子在途记录已清;平台侧 mission ${rec.missionId} 请在平台 UI 处置。` : '无影子在途记录(已清或从未派发)。' }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (mode === 'create') {
|
|
664
|
+
if (!stage || !STAGE_MAIN_DOC[stage]) return { ok: false, shadow: true, reason: `stage 必填 ∈ ${Object.keys(STAGE_MAIN_DOC).join('|')}` }
|
|
665
|
+
const tierDoc = readCeremonyTierForShadow(runtimeRoot, changeName)
|
|
666
|
+
const skip = async (reason) => {
|
|
667
|
+
await appendShadowLedgerSkip(runtimeRoot, { change: changeName, stage, tier: tierDoc ? tierDoc.tier : null, reason })
|
|
668
|
+
return { ok: true, shadow: true, skipped: true, reason }
|
|
669
|
+
}
|
|
670
|
+
// 前置①:档位 ∈ S0/S1(task-03 档位文件契约;无档位文件 = 未定价变更,不开影子)
|
|
671
|
+
if (!tierDoc) return skip('tier-file-missing(.runtime/ceremony-tier-<change>.json 不存在——影子派发只对已定价的轻档变更开跑)')
|
|
672
|
+
if (!SHADOW_LIGHT_TIERS.includes(tierDoc.tier)) return skip(`tier-not-light(当前档 ${tierDoc.tier}——影子派发只覆盖 S0/S1 轻档,S2/S3 本就是重仪式)`)
|
|
673
|
+
// 前置②:ceremony.shadow ≠ false(task-05 readCeremonyLocalConfig;动态 import 防静态环)
|
|
674
|
+
const { readCeremonyLocalConfig } = await import('./run/prompt.js')
|
|
675
|
+
const ceremony = readCeremonyLocalConfig(cwd)
|
|
676
|
+
if (ceremony && ceremony.shadow === false) return skip('shadow-off(local.yaml ceremony.shadow: off——轻仪已转正,影子期关闭)')
|
|
677
|
+
// 幂等闸(影子记录独立文件,与主线派发并行不互斥)
|
|
678
|
+
const cur = readShadowRecord(specBase, changeName)
|
|
679
|
+
if (cur && (cur.state === 'in-flight' || cur.state === 'dispatching')) {
|
|
680
|
+
return { ok: false, shadow: true, reason: `已有在途影子派发(mission ${cur.missionId},state ${cur.state})`, existing: cur }
|
|
681
|
+
}
|
|
682
|
+
if (!client || !client.probeDaemon) {
|
|
683
|
+
return skip('platform-client-missing(影子派发复用 platform 通道;agent-tool/host-mcp 是宿主侧通道 CLI 无法自动派发——宿主可自行派重仪式对照,结论不入本账)')
|
|
684
|
+
}
|
|
685
|
+
const probeFn = probe || (await import('./dispatch/probe.js')).probeSillyHub
|
|
686
|
+
const pr = await probeFn({ client, worktreePath: null, cwd })
|
|
687
|
+
if (!pr || pr.available !== true) {
|
|
688
|
+
return skip(`platform-unavailable(${pr && pr.reason ? pr.reason : 'probe 异常'}——影子链路不渲染交互式降级指引,静默记账)`)
|
|
689
|
+
}
|
|
690
|
+
// 主线轻仪结论快照(派发时点定妆;调用方可经 mainline 注入覆盖自动采集)
|
|
691
|
+
const mainlineSnapshot = mainline && typeof mainline === 'object'
|
|
692
|
+
? { source: 'caller-injected', specVerdict: String(mainline.specVerdict || ''), qualityVerdict: String(mainline.qualityVerdict || ''), caught: extractCaughtItems({ checklist: mainline.caught }) }
|
|
693
|
+
: await collectMainlineLightConclusion(runtimeRoot, stage, changeName)
|
|
694
|
+
// 影子 runId 独立生成(R-06 隔离通道②:绝不读写主线 marker / stageReviewMarkerPath)
|
|
695
|
+
const shadowRunId = generateShadowReviewRunId()
|
|
696
|
+
const changeDir = join(specBase, 'changes', changeName)
|
|
697
|
+
const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: shadowRunId, channelPriority: readReviewChannelPriority(cwd) })
|
|
698
|
+
const workerPrompt = book.workerPrompt + '\n(影子审查:本结论将由 CLI 回收到 stage-reviews-shadow/ 独立命名空间做轻/重对照记账,不影响主线任何判定。)\n'
|
|
699
|
+
const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd: budget })
|
|
700
|
+
const missionId = mission && mission.missionId
|
|
701
|
+
if (!missionId) return skip('create-mission-no-id(平台未返回 missionId)')
|
|
702
|
+
const cr = createShadowRecord(specBase, changeName, {
|
|
703
|
+
change: changeName, stage, shadow: true, missionId, workerId: null, state: 'dispatching',
|
|
704
|
+
createdAt: new Date().toISOString(), lastState: 'dispatching', lastStateAt: Date.now(), shadowRunId,
|
|
705
|
+
})
|
|
706
|
+
if (!cr.created) {
|
|
707
|
+
return { ok: false, shadow: true, reason: '并发双开被拒(O_EXCL,影子记录)', existing: cr.existing }
|
|
708
|
+
}
|
|
709
|
+
let worker = null
|
|
710
|
+
try {
|
|
711
|
+
worker = await client.dispatchWorker({
|
|
712
|
+
missionId, objective: book.objective, readOnly: true,
|
|
713
|
+
worktreePath: cwd, workerPrompt,
|
|
714
|
+
})
|
|
715
|
+
} catch (e) {
|
|
716
|
+
updateShadowRecord(specBase, changeName, { state: 'abandoned', errorCode: 'dispatch_worker_threw' })
|
|
717
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
718
|
+
ledger.entries.push({
|
|
719
|
+
id: shadowRunId, change: changeName, stage, tier: tierDoc.tier, shadowRunId,
|
|
720
|
+
at: new Date().toISOString(), state: 'abandoned', failReason: `dispatch_worker 异常: ${e && e.message ? e.message : String(e)}`,
|
|
721
|
+
mainline: mainlineSnapshot,
|
|
722
|
+
})
|
|
723
|
+
})
|
|
724
|
+
clearShadowRecord(specBase, changeName)
|
|
725
|
+
console.warn(`[sillyspec] 影子派发失败(只记账不阻断): dispatch_worker 异常 ${e && e.message ? e.message : String(e)}`)
|
|
726
|
+
return { ok: false, shadow: true, reason: 'dispatch_worker 异常(已记对照账 abandoned)' }
|
|
727
|
+
}
|
|
728
|
+
const workerId = worker && worker.workerId
|
|
729
|
+
if (!workerId) {
|
|
730
|
+
updateShadowRecord(specBase, changeName, { state: 'abandoned', errorCode: 'no_worker_id' })
|
|
731
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
732
|
+
ledger.entries.push({
|
|
733
|
+
id: shadowRunId, change: changeName, stage, tier: tierDoc.tier, shadowRunId,
|
|
734
|
+
at: new Date().toISOString(), state: 'abandoned', failReason: 'dispatch_worker 未返回 workerId',
|
|
735
|
+
mainline: mainlineSnapshot,
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
clearShadowRecord(specBase, changeName)
|
|
739
|
+
return { ok: false, shadow: true, reason: 'dispatch_worker 未返回 workerId(已记对照账 abandoned)' }
|
|
740
|
+
}
|
|
741
|
+
updateShadowRecord(specBase, changeName, { workerId, state: 'in-flight', lastState: 'queued', lastStateAt: Date.now() })
|
|
742
|
+
// 对照账登记(in-flight 态——回收时补影子结论与 catch 差异)
|
|
743
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
744
|
+
ledger.entries.push({
|
|
745
|
+
id: shadowRunId, change: changeName, stage, tier: tierDoc.tier, shadowRunId,
|
|
746
|
+
at: new Date().toISOString(), state: 'in-flight',
|
|
747
|
+
missionId, mainline: mainlineSnapshot,
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
return {
|
|
751
|
+
ok: true, shadow: true, missionId, workerId, shadowRunId,
|
|
752
|
+
nextStep: '影子派发 fire-and-forget:只记账不阻断——status 回收随主线检查点顺带跑(dispatchShadowReview mode="status")或由 doctor 影子对照维度报告在途。',
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (mode === 'status') {
|
|
757
|
+
const rec = readShadowRecord(specBase, changeName)
|
|
758
|
+
if (!rec) return { ok: false, shadow: true, reason: '无影子在途记录——先 create。' }
|
|
759
|
+
if (!client || !client.listWorkers) return { ok: false, shadow: true, reason: 'client 未注入' }
|
|
760
|
+
const workers = await client.listWorkers(rec.missionId)
|
|
761
|
+
const w = Array.isArray(workers) ? workers.find((x) => x && (x.id === rec.workerId || x.worker_id === rec.workerId)) : null
|
|
762
|
+
const st = w ? String(w.status || '') : 'unknown'
|
|
763
|
+
const statusLine = `影子 mission ${rec.missionId} worker ${rec.workerId}: ${st}`
|
|
764
|
+
if (w && st && st !== rec.lastState) {
|
|
765
|
+
updateShadowRecord(specBase, changeName, { lastState: st, lastStateAt: Date.now() })
|
|
766
|
+
rec.lastState = st; rec.lastStateAt = Date.now()
|
|
767
|
+
}
|
|
768
|
+
if (st === 'completed') {
|
|
769
|
+
const gr = await client.getWorkerResult({ missionId: rec.missionId, workerId: rec.workerId })
|
|
770
|
+
const review = extractReviewFromArtifacts(gr && gr.artifacts)
|
|
771
|
+
if (!review) {
|
|
772
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
773
|
+
const e = ledger.entries.find((x) => x && x.shadowRunId === rec.shadowRunId)
|
|
774
|
+
if (e && e.state !== 'completed') { e.state = 'failed'; e.failReason = 'completed-no-artifact(worker completed 但 artifacts 提取不到 review JSON)' }
|
|
775
|
+
})
|
|
776
|
+
clearShadowRecord(specBase, changeName)
|
|
777
|
+
return { ok: false, shadow: true, state: 'completed-no-artifact', statusLine, accountingOnly: true }
|
|
778
|
+
}
|
|
779
|
+
const mainDocPath = join(specBase, 'changes', changeName, STAGE_MAIN_DOC[rec.stage] || 'design.md')
|
|
780
|
+
const p = persistShadowStageReview({
|
|
781
|
+
runtimeRoot, stage: rec.stage, changeName, shadowRunId: rec.shadowRunId,
|
|
782
|
+
reviewObj: review, missionId: rec.missionId, mainDocPath,
|
|
783
|
+
})
|
|
784
|
+
// 对照账补全:影子重仪式结论 + catch 差异(漏检 = 影子抓到而主线轻仪没抓到)
|
|
785
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
786
|
+
const e = ledger.entries.find((x) => x && x.shadowRunId === rec.shadowRunId)
|
|
787
|
+
if (!e) return
|
|
788
|
+
const shadowCaught = extractCaughtItems(review)
|
|
789
|
+
const mainlineCaught = e.mainline && Array.isArray(e.mainline.caught) ? e.mainline.caught : []
|
|
790
|
+
const mainKeys = new Set(mainlineCaught.map((c) => normalizeShadowItemKey(c.item)))
|
|
791
|
+
const missedByLight = shadowCaught
|
|
792
|
+
.filter((c) => !mainKeys.has(normalizeShadowItemKey(c.item)))
|
|
793
|
+
.map((c) => ({ item: c.item, result: c.result, note: c.note }))
|
|
794
|
+
const shadowFailed = review.specVerdict === 'fail' || review.qualityVerdict === 'fail'
|
|
795
|
+
const lightFailed = (e.mainline && (e.mainline.specVerdict === 'fail' || e.mainline.qualityVerdict === 'fail')) || false
|
|
796
|
+
e.state = p.ok ? 'completed' : 'bad-review'
|
|
797
|
+
e.completedAt = new Date().toISOString()
|
|
798
|
+
e.shadow = {
|
|
799
|
+
specVerdict: String(review.specVerdict || ''), qualityVerdict: String(review.qualityVerdict || ''),
|
|
800
|
+
caught: shadowCaught,
|
|
801
|
+
}
|
|
802
|
+
e.diff = {
|
|
803
|
+
missedByLight,
|
|
804
|
+
shadowFailed,
|
|
805
|
+
lightFailed,
|
|
806
|
+
verdictDelta: shadowFailed && !lightFailed,
|
|
807
|
+
}
|
|
808
|
+
e.reviewPath = p.ok ? p.reviewPath : p.rawPath
|
|
809
|
+
if (!p.ok) e.schemaErrors = p.errors
|
|
810
|
+
})
|
|
811
|
+
clearShadowRecord(specBase, changeName)
|
|
812
|
+
return {
|
|
813
|
+
ok: p.ok, shadow: true, state: p.ok ? 'completed' : 'bad-review', statusLine,
|
|
814
|
+
reviewPath: p.reviewPath || p.rawPath, verdict: `${review.specVerdict}/${review.qualityVerdict}`,
|
|
815
|
+
errors: p.errors, accountingOnly: true,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (st === 'failed' || st === 'killed') {
|
|
819
|
+
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
820
|
+
const e = ledger.entries.find((x) => x && x.shadowRunId === rec.shadowRunId)
|
|
821
|
+
if (e && e.state !== 'completed') { e.state = 'failed'; e.failReason = `worker 终态 ${st}` }
|
|
822
|
+
})
|
|
823
|
+
clearShadowRecord(specBase, changeName)
|
|
824
|
+
return { ok: false, shadow: true, state: st, statusLine, accountingOnly: true }
|
|
825
|
+
}
|
|
826
|
+
const stallInfo = detectStall(rec, Date.now(), stall)
|
|
827
|
+
return {
|
|
828
|
+
ok: true, shadow: true, state: st || 'in-flight', statusLine,
|
|
829
|
+
stalled: stallInfo.stalled, stallHint: stallInfo.hint, accountingOnly: true,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return { ok: false, shadow: true, reason: `未知 mode: ${mode}` }
|
|
834
|
+
} catch (e) {
|
|
835
|
+
// fire-and-forget 兜底:影子链路任何异常吞掉留 warning,绝不向主线抛(FR-04 只记账不阻断)
|
|
836
|
+
console.warn(`[sillyspec] 影子派发异常(只记账不阻断): ${e && e.message ? e.message : String(e)}`)
|
|
837
|
+
return { ok: false, shadow: true, reason: `影子派发异常: ${e && e.message ? e.message : String(e)}` }
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
369
841
|
// task-06 消费契约预留(配置读取在该 task 落地;此处常量为缺省值单一来源)
|
|
370
842
|
const DEFAULT_REVIEW_DISPATCH_CONFIG = { budget_usd: 1.0, stall_ms: 900_000, timeout_ms: 0 }
|
|
371
843
|
export function readReviewDispatchConfig(cwd) {
|