sillyspec 3.28.8 → 3.28.10

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 (69) hide show
  1. package/.claude/full.json +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  3. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  4. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  5. package/AGENTS.md +3 -2
  6. package/README.md +35 -11
  7. package/SKILL.md +24 -10
  8. 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
  9. package/package.json +1 -1
  10. package/src/change-list.js +73 -20
  11. package/src/change-risk-profile.js +20 -0
  12. package/src/config-schema.js +62 -0
  13. package/src/contract-matrix.js +84 -5
  14. package/src/cross-repo-reconcile.js +134 -0
  15. package/src/db.js +11 -3
  16. package/src/decision-distill.js +15 -3
  17. package/src/design-facts.js +89 -20
  18. package/src/docs-gate.js +105 -6
  19. package/src/doctor-diagnostics.js +224 -2
  20. package/src/friction-ledger.js +171 -0
  21. package/src/hooks/worktree-guard.js +13 -1
  22. package/src/index.js +206 -18
  23. package/src/knowledge-classify.js +382 -0
  24. package/src/knowledge-hits.js +74 -0
  25. package/src/knowledge-stats.js +180 -0
  26. package/src/probe7-anchor-check.js +69 -0
  27. package/src/progress/change-registry.js +183 -8
  28. package/src/progress/shared.js +3 -2
  29. package/src/progress.js +100 -8
  30. package/src/quick-gate-profile.js +251 -0
  31. package/src/quicklog.js +164 -18
  32. package/src/review-dispatch.js +2 -1
  33. package/src/run/command.js +131 -13
  34. package/src/run/complete-handlers.js +511 -21
  35. package/src/run/complete.js +123 -23
  36. package/src/run/concurrent-detect.js +98 -0
  37. package/src/run/gate-snapshot.js +110 -2
  38. package/src/run/gates.js +141 -16
  39. package/src/run/prompt.js +221 -11
  40. package/src/run/quick-audit.js +111 -13
  41. package/src/run/shared.js +147 -11
  42. package/src/run/stage.js +6 -0
  43. package/src/scan-diff.js +61 -30
  44. package/src/scan-postcheck.js +74 -0
  45. package/src/scan-refresh.js +477 -0
  46. package/src/scan-staleness.js +25 -13
  47. package/src/scope-audit.js +456 -56
  48. package/src/stage-contract.js +185 -2
  49. package/src/stage-review.js +23 -7
  50. package/src/stages/brainstorm.js +6 -0
  51. package/src/stages/cmd-existence.js +9 -3
  52. package/src/stages/execute.js +85 -3
  53. package/src/stages/knowledge.js +76 -6
  54. package/src/stages/plan-postcheck.js +32 -5
  55. package/src/stages/quick.js +1 -1
  56. package/src/stages/verify.js +1 -0
  57. package/src/sync-noise.js +170 -6
  58. package/src/sync.js +141 -9
  59. package/src/task-review.js +366 -46
  60. package/src/verify-postcheck.js +212 -55
  61. package/src/verify-probes.js +727 -16
  62. package/src/worktree-apply.js +685 -48
  63. package/src/worktree-deps.js +124 -3
  64. package/src/worktree.js +391 -15
  65. package/templates/agents-instruction.md +2 -2
  66. package/templates/prompts/testcase-design.md +2 -1
  67. package/templates/prompts/verify-probes.md +4 -1
  68. package/.patch16-tmp.mjs +0 -169
  69. package/sa-rename-probe/src/new.js +0 -4
@@ -785,12 +785,19 @@ export function parseLocalYamlModules(yamlText) {
785
785
  * modules 从 local.yaml 提取(monorepo 子包感知):无 modules 块时仅查根 package.json
786
786
  * (与 scan-postcheck 历史行为一致);有 modules 块时多候选子包 package.json 任一命中即通过。
787
787
  *
788
+ * 跨仓卡基准根切换(坑 verify-cmd-cross-repo-root,2026-09-15 wp 会话实证):卡片声明
789
+ * repo: <key> 时其 verify/implementation 里的路径相对**该仓**根——此前恒用主仓 projectRoot,
790
+ * 跨仓卡的 `cd src && npm run lint`(src 在跨仓里)解析到主仓 → 误报死命令。repoRegistry
791
+ * 命中该 key 时以跨仓根为该卡的 projectRoot(modules 是主仓 local.yaml 的相对子包路径,
792
+ * 跨仓卡不适用,置 null);repo 未注册时由检查 1 的注册校验报错,此处维持主仓根不重复报。
793
+ *
788
794
  * @param {string} changeDir - 变更目录
789
795
  * @param {string} projectRoot - 项目根目录(package.json 查找基准)
790
796
  * @param {object|null} modules - local.yaml modules 块({ name: { path } }),可选
797
+ * @param {Map<string,string>|null} [repoRegistry=null] - local.yaml repos: 段(跨仓卡基准根解析)
791
798
  * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
792
799
  */
793
- export function validateTaskCommands(changeDir, projectRoot, modules = null) {
800
+ export function validateTaskCommands(changeDir, projectRoot, modules = null, repoRegistry = null) {
794
801
  const errors = []
795
802
  const warnings = []
796
803
 
@@ -826,7 +833,16 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
826
833
  const text = `${verifyText}\n${implText}`
827
834
  if (!text.trim()) continue
828
835
 
829
- const { invalid } = validateScriptCommands(text, { projectRoot, modules })
836
+ // 跨仓卡基准根切换(坑 verify-cmd-cross-repo-root):repo: 命中注册表 → 该卡命令以跨仓
837
+ // 根解析;modules(主仓相对子包路径)对跨仓卡不适用置 null
838
+ const cardRepo = parseRepo(content)
839
+ let cardRoot = projectRoot
840
+ let cardModules = modules
841
+ if (cardRepo && cardRepo !== 'main' && repoRegistry && repoRegistry instanceof Map && repoRegistry.has(cardRepo)) {
842
+ cardRoot = repoRegistry.get(cardRepo)
843
+ cardModules = null
844
+ }
845
+ const { invalid } = validateScriptCommands(text, { projectRoot: cardRoot, modules: cardModules })
830
846
  for (const inv of invalid) {
831
847
  errors.push(`${taskId}: ${inv.cmd} 命令不存在(${inv.reason})`)
832
848
  }
@@ -880,7 +896,7 @@ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变
880
896
  * @param {string} designPath - design.md 绝对路径
881
897
  * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[], malformedHeaders: string[] }}
882
898
  */
883
- function parseDesignCoverageByRepo(designPath) {
899
+ export function parseDesignCoverageByRepo(designPath) {
884
900
  const byRepo = new Map()
885
901
  const allFiles = []
886
902
  const malformedHeaders = []
@@ -1619,10 +1635,21 @@ export async function executePlanPostcheck(context) {
1619
1635
  let taskModules = null
1620
1636
  let repoRegistry = null
1621
1637
  if (existsSync(localYamlPath)) {
1622
- const localYamlText = readFileSync(localYamlPath, 'utf8')
1638
+ const localYamlText = _readFileSync(localYamlPath, 'utf8')
1623
1639
  taskModules = parseLocalYamlModules(localYamlText)
1624
1640
  repoRegistry = parseRepoRegistry(localYamlText)
1625
1641
  }
1642
+ // 坑 register-repo-specbase-split-brain(读侧对齐):平台模式下 specDir=平台 spec 根,此处
1643
+ // 读 spec 根 local.yaml 的 repos: 而 execute MultiRepoContext 恒读 <cwd>/.sillyspec/local.yaml
1644
+ // ——plan gate 看得到注册、execute 启动却 fail-closed(2026-09-15 wp 会话实证)。项目侧
1645
+ // local.yaml 存在时以它为 repos: 权威(register-repo 写侧已对齐项目侧);不存在(纯平台
1646
+ // 项目从未建过项目侧文件)维持 spec 根回退,零回归。
1647
+ const projectLocalYamlPath = pJoin(cwd, '.sillyspec', 'local.yaml')
1648
+ if (existsSync(projectLocalYamlPath)) {
1649
+ try {
1650
+ repoRegistry = parseRepoRegistry(_readFileSync(projectLocalYamlPath, 'utf8'))
1651
+ } catch { /* 项目侧读失败维持 spec 根结果 */ }
1652
+ }
1626
1653
 
1627
1654
  // ── 1. 一致性校验 ──
1628
1655
  const consistency = validateBlueprintConsistency(changeDir, { repoRegistry })
@@ -1656,7 +1683,7 @@ export async function executePlanPostcheck(context) {
1656
1683
  // invalid → error 硬阻断,避免 execute 子代理跑死命令(design D-04 / 问题 3)。
1657
1684
  // modules 块可选:无块时仅查根 package.json(与 scan-postcheck 历史行为一致)。
1658
1685
  // local.yaml 已在检查 1 前预读(taskModules),此处直接消费。
1659
- const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
1686
+ const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules, repoRegistry)
1660
1687
  if (taskCmds.errors.length > 0) {
1661
1688
  failures.push({
1662
1689
  name: 'TaskCard 命令存在性校验(verify/implementation 的 npm/pnpm/yarn run <script> 不存在)',
@@ -25,7 +25,7 @@ step 3 --done 审计会把 src 核心文件(CLI / 状态机 / 注入框架等
25
25
  4. 构建命令(CLI 自 local.yaml 注入,勿再读文件):
26
26
  {LOCAL_COMMANDS}
27
27
  5. 若有关联变更,加载每个变更的设计文档:\`cat {SPEC_ROOT}/changes/<c>/design.md 2>/dev/null\`(理解设计意图)
28
- 6. 如有需要,查询知识库:\`cat {SPEC_ROOT}/knowledge/INDEX.md 2>/dev/null\`
28
+ 6. 知识库:CLI 已按本会话任务描述(--input)机械匹配知识库——命中时本 prompt 末尾注入「📚 命中知识」段(top-3 文件正文截断),直接阅读即可,勿自行重跑 INDEX 匹配;段未出现(无命中或未带 --input)且确有需要时才查询:\`cat {SPEC_ROOT}/knowledge/INDEX.md 2>/dev/null\`
29
29
 
30
30
  ### 模块上下文(CLI 按任务描述匹配注入)
31
31
  按本会话任务描述从 _module-map.yaml 匹配的模块已注入本 prompt 开头「📦 模块上下文」段(职责/核心文件/风险/最近变更)——**不要再 cat _module-map.yaml 挑卡**;开头无该段 = 无命中模块,需要跨模块全局视野时才读 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\` 自查。
@@ -160,6 +160,7 @@ export const definition = {
160
160
  2. 如果存在:
161
161
  - 逐个读取 tasks/task-NN.md,对照 frontmatter 的 \`acceptance:\` 列表逐条核验(TaskCard 协议的验收标准在 frontmatter YAML,正文无 checkbox)
162
162
  - 每条 acceptance 对照实际实现/测试结果判定满足与否,未满足的项列为不通过
163
+ - 覆盖对账走探针 7 矩阵:verify-result.md「探针 7:验收×测试覆盖矩阵」段已由 CLI 预填归属测试文件与关键词提示(骨架未生成时先跑 \`sillyspec verify-probes --change <change-name> --init\`,幂等不覆盖已有正文);逐行填判定(covered/partial/uncovered/non-testable 四选一)与证据——covered/partial 附测试锚点(\`.test.\` 文件或 file:line),non-testable 写一句理由;判定/证据未填会被 verify \`--done\` 门禁阻断(fail-closed)
163
164
  3. 如果不存在:跳过此步骤
164
165
 
165
166
  ### 输出
package/src/sync-noise.js CHANGED
@@ -15,10 +15,13 @@
15
15
  * - 任意一次同步成功清 marker,且此前在闸内时打一行恢复提示;
16
16
  * - SILLYSPEC_DEBUG_SYNC=1 全程不静默。
17
17
  *
18
- * 边界:只覆盖「连接类」失败(HTTP 404/5xx、网络错、超时/abort)。409 冲突与其余
19
- * 4xx 业务态(平台就绪但状态冲突/鉴权问题)不走闸——那些是每轮都该看见的语义信号,
20
- * 且已有各自的冲突降噪机制(sync-conflict-banner-spam 一族)。用户显式命令
21
- * (platform connect 的 health ping)不应被静默——调用方传 noMute 绕过本闸。
18
+ * 边界:连接类闸只覆盖「连接类」失败(HTTP 404/5xx、网络错、超时/abort)。409 与其余
19
+ * 4xx 业务态(平台就绪但状态冲突/鉴权问题)不走连接类闸——那些是每轮都该看见的语义信号,
20
+ * 且已有各自的冲突降噪机制(sync-conflict-banner-spam 一族)。例外:409 code='change_deleted'
21
+ * 是终态幂等回执(平台侧已删该变更 key,重推永远同回执、永不自愈),走下方按变更名键控的
22
+ * change_deleted 闸(2026-09-14 用户反馈②):首报可见 + 跨进程窗口静默 + 推送成功清窗。
23
+ * 用户显式命令(platform connect 的 health ping / platform sync 手动推送)不应被静默——
24
+ * 调用方传 noMute 绕过。
22
25
  */
23
26
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
24
27
  import { join } from 'path'
@@ -130,13 +133,170 @@ export function syncConnectionWarn(msg) {
130
133
  }
131
134
 
132
135
  /**
133
- * 判定 HTTP 状态码是否属连接类(可静默):404(端点未部署/未就绪)与 5xx(服务端故障)。
136
+ * 判定 HTTP 状态码是否属连接类(可静默):404(端点未就绪/未部署)与 5xx(服务端故障)。
134
137
  * 409 与其余 4xx 不在此列(见模块头注释边界)。
135
138
  */
136
139
  export function isConnectionClassStatus(status) {
137
140
  return status === 404 || (typeof status === 'number' && status >= 500)
138
141
  }
139
142
 
143
+ // ── 变更级 change_deleted 回执噪音闸(2026-09-14 用户反馈②:--done 的真实报错被
144
+ // default 墓碑 409 回执刷屏淹没)──
145
+ // 与连接类闸同构但按变更名键控。409 code='change_deleted' 是终态幂等回执:平台侧已删
146
+ // 该变更 key 后,本地同名变更每次自动 push(每步 triggerSync/triggerStepStartSync)都收到
147
+ // 同样的回执——多步流程下逐条重打属零新信息刷屏;且与 base_ts 409 不同,它永远不会自愈
148
+ // (平台侧删除需人工介入)。语义边界(区别于连接类闸的「每轮都该看见」):
149
+ // - 首报完整可见(含 ⚠️ 本地仍 active 的可行动信号);
150
+ // - 跨进程窗口内同变更的回执静默(窗口过期后照常重报——保活信号,不静默死);
151
+ // - 开窗进程内不同回执行照打(诊断完整性,与连接类闸同款);
152
+ // - 该变更推送成功(平台侧重建/恢复)→ noteChangeDeletedResolved 清窗;
153
+ // - 手动 platform sync(opts.manual)与 SILLYSPEC_DEBUG_SYNC=1 旁路。
154
+ const DELETED_MARKER = 'sync-noise-change-deleted.json'
155
+ // 本进程开窗的变更名集合(区分「窗口是本进程开的」与「先前进程留的」,同 _openedByThisProcess)
156
+ let _openedDeletedByThisProcess = new Set()
157
+ // 本进程已见(打印/静默)过的 `${changeName}\u0000${msg}` 集合:同进程内逐字重复只打一次
158
+ const _seenDeletedInProcess = new Set()
159
+
160
+ function _readDeletedWindows() {
161
+ const p = _runtimeDir ? join(_runtimeDir, DELETED_MARKER) : null
162
+ if (!p) return null
163
+ try {
164
+ const s = JSON.parse(readFileSync(p, 'utf8'))
165
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : null
166
+ } catch { return null }
167
+ }
168
+
169
+ function _writeDeletedWindows(map) {
170
+ const p = _runtimeDir ? join(_runtimeDir, DELETED_MARKER) : null
171
+ if (!p) return
172
+ try {
173
+ mkdirSync(_runtimeDir, { recursive: true })
174
+ const now = Date.now()
175
+ const pruned = {}
176
+ for (const [k, v] of Object.entries(map)) if (typeof v === 'number' && v > now) pruned[k] = v
177
+ if (Object.keys(pruned).length === 0) {
178
+ try { unlinkSync(p) } catch { /* 无文件/占用:留空 map 文件也等效 */ }
179
+ return
180
+ }
181
+ writeFileSync(p, JSON.stringify(pruned) + '\n', 'utf8')
182
+ } catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
183
+ }
184
+
185
+ function _deletedLineVisible(changeName, msg, noMute) {
186
+ if (!changeName || noMute || process.env.SILLYSPEC_DEBUG_SYNC) return true
187
+ const key = changeName + '\u0000' + msg
188
+ if (_seenDeletedInProcess.has(key)) return false
189
+ const now = Date.now()
190
+ const windows = _readDeletedWindows()
191
+ const until = windows ? windows[changeName] : undefined
192
+ if (typeof until === 'number' && now < until) {
193
+ _seenDeletedInProcess.add(key)
194
+ // 开窗进程内不同回执行可见(诊断完整性),先前进程留的窗口静默
195
+ return _openedDeletedByThisProcess.has(changeName)
196
+ }
197
+ _seenDeletedInProcess.add(key)
198
+ const next = windows || {}
199
+ next[changeName] = now + MUTE_WINDOW_MS
200
+ _writeDeletedWindows(next)
201
+ _openedDeletedByThisProcess.add(changeName)
202
+ return true
203
+ }
204
+
205
+ /**
206
+ * change_deleted 回执行(info 口吻:本地已注销的墓碑上行 409 属预期回执)。
207
+ * @returns {boolean} true=已输出
208
+ */
209
+ export function syncChangeDeletedLog(changeName, msg, { noMute = false } = {}) {
210
+ if (_deletedLineVisible(changeName, msg, noMute)) {
211
+ console.log(msg)
212
+ return true
213
+ }
214
+ return false
215
+ }
216
+
217
+ /**
218
+ * change_deleted 回执行(warn 口吻:本地仍 active,上行被拒收的可行动信号)。
219
+ * @returns {boolean} true=已输出
220
+ */
221
+ export function syncChangeDeletedWarn(changeName, msg, { noMute = false } = {}) {
222
+ if (_deletedLineVisible(changeName, msg, noMute)) {
223
+ console.warn(msg)
224
+ return true
225
+ }
226
+ return false
227
+ }
228
+
229
+ /**
230
+ * 该变更推送成功(平台侧未删/已重建):清其回执噪音窗与进程内状态。
231
+ * 与 noteSyncSuccess 的「任意成功清连接闸」不同——本闸按变更名精清(他变更的窗口不受影响)。
232
+ */
233
+ export function noteChangeDeletedResolved(changeName) {
234
+ if (!changeName) return
235
+ _openedDeletedByThisProcess.delete(changeName)
236
+ for (const k of _seenDeletedInProcess) {
237
+ if (k.startsWith(changeName + '\u0000')) _seenDeletedInProcess.delete(k)
238
+ }
239
+ const windows = _readDeletedWindows()
240
+ if (windows && windows[changeName] !== undefined) {
241
+ delete windows[changeName]
242
+ _writeDeletedWindows(windows)
243
+ }
244
+ }
245
+
246
+ // ── push 409 自愈回执噪音闸(2026-09-15 复盘反馈:自愈重试不阻断但每步刷屏)──
247
+ // 与 change_deleted 闸同构,按 `${changeName}\u0000${kind}` 键控(kind=自竞态/内容一致/自回声
248
+ // 三类自愈)。语义边界:自愈是成功收敛路径不是故障——base_ts 409 每步自动同步都可能重撞、
249
+ // ts 逐次不同但零新信息;首报完整可见(保留原文 ⚠️ 口吻),10 分钟窗口内同变更同类型重复
250
+ // 自愈静默,窗口过期重报保活。推送成功不自愈清窗(下次 409 首报照常可见——竞态是否复发
251
+ // 本身是信号);SILLYSPEC_DEBUG_SYNC=1 与手动 platform sync(noMute)旁路。
252
+ const SELF_HEAL_MARKER = 'sync-noise-self-heal.json'
253
+ let _openedSelfHealByThisProcess = new Set()
254
+ const _seenSelfHealInProcess = new Set()
255
+
256
+ function _readSelfHealWindows() {
257
+ const p = _runtimeDir ? join(_runtimeDir, SELF_HEAL_MARKER) : null
258
+ if (!p) return null
259
+ try {
260
+ const s = JSON.parse(readFileSync(p, 'utf8'))
261
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : null
262
+ } catch { return null }
263
+ }
264
+
265
+ function _selfHealLineVisible(changeName, kind, noMute) {
266
+ if (!changeName || noMute || process.env.SILLYSPEC_DEBUG_SYNC) return true
267
+ const key = changeName + '\u0000' + kind
268
+ if (_seenSelfHealInProcess.has(key)) return false
269
+ const now = Date.now()
270
+ const windows = _readSelfHealWindows()
271
+ const until = windows ? windows[key] : undefined
272
+ if (typeof until === 'number' && now < until) {
273
+ _seenSelfHealInProcess.add(key)
274
+ // 开窗进程内同类自愈可见(诊断完整性),先前进程留的窗口静默
275
+ return _openedSelfHealByThisProcess.has(key)
276
+ }
277
+ _seenSelfHealInProcess.add(key)
278
+ const next = windows || {}
279
+ next[key] = now + MUTE_WINDOW_MS
280
+ try {
281
+ mkdirSync(_runtimeDir, { recursive: true })
282
+ writeFileSync(join(_runtimeDir, SELF_HEAL_MARKER), JSON.stringify(next) + '\n', 'utf8')
283
+ } catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
284
+ _openedSelfHealByThisProcess.add(key)
285
+ return true
286
+ }
287
+
288
+ /**
289
+ * push 409 自愈回执(warn 口吻:首报保留原文 ⚠️,窗口内同变更同类型静默)。
290
+ * @returns {boolean} true=已输出
291
+ */
292
+ export function syncSelfHealWarn(changeName, kind, msg, { noMute = false } = {}) {
293
+ if (_selfHealLineVisible(changeName, kind, noMute)) {
294
+ console.warn(msg)
295
+ return true
296
+ }
297
+ return false
298
+ }
299
+
140
300
  /**
141
301
  * 任意一次同步成功:清 marker。此前处于闸内(marker 存在)时打一行恢复提示——
142
302
  * 平台恢复的第一个信号应该可见,之后的成功照常静默(成功不打扰)。
@@ -150,9 +310,13 @@ export function noteSyncSuccess() {
150
310
  console.log('[sync] 平台连接已恢复(此前静默的同步失败已停止)')
151
311
  }
152
312
 
153
- /** 测试复位:清进程内绑定与开窗状态。 */
313
+ /** 测试复位:清进程内绑定与开窗状态(连接类闸 + change_deleted 变更级闸 + 409 自愈闸)。 */
154
314
  export function _resetSyncNoiseForTest() {
155
315
  _runtimeDir = null
156
316
  _openedByThisProcess = false
157
317
  _seenInProcess.clear()
318
+ _openedDeletedByThisProcess = new Set()
319
+ _seenDeletedInProcess.clear()
320
+ _openedSelfHealByThisProcess = new Set()
321
+ _seenSelfHealInProcess.clear()
158
322
  }
package/src/sync.js CHANGED
@@ -18,7 +18,7 @@ import { safeGit } from './git-helper.js';
18
18
  import { openDatabase } from './db-engine.js';
19
19
  import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
20
20
  import { syncSpecTree } from './spec-sync.js';
21
- import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
21
+ import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved, syncSelfHealWarn } from './sync-noise.js';
22
22
 
23
23
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
24
24
  function safePlatformSpecDir(cwd) {
@@ -595,7 +595,7 @@ export class SyncManager {
595
595
  * 外来推送(他机/他用户)不可能推进本机 DB 的 base_ts → 走原冲突路径,无误放行。
596
596
  */
597
597
  async sync(changeName, opts = {}) {
598
- const { fromResolve = false } = opts;
598
+ const { fromResolve = false, manual = false } = opts;
599
599
  const platform = this._getPlatform();
600
600
  if (!platform) {
601
601
  debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
@@ -615,8 +615,30 @@ export class SyncManager {
615
615
  if (!fromResolve) {
616
616
  const pendingConflict = this.readConflictFile(changeName);
617
617
  if (pendingConflict) {
618
- console.warn(`⚠️ [sync] 变更 ${changeName} 存在未决平台冲突,跳过自动推送。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
619
- return { synced: 0, errors: [`conflict pending: ${changeName}`], conflict: true, suppressed: true };
618
+ // 过期自清(坑 sync-self-echo-false-conflict 附带,2026-09-14 实证 ctx-usage:base_ts 已被
619
+ // 后续 resolve/自愈推进到 ≥ 冲突文件记录的平台 ts——冲突条件早已消散,但文件残留仍压着
620
+ // 自动推送(「文件在即抑制」只认文件存在,永不重判)。此时清除文件继续推送是安全的:
621
+ // base ≥ 平台 ts 意味着本地已同步到冲突点之后,重推只会更新镜像不会丢数据。判不出
622
+ // (无平台 ts / DB 读失败)维持抑制,fail-closed。
623
+ const stalePlatTs = pendingConflict.platform_last_pushed_at || null;
624
+ let stale = false;
625
+ if (stalePlatTs) {
626
+ try {
627
+ const { ProgressManager } = await import('./progress.js');
628
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
629
+ const row = pm._ensureDB(this.cwd).getDb().prepare(
630
+ 'SELECT last_synced_platform_ts FROM changes WHERE name = ?'
631
+ ).get(changeName);
632
+ if (row && row.last_synced_platform_ts && row.last_synced_platform_ts >= stalePlatTs) stale = true;
633
+ } catch { /* 判定失败维持抑制(fail-closed) */ }
634
+ }
635
+ if (stale) {
636
+ this.clearConflictFile(changeName);
637
+ console.warn(`⚠️ [sync] 变更 ${changeName} 的未决冲突已过期(base_ts ≥ 平台 ts=${stalePlatTs}),自动清除并恢复推送`);
638
+ } else {
639
+ console.warn(`⚠️ [sync] 变更 ${changeName} 存在未决平台冲突,跳过自动推送。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
640
+ return { synced: 0, errors: [`conflict pending: ${changeName}`], conflict: true, suppressed: true };
641
+ }
620
642
  }
621
643
  }
622
644
 
@@ -634,7 +656,9 @@ export class SyncManager {
634
656
  // change-delete(2026-08-30):DB status='deleted' 的行目录已随删除移除,属正常时序,
635
657
  // 单行 info 提示后继续推删除终态/墓碑(不落入「目录不存在」warn)
636
658
  if (this._changeDbStatus(changeName) === 'deleted') {
637
- console.log(`[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`);
659
+ // 变更级回执噪音闸(sync-noise.js change_deleted 闸):status=deleted 是终态,此后每步
660
+ // 自动同步都会走到这里打同一行——首报后跨进程窗口内静默;手动 platform sync 旁路。
661
+ syncChangeDeletedLog(changeName, `[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`, { noMute: manual });
638
662
  } else {
639
663
  archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
640
664
  || this._changeDbStatus(changeName) === 'archived';
@@ -728,9 +752,10 @@ export class SyncManager {
728
752
  || progressData?.changes?.[0]?.status === 'archived'
729
753
  || progressData?.changes?.[0]?.status === 'deleted'
730
754
  if (localTombstone) {
731
- console.log(`ℹ️ [sync] 平台已删除变更「${changeName}」——本地注销已由 CLI 完成,墓碑上行 409 属预期回执,无需动作`);
755
+ syncChangeDeletedLog(changeName, `ℹ️ [sync] 平台已删除变更「${changeName}」——本地注销已由 CLI 完成,墓碑上行 409 属预期回执,无需动作`, { noMute: manual });
732
756
  } else {
733
- console.warn(`⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`);
757
+ // 可行动信号(本地仍 active + 平台已删):首报必须醒目,重复回执走变更级噪音闸
758
+ syncChangeDeletedWarn(changeName, `⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`, { noMute: manual });
734
759
  }
735
760
  return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
736
761
  }
@@ -739,6 +764,8 @@ export class SyncManager {
739
764
  // 平台连接恢复信号(sync-noise.js):此前静默窗口内的失败在恢复的第一笔成功时
740
765
  // 打一行提示并清 marker;之后照常「成功不打扰」。
741
766
  noteSyncSuccess();
767
+ // 该变更推送成功 = 平台侧未删/已重建,change_deleted 回执噪音窗按变更名精清
768
+ noteChangeDeletedResolved(changeName);
742
769
  // 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
743
770
  // 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
744
771
  // 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
@@ -809,12 +836,17 @@ export class SyncManager {
809
836
  // 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
810
837
  const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
811
838
  const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
839
+ // 身份归属(ql-20260914 第二批):服务端 409 body 回传平台行既有 last_pusher——
840
+ // 与本地 pushUser 同口径比对(platform.user || resolvePlatformUser,与发送侧一致)
841
+ const platformLastPusher = (res.body && res.body.last_pusher) || null;
812
842
 
813
843
  // 自竞态自愈:fresh 重读本机 DB base_ts,若已被并发进程回填到 ≥ 平台 409 回执 ts,
814
844
  // 说明赢者是本机自己人——刷新 base_ts 重试一次即收敛,不落冲突文件(外来推送不可能
815
845
  // 推进本机 DB 的 base_ts,不满足条件自然落回下方真冲突分支,零误放行)
816
846
  if (attempt < MAX_PUSH_ATTEMPTS && await this._localBaseTsCovers(changeName, platformLastPushedAt)) {
817
- console.warn(`⚠️ [sync] push 409 自竞态判定:本机并发推送已回填 base_ts(平台 ts=${platformLastPushedAt}),刷新后自动重试`);
847
+ // 409 自愈键控闸(2026-09-15 复盘:自愈不阻断但每步刷屏)——首报可见、窗口内同变更
848
+ // 同类型静默;手动 platform sync(manual)旁路
849
+ syncSelfHealWarn(changeName, 'self-race', `⚠️ [sync] push 409 自竞态判定:本机并发推送已回填 base_ts(平台 ts=${platformLastPushedAt}),刷新后自动重试`, { noMute: manual });
818
850
  continue;
819
851
  }
820
852
 
@@ -833,11 +865,49 @@ export class SyncManager {
833
865
  'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
834
866
  ).run(platformLastPushedAt || pushedAt, platformLastPushedAt || pushedAt, changeName);
835
867
  } catch {}
836
- console.warn(`⚠️ [sync] push 409 内容一致自愈: 平台 ts=${platformLastPushedAt} 为外来噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`);
868
+ // 409 自愈键控闸同上(kind=content-equal)
869
+ syncSelfHealWarn(changeName, 'content-equal', `⚠️ [sync] push 409 内容一致自愈: 平台 ts=${platformLastPushedAt} 为外来噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`, { noMute: manual });
837
870
  return { synced: 1, errors: [], selfHealed: true, reason: 'push 409 平台内容与本地一致(外来噪声重推),base_ts 已推进' };
838
871
  }
839
872
  } catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
840
873
 
874
+ // ── 自回声归属(坑 sync-self-echo-false-conflict,2026-09-14 multi-agent-platform
875
+ // 实证 6 个假冲突全为此形;第二批升级身份优先):平台快照自带 changes[0].
876
+ // last_local_modified_ts(推送方序列化时的本地脏度血统标记)。归属判定走
877
+ // _isSelfEchoAttribution——服务端回传 last_pusher 时身份优先(≠本人一律真冲突,
878
+ // ==本人仅保血统上界),身份缺失回退 [base_ts, local_modified] 双界窗口(窗口内
879
+ // = 本机自推过的状态:并发推送乱序/失败回填让 base 没跟上,自家回执被当他端
880
+ // 更新)。判为自回声 ⇒ 推进 base_ts 到平台 ts 后重试推送即收敛,不落冲突文件;
881
+ // 判为外来 ⇒ 维持原冲突路径:本地永不覆盖血统比自己新的平台状态。
882
+ if (!fromResolve && platformProgress && platformProgress.changes && platformProgress.changes[0]
883
+ && platformProgress.changes[0].last_local_modified_ts) {
884
+ const platLin = platformProgress.changes[0].last_local_modified_ts;
885
+ try {
886
+ const { ProgressManager } = await import('./progress.js');
887
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
888
+ const row = pm._ensureDB(this.cwd).getDb().prepare(
889
+ 'SELECT last_local_modified_ts, last_synced_platform_ts FROM changes WHERE name = ?'
890
+ ).get(changeName);
891
+ const localMod = row && row.last_local_modified_ts;
892
+ const baseNow = row && row.last_synced_platform_ts;
893
+ if (this._isSelfEchoAttribution({
894
+ platformPusher: platformLastPusher, myPusher: pushUser,
895
+ platLin, baseTs: baseNow, localModified: localMod,
896
+ })) {
897
+ const healTs = platformLastPushedAt || pushedAt;
898
+ pm._ensureDB(this.cwd).getDb().prepare(
899
+ 'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
900
+ ).run(healTs, healTs, changeName);
901
+ // 409 自愈键控闸同上(kind=self-echo)
902
+ syncSelfHealWarn(changeName, 'self-echo', `⚠️ [sync] push 409 自回声判定:平台 ts=${platformLastPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformLastPusher || '未回传·窗口判定'}),base_ts 已推进到 ${healTs},自动重推`, { noMute: manual });
903
+ if (attempt < MAX_PUSH_ATTEMPTS) continue;
904
+ // 重试额度耗尽(attempt=2 仍进此分支,理论少见):不落冲突文件——base 已推进,
905
+ // 下次常规同步按新 base 直接推送收敛
906
+ return { synced: 0, errors: [], conflict: false, selfHealed: true, reason: 'push 409 自回声判定:base_ts 已推进,重试额度耗尽,下次常规同步收敛' };
907
+ }
908
+ } catch { /* 归属判定失败维持原判(fail-closed 到真冲突分支) */ }
909
+ }
910
+
841
911
  // resolve --keep-local 的自动重推再撞 409:不落新冲突文件、不打「已卡死」横幅——
842
912
  // 原 conflict 已按用户裁决处理完(base_ts 已推进),此刻的 409 只是「平台在用户裁决期间
843
913
  // 又动了」;立即落新文件会破坏「keep-local 清冲突文件回 clean」的生命周期契约(冲突文件
@@ -1193,6 +1263,14 @@ export class SyncManager {
1193
1263
  'last_active', 'last_synced_platform_ts', 'last_local_modified_ts',
1194
1264
  'started_at', 'completed_at', 'pushed_at', 'last_pushed_at', 'created_at',
1195
1265
  'deps_checked_at', 'checked_at', 'waited_at', 'completedat',
1266
+ // last_pusher:服务端 GET 顶层回传的推送者元字段(ql-20260914 第二批)——与
1267
+ // last_pushed_at 同理是同步元数据非六表内容,不忽略会让内容一致自愈恒 false。
1268
+ 'last_pusher',
1269
+ // owner_session:所有权列(v6,2026-09-14-change-ownership-guards task-02)——与
1270
+ // last_pusher 同理是归属元数据非六表内容:版本切换窗口(平台旧 v5 payload × 本地 v6
1271
+ // 脏态)owner 差异会把内容一致判不等,部署噪声落人工 conflict(last_pusher 先例
1272
+ // ql-20260914-006 同款一行)。
1273
+ 'owner_session',
1196
1274
  ]);
1197
1275
  const strip = (v) => {
1198
1276
  if (Array.isArray(v)) return v.map(strip);
@@ -1215,6 +1293,31 @@ export class SyncManager {
1215
1293
  } catch { return false }
1216
1294
  }
1217
1295
 
1296
+ /**
1297
+ * 自回声归属判定(坑 sync-self-echo-false-conflict 身份加固,2026-09-14 第二批)。
1298
+ *
1299
+ * 身份优先——服务端回传 last_pusher(409 body / GET 顶层,服务端 ql-20260914-006-e395
1300
+ * 起;last_pushed_at 亦改存服务器权威钟)且本机 pushUser 可解析时:
1301
+ * - pusher ≠ 本人 ⇒ 一律非自回声(外来否决)——堵跨机时钟偏差把他机更新伪装进
1302
+ * [base, local_modified] 血统窗口的盲区(慢钟他机的血统可落窗口内,但身份对不上);
1303
+ * - pusher == 本人 ⇒ 只保血统上界 platLin ≤ local_modified(永不覆盖血统比自己新的
1304
+ * 平台状态——含本人另一台机器的新进度),下界放开(身份已证自家推送,老于 base
1305
+ * 的快照也只是自家旧态,重推无损)。
1306
+ * 任一侧身份缺失(老服务端不回传 / 本地 user 解析不出)⇒ 回退第一批双界窗口
1307
+ * [baseTs, localModified](纯时间血统,已是当时最优)。platLin 缺失一律 false
1308
+ * (fail-closed:无血统不判自回声)。
1309
+ * @param {{platformPusher?: string|null, myPusher?: string|null, platLin?: string|null, baseTs?: string|null, localModified?: string|null}} a
1310
+ * @returns {boolean} true=判为本机自推回声(可推进 base_ts 重推本地)
1311
+ */
1312
+ _isSelfEchoAttribution({ platformPusher, myPusher, platLin, baseTs, localModified } = {}) {
1313
+ if (!platLin || !localModified) return false;
1314
+ if (platformPusher != null && myPusher != null) {
1315
+ if (platformPusher !== myPusher) return false; // 身份否决:外来更新
1316
+ return platLin <= localModified; // 本人推送:仅保上界
1317
+ }
1318
+ return !!(baseTs && platLin >= baseTs && platLin <= localModified); // 窗口回退
1319
+ }
1320
+
1218
1321
  _writeConflictFile(changeName, info = {}) {
1219
1322
  const specDir = safePlatformSpecDir(this.cwd);
1220
1323
  if (!specDir) return null;
@@ -1360,6 +1463,10 @@ export class SyncManager {
1360
1463
  // 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
1361
1464
  const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
1362
1465
  const platformPushedAt = (result && result.last_pushed_at) || null;
1466
+ // 身份归属(ql-20260914 第二批):服务端 GET 顶层回传 last_pusher(与发送侧同口径
1467
+ // 的本机用户比对——platform.user || resolvePlatformUser)
1468
+ const platformPusher = (result && result.last_pusher) || null;
1469
+ const myPusher = (platform && platform.user) || resolvePlatformUser(this.cwd) || null;
1363
1470
 
1364
1471
  // 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
1365
1472
  if (!force) {
@@ -1423,6 +1530,31 @@ export class SyncManager {
1423
1530
  }
1424
1531
  } catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
1425
1532
  }
1533
+ // ── 自回声归属(坑 sync-self-echo-false-conflict,与 push 409 路径同族,2026-09-14;
1534
+ // 第二批升级身份优先):平台「更新」的血统标记(changes[0].last_local_modified_ts)
1535
+ // 经 _isSelfEchoAttribution 判定——服务端回传 last_pusher 时身份优先(≠本人一律
1536
+ // 真冲突;==本人仅保血统上界 ≤ local_modified),身份缺失回退 [base, local_modified]
1537
+ // 双界窗口(本机自推回声:并发推送回填竞态窗口内读到自家回执)。判为自回声:
1538
+ // 不 import、不落冲突文件,推进 base_ts 到平台 ts——本地内容 ≥ 回声状态,下次
1539
+ // triggerSync 按新 base 正常推送,无损收敛。判为外来维持原冲突判定。
1540
+ if (localDirty && platformNewer && platformProgress && platformProgress.changes
1541
+ && platformProgress.changes[0] && platformProgress.changes[0].last_local_modified_ts) {
1542
+ const platLin = platformProgress.changes[0].last_local_modified_ts;
1543
+ if (this._isSelfEchoAttribution({
1544
+ platformPusher: platformPusher, myPusher: myPusher,
1545
+ platLin, baseTs: localLastSynced, localModified: localLastModified,
1546
+ })) {
1547
+ try {
1548
+ const { ProgressManager } = await import('./progress.js');
1549
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
1550
+ pm._ensureDB(this.cwd).getDb().prepare(
1551
+ 'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
1552
+ ).run(platformPushedAt, platformPushedAt, changeName);
1553
+ } catch { /* 推进失败不影响判定结论:不 import 不落冲突,下次同步重判 */ }
1554
+ debugLog(`[sync] pull 自回声判定: 平台 ts ${platformPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformPusher || '未回传·窗口判定'}),base_ts 已推进`);
1555
+ return { ok: true, imported: false, conflict: false, reason: '平台更新为本机自推回声(归属判定:身份/血统),base_ts 已推进' };
1556
+ }
1557
+ }
1426
1558
  if (localDirty && platformNewer) {
1427
1559
  console.warn('');
1428
1560
  console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');