sillyspec 3.26.9 → 3.26.11

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.
Files changed (98) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -81
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -675
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +198 -9
  62. package/src/run/complete.js +225 -24
  63. package/src/run/concurrent-detect.js +22 -0
  64. package/src/run/gates.js +755 -755
  65. package/src/run/prompt.js +761 -721
  66. package/src/run/quick-audit.js +155 -146
  67. package/src/run/shared.js +1268 -1197
  68. package/src/run/stage.js +487 -479
  69. package/src/scan-postcheck.js +371 -371
  70. package/src/setup.js +416 -416
  71. package/src/sillyhub-mcp/client.js +529 -529
  72. package/src/spec-sync.js +323 -0
  73. package/src/stage-contract.js +897 -897
  74. package/src/stage-review.js +549 -549
  75. package/src/stages/archive.js +155 -154
  76. package/src/stages/brainstorm-auto.js +236 -236
  77. package/src/stages/brainstorm.js +596 -597
  78. package/src/stages/cmd-existence.js +145 -145
  79. package/src/stages/doctor.js +371 -371
  80. package/src/stages/execute.js +1003 -991
  81. package/src/stages/explore.js +34 -34
  82. package/src/stages/index.js +29 -29
  83. package/src/stages/knowledge.js +498 -498
  84. package/src/stages/plan-postcheck.js +1302 -1281
  85. package/src/stages/plan.js +625 -604
  86. package/src/stages/quick.js +151 -143
  87. package/src/stages/scan.js +606 -605
  88. package/src/stages/status.js +64 -64
  89. package/src/stages/verify.js +264 -261
  90. package/src/sync.js +1223 -1125
  91. package/src/task-review.js +1019 -1019
  92. package/src/verify-postcheck.js +1186 -1186
  93. package/src/workflow.js +600 -600
  94. package/src/worktree-apply.js +1213 -1136
  95. package/src/worktree-deps.js +353 -353
  96. package/src/worktree.js +1481 -1437
  97. package/templates/workflows/archive-impact.yaml +79 -79
  98. package/templates/workflows/scan-docs.yaml +132 -132
package/src/quicklog.js CHANGED
@@ -1,675 +1,693 @@
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 { getRule } from './stage-contract-spec.js'
15
- import {
16
- openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
17
- readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
18
- } from 'fs'
19
- import { randomBytes } from 'crypto'
20
-
21
- const sleep = (ms) => new Promise(r => setTimeout(r, ms))
22
-
23
- /**
24
- * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建;已存在则按 mtime 判 stale 偷锁,
25
- * 否则退避重试至超时。CLI 短进程、低频写入,stale 阈值 30s 足够。
26
- * @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
27
- * @param {() => (any|Promise<any>)} fn 临界区
28
- * @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
29
- */
30
- export async function withFileLock(lockPath, fn, opts = {}) {
31
- const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
32
- mkdirSync(dirname(lockPath), { recursive: true })
33
- const start = Date.now()
34
- let fd = null
35
- // 抢锁循环
36
- while (true) {
37
- try {
38
- fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
39
- break
40
- } catch (e) {
41
- if (e.code !== 'EEXIST') throw e
42
- // 锁已存在:判 stale(持有进程崩溃残留)
43
- try {
44
- const mtime = statSync(lockPath).mtimeMs
45
- if (Date.now() - mtime > staleMs) {
46
- try { unlinkSync(lockPath) } catch {} // 偷 stale 锁后重试
47
- continue
48
- }
49
- } catch {}
50
- if (Date.now() - start > timeoutMs) {
51
- throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
52
- }
53
- await sleep(retryMs)
54
- }
55
- }
56
- try {
57
- return await fn()
58
- } finally {
59
- try { closeSync(fd) } catch {}
60
- try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
61
- }
62
- }
63
-
64
- // ── 内部工具 ──
65
-
66
- function todayStamp(d = new Date()) {
67
- return d.getFullYear()
68
- + String(d.getMonth() + 1).padStart(2, '0')
69
- + String(d.getDate()).padStart(2, '0')
70
- }
71
-
72
- function nowDatetime(d = new Date()) {
73
- return todayStamp(d).slice(0, 4) + '-' + todayStamp(d).slice(4, 6) + '-' + todayStamp(d).slice(6, 8)
74
- + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0')
75
- }
76
-
77
- // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
78
- export function sanitizeDesc(description) {
79
- const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
80
- if (!s) return '(quick 任务)'
81
- return s.length > 120 ? s.slice(0, 120) + '…' : s
82
- }
83
-
84
-
85
- // ── 平台推送(best-effort,2026-08-16-change-center-quick-tab task-06 / D-003)──
86
-
87
- // 读 local.yaml platform 段(url+token)。quicklog 不 import sync.js(会拖 ProgressManager
88
- // 整链),自带同款轻量解析:只取 platform.url / platform.token 两键,与 parseSimpleYaml
89
- // 同风格(丢注释可接受——只读)。未连接返回 null(合法本地状态,静默跳过)。
90
- function _readPlatformConfig(specBase) {
91
- try {
92
- // specBase 即 <cwd>/.sillyspec(或平台 specRoot);local.yaml 就在 spec 根下
93
- const yamlPath = join(specBase, 'local.yaml')
94
- if (!existsSync(yamlPath)) return null
95
- const lines = readFileSync(yamlPath, 'utf8').split(/\r?\n/)
96
- let inPlatform = false
97
- const cfg = {}
98
- for (const line of lines) {
99
- if (!line.trim() || line.trim().startsWith('#')) continue
100
- if (!/^\s/.test(line)) inPlatform = line.startsWith('platform:')
101
- else if (inPlatform) {
102
- const m = line.match(/^\s+(url|token)\s*:\s*(.*)$/)
103
- if (m) {
104
- let v = m[2].trim()
105
- if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) v = v.slice(1, -1)
106
- if (v) cfg[m[1]] = v
107
- }
108
- }
109
- }
110
- if (!cfg.url || !cfg.token) return null
111
- return cfg
112
- } catch {
113
- return null
114
- }
115
- }
116
-
117
- const PUSH_TIMEOUT_MS = 5_000
118
-
119
- // 从文件全文提取指定 qlId 的条目块(## 头到下一个 ## 头/文件尾)。null=未命中。
120
- // 与平台 quicklog_parser.py 同款切分口径(剥 \r),保证 CLI 推送的 raw_block 与平台解析一致。
121
- function extractRawBlock(content, qlId) {
122
- if (!content) return null
123
- const lines = content.split('\n')
124
- const start = lines.findIndex(l => l.replace(/\r$/, '').startsWith(`## ${qlId} |`))
125
- if (start === -1) return null
126
- let end = lines.length
127
- for (let i = start + 1; i < lines.length; i++) {
128
- if (lines[i].replace(/\r$/, '').startsWith('## ')) { end = i; break }
129
- }
130
- return lines.slice(start, end).map(l => l.replace(/\r$/, '')).join('\n').trimEnd()
131
- }
132
-
133
- // 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
134
- // 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
135
- function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
136
- const payload = {
137
- ql_id,
138
- timestamp: null,
139
- title: fallback_title || '(quick 任务)',
140
- status,
141
- status_note: null,
142
- author_raw,
143
- linked_changes: Array.isArray(linked_changes) ? linked_changes : [],
144
- files: [],
145
- body_sections: {},
146
- raw_block: rawBlock,
147
- }
148
- if (!rawBlock) return payload
149
- const lines = rawBlock.split('\n')
150
- const header = lines[0] || ''
151
- const parts = header.split('|').map(s => s.trim())
152
- if (parts.length >= 3) {
153
- payload.timestamp = parts[1] || null
154
- payload.title = parts.slice(2).join('|').trim() || payload.title
155
- }
156
- const labelRe = /^(状态|关联变更|文件|需求|根因|方案|结果)\s*[::]\s*(.*)$/
157
- const bulletRe = /^-\s+(.*)$/
158
- let inFiles = false
159
- let inLinked = false
160
- let lastLabel = null
161
- let lastStatus = null
162
- for (const line of lines.slice(1)) {
163
- const stripped = line.trim()
164
- if (!stripped) { inFiles = false; inLinked = false; continue }
165
- const m = stripped.match(labelRe)
166
- if (m) {
167
- const [, label, value] = m
168
- if (label === '状态') lastStatus = value
169
- else if (label === '关联变更') { payload.linked_changes = value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean); inLinked = true; inFiles = false }
170
- else if (label === '文件') { if (value) payload.files.push(...value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean).map(p => ({ path: p, note: null }))); inFiles = true; inLinked = false }
171
- else { payload.body_sections[label] = value; lastLabel = label }
172
- continue
173
- }
174
- if (bulletRe.test(stripped) && inFiles) {
175
- payload.files.push({ path: stripped.replace(/^-\s+/, ''), note: null })
176
- continue
177
- }
178
- if (bulletRe.test(stripped) && inLinked) {
179
- payload.linked_changes.push(...stripped.replace(/^-\s+/, '').split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean))
180
- continue
181
- }
182
- if (lastLabel) payload.body_sections[lastLabel] += '\n' + stripped
183
- }
184
- // 状态以落盘为准(flip 后是「已完成」或「已完成(括注)」)——多条取最后
185
- if (lastStatus) {
186
- payload.status = lastStatus.startsWith('已完成') ? 'completed'
187
- : lastStatus.startsWith('已暂存') ? 'partial_done'
188
- : lastStatus.startsWith('进行中') ? 'in_progress'
189
- : status
190
- const note = lastStatus.match(/((.+))$/)
191
- payload.status_note = note ? note[1] : null
192
- }
193
- // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表,防自由文本进反向区块)
194
- payload.linked_changes = payload.linked_changes.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c))
195
- return payload
196
- }
197
-
198
- // 单条推送:payload 字段对齐平台 QuicklogEntryPushRequest(snake_case)。测试经 allocate/complete 间接触发。
199
- // 任何失败(无配置/网络/非 2xx/超时)静默 warn 一行不抛(FR-02 best-effort,quick 主流程零阻断)。
200
- async function pushQuicklogEntryToPlatform(specBase, entry) {
201
- const cfg = _readPlatformConfig(specBase)
202
- if (!cfg) {
203
- if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn('[quicklog-push] 未配置 platform 段,跳过推送')
204
- return false
205
- }
206
- const controller = new AbortController()
207
- const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS)
208
- try {
209
- const res = await fetch(`${cfg.url.replace(/\/$/, '')}/api/quicklog-entries`, {
210
- method: 'POST',
211
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
212
- body: JSON.stringify(entry),
213
- signal: controller.signal,
214
- })
215
- if (!res.ok) {
216
- console.warn(`[quicklog-push] ${entry.ql_id} → ${res.status}(文件链路兜底,不影响本地记录)`)
217
- return false
218
- }
219
- return true
220
- } catch (err) {
221
- console.warn(`[quicklog-push] ${entry.ql_id} 推送失败: ${err && err.name === 'AbortError' ? '超时' : (err.message || err)}(文件链路兜底)`)
222
- return false
223
- } finally {
224
- clearTimeout(timer)
225
- }
226
- }
227
-
228
- // git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
229
- // 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
230
- function sanitizeQuicklogUser(user) {
231
- const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
232
- if (!s) return ''
233
- return s.length > 64 ? s.slice(0, 64) : s
234
- }
235
-
236
- // 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
237
- // 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
238
- // 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
239
- export function deriveTitleFromLinkedChange(specBase, change) {
240
- if (!change) return ''
241
- for (const f of ['proposal.md', 'design.md']) {
242
- let content
243
- try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
244
- const m = content.match(/^#\s+(.+?)\s*$/m)
245
- if (!m) continue
246
- const raw = m[1].trim()
247
- // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
248
- const dash = raw.match(/[—-]\s*(.+)$/)
249
- return dash ? dash[1].trim() : raw
250
- }
251
- return ''
252
- }
253
-
254
- // 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
255
- // (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
256
- export function extractTitleFromResult(result) {
257
- if (!result) return ''
258
- const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
259
- if (!m) return ''
260
- let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
261
- if (!t) return ''
262
- if (t.length > 80) t = t.slice(0, 80) + '…'
263
- return t
264
- }
265
-
266
- function sanitizeResult(resultText) {
267
- // 保留换行:结果块支持字段化结构(需求:/根因:/方案:/结果:...)。多行时 flipEntryInContent
268
- // 写成字段块(追加在「状态:」行下方),不是单条「结果:<长行>」。仅去首尾空白。
269
- return String(resultText || '').trim()
270
- }
271
-
272
- // ── quick step3 --file-notes 旁路通道 ──
273
- // flag 在 command.js 解析(--file-notes),completeQuicklogEntry 在 quick 收尾时消费。
274
- // 不经 completeStep → handleQuickStageCompletion → completeQuicklogEntry 三层透传(会扩 3 处签名,
275
- // 且 quick 收尾路径是多 agent 并发改热点);用 per-process setter,CLI 单进程生命周期内有效,
276
- // completeQuicklogEntry 读后即清。无 --file-notes 时为 '' → 回退 changedFiles 单行(向后兼容)。
277
- let _pendingFileNotes = ''
278
- export function setQuickFileNotes(raw) { _pendingFileNotes = raw == null ? '' : String(raw) }
279
- export function getQuickFileNotes() { return _pendingFileNotes }
280
-
281
- /**
282
- * 解析 --file-notes 原文 → [{path, note}]。
283
- * 格式:「path1::括注1 || path2::括注2」——`||` 分隔条目,`::` 分隔路径与括注(首个 `::` 为界,
284
- * 括注内含 `::` 不误切);路径反斜杠归一正斜杠(匹配 git 路径风格)。无 `::` 的段 → note=''。
285
- * 空 / 全空段 → [](调用方回退 changedFiles 单行)。导出供测试 + 未来直接调用。
286
- */
287
- export function parseFileNotes(raw) {
288
- if (!raw) return []
289
- // note 单行化 + 限长:bullet 是「数组元素内嵌 \n」落盘(flipEntryInContent),note 带换行会伪造
290
- // QUICKLOG 条目结构(\n## ql-… 可伪造后续条目头)。与 sanitizeDesc 同风格。
291
- const sanitizeNote = (n) => n.replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
292
- return String(raw)
293
- .split('||')
294
- .map((seg) => {
295
- const s = seg.trim()
296
- if (!s) return null
297
- const idx = s.indexOf('::')
298
- const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
299
- const note = idx === -1 ? '' : sanitizeNote(s.slice(idx + 2))
300
- return path ? { path, note } : null
301
- })
302
- .filter(Boolean)
303
- }
304
-
305
- // 结果块必填字段(quick step3 --done --output 的结构化结果模板,见 src/stages/quick.js)。
306
- // 4 个必填字段标签从 manifest 同源(stage-contract-spec.js quick.result-labels),prompt 事前契约与本校验单源。
307
- const RESULT_REQUIRED_LABELS = getRule('quick.result-labels').data.literals
308
-
309
- /**
310
- * quick step3 结果摘要结构校验(确定性:只查「必填字段是否都在」,不判内容质量——
311
- * 内容好坏属语义软判定,不归 CLI)。对齐 docs/sillyspec/quick-done-quicklog-duplicate-status-line
312
- * 的第二诉求:--output 不自动展开丰富格式时,用约束模板 + 结构校验保证 QUICKLOG 记录完整。
313
- * @param {string} text --done --output 原文
314
- * @returns {{ ok: boolean, missing: string[] }} ok=true 通过;missing=缺失的字段名列表
315
- */
316
- export function validateQuickResult(text) {
317
- const missing = RESULT_REQUIRED_LABELS.filter(label => !String(text || '').includes(label))
318
- return { ok: missing.length === 0, missing }
319
- }
320
-
321
- function escapeRe(s) {
322
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
323
- }
324
-
325
- function listQuicklogFiles(quicklogDir) {
326
- try {
327
- return readdirSync(quicklogDir).filter(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
328
- } catch {
329
- return []
330
- }
331
- }
332
-
333
- // 原子写:同目录临时文件 + rename 覆盖。reader 不持锁(agent cat / dashboard 轮询),
334
- // 若直接 writeFileSync 覆盖整文件,reader 会在 truncate→write 间隙读到空/半截(reader-writer 竞态)。
335
- // rename 同文件系统内原子(POSIX rename / Win MoveFileEx REPLACE_EXISTING),
336
- // reader 永远看到完整旧版或完整新版,绝不读半截。
337
- // Windows 差异:rename 覆盖「正被另一进程读取的目标」会抛 EPERM/EBUSY(POSIX 无此问题)。
338
- // reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
339
- async function writeAtomic(filePath, content, opts = {}) {
340
- const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
341
- const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
342
- writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
343
- for (let attempt = 0; ; attempt++) {
344
- try {
345
- renameSync(tmp, filePath)
346
- return
347
- } catch (e) {
348
- const retryable = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'
349
- if (!retryable || attempt >= retries) {
350
- try { unlinkSync(tmp) } catch {}
351
- throw e
352
- }
353
- await sleep(Math.min(baseDelayMs * (attempt + 1), maxDelayMs)) // 线性退避,让出 loop
354
- }
355
- }
356
- }
357
-
358
- // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
359
- function scanExisting(quicklogDir, today) {
360
- let maxSeq = 0
361
- const usedSuffix = new Set()
362
- const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
363
- // E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
364
- // 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
365
- // (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
366
- const todayDashed = today.length === 8 ? `${today.slice(0, 4)}-${today.slice(4, 6)}-${today.slice(6, 8)}` : today
367
- for (const f of listQuicklogFiles(quicklogDir)) {
368
- const dm = f.match(/-(\d{4}-\d{2}-\d{2})\.md$/)
369
- if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
370
- let content = ''
371
- try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
372
- let m
373
- re.lastIndex = 0
374
- while ((m = re.exec(content)) !== null) {
375
- if (m[1] === today) {
376
- maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
377
- usedSuffix.add(m[3].toLowerCase())
378
- }
379
- }
380
- }
381
- return { maxSeq, usedSuffix }
382
- }
383
-
384
- // >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
385
- async function rotateIfNeeded(userFile, gitUser) {
386
- if (!existsSync(userFile)) return
387
- const content = readFileSync(userFile, 'utf8')
388
- const lines = content.split('\n')
389
- if (lines.length <= 500) return
390
- let lastDate = null
391
- for (let i = lines.length - 1; i >= 0; i--) {
392
- const m = lines[i].match(/^## ql-(\d{8})-/)
393
- if (m) { lastDate = m[1]; break }
394
- }
395
- const dateStr = lastDate
396
- ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
397
- : nowDatetime().slice(0, 10)
398
- const archiveFile = join(dirname(userFile), `QUICKLOG-${sanitizeQuicklogUser(gitUser) || 'unknown'}-${dateStr}.md`)
399
- // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
400
- if (existsSync(archiveFile)) {
401
- const existing = readFileSync(archiveFile, 'utf8')
402
- await writeAtomic(archiveFile, existing + content)
403
- await writeAtomic(userFile, '')
404
- } else {
405
- renameSync(userFile, archiveFile)
406
- }
407
- // echo 轮转:归档文件是 git 跟踪的新文件,提交时带上(勿漏,否则旧 ql 条目只在本地)
408
- console.log(`🔄 QUICKLOG 已轮转(>500 行):${basename(userFile)} → ${basename(archiveFile)}(提交时带上归档文件,勿漏)`)
409
- }
410
-
411
- function tasksPath(specBase, change) {
412
- return join(specBase, 'changes', change, 'tasks.md')
413
- }
414
-
415
- // 追加未勾选 task;幂等(已含同 qlId 则跳过)。
416
- // 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
417
- // mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
418
- // QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
419
- function appendTaskCheckbox(specBase, change, qlId, desc) {
420
- const dir = join(specBase, 'changes', change)
421
- if (!existsSync(dir)) return
422
- const p = tasksPath(specBase, change)
423
- let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
424
- if (content.includes(qlId)) return
425
- const prefix = content && !content.endsWith('\n') ? '\n' : ''
426
- appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
427
- }
428
-
429
- // 勾选该 qlId 对应 task:- [ ] → - [x]
430
- async function checkTaskCheckbox(specBase, change, qlId) {
431
- const p = tasksPath(specBase, change)
432
- if (!existsSync(p)) return
433
- let content = readFileSync(p, 'utf8')
434
- const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
435
- content = content.replace(re, '- [x] $1')
436
- await writeAtomic(p, content)
437
- }
438
-
439
- // 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
440
- // CRLF 修复(缺陷 quick-done-quicklog-duplicate-status-line):QUICKLOG 在 Windows 下
441
- // 可能是 CRLF,split('\n') 后每行带 \r。原代码 `lines[i] === '状态:进行中'` 精确匹配
442
- // 恒失败 → 走 splice「兜底插入」→ 条目内同时出现「状态:已完成」+「状态:进行中」。
443
- // 状态行匹配与「已完成」判断统一用行首前缀匹配,容忍行尾 \r;写入保持 CRLF 不扩大改动。
444
- // 单行四字段(需求:/根因:/方案:/结果:)切段。先按「字段边界」严格扫描:真实标签 = 上一标签之后首次
445
- // 出现的、前导是串首/空白/句末标点(。;!?)的对应标签——字段正文引用标签字样(根因写「双层「结果:」
446
- // 前缀」或正则 split(/(?=需求:|根因:|方案:|结果:)/))因前导是「/|( 等非边界字符而跳过。严格失败
447
- // (如真实标签前导是「,」这类弱标点)退回宽松顺序扫描(上一标签之后首次出现)。缺标签返回 null → 落单行
448
- // 兜底(--done 契约校验仍会拦缺字段)。残余边界:正文引用标签且前导恰好是空白/句末标点时仍可能错位。
449
- function isFieldBoundary(body, idx) {
450
- if (idx <= 0) return true
451
- const prev = body[idx - 1]
452
- return /\s/.test(prev) || '。;!?'.includes(prev)
453
- }
454
- function findBoundaryLabel(body, label, from) {
455
- let idx = body.indexOf(label, from)
456
- while (idx !== -1 && !isFieldBoundary(body, idx)) {
457
- idx = body.indexOf(label, idx + label.length)
458
- }
459
- return idx
460
- }
461
- function scanFields(body, findLabel) {
462
- const labels = ['需求:', '根因:', '方案:', '结果:']
463
- const positions = []
464
- let cursor = 0
465
- for (const label of labels) {
466
- const idx = findLabel(body, label, cursor)
467
- if (idx === -1) return null
468
- positions.push(idx)
469
- cursor = idx + label.length
470
- }
471
- return positions.map((start, i) => {
472
- const end = i + 1 < positions.length ? positions[i + 1] : body.length
473
- return body.slice(start, end).trim()
474
- })
475
- }
476
- function splitSingleLineFields(body) {
477
- return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
478
- }
479
-
480
- function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = []) {
481
- const lines = content.split('\n')
482
- const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
483
- if (startIdx === -1) return null
484
- // 翻完成时若能从结果提取「需求:」摘要,刷新标题行(覆盖启动时的占位/弱标题)
485
- const newTitle = extractTitleFromResult(result)
486
- if (newTitle) {
487
- const headerCore = lines[startIdx].match(/^(## \S+ \| [^|]+ \| )/)
488
- if (headerCore) lines[startIdx] = headerCore[1] + newTitle
489
- }
490
- let endIdx = lines.length
491
- for (let i = startIdx + 1; i < lines.length; i++) {
492
- if (lines[i].startsWith('## ')) { endIdx = i; break }
493
- }
494
- let hasResult = false
495
- let flipped = false
496
- for (let i = startIdx + 1; i < endIdx; i++) {
497
- if (/^状态:进行中\r?$/.test(lines[i])) { lines[i] = '状态:已完成'; flipped = true }
498
- if (lines[i].startsWith('结果:')) hasResult = true
499
- // 文件行落盘:--file-notes 优先(多行 bullet 带括注),否则回填审计到的实际改动文件单行,
500
- // 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
501
- // 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
502
- if (lines[i].startsWith('文件:')) {
503
- if (fileNotes.length > 0) {
504
- const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
505
- lines[i] = `文件:\n${bullets.join('\n')}`
506
- } else if (changedFiles.length > 0) {
507
- lines[i] = `文件:${changedFiles.join(', ')}`
508
- }
509
- }
510
- }
511
- if (!flipped && !lines.slice(startIdx, endIdx).some(l => /^状态:已完成\r?$/.test(l))) {
512
- // 无状态行(异常),插一条已完成
513
- lines.splice(startIdx + 1, 0, '状态:已完成')
514
- endIdx += 1
515
- }
516
- if (result && !hasResult) {
517
- // quick step3 --output 常被压成单行「需求:…根因:…方案:…结果:…」(agent 未加换行),
518
- // 直接落盘会得到双层前缀「结果:需求:…结果:…」。把单行四字段归一为多行字段块,省 agent 手工
519
- // 拆行精修(prompt-control-debt quick-①)。多行 / 单句结果不受影响。
520
- let body = result
521
- if (!/\r?\n/.test(body) && /^需求:/.test(body)) {
522
- // 单行四字段归一为多行。不能 split(/(?=需求:|根因:|方案:|结果:)/)——正文引用字段标签字样
523
- // (如根因里写「双层「结果:」前缀」)会被任意位置误切。改按序扫描:真实标签 = 上一标签之后
524
- // 首次出现,字段正文引用更靠后的标签不误断(quick-① 2026-08-04 实证补丁)。缺标签返回 null
525
- // → 落单行兜底(--done 契约校验仍会拦缺字段)。
526
- const segs = splitSingleLineFields(body)
527
- if (segs) body = segs.join('\n')
528
- }
529
- // 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
530
- // 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
531
- const resultLines = /\r?\n/.test(body)
532
- ? body.split(/\r?\n/).filter(l => l.trim() !== '')
533
- : [`结果:${body}`]
534
- // 结果块属本条目:从 endIdx 往前跳过本条目尾部空行,在最后一个非空行之后插入。
535
- // 否则结果块会落在尾空行「之后」,与下一条目标题紧贴(缺空行分隔),且被空行从本条目
536
- // 「文件:」行隔开——视觉上像属于下一条目(用户实证多条目 QUICKLOG 间距 bug)。
537
- let insertAt = endIdx
538
- while (insertAt - 1 > startIdx && lines[insertAt - 1].trim() === '') insertAt--
539
- lines.splice(insertAt, 0, ...resultLines)
540
- // 兜底:结果块之后、下一个 ## 标题之间若无空行(条目原本无尾空行 / 轮转归档手改等),补一个。
541
- const after = insertAt + resultLines.length
542
- if (after < lines.length && lines[after].startsWith('## ')) {
543
- lines.splice(after, 0, '')
544
- }
545
- }
546
- return lines.join('\n')
547
- }
548
-
549
- // ── 对外 API ──
550
-
551
- /**
552
- * 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
553
- * @returns {Promise<{qlId: string}>}
554
- */
555
- export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
556
- const quicklogDir = join(specBase, 'quicklog')
557
- mkdirSync(quicklogDir, { recursive: true })
558
- // git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
559
- // 白名单消毒防穿越写(QUICKLOG-<user>.md / 锁文件 / 轮转归档三处拼接),与 assertSafeChangeName 同风格
560
- const user = sanitizeQuicklogUser(gitUser) || 'unknown'
561
- const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
562
- const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
563
- const desc = sanitizeDesc(description)
564
- const linked = Array.isArray(linkedChanges) ? linkedChanges : []
565
- const files = Array.isArray(allowedFiles) ? allowedFiles : []
566
- const today = todayStamp()
567
-
568
- return await withFileLock(lockPath, async () => {
569
- const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
570
- const nextSeq = maxSeq + 1
571
- // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
572
- let suffix
573
- let guard = 0
574
- do {
575
- suffix = randomBytes(2).toString('hex')
576
- guard++
577
- } while (usedSuffix.has(suffix) && guard < 100)
578
-
579
- await rotateIfNeeded(userFile, user)
580
-
581
- const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
582
- const entry = [
583
- '',
584
- `## ${qlId} | ${nowDatetime()} | ${desc}`,
585
- '状态:进行中',
586
- `关联变更:${linked.length > 0 ? linked.join(', ') : '(无)'}`,
587
- `文件:${files.length > 0 ? files.join(', ') : '(见实际改动)'}`,
588
- '',
589
- ].join('\n')
590
- appendFileSync(userFile, entry)
591
-
592
- for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
593
-
594
- // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
595
- // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
596
- await pushQuicklogEntryToPlatform(specBase, {
597
- ql_id: qlId,
598
- timestamp: nowDatetime(),
599
- title: desc,
600
- status: 'in_progress',
601
- status_note: null,
602
- author_raw: user,
603
- // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表;原文在 raw_block 不丢)
604
- linked_changes: linked.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c)),
605
- files: files.map(f => ({ path: f, note: null })),
606
- body_sections: {},
607
- raw_block: entry,
608
- }).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
609
-
610
- return { qlId }
611
- })
612
- }
613
-
614
- /**
615
- * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
616
- */
617
- export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [] } = {}) {
618
- const quicklogDir = join(specBase, 'quicklog')
619
- // 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
620
- const user = sanitizeQuicklogUser(gitUser) || 'unknown'
621
- const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
622
- const result = sanitizeResult(resultText)
623
- const linked = Array.isArray(linkedChanges) ? linkedChanges : []
624
- // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
625
- const realFiles = Array.isArray(changedFiles) ? changedFiles.filter(Boolean) : []
626
- // --file-notes(command.js 经 setQuickFileNotes 注入):有则文件行写多行 bullet 带括注。
627
- // 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
628
- const fileNotes = parseFileNotes(_pendingFileNotes)
629
- _pendingFileNotes = ''
630
-
631
- await withFileLock(lockPath, async () => {
632
- // 条目可能在主文件或轮转归档中
633
- let updatedContent = null
634
- let updatedFile = null
635
- for (const f of listQuicklogFiles(quicklogDir)) {
636
- const filePath = join(quicklogDir, f)
637
- let content = ''
638
- try { content = readFileSync(filePath, 'utf8') } catch { continue }
639
- const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes)
640
- if (updated !== null) {
641
- await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
642
- updatedContent = updated
643
- updatedFile = filePath
644
- break
645
- }
646
- }
647
- for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
648
-
649
- // 平台推送(task-06 / FR-02 / D-003):**以落盘终态为准**读回条目组装 payload
650
- // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
651
- // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
652
- const rawBlock = extractRawBlock(updatedContent, qlId)
653
- await pushQuicklogEntryToPlatform(specBase, buildPushPayloadFromRaw(rawBlock, {
654
- ql_id: qlId,
655
- author_raw: user,
656
- status: 'completed',
657
- linked_changes: linked,
658
- })).catch(() => {})
659
- })
660
- }
661
-
662
- /**
663
- * 查某 qlId 条目是否存在(只读,跨所有 QUICKLOG-*.md)。
664
- */
665
- export function findQuicklogEntry(specBase, gitUser, qlId) {
666
- const quicklogDir = join(specBase, 'quicklog')
667
- if (!existsSync(quicklogDir)) return false
668
- const header = `## ${qlId} |`
669
- for (const f of listQuicklogFiles(quicklogDir)) {
670
- let content = ''
671
- try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
672
- if (content.includes(header)) return true
673
- }
674
- return false
675
- }
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 { getRule } from './stage-contract-spec.js'
15
+ import {
16
+ openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
17
+ readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
18
+ } from 'fs'
19
+ import { randomBytes } from 'crypto'
20
+
21
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
22
+
23
+ /**
24
+ * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建;已存在则按 mtime 判 stale 偷锁,
25
+ * 否则退避重试至超时。CLI 短进程、低频写入,stale 阈值 30s 足够。
26
+ * @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
27
+ * @param {() => (any|Promise<any>)} fn 临界区
28
+ * @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
29
+ */
30
+ export async function withFileLock(lockPath, fn, opts = {}) {
31
+ const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
32
+ mkdirSync(dirname(lockPath), { recursive: true })
33
+ const start = Date.now()
34
+ let fd = null
35
+ // 抢锁循环
36
+ while (true) {
37
+ try {
38
+ fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
39
+ break
40
+ } catch (e) {
41
+ if (e.code !== 'EEXIST') throw e
42
+ // 锁已存在:判 stale(持有进程崩溃残留)
43
+ try {
44
+ const mtime = statSync(lockPath).mtimeMs
45
+ if (Date.now() - mtime > staleMs) {
46
+ try { unlinkSync(lockPath) } catch {} // 偷 stale 锁后重试
47
+ continue
48
+ }
49
+ } catch {}
50
+ if (Date.now() - start > timeoutMs) {
51
+ throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
52
+ }
53
+ await sleep(retryMs)
54
+ }
55
+ }
56
+ try {
57
+ return await fn()
58
+ } finally {
59
+ try { closeSync(fd) } catch {}
60
+ try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
61
+ }
62
+ }
63
+
64
+ // ── 内部工具 ──
65
+
66
+ function todayStamp(d = new Date()) {
67
+ return d.getFullYear()
68
+ + String(d.getMonth() + 1).padStart(2, '0')
69
+ + String(d.getDate()).padStart(2, '0')
70
+ }
71
+
72
+ function nowDatetime(d = new Date()) {
73
+ return todayStamp(d).slice(0, 4) + '-' + todayStamp(d).slice(4, 6) + '-' + todayStamp(d).slice(6, 8)
74
+ + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0')
75
+ }
76
+
77
+ // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
78
+ export function sanitizeDesc(description) {
79
+ const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
80
+ if (!s) return '(quick 任务)'
81
+ return s.length > 120 ? s.slice(0, 120) + '…' : s
82
+ }
83
+
84
+
85
+ // ── 平台推送(best-effort,2026-08-16-change-center-quick-tab task-06 / D-003)──
86
+
87
+ // 读 local.yaml platform 段(url+token)。quicklog 不 import sync.js(会拖 ProgressManager
88
+ // 整链),自带同款轻量解析:只取 platform.url / platform.token 两键,与 parseSimpleYaml
89
+ // 同风格(丢注释可接受——只读)。未连接返回 null(合法本地状态,静默跳过)。
90
+ function _readPlatformConfig(specBase) {
91
+ try {
92
+ // specBase 即 <cwd>/.sillyspec(或平台 specRoot);local.yaml 就在 spec 根下
93
+ const yamlPath = join(specBase, 'local.yaml')
94
+ if (!existsSync(yamlPath)) return null
95
+ const lines = readFileSync(yamlPath, 'utf8').split(/\r?\n/)
96
+ let inPlatform = false
97
+ const cfg = {}
98
+ for (const line of lines) {
99
+ if (!line.trim() || line.trim().startsWith('#')) continue
100
+ if (!/^\s/.test(line)) inPlatform = line.startsWith('platform:')
101
+ else if (inPlatform) {
102
+ const m = line.match(/^\s+(url|token)\s*:\s*(.*)$/)
103
+ if (m) {
104
+ let v = m[2].trim()
105
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) v = v.slice(1, -1)
106
+ if (v) cfg[m[1]] = v
107
+ }
108
+ }
109
+ }
110
+ if (!cfg.url || !cfg.token) return null
111
+ return cfg
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ const PUSH_TIMEOUT_MS = 5_000
118
+
119
+ // 从文件全文提取指定 qlId 的条目块(## 头到下一个 ## 头/文件尾)。null=未命中。
120
+ // 与平台 quicklog_parser.py 同款切分口径(剥 \r),保证 CLI 推送的 raw_block 与平台解析一致。
121
+ function extractRawBlock(content, qlId) {
122
+ if (!content) return null
123
+ const lines = content.split('\n')
124
+ const start = lines.findIndex(l => l.replace(/\r$/, '').startsWith(`## ${qlId} |`))
125
+ if (start === -1) return null
126
+ let end = lines.length
127
+ for (let i = start + 1; i < lines.length; i++) {
128
+ if (lines[i].replace(/\r$/, '').startsWith('## ')) { end = i; break }
129
+ }
130
+ return lines.slice(start, end).map(l => l.replace(/\r$/, '')).join('\n').trimEnd()
131
+ }
132
+
133
+ // 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
134
+ // 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
135
+ function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
136
+ const payload = {
137
+ ql_id,
138
+ timestamp: null,
139
+ title: fallback_title || '(quick 任务)',
140
+ status,
141
+ status_note: null,
142
+ author_raw,
143
+ linked_changes: Array.isArray(linked_changes) ? linked_changes : [],
144
+ files: [],
145
+ body_sections: {},
146
+ raw_block: rawBlock,
147
+ }
148
+ if (!rawBlock) return payload
149
+ const lines = rawBlock.split('\n')
150
+ const header = lines[0] || ''
151
+ const parts = header.split('|').map(s => s.trim())
152
+ if (parts.length >= 3) {
153
+ payload.timestamp = parts[1] || null
154
+ payload.title = parts.slice(2).join('|').trim() || payload.title
155
+ }
156
+ const labelRe = /^(状态|关联变更|文件|审计|需求|根因|方案|结果)\s*[::]\s*(.*)$/
157
+ const bulletRe = /^-\s+(.*)$/
158
+ let inFiles = false
159
+ let inLinked = false
160
+ let lastLabel = null
161
+ let lastStatus = null
162
+ for (const line of lines.slice(1)) {
163
+ const stripped = line.trim()
164
+ if (!stripped) { inFiles = false; inLinked = false; continue }
165
+ const m = stripped.match(labelRe)
166
+ if (m) {
167
+ const [, label, value] = m
168
+ if (label === '状态') lastStatus = value
169
+ else if (label === '关联变更') { payload.linked_changes = value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean); inLinked = true; inFiles = false }
170
+ else if (label === '文件') { if (value) payload.files.push(...value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean).map(p => ({ path: p, note: null }))); inFiles = true; inLinked = false }
171
+ else if (label === '审计') { /* D-8 advisory 行:只进 raw_block 不进结构化段(body_sections 不扩 schema),并阻断续行误挂 */ lastLabel = null; inFiles = false; inLinked = false }
172
+ else { payload.body_sections[label] = value; lastLabel = label }
173
+ continue
174
+ }
175
+ if (bulletRe.test(stripped) && inFiles) {
176
+ payload.files.push({ path: stripped.replace(/^-\s+/, ''), note: null })
177
+ continue
178
+ }
179
+ if (bulletRe.test(stripped) && inLinked) {
180
+ payload.linked_changes.push(...stripped.replace(/^-\s+/, '').split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean))
181
+ continue
182
+ }
183
+ if (lastLabel) payload.body_sections[lastLabel] += '\n' + stripped
184
+ }
185
+ // 状态以落盘为准(flip 后是「已完成」或「已完成(括注)」)——多条取最后
186
+ if (lastStatus) {
187
+ payload.status = lastStatus.startsWith('已完成') ? 'completed'
188
+ : lastStatus.startsWith('已暂存') ? 'partial_done'
189
+ : lastStatus.startsWith('进行中') ? 'in_progress'
190
+ : status
191
+ const note = lastStatus.match(/((.+))$/)
192
+ payload.status_note = note ? note[1] : null
193
+ }
194
+ // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表,防自由文本进反向区块)
195
+ payload.linked_changes = payload.linked_changes.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c))
196
+ return payload
197
+ }
198
+
199
+ // 单条推送:payload 字段对齐平台 QuicklogEntryPushRequest(snake_case)。测试经 allocate/complete 间接触发。
200
+ // 任何失败(无配置/网络/非 2xx/超时)静默 warn 一行不抛(FR-02 best-effort,quick 主流程零阻断)。
201
+ async function pushQuicklogEntryToPlatform(specBase, entry) {
202
+ const cfg = _readPlatformConfig(specBase)
203
+ if (!cfg) {
204
+ if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn('[quicklog-push] 未配置 platform 段,跳过推送')
205
+ return false
206
+ }
207
+ const controller = new AbortController()
208
+ const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS)
209
+ try {
210
+ const res = await fetch(`${cfg.url.replace(/\/$/, '')}/api/quicklog-entries`, {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
213
+ body: JSON.stringify(entry),
214
+ signal: controller.signal,
215
+ })
216
+ if (!res.ok) {
217
+ console.warn(`[quicklog-push] ${entry.ql_id} → ${res.status}(文件链路兜底,不影响本地记录)`)
218
+ return false
219
+ }
220
+ return true
221
+ } catch (err) {
222
+ console.warn(`[quicklog-push] ${entry.ql_id} 推送失败: ${err && err.name === 'AbortError' ? '超时' : (err.message || err)}(文件链路兜底)`)
223
+ return false
224
+ } finally {
225
+ clearTimeout(timer)
226
+ }
227
+ }
228
+
229
+ // git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
230
+ // 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
231
+ function sanitizeQuicklogUser(user) {
232
+ const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
233
+ if (!s) return ''
234
+ return s.length > 64 ? s.slice(0, 64) : s
235
+ }
236
+
237
+ // 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
238
+ // 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
239
+ // 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
240
+ export function deriveTitleFromLinkedChange(specBase, change) {
241
+ if (!change) return ''
242
+ for (const f of ['proposal.md', 'design.md']) {
243
+ let content
244
+ try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
245
+ const m = content.match(/^#\s+(.+?)\s*$/m)
246
+ if (!m) continue
247
+ const raw = m[1].trim()
248
+ // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
249
+ const dash = raw.match(/[—-]\s*(.+)$/)
250
+ return dash ? dash[1].trim() : raw
251
+ }
252
+ return ''
253
+ }
254
+
255
+ // 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
256
+ // (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
257
+ export function extractTitleFromResult(result) {
258
+ if (!result) return ''
259
+ const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
260
+ if (!m) return ''
261
+ let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
262
+ if (!t) return ''
263
+ if (t.length > 80) t = t.slice(0, 80) + '…'
264
+ return t
265
+ }
266
+
267
+ function sanitizeResult(resultText) {
268
+ // 保留换行:结果块支持字段化结构(需求:/根因:/方案:/结果:...)。多行时 flipEntryInContent
269
+ // 写成字段块(追加在「状态:」行下方),不是单条「结果:<长行>」。仅去首尾空白。
270
+ return String(resultText || '').trim()
271
+ }
272
+
273
+ // ── quick step3 --file-notes 旁路通道 ──
274
+ // flag 在 command.js 解析(--file-notes),completeQuicklogEntry 在 quick 收尾时消费。
275
+ // 不经 completeStep → handleQuickStageCompletion → completeQuicklogEntry 三层透传(会扩 3 处签名,
276
+ // 且 quick 收尾路径是多 agent 并发改热点);用 per-process setter,CLI 单进程生命周期内有效,
277
+ // completeQuicklogEntry 读后即清。无 --file-notes 时为 '' → 回退 changedFiles 单行(向后兼容)。
278
+ let _pendingFileNotes = ''
279
+ export function setQuickFileNotes(raw) { _pendingFileNotes = raw == null ? '' : String(raw) }
280
+ export function getQuickFileNotes() { return _pendingFileNotes }
281
+
282
+ /**
283
+ * 解析 --file-notes 原文 → [{path, note}]。
284
+ * 格式:「path1::括注1 || path2::括注2」——`||` 分隔条目,`::` 分隔路径与括注(首个 `::` 为界,
285
+ * 括注内含 `::` 不误切);路径反斜杠归一正斜杠(匹配 git 路径风格)。无 `::` 的段 → note=''。
286
+ * 空 / 全空段 → [](调用方回退 changedFiles 单行)。导出供测试 + 未来直接调用。
287
+ */
288
+ export function parseFileNotes(raw) {
289
+ if (!raw) return []
290
+ // note 单行化 + 限长:bullet 是「数组元素内嵌 \n」落盘(flipEntryInContent),note 带换行会伪造
291
+ // QUICKLOG 条目结构(\n## ql-… 可伪造后续条目头)。与 sanitizeDesc 同风格。
292
+ const sanitizeNote = (n) => n.replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
293
+ return String(raw)
294
+ .split('||')
295
+ .map((seg) => {
296
+ const s = seg.trim()
297
+ if (!s) return null
298
+ const idx = s.indexOf('::')
299
+ const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
300
+ const note = idx === -1 ? '' : sanitizeNote(s.slice(idx + 2))
301
+ return path ? { path, note } : null
302
+ })
303
+ .filter(Boolean)
304
+ }
305
+
306
+ // 结果块必填字段(quick step3 --done --output 的结构化结果模板,见 src/stages/quick.js)。
307
+ // 4 个必填字段标签从 manifest 同源(stage-contract-spec.js quick.result-labels),prompt 事前契约与本校验单源。
308
+ const RESULT_REQUIRED_LABELS = getRule('quick.result-labels').data.literals
309
+
310
+ /**
311
+ * quick step3 结果摘要结构校验(确定性:只查「必填字段是否都在」,不判内容质量——
312
+ * 内容好坏属语义软判定,不归 CLI)。对齐 docs/sillyspec/quick-done-quicklog-duplicate-status-line
313
+ * 的第二诉求:--output 不自动展开丰富格式时,用约束模板 + 结构校验保证 QUICKLOG 记录完整。
314
+ * @param {string} text --done --output 原文
315
+ * @returns {{ ok: boolean, missing: string[] }} ok=true 通过;missing=缺失的字段名列表
316
+ */
317
+ export function validateQuickResult(text) {
318
+ const missing = RESULT_REQUIRED_LABELS.filter(label => !String(text || '').includes(label))
319
+ return { ok: missing.length === 0, missing }
320
+ }
321
+
322
+ function escapeRe(s) {
323
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
324
+ }
325
+
326
+ function listQuicklogFiles(quicklogDir) {
327
+ try {
328
+ return readdirSync(quicklogDir).filter(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
329
+ } catch {
330
+ return []
331
+ }
332
+ }
333
+
334
+ // 原子写:同目录临时文件 + rename 覆盖。reader 不持锁(agent cat / dashboard 轮询),
335
+ // 若直接 writeFileSync 覆盖整文件,reader 会在 truncate→write 间隙读到空/半截(reader-writer 竞态)。
336
+ // rename 同文件系统内原子(POSIX rename / Win MoveFileEx REPLACE_EXISTING),
337
+ // reader 永远看到完整旧版或完整新版,绝不读半截。
338
+ // Windows 差异:rename 覆盖「正被另一进程读取的目标」会抛 EPERM/EBUSY(POSIX 无此问题)。
339
+ // reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
340
+ async function writeAtomic(filePath, content, opts = {}) {
341
+ const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
342
+ const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
343
+ writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
344
+ for (let attempt = 0; ; attempt++) {
345
+ try {
346
+ renameSync(tmp, filePath)
347
+ return
348
+ } catch (e) {
349
+ const retryable = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'
350
+ if (!retryable || attempt >= retries) {
351
+ try { unlinkSync(tmp) } catch {}
352
+ throw e
353
+ }
354
+ await sleep(Math.min(baseDelayMs * (attempt + 1), maxDelayMs)) // 线性退避,让出 loop
355
+ }
356
+ }
357
+ }
358
+
359
+ // 扫描所有 QUICKLOG-*.md(含轮转归档)当天最大 NNN + 已用 XXXX 后缀集
360
+ function scanExisting(quicklogDir, today) {
361
+ let maxSeq = 0
362
+ const usedSuffix = new Set()
363
+ const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
364
+ // E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
365
+ // 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
366
+ // (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
367
+ const todayDashed = today.length === 8 ? `${today.slice(0, 4)}-${today.slice(4, 6)}-${today.slice(6, 8)}` : today
368
+ for (const f of listQuicklogFiles(quicklogDir)) {
369
+ const dm = f.match(/-(\d{4}-\d{2}-\d{2})\.md$/)
370
+ if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
371
+ let content = ''
372
+ try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
373
+ let m
374
+ re.lastIndex = 0
375
+ while ((m = re.exec(content)) !== null) {
376
+ if (m[1] === today) {
377
+ maxSeq = Math.max(maxSeq, parseInt(m[2], 10))
378
+ usedSuffix.add(m[3].toLowerCase())
379
+ }
380
+ }
381
+ }
382
+ return { maxSeq, usedSuffix }
383
+ }
384
+
385
+ // >500 行则轮转:rename QUICKLOG-<user>.md → QUICKLOG-<user>-<最后记录日期>.md
386
+ async function rotateIfNeeded(userFile, gitUser) {
387
+ if (!existsSync(userFile)) return
388
+ const content = readFileSync(userFile, 'utf8')
389
+ const lines = content.split('\n')
390
+ if (lines.length <= 500) return
391
+ let lastDate = null
392
+ for (let i = lines.length - 1; i >= 0; i--) {
393
+ const m = lines[i].match(/^## ql-(\d{8})-/)
394
+ if (m) { lastDate = m[1]; break }
395
+ }
396
+ const dateStr = lastDate
397
+ ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
398
+ : nowDatetime().slice(0, 10)
399
+ const archiveFile = join(dirname(userFile), `QUICKLOG-${sanitizeQuicklogUser(gitUser) || 'unknown'}-${dateStr}.md`)
400
+ // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
401
+ if (existsSync(archiveFile)) {
402
+ const existing = readFileSync(archiveFile, 'utf8')
403
+ await writeAtomic(archiveFile, existing + content)
404
+ await writeAtomic(userFile, '')
405
+ } else {
406
+ renameSync(userFile, archiveFile)
407
+ }
408
+ // echo 轮转:归档文件是 git 跟踪的新文件,提交时带上(勿漏,否则旧 ql 条目只在本地)
409
+ console.log(`🔄 QUICKLOG 已轮转(>500 行):${basename(userFile)} → ${basename(archiveFile)}(提交时带上归档文件,勿漏)`)
410
+ }
411
+
412
+ function tasksPath(specBase, change) {
413
+ return join(specBase, 'changes', change, 'tasks.md')
414
+ }
415
+
416
+ // 追加未勾选 task;幂等(已含同 qlId 则跳过)。
417
+ // 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
418
+ // mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
419
+ // QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
420
+ function appendTaskCheckbox(specBase, change, qlId, desc) {
421
+ const dir = join(specBase, 'changes', change)
422
+ if (!existsSync(dir)) return
423
+ const p = tasksPath(specBase, change)
424
+ let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
425
+ if (content.includes(qlId)) return
426
+ const prefix = content && !content.endsWith('\n') ? '\n' : ''
427
+ appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
428
+ }
429
+
430
+ // 勾选该 qlId 对应 task:- [ ] → - [x]
431
+ async function checkTaskCheckbox(specBase, change, qlId) {
432
+ const p = tasksPath(specBase, change)
433
+ if (!existsSync(p)) return
434
+ let content = readFileSync(p, 'utf8')
435
+ const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
436
+ content = content.replace(re, '- [x] $1')
437
+ await writeAtomic(p, content)
438
+ }
439
+
440
+ // 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
441
+ // CRLF 修复(缺陷 quick-done-quicklog-duplicate-status-line):QUICKLOG 在 Windows 下
442
+ // 可能是 CRLF,split('\n') 后每行带 \r。原代码 `lines[i] === '状态:进行中'` 精确匹配
443
+ // 恒失败 → 走 splice「兜底插入」→ 条目内同时出现「状态:已完成」+「状态:进行中」。
444
+ // 状态行匹配与「已完成」判断统一用行首前缀匹配,容忍行尾 \r;写入保持 CRLF 不扩大改动。
445
+ // 单行四字段(需求:/根因:/方案:/结果:)切段。先按「字段边界」严格扫描:真实标签 = 上一标签之后首次
446
+ // 出现的、前导是串首/空白/句末标点(。;!?)的对应标签——字段正文引用标签字样(根因写「双层「结果:」
447
+ // 前缀」或正则 split(/(?=需求:|根因:|方案:|结果:)/))因前导是「/|( 等非边界字符而跳过。严格失败
448
+ // (如真实标签前导是「,」这类弱标点)退回宽松顺序扫描(上一标签之后首次出现)。缺标签返回 null → 落单行
449
+ // 兜底(--done 契约校验仍会拦缺字段)。残余边界:正文引用标签且前导恰好是空白/句末标点时仍可能错位。
450
+ function isFieldBoundary(body, idx) {
451
+ if (idx <= 0) return true
452
+ const prev = body[idx - 1]
453
+ return /\s/.test(prev) || '。;!?'.includes(prev)
454
+ }
455
+ function findBoundaryLabel(body, label, from) {
456
+ let idx = body.indexOf(label, from)
457
+ while (idx !== -1 && !isFieldBoundary(body, idx)) {
458
+ idx = body.indexOf(label, idx + label.length)
459
+ }
460
+ return idx
461
+ }
462
+ function scanFields(body, findLabel) {
463
+ const labels = ['需求:', '根因:', '方案:', '结果:']
464
+ const positions = []
465
+ let cursor = 0
466
+ for (const label of labels) {
467
+ const idx = findLabel(body, label, cursor)
468
+ if (idx === -1) return null
469
+ positions.push(idx)
470
+ cursor = idx + label.length
471
+ }
472
+ return positions.map((start, i) => {
473
+ const end = i + 1 < positions.length ? positions[i + 1] : body.length
474
+ return body.slice(start, end).trim()
475
+ })
476
+ }
477
+ function splitSingleLineFields(body) {
478
+ return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
479
+ }
480
+
481
+ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = [], auditNotes = []) {
482
+ const lines = content.split('\n')
483
+ const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
484
+ if (startIdx === -1) return null
485
+ // 翻完成时若能从结果提取「需求:」摘要,刷新标题行(覆盖启动时的占位/弱标题)
486
+ const newTitle = extractTitleFromResult(result)
487
+ if (newTitle) {
488
+ const headerCore = lines[startIdx].match(/^(## \S+ \| [^|]+ \| )/)
489
+ if (headerCore) lines[startIdx] = headerCore[1] + newTitle
490
+ }
491
+ let endIdx = lines.length
492
+ for (let i = startIdx + 1; i < lines.length; i++) {
493
+ if (lines[i].startsWith('## ')) { endIdx = i; break }
494
+ }
495
+ let hasResult = false
496
+ let flipped = false
497
+ for (let i = startIdx + 1; i < endIdx; i++) {
498
+ if (/^状态:进行中\r?$/.test(lines[i])) { lines[i] = '状态:已完成'; flipped = true }
499
+ if (lines[i].startsWith('结果:')) hasResult = true
500
+ // 文件行落盘:--file-notes 优先(多行 bullet 带括注),否则回填审计到的实际改动文件单行,
501
+ // 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
502
+ // 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
503
+ if (lines[i].startsWith('文件:')) {
504
+ if (fileNotes.length > 0) {
505
+ const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
506
+ lines[i] = `文件:\n${bullets.join('\n')}`
507
+ } else if (changedFiles.length > 0) {
508
+ lines[i] = `文件:${changedFiles.join(', ')}`
509
+ }
510
+ }
511
+ }
512
+ if (!flipped && !lines.slice(startIdx, endIdx).some(l => /^状态:已完成\r?$/.test(l))) {
513
+ // 无状态行(异常),插一条已完成
514
+ lines.splice(startIdx + 1, 0, '状态:已完成')
515
+ endIdx += 1
516
+ }
517
+ if (result && !hasResult) {
518
+ // quick step3 --output 常被压成单行「需求:…根因:…方案:…结果:…」(agent 未加换行),
519
+ // 直接落盘会得到双层前缀「结果:需求:…结果:…」。把单行四字段归一为多行字段块,省 agent 手工
520
+ // 拆行精修(prompt-control-debt quick-①)。多行 / 单句结果不受影响。
521
+ let body = result
522
+ if (!/\r?\n/.test(body) && /^需求:/.test(body)) {
523
+ // 单行四字段归一为多行。不能 split(/(?=需求:|根因:|方案:|结果:)/)——正文引用字段标签字样
524
+ // (如根因里写「双层「结果:」前缀」)会被任意位置误切。改按序扫描:真实标签 = 上一标签之后
525
+ // 首次出现,字段正文引用更靠后的标签不误断(quick-① 2026-08-04 实证补丁)。缺标签返回 null
526
+ // → 落单行兜底(--done 契约校验仍会拦缺字段)。
527
+ const segs = splitSingleLineFields(body)
528
+ if (segs) body = segs.join('\n')
529
+ }
530
+ // 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
531
+ // 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
532
+ const resultLines = /\r?\n/.test(body)
533
+ ? body.split(/\r?\n/).filter(l => l.trim() !== '')
534
+ : [`结果:${body}`]
535
+ // 结果块属本条目:从 endIdx 往前跳过本条目尾部空行,在最后一个非空行之后插入。
536
+ // 否则结果块会落在尾空行「之后」,与下一条目标题紧贴(缺空行分隔),且被空行从本条目
537
+ // 「文件:」行隔开——视觉上像属于下一条目(用户实证多条目 QUICKLOG 间距 bug)。
538
+ let insertAt = endIdx
539
+ while (insertAt - 1 > startIdx && lines[insertAt - 1].trim() === '') insertAt--
540
+ lines.splice(insertAt, 0, ...resultLines)
541
+ // 兜底:结果块之后、下一个 ## 标题之间若无空行(条目原本无尾空行 / 轮转归档手改等),补一个。
542
+ const after = insertAt + resultLines.length
543
+ if (after < lines.length && lines[after].startsWith('## ')) {
544
+ lines.splice(after, 0, '')
545
+ }
546
+ }
547
+ // D-8 落盘(2026-08-18 修):advisory 审计行(文档欠账 / 引用失效)写入条目尾部——修复
548
+ // quick-audit「欠账已记录(QUICKLOG reasons)」的不实承诺(原先纯 console 打印,事后不可审计;
549
+ // 两周实测需要分母:信号触发次数必须可追溯)。幂等:条目内已有 审计: 行则不重复写(--done 重跑安全)。
550
+ // 位置:结果块之后 / 条目尾部(重算边界,不吃上方 splice 的陈旧索引)。
551
+ if (auditNotes.length > 0) {
552
+ let entryEnd = -1
553
+ for (let i = startIdx + 1; i < lines.length; i++) {
554
+ if (lines[i].startsWith('## ')) { entryEnd = i; break }
555
+ }
556
+ if (entryEnd === -1) entryEnd = lines.length
557
+ if (!lines.slice(startIdx, entryEnd).some((l) => l.startsWith('审计:'))) {
558
+ let insertAt = entryEnd
559
+ while (insertAt - 1 > startIdx && lines[insertAt - 1].trim() === '') insertAt--
560
+ lines.splice(insertAt, 0, ...auditNotes.map((n) => `审计:${n}`))
561
+ }
562
+ }
563
+ return lines.join('\n')
564
+ }
565
+
566
+ // ── 对外 API ──
567
+
568
+ /**
569
+ * 分配 ql-ID 并写「进行中」条目 + 关联 tasks.md。持锁、当天唯一。
570
+ * @returns {Promise<{qlId: string}>}
571
+ */
572
+ export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
573
+ const quicklogDir = join(specBase, 'quicklog')
574
+ mkdirSync(quicklogDir, { recursive: true })
575
+ // git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
576
+ // 白名单消毒防穿越写(QUICKLOG-<user>.md / 锁文件 / 轮转归档三处拼接),与 assertSafeChangeName 同风格
577
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
578
+ const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
579
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
580
+ const desc = sanitizeDesc(description)
581
+ const linked = Array.isArray(linkedChanges) ? linkedChanges : []
582
+ const files = Array.isArray(allowedFiles) ? allowedFiles : []
583
+ const today = todayStamp()
584
+
585
+ return await withFileLock(lockPath, async () => {
586
+ const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
587
+ const nextSeq = maxSeq + 1
588
+ // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
589
+ let suffix
590
+ let guard = 0
591
+ do {
592
+ suffix = randomBytes(2).toString('hex')
593
+ guard++
594
+ } while (usedSuffix.has(suffix) && guard < 100)
595
+
596
+ await rotateIfNeeded(userFile, user)
597
+
598
+ const qlId = `ql-${today}-${String(nextSeq).padStart(3, '0')}-${suffix}`
599
+ const entry = [
600
+ '',
601
+ `## ${qlId} | ${nowDatetime()} | ${desc}`,
602
+ '状态:进行中',
603
+ `关联变更:${linked.length > 0 ? linked.join(', ') : '(无)'}`,
604
+ `文件:${files.length > 0 ? files.join(', ') : '(见实际改动)'}`,
605
+ '',
606
+ ].join('\n')
607
+ appendFileSync(userFile, entry)
608
+
609
+ for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
610
+
611
+ // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
612
+ // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
613
+ await pushQuicklogEntryToPlatform(specBase, {
614
+ ql_id: qlId,
615
+ timestamp: nowDatetime(),
616
+ title: desc,
617
+ status: 'in_progress',
618
+ status_note: null,
619
+ author_raw: user,
620
+ // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表;原文在 raw_block 不丢)
621
+ linked_changes: linked.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c)),
622
+ files: files.map(f => ({ path: f, note: null })),
623
+ body_sections: {},
624
+ raw_block: entry,
625
+ }).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
626
+
627
+ return { qlId }
628
+ })
629
+ }
630
+
631
+ /**
632
+ * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
633
+ */
634
+ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [], auditNotes = [] } = {}) {
635
+ const quicklogDir = join(specBase, 'quicklog')
636
+ // 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
637
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
638
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
639
+ const result = sanitizeResult(resultText)
640
+ const linked = Array.isArray(linkedChanges) ? linkedChanges : []
641
+ // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
642
+ const realFiles = Array.isArray(changedFiles) ? changedFiles.filter(Boolean) : []
643
+ // --file-notes(command.js 经 setQuickFileNotes 注入):有则文件行写多行 bullet 带括注。
644
+ // 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
645
+ const fileNotes = parseFileNotes(_pendingFileNotes)
646
+ _pendingFileNotes = ''
647
+
648
+ await withFileLock(lockPath, async () => {
649
+ // 条目可能在主文件或轮转归档中
650
+ let updatedContent = null
651
+ let updatedFile = null
652
+ for (const f of listQuicklogFiles(quicklogDir)) {
653
+ const filePath = join(quicklogDir, f)
654
+ let content = ''
655
+ try { content = readFileSync(filePath, 'utf8') } catch { continue }
656
+ const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes,
657
+ Array.isArray(auditNotes) ? auditNotes.filter((n) => typeof n === 'string' && n.trim() !== '') : [])
658
+ if (updated !== null) {
659
+ await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
660
+ updatedContent = updated
661
+ updatedFile = filePath
662
+ break
663
+ }
664
+ }
665
+ for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
666
+
667
+ // 平台推送(task-06 / FR-02 / D-003):**以落盘终态为准**读回条目组装 payload
668
+ // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
669
+ // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
670
+ const rawBlock = extractRawBlock(updatedContent, qlId)
671
+ await pushQuicklogEntryToPlatform(specBase, buildPushPayloadFromRaw(rawBlock, {
672
+ ql_id: qlId,
673
+ author_raw: user,
674
+ status: 'completed',
675
+ linked_changes: linked,
676
+ })).catch(() => {})
677
+ })
678
+ }
679
+
680
+ /**
681
+ * 查某 qlId 条目是否存在(只读,跨所有 QUICKLOG-*.md)。
682
+ */
683
+ export function findQuicklogEntry(specBase, gitUser, qlId) {
684
+ const quicklogDir = join(specBase, 'quicklog')
685
+ if (!existsSync(quicklogDir)) return false
686
+ const header = `## ${qlId} |`
687
+ for (const f of listQuicklogFiles(quicklogDir)) {
688
+ let content = ''
689
+ try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
690
+ if (content.includes(header)) return true
691
+ }
692
+ return false
693
+ }