sillyspec 3.26.14 → 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 (50) 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 +508 -6
  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 +13 -1
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +95 -1
  26. package/src/run/complete-handlers.js +56 -3
  27. package/src/run/complete.js +4 -7
  28. package/src/run/gates.js +62 -4
  29. package/src/run/multi-repo-context.js +3 -1
  30. package/src/run/next.js +128 -0
  31. package/src/run/prompt.js +20 -2
  32. package/src/run/shared.js +2 -2
  33. package/src/scan-postcheck.js +142 -2
  34. package/src/stage-contract-spec.js +1 -1
  35. package/src/stage-review.js +15 -6
  36. package/src/stages/archive.js +1 -1
  37. package/src/stages/brainstorm.js +1 -0
  38. package/src/stages/execute.js +105 -26
  39. package/src/stages/knowledge.js +3 -2
  40. package/src/stages/quick.js +14 -6
  41. package/src/stages/scan.js +1 -0
  42. package/src/stages/verify.js +7 -49
  43. package/src/task-review.js +218 -4
  44. package/src/verify-probes.js +264 -0
  45. package/src/workspace.js +125 -0
  46. package/src/worktree-apply.js +15 -1
  47. package/src/worktree.js +58 -8
  48. package/templates/claude-instruction.md +1 -1
  49. package/templates/prompts/taskcard-rules.md +1 -1
  50. package/templates/prompts/verify-probes.md +13 -47
@@ -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
 
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
+ }
@@ -44,6 +44,8 @@ const VALUE_FLAGS = new Set([
44
44
  '--output', '--input', '--change', '--linked-changes',
45
45
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
46
46
  '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
47
+ '--req', '--cause', '--solution', '--result', // quick 末步四字段参数(合成 --output,见 outputText 解析段)
48
+ '--ql', // quick --cancel 显式指定 qlId(缺省读会话 guard.json)
47
49
  '--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
48
50
  ])
49
51
 
@@ -232,6 +234,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
232
234
  const isStatus = flags.includes('--status')
233
235
  const isReset = flags.includes('--reset')
234
236
  const isReopen = flags.includes('--reopen')
237
+ // --cancel(2026-08-21 审计第四批 C-2):quick 误启动会话的官方取消口。取消 = QUICKLOG 条目
238
+ // 翻「已取消」+ tasks.md 挂载行移除 + 会话 guard 目录清理 + db 行注销(取消 quick 会话行,
239
+ // 非真实变更)。fail-closed:已完成/已勾选的拒绝(真实工作记录)。
240
+ const isCancel = flags.includes('--cancel')
241
+ if (isCancel && stageName !== 'quick') {
242
+ console.error('❌ --cancel 仅支持 quick 阶段(sillyspec run quick --cancel --change <quick-会话ID>)')
243
+ process.exit(2)
244
+ }
245
+ if (isCancel && (isDone || isSkip || isReset || isReopen || isStatus)) {
246
+ console.error('❌ --cancel 与 --done/--skip/--reset/--reopen/--status 互斥')
247
+ process.exit(2)
248
+ }
235
249
  const fromStepValue = getFlagValue('--from-step')
236
250
  const isConfirm = flags.includes('--confirm')
237
251
  const isSkipApproval = flags.includes('--skip-approval')
@@ -425,6 +439,28 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
425
439
  const outputValue = getFlagValue('--output')
426
440
  if (outputValue !== null) outputText = outputValue
427
441
 
442
+ // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
443
+ // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
444
+ // splitSingleLineFields 会归一为多行字段块)。消灭 agent 手拼「需求:… 根因:…」模板的
445
+ // 格式事故面(嵌套全角冒号拆分误判 / 缺字段被 --done 拒 / 标题截断规则踩坑)。
446
+ // 任一四参数出现即要求全四;与 --output 互斥(两路同时给说明意图不明,fail-fast)。
447
+ {
448
+ const FOUR = [['--req', '需求:'], ['--cause', '根因:'], ['--solution', '方案:'], ['--result', '结果:']]
449
+ const fourVals = FOUR.map(([f]) => [f, getFlagValue(f)])
450
+ if (fourVals.some(([, v]) => v !== null)) {
451
+ if (outputText !== null) {
452
+ console.error('❌ --req/--cause/--solution/--result 与 --output 互斥——四参数由 CLI 合成结构化 output,勿混用');
453
+ process.exit(2)
454
+ }
455
+ const missing = fourVals.filter(([, v]) => v === null).map(([f]) => f)
456
+ if (missing.length > 0) {
457
+ console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
458
+ process.exit(2)
459
+ }
460
+ outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
461
+ }
462
+ }
463
+
428
464
  // 解析 --input
429
465
  let inputText = null
430
466
  const inputValue = getFlagValue('--input')
@@ -588,10 +624,11 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
588
624
 
589
625
  // 未知参数 fail-fast
590
626
  const knownFlags = new Set([
591
- '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
627
+ '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval', '--cancel', '--ql',
592
628
  '--wait', '--continue', '--non-interactive', '--interactive',
593
629
  '--reason', '--options', '--answer', '--confirm-mode',
594
630
  '--output', '--input', '--change', '--linked-changes',
631
+ '--req', '--cause', '--solution', '--result',
595
632
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
596
633
  '--files', '--file-notes', '--allow-new', '--allow-delete', '--force-baseline', '--force-rescan',
597
634
  '--json', '--dir', '--help',
@@ -830,6 +867,15 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
830
867
  changeName = autoName
831
868
  } else {
832
869
  console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
870
+ // 自愈引导(坑 suggestion-command-missing-change 同族):多活跃变更仓不带 --change 时
871
+ // pm.read 无法自动定位(单活跃可自动),列出候选让 agent 一步补对,而非盲猜变更名
872
+ try {
873
+ const activeChanges = pm.listChanges(cwd)
874
+ if (activeChanges.length > 1) {
875
+ console.error(` 当前有 ${activeChanges.length} 个活跃变更(无法自动定位),请显式指定:`)
876
+ for (const c of activeChanges) console.error(` - --change ${c}`)
877
+ }
878
+ } catch { /* 列举失败不掩盖原报错 */ }
833
879
  process.exit(2) // 环境错(未 init / 未传 --change)→ exit 2
834
880
  }
835
881
  }
@@ -950,6 +996,54 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
950
996
  }
951
997
  }
952
998
 
999
+ // ── quick --cancel:误启动会话的取消口(数据源 quicklog.js cancelQuickSession)──
1000
+ // --change 取 quick-<uuid8> 会话 ID(quick 启动时 CLI echo 的 sessionId);qlId 从会话 guard
1001
+ // 或 --ql 显式参数解析。取消后 db 行注销(quick 会话行非真实变更,直接 unregister 安全)。
1002
+ if (stageName === 'quick' && isCancel) {
1003
+ const cancelSessionRaw = quickSessionId || changeName
1004
+ const qlFlag = getFlagValue('--ql')
1005
+ if (!cancelSessionRaw || cancelSessionRaw === 'quick-unknown') {
1006
+ console.error('用法: sillyspec run quick --cancel --change <quick-会话ID> [--ql <ql-xxx>]\n 取消误启动的 quick 会话:QUICKLOG 条目翻「已取消」+ tasks.md 挂载行移除 + 会话目录清理 + db 行注销。\n 已完成/已勾选的会话拒绝取消(真实工作记录)')
1007
+ process.exit(2)
1008
+ }
1009
+ const specBase = resolveSpecDir(cwd, { specDir })
1010
+ // qlId 优先 --ql 显式;否则读会话 guard.json(allocate 时 CLI 写入);再退 current marker 旁的会话
1011
+ let qlId = qlFlag
1012
+ if (!qlId) {
1013
+ const guardPath = join(specBase, '.runtime', 'quick-sessions', cancelSessionRaw, 'guard.json')
1014
+ try {
1015
+ if (existsSync(guardPath)) qlId = JSON.parse(readFileSync(guardPath, 'utf8')).qlId
1016
+ } catch { /* guard 损坏 → 下面按找不到报错 */ }
1017
+ }
1018
+ if (!qlId) {
1019
+ console.error(`❌ 无法定位会话 ${cancelSessionRaw} 的 qlId(guard.json 缺失/损坏)——显式传 --ql <ql-xxx>(QUICKLOG 条目头)`)
1020
+ process.exit(1)
1021
+ }
1022
+ let gitUser = 'unknown'
1023
+ try { gitUser = (await import('../git-helper.js')).git(cwd, ['config', 'user.name']) || 'unknown' } catch {}
1024
+ const { cancelQuickSession } = await import('../quicklog.js')
1025
+ let result
1026
+ try {
1027
+ result = await cancelQuickSession({ specBase, gitUser, qlId, sessionId: cancelSessionRaw })
1028
+ } catch (e) {
1029
+ console.error(`❌ 取消失败: ${e.message}`)
1030
+ process.exit(1)
1031
+ }
1032
+ if (!result.ok) {
1033
+ console.error(`❌ 取消失败: ${result.reason}`)
1034
+ process.exit(1)
1035
+ }
1036
+ // db 侧:quick 会话行注销(quick-<uuid8> 是进度库里的会话行,非真实变更目录)
1037
+ try {
1038
+ pm.unregisterChange(cwd, cancelSessionRaw)
1039
+ } catch { /* 行不存在/平台模式 → 会话目录清理已完成,db 无行可注销不算失败 */ }
1040
+ console.log(`🗑️ 已取消 quick 会话 ${cancelSessionRaw}(qlId ${qlId})`)
1041
+ console.log(` QUICKLOG 条目翻「已取消」: ${result.quicklogFile}`)
1042
+ if (result.removedTaskRows.length > 0) console.log(` tasks.md 挂载行已移除: ${result.removedTaskRows.join('、')}`)
1043
+ console.log(' 会话 guard 目录与 current marker 已清理(已完成条目不会被取消)')
1044
+ return
1045
+ }
1046
+
953
1047
  // quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
954
1048
  // sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
955
1049
  // 这里复用同一值写 current-quick-run-id,保证两处一致。