sillyspec 3.28.4 → 3.28.6

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 (52) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/sa-rename-probe/src/new.js +4 -0
  5. package/src/agent-session-log.js +4 -3
  6. package/src/archive-delta.js +2 -2
  7. package/src/change-list.js +11 -1
  8. package/src/commit-suggest.js +52 -1
  9. package/src/config-cat.js +6 -1
  10. package/src/config-schema.js +13 -0
  11. package/src/decision-distill.js +53 -7
  12. package/src/decisions-io.js +96 -0
  13. package/src/design-facts.js +3 -0
  14. package/src/dispatch/probe.js +31 -8
  15. package/src/docs-check.js +77 -8
  16. package/src/friction-tally.js +221 -0
  17. package/src/index.js +430 -18
  18. package/src/mcp-server.js +144 -0
  19. package/src/module-impact.js +8 -1
  20. package/src/modules.js +46 -34
  21. package/src/progress/change-registry.js +20 -0
  22. package/src/progress.js +16 -1
  23. package/src/quicklog.js +60 -6
  24. package/src/review-dispatch.js +377 -0
  25. package/src/run/archive-distill.js +48 -0
  26. package/src/run/command.js +35 -5
  27. package/src/run/complete-handlers.js +136 -3
  28. package/src/run/complete.js +170 -2
  29. package/src/run/gate-snapshot.js +159 -0
  30. package/src/run/gates.js +166 -69
  31. package/src/run/prompt.js +54 -9
  32. package/src/run/quick-audit.js +78 -16
  33. package/src/run/shared.js +138 -7
  34. package/src/run/stage.js +31 -2
  35. package/src/run/verify-quality-scan.js +383 -0
  36. package/src/scope-audit.js +941 -0
  37. package/src/sillyhub-mcp/client.js +58 -3
  38. package/src/stage-review-checklist.js +44 -0
  39. package/src/stage-review.js +122 -4
  40. package/src/stages/archive.js +57 -164
  41. package/src/stages/brainstorm.js +8 -12
  42. package/src/stages/execute.js +13 -10
  43. package/src/stages/plan.js +3 -10
  44. package/src/stages/verify.js +14 -33
  45. package/src/sync.js +51 -10
  46. package/src/task-review.js +108 -16
  47. package/src/taskcard.js +46 -3
  48. package/src/validate-artifacts.js +163 -0
  49. package/src/verify-postcheck.js +90 -10
  50. package/src/verify-probes.js +134 -12
  51. package/src/worktree-apply.js +119 -14
  52. 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, assertDatedChangeName, 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++) {
@@ -1036,6 +1046,22 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1036
1046
  process.exit(2) // 用法/环境错(目标变更不在当前库)→ exit 2
1037
1047
  }
1038
1048
  }
1049
+ // ── 变更名日期前缀门禁(brainstorm step6 规则 CLI 化,2026-09-11 实证 friction-signal-hint)──
1050
+ // 格式 YYYY-MM-DD-<简短描述> 此前只是 prompt 约束,agent 自拟名/重命名丢前缀时 CLI 照单
1051
+ // 全收静默物化。只拦「净新建」:DB 无行(!progress 已证)+ changes/(含 archive/)无目录;
1052
+ // 已物化存量(含归档无前缀旧名 auto-flow-optimization 等)不追诉,照常自愈初始化。
1053
+ // quick 阶段豁免:其 changeName 是会话 key(quick-<8hex>),非变更名。
1054
+ if (changeName && stageName !== 'quick' && !/^quick-[0-9a-f]{8}$/.test(changeName)
1055
+ && !existsSync(join(specBase, 'changes', changeName))
1056
+ && !existsSync(join(specBase, 'changes', 'archive', changeName))) {
1057
+ try {
1058
+ assertDatedChangeName(changeName)
1059
+ } catch (e) {
1060
+ console.error(`❌ ${e.message}`)
1061
+ console.error(` 重试:sillyspec run ${stageName} --change <YYYY-MM-DD-简短描述>`)
1062
+ process.exit(2) // 用法错(新变更名格式非法)→ exit 2
1063
+ }
1064
+ }
1039
1065
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
1040
1066
  const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
1041
1067
  if (autoChange) {
@@ -1738,8 +1764,12 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1738
1764
  }
1739
1765
 
1740
1766
  if (!outputText) {
1741
- console.error('auto --done requires --output')
1742
- process.exit(2) // 用法错 → exit 2
1767
+ // P0-2(noai-ir-roadmap §3):auto --done 的 --output 同样可省略——与 completeStep 同源
1768
+ // 的事实性摘要合成(此前硬拒 exit 2 是用法守卫;SS-META 的 doneCommand 仍带 --output,
1769
+ // 照抄路径零变化,省略路径不再罚一轮)。
1770
+ const _autoStep = progress?.stages?.[currentStage]?.steps?.find((s) => s && ['pending', 'in-progress', 'blocked'].includes(s.status))
1771
+ outputText = synthesizeStepOutput({ stageName: currentStage, stepName: _autoStep?.name || currentStage, cwd })
1772
+ console.log(`🤖 auto --done 未带 --output——CLI 已按事实合成步骤摘要(语义说明下次带 --output 手写)。`)
1743
1773
  }
1744
1774
 
1745
1775
  const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })