sillyspec 3.23.9 → 3.24.0
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-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +1 -0
- package/package.json +1 -1
- package/src/quicklog.js +333 -306
- package/src/run.js +61 -5
- package/src/stage-contract.js +30 -0
- package/src/stages/brainstorm.js +569 -657
- package/src/stages/execute.js +3 -4
- package/src/stages/plan-postcheck.js +5 -1
- package/src/stages/plan.js +1 -1
- package/src/stages/propose.js +1 -1
- package/src/stages/quick.js +29 -18
- package/src/stages/verify.js +4 -82
- package/src/worktree.js +16 -0
- package/templates/prompts/verify-probes.md +53 -0
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
for (const c of linked)
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|