sillyspec 3.26.13 → 3.26.15

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 (56) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  9. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  11. package/package.json +1 -1
  12. package/src/commit-suggest.js +202 -0
  13. package/src/config-cat.js +88 -0
  14. package/src/docs-check.js +48 -12
  15. package/src/doctor-diagnostics.js +49 -0
  16. package/src/hooks/worktree-guard.js +20 -1
  17. package/src/index.js +547 -21
  18. package/src/init.js +1 -1
  19. package/src/local-register.js +98 -0
  20. package/src/module-impact.js +146 -0
  21. package/src/progress/change-registry.js +43 -4
  22. package/src/progress/stage-machine.js +10 -5
  23. package/src/progress.js +18 -3
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +151 -38
  26. package/src/run/complete-handlers.js +62 -4
  27. package/src/run/complete.js +10 -8
  28. package/src/run/concurrent-detect.js +3 -1
  29. package/src/run/gates.js +86 -4
  30. package/src/run/multi-repo-context.js +3 -1
  31. package/src/run/next.js +128 -0
  32. package/src/run/prompt.js +28 -6
  33. package/src/run/scan-profile.js +35 -23
  34. package/src/run/shared.js +28 -7
  35. package/src/scan-postcheck.js +142 -2
  36. package/src/stage-contract-spec.js +1 -1
  37. package/src/stage-review.js +55 -7
  38. package/src/stages/archive.js +1 -1
  39. package/src/stages/brainstorm.js +26 -12
  40. package/src/stages/doctor.js +2 -4
  41. package/src/stages/execute.js +110 -26
  42. package/src/stages/knowledge.js +21 -5
  43. package/src/stages/plan-postcheck.js +24 -10
  44. package/src/stages/plan.js +14 -9
  45. package/src/stages/quick.js +14 -6
  46. package/src/stages/scan.js +1 -0
  47. package/src/stages/verify.js +7 -49
  48. package/src/sync.js +19 -7
  49. package/src/task-review.js +256 -10
  50. package/src/verify-probes.js +264 -0
  51. package/src/workspace.js +125 -0
  52. package/src/worktree-apply.js +15 -1
  53. package/src/worktree.js +58 -8
  54. package/templates/claude-instruction.md +1 -1
  55. package/templates/prompts/taskcard-rules.md +1 -1
  56. package/templates/prompts/verify-probes.md +13 -47
package/src/init.js CHANGED
@@ -308,7 +308,7 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null, options
308
308
  mkdirSync(knowledgeDir, { recursive: true });
309
309
  const indexPath = join(knowledgeDir, 'INDEX.md');
310
310
  if (!existsSync(indexPath)) {
311
- writeFileSync(indexPath, `# Knowledge Index\n\n> 子代理任务开始前查询此文件,按关键词匹配,只读命中的知识文件。\n> execute/quick 执行中发现的坑自动追加到 uncategorized.md,经用户确认后归类到对应文件。\n\n<!-- 格式:关键词1|关键词2|关键词3 → 文件路径 -->\n<!-- 示例:mybatis-plus|分页|Page → pagination.md -->\n<!-- 示例:跨域|CORS|preflight → cors.md -->\n`);
311
+ writeFileSync(indexPath, `# Knowledge Index\n\n> 子代理任务开始前查询此文件,按关键词匹配,只读命中的知识文件。\n> 只有遇到真正的项目特有坑(跨变更可复用、未来 agent 可能再次踩到)时才追加到 uncategorized.md;不要为了完成任务硬凑条目。经用户确认后归类到对应文件并更新本索引。\n\n<!-- 格式:关键词1|关键词2|关键词3 → 文件路径 -->\n<!-- 示例:mybatis-plus|分页|Page → pagination.md -->\n<!-- 示例:跨域|CORS|preflight → cors.md -->\n`);
312
312
  }
313
313
  const uncatPath = join(knowledgeDir, 'uncategorized.md');
314
314
  if (!existsSync(uncatPath)) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * local-register.js — `sillyspec local register-repo <key> <path>` 的 repos: 段外科手术式写入。
3
+ *
4
+ * 背景(2026-08-21 agent-手工产出审计项④):跨仓 change 的 repo key 必须在 local.yaml
5
+ * `repos:` 段注册(MultiRepoContext 约束② fail-closed),此前靠 agent 手编 YAML——正是
6
+ * agent 满目录找/在根目录乱建 local.yaml 的上游。本模块只动 `repos:` 段:已有该 key 就地
7
+ * 改值,没有就插入段内;其余内容(注释/凭据段/手调配置)逐行原样保留,绝不整文件重序列化。
8
+ *
9
+ * 写入口径与 parseRepoRegistry(plan-postcheck.js,读侧)成对:
10
+ * repos:
11
+ * sillyspec: C:/Users/you/IdeaProjects/sillyspec
12
+ * # main 不用注册(隐式 = cwd)
13
+ *
14
+ * 文件不存在时新建只含 repos: 段的 minimal local.yaml(真实 local.yaml 其余段由
15
+ * `sillyspec local detect` / `platform connect` 各自补齐)。落盘走 writeAtomicSync
16
+ * (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
17
+ */
18
+ import { existsSync, readFileSync, mkdirSync } from 'fs'
19
+ import { dirname } from 'path'
20
+ import { writeAtomicSync } from './fs-atomic.js'
21
+
22
+ function escapeRe(s) {
23
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
24
+ }
25
+
26
+ /**
27
+ * 把 `<key>: <path>` 注册进 local.yaml 的 repos: 段(幂等,已有该 key 则改值)。
28
+ *
29
+ * @param {string} yamlPath - local.yaml 绝对路径(不存在则新建)
30
+ * @param {string} key - repo key(限 [A-Za-z0-9_.\-]+,'main' 隐式禁止注册)
31
+ * @param {string} repoPath - 跨仓仓根绝对路径(写入时统一正斜杠,与示例格式一致)
32
+ * @returns {{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }}
33
+ * @throws {Error} key 非法 / key='main' / repoPath 空 / 读写失败
34
+ */
35
+ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
36
+ if (!yamlPath) throw new Error('registerRepoInLocalYaml: yamlPath 不能为空')
37
+ if (!key || !/^[A-Za-z0-9_.\-]+$/.test(key)) {
38
+ throw new Error(`repo key 非法: "${key}"(限字母数字 . _ -,与 parseRepoRegistry 读侧口径一致)`)
39
+ }
40
+ if (key === 'main') {
41
+ throw new Error("repo key 'main' 不用注册(隐式 = 主仓 cwd),跨仓仓才需要 register-repo")
42
+ }
43
+ if (!repoPath) throw new Error('registerRepoInLocalYaml: repoPath 不能为空')
44
+
45
+ const existed = existsSync(yamlPath)
46
+ // CRLF 归一后按 LF 写回(同 task 卡锚点写入的 CRLF 坑;local.yaml 各 CLI 写入口径均 LF)
47
+ const raw = existed ? readFileSync(yamlPath, 'utf8').replace(/\r\n?/g, '\n') : ''
48
+ const posixPath = String(repoPath).replace(/\\/g, '/')
49
+ const entryLine = ` ${key}: ${posixPath}`
50
+
51
+ const write = (text) => {
52
+ mkdirSync(dirname(yamlPath), { recursive: true })
53
+ writeAtomicSync(yamlPath, text)
54
+ }
55
+
56
+ const lines = raw.split('\n')
57
+ // repos: 段起点(顶层 key 行,与 parseRepoRegistry 同口径)
58
+ let reposIdx = -1
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { reposIdx = i; break }
61
+ }
62
+
63
+ if (reposIdx === -1) {
64
+ // 无 repos: 段 → 文件尾追加(保留既有内容与注释;空文件/新文件直接起段)
65
+ const sep = raw.trim() === '' ? '' : (raw.endsWith('\n') ? '' : '\n') + '\n'
66
+ write(raw + sep + `repos:\n${entryLine}\n`)
67
+ return { fileCreated: !existed, sectionCreated: true, replaced: false }
68
+ }
69
+
70
+ // 段范围:reposIdx+1 起到下一个顶层 key(行首非空白非注释)为止(与 parseRepoRegistry 同口径)
71
+ let end = lines.length
72
+ for (let i = reposIdx + 1; i < lines.length; i++) {
73
+ const l = lines[i]
74
+ if (l.length > 0 && !/^[\s#]/.test(l) && l.trim() !== '') { end = i; break }
75
+ }
76
+
77
+ // 已有该 key → 就地改值(幂等)
78
+ const keyRe = new RegExp(`^\\s+${escapeRe(key)}:\\s`)
79
+ for (let i = reposIdx + 1; i < end; i++) {
80
+ if (keyRe.test(lines[i])) {
81
+ if (lines[i] === entryLine) {
82
+ return { fileCreated: false, sectionCreated: false, replaced: false } // 值未变,幂等跳过
83
+ }
84
+ lines[i] = entryLine
85
+ write(lines.join('\n'))
86
+ return { fileCreated: false, sectionCreated: false, replaced: true }
87
+ }
88
+ }
89
+
90
+ // 无该 key → 插到段内最后一个非空行(条目或段内注释)之后
91
+ let insertAt = reposIdx + 1
92
+ for (let i = reposIdx + 1; i < end; i++) {
93
+ if (lines[i].trim() !== '') insertAt = i + 1
94
+ }
95
+ lines.splice(insertAt, 0, entryLine)
96
+ write(lines.join('\n'))
97
+ return { fileCreated: false, sectionCreated: false, replaced: false }
98
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * module-impact.js — `sillyspec module-impact` 的归类骨架生成
3
+ * (2026-08-21 agent-手工产出审计第三批 D3)。
4
+ *
5
+ * archive 阶段的 module-impact.md 要求「模块影响矩阵」+「未匹配文件」两章节、≥20 行,
6
+ * agent 此前从零手写整份矩阵。文件 × 模块的归属是纯机械分类(module-map paths 前缀匹配,
7
+ * 与 scan diff 归属同源),影响类型判定(逻辑/数据结构/接口/调用关系/配置/新增)是语义——
8
+ * 骨架预填「模块|文件|状态」三列,影响类型列留 <!--TODO--> 由 agent 逐行填。
9
+ *
10
+ * diff 源:resolveVerifyChangedFiles(worktree-aware,与 task-review 同源);
11
+ * module-map:.sillyspec/docs/<p>/modules/_module-map.yaml 的 modules.<id>.paths
12
+ * (目录条目以 / 结尾按前缀匹配,与模块上下文索引读侧口径一致)。
13
+ */
14
+ import { existsSync, readFileSync, readdirSync } from 'fs'
15
+ import { join } from 'path'
16
+ import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
+ import { resolveSpecDir } from './run/shared.js'
18
+
19
+ /**
20
+ * 轻量解析 _module-map.yaml:modules.<id>.paths 列表(手写解析同 parseRepoRegistry 风格,
21
+ * 只取 paths 平面信息,status/doc 等忽略)。
22
+ * @returns {Map<string, string[]>} moduleId → paths(posix)
23
+ */
24
+ export function parseModuleMapPaths(yamlText) {
25
+ const map = new Map()
26
+ if (!yamlText) return map
27
+ let currentModule = null
28
+ let inPaths = false
29
+ for (const line of yamlText.split('\n')) {
30
+ if (!line.trim() || line.trim().startsWith('#')) continue
31
+ if (!/^\s/.test(line)) {
32
+ inPaths = line.startsWith('modules:')
33
+ currentModule = null
34
+ continue
35
+ }
36
+ const modMatch = line.match(/^ ([A-Za-z0-9_.\-]+):\s*$/)
37
+ if (modMatch) {
38
+ currentModule = modMatch[1]
39
+ inPaths = false
40
+ continue
41
+ }
42
+ if (currentModule && /^ paths:\s*$/.test(line)) {
43
+ inPaths = true
44
+ if (!map.has(currentModule)) map.set(currentModule, [])
45
+ continue
46
+ }
47
+ if (inPaths && currentModule) {
48
+ const item = line.match(/^ - (.+)$/)
49
+ if (item) map.get(currentModule).push(item[1].trim().replace(/\\/g, '/'))
50
+ }
51
+ }
52
+ return map
53
+ }
54
+
55
+ function classifyFile(posixPath, modulePaths) {
56
+ for (const [moduleId, paths] of modulePaths) {
57
+ for (const p of paths) {
58
+ // 目录条目以 / 结尾 → 前缀匹配;文件条目 → 精确或子路径(与模块上下文索引读侧口径一致)
59
+ if (p.endsWith('/')) {
60
+ if (posixPath.startsWith(p)) return moduleId
61
+ } else if (posixPath === p || posixPath.startsWith(p + '/')) {
62
+ return moduleId
63
+ }
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /**
70
+ * 生成 module-impact.md 骨架。
71
+ * @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
72
+ * @returns {{ markdown: string, matchedCount: number, unmatchedCount: number }|null}
73
+ * 无 module-map / 无 diff → null(无可归类输入,agent 全手写)
74
+ */
75
+ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }) {
76
+ const specBase = resolveSpecDir(cwd, { specDir })
77
+
78
+ // 找 _module-map.yaml(docs/<p>/modules/;多项目取首个含 modules 索引的)
79
+ let moduleMapPath = null
80
+ try {
81
+ for (const p of readdirSync(join(specBase, 'docs'), { withFileTypes: true })) {
82
+ if (!p.isDirectory()) continue
83
+ const candidate = join(specBase, 'docs', p.name, 'modules', '_module-map.yaml')
84
+ if (existsSync(candidate)) {
85
+ moduleMapPath = candidate
86
+ break
87
+ }
88
+ }
89
+ } catch { /* 无 docs/ */ }
90
+ if (!moduleMapPath) return null
91
+
92
+ const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
93
+ if (modulePaths.size === 0) return null
94
+
95
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName) || []
96
+ const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
97
+ if (sourceFiles.length === 0) return null
98
+
99
+ const byModule = new Map()
100
+ const unmatched = []
101
+ for (const f of sourceFiles) {
102
+ const mod = classifyFile(f, modulePaths)
103
+ if (mod) {
104
+ if (!byModule.has(mod)) byModule.set(mod, [])
105
+ byModule.get(mod).push(f)
106
+ } else {
107
+ unmatched.push(f)
108
+ }
109
+ }
110
+
111
+ const L = [
112
+ '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
113
+ '',
114
+ '> 文件×模块归属由 CLI 按 _module-map.yaml paths 前缀匹配预填;',
115
+ '> **影响类型**(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 review 标记是语义判断,',
116
+ '> 逐行把 <!--TODO--> 替换为真实结论——以 git diff 为准(真实 > 声明)。',
117
+ '',
118
+ '## 模块影响矩阵',
119
+ '',
120
+ '| 模块 | 变更文件 | 影响类型 | 需 review |',
121
+ '|---|---|---|---|',
122
+ ]
123
+ const rows = [...byModule.entries()].sort((a, b) => a[0].localeCompare(b[0]))
124
+ for (const [moduleId, files] of rows) {
125
+ for (const f of files) {
126
+ L.push(`| ${moduleId} | \`${f}\` | <!--TODO--> | <!--TODO--> |`)
127
+ }
128
+ }
129
+ L.push('')
130
+ L.push('## 未匹配文件')
131
+ L.push('')
132
+ if (unmatched.length === 0) {
133
+ L.push('无(全部变更文件已归属到模块)。')
134
+ } else {
135
+ L.push('以下变更文件未命中 _module-map.yaml 任何模块 paths——确认是模块索引过期(该跑 `sillyspec modules rebuild`)还是真的游离文件:')
136
+ L.push('')
137
+ for (const f of unmatched) L.push(`- \`${f}\` <!--TODO: 归属判定-->`)
138
+ }
139
+ L.push('')
140
+ L.push('## 影响类型说明')
141
+ L.push('')
142
+ L.push('逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增;不确定的影响标 needs review。')
143
+ L.push('')
144
+
145
+ return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
146
+ }
@@ -276,8 +276,15 @@ export class ChangeRegistry {
276
276
  /**
277
277
  * 从活跃列表移除变更(归档时调用,不物理删除)
278
278
  * SQL: UPDATE changes SET status = 'archived'
279
+ *
280
+ * 归档终态一致化(坑 manual-archive-desync-status-only,2026-08-21 实证):手动搬目录绕过
281
+ * `run archive --done --confirm` 后,自愈/幽灵清理路径只改 status 一个字段,留下「已归档 +
282
+ * current_stage 停在 execute + 归档 0/5 步」的自相矛盾终态,推送平台后渲染成「进度丢失」。
283
+ * opts.archiveStepNames(调用方取自 stageRegistry 单一真相)给定时同事务收尾:
284
+ * current_stage='archive' + stages.archive=completed + 其步骤行全 completed
285
+ * (缺行按定义补种——平台按步骤数展示完成度,零行会显示 0/5)。
279
286
  */
280
- unregisterChange(cwd, changeName) {
287
+ unregisterChange(cwd, changeName, opts = {}) {
281
288
  if (!changeName) {
282
289
  console.warn('⚠️ unregisterChange: changeName 为空,跳过');
283
290
  return;
@@ -286,9 +293,41 @@ export class ChangeRegistry {
286
293
  db.transaction(() => {
287
294
  const sqlDb = db.getDb();
288
295
  const now = new Date().toISOString();
289
- sqlDb.prepare(
290
- `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`
291
- ).run(now, changeName);
296
+ let sql = `UPDATE changes SET status = 'archived', last_active = ?`;
297
+ const params = [now];
298
+ if (Array.isArray(opts.archiveStepNames)) sql += `, current_stage = 'archive'`;
299
+ sql += ` WHERE name = ?`;
300
+ params.push(changeName);
301
+ sqlDb.prepare(sql).run(...params);
302
+ if (Array.isArray(opts.archiveStepNames)) {
303
+ const row = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
304
+ if (row) {
305
+ sqlDb.prepare(
306
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at)
307
+ VALUES (?, 'archive', 'completed', ?, ?)
308
+ ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
309
+ ).run(row.id, now, now);
310
+ const stageRow = sqlDb.prepare(
311
+ 'SELECT id FROM stages WHERE change_id = ? AND stage = ?'
312
+ ).get(row.id, 'archive');
313
+ if (stageRow) {
314
+ // 现有步骤行(含 pending/waiting)全收尾;定义里有而行里没有的(阶段从未初始化)按定义补种
315
+ sqlDb.prepare(
316
+ `UPDATE steps SET status = 'completed', completed_at = ? WHERE stage_id = ?`
317
+ ).run(now, stageRow.id);
318
+ const existing = new Set(
319
+ sqlDb.prepare('SELECT name FROM steps WHERE stage_id = ?').all(stageRow.id).map(r => r.name)
320
+ );
321
+ let order = existing.size;
322
+ const ins = sqlDb.prepare(
323
+ `INSERT INTO steps (stage_id, name, status, completed_at, ordering) VALUES (?, ?, 'completed', ?, ?)`
324
+ );
325
+ for (const name of opts.archiveStepNames) {
326
+ if (!existing.has(name)) { ins.run(stageRow.id, name, now, order++) }
327
+ }
328
+ }
329
+ }
330
+ }
292
331
  // 本地脏度(D-013 / task-04):归档也是本地状态推进
293
332
  this.pm._touchLocalModified(cwd, changeName, now);
294
333
  });
@@ -282,13 +282,18 @@ export class StageMachine {
282
282
  * @returns {{ text: string, command?: string }|null}
283
283
  */
284
284
  _getNextSuggestion(data) {
285
+ // 建议命令携带 --change(坑 suggestion-command-missing-change,2026-08-21 实证):
286
+ // 多活跃变更仓 pm.read(cwd, null) 无法自动定位变更 → 照抄建议命令(不带 --change)执行
287
+ // 报「未找到进度数据」,阶段完结后的 --wait 确认门也登记不上。progress 带 currentChange
288
+ // 时附加;无(测试 fixture / 旧数据)保持裸命令(存量断言零回归)。
289
+ const cc = data.currentChange ? ` --change ${data.currentChange}` : '';
285
290
  // 找到第一个 revising 阶段
286
291
  const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
287
292
  if (revisingStage) {
288
293
  const sd = data.stages[revisingStage];
289
294
  return {
290
295
  text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
291
- command: `sillyspec run ${revisingStage}`,
296
+ command: `sillyspec run ${revisingStage}${cc}`,
292
297
  };
293
298
  }
294
299
 
@@ -298,7 +303,7 @@ export class StageMachine {
298
303
  const sd = data.stages[staleStage];
299
304
  return {
300
305
  text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
301
- command: `sillyspec run ${staleStage} --reopen --from-step 1`,
306
+ command: `sillyspec run ${staleStage} --reopen --from-step 1${cc}`,
302
307
  };
303
308
  }
304
309
 
@@ -318,14 +323,14 @@ export class StageMachine {
318
323
  const ws = sd.steps[waitIdx];
319
324
  return {
320
325
  text: `${STAGE_LABELS[s] || s} 进行中,Step ${waitIdx + 1}「${ws.name}」等待用户输入${ws.waitReason ? `(${ws.waitReason})` : ''},用 --answer 恢复。`,
321
- command: `sillyspec run ${s} --continue --answer "用户回答"`,
326
+ command: `sillyspec run ${s} --continue --answer "用户回答"${cc}`,
322
327
  };
323
328
  }
324
329
  const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
325
330
  if (hasPending) {
326
331
  return {
327
332
  text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
328
- command: `sillyspec run ${s}`,
333
+ command: `sillyspec run ${s}${cc}`,
329
334
  };
330
335
  }
331
336
  }
@@ -349,7 +354,7 @@ export class StageMachine {
349
354
  if (upstreamOk) {
350
355
  return {
351
356
  text: `可以开始 ${STAGE_LABELS[s] || s}。`,
352
- command: `sillyspec run ${s}`,
357
+ command: `sillyspec run ${s}${cc}`,
353
358
  };
354
359
  }
355
360
  }
package/src/progress.js CHANGED
@@ -19,6 +19,7 @@ import { ConsistencyDoctor } from './progress/consistency-doctor.js';
19
19
  import { ChangeRegistry } from './progress/change-registry.js';
20
20
  import { StepStore } from './progress/step-store.js';
21
21
  import { StageMachine } from './progress/stage-machine.js';
22
+ import { stageRegistry } from './stages/index.js';
22
23
  import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './progress/shared.js';
23
24
  // resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
24
25
  // 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
@@ -812,7 +813,18 @@ export class ProgressManager {
812
813
 
813
814
  renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
814
815
 
815
- unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
816
+ // opts.archiveStepNames 给定时做归档终态一致化收尾(见 change-registry.unregisterChange 注释,
817
+ // 坑 manual-archive-desync-status-only);archiveStepNamesForArchive() 取 stageRegistry 单一真相
818
+ //(stages/* 不反向依赖 progress.js,静态 import 无环)。
819
+ unregisterChange(cwd, changeName, opts) { return this._changeRegistry.unregisterChange(cwd, changeName, opts); }
820
+
821
+ archiveStepNamesForArchive() {
822
+ if (!this._archiveStepNamesCache) {
823
+ const defs = stageRegistry?.archive?.steps
824
+ this._archiveStepNamesCache = Array.isArray(defs) ? defs.map(s => s.name) : []
825
+ }
826
+ return this._archiveStepNamesCache
827
+ }
816
828
 
817
829
  updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
818
830
 
@@ -1188,8 +1200,11 @@ export class ProgressManager {
1188
1200
  if (projectRow === undefined) return null;
1189
1201
  const projectName = projectRow.name;
1190
1202
 
1191
- // 2. 活跃变更(取第一个,或空)
1192
- const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY name").all();
1203
+ // 2. 活跃变更(取 last_active 最新的一个)
1204
+ // ql-20260821-003:多活跃仓(变更隔离是常态,单仓可同时存在 N 个 active)下
1205
+ // 原 ORDER BY name 恒取字典序最前(通常是最老的变更),「当前变更/最后活动」
1206
+ // 停留在历史数据。dump 语义是「当前工作流状态」→ 取最近活跃的那个。
1207
+ const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY last_active DESC").all();
1193
1208
  if (changeRows.length === 0) {
1194
1209
  // 无活跃变更 → 返回仅含 project 的骨架
1195
1210
  return {
package/src/quicklog.js CHANGED
@@ -14,7 +14,7 @@ import { join, dirname, basename } from 'path'
14
14
  import { getRule } from './stage-contract-spec.js'
15
15
  import {
16
16
  openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
17
- readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync,
17
+ readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync, rmSync,
18
18
  } from 'fs'
19
19
  import { randomBytes } from 'crypto'
20
20
 
@@ -722,3 +722,108 @@ export function findQuicklogEntry(specBase, gitUser, qlId) {
722
722
  }
723
723
  return false
724
724
  }
725
+
726
+ /**
727
+ * 取消一个 quick 会话(2026-08-21 agent-手工产出审计第四批 C-2,`run quick --cancel` 数据源)。
728
+ *
729
+ * 误启动/放弃的 quick 会话此前无取消出口:QUICKLOG「进行中」条目 + 关联变更 tasks.md 挂载行
730
+ * + .runtime/quick-sessions/<id>/ 永久残留,doctor --cleanup-ghosts 只清 db 幽灵行不清这三处。
731
+ * 本函数做 allocateQuicklogEntry 的反操作(三步全是已有函数的反面):
732
+ * 1. QUICKLOG 条目状态翻「已取消」(非删除——保留痕迹可审计,与 doctor 宁漏勿杀口径一致)
733
+ * 2. tasks.md 里该 qlId 的挂载行整行移除(未勾的空壳行,勾了说明实际做了工作 → 拒绝取消)
734
+ * 3. 会话 guard 目录删除(quick-sessions/<sessionId>/,含 guard.json)
735
+ * 4. current-quick-run-id 若指向本会话则删除(防后续 --done fallback 命中死会话)
736
+ * db 侧 change 行由调用方 unregisterChange(quick 会话行,非真实变更)。
737
+ *
738
+ * fail-closed:已翻「已完成」的条目拒绝取消(那是真实工作);tasks.md 挂载行已勾选同样拒绝。
739
+ *
740
+ * @param {{ specBase: string, gitUser: string, qlId: string, sessionId: string|null }} opts
741
+ * @returns {Promise<{ ok: boolean, reason?: string, quicklogFile?: string, removedTaskRows: string[] }>}
742
+ */
743
+ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId = null }) {
744
+ if (!qlId) return { ok: false, reason: '缺 qlId(--change <quick-会话ID> 或 --ql <ql-xxx>)' }
745
+ const quicklogDir = join(specBase, 'quicklog')
746
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
747
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
748
+
749
+ // 1. QUICKLOG 翻「已取消」:找到条目,校验未完成,就地改状态行
750
+ let quicklogFile = null
751
+ let flipped = false
752
+ await withFileLock(lockPath, async () => {
753
+ // 用户文件缺失时扫全部 QUICKLOG 文件(条目可能在别的 user 文件里,如 CI 场景 user 漂移)
754
+ const files = existsSync(join(quicklogDir, `QUICKLOG-${user}.md`))
755
+ ? [`QUICKLOG-${user}.md`]
756
+ : listQuicklogFiles(quicklogDir)
757
+ for (const f of files) {
758
+ const p = join(quicklogDir, f)
759
+ const content = existsSync(p) ? readFileSync(p, 'utf8') : ''
760
+ if (!content.includes(`## ${qlId} |`)) continue
761
+ quicklogFile = p
762
+ const lines = content.split('\n')
763
+ const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
764
+ let endIdx = lines.length
765
+ for (let i = startIdx + 1; i < lines.length; i++) {
766
+ if (lines[i].startsWith('## ')) { endIdx = i; break }
767
+ }
768
+ for (let i = startIdx + 1; i < endIdx; i++) {
769
+ if (/^状态:已完成/.test(lines[i])) {
770
+ throw new Error(`${qlId} 已完成(真实工作记录),拒绝取消;如需修正请手改 QUICKLOG`)
771
+ }
772
+ }
773
+ for (let i = startIdx + 1; i < endIdx; i++) {
774
+ if (/^状态:进行中/.test(lines[i])) {
775
+ lines[i] = '状态:已取消'
776
+ flipped = true
777
+ break
778
+ }
779
+ }
780
+ if (flipped) {
781
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
782
+ writeFileSync(p, lines.join(eol === '\r\n' ? '\r\n' : '\n'))
783
+ }
784
+ break
785
+ }
786
+ })
787
+ if (!quicklogFile) return { ok: false, reason: `未在任何 QUICKLOG 文件找到条目 ${qlId}` }
788
+ if (!flipped) return { ok: false, reason: `${qlId} 状态行异常(非进行中也非已完成),请手查 QUICKLOG` }
789
+
790
+ // 2. tasks.md 挂载行移除(所有活跃变更统一扫——allocate 时可能挂多个关联变更)
791
+ const removedTaskRows = []
792
+ const changesRoot = join(specBase, 'changes')
793
+ if (existsSync(changesRoot)) {
794
+ for (const c of readdirSync(changesRoot, { withFileTypes: true })) {
795
+ if (!c.isDirectory() || c.name === 'archive') continue
796
+ const tp = tasksPath(specBase, c.name)
797
+ if (!existsSync(tp)) continue
798
+ await withFileLock(tasksLockPath(tp), () => {
799
+ const content = readFileSync(tp, 'utf8')
800
+ if (!content.includes(qlId)) return
801
+ const lines = content.split('\n')
802
+ const kept = lines.filter(l => {
803
+ const m = l.match(/^[-*]\s*\[([ xX])\]\s*(\S+)/)
804
+ if (m && m[2] === qlId) {
805
+ if (m[1] !== ' ') throw new Error(`${c.name}/tasks.md 的 ${qlId} 行已勾选(实际做了工作),拒绝取消`)
806
+ removedTaskRows.push(`${c.name}/tasks.md`)
807
+ return false
808
+ }
809
+ return true
810
+ })
811
+ if (kept.length !== lines.length) {
812
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
813
+ writeFileSync(tp, kept.join(eol === '\r\n' ? '\r\n' : '\n'))
814
+ }
815
+ })
816
+ }
817
+ }
818
+
819
+ // 3+4. 会话目录与 current marker
820
+ if (sessionId) {
821
+ try { rmSync(join(specBase, '.runtime', 'quick-sessions', sessionId), { recursive: true, force: true }) } catch {}
822
+ const marker = join(specBase, '.runtime', 'current-quick-run-id')
823
+ try {
824
+ if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === sessionId) unlinkSync(marker)
825
+ } catch {}
826
+ }
827
+
828
+ return { ok: true, quicklogFile, removedTaskRows }
829
+ }