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
package/src/scope-audit.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
|
|
20
20
|
*/
|
|
21
21
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
22
23
|
import { join, dirname } from 'node:path'
|
|
23
24
|
import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
|
|
24
25
|
import { safeGit } from './git-helper.js'
|
|
@@ -41,7 +42,9 @@ function toPosix(p) {
|
|
|
41
42
|
function extractRenameTarget(raw) {
|
|
42
43
|
const brace = raw.match(/^(.*)\{([^{}]*)=>([^{}]*)\}(.*)$/)
|
|
43
44
|
if (brace) {
|
|
44
|
-
|
|
45
|
+
// git 花括号形态 `src/{old => new}.js`:组3 带前导对齐空格须剥(审查 C-F03——
|
|
46
|
+
// 不剥则拼出 `src/ new.js` 键错,改名文件落 untracked wc-l 档记成整文件新增)
|
|
47
|
+
const merged = brace[1] + brace[3].replace(/^ /, '') + brace[4]
|
|
45
48
|
return merged.replace(/\/{2,}/g, '/')
|
|
46
49
|
}
|
|
47
50
|
const arrow = raw.indexOf(' => ')
|
|
@@ -262,6 +265,21 @@ function filterPatchForFiles(patchText, filesSet) {
|
|
|
262
265
|
return out.join('\n')
|
|
263
266
|
}
|
|
264
267
|
|
|
268
|
+
/**
|
|
269
|
+
/** patch 内容 sha256(A-F01 防篡改锚——写入方存 json.patchSha256,读取方校验) */
|
|
270
|
+
function sha256Text(text) {
|
|
271
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 冻结 patch 完整性校验(A-F01):json 记录的 patchSha256 与 patch 实文比对。
|
|
276
|
+
* @returns {boolean|null} true=一致;false=不匹配(可能被篡改);null=未记录 hash(旧记录)
|
|
277
|
+
*/
|
|
278
|
+
function verifyPatchIntegrity(recordedSha, patchText) {
|
|
279
|
+
if (!recordedSha || typeof recordedSha !== 'string') return null
|
|
280
|
+
return sha256Text(patchText) === recordedSha
|
|
281
|
+
}
|
|
282
|
+
|
|
265
283
|
/**
|
|
266
284
|
* 冻结 patch 生成(quick-359a48f1 审计级 / quick-90015473 归属口径修正):tracked 改动 =
|
|
267
285
|
* `git diff --no-color <baseRef>` 后**按 files 过滤段**(与 rows 同归属口径,不含并行会话
|
|
@@ -438,8 +456,12 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
|
|
|
438
456
|
}
|
|
439
457
|
}
|
|
440
458
|
|
|
459
|
+
// 审查 C-F05:patch 文件集对齐落盘 rows(declared+soft)——undeclared 可能是并行会话的
|
|
460
|
+
// 改动,冻结进本会话审计 patch 违背 filterPatchForFiles 的归属契约(json rows 落盘时
|
|
461
|
+
// complete-handlers 滤 undeclared,patch 必须同口径)
|
|
462
|
+
const patchFiles = [...new Set([...attributed, ...softSet])]
|
|
441
463
|
const frozenPatch = collectPatch
|
|
442
|
-
? buildFrozenPatch(sessionRoot,
|
|
464
|
+
? buildFrozenPatch(sessionRoot, patchFiles, { baseRef: 'HEAD' })
|
|
443
465
|
: undefined
|
|
444
466
|
|
|
445
467
|
return {
|
|
@@ -462,7 +484,7 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
|
|
|
462
484
|
* only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
|
|
463
485
|
* 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
|
|
464
486
|
*/
|
|
465
|
-
async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch }) {
|
|
487
|
+
async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual }) {
|
|
466
488
|
const sb = specBase || join(cwd, '.sillyspec')
|
|
467
489
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
|
|
468
490
|
const changeDirInfo = resolveChangeDir(sb, changeName)
|
|
@@ -474,7 +496,30 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
474
496
|
const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
|
|
475
497
|
const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
|
|
476
498
|
const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
|
|
477
|
-
|
|
499
|
+
// 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
|
|
500
|
+
// 「未执行」实证——tag 只在分支被 review 引用时才打)+ 补四类证据:快照文件本身 /
|
|
501
|
+
// execute-runs change 戳(task-review readExecuteRunChangeStamp 同口径)/ apply-pathspec /
|
|
502
|
+
// execute-cleanup 回执。任一命中 = 进过 execute。
|
|
503
|
+
const hasSnapshotFile = changeDirInfo
|
|
504
|
+
? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
|
|
505
|
+
: existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
|
|
506
|
+
const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
|
|
507
|
+
const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
|
|
508
|
+
let hasExecuteRunStamp = false
|
|
509
|
+
if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
|
|
510
|
+
try {
|
|
511
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
512
|
+
if (existsSync(runsDir)) {
|
|
513
|
+
for (const e of readdirSync(runsDir)) {
|
|
514
|
+
try {
|
|
515
|
+
if (readFileSync(join(runsDir, e, 'change'), 'utf8').trim() === changeName) { hasExecuteRunStamp = true; break }
|
|
516
|
+
} catch { /* 无戳 run 跳过 */ }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch { /* 扫描失败视为无证据(fail-open 到预执行视图,note 有 hedge) */ }
|
|
520
|
+
}
|
|
521
|
+
const isArchived = !!(changeDirInfo && changeDirInfo.archived)
|
|
522
|
+
if (!isArchived && !hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt && !hasExecuteRunStamp) {
|
|
478
523
|
const emptyBase = {
|
|
479
524
|
mode: 'full-flow', ok: true, degradedReason: null, baseAnchor: null,
|
|
480
525
|
totals: { files: 0, additions: 0, deletions: 0 }, rows: [],
|
|
@@ -495,8 +540,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
495
540
|
totals: { files: preRows.length, additions: 0, deletions: 0 },
|
|
496
541
|
rows: preRows,
|
|
497
542
|
note: preRows.length > 0
|
|
498
|
-
? '变更尚未进入 execute(无 worktree/分支/审计 tag
|
|
499
|
-
: '变更尚未进入 execute(无 worktree/分支/审计 tag
|
|
543
|
+
? '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但全部执行证据被清理,此处会误判——git log 核实)'
|
|
544
|
+
: '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)且 design 无可解析清单——暂无可对账内容',
|
|
500
545
|
}
|
|
501
546
|
}
|
|
502
547
|
|
|
@@ -558,7 +603,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
558
603
|
// 快照即冻结记录(含行数)——已收尾一律先出快照;实时窗口(开放区间)只作快照缺失的兜底,
|
|
559
604
|
// note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint(并行 WIP 快照),本变更
|
|
560
605
|
// 改动经工作树 apply 从未 commit 到分支。 ——
|
|
561
|
-
|
|
606
|
+
// freshActual(审查 C-F01):verify 漂移 current 侧走快照捷径会变成快照比快照恒「一致」
|
|
607
|
+
// (假阴性);execute --done 采集重跑也会陈旧回写——两处传 true 绕过,快照捷径只留给查询面
|
|
608
|
+
const settled = !freshActual && changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
|
|
562
609
|
if (settled) {
|
|
563
610
|
const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
|
|
564
611
|
if (snap && snap.rows.length > 0) {
|
|
@@ -571,7 +618,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
571
618
|
const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
|
|
572
619
|
if (needsStats && actual && actual.ok && actual.baseAnchor) {
|
|
573
620
|
const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
|
|
574
|
-
|
|
621
|
+
// 审查 C-F04:补采执行根走共享 resolveDiffRoot(form=worktree 时必须对 worktree 跑,
|
|
622
|
+
// 与主链路 :629/:636 同源配对——直用 cwd 会基点×根错位)
|
|
623
|
+
const stats = collectNumstatByPath(resolveDiffRoot(sb, changeName, actual.form, cwd), paths, { baseRef: actual.baseAnchor })
|
|
575
624
|
let recovered = 0
|
|
576
625
|
rows = rows.map(r => {
|
|
577
626
|
if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
|
|
@@ -591,6 +640,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
591
640
|
excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
|
|
592
641
|
? snap.excluded
|
|
593
642
|
: { foreignDeclared: [] },
|
|
643
|
+
patchSha256: snap.patchSha256 || null,
|
|
644
|
+
patchStatus: snap.patchStatus || null,
|
|
594
645
|
note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
|
|
595
646
|
}
|
|
596
647
|
}
|
|
@@ -606,6 +657,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
606
657
|
excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
|
|
607
658
|
? snap.excluded
|
|
608
659
|
: { foreignDeclared: [] },
|
|
660
|
+
patchSha256: snap.patchSha256 || null,
|
|
661
|
+
patchStatus: snap.patchStatus || null,
|
|
609
662
|
note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
|
|
610
663
|
}
|
|
611
664
|
}
|
|
@@ -715,7 +768,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
|
|
|
715
768
|
* excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
|
|
716
769
|
* note?: string|null }>}
|
|
717
770
|
*/
|
|
718
|
-
export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch } = {}) {
|
|
771
|
+
export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual } = {}) {
|
|
719
772
|
const empty = {
|
|
720
773
|
mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
|
|
721
774
|
totals: { files: 0, additions: 0, deletions: 0 },
|
|
@@ -746,6 +799,8 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
|
|
|
746
799
|
? rec.record.excluded
|
|
747
800
|
: { foreignDeclared: [] },
|
|
748
801
|
frozenPatchPath: rec.patchPath,
|
|
802
|
+
patchSha256: rec.record.patchSha256 || null,
|
|
803
|
+
patchStatus: rec.record.patchStatus || null,
|
|
749
804
|
note: `quick 会话已收尾——记录态(quicklog/patches/${rec.qlId},${rec.record.savedAt ? String(rec.record.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘' : '--done 时点冻结'})`,
|
|
750
805
|
}
|
|
751
806
|
}
|
|
@@ -755,7 +810,7 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
|
|
|
755
810
|
degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
|
|
756
811
|
}
|
|
757
812
|
}
|
|
758
|
-
return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch })
|
|
813
|
+
return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual })
|
|
759
814
|
} catch (e) {
|
|
760
815
|
// fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
|
|
761
816
|
return {
|
|
@@ -789,16 +844,36 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
|
|
|
789
844
|
// —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
|
|
790
845
|
// ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
|
|
791
846
|
// patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
847
|
+
// A-F01:读取时按 json 记录的 patchSha256 校验,不匹配显式告警(防低级篡改)。
|
|
848
|
+
const patchRecord = result.frozenPatchPath
|
|
849
|
+
? { path: result.frozenPatchPath, sha: result.patchSha256 || null }
|
|
850
|
+
: (() => { const d = resolveChangeDir(sb, changeName); return d ? { path: join(d.dir, 'scope-audit.patch'), sha: null } : null })()
|
|
851
|
+
let recordedSha = patchRecord ? patchRecord.sha : null
|
|
852
|
+
if (patchRecord && !recordedSha && result.rows) {
|
|
853
|
+
// json 里未直接带回 hash(记录态捷径不透传)→ 读 json 原文取(快照/quick 记录都存该字段)
|
|
854
|
+
try {
|
|
855
|
+
const snapRaw = JSON.parse(readFileSync(result.frozenPatchPath
|
|
856
|
+
? result.frozenPatchPath.replace(/\.patch$/, '.json')
|
|
857
|
+
: join(resolveChangeDir(sb, changeName).dir, 'scope-audit.json'), 'utf8'))
|
|
858
|
+
recordedSha = snapRaw.patchSha256 || null
|
|
859
|
+
} catch { /* json 不可读 → 无 hash 可校验(null=不校验) */ }
|
|
860
|
+
}
|
|
861
|
+
const frozenPatch = (patchRecord
|
|
862
|
+
&& (() => { try { return readFileSync(patchRecord.path, 'utf8') } catch { return null } })())
|
|
863
|
+
if (frozenPatch !== null && frozenPatch !== undefined && patchRecord) {
|
|
864
|
+
const integrity = verifyPatchIntegrity(recordedSha, frozenPatch)
|
|
865
|
+
if (integrity === false) {
|
|
866
|
+
return { ok: false, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '⚠️ 冻结 patch 与快照记录的 patchSha256 不匹配——记录可能被篡改,审计前人工核验(git 历史比对)' }
|
|
867
|
+
}
|
|
796
868
|
const sliced = slicePatchForFile(frozenPatch, norm)
|
|
797
869
|
if (sliced) {
|
|
798
870
|
return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch(收尾时点内容,无后续演进混入)', diff: sliced, note: null }
|
|
799
871
|
}
|
|
800
872
|
return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '该文件不在冻结 patch 内(窗口外文件)' }
|
|
801
873
|
}
|
|
874
|
+
if (result.patchStatus === 'failed') {
|
|
875
|
+
return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结记录', diff: null, note: '冻结 patch 当时采集失败(非无改动)——退实时锚比对' }
|
|
876
|
+
}
|
|
802
877
|
|
|
803
878
|
// 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
|
|
804
879
|
let baseRef = 'HEAD'
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semantic-guard.js — quick 语义护栏聚合模块(change: 2026-09-11-cross-change-decision-guard,
|
|
3
|
+
* task-03,FR-03/FR-04,D-001@v1):近因他者交付归因 + 断言重写检测 + advisory 渲染 + 总开关。
|
|
4
|
+
*
|
|
5
|
+
* 被 run/prompt.js(task-05,进场注入)与 run/quick-audit.js(task-06,--done 断言 WARNING)
|
|
6
|
+
* 两端消费。纯模块纪律:不 console(渲染返回字符串,输出留给消费端)、不写盘、
|
|
7
|
+
* 不 import run/ 下模块(避免与 task-05/06 循环依赖)。
|
|
8
|
+
*
|
|
9
|
+
* git 交互一律走 git-helper 的 safeGit(数组参数不经 shell,Windows 路径安全);失败静默
|
|
10
|
+
* 降级空结果不抛(acceptance:git 不可用 → 全部降级空结果)。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { safeGit } from './git-helper.js'
|
|
15
|
+
import { matchDecisionsByFiles } from './knowledge-match.js'
|
|
16
|
+
|
|
17
|
+
/** 路径反斜杠归一 POSIX(Windows 调用方传 \ 分隔时不丢匹配;与 matchDecisionsByFiles 双侧归一口径一致) */
|
|
18
|
+
function toPosix(p) {
|
|
19
|
+
return String(p || '').trim().replace(/\\/g, '/')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ───────────────────────── 交付归因(FR-03) ─────────────────────────
|
|
23
|
+
|
|
24
|
+
/** 提交信息里的完整变更名标记:init 生成的 change 目录名形态(2026-09-11-cross-change-decision-guard) */
|
|
25
|
+
const CHANGE_NAME_RE = /\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*/g
|
|
26
|
+
/** quicklog 条目 ID 标记:ql-YYYYMMDD-NNN-xxxx(quick 会话提交信息的归因键) */
|
|
27
|
+
const QL_ID_RE = /ql-\d{8}-\d{3}-[a-z0-9]{4}/g
|
|
28
|
+
|
|
29
|
+
/** 单条提交信息 → 两类标记列表(按出现位置排序;两类正则无交集,ql-ID 的日期段无连字符) */
|
|
30
|
+
function extractChangeMarkers(subject) {
|
|
31
|
+
const found = []
|
|
32
|
+
for (const re of [CHANGE_NAME_RE, QL_ID_RE]) {
|
|
33
|
+
for (const m of subject.matchAll(re)) found.push({ token: m[0], at: m.index })
|
|
34
|
+
}
|
|
35
|
+
return found.sort((a, b) => a.at - b.at).map(f => f.token)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 近因他者交付归因:每文件查最近 days 天提交信息(--format=%s,仅 subject),解析变更名 /
|
|
40
|
+
* ql-ID 两类标记,取最新一条非 currentChange 标记 → { [file]: changeName }。
|
|
41
|
+
* git log 输出行为提交 subject,无 porcelain 首行前导空格语义(git-helper.js:88 的
|
|
42
|
+
* trim 坑不影响此处),safeGit 默认 trim 安全。
|
|
43
|
+
* @param {{ cwd?: string, files?: string[], currentChange?: string, days?: number }} opts
|
|
44
|
+
* @returns {{ [file]: string }} 键 = POSIX 归一路径;无标记 / 仅本变更标记 / git 失败 /
|
|
45
|
+
* 第 20 个之后的文件 → 不记
|
|
46
|
+
*/
|
|
47
|
+
export function collectRecentForeignDelivery({ cwd, files, currentChange, days = 7 } = {}) {
|
|
48
|
+
const result = {}
|
|
49
|
+
if (!cwd || !Array.isArray(files)) return result
|
|
50
|
+
const list = files.map(toPosix).filter(Boolean).slice(0, 20)
|
|
51
|
+
for (const file of list) {
|
|
52
|
+
const r = safeGit(cwd, ['log', `--since=${days}.days`, '--format=%s', '--', file])
|
|
53
|
+
if (r.error || !r.value) continue
|
|
54
|
+
// git log 新→旧:逐提交找第一条非 currentChange 标记,命中即止(取最新他者标记)
|
|
55
|
+
for (const subject of r.value.split('\n')) {
|
|
56
|
+
const foreign = extractChangeMarkers(subject).find(m => m !== currentChange)
|
|
57
|
+
if (foreign) {
|
|
58
|
+
result[file] = foreign
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ───────────────────────── 断言重写检测(FR-04) ─────────────────────────
|
|
67
|
+
|
|
68
|
+
/** 删除侧断言 token:expect( / assert / t.equal / toBe / toEqual / strictEqual / should. */
|
|
69
|
+
const ASSERTION_TOKEN_RE = /expect\(|assert|t\.equal|toBe|toEqual|strictEqual|should\./
|
|
70
|
+
|
|
71
|
+
/** 测试文件判定:test/ 前缀 或 *.test.* / *_test.* 命名(路径先 POSIX 归一) */
|
|
72
|
+
function isTestFilePath(p) {
|
|
73
|
+
return p.startsWith('test/') || /\.test\./.test(p) || /_test\./.test(p)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 断言重写检测:测试文件跑 git diff HEAD -U0(HEAD 口径非 index——quick step3 先 git add
|
|
78
|
+
* 暂存的时序下裸 diff 恒空转,坑 Grill X-001),hunk 删除侧(- 行)含断言 token → 命中;
|
|
79
|
+
* 纯新增 hunk(无 - 行)不算;样例行封顶 5/文件。
|
|
80
|
+
* @param {{ cwd?: string, files?: string[] }} opts
|
|
81
|
+
* @returns {{ [file]: string[] }} 键 = POSIX 归一测试文件路径,值 = 删除侧含 token 的原行
|
|
82
|
+
* (去 - 前缀);非测试文件不查;无命中 / git 失败 → 不记
|
|
83
|
+
*/
|
|
84
|
+
export function detectAssertionRewrites({ cwd, files } = {}) {
|
|
85
|
+
const result = {}
|
|
86
|
+
if (!cwd || !Array.isArray(files)) return result
|
|
87
|
+
for (const raw of files) {
|
|
88
|
+
const file = toPosix(raw)
|
|
89
|
+
if (!file || !isTestFilePath(file)) continue
|
|
90
|
+
const r = safeGit(cwd, ['diff', 'HEAD', '-U0', '--', file])
|
|
91
|
+
if (r.error || !r.value) continue
|
|
92
|
+
const samples = []
|
|
93
|
+
let inHunk = false
|
|
94
|
+
let deletedLines = []
|
|
95
|
+
const flushHunk = () => {
|
|
96
|
+
if (!inHunk || deletedLines.length === 0) return // 纯新增 hunk(无 - 行)不算
|
|
97
|
+
for (const line of deletedLines) {
|
|
98
|
+
if (samples.length >= 5) break
|
|
99
|
+
if (ASSERTION_TOKEN_RE.test(line)) samples.push(line)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const line of r.value.split('\n')) {
|
|
103
|
+
if (line.startsWith('@@')) {
|
|
104
|
+
flushHunk()
|
|
105
|
+
inHunk = true
|
|
106
|
+
deletedLines = []
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (!inHunk) continue // 文件头(diff --git / index / --- / +++)不进 hunk 统计
|
|
110
|
+
if (line.startsWith('-')) deletedLines.push(line.slice(1)) // + 行与 "\ No newline" 行跳过
|
|
111
|
+
}
|
|
112
|
+
flushHunk()
|
|
113
|
+
if (samples.length > 0) result[file] = samples
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ───────────────────────── advisory 渲染(FR-03/FR-04 组合) ─────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* advisory 文本段渲染:决策命中(matchDecisionsByFiles,knowledge 目录 = specBase/knowledge,
|
|
122
|
+
* task-02 契约)+ 交付归因(collectRecentForeignDelivery 默认 7 天窗)组合成注入文本,
|
|
123
|
+
* 供 task-05 进场注入 / task-06 --done WARNING 拼接;两类均零命中 → ''(不留空段)。
|
|
124
|
+
* @param {{ specBase?: string, cwd?: string, candidateFiles?: string[], currentChange?: string }} opts
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function renderSemanticGuardBlock({ specBase, cwd, candidateFiles, currentChange } = {}) {
|
|
128
|
+
if (!Array.isArray(candidateFiles) || candidateFiles.length === 0) return ''
|
|
129
|
+
const files = [...new Set(candidateFiles.map(toPosix).filter(Boolean))]
|
|
130
|
+
if (files.length === 0) return ''
|
|
131
|
+
const decisions = specBase
|
|
132
|
+
? matchDecisionsByFiles(join(specBase, 'knowledge'), files)
|
|
133
|
+
: {}
|
|
134
|
+
const delivery = cwd
|
|
135
|
+
? collectRecentForeignDelivery({ cwd, files, currentChange })
|
|
136
|
+
: {}
|
|
137
|
+
const decisionFiles = files.filter(f => Array.isArray(decisions[f]) && decisions[f].length > 0)
|
|
138
|
+
const deliveryFiles = files.filter(f => delivery[f])
|
|
139
|
+
if (decisionFiles.length === 0 && deliveryFiles.length === 0) return ''
|
|
140
|
+
const lines = ['【语义护栏】改动文件触及既有决策 / 近期他者交付,动手前请对照:']
|
|
141
|
+
if (decisionFiles.length > 0) {
|
|
142
|
+
lines.push(`■ 决策库命中(${decisionFiles.length} 文件)`)
|
|
143
|
+
for (const f of decisionFiles) {
|
|
144
|
+
for (const hit of decisions[f]) {
|
|
145
|
+
const rejected = hit.status === 'rejected'
|
|
146
|
+
lines.push(` - ${f} → ${hit.id} ${hit.title}[${rejected ? 'rejected,已否决,勿复潮' : hit.status || 'unknown'}]`)
|
|
147
|
+
lines.push(` 理由:${hit.reason || '(未记录)'}`)
|
|
148
|
+
if (hit.file) lines.push(` 详见:knowledge/${hit.file}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (deliveryFiles.length > 0) {
|
|
153
|
+
lines.push('■ 近 7 天他者交付归因(最后交付的非本变更标记)')
|
|
154
|
+
for (const f of deliveryFiles) lines.push(` - ${f} ← ${delivery[f]}`)
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ───────────────────────── 总开关(D-001@v1) ─────────────────────────
|
|
160
|
+
|
|
161
|
+
/** YAML 布尔宽松解析:仅显式 false(含引号形态)为关,其余值一律 true(fail-open,先例 friction-tally R-05) */
|
|
162
|
+
function parseEnabledValue(raw) {
|
|
163
|
+
const v = String(raw || '').trim().replace(/^['"]|['"]$/g, '')
|
|
164
|
+
return v.toLowerCase() !== 'false'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 读 specBase/local.yaml 的 semantic_guard.enabled 总开关(D-001@v1):默认 true;
|
|
169
|
+
* 缺键 / 文件缺失 / 解析异常 → true(fail-open——配置读取异常不该把护栏误判为关)。
|
|
170
|
+
* 轻量行式解析不引 yaml 依赖(先例 friction-tally readFrictionHintEnabled 同款形态),
|
|
171
|
+
* 兼容嵌套(semantic_guard:\n enabled: false)与 flat(semantic_guard.enabled: false);
|
|
172
|
+
* 行尾先归一(Windows CRLF 残留致行式正则整体失配)。
|
|
173
|
+
* @param {string} specBase - spec 根(local.yaml 所在目录)
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
export function readSemanticGuardEnabled(specBase) {
|
|
177
|
+
try {
|
|
178
|
+
const yamlPath = join(specBase, 'local.yaml')
|
|
179
|
+
if (!existsSync(yamlPath)) return true
|
|
180
|
+
const text = String(readFileSync(yamlPath, 'utf8')).replace(/\r\n?/g, '\n')
|
|
181
|
+
let inSection = false
|
|
182
|
+
let sectionIndent = -1
|
|
183
|
+
for (const line of text.split('\n')) {
|
|
184
|
+
// flat 形态优先:键名带点,不与嵌套段头冲突
|
|
185
|
+
const flat = line.match(/^\s*semantic_guard\.enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
|
|
186
|
+
if (flat) return parseEnabledValue(flat[1])
|
|
187
|
+
const section = line.match(/^(\s*)semantic_guard\s*:\s*(?:#.*)?$/)
|
|
188
|
+
if (section) {
|
|
189
|
+
inSection = true
|
|
190
|
+
sectionIndent = section[1].length
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (!inSection) continue
|
|
194
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
195
|
+
// 缩进回落到段头层级(或更浅)= semantic_guard 段结束,之后的 enabled 不属于它
|
|
196
|
+
if (/^\s*/.exec(line)[0].length <= sectionIndent) {
|
|
197
|
+
inSection = false
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
const kv = line.match(/^\s*enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
|
|
201
|
+
if (kv) return parseEnabledValue(kv[1])
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
} catch {
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -282,9 +282,14 @@ export class SillyHubMcpClient {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
if (!res.ok) {
|
|
285
|
+
// 401 状态留痕(2026-09-11 用户实测反馈:token 失效被 probe 报成 daemon-unreachable,
|
|
286
|
+
// 误导排障方向——吊销/过期的 shmcp_ 是「重连再配对」能解的,不是 daemon 的问题)。
|
|
287
|
+
// getLastInitStatus() 供 probeSillyHub 类型化 reason;成功/其他失败置 null(不误判旧状态)。
|
|
288
|
+
this._lastInitHttpStatus = res.status;
|
|
285
289
|
if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
|
|
286
290
|
return false;
|
|
287
291
|
}
|
|
292
|
+
this._lastInitHttpStatus = null;
|
|
288
293
|
|
|
289
294
|
// 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
|
|
290
295
|
// ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
|
|
@@ -391,6 +396,15 @@ export class SillyHubMcpClient {
|
|
|
391
396
|
return result !== null;
|
|
392
397
|
}
|
|
393
398
|
|
|
399
|
+
/**
|
|
400
|
+
* 最近一次 initialize 握手的 HTTP 状态码(null = 无记录/成功/非 HTTP 失败)。
|
|
401
|
+
* 消费方:probeSillyHub 的 token 失效类型化(401 → reason='mcp-token-invalid',
|
|
402
|
+
* 区别于 daemon-unreachable——2026-09-11 用户实测:吊销 token 被误报 daemon 不可达)。
|
|
403
|
+
*/
|
|
404
|
+
getLastInitStatus() {
|
|
405
|
+
return this._lastInitHttpStatus ?? null;
|
|
406
|
+
}
|
|
407
|
+
|
|
394
408
|
/**
|
|
395
409
|
* 列出 daemon 暴露的 MCP tools(JSON-RPC `tools/list`,task-11 路径A 探测用)。
|
|
396
410
|
*
|
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\`)
|