sillyspec 3.28.7 → 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/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/config-cat.js +6 -3
- package/src/constants.js +3 -0
- package/src/db.js +12 -3
- 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/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 +41 -35
- 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 +23 -0
- package/src/quicklog.js +44 -18
- package/src/run/command.js +17 -3
- package/src/run/complete-handlers.js +3 -3
- package/src/run/complete.js +28 -9
- 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 +9 -1
- package/src/run/quick-audit.js +25 -2
- package/src/run/shared.js +98 -10
- package/src/run/verify-quality-scan.js +14 -3
- 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
package/src/run/shared.js
CHANGED
|
@@ -299,7 +299,7 @@ export function detectWorktreeSpecDrift(specBase) {
|
|
|
299
299
|
const seg = resolve(specBase).split(sep)
|
|
300
300
|
// specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
|
|
301
301
|
// <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
|
|
302
|
-
// 否则 worktree 根目录本身(<.../worktrees/<change
|
|
302
|
+
// 否则 worktree 根目录本身(<.../worktrees/<change>>)会被误判(它不是 spec,不会作 specBase 传入)。
|
|
303
303
|
if (seg[seg.length - 1] !== '.sillyspec') return null
|
|
304
304
|
for (let i = 0; i + 3 < seg.length; i++) {
|
|
305
305
|
if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
|
|
@@ -317,6 +317,33 @@ export function detectWorktreeSpecDrift(specBase) {
|
|
|
317
317
|
return null
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* 判定「目录在 sillyspec 管理的隔离 worktree 内」(坑 worktree-cwd-silent-split,2026-08-29 用户实证:
|
|
322
|
+
* Bash cwd 残留两次误在 worktree 内跑 sillyspec——第一次走 status 类命令完全静默,进度险些写进
|
|
323
|
+
* worktree 副本 .sillyspec 与主仓进度库分裂)。
|
|
324
|
+
*
|
|
325
|
+
* 与 detectWorktreeSpecDrift 的分工:那边判 specBase(恒以 .sillyspec 结尾,runCommand 内自动
|
|
326
|
+
* 锚定用,只覆盖 plan/execute/verify/archive 四 stage);这边判任意目录(CLI 入口硬拦用,目录
|
|
327
|
+
* 可以是 worktree 根或其任意子目录,无尾段要求)。
|
|
328
|
+
*
|
|
329
|
+
* @param {string} dir 目标目录(通常为 process.cwd() 或 --dir 显式值)
|
|
330
|
+
* @returns {{ changeName: string, mainRepoRoot: string, worktreeRoot: string } | null} null=不在 worktree 内
|
|
331
|
+
*/
|
|
332
|
+
export function detectCwdInsideWorktree(dir) {
|
|
333
|
+
if (!dir) return null
|
|
334
|
+
const seg = resolve(dir).split(sep)
|
|
335
|
+
for (let i = 0; i + 3 < seg.length; i++) {
|
|
336
|
+
if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees' && seg[i + 3]) {
|
|
337
|
+
return {
|
|
338
|
+
changeName: seg[i + 3],
|
|
339
|
+
mainRepoRoot: seg.slice(0, i).join(sep),
|
|
340
|
+
worktreeRoot: seg.slice(0, i + 4).join(sep),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
|
|
320
347
|
/**
|
|
321
348
|
* 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
|
|
322
349
|
*
|
|
@@ -791,14 +818,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
|
|
|
791
818
|
* 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
|
|
792
819
|
* sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
|
|
793
820
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* 变更补上;execute 阶段的任务粒度信号已由 X4(stages/execute.js Wave prompt
|
|
821
|
+
* 接线说明:已接线——run/prompt.js outputStep 步骤渲染前段 fire-and-forget 调用本钩子
|
|
822
|
+
* (task-13 当时 allowed_paths 不含渲染层,遗留活跃坑由 2026-08-29 docs/sillyspec
|
|
823
|
+
* 定期收口补上);execute 阶段的任务粒度信号另由 X4(stages/execute.js Wave prompt
|
|
798
824
|
* 每任务上报指引)覆盖。
|
|
799
825
|
*/
|
|
800
826
|
export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
|
|
801
827
|
try {
|
|
828
|
+
// 无 changeName(渲染层个别路径/测试 fixture)直接静默——sync(null) 只会打 warn 噪音
|
|
829
|
+
if (!changeName) return
|
|
802
830
|
const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
803
831
|
if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
|
|
804
832
|
return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
|
|
@@ -948,16 +976,20 @@ export function warnApprovalUnknown(cwd, changeName, reason) {
|
|
|
948
976
|
/**
|
|
949
977
|
* 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
|
|
950
978
|
* 平台模式走自己的链路,跳过;否则 await import sync.js。
|
|
979
|
+
* 返回语义:null = 平台模式有意跳过;{status:'unknown'} = 检查链路意外失败——调用方走
|
|
980
|
+
* warnApprovalUnknown 横幅 + approval-unknown.log 留痕(HUB-07)。异常折叠成 null 会绕过
|
|
981
|
+
* unknown 留痕静默放行(2026-09-11 审查)。
|
|
951
982
|
* @returns {{ status: string, reason?: string } | null}
|
|
952
|
-
*/export async function checkApproval(cwd, changeName, platformOpts = {}) {
|
|
983
|
+
*/export async function checkApproval(cwd, changeName, platformOpts = {}, { loadSyncMod = null } = {}) {
|
|
953
984
|
// 平台模式不需要 CLI 内置审批检查;自指回环(repo-native junction)按本地模式处理
|
|
954
985
|
if (isPlatformMode(platformOpts, cwd)) return null
|
|
986
|
+
const load = loadSyncMod || (() => import('../sync.js'))
|
|
955
987
|
try {
|
|
956
|
-
// shared.js 在 src/run/,sync.js 在 src/ →
|
|
957
|
-
const syncMod = await
|
|
988
|
+
// shared.js 在 src/run/,sync.js 在 src/ → 退一层;loadSyncMod 为测试缝(注入抛错验证 unknown 路由)
|
|
989
|
+
const syncMod = await load()
|
|
958
990
|
return await syncMod.checkApproval(changeName, cwd)
|
|
959
991
|
} catch (e) {
|
|
960
|
-
return
|
|
992
|
+
return { status: 'unknown', reason: `审批检查异常: ${e?.message || e}` }
|
|
961
993
|
}
|
|
962
994
|
}
|
|
963
995
|
|
|
@@ -1249,6 +1281,23 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
1249
1281
|
// 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
|
|
1250
1282
|
const currentEntries = gitStatus.split('\n').filter(Boolean)
|
|
1251
1283
|
|
|
1284
|
+
// EOL-only 噪声过滤(坑 eol-rewrite-fake-mtime-noise,2026-09-12 驾驭第十六批②,用户实证
|
|
1285
|
+
// gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
|
|
1286
|
+
// 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
|
|
1287
|
+
// 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
|
|
1288
|
+
// 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
|
|
1289
|
+
// ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
|
|
1290
|
+
// 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
|
|
1291
|
+
// 返回 {value,error}),失败显式落 null 透传不过滤。
|
|
1292
|
+
const eolRawList = (() => {
|
|
1293
|
+
try {
|
|
1294
|
+
const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
|
|
1295
|
+
if (!res || res.error) return null
|
|
1296
|
+
return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
|
|
1297
|
+
} catch { return null }
|
|
1298
|
+
})()
|
|
1299
|
+
const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
|
|
1300
|
+
|
|
1252
1301
|
const normalizeGitPath = (p) => p.replace(/\\/g, '/')
|
|
1253
1302
|
// step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
|
|
1254
1303
|
// 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
|
|
@@ -1321,6 +1370,14 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
1321
1370
|
continue
|
|
1322
1371
|
}
|
|
1323
1372
|
|
|
1373
|
+
// EOL-only(坑 eol-rewrite-fake-mtime-noise):tracked 修改但真实内容零变化(仅行尾重写)
|
|
1374
|
+
// → 剔出审计归属(changedFiles/各门),归 eolOnlyFiles 软警告。untracked(??)不经此径。
|
|
1375
|
+
if (eolRealChanged instanceof Set && status !== '??' && rawStatus[0] !== 'D' && rawStatus[1] !== 'D'
|
|
1376
|
+
&& !eolRealChanged.has(file)) {
|
|
1377
|
+
result.eolOnlyFiles = result.eolOnlyFiles || []
|
|
1378
|
+
result.eolOnlyFiles.push(file)
|
|
1379
|
+
continue
|
|
1380
|
+
}
|
|
1324
1381
|
result.changedFiles.push(file)
|
|
1325
1382
|
if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
|
|
1326
1383
|
if (status === '??') result.newFiles.push(file)
|
|
@@ -1357,8 +1414,28 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
1357
1414
|
}
|
|
1358
1415
|
|
|
1359
1416
|
// 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
|
|
1417
|
+
// spec 共享面降级(坑 foreign-spec-churn-fail-closed,2026-09-12 驾驭第十六批①,用户实证
|
|
1418
|
+
// 并行会话归档移动 docs/sillyspec + changes/ 被拦只能 --allow-delete):.sillyspec/ 与 docs/
|
|
1419
|
+
// 是多会话共享 spec 面——非本会话声明的删除在此面发生时,归因「并行他者 spec 收尾/归档移动」
|
|
1420
|
+
// 降级软警告不阻断(src/test 交付面删除仍 fail-closed——那是本会话可支配域)。
|
|
1421
|
+
const isSpecSharedPath = (p) => {
|
|
1422
|
+
const n = p.replace(/\\/g, '/')
|
|
1423
|
+
return n.startsWith('.sillyspec/') || n.startsWith('docs/')
|
|
1424
|
+
}
|
|
1360
1425
|
if (!allowDelete) {
|
|
1426
|
+
const stillBlocked = []
|
|
1361
1427
|
for (const f of result.deletedFiles) {
|
|
1428
|
+
if (isSpecSharedPath(f) && !ownDeclaredNorm.has(f.replace(/\\/g, '/'))) {
|
|
1429
|
+
result.foreignSpecChurn = result.foreignSpecChurn || []
|
|
1430
|
+
result.foreignSpecChurn.push(f)
|
|
1431
|
+
continue
|
|
1432
|
+
}
|
|
1433
|
+
stillBlocked.push(f)
|
|
1434
|
+
}
|
|
1435
|
+
// 整栈退(对齐 linkedChangeLeftovers 模式):归他者的 spec 面删除从 deletedFiles 移除——
|
|
1436
|
+
// 下方 status 判定与 --allow-delete 渲染均不再见它(否则 blocked-with-empty-reasons)
|
|
1437
|
+
result.deletedFiles = stillBlocked
|
|
1438
|
+
for (const f of stillBlocked) {
|
|
1362
1439
|
result.reasons.push(`删除文件: ${f}`)
|
|
1363
1440
|
}
|
|
1364
1441
|
}
|
|
@@ -1374,7 +1451,18 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
1374
1451
|
if (!allowNew) {
|
|
1375
1452
|
for (const f of result.newFiles) {
|
|
1376
1453
|
if (!isQuickMetadata(f, guard.linkedChanges)) {
|
|
1377
|
-
|
|
1454
|
+
// mtime 归因(坑 foreign-spec-churn-fail-closed 配套,用户建议「按 quick 启动后
|
|
1455
|
+
// mtime 软判定」):早于会话启动 = 预存残留(baseline 漏记或并行会话预存,非本
|
|
1456
|
+
// 会话窗口新建)——reason 标注供裁决,门语义不变(新增本就 warning 档)。
|
|
1457
|
+
let mtimeNote = ''
|
|
1458
|
+
try {
|
|
1459
|
+
const st = statSync(join(cwd, f))
|
|
1460
|
+
const startedAtMs = guard.startedAt ? new Date(guard.startedAt).getTime() : 0
|
|
1461
|
+
if (startedAtMs && st.mtimeMs < startedAtMs) {
|
|
1462
|
+
mtimeNote = '(mtime 早于会话启动——预存残留/并行预存,非本会话新建;--files 声明归属或忽略)'
|
|
1463
|
+
}
|
|
1464
|
+
} catch { /* stat 失败无注记 */ }
|
|
1465
|
+
result.reasons.push(`新增文件(需 --allow-new): ${f}${mtimeNote}`)
|
|
1378
1466
|
}
|
|
1379
1467
|
}
|
|
1380
1468
|
}
|
|
@@ -276,7 +276,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
276
276
|
const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
|
|
277
277
|
snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
|
|
278
278
|
if (snap) {
|
|
279
|
-
console.log(`🧪 noAI
|
|
279
|
+
console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
|
|
280
280
|
}
|
|
281
281
|
} catch { /* 快照链路异常 → 主仓现行为 */ }
|
|
282
282
|
}
|
|
@@ -292,7 +292,14 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
292
292
|
try {
|
|
293
293
|
testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
|
|
294
294
|
printVerifyTestCheck(testCheck)
|
|
295
|
-
lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
|
|
295
|
+
lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snap ? 5 * 60 * 1000 : undefined })
|
|
296
|
+
// 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
|
|
297
|
+
// 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
|
|
298
|
+
// 并行噪声混入时失败输出带归属鉴定提示)
|
|
299
|
+
if (snap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
|
|
300
|
+
console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
|
|
301
|
+
lintCheck = runVerifyLintCheck({ cwd, specBase })
|
|
302
|
+
}
|
|
296
303
|
if (lintCheck.status !== 'skipped') {
|
|
297
304
|
console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
|
|
298
305
|
}
|
|
@@ -315,9 +322,13 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
315
322
|
// lint 失败(SILLYSPEC_VERIFY_LINT_GATE=advisory 时不 throw)同样是摩擦信号,计数不应随
|
|
316
323
|
// 逃生阀漂移。recordFrictionEvent 自带静默降级,不影响本步推进/throw 语义。
|
|
317
324
|
if (testFailed || lintCheck.status === 'failed') {
|
|
318
|
-
recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
|
|
325
|
+
await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
|
|
319
326
|
}
|
|
320
327
|
if (testFailed || lintBlocked) {
|
|
328
|
+
if (snap) {
|
|
329
|
+
try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
|
|
330
|
+
printSnapshotFailureHint(snap) } catch { /* 提示失败不影响阻断 */ }
|
|
331
|
+
}
|
|
321
332
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
322
333
|
await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot })
|
|
323
334
|
const which = testFailed ? '测试' : 'lint'
|
package/src/spec-sync.js
CHANGED
|
@@ -124,6 +124,9 @@ export function hashFiles(entries) {
|
|
|
124
124
|
hash: _hashBuffer(buf),
|
|
125
125
|
mtime: Math.floor(e.mtimeMs / 1000),
|
|
126
126
|
absPath: e.absPath,
|
|
127
|
+
// 批 E-①:buf 随行携带(computeSpecOps 的 update/add 直接取用)——消除变更文件二次
|
|
128
|
+
// readFileSync(大文件双读)。docs 树为文本内容、量级有限,整批驻留可接受。
|
|
129
|
+
buf,
|
|
127
130
|
};
|
|
128
131
|
});
|
|
129
132
|
}
|
|
@@ -177,20 +180,22 @@ export function computeSpecOps(serverManifest, localFiles) {
|
|
|
177
180
|
return [];
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
// rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash
|
|
183
|
+
// rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同。
|
|
184
|
+
// 批 E-①:候选新路径按 hash 建索引 O(N+M)——旧双重循环 O(N×M),万级文件树明显变慢。
|
|
181
185
|
const renames = [];
|
|
182
|
-
const
|
|
186
|
+
const newByHash = new Map(); // hash → 未被服务器收录且未被消费的新路径队列
|
|
187
|
+
for (const [newPath, entry] of localMap) {
|
|
188
|
+
if (serverSet.has(newPath)) continue
|
|
189
|
+
const list = newByHash.get(entry.hash)
|
|
190
|
+
if (list) list.push(newPath); else newByHash.set(entry.hash, [newPath])
|
|
191
|
+
}
|
|
183
192
|
for (const oldPath of serverPaths) {
|
|
184
|
-
if (localSet.has(oldPath)) continue
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
renames.push({ oldPath, newPath });
|
|
191
|
-
consumedNew.add(newPath);
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
193
|
+
if (localSet.has(oldPath)) continue
|
|
194
|
+
const candidates = newByHash.get(server[oldPath].hash)
|
|
195
|
+
if (candidates && candidates.length > 0) {
|
|
196
|
+
const newPath = candidates.shift()
|
|
197
|
+
renames.push({ oldPath, newPath })
|
|
198
|
+
if (candidates.length === 0) newByHash.delete(server[oldPath].hash)
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
const renamedOld = new Set(renames.map((r) => r.oldPath));
|
|
@@ -212,7 +217,7 @@ export function computeSpecOps(serverManifest, localFiles) {
|
|
|
212
217
|
if (localSet.has(p)) {
|
|
213
218
|
const localEntry = localMap.get(p);
|
|
214
219
|
if (localEntry.hash !== server[p].hash) {
|
|
215
|
-
const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
|
|
220
|
+
const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
|
|
216
221
|
ops.push({
|
|
217
222
|
op: 'update',
|
|
218
223
|
path: p,
|
|
@@ -235,7 +240,7 @@ export function computeSpecOps(serverManifest, localFiles) {
|
|
|
235
240
|
if (renamedNew.has(p)) continue;
|
|
236
241
|
if (!serverSet.has(p)) {
|
|
237
242
|
const localEntry = localMap.get(p);
|
|
238
|
-
const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
|
|
243
|
+
const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
|
|
239
244
|
ops.push({
|
|
240
245
|
op: 'add',
|
|
241
246
|
path: p,
|
|
@@ -317,10 +317,11 @@ const PLAN_RULES = [
|
|
|
317
317
|
target: { root: 'change', path: 'design.md', scope: 'full' },
|
|
318
318
|
data: {
|
|
319
319
|
messageMissingList: 'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。',
|
|
320
|
-
messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md
|
|
320
|
+
messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
|
|
321
|
+
messageMalformedHeader: 'design.md「文件变更清单」内疑似仓变更段头但格式无法解析:「${line}」\n 该段下文件会被记到上一段仓(或 main)→ 文件覆盖对账整段错位(误报未覆盖或漏检)。\n 修复:段头写成「## <repo-key> 仓变更」(repo-key 限字母数字._-,可加编号前缀与(…)备注后缀);若这是下一章节标题而非段头,请改名避免形如「<词> 仓变更…」。',
|
|
321
322
|
},
|
|
322
|
-
spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)
|
|
323
|
-
failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md
|
|
323
|
+
spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。疑似仓变更段头但格式解析不了(repo-key 非法/仓变更后接自由文本)同样阻断并点名该行——段头错记仓会让对账整段错位。',
|
|
324
|
+
failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
|
|
324
325
|
},
|
|
325
326
|
// task-plan-reconciliation(custom):plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b)。
|
|
326
327
|
// 修复场景:plan 列 17 个任务只生成 12 张卡(尾部文档同步类任务漏卡),execute 审计只看
|
package/src/stage-review.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* (平台模式落 <runtimeRoot>/stage-reviews/...,与 execute-runs 同构)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readFileSync, mkdirSync, readdirSync
|
|
16
|
+
import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
|
|
17
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
17
18
|
import { join } from 'path'
|
|
18
19
|
import { createHash } from 'crypto'
|
|
19
20
|
import jsYaml from 'js-yaml'
|
|
@@ -561,7 +562,7 @@ export function validateStageReviewWithAutoRefresh(opts) {
|
|
|
561
562
|
reviewerNotes: `${result.review.reviewerNotes || ''}\ndocHash auto-refreshed at ${refreshedAt}(gate 检测主文档改版,机械重算;verdict 保留,结论是否仍适用需人工确认)`.trim(),
|
|
562
563
|
}
|
|
563
564
|
try {
|
|
564
|
-
|
|
565
|
+
writeAtomicSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
|
|
565
566
|
} catch {
|
|
566
567
|
return { result, autoRefreshed: false } // 写失败回落原失败结果,不静默放行
|
|
567
568
|
}
|
|
@@ -698,7 +699,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
|
|
|
698
699
|
reviewedFiles,
|
|
699
700
|
reviewerNotes: `${existing.reviewerNotes || ''}\ndocHash refreshed at ${refreshedAt}(主文档改版后机械重算;verdict 结论是否仍适用需人工确认)`.trim(),
|
|
700
701
|
}
|
|
701
|
-
|
|
702
|
+
writeAtomicSync(existingPath, JSON.stringify(review, null, 2) + '\n')
|
|
702
703
|
const schemaRecheck = validateStageReviewSchema(review)
|
|
703
704
|
if (!schemaRecheck.ok) {
|
|
704
705
|
throw new Error(`register-stage-review: refresh 后 schema 自检失败 — ${schemaRecheck.errors.join('; ')}(原 review 字段异常,先修原文件)`)
|
|
@@ -750,7 +751,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
|
|
|
750
751
|
const reviewDir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
|
|
751
752
|
const reviewPath = join(reviewDir, 'review.json')
|
|
752
753
|
mkdirSync(reviewDir, { recursive: true })
|
|
753
|
-
|
|
754
|
+
writeAtomicSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
|
|
754
755
|
// stage-reviews 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
|
|
755
756
|
// 回收(pruneArchivedChangeRuntime 按 reviewedFiles 归属);写入侧滚动是启发式,
|
|
756
757
|
// 活跃变更的 review 可能因他端高频写而跌出 keep 窗,不进证据类目录。
|
|
@@ -760,7 +761,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
|
|
|
760
761
|
console.warn(`⚠️ register-stage-review: marker 已存在 ${markerPath},将被覆盖为 ${reviewRunId}`)
|
|
761
762
|
}
|
|
762
763
|
mkdirSync(runtimeRoot, { recursive: true })
|
|
763
|
-
|
|
764
|
+
writeAtomicSync(markerPath, reviewRunId + '\n')
|
|
764
765
|
|
|
765
766
|
// self-check(fail-closed):刚写的 review 必过 schema + docHash 真实性。只验 mechanics,
|
|
766
767
|
// 不判 verdict(verdict 是 agent/子代理的审查结论,even fail 也如实落盘,由 Stage Review Gate 裁决)。
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -293,7 +293,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
|
|
|
293
293
|
- 仅改内部实现、无对外字段变动时,说明列照常写「新增 xx 方法」即可,无需数据流。
|
|
294
294
|
|
|
295
295
|
**跨仓变更写法**(本次变更涉及 local.yaml repos: 注册的其他仓时必读;纯主仓变更跳过):
|
|
296
|
-
- 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security
|
|
296
|
+
- 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security 仓变更\`;可加编号前缀或(跨仓,X1-X4)这类括号备注后缀,其余后缀/写法解析不了)。plan-postcheck 文件覆盖对账按此段头解析仓归属,写成「前端仓 / backend」等其他标题 → 对账不上且会点名段头格式错误
|
|
297
297
|
- 段内路径相对**该仓根**写(\`src/routes/x.js\`);❌ 禁止带仓库名前缀(\`sub-grid-security/src/routes/x.js\`)、❌ 禁止绝对路径/盘符路径(\`C:/repo/src/...\`)——后续 TaskCard 的 allowed_paths 与 review 对账(\`git -C <仓根> diff\` 产仓根相对路径)都用同一口径,前缀/绝对路径永不命中
|
|
298
298
|
- 主仓文件放在第一个段头之前(无段头区域 = main),或单独写 \`## main 仓变更\` 段
|
|
299
299
|
- repo-key 必须是 local.yaml repos: 段已注册的键(未注册先跑 \`sillyspec local register-repo <key> <仓根路径>\`,main 隐式不用注册)
|
package/src/stages/execute.js
CHANGED
|
@@ -1074,6 +1074,10 @@ ${crossLines}${crossWorktreeSection}${crossLegacySection}
|
|
|
1074
1074
|
` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
|
|
1075
1075
|
).join('\n')
|
|
1076
1076
|
worktreeSection = `
|
|
1077
|
+
### task 进行中标记(中断续跑的半成品归属,2026-09-12 起)
|
|
1078
|
+
|
|
1079
|
+
每个 task **开工/完工各跑一条命令**(低摩擦,主代理或子代理均可):开工「sillyspec task start --change {CHANGE_NAME} --task task-NN --note 一句话在做什么」;完工「sillyspec task finish --change {CHANGE_NAME} --task task-NN」。中断续跑时主代理用「sillyspec task list --change {CHANGE_NAME}」点名接管(>2h 标红为中断遗留),不再靠翻 git 工作区猜哪个半成品是谁的。
|
|
1080
|
+
|
|
1077
1081
|
### 工作目录(per-task)
|
|
1078
1082
|
|
|
1079
1083
|
调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
|
|
@@ -1213,7 +1217,8 @@ ${taskList}
|
|
|
1213
1217
|
- 最后一个 Wave 完成后做一次全量编译验证
|
|
1214
1218
|
- 用户明确要求编译时
|
|
1215
1219
|
4. 每个任务完成后:
|
|
1216
|
-
-
|
|
1220
|
+
- **使用 \`sillyspec wt-commit --change <change-name> -- <task-files>\` 串行提交**(坑 wt-parallel-commit-race:同一 Wave 多子代理共享 worktree,裸 \`git add -A\` 互卷 WIP / 撞 index.lock;\`git add -A\` 还会把并行子代理未提交的半成品一并暂存——**禁 \`git add -A\`**;sillyspec wt-commit 自动处理分支切换与序列化,无需手动 git 操作;未 commit 的新文件不在 base commit 也不在 index,apply --3way 报 "does not exist in index")
|
|
1221
|
+
- commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点
|
|
1217
1222
|
- **写 review.json 即可**(checkbox 由 CLI 自动勾选,见下方 Task Review Gate)
|
|
1218
1223
|
- **任务边界上报(每任务一次,主仓根目录)**:review write 落盘(CLI 已自动勾选)后跑一次 \`sillyspec platform sync --change <change-name>\`——以任务粒度把「最后信号」(last_pushed_at)与 tasks.md 勾选状态推上平台(变更中心「进行中」可见性);未连接平台时该命令静默跳过,无需先检查连接状态
|
|
1219
1224
|
- **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
|
|
@@ -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
|
}
|