sillyspec 3.28.6 → 3.28.8
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-execute/SKILL.md +224 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
- package/.husky/pre-push +3 -1
- package/.patch16-tmp.mjs +169 -0
- package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
- package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
- package/meta.json +11 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +13 -0
- package/src/constants.js +3 -0
- package/src/db.js +12 -3
- package/src/decision-distill.js +6 -2
- package/src/dispatch/probe.js +8 -1
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +3 -0
- package/src/doctor-diagnostics.js +19 -7
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +59 -29
- package/src/index.js +168 -64
- package/src/knowledge-match.js +90 -6
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +58 -26
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +26 -0
- package/src/quicklog.js +44 -18
- package/src/review-dispatch.js +7 -1
- package/src/run/command.js +17 -3
- package/src/run/complete-handlers.js +40 -10
- package/src/run/complete.js +48 -17
- package/src/run/concurrent-detect.js +1 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +35 -5
- package/src/run/prompt.js +61 -1
- package/src/run/quick-audit.js +80 -4
- package/src/run/shared.js +98 -10
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scope-audit.js +89 -14
- package/src/semantic-guard.js +207 -0
- package/src/sillyhub-mcp/client.js +14 -0
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-review.js +6 -5
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/execute.js +6 -1
- package/src/stages/plan-postcheck.js +57 -12
- package/src/sync.js +66 -4
- package/src/task-review.js +41 -6
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +21 -6
- package/src/verify-probes.js +8 -3
- package/src/worktree-apply.js +56 -18
- package/src/worktree-cross.js +12 -18
- package/src/worktree.js +138 -25
- package/src/wt-commit.js +128 -0
|
@@ -840,10 +840,21 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
|
|
|
840
840
|
|
|
841
841
|
/**
|
|
842
842
|
* design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
|
|
843
|
-
* 段头为 h2,repo 名取段头首 token
|
|
843
|
+
* 段头为 h2/h3,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀、(…)备注后缀
|
|
844
|
+
* (`(跨仓,X1-X4)` 等,全/半角括号均可)与尾随空白/冒号——后缀不容忍时(用户实证
|
|
845
|
+
* 2026-08-29)`### sillyspec 仓变更(跨仓,X1-X4)` 整段判 main、跨仓文件全量误报未覆盖。
|
|
844
846
|
* 不命中(非仓变更段头)→ null。
|
|
845
847
|
*/
|
|
846
|
-
const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s
|
|
848
|
+
const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 疑似段头宽匹配:标题行含「<token> 仓变更」字样(token 与仓变更间有空白)。
|
|
852
|
+
* 宽匹配命中而严格式(REPO_SECTION_HEADER_RE)不命中 = 段头意图明确但格式解析不了
|
|
853
|
+
* (repo-key 含中文等非法字符 / 仓变更后接自由文本)。此时该段文件会被错记到上一段仓
|
|
854
|
+
* (或 main)→ 覆盖对账整段错位,validateDesignFileCoverage 据此点名报错,
|
|
855
|
+
* 不让用户对着「未覆盖」清单反推是段头问题。
|
|
856
|
+
*/
|
|
857
|
+
const REPO_SECTION_HEADER_LOOSE_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?\S+\s+仓变更/
|
|
847
858
|
|
|
848
859
|
/**
|
|
849
860
|
* design §6 文件清单章节标题(与 change-list.js FILE_LIST_SECTION_RE 同源,避免 import 私有常量)。
|
|
@@ -867,19 +878,20 @@ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变
|
|
|
867
878
|
* 外加 _hasSegmentHeader 标记(调用方据此决定是否走分段对账路径)
|
|
868
879
|
*
|
|
869
880
|
* @param {string} designPath - design.md 绝对路径
|
|
870
|
-
* @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[] }}
|
|
881
|
+
* @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[], malformedHeaders: string[] }}
|
|
871
882
|
*/
|
|
872
883
|
function parseDesignCoverageByRepo(designPath) {
|
|
873
884
|
const byRepo = new Map()
|
|
874
885
|
const allFiles = []
|
|
886
|
+
const malformedHeaders = []
|
|
875
887
|
if (!designPath || !existsSync(designPath)) {
|
|
876
|
-
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
888
|
+
return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
|
|
877
889
|
}
|
|
878
890
|
const content = readFileSync(designPath, 'utf8')
|
|
879
891
|
|
|
880
892
|
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
881
893
|
if (!sectionMatch) {
|
|
882
|
-
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
894
|
+
return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
|
|
883
895
|
}
|
|
884
896
|
|
|
885
897
|
// 主章节起点(match index)→ 扫描到下一个非段头的 `## ` 标题或文件末尾。
|
|
@@ -891,10 +903,14 @@ function parseDesignCoverageByRepo(designPath) {
|
|
|
891
903
|
for (let i = 0; i < lines.length; i++) {
|
|
892
904
|
const line = lines[i]
|
|
893
905
|
if (i > 0 && /^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) {
|
|
894
|
-
// 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→
|
|
906
|
+
// 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束。
|
|
907
|
+
// 但若它疑似仓变更段头(宽匹配命中),先记格式错误再结束——静默截断会让
|
|
908
|
+
// 该段文件整段脱离对账基准,比报错更危险。
|
|
909
|
+
if (REPO_SECTION_HEADER_LOOSE_RE.test(line)) malformedHeaders.push(line.trim())
|
|
895
910
|
break
|
|
896
911
|
}
|
|
897
912
|
if (REPO_SECTION_HEADER_RE.test(line)) hasSegmentHeader = true
|
|
913
|
+
else if (REPO_SECTION_HEADER_LOOSE_RE.test(line)) malformedHeaders.push(line.trim())
|
|
898
914
|
sectionLines.push(line)
|
|
899
915
|
}
|
|
900
916
|
|
|
@@ -905,7 +921,7 @@ function parseDesignCoverageByRepo(designPath) {
|
|
|
905
921
|
const main = new Set(parseFileChangeList(designPath, { keepSillyspecDocs: true }))
|
|
906
922
|
if (main.size > 0) byRepo.set('main', main)
|
|
907
923
|
for (const p of main) allFiles.push(p)
|
|
908
|
-
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
924
|
+
return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
|
|
909
925
|
}
|
|
910
926
|
|
|
911
927
|
// 有段头 → 按 repo 切片,每段构造临时 design 调 parseFileChangeList
|
|
@@ -951,7 +967,7 @@ function parseDesignCoverageByRepo(designPath) {
|
|
|
951
967
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
952
968
|
}
|
|
953
969
|
|
|
954
|
-
return { byRepo, hasSegmentHeader: true, allFiles }
|
|
970
|
+
return { byRepo, hasSegmentHeader: true, allFiles, malformedHeaders }
|
|
955
971
|
}
|
|
956
972
|
|
|
957
973
|
/**
|
|
@@ -996,9 +1012,16 @@ export function validateDesignFileCoverage(changeDir) {
|
|
|
996
1012
|
}
|
|
997
1013
|
|
|
998
1014
|
// 按仓分段解析 design §6(约束③):段头 `## <repo> 仓变更` → 段内路径归该 repo;无段头 → 全 main
|
|
999
|
-
const {
|
|
1015
|
+
const {
|
|
1016
|
+
byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles, malformedHeaders,
|
|
1017
|
+
} = parseDesignCoverageByRepo(designPath)
|
|
1000
1018
|
// 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
|
|
1001
1019
|
const dcRule = getRule('plan.design-file-coverage')
|
|
1020
|
+
// 疑似仓变更段头但格式解析不了 → 点名报错(manifest 同源文案)。不拦的话该段文件被错记
|
|
1021
|
+
// 到别的仓,下方未覆盖对账整段错位,报错又不指向段头本身(用户实证 2026-08-29:排查靠猜)。
|
|
1022
|
+
for (const h of malformedHeaders) {
|
|
1023
|
+
errors.push(dcRule.data.messageMalformedHeader.replaceAll('${line}', h))
|
|
1024
|
+
}
|
|
1002
1025
|
if (designFiles.length === 0) {
|
|
1003
1026
|
// 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
|
|
1004
1027
|
// 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
|
|
@@ -1047,6 +1070,13 @@ export function validateDesignFileCoverage(changeDir) {
|
|
|
1047
1070
|
return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
|
|
1048
1071
|
}
|
|
1049
1072
|
|
|
1073
|
+
// acceptance 标识符 grep 停用词:工具域/格式名词——acceptance 行文高频出现但不是代码实体,
|
|
1074
|
+
// 不排除会给几乎每张卡稳定制造一条误报(宁漏不噪,漏一条提示的代价远低于满屏噪音淹没人)。
|
|
1075
|
+
const ACCEPT_IDENT_STOPWORDS = new Set([
|
|
1076
|
+
'snake_case', 'camelCase', 'kebab_case', // 格式名词(acceptance 常写「输出 snake_case 格式」)
|
|
1077
|
+
'allowed_paths', 'related_tests', 'depends_on', 'expects_from', 'title_zh', // TaskCard 字段名
|
|
1078
|
+
])
|
|
1079
|
+
|
|
1050
1080
|
/**
|
|
1051
1081
|
* 流程产物前缀(target_files 不该声明的路径域)。
|
|
1052
1082
|
* 与 worktree-apply filterDeliverableFiles 同口径硬编码——不 import 复用是因为
|
|
@@ -1189,6 +1219,16 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
|
|
|
1189
1219
|
const allTaskIds = []
|
|
1190
1220
|
const depMap = new Map()
|
|
1191
1221
|
|
|
1222
|
+
// acceptance grep 降噪依据:design.md 全文——标识符已在 design 声明 = 本次计划要新增的
|
|
1223
|
+
// 字段/函数(此刻自然不在源码里),不是噪音(用户实证 2026-08-29:15 卡 20+ 条 warning
|
|
1224
|
+
// 全是这类误报)。无 design.md(none/light)→ 空串退化为纯源码 grep。
|
|
1225
|
+
let designText = ''
|
|
1226
|
+
try {
|
|
1227
|
+
designText = readFileSync(pJoin(changeDir, 'design.md'), 'utf8')
|
|
1228
|
+
} catch {
|
|
1229
|
+
// design.md 不存在 → 跳过该降噪门,行为退化为只有源码 grep
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1192
1232
|
for (const file of taskFiles) {
|
|
1193
1233
|
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
1194
1234
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
@@ -1274,8 +1314,11 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
|
|
|
1274
1314
|
|
|
1275
1315
|
// 6. acceptance best-effort 字段 grep(D-05 软约束,warning 不阻断)
|
|
1276
1316
|
// 从 acceptance 文本提取 snake_case/camelCase 标识符,grep allowed_paths 指向的源文件;
|
|
1277
|
-
// 找不到 → warning(给 LLM 审查提线索,不阻断 execute
|
|
1278
|
-
//
|
|
1317
|
+
// 找不到 → warning(给 LLM 审查提线索,不阻断 execute)。宁漏不噪,三道降噪门:
|
|
1318
|
+
// ① 停用词(格式名词/TaskCard 字段名,ACCEPT_IDENT_STOPWORDS);
|
|
1319
|
+
// ② design.md 已声明的标识符跳过——本次计划新增的字段/函数此刻不在源码里是正常的;
|
|
1320
|
+
// ③ 正则天然避开命令(无 _ 或大写)/路径(无 /)/中文;glob allowed_path / 目录 /
|
|
1321
|
+
// 不存在文件一律跳过,且至少读到一个源文件才比对(防全读不到 → 全标识符误报)。
|
|
1279
1322
|
if (projectRoot && hasAcceptance && allowedPaths.length > 0) {
|
|
1280
1323
|
let acceptanceText = ''
|
|
1281
1324
|
try {
|
|
@@ -1301,8 +1344,10 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
|
|
|
1301
1344
|
// 至少读到一个源文件才比对,避免「全没读到 → 全部标识符误报」噪声
|
|
1302
1345
|
if (readableFiles.length > 0) {
|
|
1303
1346
|
for (const ident of idents) {
|
|
1347
|
+
if (ACCEPT_IDENT_STOPWORDS.has(ident)) continue
|
|
1348
|
+
if (designText.includes(ident)) continue // design 已声明 → 本次计划新增,非噪音
|
|
1304
1349
|
if (!readableFiles.some(c => c.includes(ident))) {
|
|
1305
|
-
warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths
|
|
1350
|
+
warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths 源文件与 design.md 均未命中`)
|
|
1306
1351
|
}
|
|
1307
1352
|
}
|
|
1308
1353
|
}
|
package/src/sync.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlink
|
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
13
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
14
14
|
import { join, dirname, basename, isAbsolute, relative } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
15
16
|
import { resolvePlatformSpecDir } from './progress.js';
|
|
16
17
|
import { safeGit } from './git-helper.js';
|
|
17
18
|
import { openDatabase } from './db-engine.js';
|
|
@@ -534,7 +535,20 @@ export class SyncManager {
|
|
|
534
535
|
* 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
|
|
535
536
|
* 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
|
|
536
537
|
*/
|
|
537
|
-
disconnect() {
|
|
538
|
+
disconnect(opts = {}) {
|
|
539
|
+
const { keepPointer = false } = opts
|
|
540
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑1③(2026-09-08 实证):三清里的
|
|
541
|
+
// 指针/声明是 daemon 内嵌 CLI 的 specDir 解析依据——指针缺席期间 daemon 心跳读仓库
|
|
542
|
+
// .sillyspec(空)并把空状态推上平台,反而放大回滚。两道防线:
|
|
543
|
+
// ① keepPointer(CLI `platform disconnect --keep-pointer`):只清 local.yaml platform
|
|
544
|
+
// 段(自己 CLI 的同步断开),指针/声明保留给 daemon——回滚环恢复序列的正规化出口;
|
|
545
|
+
// ② 默认三清前检测 daemon 主目录存在 → 醒目警告(在线 daemon 会失锚)。
|
|
546
|
+
const daemonHome = join(homedir(), '.sillyhub', 'daemon')
|
|
547
|
+
if (!keepPointer && existsSync(daemonHome)) {
|
|
548
|
+
console.warn(`⚠️ 检测到本机存在 SillyHub daemon(${daemonHome})——三清将删除平台指针,daemon 内嵌 CLI 将失锚:`)
|
|
549
|
+
console.warn(` 指针缺席期间 daemon 心跳读仓库本地 .sillyspec(可能为空)并把空状态推上平台,会放大进度回滚。`)
|
|
550
|
+
console.warn(` 若只想断开自己 CLI 的同步(保留 daemon 锚点):sillyspec platform disconnect --keep-pointer`)
|
|
551
|
+
}
|
|
538
552
|
const p = join(this.cwd, LOCAL_YAML);
|
|
539
553
|
if (existsSync(p)) {
|
|
540
554
|
const text = readFileSync(p, 'utf8');
|
|
@@ -549,6 +563,10 @@ export class SyncManager {
|
|
|
549
563
|
}
|
|
550
564
|
}
|
|
551
565
|
}
|
|
566
|
+
if (keepPointer) {
|
|
567
|
+
console.log('[sync] 已断开连接(--keep-pointer:仅清 local.yaml platform 段,指针与接管声明保留给 daemon 内嵌 CLI)');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
552
570
|
// 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)。
|
|
553
571
|
// HUB-12 有意不清 .sillyspec-platform-cleaned marker:它防的是「重新接入平台时 init
|
|
554
572
|
// 重复做残留清理」——disconnect 后重连若清掉 marker,init 会对现在装着本地数据的
|
|
@@ -1723,7 +1741,51 @@ export class SyncManager {
|
|
|
1723
1741
|
const cf = this.readConflictFile(changeName);
|
|
1724
1742
|
const specCf = this.readSpecConflictFile(changeName);
|
|
1725
1743
|
if (!cf && !specCf) {
|
|
1726
|
-
|
|
1744
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑1②(2026-09-08 实证):回滚环
|
|
1745
|
+
// 中间态——冲突文件已被某次常规同步静默清除,但 DB 的 base_ts 仍落后,keep-local
|
|
1746
|
+
// 找不到裁决目标被拒(「无可解决冲突」),恢复序列被迫走人肉 SQL。显式出口:keep-local
|
|
1747
|
+
// 在无冲突文件时降级为「无冲突强推」——拉平台 last_pushed_at 推进 base_ts(与有冲突
|
|
1748
|
+
// 路径同款 MAX/COALESCE 单调 SQL)+ 自动重推闭环,等价完成裁决意图。take-platform
|
|
1749
|
+
// 依赖冲突文件里的 platform_progress 快照,无文件时仍拒绝(无可覆盖源)。
|
|
1750
|
+
if (mode !== 'keep-local') {
|
|
1751
|
+
return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict / spec-sync-conflict 文件)` };
|
|
1752
|
+
}
|
|
1753
|
+
const platform = this._getPlatform();
|
|
1754
|
+
if (!platform) {
|
|
1755
|
+
return { ok: false, resolved: false, reason: `无可解决冲突且未连接平台: ${changeName}——keep-local 无冲突强推需拉平台 last_pushed_at` };
|
|
1756
|
+
}
|
|
1757
|
+
let pushedAt = null;
|
|
1758
|
+
try {
|
|
1759
|
+
const res = await fetchJson(
|
|
1760
|
+
`${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`,
|
|
1761
|
+
{ headers: { Authorization: `Bearer ${platform.token}` } },
|
|
1762
|
+
);
|
|
1763
|
+
pushedAt = (res && res.last_pushed_at) || null;
|
|
1764
|
+
} catch { /* 拉取失败按 null:仅跳过 base_ts 推进,重推仍可尝试 */ }
|
|
1765
|
+
if (pushedAt) {
|
|
1766
|
+
try {
|
|
1767
|
+
const { ProgressManager } = await import('./progress.js');
|
|
1768
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
1769
|
+
const db = pm._ensureDB(this.cwd).getDb();
|
|
1770
|
+
db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
|
|
1771
|
+
.run(pushedAt, pushedAt, changeName);
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
return { ok: false, resolved: false, reason: `无冲突 keep-local 推进 base_ts 失败: ${err.message}` };
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
let repush = null;
|
|
1777
|
+
try {
|
|
1778
|
+
repush = await this.sync(changeName, { fromResolve: true });
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
debugLog(`[sync] 无冲突 keep-local 重推异常: ${changeName}: ${err.message}`);
|
|
1781
|
+
}
|
|
1782
|
+
if (repush && repush.synced === 1) {
|
|
1783
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已对齐平台最新,本地已重推——回滚环收敛出口' };
|
|
1784
|
+
}
|
|
1785
|
+
if (repush && repush.conflict) {
|
|
1786
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已推进;重推被拒(平台刚又有更新,已落冲突文件)——再跑一次 resolve --keep-local 即可' };
|
|
1787
|
+
}
|
|
1788
|
+
return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts ' + (pushedAt ? '已推进' : '未获取(平台拉取失败)') + ';重推未成功(未连接/网络),请手动 sillyspec platform sync --change ' + changeName };
|
|
1727
1789
|
}
|
|
1728
1790
|
const platformPushedAt = (cf && cf.platform_last_pushed_at) || null;
|
|
1729
1791
|
|
|
@@ -1954,8 +2016,8 @@ export async function connect(url, token, cwd) {
|
|
|
1954
2016
|
return new SyncManager(cwd).connect(url, token);
|
|
1955
2017
|
}
|
|
1956
2018
|
|
|
1957
|
-
export async function disconnect(cwd) {
|
|
1958
|
-
return new SyncManager(cwd).disconnect();
|
|
2019
|
+
export async function disconnect(cwd, opts = {}) {
|
|
2020
|
+
return new SyncManager(cwd).disconnect(opts);
|
|
1959
2021
|
}
|
|
1960
2022
|
|
|
1961
2023
|
export async function sync(changeName, cwd, opts) {
|
package/src/task-review.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
|
-
import { join, resolve } from 'path'
|
|
12
|
+
import { join, resolve, basename, dirname } from 'path'
|
|
13
13
|
import { git, unquoteGitPath } from './git-helper.js'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
15
|
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
17
|
+
import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
|
|
17
18
|
import { WorktreeManager } from './worktree.js'
|
|
18
19
|
import { resolveRuntimeRoot } from './run/shared.js'
|
|
19
20
|
|
|
@@ -30,6 +31,16 @@ import { resolveRuntimeRoot } from './run/shared.js'
|
|
|
30
31
|
// 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
|
|
31
32
|
// 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
|
|
32
33
|
export const REVIEW_SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* review.json 写入溯源戳(坑 review-json-mystery-overwrite,2026-09-12 驾驭第十七批①,
|
|
37
|
+
* 用户实证 task-04 review 被覆写成空壳、gate 报零改动伪造重写恢复——四个 writer 均有
|
|
38
|
+
* 覆盖守卫,无堆栈钉不死覆写者):每次写入附 writtenBy(写入通道 + PID)+ writtenAt,
|
|
39
|
+
* emptyDiff 伪造 error 直显两字段——下次复现自曝身份,事后可归因。
|
|
40
|
+
*/
|
|
41
|
+
function reviewProvenanceStamp(channel) {
|
|
42
|
+
return { writtenBy: `${channel}#pid${process.pid}`, writtenAt: new Date().toISOString() }
|
|
43
|
+
}
|
|
33
44
|
const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
|
|
34
45
|
// repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
|
|
35
46
|
const DEFAULT_REPO_KEY = 'main'
|
|
@@ -493,7 +504,11 @@ export function validateTaskReviews(opts) {
|
|
|
493
504
|
}
|
|
494
505
|
}
|
|
495
506
|
if (reviewGitDir) {
|
|
496
|
-
const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache
|
|
507
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache, {
|
|
508
|
+
mainGitDir: gitDir,
|
|
509
|
+
changeName: changeDir ? basename(changeDir) : null,
|
|
510
|
+
specBase: changeDir ? dirname(dirname(changeDir)) : null,
|
|
511
|
+
})
|
|
497
512
|
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
498
513
|
if (!evidence.ok) {
|
|
499
514
|
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
@@ -515,7 +530,8 @@ export function validateTaskReviews(opts) {
|
|
|
515
530
|
const scopeNote = Array.isArray(review.diffPaths) && review.diffPaths.length > 0
|
|
516
531
|
? `(diffPaths 路径限定切片为空——统一 commit 模式下 diffPaths 应为本 task 的 allowed_paths;task 确未实现请把 verdict 回 fail)`
|
|
517
532
|
: ''
|
|
518
|
-
|
|
533
|
+
const prov = (review.writtenBy || review.writtenAt) ? `[写入溯源:${review.writtenBy || '?'} @ ${review.writtenAt || '?'}]` : '[无写入溯源—— older writer 产物]'
|
|
534
|
+
errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造${scopeNote}${prov}`)
|
|
519
535
|
continue
|
|
520
536
|
}
|
|
521
537
|
}
|
|
@@ -622,7 +638,7 @@ function parsePorcelainFiles(statusOut) {
|
|
|
622
638
|
* 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
|
|
623
639
|
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
624
640
|
*/
|
|
625
|
-
export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
641
|
+
export function verifyReviewGitEvidence(review, gitDir, cache = null, opts = {}) {
|
|
626
642
|
const errors = []
|
|
627
643
|
const warnings = []
|
|
628
644
|
const c = cache || (cache = {})
|
|
@@ -667,7 +683,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
|
667
683
|
// 可执行指引(坑 review-head-real-commit-late,2026-08-22 实证:head 必须真实 commit 的
|
|
668
684
|
// 要求撞门才知道——报错直接给操作序列:worktree 改动先 commit,取 HEAD 作 head)
|
|
669
685
|
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造。` +
|
|
670
|
-
`head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先
|
|
686
|
+
`head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 sillyspec wt-commit --change <变更名> -m "<task-NN>" -- <本 task 精确路径>(串行锁 + pathspec,防卷入并行兄弟 WIP;退路 git add -- <精确路径> && git commit,禁 add -A),` +
|
|
671
687
|
`再取 git rev-parse HEAD 作 head(base 用 task 卡锡点或基线 commit);不要填分支名/伪 hash/working-tree 描述`)
|
|
672
688
|
}
|
|
673
689
|
}
|
|
@@ -703,7 +719,22 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
|
703
719
|
c.wtStatus.set(gitDir, wtStatus)
|
|
704
720
|
}
|
|
705
721
|
if (wtStatus && wtStatus.trim().length > 0) {
|
|
706
|
-
|
|
722
|
+
let wtFiles = parsePorcelainFiles(wtStatus)
|
|
723
|
+
// 归属过滤(2026-09-12 审查批 A-①,坑 task-review-foreign-wip-blindspot):主仓 in-place
|
|
724
|
+
// 模式下 wtStatus 含**全部**并行会话的未提交文件——他者 WIP 稀释 diffFiles 使 emptyDiff
|
|
725
|
+
// (零改动→伪造 ERROR)在共享仓永不触发、交叉比对更易混过。对齐 verify-postcheck
|
|
726
|
+
// resolveMainChangedFiles 的 splitOwnVsForeignDiffFiles 剔除口径(他者声明文件归他者)。
|
|
727
|
+
// 仅主仓 gitDir 过滤(跨仓/worktree 无主 specBase 声明源,行为不变);无 opts 零回归。
|
|
728
|
+
if (wtFiles.length > 0 && opts.mainGitDir && opts.changeName
|
|
729
|
+
&& resolve(gitDir) === resolve(opts.mainGitDir)) {
|
|
730
|
+
try {
|
|
731
|
+
const { own, foreign } = splitOwnVsForeignDiffFiles(gitDir, opts.changeName, wtFiles, { specBase: opts.specBase })
|
|
732
|
+
if (foreign.length > 0) {
|
|
733
|
+
wtFiles = own
|
|
734
|
+
warnings.push(`working-tree 并入已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 3).map(x => x.file).join(', ')}${foreign.length > 3 ? ' 等' : ''})`)
|
|
735
|
+
}
|
|
736
|
+
} catch { /* 归属过滤失败退回全量并入(fail-open 与旧口径一致) */ }
|
|
737
|
+
}
|
|
707
738
|
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
708
739
|
if (commitDiffFiles.length === 0) {
|
|
709
740
|
warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
|
|
@@ -1321,6 +1352,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1321
1352
|
';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
|
|
1322
1353
|
}
|
|
1323
1354
|
if (draftRepo) draft.repo = draftRepo
|
|
1355
|
+
Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts:no-attribution'))
|
|
1324
1356
|
// fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
|
|
1325
1357
|
// 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
|
|
1326
1358
|
try {
|
|
@@ -1352,6 +1384,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1352
1384
|
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
|
|
1353
1385
|
}
|
|
1354
1386
|
if (draftRepo) draft.repo = draftRepo
|
|
1387
|
+
Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts'))
|
|
1355
1388
|
mkdirSync(reviewDir, { recursive: true })
|
|
1356
1389
|
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
1357
1390
|
generated++
|
|
@@ -1568,6 +1601,7 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
|
|
|
1568
1601
|
|
|
1569
1602
|
try {
|
|
1570
1603
|
if (mechanicsChanged || verdictTouched || evidenceFilled) {
|
|
1604
|
+
Object.assign(merged, reviewProvenanceStamp('adoptTaskReviewMechanics'))
|
|
1571
1605
|
writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
|
|
1572
1606
|
adopted++
|
|
1573
1607
|
} else {
|
|
@@ -1770,6 +1804,7 @@ export async function writeTaskReview({
|
|
|
1770
1804
|
...(requiredEvidence.filter(Boolean).length > 0 ? { requiredEvidence: requiredEvidence.filter(Boolean) } : {}),
|
|
1771
1805
|
reviewerNotes: reviewerNotes || `written by sillyspec review write (${base.slice(0, 8)}..${head.slice(0, 8)})`,
|
|
1772
1806
|
}
|
|
1807
|
+
Object.assign(review, reviewProvenanceStamp('writeTaskReview' + (force ? ':force' : '')))
|
|
1773
1808
|
const schemaResult = validateReviewSchema(review)
|
|
1774
1809
|
if (!schemaResult.ok) {
|
|
1775
1810
|
return { ok: false, errors: schemaResult.errors, warnings }
|
|
@@ -42,8 +42,15 @@ export function classifyVerifiedFile(path) {
|
|
|
42
42
|
/** 证据账 task 行:`- task-NN: <status> | verifiedFiles: a, b(豁免:…)`——占位 <待填:*> 不命中(fail-closed) */
|
|
43
43
|
const EVIDENCE_LINE_RE = /^- (task-\d+):\s*(satisfied|missing|partial)\b([^\n]*)$/
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中。
|
|
47
|
+
* 双宽度容收(坑 receipt-fullwidth-parse,2026-09-12 驾驭第十四批③,用户上一变更实证、
|
|
48
|
+
* 本次手动避开后要求修根:①分隔符同时认半角 | 与全角 |(中文输入法手写回执高频形态,
|
|
49
|
+
* 全角形态旧正则整行不命中 → 回执槽 0 条 → integration-critical 误报无绿回执);
|
|
50
|
+
* ②log 字段 rest-of-line(旧 [^\s|+] 遇含空格/全角括号路径截断——`日志(第 1 次).log`
|
|
51
|
+
* 被截成 `日志(第` → 「日志不存在」假红);log 是末字段,行内若有 |/| 尾注取首段剥注。
|
|
52
|
+
*/
|
|
53
|
+
const RECEIPT_LINE_RE = /^- claim:\s*([^||\n]+)[||]\s*command:\s*([^||\n]+)[||]\s*exit:\s*(\d+)\s*[||]\s*log:\s*(.+)$/
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
56
|
* 解析 verify-result.md 的两个受控槽段(D-001@v2 slot-backfill 录入界面)。
|
|
@@ -101,7 +108,7 @@ export function parseEvidenceSlots(mdText) {
|
|
|
101
108
|
claim: m[1].trim(),
|
|
102
109
|
command: m[2].trim(),
|
|
103
110
|
exitCode: Number(m[3]),
|
|
104
|
-
logPath: m[4].trim().replace(/^`|`$/g, ''),
|
|
111
|
+
logPath: m[4].split(/[||]/)[0].trim().replace(/^`|`$/g, ''),
|
|
105
112
|
})
|
|
106
113
|
}
|
|
107
114
|
}
|
package/src/verify-postcheck.js
CHANGED
|
@@ -88,7 +88,11 @@ export function extractTestCommand(yamlText) {
|
|
|
88
88
|
if (quoted && quoted[1]) {
|
|
89
89
|
return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// bare 值不得排除引号字符(坑 verify-bare-quote-miss,2026-09-12 审查批 B-③):
|
|
92
|
+
// `test: cd "C:\proj" && npm test` 是合法 YAML 裸标量,旧 char class [^\n#"'] 对含引号值
|
|
93
|
+
// 整体不匹配 → 判「未配置」→ 实测硬门静默 skipped(fail-open)。全引号形态已被上方
|
|
94
|
+
// quoted 分支先行消费,此处的引号只可能是嵌在值中部的。同款修正在 extractLintCommand。
|
|
95
|
+
const bare = yamlText.match(/^\s*test:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
|
|
92
96
|
if (bare && bare[1]) {
|
|
93
97
|
const cmd = bare[1].trim()
|
|
94
98
|
return cmd.toLowerCase() === 'unavailable' ? null : cmd
|
|
@@ -109,7 +113,7 @@ function extractLintCommand(yamlText) {
|
|
|
109
113
|
if (quoted && quoted[1]) {
|
|
110
114
|
return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
|
|
111
115
|
}
|
|
112
|
-
const bare = yamlText.match(/^\s*lint
|
|
116
|
+
const bare = yamlText.match(/^\s*lint:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
|
|
113
117
|
if (bare && bare[1]) {
|
|
114
118
|
const cmd = bare[1].trim()
|
|
115
119
|
return cmd.toLowerCase() === 'unavailable' ? null : cmd
|
|
@@ -154,7 +158,7 @@ function recordVerifyLintTally({ specBase, result }) {
|
|
|
154
158
|
* advisory 起步:失败只打印不阻断(test 门已 fail-closed,lint 门观察期后再升级)。
|
|
155
159
|
* 信任边界与 runVerifyTestCheck 一致:命令只来源于主仓 .sillyspec/local.yaml。
|
|
156
160
|
*/
|
|
157
|
-
export function runVerifyLintCheck({ cwd, specBase }) {
|
|
161
|
+
export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
|
|
158
162
|
const localYamlPath = join(specBase, 'local.yaml')
|
|
159
163
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
160
164
|
const command = extractLintCommand(yamlText)
|
|
@@ -172,7 +176,9 @@ export function runVerifyLintCheck({ cwd, specBase }) {
|
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
|
|
175
|
-
|
|
179
|
+
// timeoutMs(2026-09-12 驾驭第十七批 dogfood):门禁快照内 node_modules 经 junction I/O 较慢,
|
|
180
|
+
// 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 > env > 默认
|
|
181
|
+
const LINT_TIMEOUT_MS = timeoutMs || Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
|
|
176
182
|
const startedAt = Date.now()
|
|
177
183
|
let exitCode = 0
|
|
178
184
|
let output = ''
|
|
@@ -577,7 +583,12 @@ export function extractModules(yamlText) {
|
|
|
577
583
|
*/
|
|
578
584
|
function parseFlowValue(flowText, key) {
|
|
579
585
|
// 键可能带引号也可能不带:path: "x" 或 "path": "x"
|
|
580
|
-
|
|
586
|
+
// bare 值的逗号终止是「下一键开始的逗号」而非任意逗号(坑 flow-value-comma-truncate,
|
|
587
|
+
// 2026-09-12 审查批 C-③):`test: pytest -k a,b` 旧 [^,}]+? 在第一个逗号截断 → 实测跑
|
|
588
|
+
// 残损命令 `pytest -k a`(假红/碰巧假绿)。bare 分支与终止 lookahead 双侧同口径:只把
|
|
589
|
+
// 「后随键形态的逗号」当分隔符——消费侧负向前瞻排除它,终止侧正向前瞻要求它(或 } / 行尾)。
|
|
590
|
+
const sepComma = String.raw`,\s*"?[A-Za-z0-9_.\-]+"?\s*:`
|
|
591
|
+
const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]|,(?!${sepComma.slice(1)}))+?)\s*(?=\}|${sepComma}|$)`)
|
|
581
592
|
const m = flowText.match(re)
|
|
582
593
|
if (!m) return null
|
|
583
594
|
let v = m[1].trim()
|
|
@@ -666,7 +677,11 @@ export function extractKnownFailures(yamlText) {
|
|
|
666
677
|
// CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
|
|
667
678
|
// 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
|
|
668
679
|
const yaml = normalizeLineEndings(yamlText)
|
|
669
|
-
|
|
680
|
+
// inline 捕获组用贪婪 .*(坑 known-failures-inline-nested-bracket,2026-09-12 审查批 C-④):
|
|
681
|
+
// 旧 [^\]]* 在值内首个 ] 停——豁免项是 glob/pytest id 可含 [] 字符类(tests/\[x\]::case),
|
|
682
|
+
// 旧版整表静默清空 → 预存失败全落 remaining 假红且难归因。贪婪 .* + 后随 \] 行尾锚,
|
|
683
|
+
// 吃到最后一个 ](行尾注释里含 ] 的极端形态会被并入值,远优于静默清空)。
|
|
684
|
+
const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
|
|
670
685
|
if (inline) {
|
|
671
686
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
672
687
|
}
|
package/src/verify-probes.js
CHANGED
|
@@ -121,9 +121,14 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
|
|
|
121
121
|
probe1.globEntries.push(e.path)
|
|
122
122
|
continue
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// NEW: 待建前缀剥离(坑 probe1-new-prefix-miss,2026-09-12 驾驭第十七批③,用户实证
|
|
125
|
+
// 「NEW: 前缀文件已合入主仓」时 existsSync(join(cwd,'NEW:src/x')) 恒 miss 落 skippedFiles
|
|
126
|
+
// 留 ⚠️ 噪声):design 清单的 NEW: 是「新建意图」标记,文件 apply 后已是无前缀实体——
|
|
127
|
+
// 探针按目标文件扫(与 pathMatches 比对侧剥除同源)。
|
|
128
|
+
const probePath = String(e.path).replace(/^NEW:\s*/, '')
|
|
129
|
+
let abs = join(cwd, probePath)
|
|
125
130
|
if (!existsSync(abs)) {
|
|
126
|
-
const wtAbs = wtRoot ? join(wtRoot,
|
|
131
|
+
const wtAbs = wtRoot ? join(wtRoot, probePath) : null
|
|
127
132
|
if (wtAbs && existsSync(wtAbs)) {
|
|
128
133
|
abs = wtAbs
|
|
129
134
|
probe1.worktreeHits++
|
|
@@ -136,7 +141,7 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
|
|
|
136
141
|
const lines = readFileSync(abs, 'utf8').split('\n')
|
|
137
142
|
lines.forEach((line, i) => {
|
|
138
143
|
if (probe1.matches.length >= PROBE1_MAX_MATCHES) return
|
|
139
|
-
if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file:
|
|
144
|
+
if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: probePath, line: i + 1, content: line.trim().slice(0, 160) })
|
|
140
145
|
})
|
|
141
146
|
} catch {
|
|
142
147
|
probe1.skippedFiles.push(e.path)
|
package/src/worktree-apply.js
CHANGED
|
@@ -131,9 +131,18 @@ export function mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash
|
|
|
131
131
|
if (code < 0) { conflicts.push(f); continue } // merge-file 本身不可用 → 原拦截
|
|
132
132
|
}
|
|
133
133
|
if (code === 0) {
|
|
134
|
+
// 覆写前备份主仓在途原文(2026-09-12 审查批 D-②):合并写回发生在 apply 整体成败
|
|
135
|
+
// 判定之前——后续步骤(5a/7)失败返回时主仓已是合并版,而原文从未进任何 git 对象
|
|
136
|
+
// (也未 commit),不留备份即不可恢复。备份落 <projectRoot>/.sillyspec/.runtime/
|
|
137
|
+
// merge-backups/(运行时目录,doctor/hygiene 常规清理面,不污染工作区)。
|
|
138
|
+
try {
|
|
139
|
+
const backupDir = join(projectRoot, '.sillyspec', '.runtime', 'merge-backups')
|
|
140
|
+
mkdirSync(backupDir, { recursive: true })
|
|
141
|
+
writeFileSync(join(backupDir, `${Date.now()}-${f.replace(/[\\/]/g, '__')}`), mainRaw)
|
|
142
|
+
} catch { /* 备份失败不阻断合并(原文仍可经 rescue 出口重取,不静默弃救 */ }
|
|
134
143
|
writeFileSync(join(projectRoot, f), outBuf)
|
|
135
144
|
merged.push(f)
|
|
136
|
-
console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree
|
|
145
|
+
console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree 交付均已保留(覆写前原文已备份至 .sillyspec/.runtime/merge-backups/)`)
|
|
137
146
|
} else {
|
|
138
147
|
conflicts.push(f)
|
|
139
148
|
}
|
|
@@ -242,19 +251,39 @@ export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
|
|
|
242
251
|
function getBlobHashMap(cwd, treeish, files) {
|
|
243
252
|
const map = new Map();
|
|
244
253
|
if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
for (const batch of chunkPaths(files)) {
|
|
255
|
+
const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
|
|
256
|
+
if (!raw) continue
|
|
257
|
+
for (const line of raw.split('\n')) {
|
|
258
|
+
if (!line) continue;
|
|
259
|
+
const tabIdx = line.indexOf('\t');
|
|
260
|
+
if (tabIdx === -1) continue;
|
|
261
|
+
const filePath = line.slice(tabIdx + 1);
|
|
262
|
+
const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
|
|
263
|
+
if (hash) map.set(filePath, hash);
|
|
264
|
+
}
|
|
254
265
|
}
|
|
255
266
|
return map;
|
|
256
267
|
}
|
|
257
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 按估算 argv 长度切批(2026-09-12 审查批 D-③):Windows CreateProcess 命令行 32767 字符
|
|
271
|
+
* 上限——数百个长路径(Windows 深路径 160+ 字符 × 200 文件)全量展开时 spawn 直接失败,
|
|
272
|
+
* add/reset/diff 整体炸(hash-object 同病灶在 worktree.js,本文件先行收口)。保守 8000
|
|
273
|
+
* 字符/批,预留 git 参数与编码余量。返回至少一批(空输入返回单空批)。
|
|
274
|
+
*/
|
|
275
|
+
export function chunkPaths(paths, maxChars = 8000) {
|
|
276
|
+
const batches = [[]]
|
|
277
|
+
let len = 0
|
|
278
|
+
for (const p of paths) {
|
|
279
|
+
const l = String(p).length + 1
|
|
280
|
+
if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
|
|
281
|
+
batches[batches.length - 1].push(p)
|
|
282
|
+
len += l
|
|
283
|
+
}
|
|
284
|
+
return batches
|
|
285
|
+
}
|
|
286
|
+
|
|
258
287
|
/**
|
|
259
288
|
* apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
|
|
260
289
|
*
|
|
@@ -676,14 +705,24 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
676
705
|
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
677
706
|
const patchParts = [];
|
|
678
707
|
if (trackedFiles.length > 0) {
|
|
679
|
-
|
|
708
|
+
// argv 分批(批 D-③):数百长路径全量展开超 Windows 命令行上限;diff 输出按批拼接
|
|
709
|
+
// (各批均为完整 patch 文档,concat 对 git apply 合法)
|
|
710
|
+
for (const batch of chunkPaths(trackedFiles)) {
|
|
711
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
|
|
712
|
+
}
|
|
680
713
|
}
|
|
681
714
|
if (untrackedPatchFiles.length > 0) {
|
|
682
|
-
|
|
715
|
+
for (const batch of chunkPaths(untrackedPatchFiles)) {
|
|
716
|
+
git(cm.worktreePath, ['add', '--', ...batch]);
|
|
717
|
+
}
|
|
683
718
|
try {
|
|
684
|
-
|
|
719
|
+
for (const batch of chunkPaths(untrackedPatchFiles)) {
|
|
720
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
|
|
721
|
+
}
|
|
685
722
|
} finally {
|
|
686
|
-
|
|
723
|
+
for (const batch of chunkPaths(untrackedPatchFiles)) {
|
|
724
|
+
gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...batch]);
|
|
725
|
+
}
|
|
687
726
|
}
|
|
688
727
|
}
|
|
689
728
|
const patchContent = Buffer.concat(patchParts);
|
|
@@ -1232,10 +1271,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1232
1271
|
// 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
|
|
1233
1272
|
// tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
|
|
1234
1273
|
// 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
});
|
|
1274
|
+
// 批 D-③:per-file cat-file N+1 spawn 改单次 ls-tree blob 哈希表(同 :1188 既有口径复用)
|
|
1275
|
+
const headBlobMap = getBlobHashMap(projectRoot, 'HEAD', patchFiles)
|
|
1276
|
+
const trackedPatchFiles = patchFiles.filter(f => headBlobMap.has(f));
|
|
1239
1277
|
const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
|
|
1240
1278
|
|
|
1241
1279
|
// apply --3way 正式应用(主干已提交推进时自动三路合并)
|