sillyspec 3.23.7 → 3.23.9

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.
@@ -42,6 +42,9 @@ USER=$(git config user.name 2>/dev/null || echo "default")
42
42
  cat .sillyspec/quicklog/QUICKLOG-${USER}.md 2>/dev/null
43
43
  # 同时扫描活跃变更目录下的归属 quicklog
44
44
  for dir in .sillyspec/changes/*/quicklog/*.md; do [ -f "$dir" ] && cat "$dir"; done 2>/dev/null
45
+ # 注:QUICKLOG 现由 CLI 接管写入(src/quicklog.js),描述取 quick 启动的任务参数、
46
+ # 结果取 step3 --output 一句话,粒度可能较粗;条目仍含 标题|时间|描述 + 状态 + 结果 结构,
47
+ # 可直接读取。描述过泛时结合 diff stat 与 tasks.md 补充语义。
45
48
 
46
49
  来源 B — tasks.md(execute 产生的修改):
47
50
  LATEST=$(ls -d .sillyspec/changes/*/ 2>/dev/null | grep -v archive | tail -1)
@@ -43,6 +43,7 @@ sillyspec run execute --reopen --from-step N # 重新打开已完成阶段修
43
43
  - **禁止跳过 worktree 或在主仓库直接写代码**
44
44
  - worktree 创建失败时 CLI 报错退出,排查后重试
45
45
  - dirty 状态/未提交文件**不影响** worktree 创建和进入,直接按 CLI 输出的路径操作,不要自行检查 git 状态
46
+ - create 时若检测到 base 落后/分叉 `origin/<默认分支>`,CLI 会醒目报告(⚠️「落后 N 个 commit」+ 对齐命令),**不阻断** execute。看到此报告时评估是否提示用户先对齐 main,但**不要自行 fetch/ff**——对齐是用户/主仓库的显式动作
46
47
 
47
48
  ### 依赖门控(depsStatus)
48
49
 
@@ -14,7 +14,7 @@ description: 用于明确、低风险、范围很小的直接任务。适合用
14
14
 
15
15
  quick 阶段的 `--change` 语义是「关联变更」**且会触发步骤重置**,**不要用 `--change` 来指定关联变更**。多活跃变更时改用:
16
16
 
17
- - `--linked-changes none`:不关联,仅记 QUICKLOG
17
+ - `--linked-changes none`:不关联,仅记 QUICKLOG(CLI 启动时写入)
18
18
  - `--linked-changes a,b`:显式关联到变更 a、b
19
19
  - `--non-interactive`:CI/脚本环境,默认不关联(避免交互 prompt 崩溃)
20
20
 
@@ -78,7 +78,7 @@ sillyspec run quick --files src/phone.ts,src/phone.test.ts
78
78
  - **必须用 exec 工具(shell)执行 CLI,不要自己编造流程**
79
79
  - quick 直接在主工作区改代码(不创建 worktree),范围必须小且明确
80
80
  - 完成后立即 `--done`,不跳过
81
- - step 1 会创建 `.sillyspec/quicklog/QUICKLOG-<user>.md` 记录(理解任务时),完成前 CLI 会校验它存在
81
+ - QUICKLOG 记录由 **CLI 接管**:启动时 CLI 自动分配 ql-ID 并在 `.sillyspec/quicklog/QUICKLOG-<user>.md` 写「进行中」条目(含关联变更 tasks.md),完成时 CLI 自动翻「已完成」并勾选 task。**你无需手写 QUICKLOG / tasks.md**,只需用注入的 `<quicklog-id>` 在模块文档变更索引引用
82
82
  - **禁止**在没有运行 CLI 的情况下自行决定流程
83
83
 
84
84
  ## 用户指令
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.23.7",
3
+ "version": "3.23.9",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -0,0 +1,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
+ 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
+ }
package/src/run.js CHANGED
@@ -11,6 +11,7 @@ import { createRequire } from 'module'
11
11
  const require = createRequire(import.meta.url)
12
12
  import { ProgressManager } from './progress.js'
13
13
  import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
14
+ import { allocateQuicklogEntry, completeQuicklogEntry, findQuicklogEntry } from './quicklog.js'
14
15
  import { checkbox } from '@inquirer/prompts'
15
16
 
16
17
  /**
@@ -721,6 +722,19 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
721
722
  if (changeName && promptText.includes('<quick-session-id>')) {
722
723
  promptText = promptText.replace(/<quick-session-id>/g, changeName)
723
724
  }
725
+ // 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
726
+ // 供 agent 在模块文档变更索引等处引用)
727
+ if (promptText.includes('<quicklog-id>')) {
728
+ const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
729
+ let qlIdVal = ''
730
+ try {
731
+ const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
732
+ if (existsSync(sessionGuardFile)) {
733
+ qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
734
+ }
735
+ } catch {}
736
+ promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
737
+ }
724
738
  // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
725
739
  if (promptText.includes('<linked-changes>')) {
726
740
  const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
@@ -1740,7 +1754,7 @@ export async function runCommand(args, cwd, specDir = null) {
1740
1754
  }
1741
1755
 
1742
1756
  // 默认:输出当前步骤
1743
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges })
1757
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges, taskDescription: inputText })
1744
1758
  }
1745
1759
 
1746
1760
  /**
@@ -2000,45 +2014,67 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
2000
2014
  console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
2001
2015
  }
2002
2016
 
2003
- // quick 阶段:记录 baselineFiles
2004
- if (stageName === 'quick' && !progress.quickGuard) {
2017
+ // quick 阶段:记录 baselineFiles + 分配 ql-ID(CLI 接管 QUICKLOG 写入)
2018
+ // 幂等判据是 session guard.json 文件(跨进程可靠),不是 progress.quickGuard
2019
+ // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
2020
+ // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
2021
+ if (stageName === 'quick') {
2022
+ const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
2023
+ const guardFile = join(sessionGuardDir, 'guard.json')
2024
+ let existingGuard = null
2005
2025
  try {
2006
- const { execSync } = await import('child_process')
2007
- const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
2008
- // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
2009
- // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
2010
- // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
2011
- // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
2012
- const baselineFiles = gitStatus
2013
- .trim().split('\n').filter(Boolean)
2014
- .map(line => line.slice(3).trim())
2015
- const allowedFiles = quickOpts?.quickFiles || []
2016
- const allowNew = quickOpts?.isAllowNew || false
2017
- const forceBaseline = quickOpts?.isForceBaseline || false
2018
- progress.quickGuard = {
2019
- sessionId: changeName,
2020
- name_zh: '快速任务守卫',
2021
- baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
2022
- baselineFiles,
2023
- allowedFiles,
2024
- allowNew,
2025
- forceBaseline,
2026
- linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
2027
- startedAt: new Date().toISOString(),
2026
+ if (existsSync(guardFile)) existingGuard = JSON.parse(readFileSync(guardFile, 'utf8'))
2027
+ } catch {}
2028
+ if (existingGuard) {
2029
+ // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
2030
+ progress.quickGuard = existingGuard
2031
+ } else {
2032
+ try {
2033
+ const { execSync } = await import('child_process')
2034
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
2035
+ // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
2036
+ // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
2037
+ // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
2038
+ // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
2039
+ const baselineFiles = gitStatus
2040
+ .trim().split('\n').filter(Boolean)
2041
+ .map(line => line.slice(3).trim())
2042
+ const allowedFiles = quickOpts?.quickFiles || []
2043
+ const allowNew = quickOpts?.isAllowNew || false
2044
+ const forceBaseline = quickOpts?.isForceBaseline || false
2045
+ const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
2046
+ // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
2047
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
2048
+ const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
2049
+ description: quickOpts?.taskDescription || '',
2050
+ linkedChanges,
2051
+ allowedFiles,
2052
+ })
2053
+ progress.quickGuard = {
2054
+ sessionId: changeName,
2055
+ name_zh: '快速任务守卫',
2056
+ baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
2057
+ baselineFiles,
2058
+ allowedFiles,
2059
+ allowNew,
2060
+ forceBaseline,
2061
+ linkedChanges,
2062
+ quicklogId: qlId,
2063
+ startedAt: new Date().toISOString(),
2064
+ }
2065
+ // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2066
+ // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2067
+ mkdirSync(sessionGuardDir, { recursive: true })
2068
+ writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2069
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
2070
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2071
+ if (allowNew) parts.push('允许新增文件')
2072
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
2073
+ console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
2074
+ await pm._write(cwd, progress, changeName)
2075
+ } catch (e) {
2076
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
2028
2077
  }
2029
- // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2030
- // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2031
- const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
2032
- mkdirSync(sessionGuardDir, { recursive: true })
2033
- const guardFile = join(sessionGuardDir, 'guard.json')
2034
- writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2035
- const parts = [`${baselineFiles.length} 个已有脏文件`]
2036
- if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2037
- if (allowNew) parts.push('允许新增文件')
2038
- console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
2039
- await pm._write(cwd, progress, changeName)
2040
- } catch (e) {
2041
- console.warn(`⚠️ baseline 记录失败: ${e.message}`)
2042
2078
  }
2043
2079
  }
2044
2080
 
@@ -3011,61 +3047,96 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3011
3047
  if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
3012
3048
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
3013
3049
  }
3014
- // quick 阶段完成前强制检查 quicklog 是否创建
3050
+ // quick 收尾:强校验 QUICKLOG 条目 + 翻状态 + 勾 tasks.md(CLI 接管)
3015
3051
  if (stageName === 'quick') {
3016
- const quicklogDir = join(specBase, 'quicklog')
3017
- const hasQuicklog = existsSync(quicklogDir) && readdirSync(quicklogDir).some(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
3018
- if (!hasQuicklog) {
3019
- console.error(`\n❌ quick 阶段完成校验失败:未检测到 QUICKLOG 记录文件。`)
3020
- console.error(` step 2 要求创建 quicklog 记录,但文件不存在。`)
3021
- console.error(` 请先创建 quicklog 记录再 --done。`)
3022
- return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
3023
- }
3024
- // §4.6 quick 收尾:从 session guard.json 读 guard(不依赖 progress.quickGuard)。
3052
+ // §4.6 从 session guard.json 读 guard(不依赖 progress.quickGuard)。
3025
3053
  // D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress 无 quickGuard,
3026
3054
  // 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
3027
3055
  // 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
3028
3056
  // sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
3029
- {
3030
- const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
3031
- const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
3032
- const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
3033
- let guard = null
3034
- try {
3035
- guard = existsSync(sessionGuardFile)
3036
- ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
3037
- : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
3038
- } catch {}
3039
- if (guard) {
3040
- // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
3041
- // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
3042
- // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
3043
- const mergedGuard = {
3044
- ...guard,
3045
- forceBaseline: guard.forceBaseline || isForceBaseline,
3046
- allowNew: guard.allowNew || isAllowNew,
3047
- }
3048
- const review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
3049
- printQuickAuditReview(review)
3050
- if (review.status === 'blocked') {
3051
- steps[currentIdx].status = 'pending'
3052
- steps[currentIdx].completedAt = null
3053
- if (outputText) steps[currentIdx].output = null
3054
- process.exit(1)
3055
- }
3056
- progress.lastQuickReview = review
3057
+ const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
3058
+ const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
3059
+ const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
3060
+ let guard = null
3061
+ try {
3062
+ guard = existsSync(sessionGuardFile)
3063
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
3064
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
3065
+ } catch {}
3066
+
3067
+ // 强校验 / 收尾:本会话必须有一条真实 QUICKLOG 条目(治「报 SAFE 但漏写」bug)。
3068
+ // guard 缺失(brownfield:新代码前启动的会话)不阻断——兜底补写一条记录,保住「完成必有记录」不变量。
3069
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
3070
+ let qlId = guard?.quicklogId || null
3071
+ const linkedChanges = Array.isArray(guard?.linkedChanges) ? guard.linkedChanges : []
3072
+
3073
+ // 审计:仅在有 guard 时跑(brownfield 无 guard 跳过,兼容 D-003 brownfield 行为)。
3074
+ if (guard) {
3075
+ // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
3076
+ // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
3077
+ // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
3078
+ const mergedGuard = {
3079
+ ...guard,
3080
+ forceBaseline: guard.forceBaseline || isForceBaseline,
3081
+ allowNew: guard.allowNew || isAllowNew,
3082
+ }
3083
+ const review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
3084
+ printQuickAuditReview(review)
3085
+ if (review.status === 'blocked') {
3086
+ steps[currentIdx].status = 'pending'
3087
+ steps[currentIdx].completedAt = null
3088
+ if (outputText) steps[currentIdx].output = null
3089
+ process.exit(1)
3057
3090
  }
3058
- // 清理:guard 命中与缺失两种情况都执行(guard 缺失 → 跳过审计,仅清理不抛错)。
3059
- // rmSync {recursive,force} / unlinkSync 都容忍文件不存在(brownfield)。
3091
+ progress.lastQuickReview = review
3092
+ }
3093
+
3094
+ if (!qlId) {
3095
+ // 无 ql-ID(guard 缺失或 brownfield 无 quicklogId):补分配后立即完成,不阻断。
3060
3096
  try {
3061
- const { unlinkSync } = await import('fs')
3062
- if (changeName) {
3063
- const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
3064
- rmSync(sessionDir, { recursive: true, force: true })
3065
- }
3066
- if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
3067
- } catch {}
3097
+ const alloc = await allocateQuicklogEntry(specBase, gitUser, {
3098
+ description: guard?.taskDescription || '(补分配)',
3099
+ linkedChanges,
3100
+ allowedFiles: Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [],
3101
+ })
3102
+ qlId = alloc.qlId
3103
+ console.log(`📝 QUICKLOG 兜底补写: ${qlId}(guard 缺失/brownfield 会话)`)
3104
+ } catch (e) {
3105
+ console.error(`\n❌ QUICKLOG 补分配失败: ${e.message}`)
3106
+ steps[currentIdx].status = 'pending'
3107
+ steps[currentIdx].completedAt = null
3108
+ if (outputText) steps[currentIdx].output = null
3109
+ process.exit(1)
3110
+ }
3111
+ }
3112
+ if (!findQuicklogEntry(specBase, gitUser, qlId)) {
3113
+ console.error(`\n❌ quick 阶段完成校验失败:QUICKLOG 条目 ${qlId} 不存在。`)
3114
+ console.error(` 会话期间记录被删除或从未写入。请检查 .sillyspec/quicklog/ 后重跑 --done。`)
3115
+ steps[currentIdx].status = 'pending'
3116
+ steps[currentIdx].completedAt = null
3117
+ if (outputText) steps[currentIdx].output = null
3118
+ process.exit(1)
3068
3119
  }
3120
+ // 翻状态进行中→已完成 + 追加结果 + 勾选关联 tasks.md
3121
+ try {
3122
+ await completeQuicklogEntry(specBase, gitUser, qlId, {
3123
+ resultText: outputText ? outputText.slice(0, 500) : '',
3124
+ linkedChanges,
3125
+ })
3126
+ console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
3127
+ } catch (e) {
3128
+ console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
3129
+ }
3130
+
3131
+ // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。
3132
+ try {
3133
+ const { unlinkSync } = await import('fs')
3134
+ if (changeName) {
3135
+ const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
3136
+ rmSync(sessionDir, { recursive: true, force: true })
3137
+ }
3138
+ if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
3139
+ } catch {}
3069
3140
  }
3070
3141
 
3071
3142
  stageData.status = 'completed'
@@ -28,38 +28,15 @@ export const definition = {
28
28
  9. 根据任务描述初步判断可能涉及的模块
29
29
  10. 读取匹配到的 \`{SPEC_ROOT}/docs/<project>/modules/<module>.md\`
30
30
 
31
- ### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
32
- 理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
31
+ ### QUICKLOG 记录(CLI 已接管,无需你手动创建)
32
+ 本 quick 会话的 ql-ID 由 CLI 在启动时分配,已注入为 \`<quicklog-id>\`。CLI 同时已自动:
33
+ - 在 \`{SPEC_ROOT}/quicklog/QUICKLOG-<git-user>.md\` 写入「进行中」条目(含关联变更与预估文件)
34
+ - 对每个关联变更 \`<c>\`:在 \`{SPEC_ROOT}/changes/<c>/tasks.md\` 追加未勾选 task \`- [ ] <quicklog-id> <任务描述>\`
33
35
 
34
- **A. 始终创建 QUICKLOG(无论是否关联变更)**
35
- 1. 使用预注入的 git 用户名:\`<git-user>\`
36
- 2. 创建/追加 \`{SPEC_ROOT}/quicklog/QUICKLOG-\`<git-user>\`.md\`,写入:
37
- \`\`\`
38
- ## ql-<YYYYMMDD>-<NNN>-<XXXX> | <now-datetime> | <一句话任务描述>
39
- 状态:进行中
40
- 关联变更:<linked-changes>
41
- 文件:<预估要改的文件>
42
- \`\`\`
43
- - ID 格式:\`ql-YYYYMMDD-NNN-XXXX\`
44
- - \`XXXX\` 是 4 位随机十六进制字符(如 a3f2、b7c1、00ef),**不是描述词缩写**
45
- - 追加前扫描文件中已有的 \`ql-<当天日期>-\` 前缀的最大序号,+1 作为新序号
46
- - 每天从 001 开始,跨日重新计数
47
- - 此 ID 可被 design.md / plan.md / archive / module 变更索引引用
48
-
49
- **B. 若关联变更(\`<linked-changes>\` 不为「(无)」)**
50
- 3. 解析 \`<linked-changes>\`(逗号分隔的变更名列表,可能有多个 = 一次 quick 同时归属多个变更)
51
- 4. 对**每个**变更 \`<c>\`:在 \`{SPEC_ROOT}/changes/<c>/tasks.md\` 追加一条未勾选 task:
52
- \`- [ ] <ql-ID> <一句话任务描述>\`(复用步骤 A 生成的同一个 ql-ID)
53
- - 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p {SPEC_ROOT}/changes/<c>\` 再创建 tasks.md
54
- - 同一个 ql-ID 出现在 QUICKLOG 和所有关联变更的 tasks.md 中,便于交叉引用
55
- - \`<linked-changes>\` 为「(无)」时跳过本步,仅保留 QUICKLOG
56
-
57
- 这样 Gate 检测到 \`{SPEC_ROOT}/\` 下有变更,就不会拦截后续的代码修改。
36
+ **你不要创建或修改任何 QUICKLOG / tasks.md 记录**——完成本任务时 CLI 会自动将条目翻为「已完成」并勾选 task(含轮转)。\`<quicklog-id>\` 可用于 design.md / plan.md / archive / 模块变更索引引用。
58
37
 
59
38
  ### 输出
60
- quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
61
-
62
- ⚠️ **先创建 quicklog,再输出任务理解。** 如果 quicklog 未创建,CLI post-check 会报 warning。`,
39
+ 任务理解 + 上下文摘要`,
63
40
  outputHint: '任务理解',
64
41
  optional: false
65
42
  },
@@ -97,23 +74,20 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
97
74
  2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
98
75
  - 禁止使用 \`git add -A\`
99
76
  - 不要暂存 quick 开始前就已存在的无关改动
100
- 3. 更新 Step 1 创建的记录:
101
- - QUICKLOG:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要(**始终做**,无论是否关联变更)
102
- - 关联变更:对 Step 1 在每个 \`changes/<c>/tasks.md\` 写入的 ql-ID task,将其 checkbox 由 \`- [ ]\` 勾选为 \`- [x]\`;若 Step 1 未写入(用户事后才决定关联),则按 Step 1 的格式补写并直接勾选
103
- 4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
104
- 5. 如果发现项目特有的坑,追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`
105
- 6. 任务比预期复杂 → 建议用完整流程
77
+ 3. QUICKLOG / tasks.md 记录由 CLI 在本 step 完成时自动收尾(翻「已完成」+ 勾选 task + 轮转),你无需手动更新
78
+ 4. 如果发现项目特有的坑,追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`
79
+ 5. 任务比预期复杂 → 建议用完整流程
106
80
 
107
81
  ### 模块文档同步
108
- 7. 读取 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
109
- 8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
110
- 9. 如果命中模块 → 直接同步模块文档:
82
+ 6. 读取 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
83
+ 7. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
84
+ 8. 如果命中模块 → 直接同步模块文档:
111
85
  - 读取对应的 \`{SPEC_ROOT}/docs/<project>/modules/<module>.md\`(如不存在则新建)
112
- - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
113
- - 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
86
+ - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本步骤预注入的 ql-ID)
87
+ - 变更索引格式:\`- <quicklog-id> | <一句话描述>\`
114
88
  - 写入模块文档
115
89
  - 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
116
- 10. 未命中任何模块 → 跳过,不做额外操作
90
+ 9. 未命中任何模块 → 跳过,不做额外操作
117
91
 
118
92
  ### 输出
119
93
  暂存确认 + 记录路径 + 模块文档同步结果(如有)`,
package/src/worktree.js CHANGED
@@ -69,6 +69,79 @@ function gitQuiet(cwd, args) {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * 检测 worktree base(主仓库 HEAD)与 origin 默认分支的同步状态(只读:不 ff、不改 HEAD)。
74
+ * 替代旧的 fetch + merge --ff-only:旧逻辑分叉时 ff 失败被静默吞(syncStatus=failed),
75
+ * 成功时 baseHash 锚点又不更新(ff 引入的 main 内容污染 apply patch)。现改为只检测+报告,
76
+ * 不阻断 create,对齐 origin 的动作留给用户/agent。
77
+ *
78
+ * @param {string} cwd 主仓库目录
79
+ * @param {string} baseHash worktree base commit(= 主仓库 HEAD)
80
+ * @returns {{ status: 'up-to-date'|'behind'|'diverged'|'ahead'|'unknown', defaultBranch: string|null, behind: number, ahead: number }}
81
+ */
82
+ export function computeBaseSync(cwd, baseHash) {
83
+ const diag = { status: 'unknown', defaultBranch: null, behind: 0, ahead: 0 };
84
+ // 推断默认分支:优先 origin/HEAD 指向的真实分支名(修复旧 339-342 的运算符优先级 bug
85
+ // ——旧代码在 origin/HEAD 存在时恒返回 'main',丢弃 symbolic-ref 的真实结果)。
86
+ const headRef = gitQuiet(cwd, 'symbolic-ref refs/remotes/origin/HEAD --short');
87
+ let defaultBranch = headRef ? headRef.replace('origin/', '') : null;
88
+ if (!defaultBranch) {
89
+ // fallback:origin/HEAD 未设置时,探测常见默认分支是否存在
90
+ for (const cand of ['main', 'master']) {
91
+ if (gitQuiet(cwd, `rev-parse --verify --quiet refs/remotes/origin/${cand}`)) {
92
+ defaultBranch = cand;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ if (!defaultBranch) return diag; // 无 origin 或推不出默认分支 → unknown
98
+ diag.defaultBranch = defaultBranch;
99
+
100
+ // best-effort fetch(只更新 remote-tracking,不改工作区/HEAD/分支;失败静默降级用缓存)
101
+ gitQuiet(cwd, 'fetch origin --quiet');
102
+ const remoteHead = gitQuiet(cwd, `rev-parse --verify --quiet refs/remotes/origin/${defaultBranch}`);
103
+ if (!baseHash || !remoteHead) return diag;
104
+ if (baseHash === remoteHead) {
105
+ diag.status = 'up-to-date';
106
+ return diag;
107
+ }
108
+ const behind = Number(gitQuiet(cwd, `rev-list --count ${baseHash}..${remoteHead}`) || 0);
109
+ const ahead = Number(gitQuiet(cwd, `rev-list --count ${remoteHead}..${baseHash}`) || 0);
110
+ diag.behind = behind;
111
+ diag.ahead = ahead;
112
+ diag.status = (behind > 0 && ahead > 0) ? 'diverged' : (behind > 0 ? 'behind' : 'ahead');
113
+ return diag;
114
+ }
115
+
116
+ /**
117
+ * 把 base 同步检测结果打印到 stdout(醒目但不阻断 create)。
118
+ * up-to-date 静默不刷屏;其余给风险等级 + 对齐命令,由用户/agent 决定是否处理。
119
+ */
120
+ function printSyncReport(diag, baseHash, changeName) {
121
+ const short = (baseHash || '').slice(0, 8);
122
+ if (diag.status === 'up-to-date') return;
123
+ if (diag.status === 'unknown') {
124
+ console.log(`ℹ️ base 同步检测:无 origin 远端或未设置默认分支,跳过(base ${short})`);
125
+ return;
126
+ }
127
+ const b = diag.defaultBranch;
128
+ const ch = changeName || '<change>';
129
+ if (diag.status === 'behind') {
130
+ console.log(`⚠️ base 同步检测:落后 origin/${b} ${diag.behind} 个 commit(base ${short})`);
131
+ console.log(` execute 期间若主仓库对齐 main,apply 回来可能冲突。建议先对齐:`);
132
+ console.log(` git merge --ff-only origin/${b}`);
133
+ console.log(` sillyspec worktree cleanup ${ch} && sillyspec run execute --change ${ch}`);
134
+ console.log(` 或继续,apply 时用 --merge 降级(会引入合并提交)。`);
135
+ } else if (diag.status === 'diverged') {
136
+ console.log(`⚠️ base 同步检测:与 origin/${b} 分叉(领先 ${diag.ahead} / 落后 ${diag.behind},base ${short})`);
137
+ console.log(` apply 回 main 大概率冲突。建议先在主仓库对齐(rebase 或 merge origin/${b}),`);
138
+ console.log(` 再 sillyspec worktree cleanup ${ch} + 重跑 execute。`);
139
+ } else {
140
+ // ahead:本地有未 push 的提交,无 apply 冲突风险
141
+ console.log(`ℹ️ base 同步检测:领先 origin/${b} ${diag.ahead} 个 commit(本地有未 push 的提交,无冲突风险)`);
142
+ }
143
+ }
144
+
72
145
  function parseJSON(raw) {
73
146
  try { return JSON.parse(raw); } catch { return null; }
74
147
  }
@@ -328,37 +401,12 @@ export class WorktreeManager {
328
401
  writeFileSync(join(worktreePath, META_FILE), JSON.stringify(placeholderMeta, null, 2) + '\n');
329
402
  } catch {}
330
403
 
331
- // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
332
- let syncStatus = 'ok';
333
- let syncError = null;
334
- try {
335
- // 先 fetch origin
336
- gitQuiet(worktreePath, 'fetch origin');
337
-
338
- // 尝试 merge origin/main(或 origin/master)到 worktree 分支
339
- const defaultBranch = gitQuiet(this.cwd, 'symbolic-ref refs/remotes/origin/HEAD --short')?.replace('origin/', '')
340
- || gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/main') ? 'main'
341
- : gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/master') ? 'master'
342
- : null;
343
-
344
- if (defaultBranch) {
345
- // 检查 worktree 是否落后于远程
346
- const localHead = gitQuiet(worktreePath, 'rev-parse HEAD');
347
- const remoteHead = gitQuiet(worktreePath, `rev-parse origin/${defaultBranch}`);
348
-
349
- if (localHead && remoteHead && localHead !== remoteHead) {
350
- // 检查是否有共同祖先(避免完全不相关的分支强行 merge)
351
- const mergeBase = gitQuiet(worktreePath, `merge-base ${localHead} origin/${defaultBranch}`);
352
- if (mergeBase) {
353
- git(worktreePath, `merge origin/${defaultBranch} --ff-only`);
354
- }
355
- }
356
- }
357
- } catch (e) {
358
- syncStatus = 'failed';
359
- syncError = e.message || String(e);
360
- console.warn(`⚠️ worktree 远程同步失败:${syncError}`);
361
- }
404
+ // 5.5 base 同步检测(只读:不 ff、不改 HEAD;best-effort fetch 失败降级用缓存)。
405
+ // 旧逻辑 fetch + merge --ff-only:分叉时 ff 失败被静默吞(syncStatus=failed),成功时
406
+ // baseHash 锚点又不更新(ff 引入的 main 内容污染 apply patch)。现改为只检测 + 报告,
407
+ // 不阻断 create,对齐 origin 的动作留给用户/agent。
408
+ const syncDiagnostic = computeBaseSync(this.cwd, baseHash);
409
+ printSyncReport(syncDiagnostic, baseHash, name);
362
410
 
363
411
  // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
364
412
  const baselineResult = this._overlayBaseline(this.cwd, worktreePath);
@@ -394,8 +442,7 @@ export class WorktreeManager {
394
442
  baselineFiles,
395
443
  baselineCommit,
396
444
  baselineHash,
397
- syncStatus,
398
- ...(syncError ? { syncError } : {}),
445
+ syncDiagnostic,
399
446
  depsStatus: deps.depsStatus,
400
447
  depsMethod: deps.depsMethod || null,
401
448
  depsSource: deps.depsSource || null,
@@ -407,7 +454,7 @@ export class WorktreeManager {
407
454
  const metaPath = join(worktreePath, META_FILE);
408
455
  writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
409
456
 
410
- return { branch, worktreePath, baseHash, mode: meta.mode };
457
+ return { branch, worktreePath, baseHash, mode: meta.mode, syncDiagnostic };
411
458
  }
412
459
 
413
460
  /**