sillyspec 3.32.4 → 3.32.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.
@@ -497,6 +497,22 @@ function parseSimpleYaml(content) {
497
497
  if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
498
498
  if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
499
499
  result[topKey][childKey].push(parseValue(trimmed.slice(2)))
500
+ continue
501
+ }
502
+
503
+ // indent>=4 的 `<子键>: <值>`(三层嵌套标量,坑 repo-inline-worktree-placement:repos 条目
504
+ // 对象形态 `repos: → <key>: → path:/worktree:`)——空数组占位(indent2 无值条目)转对象后
505
+ // 挂键。此前该形态被静默丢弃,analyzeCrossRepoCd 等消费方读不到三层配置。
506
+ if (indent >= 4 && childKey) {
507
+ const subMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
508
+ if (!subMatch) continue
509
+ if (UNSAFE_KEYS.has(subMatch[1].trim())) continue
510
+ if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
511
+ const cur = result[topKey][childKey]
512
+ if (Array.isArray(cur) && cur.length === 0) result[topKey][childKey] = {}
513
+ if (result[topKey][childKey] && typeof result[topKey][childKey] === 'object' && !Array.isArray(result[topKey][childKey])) {
514
+ result[topKey][childKey][subMatch[1].trim()] = parseValue(subMatch[2])
515
+ }
500
516
  }
501
517
  }
502
518
 
@@ -886,8 +902,25 @@ function analyzeCrossRepoCd(command, callerCwd, projectRoot, inWorktree) {
886
902
  const shellResolved = path.isAbsolute(t) ? t : path.resolve(callerCwd, t)
887
903
  const intended = path.isAbsolute(t) ? t : path.resolve(inWorktree ? projectRoot : callerCwd, t)
888
904
  for (const [key, raw] of Object.entries(repos)) {
889
- if (typeof raw !== 'string' || !raw) continue
890
- const repoRoot = path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw)
905
+ // 条目双形态(坑 repo-inline-worktree-placement):'path' 字符串(parseSimpleYaml 原样);
906
+ // 块式对象 {path, worktree}(parseSimpleYaml 嵌套);inline '{path: ..}' 字符串
907
+ // (parseSimpleYaml 不解析 inline flow,原样字符串——手动提取 path 子键)
908
+ let repoPath = null
909
+ if (typeof raw === 'string' && raw) {
910
+ const t = raw.trim()
911
+ if (t.startsWith('{') && t.endsWith('}')) {
912
+ for (const part of t.slice(1, -1).split(',')) {
913
+ const idx = part.indexOf(':')
914
+ if (idx !== -1 && part.slice(0, idx).trim() === 'path') { repoPath = part.slice(idx + 1).trim().replace(/^['"]|['"]$/g, ''); break }
915
+ }
916
+ } else {
917
+ repoPath = raw
918
+ }
919
+ } else if (raw && typeof raw === 'object' && typeof raw.path === 'string' && raw.path) {
920
+ repoPath = raw.path
921
+ }
922
+ if (!repoPath) continue
923
+ const repoRoot = path.isAbsolute(repoPath) ? repoPath : path.resolve(projectRoot, repoPath)
891
924
  if (intended === repoRoot || isPathInside(intended, repoRoot)) {
892
925
  hits.push({ key, repoRoot, resolved: intended, shellResolved, target: t })
893
926
  break
package/src/index.js CHANGED
@@ -4139,7 +4139,8 @@ SillySpec platform — SillyHub 平台同步
4139
4139
  用法:
4140
4140
  sillyspec platform connect <url> [--token <token>]
4141
4141
  sillyspec platform disconnect
4142
- sillyspec platform sync [--change <name>]
4142
+ sillyspec platform sync [--change <name>] 带 --change 同步该变更进度六表+spec 树;
4143
+ 不带 --change 全树 spec 文件同步
4143
4144
  sillyspec platform sync-docs [--change <name>]
4144
4145
  sillyspec platform pull [--change <name>]
4145
4146
  sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>
@@ -4254,8 +4255,26 @@ SillySpec platform — SillyHub 平台同步
4254
4255
  case 'sync': {
4255
4256
  const syncChangeIdx = args.indexOf('--change');
4256
4257
  const syncChangeName = syncChangeIdx >= 0 && args[syncChangeIdx + 1] && !String(args[syncChangeIdx + 1]).startsWith("--") ? args[syncChangeIdx + 1] : null;
4257
- // manual:用户显式同步,change_deleted 回执噪音闸旁路(显式动作的回执必须可见)
4258
- await syncModule.sync(syncChangeName, dir, { manual: true });
4258
+ if (syncChangeName) {
4259
+ // manual:用户显式同步,change_deleted 回执噪音闸旁路(显式动作的回执必须可见)
4260
+ await syncModule.sync(syncChangeName, dir, { manual: true });
4261
+ } else {
4262
+ // 全树 spec 文件同步(2026-10-11-sync-conflict-tri-fix FR-03):不带 --change
4263
+ // 此前报「需要指定变更名称」——归档/历史变更无活跃会话标签时无从下手。现走
4264
+ // syncSpecTreeOnly(null) 全树 diff(以服务器清单为锚);进度六表仍按变更
4265
+ // 粒度走 --change。未连接明确报错不静默(对齐 platform pull 口径)。
4266
+ const smForCheck = new syncModule.SyncManager(dir);
4267
+ if (!smForCheck._getPlatform()) {
4268
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
4269
+ process.exit(1);
4270
+ }
4271
+ const r = await syncModule.syncSpecTreeOnly(null, dir, {});
4272
+ if (r && r.conflict) {
4273
+ console.error(`⚠️ 全树同步检测到冲突(${(r.serverVersions && Object.keys(r.serverVersions).length) || 0} 个文件待裁决),处理:sillyspec platform status 查看 / platform resolve <变更名> 处置`);
4274
+ process.exit(1);
4275
+ }
4276
+ console.log(`✅ 全树 spec 同步完成(${r ? r.synced : 0} 个文件差异)`);
4277
+ }
4259
4278
  break;
4260
4279
  }
4261
4280
  case 'sync-docs': {
@@ -1274,6 +1274,25 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1274
1274
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
1275
1275
  const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
1276
1276
  if (autoChange) {
1277
+ // 归档同名幽灵复活门(坑 archived-change-resurrect-by-merge,2026-10-10 实证:远端
1278
+ // merge 把已归档变更的旧四件套带回活跃路径,run verify 见活跃目录即自动注册新进度行、
1279
+ // 幽灵态推上平台)。活跃目录与 changes/archive/<同名>/ 并存且库内无该变更活跃行 →
1280
+ // 拒绝注册,指向两出口(认归档:清幽灵目录 / 真要重开:换新名或先恢复归档)。
1281
+ try {
1282
+ const _archiveDup = join(specBase, 'changes', 'archive', autoChange)
1283
+ if (existsSync(_archiveDup) && !progress) {
1284
+ let _activeRows = []
1285
+ try { _activeRows = pm.listChanges(cwd) } catch { /* 列举失败按无活跃行处置 */ }
1286
+ if (!_activeRows.includes(autoChange)) {
1287
+ console.error(`❌ 拒绝注册:${autoChange} 已有归档目录(changes/archive/${autoChange}/),当前活跃目录疑似 merge 带回的幽灵复活(库内无活跃行)。`)
1288
+ console.error(' 两个出口:')
1289
+ console.error(` ① 认归档(多数场景):git rm -r .sillyspec/changes/${autoChange} 清幽灵目录后提交;归档侧工件在 changes/archive/${autoChange}/`)
1290
+ console.error(' ② 确要重开同名变更:先换新变更名(推荐),或恢复归档(mv 回活跃目录并清理库内 archived 行)后再跑')
1291
+ console.error(' 核对:递归 diff 活跃 vs 归档目录确认归档侧为超集零信息损失(坑内处置实录)')
1292
+ process.exit(2)
1293
+ }
1294
+ }
1295
+ } catch { /* 守卫异常不拦正常注册(fail-open)*/ }
1277
1296
  // 创建时即写 title(与 name/KEY 同时落 db):--input 首行推导 deriveChangeTitle(≤50 字,
1278
1297
  // 用户需求 2026-09-29)优先,长输入兜底 sanitizeDesc 压行,无则用 name 兜底。
1279
1298
  // proposal 还没写、无权威标题来源,这是临时值;brainstorm/plan 完成 proposal/design 落盘后由
@@ -25,6 +25,7 @@
25
25
  import { existsSync, readFileSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { git, gitQuiet } from '../git-helper.js';
28
+ import { resolveCrossWorktreePathCandidates } from '../cross-placement.js';
28
29
 
29
30
  /**
30
31
  * @typedef {Object} RepoEntry
@@ -152,14 +153,19 @@ export class MultiRepoContext {
152
153
  `请补全路径(约束② fail-closed)。`
153
154
  );
154
155
  }
155
- // worktree 模式检测:meta 在 = worktree-cross.js 已建隔离(路径公式与其 crossWorktreePath
156
- // 保持同步;execute 启动时创建,晚于本构造的调用会命中)。
156
+ // worktree 模式检测:meta 在 = worktree-cross.js 已建隔离(寻址经 cross-placement 叶子模块
157
+ // resolveCrossWorktreePath——注册表优先、公式兜底;execute 启动时创建,晚于本构造的调用会
158
+ // 命中。不 import worktree-cross 避免 shared↔multi-repo-context 循环,落位逻辑下沉的
159
+ // 零依赖叶子模块两侧共用无环——坑 cross-wt-toolchain-split 评审吸收①)。
157
160
  const specBase = (this.platformOpts && this.platformOpts.specRoot) || join(this.cwd, '.sillyspec');
158
161
  let crossMeta = null;
159
162
  try {
160
- // 同步读(构造函数是同步的,不 import worktree-cross 避免 shared↔multi-repo-context 循环)
161
- const metaPath = join(specBase, '.runtime', 'worktrees', `${this.changeName}--${repoKey}`, 'meta.json');
162
- if (existsSync(metaPath)) crossMeta = JSON.parse(readFileSync(metaPath, 'utf8'));
163
+ // 同步读(构造函数是同步的);寻址候选逐级探测(注册表 → 仓内新公式 → 主仓 specBase 旧
164
+ // 公式 legacy 兜底)——无注册表条目的存量 worktree 在本读取面不失联
165
+ for (const cand of resolveCrossWorktreePathCandidates(specBase, this.changeName, repoKey, crossRepoPath)) {
166
+ const metaPath = join(cand, 'meta.json');
167
+ if (existsSync(metaPath)) { crossMeta = JSON.parse(readFileSync(metaPath, 'utf8')); break }
168
+ }
163
169
  } catch { crossMeta = null }
164
170
  const inWorktree = !!(crossMeta && crossMeta.isCross && crossMeta.worktreePath && existsSync(crossMeta.worktreePath));
165
171
  const workRoot = inWorktree ? crossMeta.worktreePath : crossRepoPath;
package/src/run/shared.js CHANGED
@@ -60,7 +60,12 @@ export function didYouMean(input, candidates) {
60
60
  // ── 变更名路径穿越消毒(F6)──
61
61
  // change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
62
62
  // 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
63
- // 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
63
+ // 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-/CJK,
64
+ // 禁分隔符与 ..
65
+ // 2026-10-11-sync-conflict-tri-fix FR-03:字符集扩 CJK(一-鿿,与下方 assertDatedChangeName
66
+ // slug 同集)——本守卫的本职是路径穿越消毒,ASCII 限制是历史误伤:磁盘既有中文名变更
67
+ //(实测 2026-10-10-取消LTC宽表校验规则)被拒致 platform sync/resolve 无法操作。
68
+ // 新建变更名仍受 assertDatedChangeName 日期前缀门约束,不因本放宽回归。
64
69
  export function assertSafeChangeName(name, label = '变更名') {
65
70
  if (name == null) return
66
71
  const s = String(name)
@@ -69,8 +74,8 @@ export function assertSafeChangeName(name, label = '变更名') {
69
74
  if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
70
75
  throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
71
76
  }
72
- if (!/^[\w.\-]+$/.test(s)) {
73
- throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
77
+ if (!/^[\w.\-一-鿿]+$/.test(s)) {
78
+ throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-、中文)`)
74
79
  }
75
80
  }
76
81
 
@@ -83,7 +88,8 @@ export function assertSafeChangeName(name, label = '变更名') {
83
88
  // 日期只校验形状(月 01-12 日 01-31),不校验「当天」——跨天续跑/次日重命名不应被拦。
84
89
  // slug 首字符放宽含 CJK(一-鿿,与 flow 族 validateChangeName 同字符集):flow start 2026-09-28
85
90
  // 起复用本校验器,中文名(平台键 2026-09-25-中文名-a1b2c3 形态)是 flow 族既有测试特性;
86
- // run 族不受影响——其上游 assertSafeChangeName 仅放行 ASCII [\w.\-],中文在到达本门之前已被拦。
91
+ // 上游 assertSafeChangeName 自 2026-10-11-sync-conflict-tri-fix 起同放行 CJK(此前 ASCII
92
+ // 限宽拦中文名,属历史误伤,见该守卫注释)。
87
93
  const DATED_CHANGE_NAME_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])-[a-zA-Z0-9一-鿿][\w.\-一-鿿]*$/
88
94
  export function assertDatedChangeName(name, label = '变更名') {
89
95
  if (name == null) return
@@ -480,7 +480,7 @@ const QL_ID_RE = /^ql-\d{8}-\d{3}-[0-9a-f]{4}$/
480
480
  * @param {string} specBase .sillyspec 根
481
481
  * @returns {string[]} repoKey 清单
482
482
  */
483
- function loadRegisteredRepoKeys(specBase) {
483
+ export function loadRegisteredRepoKeys(specBase) {
484
484
  try {
485
485
  const p = join(specBase || '', 'local.yaml')
486
486
  if (!specBase || !existsSync(p)) return []
@@ -1214,90 +1214,14 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1214
1214
  }
1215
1215
 
1216
1216
  // —— 计划侧跨仓段分仓真实对账(2026-09-20 scope-audit-cross-repo task-02,design Wave 2)——
1217
- // 旧形态「⊘ 行不在本仓对账面(实际侧只采主仓 git)」升级:plannedEntries 按 e.repo 分组,
1218
- // 每组调 collectRepoActual 共享内核(task-01)在**该仓**取 actual(锚点四级:A reviews-range
1219
- // > B head~1-window > C head-uncommitted-window > degraded),主仓同款三类差集出真实三态行
1220
- // + 行数;degraded 仓(未注册/路径不可达/git 双源失败)退 v1 ⊘ 形态(untouched + crossRepo)
1221
- // + 降级注记进 note,不炸主仓表(D-006 fail-soft)。
1222
- // 行数由本集成层对内核产物跑 collectNumstatByPath(内核不 import scope-audit 防环,评审
1223
- // G3——调用方职责);anchor.base 为 null(B/C 档降级锚)→ numstat 空 Map → degradedStat
1224
- // 该仓根兜底(行数 null 降级档,不出伪数据)。跨仓行过滤口径与主仓 actual 侧一致
1225
- // (filterDeliverableFiles:排 .sillyspec 运行时面/平台自装脚手架),unplanned 行的
1226
- // classifyToolScaffold 软桶与主仓 unplanned 行同款。
1227
- const crossRepoKeys = [...new Set(plannedEntries.map(e => e.repo).filter(Boolean))]
1228
- const crossRepoRows = []
1229
- const crossRepoEntries = []
1230
- if (crossRepoKeys.length > 0) {
1231
- const crossCount = plannedEntries.filter(e => e.repo).length
1232
- notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——已按 local.yaml repos 注册表分仓对账(各仓锚点档见 repos[].anchor)`)
1233
- for (const repoKey of crossRepoKeys) {
1234
- const entries = plannedEntries.filter(e => e.repo === repoKey)
1235
- const repoRows = []
1236
- let repoActual = null
1237
- let repoFailure = null
1238
- try {
1239
- repoActual = collectRepoActual({ repoKey, specBase: sb, cwd, runtimeRoot, changeName })
1240
- } catch (e) {
1241
- repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1242
- }
1243
- if (repoFailure || !repoActual || repoActual.degradedReason) {
1244
- // degraded 仓(内核 fail-soft 产物或调用点异常):v1 ⊘ 形态回退——该组行恒 untouched,
1245
- // 降级原因进 note 与 repos[] 条目(平台消费方可感知),主仓表不受影响。
1246
- const reason = repoFailure
1247
- ? `内核采集异常: ${repoFailure}`
1248
- : (repoActual && repoActual.degradedReason) || '跨仓采集不可用'
1249
- for (const e of entries) {
1250
- repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1251
- }
1252
- notes.push(`跨仓 ${repoKey} 对账降级: ${reason}`)
1253
- crossRepoEntries.push({
1254
- key: repoKey,
1255
- repoPath: repoActual ? repoActual.repoPath : null,
1256
- anchor: repoActual ? repoActual.anchor : { source: 'degraded', base: null, head: null, label: 'degraded' },
1257
- totals: repoTotals(repoRows),
1258
- degraded: true,
1259
- degradedReason: reason,
1260
- })
1261
- crossRepoRows.push(...repoRows)
1262
- continue
1263
- }
1264
- // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1265
- // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1266
- const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1267
- const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1268
- const repoMatched = new Set()
1269
- for (const f of repoFiles) {
1270
- const path = toPosix(f)
1271
- const st = repoStats.get(path) || degradedStat(repoActual.repoPath, path)
1272
- const row = { path, planned: null, additions: st.additions, deletions: st.deletions, kind: st.kind, verdict: 'unplanned', crossRepo: repoKey }
1273
- const entry = entries.find(e => pathMatches(path, e.path))
1274
- if (entry) {
1275
- repoMatched.add(entry.path)
1276
- row.planned = entry.operation || null
1277
- row.verdict = 'planned'
1278
- } else {
1279
- // 工具/平台脚手架软桶(主仓 unplanned 行同款):CLI/平台自装文件不占「计划外」逐条清单
1280
- const facility = classifyToolScaffold(path)
1281
- if (facility) row.facility = facility
1282
- }
1283
- repoRows.push(row)
1284
- }
1285
- // 声明未动:该仓清单文件无实际改动 → 补行(行数 0/0,主仓补行同款;crossRepo 恒带)
1286
- for (const e of entries) {
1287
- if (repoMatched.has(e.path)) continue
1288
- repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1289
- }
1290
- crossRepoEntries.push({
1291
- key: repoKey,
1292
- repoPath: repoActual.repoPath,
1293
- anchor: repoActual.anchor,
1294
- totals: repoTotals(repoRows),
1295
- degraded: false,
1296
- degradedReason: null,
1297
- })
1298
- crossRepoRows.push(...repoRows)
1299
- }
1300
- }
1217
+ // 集成逻辑抽共享导出 reconcileCrossRepoPlan(2026-10-10-cross-repo-patch-freeze D-002@v1):
1218
+ // heavy(本函数)与 thin(flow.js done)同源消费防口径漂移;本处行为等价(rows/repos/notes
1219
+ // 三产物原样并回)+ collectPatch 时 repos[].patch/patchSha256 增量。
1220
+ const crossRecon = reconcileCrossRepoPlan({ cwd, specBase: sb, runtimeRoot, changeName, planEntries: plannedEntries, collectPatch: !!collectPatch })
1221
+ const crossRepoRows = crossRecon.rows
1222
+ const crossRepoEntries = crossRecon.repos
1223
+ for (const n of crossRecon.notes) notes.push(n)
1224
+ const crossRepoKeys = crossRepoEntries.map(e => e.key)
1301
1225
 
1302
1226
  // —— 三态判定 ——
1303
1227
  const rows = []
@@ -1382,6 +1306,127 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1382
1306
  }
1383
1307
  }
1384
1308
 
1309
+ /**
1310
+ * 跨仓对账集成层(2026-10-10-cross-repo-patch-freeze D-002@v1,自 computeFullFlowAudit 内联段
1311
+ * 抽出——heavy(computeFullFlowAudit)与 thin(flow.js done)同源消费防两份口径漂移;复用
1312
+ * collectRepoActual 共享内核(2026-09-20「单一真相源」哲学))。
1313
+ *
1314
+ * planEntries 按 e.repo 分组,每组调 collectRepoActual 在**该仓**取 actual(锚点:A
1315
+ * reviews-range > B' worktree-baseline > B head~1-window > C head-uncommitted-window >
1316
+ * degraded),主仓同款三类差集出真实三态行 + 行数;degraded 仓退 ⊘ 形态(untouched +
1317
+ * crossRepo + degradedReason),不炸调用方表(D-006 fail-soft)。行数由本层对内核产物跑
1318
+ * collectNumstatByPath(内核不 import 本模块防环,评审 G3——调用方职责)。
1319
+ *
1320
+ * collectPatch=true 时每仓增 patch 采集(D-001@v1/D-003@v1):窗口与行数同根同窗——A/B' 档
1321
+ * 锚 hash 为 baseRef;B 档字面 HEAD~1、C 档字面 HEAD(工作树口径含 untracked 自拼 hunk);
1322
+ * buildFrozenPatch 失败或空窗 → patch=null 不出伪件;sha256 走 sha256PatchNormalized(LF 归一,
1323
+ * autocrlf 跨平台一致)。collectPatch=false 不产 patch 两键(additive 契约,既有读方零感知)。
1324
+ *
1325
+ * @param {{ cwd: string, specBase: string, runtimeRoot?: string|null, changeName?: string|null,
1326
+ * planEntries: Array<{path, operation?, repo?}>, collectPatch?: boolean }} args
1327
+ * @returns {{ rows: Array<object>, repos: Array<object>, notes: string[] }}
1328
+ * rows 带 crossRepo 标记(真实三态或 degraded ⊘);repos 条目
1329
+ * { key, repoPath, anchor, totals, degraded, degradedReason, patch?, patchSha256? };
1330
+ * notes 为调用方 note 链的增量行(跨仓计数行 + 降级行)。
1331
+ */
1332
+ export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName, planEntries, collectPatch = false }) {
1333
+ const rows = []
1334
+ const repos = []
1335
+ const notes = []
1336
+ const planned = Array.isArray(planEntries) ? planEntries : []
1337
+ const crossRepoKeys = [...new Set(planned.map(e => e.repo).filter(Boolean))]
1338
+ if (crossRepoKeys.length === 0) return { rows, repos, notes }
1339
+ const crossCount = planned.filter(e => e.repo).length
1340
+ notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——已按 local.yaml repos 注册表分仓对账(各仓锚点档见 repos[].anchor)`)
1341
+ for (const repoKey of crossRepoKeys) {
1342
+ const entries = planned.filter(e => e.repo === repoKey)
1343
+ const repoRows = []
1344
+ let repoActual = null
1345
+ let repoFailure = null
1346
+ try {
1347
+ repoActual = collectRepoActual({ repoKey, specBase, cwd, runtimeRoot, changeName })
1348
+ } catch (e) {
1349
+ repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1350
+ }
1351
+ if (repoFailure || !repoActual || repoActual.degradedReason) {
1352
+ // degraded 仓(内核 fail-soft 产物或调用点异常):⊘ 形态回退——该组行恒 untouched,
1353
+ // 降级原因进 note 与 repos[] 条目(平台消费方可感知),调用方主表不受影响。
1354
+ const reason = repoFailure
1355
+ ? `内核采集异常: ${repoFailure}`
1356
+ : (repoActual && repoActual.degradedReason) || '跨仓采集不可用'
1357
+ for (const e of entries) {
1358
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1359
+ }
1360
+ notes.push(`跨仓 ${repoKey} 对账降级: ${reason}`)
1361
+ repos.push({
1362
+ key: repoKey,
1363
+ repoPath: repoActual ? repoActual.repoPath : null,
1364
+ anchor: repoActual ? repoActual.anchor : { source: 'degraded', base: null, head: null, label: 'degraded' },
1365
+ totals: repoTotals(repoRows),
1366
+ degraded: true,
1367
+ degradedReason: reason,
1368
+ ...(collectPatch ? { patch: null, patchSha256: null } : {}),
1369
+ })
1370
+ rows.push(...repoRows)
1371
+ continue
1372
+ }
1373
+ // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1374
+ // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1375
+ const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1376
+ const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1377
+ const repoMatched = new Set()
1378
+ for (const f of repoFiles) {
1379
+ const path = toPosix(f)
1380
+ const st = repoStats.get(path) || degradedStat(repoActual.repoPath, path)
1381
+ const row = { path, planned: null, additions: st.additions, deletions: st.deletions, kind: st.kind, verdict: 'unplanned', crossRepo: repoKey }
1382
+ const entry = entries.find(e => pathMatches(path, e.path))
1383
+ if (entry) {
1384
+ repoMatched.add(entry.path)
1385
+ row.planned = entry.operation || null
1386
+ row.verdict = 'planned'
1387
+ } else {
1388
+ // 工具/平台脚手架软桶(主仓 unplanned 行同款):CLI/平台自装文件不占「计划外」逐条清单
1389
+ const facility = classifyToolScaffold(path)
1390
+ if (facility) row.facility = facility
1391
+ }
1392
+ repoRows.push(row)
1393
+ }
1394
+ // 声明未动:该仓清单文件无实际改动 → 补行(行数 0/0,主仓补行同款;crossRepo 恒带)
1395
+ for (const e of entries) {
1396
+ if (repoMatched.has(e.path)) continue
1397
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1398
+ }
1399
+ // patch 采集(D-001/D-003):窗口 = 行数窗口——锚 hash 优先;B/C 档降级锚用字面 ref
1400
+ // (head~1-window → HEAD~1;head-uncommitted-window → HEAD),工作树口径含 untracked。
1401
+ let patch = null
1402
+ let patchSha256 = null
1403
+ if (collectPatch && repoFiles.length > 0) {
1404
+ const patchBase = repoActual.anchor.base
1405
+ || (repoActual.anchor.source === 'head~1-window' ? 'HEAD~1'
1406
+ : repoActual.anchor.source === 'head-uncommitted-window' ? 'HEAD'
1407
+ : null)
1408
+ if (patchBase) {
1409
+ const text = buildFrozenPatch(repoActual.repoPath, repoFiles, { baseRef: patchBase })
1410
+ if (typeof text === 'string' && text.length > 0) {
1411
+ patch = text
1412
+ patchSha256 = sha256PatchNormalized(text)
1413
+ }
1414
+ }
1415
+ }
1416
+ repos.push({
1417
+ key: repoKey,
1418
+ repoPath: repoActual.repoPath,
1419
+ anchor: repoActual.anchor,
1420
+ totals: repoTotals(repoRows),
1421
+ degraded: false,
1422
+ degradedReason: null,
1423
+ ...(collectPatch ? { patch, patchSha256 } : {}),
1424
+ })
1425
+ rows.push(...repoRows)
1426
+ }
1427
+ return { rows, repos, notes }
1428
+ }
1429
+
1385
1430
  /**
1386
1431
  * 变更范围对账纯函数(scope-audit 命令与三处阶段注入的唯一数据源,D-003)。
1387
1432
  *
package/src/spec-sync.js CHANGED
@@ -594,11 +594,29 @@ export function isFollowerSetChanged(specRoot, key, list) {
594
594
  * @param {string} changeName - 当前变更名
595
595
  * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
596
596
  */
597
+ /**
598
+ * 冲突路径 → 变更名归因标签(2026-10-11-sync-conflict-tri-fix FR-03 全树同步入口用):
599
+ * changes/(archive/)?<名>/ 前缀提取,全部路径归因到同一变更 → 用该变更名(冲突文件
600
+ * spec-sync-conflict-<名>.json 与 per-change 流程兼容,resolve/status UX 不变);
601
+ * 跨多变更或无可归因路径 → 'full-tree'(单文件合并裁决,resolve 整体 force-push)。
602
+ */
603
+ function attributeChangeLabel(paths) {
604
+ const names = new Set()
605
+ for (const p of paths) {
606
+ const m = p.match(/^changes\/(?:archive\/)?([^/]+)\//)
607
+ if (m) names.add(m[1])
608
+ }
609
+ return names.size === 1 ? [...names][0] : 'full-tree'
610
+ }
611
+
597
612
  export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
598
613
  if (!platform || !platform.url || !platform.token) {
599
614
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
600
615
  return { synced: 0 };
601
616
  }
617
+ // 全树入口(platform sync 无 --change,2026-10-11-sync-conflict-tri-fix FR-03)
618
+ // changeName=null:日志用 full-tree 标签,冲突归因见下方 conflictLabel。
619
+ const logLabel = changeName || 'full-tree';
602
620
  // 绑定连接类失败噪音闸(sync-noise.js):本目录所有 [spec-sync]/[sync] 失败 warn 的
603
621
  // 跨进程静默窗口 marker 落这里(与 SyncManager 构造器绑定同目录)。
604
622
  try { bindSyncNoiseRoot(join(specRoot, '.runtime')); } catch { /* 直通降级 */ }
@@ -622,7 +640,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
622
640
  // 8 分钟才被后续步骤的同步补上)。失败可见、成功不打扰。
623
641
  // 连接类失败(404=端点未就绪 / 5xx)走噪音闸(sync-noise.js):平台未就绪期每步
624
642
  // 自动同步重刷同一行,淹没门禁输出;其余状态码(4xx 业务态)每轮该看见。
625
- const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
643
+ const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
626
644
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
627
645
  else console.warn(line);
628
646
  return { synced: 0 };
@@ -630,7 +648,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
630
648
  const body = await res.json().catch(() => ({}));
631
649
  serverManifest = body.files || {};
632
650
  } catch (err) {
633
- const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
651
+ const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
634
652
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
635
653
  return { synced: 0 };
636
654
  }
@@ -671,7 +689,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
671
689
  }
672
690
 
673
691
  if (ops.length === 0) {
674
- debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
692
+ debugLog(`[spec-sync] 无差异,跳过同步: ${logLabel}`);
675
693
  clearStaleTombstoneMarkers(specRoot);
676
694
  return { synced: 0 };
677
695
  }
@@ -688,7 +706,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
688
706
  signal: withSignal(30000),
689
707
  });
690
708
  if (!res.ok) {
691
- const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
709
+ const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
692
710
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
693
711
  else console.warn(line);
694
712
  return { synced: 0 };
@@ -719,6 +737,9 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
719
737
  ? { followers: [], real: serverVersions }
720
738
  : partitionConflictPaths(serverVersions, localFiles, baseHashes, lastSyncTs);
721
739
  const realPaths = Object.keys(real).filter((p) => !tombSet.has(p));
740
+ // 全树入口(changeName=null)冲突归因:单一变更 → 该变更名(冲突文件/markers 与
741
+ // per-change 流程同名兼容);跨变更 → 'full-tree' 单文件合并裁决。
742
+ const conflictLabel = changeName || attributeChangeLabel(realPaths);
722
743
 
723
744
  // 纯墓碑形态:版本冲突面为空、拒收全是墓碑 → 不写/不清通用冲突标记(clear 会把
724
745
  // 状态洗成「已消解」误导 status),单独落冲突文件 + 正确恢复指引。
@@ -743,13 +764,13 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
743
764
  // 自愈」连环刷屏。冲突文件在且冲突集未变 = 用户已被告知:重复轮次单行提示(可见但不
744
765
  // 吓人),不重写文件(created_at 稳定,status 红标语义不变);冲突集变化才重写+全幅。
745
766
  // forcePush(resolve --keep-local 刚裁决完重推)不参与去重——需真实判定与文件刷新。
746
- const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, changeName, realPaths);
767
+ const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, conflictLabel, realPaths);
747
768
  if (pendingSame) {
748
769
  if (followers.length > 0) {
749
770
  debugLog(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器: ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
750
771
  }
751
- console.warn(`⚠️ [spec-sync] 变更 ${changeName} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
752
- return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${changeName}.json`), suppressed: true };
772
+ console.warn(`⚠️ [spec-sync] 变更 ${conflictLabel} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort`);
773
+ return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${conflictLabel}.json`), suppressed: true };
753
774
  }
754
775
  if (followers.length > 0) {
755
776
  console.warn(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器(正常多端前进,无需人工裁决): ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
@@ -757,18 +778,18 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
757
778
  if (realPaths.length === 0) {
758
779
  writeLastSyncTs(specRoot);
759
780
  writeBaseSnapshot(specRoot, localFiles);
760
- clearSpecConflictMarker(specRoot, changeName);
781
+ clearSpecConflictMarker(specRoot, conflictLabel);
761
782
  noteSyncSuccess();
762
- console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${changeName}`);
783
+ console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${conflictLabel}`);
763
784
  return { synced: Math.max(ops.length - followers.length, 0), conflict: false, autoResolved: followers.length };
764
785
  }
765
786
  let conflictPath = null;
766
787
  try {
767
788
  const runtimeDir = join(specRoot, '.runtime');
768
789
  mkdirSync(runtimeDir, { recursive: true });
769
- conflictPath = join(runtimeDir, `spec-sync-conflict-${changeName}.json`);
790
+ conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
770
791
  writeFileSync(conflictPath, JSON.stringify({
771
- change: changeName,
792
+ change: conflictLabel,
772
793
  kind: 'spec-tree',
773
794
  created_at: new Date().toISOString(),
774
795
  server_versions: real,
@@ -786,20 +807,28 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
786
807
  console.warn('');
787
808
  console.warn(`⚠️ [spec-sync] 检测到 spec 树冲突(${realPaths.length} 个文件: ${shownPaths};逐文件服务器版本见冲突详情文件)`);
788
809
  if (tombNote) console.warn(`⚠️ [spec-sync]${tombNote.trim()}`);
789
- console.warn(`⚠️ 处置:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
810
+ console.warn(`⚠️ 处置:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
790
811
  return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath };
791
812
  }
792
813
  noteSyncSuccess();
793
- console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
814
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${logLabel}`);
794
815
  // 本轮无冲突 = 此前的未决 spec 树冲突已不存在,清残留标记(否则 status 永久红标、
795
- // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)
796
- clearSpecConflictMarker(specRoot, changeName);
816
+ // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)。全树入口
817
+ // (changeName=null,2026-10-11-sync-conflict-tri-fix FR-03)按归因清:只清本轮
818
+ // ops 触及变更的标记——未触及变更的未决冲突不因全树绿而误清。
819
+ if (changeName) {
820
+ clearSpecConflictMarker(specRoot, changeName);
821
+ } else {
822
+ for (const name of new Set(ops.map((o) => attributeChangeLabel([o.path])))) {
823
+ if (name !== 'full-tree') clearSpecConflictMarker(specRoot, name);
824
+ }
825
+ }
797
826
  clearStaleTombstoneMarkers(specRoot);
798
827
  writeLastSyncTs(specRoot);
799
828
  writeBaseSnapshot(specRoot, localFiles);
800
829
  return { synced: ops.length };
801
830
  } catch (err) {
802
- const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
831
+ const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
803
832
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
804
833
  return { synced: 0 };
805
834
  }