sillyspec 3.28.7 → 3.28.9

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 (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
package/src/quicklog.js CHANGED
@@ -67,6 +67,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
67
67
  if (!restored) { try { unlinkSync(claim) } catch {} }
68
68
  }
69
69
  } catch {}
70
+ // 偷锁重试前同样受超时与退避约束(坑 quicklog-lock-steal-spin,2026-09-12 审查批 A-③):
71
+ // 旧版 continue 直接跳过下方检查——锁文件被 AV/索引器长期占用、renameSync 持续失败
72
+ // (每次被外层 catch 吞掉)时忙等自旋:不检查 timeoutMs、不让出 CPU,命令挂死 + 100% CPU。
73
+ if (Date.now() - start > timeoutMs) {
74
+ throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
75
+ }
76
+ await sleep(retryMs)
70
77
  continue
71
78
  }
72
79
  } catch {}
@@ -80,7 +87,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
80
87
  return await fn()
81
88
  } finally {
82
89
  try { closeSync(fd) } catch {}
83
- try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
90
+ // 释放前校验持有者身份(坑 quicklog-lock-release-mismatch,2026-09-12 审查批 A-③):
91
+ // 本进程临界区超 staleMs 被他进程偷锁后,lockPath 上可能是**他人新建**的锁——无条件
92
+ // unlink 误删它会让第三方抢锁成功,形成双进程并发临界区。仅内容仍为本会话 myId 才删
93
+ // (读失败=锁已不在/被偷,容忍——偷锁路径自会回收旧 claim)。
94
+ try {
95
+ if (readFileSync(lockPath, 'utf8') === myId) unlinkSync(lockPath)
96
+ } catch {}
84
97
  }
85
98
  }
86
99
 
@@ -466,11 +479,17 @@ async function writeAtomic(filePath, content, opts = {}) {
466
479
  }
467
480
  }
468
481
 
469
- // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
482
+ // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集 + 当天已用全 ID 集
483
+ // (坑 ql-id-double-occupancy 配套:全 ID 集供分配末检防整 ID 复用;容错正则见下)
470
484
  function scanExisting(quicklogDir, today) {
471
485
  let maxSeq = 0
472
486
  const usedSuffix = new Set()
487
+ const usedIds = new Set()
473
488
  const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
489
+ // 容错形态:手改/工具写入的畸形头(双空格、后缀紧贴 |)不匹配严格正则 → maxSeq 漏计 →
490
+ // 新分配复用同序号。第二正则只宽松空白,日期/序号/后缀结构仍须齐整;命中的序号并入
491
+ // maxSeq(防同序号双条目),全 ID 进 usedIds(防整 ID 复用)。引用字样(行首带前缀)不命中。
492
+ const reLoose = /^##[ \t]*ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})[ \t]*\|/gm
474
493
  // E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
475
494
  // 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
476
495
  // (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
@@ -480,16 +499,19 @@ function scanExisting(quicklogDir, today) {
480
499
  if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
481
500
  let content = ''
482
501
  try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
483
- let m
484
- re.lastIndex = 0
485
- while ((m = re.exec(content)) !== null) {
486
- if (m[1] === today) {
487
- maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
488
- usedSuffix.add(m[3].toLowerCase())
502
+ for (const reOne of [re, reLoose]) {
503
+ let m
504
+ reOne.lastIndex = 0
505
+ while ((m = reOne.exec(content)) !== null) {
506
+ if (m[1] === today) {
507
+ maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
508
+ usedSuffix.add(m[3].toLowerCase())
509
+ usedIds.add(`ql-${today}-${m[2]}-${m[3].toLowerCase()}`)
510
+ }
489
511
  }
490
512
  }
491
513
  }
492
- return { maxSeq, usedSuffix }
514
+ return { maxSeq, usedSuffix, usedIds }
493
515
  }
494
516
 
495
517
  // >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
@@ -701,11 +723,128 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
701
723
 
702
724
  // ── 对外 API ──
703
725
 
726
+ // 他者会话 guard 预留 ql-ID 的采集阈值:超龄会话的预留不再钉序号。与 run/shared.js
727
+ // collectOtherQuickSessionDeclarations 的 FOREIGN_SESSION_STALE_MS 同口径(7 天)——正常并发
728
+ // 会话间隔在分钟/小时级,7 天未收尾的会话按僵尸处理。本模块不 import run 层(分层:
729
+ // run/* → quicklog,反向会成环),阈值在此独立声明。
730
+ const GUARD_CLAIM_STALE_MS = 7 * 24 * 60 * 60 * 1000
731
+
732
+ /**
733
+ * 采集当前库内各 quick 会话 guard.json 已预留的 ql-ID(坑 ql-id-double-occupancy)。
734
+ *
735
+ * quick 启动在锁内追加 QUICKLOG 条目后把 ql-ID 写入 guard.json。条目随后丢失(并行 git
736
+ * 操作回滚未提交 QUICKLOG / worktree 分裂合并等)时,QUICKLOG 扫描看不到该预留 → 下一个
737
+ * 会话分配复用同一序号(suffix 再撞上即整 ID 双占用,2026-09-13 实证 007-1351、历史
738
+ * 2026-06-04 001-7a4c 同款)。分配期对 guard 预留让位(allocateQuicklogEntry)、--done 期
739
+ * 对残留占用校验(complete-handlers)都以此为准。
740
+ *
741
+ * fail-open:目录不存在/损坏 guard.json/超龄僵尸/任何异常 → 跳过或空集,采集失败回到
742
+ * 无预留现状(不放大让位面)。
743
+ *
744
+ * @param {string} specBase .sillyspec 根目录
745
+ * @param {string|null} [sessionsDirHint] quick-sessions 目录(run 层经 resolveQuickSessionsDir
746
+ * 解析后传入;缺省 <specBase>/.runtime/quick-sessions)
747
+ * @param {number} [nowMs] 当前时间戳(可注入,测试用)
748
+ * @returns {Map<string, string[]>} qlId → 预留该 ID 的 sessionId 列表
749
+ */
750
+ /**
751
+ * 遍历 quick-sessions 目录,按同一僵尸窗口口径读各会话 guard.json(collectGuardReservedQuicklogIds
752
+ * 与 collectActiveQuickGuardFiles 共用,活跃判定勿分叉):guard.json 损坏/缺失 → guard=null 保留
753
+ * 条目(消费方各自决定跳过或视为空声明);startedAt 可解析且超 GUARD_CLAIM_STALE_MS → 僵尸剔除。
754
+ *
755
+ * @param {string} dir quick-sessions 目录
756
+ * @param {number} [nowMs] 当前时间戳(可注入,测试用)
757
+ * @returns {Array<{sessionId: string, guard: object|null}>}
758
+ */
759
+ function listQuickSessionGuards(dir, nowMs = Date.now()) {
760
+ const out = []
761
+ let sessionDirs = []
762
+ try {
763
+ sessionDirs = readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
764
+ } catch { return out } // 目录不存在/不可读 → 无会话
765
+ for (const sessionName of sessionDirs) {
766
+ let guard = null
767
+ try { guard = JSON.parse(readFileSync(join(dir, sessionName, 'guard.json'), 'utf8')) } catch { guard = null } // 损坏/缺失 → null
768
+ if (guard && guard.startedAt) {
769
+ const startedAtMs = Date.parse(guard.startedAt)
770
+ if (Number.isFinite(startedAtMs) && nowMs - startedAtMs > GUARD_CLAIM_STALE_MS) continue // 僵尸不活跃
771
+ }
772
+ out.push({ sessionId: sessionName, guard })
773
+ }
774
+ return out
775
+ }
776
+
777
+ export function collectGuardReservedQuicklogIds(specBase, sessionsDirHint = null, nowMs = Date.now()) {
778
+ const out = new Map()
779
+ try {
780
+ const dir = sessionsDirHint || join(specBase, '.runtime', 'quick-sessions')
781
+ for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
782
+ if (!guard || typeof guard.quicklogId !== 'string' || !guard.quicklogId) continue
783
+ if (!out.has(guard.quicklogId)) out.set(guard.quicklogId, [])
784
+ out.get(guard.quicklogId).push(sessionId)
785
+ }
786
+ } catch { /* fail-open:采集失败等同无预留 */ }
787
+ return out
788
+ }
789
+
790
+ /**
791
+ * 采集各活跃 quick 会话 guard.json 声明的 allowedFiles(apply 前 guard 相交预检,FR-03 / D-002@v1)。
792
+ *
793
+ * 活跃口径与 collectGuardReservedQuicklogIds 同源(listQuickSessionGuards):guard 目录存在即活跃
794
+ * (quick --done 完成时清理 guard 目录,完成态天然退出),超 7 天僵尸窗口(GUARD_CLAIM_STALE_MS,
795
+ * 异常残留不钉死)剔除。勿用 changes.last_active 判活跃(非周期心跳,D-002)。
796
+ *
797
+ * @param {string} specBase .sillyspec 根目录
798
+ * @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number }} [opts]
799
+ * - excludeChange:排除自身 change 的 quick 会话——sessionId == changeName(quick 会话 id 即
800
+ * change 名)或 guard.linkedChanges 显式关联该 change 的协作会话(自己人,非拦截面)
801
+ * - sessionsDir:quick-sessions 目录(缺省 <specBase>/.runtime/quick-sessions)
802
+ * - nowMs:当前时间戳(可注入,测试用)
803
+ * @returns {Map<string, string[]>} sessionId → guard.allowedFiles(无 guard/无声明 → 空数组;fail-open)
804
+ */
805
+ export function collectActiveQuickGuardFiles(specBase, { excludeChange = null, sessionsDir = null, nowMs = Date.now() } = {}) {
806
+ const out = new Map()
807
+ try {
808
+ const dir = sessionsDir || join(specBase, '.runtime', 'quick-sessions')
809
+ for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
810
+ if (excludeChange && (sessionId === excludeChange
811
+ || (guard && Array.isArray(guard.linkedChanges) && guard.linkedChanges.includes(excludeChange)))) continue
812
+ const allowed = (guard && Array.isArray(guard.allowedFiles))
813
+ ? guard.allowedFiles.filter(f => typeof f === 'string' && f.length > 0)
814
+ : []
815
+ out.set(sessionId, allowed)
816
+ }
817
+ } catch { /* fail-open:采集失败等同无活跃 guard(不放大拦截面) */ }
818
+ return out
819
+ }
820
+
821
+ /**
822
+ * 数某 qlId 在所有 QUICKLOG-*.md 里的条目头出现次数(--done 占用校验用)。
823
+ * ≥2 = 分配竞态残留已损坏记录(同 ID 双条目);容错空白形态与 scanExisting 的 reLoose 同口径。
824
+ * @returns {{count: number, files: string[]}} count=命中头总数;files=「文件名×次数」列表
825
+ */
826
+ export function countQuicklogEntries(specBase, qlId) {
827
+ const out = { count: 0, files: [] }
828
+ if (!qlId || typeof qlId !== 'string') return out
829
+ const quicklogDir = join(specBase, 'quicklog')
830
+ if (!existsSync(quicklogDir)) return out
831
+ const re = new RegExp(`^##[ \\t]*${escapeRe(qlId)}[ \\t]*\\|`, 'gm')
832
+ for (const f of listQuicklogFiles(quicklogDir)) {
833
+ let content = ''
834
+ try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
835
+ const n = (content.match(re) || []).length
836
+ if (n > 0) { out.count += n; out.files.push(`${f}×${n}`) }
837
+ }
838
+ return out
839
+ }
840
+
704
841
  /**
705
842
  * 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
843
+ * 分配查重(坑 ql-id-double-occupancy):盘上当天全 ID(含容错空白形态)+ 他者活跃会话
844
+ * guard 预留(sessionsDir)的序号一并让位,maxSeq 取三者最大后再 +1。
706
845
  * @returns {Promise<{qlId: string}>}
707
846
  */
708
- export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
847
+ export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [], sessionsDir = null } = {}) {
709
848
  const quicklogDir = join(specBase, 'quicklog')
710
849
  mkdirSync(quicklogDir, { recursive: true })
711
850
  // git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
@@ -718,20 +857,40 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
718
857
  const files = Array.isArray(allowedFiles) ? allowedFiles : []
719
858
  const today = todayStamp()
720
859
 
721
- return await withFileLock(lockPath, async () => {
722
- const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
860
+ // 平台推送在锁外执行(2026-09-11 审查性能包①):锁内 5s 网络推送 + 逐变更 tasks 锁
861
+ // (最坏各 10s)可使临界区突破 30s stale 偷锁阈值——他进程偷锁后双写者并发,正是本模块
862
+ // 头声明要根治的并发丢更新被重新打开。推送 best-effort 不回写本地,锁外语义等同(原注释
863
+ // 自证),仅放弃「分配即推送」的顺序一致性(平台为 best-effort 面板,乱序窗口极小)。
864
+ let pushPayload = null
865
+ const { qlId } = await withFileLock(lockPath, async () => {
866
+ const { maxSeq: diskMaxSeq, usedSuffix, usedIds } = scanExisting(quicklogDir, today)
867
+ // 他者 guard 预留让位(坑 ql-id-double-occupancy):同日预留序号并入 maxSeq;整 ID 进
868
+ // 末检集。QUICKLOG 条目丢失但 guard 仍在的窗口内,盘上扫描看不见该预留——正是
869
+ // 2026-09-13 007-1351 双占用的形态。
870
+ const claims = collectGuardReservedQuicklogIds(specBase, sessionsDir)
871
+ let maxSeq = diskMaxSeq
872
+ const claimedIds = new Set()
873
+ for (const [claimedId] of claims) {
874
+ claimedIds.add(claimedId.toLowerCase())
875
+ const cm = claimedId.match(/^ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})$/)
876
+ if (cm && cm[1] === today) maxSeq = Math.max(maxSeq, parseInt(cm[2], 10))
877
+ }
723
878
  const nextSeq = maxSeq + 1
724
- // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
879
+ // XXXX 4 位 hex 随机后缀(消歧;NNN 已让位到所有已知占用之后,此处 belt-and-suspenders:
880
+ // usedSuffix 全日后缀避让 + 全 ID 末检,理论撞号需单日后缀空间耗尽,200 次兜底抛错)
725
881
  let suffix
882
+ let qlId
726
883
  let guard = 0
727
884
  do {
728
885
  suffix = randomBytes(2).toString('hex')
886
+ qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
729
887
  guard++
730
- } while (usedSuffix.has(suffix) && guard < 100)
888
+ } while ((usedSuffix.has(suffix) || usedIds.has(qlId) || claimedIds.has(qlId)) && guard < 200)
889
+ if (usedIds.has(qlId) || claimedIds.has(qlId)) {
890
+ throw new Error('allocateQuicklogEntry: ql-ID 查重 200 次仍未命中空闲号(当日条目异常膨胀,请检查 quicklog/)')
891
+ }
731
892
 
732
893
  await rotateIfNeeded(userFile, user)
733
-
734
- const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
735
894
  const entry = [
736
895
  '',
737
896
  `## ${qlId} | ${nowDatetime()} | ${desc}`,
@@ -744,9 +903,7 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
744
903
 
745
904
  for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
746
905
 
747
- // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
748
- // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
749
- await pushQuicklogEntryToPlatform(specBase, {
906
+ pushPayload = {
750
907
  ql_id: qlId,
751
908
  timestamp: nowDatetime(),
752
909
  title: desc,
@@ -758,10 +915,13 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
758
915
  files: files.map(f => ({ path: f, note: null })),
759
916
  body_sections: {},
760
917
  raw_block: entry,
761
- }).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
762
-
918
+ }
763
919
  return { qlId }
764
920
  })
921
+ if (pushPayload) {
922
+ await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {}) // 自身已吞错;双保险防未来改动破坏 best-effort 契约
923
+ }
924
+ return { qlId }
765
925
  }
766
926
 
767
927
  /**
@@ -785,9 +945,11 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
785
945
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
786
946
  const files = Array.isArray(allowedFiles) ? allowedFiles : []
787
947
 
788
- return await withFileLock(lockPath, async () => {
948
+ // 推送出锁(同 allocateQuicklogEntry 性能包①:临界区只留本地写 + tasks 挂载)
949
+ let pushPayload = null
950
+ const { existed } = await withFileLock(lockPath, async () => {
789
951
  // 双检(锁内):并发兜底同 ID 只落一条
790
- if (findQuicklogEntry(specBase, gitUser, qlId)) return { qlId, existed: true }
952
+ if (findQuicklogEntry(specBase, gitUser, qlId)) return { existed: true }
791
953
  await rotateIfNeeded(userFile, user)
792
954
  const entry = [
793
955
  '',
@@ -799,7 +961,7 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
799
961
  ].join('\n')
800
962
  appendFileSync(userFile, entry)
801
963
  for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
802
- await pushQuicklogEntryToPlatform(specBase, {
964
+ pushPayload = {
803
965
  ql_id: qlId,
804
966
  timestamp: nowDatetime(),
805
967
  title: desc,
@@ -810,9 +972,13 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
810
972
  files: files.map(f => ({ path: f, note: null })),
811
973
  body_sections: {},
812
974
  raw_block: entry,
813
- }).catch(() => {})
814
- return { qlId, existed: false }
975
+ }
976
+ return { existed: false }
815
977
  })
978
+ if (pushPayload && !existed) {
979
+ await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {})
980
+ }
981
+ return { qlId, existed }
816
982
  }
817
983
 
818
984
  /**
@@ -837,10 +1003,12 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
837
1003
  ? softFiles.filter(Boolean).map((f) => String(f).replace(/\\/g, '/'))
838
1004
  : []
839
1005
 
1006
+ // 推送出锁(性能包①):终态 payload 以锁内落盘结果组装,锁外 best-effort 推送 + sidecar 持久化
1007
+ // (sidecar 与推送同源同解析器,同移锁外不改变「以落盘终态为准」语义)
1008
+ let finalPayload = null
840
1009
  await withFileLock(lockPath, async () => {
841
1010
  // 条目可能在主文件或轮转归档中
842
1011
  let updatedContent = null
843
- let updatedFile = null
844
1012
  for (const f of listQuicklogFiles(quicklogDir)) {
845
1013
  const filePath = join(quicklogDir, f)
846
1014
  let content = ''
@@ -850,7 +1018,6 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
850
1018
  if (updated !== null) {
851
1019
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
852
1020
  updatedContent = updated
853
- updatedFile = filePath
854
1021
  break
855
1022
  }
856
1023
  }
@@ -860,16 +1027,16 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
860
1027
  // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
861
1028
  // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
862
1029
  const rawBlock = extractRawBlock(updatedContent, qlId)
863
- const finalPayload = buildPushPayloadFromRaw(rawBlock, {
1030
+ finalPayload = buildPushPayloadFromRaw(rawBlock, {
864
1031
  ql_id: qlId,
865
1032
  author_raw: user,
866
1033
  status: 'completed',
867
1034
  linked_changes: linked,
868
1035
  })
869
- // P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
870
- writeQuicklogSidecar(specBase, finalPayload)
871
- await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
872
1036
  })
1037
+ // P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
1038
+ writeQuicklogSidecar(specBase, finalPayload)
1039
+ await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
873
1040
  }
874
1041
 
875
1042
  /**
@@ -197,7 +197,8 @@ export function persistStageReview({ runtimeRoot, stage, changeName, reviewRunId
197
197
  const h = computeDocHash(mainDocPath)
198
198
  if (h) review.docHash = h
199
199
  }
200
- const schema = validateStageReviewSchema(review)
200
+ // ql-20260915-001 修复③:透传 stage——schema 错误文案给本 stage 期望 reviewType
201
+ const schema = validateStageReviewSchema(review, stage)
201
202
  if (!schema.ok) return { ok: false, errors: schema.errors }
202
203
  review.reviewedFiles = Array.isArray(review.reviewedFiles) && review.reviewedFiles.length > 0
203
204
  ? review.reviewedFiles