sillyspec 3.26.15 → 3.27.2
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-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/agent-session-log.js +763 -0
- package/src/change-list.js +22 -2
- package/src/change-risk-profile.js +21 -6
- package/src/contract-matrix.js +91 -7
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -570
- package/src/doctor-diagnostics.js +53 -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 +22 -3
- package/src/index.js +59 -7
- package/src/module-impact.js +21 -1
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +6 -2
- package/src/quicklog.js +11 -3
- package/src/run/command.js +40 -0
- package/src/run/complete-handlers.js +17 -2
- package/src/run/complete.js +81 -8
- package/src/run/gates.js +83 -14
- package/src/run/prompt.js +16 -2
- package/src/run/quick-audit.js +22 -2
- package/src/run/shared.js +70 -19
- package/src/run/stage.js +60 -3
- package/src/stage-contract.js +14 -1
- package/src/stages/archive.js +3 -1
- package/src/stages/execute.js +171 -18
- package/src/stages/explore.js +11 -2
- package/src/stages/plan-postcheck.js +3 -1
- package/src/stages/plan.js +3 -0
- package/src/stages/quick.js +4 -3
- package/src/stages/verify.js +9 -2
- package/src/sync.js +98 -3
- package/src/task-review.js +55 -7
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/worktree-apply.js +28 -10
- package/src/worktree-deps.js +71 -5
- package/src/worktree.js +70 -6
|
@@ -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 生成模块文档');
|
|
@@ -88,16 +88,16 @@ export class StepStore {
|
|
|
88
88
|
}
|
|
89
89
|
const stageId = stageRow.id;
|
|
90
90
|
|
|
91
|
-
// 重复步骤名检查
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// INSERT INTO steps(ordering 递增)
|
|
91
|
+
// 重复步骤名检查 + INSERT 同事务(2026-08-21 审查 BUG-8):查重在事务外时,
|
|
92
|
+
// 并发 add-step 同名可双双通过检查双插(steps 无 UNIQUE 约束),另一份 status 后续被 _write 覆盖丢失
|
|
93
|
+
let dupFound = false;
|
|
99
94
|
db.transaction(() => {
|
|
100
95
|
const tDb = db.getDb();
|
|
96
|
+
const dupRow = tDb.prepare('SELECT id FROM steps WHERE stage_id = ? AND name = ?').get(stageId, stepName);
|
|
97
|
+
if (dupRow !== undefined) {
|
|
98
|
+
dupFound = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
101
|
tDb.prepare(
|
|
102
102
|
`INSERT INTO steps (stage_id, name, ordering, status)
|
|
103
103
|
VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`
|
|
@@ -106,6 +106,10 @@ export class StepStore {
|
|
|
106
106
|
// 本地脏度(D-013 / task-04):添加步骤是本地推进
|
|
107
107
|
this.pm._touchLocalModified(cwd, cn);
|
|
108
108
|
});
|
|
109
|
+
if (dupFound) {
|
|
110
|
+
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
109
113
|
|
|
110
114
|
console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
|
|
111
115
|
}
|
package/src/progress.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
16
17
|
import { DB } from './db.js';
|
|
17
18
|
import { checkExecuteCodeEvidence } from './stage-contract.js';
|
|
18
19
|
import { ConsistencyDoctor } from './progress/consistency-doctor.js';
|
|
@@ -1015,9 +1016,12 @@ export class ProgressManager {
|
|
|
1015
1016
|
if (existsSync(gitignorePath)) {
|
|
1016
1017
|
const content = readFileSync(gitignorePath, 'utf8');
|
|
1017
1018
|
if (content.includes(rule)) return;
|
|
1018
|
-
|
|
1019
|
+
// 按既有 EOL 追加(2026-08-21 审查 BUG-9):CRLF 检出的文件混入 LF 行产生全文件
|
|
1020
|
+
// diff 噪声/autocrlf 折腾;写走原子 rename 防并发 init 双跑交错
|
|
1021
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
1022
|
+
writeAtomicSync(gitignorePath, content.trimEnd() + eol + rule + eol);
|
|
1019
1023
|
} else {
|
|
1020
|
-
|
|
1024
|
+
writeAtomicSync(gitignorePath, rule + '\n');
|
|
1021
1025
|
}
|
|
1022
1026
|
}
|
|
1023
1027
|
|
package/src/quicklog.js
CHANGED
|
@@ -265,10 +265,18 @@ export function deriveTitleFromLinkedChange(specBase, change) {
|
|
|
265
265
|
try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
|
|
266
266
|
const m = content.match(/^#\s+(.+?)\s*$/m)
|
|
267
267
|
if (!m) continue
|
|
268
|
-
|
|
269
|
-
// 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
|
|
268
|
+
let raw = m[1].trim()
|
|
269
|
+
// 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc。
|
|
270
|
+
// 固定前缀显式剥(坑 linked-task-placeholder-title,2026-08-22 实证:标题「# 提案书(Proposal):
|
|
271
|
+
// xxx」或「# 提案书(Proposal)」无破折号时 dash 匹配失败,整串前缀落进 tasks.md 追加行)——
|
|
272
|
+
// 先剥固定模板词(含全/半角冒号分隔形态),再剥破折号;剥完为空(纯模板标题)继续找下一文档
|
|
273
|
+
for (const prefix of [/^提案书(Proposal)\s*[::—\-]?\s*/, /^设计文档(Design)\s*[::—\-]?\s*/, /^Proposal\s*[::—\-]?\s*/i, /^Design\s*[::—\-]?\s*/i]) {
|
|
274
|
+
raw = raw.replace(prefix, '').trim()
|
|
275
|
+
}
|
|
276
|
+
if (!raw) continue
|
|
270
277
|
const dash = raw.match(/[—-]\s*(.+)$/)
|
|
271
|
-
|
|
278
|
+
const title = dash ? dash[1].trim() : raw
|
|
279
|
+
if (title) return title
|
|
272
280
|
}
|
|
273
281
|
return ''
|
|
274
282
|
}
|
package/src/run/command.js
CHANGED
|
@@ -163,6 +163,9 @@ sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${
|
|
|
163
163
|
* sillyspec run <stage> 主命令
|
|
164
164
|
*/
|
|
165
165
|
export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
166
|
+
// 原始 cwd 快照(下方 cwd 纠正前):agent 在子目录启动时,其会话 transcript 挂在子目录
|
|
167
|
+
// 对应的 harness 项目目录下,agent-log 探测须用原始 cwd(纠正后的是项目根,探测会扑空)
|
|
168
|
+
const invokedCwd = cwd
|
|
166
169
|
// W1-B: run <stage> 暂不支持 --json。之前 --json 进 knownFlags 白名单却从不读取(静默吞),
|
|
167
170
|
// agent 按 gate/derive 契约期望 envelope 实则拿到混合人类文本。显式 fail-fast 杜绝歧义;
|
|
168
171
|
// 完整 run envelope 待 outputStep 重构(W6)后支持(prompt + nextAction 结构化)。
|
|
@@ -366,6 +369,29 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
366
369
|
// let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
|
|
367
370
|
let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
368
371
|
|
|
372
|
+
// ── 本地 agent 会话日志登记 + 上报(平台会话展示用,best-effort)──
|
|
373
|
+
// 平台/daemon 模式下 SillyHub 只能看到 CLI 阶段信息,看不到本地 agent 的实际执行日志。
|
|
374
|
+
// 在此探测当前 agent 环境(Claude Code / Codex / ZCode transcript / SILLYSPEC_AGENT_LOG
|
|
375
|
+
// 覆盖),把日志本地路径登记进 <runtimeRoot>/agent-session-log.json(留底)并立即
|
|
376
|
+
// REST 上报平台(POST /api/agent-logs,与进度上报同风格;协议见
|
|
377
|
+
// docs/platform-agent-log-protocol.md)。探测不到不发不写;任何失败只 warn 一行,
|
|
378
|
+
// 绝不阻断 run 主流程。
|
|
379
|
+
try {
|
|
380
|
+
const { recordAgentLogInvocation } = await import('../agent-session-log.js')
|
|
381
|
+
const r = await recordAgentLogInvocation({
|
|
382
|
+
cwd,
|
|
383
|
+
invokedCwd,
|
|
384
|
+
platformOpts,
|
|
385
|
+
specBase,
|
|
386
|
+
// 只记 flag 名不记值(--output 等 flag 值是 agent 工作文本,不进产物)
|
|
387
|
+
command: [stageName, ...flags.filter(t => typeof t === 'string' && t.startsWith('--'))].join(' '),
|
|
388
|
+
})
|
|
389
|
+
if (r && r.isNew && r.latestLogPath) {
|
|
390
|
+
const pushNote = r.pushed === true ? '已上报平台' : r.pushed === false ? '上报失败(本地已留底)' : '本地留底'
|
|
391
|
+
console.log(`📄 本地 agent 日志已登记(${pushNote}): ${r.latestLogPath}`)
|
|
392
|
+
}
|
|
393
|
+
} catch { /* best-effort:登记失败不影响 run 主流程 */ }
|
|
394
|
+
|
|
369
395
|
// 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
|
|
370
396
|
// 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
|
|
371
397
|
// 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
|
|
@@ -1076,6 +1102,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
1076
1102
|
progress = pm.read(cwd, effectiveChange) || progress
|
|
1077
1103
|
}
|
|
1078
1104
|
|
|
1105
|
+
// --file-notes 非末步静默忽略前置 warn(坑 quick-file-notes-nonfinal-ignored,2026-08-22 实证:
|
|
1106
|
+
// step2 --done 传 --file-notes 被静默丢——CLI 短进程,注入随进程结束即丢,白传一轮)。
|
|
1107
|
+
// 放此处:ensureStageSteps 之后 progress 已就绪可判步骤状态;仅 quick + 带了 --file-notes +
|
|
1108
|
+
// 非「唯一 pending 的末步 --done」时提示。末步 --done 是消费点,不提示。
|
|
1109
|
+
if (quickFileNotes !== '' && stageName === 'quick' && !isStatus) {
|
|
1110
|
+
try {
|
|
1111
|
+
const qSteps = progress?.stages?.quick?.steps
|
|
1112
|
+
const pendingCount = Array.isArray(qSteps) ? qSteps.filter(st => ['pending', 'in-progress'].includes(st?.status)).length : 0
|
|
1113
|
+
if (!isDone || pendingCount > 1) {
|
|
1114
|
+
console.warn(`⚠️ --file-notes 本次不会生效:它只在 quick 末步(暂存和更新记录)--done 时随收尾消费——CLI 是短进程,本次注入随进程结束即丢。请在末步 --done 时连同 --output 一起传。`)
|
|
1115
|
+
}
|
|
1116
|
+
} catch { /* 判定失败不提示(fail-open) */ }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1079
1119
|
// --status
|
|
1080
1120
|
if (isStatus) {
|
|
1081
1121
|
// D9:状态展示末尾附「下一步该跑什么」。pm 持有状态机,按 progress 实际进度算出精确命令;
|
|
@@ -127,7 +127,7 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
|
|
|
127
127
|
* 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
|
|
128
128
|
* 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
|
|
129
129
|
*/
|
|
130
|
-
async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
|
|
130
|
+
export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
|
|
131
131
|
// ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
|
|
132
132
|
// (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
|
|
133
133
|
// 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
|
|
@@ -158,7 +158,17 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
|
|
|
158
158
|
const { WorktreeManager } = await import('../worktree.js')
|
|
159
159
|
const wm = new WorktreeManager({ cwd })
|
|
160
160
|
const meta = wm.getMeta(archiveChangeName)
|
|
161
|
-
if (!meta)
|
|
161
|
+
if (!meta) {
|
|
162
|
+
// 无 meta 兜底(坑 archive-cleanup-orphan-physical-dir,2026-08-21 实证):meta 已被先行
|
|
163
|
+
// 流程注销(apply 自动 cleanup / doctor 幽灵清理)时此前直接 return——若物理目录因中途
|
|
164
|
+
// 失败残留下就是孤儿(无 meta=无锚定基准,残留即孤儿)。force 清理(幂等:什么都不存在
|
|
165
|
+
// 时 cleanup 返回 skipped 零副作用)。
|
|
166
|
+
const orphan = wm.cleanup(archiveChangeName, { force: true })
|
|
167
|
+
if (orphan.result === 'cleaned' || orphan.result === 'force-cleaned' || orphan.result === 'partial') {
|
|
168
|
+
console.log(`🧹 归档清理孤儿 worktree 残留(meta 已注销): ${archiveChangeName}${orphan.residual?.length ? '(残留: ' + orphan.residual.join('; ') + ')' : ''}`)
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
162
172
|
const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
|
|
163
173
|
if (check.hasChanges) {
|
|
164
174
|
console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
|
|
@@ -423,6 +433,11 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
|
|
|
423
433
|
}
|
|
424
434
|
const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
|
|
425
435
|
if (archivedDir && existsSync(archivedDir)) {
|
|
436
|
+
// 内存快照同步(坑 archive-progress-show-stale,2026-08-21 实证):archiveChangeDirectory 内
|
|
437
|
+
// unregisterChange 已在 DB 写 current_stage='archive'(终态一致化),但本进程 progress 是命令
|
|
438
|
+
// 开始时读的旧快照——不同步的话,completeStep 后续 _write 会用旧值(如停在 verify)把
|
|
439
|
+
// DB 覆盖回去,progress show 就一直显示归档前的阶段
|
|
440
|
+
progress.currentStage = 'archive'
|
|
426
441
|
const recommendedDocs = ['design.md', 'module-impact.md']
|
|
427
442
|
const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
|
|
428
443
|
if (missingRecommended.length > 0) {
|
package/src/run/complete.js
CHANGED
|
@@ -167,6 +167,38 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
167
167
|
steps[_staleIdx].status = 'pending'
|
|
168
168
|
currentIdx = _staleIdx
|
|
169
169
|
console.log(`⚠️ --confirm 收尾:把 stale 步骤 "${steps[_staleIdx].name}" 拉回当前步骤走完成管线,其余 stale 由回填门控一并确认。`)
|
|
170
|
+
} else if (stageData?.status !== 'completed' && steps.length > 0) {
|
|
171
|
+
// ── 矛盾态自愈(坑 execute-status-machine-contradiction,2026-08-23 实证:--done 报
|
|
172
|
+
// 「没有待完成的步骤」而 status 显示 15 步未完成——并发进程写入竞争后 DB 步骤行停在
|
|
173
|
+
// completed 但阶段 status 未推进的矛盾态,用户只能靠 verify 启动碰运气自愈)。
|
|
174
|
+
// 自愈 = 重播种:ensureStageSteps 按 def 步骤名重建步骤行(保留同名完成态),矛盾行
|
|
175
|
+
// (DB 有但 def 无的幽灵行/半写行)被清掉;重读后再走正常管线。自愈失败再报错给精确指引。
|
|
176
|
+
console.warn(`⚠️ 检测到状态机矛盾:阶段 ${stageName} 无 pending/in-progress 步骤但 status=${stageData.status}(并发写入竞争的半写态)。自动重播种修复…`)
|
|
177
|
+
try {
|
|
178
|
+
const { ensureStageSteps } = await import('./command.js')
|
|
179
|
+
const reseeded = await ensureStageSteps(progress, stageName, cwd, platformOpts?.specRoot || null)
|
|
180
|
+
if (reseeded && effectiveChange) {
|
|
181
|
+
pm._write(cwd, progress, effectiveChange)
|
|
182
|
+
progress = pm.read(cwd, effectiveChange) || progress
|
|
183
|
+
}
|
|
184
|
+
const freshSteps = progress?.stages?.[stageName]?.steps || []
|
|
185
|
+
const freshPending = freshSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
186
|
+
if (freshPending !== -1) {
|
|
187
|
+
console.log(`✅ 重播种成功:${freshSteps.filter(s => s.status === 'pending').length} 个待完成步骤恢复,继续完成管线`)
|
|
188
|
+
currentIdx = freshPending
|
|
189
|
+
steps.length = 0
|
|
190
|
+
steps.push(...freshSteps)
|
|
191
|
+
stageData.steps = freshSteps
|
|
192
|
+
} else {
|
|
193
|
+
console.error(`重播种后仍无待完成步骤(阶段 status=${progress?.stages?.[stageName]?.status ?? '未知'})。`)
|
|
194
|
+
console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''} 从头重跑,或 sillyspec doctor 诊断。`)
|
|
195
|
+
process.exit(1)
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error(`❌ 矛盾态自愈失败: ${e.message}`)
|
|
199
|
+
console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''},或 sillyspec doctor`)
|
|
200
|
+
process.exit(1)
|
|
201
|
+
}
|
|
170
202
|
} else {
|
|
171
203
|
console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
|
|
172
204
|
process.exit(1)
|
|
@@ -222,10 +254,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
222
254
|
}
|
|
223
255
|
|
|
224
256
|
// ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
|
|
225
|
-
|
|
257
|
+
// persist 回调:gate 置 step=blocked 后 exit(1) 前落库(否则 blocked 只在内存,
|
|
258
|
+
// progress show/doctor 看到的仍是 pending,误导诊断)
|
|
259
|
+
const persistBlocked = () => pm._write(cwd, progress, changeName)
|
|
260
|
+
await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
|
|
226
261
|
// review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
|
|
227
262
|
// 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
|
|
228
|
-
await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
|
|
263
|
+
await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
|
|
229
264
|
// 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
|
|
230
265
|
// symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
|
|
231
266
|
await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
|
|
@@ -594,6 +629,22 @@ async function buildDraftContext(cwd, changeName) {
|
|
|
594
629
|
}
|
|
595
630
|
}
|
|
596
631
|
|
|
632
|
+
/**
|
|
633
|
+
* 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
|
|
634
|
+
* 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
|
|
635
|
+
* 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
|
|
636
|
+
*/
|
|
637
|
+
function prefetchDiffFileSet(ctx) {
|
|
638
|
+
if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
|
|
639
|
+
try {
|
|
640
|
+
const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
|
|
641
|
+
if (!out) return ctx
|
|
642
|
+
return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
|
|
643
|
+
} catch {
|
|
644
|
+
return ctx
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
597
648
|
/**
|
|
598
649
|
* 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
|
|
599
650
|
* 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
|
|
@@ -628,6 +679,16 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
|
|
|
628
679
|
console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
|
|
629
680
|
return false
|
|
630
681
|
}
|
|
682
|
+
// 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
|
|
683
|
+
if (ctx.diffFileSet) {
|
|
684
|
+
const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
|
|
685
|
+
if (hit.length === 0) {
|
|
686
|
+
console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
|
|
690
|
+
return true
|
|
691
|
+
}
|
|
631
692
|
try {
|
|
632
693
|
// 实测 diff:git diff --name-only <base>..<head> -- <changedFiles>
|
|
633
694
|
// 用数组参数避免 shell 拆词,复用 git-helper 安全模式
|
|
@@ -650,7 +711,12 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
|
|
|
650
711
|
return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
|
|
651
712
|
}
|
|
652
713
|
try {
|
|
653
|
-
|
|
714
|
+
// 勾选写入目标漂移锚定(坑 autocheck-worktree-tasks-lost,2026-08-23 实证:agent 在
|
|
715
|
+
// worktree 内跑 execute --done 且 drift 守卫未拦(非平台模式+无 specDriftAnchor 透传的
|
|
716
|
+
// 直调路径)时,tasks.md 勾选写进 worktree 的 .sillyspec/changes/ 副本——worktree 被
|
|
717
|
+
// cleanup 整目录删除后勾选丢失,主仓 tasks.md 停在未勾态需手动补。runtimeRoot 已走
|
|
718
|
+
// resolveRuntimeRoot(drift-aware),changeDir 同步对齐:specDriftAnchor > specRoot > cwd。
|
|
719
|
+
const specBaseLc = platformOpts?.specDriftAnchor || platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
654
720
|
const changeDir = join(specBaseLc, 'changes', changeName)
|
|
655
721
|
// 2026-08-20-task-truth-unify D-001@v1:勾选写入目标从 plan.md 迁 tasks.md(任务唯一真相;
|
|
656
722
|
// plan.md Wave 引用行无 checkbox,无处可勾)。tasks.md 存在性作前置。
|
|
@@ -670,7 +736,7 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
|
|
|
670
736
|
const executeRunId = c
|
|
671
737
|
|
|
672
738
|
// W2 task-04 FR-03:构造 ctx 供草稿零 diff 守卫用(与 task-review.js generateTaskReviewDrafts 同源)
|
|
673
|
-
const ctx = await buildDraftContext(cwd, changeName)
|
|
739
|
+
const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
|
|
674
740
|
|
|
675
741
|
// tasks.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
|
|
676
742
|
// 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
|
|
@@ -748,7 +814,7 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
|
|
|
748
814
|
}
|
|
749
815
|
|
|
750
816
|
// 构造 ctx 供草稿零 diff 校验用(与 task-04 同源)
|
|
751
|
-
const ctx = await buildDraftContext(cwd, changeName)
|
|
817
|
+
const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
|
|
752
818
|
const runtimeRoot = resolveRuntimeRoot({ specRoot: specBase }, specBase)
|
|
753
819
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
754
820
|
const blockedTasks = []
|
|
@@ -783,11 +849,18 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
|
|
|
783
849
|
blockedTasks.push(taskId)
|
|
784
850
|
continue
|
|
785
851
|
}
|
|
786
|
-
// 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04
|
|
852
|
+
// 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源;优先用预取全量集,
|
|
853
|
+
// 免去每 task 一次 spawn——与 shouldAutoCheckTask 同口径)
|
|
787
854
|
if (ctx && ctx.gitDir && ctx.base && ctx.head) {
|
|
788
855
|
try {
|
|
789
|
-
|
|
790
|
-
if (
|
|
856
|
+
let hit = null
|
|
857
|
+
if (ctx.diffFileSet) {
|
|
858
|
+
hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
|
|
859
|
+
} else {
|
|
860
|
+
const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
|
|
861
|
+
hit = diffResult && diffResult.trim() !== '' ? changedFiles : []
|
|
862
|
+
}
|
|
863
|
+
if (hit.length === 0) {
|
|
791
864
|
blockedTasks.push(taskId)
|
|
792
865
|
}
|
|
793
866
|
} catch (e) {
|