sillyspec 3.28.3 → 3.28.5

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.
Files changed (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
package/src/quicklog.js CHANGED
@@ -155,6 +155,39 @@ function extractRawBlock(content, qlId) {
155
155
 
156
156
  // 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
157
157
  // 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
158
+ /**
159
+ * QUICKLOG 结构化 sidecar(P1-4,noai-ir-roadmap §4):completeQuicklogEntry 组装 payload 的
160
+ * 同时把终态结构落 .runtime/quicklog-sidecar/<qlId>.json——一次解析持久化,daemon/查询/重推
161
+ * 直读结构化数据不必再解析 md(md 只给人看)。fail-soft:sidecar 是加速层不是正确性依赖,
162
+ * 落盘失败零阻断(读取侧自动回退 md 解析路径)。
163
+ */
164
+ const QUICKLOG_SIDECAR_SCHEMA_VERSION = 1
165
+
166
+ export function quicklogSidecarPath(specBase, qlId) {
167
+ return join(specBase, '.runtime', 'quicklog-sidecar', `${qlId}.json`)
168
+ }
169
+
170
+ export function writeQuicklogSidecar(specBase, payload) {
171
+ if (!payload || !payload.ql_id) return false
172
+ try {
173
+ const p = quicklogSidecarPath(specBase, payload.ql_id)
174
+ mkdirSync(dirname(p), { recursive: true })
175
+ writeAtomic(p, JSON.stringify({ schemaVersion: QUICKLOG_SIDECAR_SCHEMA_VERSION, ...payload }, null, 2) + String.fromCharCode(10))
176
+ return true
177
+ } catch {
178
+ return false
179
+ }
180
+ }
181
+
182
+ export function readQuicklogSidecar(specBase, qlId) {
183
+ try {
184
+ const rec = JSON.parse(readFileSync(quicklogSidecarPath(specBase, qlId), 'utf8'))
185
+ return rec && rec.schemaVersion === QUICKLOG_SIDECAR_SCHEMA_VERSION ? rec : null
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
158
191
  function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
159
192
  const payload = {
160
193
  ql_id,
@@ -294,9 +327,17 @@ export function extractTitleFromResult(result) {
294
327
  if (!result) return ''
295
328
  const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
296
329
  if (!m) return ''
297
- let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
330
+ // 不再截到首个标点(2026-09-08 用户反馈②):旧口径迫使 agent 刻意避开标点写标题,
331
+ // 「A,B」被截成「A」反而降低可读性。--req 的模板指引本就要求一句话短标题,
332
+ // 标点由写者自主掌控;CLI 只兜底超长截断。
333
+ let t = m[1].trim()
298
334
  if (!t) return ''
299
- if (t.length > 80) t = t.slice(0, 80) + '…'
335
+ if (t.length > 80) {
336
+ // 超长截断优先在标点/空格处断(标题残句可读),找不到就近边界才硬截。
337
+ const cut = t.slice(0, 80)
338
+ const m2 = cut.match(/^[\s\S]*[,。;:,;:]\s*/)
339
+ t = (m2 && m2[0].length >= 40 ? m2[0] : cut).replace(/[,。;:,;:\s]+$/, '') + '…'
340
+ }
300
341
  return t
301
342
  }
302
343
 
@@ -560,7 +601,7 @@ function splitSingleLineFields(body) {
560
601
  return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
561
602
  }
562
603
 
563
- function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = [], auditNotes = []) {
604
+ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = [], auditNotes = [], softFiles = []) {
564
605
  const lines = content.split('\n')
565
606
  const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
566
607
  if (startIdx === -1) return null
@@ -583,9 +624,19 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
583
624
  // 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
584
625
  // 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
585
626
  if (lines[i].startsWith('文件:')) {
627
+ // 软归属(2026-09-10 用户反馈):softFiles = 审计判定的「窗口内未声明同模块测试文件」——
628
+ // 补入文件行 bullet 带括注标记,消每回手工核对;fileNotes 已显式括注的不重复加。
629
+ // fileNotes 空但有 softFiles 时同样升级 bullet(单行格式放不下括注标记)。
630
+ const notedPaths = new Set(fileNotes.map((n) => n.path))
631
+ const softBullets = []
632
+ for (const f of softFiles) {
633
+ if (!notedPaths.has(f)) softBullets.push(`- ${f}(软归属·同模块测试,未声明)`)
634
+ }
586
635
  if (fileNotes.length > 0) {
587
- const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
636
+ const bullets = [...fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`), ...softBullets]
588
637
  lines[i] = `文件:\n${bullets.join('\n')}`
638
+ } else if (softBullets.length > 0) {
639
+ lines[i] = `文件:\n${[...changedFiles.map((f) => `- ${f}`), ...softBullets].join('\n')}`
589
640
  } else if (changedFiles.length > 0) {
590
641
  lines[i] = `文件:${changedFiles.join(', ')}`
591
642
  }
@@ -767,7 +818,7 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
767
818
  /**
768
819
  * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
769
820
  */
770
- export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [], auditNotes = [] } = {}) {
821
+ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [], auditNotes = [], softFiles = [] } = {}) {
771
822
  const quicklogDir = join(specBase, 'quicklog')
772
823
  // 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
773
824
  const user = sanitizeQuicklogUser(gitUser) || 'unknown'
@@ -780,6 +831,11 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
780
831
  // 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
781
832
  const fileNotes = parseFileNotes(_pendingFileNotes)
782
833
  _pendingFileNotes = ''
834
+ // 软归属文件(complete-handlers 传入,已过 isQuickMetadata;反斜杠归一与 fileNotes 同口径):
835
+ // 窗口内未声明同模块测试文件,flipEntryInContent 补入文件行 bullet 带「软归属」括注。
836
+ const soft = Array.isArray(softFiles)
837
+ ? softFiles.filter(Boolean).map((f) => String(f).replace(/\\/g, '/'))
838
+ : []
783
839
 
784
840
  await withFileLock(lockPath, async () => {
785
841
  // 条目可能在主文件或轮转归档中
@@ -790,7 +846,7 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
790
846
  let content = ''
791
847
  try { content = readFileSync(filePath, 'utf8') } catch { continue }
792
848
  const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes,
793
- Array.isArray(auditNotes) ? auditNotes.filter((n) => typeof n === 'string' && n.trim() !== '') : [])
849
+ Array.isArray(auditNotes) ? auditNotes.filter((n) => typeof n === 'string' && n.trim() !== '') : [], soft)
794
850
  if (updated !== null) {
795
851
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
796
852
  updatedContent = updated
@@ -804,12 +860,15 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
804
860
  // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
805
861
  // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
806
862
  const rawBlock = extractRawBlock(updatedContent, qlId)
807
- await pushQuicklogEntryToPlatform(specBase, buildPushPayloadFromRaw(rawBlock, {
863
+ const finalPayload = buildPushPayloadFromRaw(rawBlock, {
808
864
  ql_id: qlId,
809
865
  author_raw: user,
810
866
  status: 'completed',
811
867
  linked_changes: linked,
812
- })).catch(() => {})
868
+ })
869
+ // P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
870
+ writeQuicklogSidecar(specBase, finalPayload)
871
+ await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
813
872
  })
814
873
  }
815
874
 
@@ -887,6 +946,9 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
887
946
  // 会产出 \r\r\n 字节级污染(git diff 全文件红);原子写防 agent/dashboard 轮询读半截
888
947
  const eol = content.includes('\r\n') ? '\r\n' : '\n'
889
948
  await writeAtomic(p, lines.join(eol))
949
+ // P1-4:sidecar 同步翻「已取消」(无 sidecar 的存量条目跳过——md 仍是真相源)
950
+ const _sc = readQuicklogSidecar(specBase, qlId)
951
+ if (_sc) writeQuicklogSidecar(specBase, { ..._sc, status: '已取消' })
890
952
  }
891
953
  break
892
954
  }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * review-dispatch — tier=independent 独立审查的平台派发核心(2026-09-10-review-dispatch task-03 / FR-01~04 / D-001~003@1)
3
+ *
4
+ * 定位:纯逻辑核心 + 注入式编排。MCP 调用全部经注入的 client(SillyHubMcpClient 实例,
5
+ * probe 同款依赖注入风格——本模块不 import client.js,测试零网络);probe 经参数注入
6
+ * (默认真 probeSillyHub,测试传 stub)。
7
+ *
8
+ * 三链(runReviewDispatch 总入口,task-04 薄壳消费):
9
+ * create —— probe 三层前置 → createMission(external) → dispatchWorker(read_only)
10
+ * → 在途记录(O_EXCL 防双开)→ 立即返回(异步,D-002)
11
+ * status —— listWorkers 状态迁移 → detectStall(queued 不计时,D-003)
12
+ * → 终态回收(getWorkerResult → artifacts 提取 → persistStageReview 落盘
13
+ * 既有 stage-reviews 路径 + reviewer.channel=platform 落款)
14
+ * kill —— 清本地在途记录 + 平台处置指引(不自动 kill 任何东西,D-003)
15
+ *
16
+ * 失败全链路有出口(design 风险登记):probe 不可用/网络异常 → {ok:false, reason,
17
+ * guidance}(guidance 按 channel_priority 去 platform 剩余序渲染),绝不抛穿。
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import jsYaml from 'js-yaml'
23
+ import { writeAtomicSync } from './fs-atomic.js'
24
+ import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
25
+ import {
26
+ renderReviewJsonContract,
27
+ validateStageReviewSchema,
28
+ computeDocHash,
29
+ readReviewChannelPriority,
30
+ } from './stage-review.js'
31
+
32
+ // stage → 主审查文档(与 stage-review.js STAGE_MAIN_DOC 同映射;该常量未导出——本地声明并
33
+ // 与 design.md 模块设计对齐,persistStageReview 消费方算 docHash 用)
34
+ const STAGE_MAIN_DOC = { brainstorm: 'design.md', plan: 'plan.md', execute: 'design.md' }
35
+
36
+ /** 在途记录状态机(design 生命周期契约表):五态,终态即清记录(无 terminalAt 字段)。 */
37
+ const DISPATCH_STATES = ['dispatching', 'in-flight', 'completed', 'failed', 'abandoned']
38
+
39
+ /** 在途记录路径:{SPEC_ROOT}/.runtime/review-dispatch-<change>.json */
40
+ function dispatchRecordPath(specBase, changeName) {
41
+ return join(specBase, '.runtime', `review-dispatch-${changeName}.json`)
42
+ }
43
+
44
+ /** 读在途记录(无/坏 → null,fail-open 调用方按无在途处理)。 */
45
+ export function readDispatchRecord(specBase, changeName) {
46
+ try {
47
+ const p = dispatchRecordPath(specBase, changeName)
48
+ if (!existsSync(p)) return null
49
+ const r = JSON.parse(readFileSync(p, 'utf8'))
50
+ return r && typeof r === 'object' && typeof r.missionId === 'string' ? r : null
51
+ } catch { return null }
52
+ }
53
+
54
+ /**
55
+ * 创建在途记录(O_EXCL 语义:已存在即拒绝——防双 CLI 并发双开,D-002 附带契约)。
56
+ * @returns {{created: boolean, existing?: object}} created=false 时带 existing 供调用方给指引。
57
+ */
58
+ export function createDispatchRecord(specBase, changeName, record) {
59
+ const p = dispatchRecordPath(specBase, changeName)
60
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
61
+ try {
62
+ writeFileSync(p, JSON.stringify(record, null, 2) + '\n', { flag: 'wx' })
63
+ return { created: true }
64
+ } catch (e) {
65
+ if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
66
+ // Windows 上 wx 对已存在文件可能抛 EPERM——两者都按「已存在」处理
67
+ return { created: false, existing: readDispatchRecord(specBase, changeName) }
68
+ }
69
+ throw e
70
+ }
71
+ }
72
+
73
+ /** 更新在途记录(原子替换;无记录时 no-op 返回 false)。 */
74
+ function updateDispatchRecord(specBase, changeName, patch) {
75
+ const cur = readDispatchRecord(specBase, changeName)
76
+ if (!cur) return false
77
+ writeAtomicSync(dispatchRecordPath(specBase, changeName), JSON.stringify({ ...cur, ...patch }, null, 2) + '\n')
78
+ return true
79
+ }
80
+
81
+ /** 清在途记录(终态回收 / kill / 失败后调用;设计口径「终态即清」——删除文件不留 tombstone,
82
+ * review.json(completed)与命令输出(missionId)是持久真相)。不存在 no-op。 */
83
+ export function clearDispatchRecord(specBase, changeName) {
84
+ const p = dispatchRecordPath(specBase, changeName)
85
+ try { if (existsSync(p)) { rmSync(p); return true } return false } catch { return false }
86
+ }
87
+
88
+ /**
89
+ * 停滞判定(D-003):queued 不计时(排队慢是正常形态);running 后 lastStateAt 超窗 → stalled。
90
+ * @returns {{stalled: boolean, queuedWait: boolean, hint: string}}
91
+ */
92
+ export function detectStall(record, nowMs, stallMs) {
93
+ const lastState = record && typeof record.lastState === 'string' ? record.lastState : ''
94
+ const lastAt = record && typeof record.lastStateAt === 'number' ? record.lastStateAt : null
95
+ if (lastState === 'queued' || lastState === 'pending' || lastAt === null) {
96
+ return { stalled: false, queuedWait: lastState === 'queued' || lastState === 'pending', hint: '' }
97
+ }
98
+ const stalled = nowMs - lastAt > stallMs
99
+ return {
100
+ stalled,
101
+ queuedWait: false,
102
+ hint: stalled
103
+ ? `疑似停滞(${lastState} 超 ${Math.round(stallMs / 60000)} 分钟无状态迁移)。三选项:①继续等(慢模型/长任务属正常)②sillyspec review-dispatch --kill 显式处置 ③按通道优先序降级(宿主子代理/降级自审)`
104
+ : '',
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 从 artifacts 提取 review JSON(双通道容错,design 风险登记「平台 artifacts 形态演进」):
110
+ * ① kind 含 review/review_json 的 artifact,content/content_ref/text 字段 JSON.parse 优先;
111
+ * ② 兜底任一 artifact 文本字段提取首个平衡 {} 对象。全失败 → null(调用方给人工 get_worker_result 指引)。
112
+ */
113
+ export function extractReviewFromArtifacts(artifacts) {
114
+ if (!Array.isArray(artifacts)) return null
115
+ const texts = []
116
+ for (const a of artifacts) {
117
+ if (!a || typeof a !== 'object') continue
118
+ const kind = String(a.kind || '')
119
+ const text = typeof a.content === 'string' ? a.content
120
+ : typeof a.content_ref === 'string' ? a.content_ref
121
+ : typeof a.text === 'string' ? a.text : null
122
+ if (text === null) continue
123
+ if (/review/i.test(kind)) {
124
+ try {
125
+ const obj = JSON.parse(text)
126
+ if (obj && typeof obj === 'object') return obj
127
+ } catch { /* 该条不是合法 JSON,落兜底通道 */ }
128
+ }
129
+ texts.push(text)
130
+ }
131
+ for (const t of texts) {
132
+ const start = t.indexOf('{')
133
+ if (start === -1) continue
134
+ // 首个平衡对象:括号配对扫描(字符串字面量内的 } 不计数)
135
+ let depth = 0, inStr = false, esc = false
136
+ for (let i = start; i < t.length; i++) {
137
+ const ch = t[i]
138
+ if (esc) { esc = false; continue }
139
+ if (ch === '\\') { esc = true; continue }
140
+ if (ch === '"') { inStr = !inStr; continue }
141
+ if (inStr) continue
142
+ if (ch === '{') depth++
143
+ else if (ch === '}') {
144
+ depth--
145
+ if (depth === 0) {
146
+ try {
147
+ const obj = JSON.parse(t.slice(start, i + 1))
148
+ if (obj && typeof obj === 'object') return obj
149
+ } catch { break }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ return null
155
+ }
156
+
157
+ /**
158
+ * 组装 reviewer 任务书(FR-01 派发质量的核心):四要素——①单源清单(前缀按 checklist
159
+ * docblock 规则)②review.json 契约(与 gate 同源)③期望产出方式(read_only worker:
160
+ * 结论经 artifacts 返回,**不写任何文件**——落盘归 CLI)④铁律段(禁改文件/禁 git commit)。
161
+ */
162
+ export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority }) {
163
+ const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
164
+ const items = (REVIEW_CHECKLISTS[stage] || []).map((t, i) => `${i + 1}. ${t}`).join('\n')
165
+ const contract = renderReviewJsonContract({ stage, changeDir, reviewRunId, tier: 'independent', channelPriority })
166
+ const workerPrompt = [
167
+ `你是独立审查者(platform 通道派发)。审查对象:${changeDir ? join(changeDir, mainDoc) : mainDoc}`,
168
+ '',
169
+ '## 审查清单(逐条给 pass/gap/fail + 证据锚点)',
170
+ items,
171
+ '',
172
+ contract,
173
+ '',
174
+ '## 产出方式(read_only 约束)',
175
+ '- **禁止修改任何文件、禁止 git commit、禁止写盘**——你只读文档与必要的源码佐证。',
176
+ '- 把**完整 review.json 文本**(按上方契约 schema,含 reviewer: {"channel":"platform"})作为产出返回(artifacts 里的 content 字段放纯 JSON 文本)。',
177
+ '- docHash 可占位(调度方 CLI 会按主文档机械重算),其余字段照实填写。',
178
+ ].join('\n')
179
+ return {
180
+ objective: `独立审查 ${mainDoc}(stage=${stage},清单 ${items.split('\n').length} 条)并产出 review.json 结论`,
181
+ workerPrompt,
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 落盘 stage review(FR-04 终态回收):reviewer 落款补 channel=platform + missionId;
187
+ * docHash 机械重算(worker 只读无盘,占位 hash 由真值替换——与 gate auto-refresh 同哲学);
188
+ * schema 校验不过 → 返回错误(不落盘坏 review)。
189
+ * @returns {{ok: boolean, reviewPath?: string, errors?: string[]}}
190
+ */
191
+ export function persistStageReview({ runtimeRoot, stage, changeName, reviewRunId, reviewObj, missionId, mainDocPath }) {
192
+ const review = {
193
+ ...reviewObj,
194
+ reviewer: { channel: 'platform', missionId: missionId || null, model: (reviewObj && reviewObj.reviewer && reviewObj.reviewer.model) || null },
195
+ }
196
+ if (mainDocPath && existsSync(mainDocPath)) {
197
+ const h = computeDocHash(mainDocPath)
198
+ if (h) review.docHash = h
199
+ }
200
+ const schema = validateStageReviewSchema(review)
201
+ if (!schema.ok) return { ok: false, errors: schema.errors }
202
+ review.reviewedFiles = Array.isArray(review.reviewedFiles) && review.reviewedFiles.length > 0
203
+ ? review.reviewedFiles
204
+ : [`changes/${changeName}/${STAGE_MAIN_DOC[stage] || 'design.md'}`]
205
+ const dir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
206
+ mkdirSync(dir, { recursive: true })
207
+ const reviewPath = join(dir, 'review.json')
208
+ writeAtomicSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
209
+ return { ok: true, reviewPath }
210
+ }
211
+
212
+ /** 降级指引(channel_priority 去 platform 的剩余序渲染;probe 失败 / worker 失败共用)。 */
213
+ export function renderFallbackGuidance(cwd) {
214
+ const rest = readReviewChannelPriority(cwd).filter((c) => c !== 'platform')
215
+ const DESC = {
216
+ 'agent-tool': '宿主 Agent tool 派独立子代理(subagent_type: general)',
217
+ 'host-mcp': '宿主自有 MCP 派独立执行者',
218
+ 'self': '降级自审:reviewerNotes 首行记「降级:环境无子代理可用」+ reviewer.channel="self"(gate ⚠️ 留痕)',
219
+ }
220
+ return rest.map((c, i) => `${i + 1}. ${c} —— ${DESC[c] || c}`).join('\n')
221
+ }
222
+
223
+ /**
224
+ * 总入口(task-04 薄壳消费契约):三链编排。
225
+ * @param {object} opts
226
+ * mode: 'create'|'status'|'kill';cwd/specBase/changeName 必填;stage(create 必填);
227
+ * client: SillyHubMcpClient 实例(create/status 用);probe: 可注入(默认真 probeSillyHub);
228
+ * budgetUsd(默认 1.0);stallMs(默认 900000);reviewRunId(status 回收时缺省读 marker)。
229
+ * @returns {Promise<{ok: boolean, ...}>} 永不抛——所有异常转 {ok:false, reason}
230
+ */
231
+ export async function runReviewDispatch(opts = {}) {
232
+ const {
233
+ mode = 'create', cwd = process.cwd(), specBase, changeName,
234
+ stage, client, probe, budgetUsd = 1.0, stallMs = 900_000, reviewRunId,
235
+ } = opts
236
+ if (!specBase || !changeName) return { ok: false, reason: '缺少 specBase/changeName' }
237
+
238
+ try {
239
+ if (mode === 'kill') {
240
+ const rec = readDispatchRecord(specBase, changeName)
241
+ clearDispatchRecord(specBase, changeName)
242
+ return {
243
+ ok: true,
244
+ killedLocally: true,
245
+ platformHint: rec
246
+ ? `本地在途记录已清。平台侧 mission ${rec.missionId} 的 lease 请在平台 UI 处置(或等 daemon 自然回收)——本命令不自动 kill(D-003 处置权在人)。后续走通道降级:\n${renderFallbackGuidance(cwd)}`
247
+ : '无在途记录(已清或从未派发)。',
248
+ }
249
+ }
250
+
251
+ if (mode === 'create') {
252
+ if (!stage || !STAGE_MAIN_DOC[stage]) return { ok: false, reason: `stage 必填 ∈ ${Object.keys(STAGE_MAIN_DOC).join('|')}` }
253
+ // 幂等闸:已有 in-flight/dispatching 记录拒双开
254
+ const cur = readDispatchRecord(specBase, changeName)
255
+ if (cur && (cur.state === 'in-flight' || cur.state === 'dispatching')) {
256
+ return { ok: false, reason: `已有在途派发(mission ${cur.missionId},state ${cur.state})`, existing: cur, guidance: '先 sillyspec review-dispatch --status 轮询;或 --kill 显式放弃后重派。' }
257
+ }
258
+ if (!client || !client.probeDaemon) {
259
+ return { ok: false, reason: 'client 未注入(SillyHubMcpClient 实例)' }
260
+ }
261
+ // probe 三层前置(no-config / daemon-unreachable / daemon-offline 任一 → 非零退 + 降级指引)
262
+ const probeFn = probe || (await import('./dispatch/probe.js')).probeSillyHub
263
+ const pr = await probeFn({ client, worktreePath: null, cwd })
264
+ if (!pr || pr.available !== true) {
265
+ return { ok: false, reason: `平台通道不可用(${pr && pr.reason ? pr.reason : 'probe 异常'})`, guidance: renderFallbackGuidance(cwd) }
266
+ }
267
+ // reviewRunId:优先复用 marker(与 prompt/gate 同链,勿手算)
268
+ let runId = reviewRunId
269
+ if (!runId) {
270
+ const { getLatestStageReviewRunId, generateStageReviewRunId, stageReviewMarkerPath } = await import('./stage-review.js')
271
+ const runtimeRoot = join(specBase, '.runtime')
272
+ runId = getLatestStageReviewRunId(runtimeRoot, stage, changeName) || generateStageReviewRunId()
273
+ try {
274
+ mkdirSync(runtimeRoot, { recursive: true })
275
+ writeAtomicSync(stageReviewMarkerPath(runtimeRoot, stage, changeName), runId + '\n')
276
+ } catch { /* marker 写失败不阻断派发(status 回收时再补) */ }
277
+ }
278
+ const changeDir = join(specBase, 'changes', changeName)
279
+ const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: runId, channelPriority: readReviewChannelPriority(cwd) })
280
+ const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd })
281
+ const missionId = mission && mission.missionId
282
+ if (!missionId) return { ok: false, reason: 'create_mission 未返回 missionId', guidance: renderFallbackGuidance(cwd) }
283
+ const cr = createDispatchRecord(specBase, changeName, {
284
+ change: changeName, stage, missionId, workerId: null, state: 'dispatching',
285
+ createdAt: new Date().toISOString(), lastState: 'dispatching', lastStateAt: Date.now(), reviewRunId: runId,
286
+ })
287
+ if (!cr.created) {
288
+ return { ok: false, reason: '并发双开被拒(O_EXCL)', existing: cr.existing, guidance: '先 --status 或 --kill。' }
289
+ }
290
+ let worker = null
291
+ try {
292
+ worker = await client.dispatchWorker({
293
+ missionId, objective: book.objective, readOnly: true,
294
+ worktreePath: cwd, workerPrompt: book.workerPrompt,
295
+ })
296
+ } catch (e) {
297
+ updateDispatchRecord(specBase, changeName, { state: 'abandoned', errorCode: 'dispatch_worker_threw' })
298
+ return { ok: false, reason: `dispatch_worker 异常: ${e && e.message}`, guidance: '记录已转 abandoned——可重派;平台侧 mission 请在 UI 核对。' }
299
+ }
300
+ const workerId = worker && worker.workerId
301
+ if (!workerId) {
302
+ updateDispatchRecord(specBase, changeName, { state: 'abandoned', errorCode: 'no_worker_id' })
303
+ return { ok: false, reason: 'dispatch_worker 未返回 workerId', guidance: '记录已转 abandoned;可重派或降级:\n' + renderFallbackGuidance(cwd) }
304
+ }
305
+ updateDispatchRecord(specBase, changeName, { workerId, state: 'in-flight', lastState: 'queued', lastStateAt: Date.now() })
306
+ return {
307
+ ok: true, missionId, workerId, reviewRunId: runId,
308
+ nextStep: `已异步派发(D-002,不阻塞等待)。轮询:sillyspec review-dispatch --status --change ${changeName}`,
309
+ }
310
+ }
311
+
312
+ if (mode === 'status') {
313
+ const rec = readDispatchRecord(specBase, changeName)
314
+ if (!rec) return { ok: false, reason: '无在途记录——先 create(缺省形态)派发。' }
315
+ if (!client || !client.listWorkers) return { ok: false, reason: 'client 未注入' }
316
+ const workers = await client.listWorkers(rec.missionId)
317
+ const w = Array.isArray(workers) ? workers.find((x) => x && (x.id === rec.workerId || x.worker_id === rec.workerId)) : null
318
+ const st = w ? String(w.status || '') : 'unknown'
319
+ const statusLine = `mission ${rec.missionId} worker ${rec.workerId}: ${st}(最近迁移 ${new Date(rec.lastStateAt).toLocaleString('zh-CN')})`
320
+ // 状态迁移更新(同态不刷时间戳——停滞计时基于真实迁移)
321
+ if (w && st && st !== rec.lastState) {
322
+ updateDispatchRecord(specBase, changeName, { lastState: st, lastStateAt: Date.now() })
323
+ rec.lastState = st; rec.lastStateAt = Date.now()
324
+ }
325
+ if (st === 'completed') {
326
+ const gr = await client.getWorkerResult({ missionId: rec.missionId, workerId: rec.workerId })
327
+ const review = extractReviewFromArtifacts(gr && gr.artifacts)
328
+ if (!review) {
329
+ return { ok: false, state: 'completed-no-artifact', statusLine, reason: 'worker completed 但 artifacts 提取不到 review JSON', guidance: `人工核对:平台 get_worker_result(mission ${rec.missionId} / worker ${rec.workerId});确认无法回收再 --kill 降级。` }
330
+ }
331
+ const runtimeRoot = join(specBase, '.runtime')
332
+ const mainDocPath = join(specBase, 'changes', changeName, STAGE_MAIN_DOC[rec.stage] || 'design.md')
333
+ const p = persistStageReview({
334
+ runtimeRoot, stage: rec.stage, changeName, reviewRunId: rec.reviewRunId,
335
+ reviewObj: review, missionId: rec.missionId, mainDocPath,
336
+ })
337
+ if (!p.ok) {
338
+ return { ok: false, state: 'bad-review', statusLine, errors: p.errors, guidance: 'artifacts 里的 review 不符 schema——--kill 后降级,或人工按契约修 review.json(mission 证据保留)。' }
339
+ }
340
+ clearDispatchRecord(specBase, changeName)
341
+ return { ok: true, state: 'completed', statusLine, reviewPath: p.reviewPath, verdict: `${review.specVerdict}/${review.qualityVerdict}` }
342
+ }
343
+ if (st === 'failed' || st === 'killed') {
344
+ clearDispatchRecord(specBase, changeName)
345
+ return { ok: false, state: st, statusLine, reason: `worker 终态 ${st}`, guidance: '降级:\n' + renderFallbackGuidance(cwd) }
346
+ }
347
+ // 在途:停滞检测(queued 不计时)
348
+ const stall = detectStall(rec, Date.now(), stallMs)
349
+ return {
350
+ ok: true, state: st || 'in-flight', statusLine,
351
+ stalled: stall.stalled, stallHint: stall.hint,
352
+ nextStep: stall.stalled ? stall.hint : '继续轮询 --status(或稍后重查)。',
353
+ }
354
+ }
355
+
356
+ return { ok: false, reason: `未知 mode: ${mode}` }
357
+ } catch (e) {
358
+ return { ok: false, reason: `review-dispatch 异常: ${e && e.message ? e.message : String(e)}` }
359
+ }
360
+ }
361
+
362
+ // task-06 消费契约预留(配置读取在该 task 落地;此处常量为缺省值单一来源)
363
+ const DEFAULT_REVIEW_DISPATCH_CONFIG = { budget_usd: 1.0, stall_ms: 900_000, timeout_ms: 0 }
364
+ export function readReviewDispatchConfig(cwd) {
365
+ try {
366
+ const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml')
367
+ if (!existsSync(p)) return { ...DEFAULT_REVIEW_DISPATCH_CONFIG }
368
+ const doc = jsYaml.load(readFileSync(p, 'utf8'))
369
+ const rd = doc && typeof doc === 'object' && doc.review_dispatch && typeof doc.review_dispatch === 'object' ? doc.review_dispatch : {}
370
+ const num = (v, dflt) => (typeof v === 'number' && v >= 0 ? v : dflt)
371
+ return {
372
+ budget_usd: num(rd.budget_usd, DEFAULT_REVIEW_DISPATCH_CONFIG.budget_usd),
373
+ stall_ms: num(rd.stall_ms, DEFAULT_REVIEW_DISPATCH_CONFIG.stall_ms),
374
+ timeout_ms: num(rd.timeout_ms, DEFAULT_REVIEW_DISPATCH_CONFIG.timeout_ms),
375
+ }
376
+ } catch { return { ...DEFAULT_REVIEW_DISPATCH_CONFIG } }
377
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * run/archive-distill.js(P0-4 安全变体,docs/sillyspec/noai-ir-roadmap.md §3)。
3
+ *
4
+ * archive「decision-distill 决策提炼」步 noAI 化:该步旧 prompt 本身就是「调用 CLI 纯函数
5
+ * distillIntoKnowledge 并转述返回」——全流程最纯的中继步(提炼/幂等/INDEX 路由本体全在
6
+ * src/decision-distill.js),LLM 在此零判断增量,仅 rejected 缺字段时的人工裁决留价值。
7
+ *
8
+ * 动作语义(与旧 prompt 三点分流逐字对齐):
9
+ * - written 非空 → 已写盘,逐条打印(旧「常规」分支);
10
+ * - needsWait 真 → 该批 rejected 条目未写盘,打印裁决指引:补录 decisions.md 后重跑提炼
11
+ * (幂等,命令行保留在指引里),或经用户裁决跳过并在「确认归档」--output 注记
12
+ * (旧 conditionalWait 三段式收敛到确认归档的用户确认点——归档的用户裁决本就归那里);
13
+ * - skipped(无 decisions.md / 0 条入选)→ 零输出注记(同 docs-debt 无债零输出原则);
14
+ * - 执行异常 → warn 降级跳过不阻断归档(旧步第 4 点 best-effort 语义)。
15
+ *
16
+ * 全路径不抛——本步永远可完成(裁决是确认归档步的输入,不是本步的阻断条件)。
17
+ */
18
+ import { join } from 'node:path'
19
+ import { gitQuiet } from '../git-helper.js'
20
+
21
+ export async function executeArchiveDistill({ cwd, specBase, changeName }) {
22
+ const changeDir = join(specBase, 'changes', changeName)
23
+ const knowledgeRoot = join(specBase, 'knowledge')
24
+ let headHash = ''
25
+ try {
26
+ headHash = gitQuiet(cwd, ['rev-parse', '--short', 'HEAD']) || ''
27
+ } catch { /* git 不可用 → 空串(「最近确认」字段留空,不阻断) */ }
28
+ try {
29
+ const { distillIntoKnowledge } = await import('../decision-distill.js')
30
+ const r = distillIntoKnowledge(changeDir, knowledgeRoot, headHash)
31
+ const written = Array.isArray(r && r.written) ? r.written : []
32
+ if (written.length > 0) {
33
+ console.log(`\n📝 决策提炼(CLI 机械执行,幂等):${written.length} 条已写入决策知识库——`)
34
+ for (const w of written) console.log(` - ${typeof w === 'string' ? w : JSON.stringify(w)}`)
35
+ } else if (r && r.skipped) {
36
+ console.log(`\nℹ️ 决策提炼零输出:${r.skipped}`)
37
+ }
38
+ if (r && r.needsWait) {
39
+ console.warn(`\n⚠️ rejected 决策缺否决理由/复潮条件(needsWait)——这些条目未写盘,其余条目照常提炼。`)
40
+ console.warn(` 裁决指引(确认归档 --confirm 前处理):`)
41
+ console.warn(` ① 补录:把缺失字段补进 ${join(changeDir, 'decisions.md')} 后重跑提炼(幂等)——`)
42
+ console.warn(` node --input-type=module -e "import { distillIntoKnowledge } from '<sillyspec 源码路径>/src/decision-distill.js'; console.log(JSON.stringify(distillIntoKnowledge(${JSON.stringify(changeDir)}, ${JSON.stringify(knowledgeRoot)}, ${JSON.stringify(headHash)}), null, 2))"`)
43
+ console.warn(` ② 跳过:经用户裁决不入库该条,并在「确认归档」--output 注记跳过原因。`)
44
+ }
45
+ } catch (e) {
46
+ console.warn(`\n⚠️ 决策提炼执行异常,降级跳过(best-effort,不阻断归档——同旧步降级语义): ${e && e.message ? e.message : e}`)
47
+ }
48
+ }