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/decision-distill.js
CHANGED
|
@@ -78,6 +78,8 @@ function applyField(entry, label, value) {
|
|
|
78
78
|
case 'answer': case '答案': entry.answer = value; break
|
|
79
79
|
case '锚点': case 'anchor': entry.anchor = value; break
|
|
80
80
|
case '模块域': case '模块': case 'domains': case 'domain': entry.domains = parseListValue(value); break
|
|
81
|
+
// 文件字段(FR-01/D-001@v1,2026-09-11-cross-change-decision-guard):列表语义同模块域 + 逐项反斜杠归一 POSIX
|
|
82
|
+
case '文件': case 'files': entry.files = parseListValue(value).map(s => s.replace(/\\/g, '/')); break
|
|
81
83
|
case '否决理由': case 'reject_reason': case 'rejectreason': entry.rejectReason = value; break
|
|
82
84
|
case '复潮条件': case 'revisit_when': case 'revisitwhen': entry.revisitWhen = value; break
|
|
83
85
|
case 'supersedes': entry.supersedes = value; break
|
|
@@ -93,7 +95,7 @@ function applyField(entry, label, value) {
|
|
|
93
95
|
* @returns {{ entries: Array<{
|
|
94
96
|
* id: string, number: string, version: number, title: string,
|
|
95
97
|
* type?: string, status?: string, question?: string, answer?: string,
|
|
96
|
-
* anchor?: string, domains?: string[], rejectReason?: string, revisitWhen?: string,
|
|
98
|
+
* anchor?: string, domains?: string[], files?: string[], rejectReason?: string, revisitWhen?: string,
|
|
97
99
|
* supersedes?: string, impacts?: string, normalizedRequirement?: string,
|
|
98
100
|
* selected: 'implemented'|'rejected'|null, raw: string }>, missing: boolean }}
|
|
99
101
|
* missing=true = decisions.md 不存在;selected = FR-02 入选裁决,null 表示解析保留但不入选。
|
|
@@ -165,7 +167,7 @@ export function parseDecisions(changeDir) {
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
/** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零) */
|
|
168
|
-
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts
|
|
170
|
+
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|文件|files|normalized_requirement|状态|类型)$/i
|
|
169
171
|
|
|
170
172
|
// ---------------------------------------------------------------------------
|
|
171
173
|
// 域三级兜底(FR-03)
|
|
@@ -293,6 +295,8 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
293
295
|
lines.push(`状态:${entry.selected}`)
|
|
294
296
|
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
295
297
|
lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
|
|
298
|
+
// 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
|
|
299
|
+
if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
|
|
296
300
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
297
301
|
lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
|
|
298
302
|
if (supersedesNote) lines.push(`supersedes:${oneLine(supersedesNote)}`)
|
package/src/dispatch/probe.js
CHANGED
|
@@ -174,7 +174,14 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, tot
|
|
|
174
174
|
console.warn(`[dispatch/probe] probeDaemon 抛异常(保守判 unavailable): ${msg}`);
|
|
175
175
|
reachable = false;
|
|
176
176
|
}
|
|
177
|
-
if (!reachable)
|
|
177
|
+
if (!reachable) {
|
|
178
|
+
// token 失效类型化(2026-09-11 用户实测反馈:吊销/过期 shmcp_ 被报成
|
|
179
|
+
// daemon-unreachable,误导排障——gateway 在、daemon 在,死的只是凭据)。
|
|
180
|
+
// 401 与 unreachable 分诊:进同款负面缓存(TTL 内不重试,token 不会自愈)。
|
|
181
|
+
const initStatus = typeof cli.getLastInitStatus === 'function' ? cli.getLastInitStatus() : null;
|
|
182
|
+
if (initStatus === 401) return cacheNegative('mcp-token-invalid');
|
|
183
|
+
return cacheNegative('daemon-unreachable');
|
|
184
|
+
}
|
|
178
185
|
|
|
179
186
|
// 3.5+4 合并(HUB-12a):一次 tools/list 同时拿 tools 数组(路径A schema 预热)与
|
|
180
187
|
// root_path(越界校验)。旧 client 无 listToolsWithMeta 时回退 listTools +
|
package/src/docs-check.js
CHANGED
|
@@ -14,10 +14,95 @@
|
|
|
14
14
|
* 仅「目录递归」「目录单层」「字面路径」三形态);Windows 路径归一化;兼容 CRLF/LF。
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
17
|
-
import { join } from 'node:path'
|
|
17
|
+
import { join, resolve as pathResolve, relative as pathRelative, sep as pathSep } from 'node:path'
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
18
19
|
import jsYaml from 'js-yaml'
|
|
19
20
|
import { parseModuleMapSimple } from './modules.js'
|
|
20
21
|
|
|
22
|
+
// ── HEAD 模式内容解析层(坑 docs-gate-shared-worktree-parallel-block,2026-09-08 实证)──
|
|
23
|
+
// pre-push gate 校验的语义对象是「被推送的提交树」,而磁盘工作区混有并行会话的未提交
|
|
24
|
+
// 改动——活文档锚点被在途编辑瞬时漂移,任何会话此时推送都被拦(移动靶,5→7 波动实证)。
|
|
25
|
+
// --against HEAD 模式:文档与源码内容一律取 HEAD 版本——干净文件磁盘直读(与 HEAD
|
|
26
|
+
// 逐字节一致,零额外开销)、脏文件(M/D/R)git show 取 HEAD 内容、未跟踪(??)视为
|
|
27
|
+
// 不存在(不随推送走,本就不该校验)。已知边界(接受并留档):工作区未提交删除的
|
|
28
|
+
// tracked 文档不出现在磁盘 doc 枚举里(walkGlob 扫盘)——其 HEAD 版本确属推送树,
|
|
29
|
+
// 此边角漏检远轻于移动靶误拦;裸名引用仅命中「工作区已删」源文件时层1 找不到候选。
|
|
30
|
+
// 模块级 active reader(set/try/finally restore,quicklog setQuickFileNotes 同款先例):
|
|
31
|
+
// readLines / resolveCandidates / runDocsCheck / collectInvalidDocRefs 的读统一经此层,
|
|
32
|
+
// reader 未激活时行为与磁盘直读逐字节等价(既有调用方/单测零感知)。
|
|
33
|
+
let _headReader = null
|
|
34
|
+
/** 测试与 runDocsCheck 内部使用:激活/恢复 HEAD reader(返回前值供 finally 还原)。 */
|
|
35
|
+
export function _setHeadReaderForCheck(reader) {
|
|
36
|
+
const prev = _headReader
|
|
37
|
+
_headReader = reader
|
|
38
|
+
return prev
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _rdText(absPath) {
|
|
42
|
+
if (!_headReader) {
|
|
43
|
+
try { return readFileSync(absPath, 'utf8') } catch { return null }
|
|
44
|
+
}
|
|
45
|
+
return _headReader.readText(absPath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _rdExists(absPath) {
|
|
49
|
+
if (!_headReader) return existsSync(absPath)
|
|
50
|
+
return _headReader.existsAt(absPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 构造 HEAD 模式 reader。非 git 仓 / projectRoot 非 git 根(porcelain 路径相对仓根,
|
|
55
|
+
* 归一失配)/ git 调用失败 → 返回 null(调用方回退磁盘模式并 warn)。
|
|
56
|
+
* git status 只列差异文件:不在 status 里的 tracked 文件 = 干净(磁盘读即 HEAD 读);
|
|
57
|
+
* ?? 未跟踪 → HEAD 无此文件(existsAt=false, readText=null);M/D/R → HEAD 仍在
|
|
58
|
+
* (existsAt=true),内容经 git show(带缓存,脏文件数量级 = 并行会话在途编辑数)。
|
|
59
|
+
*/
|
|
60
|
+
export function createHeadReader(projectRoot, ref = 'HEAD') {
|
|
61
|
+
const root = pathResolve(projectRoot)
|
|
62
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], {
|
|
63
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
})
|
|
65
|
+
let toplevel
|
|
66
|
+
try { toplevel = git(['rev-parse', '--show-toplevel']).trim() } catch { return null }
|
|
67
|
+
if (toplevel.split(/[\\/]/).join('/') !== root.split(/[\\/]/).join('/')) return null
|
|
68
|
+
const status = new Map() // rel(正斜杠) → 'M' | 'D' | 'U'
|
|
69
|
+
try {
|
|
70
|
+
for (const line of git(['status', '--porcelain', '--', '.']).split(/\r?\n/)) {
|
|
71
|
+
if (!line) continue
|
|
72
|
+
const code = line.slice(0, 2)
|
|
73
|
+
let p = line.slice(3)
|
|
74
|
+
if (code.includes('R')) p = p.split(' -> ').pop() // 重命名记新路径
|
|
75
|
+
p = p.replace(/^"|"$/g, '')
|
|
76
|
+
const st = code.includes('?') ? 'U' : code.includes('D') ? 'D' : 'M'
|
|
77
|
+
status.set(p, st)
|
|
78
|
+
}
|
|
79
|
+
} catch { return null }
|
|
80
|
+
const showCache = new Map()
|
|
81
|
+
const relOf = (abs) => pathRelative(root, pathResolve(abs)).split(pathSep).join('/')
|
|
82
|
+
return {
|
|
83
|
+
ref,
|
|
84
|
+
readText(absPath) {
|
|
85
|
+
const rel = relOf(absPath)
|
|
86
|
+
const st = status.get(rel)
|
|
87
|
+
if (st === undefined) {
|
|
88
|
+
try { return readFileSync(absPath, 'utf8') } catch { return null } // 干净:磁盘即 HEAD
|
|
89
|
+
}
|
|
90
|
+
if (st === 'U') return null // 未跟踪:HEAD 无此文件
|
|
91
|
+
if (showCache.has(rel)) return showCache.get(rel)
|
|
92
|
+
let text = null
|
|
93
|
+
try { text = git(['show', `${ref}:${rel}`]) } catch { text = null }
|
|
94
|
+
showCache.set(rel, text)
|
|
95
|
+
return text
|
|
96
|
+
},
|
|
97
|
+
existsAt(absPath) {
|
|
98
|
+
const st = status.get(relOf(absPath))
|
|
99
|
+
if (st === 'U') return false // 未跟踪 → HEAD 不存在
|
|
100
|
+
if (st === 'D' || st === 'M') return true // 工作区删/改 → HEAD 仍在
|
|
101
|
+
return existsSync(absPath)
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
21
106
|
/** 提取 file.js:line / file.js:start-end 引用(反引号包裹与裸文本均命中,全文扫描 D-006)。
|
|
22
107
|
* 扩展名覆盖(archify 借鉴 P1b,2026-09-04):js/mjs/cjs/ts/tsx/jsx/py/java/go——
|
|
23
108
|
* scan 产物面向任意技术栈项目(如 SillyHub = Python + TypeScript),只认 .js 会让
|
|
@@ -268,16 +353,19 @@ function findInTree(dir, baseName, rel = '', treeCache = null) {
|
|
|
268
353
|
export function resolveCandidates(projectRoot, refFile, treeCache = null) {
|
|
269
354
|
if (refFile.includes('/')) {
|
|
270
355
|
const direct = join(projectRoot, refFile)
|
|
271
|
-
if (
|
|
356
|
+
if (_rdExists(direct)) return [direct]
|
|
272
357
|
const inSrc = join(projectRoot, 'src', refFile)
|
|
273
|
-
if (
|
|
358
|
+
if (_rdExists(inSrc)) return [inSrc]
|
|
274
359
|
const slash = refFile.lastIndexOf('/')
|
|
275
360
|
const baseName = slash === -1 ? refFile : refFile.slice(slash + 1)
|
|
276
361
|
return findInTree(join(projectRoot, 'src'), baseName, '', treeCache)
|
|
277
362
|
.filter((rel) => ('src/' + rel).endsWith('/' + refFile) || 'src/' + rel === 'src/' + refFile)
|
|
278
363
|
.map((rel) => join(projectRoot, 'src', rel))
|
|
364
|
+
.filter((abs) => _rdExists(abs)) // HEAD 模式:未跟踪源文件不进推送树,不作候选
|
|
279
365
|
}
|
|
280
|
-
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache)
|
|
366
|
+
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache)
|
|
367
|
+
.map((rel) => join(projectRoot, 'src', rel))
|
|
368
|
+
.filter((abs) => _rdExists(abs))
|
|
281
369
|
}
|
|
282
370
|
|
|
283
371
|
/**
|
|
@@ -301,9 +389,8 @@ export function collectInvalidDocRefs(projectRoot, docEntries) {
|
|
|
301
389
|
const linesCache = new Map()
|
|
302
390
|
for (const { name, absPath } of docEntries) {
|
|
303
391
|
let content
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
} catch {
|
|
392
|
+
content = _rdText(absPath)
|
|
393
|
+
if (content === null) {
|
|
307
394
|
readFailures++
|
|
308
395
|
continue
|
|
309
396
|
}
|
|
@@ -355,7 +442,9 @@ function readLines(absPath, linesCache = null) {
|
|
|
355
442
|
return lines
|
|
356
443
|
}
|
|
357
444
|
try {
|
|
358
|
-
const
|
|
445
|
+
const text = _rdText(absPath)
|
|
446
|
+
if (text === null) return null
|
|
447
|
+
const lines = text.split(/\r?\n/)
|
|
359
448
|
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
360
449
|
return lines
|
|
361
450
|
} catch { return null }
|
|
@@ -479,6 +568,28 @@ function relDisplay(absPath, projectRoot) {
|
|
|
479
568
|
* @throws {DocsCheckConfigError} glob 形态不支持
|
|
480
569
|
*/
|
|
481
570
|
export function runDocsCheck(opts) {
|
|
571
|
+
// --against <ref>(坑 docs-gate-shared-worktree-parallel-block):pre-push gate 校验
|
|
572
|
+
// 「被推送的提交树」而非工作区——激活 HEAD reader 隔离并行会话在途编辑(移动靶)。
|
|
573
|
+
// 非 git 仓/子目录/git 异常 → 回退磁盘模式并附 warning。--fix 路径不经此(applyFixes
|
|
574
|
+
// 恒操作工作区磁盘内容,against 对 fix 无意义,CLI 层已拒绝组合)。
|
|
575
|
+
const against = opts && opts.against ? String(opts.against) : null
|
|
576
|
+
if (!against) return _runDocsCheckCore(opts)
|
|
577
|
+
const reader = createHeadReader(opts.projectRoot, against)
|
|
578
|
+
if (!reader) {
|
|
579
|
+
const r = _runDocsCheckCore(opts)
|
|
580
|
+
r.warnings = [...(r.warnings || []),
|
|
581
|
+
`--against ${against} 不可用(非 git 仓 / 非 git 根子目录 / git 异常),已回退磁盘模式——并行在途编辑可能造成移动靶误拦`]
|
|
582
|
+
return r
|
|
583
|
+
}
|
|
584
|
+
const prev = _setHeadReaderForCheck(reader)
|
|
585
|
+
try {
|
|
586
|
+
return _runDocsCheckCore(opts)
|
|
587
|
+
} finally {
|
|
588
|
+
_setHeadReaderForCheck(prev)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function _runDocsCheckCore(opts) {
|
|
482
593
|
// paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
|
|
483
594
|
// 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
|
|
484
595
|
const {
|
|
@@ -511,7 +622,10 @@ export function runDocsCheck(opts) {
|
|
|
511
622
|
|
|
512
623
|
for (const docRel of docFiles) {
|
|
513
624
|
const docAbs = join(projectRoot, docRel)
|
|
514
|
-
|
|
625
|
+
// HEAD 模式:未跟踪文档(walkGlob 扫盘枚举到但不随推送走)静默跳过——不是失效,
|
|
626
|
+
// 也不该以「文档不存在」入 invalid(它存在于工作区,只是不属于被推送的提交树)。
|
|
627
|
+
if (_headReader && !_rdExists(docAbs)) { skippedExempt++; continue }
|
|
628
|
+
if (!_rdExists(docAbs)) {
|
|
515
629
|
invalid.push({
|
|
516
630
|
doc: docRel, docLine: 0, ref: '', reason: '文档不存在',
|
|
517
631
|
fix: { fixable: false, reason: '文档不存在,无法自动定位' },
|
|
@@ -521,7 +635,16 @@ export function runDocsCheck(opts) {
|
|
|
521
635
|
// 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
|
|
522
636
|
// frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
|
|
523
637
|
if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
|
|
524
|
-
const md =
|
|
638
|
+
const md = _rdText(docAbs)
|
|
639
|
+
if (md === null) {
|
|
640
|
+
// HEAD 模式下未跟踪文档(不随推送走,walkGlob 扫盘枚举到此)——不计失效不报警
|
|
641
|
+
if (_headReader) { skippedExempt++; continue }
|
|
642
|
+
invalid.push({
|
|
643
|
+
doc: docRel, docLine: 0, ref: '', reason: '文档不可读',
|
|
644
|
+
fix: { fixable: false, reason: '文档不可读(权限/路径异常)' },
|
|
645
|
+
})
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
525
648
|
if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
|
|
526
649
|
const refs = collectDocRefs(md)
|
|
527
650
|
const mdLines = md.split(/\r?\n/)
|
|
@@ -836,10 +959,12 @@ export const DECISIONS_DEFAULT_BEHIND_THRESHOLD = 10
|
|
|
836
959
|
* @param {string} yamlText local.yaml 全文
|
|
837
960
|
* @returns {string[]} 键列表;无声明返回 []
|
|
838
961
|
*/
|
|
839
|
-
function extractKnownFailureKeys(yamlText) {
|
|
962
|
+
export function extractKnownFailureKeys(yamlText) {
|
|
840
963
|
if (!yamlText) return []
|
|
841
964
|
const yaml = String(yamlText).replace(/\r\n?/g, '\n')
|
|
842
|
-
|
|
965
|
+
// 贪婪 .* 同 verify-postcheck.extractKnownFailures(坑 known-failures-inline-nested-bracket:
|
|
966
|
+
// 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)
|
|
967
|
+
const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
|
|
843
968
|
if (inline) {
|
|
844
969
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
845
970
|
}
|
package/src/docs-gate.js
CHANGED
|
@@ -77,6 +77,9 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
77
77
|
skip: checkOpts.skip || cfg.skip,
|
|
78
78
|
keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
|
|
79
79
|
crossRepoRoots: checkOpts.crossRepoRoots || cfg.crossRepoRoots,
|
|
80
|
+
// 坑 docs-gate-shared-worktree-parallel-block:--against <ref> 透传——按提交树校验
|
|
81
|
+
//(pre-push 语义),隔离并行会话在途编辑的锚点瞬时漂移(移动靶)
|
|
82
|
+
...(checkOpts.against ? { against: checkOpts.against } : {}),
|
|
80
83
|
});
|
|
81
84
|
} catch (e) {
|
|
82
85
|
if (e instanceof DocsCheckConfigError) {
|
|
@@ -856,7 +856,7 @@ export function detectMcpEndpoints(cwd) {
|
|
|
856
856
|
* _module-map.yaml 的 needs_review=true 清单(卡缺失/精确校验归 modules rebuild/status)。
|
|
857
857
|
* 只读 fail-soft。
|
|
858
858
|
*/
|
|
859
|
-
function detectModuleDocHealth(cwd) {
|
|
859
|
+
export function detectModuleDocHealth(cwd) {
|
|
860
860
|
const base = { name: 'module_doc_health', label: '模块文档健康', safe_actions: [] }
|
|
861
861
|
try {
|
|
862
862
|
let mapText = null
|
|
@@ -870,7 +870,8 @@ function detectModuleDocHealth(cwd) {
|
|
|
870
870
|
if (!mapText) return { ...base, findings: ['无 _module-map.yaml(未 scan)——skipped'], pass: true, severity: null, skipped: true }
|
|
871
871
|
const findings = []
|
|
872
872
|
// needs_review 清单(模块块内 needs_review: true)
|
|
873
|
-
|
|
873
|
+
// \\w 是「字面反斜杠+w」——永不分割,整文件当一块只查首个模块(ql-20260911-029)
|
|
874
|
+
const blocks = mapText.split(/\n(?= \w)/)
|
|
874
875
|
for (const blk of blocks) {
|
|
875
876
|
const idM = blk.match(/^ ([\w.-]+):/)
|
|
876
877
|
if (idM && /needs_review:\s*true/.test(blk)) {
|
|
@@ -999,13 +1000,24 @@ function detectLifecycleDocStaleness(cwd) {
|
|
|
999
1000
|
return { ...base, pass: true, severity: null, findings: ['文档时间戳不可解析,跳过(不误报)'] };
|
|
1000
1001
|
}
|
|
1001
1002
|
// 生命周期代码侧最新提交(取各敏感路径 max %ct;任一路径 untracked 新文件不在 log 里,
|
|
1002
|
-
// git 不可见性注记——工作树未提交改动本就不该由 doctor
|
|
1003
|
+
// git 不可见性注记——工作树未提交改动本就不该由 doctor 报,提交后自然可见)。
|
|
1004
|
+
// 批 E-②:单次多 pathspec git log 取 max(原逐路径 6 个串行子进程,Windows ~0.4-1s);
|
|
1005
|
+
// 仅当检出「文档落后」需要归因到具体路径时,才退回逐路径解析(罕见告警路径)。
|
|
1003
1006
|
let srcTs = 0;
|
|
1004
1007
|
let srcPath = null;
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1008
|
+
const combined = safeGit(cwd, ['log', '-1', '--format=%ct', '--', ...LIFECYCLE_SENSITIVE_PATHS]);
|
|
1009
|
+
srcTs = Number(combined.value);
|
|
1010
|
+
if (!Number.isFinite(srcTs) || srcTs <= 0) srcTs = 0;
|
|
1011
|
+
if (docTs < srcTs) {
|
|
1012
|
+
// 归因:找 ts === srcTs 的路径(等不到就取最大有限值兜底)
|
|
1013
|
+
let bestTs = 0
|
|
1014
|
+
for (const p of LIFECYCLE_SENSITIVE_PATHS) {
|
|
1015
|
+
const r = safeGit(cwd, ['log', '-1', '--format=%ct', '--', p]);
|
|
1016
|
+
const ts = Number(r.value);
|
|
1017
|
+
if (!Number.isFinite(ts)) continue;
|
|
1018
|
+
if (ts === srcTs) { srcPath = p; break; }
|
|
1019
|
+
if (ts > bestTs) { bestTs = ts; srcPath = p; }
|
|
1020
|
+
}
|
|
1009
1021
|
}
|
|
1010
1022
|
if (srcTs === 0) {
|
|
1011
1023
|
return { ...base, pass: true, severity: null, findings: ['生命周期代码无提交历史,跳过'] };
|
package/src/friction-tally.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
27
27
|
import { dirname, join } from 'path'
|
|
28
28
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
29
|
+
import { withFileLock } from './quicklog.js'
|
|
29
30
|
import { resolveRuntimeRoot, resolveQuickSessionsDir } from './run/shared.js'
|
|
30
31
|
|
|
31
32
|
/** 摩擦事件类型枚举(D-004):审查打回走专属 review_rejected,不与 gate_rollback 重复计 */
|
|
@@ -169,13 +170,14 @@ export function renderFrictionHintLine(counts) {
|
|
|
169
170
|
* @param {string} [p.detail] 预定义来源标签(如 verify-test/quick-audit),非字符串落 null
|
|
170
171
|
* @returns {{ type: string, count: number } | null} 落盘成功返回新计数;null = 未落盘
|
|
171
172
|
*/
|
|
172
|
-
export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
|
|
173
|
+
export async function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
|
|
173
174
|
try {
|
|
174
175
|
if (!FRICTION_TYPES.has(type)) return null
|
|
175
176
|
const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
|
|
176
177
|
if (!tallyPath) return null
|
|
177
178
|
// enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)
|
|
178
179
|
if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
|
|
180
|
+
return await withFileLock(tallyPath + ".lock", async () => {
|
|
179
181
|
const tally = readFrictionTally(tallyPath)
|
|
180
182
|
const at = new Date().toISOString()
|
|
181
183
|
const prev = Number(tally.events[type] && tally.events[type].count)
|
|
@@ -188,6 +190,7 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
|
|
|
188
190
|
mkdirSync(dirname(tallyPath), { recursive: true })
|
|
189
191
|
writeAtomicSync(tallyPath, JSON.stringify(tally, null, 2))
|
|
190
192
|
return { type, count }
|
|
193
|
+
})
|
|
191
194
|
} catch {
|
|
192
195
|
return null
|
|
193
196
|
}
|
|
@@ -201,11 +204,12 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
|
|
|
201
204
|
* 会再消费一次,advisory 容忍重复,不因删失败丢提示。
|
|
202
205
|
* @returns {{ hint: string | null, counts: Record<string, number> }}
|
|
203
206
|
*/
|
|
204
|
-
export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
207
|
+
export async function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
205
208
|
try {
|
|
206
209
|
const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
|
|
207
210
|
if (!tallyPath || !existsSync(tallyPath)) return { hint: null, counts: {} }
|
|
208
211
|
if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
|
|
212
|
+
return await withFileLock(tallyPath + ".lock", async () => {
|
|
209
213
|
const tally = readFrictionTally(tallyPath)
|
|
210
214
|
const counts = {}
|
|
211
215
|
for (const type of FRICTION_TYPES) {
|
|
@@ -215,6 +219,7 @@ export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
|
215
219
|
if (Object.keys(counts).length === 0) return { hint: null, counts }
|
|
216
220
|
try { rmSync(tallyPath, { force: true }) } catch { /* 删失败不吞提示 */ }
|
|
217
221
|
return { hint: renderFrictionHintLine(counts), counts }
|
|
222
|
+
})
|
|
218
223
|
} catch {
|
|
219
224
|
return { hint: null, counts: {} }
|
|
220
225
|
}
|
package/src/git-helper.js
CHANGED
|
@@ -124,16 +124,18 @@ export function safeGit(cwd, args, opts = {}) {
|
|
|
124
124
|
* @returns {string|Buffer}
|
|
125
125
|
*/
|
|
126
126
|
export function git(cwd, args, opts = {}) {
|
|
127
|
-
const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
|
|
127
|
+
const { trim = true, timeout = 5000, encoding = 'utf8', env } = opts
|
|
128
128
|
const fullArgs = buildFullArgs(cwd, args)
|
|
129
|
-
|
|
129
|
+
// env(终批-③):可选注入(如 baseline checkpoint 的临时 GIT identity)——调用方必须
|
|
130
|
+
// 用 { ...process.env, GIT_xxx } 展开形式,裸替换会丢 SystemRoot/USERPROFILE/TEMP(Windows)
|
|
131
|
+
const value = execFileSync('git', fullArgs, { encoding, timeout, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'], env })
|
|
130
132
|
if (Buffer.isBuffer(value)) return value
|
|
131
133
|
return trim ? value.trim() : value
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
135
137
|
* 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
|
|
136
|
-
* ENOBUFS(输出超
|
|
138
|
+
* ENOBUFS(输出超 GIT_MAX_BUFFER 上限)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
|
|
137
139
|
* 至少 stderr 留一行,不与正常空结果同语义。
|
|
138
140
|
* @param {string} cwd
|
|
139
141
|
* @param {string[]} args
|
|
@@ -145,7 +147,7 @@ export function gitQuiet(cwd, args, opts = {}) {
|
|
|
145
147
|
return git(cwd, args, opts)
|
|
146
148
|
} catch (e) {
|
|
147
149
|
if (e && e.code === 'ENOBUFS') {
|
|
148
|
-
console.error(`[sillyspec] git 输出超过
|
|
150
|
+
console.error(`[sillyspec] git 输出超过 ${GIT_MAX_BUFFER / (1024 * 1024)}MB 上限被截断:git ${args.join(' ')}`)
|
|
149
151
|
}
|
|
150
152
|
return null
|
|
151
153
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
13
13
|
import path from 'path'
|
|
14
|
-
import {
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
15
|
|
|
16
16
|
// ── 常量 ──
|
|
17
17
|
|
|
@@ -29,8 +29,9 @@ const READONLY_COMMANDS = new Set([
|
|
|
29
29
|
'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
|
|
30
30
|
])
|
|
31
31
|
|
|
32
|
-
/** 只读 git 子命令(stash
|
|
33
|
-
|
|
32
|
+
/** 只读 git 子命令(stash/branch/worktree 不在此列:branch 的 -d/-D/-m/-M 破坏性、worktree
|
|
33
|
+
* 的 remove --force 可删未提交工作区——各自在下方按参数细化放行) */
|
|
34
|
+
const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show'])
|
|
34
35
|
|
|
35
36
|
/** 危险 git 子命令 */
|
|
36
37
|
const DANGER_GIT_SUBS = new Set([
|
|
@@ -38,6 +39,14 @@ const DANGER_GIT_SUBS = new Set([
|
|
|
38
39
|
'mv', 'rm',
|
|
39
40
|
])
|
|
40
41
|
|
|
42
|
+
/** git branch 的破坏性 flag(-d/-D 删分支、-m/-M 改名、-f 强建重指;含长形同义)——
|
|
43
|
+
* 删/改分支会让 task review 的 base/head ref 引用悬空(gc 后真丢),2026-09-11 审查 P1 收口 */
|
|
44
|
+
const BRANCH_DESTRUCTIVE_RE = /^(?:-[dDmMf]|--delete|--force|--move|--edit-description)(?:=|$)/
|
|
45
|
+
|
|
46
|
+
function hasDestructiveBranchFlag(parts) {
|
|
47
|
+
return parts.slice(2).some(tok => BRANCH_DESTRUCTIVE_RE.test(tok))
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
/** 危险 git stash 操作 */
|
|
42
51
|
const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
|
|
43
52
|
|
|
@@ -235,9 +244,9 @@ function isInsideWorktreeStorage(filePath, cwd) {
|
|
|
235
244
|
/**
|
|
236
245
|
* 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
|
|
237
246
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
247
|
+
* 进程内 createRequire 同步加载 node:sqlite 的 DatabaseSync(2026-09-11 审查性能包②:旧实现
|
|
248
|
+
* 每次查询 spawn 一个 node 子进程,Windows 100-300ms/次,hook 每次 Write/Edit/Bash 触发 1-2 次;
|
|
249
|
+
* hook 自身就是同一 node 二进制,子进程无隔离收益只有延迟):
|
|
241
250
|
* - readOnly 打开只读连接(替代旧方案 readonly/fileMustExist 语义):绝不写库;WAL 并发读不阻塞主进程写,
|
|
242
251
|
* 且只读连接可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
|
|
243
252
|
* 看不见 WAL 的过渡态可见性窗口)。db 缺失/损坏由 open 异常自然触发 fail-closed(外层 catch)。
|
|
@@ -245,7 +254,7 @@ function isInsideWorktreeStorage(filePath, cwd) {
|
|
|
245
254
|
* 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
|
|
246
255
|
* 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
|
|
247
256
|
*
|
|
248
|
-
* fail-closed:db 文件不存在 /
|
|
257
|
+
* fail-closed:db 文件不存在 / 打开、查询异常 / node:sqlite 不可用——一律 console.warn
|
|
249
258
|
* (含 e.stderr 详情)并返回 null,调用方(readCurrentStage/isNoWorktreeMode)对 null 走
|
|
250
259
|
* fail-closed,禁止 fail-open。
|
|
251
260
|
*/
|
|
@@ -257,26 +266,26 @@ function queryDbFirstCell(cwd, sql) {
|
|
|
257
266
|
console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
|
|
258
267
|
return null
|
|
259
268
|
}
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
"dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
|
|
265
|
-
"let db;" +
|
|
266
|
-
"try{db=new DatabaseSync(dbPath,{readOnly:true});" +
|
|
267
|
-
"const row=db.prepare(sql).get();" +
|
|
268
|
-
"const v=row===undefined?undefined:Object.values(row)[0];" +
|
|
269
|
-
"if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
|
|
270
|
-
"catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
|
|
269
|
+
// 进程内同步 require node:sqlite(DatabaseSync 同步 API;2026-09-11 审查性能包②:旧实现每次
|
|
270
|
+
// hook 触发 spawn 一个 node 子进程查库,Windows 100-300ms/次×每 Write/Edit/Bash 1-2 次——
|
|
271
|
+
// hook 自身就是同一 node 二进制,子进程无隔离收益只有延迟)。异常路径与旧子进程版同语义:
|
|
272
|
+
// 捕获后 warn + 返回 null,调用方(readCurrentStage 等)对 null 走 fail-closed。
|
|
271
273
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
274
|
+
const req = createRequire(import.meta.url)
|
|
275
|
+
const { DatabaseSync } = req('node:sqlite')
|
|
276
|
+
let db
|
|
277
|
+
try {
|
|
278
|
+
db = new DatabaseSync(dbPath, { readOnly: true })
|
|
279
|
+
const row = db.prepare(sql).get()
|
|
280
|
+
const v = row === undefined ? undefined : Object.values(row)[0]
|
|
281
|
+
return v === undefined ? null : (String(v ?? '') || null)
|
|
282
|
+
} finally {
|
|
283
|
+
if (db) db.close()
|
|
284
|
+
}
|
|
275
285
|
} catch (e) {
|
|
276
|
-
// DB 查询失败(db 损坏 / readOnly 打开失败 /
|
|
277
|
-
//(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent
|
|
278
|
-
|
|
279
|
-
const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
|
|
286
|
+
// DB 查询失败(db 损坏 / readOnly 打开失败 / node:sqlite 不可用):降级返回 null,调用方
|
|
287
|
+
//(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因。
|
|
288
|
+
const detail = (e && e.message) ? e.message : String(e)
|
|
280
289
|
console.warn('⚠️ sillyspec.db 查询失败,hook 降级 fail-closed: ' + detail)
|
|
281
290
|
return null
|
|
282
291
|
}
|
|
@@ -532,10 +541,13 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
|
|
|
532
541
|
if (cmdName === 'git') {
|
|
533
542
|
const sub = parts[1] || ''
|
|
534
543
|
if (READONLY_GIT_SUBS.has(sub)) return true
|
|
544
|
+
// git branch:裸查询/建分支放行;-d/-D/-m/-M/-f(含长形)破坏性不放行(按参数细化,不再整类放行)
|
|
545
|
+
if (sub === 'branch' && !hasDestructiveBranchFlag(parts)) return true
|
|
535
546
|
// git stash list
|
|
536
547
|
if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
|
|
537
|
-
// git worktree
|
|
538
|
-
|
|
548
|
+
// git worktree:仅 list(裸 worktree 同 list)。remove --force 可删含未提交改动的工作区、
|
|
549
|
+
// 且 junction 会穿透删主仓 node_modules——add/remove/prune 一律走 sillyspec worktree 命令(内建护栏)
|
|
550
|
+
if (sub === 'worktree' && (parts.length === 2 || parts[2] === 'list')) return true
|
|
539
551
|
return false
|
|
540
552
|
}
|
|
541
553
|
|
|
@@ -563,6 +575,10 @@ function isSingleCommandDangerous(cmd) {
|
|
|
563
575
|
if (cmdName === 'git') {
|
|
564
576
|
const sub = parts[1] || ''
|
|
565
577
|
if (DANGER_GIT_SUBS.has(sub)) return true
|
|
578
|
+
// git branch -d/-D/-m/-M/-f(含长形):删/改分支让 task review 的 base/head 引用悬空
|
|
579
|
+
if (sub === 'branch' && hasDestructiveBranchFlag(parts)) return true
|
|
580
|
+
// git worktree remove/prune:删工作区(可能含未提交改动)——清理走 sillyspec worktree cleanup
|
|
581
|
+
if (sub === 'worktree' && ['remove', 'prune'].includes(parts[2] || '')) return true
|
|
566
582
|
// git stash drop/clear/pop
|
|
567
583
|
if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
|
|
568
584
|
}
|
|
@@ -603,6 +619,20 @@ function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
|
|
|
603
619
|
* @param {string} command
|
|
604
620
|
* @returns {boolean}
|
|
605
621
|
*/
|
|
622
|
+
/**
|
|
623
|
+
* 危险拦截原因的定向指引(branch/worktree 的破坏形态有官方替代路径,提示同步到拦截消息)
|
|
624
|
+
*/
|
|
625
|
+
function dangerBlockHint(command) {
|
|
626
|
+
const parts = String(command).trim().toLowerCase().split(/\s+/)
|
|
627
|
+
if (parts[0] === 'git' && parts[1] === 'branch') {
|
|
628
|
+
return '——分支删除/改名/强建会让 task review 的 base/head 引用悬空;分支管理走 sillyspec worktree 流程'
|
|
629
|
+
}
|
|
630
|
+
if (parts[0] === 'git' && parts[1] === 'worktree') {
|
|
631
|
+
return '——worktree 清理走 sillyspec worktree cleanup(内建未落仓护栏),勿裸删工作区'
|
|
632
|
+
}
|
|
633
|
+
return ''
|
|
634
|
+
}
|
|
635
|
+
|
|
606
636
|
function matchDangerBlacklist(command) {
|
|
607
637
|
const parts = splitCommandParts(command)
|
|
608
638
|
return parts.some(p => isSingleCommandDangerous(p))
|
|
@@ -825,7 +855,7 @@ function shouldBlockBash(command, cwd) {
|
|
|
825
855
|
if (stage === 'quick') {
|
|
826
856
|
// 危险黑名单仍然拦截
|
|
827
857
|
if (matchDangerBlacklist(command)) {
|
|
828
|
-
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
|
|
858
|
+
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}${dangerBlockHint(command)}` }
|
|
829
859
|
}
|
|
830
860
|
// 检查命令是否会覆盖 baseline files(并集 = 保护所有 session 的 baseline)
|
|
831
861
|
const { baselineFiles } = readAllQuickGuards(projectRoot)
|
|
@@ -843,7 +873,7 @@ function shouldBlockBash(command, cwd) {
|
|
|
843
873
|
|
|
844
874
|
// 危险黑名单
|
|
845
875
|
if (matchDangerBlacklist(command)) {
|
|
846
|
-
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
|
|
876
|
+
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}${dangerBlockHint(command)}` }
|
|
847
877
|
}
|
|
848
878
|
|
|
849
879
|
// 只读白名单放行
|