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.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -3
- package/.claude/skills/sillyspec-explore/SKILL.md +2 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +116 -0
- package/package.json +1 -1
- package/src/config-schema.js +8 -0
- package/src/cross-placement.js +246 -0
- package/src/cross-repo-reconcile.js +8 -5
- package/src/doctor-diagnostics.js +21 -1
- package/src/flow-parity.js +14 -1
- package/src/flow.js +32 -2
- package/src/hooks/worktree-guard.js +35 -2
- package/src/index.js +22 -3
- package/src/run/command.js +19 -0
- package/src/run/multi-repo-context.js +11 -5
- package/src/run/shared.js +10 -4
- package/src/scope-audit.js +130 -85
- package/src/spec-sync.js +45 -16
- package/src/stages/plan-postcheck.js +67 -9
- package/src/sync.js +3 -0
- package/src/verify-postcheck.js +56 -8
- package/src/verify-probes.js +14 -7
- package/src/worktree-apply.js +62 -8
- package/src/worktree-cross.js +188 -27
- package/src/worktree-deps.js +50 -9
|
@@ -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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
4258
|
-
|
|
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': {
|
package/src/run/command.js
CHANGED
|
@@ -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
|
|
156
|
-
//
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
package/src/scope-audit.js
CHANGED
|
@@ -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
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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}(文件树本次未同步,下次自动重试): ${
|
|
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] 拉取清单异常(文件树本次未同步,下次自动重试): ${
|
|
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] 无差异,跳过同步: ${
|
|
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}(文件树本次未同步,下次自动重试): ${
|
|
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,
|
|
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] 变更 ${
|
|
752
|
-
return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${
|
|
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,
|
|
781
|
+
clearSpecConflictMarker(specRoot, conflictLabel);
|
|
761
782
|
noteSyncSuccess();
|
|
762
|
-
console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${
|
|
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-${
|
|
790
|
+
conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
|
|
770
791
|
writeFileSync(conflictPath, JSON.stringify({
|
|
771
|
-
change:
|
|
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 ${
|
|
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} 个文件变更: ${
|
|
814
|
+
console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${logLabel}`);
|
|
794
815
|
// 本轮无冲突 = 此前的未决 spec 树冲突已不存在,清残留标记(否则 status 永久红标、
|
|
795
|
-
// 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam
|
|
796
|
-
|
|
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] 同步异常(文件树本次未同步,下次自动重试): ${
|
|
831
|
+
const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
|
|
803
832
|
syncConnectionWarn(line); // 网络/超时/熔断均连接类
|
|
804
833
|
return { synced: 0 };
|
|
805
834
|
}
|