sillyspec 3.28.3 → 3.28.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * sync-noise.js — 平台连接类失败的跨进程噪音闸(2026-09-08 用户反馈③)。
3
+ *
4
+ * 场景:平台 sillyhub 未就绪(端点 404 / 断连 / 超时)期间,每条 CLI 命令的自动同步
5
+ * (triggerSync → sync() / syncSpecTree)都会打同一批 `[sync] … → 404` / `[spec-sync] …`
6
+ * 失败 warn,淹没真正的门禁输出。CLI 是短命进程,进程内去重无效——必须跨进程落 marker
7
+ * (对齐 run/shared.js 自动 pull 的 AUTO_PULL_THROTTLE_MS marker 先例)。
8
+ *
9
+ * 语义:
10
+ * - 首个失败进程(开窗者)完整展示本轮所有失败行:首行打 warn 并写 mutedUntil 窗口,
11
+ * 同轮**不同**失败行照打(诊断完整性),逐字重复只打一次;
12
+ * - 窗口内**后续进程**的同类失败静默(SILLYSPEC_DEBUG_SYNC=1 时降级为可见输出,排障不瞎)
13
+ * ——每步 CLI 命令重刷的正是这种;
14
+ * - 窗口过期后下一个失败进程再完整报一轮并重开窗——保活信号约每窗口一轮,不静默死;
15
+ * - 任意一次同步成功清 marker,且此前在闸内时打一行恢复提示;
16
+ * - SILLYSPEC_DEBUG_SYNC=1 全程不静默。
17
+ *
18
+ * 边界:只覆盖「连接类」失败(HTTP 404/5xx、网络错、超时/abort)。409 冲突与其余
19
+ * 4xx 业务态(平台就绪但状态冲突/鉴权问题)不走闸——那些是每轮都该看见的语义信号,
20
+ * 且已有各自的冲突降噪机制(sync-conflict-banner-spam 一族)。用户显式命令
21
+ * (platform connect 的 health ping)不应被静默——调用方传 noMute 绕过本闸。
22
+ */
23
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
24
+ import { join } from 'path'
25
+
26
+ const NOISE_MARKER = 'sync-noise-mute.json'
27
+ // 静默窗口:10 分钟 ≈ 一个平台重启/发布窗口;期间每步命令的重复失败全部静默。
28
+ const MUTE_WINDOW_MS = 10 * 60_000
29
+
30
+ // 进程内绑定的 marker 目录(.runtime 绝对路径)。CLI 单用途短进程,last-wins 足够;
31
+ // 未绑定(库形态调用)时闸门退化为直通——宁可多一行 warn 不可丢信号。
32
+ let _runtimeDir = null
33
+ // 本进程是否已开窗(打印过失败并写了 marker)。区分「窗口是本进程开的」与「窗口是先前
34
+ // 进程留下的」:首报命令(开窗者)内后续**不同**失败行仍应完整可见(诊断信息,用户正
35
+ // 在看第一条失败);只有先前进程留下的窗口才静默本进程——这正是跨步去重的目标形态
36
+ // (platform-sync-failure-visibility 契约:同轮清单 500 + POST 500 都要打)。
37
+ let _openedByThisProcess = false
38
+ // 本进程已打印/已静默过的消息集合:同进程内逐字重复的失败行只打一次。
39
+ const _seenInProcess = new Set()
40
+
41
+ /**
42
+ * 绑定噪音闸 marker 的落盘目录(specRoot 下的 .runtime)。幂等,last-wins。
43
+ * @param {string|null} runtimeDir
44
+ */
45
+ export function bindSyncNoiseRoot(runtimeDir) {
46
+ _runtimeDir = runtimeDir || null
47
+ }
48
+
49
+ /**
50
+ * 从 cwd 静默推导 marker 目录(SyncManager 构造器用):有平台指针 → 指针 specRoot/.runtime;
51
+ * 否则 cwd/.sillyspec/.runtime。**零副作用**——只读指针 JSON,不做存在性/自指/temp 守卫,
52
+ * 也不 resolveSpecDir 上溯:resolvePlatformSpecDir 会打「指针不可用/temp 残留」warn
53
+ * (其职责),卫生 marker 不该引入新的输出副作用(cli-top-level-aliases 字节一致契约)。
54
+ * 坏指针/缺字段落回本地目录——marker 落点只需稳定,不需权威。
55
+ * @param {string} cwd
56
+ */
57
+ export function bindSyncNoiseFromCwd(cwd) {
58
+ try {
59
+ const p = join(cwd, '.sillyspec-platform.json')
60
+ if (existsSync(p)) {
61
+ const s = JSON.parse(readFileSync(p, 'utf8'))
62
+ if (s && typeof s.specRoot === 'string' && s.specRoot) {
63
+ _runtimeDir = join(s.specRoot, '.runtime')
64
+ return
65
+ }
66
+ }
67
+ } catch { /* 坏指针:回落本地目录 */ }
68
+ _runtimeDir = join(cwd, '.sillyspec', '.runtime')
69
+ }
70
+
71
+ function _markerPath() {
72
+ return _runtimeDir ? join(_runtimeDir, NOISE_MARKER) : null
73
+ }
74
+
75
+ function _readMute() {
76
+ const p = _markerPath()
77
+ if (!p) return null
78
+ try {
79
+ const s = JSON.parse(readFileSync(p, 'utf8'))
80
+ return typeof s.mutedUntil === 'number' ? s : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ function _writeMute(mutedUntil, lastMsg) {
87
+ const p = _markerPath()
88
+ if (!p) return
89
+ try {
90
+ mkdirSync(_runtimeDir, { recursive: true })
91
+ writeFileSync(p, JSON.stringify({ mutedUntil, lastMsg }) + '\n', 'utf8')
92
+ } catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
93
+ }
94
+
95
+ /**
96
+ * 连接类失败 warn 的闸门口。判序(platform-sync-failure-visibility 契约):
97
+ * 1. debug env → 恒打;
98
+ * 2. 本进程内逐字重复 → 静默(重复行无新信息);
99
+ * 3. 窗口是**本进程**开的(本进程是首报命令)→ 不同失败行照打——首报命令应完整展示
100
+ * 自己轮次内的所有失败细节;
101
+ * 4. 窗口是**先前进程**留的 → 静默(每步命令重刷的正是这种),标记已见;
102
+ * 5. 无窗口(首次/已过期/刚成功清闸)→ 打 + 开窗。
103
+ * @param {string} msg 已拼好的 warn 文案
104
+ * @returns {boolean} true=已输出到 stderr
105
+ */
106
+ export function syncConnectionWarn(msg) {
107
+ if (process.env.SILLYSPEC_DEBUG_SYNC) {
108
+ console.warn(msg)
109
+ _seenInProcess.add(msg)
110
+ return true
111
+ }
112
+ if (_seenInProcess.has(msg)) return false
113
+ const now = Date.now()
114
+ const m = _readMute()
115
+ if (m && now < m.mutedUntil) {
116
+ if (_openedByThisProcess) {
117
+ // 首报命令的后续不同失败行:可见(诊断完整性),不重复开窗
118
+ console.warn(msg)
119
+ _seenInProcess.add(msg)
120
+ return true
121
+ }
122
+ _seenInProcess.add(msg)
123
+ return false
124
+ }
125
+ console.warn(msg)
126
+ _seenInProcess.add(msg)
127
+ _writeMute(now + MUTE_WINDOW_MS, msg)
128
+ _openedByThisProcess = true
129
+ return true
130
+ }
131
+
132
+ /**
133
+ * 判定 HTTP 状态码是否属连接类(可静默):404(端点未部署/未就绪)与 5xx(服务端故障)。
134
+ * 409 与其余 4xx 不在此列(见模块头注释边界)。
135
+ */
136
+ export function isConnectionClassStatus(status) {
137
+ return status === 404 || (typeof status === 'number' && status >= 500)
138
+ }
139
+
140
+ /**
141
+ * 任意一次同步成功:清 marker。此前处于闸内(marker 存在)时打一行恢复提示——
142
+ * 平台恢复的第一个信号应该可见,之后的成功照常静默(成功不打扰)。
143
+ */
144
+ export function noteSyncSuccess() {
145
+ _openedByThisProcess = false
146
+ _seenInProcess.clear()
147
+ if (!_readMute()) return
148
+ const p = _markerPath()
149
+ if (p) { try { unlinkSync(p) } catch { /* 残留 marker 只多静默一轮失败,无害 */ } }
150
+ console.log('[sync] 平台连接已恢复(此前静默的同步失败已停止)')
151
+ }
152
+
153
+ /** 测试复位:清进程内绑定与开窗状态。 */
154
+ export function _resetSyncNoiseForTest() {
155
+ _runtimeDir = null
156
+ _openedByThisProcess = false
157
+ _seenInProcess.clear()
158
+ }
package/src/sync.js CHANGED
@@ -17,6 +17,7 @@ import { safeGit } from './git-helper.js';
17
17
  import { openDatabase } from './db-engine.js';
18
18
  import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
19
19
  import { syncSpecTree } from './spec-sync.js';
20
+ import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
20
21
 
21
22
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
22
23
  function safePlatformSpecDir(cwd) {
@@ -218,11 +219,18 @@ function combineSignals(external, timeoutMs = REQUEST_TIMEOUT_MS) {
218
219
 
219
220
  async function fetchJson(url, options = {}) {
220
221
  const signal = combineSignals(options.signal);
222
+ // noMute 是本模块的闸门开关不是 fetch 参数,先剥掉再透传
223
+ const { noMute, ...init } = options;
221
224
  try {
222
- const res = await fetch(url, { ...options, signal });
225
+ const res = await fetch(url, { ...init, signal });
223
226
  if (!res.ok) {
224
227
  const text = await res.text().catch(() => '');
225
- console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
228
+ const line = `[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`;
229
+ // 连接类失败(404=端点未就绪 / 5xx)走跨进程噪音闸:平台未就绪期每条命令的自动
230
+ // 同步都撞同一批失败,重复刷屏淹没门禁输出(见 sync-noise.js 模块头)。409 冲突与
231
+ // 其余 4xx 业务态每轮都该看见,不走闸。用户显式命令(connect health ping)传 noMute。
232
+ if (!noMute && isConnectionClassStatus(res.status)) syncConnectionWarn(line);
233
+ else console.warn(line);
226
234
  return null;
227
235
  }
228
236
  const ct = res.headers.get('content-type') || '';
@@ -231,11 +239,11 @@ async function fetchJson(url, options = {}) {
231
239
  }
232
240
  return null;
233
241
  } catch (err) {
234
- if (err.name === 'AbortError') {
235
- console.warn(`[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`);
236
- } else {
237
- console.warn(`[sync] ${url} 请求失败: ${err.message}`);
238
- }
242
+ const line = err.name === 'AbortError' || err.name === 'TimeoutError'
243
+ ? `[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`
244
+ : `[sync] ${url} 请求失败: ${err.message}`;
245
+ if (!noMute) syncConnectionWarn(line);
246
+ else console.warn(line);
239
247
  return null;
240
248
  }
241
249
  }
@@ -247,23 +255,26 @@ async function fetchJson(url, options = {}) {
247
255
  */
248
256
  async function fetchJsonWithStatus(url, options = {}) {
249
257
  const signal = combineSignals(options.signal);
258
+ const { noMute, ...init } = options;
250
259
  try {
251
- const res = await fetch(url, { ...options, signal });
260
+ const res = await fetch(url, { ...init, signal });
252
261
  const text = await res.text().catch(() => '');
253
262
  let body = null;
254
263
  if (text) {
255
264
  try { body = JSON.parse(text); } catch { body = null; }
256
265
  }
257
266
  if (!res.ok) {
258
- console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
267
+ const line = `[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`;
268
+ if (!noMute && isConnectionClassStatus(res.status)) syncConnectionWarn(line);
269
+ else console.warn(line);
259
270
  }
260
271
  return { ok: res.ok, status: res.status, body };
261
272
  } catch (err) {
262
- if (err.name === 'AbortError') {
263
- console.warn(`[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`);
264
- } else {
265
- console.warn(`[sync] ${url} 请求失败: ${err.message}`);
266
- }
273
+ const line = err.name === 'AbortError' || err.name === 'TimeoutError'
274
+ ? `[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`
275
+ : `[sync] ${url} 请求失败: ${err.message}`;
276
+ if (!noMute) syncConnectionWarn(line);
277
+ else console.warn(line);
267
278
  return { ok: false, status: 0, body: null };
268
279
  }
269
280
  }
@@ -399,6 +410,12 @@ export class SyncManager {
399
410
  // 归档尾声静默化旗标(坑 post-archive-sync-noise):sync() 探测到已归档时置位,
400
411
  // 链内 syncDocuments 的「变更不存在」降 debug(每次 sync 调用重置)
401
412
  this._suppressDocsMissingWarn = false;
413
+ // 绑定连接类失败噪音闸的 marker 目录(sync-noise.js):本实例所有 fetch 失败 warn 的
414
+ // 跨进程静默窗口落这里。bindSyncNoiseFromCwd 零副作用(只读指针 JSON,不触发
415
+ // resolvePlatformSpecDir 的指针守卫 warn——doctor 两路字节一致契约)。
416
+ try {
417
+ bindSyncNoiseFromCwd(cwd);
418
+ } catch { /* 未绑定 = 闸门直通(多一行 warn,无正确性影响) */ }
402
419
  }
403
420
 
404
421
  /**
@@ -423,7 +440,8 @@ export class SyncManager {
423
440
  console.warn(`[sync] platform.url 非 https(${normalizedUrl.slice(0, 60)}),同步 token 将明文传输——请确认为受控内网环境`);
424
441
  }
425
442
  const healthUrl = `${normalizedUrl}/api/health`;
426
- const result = await fetchJson(healthUrl);
443
+ // noMute:connect 是用户显式命令,health ping 的失败细节不走静默闸(用户正在诊断连接)
444
+ const result = await fetchJson(healthUrl, { noMute: true });
427
445
  if (result === null) {
428
446
  console.warn(`[sync] 平台连接验证失败: ${url}`);
429
447
  return;
@@ -453,6 +471,28 @@ export class SyncManager {
453
471
  );
454
472
  }
455
473
 
474
+ // mcp 段成对签发(2026-09-10 平台侧 P0-2 修复的消费侧落地,交付遗留②):mcp-tokens
475
+ // 签发 API 的 201 响应带 gateway_url,与 token 成对——「url 指哪、token 就在哪生效」,
476
+ // 消 spike 实证的凭据三头分裂(mcp.url 指远端而 token 属本地部署 / 旧 token 缺 read
477
+ // scope 致 get_daemon_status 等全挂)。scope 定 read+dispatch(P2 审查通道:get_daemon_
478
+ // status/get_worker_result 需 read,dispatch_worker 需 dispatch;converge 按 D-004 不申请)。
479
+ // 失败(旧 backend 无端点 / 403 / 断网)→ 降级旧行为(下方 §7.4 分支),不阻断 connect。
480
+ let mcpIssued = null;
481
+ if (resolved && resolved.workspace_id) {
482
+ const issueUrl = `${normalizedUrl}/api/workspaces/${resolved.workspace_id}/mcp-tokens`;
483
+ const issued = await fetchJson(issueUrl, {
484
+ method: 'POST',
485
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ name: 'sillyspec-cli', scope: ['read', 'dispatch'] }),
487
+ });
488
+ if (issued && typeof issued.token === 'string' && issued.token.startsWith('shmcp_')) {
489
+ mcpIssued = issued;
490
+ console.log(`[sync] MCP token 成对签发成功 (gateway: ${issued.gateway_url || normalizedUrl})`);
491
+ } else {
492
+ console.warn('[sync] MCP token 签发失败(旧 backend 无端点/权限不足),mcp 段按旧口径处理');
493
+ }
494
+ }
495
+
456
496
  // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
457
497
  const resolvedUser = resolvePlatformUser(this.cwd, user);
458
498
 
@@ -471,10 +511,16 @@ export class SyncManager {
471
511
  }
472
512
  let text = readLocalYamlRaw(this.cwd);
473
513
  text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
474
- // mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
475
- // (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
476
- // 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
477
- if (findTopLevelSectionRange(text, 'mcp') === null) {
514
+ // mcp 段写入优先级(2026-09-10 成对签发批次):①签发成功 → 覆盖写(含手填旧段——
515
+ // 成对值是确定性有效凭据,覆盖陈旧/错部署段是修复不是破坏,spike 实证的「旧 token
516
+ // 永不愈合」正源于旧口径的不覆盖);②签发失败但段缺 → §7.4 同源假设补 url + user
517
+ // token(旧行为);③签发失败且段在 → 手填段保留(R-09 不覆盖)。
518
+ if (mcpIssued) {
519
+ const gwUrl = typeof mcpIssued.gateway_url === 'string' && mcpIssued.gateway_url
520
+ ? mcpIssued.gateway_url.replace(/\/+$/, '')
521
+ : `${normalizedUrl}/mcp`;
522
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(gwUrl)}\n token: ${yamlStr(mcpIssued.token)}`);
523
+ } else if (findTopLevelSectionRange(text, 'mcp') === null) {
478
524
  text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
479
525
  }
480
526
  writeLocalYamlRaw(this.cwd, text);
@@ -653,14 +699,28 @@ export class SyncManager {
653
699
  });
654
700
 
655
701
  // X1(task-04 契约):409 + code='change_deleted' = 平台已删 key 拒收——不是 base_ts 冲突。
656
- // 单行提示即可,不落冲突文件、不打全幅横幅:墓碑已收敛后的重复上行(归档收尾多步各推一次)、
702
+ // 不落冲突文件、不打全幅横幅:墓碑已收敛后的重复上行(归档收尾多步各推一次)、
657
703
  // 多用户下他端删除后本地仍在推进,都是可预期态,按冲突卡死人工 resolve 反而是误报。
658
704
  if (res.status === 409 && res.body && res.body.code === 'change_deleted') {
659
- console.warn(`⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`);
705
+ // 预期回执分流(2026-09-10 驾驭小结第四批③,用户实锤「提示语义滞后一个动作」):本地 DB
706
+ // status 已是 archived/deleted(unregisterChange 链——CLI 归档/change-delete 收尾自己置的)
707
+ // 时,本次上行本就是 CLI 主动发的墓碑,409 是平台「已删」幂等回执——本地注销早已完成,
708
+ // 「请在平台侧确认」口吻滞后一个动作;降为 ℹ️ 单行。仅本地仍 active(他端删除)才 warn。
709
+ const localTombstone = tombstoneDue
710
+ || progressData?.changes?.[0]?.status === 'archived'
711
+ || progressData?.changes?.[0]?.status === 'deleted'
712
+ if (localTombstone) {
713
+ console.log(`ℹ️ [sync] 平台已删除变更「${changeName}」——本地注销已由 CLI 完成,墓碑上行 409 属预期回执,无需动作`);
714
+ } else {
715
+ console.warn(`⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`);
716
+ }
660
717
  return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
661
718
  }
662
719
 
663
720
  if (res.ok) {
721
+ // 平台连接恢复信号(sync-noise.js):此前静默窗口内的失败在恢复的第一笔成功时
722
+ // 打一行提示并清 marker;之后照常「成功不打扰」。
723
+ noteSyncSuccess();
664
724
  // 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
665
725
  // 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
666
726
  // 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
@@ -1054,10 +1114,12 @@ export class SyncManager {
1054
1114
  /**
1055
1115
  * 获取当前平台配置,未连接返回 null。
1056
1116
  * 优先级:env SILLYHUB_PLATFORM_URL + SILLYHUB_PLATFORM_TOKEN(两键齐全才生效——
1057
- * daemon 注入通道,平台模式 specRoot/local.yaml 常无 platform 段,与链路 D
1058
- * readPlatformPushConfig 同款先例)> local.yaml platform 段。
1059
- * env 由 daemon 注入、CLI 不管其生命周期(disconnect 仍只清 local.yaml——daemon
1060
- * 停止注入即失效);triggerSync 平台模式放行(2026-08-26)后这是回传走通的关键通道。
1117
+ * 预留的外部注入通道,与链路 D readPlatformPushConfig 同款先例。⚠️ daemon 并未实现
1118
+ * 该注入(2026-09-09 核对全仓历史,见 multi-agent-platform 仓
1119
+ * docs/sillyspec/init-lease-silent-no-local-yaml.md)——env 需用户/脚本在 shell 显式
1120
+ * export,别按「daemon 会注」排障)> local.yaml platform 段。
1121
+ * env 生命周期 CLI 不管(disconnect 仍只清 local.yaml);triggerSync 平台模式放行
1122
+ * (2026-08-26)后平台模式回传的主凭据实为 local.yaml platform 段(env 缺席时)。
1061
1123
  */
1062
1124
  _getPlatform() {
1063
1125
  if (process.env.SILLYHUB_PLATFORM_URL && process.env.SILLYHUB_PLATFORM_TOKEN) {
@@ -1268,8 +1330,12 @@ export class SyncManager {
1268
1330
  signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
1269
1331
  });
1270
1332
  if (result === null) {
1271
- // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级
1272
- console.warn(`[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`);
1333
+ // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级。
1334
+ // 自动注入点(triggerPull,opts.autoPull)走噪音闸防刷屏;手动 `platform pull`
1335
+ // 是用户显式诊断动作,恒可见。
1336
+ const line = `[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`;
1337
+ if (opts.autoPull) syncConnectionWarn(line);
1338
+ else console.warn(line);
1273
1339
  return { ok: false, imported: false, conflict: false, reason: '拉取变更进度失败' };
1274
1340
  }
1275
1341
 
@@ -781,25 +781,83 @@ export function writeVerifyRequiredEvidence(changeDir, requiredEvidence) {
781
781
  }
782
782
 
783
783
  /**
784
- * 生成 execute run id
785
- * @returns {string} 如 'exec-2026-06-23-131400'
784
+ * 生成 execute run id。
785
+ * changeName 给定时附 change 名 FNV-1a 哈希后缀(坑 exec-run-id-same-second-collision
786
+ * 结构化隔离层,2026-09-10 驾驭小结第三批①,用户建议「run 目录取 change 名哈希隔离」):
787
+ * 两变更同秒启动 execute 必得不同 runId(run 目录天然分家),与 claimExecuteRunId 的
788
+ * 排他认领互补——认领治「同 ID 两主」,哈希治「不同变更天生同 ID」。同变更重试(marker
789
+ * 断裂 regenerate)哈希恒同,幂等;无参调用保持裸秒级形态(向后兼容/测试注入)。
790
+ * @param {string|null} [changeName] 变更名(缺省=裸秒级形态,无哈希后缀)
791
+ * @returns {string} 如 'exec-2026-06-23-131400' / 'exec-2026-06-23-131400-3fa2c1'
786
792
  */
787
- export function generateExecuteRunId() {
793
+ export function generateExecuteRunId(changeName = null) {
788
794
  const now = new Date()
789
795
  const pad = (n) => String(n).padStart(2, '0')
790
- return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
796
+ const base = `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
797
+ if (!changeName) return base
798
+ return `${base}-${changeNameHash(changeName)}`
799
+ }
800
+
801
+ /** change 名 → 6 位小写十六进制哈希(FNV-1a 32bit 截断;确定性、字符集落在 runId 后缀合法域 [a-z0-9]) */
802
+ function changeNameHash(name) {
803
+ let h = 0x811c9dc5
804
+ for (let i = 0; i < name.length; i++) {
805
+ h ^= name.charCodeAt(i)
806
+ h = Math.imul(h, 0x01000193) >>> 0
807
+ }
808
+ return h.toString(16).padStart(6, '0').slice(0, 6)
791
809
  }
792
810
 
793
811
  /**
794
- * execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
812
+ * 排他认领 execute run 目录(坑 exec-run-id-same-second-collision,2026-09-10 用户两次实锤):
813
+ * generateExecuteRunId 是秒级时间戳,并行会话(不同 change)同秒启动 execute 会生成同一
814
+ * runId——两变更的 tasks/task-NN/review.json 落进同一 execute-runs/<runId>/ 互相覆盖。
815
+ * 认领靠非递归 mkdir 的原子性:目录已存在(EEXIST)= 他者已认领同秒 ID → 附随机短后缀
816
+ * 重试;真实 fs 障碍(execute-runs 被普通文件占用等)原样上抛,由四处写入点既有分层 fail
817
+ * 语义接管(stage throw / gates 直穿 fail-closed / prompt+task-review catch 降级,
818
+ * execute-run-dir-fail-loud.test.mjs 锁定)。认领成功即含 tasks/ 子目录(不变量:认领则在,
819
+ * 后续 marker 写入点 mkdir 为幂等 no-op)。
820
+ * @param {string} runtimeRoot .sillyspec/.runtime 绝对路径
821
+ * @param {string|null} [baseId] 认领基准 ID(须为裸秒级形态;缺省现取 generateExecuteRunId()。
822
+ * 测试注入定值以确定性模拟「同秒两会话」,消除跨秒抖动)
823
+ * @returns {string} 已认领的 runId(无碰撞=原样秒级形态;碰撞=带 -<rand4> 后缀)
824
+ */
825
+ export function claimExecuteRunId(runtimeRoot, baseId = null) {
826
+ const base = baseId || generateExecuteRunId()
827
+ mkdirSync(join(runtimeRoot, 'execute-runs'), { recursive: true })
828
+ for (let attempt = 0; attempt < 8; attempt++) {
829
+ const candidate = attempt === 0 ? base : `${base}-${randomRunIdSuffix()}`
830
+ try {
831
+ mkdirSync(join(runtimeRoot, 'execute-runs', candidate)) // 非递归=排他:EEXIST 即同秒碰撞
832
+ mkdirSync(join(runtimeRoot, 'execute-runs', candidate, 'tasks'), { recursive: true })
833
+ return candidate
834
+ } catch (e) {
835
+ if (e.code !== 'EEXIST') throw e
836
+ }
837
+ }
838
+ // 极端拥挤兜底:放弃排他返回带后缀 ID(调用方 marker 写入点的 recursive mkdir 兜住;
839
+ // 同秒 8 次随机后缀连续碰撞的概率可忽略,行为不劣于修复前)
840
+ return `${base}-${randomRunIdSuffix()}`
841
+ }
842
+
843
+ function randomRunIdSuffix() {
844
+ return Math.random().toString(36).slice(2, 6).padEnd(4, '0')
845
+ }
846
+
847
+ /**
848
+ * execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS(可选 1-2 段小写字母数字短后缀)。
795
849
  * marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
796
850
  * join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
797
851
  * (对齐 stage-review marker 的 review- 前缀校验范式)。
852
+ * 后缀两段语义:①change 哈希段(generateExecuteRunId(changeName),坑
853
+ * exec-run-id-same-second-collision 结构化隔离);②claim 碰撞随机段(claimExecuteRunId
854
+ * 同秒认领冲突换道)。存量无后缀 ID(历史 run)继续通过,读侧全形态兼容。
798
855
  * @param {string} runId
799
856
  * @returns {boolean}
800
857
  */
801
858
  export function isValidExecuteRunId(runId) {
802
- return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
859
+ return typeof runId === 'string'
860
+ && /^exec-\d{4}-\d{2}-\d{2}-\d{6}(?:-[a-z0-9]{1,8}){0,2}$/.test(runId)
803
861
  }
804
862
 
805
863
  /**
@@ -838,8 +896,10 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName = n
838
896
  .sort((a, b) => b.mtime - a.mtime)
839
897
  if (entries.length === 0) return null
840
898
  // changeName 给定时优先 change 戳等值的 run(坑 worktree-cleanup-marker-chain:mtime 最新
841
- // 会错拿其他变更的 run);无戳命中退回 mtime 最新(向后兼容,调用方多为 marker 漂移兜底,
842
- // 宁可拿最新 run 也不空手而归——覆盖度由调用方 validateTaskReviews 复校验)
899
+ // 会错拿其他变更的 run);无戳命中退 mtime 最新——但排除「戳属他变更」的有主 run(坑
900
+ // exec-run-id-same-second-collision 收尾,2026-09-10 驾驭小结第二批②:与
901
+ // resolveExecuteRunForChange / resolveLatestExecuteRunId 同语义,戳存在且不等值 = 有明确
902
+ // 主人,误拿即串台)。无 changeName(旧行为,调用方 marker 漂移兜底)纯 mtime 零回归。
843
903
  if (changeName) {
844
904
  for (const x of entries) {
845
905
  try {
@@ -847,6 +907,11 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName = n
847
907
  if (c === changeName) return x.e
848
908
  } catch {}
849
909
  }
910
+ const ownerless = entries.find(x => {
911
+ try { return !existsSync(join(runsDir, x.e, 'change')) } catch { return true }
912
+ })
913
+ if (ownerless) return ownerless.e
914
+ return null
850
915
  }
851
916
  return entries[0].e
852
917
  } catch {
@@ -865,11 +930,16 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
865
930
  } catch {}
866
931
  // marker 缺失(worktree cleanup / 归档清理 / 并行会话误删——坑 worktree-cleanup-marker-chain)
867
932
  // 时不再盲目取 mtime 最新(会拿到其他变更的 run):先按 change 归属戳过滤,命中才返回;
868
- // 无戳(旧 run)退回 mtime 最新保持向后兼容。
933
+ // 无戳(旧 run)退 mtime 最新——但排除「戳属他变更」的有主 run(坑
934
+ // exec-run-id-same-second-collision 收尾,2026-09-10 驾驭小结第二批②:writeTaskReview 在
935
+ // marker 缺失场景靠本函数定位 run,误拿有主 run 即把本变更 review.json 写进他变更 run 的
936
+ // tasks/——串台残余入口。与 resolveExecuteRunForChange 的覆盖度启发排除同语义);全部 run
937
+ // 有主 → null(宁缺毋错,调用方走「无 run」分支而非错写)。
869
938
  try {
870
939
  const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
871
940
  if (candidates.stamped.length > 0) return candidates.stamped[0]
872
- if (candidates.all.length > 0) return candidates.all[0]
941
+ const ownerless = candidates.all.find(runId => readExecuteRunChangeStamp(runtimeRoot, runId) === null)
942
+ if (ownerless) return ownerless
873
943
  } catch {}
874
944
  return null
875
945
  }
@@ -1054,11 +1124,13 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1054
1124
  }
1055
1125
  } catch {}
1056
1126
  if (!executeRunId) {
1057
- executeRunId = generateExecuteRunId()
1127
+ executeRunId = generateExecuteRunId(changeName)
1058
1128
  // D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在)。
1059
1129
  // 保留 fail-open 契约(调用方 catch 降级)——只去静默:失败 console.error 留痕,不 throw
1060
1130
  //(草稿兜底缺了也只是退回 gate 报缺 review.json,不比修复前差)。
1061
1131
  try {
1132
+ // 排他认领(坑 exec-run-id-same-second-collision):并行会话同秒 runId 碰撞在认领处消解
1133
+ executeRunId = claimExecuteRunId(runtimeRoot, executeRunId)
1062
1134
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
1063
1135
  writeFileSync(runIdFile, executeRunId + '\n')
1064
1136
  stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
@@ -1086,9 +1158,27 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1086
1158
  const wtFiles = parsePorcelainFiles(wtStatus)
1087
1159
  .map(p => String(p).replace(/\\/g, '/'))
1088
1160
  .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1089
- if (wtFiles.length > 0) {
1090
- diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1091
- console.log(`[sillyspec] 草稿归属并入 ${wtFiles.length} 个 worktree 未提交文件(子代理未 commit 的改动按 allowed_paths 归属)`)
1161
+ // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」:worktree 内已 commit 的改动
1162
+ // 对主仓 base..head 与 status 双不可见,草稿 changedFiles 恒空)——merge-base(主仓 HEAD,
1163
+ // worktree HEAD)(= 创建锚点)..worktree HEAD 的 commit diff 并入,与
1164
+ // resolveVerifyChangedFiles 的同款补齐同源(verify-postcheck.js,两处口径须同步改)。
1165
+ // in-place 退化(wtGitDir===cwd)时已提交改动在主仓 diff 内,跳过防双并;git 失败 fail-open。
1166
+ let committedFiles = []
1167
+ if (wtGitDir !== cwd) {
1168
+ try {
1169
+ const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1170
+ const wtHead = runGit(wtGitDir, ['rev-parse', 'HEAD'])
1171
+ const mb = (mainHead && wtHead) ? runGit(wtGitDir, ['merge-base', wtHead, mainHead]) : null
1172
+ if (mb) {
1173
+ committedFiles = String(runGit(wtGitDir, ['diff', '--name-only', mb, wtHead]) || '')
1174
+ .split('\n').map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1175
+ .filter(p => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1176
+ }
1177
+ } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1178
+ }
1179
+ if (wtFiles.length > 0 || committedFiles.length > 0) {
1180
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles, ...committedFiles])]
1181
+ console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${wtFiles.length + committedFiles.length} 个(未提交 ${wtFiles.length} + 已提交 ${committedFiles.length},按 allowed_paths 归属)`)
1092
1182
  }
1093
1183
  }
1094
1184
  } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
@@ -1398,7 +1488,21 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1398
1488
  ? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1399
1489
  : crossDiffFiles
1400
1490
  } else {
1401
- const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1491
+ // 坑 backfill-reviews-adopt-empties-changedfiles(2026-09-08 实证):主仓 task 同样
1492
+ // 优先卡锚点切片(base_commit/head_commit),不再共用「worktree 基线..当前 HEAD」——
1493
+ // per-task commit 模式下 base 必须是本 task 起点commit,diff 才与 allowed_paths 对齐
1494
+ // (跨仓路径本就按卡切片);卡无锚点回落 change 级切片(旧语义,兼容 wave 级提交)。
1495
+ const cardHead = parseHeadCommit(content)
1496
+ const cardBase = parseBaseCommit(content)
1497
+ let mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1498
+ if (cardHead) {
1499
+ taskHead = cardHead
1500
+ taskBase = cardBase || taskBase
1501
+ try {
1502
+ const out = runGit(reviewGitDir, ['diff', '--name-only', `${taskBase}..${taskHead}`])
1503
+ mainDiff = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
1504
+ } catch { /* 锚点不可解析(如卡记短 hash 仓内无引用)→ 回落 change 级切片 */ }
1505
+ }
1402
1506
  taskChangedFiles = allowedPaths.length > 0
1403
1507
  ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1404
1508
  : []
@@ -1410,6 +1514,16 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1410
1514
  continue
1411
1515
  }
1412
1516
 
1517
+ // 切片空不冲声明(2026-09-08 用户反馈:backfill-reviews --adopt 冲掉 review):task 卡
1518
+ // 无 allowed_paths、或 allowed_paths 与 change 级 diff 的路径形态失配时,上方过滤产出
1519
+ // 空 changedFiles——旧代码照样整字段覆写,reviewer 已声明且过证据校验的 changedFiles
1520
+ // 被清空,gate(verifyReviewGitEvidence)随即按空集误判。adopt 的职责是重算 mechanics,
1521
+ // 不是降级声明:计算切片为空而原声明非空时保留原声明并留 reason 可追溯。
1522
+ if (taskChangedFiles.length === 0 && Array.isArray(prev.changedFiles) && prev.changedFiles.length > 0) {
1523
+ taskChangedFiles = prev.changedFiles
1524
+ reasons.push(`${taskId}: allowed_paths 未切出任何 diff 文件(卡无声明/路径形态失配),保留原 changedFiles ${prev.changedFiles.length} 条不冲空`)
1525
+ }
1526
+
1413
1527
  const merged = { ...prev }
1414
1528
  merged.schemaVersion = REVIEW_SCHEMA_VERSION
1415
1529
  merged.task = taskId
@@ -1544,12 +1658,14 @@ export async function writeTaskReview({
1544
1658
  executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
1545
1659
  }
1546
1660
  if (!executeRunId) {
1547
- executeRunId = generateExecuteRunId()
1548
- warnings.push(`无 execute run marker——已生成新 runId ${executeRunId} 并建目录(正常应先跑过 execute Wave 步)`)
1661
+ executeRunId = generateExecuteRunId(changeName)
1549
1662
  try {
1663
+ // 排他认领(坑 exec-run-id-same-second-collision)+ 幂等 mkdir,认领碰撞换后缀
1664
+ executeRunId = claimExecuteRunId(runtimeRoot, executeRunId)
1550
1665
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
1551
1666
  writeFileSync(markerFile, executeRunId + '\n')
1552
1667
  stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
1668
+ warnings.push(`无 execute run marker——已生成新 runId ${executeRunId} 并建目录(正常应先跑过 execute Wave 步)`)
1553
1669
  } catch (e) {
1554
1670
  return { ok: false, errors: [`execute run 目录/marker 写入失败:${e.message}`], warnings }
1555
1671
  }