sillyspec 3.33.7 → 3.33.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.33.7",
3
+ "version": "3.33.8",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -106,12 +106,23 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
106
106
 
107
107
  const dirFiles = dirExists ? countFiles(dirPath) : 0;
108
108
  const worktreeInfo = await probeWorktree(cwd, changeName);
109
+ // 影子存档探测(2026-10-28-shadow-checkpoint):dry-run 也报存档链规模——「删了丢多少存档」
110
+ // 与「删了丢代码」同级的决策信息。非 git 仓/无 ref 静默 null。
111
+ let checkpointInfo = null;
112
+ try {
113
+ const { hasCheckpointRef, listCheckpoints } = await import('./checkpoint.js');
114
+ if (hasCheckpointRef(cwd, changeName)) {
115
+ const list = await listCheckpoints({ repoRoot: cwd, changeName });
116
+ checkpointInfo = { ref: `refs/sillyspec/checkpoints/${changeName}`, count: list.length };
117
+ }
118
+ } catch { /* 非 git 仓/探测失败:无存档面 */ }
109
119
 
110
120
  if (!confirm) {
111
121
  return {
112
122
  ok: true, action: 'dry_run', change: changeName,
113
123
  db_row: row, dir: dirExists ? { path: dirPath, files: dirFiles } : null,
114
124
  worktree: worktreeInfo,
125
+ checkpoints: checkpointInfo,
115
126
  already_deleted: alreadyDeleted,
116
127
  };
117
128
  }
@@ -149,6 +160,18 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
149
160
  } catch (e) {
150
161
  warnings.push(`worktree 清理失败(不阻断删除):${e.message}`);
151
162
  }
163
+ // 3.5 影子存档 ref 清理(2026-10-28-shadow-checkpoint FR-05):删除是终态,存档链随变更
164
+ // 一并清 ref(对象交 git gc 自然回收);归档不删(审计面保留)。best-effort:失败仅警告。
165
+ let checkpointsRemoved = false;
166
+ try {
167
+ const { hasCheckpointRef, deleteCheckpointRef } = await import('./checkpoint.js');
168
+ if (hasCheckpointRef(cwd, changeName)) {
169
+ checkpointsRemoved = deleteCheckpointRef(cwd, changeName);
170
+ if (!checkpointsRemoved) warnings.push(`影子存档 ref 删除失败(不阻断,可手删 refs/sillyspec/checkpoints/${changeName})`);
171
+ }
172
+ } catch (e) {
173
+ warnings.push(`影子存档 ref 清理异常(不阻断删除):${e.message}`);
174
+ }
152
175
  // 4. git 暂存目录删除(best-effort:非 git 仓 / specBase 在仓外时跳过,git status 照常可见)
153
176
  try {
154
177
  const relDir = relative(cwd, dirPath).split('\\').join('/');
@@ -178,6 +201,6 @@ export async function deleteChange({ cwd, specDir = null, changeName, confirm =
178
201
  return {
179
202
  ok: true, action: 'deleted', change: changeName,
180
203
  db_updated: dbUpdated, dir_removed: dirExists, files_removed: dirFiles,
181
- worktree: worktreeInfo, warnings,
204
+ worktree: worktreeInfo, checkpoints_removed: checkpointsRemoved, warnings,
182
205
  };
183
206
  }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * checkpoint.js — 任务级影子提交存档(tick 即快照,2026-10-28-shadow-checkpoint)
3
+ *
4
+ * 定位:断点恢复从「进度级」(tasks.md 记做到哪)升级到「内容级」(工作区长什么样)——
5
+ * task tick 成功翻格后落一个 git 影子提交到 refs/sillyspec/checkpoints/<change>,agent
6
+ * 跑歪/改崩时 git restore --source 一键读档。git 对象库本身是内容寻址存储:增量成本=
7
+ * 只哈希变化文件,相同内容全局一份——零新依赖即得 CAS 快照性质。
8
+ *
9
+ * 机制(多 agent 共享仓安全是第一约束):
10
+ * - 临时索引 GIT_INDEX_FILE=.git/sillyspec-cp-index-<session>(会话隔离)——read-tree HEAD
11
+ * 起底 → add -A(硬排除清单)→ write-tree → commit-tree 成链(父=上一存档,首档父=
12
+ * 当时 HEAD)→ update-ref。共享 index 全程零读写(staged 面不可能被卷走,规则 11 纪律)。
13
+ * - 无变化 no-op:write-tree 结果与现 ref 树 SHA 相同即跳过(零对象零 ref 翻转)。
14
+ * - fail-open 铁律:本模块任何失败只返回 skipped(reason 留痕),绝不抛出阻断 tick。
15
+ *
16
+ * 边界:worktree 会话跳过(v1 主仓形态——git-dir ≠ common-dir 判定);未忽略 untracked
17
+ * 总量超阈值(缺省 200MB,checkpoint.maxUntrackedMB)早停跳过;.sillyspec/.runtime 与
18
+ * 常见产物目录/.env* 硬排除不进快照(gitignore 之外的第二道防线)。
19
+ */
20
+ import { existsSync, readFileSync, rmSync, statSync } from 'fs'
21
+ import { join, resolve as resolvePath } from 'path'
22
+ import { git, gitQuiet } from './git-helper.js'
23
+
24
+ const REF_PREFIX = 'refs/sillyspec/checkpoints/'
25
+ const DEFAULT_MAX_UNTRACKED_MB = 200
26
+ const SUBJECT_PREFIX = 'sillyspec-cp: '
27
+ const NOOP_GUARD_TREE_TIMEOUT_MS = 60000
28
+
29
+ /** 硬排除清单(add 后在临时索引上 reset 的路径)。目录用 glob 双星任意深度形态
30
+ * (:(glob) 魔法 + 前置双星斜杠 + 尾部斜杠双星——实证 2.45:缺尾部 /** 时零匹配,
31
+ * 根级与嵌套 packages/x/node_modules 一并排除,2026-10-28-cp-review-debt FR-01);
32
+ * .env* 与 .sillyspec/.runtime 维持根级语义。 */
33
+ const EXCLUDE_DIRS = ['node_modules', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage']
34
+ const RESET_SPECS = [...EXCLUDE_DIRS.map((d) => `:(glob)**/${d}/**`), '.env', '.env.*', '.sillyspec/.runtime']
35
+
36
+ /** 与 EXCLUDE_SPECS 同口径的 JS 判定(体积护栏用——只计真会进快照的文件)。 */
37
+ function isHardExcluded(p) {
38
+ const segs = p.split('/')
39
+ if (segs.some((s) => EXCLUDE_DIRS.includes(s))) return true
40
+ const root = segs[0]
41
+ if (root === '.env' || /^\.env\..+$/.test(root)) return true
42
+ if (p === '.sillyspec/.runtime' || p.startsWith('.sillyspec/.runtime/')) return true
43
+ return false
44
+ }
45
+
46
+ const SAFE_CHANGE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
47
+
48
+ /**
49
+ * 读 .sillyspec/local.yaml 的 checkpoint 块(手解析单一键路径,沿 worktree supplyFiles
50
+ * 先例零 yaml 依赖)。fail-open:文件缺失/解析异常一律返回缺省(enabled=true)。
51
+ * @returns {{enabled: boolean, maxUntrackedMB: number}}
52
+ */
53
+ export function readCheckpointConfig(repoRoot) {
54
+ const defaults = { enabled: true, maxUntrackedMB: DEFAULT_MAX_UNTRACKED_MB }
55
+ try {
56
+ const p = join(repoRoot, '.sillyspec', 'local.yaml')
57
+ if (!existsSync(p)) return defaults
58
+ const vals = {}
59
+ let inBlock = false
60
+ for (const rawLine of readFileSync(p, 'utf8').split(/\r?\n/)) {
61
+ const line = rawLine.replace(/\t/g, ' ')
62
+ if (/^checkpoint:\s*(#.*)?$/.test(line)) { inBlock = true; continue }
63
+ if (!inBlock) continue
64
+ if (/^\S/.test(line)) break // 出块:下一个顶层键
65
+ const m = line.match(/^\s+([\w.-]+):\s*(.*?)\s*(#.*)?$/)
66
+ if (m && m[2] !== '') vals[m[1]] = m[2]
67
+ }
68
+ const enabled = vals.enabled === undefined ? true : !/^(false|off|no|0)$/i.test(String(vals.enabled))
69
+ const mb = Number(vals.maxUntrackedMB)
70
+ return { enabled, maxUntrackedMB: Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_MAX_UNTRACKED_MB }
71
+ } catch {
72
+ return defaults
73
+ }
74
+ }
75
+
76
+ /** 主仓判定:worktree 会话(git-dir ≠ common-dir)v1 跳过存档。 */
77
+ function isWorktreeSession(repoRoot) {
78
+ const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
79
+ const commonDir = gitQuiet(repoRoot, ['rev-parse', '--git-common-dir'])
80
+ if (!gitDir || !commonDir) return false // 探测失败不拦(留给后续 git 调用自然 fail-open)
81
+ if (commonDir === '.git') return false // 主仓:两者同指
82
+ try {
83
+ return resolvePath(repoRoot, gitDir) !== resolvePath(repoRoot, commonDir)
84
+ } catch {
85
+ return gitDir !== commonDir
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 落一个影子提交(fail-open:任何失败返回 {kind:'skipped', reason},绝不抛出)。
91
+ * @param {{repoRoot: string, changeName: string, session?: string, taskId?: string}} opts
92
+ * @returns {Promise<{kind:'created'|'noop'|'skipped', sha?: string, treeSha?: string,
93
+ * parent?: string, files?: number, reason?: string, detail?: string}>}
94
+ */
95
+ export async function createCheckpoint({ repoRoot, changeName, session, taskId }) {
96
+ const name = String(changeName || '')
97
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return { kind: 'skipped', reason: 'invalid-change-name', detail: name }
98
+ const cfg = readCheckpointConfig(repoRoot)
99
+ if (!cfg.enabled) return { kind: 'skipped', reason: 'checkpoint-disabled' }
100
+ if (isWorktreeSession(repoRoot)) return { kind: 'skipped', reason: 'worktree-session' }
101
+
102
+ const ref = REF_PREFIX + name
103
+ const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
104
+ if (!gitDir) return { kind: 'skipped', reason: 'not-a-git-repo' }
105
+ const sessionId = String(session || process.env.SILLYSPEC_SESSION_ID || 'anon').replace(/[^A-Za-z0-9._-]/g, '_')
106
+ const tmpIndex = join(gitDir, `sillyspec-cp-index-${sessionId}`)
107
+ // env 展开铁律(git-helper 终批-③):裸替换丢 SystemRoot/TEMP 在 Windows 必炸
108
+ const idxEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex }
109
+ const head = gitQuiet(repoRoot, ['rev-parse', 'HEAD'])
110
+ if (!head) return { kind: 'skipped', reason: 'no-head-commit' }
111
+
112
+ try {
113
+ rmSync(tmpIndex, { force: true }) // 清上次中断残留(同会话同路径幂等)
114
+ git(repoRoot, ['read-tree', 'HEAD'], { env: idxEnv })
115
+
116
+ // 体积护栏:只计真会进快照的 untracked(硬排除同口径),累计超限早停
117
+ const maxBytes = cfg.maxUntrackedMB * 1024 * 1024
118
+ const othersRaw = gitQuiet(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z'])
119
+ if (othersRaw) {
120
+ let bytes = 0
121
+ for (const p of othersRaw.split('\0')) {
122
+ if (!p || isHardExcluded(p)) continue
123
+ try {
124
+ bytes += statSync(join(repoRoot, p)).size
125
+ } catch { /* 文件瞬失:计 0 不拦 */}
126
+ if (bytes > maxBytes) return { kind: 'skipped', reason: 'untracked-size-over-limit', detail: `${Math.round(bytes / 1048576)}MB > ${cfg.maxUntrackedMB}MB` }
127
+ }
128
+ }
129
+
130
+ // 裸 add -A(无 pathspec):ignored 文件静默跳过——显式 pathspec 形态遇 gitignored
131
+ // 候选会整体报错拒加("paths are ignored by .gitignore",.sillyspec/ 被忽略的仓必炸)。
132
+ // 硬排除随后以 reset 收窄:临时索引上把排除路径退回 HEAD 态(不在 HEAD 的即整条移出)。
133
+ git(repoRoot, ['add', '-A'], { env: idxEnv, timeout: NOOP_GUARD_TREE_TIMEOUT_MS })
134
+ git(repoRoot, ['reset', '-q', '--', ...RESET_SPECS], { env: idxEnv })
135
+ const treeSha = git(repoRoot, ['write-tree'], { env: idxEnv })
136
+
137
+ const current = gitQuiet(repoRoot, ['rev-parse', '--verify', ref])
138
+ if (current) {
139
+ const currentTree = gitQuiet(repoRoot, ['rev-parse', `${ref}^{tree}`])
140
+ if (currentTree === treeSha) return { kind: 'noop', treeSha } // 无变化:零对象零翻转
141
+ }
142
+
143
+ const parent = current || head
144
+ const subject = `${SUBJECT_PREFIX}${name} ${taskId || '-'} ${new Date().toISOString()}`
145
+ const message = `${subject}\n\nshadow checkpoint for ${name}\ntask: ${taskId || '-'}\nsession: ${sessionId}\n`
146
+ // 身份兜底(沿 worktree baseline checkpoint 先例):未配置 user.email 时注入临时身份,
147
+ // 已配置则用真实身份——存档提交可归属
148
+ const cpEnv = { ...process.env }
149
+ if (!gitQuiet(repoRoot, ['config', 'user.email'])) {
150
+ cpEnv.GIT_AUTHOR_NAME = 'sillyspec-checkpoint'
151
+ cpEnv.GIT_AUTHOR_EMAIL = 'checkpoint@sillyspec.local'
152
+ cpEnv.GIT_COMMITTER_NAME = 'sillyspec-checkpoint'
153
+ cpEnv.GIT_COMMITTER_EMAIL = 'checkpoint@sillyspec.local'
154
+ }
155
+ const sha = git(repoRoot, ['commit-tree', treeSha, '-p', parent, '-F', '-'], { input: message, env: cpEnv })
156
+ git(repoRoot, ['update-ref', ref, sha])
157
+
158
+ let files = 0
159
+ const changed = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
160
+ if (changed) files = changed.split('\n').filter(Boolean).length
161
+ return { kind: 'created', sha, treeSha, parent, files }
162
+ } catch (e) {
163
+ return { kind: 'skipped', reason: 'git-error', detail: e && e.message ? e.message : String(e) }
164
+ } finally {
165
+ try { rmSync(tmpIndex, { force: true }) } catch { /* 清理失败:同会话下次覆盖 */ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 列出变更的存档链(按时间倒序;链尾混入主历史,按 subject 前缀过滤)。
171
+ * @returns {Promise<Array<{sha: string, taskId: string|null, ts: number, files: number}>>}
172
+ */
173
+ export async function listCheckpoints({ repoRoot, changeName }) {
174
+ const name = String(changeName || '')
175
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return []
176
+ const ref = REF_PREFIX + name
177
+ const log = gitQuiet(repoRoot, ['log', '--format=%H%x09%at%x09%s', '-n', '200', ref])
178
+ if (!log) return []
179
+ const out = []
180
+ for (const line of log.split('\n')) {
181
+ if (!line) continue
182
+ const [sha, at, subject] = line.split('\t')
183
+ if (!subject || !subject.startsWith(SUBJECT_PREFIX)) continue // 链尾以下的主历史止步
184
+ const m = /^sillyspec-cp: \S+ (task-\d+|-) /.exec(subject)
185
+ const files = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
186
+ out.push({ sha, taskId: m && m[1] !== '-' ? m[1] : null, ts: Number(at) * 1000, files: files ? files.split('\n').filter(Boolean).length : 0 })
187
+ }
188
+ return out
189
+ }
190
+
191
+ /**
192
+ * 恢复预览:工作区 vs 存档点的 diff(patch 与 stat 各一份)。
193
+ * @returns {Promise<{stat: string, patch: string}>}
194
+ */
195
+ export async function previewRestore({ repoRoot, sha, paths = [] }) {
196
+ const spec = ['--', ...(paths && paths.length ? paths : ['.'])]
197
+ const stat = git(repoRoot, ['diff', '--stat', sha, ...spec])
198
+ const patch = git(repoRoot, ['diff', sha, ...spec])
199
+ return { stat, patch }
200
+ }
201
+
202
+ /**
203
+ * 链内校验(无上限):subject 前缀过滤全链——不复用 listCheckpoints 的 -n 200 显示帽
204
+ * (截断解耦,2026-10-28-cp-review-debt FR-03:超帽旧档同样可读,主历史普通提交仍拒)。
205
+ */
206
+ function isCheckpointInChain(repoRoot, ref, sha) {
207
+ const subjects = gitQuiet(repoRoot, ['log', '--format=%H %s', ref])
208
+ if (!subjects) return false
209
+ for (const line of subjects.split('\n')) {
210
+ if (!line) continue
211
+ const commitSha = line.slice(0, 40)
212
+ const subject = line.slice(41)
213
+ if (!subject.startsWith(SUBJECT_PREFIX)) break // 链尾以下为主历史,止步
214
+ if (commitSha === sha) return true
215
+ }
216
+ return false
217
+ }
218
+
219
+ /**
220
+ * 读档:git restore --source(仅写工作区,零触碰 index)。调用方负责 --yes 确认纪律。
221
+ * @param {{repoRoot: string, sha: string, paths?: string[], all?: boolean}} opts
222
+ * @returns {Promise<{ok: boolean, reason?: string, restored: string[]}>}
223
+ */
224
+ export async function restoreCheckpoint({ repoRoot, sha, changeName, paths = [], all = false }) {
225
+ const name = String(changeName || '')
226
+ const ref = REF_PREFIX + name
227
+ if (!SAFE_CHANGE_NAME_RE.test(name) || !isCheckpointInChain(repoRoot, ref, sha)) {
228
+ return { ok: false, reason: 'sha 不在该变更存档链中(用 checkpoint --change 列出可用 SHA)', restored: [] }
229
+ }
230
+ const target = (all || !paths.length) ? ['.'] : paths
231
+ try {
232
+ git(repoRoot, ['restore', `--source=${sha}`, '--', ...target])
233
+ return { ok: true, restored: target }
234
+ } catch (e) {
235
+ return { ok: false, reason: e && e.message ? e.message : String(e), restored: [] }
236
+ }
237
+ }
238
+
239
+ /** 删除变更的存档 ref(change-delete 清理步用;best-effort 不抛出)。 */
240
+ export function deleteCheckpointRef(repoRoot, changeName) {
241
+ const name = String(changeName || '')
242
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return false
243
+ try {
244
+ git(repoRoot, ['update-ref', '-d', REF_PREFIX + name])
245
+ return true
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+
251
+ /** ref 存在性探测(dry-run 报告面用)。 */
252
+ export function hasCheckpointRef(repoRoot, changeName) {
253
+ const name = String(changeName || '')
254
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return false
255
+ return Boolean(gitQuiet(repoRoot, ['rev-parse', '--verify', REF_PREFIX + name]))
256
+ }
257
+
258
+ export default { createCheckpoint, listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef, hasCheckpointRef, readCheckpointConfig }
@@ -78,6 +78,15 @@ export const LOCAL_YAML_SCHEMA = {
78
78
  { path: 'commands.scan:check', type: 'string', optional: true, status: 'live', readers: ['scan 漂移检查(scripts/scan-drift-check.py 经 scan 流程调用)'], desc: '【2026-10-23-schema-registration-debt 平台仓 dogfood 补登】scan 产物漂移自检命令。', example: 'python scripts/scan-drift-check.py' },
79
79
  ],
80
80
  },
81
+ {
82
+ id: 'checkpoint',
83
+ title: '影子存档(task tick 快照)',
84
+ note: '机制先落代码缺省(fail-open,缺省零回归)——键只用于仓主显式调优(2026-10-28-shadow-checkpoint)。',
85
+ keys: [
86
+ { path: 'checkpoint.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '影子存档开关——task tick 成功翻格后落 git 影子提交到 refs/sillyspec/checkpoints/<change>(临时索引零触碰共享暂存区,断点恢复从进度级升到内容级)。缺省 true;false 关闭(tick 照常)。解析失败 fail-open 取缺省。', example: 'false' },
87
+ { path: 'checkpoint.maxUntrackedMB', type: 'number', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '体积护栏——未忽略 untracked 总量超过该 MB 数时跳过本次存档并留痕(早停判定,不遍历全树;硬排除目录不计入)。缺省 200。', example: '50' },
88
+ ],
89
+ },
81
90
  {
82
91
  id: 'knowledge',
83
92
  title: '知识检索(平台向量语义召回)',
@@ -425,6 +434,11 @@ commands:
425
434
  # lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
426
435
  # knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
427
436
 
437
+ # ── 影子存档(task tick 快照;缺省开启零配置,仅在显式调优时写)──
438
+ # checkpoint:
439
+ # enabled: true # 影子存档开关(false 关闭,tick 照常)
440
+ # maxUntrackedMB: 200 # 体积护栏——未忽略 untracked 总量超此 MB 数跳过存档留痕
441
+
428
442
  # ── 知识检索(平台向量语义召回)──
429
443
  # 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
430
444
  # 启用,零配置)。仅显式逃生阀时才需要写:
package/src/flow.js CHANGED
@@ -2942,6 +2942,13 @@ async function cmdFlowVerifyDrafts({ change, cwd, specBase, runtimeRootOpt = nul
2942
2942
  for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
2943
2943
  if (r.degraded) console.log(`ℹ️ 自查降级:draft-ledger 不在此机(.runtime 机本位/存量变更)——空槽兜底校验通过(v2 纯 markdown 无法对锚校验,如实降级)`)
2944
2944
  console.log(`✅ spec 面自查通过(0 violations)——通过≠批准:请把摘要给用户看,用户确认后 sillyspec flow approve --change ${change}(批准留痕后动码)`)
2945
+ // 重叠路由 advisory(2026-10-28-overlap-route FR-04):批准前最后一个高频触点——当前
2946
+ // 变更声明与他人活跃变更相交时一行分流提示。fail-open 静默:检测异常不拦批准不影响退出码。
2947
+ try {
2948
+ const { overlapAdvisoryFor } = await import('./overlap-detect.js')
2949
+ const line = overlapAdvisoryFor(change, { cwd, specBase })
2950
+ if (line) console.warn(line)
2951
+ } catch { /* advisory 静默:检测失败不拦批准 */ }
2945
2952
  } else {
2946
2953
  for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
2947
2954
  if (hasViolations) {
@@ -174,7 +174,8 @@ export function collectForeignDeclaredFiles(cwd, currentChangeName, opts = {}) {
174
174
  * (退回旧 foreign-first 行为,fail-closed 不放大放行面)。
175
175
  * @returns {Set<string>} 正斜杠归一后的声明路径集
176
176
  */
177
- function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
177
+ // export 补齐(2026-10-28-overlap-route):重叠检测复用声明加载单一源,防口径漂移
178
+ export function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
178
179
  const set = new Set()
179
180
  if (!currentChangeName) return set
180
181
  const norm = (p) => String(p || '').replace(/\\/g, '/')
package/src/index.js CHANGED
@@ -148,6 +148,11 @@ SillySpec CLI — 规范驱动开发工具包
148
148
  sillyspec change-rename <旧变更名> <新变更名>
149
149
  sillyspec change-delete <变更名> [--confirm] 删除变更(DB status='deleted' 与归档语义分离 +
150
150
  移目录 + 清 worktree + 推平台墓碑;默认 dry-run)
151
+ sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha>
152
+ [--path <p>|--all] --yes] [--delete-ref]
153
+ 影子存档面:列出 task tick 自动落的存档链,预览/按路径读档
154
+ sillyspec overlap 查询活跃变更 target-file 声明重叠(advisory——检出即附
155
+ worktree 分流与落地序列指引)
151
156
  sillyspec knowledge <search --query "..." --limit N
152
157
  | inspect --id "..."
153
158
  | validate | refresh
@@ -5055,6 +5060,96 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
5055
5060
  process.exitCode = r.ok ? 0 : 1;
5056
5061
  break;
5057
5062
  }
5063
+ case 'checkpoint': {
5064
+ // 2026-10-28-shadow-checkpoint:影子存档面——task tick 自动落档(refs/sillyspec/
5065
+ // checkpoints/<change>),本动词提供列出/预览/读档/清 ref 的人工面。恢复纪律(FR-03):
5066
+ // --restore 必须显式 --yes;先 --diff 同参预览再执行是推荐路径。
5067
+ const cpVal = (flag) => {
5068
+ const i = filteredArgs.indexOf(flag);
5069
+ return i >= 0 && filteredArgs[i + 1] && !String(filteredArgs[i + 1]).startsWith('--')
5070
+ ? filteredArgs[i + 1] : null;
5071
+ };
5072
+ const cpChange = cpVal('--change');
5073
+ if (!cpChange) {
5074
+ console.error('❌ 用法: sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha> [--path <p>|--all] --yes] [--delete-ref]');
5075
+ process.exitCode = 2;
5076
+ break;
5077
+ }
5078
+ const { listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef } = await import('./checkpoint.js');
5079
+ const cpDiffSha = cpVal('--diff');
5080
+ const cpRestoreSha = cpVal('--restore');
5081
+ const pathArgs = [];
5082
+ for (let i = 0; i < filteredArgs.length - 1; i++) {
5083
+ if (filteredArgs[i] === '--path' && !String(filteredArgs[i + 1]).startsWith('--')) pathArgs.push(filteredArgs[i + 1]);
5084
+ }
5085
+ const cpAll = filteredArgs.includes('--all');
5086
+ const cpYes = filteredArgs.includes('--yes');
5087
+ const cpDeleteRef = filteredArgs.includes('--delete-ref');
5088
+ if (cpDeleteRef) {
5089
+ const okDel = deleteCheckpointRef(dir, cpChange);
5090
+ console.log(okDel ? `🗑️ 已删除 refs/sillyspec/checkpoints/${cpChange}` : `ℹ️ 无 refs/sillyspec/checkpoints/${cpChange}(无可删)`);
5091
+ break;
5092
+ }
5093
+ if (cpDiffSha) {
5094
+ try {
5095
+ const pv = await previewRestore({ repoRoot: dir, sha: cpDiffSha, paths: pathArgs });
5096
+ console.log(pv.stat || '(无差异)');
5097
+ if (pv.patch) console.log(pv.patch);
5098
+ } catch (e) {
5099
+ console.error(`❌ 预览失败:${e && e.message ? e.message : e}`);
5100
+ process.exitCode = 1;
5101
+ }
5102
+ break;
5103
+ }
5104
+ if (cpRestoreSha) {
5105
+ if (!cpYes) {
5106
+ console.error(`❌ 恢复需显式 --yes(建议先 --diff 同参预览):sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p] 再 --restore <sha> --path <p> --yes`);
5107
+ process.exitCode = 2;
5108
+ break;
5109
+ }
5110
+ if (!cpAll && pathArgs.length === 0) {
5111
+ console.error('❌ 整树恢复需 --all(按路径恢复用 --path <p>,可多次)');
5112
+ process.exitCode = 2;
5113
+ break;
5114
+ }
5115
+ const rr = await restoreCheckpoint({ repoRoot: dir, sha: cpRestoreSha, changeName: cpChange, paths: pathArgs, all: cpAll });
5116
+ if (rr.ok) console.log(`🗂️ 已读档 ${cpRestoreSha.slice(0, 8)} → ${(cpAll ? ['.'] : pathArgs).join(' ')}(仅写工作区,共享暂存区未动)`);
5117
+ else { console.error(`❌ ${rr.reason}`); process.exitCode = 1; }
5118
+ break;
5119
+ }
5120
+ const list = await listCheckpoints({ repoRoot: dir, changeName: cpChange });
5121
+ if (!list.length) {
5122
+ console.log(`ℹ️ 变更 ${cpChange} 暂无影子存档(task tick 成功勾选时自动落档;checkpoint.enabled=false 时关闭)`);
5123
+ break;
5124
+ }
5125
+ console.log(`🗂️ 变更 ${cpChange} 影子存档链(${list.length} 档,新→旧):`);
5126
+ for (const c of list) {
5127
+ const when = new Date(c.ts).toISOString().replace('T', ' ').slice(0, 19);
5128
+ console.log(` ${c.sha.slice(0, 8)} ${when} ${c.taskId || '-'} ${c.files} 文件`);
5129
+ }
5130
+ console.log(`\n预览:sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p]|读档:--restore <sha> --path <p> --yes${list.length >= 200 ? '\nℹ️ 列表显示截 200 档(仅展示面)——全量走 git:git log --format="%H %s" refs/sillyspec/checkpoints/' + cpChange : ''}`);
5131
+ break;
5132
+ }
5133
+ case 'overlap': {
5134
+ // 2026-10-28-overlap-route:target-file 声明重叠仓级查询(advisory 恒 exit 0)——
5135
+ // 事前路由信号:检出重叠附 worktree 分流与落地序列指引(不是禁止是分流)。
5136
+ const { detectDeclaredOverlaps } = await import('./overlap-detect.js');
5137
+ let overlaps = [];
5138
+ try { overlaps = detectDeclaredOverlaps({ cwd: dir, specBase: resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec') }); }
5139
+ catch (e) { console.log(`ℹ️ 重叠检测不可用(advisory 面静默退化):${e && e.message ? e.message : e}`); break; }
5140
+ if (!overlaps.length) {
5141
+ console.log(`✅ 无 target-file 声明重叠——各活跃变更声明面互不相交(落地零冲突预期)`);
5142
+ break;
5143
+ }
5144
+ console.log(`⚠️ ${overlaps.length} 个文件被多个活跃变更声明(并行改同一文件是冲突地带):`);
5145
+ for (const o of overlaps) {
5146
+ console.log(` ${o.file} ← ${o.changes.join('、')}`);
5147
+ }
5148
+ console.log(`\n🔀 分流建议:其一转 worktree 形态并行——sillyspec worktree apply <后到变更名>(隔离干活,互不可见互不干扰)。`);
5149
+ console.log(` 落地序列(既有机器):先落地者干净进主线;后落地者 apply --3way 三方合并(同文件不同区域自动过,同段冲突摆出裁决);并发落地由 main-repo.lock 串行。`);
5150
+ console.log(` 确认某方已死:归档后其声明自动退出重叠判定(归档件只读)。`);
5151
+ break;
5152
+ }
5058
5153
  case 'flow': {
5059
5154
  // R7 切片二:2-调用轻量协议命令族(start/done/amend-draft)——协议必需交互=2。
5060
5155
  // --json 全局透传(2026-10-06-flow-status-json):该 flag 在上方全局解析段被剥出
@@ -5806,7 +5901,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5806
5901
  break;
5807
5902
  }
5808
5903
  default: {
5809
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
5904
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'checkpoint', 'overlap', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
5810
5905
  const suggestion = didYouMean(command, topCommands);
5811
5906
  console.error(`❌ 未知命令: ${command}`);
5812
5907
  if (command === '--status') {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * overlap-detect.js — 活跃变更 target-file 声明重叠检测(2026-10-28-overlap-route)
3
+ *
4
+ * 定位:同 target-file 并行的事前路由信号——落地侧机器已厚(worktree apply --3way 三方
5
+ * 合并、main-repo.lock 全仓串行、guard 相交预检、base 漂移 re-anchor),缺的是干活期的
6
+ * 「两个活跃变更声明了同一文件」检测:重叠拖到落地才撞见,错失把后到者分流进 worktree
7
+ * 的窗口。本模块只做检测与呈现(advisory),不做调度不做阻断——「不是禁止,是分流」。
8
+ *
9
+ * 机制:声明加载单一源复用 foreign-declared.loadOwnDeclaredSet(design §6 文件变更
10
+ * 清单 ∪ task 卡 allowed_paths/target_files,quick 会话 guard 形态),构建文件→变更集
11
+ * 反查表,声明同一文件的变更数 ≥2 即重叠条目。瞬时快照零持久状态;归档(目录移入
12
+ * changes/archive/)天然退出反查表。
13
+ *
14
+ * 消费面:sillyspec overlap 仓级查询动词;flow verify-drafts 通过分支的路由 advisory。
15
+ */
16
+ import { readdirSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { loadOwnDeclaredSet } from './foreign-declared.js'
19
+
20
+ /**
21
+ * 检测活跃变更间的 target-file 声明重叠。
22
+ * @param {{cwd?: string, specBase?: string}} opts 二选一(specBase 优先)
23
+ * @returns {Array<{file: string, changes: string[]}>} file 正斜杠归一、changes 字典序、
24
+ * 条目按 file 排序;无重叠返回 []。任何读取失败 fail-open(该变更空集),绝不抛出。
25
+ */
26
+ export function detectDeclaredOverlaps({ cwd, specBase } = {}) {
27
+ const base = specBase || join(cwd || process.cwd(), '.sillyspec')
28
+ const runtimeRoot = join(base, '.runtime')
29
+ let names = []
30
+ try {
31
+ names = readdirSync(join(base, 'changes'), { withFileTypes: true })
32
+ .filter((e) => e.isDirectory() && e.name !== 'archive')
33
+ .map((e) => e.name)
34
+ } catch {
35
+ return [] // 无 changes/ 目录:零活跃变更
36
+ }
37
+ const byFile = new Map()
38
+ for (const name of names) {
39
+ const own = loadOwnDeclaredSet(base, runtimeRoot, name) // 内部 fail-open:解析失败空集
40
+ if (!own || own.size === 0) continue
41
+ for (const f of own) {
42
+ if (!byFile.has(f)) byFile.set(f, new Set())
43
+ byFile.get(f).add(name)
44
+ }
45
+ }
46
+ const overlaps = []
47
+ for (const [file, changes] of byFile) {
48
+ if (changes.size >= 2) overlaps.push({ file, changes: [...changes].sort() })
49
+ }
50
+ return overlaps.sort((a, b) => a.file.localeCompare(b.file))
51
+ }
52
+
53
+ /**
54
+ * 指定变更的重叠路由提示文案(verify-drafts 通过分支钩子用)。
55
+ * @returns {string|null} null=该变更无重叠(调用方零输出);有则返回一行提示
56
+ * (分流建议 + 落地序列 + 详查指引)。纯函数:检测异常由上层 try/catch fail-open。
57
+ */
58
+ export function overlapAdvisoryFor(changeName, { cwd, specBase } = {}) {
59
+ const name = String(changeName || '')
60
+ if (!name) return null
61
+ const mine = detectDeclaredOverlaps({ cwd, specBase }).filter((o) => o.changes.includes(name))
62
+ if (mine.length === 0) return null
63
+ const others = [...new Set(mine.flatMap((o) => o.changes).filter((c) => c !== name))].join('、')
64
+ const files = mine.slice(0, 3).map((o) => `${o.file}←${o.changes.join(',')}`).join(';') + (mine.length > 3 ? ` 等 ${mine.length} 个` : '')
65
+ return `⚠️ [重叠路由] 本变更与活跃变更 ${others} 声明了相同文件(${files})——并行改同一文件是冲突地带,建议其一转 worktree 形态(sillyspec worktree apply <名>);落地序列:先落地干净进、后落地 apply --3way 三方合并(并发由 main-repo.lock 串行)。详见 sillyspec overlap`
66
+ }
67
+
68
+ export default { detectDeclaredOverlaps, overlapAdvisoryFor }
package/src/run/shared.js CHANGED
@@ -109,7 +109,10 @@ export function assertDatedChangeName(name, label = '变更名') {
109
109
 
110
110
  import { buildExecuteSteps } from '../stages/execute.js'
111
111
  import { buildPlanSteps } from '../stages/plan.js'
112
- import { stageRegistry } from '../stages/index.js'
112
+ // stageRegistry 不静态引 stages/index.js(2026-10-28-stages-cycle-root-fix):execute.js 子树
113
+ // (knowledge-hits → agent-session-log → run/shared)回边使 index.js 首次求值 stageRegistry
114
+ // 时 execute 绑定仍在 TDZ(execute.js:19-21 注释记载的坑形态再犯)——唯一使用点
115
+ // getStageSteps 已是 async,点用动态 import 断环(同函数 await import 惰性先例)。
113
116
  import { parseRepo, parseRepoRegistry } from '../stages/plan-postcheck.js'
114
117
  import { MultiRepoContext } from './multi-repo-context.js'
115
118
 
@@ -2505,6 +2508,9 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
2505
2508
  return buildQuickScanSteps()
2506
2509
  }
2507
2510
  }
2511
+ // 动态取注册表(断环点,见文件头 import 区注释):首次求值时机后移到调用点,
2512
+ // 此处到达时 stages/index.js 及其全部阶段定义必已完成初始化
2513
+ const { stageRegistry } = await import('../stages/index.js')
2508
2514
  const def = stageRegistry[stageName]
2509
2515
  return def ? def.steps : null
2510
2516
  }
package/src/task-tick.js CHANGED
@@ -158,6 +158,22 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase, evidence
158
158
  } catch (e) {
159
159
  console.warn(`⚠️ 勾选留痕写失败(task-ticks.jsonl:${e && e.code ? e.code : e})——翻格不受影响,timeline 勾选时刻退事件流推断`)
160
160
  }
161
+ // 影子存档(2026-10-28-shadow-checkpoint):勾选即快照——tasks.md 翻格+侧车落盘后落一个
162
+ // git 影子提交到 refs/sillyspec/checkpoints/<change>,断点恢复从进度级升到内容级。
163
+ // best-effort fail-open(模块内零抛出,此处再兜一层):任何失败只回显一行,绝不影响翻格。
164
+ try {
165
+ const { createCheckpoint } = await import('./checkpoint.js')
166
+ const cp = await createCheckpoint({
167
+ repoRoot: cwd, changeName,
168
+ session: process.env.SILLYSPEC_SESSION_ID,
169
+ taskId: normalizeTaskId(taskId) || taskId,
170
+ })
171
+ if (cp.kind === 'created') console.log(`🗂️ 影子存档 ${String(cp.sha).slice(0, 8)}(${cp.files} 文件变更)→ refs/sillyspec/checkpoints/${changeName}`)
172
+ else if (cp.kind === 'noop') console.log(`🗂️ 影子存档 noop(工作区无变化,零对象)`)
173
+ else console.log(`🗂️ 影子存档跳过:${cp.reason}${cp.detail ? `(${cp.detail})` : ''}`)
174
+ } catch (e) {
175
+ console.warn(`⚠️ 影子存档失败(不阻断勾选):${e && e.message ? e.message : e}`)
176
+ }
161
177
  // 工件即时重推(2026-10-07-allticked-gate-docs-resync):翻格即任务面变更——顺带触发
162
178
  // spec-sync,平台不再停留 start 时点快照(真实会话实证:改写后平台恒显初稿)。best-effort。
163
179
  try {