sillyspec 3.28.8 → 3.28.10
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/full.json +1 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/change-risk-profile.js +20 -0
- package/src/config-schema.js +62 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +89 -20
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +206 -18
- package/src/knowledge-classify.js +382 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/probe7-anchor-check.js +69 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +164 -18
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +131 -13
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +123 -23
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gate-snapshot.js +110 -2
- package/src/run/gates.js +141 -16
- package/src/run/prompt.js +221 -11
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +147 -11
- package/src/run/stage.js +6 -0
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +456 -56
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +76 -6
- package/src/stages/plan-postcheck.js +32 -5
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +170 -6
- package/src/sync.js +141 -9
- package/src/task-review.js +366 -46
- package/src/verify-postcheck.js +212 -55
- package/src/verify-probes.js +727 -16
- package/src/worktree-apply.js +685 -48
- package/src/worktree-deps.js +124 -3
- package/src/worktree.js +391 -15
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- package/sa-rename-probe/src/new.js +0 -4
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-gate-profile.js — quick 出口分级门禁画像(信号层,纯函数零 IO)
|
|
3
|
+
*
|
|
4
|
+
* quick --done 分级门禁的信号单一来源(2026-09-14-quick-exit-tiered-gates task-01 / FR-02):
|
|
5
|
+
* 输入 CLI 审计链的 git 事实 changedFiles(非 --files 自声明)+ 调用方传入的 moduleIndex
|
|
6
|
+
* (_module-map.yaml 解析结果),输出 L0/L1/L2 画像与检查项,供 Wave 2 接线消费
|
|
7
|
+
* (run/shared.js 挂 review.gateProfile → complete-handlers 落账 / quick-audit 打印 /
|
|
8
|
+
* scope-audit 表格与 --json 双出口)。本模块零 IO、零子进程:moduleIndex、风险表、阈值
|
|
9
|
+
* 全部由参数或默认值提供(D-007 独立纯函数模块;R-04 画像=纯字符串运算)。
|
|
10
|
+
*
|
|
11
|
+
* 判级(阈值只引用 THRESHOLDS 常量,不散落字面量):
|
|
12
|
+
* 正常态(moduleIndex 可用):L2 = 跨 ≥L2_SPAN 模块 或 风险命中;L1 = 跨 ≥L1_SPAN 模块
|
|
13
|
+
* 或 ≥L1_FILES 文件;否则 L0。
|
|
14
|
+
* 降级态(moduleIndex 为 null/undefined/空,如未 scan / 无 module-map 的存量项目):
|
|
15
|
+
* span 退出判级,L2 = ≥L2_FILES_DEGRADED 文件 或 风险命中;L1 = ≥L1_FILES 文件。
|
|
16
|
+
* 全部 advisory(D-003:升 blocking 是另立变更的显式决策),不抛错不阻断。
|
|
17
|
+
*
|
|
18
|
+
* 计数口径(design 接口定义,勿自创字段):
|
|
19
|
+
* fileCount = 全部变更文件数(判级用的「文件数」信号与之一致——与旧「≤3 文件」规则
|
|
20
|
+
* 及 sillyhub 校准统计同基);
|
|
21
|
+
* testFileCount = 非文档文件中:路径含 __tests__/、/tests/ 目录段、basename test_ 前缀
|
|
22
|
+
* (python 风格)或 .test./_test./.spec. 命名(对齐仓内既有测试判定先例);
|
|
23
|
+
* codeFileCount = 其余非文档文件。文档口径沿用 docSyncHint 的 isDoc(run/shared.js
|
|
24
|
+
* auditQuickCompletion 内 isDocFile 同款:.md/.yaml/.yml 扩展或 docs/、
|
|
25
|
+
* .sillyspec/docs/ 前缀——模块卡与 changelog sidecar 均 .md 天然覆盖),
|
|
26
|
+
* 文档文件不计入 testFileCount/codeFileCount,也不参与模块归属与风险命中。
|
|
27
|
+
*/
|
|
28
|
+
import { QUICK_RISK_PATH_PATTERNS } from './change-risk-profile.js'
|
|
29
|
+
|
|
30
|
+
/** 代码内默认阈值(task-05 真实图谱校准定稿,缺省行为的单一事实源;local.yaml quick-gate 段可覆写,D-009)。
|
|
31
|
+
* 定稿依据(2026-09-14,sillyhub 919 条 quicklog × 5 份真实 _module-map.yaml 重算交叉表,
|
|
32
|
+
* 证据全文见主仓 changes/2026-09-14-quick-exit-tiered-gates/design.md「阈值校准记录」):
|
|
33
|
+
* L1_SPAN=2 落在文档同步率断崖上(≤3 文件档 span1 33.3% → span2-3 5.9%,近期口径);
|
|
34
|
+
* L2_SPAN=4 边界内格子全档最差(近期 span4+×4-10 文件 0-14.3%);L1_FILES=4 维持旧规则
|
|
35
|
+
* 「≤3 文件」边界连续性(文件维度无同步率断崖——量错维度的实证本身);L2_FILES_DEGRADED=8
|
|
36
|
+
* 覆盖降级档最差文件桶 7-10(32.9-35.3%)。四值与 brainstorm 初值一致,维持不调。 */
|
|
37
|
+
export const THRESHOLDS = {
|
|
38
|
+
L1_SPAN: 2, // 跨 ≥2 模块 → L1
|
|
39
|
+
L1_FILES: 4, // 或 ≥4 文件 → L1
|
|
40
|
+
L2_SPAN: 4, // 跨 ≥4 模块 → L2
|
|
41
|
+
L2_FILES_DEGRADED: 8, // module-map 缺失降级档:≥8 文件 → L2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** local.yaml quick-gate 键 → THRESHOLDS 常量键(覆写面与代码默认值的单一对照)。 */
|
|
45
|
+
const GATE_KEY_TO_THRESHOLD = {
|
|
46
|
+
l1_span: 'L1_SPAN',
|
|
47
|
+
l1_files: 'L1_FILES',
|
|
48
|
+
l2_span: 'L2_SPAN',
|
|
49
|
+
l2_files_degraded: 'L2_FILES_DEGRADED',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 阈值合法性:≥1 的有限整数(0/负数会让一切改动升 L1、浮点/字符串无意义)。 */
|
|
53
|
+
function isValidThreshold(v) {
|
|
54
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 合并 local.yaml quick-gate 段覆写与代码默认值(D-009)。
|
|
59
|
+
* @param {object} config local.yaml 解析结果(无 quick-gate 段 → 全默认;键非法回退默认并 warn)
|
|
60
|
+
* @returns {{ l1_span: number, l1_files: number, l2_span: number, l2_files_degraded: number }}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveGateThresholds(config) {
|
|
63
|
+
const merged = {
|
|
64
|
+
l1_span: THRESHOLDS.L1_SPAN,
|
|
65
|
+
l1_files: THRESHOLDS.L1_FILES,
|
|
66
|
+
l2_span: THRESHOLDS.L2_SPAN,
|
|
67
|
+
l2_files_degraded: THRESHOLDS.L2_FILES_DEGRADED,
|
|
68
|
+
}
|
|
69
|
+
const section = config == null ? undefined : config['quick-gate']
|
|
70
|
+
if (section == null) return merged
|
|
71
|
+
if (typeof section !== 'object' || Array.isArray(section)) {
|
|
72
|
+
console.warn('[quick-gate] local.yaml quick-gate 段须为键值对象,已忽略(使用默认阈值)')
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
for (const [key, constName] of Object.entries(GATE_KEY_TO_THRESHOLD)) {
|
|
76
|
+
if (!(key in section)) continue
|
|
77
|
+
const v = section[key]
|
|
78
|
+
if (isValidThreshold(v)) merged[key] = v
|
|
79
|
+
else console.warn(`[quick-gate] local.yaml 配置键 quick-gate.${key}=${JSON.stringify(v)} 非法(须为 ≥1 整数),回退默认 ${THRESHOLDS[constName]}`)
|
|
80
|
+
}
|
|
81
|
+
return merged
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 计数口径判定(文档/测试/代码三分;口径见文件头注释) ──
|
|
85
|
+
|
|
86
|
+
/** 文档文件口径:沿用 docSyncHint 的 isDoc(run/shared.js auditQuickCompletion 内 isDocFile 逐款同源)。 */
|
|
87
|
+
function isDocPath(p) {
|
|
88
|
+
return p.endsWith('.md') || p.endsWith('.yaml') || p.endsWith('.yml') ||
|
|
89
|
+
p.startsWith('docs/') || p.startsWith('.sillyspec/docs/')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** __tests__/ 或 tests/ 目录段(/test_ 前缀与命名判定在 isTestPath 内)。 */
|
|
93
|
+
const TEST_DIR_RE = /(^|\/)(__tests__|tests)\//i
|
|
94
|
+
|
|
95
|
+
/** 测试文件判定:目录段、basename test_ 前缀、.test./_test./.spec. 命名三信号任一命中。 */
|
|
96
|
+
function isTestPath(p) {
|
|
97
|
+
if (TEST_DIR_RE.test(p)) return true
|
|
98
|
+
const base = p.slice(p.lastIndexOf('/') + 1)
|
|
99
|
+
if (/^test_/i.test(base)) return true
|
|
100
|
+
return /[._](test|spec)\.[^.]+$/i.test(base)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** moduleIndex 兼容两种形态:parseModuleMapSimple 扁平 { id: {...} } 或 { modules: {...} } 包装;空 → null(degraded)。 */
|
|
104
|
+
function resolveModulesObject(moduleIndex) {
|
|
105
|
+
if (moduleIndex == null || typeof moduleIndex !== 'object') return null
|
|
106
|
+
const wrapped = moduleIndex.modules
|
|
107
|
+
const obj = wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped) ? wrapped : moduleIndex
|
|
108
|
+
return Object.keys(obj).length > 0 ? obj : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 一级归属:paths/core_files 字面量或目录前缀命中(与 docs-debt matchFilesToModules 一级口径同款,纯内存零 IO)。 */
|
|
112
|
+
function matchModuleForFile(posix, modulesObj) {
|
|
113
|
+
for (const [id, m] of Object.entries(modulesObj)) {
|
|
114
|
+
if (m == null || typeof m !== 'object') continue
|
|
115
|
+
const prefixes = [...(Array.isArray(m.paths) ? m.paths : []), ...(Array.isArray(m.core_files) ? m.core_files : [])]
|
|
116
|
+
for (const raw of prefixes) {
|
|
117
|
+
const p = String(raw).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
118
|
+
if (!p) continue
|
|
119
|
+
if (posix === p || posix.startsWith(p + '/')) return id
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 卡片认领判定:模块 doc 字段(如 modules/core-engine.md)对应卡片文件在 changedFiles 内。
|
|
126
|
+
* changedFiles 为仓库根相对路径(如 .sillyspec/docs/<project>/modules/core-engine.md),
|
|
127
|
+
* 本函数无 cwd/project 入参,按字面量或后缀对齐。 */
|
|
128
|
+
function cardInFiles(doc, fileSet) {
|
|
129
|
+
const d = doc.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
130
|
+
for (const f of fileSet) {
|
|
131
|
+
if (f === d || f.endsWith('/' + d)) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* quick 出口门禁画像(纯函数:无 IO、无子进程,moduleIndex/风险表/阈值全由参数或默认值)。
|
|
138
|
+
* @param {string[]} changedFiles 变更文件(CLI 审计链 git 事实;反斜杠自动归一 POSIX)
|
|
139
|
+
* @param {object|null} moduleIndex _module-map.yaml 解析结果(null/undefined/空 → degraded)
|
|
140
|
+
* @param {object} [opts]
|
|
141
|
+
* @param {Array<{pattern: string, re: RegExp}>} [opts.riskTable] 路径模式风险表(默认 QUICK_RISK_PATH_PATTERNS)
|
|
142
|
+
* @param {object} [opts.thresholds] 已合并阈值(resolveGateThresholds 产物;缺省用 THRESHOLDS,D-009)
|
|
143
|
+
* @param {Array<{path: string}>} [opts.fileNotes] --file-notes 解析结果(perFileNotes 覆盖率判定)
|
|
144
|
+
* @param {boolean} [opts.noDocs] --no-docs 显式豁免(docClaim → exempt-no-docs)
|
|
145
|
+
* @returns 画像对象(字段与判级规则见 design.md 接口定义)
|
|
146
|
+
*/
|
|
147
|
+
export function computeGateProfile(changedFiles, moduleIndex, opts = {}) {
|
|
148
|
+
const riskTable = Array.isArray(opts.riskTable) ? opts.riskTable : QUICK_RISK_PATH_PATTERNS
|
|
149
|
+
|
|
150
|
+
// 阈值:opts.thresholds 逐键取合法值,否则回 THRESHOLDS 默认(warn 责任在 resolveGateThresholds)
|
|
151
|
+
const th = {}
|
|
152
|
+
for (const [key, constName] of Object.entries(GATE_KEY_TO_THRESHOLD)) {
|
|
153
|
+
const v = opts.thresholds ? opts.thresholds[key] : undefined
|
|
154
|
+
th[key] = isValidThreshold(v) ? v : THRESHOLDS[constName]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const files = (Array.isArray(changedFiles) ? changedFiles : [])
|
|
158
|
+
.map((f) => String(f).replace(/\\/g, '/'))
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
|
|
161
|
+
const notedPaths = new Set(
|
|
162
|
+
(Array.isArray(opts.fileNotes) ? opts.fileNotes : [])
|
|
163
|
+
.filter((n) => n != null && typeof n.path === 'string' && typeof n.note === 'string' && n.note.trim() !== '')
|
|
164
|
+
.map((n) => n.path.replace(/\\/g, '/')),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const codeFiles = []
|
|
168
|
+
const testFiles = []
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
if (isDocPath(f)) continue // 文档文件不计入两者、不参与归属与风险命中(口径见文件头)
|
|
171
|
+
if (isTestPath(f)) testFiles.push(f)
|
|
172
|
+
else codeFiles.push(f)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const modulesObj = resolveModulesObject(moduleIndex)
|
|
176
|
+
const degraded = modulesObj === null
|
|
177
|
+
|
|
178
|
+
// 模块归属(非文档文件 × paths/core_files 前缀聚类);degraded 时不算,modules=[]、unmappedFiles=[]
|
|
179
|
+
const modules = []
|
|
180
|
+
const unmappedFiles = []
|
|
181
|
+
if (!degraded) {
|
|
182
|
+
const byModule = new Map()
|
|
183
|
+
for (const f of [...codeFiles, ...testFiles]) {
|
|
184
|
+
const id = matchModuleForFile(f, modulesObj)
|
|
185
|
+
if (id == null) { unmappedFiles.push(f); continue }
|
|
186
|
+
const entry = byModule.get(id)
|
|
187
|
+
if (entry) entry.files.push(f)
|
|
188
|
+
else byModule.set(id, { id, files: [f] })
|
|
189
|
+
}
|
|
190
|
+
modules.push(...byModule.values())
|
|
191
|
+
}
|
|
192
|
+
const moduleSpan = degraded ? null : modules.length
|
|
193
|
+
|
|
194
|
+
// 风险命中:非文档文件 × 路径模式表(v1 仅路径模式,无 diff 关键词维度;元素只含 pattern/file)
|
|
195
|
+
const riskHits = []
|
|
196
|
+
for (const f of [...codeFiles, ...testFiles]) {
|
|
197
|
+
for (const entry of riskTable) {
|
|
198
|
+
const re = entry == null ? null : entry.re
|
|
199
|
+
if (!(re instanceof RegExp)) continue
|
|
200
|
+
if (re.global) re.lastIndex = 0 // 防调用方传入 /g 正则跨文件携带 lastIndex 状态
|
|
201
|
+
if (re.test(f)) riskHits.push({ pattern: entry.pattern, file: f })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 判级:正常态 span 参与;degraded 态 span 退出(L1=≥l1_files、L2=≥l2_files_degraded 或风险命中)
|
|
206
|
+
let level = 'L0'
|
|
207
|
+
if (degraded) {
|
|
208
|
+
if (files.length >= th.l2_files_degraded || riskHits.length > 0) level = 'L2'
|
|
209
|
+
else if (files.length >= th.l1_files) level = 'L1'
|
|
210
|
+
} else if (moduleSpan >= th.l2_span || riskHits.length > 0) level = 'L2'
|
|
211
|
+
else if (moduleSpan >= th.l1_span || files.length >= th.l1_files) level = 'L1'
|
|
212
|
+
|
|
213
|
+
// L1:--file-notes 覆盖率(changedFiles 全集均有非空注记 → true;`path::` 空括注不算——防形式化绕过;
|
|
214
|
+
// 空集空真 true——L0 不消费)
|
|
215
|
+
const perFileNotes = files.every((f) => notedPaths.has(f))
|
|
216
|
+
|
|
217
|
+
// L1 机械规则:codeFileCount ≥2 且 testFileCount===0 → missing;codeFileCount ≤1 → na;其余 ok
|
|
218
|
+
let testDelta
|
|
219
|
+
if (codeFiles.length <= 1) testDelta = 'na'
|
|
220
|
+
else if (testFiles.length === 0) testDelta = 'missing'
|
|
221
|
+
else testDelta = 'ok'
|
|
222
|
+
|
|
223
|
+
// L2:模块文档认领(触及模块中带 doc 卡片的模块,其卡片文件全在 changedFiles → claimed;
|
|
224
|
+
// 无带卡片的触及模块(含 degraded)→ 无可认领,空真 claimed 不出假 advisory;--no-docs 优先豁免)
|
|
225
|
+
let docClaim
|
|
226
|
+
if (opts.noDocs) docClaim = 'exempt-no-docs'
|
|
227
|
+
else {
|
|
228
|
+
const fileSet = new Set(files)
|
|
229
|
+
const claimable = modules.filter((e) => {
|
|
230
|
+
const m = modulesObj == null ? null : modulesObj[e.id]
|
|
231
|
+
return m != null && typeof m === 'object' && typeof m.doc === 'string' && m.doc.trim() !== ''
|
|
232
|
+
})
|
|
233
|
+
docClaim = claimable.every((e) => cardInFiles(modulesObj[e.id].doc, fileSet)) ? 'claimed' : 'missing'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// L2 风险命中:运行时证据要求(advisory 提示,不引入 quick 没有的 wait 机制)
|
|
237
|
+
const runtimeEvidence = riskHits.length > 0 ? 'required' : 'na'
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
fileCount: files.length,
|
|
241
|
+
codeFileCount: codeFiles.length,
|
|
242
|
+
testFileCount: testFiles.length,
|
|
243
|
+
moduleSpan,
|
|
244
|
+
modules,
|
|
245
|
+
unmappedFiles,
|
|
246
|
+
riskHits,
|
|
247
|
+
level,
|
|
248
|
+
degraded,
|
|
249
|
+
checks: { perFileNotes, testDelta, docClaim, runtimeEvidence },
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/quicklog.js
CHANGED
|
@@ -479,11 +479,17 @@ async function writeAtomic(filePath, content, opts = {}) {
|
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
// 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
|
|
482
|
+
// 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集 + 当天已用全 ID 集
|
|
483
|
+
// (坑 ql-id-double-occupancy 配套:全 ID 集供分配末检防整 ID 复用;容错正则见下)
|
|
483
484
|
function scanExisting(quicklogDir, today) {
|
|
484
485
|
let maxSeq = 0
|
|
485
486
|
const usedSuffix = new Set()
|
|
487
|
+
const usedIds = new Set()
|
|
486
488
|
const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
|
|
489
|
+
// 容错形态:手改/工具写入的畸形头(双空格、后缀紧贴 |)不匹配严格正则 → maxSeq 漏计 →
|
|
490
|
+
// 新分配复用同序号。第二正则只宽松空白,日期/序号/后缀结构仍须齐整;命中的序号并入
|
|
491
|
+
// maxSeq(防同序号双条目),全 ID 进 usedIds(防整 ID 复用)。引用字样(行首带前缀)不命中。
|
|
492
|
+
const reLoose = /^##[ \t]*ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})[ \t]*\|/gm
|
|
487
493
|
// E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
|
|
488
494
|
// 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
|
|
489
495
|
// (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
|
|
@@ -493,16 +499,19 @@ function scanExisting(quicklogDir, today) {
|
|
|
493
499
|
if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
|
|
494
500
|
let content = ''
|
|
495
501
|
try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
+
for (const reOne of [re, reLoose]) {
|
|
503
|
+
let m
|
|
504
|
+
reOne.lastIndex = 0
|
|
505
|
+
while ((m = reOne.exec(content)) !== null) {
|
|
506
|
+
if (m[1] === today) {
|
|
507
|
+
maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
|
|
508
|
+
usedSuffix.add(m[3].toLowerCase())
|
|
509
|
+
usedIds.add(`ql-${today}-${m[2]}-${m[3].toLowerCase()}`)
|
|
510
|
+
}
|
|
502
511
|
}
|
|
503
512
|
}
|
|
504
513
|
}
|
|
505
|
-
return { maxSeq, usedSuffix }
|
|
514
|
+
return { maxSeq, usedSuffix, usedIds }
|
|
506
515
|
}
|
|
507
516
|
|
|
508
517
|
// >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
|
|
@@ -714,11 +723,128 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
|
|
|
714
723
|
|
|
715
724
|
// ── 对外 API ──
|
|
716
725
|
|
|
726
|
+
// 他者会话 guard 预留 ql-ID 的采集阈值:超龄会话的预留不再钉序号。与 run/shared.js
|
|
727
|
+
// collectOtherQuickSessionDeclarations 的 FOREIGN_SESSION_STALE_MS 同口径(7 天)——正常并发
|
|
728
|
+
// 会话间隔在分钟/小时级,7 天未收尾的会话按僵尸处理。本模块不 import run 层(分层:
|
|
729
|
+
// run/* → quicklog,反向会成环),阈值在此独立声明。
|
|
730
|
+
const GUARD_CLAIM_STALE_MS = 7 * 24 * 60 * 60 * 1000
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* 采集当前库内各 quick 会话 guard.json 已预留的 ql-ID(坑 ql-id-double-occupancy)。
|
|
734
|
+
*
|
|
735
|
+
* quick 启动在锁内追加 QUICKLOG 条目后把 ql-ID 写入 guard.json。条目随后丢失(并行 git
|
|
736
|
+
* 操作回滚未提交 QUICKLOG / worktree 分裂合并等)时,QUICKLOG 扫描看不到该预留 → 下一个
|
|
737
|
+
* 会话分配复用同一序号(suffix 再撞上即整 ID 双占用,2026-09-13 实证 007-1351、历史
|
|
738
|
+
* 2026-06-04 001-7a4c 同款)。分配期对 guard 预留让位(allocateQuicklogEntry)、--done 期
|
|
739
|
+
* 对残留占用校验(complete-handlers)都以此为准。
|
|
740
|
+
*
|
|
741
|
+
* fail-open:目录不存在/损坏 guard.json/超龄僵尸/任何异常 → 跳过或空集,采集失败回到
|
|
742
|
+
* 无预留现状(不放大让位面)。
|
|
743
|
+
*
|
|
744
|
+
* @param {string} specBase .sillyspec 根目录
|
|
745
|
+
* @param {string|null} [sessionsDirHint] quick-sessions 目录(run 层经 resolveQuickSessionsDir
|
|
746
|
+
* 解析后传入;缺省 <specBase>/.runtime/quick-sessions)
|
|
747
|
+
* @param {number} [nowMs] 当前时间戳(可注入,测试用)
|
|
748
|
+
* @returns {Map<string, string[]>} qlId → 预留该 ID 的 sessionId 列表
|
|
749
|
+
*/
|
|
750
|
+
/**
|
|
751
|
+
* 遍历 quick-sessions 目录,按同一僵尸窗口口径读各会话 guard.json(collectGuardReservedQuicklogIds
|
|
752
|
+
* 与 collectActiveQuickGuardFiles 共用,活跃判定勿分叉):guard.json 损坏/缺失 → guard=null 保留
|
|
753
|
+
* 条目(消费方各自决定跳过或视为空声明);startedAt 可解析且超 GUARD_CLAIM_STALE_MS → 僵尸剔除。
|
|
754
|
+
*
|
|
755
|
+
* @param {string} dir quick-sessions 目录
|
|
756
|
+
* @param {number} [nowMs] 当前时间戳(可注入,测试用)
|
|
757
|
+
* @returns {Array<{sessionId: string, guard: object|null}>}
|
|
758
|
+
*/
|
|
759
|
+
function listQuickSessionGuards(dir, nowMs = Date.now()) {
|
|
760
|
+
const out = []
|
|
761
|
+
let sessionDirs = []
|
|
762
|
+
try {
|
|
763
|
+
sessionDirs = readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
|
|
764
|
+
} catch { return out } // 目录不存在/不可读 → 无会话
|
|
765
|
+
for (const sessionName of sessionDirs) {
|
|
766
|
+
let guard = null
|
|
767
|
+
try { guard = JSON.parse(readFileSync(join(dir, sessionName, 'guard.json'), 'utf8')) } catch { guard = null } // 损坏/缺失 → null
|
|
768
|
+
if (guard && guard.startedAt) {
|
|
769
|
+
const startedAtMs = Date.parse(guard.startedAt)
|
|
770
|
+
if (Number.isFinite(startedAtMs) && nowMs - startedAtMs > GUARD_CLAIM_STALE_MS) continue // 僵尸不活跃
|
|
771
|
+
}
|
|
772
|
+
out.push({ sessionId: sessionName, guard })
|
|
773
|
+
}
|
|
774
|
+
return out
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function collectGuardReservedQuicklogIds(specBase, sessionsDirHint = null, nowMs = Date.now()) {
|
|
778
|
+
const out = new Map()
|
|
779
|
+
try {
|
|
780
|
+
const dir = sessionsDirHint || join(specBase, '.runtime', 'quick-sessions')
|
|
781
|
+
for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
|
|
782
|
+
if (!guard || typeof guard.quicklogId !== 'string' || !guard.quicklogId) continue
|
|
783
|
+
if (!out.has(guard.quicklogId)) out.set(guard.quicklogId, [])
|
|
784
|
+
out.get(guard.quicklogId).push(sessionId)
|
|
785
|
+
}
|
|
786
|
+
} catch { /* fail-open:采集失败等同无预留 */ }
|
|
787
|
+
return out
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* 采集各活跃 quick 会话 guard.json 声明的 allowedFiles(apply 前 guard 相交预检,FR-03 / D-002@v1)。
|
|
792
|
+
*
|
|
793
|
+
* 活跃口径与 collectGuardReservedQuicklogIds 同源(listQuickSessionGuards):guard 目录存在即活跃
|
|
794
|
+
* (quick --done 完成时清理 guard 目录,完成态天然退出),超 7 天僵尸窗口(GUARD_CLAIM_STALE_MS,
|
|
795
|
+
* 异常残留不钉死)剔除。勿用 changes.last_active 判活跃(非周期心跳,D-002)。
|
|
796
|
+
*
|
|
797
|
+
* @param {string} specBase .sillyspec 根目录
|
|
798
|
+
* @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number }} [opts]
|
|
799
|
+
* - excludeChange:排除自身 change 的 quick 会话——sessionId == changeName(quick 会话 id 即
|
|
800
|
+
* change 名)或 guard.linkedChanges 显式关联该 change 的协作会话(自己人,非拦截面)
|
|
801
|
+
* - sessionsDir:quick-sessions 目录(缺省 <specBase>/.runtime/quick-sessions)
|
|
802
|
+
* - nowMs:当前时间戳(可注入,测试用)
|
|
803
|
+
* @returns {Map<string, string[]>} sessionId → guard.allowedFiles(无 guard/无声明 → 空数组;fail-open)
|
|
804
|
+
*/
|
|
805
|
+
export function collectActiveQuickGuardFiles(specBase, { excludeChange = null, sessionsDir = null, nowMs = Date.now() } = {}) {
|
|
806
|
+
const out = new Map()
|
|
807
|
+
try {
|
|
808
|
+
const dir = sessionsDir || join(specBase, '.runtime', 'quick-sessions')
|
|
809
|
+
for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
|
|
810
|
+
if (excludeChange && (sessionId === excludeChange
|
|
811
|
+
|| (guard && Array.isArray(guard.linkedChanges) && guard.linkedChanges.includes(excludeChange)))) continue
|
|
812
|
+
const allowed = (guard && Array.isArray(guard.allowedFiles))
|
|
813
|
+
? guard.allowedFiles.filter(f => typeof f === 'string' && f.length > 0)
|
|
814
|
+
: []
|
|
815
|
+
out.set(sessionId, allowed)
|
|
816
|
+
}
|
|
817
|
+
} catch { /* fail-open:采集失败等同无活跃 guard(不放大拦截面) */ }
|
|
818
|
+
return out
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 数某 qlId 在所有 QUICKLOG-*.md 里的条目头出现次数(--done 占用校验用)。
|
|
823
|
+
* ≥2 = 分配竞态残留已损坏记录(同 ID 双条目);容错空白形态与 scanExisting 的 reLoose 同口径。
|
|
824
|
+
* @returns {{count: number, files: string[]}} count=命中头总数;files=「文件名×次数」列表
|
|
825
|
+
*/
|
|
826
|
+
export function countQuicklogEntries(specBase, qlId) {
|
|
827
|
+
const out = { count: 0, files: [] }
|
|
828
|
+
if (!qlId || typeof qlId !== 'string') return out
|
|
829
|
+
const quicklogDir = join(specBase, 'quicklog')
|
|
830
|
+
if (!existsSync(quicklogDir)) return out
|
|
831
|
+
const re = new RegExp(`^##[ \\t]*${escapeRe(qlId)}[ \\t]*\\|`, 'gm')
|
|
832
|
+
for (const f of listQuicklogFiles(quicklogDir)) {
|
|
833
|
+
let content = ''
|
|
834
|
+
try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
|
|
835
|
+
const n = (content.match(re) || []).length
|
|
836
|
+
if (n > 0) { out.count += n; out.files.push(`${f}×${n}`) }
|
|
837
|
+
}
|
|
838
|
+
return out
|
|
839
|
+
}
|
|
840
|
+
|
|
717
841
|
/**
|
|
718
842
|
* 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
|
|
843
|
+
* 分配查重(坑 ql-id-double-occupancy):盘上当天全 ID(含容错空白形态)+ 他者活跃会话
|
|
844
|
+
* guard 预留(sessionsDir)的序号一并让位,maxSeq 取三者最大后再 +1。
|
|
719
845
|
* @returns {Promise<{qlId: string}>}
|
|
720
846
|
*/
|
|
721
|
-
export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
|
|
847
|
+
export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [], sessionsDir = null } = {}) {
|
|
722
848
|
const quicklogDir = join(specBase, 'quicklog')
|
|
723
849
|
mkdirSync(quicklogDir, { recursive: true })
|
|
724
850
|
// git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
|
|
@@ -737,19 +863,34 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
737
863
|
// 自证),仅放弃「分配即推送」的顺序一致性(平台为 best-effort 面板,乱序窗口极小)。
|
|
738
864
|
let pushPayload = null
|
|
739
865
|
const { qlId } = await withFileLock(lockPath, async () => {
|
|
740
|
-
const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
|
|
866
|
+
const { maxSeq: diskMaxSeq, usedSuffix, usedIds } = scanExisting(quicklogDir, today)
|
|
867
|
+
// 他者 guard 预留让位(坑 ql-id-double-occupancy):同日预留序号并入 maxSeq;整 ID 进
|
|
868
|
+
// 末检集。QUICKLOG 条目丢失但 guard 仍在的窗口内,盘上扫描看不见该预留——正是
|
|
869
|
+
// 2026-09-13 007-1351 双占用的形态。
|
|
870
|
+
const claims = collectGuardReservedQuicklogIds(specBase, sessionsDir)
|
|
871
|
+
let maxSeq = diskMaxSeq
|
|
872
|
+
const claimedIds = new Set()
|
|
873
|
+
for (const [claimedId] of claims) {
|
|
874
|
+
claimedIds.add(claimedId.toLowerCase())
|
|
875
|
+
const cm = claimedId.match(/^ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})$/)
|
|
876
|
+
if (cm && cm[1] === today) maxSeq = Math.max(maxSeq, parseInt(cm[2], 10))
|
|
877
|
+
}
|
|
741
878
|
const nextSeq = maxSeq + 1
|
|
742
|
-
// XXXX 4 位 hex 随机后缀(消歧;NNN
|
|
879
|
+
// XXXX 4 位 hex 随机后缀(消歧;NNN 已让位到所有已知占用之后,此处 belt-and-suspenders:
|
|
880
|
+
// usedSuffix 全日后缀避让 + 全 ID 末检,理论撞号需单日后缀空间耗尽,200 次兜底抛错)
|
|
743
881
|
let suffix
|
|
882
|
+
let qlId
|
|
744
883
|
let guard = 0
|
|
745
884
|
do {
|
|
746
885
|
suffix = randomBytes(2).toString('hex')
|
|
886
|
+
qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
|
|
747
887
|
guard++
|
|
748
|
-
} while (usedSuffix.has(suffix) && guard <
|
|
888
|
+
} while ((usedSuffix.has(suffix) || usedIds.has(qlId) || claimedIds.has(qlId)) && guard < 200)
|
|
889
|
+
if (usedIds.has(qlId) || claimedIds.has(qlId)) {
|
|
890
|
+
throw new Error('allocateQuicklogEntry: ql-ID 查重 200 次仍未命中空闲号(当日条目异常膨胀,请检查 quicklog/)')
|
|
891
|
+
}
|
|
749
892
|
|
|
750
893
|
await rotateIfNeeded(userFile, user)
|
|
751
|
-
|
|
752
|
-
const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
|
|
753
894
|
const entry = [
|
|
754
895
|
'',
|
|
755
896
|
`## ${qlId} | ${nowDatetime()} | ${desc}`,
|
|
@@ -940,10 +1081,15 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
|
|
|
940
1081
|
let quicklogFile = null
|
|
941
1082
|
let flipped = false
|
|
942
1083
|
await withFileLock(lockPath, async () => {
|
|
943
|
-
//
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1084
|
+
// 坑 quick-cancel-blind-after-quicklog-rotation(2026-09-16 实证):主文件存在时旧逻辑
|
|
1085
|
+
// 只扫主文件——轮转发生后历史条目在归档文件(QUICKLOG-<user>-<日期>.md)里永久不可达,
|
|
1086
|
+
// 「提示清理 → 清理必失败」死循环。恒扫全部 QUICKLOG 文件(主文件优先,命中即停),
|
|
1087
|
+
// 用户文件缺失的 CI user 漂移场景语义不变。
|
|
1088
|
+
const allFiles = listQuicklogFiles(quicklogDir)
|
|
1089
|
+
const mainFile = `QUICKLOG-${user}.md`
|
|
1090
|
+
const files = existsSync(join(quicklogDir, mainFile))
|
|
1091
|
+
? [mainFile, ...allFiles.filter(f => f !== mainFile)]
|
|
1092
|
+
: allFiles
|
|
947
1093
|
for (const f of files) {
|
|
948
1094
|
const p = join(quicklogDir, f)
|
|
949
1095
|
const content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
package/src/review-dispatch.js
CHANGED
|
@@ -197,7 +197,8 @@ export function persistStageReview({ runtimeRoot, stage, changeName, reviewRunId
|
|
|
197
197
|
const h = computeDocHash(mainDocPath)
|
|
198
198
|
if (h) review.docHash = h
|
|
199
199
|
}
|
|
200
|
-
|
|
200
|
+
// ql-20260915-001 修复③:透传 stage——schema 错误文案给本 stage 期望 reviewType
|
|
201
|
+
const schema = validateStageReviewSchema(review, stage)
|
|
201
202
|
if (!schema.ok) return { ok: false, errors: schema.errors }
|
|
202
203
|
review.reviewedFiles = Array.isArray(review.reviewedFiles) && review.reviewedFiles.length > 0
|
|
203
204
|
? review.reviewedFiles
|