sillyspec 3.23.9 → 3.24.1

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/src/quicklog.js CHANGED
@@ -1,306 +1,333 @@
1
- /**
2
- * quicklog.js — QUICKLOG 记录的 CLI 接管层
3
- *
4
- * 历史问题:QUICKLOG 条目(ql-ID 分配 + 追加)原本由 Agent(LLM)手写
5
- * (见 src/stages/quick.js 旧 prompt)。两个后果:
6
- * 1. 漏写静默通过:CLI 只查目录级存在性,agent 漏写仍报「3/3 SAFE」。
7
- * 2. 并发丢更新:多 quick 会话并发写同一 per-user QUICKLOG 文件,读-改-写非原子,
8
- * 已有实证(QUICKLOG-qinyi.md 同一 ql-20260604-001-7a4c 出现两次)。
9
- *
10
- * 本模块把分配与写入下沉到 CLI 进程内,O_EXCL lockfile 串行化,彻底消除上述问题。
11
- * 无新 npm 依赖(仅 fs/path/crypto)——匹配项目零 FS 工具依赖的风格。
12
- */
13
- import { join, dirname, basename } from 'path'
14
- import {
15
- openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
16
- readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
17
- } from 'fs'
18
- import { randomBytes } from 'crypto'
19
-
20
- const sleep = (ms) => new Promise(r => setTimeout(r, ms))
21
-
22
- /**
23
- * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建;已存在则按 mtime 判 stale 偷锁,
24
- * 否则退避重试至超时。CLI 短进程、低频写入,stale 阈值 30s 足够。
25
- * @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
26
- * @param {() => (any|Promise<any>)} fn 临界区
27
- * @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
28
- */
29
- export async function withFileLock(lockPath, fn, opts = {}) {
30
- const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
31
- mkdirSync(dirname(lockPath), { recursive: true })
32
- const start = Date.now()
33
- let fd = null
34
- // 抢锁循环
35
- while (true) {
36
- try {
37
- fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
38
- break
39
- } catch (e) {
40
- if (e.code !== 'EEXIST') throw e
41
- // 锁已存在:判 stale(持有进程崩溃残留)
42
- try {
43
- const mtime = statSync(lockPath).mtimeMs
44
- if (Date.now() - mtime > staleMs) {
45
- try { unlinkSync(lockPath) } catch {} // 偷 stale 锁后重试
46
- continue
47
- }
48
- } catch {}
49
- if (Date.now() - start > timeoutMs) {
50
- throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
51
- }
52
- await sleep(retryMs)
53
- }
54
- }
55
- try {
56
- return await fn()
57
- } finally {
58
- try { closeSync(fd) } catch {}
59
- try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
60
- }
61
- }
62
-
63
- // ── 内部工具 ──
64
-
65
- function todayStamp(d = new Date()) {
66
- return d.getFullYear()
67
- + String(d.getMonth() + 1).padStart(2, '0')
68
- + String(d.getDate()).padStart(2, '0')
69
- }
70
-
71
- function nowDatetime(d = new Date()) {
72
- return todayStamp(d).slice(0, 4) + '-' + todayStamp(d).slice(4, 6) + '-' + todayStamp(d).slice(6, 8)
73
- + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0')
74
- }
75
-
76
- // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
77
- function sanitizeDesc(description) {
78
- const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
79
- if (!s) return '(quick 任务)'
80
- return s.length > 120 ? s.slice(0, 120) + '…' : s
81
- }
82
-
83
- function sanitizeResult(resultText) {
84
- const s = String(resultText || '').replace(/\n\s*\n/g, ' ').replace(/\r?\n/g, ' ').trim()
85
- return s
86
- }
87
-
88
- function escapeRe(s) {
89
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
90
- }
91
-
92
- function listQuicklogFiles(quicklogDir) {
93
- try {
94
- return readdirSync(quicklogDir).filter(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
95
- } catch {
96
- return []
97
- }
98
- }
99
-
100
- // 原子写:同目录临时文件 + rename 覆盖。reader 不持锁(agent cat / dashboard 轮询),
101
- // 若直接 writeFileSync 覆盖整文件,reader 会在 truncate→write 间隙读到空/半截(reader-writer 竞态)。
102
- // rename 同文件系统内原子(POSIX rename / Win MoveFileEx REPLACE_EXISTING),
103
- // reader 永远看到完整旧版或完整新版,绝不读半截。
104
- // Windows 差异:rename 覆盖「正被另一进程读取的目标」会抛 EPERM/EBUSY(POSIX 无此问题)。
105
- // reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
106
- async function writeAtomic(filePath, content, opts = {}) {
107
- const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
108
- const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
109
- writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
110
- for (let attempt = 0; ; attempt++) {
111
- try {
112
- renameSync(tmp, filePath)
113
- return
114
- } catch (e) {
115
- const retryable = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'
116
- if (!retryable || attempt >= retries) {
117
- try { unlinkSync(tmp) } catch {}
118
- throw e
119
- }
120
- await sleep(Math.min(baseDelayMs * (attempt + 1), maxDelayMs)) // 线性退避,让出 loop
121
- }
122
- }
123
- }
124
-
125
- // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
126
- function scanExisting(quicklogDir, today) {
127
- let maxSeq = 0
128
- const usedSuffix = new Set()
129
- const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
130
- for (const f of listQuicklogFiles(quicklogDir)) {
131
- let content = ''
132
- try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
133
- let m
134
- re.lastIndex = 0
135
- while ((m = re.exec(content)) !== null) {
136
- if (m[1] === today) {
137
- maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
138
- usedSuffix.add(m[3].toLowerCase())
139
- }
140
- }
141
- }
142
- return { maxSeq, usedSuffix }
143
- }
144
-
145
- // >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
146
- async function rotateIfNeeded(userFile, gitUser) {
147
- if (!existsSync(userFile)) return
148
- const content = readFileSync(userFile, 'utf8')
149
- const lines = content.split('\n')
150
- if (lines.length <= 500) return
151
- let lastDate = null
152
- for (let i = lines.length - 1; i >= 0; i--) {
153
- const m = lines[i].match(/^## ql-(\d{8})-/)
154
- if (m) { lastDate = m[1]; break }
155
- }
156
- const dateStr = lastDate
157
- ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
158
- : nowDatetime().slice(0, 10)
159
- const archiveFile = join(dirname(userFile), `QUICKLOG-${gitUser}-${dateStr}.md`)
160
- // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
161
- if (existsSync(archiveFile)) {
162
- const existing = readFileSync(archiveFile, 'utf8')
163
- await writeAtomic(archiveFile, existing + content)
164
- await writeAtomic(userFile, '')
165
- } else {
166
- renameSync(userFile, archiveFile)
167
- }
168
- }
169
-
170
- function tasksPath(specBase, change) {
171
- return join(specBase, 'changes', change, 'tasks.md')
172
- }
173
-
174
- // 追加未勾选 task;幂等(已含同 qlId 则跳过)
175
- function appendTaskCheckbox(specBase, change, qlId, desc) {
176
- const dir = join(specBase, 'changes', change)
177
- mkdirSync(dir, { recursive: true })
178
- const p = tasksPath(specBase, change)
179
- let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
180
- if (content.includes(qlId)) return
181
- const prefix = content && !content.endsWith('\n') ? '\n' : ''
182
- appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
183
- }
184
-
185
- // 勾选该 qlId 对应 task:- [ ] → - [x]
186
- async function checkTaskCheckbox(specBase, change, qlId) {
187
- const p = tasksPath(specBase, change)
188
- if (!existsSync(p)) return
189
- let content = readFileSync(p, 'utf8')
190
- const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
191
- content = content.replace(re, '- [x] $1')
192
- await writeAtomic(p, content)
193
- }
194
-
195
- // 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
196
- function flipEntryInContent(content, qlId, result) {
197
- const lines = content.split('\n')
198
- const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
199
- if (startIdx === -1) return null
200
- let endIdx = lines.length
201
- for (let i = startIdx + 1; i < lines.length; i++) {
202
- if (lines[i].startsWith('## ')) { endIdx = i; break }
203
- }
204
- let hasResult = false
205
- let flipped = false
206
- for (let i = startIdx + 1; i < endIdx; i++) {
207
- if (lines[i] === '状态:进行中') { lines[i] = '状态:已完成'; flipped = true }
208
- if (lines[i].startsWith('结果:')) hasResult = true
209
- }
210
- if (!flipped && !lines.slice(startIdx, endIdx).includes('状态:已完成')) {
211
- // 无状态行(异常),插一条已完成
212
- lines.splice(startIdx + 1, 0, '状态:已完成')
213
- endIdx += 1
214
- }
215
- if (result && !hasResult) {
216
- lines.splice(endIdx, 0, `结果:${result}`)
217
- }
218
- return lines.join('\n')
219
- }
220
-
221
- // ── 对外 API ──
222
-
223
- /**
224
- * 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
225
- * @returns {Promise<{qlId: string}>}
226
- */
227
- export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
228
- const quicklogDir = join(specBase, 'quicklog')
229
- mkdirSync(quicklogDir, { recursive: true })
230
- const user = gitUser || 'unknown'
231
- const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
232
- const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
233
- const desc = sanitizeDesc(description)
234
- const linked = Array.isArray(linkedChanges) ? linkedChanges : []
235
- const files = Array.isArray(allowedFiles) ? allowedFiles : []
236
- const today = todayStamp()
237
-
238
- return await withFileLock(lockPath, async () => {
239
- const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
240
- const nextSeq = maxSeq + 1
241
- // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
242
- let suffix
243
- let guard = 0
244
- do {
245
- suffix = randomBytes(2).toString('hex')
246
- guard++
247
- } while (usedSuffix.has(suffix) && guard < 100)
248
-
249
- await rotateIfNeeded(userFile, user)
250
-
251
- const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
252
- const entry = [
253
- '',
254
- `## ${qlId} | ${nowDatetime()} | ${desc}`,
255
- '状态:进行中',
256
- `关联变更:${linked.length > 0 ? linked.join(', ') : '(无)'}`,
257
- `文件:${files.length > 0 ? files.join(', ') : '(见实际改动)'}`,
258
- '',
259
- ].join('\n')
260
- appendFileSync(userFile, entry)
261
-
262
- for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
263
-
264
- return { qlId }
265
- })
266
- }
267
-
268
- /**
269
- * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
270
- */
271
- export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [] } = {}) {
272
- const quicklogDir = join(specBase, 'quicklog')
273
- const lockPath = join(quicklogDir, `.QUICKLOG-${(gitUser || 'unknown')}.md.lock`)
274
- const result = sanitizeResult(resultText)
275
- const linked = Array.isArray(linkedChanges) ? linkedChanges : []
276
-
277
- await withFileLock(lockPath, async () => {
278
- // 条目可能在主文件或轮转归档中
279
- for (const f of listQuicklogFiles(quicklogDir)) {
280
- const filePath = join(quicklogDir, f)
281
- let content = ''
282
- try { content = readFileSync(filePath, 'utf8') } catch { continue }
283
- const updated = flipEntryInContent(content, qlId, result)
284
- if (updated !== null) {
285
- await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
286
- break
287
- }
288
- }
289
- for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
290
- })
291
- }
292
-
293
- /**
294
- * 查某 qlId 条目是否存在(只读,跨所有 QUICKLOG-*.md)。
295
- */
296
- export function findQuicklogEntry(specBase, gitUser, qlId) {
297
- const quicklogDir = join(specBase, 'quicklog')
298
- if (!existsSync(quicklogDir)) return false
299
- const header = `## ${qlId} |`
300
- for (const f of listQuicklogFiles(quicklogDir)) {
301
- let content = ''
302
- try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
303
- if (content.includes(header)) return true
304
- }
305
- return false
306
- }
1
+ /**
2
+ * quicklog.js — QUICKLOG 记录的 CLI 接管层
3
+ *
4
+ * 历史问题:QUICKLOG 条目(ql-ID 分配 + 追加)原本由 Agent(LLM)手写
5
+ * (见 src/stages/quick.js 旧 prompt)。两个后果:
6
+ * 1. 漏写静默通过:CLI 只查目录级存在性,agent 漏写仍报「3/3 SAFE」。
7
+ * 2. 并发丢更新:多 quick 会话并发写同一 per-user QUICKLOG 文件,读-改-写非原子,
8
+ * 已有实证(QUICKLOG-qinyi.md 同一 ql-20260604-001-7a4c 出现两次)。
9
+ *
10
+ * 本模块把分配与写入下沉到 CLI 进程内,O_EXCL lockfile 串行化,彻底消除上述问题。
11
+ * 无新 npm 依赖(仅 fs/path/crypto)——匹配项目零 FS 工具依赖的风格。
12
+ */
13
+ import { join, dirname, basename } from 'path'
14
+ import {
15
+ openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
16
+ readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
17
+ } from 'fs'
18
+ import { randomBytes } from 'crypto'
19
+
20
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
21
+
22
+ /**
23
+ * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建;已存在则按 mtime 判 stale 偷锁,
24
+ * 否则退避重试至超时。CLI 短进程、低频写入,stale 阈值 30s 足够。
25
+ * @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
26
+ * @param {() => (any|Promise<any>)} fn 临界区
27
+ * @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
28
+ */
29
+ export async function withFileLock(lockPath, fn, opts = {}) {
30
+ const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
31
+ mkdirSync(dirname(lockPath), { recursive: true })
32
+ const start = Date.now()
33
+ let fd = null
34
+ // 抢锁循环
35
+ while (true) {
36
+ try {
37
+ fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
38
+ break
39
+ } catch (e) {
40
+ if (e.code !== 'EEXIST') throw e
41
+ // 锁已存在:判 stale(持有进程崩溃残留)
42
+ try {
43
+ const mtime = statSync(lockPath).mtimeMs
44
+ if (Date.now() - mtime > staleMs) {
45
+ try { unlinkSync(lockPath) } catch {} // 偷 stale 锁后重试
46
+ continue
47
+ }
48
+ } catch {}
49
+ if (Date.now() - start > timeoutMs) {
50
+ throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
51
+ }
52
+ await sleep(retryMs)
53
+ }
54
+ }
55
+ try {
56
+ return await fn()
57
+ } finally {
58
+ try { closeSync(fd) } catch {}
59
+ try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
60
+ }
61
+ }
62
+
63
+ // ── 内部工具 ──
64
+
65
+ function todayStamp(d = new Date()) {
66
+ return d.getFullYear()
67
+ + String(d.getMonth() + 1).padStart(2, '0')
68
+ + String(d.getDate()).padStart(2, '0')
69
+ }
70
+
71
+ function nowDatetime(d = new Date()) {
72
+ return todayStamp(d).slice(0, 4) + '-' + todayStamp(d).slice(4, 6) + '-' + todayStamp(d).slice(6, 8)
73
+ + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0')
74
+ }
75
+
76
+ // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
77
+ function sanitizeDesc(description) {
78
+ const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
79
+ if (!s) return '(quick 任务)'
80
+ return s.length > 120 ? s.slice(0, 120) + '…' : s
81
+ }
82
+
83
+ function sanitizeResult(resultText) {
84
+ // 保留换行:结果块支持字段化结构(需求:/根因:/方案:/结果:...)。多行时 flipEntryInContent
85
+ // 写成字段块(追加在「状态:」行下方),不是单条「结果:<长行>」。仅去首尾空白。
86
+ return String(resultText || '').trim()
87
+ }
88
+
89
+ // 结果块必填字段(quick step3 --done --output 的结构化结果模板,见 src/stages/quick.js)。
90
+ const RESULT_REQUIRED_LABELS = ['需求:', '根因:', '方案:', '结果:']
91
+
92
+ /**
93
+ * quick step3 结果摘要结构校验(确定性:只查「必填字段是否都在」,不判内容质量——
94
+ * 内容好坏属语义软判定,不归 CLI)。对齐 docs/sillyspec/quick-done-quicklog-duplicate-status-line
95
+ * 的第二诉求:--output 不自动展开丰富格式时,用约束模板 + 结构校验保证 QUICKLOG 记录完整。
96
+ * @param {string} text --done --output 原文
97
+ * @returns {{ ok: boolean, missing: string[] }} ok=true 通过;missing=缺失的字段名列表
98
+ */
99
+ export function validateQuickResult(text) {
100
+ const missing = RESULT_REQUIRED_LABELS.filter(label => !String(text || '').includes(label))
101
+ return { ok: missing.length === 0, missing }
102
+ }
103
+
104
+ function escapeRe(s) {
105
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
106
+ }
107
+
108
+ function listQuicklogFiles(quicklogDir) {
109
+ try {
110
+ return readdirSync(quicklogDir).filter(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
111
+ } catch {
112
+ return []
113
+ }
114
+ }
115
+
116
+ // 原子写:同目录临时文件 + rename 覆盖。reader 不持锁(agent cat / dashboard 轮询),
117
+ // 若直接 writeFileSync 覆盖整文件,reader 会在 truncate→write 间隙读到空/半截(reader-writer 竞态)。
118
+ // rename 同文件系统内原子(POSIX rename / Win MoveFileEx REPLACE_EXISTING),
119
+ // reader 永远看到完整旧版或完整新版,绝不读半截。
120
+ // Windows 差异:rename 覆盖「正被另一进程读取的目标」会抛 EPERM/EBUSY(POSIX 无此问题)。
121
+ // reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
122
+ async function writeAtomic(filePath, content, opts = {}) {
123
+ const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
124
+ const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
125
+ writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
126
+ for (let attempt = 0; ; attempt++) {
127
+ try {
128
+ renameSync(tmp, filePath)
129
+ return
130
+ } catch (e) {
131
+ const retryable = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'
132
+ if (!retryable || attempt >= retries) {
133
+ try { unlinkSync(tmp) } catch {}
134
+ throw e
135
+ }
136
+ await sleep(Math.min(baseDelayMs * (attempt + 1), maxDelayMs)) // 线性退避,让出 loop
137
+ }
138
+ }
139
+ }
140
+
141
+ // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
142
+ function scanExisting(quicklogDir, today) {
143
+ let maxSeq = 0
144
+ const usedSuffix = new Set()
145
+ const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
146
+ for (const f of listQuicklogFiles(quicklogDir)) {
147
+ let content = ''
148
+ try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
149
+ let m
150
+ re.lastIndex = 0
151
+ while ((m = re.exec(content)) !== null) {
152
+ if (m[1] === today) {
153
+ maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
154
+ usedSuffix.add(m[3].toLowerCase())
155
+ }
156
+ }
157
+ }
158
+ return { maxSeq, usedSuffix }
159
+ }
160
+
161
+ // >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
162
+ async function rotateIfNeeded(userFile, gitUser) {
163
+ if (!existsSync(userFile)) return
164
+ const content = readFileSync(userFile, 'utf8')
165
+ const lines = content.split('\n')
166
+ if (lines.length <= 500) return
167
+ let lastDate = null
168
+ for (let i = lines.length - 1; i >= 0; i--) {
169
+ const m = lines[i].match(/^## ql-(\d{8})-/)
170
+ if (m) { lastDate = m[1]; break }
171
+ }
172
+ const dateStr = lastDate
173
+ ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
174
+ : nowDatetime().slice(0, 10)
175
+ const archiveFile = join(dirname(userFile), `QUICKLOG-${gitUser}-${dateStr}.md`)
176
+ // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
177
+ if (existsSync(archiveFile)) {
178
+ const existing = readFileSync(archiveFile, 'utf8')
179
+ await writeAtomic(archiveFile, existing + content)
180
+ await writeAtomic(userFile, '')
181
+ } else {
182
+ renameSync(userFile, archiveFile)
183
+ }
184
+ }
185
+
186
+ function tasksPath(specBase, change) {
187
+ return join(specBase, 'changes', change, 'tasks.md')
188
+ }
189
+
190
+ // 追加未勾选 task;幂等(已含同 qlId 则跳过)
191
+ function appendTaskCheckbox(specBase, change, qlId, desc) {
192
+ const dir = join(specBase, 'changes', change)
193
+ mkdirSync(dir, { recursive: true })
194
+ const p = tasksPath(specBase, change)
195
+ let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
196
+ if (content.includes(qlId)) return
197
+ const prefix = content && !content.endsWith('\n') ? '\n' : ''
198
+ appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
199
+ }
200
+
201
+ // 勾选该 qlId 对应 task:- [ ] → - [x]
202
+ async function checkTaskCheckbox(specBase, change, qlId) {
203
+ const p = tasksPath(specBase, change)
204
+ if (!existsSync(p)) return
205
+ let content = readFileSync(p, 'utf8')
206
+ const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
207
+ content = content.replace(re, '- [x] $1')
208
+ await writeAtomic(p, content)
209
+ }
210
+
211
+ // 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
212
+ // CRLF 修复(缺陷 quick-done-quicklog-duplicate-status-line):QUICKLOG 在 Windows 下
213
+ // 可能是 CRLF,split('\n') 后每行带 \r。原代码 `lines[i] === '状态:进行中'` 精确匹配
214
+ // 恒失败 → 走 splice「兜底插入」→ 条目内同时出现「状态:已完成」+「状态:进行中」。
215
+ // 状态行匹配与「已完成」判断统一用行首前缀匹配,容忍行尾 \r;写入保持 CRLF 不扩大改动。
216
+ function flipEntryInContent(content, qlId, result) {
217
+ const lines = content.split('\n')
218
+ const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
219
+ if (startIdx === -1) return null
220
+ let endIdx = lines.length
221
+ for (let i = startIdx + 1; i < lines.length; i++) {
222
+ if (lines[i].startsWith('## ')) { endIdx = i; break }
223
+ }
224
+ let hasResult = false
225
+ let flipped = false
226
+ for (let i = startIdx + 1; i < endIdx; i++) {
227
+ if (/^状态:进行中\r?$/.test(lines[i])) { lines[i] = '状态:已完成'; flipped = true }
228
+ if (lines[i].startsWith('结果:')) hasResult = true
229
+ }
230
+ if (!flipped && !lines.slice(startIdx, endIdx).some(l => /^状态:已完成\r?$/.test(l))) {
231
+ // 无状态行(异常),插一条已完成
232
+ lines.splice(startIdx + 1, 0, '状态:已完成')
233
+ endIdx += 1
234
+ }
235
+ if (result && !hasResult) {
236
+ // 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
237
+ // 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
238
+ if (/\r?\n/.test(result)) {
239
+ const resultLines = result.split(/\r?\n/).filter(l => l.trim() !== '')
240
+ lines.splice(endIdx, 0, ...resultLines)
241
+ } else {
242
+ lines.splice(endIdx, 0, `结果:${result}`)
243
+ }
244
+ }
245
+ return lines.join('\n')
246
+ }
247
+
248
+ // ── 对外 API ──
249
+
250
+ /**
251
+ * 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
252
+ * @returns {Promise<{qlId: string}>}
253
+ */
254
+ export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
255
+ const quicklogDir = join(specBase, 'quicklog')
256
+ mkdirSync(quicklogDir, { recursive: true })
257
+ const user = gitUser || 'unknown'
258
+ const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
259
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
260
+ const desc = sanitizeDesc(description)
261
+ const linked = Array.isArray(linkedChanges) ? linkedChanges : []
262
+ const files = Array.isArray(allowedFiles) ? allowedFiles : []
263
+ const today = todayStamp()
264
+
265
+ return await withFileLock(lockPath, async () => {
266
+ const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
267
+ const nextSeq = maxSeq + 1
268
+ // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
269
+ let suffix
270
+ let guard = 0
271
+ do {
272
+ suffix = randomBytes(2).toString('hex')
273
+ guard++
274
+ } while (usedSuffix.has(suffix) && guard < 100)
275
+
276
+ await rotateIfNeeded(userFile, user)
277
+
278
+ const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
279
+ const entry = [
280
+ '',
281
+ `## ${qlId} | ${nowDatetime()} | ${desc}`,
282
+ '状态:进行中',
283
+ `关联变更:${linked.length > 0 ? linked.join(', ') : '(无)'}`,
284
+ `文件:${files.length > 0 ? files.join(', ') : '(见实际改动)'}`,
285
+ '',
286
+ ].join('\n')
287
+ appendFileSync(userFile, entry)
288
+
289
+ for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
290
+
291
+ return { qlId }
292
+ })
293
+ }
294
+
295
+ /**
296
+ * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
297
+ */
298
+ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [] } = {}) {
299
+ const quicklogDir = join(specBase, 'quicklog')
300
+ const lockPath = join(quicklogDir, `.QUICKLOG-${(gitUser || 'unknown')}.md.lock`)
301
+ const result = sanitizeResult(resultText)
302
+ const linked = Array.isArray(linkedChanges) ? linkedChanges : []
303
+
304
+ await withFileLock(lockPath, async () => {
305
+ // 条目可能在主文件或轮转归档中
306
+ for (const f of listQuicklogFiles(quicklogDir)) {
307
+ const filePath = join(quicklogDir, f)
308
+ let content = ''
309
+ try { content = readFileSync(filePath, 'utf8') } catch { continue }
310
+ const updated = flipEntryInContent(content, qlId, result)
311
+ if (updated !== null) {
312
+ await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
313
+ break
314
+ }
315
+ }
316
+ for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
317
+ })
318
+ }
319
+
320
+ /**
321
+ * 查某 qlId 条目是否存在(只读,跨所有 QUICKLOG-*.md)。
322
+ */
323
+ export function findQuicklogEntry(specBase, gitUser, qlId) {
324
+ const quicklogDir = join(specBase, 'quicklog')
325
+ if (!existsSync(quicklogDir)) return false
326
+ const header = `## ${qlId} |`
327
+ for (const f of listQuicklogFiles(quicklogDir)) {
328
+ let content = ''
329
+ try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
330
+ if (content.includes(header)) return true
331
+ }
332
+ return false
333
+ }