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.
- package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/package.json +1 -1
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/docs-check.js +48 -12
- package/src/doctor-diagnostics.js +49 -0
- package/src/hooks/worktree-guard.js +20 -1
- package/src/index.js +547 -21
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +146 -0
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress.js +18 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +151 -38
- package/src/run/complete-handlers.js +62 -4
- package/src/run/complete.js +10 -8
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +86 -4
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +28 -6
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +28 -7
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-review.js +55 -7
- package/src/stages/archive.js +1 -1
- package/src/stages/brainstorm.js +26 -12
- package/src/stages/doctor.js +2 -4
- package/src/stages/execute.js +110 -26
- package/src/stages/knowledge.js +21 -5
- package/src/stages/plan-postcheck.js +24 -10
- package/src/stages/plan.js +14 -9
- package/src/stages/quick.js +14 -6
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +7 -49
- package/src/sync.js +19 -7
- package/src/task-review.js +256 -10
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +15 -1
- package/src/worktree.js +58 -8
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- 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>
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|