sillyspec 3.32.4 → 3.32.6

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/flow.js CHANGED
@@ -759,14 +759,19 @@ export async function cmdFlowStart({ change, input, title: titleFlag = null, thi
759
759
  `【协议调用 1/2(本次)】change 已建 + 基线锚定(baseline_commit=${baseline ? baseline.slice(0, 10) : '(无 git 历史)'})${withTasks ? ' + 任务卡模式(--with-tasks:中间自愿用 task done,收尾仍 flow done)' : ''}`,
760
760
  ``,
761
761
  `【你要做的】直接干活:改代码、写测试。治理工件不用你写——flow done 机器做(协议记账单位=change 级)。`,
762
+ // 收口拒收红线前置(2026-10-11-flow-start-redline-frontload):钉死/空槽/绑定三面都是文档
763
+ // 先行校验,实证第一次最容易踩(四问文本被改写、区间 task-NN 写法零证据被拒)——横幅
764
+ // 头部先行告知,收口才暴露的规则前置到动笔前。
765
+ `🚫 收口拒收红线(文档先行校验,动笔前先读——第一次最容易踩的就是这三条):`,
766
+ ` ① design 四问文本与 FR 标题锚逐字钉死勿改写——问题原文保持原样、答案写在问题下方,改写/删除即收口拒收(文档↔锚逐字对比);`,
767
+ ` ② design.md 四节(做法/接口契约/边界并发四问/风险)每节至少一行——空节拒收(写「不适用:<理由>」也算答);`,
768
+ ` ③ requirements 每条 FR 须有带强度词的行为句(必须/禁止/SHOULD)+ 测试绑定行——缺任一即拒收。`,
762
769
  `书写面(纯 markdown 起草):requirements FR 正文/design 四节作答/测试绑定行都直接写正文——`,
763
- ` 文档内零指纹标记、零槽注释、零书写指导(规则以本横幅与命令卡为唯一源);`,
764
- ` 标题锚(FR 标题、四问文本)勿改写,收口做文档↔锚对比(锚失踪/空答拒收,成功标准漂移出 advisory)。`,
765
- `⚠️ design.md 四节(做法/接口契约/边界并发四问/风险)动码前后顺手作答——答案写在问题下方,`,
766
- ` 每节至少一行,写「不适用:<理由>」也算答;flow done 空节拒收(承诺锚点,评审与 FR 对账都对着它);`,
767
- ` 列改动文件用独立「## 文件变更清单」节+表格(| 操作 | 路径 | 说明 |)——勿写在「接口契约」节内(收口按节名解析声明面)。`,
768
- `📜 requirements 每条 FR = 标题锚 + 一句带强度词的行为规定(必须/禁止/SHOULD/可以)+ 按需场景块`,
769
- ` (#### 场景:名 + Given/When/Then);测试绑定节每条 FR 一行 \`FR-NN: test/路径「用例」\`。`,
770
+ ` 文档内零指纹标记、零槽注释、零书写指导(规则以本横幅与命令卡为唯一源);成功标准漂移只出 advisory 不拒收。`,
771
+ `⚠️ design.md 四节动码前后顺手作答;列改动文件用独立「## 文件变更清单」节+表格(| 操作 | 路径 | 说明 |)`,
772
+ ` ——勿写在「接口契约」节内(收口按节名解析声明面)。`,
773
+ `📜 requirements 每条 FR = 标题锚 + 行为句 + 按需场景块(#### 场景:名 + Given/When/Then);`,
774
+ ` 测试绑定节每条 FR 一行 \`FR-NN: test/路径「用例」\`(无测试面写「不适用:理由」)。`,
770
775
  `📦 冻结面在 flow done 时点采集(baseline..HEAD 提交面):未提交交付文件——会话专属 worktree 自动并入;`,
771
776
  ` 共享主仓可带 --freeze-dirty 显式声明并入;git 中间提交归档后可 reset --soft 压扁为单提交`,
772
777
  ` (审计真相在 change.patch 冻结件 sha256 锚定,不依赖 git 历史形态)。`,
@@ -781,7 +786,7 @@ export async function cmdFlowStart({ change, input, title: titleFlag = null, thi
781
786
  ` 执行循环:对每个未勾行 Working on task N/M → 做一件 → 测试绿后当场勾这一格 → 下一行——CLI 以 checkbox 为进度状态机`,
782
787
  ` (sillyspec task tick --change ${change} --task task-NN 即时回显进度 N/M 与下一任务指针,flow status 同源;或直接 Edit 翻格 \`- [ ]\`→\`- [x]\`),watcher 实时上平台。`,
783
788
  ` ⚠️ harness 的 TodoWrite 类工具是会话内便利面,不替代 tasks.md——平台进度/收口证据只读它(0/12 事故实证:todo 全 completed 而 tasks.md 0 勾);`,
784
- ` 勾选是收口证据面:每格勾选要有对应提交(消息带 task-NN)或 review.json,全勾但零证据会被拒收;勿攒一把勾(收口硬门拒单拍多格);`,
789
+ ` 勾选是收口证据面:每格勾选要有对应提交(消息带 task-NN——逐个列出或 /、,连写如 task-01/02/03,「task-01..task-05」区间写法不认)或 review.json,全勾但零证据会被拒收;勿攒一把勾(收口硬门拒单拍多格);`,
785
790
  ` flow status --change ${change} 为自愿查看/恢复面(恢复时给下一任务指针与进度)。`,
786
791
  ``,
787
792
  `🛑 三断点纪律(可控性要求——用户没说「全跑完」就必须在每个断点向用户汇报并等确认):`,
@@ -840,6 +845,9 @@ function printRecoveryBriefing({ cwd, specBase, change, changeDir, runtimeRoot,
840
845
  ...(title ? [`- 标题:${title}`] : []),
841
846
  `- 做到哪:任务勾选 ${checked}/${total};基线以来提交 ${commits} 个;变更/dirty 文件 ${dirty} 个;P2 质量扫描记录 ${hasLedger ? '在场' : '无'}。`,
842
847
  `- 六子步标记:${done.length ? `已完成 ${done.join('/')}` : '(无)'}${left.length ? `;待办 ${left.join('/')}` : ';全部完成'}`,
848
+ // 红线重申(2026-10-11-flow-start-redline-frontload):重入不出首进横幅——钉死规则与
849
+ // task-NN token 形态随简报补位,断点续跑生命周期内规则不丢。
850
+ `- 红线重申:design 四问文本/FR 标题锚逐字钉死勿改写(改写即拒收);勾选证据提交消息逐个带 task-NN 或 /、,连写(「task-01..task-05」区间写法不认)。`,
843
851
  `- 剩什么:${left.length === 0 && dirty === 0 ? '活已干完' : dirty > 0 ? '活未干完(继续改代码)' : '收尾待裁决'}`,
844
852
  `- 下一步:${left.length === 0 ? `sillyspec flow done --change ${change}` : `继续干活;干完跑 flow done(断点续)`}`,
845
853
  ...(digestLines.length > 0 ? ['', ...digestLines] : []),
@@ -1034,8 +1042,10 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1034
1042
  // 机器稿基线做镜像豁免(route-hindsight 基线快照仍供改写比指标,与哨兵解耦)。
1035
1043
  const sent = detectFakeCheckCompletion({ changeDir, tasksMd: readFileSync(tasksPath, 'utf8'), commits: commitMessages })
1036
1044
  if (sent.status === 'fake') {
1037
- console.error(`🚫 哨兵断言拒收:tasks.md 全勾(${sent.checked}/${sent.claimTotal})但 ${sent.missing.length} 个任务零完成证据(区间提交标题与正文均无 token、无 review.json):${sent.missing.join('、')}`)
1038
- console.error(' 补证据(提交标题或正文带 task-NN,或产 review.json)或取消勾选后重跑——假完成主张不许过门')
1045
+ console.error(`🚫 哨兵断言拒收:tasks.md 全勾(${sent.checked}/${sent.claimTotal})但 ${sent.missing.length} 个任务零完成证据(baseline..HEAD 提交标题与正文均无 token、无 review.json):${sent.missing.join('、')}`)
1046
+ // token 合法形态写明(2026-10-11-flow-start-redline-frontload):实证「task-01..task-05」
1047
+ // 区间写法被判零证据时才知道格式约束——拒收文案自释,减少一轮试错。
1048
+ console.error(' 补证据(提交标题或正文带 task-NN——逐个列出或 /、,连写(如 task-01/02/03),「task-01..task-05」区间写法不认;或产 review.json)或取消勾选后重跑——假完成主张不许过门')
1039
1049
  appendTelemetry({ sentinel: 'fake', missing: sent.missing.length })
1040
1050
  reportMidFail('ledger')
1041
1051
  process.exit(1)
@@ -1486,10 +1496,34 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1486
1496
  let planEntries = []
1487
1497
  try {
1488
1498
  const { parseFileChangeListDetailed } = await import('./change-list.js')
1489
- planEntries = parseFileChangeListDetailed(join(changeDir, 'design.md'), { keepSillyspecDocs: true })
1499
+ // repoKeys(2026-10-10-cross-repo-patch-freeze D-002@v1):注册仓清单传入——跨仓
1500
+ // 子段/cross-repo: 前缀条目才带 repo 标注(不传则跨仓声明全落主仓口径, thin 跨仓
1501
+ // 对账无从谈起)
1502
+ const { loadRegisteredRepoKeys } = await import('./scope-audit.js')
1503
+ planEntries = parseFileChangeListDetailed(join(changeDir, 'design.md'), { keepSillyspecDocs: true, repoKeys: loadRegisteredRepoKeys(specBase) })
1490
1504
  } catch { /* 清单缺失/不可解析 → 实际侧 only 快照行(无 verdict,与主链路降级同语义) */ }
1505
+ // 跨仓声明在场 → 共享集成层分仓对账 + patch 冻结(D-001/D-002@v1):与 heavy 通道
1506
+ // (execute --done)同源 reconcileCrossRepoPlan,repos[](锚点/计数/patch 正文)
1507
+ // 随 snapObj 进 change-patch.json;任何异常 fail-soft 退 ⊘ 补行现行为(不阻断收口)
1508
+ let crossRepos = []
1509
+ let crossRepoRows = undefined
1510
+ if (planEntries.some((e) => e && e.repo)) {
1511
+ try {
1512
+ const { reconcileCrossRepoPlan } = await import('./scope-audit.js')
1513
+ const recon = reconcileCrossRepoPlan({
1514
+ cwd, specBase,
1515
+ runtimeRoot: resolveRuntimeRoot(runtimeRootOpt ? { runtimeRoot: runtimeRootOpt } : {}, specBase),
1516
+ changeName: change,
1517
+ planEntries,
1518
+ collectPatch: true,
1519
+ })
1520
+ crossRepoRows = recon.rows
1521
+ crossRepos = recon.repos
1522
+ for (const n of recon.notes) console.log(` 📎 ${n}`)
1523
+ } catch { /* 跨仓对账失败 → 退 ⊘ 补行(crossRepoRows 保持 undefined) */ }
1524
+ }
1491
1525
  const { buildThinSnapshotRows, writeCloseTraceArtifacts } = await import('./flow-parity.js')
1492
- const { rows: snapRows, totals: snapTotals } = buildThinSnapshotRows({ ownFiles, stats, planEntries })
1526
+ const { rows: snapRows, totals: snapTotals } = buildThinSnapshotRows({ ownFiles, stats, planEntries, crossRepoRows })
1493
1527
  const closeTrace = writeCloseTraceArtifacts({
1494
1528
  changeDir,
1495
1529
  change,
@@ -1507,6 +1541,7 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1507
1541
  excluded: { foreignDeclared: [] },
1508
1542
  note: 'flow done 时点冻结(本文件落盘时采集)',
1509
1543
  closedBy: 'flow done',
1544
+ ...(crossRepos.length > 0 ? { repos: crossRepos } : {}),
1510
1545
  },
1511
1546
  meta: {
1512
1547
  note: 'flow done 时点冻结(本变更可归属面:baseline..HEAD 提交面过滤 .sillyspec/ 非本变更目录但保留 .sillyspec/docs/ 交付文档 + 本变更目录工作树件;含未提交与 untracked,排除 flow-state.yaml 运行态)',
@@ -1519,6 +1554,11 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1519
1554
  },
1520
1555
  })
1521
1556
  console.log(`📦 变更 patch 留档(单套):change.patch + change-patch.json(${ownFiles.length} 文件,+${additions}/-${deletions},对账面并入 scopeAudit 子对象)${closeTrace.patchStatus === 'ok' ? ',sha256 已锚' : '——patch 采集失败已留痕'}`)
1557
+ if (crossRepos.length > 0) {
1558
+ const frozenRepos = crossRepos.filter((r) => r && r.patch).length
1559
+ const degradedRepos = crossRepos.filter((r) => r && r.degraded).length
1560
+ console.log(` 📦 跨仓 patch 冻结:${crossRepos.length} 仓(正文 ${frozenRepos}、降级 ${degradedRepos})——锚点/计数/diff 正文见 change-patch.json scopeAudit.repos[]`)
1561
+ }
1522
1562
  // design 声明面自证(2026-09-25-platform-feedback-batch2 E):design.md 文件变更清单
1523
1563
  // 声明的交付文件是否都在冻结面——不在=承诺改了但没交付(承诺未兑现面,advisory 不
1524
1564
  // 阻断——可能是范围裁剪了但 design 没同步更新)
@@ -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': {
@@ -469,8 +469,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
469
469
  });
470
470
  }
471
471
  }
472
- // e2. stage review 在场探测(verify/execute --done 的 Stage Review Gate)
473
- try {
472
+ // e2. stage review 在场探测(execute --done 的 Stage Review Gate——verify 不在其列,
473
+ // 坑 stage-review-verify-enum-gap 2026-10-11:gate 实际只拦 brainstorm/plan/execute,
474
+ // register 枚举亦无 verify,verify 阶段不产 stage review 期望;verify 的真实门面是 e1 对账)
475
+ if (stage === 'execute') try {
474
476
  const { getLatestStageReviewRunId } = await import('./stage-review.js');
475
477
  const rrRoot = runtimeRoot || join(specRoot, '.runtime');
476
478
  const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
@@ -479,7 +481,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
479
481
  code: checkCode('full-stage-review') || 'full_stage_review_missing',
480
482
  ok: !!runId,
481
483
  errors: !runId
482
- ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——--done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
484
+ ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——execute --done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
483
485
  : [],
484
486
  data: { runId: runId || null },
485
487
  });
@@ -500,7 +502,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
500
502
  // (同一 getLatestStageReviewRunId 只读 marker 存在性,秒级):缺 review → warning 带
501
503
  // register 指引;在场 → 静默 ok。独立 check id 不与 --full 的 full-stage-review 碰撞,
502
504
  // error 语义(--done 拦)仍归 --full 档与 --done 本体。
503
- if (!full && (stage === 'verify' || stage === 'execute')) {
505
+ // stage 期望面对齐(坑 stage-review-verify-enum-gap,2026-10-11 实证):Stage Review Gate
506
+ // 实际只拦 brainstorm/plan/execute(gates.js),register-stage-review 枚举亦无 verify——
507
+ // verify 阶段不产 stage review 提示(提示命令不可执行 + 指称的门禁不存在,双重误导)。
508
+ if (!full && stage === 'execute') {
504
509
  try {
505
510
  const { getLatestStageReviewRunId } = await import('./stage-review.js');
506
511
  const rrRoot = runtimeRoot || join(specRoot, '.runtime');
@@ -511,7 +516,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
511
516
  ok: true,
512
517
  informational: true,
513
518
  warnings: runId ? [] : [
514
- `缺 ${stage} 阶段的 stage review.json——verify/execute --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
519
+ `缺 ${stage} 阶段的 stage review.json——execute --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
515
520
  ],
516
521
  data: { runId: runId || null },
517
522
  });
@@ -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
  *