sillyspec 3.26.14 → 3.27.1
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-explore/SKILL.md +10 -3
- 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 +4 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/change-risk-profile.js +21 -6
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -534
- package/src/doctor-diagnostics.js +102 -1
- package/src/endpoint-extractor.js +21 -2
- package/src/fs-atomic.js +4 -2
- package/src/git-helper.js +73 -5
- package/src/hooks/claude-pre-tool-use.cjs +10 -5
- package/src/hooks/worktree-guard.js +42 -4
- package/src/index.js +557 -12
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +166 -0
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +19 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +95 -1
- package/src/run/complete-handlers.js +73 -5
- package/src/run/complete.js +47 -14
- package/src/run/gates.js +144 -17
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +36 -4
- package/src/run/quick-audit.js +5 -2
- package/src/run/shared.js +48 -20
- package/src/run/stage.js +37 -3
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-contract.js +14 -1
- package/src/stage-review.js +15 -6
- package/src/stages/archive.js +4 -2
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/execute.js +275 -44
- package/src/stages/explore.js +11 -2
- package/src/stages/knowledge.js +3 -2
- package/src/stages/quick.js +18 -9
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +16 -51
- package/src/sync.js +44 -3
- package/src/task-review.js +273 -11
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +43 -11
- package/src/worktree-deps.js +59 -5
- package/src/worktree.js +128 -14
- 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,166 @@
|
|
|
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
|
+
// includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
|
|
96
|
+
// worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
|
|
97
|
+
const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
|
|
98
|
+
const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
|
|
99
|
+
if (sourceFiles.length === 0) return null
|
|
100
|
+
|
|
101
|
+
const byModule = new Map()
|
|
102
|
+
const unmatched = []
|
|
103
|
+
for (const f of sourceFiles) {
|
|
104
|
+
const mod = classifyFile(f, modulePaths)
|
|
105
|
+
if (mod) {
|
|
106
|
+
if (!byModule.has(mod)) byModule.set(mod, [])
|
|
107
|
+
byModule.get(mod).push(f)
|
|
108
|
+
} else {
|
|
109
|
+
unmatched.push(f)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const L = [
|
|
114
|
+
'# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
|
|
115
|
+
'',
|
|
116
|
+
'> 文件×模块归属由 CLI 按 _module-map.yaml paths 前缀匹配预填;',
|
|
117
|
+
'> **影响类型**(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 review 标记是语义判断,',
|
|
118
|
+
'> 逐行把 <!--TODO--> 替换为真实结论——以 git diff 为准(真实 > 声明)。',
|
|
119
|
+
'',
|
|
120
|
+
'## 模块影响矩阵',
|
|
121
|
+
'',
|
|
122
|
+
'| 模块 | 变更文件 | 影响类型 | 需 review |',
|
|
123
|
+
'|---|---|---|---|',
|
|
124
|
+
]
|
|
125
|
+
const rows = [...byModule.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
126
|
+
for (const [moduleId, files] of rows) {
|
|
127
|
+
for (const f of files) {
|
|
128
|
+
L.push(`| ${moduleId} | \`${f}\` | <!--TODO--> | <!--TODO--> |`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
L.push('')
|
|
132
|
+
L.push('## 未匹配文件')
|
|
133
|
+
L.push('')
|
|
134
|
+
if (unmatched.length === 0) {
|
|
135
|
+
L.push('无(全部变更文件已归属到模块)。')
|
|
136
|
+
} else {
|
|
137
|
+
L.push('以下变更文件未命中 _module-map.yaml 任何模块 paths——确认是模块索引过期(该跑 `sillyspec modules rebuild`)还是真的游离文件:')
|
|
138
|
+
L.push('')
|
|
139
|
+
for (const f of unmatched) L.push(`- \`${f}\` <!--TODO: 归属判定-->`)
|
|
140
|
+
}
|
|
141
|
+
L.push('')
|
|
142
|
+
L.push('## 影响类型说明')
|
|
143
|
+
L.push('')
|
|
144
|
+
L.push('逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增;不确定的影响标 needs review。')
|
|
145
|
+
L.push('')
|
|
146
|
+
// 「更新结果」表骨架(2026-08-21 审查 CLI-3):verify --done 死信门(extractPendingDocSyncRows)
|
|
147
|
+
// 与 archive 移动前校验同一口径硬校验本表无 pending 行,但骨架此前不产出该表——agent 只能
|
|
148
|
+
// 从 gate 报错反推格式(plan.js prompt 自认的坑)。此处按矩阵命中模块机械生成行,agent 只回填状态。
|
|
149
|
+
L.push('## 更新结果')
|
|
150
|
+
L.push('')
|
|
151
|
+
L.push('| 目标 | 操作 | 状态 |')
|
|
152
|
+
L.push('|------|------|------|')
|
|
153
|
+
for (const [moduleId] of rows) {
|
|
154
|
+
L.push(`| \`modules/${moduleId}.md\` | 更新${moduleId}模块卡(本次变更涉及) | pending |`)
|
|
155
|
+
}
|
|
156
|
+
if (unmatched.length > 0) {
|
|
157
|
+
L.push('| `_module-map.yaml` | <!--TODO: 有未匹配文件,判定模块索引是否需增改(modules rebuild)--> | pending |')
|
|
158
|
+
} else {
|
|
159
|
+
L.push('| `_module-map.yaml` | 无变化(未增删模块) | skipped |')
|
|
160
|
+
}
|
|
161
|
+
L.push('')
|
|
162
|
+
L.push('规则:execute/verify 完成文档同步后把对应行回填 done;确定不同步的行改 skipped 并在操作列写明原因。')
|
|
163
|
+
L.push('')
|
|
164
|
+
|
|
165
|
+
return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
|
|
166
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-resolve.js — 模块卡两级级联解析(token 成本优化 P0a,2026-08-22-token-cost-optimization)
|
|
3
|
+
*
|
|
4
|
+
* 背景(multi-agent-platform 2026-08-22 实测):monorepo 根层 _module-map.yaml 把整个子项目
|
|
5
|
+
* (backend/**)映射到单张大卡(backend.md 58KB),而子项目细粒度卡(docs/backend/modules/
|
|
6
|
+
* agent.md,~6KB)由 scan 生成却无人消费——execute 每个 task 子代理都被指引去读整张大卡,
|
|
7
|
+
* 单卡一项全流程烧 20 万+ tokens 输入,且大卡「变更索引」段每变更追加、单调膨胀。
|
|
8
|
+
*
|
|
9
|
+
* 本模块 = 纯机械分类(与 module-impact.js 同性质,CLI 算事实):
|
|
10
|
+
* - collectModuleMaps:收齐 <spec>/docs/<project>/modules/_module-map.yaml 全部层级;
|
|
11
|
+
* project 名恰为仓库顶层目录(monorepo 子项目,如 backend/)→ 其 paths 视为相对该
|
|
12
|
+
* 子目录(effective = `<project>/` + path);否则视为仓库根相对(根层粗粒度 map)
|
|
13
|
+
* - matchModuleCard:跨全部 map 最长有效前缀匹配(细卡 agent/** 胜过粗卡 backend/**)
|
|
14
|
+
* - resolveChangeModuleCards:tasks.md 注册表 × tasks/task-NN.md allowed_paths → per-task
|
|
15
|
+
* 最优卡表;卡的体量(字节数)一并给出,读取建议按体量分级(>12KB 按节读)
|
|
16
|
+
*
|
|
17
|
+
* 铁律(对齐仓内先例):
|
|
18
|
+
* - 纯读:不写任何文件,不跑 git
|
|
19
|
+
* - 无 map / 无 tasks.md / 无 task 卡 → 空结果(调用方注入占位说明,不抛)
|
|
20
|
+
* - 判定语义(哪个模块才是「对的」)仍归 agent——本表只是机械最优匹配
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
23
|
+
import { join, relative } from 'path'
|
|
24
|
+
import { parseModuleMapSimple } from './modules.js'
|
|
25
|
+
import { parseTaskRegistry } from './stages/execute.js'
|
|
26
|
+
import { parseAllowedPaths } from './stages/plan-postcheck.js'
|
|
27
|
+
|
|
28
|
+
/** 模块卡「整卡可读」软上限(字节)。超过则建议按节读(契约摘要/注意事项/定位),跳过历史累积段。 */
|
|
29
|
+
export const MODULE_CARD_SOFT_LIMIT_BYTES = 12 * 1024
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 收齐 spec 下全部模块映射层。
|
|
33
|
+
* @param {{ cwd: string, specBase: string }} opts
|
|
34
|
+
* @returns {Array<{ project: string, mapPath: string, modulesDir: string, prefix: string,
|
|
35
|
+
* entries: Map<string, { paths: string[], doc: string|null }> }>}
|
|
36
|
+
* prefix:project 名为仓库顶层目录时 `<project>/`(子项目细 map),否则 ''(根层粗 map)
|
|
37
|
+
*/
|
|
38
|
+
export function collectModuleMaps({ cwd, specBase }) {
|
|
39
|
+
const maps = []
|
|
40
|
+
const docsDir = join(specBase, 'docs')
|
|
41
|
+
if (!existsSync(docsDir)) return maps
|
|
42
|
+
let projects = []
|
|
43
|
+
try {
|
|
44
|
+
projects = readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
|
|
45
|
+
} catch { return maps }
|
|
46
|
+
for (const project of projects) {
|
|
47
|
+
const modulesDir = join(docsDir, project, 'modules')
|
|
48
|
+
const mapPath = join(modulesDir, '_module-map.yaml')
|
|
49
|
+
if (!existsSync(mapPath)) continue
|
|
50
|
+
let parsed = {}
|
|
51
|
+
try {
|
|
52
|
+
parsed = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
|
|
53
|
+
} catch { continue }
|
|
54
|
+
const entries = new Map()
|
|
55
|
+
for (const [id, data] of Object.entries(parsed)) {
|
|
56
|
+
const paths = Array.isArray(data?.paths) ? data.paths : []
|
|
57
|
+
if (paths.length === 0) continue
|
|
58
|
+
entries.set(id, { paths, doc: typeof data?.doc === 'string' ? data.doc : null })
|
|
59
|
+
}
|
|
60
|
+
if (entries.size === 0) continue
|
|
61
|
+
// 子项目判定:project 名恰为仓库顶层目录 → map 的 paths 相对该子目录(monorepo 细 map)。
|
|
62
|
+
// 根层 map(project = 仓名)paths 本就仓库根相对,prefix=''。
|
|
63
|
+
let prefix = ''
|
|
64
|
+
try {
|
|
65
|
+
const topDir = join(cwd, project)
|
|
66
|
+
if (existsSync(topDir) && statSync(topDir).isDirectory()) prefix = `${project}/`
|
|
67
|
+
} catch { /* stat 失败按根层处理 */ }
|
|
68
|
+
maps.push({ project, mapPath, modulesDir, prefix, entries })
|
|
69
|
+
}
|
|
70
|
+
return maps
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 路径归一:反斜杠→正斜杠,剥尾部 glob(/** 与 /*)与目录斜杠。
|
|
75
|
+
* `backend/**` / `backend/` / `backend` → `backend`。
|
|
76
|
+
*/
|
|
77
|
+
function normalizeMapPath(raw) {
|
|
78
|
+
return String(raw).replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/$/, '')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 跨全部 map 最长有效前缀匹配一个仓库根相对路径。
|
|
83
|
+
* @param {string} posixPath 仓库根相对 posix 路径
|
|
84
|
+
* @param {Array} maps collectModuleMaps 产物
|
|
85
|
+
* @returns {{ moduleId: string, map: object, effectivePrefix: string }|null}
|
|
86
|
+
*/
|
|
87
|
+
export function matchModuleCard(posixPath, maps) {
|
|
88
|
+
const p = String(posixPath).replace(/\\/g, '/')
|
|
89
|
+
let best = null
|
|
90
|
+
for (const map of maps) {
|
|
91
|
+
for (const [moduleId, entry] of map.entries) {
|
|
92
|
+
for (const raw of entry.paths) {
|
|
93
|
+
const effective = (map.prefix + normalizeMapPath(raw)).replace(/\/+$/, '')
|
|
94
|
+
if (!effective) continue
|
|
95
|
+
if (p === effective || p.startsWith(effective + '/')) {
|
|
96
|
+
if (!best || effective.length > best.effectivePrefix.length) {
|
|
97
|
+
best = { moduleId, map, effectivePrefix: effective }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 解析变更全部 task 的最优模块卡。
|
|
108
|
+
* @param {{ cwd: string, specBase: string, changeName: string }} opts
|
|
109
|
+
* @returns {{ hasMaps: boolean, rows: Array<{ taskId: string, taskName: string,
|
|
110
|
+
* moduleId: string|null, granularity: 'fine'|'coarse'|null,
|
|
111
|
+
* cardPath: string|null, cardBytes: number, advice: string,
|
|
112
|
+
* matchedCount: number, pathCount: number }> }}
|
|
113
|
+
* granularity:按胜者有效前缀段数判——1 段(整子项目)=coarse 大卡,≥2 段=fine 细卡
|
|
114
|
+
*/
|
|
115
|
+
export function resolveChangeModuleCards({ cwd, specBase, changeName }) {
|
|
116
|
+
const maps = collectModuleMaps({ cwd, specBase })
|
|
117
|
+
const changeDir = join(specBase, 'changes', changeName || '')
|
|
118
|
+
let registry = []
|
|
119
|
+
const tasksMd = join(changeDir, 'tasks.md')
|
|
120
|
+
if (existsSync(tasksMd)) {
|
|
121
|
+
registry = parseTaskRegistry(readFileSync(tasksMd, 'utf8'))
|
|
122
|
+
}
|
|
123
|
+
const rows = []
|
|
124
|
+
for (const task of registry) {
|
|
125
|
+
// 任务路径源:task 卡 allowed_paths(主)> 注册表行内 (文件路径) 尾注(兜底)
|
|
126
|
+
let paths = []
|
|
127
|
+
const cardFile = join(changeDir, 'tasks', `${task.id}.md`)
|
|
128
|
+
if (existsSync(cardFile)) {
|
|
129
|
+
paths = parseAllowedPaths(readFileSync(cardFile, 'utf8'))
|
|
130
|
+
}
|
|
131
|
+
if (paths.length === 0 && task.file) paths = [task.file]
|
|
132
|
+
paths = paths.map(p => p.replace(/\\/g, '/')).filter(Boolean)
|
|
133
|
+
|
|
134
|
+
// 全路径逐个匹配,取「最长有效前缀」的模块为该 task 的最优卡。粒度按有效前缀段数判:
|
|
135
|
+
// 1 段(backend/frontend 整子项目)=粗层大卡;≥2 段=细层卡(段数与卡体量正相关,
|
|
136
|
+
// SillyHub 这类根相对细 map 同样正确——不能按 map 的 prefix 有无判,会误标)
|
|
137
|
+
let winner = null
|
|
138
|
+
let matchedCount = 0
|
|
139
|
+
for (const p of paths) {
|
|
140
|
+
const m = matchModuleCard(p, maps)
|
|
141
|
+
if (!m) continue
|
|
142
|
+
matchedCount++
|
|
143
|
+
if (!winner || m.effectivePrefix.length > winner.effectivePrefix.length) winner = m
|
|
144
|
+
}
|
|
145
|
+
let granularity = null
|
|
146
|
+
if (winner) {
|
|
147
|
+
const segs = winner.effectivePrefix.split('/').filter(Boolean).length
|
|
148
|
+
granularity = segs >= 2 ? 'fine' : 'coarse'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let cardPath = null
|
|
152
|
+
let cardBytes = 0
|
|
153
|
+
let advice = '—'
|
|
154
|
+
if (winner) {
|
|
155
|
+
const docRel = winner.map.entries.get(winner.moduleId)?.doc || `modules/${winner.moduleId}.md`
|
|
156
|
+
const abs = join(winner.map.modulesDir, '..', docRel)
|
|
157
|
+
if (existsSync(abs)) {
|
|
158
|
+
const rel = relative(cwd, abs).replace(/\\/g, '/')
|
|
159
|
+
cardPath = rel.startsWith('..') ? abs.replace(/\\/g, '/') : rel
|
|
160
|
+
try { cardBytes = statSync(abs).size } catch { cardBytes = 0 }
|
|
161
|
+
advice = cardBytes > MODULE_CARD_SOFT_LIMIT_BYTES
|
|
162
|
+
? `按节读(契约摘要/注意事项/定位;跳过变更索引/人工备注)`
|
|
163
|
+
: '整卡可读'
|
|
164
|
+
} else {
|
|
165
|
+
cardPath = relative(cwd, abs).replace(/\\/g, '/')
|
|
166
|
+
advice = '卡文件缺失(modules rebuild?)'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
rows.push({
|
|
170
|
+
taskId: task.id,
|
|
171
|
+
taskName: task.name,
|
|
172
|
+
moduleId: winner ? winner.moduleId : null,
|
|
173
|
+
granularity,
|
|
174
|
+
cardPath,
|
|
175
|
+
cardBytes,
|
|
176
|
+
advice,
|
|
177
|
+
matchedCount,
|
|
178
|
+
pathCount: paths.length,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return { hasMaps: maps.length > 0, rows }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 渲染 per-task 模块卡表(注入 execute「加载上下文」步 prompt 用)。
|
|
186
|
+
* 无 map → 提示跳过;有 map 无命中行 → 单行说明。恒返回非空字符串(占位符必被替换)。
|
|
187
|
+
*/
|
|
188
|
+
export function renderModuleResolveTable({ cwd, specBase, changeName }) {
|
|
189
|
+
const { hasMaps, rows } = resolveChangeModuleCards({ cwd, specBase, changeName })
|
|
190
|
+
if (!hasMaps) return '(未找到 _module-map.yaml——本项目无模块卡,跳过模块文档加载)'
|
|
191
|
+
const withMatch = rows.filter(r => r.moduleId)
|
|
192
|
+
if (withMatch.length === 0) {
|
|
193
|
+
return '(已找到 _module-map.yaml,但当前变更任务未命中任何模块 paths——跳过模块文档加载;游离文件可在 Wave 收尾后跑 `sillyspec modules rebuild`)'
|
|
194
|
+
}
|
|
195
|
+
const kb = (n) => n > 0 ? `${(n / 1024).toFixed(1)}KB` : '?'
|
|
196
|
+
const L = [
|
|
197
|
+
'| task | 模块 | 粒度 | 模块卡(只读命中的卡,勿按目录整读) | 体量 | 读取建议 |',
|
|
198
|
+
'|---|---|---|---|---|---|',
|
|
199
|
+
]
|
|
200
|
+
for (const r of rows) {
|
|
201
|
+
if (r.moduleId) {
|
|
202
|
+
L.push(`| ${r.taskId} | ${r.moduleId} | ${r.granularity === 'fine' ? '细' : '粗'} | \`${r.cardPath}\` | ${kb(r.cardBytes)} | ${r.advice} |`)
|
|
203
|
+
} else {
|
|
204
|
+
L.push(`| ${r.taskId} | — | — | (无命中,跳过模块文档) | — | — |`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
L.push('')
|
|
208
|
+
L.push('> 匹配规则:全部 _module-map.yaml 跨层最长前缀(子项目细卡优先于根层大卡),源=tasks 卡 allowed_paths。')
|
|
209
|
+
L.push('> 细卡整卡可读;仅粗卡命中且体量大时**按节读**——只读「契约摘要/注意事项/定位」,跳过「变更索引/人工备注」历史累积段。')
|
|
210
|
+
return L.join('\n')
|
|
211
|
+
}
|
package/src/modules.js
CHANGED
|
@@ -181,11 +181,91 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
|
|
|
181
181
|
return { dryRun: false, path: mapPath, modules: allModuleIds.size }
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* split-changelog:把模块卡的「变更索引」历史段迁出到同目录 <module>.changelog.md sidecar
|
|
186
|
+
* (token 成本优化 P0b,2026-08-22-token-cost-optimization)。
|
|
187
|
+
*
|
|
188
|
+
* 背景:变更索引段每变更追加、单调膨胀(multi-agent-platform backend.md 实测 19.9KB/占整卡
|
|
189
|
+
* 36%,18 天 3.6 倍),而子代理实现新 task 只需要卡的「定位/契约摘要/注意事项」当前态——
|
|
190
|
+
* 历史段是纯读取税。迁出后卡只留指针行,新条目由 prompt 约定直接追加 sidecar。
|
|
191
|
+
*
|
|
192
|
+
* 安全(对齐 rebuild 的破坏性保护):默认 dry-run 只预览不写,--force 才落盘。
|
|
193
|
+
* 只动「## 变更索引」一节,卡内其他节原样保留;sidecar 已存在则追加(幂等可重跑)。
|
|
194
|
+
*
|
|
195
|
+
* @param {string} cwd 项目根
|
|
196
|
+
* @param {{ force?: boolean }} opts
|
|
197
|
+
* @returns {{ dryRun: boolean, files: Array<{ card: string, sidecar: string, bytesMoved: number }>, skipped: number }}
|
|
198
|
+
*/
|
|
199
|
+
export function splitChangelog(cwd, { force = false } = {}) {
|
|
200
|
+
const docsDir = join(cwd, '.sillyspec', 'docs');
|
|
201
|
+
const results = [];
|
|
202
|
+
let skipped = 0;
|
|
203
|
+
const projects = existsSync(docsDir)
|
|
204
|
+
? readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
|
|
205
|
+
: [];
|
|
206
|
+
|
|
207
|
+
for (const project of projects) {
|
|
208
|
+
const modulesDir = join(docsDir, project, 'modules');
|
|
209
|
+
if (!existsSync(modulesDir)) continue;
|
|
210
|
+
const cards = readdirSync(modulesDir).filter(f => f.endsWith('.md') && f !== '_module-map.yaml');
|
|
211
|
+
for (const f of cards) {
|
|
212
|
+
const cardPath = join(modulesDir, f);
|
|
213
|
+
const content = readFileSync(cardPath, 'utf8');
|
|
214
|
+
// 提取「## 变更索引」节(CRLF 归一后行级匹配,与 modules.js 其余解析同防线)
|
|
215
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
216
|
+
const startIdx = lines.findIndex(l => /^##\s*变更索引\s*$/.test(l));
|
|
217
|
+
if (startIdx < 0) { skipped++; continue; }
|
|
218
|
+
let endIdx = lines.length;
|
|
219
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
220
|
+
if (/^##\s/.test(lines[i])) { endIdx = i; break; }
|
|
221
|
+
}
|
|
222
|
+
const body = lines.slice(startIdx + 1, endIdx).join('\n').trim();
|
|
223
|
+
// 已迁出(只剩指针行)或本就为空 → 跳过
|
|
224
|
+
if (!body || /^见\s/.test(body) || body.includes('.changelog.md')) { skipped++; continue; }
|
|
225
|
+
const bytesMoved = Buffer.byteLength(body, 'utf8');
|
|
226
|
+
const stem = f.replace(/\.md$/, '');
|
|
227
|
+
const sidecarPath = join(modulesDir, `${stem}.changelog.md`);
|
|
228
|
+
results.push({ card: cardPath, sidecar: sidecarPath, bytesMoved, _lines: lines, _startIdx: startIdx, _endIdx: endIdx, _body: body });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!force) {
|
|
233
|
+
if (results.length === 0) {
|
|
234
|
+
console.log('📭 没有需要迁出的「变更索引」段(无模块卡 / 已迁出 / 段为空)');
|
|
235
|
+
} else {
|
|
236
|
+
console.log('⚠️ split-changelog 默认不写入(破坏性保护):会把模块卡「变更索引」节迁出到同目录 <module>.changelog.md');
|
|
237
|
+
for (const r of results) {
|
|
238
|
+
console.log(` ${r.card} → ${r.sidecar}(${(r.bytesMoved / 1024).toFixed(1)}KB)`);
|
|
239
|
+
}
|
|
240
|
+
console.log(' 本次为预览,未写入磁盘。确认迁出请运行:sillyspec modules split-changelog --force');
|
|
241
|
+
}
|
|
242
|
+
return { dryRun: true, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const r of results) {
|
|
246
|
+
// sidecar:已存在追加(分隔线 + 迁出来源),否则新建带头部
|
|
247
|
+
const header = `# ${r.card.split(/[\\/]/).pop().replace(/\.md$/, '')} 变更索引\n\n> 由 sillyspec modules split-changelog 自模块卡迁出;新条目继续追加到本文件,勿写回模块卡。\n`;
|
|
248
|
+
let sidecarContent = header;
|
|
249
|
+
if (existsSync(r.sidecar)) {
|
|
250
|
+
sidecarContent = readFileSync(r.sidecar, 'utf8').replace(/\s*$/, '') + '\n\n---\n\n' + r._body + '\n';
|
|
251
|
+
} else {
|
|
252
|
+
sidecarContent = header + '\n' + r._body + '\n';
|
|
253
|
+
}
|
|
254
|
+
writeFileSync(r.sidecar, sidecarContent, 'utf8');
|
|
255
|
+
// 卡内节体替换为指针行(只动该节,其余逐字保留;_lines 已是 LF 归一)
|
|
256
|
+
const stem = r.card.split(/[\\/]/).pop().replace(/\.md$/, '');
|
|
257
|
+
const newLines = [...r._lines.slice(0, r._startIdx + 1), '', `见 \`${stem}.changelog.md\`——历史条目已迁出;新条目直接追加 sidecar,勿写回本卡。`, '', ...r._lines.slice(r._endIdx)];
|
|
258
|
+
writeFileSync(r.card, newLines.join('\n'), 'utf8');
|
|
259
|
+
console.log(`✅ ${r.card} → ${r.sidecar}(迁出 ${(r.bytesMoved / 1024).toFixed(1)}KB,卡内留指针)`);
|
|
260
|
+
}
|
|
261
|
+
if (results.length === 0) console.log('📭 没有需要迁出的「变更索引」段');
|
|
262
|
+
return { dryRun: false, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
|
|
263
|
+
}
|
|
264
|
+
|
|
184
265
|
/**
|
|
185
266
|
* status: 显示模块索引状态
|
|
186
267
|
*/
|
|
187
|
-
export async function showModuleStatus(cwd) {
|
|
188
|
-
const mapPath = findModuleMapPath(cwd);
|
|
268
|
+
export async function showModuleStatus(cwd) { const mapPath = findModuleMapPath(cwd);
|
|
189
269
|
if (!mapPath || !existsSync(mapPath)) {
|
|
190
270
|
console.log('📭 未找到 _module-map.yaml');
|
|
191
271
|
console.log(' 提示:先运行 sillyspec run scan 生成模块文档');
|
|
@@ -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
|
});
|