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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-probes.js — `sillyspec verify-probes` 的机械探针实现 + verify-result.md 骨架生成
|
|
3
|
+
* (2026-08-21 agent-手工产出审计第三批 H1/H3/H5/H7/F9)。
|
|
4
|
+
*
|
|
5
|
+
* verify-probes.md 模板定义六个探针,agent 此前逐条手跑 grep/递归查找/git 对账再手工拼表格。
|
|
6
|
+
* 本模块把纯机械的四个探针命令化(语义判断的留 agent,输出里显式标注):
|
|
7
|
+
* 探针1 未实现标记扫描:design §6 清单的具体文件逐行 grep TODO/FIXME/尚未实现 等
|
|
8
|
+
* 探针3 测试覆盖:逐 task 按 allowed_paths 定位模块目录,递归找测试文件(co-located tests/ 陷阱)
|
|
9
|
+
* 探针5 API 契约对账:复用 contract-matrix.verifyApiParity(endpoints.json × 前端调用)+ 表格渲染
|
|
10
|
+
* 探针6 删除对账:git diff --name-status HEAD 的 D/R × design 声明操作三态判定
|
|
11
|
+
* 探针2(关键词提取半语义)/探针3.4 集成盲区/3.5 断言抽查/探针4(决策追踪语义)留 agent。
|
|
12
|
+
*
|
|
13
|
+
* verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。
|
|
14
|
+
* 结论章节留「待填」——extractVerifyConclusion 找不到 PASS/FAIL 关键词即判不过,骨架不能
|
|
15
|
+
* 直接过门(与 symbol-impact 骨架同款防偷懒语义)。
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
|
18
|
+
import { join, dirname, basename } from 'path'
|
|
19
|
+
import { gitQuiet } from './git-helper.js'
|
|
20
|
+
import { parseFileChangeListDetailed } from './change-list.js'
|
|
21
|
+
import { parseAllowedPaths } from './stages/plan-postcheck.js'
|
|
22
|
+
import { verifyApiParity } from './contract-matrix.js'
|
|
23
|
+
import { resolveSpecDir, resolveRuntimeRoot } from './run/shared.js'
|
|
24
|
+
|
|
25
|
+
const TODO_MARKER_RE = /尚未实现|TODO|FIXME|HACK|XXX/
|
|
26
|
+
const TEST_FILE_RE = /test|spec/i
|
|
27
|
+
const PROBE1_MAX_MATCHES = 200
|
|
28
|
+
|
|
29
|
+
function listTaskIds(tasksPath) {
|
|
30
|
+
if (!existsSync(tasksPath)) return []
|
|
31
|
+
const ids = []
|
|
32
|
+
for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
|
|
33
|
+
const m = line.match(/^[-*]\s*\[[ xX]\]\s*(task-\d+)\b/)
|
|
34
|
+
if (m) ids.push(m[1])
|
|
35
|
+
}
|
|
36
|
+
return ids
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 递归找测试文件(排除 node_modules/.git/dist 等;返回 posix 相对 cwd 路径,封顶展示) */
|
|
40
|
+
function findTestFiles(rootDir, cwd, cap = 10) {
|
|
41
|
+
const found = []
|
|
42
|
+
const skip = new Set(['node_modules', '.git', '.gradle', '__pycache__', '.venv', 'dist', 'build', 'target', 'out'])
|
|
43
|
+
const walk = (d) => {
|
|
44
|
+
let entries
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
47
|
+
} catch {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const full = join(d, e.name)
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
if (!skip.has(e.name)) walk(full)
|
|
54
|
+
} else if (e.isFile() && TEST_FILE_RE.test(basename(e.name))) {
|
|
55
|
+
found.push(full.split('\\').join('/').replace(cwd.split('\\').join('/').replace(/\/$/, '') + '/', ''))
|
|
56
|
+
if (found.length >= cap) return
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (existsSync(rootDir)) walk(rootDir)
|
|
61
|
+
return found
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 跑四个机械探针。
|
|
66
|
+
* @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
|
|
67
|
+
* @returns {{ probe1: object, probe3: object, probe5: object, probe6: object }}
|
|
68
|
+
*/
|
|
69
|
+
export function runVerifyProbes({ cwd, changeName, specDir = null }) {
|
|
70
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
71
|
+
const changeDir = join(specBase, 'changes', changeName)
|
|
72
|
+
const designPath = join(changeDir, 'design.md')
|
|
73
|
+
const detailed = existsSync(designPath) ? parseFileChangeListDetailed(designPath) : []
|
|
74
|
+
const designOps = new Map(detailed.map(e => [e.path, e.operation]))
|
|
75
|
+
|
|
76
|
+
// ── 探针 1:未实现标记扫描(design 清单具体文件;glob 项列出让 agent 展开)──
|
|
77
|
+
const probe1 = { matches: [], globEntries: [], skippedFiles: [] }
|
|
78
|
+
for (const e of detailed) {
|
|
79
|
+
if (e.path.startsWith('.sillyspec/')) continue
|
|
80
|
+
if (/[*?[\]]/.test(e.path)) {
|
|
81
|
+
probe1.globEntries.push(e.path)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const abs = join(cwd, e.path)
|
|
85
|
+
if (!existsSync(abs)) {
|
|
86
|
+
probe1.skippedFiles.push(e.path)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const lines = readFileSync(abs, 'utf8').split('\n')
|
|
91
|
+
lines.forEach((line, i) => {
|
|
92
|
+
if (probe1.matches.length >= PROBE1_MAX_MATCHES) return
|
|
93
|
+
if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: e.path, line: i + 1, content: line.trim().slice(0, 160) })
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
probe1.skippedFiles.push(e.path)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 探针 3:验收标准测试覆盖(task 卡 allowed_paths → 模块目录递归找测试文件)──
|
|
101
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
102
|
+
const probe3 = { tasks: [], note: '集成盲区(路由/跨模块装配)与断言有效性抽查是语义判断,留给 agent 逐 task 标注 ⚠️' }
|
|
103
|
+
for (const taskId of listTaskIds(tasksPath)) {
|
|
104
|
+
const cardPath = join(changeDir, 'tasks', `${taskId}.md`)
|
|
105
|
+
let allowed = []
|
|
106
|
+
if (existsSync(cardPath)) allowed = parseAllowedPaths(readFileSync(cardPath, 'utf8'))
|
|
107
|
+
const moduleDirs = [...new Set(allowed.map(p => dirname(p.split('\\').join('/'))).filter(d => d && d !== '.'))]
|
|
108
|
+
const testFiles = []
|
|
109
|
+
for (const d of moduleDirs) {
|
|
110
|
+
for (const f of findTestFiles(join(cwd, d), cwd)) {
|
|
111
|
+
if (!testFiles.includes(f)) testFiles.push(f)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
probe3.tasks.push({
|
|
115
|
+
task: taskId,
|
|
116
|
+
moduleDirs,
|
|
117
|
+
testFiles: testFiles.slice(0, 10),
|
|
118
|
+
testFileCount: testFiles.length,
|
|
119
|
+
hasTest: testFiles.length > 0,
|
|
120
|
+
located: moduleDirs.length > 0,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── 探针 5:API 契约对账(复用 verifyApiParity:endpoints.json × 前端调用)──
|
|
125
|
+
const runtimeRoot = resolveRuntimeRoot({ specRoot: specDir }, specBase)
|
|
126
|
+
const probe5 = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
|
|
127
|
+
|
|
128
|
+
// ── 探针 6:代码删除对账(git diff --name-status HEAD 的 D/R × design 声明三态)──
|
|
129
|
+
const probe6 = { deletions: [], note: '以 git 事实为准(真实 > 声明);是否 FAIL blocker 由 agent 诚实判定' }
|
|
130
|
+
const nsOut = gitQuiet(cwd, ['diff', '--name-status', 'HEAD']) || ''
|
|
131
|
+
for (const row of nsOut.split('\n').filter(Boolean)) {
|
|
132
|
+
const [status, ...paths] = row.split('\t')
|
|
133
|
+
const st = (status || '').trim().toUpperCase()
|
|
134
|
+
if (!/^[DRC]/.test(st)) continue
|
|
135
|
+
// R/C 的旧路径等价删除(paths[0]);D 的唯一路径
|
|
136
|
+
const target = (st.startsWith('D') ? paths[0] : paths[0]) || ''
|
|
137
|
+
const posix = target.split('\\').join('/')
|
|
138
|
+
if (!posix || posix.startsWith('.sillyspec/') || posix === 'meta.json' || basename(posix) === 'meta.json') continue
|
|
139
|
+
const op = designOps.get(posix)
|
|
140
|
+
let verdict
|
|
141
|
+
if (op && /删除|delete|del/i.test(op)) verdict = '✅ 合规(design 声明删除)'
|
|
142
|
+
else if (op) verdict = `❌ 高风险(design 声明「${op}」却整文件删除)`
|
|
143
|
+
else verdict = '⚠️ 未声明删除(design 清单未列出)'
|
|
144
|
+
probe6.deletions.push({ path: posix, status: st, designOp: op || null, verdict })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { probe1, probe3, probe5, probe6 }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 渲染探针结果为 markdown(可直接粘进 verify-result.md「探针结果」章节)。
|
|
152
|
+
*/
|
|
153
|
+
export function renderVerifyProbesReport(result) {
|
|
154
|
+
const L = []
|
|
155
|
+
const { probe1, probe3, probe5, probe6 } = result
|
|
156
|
+
|
|
157
|
+
L.push('#### 探针 1:未实现标记扫描(design 清单文件)')
|
|
158
|
+
if (probe1.matches.length === 0) {
|
|
159
|
+
L.push('- ✅ 无 TODO/FIXME/尚未实现 标记命中')
|
|
160
|
+
} else {
|
|
161
|
+
for (const m of probe1.matches) L.push(`- ⚠️ \`${m.file}:${m.line}\` ${m.content}`)
|
|
162
|
+
}
|
|
163
|
+
if (probe1.globEntries.length > 0) L.push(`- ℹ️ glob 项未展开(agent 手动展开扫描):${probe1.globEntries.join('、')}`)
|
|
164
|
+
if (probe1.skippedFiles.length > 0) L.push(`- ℹ️ 清单文件不存在(跳过):${probe1.skippedFiles.join('、')}`)
|
|
165
|
+
L.push('')
|
|
166
|
+
|
|
167
|
+
L.push('#### 探针 2:设计关键词覆盖')
|
|
168
|
+
L.push('<!--TODO: 半语义探针——从 design 提取能力关键词逐个 grep 确认实现(agent 执行)-->')
|
|
169
|
+
L.push('')
|
|
170
|
+
|
|
171
|
+
L.push('#### 探针 3:验收标准测试覆盖')
|
|
172
|
+
if (probe3.tasks.length === 0) {
|
|
173
|
+
L.push('- ℹ️ tasks.md 无 checkbox 任务')
|
|
174
|
+
} else {
|
|
175
|
+
for (const t of probe3.tasks) {
|
|
176
|
+
if (!t.located) {
|
|
177
|
+
L.push(`- ⚠️ ${t.task}: 无 task 卡/allowed_paths,无法定位模块目录(agent 手查)`)
|
|
178
|
+
} else if (t.hasTest) {
|
|
179
|
+
L.push(`- ✅ ${t.task}: 模块目录(${t.moduleDirs.join('、')})找到 ${t.testFileCount} 个测试文件(${t.testFiles.slice(0, 5).join('、')}${t.testFileCount > 5 ? ' …' : ''})`)
|
|
180
|
+
} else {
|
|
181
|
+
L.push(`- ⚠️ ${t.task}: 模块目录(${t.moduleDirs.join('、')})递归未找到测试文件(含 co-located tests/)`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
L.push(`- ℹ️ ${probe3.note}`)
|
|
186
|
+
L.push('')
|
|
187
|
+
|
|
188
|
+
L.push('#### 探针 4:决策追踪覆盖')
|
|
189
|
+
L.push('<!--TODO: 语义探针——D-xxx@vN → FR-xxx → plan/task 引用 → 证据回指闭环(agent 执行)-->')
|
|
190
|
+
L.push('')
|
|
191
|
+
|
|
192
|
+
L.push('#### 探针 5:API Contract Parity')
|
|
193
|
+
L.push(`- ${probe5.summary || `backend ${probe5.backendCount ?? 0} 端点 / frontend ${probe5.frontendCount ?? 0} 调用`}`)
|
|
194
|
+
if ((probe5.missingBackend || []).length > 0) {
|
|
195
|
+
L.push('')
|
|
196
|
+
L.push('| 状态 | 前端调用 | 后端端点 | 文件 |')
|
|
197
|
+
L.push('|---|---|---|---|')
|
|
198
|
+
for (const m of probe5.missingBackend) {
|
|
199
|
+
L.push(`| ❌ missing | ${m.method} ${m.path} | — | ${m.consumerFile || '?'}${m.consumerLine ? ':' + m.consumerLine : ''} |`)
|
|
200
|
+
}
|
|
201
|
+
L.push('')
|
|
202
|
+
L.push('- ❌ contract gap 是真实集成缺陷——诚实判 FAIL 并回 execute 补端点(CLI 仅 advisory 不硬阻断)')
|
|
203
|
+
}
|
|
204
|
+
if ((probe5.unusedBackend || []).length > 0) {
|
|
205
|
+
L.push(`- ⚠️ ${probe5.unusedBackend.length} 个后端端点前端未调用(warning 不阻断):${probe5.unusedBackend.slice(0, 5).map(u => `${u.method} ${u.path}`).join('、')}${probe5.unusedBackend.length > 5 ? ' …' : ''}`)
|
|
206
|
+
}
|
|
207
|
+
L.push('')
|
|
208
|
+
|
|
209
|
+
L.push('#### 探针 6:代码删除对账')
|
|
210
|
+
if (probe6.deletions.length === 0) {
|
|
211
|
+
L.push('- ✅ git diff 无整文件删除(D/R/C)记录')
|
|
212
|
+
} else {
|
|
213
|
+
for (const d of probe6.deletions) {
|
|
214
|
+
L.push(`- ${d.verdict} \`${d.path}\`(git 状态 ${d.status})`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
L.push(`- ℹ️ ${probe6.note}`)
|
|
218
|
+
return L.join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。
|
|
223
|
+
* 结论章节留「待填」——extractVerifyConclusion 无 PASS/FAIL 关键词即判不过,骨架不能直接过门。
|
|
224
|
+
* @returns {string|null} 骨架全文;无 design.md/tasks.md(非完整流程变更)→ null
|
|
225
|
+
*/
|
|
226
|
+
export function generateVerifyResultSkeleton(result) {
|
|
227
|
+
const L = [
|
|
228
|
+
'# 验证报告(骨架由 `sillyspec verify-probes --change <变更名> --init` 生成)',
|
|
229
|
+
'',
|
|
230
|
+
'> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论必须写明 PASS / FAIL**——',
|
|
231
|
+
'> 留「待填」会被 gate 判不过(fail-closed)。',
|
|
232
|
+
'',
|
|
233
|
+
'## 结论:<待填:PASS 或 FAIL(+一句话理由)>',
|
|
234
|
+
'',
|
|
235
|
+
'## 任务完成度',
|
|
236
|
+
'<!--TODO: 逐 task 对照 tasks.md 勾选与验收标准,完成/未完成/存疑三态-->',
|
|
237
|
+
'',
|
|
238
|
+
'## 设计一致性',
|
|
239
|
+
'<!--TODO: 实现与 design.md 的偏差(无偏差也显式写「一致」)-->',
|
|
240
|
+
'',
|
|
241
|
+
'## 探针结果(CLI 机械预填)',
|
|
242
|
+
renderVerifyProbesReport(result),
|
|
243
|
+
'',
|
|
244
|
+
'## 测试结果',
|
|
245
|
+
'<!--TODO: 测试命令 + 结果(通过数/失败数;known_failures 豁免逐条注明)-->',
|
|
246
|
+
'',
|
|
247
|
+
'## 决策追踪矩阵(如存在 decisions.md;无则删本节)',
|
|
248
|
+
'<!--TODO: | 决策 ID | FR | Task | Evidence | 状态 |(D-xxx@vN → FR-xxx → task → 证据回指闭环)-->',
|
|
249
|
+
'',
|
|
250
|
+
'## 技术债务',
|
|
251
|
+
'<!--TODO: TODO/FIXME/HACK 统计(探针 1 的命中已预填在上方探针结果)-->',
|
|
252
|
+
'',
|
|
253
|
+
'## 变更风险等级',
|
|
254
|
+
'<!--TODO: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,写明「显式声明 = <等级>」+ 理由(豁免可审计)-->',
|
|
255
|
+
'',
|
|
256
|
+
'## Runtime Evidence',
|
|
257
|
+
'<!--TODO: 关键命令输出/时间戳/commit hash 证据链;integration/deployment-critical 必填,按实际触碰的运行时组件写(启动命令/端点/请求响应/日志片段/生命周期终态断言/失败模式排除),未涉及的行写「不涉及」-->',
|
|
258
|
+
'',
|
|
259
|
+
'## 代码审查',
|
|
260
|
+
'<!--TODO: 问题列表 + 总体评价-->',
|
|
261
|
+
'',
|
|
262
|
+
]
|
|
263
|
+
return L.join('\n')
|
|
264
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace.js — `sillyspec workspace add/remove/status` 的多项目工作区管理
|
|
3
|
+
* (2026-08-21 agent-手工产出审计第四批 C-1)。
|
|
4
|
+
*
|
|
5
|
+
* workspace skill 此前纯手写:`.sillyspec/projects/<name>.yaml` 5 字段 YAML 手编、remove 手删
|
|
6
|
+
* 文件手 commit、status 用 bash for-loop + grep/sed 解析 YAML 逐项目 cd 探测——skill 明言
|
|
7
|
+
* 「CLI 没有 workspace 命令」。本模块命令化全部机械面:
|
|
8
|
+
* - add:YAML 外科写入(name/path/status 必填 + role/repo 可选追加,已有字段保留)、路径校验
|
|
9
|
+
* - remove:删 yaml(--commit 时顺带 git add 该删除)
|
|
10
|
+
* - status:逐项目探测(yaml 解析 + .sillyspec 产物存在性),输出 skill 原格式的三态表
|
|
11
|
+
* init 语义对齐(skill L75):init 只写 name/path/status 三字段,role/repo 是 add 专属追加。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync } from 'fs'
|
|
14
|
+
import { join, basename, resolve } from 'path'
|
|
15
|
+
import { resolveSpecDir } from './run/shared.js'
|
|
16
|
+
|
|
17
|
+
function parseSimpleYaml(text) {
|
|
18
|
+
const out = {}
|
|
19
|
+
for (const line of String(text || '').split('\n')) {
|
|
20
|
+
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
|
|
21
|
+
if (m) out[m[1]] = m[2].trim()
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readProjectYaml(specBase, name) {
|
|
27
|
+
const p = join(specBase, 'projects', `${name}.yaml`)
|
|
28
|
+
if (!existsSync(p)) return null
|
|
29
|
+
return parseSimpleYaml(readFileSync(p, 'utf8'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 新增/更新子项目登记。
|
|
34
|
+
* @param {{ cwd: string, name: string, path: string, role?: string|null, repo?: string|null, specDir?: string|null }} opts
|
|
35
|
+
* @returns {{ file: string, created: boolean, fieldsKept: string[] }}
|
|
36
|
+
* @throws {Error} name 非法 / path 不存在
|
|
37
|
+
*/
|
|
38
|
+
export function workspaceAdd({ cwd, name, path: projPath, role = null, repo = null, specDir = null }) {
|
|
39
|
+
if (!name || !/^[A-Za-z0-9_.\-]+$/.test(name)) {
|
|
40
|
+
throw new Error(`子项目名非法: "${name}"(限字母数字 . _ -,作 yaml 文件名)`)
|
|
41
|
+
}
|
|
42
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
43
|
+
const abs = resolve(cwd, projPath)
|
|
44
|
+
if (!existsSync(abs)) {
|
|
45
|
+
throw new Error(`路径不存在: ${abs}`)
|
|
46
|
+
}
|
|
47
|
+
const yamlPath = join(specBase, 'projects', `${name}.yaml`)
|
|
48
|
+
const existed = existsSync(yamlPath)
|
|
49
|
+
const prev = existed ? parseSimpleYaml(readFileSync(yamlPath, 'utf8')) : {}
|
|
50
|
+
// init 三字段 + add 追加字段;已有值优先保留(幂等重跑不覆盖手调内容)
|
|
51
|
+
const fields = {
|
|
52
|
+
name,
|
|
53
|
+
path: projPath.replace(/\\/g, '/'),
|
|
54
|
+
status: prev.status || 'active',
|
|
55
|
+
}
|
|
56
|
+
if (role) fields.role = role
|
|
57
|
+
else if (prev.role) fields.role = prev.role
|
|
58
|
+
if (repo) fields.repo = repo
|
|
59
|
+
else if (prev.repo) fields.repo = prev.repo
|
|
60
|
+
const fieldsKept = Object.keys(prev).filter(k => fields[k] === prev[k] && k !== 'name' && k !== 'path')
|
|
61
|
+
mkdirSync(join(specBase, 'projects'), { recursive: true })
|
|
62
|
+
// 追加不认识的手写字段(防整文件重序列化丢内容)
|
|
63
|
+
for (const k of Object.keys(prev)) {
|
|
64
|
+
if (!(k in fields)) fields[k] = prev[k]
|
|
65
|
+
}
|
|
66
|
+
const text = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n'
|
|
67
|
+
writeFileSync(yamlPath, text)
|
|
68
|
+
return { file: yamlPath, created: !existed, fieldsKept }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 移除子项目登记(删 yaml;git add 由调用方/CLI 层做)。
|
|
73
|
+
* @returns {{ file: string }} @throws {Error} 不存在
|
|
74
|
+
*/
|
|
75
|
+
export function workspaceRemove({ cwd, name, specDir = null }) {
|
|
76
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
77
|
+
const yamlPath = join(specBase, 'projects', `${name}.yaml`)
|
|
78
|
+
if (!existsSync(yamlPath)) throw new Error(`子项目不存在: ${yamlPath}`)
|
|
79
|
+
unlinkSync(yamlPath)
|
|
80
|
+
return { file: yamlPath }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 工作区状态探测(skill 3d 的命令化):逐项目 yaml 解析 + 三态探测。
|
|
85
|
+
* @returns {{ projects: Array<{name, path, role, state, detail}>, sharedDocs: string[] }}
|
|
86
|
+
* state ∈ initialized(有 .sillyspec)/ scanned(docs/<name>/scan 有文档)/ unregistered(无 .sillyspec)
|
|
87
|
+
*/
|
|
88
|
+
export function workspaceStatus({ cwd, specDir = null }) {
|
|
89
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
90
|
+
const projects = []
|
|
91
|
+
const projDir = join(specBase, 'projects')
|
|
92
|
+
if (existsSync(projDir)) {
|
|
93
|
+
for (const f of readdirSync(projDir).filter(f => f.endsWith('.yaml')).sort()) {
|
|
94
|
+
const y = parseSimpleYaml(readFileSync(join(projDir, f), 'utf8'))
|
|
95
|
+
const name = y.name || basename(f, '.yaml')
|
|
96
|
+
const projAbs = resolve(cwd, y.path || '.')
|
|
97
|
+
let state
|
|
98
|
+
let detail
|
|
99
|
+
if (!existsSync(projAbs)) {
|
|
100
|
+
state = 'missing'
|
|
101
|
+
detail = '路径不存在(yaml 的 path 失效)'
|
|
102
|
+
} else if (!existsSync(join(projAbs, '.sillyspec'))) {
|
|
103
|
+
state = 'unregistered'
|
|
104
|
+
detail = '未初始化(无 .sillyspec)'
|
|
105
|
+
} else {
|
|
106
|
+
const scanDir = join(projAbs, '.sillyspec', 'docs', name, 'scan')
|
|
107
|
+
let scanCount = 0
|
|
108
|
+
try { scanCount = readdirSync(scanDir).filter(x => x.endsWith('.md')).length } catch {}
|
|
109
|
+
if (scanCount > 0) {
|
|
110
|
+
state = 'scanned'
|
|
111
|
+
detail = `已扫描(${scanCount} 份文档)`
|
|
112
|
+
} else {
|
|
113
|
+
state = 'initialized'
|
|
114
|
+
detail = '已初始化(未扫描)'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
projects.push({ name, path: y.path || '?', role: y.role || '', state, detail })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let sharedDocs = []
|
|
121
|
+
try {
|
|
122
|
+
sharedDocs = readdirSync(join(specBase, 'shared')).filter(f => f.endsWith('.md')).sort()
|
|
123
|
+
} catch {}
|
|
124
|
+
return { projects, sharedDocs }
|
|
125
|
+
}
|
package/src/worktree-apply.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 8. 成功后自动 cleanup
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
15
|
+
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync, mkdirSync } from 'fs';
|
|
16
16
|
import { join, resolve } from 'path';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
import { WorktreeManager } from './worktree.js';
|
|
@@ -596,7 +596,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
596
596
|
result.patchPath = patchPath;
|
|
597
597
|
|
|
598
598
|
try {
|
|
599
|
-
|
|
599
|
+
// patch 以 Buffer 聚合:git diff --binary 对二进制/非 UTF-8 文件输出任意字节序列,
|
|
600
|
+
// 按 utf8 string 拼接会破坏 NUL 字节成 U+FFFD → corrupt patch(git-helper.js git() 注释
|
|
601
|
+
// 同款坑;worktree.js 的 diff 采集已是 encoding:'buffer' 正确写法)。
|
|
602
|
+
const patchParts = [];
|
|
600
603
|
|
|
601
604
|
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
602
605
|
// 批量化:一次 ls-tree(deliverableBase tree 中存在的文件)+ 一次 ls-files(index 中存在的文件)
|
|
@@ -615,32 +618,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
615
618
|
|
|
616
619
|
// tracked 文件:git diff patchBase(patch 生成锚点,默认 merge-base)
|
|
617
620
|
// 数组形式,文件名逐个展开为独立 argv,不经 shell;
|
|
618
|
-
//
|
|
621
|
+
// encoding:'buffer' 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时——原裸 execSync 无 timeout
|
|
619
622
|
if (trackedFiles.length > 0) {
|
|
620
|
-
|
|
623
|
+
patchParts.push(git(
|
|
621
624
|
worktreePath,
|
|
622
625
|
['diff', '--binary', patchBase, '--', ...trackedFiles],
|
|
623
|
-
{
|
|
624
|
-
);
|
|
626
|
+
{ encoding: 'buffer', timeout: 60000 }
|
|
627
|
+
));
|
|
625
628
|
}
|
|
626
629
|
|
|
627
630
|
// untracked 新文件:git add 到 index,git diff --cached,然后 reset(均数组形式,文件名逐个展开)
|
|
628
631
|
if (untrackedPatchFiles.length > 0) {
|
|
629
632
|
git(worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
630
633
|
try {
|
|
631
|
-
//
|
|
632
|
-
|
|
634
|
+
// encoding:'buffer' 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时
|
|
635
|
+
patchParts.push(git(
|
|
633
636
|
worktreePath,
|
|
634
637
|
['diff', '--binary', '--cached', '--', ...untrackedPatchFiles],
|
|
635
|
-
{
|
|
636
|
-
);
|
|
638
|
+
{ encoding: 'buffer', timeout: 60000 }
|
|
639
|
+
));
|
|
637
640
|
} finally {
|
|
638
641
|
// 重置 index(不保留 staged 状态)
|
|
639
642
|
gitQuiet(worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
640
643
|
}
|
|
641
644
|
}
|
|
642
645
|
|
|
643
|
-
|
|
646
|
+
const patchContent = Buffer.concat(patchParts);
|
|
647
|
+
if (patchContent.length === 0) {
|
|
644
648
|
// patch 为空(清单中部分文件可能没实际变更)
|
|
645
649
|
result.ok = true;
|
|
646
650
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -694,6 +698,20 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
694
698
|
|
|
695
699
|
result.ok = true;
|
|
696
700
|
|
|
701
|
+
// --- 7.5 提交复用 pathspec(坑 apply-commit-pathspec-sweep,2026-08-21 实证)---
|
|
702
|
+
// apply 后主仓常混有无关未提交文件(他者会话/并行 quick),agent 习惯 `git add <目录>/`
|
|
703
|
+
// 会把无关文件扫进暂存需手工剔除。落盘本变更精确 pathspec 供提交直接复用:
|
|
704
|
+
// 文件 + result 字段(CLI 输出可照抄 git add -- … / 长清单用 --pathspec-from-file)。
|
|
705
|
+
try {
|
|
706
|
+
const commitFiles = [...new Set(patchFiles)].sort().filter(Boolean);
|
|
707
|
+
result.commitPathspec = commitFiles;
|
|
708
|
+
const runtimeRoot = join(projectRoot, '.sillyspec', '.runtime');
|
|
709
|
+
mkdirSync(runtimeRoot, { recursive: true });
|
|
710
|
+
const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`);
|
|
711
|
+
writeFileSync(pathspecFile, commitFiles.join('\n') + '\n');
|
|
712
|
+
result.pathspecFile = pathspecFile;
|
|
713
|
+
} catch { /* pathspec 落盘失败不影响 apply 结果 */ }
|
|
714
|
+
|
|
697
715
|
// --- 8. 成功后自动 cleanup(失败不影响整体结果) ---
|
|
698
716
|
try {
|
|
699
717
|
wm.cleanup(changeName, { force: true });
|
|
@@ -703,6 +721,20 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
703
721
|
}
|
|
704
722
|
|
|
705
723
|
} catch (e) {
|
|
724
|
+
// ENOBUFS 自动降级(坑 apply-spawnsync-enobufs,2026-08-22 实证:大 diff 的 binary patch
|
|
725
|
+
// 超 maxBuffer 抛 ENOBUFS,此前只能 agent 手动 git merge 绕行)——git apply 路径走不通时
|
|
726
|
+
// 自动落 applyByMerge(既有 --merge 兜底,语义同为三方合并)
|
|
727
|
+
if (/ENOBUFS/i.test(String(e.message || e.code || ''))) {
|
|
728
|
+
result.warnings = (result.warnings || []).concat(
|
|
729
|
+
[`patch 生成超出进程缓冲区(ENOBUFS,超大 diff)——自动降级 git merge 路径应用`]
|
|
730
|
+
);
|
|
731
|
+
try {
|
|
732
|
+
return applyByMerge(result, changeName, projectRoot, wm);
|
|
733
|
+
} catch (mergeErr) {
|
|
734
|
+
result.errors.push(`ENOBUFS 降级 merge 也失败: ${mergeErr.message}`);
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
706
738
|
result.errors.push(`patch 生成/应用异常: ${e.message}`);
|
|
707
739
|
return result;
|
|
708
740
|
} finally {
|
package/src/worktree-deps.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* 见 change 2026-06-28-worktree-deps-provision / D-005@v1, D-007@v1。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
12
|
-
import { join, isAbsolute, resolve as resolvePath, sep as pathSep } from 'path';
|
|
11
|
+
import { existsSync, readFileSync, realpathSync, lstatSync } from 'fs';
|
|
12
|
+
import { join, isAbsolute, relative, resolve as resolvePath, sep as pathSep } from 'path';
|
|
13
13
|
import { execFileSync } from 'child_process';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
|
|
@@ -137,10 +137,22 @@ function tryLink(mainNodeModules, linkPath) {
|
|
|
137
137
|
// execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
|
|
138
138
|
// linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
|
|
139
139
|
execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
140
|
-
|
|
140
|
+
} else {
|
|
141
|
+
execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
141
142
|
}
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// 创建后实物复核(坑 provision-silent-fake-installed 第②层,2026-08-21 实证:doctor 报
|
|
144
|
+
// re-provisioned 成功但 junction 实际没建)——mklink/ln 退出码 0 不等于链接落盘(cmd.exe
|
|
145
|
+
// 垫片链存在静默失败面),lstat+existsSync 复核不存在即判失败,交由调用方走 install 兜底
|
|
146
|
+
//(junction(reparse point)与 symlink 的 lstat 均报 isSymbolicLink=true)
|
|
147
|
+
try {
|
|
148
|
+
const st = lstatSync(linkPath);
|
|
149
|
+
if (!st.isSymbolicLink() || !existsSync(linkPath)) {
|
|
150
|
+
return { ok: false, error: 'link 创建后复核失败:lstat 非 link 或路径不可达' };
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { ok: false, error: `link 创建后复核失败(lstat ${e.message})` };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, method: process.platform === 'win32' ? 'junction' : 'symlink' };
|
|
144
156
|
} catch (e) {
|
|
145
157
|
return { ok: false, error: e.message };
|
|
146
158
|
}
|
|
@@ -344,6 +356,48 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
344
356
|
? { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash }
|
|
345
357
|
: { depsStatus: 'failed', depsMethod: null, depsSource: null, depsLockHash: wtHash, depsError: installResult.error };
|
|
346
358
|
}
|
|
359
|
+
// ── 结果后验证(坑 provision-silent-fake-installed,2026-08-21 Windows 实证;
|
|
360
|
+
// 坑 provision-monorepo-subpackage-fake-installed,2026-08-22 复发:pnpm monorepo
|
|
361
|
+
// workspace 根 package.json 的 dependencies 常为空(依赖在 frontend/daemon 子包),
|
|
362
|
+
// 只查根会把 installed 状态整体跳过——doctor 标 installed 但子包 node_modules 全缺,
|
|
363
|
+
// Wave 1 测试才挂)。分层校验:
|
|
364
|
+
// linked:根 junction 实存硬校验(link 声称成功必留痕);
|
|
365
|
+
// installed:校验目标集 = 根 + local.yaml modules 块声明的含 package.json 子模块,
|
|
366
|
+
// 其中【声明了依赖】的目录逐一要求 node_modules 存在(pnpm/npm/yarn workspace
|
|
367
|
+
// 安装都会给子包建 node_modules;无依赖声明的空壳目录合法地不建,不校验)。
|
|
368
|
+
// python/.venv、jvm 本地仓库本就不适用(整体仅 nodejs)。
|
|
369
|
+
if (projectType === 'nodejs') {
|
|
370
|
+
const pkgHasDeps = (dir) => {
|
|
371
|
+
try {
|
|
372
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
373
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
374
|
+
.some(k => pkg[k] && Object.keys(pkg[k]).length > 0)
|
|
375
|
+
} catch { return false }
|
|
376
|
+
}
|
|
377
|
+
// 目标集:根 + modules 块内含 package.json 的安全子模块(monorepo 子包)
|
|
378
|
+
const verifyDirs = [worktreePath]
|
|
379
|
+
for (const mp of extractModulePaths(yamlText)) {
|
|
380
|
+
if (!isSafeModulePath(mp)) continue
|
|
381
|
+
const sub = join(worktreePath, mp)
|
|
382
|
+
if (existsSync(join(sub, 'package.json'))) verifyDirs.push(sub)
|
|
383
|
+
}
|
|
384
|
+
const depsDeclaredDirs = verifyDirs.filter(pkgHasDeps)
|
|
385
|
+
const needVerify = result.depsStatus === 'linked'
|
|
386
|
+
|| (result.depsStatus === 'installed' && depsDeclaredDirs.length > 0)
|
|
387
|
+
if (needVerify) {
|
|
388
|
+
// linked 只验根(junction 语义);installed 验全部声明依赖的目标目录
|
|
389
|
+
const targets = result.depsStatus === 'linked' ? [worktreePath] : depsDeclaredDirs
|
|
390
|
+
const missing = targets.filter(dir => !existsSync(join(dir, 'node_modules')))
|
|
391
|
+
if (missing.length > 0) {
|
|
392
|
+
const missingRel = missing.map(dir => relative(worktreePath, dir) || '(根)')
|
|
393
|
+
result = {
|
|
394
|
+
depsStatus: 'failed', depsMethod: null, depsSource: null, depsLockHash: wtHash,
|
|
395
|
+
depsError: `${result.depsStatus} 后验证失败:${missingRel.join('、')} 的 node_modules 不存在(报成功但实际未落盘——Windows 静默失败面:cmd.exe 垫片解析/杀毒拦截/frozen-lockfile 快退)。` +
|
|
396
|
+
`手动兜底:进 worktree 手动跑 ${installCmd}(子包随 workspace 一起装),或对 lockfile 一致的目录用 junction:New-Item -ItemType Junction -Path "<目标>/node_modules" -Target "<主仓对应目录>/node_modules",然后重试 doctor --fix`,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
347
401
|
}
|
|
348
402
|
result.depsCheckedAt = depsCheckedAt;
|
|
349
403
|
|