sillyspec 3.28.10 → 3.28.12
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 +223 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
- package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
- package/.husky/pre-commit +4 -0
- package/.husky/pre-push +3 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/change-risk-profile.js +76 -6
- package/src/commit-guard.js +119 -0
- package/src/config-schema.js +23 -0
- package/src/design-facts.js +46 -9
- package/src/diagnostic-codes.js +95 -0
- package/src/docs-check.js +14 -10
- package/src/docs-debt.js +7 -2
- package/src/docs-gate.js +42 -15
- package/src/doctor-diagnostics.js +141 -1
- package/src/endpoint-extractor.js +50 -2
- package/src/foreign-declared.js +25 -16
- package/src/hooks/worktree-guard.js +75 -2
- package/src/index.js +129 -1
- package/src/machine-interface.js +63 -0
- package/src/probe7-anchor-check.js +20 -2
- package/src/progress/stage-machine.js +34 -5
- package/src/progress.js +1 -1
- package/src/quicklog.js +231 -1
- package/src/run/complete-handlers.js +172 -1
- package/src/run/complete.js +73 -3
- package/src/run/gate-snapshot.js +262 -0
- package/src/run/gates.js +41 -7
- package/src/run/prompt.js +79 -1
- package/src/run/shared.js +10 -0
- package/src/run/verify-quality-scan.js +151 -5
- package/src/stage-contract-spec.js +14 -0
- package/src/stage-contract.js +738 -8
- package/src/stage-review-checklist.js +16 -2
- package/src/stage-review.js +137 -2
- package/src/stages/archive.js +9 -5
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +62 -8
- package/src/stages/plan-postcheck.js +42 -5
- package/src/stages/plan.js +8 -1
- package/src/stages/quick.js +5 -5
- package/src/stages/verify.js +8 -3
- package/src/sync.js +69 -0
- package/src/task-review.js +9 -3
- package/src/verify-facts-schema.js +95 -10
- package/src/verify-postcheck.js +346 -28
- package/src/verify-probes.js +1221 -46
- package/src/worktree-apply.js +175 -3
- package/src/write-audit.js +55 -0
- package/.claude/full.json +0 -1
package/src/quicklog.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* 本模块把分配与写入下沉到 CLI 进程内,O_EXCL lockfile 串行化,彻底消除上述问题。
|
|
11
11
|
* 无新 npm 依赖(仅 fs/path/crypto)——匹配项目零 FS 工具依赖的风格。
|
|
12
12
|
*/
|
|
13
|
-
import { join, dirname, basename } from 'path'
|
|
13
|
+
import { join, dirname, basename, resolve, relative, isAbsolute } from 'path'
|
|
14
14
|
import { getRule } from './stage-contract-spec.js'
|
|
15
15
|
import {
|
|
16
16
|
openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
|
|
17
17
|
readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync, rmSync,
|
|
18
18
|
} from 'fs'
|
|
19
19
|
import { randomBytes } from 'crypto'
|
|
20
|
+
import { safeGit } from './git-helper.js'
|
|
20
21
|
|
|
21
22
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
22
23
|
|
|
@@ -1168,3 +1169,232 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
|
|
|
1168
1169
|
|
|
1169
1170
|
return { ok: true, quicklogFile, removedTaskRows }
|
|
1170
1171
|
}
|
|
1172
|
+
|
|
1173
|
+
// ── quicklog commit(known-issues ④ v1:本会话条目切片提交,2026-09-16)──
|
|
1174
|
+
//
|
|
1175
|
+
// 坑 QUICKLOG 多会话条目交织:QUICKLOG 是多会话共享追加的单文件,某会话 `git add` 整文件
|
|
1176
|
+
// 会夹带并行会话未完成条目(违反显式 pathspec 隔离纪律,AGENTS.md 第 18 条),只能
|
|
1177
|
+
// 「备份 → 剥离并行条目 → commit pathspec → 恢复」四步舞(2026-09-15/16 单会话实证 6 次)。
|
|
1178
|
+
// 本命令把四步舞机制化:
|
|
1179
|
+
// 切片(工作区 = HEAD 基线 + 本会话条目块)→ git add/commit 显式 pathspec(含 patches
|
|
1180
|
+
// sidecar 与额外 pathspec)→ finally 恢复工作区全量(并行条目留「未提交」预期态)。
|
|
1181
|
+
// 任一步失败 fail-fast 打印人工兜底;恢复在 finally,commit 失败也不留半态。
|
|
1182
|
+
// 「完整文件化」(entries sidecar 权威 + 聚合渲染)不在 v1 范围,见 known-issues 条目。
|
|
1183
|
+
|
|
1184
|
+
// git 子操作退避重试:与 wt-commit.js gitWithLockRetry 同款(本文件锁只约束 quicklog 写方,
|
|
1185
|
+
// 锁队列外裸跑 git 的并行进程仍可能短暂持 index.lock/HEAD 锁,等待重试消化)。
|
|
1186
|
+
async function quicklogGitRetry(cwd, args, label, { retries = 6, waitMs = 1000 } = {}) {
|
|
1187
|
+
for (let i = 0; ; i++) {
|
|
1188
|
+
const { value, error } = safeGit(cwd, args, { timeout: 30000 })
|
|
1189
|
+
if (!error) return value
|
|
1190
|
+
const msg = String((error && error.message) || error)
|
|
1191
|
+
if (/index\.lock|HEAD\.lock|cannot lock ref/i.test(msg) && i < retries) {
|
|
1192
|
+
await sleep(waitMs)
|
|
1193
|
+
continue
|
|
1194
|
+
}
|
|
1195
|
+
throw new Error(`${label} 失败:${msg.split('\n')[0].trim()}`)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 行级条目头匹配(容错口径与 countQuicklogEntries 一致:## 后空白可省、ID 与 | 间空白可变)
|
|
1200
|
+
function findQuicklogEntryLineIdx(lines, qlId) {
|
|
1201
|
+
const re = new RegExp(`^##[ \\t]*${escapeRe(qlId)}[ \\t]*\\|`)
|
|
1202
|
+
return lines.findIndex(l => re.test(l))
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* 切片:工作区全量 → 「基线 + 指定条目块」。
|
|
1207
|
+
* 块界 = `## ql-<id>` 头到下一个 `## ` 头/文件尾(extractRawBlock 同口径);条目间补单空行
|
|
1208
|
+
* 分隔(对齐 allocateQuicklogEntry 条目模板形态)。EOL 随工作区文件——HEAD blob 归一到
|
|
1209
|
+
* 工作区 EOL 再拼(.gitattributes eol=lf + autocrlf=true 环境下 blob 是 LF、工作区 CRLF;
|
|
1210
|
+
* git add 时按配置归一回去,不产生整文件行尾假差异)。条目头已在基线(先前提交过)→
|
|
1211
|
+
* 跳过不重复(重跑幂等)。返回 null = 无任何目标条目;blocks 为空 = 全部已在基线
|
|
1212
|
+
* (调用方不落盘不动文件,避免基线归一化重写制造假提交)。
|
|
1213
|
+
*/
|
|
1214
|
+
function sliceQuicklogFileContent(fullContent, baselineContent, qlIds) {
|
|
1215
|
+
const eol = fullContent.includes('\r\n') ? '\r\n' : '\n'
|
|
1216
|
+
const norm = (s) => String(s || '').replace(/\r\n/g, '\n')
|
|
1217
|
+
const lines = norm(fullContent).split('\n')
|
|
1218
|
+
const baseLines = norm(baselineContent).split('\n')
|
|
1219
|
+
while (baseLines.length && baseLines[baseLines.length - 1].trim() === '') baseLines.pop()
|
|
1220
|
+
const blocks = []
|
|
1221
|
+
const blockIds = []
|
|
1222
|
+
const alreadyIncluded = []
|
|
1223
|
+
for (const qlId of qlIds) {
|
|
1224
|
+
const startIdx = findQuicklogEntryLineIdx(lines, qlId)
|
|
1225
|
+
if (startIdx === -1) continue
|
|
1226
|
+
if (findQuicklogEntryLineIdx(baseLines, qlId) !== -1) { alreadyIncluded.push(qlId); continue }
|
|
1227
|
+
let endIdx = lines.length
|
|
1228
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
1229
|
+
if (/^##[ \t]*\S/.test(lines[i])) { endIdx = i; break }
|
|
1230
|
+
}
|
|
1231
|
+
const block = lines.slice(startIdx, endIdx)
|
|
1232
|
+
while (block.length && block[block.length - 1].trim() === '') block.pop()
|
|
1233
|
+
blocks.push(block)
|
|
1234
|
+
blockIds.push(qlId)
|
|
1235
|
+
}
|
|
1236
|
+
if (blocks.length === 0 && alreadyIncluded.length === 0) return null
|
|
1237
|
+
const outLines = [...baseLines]
|
|
1238
|
+
for (const block of blocks) {
|
|
1239
|
+
if (outLines.length > 0) outLines.push('') // 条目间空行分隔
|
|
1240
|
+
outLines.push(...block)
|
|
1241
|
+
}
|
|
1242
|
+
let sliced = outLines.join('\n') + '\n'
|
|
1243
|
+
if (eol === '\r\n') sliced = sliced.replace(/\n/g, '\r\n')
|
|
1244
|
+
return { content: sliced, blockIds, alreadyIncluded }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const QUICKLOG_COMMIT_MANUAL_FALLBACK = [
|
|
1248
|
+
'人工兜底(四步舞):',
|
|
1249
|
+
' ① 备份 QUICKLOG 文件:cp <文件> /tmp/quicklog-backup.md',
|
|
1250
|
+
' ② 从工作区文件剥离并行会话条目(只留本会话条目,条目块 = "## ql-…" 行到下一个 "## " 头)',
|
|
1251
|
+
' ③ git add -- <QUICKLOG文件> .sillyspec/quicklog/patches/<ql-id>.* && git commit -m "..."',
|
|
1252
|
+
' ④ 从备份恢复工作区全量(并行条目回到未提交态)',
|
|
1253
|
+
].join('\n')
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* 一键收编本会话 QUICKLOG 条目(`sillyspec quicklog commit`,known-issues ④ v1)。
|
|
1257
|
+
*
|
|
1258
|
+
* 持用户 QUICKLOG 锁(与 allocate/complete/cancel 同锁)跨「定位 → 切片 → 提交 → 恢复」
|
|
1259
|
+
* 全程——锁内重读文件,并发追加的并行条目不丢(恢复用的就是锁内读到的最新全量)。
|
|
1260
|
+
*
|
|
1261
|
+
* @param {object} opts
|
|
1262
|
+
* @param {string} opts.specBase .sillyspec 根目录
|
|
1263
|
+
* @param {string} [opts.cwd] 调用方 cwd(git rev-parse --show-toplevel 的起点)
|
|
1264
|
+
* @param {string} opts.gitUser git user.name(定位本用户 QUICKLOG 文件与锁)
|
|
1265
|
+
* @param {string[]} [opts.qlIds] 显式 ql-ID(--ql,可含已取消条目——历史一并收编)
|
|
1266
|
+
* @param {string|null} [opts.changeName] quick 会话 ID(缺 --ql 时读其 guard.json 的 quicklogId)
|
|
1267
|
+
* @param {string|null} [opts.sessionsDir] quick-sessions 目录(平台模式异位时由 dispatch 传入)
|
|
1268
|
+
* @param {string} opts.message 提交信息
|
|
1269
|
+
* @param {string[]} [opts.extraPathspecs] 额外 pathspec(-- 后透传,随本次提交带上)
|
|
1270
|
+
* @returns {Promise<{ok:boolean,skipped:boolean,head:string,shortHead:string,files:string[],
|
|
1271
|
+
* quicklogFiles:string[],committedQlIds:string[],skippedQlIds:string[],backupPaths:string[]}>}
|
|
1272
|
+
*/
|
|
1273
|
+
export async function runQuicklogCommit({ specBase, cwd = process.cwd(), gitUser, qlIds = [], changeName = null, sessionsDir = null, message = '', extraPathspecs = [] }) {
|
|
1274
|
+
if (!message || !message.trim()) throw new Error('缺少 -m/--message <提交信息>(建议 "chore: 收编本会话 QUICKLOG 条目——<一句话>")')
|
|
1275
|
+
const ids = [...new Set((Array.isArray(qlIds) ? qlIds : []).filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))]
|
|
1276
|
+
|
|
1277
|
+
// ql-ID 解析优先级:--ql 显式 > 会话 guard.json(写侧字段 quicklogId,兼容旧 .qlId)
|
|
1278
|
+
if (ids.length === 0 && changeName) {
|
|
1279
|
+
const guardPath = join(sessionsDir || join(specBase, '.runtime', 'quick-sessions'), changeName, 'guard.json')
|
|
1280
|
+
try {
|
|
1281
|
+
if (existsSync(guardPath)) {
|
|
1282
|
+
const g = JSON.parse(readFileSync(guardPath, 'utf8'))
|
|
1283
|
+
const q = g.quicklogId || g.qlId
|
|
1284
|
+
if (typeof q === 'string' && q.trim()) ids.push(q.trim())
|
|
1285
|
+
}
|
|
1286
|
+
} catch { /* guard 损坏 → 按缺失走下方 fail-fast */ }
|
|
1287
|
+
}
|
|
1288
|
+
if (ids.length === 0) {
|
|
1289
|
+
throw new Error(`无法定位 ql-ID:--change 会话的 guard.json 缺失/损坏且未传 --ql——显式传 --ql <ql-xxx>(可重复传多个;含已取消条目,cancelled 也是历史一并收编;ID 即 QUICKLOG 条目头 "## ql-…" 的 ID)`)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const quicklogDir = join(specBase, 'quicklog')
|
|
1293
|
+
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
1294
|
+
const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
|
|
1295
|
+
const repoRootRaw = safeGit(cwd, ['rev-parse', '--show-toplevel'], { timeout: 10000 }).value
|
|
1296
|
+
if (!repoRootRaw) throw new Error(`git 仓库定位失败(cwd=${cwd} 的 git rev-parse --show-toplevel 无输出)`)
|
|
1297
|
+
const repoRoot = resolve(repoRootRaw)
|
|
1298
|
+
const toRel = (abs) => relative(repoRoot, abs).replace(/\\/g, '/')
|
|
1299
|
+
|
|
1300
|
+
const result = await withFileLock(lockPath, async () => {
|
|
1301
|
+
// 锁内重扫(并发追加不丢):主文件优先,恒扫全部 QUICKLOG 文件(含轮转归档——
|
|
1302
|
+
// 2026-09-16 实证 ql-013 落轮转新文件、ql-014 又落回旧文件,新旧都可能有本会话条目)
|
|
1303
|
+
const allFiles = listQuicklogFiles(quicklogDir)
|
|
1304
|
+
const mainName = `QUICKLOG-${user}.md`
|
|
1305
|
+
const ordered = existsSync(join(quicklogDir, mainName))
|
|
1306
|
+
? [mainName, ...allFiles.filter(f => f !== mainName)] : allFiles
|
|
1307
|
+
const plans = []
|
|
1308
|
+
const found = new Set()
|
|
1309
|
+
for (const f of ordered) {
|
|
1310
|
+
const p = join(quicklogDir, f)
|
|
1311
|
+
let content = ''
|
|
1312
|
+
try { content = readFileSync(p, 'utf8') } catch { continue }
|
|
1313
|
+
const contentLines = content.split(/\r?\n/)
|
|
1314
|
+
const hit = ids.filter(id => findQuicklogEntryLineIdx(contentLines, id) !== -1)
|
|
1315
|
+
for (const id of hit) found.add(id)
|
|
1316
|
+
if (hit.length > 0) plans.push({ file: p, rel: toRel(p), content, hitIds: hit })
|
|
1317
|
+
}
|
|
1318
|
+
// 工作区未命中者查 HEAD 基线:已在 = 幂等跳过;两边都无 = fail-fast(--ql 笔误防护)
|
|
1319
|
+
const alreadyCommitted = []
|
|
1320
|
+
for (const id of ids.filter(i => !found.has(i))) {
|
|
1321
|
+
let inHead = false
|
|
1322
|
+
for (const f of ordered) {
|
|
1323
|
+
const blob = safeGit(repoRoot, ['show', `HEAD:${toRel(join(quicklogDir, f))}`], { trim: false, timeout: 10000 }).value
|
|
1324
|
+
if (blob && findQuicklogEntryLineIdx(blob.replace(/\r\n/g, '\n').split('\n'), id) !== -1) { inHead = true; break }
|
|
1325
|
+
}
|
|
1326
|
+
if (inHead) alreadyCommitted.push(id)
|
|
1327
|
+
else throw new Error(`条目 ${id} 在所有 QUICKLOG-*.md(含轮转归档)与 HEAD 中均未找到——核对 --ql 的 ql-ID。\n${QUICKLOG_COMMIT_MANUAL_FALLBACK}`)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// 切片 + 备份(.runtime 不入库,恢复失败时的最后兜底)。无新块的文件不动不落盘——
|
|
1331
|
+
// 已收编条目重跑形态下按基线归一化重写会制造只差尾部空行的假提交。
|
|
1332
|
+
const now = new Date()
|
|
1333
|
+
const stamp = `${todayStamp(now)}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
|
|
1334
|
+
mkdirSync(join(specBase, '.runtime'), { recursive: true })
|
|
1335
|
+
const backups = []
|
|
1336
|
+
const originals = []
|
|
1337
|
+
const slicePlans = []
|
|
1338
|
+
for (const plan of plans) {
|
|
1339
|
+
const baseline = safeGit(repoRoot, ['show', `HEAD:${plan.rel}`], { trim: false, timeout: 10000 }).value || ''
|
|
1340
|
+
const sliced = sliceQuicklogFileContent(plan.content, baseline, plan.hitIds)
|
|
1341
|
+
if (!sliced) continue // 不可能(plan.hitIds 非空才进 plans),防御
|
|
1342
|
+
for (const id of sliced.alreadyIncluded) alreadyCommitted.push(id)
|
|
1343
|
+
if (sliced.blockIds.length === 0) continue // 全部已在基线:文件不动、不进提交
|
|
1344
|
+
const backupPath = join(specBase, '.runtime', `quicklog-slice-${stamp}-${basename(plan.file)}`)
|
|
1345
|
+
writeFileSync(backupPath, plan.content)
|
|
1346
|
+
backups.push(backupPath)
|
|
1347
|
+
originals.push({ file: plan.file, content: plan.content })
|
|
1348
|
+
await writeAtomic(plan.file, sliced.content)
|
|
1349
|
+
slicePlans.push(plan)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// pathspec 组装:QUICKLOG 文件 + patches sidecar(--done 冻结件,存在才加)+ 额外透传
|
|
1353
|
+
const addPaths = slicePlans.map(pl => pl.rel)
|
|
1354
|
+
for (const id of ids) {
|
|
1355
|
+
for (const ext of ['.json', '.patch']) {
|
|
1356
|
+
const sp = join(quicklogDir, 'patches', id + ext)
|
|
1357
|
+
if (existsSync(sp)) addPaths.push(toRel(sp))
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
for (const extra of (Array.isArray(extraPathspecs) ? extraPathspecs : []).filter(Boolean)) {
|
|
1361
|
+
addPaths.push(toRel(isAbsolute(extra) ? extra : resolve(cwd, extra)))
|
|
1362
|
+
}
|
|
1363
|
+
if (addPaths.length === 0) {
|
|
1364
|
+
// 条目均已收编且无 sidecar/额外 pathspec → 无事可做(幂等重跑)
|
|
1365
|
+
return { skipped: true, head: safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || '', addPaths: [], backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
await quicklogGitRetry(repoRoot, ['add', '--', ...addPaths], 'git add')
|
|
1370
|
+
// pathspec 范围内无 staged 变更 = 条目均已收编(幂等重跑),HEAD 不动不算失败
|
|
1371
|
+
const staged = safeGit(repoRoot, ['diff', '--cached', '--name-only', '--', ...addPaths], { timeout: 10000 }).value || ''
|
|
1372
|
+
if (!staged.trim()) {
|
|
1373
|
+
return { skipped: true, head: safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || '', addPaths, backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1374
|
+
}
|
|
1375
|
+
await quicklogGitRetry(repoRoot, ['commit', '-m', message, '--', ...addPaths], 'git commit')
|
|
1376
|
+
const head = safeGit(repoRoot, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || ''
|
|
1377
|
+
return { skipped: false, head, addPaths, backups, alreadyCommitted, quicklogFiles: slicePlans.map(pl => basename(pl.file)) }
|
|
1378
|
+
} finally {
|
|
1379
|
+
// 恢复:切片前工作区全量写回(并行会话条目回到「未提交」预期态;原子写防轮询读半截)
|
|
1380
|
+
for (const o of originals) await writeAtomic(o.file, o.content)
|
|
1381
|
+
}
|
|
1382
|
+
}, {
|
|
1383
|
+
staleMs: 120000, // 临界区含 git add/commit(AV 扫描的 Windows 上秒级起步),30s 默认会被误偷锁
|
|
1384
|
+
timeoutMs: 120000,
|
|
1385
|
+
content: JSON.stringify({ pid: process.pid, purpose: 'quicklog-commit' }),
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
const already = [...new Set(result.alreadyCommitted)]
|
|
1389
|
+
return {
|
|
1390
|
+
ok: true,
|
|
1391
|
+
skipped: !!result.skipped,
|
|
1392
|
+
head: result.head,
|
|
1393
|
+
shortHead: (result.head || '').slice(0, 8),
|
|
1394
|
+
files: result.addPaths,
|
|
1395
|
+
quicklogFiles: result.quicklogFiles,
|
|
1396
|
+
committedQlIds: ids.filter(id => !already.includes(id)),
|
|
1397
|
+
skippedQlIds: already,
|
|
1398
|
+
backupPaths: result.backups,
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* srcDir 缺失时走 findAlreadyArchivedDir 幂等自愈(issue archive-stage-physical-tracking-desync);
|
|
10
10
|
* archiveChangeDirectory + findAlreadyArchivedDir 已 export 供 test 直接 import
|
|
11
11
|
* - sanitizeProjectName / validateParsedProjects:项目名清洗 + 列表校验纯函数(handleScanProjectListStep 专用)
|
|
12
|
+
* - assertWaveTasksComplete:execute「Wave N 执行」步骤 --done 前的完成度门(task-08 / FR-12——
|
|
13
|
+
* 本 Wave 任一 task 的 tasks.md checkbox 未勾 → exit 1;completeStep 在 status='completed' 赋值前
|
|
14
|
+
* 调用,异常 fail-open),实现收拢文件尾(同知识闭环段先例)
|
|
12
15
|
*
|
|
13
16
|
* 安全锚:run.js 始终 barrel。3 handler 由 run.js import 回来;sanitizeProjectName + validateParsedProjects
|
|
14
17
|
* 被 test 直接 import(run-sanitize-project-name / run-scan-project-parse),run.js barrel re-export 契约保留。
|
|
@@ -46,7 +49,9 @@ import { resolveSessionIdentity } from '../progress.js'
|
|
|
46
49
|
// ql-20260915-001 修复④:chunkPaths(argv 分批)供归档窄化 add / minePaths 精确补暂存用。
|
|
47
50
|
// 无环:worktree-apply 静态闭包(worktree/task-review/quicklog 等)不引本文件;既有
|
|
48
51
|
// withMainRepoLock 走动态 import 是归档链防环的历史形态,chunkPaths 纯函数无此约束。
|
|
49
|
-
|
|
52
|
+
// checkDbScriptDeclarationGate(task-04 / FR-05)同排静态引入:门函数自身纯文件集 ×
|
|
53
|
+
// 文本声明对账(verify-probes 文法经 worktree-apply 顶层动态绑定),同样无环约束。
|
|
54
|
+
import { chunkPaths, checkDbScriptDeclarationGate } from '../worktree-apply.js'
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
57
|
* 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
|
|
@@ -785,6 +790,32 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
|
|
|
785
790
|
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
786
791
|
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
787
792
|
}
|
|
793
|
+
// ── db/*.sql 声明门·归档前置(2026-09-17-pass-cap-semantics task-04 / FR-05 / D-007/D-012
|
|
794
|
+
// 兜底第二门)── --confirm 已过、目录移动前:apply-manifest.json 的 files 过 worktree-apply
|
|
795
|
+
// 同款门(对账 verify-result.md 声明,含 db-script handover 互斥)。缺失/互斥 → 复用 !
|
|
796
|
+
// confirm 早退形态(status 回 pending + pm._write + early return),变更目录不动、输出修复
|
|
797
|
+
// 指引;apply-manifest.json 缺失 → 门空转不阻断(无 apply 面,主门在 verify 侧事实③);
|
|
798
|
+
// manifest 读取/解析异常 → fail-open warn 放行(门异常不锁死归档)。
|
|
799
|
+
const dbGateManifestPath = join(specBase, 'changes', changeName, 'apply-manifest.json')
|
|
800
|
+
if (existsSync(dbGateManifestPath)) {
|
|
801
|
+
try {
|
|
802
|
+
const dbGateManifest = JSON.parse(readFileSync(dbGateManifestPath, 'utf8'))
|
|
803
|
+
const dbGateFiles = (dbGateManifest && Array.isArray(dbGateManifest.files) ? dbGateManifest.files : [])
|
|
804
|
+
.map((e) => (e && typeof e === 'object' ? e.path : e)).filter(Boolean)
|
|
805
|
+
const dbGate = checkDbScriptDeclarationGate({ projectRoot: cwd, specBase, changeName, files: dbGateFiles })
|
|
806
|
+
if (!dbGate.ok) {
|
|
807
|
+
steps[currentIdx].status = 'pending'
|
|
808
|
+
steps[currentIdx].completedAt = null
|
|
809
|
+
if (outputText) steps[currentIdx].output = null
|
|
810
|
+
pm._write(cwd, progress, changeName)
|
|
811
|
+
console.error(`⛔ db 脚本执行声明对账未过(归档阻断,变更目录未移动):\n${dbGate.error}`)
|
|
812
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
813
|
+
}
|
|
814
|
+
if (dbGate.warning) console.warn(`⚠️ ${dbGate.warning}`)
|
|
815
|
+
} catch (e) {
|
|
816
|
+
console.warn(`⚠️ db 声明门异常降级放行(apply-manifest.json 读取/解析失败,fail-open): ${(e && e.message) || e}`)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
788
819
|
// ── 归档前 delta.md 自动生成(P3d task-02,fail-soft 零阻断)──
|
|
789
820
|
// 目录移走前在 changes/<name>/ 落一份 Before/Delta/After 三段式快照(四源采集容缺,
|
|
790
821
|
// buildDeltaReport 见 ../archive-delta.js),随归档包留存。失败只 console.error 留痕、
|
|
@@ -2481,3 +2512,143 @@ export function extractQuickCauseField(outputText) {
|
|
|
2481
2512
|
return m[1].replace(/\s+/g, ' ').trim()
|
|
2482
2513
|
}
|
|
2483
2514
|
|
|
2515
|
+
// ── Wave 步骤完成度门(2026-09-17-pass-cap-semantics task-08 / FR-12 / D-013@v1)──
|
|
2516
|
+
// 动机锚:2026-09-17 本变更执行中 Wave 2 越位实证——review write 退出码被 shell 管道吞掉后
|
|
2517
|
+
// --done 落在下一 Wave,「Wave 2 执行」步骤被静默标 completed;既有防护(--step 意图断言
|
|
2518
|
+
// 可选、并发 60s 横幅仅 warn)均非阻断。本门 fail-closed 补执行期缺口,放文件尾(函数声明
|
|
2519
|
+
// 提升 + B1 源码文本级断言窗口约束,同知识闭环段先例——门内含 process.exit,收拢在此避免
|
|
2520
|
+
// 推挪 handleQuickStageCompletion 附近 detectConcurrentChanges ±[200,600] 的断言锚)。
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* 解析 plan.md 第 seq 个显式 Wave 段的任务 ID 列表。
|
|
2524
|
+
*
|
|
2525
|
+
* 同源口径孪生(不造第二套解析):src/stages/execute.js 的 parseWavesFromPlan 未导出,且
|
|
2526
|
+
* execute.js 不在本任务 allowed_paths,无法直接 import——正则与段边界守卫逐字对齐:
|
|
2527
|
+
* - Wave 标题 /^#+\s*Wave\s*(\d+)/i(空格可选、编号后缀任意——解析侧宁可多收不可静默丢)
|
|
2528
|
+
* - 引用行 /^[-*]\s+task-(\d+)\s*$/i → 归一 task-NN(padStart(2,'0'))
|
|
2529
|
+
* - 任何非 Wave 标题行(/^#{1,6}\s+/)退出当前段(「## 自检」等后续段的行不收)
|
|
2530
|
+
* - 段序 = 出现顺序(buildExecuteSteps 的步骤名 `Wave ${i+1} 执行` 取 waves 数组位次,
|
|
2531
|
+
* 非段内编号——显式段存在时两者通常一致,乱序编号时位次与 prompt 实际下发任务一致)
|
|
2532
|
+
* 差异(有意):不做 parseWavesFromPlan 的隐式 Wave 合成——门只核对显式段,plan 无显式段
|
|
2533
|
+
* 一律 null 交由调用方 warn 放行(D-003@v1 隐式串行语义无段可核对,不误伤)。
|
|
2534
|
+
* 漂移风险由 test/wave-task-complete-gate.test.mjs 断言锁定。
|
|
2535
|
+
*
|
|
2536
|
+
* @param {string} planContent plan.md 全文
|
|
2537
|
+
* @param {number} seq 1-based 显式段位次(步骤名编号同源)
|
|
2538
|
+
* @returns {string[]|null} 该段 task ID 列表;null = 显式段不足 seq 个(隐式/light/兜底形态)
|
|
2539
|
+
*/
|
|
2540
|
+
function parseExplicitWaveTaskIds(planContent, seq) {
|
|
2541
|
+
const WAVE_HEADING_RE = /^#+\s*Wave\s*(\d+)/i // ← execute.js parseWavesFromPlan 同源正则
|
|
2542
|
+
const waveRefRe = /^[-*]\s+task-(\d+)\s*$/i // ← execute.js parseWavesFromPlan 同源正则
|
|
2543
|
+
const sections = [] // [{ index, ids }] 按出现顺序(不合成隐式 Wave)
|
|
2544
|
+
let current = null
|
|
2545
|
+
for (const rawLine of String(planContent || '').split('\n')) {
|
|
2546
|
+
const line = rawLine.replace(/\r$/, '')
|
|
2547
|
+
const waveMatch = line.match(WAVE_HEADING_RE)
|
|
2548
|
+
if (waveMatch) {
|
|
2549
|
+
current = { index: parseInt(waveMatch[1], 10), ids: [] }
|
|
2550
|
+
sections.push(current)
|
|
2551
|
+
continue
|
|
2552
|
+
}
|
|
2553
|
+
if (/^#{1,6}\s+/.test(line)) { current = null; continue }
|
|
2554
|
+
const refMatch = line.match(waveRefRe)
|
|
2555
|
+
if (refMatch && current) current.ids.push(`task-${refMatch[1].padStart(2, '0')}`)
|
|
2556
|
+
}
|
|
2557
|
+
const target = sections[seq - 1]
|
|
2558
|
+
return target ? target.ids : null
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/**
|
|
2562
|
+
* Wave 步骤完成度门(task-08 / FR-12 / D-013@v1):execute 阶段名为「Wave N 执行」的步骤
|
|
2563
|
+
* --done 时 fail-closed 核对本 Wave 全部 task 的 tasks.md checkbox 已勾(勾选唯一真源是
|
|
2564
|
+
* CLI:review write 落盘即勾 + autoCheckPlanFromReviews 兜底)。任一未勾 → console.error
|
|
2565
|
+
* 列未勾清单与两条出路 + process.exit(1),步骤保持待完成(接线点在 completeStep 的
|
|
2566
|
+
* status='completed' 赋值之前,exit 时 DB 不落假完成态)。
|
|
2567
|
+
*
|
|
2568
|
+
* 生效面与放行面:
|
|
2569
|
+
* - 仅 steps[currentIdx].name 匹配 /^Wave (\d+) 执行$/ 时生效,其他步骤名直接 return(零行为)
|
|
2570
|
+
* - ① autoCheckPlanFromReviews 幂等先行(review pass 自动勾选——先补勾再核对,「review 已
|
|
2571
|
+
* pass 但 checkbox 未回填」形态不误拦;该函数自身全路径 try/catch 从不抛)
|
|
2572
|
+
* - plan.md 无第 N 个显式 Wave 段(隐式 Wave/light 计划)→ warn 放行(D-003@v1 不误伤)
|
|
2573
|
+
* - plan.md/tasks.md 缺失或读取失败 → warn 放行(fail-open,文档瞬态不锁死流程)
|
|
2574
|
+
* - 门自身意外异常不在本函数内吞——上抛给接线层(complete.js)fail-open warn 放行;
|
|
2575
|
+
* 正常判定路径(读到未勾)必须硬拦,process.exit 不受 try/catch 影响
|
|
2576
|
+
*
|
|
2577
|
+
* @param {{ steps: Array<{name:string}>, currentIdx: number, changeName: string, cwd: string, specBase?: string, platformOpts?: object }} opts
|
|
2578
|
+
* changeDir 锚定与 autoCheckPlanFromReviews 写入侧同口径(driftAnchor > specRoot > specBase >
|
|
2579
|
+
* cwd/.sillyspec)——tasks.md 是 autoCheck 写的,门必须读同一份,防双真源分裂。
|
|
2580
|
+
*/
|
|
2581
|
+
export async function assertWaveTasksComplete({ steps, currentIdx, changeName, cwd, specBase, platformOpts = {} }) {
|
|
2582
|
+
const stepName = String(steps?.[currentIdx]?.name || '')
|
|
2583
|
+
const waveMatch = /^Wave (\d+) 执行$/.exec(stepName)
|
|
2584
|
+
if (!waveMatch) return // 非「Wave N 执行」步骤:零行为(其他 execute 步骤 / 其他阶段不门)
|
|
2585
|
+
const waveSeq = parseInt(waveMatch[1], 10)
|
|
2586
|
+
if (!changeName || !cwd) {
|
|
2587
|
+
console.warn(`⚠️ [wave-complete-gate] 缺 changeName/cwd,跳过「${stepName}」完成度核对(fail-open)`)
|
|
2588
|
+
return
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// ① autoCheck 幂等先行(D-013):兜底 catch 仅防动态 import 自身失败——warn 后继续核对
|
|
2592
|
+
// (checkbox 才是门的判定依据;勾选补偿失败留给两条出路①重跑 --done 再试,不锁死)。
|
|
2593
|
+
// autoCheckPlanFromReviews 定义于 ./complete.js(grep 实证 :1172 export;任务卡原文写
|
|
2594
|
+
// ../task-review.js 系定位笔误,该文件仅有引用注释无定义);动态 import 防顶层环
|
|
2595
|
+
// (complete.js 静态 import 本文件,运行时调用时 complete.js 已在模块缓存,无死锁)。
|
|
2596
|
+
try {
|
|
2597
|
+
const { autoCheckPlanFromReviews } = await import('./complete.js')
|
|
2598
|
+
await autoCheckPlanFromReviews({ stageName: 'execute', changeName, cwd, platformOpts })
|
|
2599
|
+
} catch (e) {
|
|
2600
|
+
console.warn(`⚠️ [wave-complete-gate] autoCheck 先行勾选失败,按 tasks.md 现状继续核对: ${(e && e.message) || e}`)
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const gateSpecBase = platformOpts?.specDriftAnchor || platformOpts?.specRoot || specBase || join(cwd, '.sillyspec')
|
|
2604
|
+
const changeDir = join(gateSpecBase, 'changes', changeName)
|
|
2605
|
+
const planPath = join(changeDir, 'plan.md')
|
|
2606
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
2607
|
+
|
|
2608
|
+
let planContent, tasksContent
|
|
2609
|
+
try {
|
|
2610
|
+
if (!existsSync(planPath) || !existsSync(tasksPath)) {
|
|
2611
|
+
console.warn(`⚠️ [wave-complete-gate] plan.md/tasks.md 缺失(${changeDir}),跳过「${stepName}」完成度核对(fail-open 放行)`)
|
|
2612
|
+
return
|
|
2613
|
+
}
|
|
2614
|
+
planContent = readFileSync(planPath, 'utf8')
|
|
2615
|
+
tasksContent = readFileSync(tasksPath, 'utf8')
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
console.warn(`⚠️ [wave-complete-gate] plan.md/tasks.md 读取失败(${(e && e.message) || e}),跳过核对(fail-open 放行)`)
|
|
2618
|
+
return
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// ② 本 Wave 任务 ID 列表(parseExplicitWaveTaskIds:execute.js parseWavesFromPlan 同源孪生)
|
|
2622
|
+
const waveTaskIds = parseExplicitWaveTaskIds(planContent, waveSeq)
|
|
2623
|
+
if (waveTaskIds === null) {
|
|
2624
|
+
console.warn(`⚠️ [wave-complete-gate] plan.md 无第 ${waveSeq} 个显式 Wave 段(隐式 Wave/light 计划无显式段头),跳过完成度核对(fail-open 放行——D-003@v1 隐式串行语义不误伤)`)
|
|
2625
|
+
return
|
|
2626
|
+
}
|
|
2627
|
+
if (waveTaskIds.length === 0) {
|
|
2628
|
+
// 段在但无引用行:execute 启动时 validatePlanForExecute 已拦(空 Wave 段诊断),不重复拦
|
|
2629
|
+
return
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// ③ tasks.md checkbox 逐 ID 核对(parseTaskRegistry 是 execute.js 导出的同源解析,复用不另造)
|
|
2633
|
+
const { parseTaskRegistry } = await import('../stages/execute.js')
|
|
2634
|
+
const registry = parseTaskRegistry(tasksContent)
|
|
2635
|
+
const regById = new Map(registry.map(t => [t.id, t]))
|
|
2636
|
+
const unchecked = waveTaskIds.filter(id => regById.get(id)?.done !== true)
|
|
2637
|
+
|
|
2638
|
+
if (unchecked.length > 0) {
|
|
2639
|
+
// ④ fail-closed 硬拦:沿用仓内 console.error + process.exit(1) 既有形态(错误信息
|
|
2640
|
+
// process.exit 前同步打印完整——Windows 下 UV_HANDLE_CLOSING 退出码坑不复现)
|
|
2641
|
+
console.error(`❌ Wave ${waveSeq} 完成度门未过:本 Wave ${waveTaskIds.length} 个 task 中 ${unchecked.length} 个的 tasks.md checkbox 未勾——本次 --done 未完成,步骤「${stepName}」保持待完成。`)
|
|
2642
|
+
console.error(' 未勾任务:')
|
|
2643
|
+
for (const id of unchecked) {
|
|
2644
|
+
const reg = regById.get(id)
|
|
2645
|
+
console.error(` - ${id}${reg && reg.name ? `(${reg.name})` : '(tasks.md 注册表无此行——plan 悬空引用)'}`)
|
|
2646
|
+
}
|
|
2647
|
+
console.error(' 两条出路:')
|
|
2648
|
+
console.error(` ① 任务确未完成:补实现与 review write(sillyspec review write --change ${changeName} --task <task-NN> --spec pass --quality pass --force --changed-files <文件>)后重跑 --done——review pass 落盘即自动勾选`)
|
|
2649
|
+
console.error(` ② 确属误推进(步骤被越位标完成):sillyspec run execute --reopen --from-step ${currentIdx + 1}${changeName ? ` --change ${changeName}` : ''} 退回本步重走完成`)
|
|
2650
|
+
process.exit(1)
|
|
2651
|
+
}
|
|
2652
|
+
console.log(`🛡️ 「${stepName}」完成度门通过:${waveTaskIds.length} 个 task checkbox 全勾(${waveTaskIds.join('、')})`)
|
|
2653
|
+
}
|
|
2654
|
+
|
package/src/run/complete.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
|
|
17
17
|
* - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
|
|
18
18
|
*/
|
|
19
|
-
import { join } from 'node:path'
|
|
19
|
+
import { join, isAbsolute, resolve } from 'node:path'
|
|
20
20
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
21
21
|
import { createHash } from 'node:crypto'
|
|
22
22
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
@@ -43,10 +43,10 @@ async function getStageStepsAutoAware(stageName, cwd, progress, specDir) {
|
|
|
43
43
|
import { executeScanPreflight, executeScanPostcheck, computeScanProfile, executeScanDetectProjects, executeScanResumeCheck, executeScanFinalize } from './scan-profile.js'
|
|
44
44
|
import { executeProgressConfirm } from './progress-confirm.js'
|
|
45
45
|
import { AUXILIARY_STAGES } from '../constants.js'
|
|
46
|
-
import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
|
|
46
|
+
import { executePlanPostcheck as runPlanPostcheckLib, parseRepoRegistry } from '../stages/plan-postcheck.js'
|
|
47
47
|
import { outputStep, collectStageWaitHistory } from './prompt.js'
|
|
48
48
|
import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
|
|
49
|
-
import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
|
|
49
|
+
import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, assertWaveTasksComplete } from './complete-handlers.js'
|
|
50
50
|
import { formatExecuteSummary } from '../worktree-apply.js'
|
|
51
51
|
import { validateDecisionModuleRefs } from '../design-facts.js'
|
|
52
52
|
import { isEndToEndTaskText } from '../change-risk-profile.js'
|
|
@@ -488,6 +488,19 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
488
488
|
process.exit(1)
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
// ── Wave 步骤完成度门(2026-09-17-pass-cap-semantics task-08 / FR-12 / D-013@v1)──
|
|
492
|
+
// 动机锚:2026-09-17 本变更执行中 Wave 2 越位实证——review write 退出码被 shell 管道吞掉后
|
|
493
|
+
// --done 落在下一 Wave,步骤被静默标 completed;既有防护(--step 可选断言 / 60s 并发横幅)
|
|
494
|
+
// 均非阻断。本门 fail-closed:名为「Wave N 执行」的步骤 --done 时本 Wave 任一 task 的
|
|
495
|
+
// tasks.md checkbox 未勾 → exit 1(先 autoCheckPlanFromReviews 幂等补勾再核对,见
|
|
496
|
+
// complete-handlers.js assertWaveTasksComplete)。门自身异常 fail-open warn——门 bug 不
|
|
497
|
+
// 锁死完成路径,正常判定路径硬拦;exit 时 status 尚未赋 completed,DB 不落假完成态。
|
|
498
|
+
try {
|
|
499
|
+
await assertWaveTasksComplete({ steps, currentIdx, changeName, cwd, specBase, platformOpts })
|
|
500
|
+
} catch (e) {
|
|
501
|
+
console.warn(`⚠️ Wave 完成度门异常,fail-open 放行(不阻断本次完成): ${(e && e.message) || e}`)
|
|
502
|
+
}
|
|
503
|
+
|
|
491
504
|
steps[currentIdx].status = 'completed'
|
|
492
505
|
steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
493
506
|
if (outputText) {
|
|
@@ -1009,7 +1022,33 @@ async function buildDraftContext(cwd, changeName) {
|
|
|
1009
1022
|
* merge-base 补齐,与草稿归因同口径),再剔 meta.baselineFiles(R-07 防伪底线:merge-base..
|
|
1010
1023
|
* wtHEAD 含 baseline checkpoint 提交,夹带文件=非本变更改动,不剔则「声明未做恰被夹带」的
|
|
1011
1024
|
* task 误勾)。全空时仍不设 diffFileSet(退回逐 task 实测路径,原语义)。
|
|
1025
|
+
*
|
|
1026
|
+
* task-05 FR-08:另并入跨仓仓根双源(diff HEAD~1..HEAD ∪ status porcelain,见函数体
|
|
1027
|
+
* collectCrossRepoDiffRoots 注记)——跨仓 task 的 changedFiles 按其仓根相对路径命中。
|
|
1012
1028
|
*/
|
|
1029
|
+
/**
|
|
1030
|
+
* 跨仓仓根收集(task-05 / FR-08 diff 源层):读侧口径 = <cwd>/.sillyspec/local.yaml 的
|
|
1031
|
+
* parseRepoRegistry(与 design-facts.js 有段头分支 / execute MultiRepoContext 同款);相对
|
|
1032
|
+
* 注册路径按 cwd resolve,绝对路径原样,根不存在跳过。local.yaml 不可读/解析失败 fail-open
|
|
1033
|
+
* 返回 [](不炸勾选主流程、不放大勾选面)。
|
|
1034
|
+
*/
|
|
1035
|
+
function collectCrossRepoDiffRoots(cwd) {
|
|
1036
|
+
const roots = []
|
|
1037
|
+
try {
|
|
1038
|
+
if (!cwd) return roots
|
|
1039
|
+
const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
|
|
1040
|
+
if (!existsSync(localYamlPath)) return roots
|
|
1041
|
+
const registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8'))
|
|
1042
|
+
if (!registry) return roots
|
|
1043
|
+
for (const raw of registry.values()) {
|
|
1044
|
+
if (!raw) continue
|
|
1045
|
+
const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
|
|
1046
|
+
if (root && existsSync(root)) roots.push(root)
|
|
1047
|
+
}
|
|
1048
|
+
} catch { /* registry 不可读 → 空表(fail-open) */ }
|
|
1049
|
+
return roots
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1013
1052
|
function prefetchDiffFileSet(ctx) {
|
|
1014
1053
|
if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
|
|
1015
1054
|
try {
|
|
@@ -1030,6 +1069,37 @@ function prefetchDiffFileSet(ctx) {
|
|
|
1030
1069
|
const n = String(b).replace(/\\/g, '/').trim()
|
|
1031
1070
|
if (n) diffFileSet.delete(n)
|
|
1032
1071
|
}
|
|
1072
|
+
// task-05 FR-08:跨仓仓根 diff 双源并入(adopt 通道跨仓 review 的 changedFiles 是其仓根
|
|
1073
|
+
// 相对路径,只算主仓源时 shouldAutoCheckTask 的 ctx.diffFileSet.has(f) 恒 miss → 跨仓
|
|
1074
|
+
// task 自动勾选断链)。双源 = git diff --name-only HEAD~1..HEAD ∪ git status --porcelain
|
|
1075
|
+
// --untracked-files=all(cross-repo-reconcile.js:94-106 双源口径只读复用,数组参数防
|
|
1076
|
+
// shell 拆词),产物正斜杠归一;置于剔 baseline 之后(跨仓仓不含主仓 baseline checkpoint
|
|
1077
|
+
// 提交,不被主仓夹带清单误剔)。git 失败/registry 不可读 fail-open 跳过该仓不炸主流程。
|
|
1078
|
+
try {
|
|
1079
|
+
for (const root of collectCrossRepoDiffRoots(ctx.cwd)) {
|
|
1080
|
+
let anySource = false
|
|
1081
|
+
const diffOut = gitQuiet(root, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
|
|
1082
|
+
if (diffOut !== null) {
|
|
1083
|
+
anySource = true
|
|
1084
|
+
for (const f of String(diffOut).split('\n')) {
|
|
1085
|
+
const n = String(f || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
|
|
1086
|
+
if (n) diffFileSet.add(n)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const statusOut = gitQuiet(root, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
|
|
1090
|
+
if (statusOut !== null) {
|
|
1091
|
+
anySource = true
|
|
1092
|
+
for (const line of String(statusOut).split('\n')) {
|
|
1093
|
+
// porcelain 行 = XY<space>path(剥前 3 列);rename 取 -> 后新路径;剥引号、正斜杠归一
|
|
1094
|
+
let p = line.slice(3).trim()
|
|
1095
|
+
if (p.includes(' -> ')) p = p.slice(p.lastIndexOf(' -> ') + 4).trim()
|
|
1096
|
+
p = p.replace(/^"|"$/g, '').replace(/\\/g, '/')
|
|
1097
|
+
if (p) diffFileSet.add(p)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (!anySource) console.warn(`⚠️ 跨仓仓根 ${root} git 不可用/非仓库(diff 与 status 双失败),跳过该仓 diff 源(fail-open,不阻断勾选主流程)`)
|
|
1101
|
+
}
|
|
1102
|
+
} catch { /* 跨仓源异常 → 不炸主流程(fail-open) */ }
|
|
1033
1103
|
if (diffFileSet.size === 0) return ctx
|
|
1034
1104
|
return { ...ctx, diffFileSet }
|
|
1035
1105
|
} catch {
|