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/.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-review.js +11 -2
- package/src/flow.js +52 -12
- package/src/hooks/worktree-guard.js +35 -2
- package/src/index.js +22 -3
- package/src/machine-interface.js +10 -5
- 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
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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}
|
|
1038
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/machine-interface.js
CHANGED
|
@@ -469,8 +469,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
469
469
|
});
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
|
-
// e2. stage review 在场探测(
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
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——
|
|
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
|
});
|
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
|
*
|