sillyspec 3.28.4 → 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 (49) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/src/agent-session-log.js +4 -3
  5. package/src/archive-delta.js +2 -2
  6. package/src/change-list.js +11 -1
  7. package/src/commit-suggest.js +52 -1
  8. package/src/config-cat.js +6 -1
  9. package/src/decision-distill.js +45 -5
  10. package/src/decisions-io.js +96 -0
  11. package/src/design-facts.js +3 -0
  12. package/src/dispatch/probe.js +31 -8
  13. package/src/docs-check.js +68 -3
  14. package/src/index.js +426 -17
  15. package/src/mcp-server.js +144 -0
  16. package/src/module-impact.js +8 -1
  17. package/src/modules.js +46 -34
  18. package/src/progress/change-registry.js +18 -0
  19. package/src/progress.js +13 -1
  20. package/src/quicklog.js +60 -6
  21. package/src/review-dispatch.js +377 -0
  22. package/src/run/archive-distill.js +48 -0
  23. package/src/run/command.js +19 -5
  24. package/src/run/complete-handlers.js +63 -3
  25. package/src/run/complete.js +139 -2
  26. package/src/run/gate-snapshot.js +88 -0
  27. package/src/run/gates.js +115 -55
  28. package/src/run/prompt.js +54 -9
  29. package/src/run/quick-audit.js +38 -13
  30. package/src/run/shared.js +109 -6
  31. package/src/run/stage.js +17 -2
  32. package/src/run/verify-quality-scan.js +336 -0
  33. package/src/scope-audit.js +735 -0
  34. package/src/sillyhub-mcp/client.js +58 -3
  35. package/src/stage-review-checklist.js +44 -0
  36. package/src/stage-review.js +122 -4
  37. package/src/stages/archive.js +57 -164
  38. package/src/stages/brainstorm.js +5 -9
  39. package/src/stages/execute.js +13 -10
  40. package/src/stages/plan.js +3 -10
  41. package/src/stages/verify.js +14 -33
  42. package/src/sync.js +51 -10
  43. package/src/task-review.js +108 -16
  44. package/src/taskcard.js +46 -3
  45. package/src/validate-artifacts.js +163 -0
  46. package/src/verify-postcheck.js +90 -10
  47. package/src/verify-probes.js +134 -12
  48. package/src/worktree-apply.js +119 -14
  49. package/templates/agents-instruction.md +1 -0
@@ -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
+ }
@@ -23,10 +23,10 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, resolveQuickSessionsDir, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, isTempResidueSpecRoot, PLATFORM_MANAGED_FILENAME } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, resolveQuickSessionsDir, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, isTempResidueSpecRoot, PLATFORM_MANAGED_FILENAME, warnSelfRefPointerOnce } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep, collectStageWaitHistory } from './prompt.js'
29
- import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
29
+ import { completeStep, skipStep, waitStep, continueStep, synthesizeStepOutput } from './complete.js'
30
30
  import { runStage } from './stage.js'
31
31
  import { sanitizeDesc } from '../quicklog.js'
32
32
  import { ProgressManager } from '../progress.js'
@@ -267,6 +267,13 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
267
267
  process.exit(2) // 用法错 → exit 2
268
268
  }
269
269
 
270
+ // P1-3(noai-ir-roadmap §4):--meta → 本次调用渲染 SS-META 机器块(prompt.js outputStep
271
+ // 读 SILLYSPEC_RUN_META)。CLI 是短进程:env 只在本进程生命周期内有效,不跨调用泄漏;
272
+ // auto 模式本就恒渲染(autoMeta 路径不变),--meta 对常规模式补齐同款出口。
273
+ if (flags.includes('--meta')) {
274
+ process.env.SILLYSPEC_RUN_META = '1'
275
+ }
276
+
270
277
  if (!stageRegistry[stageName] && stageName !== 'auto') {
271
278
  console.error(`❌ 未知阶段: ${stageName}`)
272
279
  console.error(`可选: ${Object.keys(stageRegistry).join(', ')}, auto`)
@@ -395,7 +402,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
395
402
  // 忽略恢复(specRoot/runtimeRoot/workspaceId/scanRunId 全不回填,platformOpts 保持
396
403
  // 无平台参数状态),按本地模式运行——内置 sync 走本地链路,specRoot 保持本地取值链。
397
404
  if (isSelfReferentialSpecRoot(cwd, saved.specRoot)) {
398
- console.warn(`⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已忽略并按本地模式运行: ${platformOptsFile}`)
405
+ // 跨进程窗口降频(2026-09-10 驾驭小结第五批①):daemon junction 形态下每命令命中的重复噪音
406
+ warnSelfRefPointerOnce(cwd, saved.specRoot,
407
+ `⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已忽略并按本地模式运行: ${platformOptsFile}(10 分钟内不重复提示)`)
399
408
  } else {
400
409
  if (saved.specRoot) platformOpts.specRoot = saved.specRoot
401
410
  if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
@@ -820,6 +829,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
820
829
  '--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
821
830
  '--adopt-branch', // execute 显式收编既有 sillyspec/<change> 分支为 worktree 工作分支(坑 worktree-user-branch-conflict)
822
831
  '--diff', '--base', '--full', '--report', // scan diff(D-001:command.js 只补 flag,裸 token 解析归 index.js 子命令拦截)
832
+ '--meta', // P1-3:渲染 SS-META 机器块(布尔 flag,不吃值)
823
833
  '-h',
824
834
  ])
825
835
  for (let i = 0; i < flags.length; i++) {
@@ -1738,8 +1748,12 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1738
1748
  }
1739
1749
 
1740
1750
  if (!outputText) {
1741
- console.error('auto --done requires --output')
1742
- process.exit(2) // 用法错 → exit 2
1751
+ // P0-2(noai-ir-roadmap §3):auto --done 的 --output 同样可省略——与 completeStep 同源
1752
+ // 的事实性摘要合成(此前硬拒 exit 2 是用法守卫;SS-META 的 doneCommand 仍带 --output,
1753
+ // 照抄路径零变化,省略路径不再罚一轮)。
1754
+ const _autoStep = progress?.stages?.[currentStage]?.steps?.find((s) => s && ['pending', 'in-progress', 'blocked'].includes(s.status))
1755
+ outputText = synthesizeStepOutput({ stageName: currentStage, stepName: _autoStep?.name || currentStage, cwd })
1756
+ console.log(`🤖 auto --done 未带 --output——CLI 已按事实合成步骤摘要(语义说明下次带 --output 手写)。`)
1743
1757
  }
1744
1758
 
1745
1759
  const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
@@ -34,6 +34,7 @@ import { printQuickAuditReview, runQuickTestLintGate, printQuickTestLintGate } f
34
34
  import { validateQuickResult, allocateQuicklogEntry, appendQuicklogEntryWithId, findQuicklogEntry, completeQuicklogEntry, extractTitleFromResult, parseFileNotes, getQuickFileNotes } from '../quicklog.js'
35
35
  import { getRule } from '../stage-contract-spec.js'
36
36
  import { archiveDestDirName } from '../stage-contract.js'
37
+ import { collectNumstatByPath } from '../scope-audit.js'
37
38
 
38
39
  /**
39
40
  * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
@@ -1203,6 +1204,14 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1203
1204
  ? review.attributedFiles
1204
1205
  : (Array.isArray(review?.changedFiles) ? review.changedFiles : [])
1205
1206
  const realFiles = auditFiles.filter(f => !isQuicklogFileLineNoise(f, linkedChanges))
1207
+ // 软归属(2026-09-10 用户反馈:--file-notes 括注只落声明文件,同模块测试文件漏声明时文件行
1208
+ // 不自动补齐,连续多批手工核对):auditQuickCompletion 判定的「窗口内未声明同模块测试文件」
1209
+ // → 补入文件行 bullet 带软归属括注;审计面单列 🔍 行(可追溯、错认可手工剔除),⚠️ 不进
1210
+ // attributedFiles——硬归属口径「声明即归属」不变(2026-08-18 误归属修复)。
1211
+ const softFiles = (Array.isArray(review?.softTestFiles) ? review.softTestFiles : [])
1212
+ .map(f => String(f).replace(/\\/g, '/')).filter(f => !isQuickMetadata(f, linkedChanges))
1213
+ // task-06(FR-04):文件行/审计行并上 numstat 行数——attachQuickLineCounts 见文件尾(声明提升)
1214
+ const { annotatedRealFiles, annotatedSoftFiles, fmtLineCounts } = attachQuickLineCounts({ cwd, realFiles, softFiles, review, auditFiles, qlId })
1206
1215
  // D-8 落盘(2026-08-18 修):advisory 欠账信号从「纯打印」升级为「随条目落盘」——修复
1207
1216
  // 「欠账已记录(QUICKLOG reasons)」的不实承诺(交叉审查实证 reasons 纯 stdout,事后不可审计)。
1208
1217
  // 两周实测(2026-08-31 裁决,doc-consistency-debt §七)需要分母:信号触发次数必须可追溯。
@@ -1236,17 +1245,25 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1236
1245
  }
1237
1246
  // 归属切分注(2026-08-18 误归属修复):窗口内未声明脏文件不进「文件:」行,但必须落盘可追溯
1238
1247
  // (多 agent 并发仓他者窗口改动 / 本会话漏声明均可能),防真实改动被静默挤走。
1248
+ // 软归属拆分(2026-09-10):softFiles 中的同模块测试文件已补入文件行,不再占 ⚖️「未计入」
1249
+ // 文案(否则审计行与文件行自相矛盾),单列 🔍 行交代软归属依据与剔除指引。
1239
1250
  if (Array.isArray(review?.undeclaredFiles) && review.undeclaredFiles.length > 0) {
1240
- const undeclared = review.undeclaredFiles.filter(f => !isQuickMetadata(f, linkedChanges))
1251
+ const softSet = new Set(softFiles)
1252
+ const undeclared = review.undeclaredFiles.filter(f => !isQuickMetadata(f, linkedChanges) && !softSet.has(String(f).replace(/\\/g, '/')))
1241
1253
  if (undeclared.length > 0) {
1242
- auditNotes.push(`⚖️ 归属切分:${undeclared.length} 个窗口内未声明脏文件未计入文件行(并行会话改动或本会话漏声明):${undeclared.join(', ')}`)
1254
+ auditNotes.push(`⚖️ 归属切分:${undeclared.length} 个窗口内未声明脏文件未计入文件行(并行会话改动或本会话漏声明):${undeclared.map(f => `${f}${fmtLineCounts(f)}`).join(', ')}`)
1255
+ }
1256
+ if (softFiles.length > 0) {
1257
+ auditNotes.push(`🔍 软归属:${softFiles.length} 个窗口内未声明同模块测试文件已补入文件行(若属并行会话改动请手工剔除):${annotatedSoftFiles.join(', ')}`)
1258
+ console.log(`🔍 软归属:${softFiles.length} 个窗口内未声明同模块测试文件已按软归属补入文件行(若属并行会话改动请手工剔除):${annotatedSoftFiles.join(', ')}`)
1243
1259
  }
1244
1260
  }
1245
1261
  await completeQuicklogEntry(specBase, gitUser, qlId, {
1246
1262
  resultText: outputText || '',
1247
1263
  linkedChanges,
1248
- changedFiles: realFiles,
1264
+ changedFiles: annotatedRealFiles,
1249
1265
  auditNotes,
1266
+ softFiles,
1250
1267
  })
1251
1268
  console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
1252
1269
  // 刷新 DB title:从 step3「需求:」提取(agent 可改 title 的途径),覆盖启动时的兜底快照。
@@ -1828,3 +1845,46 @@ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, pro
1828
1845
  return null
1829
1846
  }
1830
1847
 
1848
+ // ── task-06(2026-09-10-change-scope-audit,FR-04):quick 文件行/审计行 numstat 行数注入 ──
1849
+ // collectNumstatByPath 与 scope-audit 命令同源采集(D-003 单一真相;R-03 单次 git 调用非逐文件)。
1850
+ // 基点 'HEAD' = 未提交工作区窗口(D-004:quick 无 merge-base 锚,对未提交窗口采集语义自洽;
1851
+ // untracked 新文件 wc-l 记全 + 行、binary 显 BIN)。纯展示列(advisory,D-006):采集失败/行数
1852
+ // 不可得 → 括注省略不出伪数据;不改 auditQuickCompletion 判定、runQuickTestLintGate 与任何门禁。
1853
+ // 放文件尾(函数声明提升)而不内联进 handleQuickStageCompletion:docs/platform-interface-map
1854
+ // 行号锚在上方(complete-handlers.js:1688 handleScanStageCompleted),内联大块会把 doc-ref 锚
1855
+ // 推出关键词窗口。
1856
+ //
1857
+ // 消费分工(调用方 quick 收尾):
1858
+ // - annotatedRealFiles → completeQuicklogEntry changedFiles(QUICKLOG「文件:」行/bullet 带括注;
1859
+ // fileNotes 模式下 realFiles 本就不渲染,括注只出现在回退渲染面)
1860
+ // - annotatedSoftFiles → 🔍 软归属审计行 + console;softFiles 传参保持**裸路径**——
1861
+ // flipEntryInContent 用它与 fileNotes.path 精确等值去重(带括注会不等值 → 同文件双 bullet)
1862
+ // - fmtLineCounts → ⚖️ 归属切分审计行逐文件括注(降级返回空串 → 原样不带)
1863
+ // - 已提交降级(D-004):窗口空(改动已 commit/会话未产生改动)且 QUICKLOG 条目已存在 →
1864
+ // 记录态提示读 QUICKLOG 条目文件行,不以空表冒充实时(与 scope-audit quick 模式 note 同口径)
1865
+ function attachQuickLineCounts({ cwd, realFiles, softFiles, review, auditFiles, qlId }) {
1866
+ const posix = (f) => String(f).replace(/\\/g, '/')
1867
+ const lineCountFiles = [...new Set([...(realFiles || []).map(posix), ...(softFiles || [])])]
1868
+ let numstatByPath = new Map()
1869
+ if (lineCountFiles.length > 0) {
1870
+ try {
1871
+ numstatByPath = collectNumstatByPath(cwd, lineCountFiles, { baseRef: 'HEAD' })
1872
+ } catch { /* 采集异常 → 行数列整体降级省略(fail-soft) */ }
1873
+ }
1874
+ const fmtLineCounts = (f) => {
1875
+ const st = numstatByPath.get(posix(f))
1876
+ if (!st) return ''
1877
+ if (st.kind === 'binary') return '(BIN)'
1878
+ if (!Number.isFinite(st.additions) || !Number.isFinite(st.deletions)) return ''
1879
+ return `(+${st.additions}/-${st.deletions})`
1880
+ }
1881
+ const annotatedRealFiles = (realFiles || []).map(f => `${f}${fmtLineCounts(f)}`)
1882
+ const annotatedSoftFiles = (softFiles || []).map(f => `${f}${fmtLineCounts(f)}`)
1883
+ if (lineCountFiles.length > 0) {
1884
+ console.log(`📊 变更行数(vs HEAD 未提交窗口):${[...annotatedRealFiles, ...annotatedSoftFiles].join(', ')}`)
1885
+ } else if (review && Array.isArray(auditFiles) && auditFiles.length === 0) {
1886
+ console.log(`ℹ️ 本会话无未提交改动(窗口已提交或会话未产生改动)——实时行数不可采,文件行请读 QUICKLOG 条目 ${qlId}(记录态)`)
1887
+ }
1888
+ return { annotatedRealFiles, annotatedSoftFiles, fmtLineCounts }
1889
+ }
1890
+