sillyspec 3.25.11 → 3.26.0

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.
@@ -25,7 +25,7 @@
25
25
  import { basename, join, resolve, relative, isAbsolute } from 'node:path'
26
26
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
27
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
- import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata } from './shared.js'
28
+ import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, resolveRuntimeRoot } from './shared.js'
29
29
  import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
30
30
  import { stageRegistry } from '../stages/index.js'
31
31
  import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
@@ -130,9 +130,32 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
130
130
  /**
131
131
  * 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
132
132
  * 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
133
+ * 同时清理该变更的 execute / stage-review runId marker(只写不删的累积物,
134
+ * 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
133
135
  * 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
134
136
  */
135
- async function archiveWorktreeCleanup(cwd, archiveChangeName) {
137
+ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
138
+ // ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
139
+ // (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
140
+ // 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
141
+ // (resolveRuntimeRoot 锚主仓),避免平台模式清理错位置。
142
+ try {
143
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
144
+ if (existsSync(runtimeRoot)) {
145
+ const markers = readdirSync(runtimeRoot).filter((f) =>
146
+ f === `current-execute-run-id-${archiveChangeName}` ||
147
+ (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
148
+ )
149
+ for (const m of markers) {
150
+ try { unlinkSync(join(runtimeRoot, m)) } catch {}
151
+ }
152
+ if (markers.length > 0) {
153
+ console.log(`🧹 归档清理 ${markers.length} 个 runId marker(execute/stage-review)`)
154
+ }
155
+ }
156
+ } catch (e) {
157
+ console.warn(`⚠️ 归档 runId marker 清理失败(不阻断归档): ${e.message}`)
158
+ }
136
159
  try {
137
160
  const { WorktreeManager } = await import('../worktree.js')
138
161
  const wm = new WorktreeManager({ cwd })
@@ -154,7 +177,7 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName) {
154
177
  }
155
178
  }
156
179
 
157
- export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
180
+ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts = {}) {
158
181
  const archiveChangeName = progress.currentChange
159
182
  if (!archiveChangeName) {
160
183
  console.error('❌ 归档失败:未找到当前变更名(currentChange)')
@@ -176,7 +199,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
176
199
  if (alreadyArchivedDir) {
177
200
  console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
178
201
  pm.unregisterChange(cwd, archiveChangeName)
179
- await archiveWorktreeCleanup(cwd, archiveChangeName)
202
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
180
203
  console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
181
204
  return alreadyArchivedDir
182
205
  }
@@ -221,7 +244,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
221
244
  } catch {}
222
245
 
223
246
  // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
224
- await archiveWorktreeCleanup(cwd, archiveChangeName)
247
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
225
248
 
226
249
  console.log(`📦 已归档:${archiveChangeName} → archive/${destName}/`)
227
250
  return destDir
@@ -236,7 +259,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
236
259
  *
237
260
  * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
238
261
  */
239
- export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase }) {
262
+ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts = {} }) {
240
263
  if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
241
264
  if (!confirm) {
242
265
  steps[currentIdx].status = 'pending'
@@ -246,7 +269,7 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
246
269
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
247
270
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
248
271
  }
249
- const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
272
+ const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
250
273
  if (archivedDir && existsSync(archivedDir)) {
251
274
  const recommendedDocs = ['design.md', 'module-impact.md']
252
275
  const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
@@ -179,7 +179,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
179
179
  // archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
180
180
  // (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
181
181
  {
182
- const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase })
182
+ const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts })
183
183
  if (_archiveEarlyReturn) return _archiveEarlyReturn
184
184
  }
185
185
 
@@ -269,7 +269,12 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
269
269
  if (outputText) {
270
270
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
271
271
  const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
272
- appendFileSync(inputsPath, entry)
272
+ try {
273
+ appendFileSync(inputsPath, entry)
274
+ } catch (e) {
275
+ // best-effort:Windows AV/索引占用偶发 EPERM,历史日志写失败不阻断主流程(review-2026-08-09 #7,对齐 shared.js triggerSync)
276
+ console.warn('⚠️ user-inputs.md 追加失败(不阻断):', e.message)
277
+ }
273
278
  }
274
279
 
275
280
  // 阶段完成收尾共享管线(handleScan manifest + validateMetadata/FileLocations + auxiliary 重置 +
@@ -346,7 +351,11 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
346
351
  if (outputText) {
347
352
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
348
353
  const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
349
- appendFileSync(inputsPath, entry)
354
+ try {
355
+ appendFileSync(inputsPath, entry)
356
+ } catch (e) {
357
+ console.warn('⚠️ user-inputs.md 追加失败(不阻断):', e.message)
358
+ }
350
359
  }
351
360
 
352
361
  const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
@@ -693,7 +702,11 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
693
702
  // Append to user-inputs.md
694
703
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
695
704
  const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
696
- appendFileSync(inputsPath, entry)
705
+ try {
706
+ appendFileSync(inputsPath, entry)
707
+ } catch (e) {
708
+ console.warn('⚠️ user-inputs.md 追加失败(不阻断):', e.message)
709
+ }
697
710
 
698
711
  // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
699
712
  if (shouldReturnToCurrentStep) {
package/src/run/gates.js CHANGED
@@ -115,9 +115,20 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
115
115
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
116
116
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
117
117
  if (!existsSync(runIdFile) || !existsSync(planPath)) return true
118
- const executeRunId = readFileSync(runIdFile, 'utf8').trim()
118
+ let executeRunId = readFileSync(runIdFile, 'utf8').trim()
119
119
  const planContent = readFileSync(planPath, 'utf8')
120
- const { validateCheckedTaskReviews } = await import('../task-review.js')
120
+ const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks } = await import('../task-review.js')
121
+ // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
122
+ // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
123
+ // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
124
+ // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
125
+ if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
126
+ const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
127
+ if (relocated && relocated !== executeRunId) {
128
+ console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
129
+ executeRunId = relocated
130
+ }
131
+ }
121
132
  const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
122
133
  if (result.ok) return true
123
134
  if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
package/src/run/shared.js CHANGED
@@ -349,6 +349,80 @@ export async function triggerSync(cwd, changeName, platformOpts = {}) {
349
349
  }
350
350
  }
351
351
 
352
+ /**
353
+ * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
354
+ * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
355
+ * 注入时机:CLI 启动(run/--done)+ 关键决策点(approve/archive 前)+ 手动 platform pull。
356
+ * 不在每步 pull(避免高频写入与网络压力),仅低频边界点。
357
+ * @param {string} cwd
358
+ * @param {string} changeName - 当前活跃变更(多变更时传 null 跳过,避免误拉)
359
+ * @param {object} [platformOpts] - 平台模式 opts(specRoot/runtimeRoot 存在则跳过,走平台自有链路)
360
+ */
361
+ export async function triggerPull(cwd, changeName, platformOpts = {}) {
362
+ // 平台模式(SillyHub)走自己的链路,跳过
363
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
364
+ try {
365
+ const syncMod = await import('../sync.js')
366
+ const sm = new syncMod.SyncManager(cwd)
367
+ // 未连接平台静默跳过(本地独立用户合法状态,不噪音)
368
+ if (!sm._getPlatform()) return
369
+ let timer
370
+ try {
371
+ await Promise.race([
372
+ sm.pull(changeName),
373
+ new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
374
+ ])
375
+ } finally {
376
+ clearTimeout(timer)
377
+ }
378
+ } catch (e) {
379
+ // pull 失败静默跳过(Best Effort,失败不影响正确性)
380
+ console.warn('⚠️ 拉取失败:', e.message)
381
+ }
382
+ }
383
+
384
+ /**
385
+ * triggerPull 的便捷封装:未显式传 changeName 时自动推导单活跃变更(task-10)。
386
+ * 多活跃 / 无活跃变更时跳过(无法确定目标,避免误拉);其余语义同 triggerPull。
387
+ * 供 index.js 在 stage 命令(run/--done/archive)与 approve 决策点注入一行调用。
388
+ */
389
+ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
390
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
391
+ // 先检查是否连接平台:未连接直接 return,避免 _ensureDB 在无 local.yaml 的 cwd 创建空 DB 污染
392
+ let sm = null
393
+ try {
394
+ const syncMod = await import('../sync.js')
395
+ sm = new syncMod.SyncManager(cwd)
396
+ if (!sm._getPlatform()) return
397
+ } catch {
398
+ return
399
+ }
400
+ let cn = null
401
+ try {
402
+ const { ProgressManager } = await import('../progress.js')
403
+ const pm = new ProgressManager({})
404
+ const changes = pm.listChanges(cwd)
405
+ if (changes.length === 1) cn = changes[0]
406
+ } catch {
407
+ // progress 不可达则跳过(Best Effort)
408
+ }
409
+ if (!cn) return
410
+ // 已确认连接 + 单活跃变更,调 pull(复用 triggerPull 的 8s 熔断)
411
+ try {
412
+ let timer
413
+ try {
414
+ await Promise.race([
415
+ sm.pull(cn),
416
+ new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
417
+ ])
418
+ } finally {
419
+ clearTimeout(timer)
420
+ }
421
+ } catch (e) {
422
+ console.warn('⚠️ 拉取失败:', e.message)
423
+ }
424
+ }
425
+
352
426
  /**
353
427
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
354
428
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
@@ -5,8 +5,8 @@
5
5
  * - 网络失败 / 非 2xx / 异常一律 console.warn 不抛错,绝不抛穿到 execute(约束:HTTP 错误保守返回 unavailable/空/false)
6
6
  * - 未配置(缺 SILLYHUB_MCP_URL 或 SILLYHUB_MCP_TOKEN)→ 所有方法降级,不发网络
7
7
  *
8
- * 配置来源:环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN;
9
- * 构造函数可传 { url, token, timeoutMs } 覆盖,缺省读 env。
8
+ * 配置来源(优先级):显式参数 > local.yaml mcp 段(via readMcpConfig)> 环境变量 SILLYHUB_MCP_URL/TOKEN(fallback);
9
+ * 构造函数可传 { cwd, url, token, timeoutMs },缺省经 readMcpConfig(cwd) 读 local.yaml mcp 段 + env fallback。
10
10
  *
11
11
  * 端点:POST ${url}/mcp/(尾斜杠必需,MCP streamable HTTP 协议要求),Bearer token 鉴权。
12
12
  * 请求体:JSON-RPC 2.0(tools/call 调 tool;tools/list 列 tool schema,task-11 路径A 探测用)。
@@ -19,6 +19,8 @@
19
19
  * - 仅用 Node 原生 fetch(engine>=18),不引入新依赖。
20
20
  */
21
21
 
22
+ import { readMcpConfig } from './config.js';
23
+
22
24
  const DEFAULT_TIMEOUT_MS = 10_000;
23
25
  // MCP streamable HTTP 协议版本(task 指定 2025-11-25)
24
26
  const MCP_PROTOCOL_VERSION = '2025-11-25';
@@ -26,13 +28,16 @@ const MCP_PROTOCOL_VERSION = '2025-11-25';
26
28
  export class SillyHubMcpClient {
27
29
  /**
28
30
  * @param {object} [opts]
29
- * @param {string} [opts.url] - 覆盖 SILLYHUB_MCP_URL;显式传空串视为未配置
30
- * @param {string} [opts.token] - 覆盖 SILLYHUB_MCP_TOKEN
31
+ * @param {string} [opts.cwd] - local.yaml 所在主仓库根,默认 process.cwd()(design §7.3)
32
+ * @param {string} [opts.url] - 显式覆盖(优先级最高 > readMcpConfig > env);显式传空串视为未配置
33
+ * @param {string} [opts.token] - 显式覆盖(优先级最高 > readMcpConfig > env)
31
34
  * @param {number} [opts.timeoutMs] - 请求超时毫秒,默认 10000
32
35
  */
33
- constructor({ url, token, timeoutMs } = {}) {
34
- const u = url !== undefined ? url : process.env.SILLYHUB_MCP_URL;
35
- const t = token !== undefined ? token : process.env.SILLYHUB_MCP_TOKEN;
36
+ constructor({ cwd, url, token, timeoutMs } = {}) {
37
+ const workCwd = cwd || process.cwd();
38
+ const cfg = readMcpConfig(workCwd);
39
+ const u = url !== undefined ? url : (cfg?.url ?? '');
40
+ const t = token !== undefined ? token : (cfg?.token ?? '');
36
41
  // 去掉 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
37
42
  this._url = typeof u === 'string' ? u.replace(/\/+$/, '') : '';
38
43
  this._token = typeof t === 'string' ? t : '';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * readMcpConfig — SillyHub MCP 凭据共享 helper(design §7.2 / §6 mcp 凭据数据流)。
3
+ *
4
+ * 统一读源,供三处消费点:① client.js 构造函数(_url/_token/_configured/_endpoint)
5
+ * ② dispatch/probe.js configFingerprint(缓存 key)+ no-config 快速路径 ③ execute.js
6
+ * getDispatchMode hasConfig(派发三态判定)。
7
+ *
8
+ * 优先级:local.yaml mcp 段(mcp.url + mcp.token 两键齐全)>
9
+ * process.env.SILLYHUB_MCP_URL/TOKEN fallback;
10
+ * mcp 段缺或任一键缺才回退 env。env 也缺 → 返回 null。
11
+ *
12
+ * 读法参考 src/dispatch/probe.js readProbeTtlFromLocalYaml 的 best-effort 风格:
13
+ * join(cwd,'.sillyspec','local.yaml') → existsSync 守卫 → jsYaml.load(readFileSync) → 取 doc.mcp。
14
+ *
15
+ * 铁律(design R-07):
16
+ * - best-effort 绝不抛:文件不存在 / jsYaml.load 抛 / doc 非对象 全 try/catch 回退 env
17
+ * - 纯 fs + env 读,绝不发网络(保 probe no-config 快速路径「不发网络」零回归)
18
+ * - 不引新依赖(js-yaml 复用 dispatch/probe.js:17 已引)
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import jsYaml from 'js-yaml';
24
+
25
+ /**
26
+ * 读 SillyHub MCP 凭据(local.yaml mcp 段 + env fallback,best-effort 不抛不发网络)。
27
+ * @param {string} cwd - 主仓库根(local.yaml 在 <cwd>/.sillyspec/local.yaml)
28
+ * @returns {{url: string, token: string} | null}
29
+ * 凭据齐全 → { url(尾斜杠归一), token };local.yaml mcp 段缺/缺键 + env 缺 → null
30
+ */
31
+ export function readMcpConfig(cwd) {
32
+ // 1. local.yaml mcp 段优先
33
+ try {
34
+ const p = join(cwd, '.sillyspec', 'local.yaml');
35
+ if (existsSync(p)) {
36
+ const doc = jsYaml.load(readFileSync(p, 'utf8'));
37
+ if (doc && typeof doc === 'object') {
38
+ const mcp = doc.mcp;
39
+ if (mcp && typeof mcp === 'object') {
40
+ const u = typeof mcp.url === 'string' ? mcp.url : '';
41
+ const t = typeof mcp.token === 'string' ? mcp.token : '';
42
+ // 两键齐全才视为有效源(design §7.2「缺键 → 回退 env」)
43
+ if (u && t) {
44
+ return { url: u.replace(/\/+$/, ''), token: t };
45
+ }
46
+ }
47
+ }
48
+ }
49
+ } catch {
50
+ // best-effort:文件读 / js-yaml 解析失败 → 继续回退 env
51
+ }
52
+
53
+ // 2. env fallback(兼容旧部署 / 现有测试不设 env 场景,design §9)
54
+ const eu = process.env.SILLYHUB_MCP_URL;
55
+ const et = process.env.SILLYHUB_MCP_TOKEN;
56
+ if (typeof eu === 'string' && eu && typeof et === 'string' && et) {
57
+ return { url: eu.replace(/\/+$/, ''), token: et };
58
+ }
59
+
60
+ // 3. local.yaml mcp 段缺/缺键 + env 缺 → null(调用方 _configured=false 降级不发网络)
61
+ return null;
62
+ }
@@ -13,11 +13,12 @@
13
13
  * (平台模式落 <runtimeRoot>/stage-reviews/...,与 execute-runs 同构)
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
16
+ import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync } from 'fs'
17
17
  import { join } from 'path'
18
18
  import { createHash } from 'crypto'
19
19
  import { VALID_VERDICTS, REVIEW_SCHEMA_VERSION } from './task-review.js'
20
20
  import { detectSpecDirTypo } from './spec-dir-typo.js'
21
+ import { resolveRuntimeRoot } from './run/shared.js'
21
22
 
22
23
  // 文档型 stage review 的合法 reviewType
23
24
  export const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
@@ -434,3 +435,106 @@ export function printStageReviewResult(result, context = {}) {
434
435
  }
435
436
  }
436
437
  }
438
+
439
+ /**
440
+ * 注册一个 stage 级 review(确定性 writer)—— 生成 run 目录 + review.json 骨架(或 adopt
441
+ * agent 草稿)+ 写 marker,治 tier=independent 时「调度者手动派独立子代理 → marker 不写 →
442
+ * gate 取错 run」的死锁。与 task 级 generateTaskReviewDrafts(task-review.js)对称。
443
+ *
444
+ * docHash 由 CLI 直接 computeDocHash 算(命令有文件访问权),消除 agent 手算 hash 易错
445
+ * (部分实现 P6.1b defer:仅本 scaffold 路径确定性,不改 agent 自写 review.json 的 hash 链路)。
446
+ *
447
+ * 纯新增,不改 validateStageReview / getLatestStageReviewRunId / renderReviewJsonContract 等
448
+ * 现有函数;self-check 只验 mechanics(schema + docHash),verdict 留给 Stage Review Gate 判。
449
+ *
450
+ * @param {object} opts
451
+ * @param {string} opts.changeName - 变更名
452
+ * @param {string} opts.stage - brainstorm|plan|execute
453
+ * @param {string} [opts.fromFile] - adopt 模式:agent 已写的 review.json(保留 verdict/checklist,重算 docHash + 规范化 reviewedFiles[0])
454
+ * @param {string} opts.cwd - 主仓 cwd(解析 specBase)
455
+ * @param {object} [opts.platformOpts] - 平台选项(specRoot 等)
456
+ * @returns {{ ok: boolean, reviewRunId: string, reviewPath: string, markerPath: string, mode: 'skeleton'|'adopted', mainDoc: string, review: object }}
457
+ * @throws {Error} changeName 空 / stage 非法 / 主文档缺失 / --from 不存在或 schema 不过
458
+ */
459
+ export function registerStageReview({ changeName, stage, fromFile, cwd, platformOpts = {} }) {
460
+ if (!changeName) throw new Error('register-stage-review: changeName 不能为空')
461
+ if (!stage || !STAGE_MAIN_DOC[stage]) {
462
+ throw new Error(`register-stage-review: stage 无效 "${stage}"(应为 brainstorm|plan|execute)`)
463
+ }
464
+
465
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
466
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
467
+ const changeDir = join(specBase, 'changes', changeName)
468
+ const reviewType = STAGE_REVIEW_TYPE[stage]
469
+ const mainDoc = STAGE_MAIN_DOC[stage]
470
+ const mainDocPath = join(changeDir, mainDoc)
471
+ if (!existsSync(mainDocPath)) {
472
+ throw new Error(`register-stage-review: 主审查文档不存在 ${mainDocPath}(${stage} 审 ${mainDoc}),无法算 docHash`)
473
+ }
474
+ const docHash = computeDocHash(mainDocPath)
475
+ if (!docHash) {
476
+ throw new Error(`register-stage-review: 计算 docHash 失败(computeDocHash 返回 null):${mainDocPath}`)
477
+ }
478
+ const reviewedFiles = [`changes/${changeName}/${mainDoc}`]
479
+
480
+ let review
481
+ let mode
482
+ if (fromFile) {
483
+ // adopt 模式:保留 agent 的 verdict/checklist/reviewerNotes/requiredEvidence,仅修 mechanics
484
+ const fromAbs = existsSync(fromFile) ? fromFile : join(cwd, fromFile)
485
+ if (!existsSync(fromAbs)) {
486
+ throw new Error(`register-stage-review: --from 文件不存在 ${fromFile}(相对 cwd ${fromAbs} 也未命中)`)
487
+ }
488
+ let parsed
489
+ try {
490
+ parsed = JSON.parse(readFileSync(fromAbs, 'utf8'))
491
+ } catch (e) {
492
+ throw new Error(`register-stage-review: --from 文件 JSON 解析失败 ${fromAbs}: ${e.message}`)
493
+ }
494
+ const schemaResult = validateStageReviewSchema(parsed)
495
+ if (!schemaResult.ok) {
496
+ throw new Error(`register-stage-review: --from 文件 schema 校验失败 — ${schemaResult.errors.join('; ')}`)
497
+ }
498
+ review = { ...parsed, docHash, reviewedFiles }
499
+ mode = 'adopted'
500
+ } else {
501
+ // 骨架模式:cannot_verify 待审占位(schema 反逃逸:cannot_verify 必须带非空 requiredEvidence)
502
+ review = {
503
+ schemaVersion: REVIEW_SCHEMA_VERSION,
504
+ reviewType,
505
+ specVerdict: 'cannot_verify',
506
+ qualityVerdict: 'cannot_verify',
507
+ reviewedFiles,
508
+ docHash,
509
+ requiredEvidence: [`待独立审查子代理对照 ${mainDoc} 逐节核验(骨架由 register-stage-review 生成)`],
510
+ reviewerNotes: '骨架由 register-stage-review 生成,verdict 待独立审查子代理填写',
511
+ }
512
+ mode = 'skeleton'
513
+ }
514
+
515
+ const reviewRunId = generateStageReviewRunId()
516
+ const reviewDir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
517
+ const reviewPath = join(reviewDir, 'review.json')
518
+ mkdirSync(reviewDir, { recursive: true })
519
+ writeFileSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
520
+
521
+ const markerPath = stageReviewMarkerPath(runtimeRoot, stage, changeName)
522
+ if (existsSync(markerPath)) {
523
+ console.warn(`⚠️ register-stage-review: marker 已存在 ${markerPath},将被覆盖为 ${reviewRunId}`)
524
+ }
525
+ mkdirSync(runtimeRoot, { recursive: true })
526
+ writeFileSync(markerPath, reviewRunId + '\n')
527
+
528
+ // self-check(fail-closed):刚写的 review 必过 schema + docHash 真实性。只验 mechanics,
529
+ // 不判 verdict(verdict 是 agent/子代理的审查结论,even fail 也如实落盘,由 Stage Review Gate 裁决)。
530
+ const schemaRecheck = validateStageReviewSchema(review)
531
+ if (!schemaRecheck.ok) {
532
+ throw new Error(`register-stage-review: 写入后 schema 自检失败(不应发生)— ${schemaRecheck.errors.join('; ')}`)
533
+ }
534
+ const hashRecheck = verifyStageReviewDocHash(review, [specBase, changeDir, cwd])
535
+ if (!hashRecheck.ok) {
536
+ throw new Error(`register-stage-review: 写入后 docHash 自检失败(不应发生)— ${hashRecheck.errors.join('; ')}`)
537
+ }
538
+
539
+ return { ok: true, reviewRunId, reviewPath, markerPath, mode, mainDoc, review }
540
+ }
@@ -350,7 +350,7 @@ timeout 5 which docker 2>/dev/null && echo "✅ Docker 可用" || echo "ℹ️ D
350
350
 
351
351
  **常见问题及修复:**
352
352
  - CLI 未安装 → \`npm install -g sillyspec\`
353
- - 缺少 local.yaml → \`sillyspec init\` 重新生成,或手动创建
353
+ - 缺少 local.yaml → \`sillyspec local detect\` 重新生成,或手动创建
354
354
  - local.yaml 缺少 test 命令 → 补充对应命令
355
355
  - 缺少 scan 文档(ARCHITECTURE.md 等)→ \`sillyspec run scan\` 重新扫描
356
356
  - sillyspec.db 状态不一致 → \`sillyspec run <阶段> --reset\` 重置对应阶段
@@ -4,6 +4,7 @@ import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjectio
4
4
  import { getRule } from '../stage-contract-spec.js'
5
5
  import { renderDispatchInstruction } from '../dispatch/strategy.js'
6
6
  import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
7
+ import { readMcpConfig } from '../sillyhub-mcp/config.js'
7
8
 
8
9
  /**
9
10
  * 校验 plan.md 是否满足 execute 执行契约
@@ -118,7 +119,7 @@ const fixedPrefix = [
118
119
  1. 读取 tasks.md(执行计划)
119
120
  2. 读取 design.md(技术方案)
120
121
  3. 读取 CONVENTIONS.md、ARCHITECTURE.md
121
- 4. 读取 local.yaml(构建命令)
122
+ 4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
122
123
  5. 加载项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)
123
124
 
124
125
  ### 模块文档加载
@@ -237,7 +238,7 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
237
238
  本步骤由当前 agent 执行,不需要启动独立子代理。
238
239
 
239
240
  ### 操作
240
- 1. 读取 local.yaml 获取构建和测试命令
241
+ 1. 读取 local.yaml 获取构建和测试命令;若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
241
242
  2. 运行测试套件(单元测试、集成测试)
242
243
  3. 运行 lint 检查 **+ 格式化**:凡变更涉及的源码,既跑 lint check 也跑 formatter(如 \`ruff format\` / \`prettier --write\` / \`black\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截
243
244
  4. 如果有测试失败 → 分析原因,标注是代码问题还是测试本身的问题
@@ -446,7 +447,7 @@ function parseWavesFromPlan(planContent) {
446
447
  * 同步判定派发后端模式(不发网络,零回归关键)— task-07
447
448
  *
448
449
  * stub 下 isPathASupported()=false → 有配置也只 local-fallback(加提示),不注入完整 SillyHub 指令。
449
- * 现有测试套件都不设 SILLYHUB_MCP_URL/TOKEN → 默认返回 'local' → buildWavePrompt 不注入派发段,
450
+ * 现有测试套件不设 SILLYHUB_MCP_URL/TOKEN 且无 local.yaml mcp 段 → readMcpConfig 返回 null → 默认 'local' → buildWavePrompt 不注入派发段,
450
451
  * 输出与改前字节一致(零回归,D-005)。
451
452
  *
452
453
  * @returns {'local' | 'local-fallback' | 'sillyhub'}
@@ -455,7 +456,7 @@ function parseWavesFromPlan(planContent) {
455
456
  * - sillyhub:有配置且路径A 落地(isPathASupported()=true),注入完整 SillyHub 派发指令
456
457
  */
457
458
  export function getDispatchMode() {
458
- const hasConfig = !!(process.env.SILLYHUB_MCP_URL && process.env.SILLYHUB_MCP_TOKEN)
459
+ const hasConfig = !!readMcpConfig(process.cwd())
459
460
  if (!hasConfig) return 'local'
460
461
  return isPathASupported() ? 'sillyhub' : 'local-fallback'
461
462
  }
@@ -599,7 +600,7 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
599
600
  dispatchSection = `\n### 派发后端:SillyHub MCP(探测可用,一 Wave 一 mission)\n\n本次 Wave 派发经 SillyHub MCP。按以下派发指令执行(含 create_mission / dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底):\n\n${instruction}\n`
600
601
  } else if (worktreePath && dispatchMode === 'local-fallback') {
601
602
  // 有 MCP 配置但路径A 未落地:加短提示,派发仍走 Local(与默认行为一致)
602
- dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 \`SILLYHUB_MCP_URL\`/\`SILLYHUB_MCP_TOKEN\`,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
603
+ dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 local.yaml mcp 段或 env 配置,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
603
604
  }
604
605
  // dispatchMode === 'local'(无配置)或 worktreePath 为空 → dispatchSection = '' → 输出与改前字节一致(零回归)
605
606
 
@@ -33,7 +33,7 @@ export const definition = {
33
33
 
34
34
  **你不要创建或修改任何 QUICKLOG 的条目骨架 / tasks.md 记录**——ql-ID 分配、「进行中」条目、状态翻转、task 勾选、轮转全由 CLI 接管,你无需手写。<quicklog-id> 可用于 design.md / plan.md / archive / 模块变更索引引用。
35
35
 
36
- > ⚠️ 例外:step 3 --done 之后,CLI 落盘的 QUICKLOG 只是简版骨架,**你必须手动精修正文成丰富格式**(标题语义化 + 文件多行带括注 + 四段充实),详见 step 3「QUICKLOG 正文精修」。这是 quick 交付质量的一部分,不可跳过。
36
+ > ℹ️ step 3 --done 时,CLI 已落盘结构化 QUICKLOG 条目(标题从「需求:」提取、正文四字段分行、文件行可用 --file-notes 写多行括注)。事后只需按需核对,详见 step 3「QUICKLOG 正文核对」。
37
37
 
38
38
  ### 输出
39
39
  任务理解 + 上下文摘要`,
@@ -82,12 +82,26 @@ export const definition = {
82
82
 
83
83
  缺任一项,\`--done\` 会被拒并提示缺失字段,补全后重跑即可(不丢进度)。
84
84
 
85
+ ### 📎 文件括注 --file-notes(可选,省事后手改文件行)
86
+ 若希望 QUICKLOG「文件:」行直接落盘为多行带括注(而非单行逗号路径),\`--done\` 时加 \`--file-notes\`,CLI 落盘为多行 \`- path(括注)\`,免去事后手改:
87
+
88
+ \`\`\`
89
+ sillyspec run quick --done --change <id> --output "需求:… 根因:… 方案:… 结果:…" --file-notes "src/a.js::登录端点串限流 || src/b.js::新建 INCR 计数"
90
+ \`\`\`
91
+
92
+ 格式:\`path::括注\` 一条,\`||\` 分隔多条(括注可省略只留 \`path\`)。不传则「文件:」行回填审计到的实际改动文件单行(仍可事后手改)。
93
+
94
+ ### 收尾推荐顺序(模块文档在 --done 前,QUICKLOG 在 --done 后,别记混)
95
+ 1. 【--done 前】命中模块 → 改模块文档 → \`git add -- <模块文档>\`(见下方「模块文档同步」)
96
+ 2. 【--done】\`sillyspec run quick --done --change <id> --output "四字段" [--file-notes "..."]\`(CLI 自动翻完成 + 勾 task + 落盘 QUICKLOG 标题/文件/正文)
97
+ 3. 【--done 后】核对 QUICKLOG 标题(CLI 已从「需求:」自动提取,仅弱标题才手改)→ 若改了再 \`git add\`
98
+
85
99
  ### 操作
86
100
  1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
87
101
  2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
88
102
  - 禁止使用 \`git add -A\`
89
103
  - 不要暂存 quick 开始前就已存在的无关改动
90
- 3. QUICKLOG / tasks.md 记录由 CLI 在本 step 完成时自动收尾(翻「已完成」+ 勾选 task + 轮转),你无需手动建/翻骨架——但**正文精修必做**(见下方「QUICKLOG 正文精修」)
104
+ 3. QUICKLOG / tasks.md 记录由 CLI 在本 step 完成时自动收尾(翻「已完成」+ 勾选 task + 轮转),你无需手动建/翻骨架——**QUICKLOG 核对按需**(见下方「QUICKLOG 正文核对」;文件行可用 --file-notes 免手改)
91
105
  4. 如果发现项目特有的坑,追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`
92
106
  5. 任务比预期复杂 → 建议用完整流程
93
107
 
@@ -105,17 +119,19 @@ export const definition = {
105
119
  ### 输出
106
120
  暂存确认 + 记录路径 + 模块文档同步结果(如有)
107
121
 
108
- ### 📝 QUICKLOG 正文精修(--done 之后必做,不可跳过)
109
- sillyspec run quick --done 成功后,CLI 已在 {SPEC_ROOT}/quicklog/QUICKLOG-<git-user>.md 落盘本次 <quicklog-id> 条目的简版骨架:状态翻「已完成」、「文件:」是单行逗号路径、正文是 --output 的四字段。骨架是机械产物不达标,你必须立即编辑该条目手动精修成丰富格式,三项必改:
122
+ ### 📝 QUICKLOG 正文核对(--done 之后,按需;多数无需手改)
123
+ CLI 落盘的条目已是结构化产物(非简版骨架):标题从「需求:」自动提取、正文四字段自动分行、文件行用 --file-notes 时为多行括注。--done 后只需核对,多数情况无需手改:
124
+
125
+ 1. 标题:CLI 已从「需求:」提取摘要刷新标题;仅当需求写得太弱(占位/笼统)才手改为真实需求摘要。
126
+ 2. 文件:用了 --file-notes 已是多行括注;没传则回填审计到的实际文件单行,可事后补括注(示例见下)。
127
+ 3. 正文:四字段已分行落盘,按需扩写充实(不允许只留一段「结果:」)。
110
128
 
111
- 1. 标题:把 ## <quicklog-id> | <时间> | <标题> 中的 <标题> 改为真实需求摘要(如「登录加 IP 限流(5 次/分)+ 失败 3 次滑块验证」)。禁止保留 CLI 兜底的 (quick 任务) 或一句话笼统描述。
112
- 2. 文件:把 CLI 写的单行「文件:a.js, b.js」改写为多行 bullet,每个文件带关键改动括注;禁止「(见实际改动)」或无括注纯路径单行。示例:
129
+ 补括注示例(仅当没用 --file-notes、想事后补时):
113
130
  文件:
114
131
  - backend/app/modules/auth/router.py(登录端点串 check_rate_limit→assert_captcha_if_needed)
115
132
  - backend/app/modules/auth/captcha_service.py(新建:限流 INCR + 失败计数 + Pillow 滑块生成/校验)
116
- 3. 正文:核对 需求: 根因: 方案: 结果: 四段逐项充实(--output 已写、CLI 原样插入,按需扩写),不允许只留一段「结果:」。
117
133
 
118
- 一条 quick = 一条独立 ql 条目;精修只动本次 <quicklog-id> 条目,不追加到旧条目。QUICKLOG 在 .sillyspec/(gitignore),精修不影响 --done 已通过的边界审计。`,
134
+ 一条 quick = 一条独立 ql 条目;核对只动本次 <quicklog-id> 条目,不追加到旧条目。QUICKLOG 在 .sillyspec/(gitignore),核对不影响 --done 已通过的边界审计。`,
119
135
  outputHint: '暂存和记录确认',
120
136
  optional: false
121
137
  }