sillyspec 3.34.0 → 3.34.2

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.
package/src/progress.js CHANGED
@@ -155,6 +155,36 @@ export function resolveSessionIdentity({ flagSession = null, quickChangeName = n
155
155
  *
156
156
  * 无 pointer = 纯本地项目(从未平台 scan),正常走本地解析,不受影响。
157
157
  */
158
+ /** 向上找 .git 所在目录(无 git 返回 null)——跨仓错绑告警的比对原语。 */
159
+ function gitRootOf(startPath) {
160
+ let cur = resolve(startPath)
161
+ for (;;) {
162
+ if (existsSync(join(cur, '.git'))) return cur
163
+ const parent = dirname(cur)
164
+ if (parent === cur) return null
165
+ cur = parent
166
+ }
167
+ }
168
+
169
+ /** 跨仓错绑告警(2026-11-04-junction-guard-rename-content FR-01):cwd 与解析出的
170
+ * specRoot 分属不同 git 仓 = workspace junction/指针错绑形态(2026-11-04 跨工作区污染
171
+ * 事故实证:平台 workspace junction 指向 sillyspec 仓,CLI 忠实跟随零提示,sillyspec
172
+ * 内容经平台 token 推进平台 workspace)。双侧都有 .git 才比对(裸快照目录不误报);
173
+ * 只告警不阻断(错绑下临时跨仓只读操作仍可用,由人裁决)。 */
174
+ function warnIfCrossRepoSpecRoot(cwd, specRoot, pointerPath) {
175
+ let cwdRoot = null
176
+ let specRootGit = null
177
+ try { cwdRoot = gitRootOf(cwd) } catch { /* 比对失败静默(告警 fail-open) */ }
178
+ try { specRootGit = gitRootOf(specRoot) } catch { /* 同上 */ }
179
+ if (!cwdRoot || !specRootGit || cwdRoot === specRootGit) return
180
+ console.warn(
181
+ `⚠️ 平台 spec 根与当前目录分属不同 git 仓——workspace junction/指针疑似错绑(跨工作区污染事故形态)。\n` +
182
+ ` cwd 仓:${cwdRoot}\n` +
183
+ ` specRoot 仓:${specRootGit}\n` +
184
+ ` 检查 ${pointerPath} 的 specRoot 与 ~/.sillyhub/daemon/specs/ 下对应 workspace 的 junction 绑定;或 sillyspec platform pointer --cleanup 后重扫。命令继续执行,请人工裁决。`
185
+ )
186
+ }
187
+
158
188
  export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
159
189
  if (explicitSpecDir) return resolve(explicitSpecDir);
160
190
  const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
@@ -225,11 +255,13 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
225
255
  // 偶发锚到 daemon junction 副本」):daemon 写入的 pointer specRoot 可能是通向真实 spec 根的
226
256
  // junction/symlink 路径——经 junction 读写与真实路径同文件(无害)但路径呈现与比对漂移、
227
257
  // junction 随 daemon 生命周期可能蒸发。realpath 坍缩为真实路径(失败回退原值,保守不干预)。
258
+ let resolvedSpecRoot = ptr.specRoot
228
259
  try {
229
260
  const canonical = realpathSync(ptr.specRoot)
230
- if (canonical && canonical !== ptr.specRoot) return canonical
261
+ if (canonical && canonical !== ptr.specRoot) resolvedSpecRoot = canonical
231
262
  } catch { /* realpath 失败(权限/竞态删除)→ 原值(上方已过存在性检查) */ }
232
- return ptr.specRoot;
263
+ warnIfCrossRepoSpecRoot(cwd, resolvedSpecRoot, pointerPath)
264
+ return resolvedSpecRoot;
233
265
  }
234
266
 
235
267
  /**
@@ -15,8 +15,10 @@
15
15
  * - 标记文件 route-hindsight.json:per-repo 单条,每次标记整文件覆盖——提示语义即
16
16
  * 「上个轻量变更」。
17
17
  *
18
- * 指标四元组(口径):
19
- * designRewriteRatio / tasksRewriteRatio:终稿 vs 首版快照的行级改写比——复用
18
+ * 指标口径(判定三元组 + 观测一路):
19
+ * designRewriteRatio(判定 >0.5)/ tasksRewriteRatio(仅观测,判定已退役
20
+ * 2026-11-08-hindsight-tasks-ratio-retire——种子=v3 成功标准转写,横幅明文要求改写,
21
+ * 正常与不清不可分离):终稿 vs 首版快照的行级改写比——复用
20
22
  * flow-draft.computeEditRatio 同款 LCS 口径(改写行/首版行数,纯增行不计入;CRLF/LF
21
23
  * 归一后比对)。diff 面先做结构归一:剔 frontmatter 块、HTML 注释标记行(<!--)、空行
22
24
  * ——惰性脚手架行(机器稿里约占半数)会把真实换血稀释到阈值以下(实测整写机器段仅得
@@ -39,11 +41,15 @@ import { writeAtomicSync } from './fs-atomic.js'
39
41
  import { computeEditRatio } from './flow-draft.js'
40
42
  import { runtimeStatePath } from './runtime-paths.js'
41
43
 
42
- /** 超阈判定常量(可调;口径:比例两路为严格大于——恰等于阈值不算超,保守防误标 R-01;
43
- * 计数两路为大于等于——2 维/2 次即系统性信号)。 */
44
+ /** 超阈判定常量(可调;口径:比例路为严格大于——恰等于阈值不算超,保守防误标 R-01;
45
+ * 计数两路为大于等于——2 维/2 次即系统性信号)。
46
+ * tasksRewriteRatio 判定退役(2026-11-08-hindsight-tasks-ratio-retire):种子自
47
+ * 2026-10-07-thin-tasks-v3 起为成功标准逐条转写(验收句≠实现步骤,文体必然不同),
48
+ * 且 flow start 横幅明文要求「机器种子按真实实现路径改写、增删改随意」——正常改写与
49
+ * 需求不清的改写不可区分(0.875/0.8 两连误标实证),阈值无分离度。计算面保留为观测
50
+ * (computeHindsightMetrics 照算照返回),未来有证据可重定口径后再恢复判定。 */
44
51
  export const HINDSIGHT_THRESHOLDS = {
45
52
  designRewriteRatio: 0.5, // design 机器稿改写比 > 0.5(50% 首版行被改写)
46
- tasksRewriteRatio: 0.6, // tasks 机器预填稿改写率 > 0.6
47
53
  blindDims: 2, // 评审盲维命中(dimensionNotes='finding')≥ 2 维
48
54
  testFailures: 2, // 收口实测失败记录 ≥ 2 次
49
55
  }
@@ -229,9 +235,8 @@ export function markHindsight({ cwd, specBase, change, metrics }) {
229
235
  if (num(m.designRewriteRatio) > HINDSIGHT_THRESHOLDS.designRewriteRatio) {
230
236
  reasons.push(`design 机器稿改写比 ${num(m.designRewriteRatio)} > ${HINDSIGHT_THRESHOLDS.designRewriteRatio}`)
231
237
  }
232
- if (num(m.tasksRewriteRatio) > HINDSIGHT_THRESHOLDS.tasksRewriteRatio) {
233
- reasons.push(`tasks 机器预填稿改写率 ${num(m.tasksRewriteRatio)} > ${HINDSIGHT_THRESHOLDS.tasksRewriteRatio}`)
234
- }
238
+ // tasksRewriteRatio 判定退役(2026-11-08-hindsight-tasks-ratio-retire)——metrics 字段
239
+ // 仍随标记文件落盘(观测面,向后兼容读取),不再参与判定与 reasons。
235
240
  if (num(m.blindDims) >= HINDSIGHT_THRESHOLDS.blindDims) {
236
241
  reasons.push(`评审盲维命中 ${num(m.blindDims)} 维 ≥ ${HINDSIGHT_THRESHOLDS.blindDims}`)
237
242
  }
package/src/spec-sync.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { createHash } from 'crypto';
19
19
  import { timedAsync } from './timing.js';
20
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
20
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
21
21
  import { join, relative, sep } from 'path';
22
22
  import { bindSyncNoiseRoot, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
23
23
  import { runtimeStatePath } from './runtime-paths.js';
@@ -45,6 +45,67 @@ function toPosix(p) {
45
45
  return p.replace(/\\/g, '/');
46
46
  }
47
47
 
48
+ // 全量清单拉取超时(2026-11-04-sync-fail-silent-fix FR-02):全量清单是唯一稳定大响应
49
+ // (生产实测 2.7MB;~1Mbps 入口链路需 ~25s),10s 硬编码使首拉/对账轮/倒退对账在慢链路
50
+ // 恒熔断——缓存漂移后无收敛通道(228 条知识漏推事故的叠加因之二)。对齐 bundle 端点
51
+ // 大超时档(sync.js PULL_BUNDLE_TIMEOUT_MS 同思路:大响应体独立口径);增量(带 since)
52
+ // 小响应维持 10s 噪音闸门不变。
53
+ const MANIFEST_FULL_TIMEOUT_MS = 120_000;
54
+
55
+ // 推送分批(2026-11-04-sync-push-chunking):大积压单批 POST 在单请求预算内必死——
56
+ // 生产实证 multi-agent-platform 工作区 15 天积压 2436 ops/45.7MB(base64 后 ~60MB),
57
+ // 上传+服务端 apply 双超(客户端 30s 掐线、服务器 16-34s 处理后 400/中断),37 连败
58
+ // 零落地。切块顺序推送:每块独立提交,已落地块推进服务器清单,失败即停由下轮 diff
59
+ // 断点续传(锚点刷新后已落地路径自动出 diff)。env 仅调优,缺省零回归(小批量一块直发)。
60
+ const PUSH_TIMEOUT_DEFAULT_MS = 30_000;
61
+ const CHUNK_OPS_DEFAULT = 128;
62
+ const CHUNK_BYTES_DEFAULT = 2 * 1024 * 1024;
63
+
64
+ function resolvePushTimeoutMs() {
65
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_PUSH_TIMEOUT_MS);
66
+ if (Number.isFinite(raw) && raw >= 1000) return Math.floor(raw);
67
+ return PUSH_TIMEOUT_DEFAULT_MS;
68
+ }
69
+
70
+ function resolveChunkOps() {
71
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_CHUNK_OPS);
72
+ if (Number.isInteger(raw) && raw >= 1) return raw;
73
+ return CHUNK_OPS_DEFAULT;
74
+ }
75
+
76
+ function resolveChunkBytes() {
77
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_CHUNK_BYTES);
78
+ if (Number.isFinite(raw) && raw >= 1024) return Math.floor(raw);
79
+ return CHUNK_BYTES_DEFAULT;
80
+ }
81
+
82
+ /**
83
+ * 按 op 数与 base64 字节数双约束切块(顺序保序;单 op 超任一上限独占一块——
84
+ * 4.7MB 级 change.patch 单文件不切不丢弃)。行宽为粗估(content + path×2 + 包裹),
85
+ * 只为分块比例,无需精确。
86
+ */
87
+ export function chunkOpsForPush(ops, opts = {}) {
88
+ const maxOps = opts.maxOps ?? resolveChunkOps();
89
+ const maxBytes = opts.maxBytes ?? resolveChunkBytes();
90
+ if (!Array.isArray(ops) || ops.length === 0) return [];
91
+ if (maxOps < 1 || maxBytes < 1) return [ops.slice()];
92
+ const chunks = [];
93
+ let cur = [];
94
+ let curBytes = 0;
95
+ for (const op of ops) {
96
+ const size = (op.content ? op.content.length : 0) + op.path.length * 2 + 96;
97
+ if (cur.length > 0 && (cur.length >= maxOps || curBytes + size > maxBytes)) {
98
+ chunks.push(cur);
99
+ cur = [];
100
+ curBytes = 0;
101
+ }
102
+ cur.push(op);
103
+ curBytes += size;
104
+ }
105
+ if (cur.length > 0) chunks.push(cur);
106
+ return chunks;
107
+ }
108
+
48
109
  function debugLog(msg) {
49
110
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
50
111
  }
@@ -61,7 +122,7 @@ export function describeSyncError(err) {
61
122
  return '平台响应慢,本轮同步被总预算熔断让路(best-effort,下一条命令自动重试)';
62
123
  }
63
124
  if (err && err.name === 'TimeoutError') {
64
- return '单请求超时(清单 10s / 推送 30s 口径,best-effort,下次自动重试)';
125
+ return '单请求超时(清单增量 10s/全量 120s;推送默认 30s,SILLYSPEC_SPEC_SYNC_PUSH_TIMEOUT_MS 可调;分批尺寸 SILLYSPEC_SPEC_SYNC_CHUNK_*;best-effort,下次自动重试)';
65
126
  }
66
127
  return err && err.message ? err.message : String(err);
67
128
  }
@@ -262,11 +323,17 @@ export function computeSpecOps(serverManifest, localFiles) {
262
323
  const ops = [];
263
324
 
264
325
  for (const r of renames) {
326
+ const localEntry = localMap.get(r.newPath);
265
327
  ops.push({
266
328
  op: 'rename',
267
329
  path: r.oldPath,
268
330
  new_path: r.newPath,
269
331
  base_version: server[r.oldPath].version,
332
+ // 内容随行(2026-11-04-junction-guard-rename-content FR-02):服务端源行缺失(跨工作区
333
+ // 错绑/对端已清行)时无内容 rename 必被 422「源文件缺失的重命名操作必须携带文件内容」
334
+ // 拒收整批(生产实证文件树同步 5 连败卡死);带 content 后服务端可直接物化目标路径,
335
+ // 源行在则照常移动语义。编码与 add/update 同款 base64。
336
+ content: (localEntry?.buf ?? readFileSync(localEntry?.absPath ?? join('.', r.newPath))).toString('base64'),
270
337
  });
271
338
  }
272
339
 
@@ -297,6 +364,12 @@ export function computeSpecOps(serverManifest, localFiles) {
297
364
  for (const p of localMap.keys()) {
298
365
  if (renamedNew.has(p)) continue;
299
366
  if (!serverSet.has(p)) {
367
+ // 平台已审候选不复活(2026-11-07-sync-proposed-no-revive):平台知识页 merge/reject
368
+ // 待审候选后服务器软删(manifest 行 exists=false,不置墓碑)——此处若照常 add 会走
369
+ // 平台复活分支把已审条目推回待审区(2026-11-07 平台侧核查实证)。窄化判定:仅
370
+ // knowledge/proposed/ 前缀 + 软删行在场(平台处置的唯一可信证据;manifest 无行 =
371
+ // 从未同步的新提议,照常 add)。本地残留清理由 cleanupPlatformDisposedProposed 负责。
372
+ if (p.startsWith('knowledge/proposed/') && server[p]?.exists === false) continue;
300
373
  const localEntry = localMap.get(p);
301
374
  const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
302
375
  ops.push({
@@ -313,6 +386,39 @@ export function computeSpecOps(serverManifest, localFiles) {
313
386
  return ops;
314
387
  }
315
388
 
389
+ /**
390
+ * 平台已审候选的本地残留清理(2026-11-07-sync-proposed-no-revive FR-02):
391
+ * 「本地 knowledge/proposed/ 文件 ∩ 服务器 manifest exists=false 行」= 平台侧已
392
+ * merge/reject 处置过的候选在本地留下的残件(computeSpecOps 已拦其 add 复活)——
393
+ * 此处删除本地文件并提示,防止残留永久滞留(每次 sync 重算,幂等;无残留零输出)。
394
+ * 删除安全性:服务器侧该路径已无活文件(软删行),删本地不产生两端不一致;误删窗口
395
+ * 仅「同 slug 复写新内容 + 平台恰已处置同路径」(发生率极低,见变更 design R-02)。
396
+ * @returns {string[]} 已清理的 spec 相对路径列表
397
+ */
398
+ export function cleanupPlatformDisposedProposed(specRoot, serverManifest) {
399
+ const server = serverManifest || {};
400
+ const disposed = Object.keys(server).filter(
401
+ (p) => p.startsWith('knowledge/proposed/') && server[p]?.exists === false
402
+ );
403
+ if (disposed.length === 0) return [];
404
+ const removed = [];
405
+ for (const p of disposed) {
406
+ // spec 相对路径 → 本地绝对路径(walkSpecTree 同源根;路径来自服务器清单为 POSIX 形态)
407
+ const abs = join(specRoot, ...p.split('/'));
408
+ if (!existsSync(abs)) continue; // 本地不在场的处置行零动作零上报(只清真残件)
409
+ try {
410
+ rmSync(abs, { force: true });
411
+ removed.push(p);
412
+ } catch { /* 删除失败不阻断同步——下轮重算重试(幂等) */ }
413
+ }
414
+ if (removed.length > 0) {
415
+ console.log(
416
+ `[spec-sync] 已清理 ${removed.length} 个平台已审核(合并/拒绝)的本地待审候选残留(不再回推复活): ${removed.slice(0, 5).join(', ')}${removed.length > 5 ? ' 等' : ''}`
417
+ );
418
+ }
419
+ return removed;
420
+ }
421
+
316
422
  /**
317
423
  * 从 ops 提取本次涉及的变更目录名集合(change_dirs 标注,D-005@v1)。
318
424
  *
@@ -755,7 +861,8 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
755
861
  try {
756
862
  const res = await fetch(cacheUsable ? `${manifestUrl}?since=${manifestCache.spec_version}` : manifestUrl, {
757
863
  headers: { Authorization: `Bearer ${platform.token}` },
758
- signal: withSignal(10000),
864
+ // 超时分档(FR-02):增量(带 since)小响应 10s;全量大响应 120s(慢链路全量对账可达)
865
+ signal: withSignal(cacheUsable ? 10000 : MANIFEST_FULL_TIMEOUT_MS),
759
866
  });
760
867
  if (!res.ok) {
761
868
  // ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
@@ -787,7 +894,7 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
787
894
  debugLog(`[spec-sync] spec_version 倒退(${manifestCache.spec_version} → ${body.spec_version},服务端重建?)——回退全量清单对账`);
788
895
  const full = await fetch(manifestUrl, {
789
896
  headers: { Authorization: `Bearer ${platform.token}` },
790
- signal: withSignal(10000),
897
+ signal: withSignal(MANIFEST_FULL_TIMEOUT_MS), // 倒退对账=全量大响应(FR-02 同档)
791
898
  });
792
899
  if (!full.ok) {
793
900
  const failCount = bumpFailStreak(specRoot, logLabel, `倒退对账全量 HTTP ${full.status}`);
@@ -824,6 +931,11 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
824
931
  // 2. walk/hash/diff
825
932
  const entries = walkSpecTree(specRoot);
826
933
  const localFiles = hashFiles(entries);
934
+ // 平台已审候选残留清理(2026-11-07-sync-proposed-no-revive FR-02):computeSpecOps 已拦
935
+ // 其 add 复活,此处删本地残件+提示(幂等;walk 已完成,删除不影响本轮 diff 结果)。
936
+ try {
937
+ cleanupPlatformDisposedProposed(specRoot, serverManifest);
938
+ } catch { /* 清理 fail-soft——不阻断文件树同步 */ }
827
939
  let ops = computeSpecOps(serverManifest, localFiles);
828
940
 
829
941
  // 旧副本回推防护(并行会话一致性):上次同步后本地从未改动的文件不回推覆盖服务器新内容。
@@ -863,30 +975,47 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
863
975
  return { synced: 0, ok: true };
864
976
  }
865
977
 
866
- // 3. POST ops
978
+ // 3. POST ops(2026-11-04-sync-push-chunking:分批顺序推送——单批大积压在单请求
979
+ // 预算内上传+服务端 apply 双超零落地;切块后每块独立提交,失败即停下轮续传)
867
980
  try {
868
- const res = await fetch(syncUrl, {
869
- method: 'POST',
870
- headers: {
871
- 'Content-Type': 'application/json',
872
- Authorization: `Bearer ${platform.token}`,
873
- },
874
- body: JSON.stringify({ ops }),
875
- signal: withSignal(30000),
876
- });
877
- if (!res.ok) {
878
- const raw = await res.text().catch(() => '');
879
- const detail = describeErrorBody(res.status, raw);
880
- const failCount = bumpFailStreak(specRoot, logLabel, detail);
881
- const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}${detail ? ` —— ${detail}` : ''}`;
882
- if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
883
- else console.warn(line);
884
- if (failCount >= FAIL_STREAK_ESCALATE_AT) {
885
- console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
981
+ const chunks = chunkOpsForPush(ops);
982
+ if (chunks.length > 1) {
983
+ console.log(`[spec-sync] 推送分批:${ops.length} ops 切 ${chunks.length} 块顺序提交(块上限 ${resolveChunkOps()} ops / ${Math.round(resolveChunkBytes() / 1024)}KB 估算): ${logLabel}`);
984
+ }
985
+ let landedChunks = 0;
986
+ const body = { conflict: false, server_versions: {}, platform_deleted: [] };
987
+ for (let ci = 0; ci < chunks.length; ci++) {
988
+ const res = await fetch(syncUrl, {
989
+ method: 'POST',
990
+ headers: {
991
+ 'Content-Type': 'application/json',
992
+ Authorization: `Bearer ${platform.token}`,
993
+ },
994
+ body: JSON.stringify({ ops: chunks[ci] }),
995
+ signal: withSignal(resolvePushTimeoutMs()),
996
+ });
997
+ if (!res.ok) {
998
+ const raw = await res.text().catch(() => '');
999
+ const detail = describeErrorBody(res.status, raw);
1000
+ const failCount = bumpFailStreak(specRoot, logLabel, detail);
1001
+ const landedNote = landedChunks > 0 ? `(前 ${landedChunks}/${chunks.length} 块已落地,下轮自动续传未落地部分)` : '';
1002
+ const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试)${landedNote}: ${logLabel}${detail ? ` —— ${detail}` : ''}`;
1003
+ if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
1004
+ else console.warn(line);
1005
+ if (failCount >= FAIL_STREAK_ESCALATE_AT) {
1006
+ console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
1007
+ }
1008
+ return { synced: 0, ok: false };
886
1009
  }
887
- return { synced: 0, ok: false };
1010
+ const chunkBody = await res.json().catch(() => ({}));
1011
+ landedChunks += 1;
1012
+ // 块响应合并成单批等价形状:conflict 任一块命中即真;server_versions /
1013
+ // platform_deleted 逐块累积(块间路径互斥——computeSpecOps 每 path 至多一 op)。
1014
+ if (chunkBody.conflict) body.conflict = true;
1015
+ Object.assign(body.server_versions, chunkBody.server_versions || {});
1016
+ if (Array.isArray(chunkBody.platform_deleted)) body.platform_deleted.push(...chunkBody.platform_deleted);
1017
+ if (chunks.length > 1) debugLog(`[spec-sync] 分批 ${ci + 1}/${chunks.length} 落地(${chunks[ci].length} ops)`);
888
1018
  }
889
- const body = await res.json().catch(() => ({}));
890
1019
  if (body.conflict) {
891
1020
  // HUB-08 冲突闭环:落 spec-sync-conflict-<change>.json(与进度 sync-conflict-* 同目录),
892
1021
  // 供 platform status 列出 + platform resolve 三态处置。此前只 warn 返回,下次 sync 用
@@ -1023,5 +1152,6 @@ export default {
1023
1152
  filterStaleUpdates,
1024
1153
  dropFollowServerUpdates,
1025
1154
  partitionConflictPaths,
1155
+ chunkOpsForPush,
1026
1156
  syncSpecTree,
1027
1157
  };
@@ -111,7 +111,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
111
111
  4. 探索顺序(按需):目的 → 约束 → 边界 → 成功标准
112
112
  5. **先挖为什么**:不只问「要什么」,要问出「为什么现在做、不做会怎样」(动机是后续替你补全没提的那一百个决定的依据);到本步出口仍说不清动机 → 不许进入「提出 2-3 种方案」,先把动机问出来
113
113
  6. **实现方式的选择权归谁**:技术栈/平台/语言/库/存储介质这类「怎么做」的选择,问清哪些是用户有强意见要谈的、哪些是交给你选的;你选的按决策记录规则落盘并把 answer 标注「我的判断」(不是用户同意过的)
114
- 7. 多选题优于开放式问题;被问「你觉得呢/先猜一个」时给一个具体方案并标「我的判断」,不要空转追问
114
+ 7. 提问分型(意图问开放、决策给选项):意图/动机类问题(用户想要什么、为什么、成功什么样)必须开放式提出,不附任何示例选项——用户自己的措辞携带选项装不下的意图,给选项会锚定用户;存在可命名取舍的决策类问题才给具名选项,每个选项附一句代价,必带推荐与理由,禁止裸问「选哪个」。被问「你觉得呢/先猜一个」时给一个具体方案并标「我的判断」,不要空转追问
115
115
  8. YAGNI — 砍掉不需要的功能
116
116
 
117
117
  ### B2. 分块回放(Playback——边理解边复述,与追问交替)
@@ -21,7 +21,7 @@ import { collectExecuteChangedFiles, computeDecisionTouches, renderDecisionTouch
21
21
  // (test 直 import 本文件皆此形态)触发 stages/index.js 顶层 stageRegistry 对本文件 definition
22
22
  // const 的 TDZ 求值(ESM 环实证 2026-09-14)。此处为同格式本地孪生,漂移由
23
23
  // test/knowledge-inject.test.mjs 格式等价断言锁定——改 prompt.js 版必同步改此处。
24
- import { matchKnowledge } from '../knowledge-match.js'
24
+ import { matchKnowledge, resultChannel } from '../knowledge-match.js'
25
25
  import { matchKnowledgeWithScope, graphPathCandidates, scopeTelemetryRefs } from '../knowledge-graph.js'
26
26
  import { appendKnowledgeHit } from '../knowledge-hits.js'
27
27
 
@@ -136,10 +136,12 @@ function buildWaveKnowledgeSection(changeDir, wave) {
136
136
  }
137
137
  }
138
138
  try {
139
+ // channel 单源记录(FR-04,2026-11-05-knowledge-search-observability)——画像/调优数据地基
139
140
  appendKnowledgeHit(path.join(specBase, '.runtime'), {
140
141
  type: 'inject',
141
142
  change: path.basename(changeDir),
142
143
  query,
144
+ channel: resultChannel(result),
143
145
  matchedFiles: result.matched ? [...humanEntries, ...routeHits].map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : [],
144
146
  ...(result.matched ? (() => {
145
147
  const scopeMatchedFiles = scopeTelemetryRefs(result, { includeRejected: false })
@@ -22,11 +22,13 @@ export const definition = {
22
22
  - 在途面(出方向建议前)→ \`sillyspec status\` 活跃变更一览——结论别跟在途工作撞车
23
23
  4. 输出 2-3 个有价值方向、关键风险和下一步建议
24
24
  5. 如果用户要求保存结论,先明确保存位置,再写入对应文档
25
- 6. 讨论 UI/交互方案且用户明确要求原型时,按下方「HTML 原型生成」执行
25
+ 6. 讨论 UI/交互方案或状态机/流程/时序类歧义且用户明确要求原型时,按下方「HTML 原型生成」执行
26
26
 
27
27
  ### HTML 原型生成(仅用户明确要求时)
28
- 探索保持只读姿态:不主动写原型文件;UI 讨论成形时可以提议("要不要出个原型看效果"),用户同意后再生成。探索期原型是**粗选对比件**(求快不求真,手绘单文件含内联 CSS+JS 在探索期合法,浏览器直接打开):
28
+ 探索保持只读姿态:不主动写原型文件;UI/交互讨论成形、或状态机/流程/时序类歧义文字多轮问答装不下时,可以提议("要不要出个原型看效果"),用户同意后再生成。探索期原型是**粗选对比件**(求快不求真,手绘单文件含内联 CSS+JS 在探索期合法,浏览器直接打开):
29
29
  - 呈现讨论中的布局/组件/交互流程——用户看到的是"这个方案做出来长什么样",不是示意图;能用项目真实组件库/设计 token 观感的尽量用(先读 scan 文档与现有前端代码),降低与真实效果的偏差
30
+ - 逻辑形(状态机/流转/时序类歧义用):把流转规则写成内联纯逻辑模块,配状态面板(每步操作后重渲染全量状态)+ 领域语言标注,让用户亲手把玩边界场景代替文字推演;非 JS 项目即「JS 复刻的模拟器」——验证的是复刻件非真码,提议时声明可能的翻译失真;逻辑形不进变更目录(prototype-*.html 命名是 UI 契约件专属),结论以 design/decisions 文字落盘
31
+ - 多方案对比时:各方案须在布局/信息层级(UI 形)或流转结构(逻辑形)上实质性分歧,颜色文案级差异不构成对比方案;mock 数据取真实形状与密度,不在空态页面上做判断
30
32
  - 保存到 \`{SPEC_ROOT}/explore/prototype-<名称>.html\`(目录不存在则先创建;用户指定了其他位置则从其指定)
31
33
  - 原型是探索讨论工具、不是交付物,也不是实现代码——不要顺手把原型接进项目源码或构建;话题后续进入 brainstorm 时分两路:已是可复跑真码的原型复制到 \`{SPEC_ROOT}/changes/<变更名>/\`(**保留 \`prototype-<名称>.html\` 命名**——execute 按此命名注入,改名=不注入)走登记复用;手绘粗选件只是方向输入,brainstorm 定稿须按其「生成要求」升级为真码(同方言/可复跑),不得把手绘件直接当定稿登记
32
34