sillyspec 3.26.11 → 3.26.14
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 +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +200 -27
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +187 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +88 -46
- package/src/run/complete-handlers.js +27 -15
- package/src/run/complete.js +1186 -1121
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +172 -25
- package/src/run/prompt.js +17 -9
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +164 -63
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stage-review.js +40 -1
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +27 -13
- package/src/stages/doctor.js +29 -16
- package/src/stages/execute.js +170 -154
- package/src/stages/knowledge.js +18 -3
- package/src/stages/plan-postcheck.js +72 -37
- package/src/stages/plan.js +70 -48
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +339 -225
- package/src/task-review.js +281 -64
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
package/src/taskcard.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sillyspec taskcard — 生成 Windows 安全的 TaskCard 骨架
|
|
3
|
+
*
|
|
4
|
+
* 背景:plan postcheck 任务卡被拒的根因集中在「LLM 子代理手写 frontmatter 不可靠」——
|
|
5
|
+
* CRLF 行尾、漏闭合 ---、缺 constraints 等硬校验字段(校验侧已对 CRLF 容错,但闭合/字段
|
|
6
|
+
* 缺失仍拦)。本命令从源头消灭这类格式错误:骨架由 CLI 用 writeFileSync 直写(字符串
|
|
7
|
+
* 只含 \n,天然 LF),frontmatter 闭合与硬校验 9 字段由代码保证,子代理只需 Edit 填充
|
|
8
|
+
* 占位符,不再手写整卡。
|
|
9
|
+
*
|
|
10
|
+
* 任务编号/标题从 plan.md checkbox 行自动带出(与 plan.js parseTaskNames 同源,
|
|
11
|
+
* 保编号不跳号、名称与 plan.md 一致)。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { git } from './git-helper.js'
|
|
16
|
+
import { assertSafeChangeName, resolveSpecDir } from './run/shared.js'
|
|
17
|
+
import { parseTaskNames } from './stages/plan.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 任务号归一:接受 task-01 / task-1 / 01 / 1,统一为 task-NN(两位补零,与任务卡文件名一致)。
|
|
21
|
+
* @param {string} raw
|
|
22
|
+
* @returns {string} 形如 task-01
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeTaskId(raw) {
|
|
25
|
+
const s = String(raw || '').trim()
|
|
26
|
+
const m = s.match(/^(?:task-)?(\d+)$/i)
|
|
27
|
+
if (!m) {
|
|
28
|
+
throw new Error(`任务号格式非法: "${s}"(应为 task-NN 或 NN,可逗号分隔多个)`)
|
|
29
|
+
}
|
|
30
|
+
return `task-${String(m[1]).padStart(2, '0')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* YAML 标量安全序列化(坑 taskcard-title-backtick-yaml,2026-08-20 实证):plan checkbox 行名
|
|
35
|
+
* 是自由文本,带反引号(如 "`-p` 参数支持")时裸插值进 frontmatter——`` ` `` 与 @ 是 YAML 保留
|
|
36
|
+
* 指示符,js-yaml 直接抛错,契约字段(allowed_paths/constraints 等)随整个 frontmatter 静默丢失,
|
|
37
|
+
* 靠 plan postcheck 才兜住。统一单引号包裹 + 内部 ' 双写(YAML 标准转义),无边界字符要判。
|
|
38
|
+
* @param {string} v
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function yamlScalar(v) {
|
|
42
|
+
const s = String(v ?? '')
|
|
43
|
+
return `'${s.replace(/'/g, "''")}'`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 生成 TaskCard 骨架字符串(纯函数)。
|
|
48
|
+
*
|
|
49
|
+
* 硬校验 9 字段(plan-postcheck feasibility)+ 规范字段全部就位,占位符由子代理 Edit 填充;
|
|
50
|
+
* 可选字段(provides/expects_from/related_tests)默认不生成——缩小 YAML 出错面,何时加的
|
|
51
|
+
* 判据写在文件尾注释(规则详情见 templates/prompts/taskcard-rules.md)。
|
|
52
|
+
* 字符串只含 \n:writeFileSync 直写不经任何行尾转换,Windows 下天然 LF 安全。
|
|
53
|
+
* title/title_zh/author 经 yamlScalar 转义(自由文本防 YAML 指示符炸解析)。
|
|
54
|
+
*
|
|
55
|
+
* @param {{ taskId: string, title: string, titleZh: string, author: string, now: string }} p
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now }) {
|
|
59
|
+
return `---
|
|
60
|
+
id: ${taskId}
|
|
61
|
+
title: ${yamlScalar(title)}
|
|
62
|
+
title_zh: ${yamlScalar(titleZh)}
|
|
63
|
+
author: ${yamlScalar(author)}
|
|
64
|
+
created_at: ${now}
|
|
65
|
+
priority: P0
|
|
66
|
+
depends_on: []
|
|
67
|
+
blocks: []
|
|
68
|
+
requirement_ids: [FR-XX]
|
|
69
|
+
decision_ids: [D-XXX@vN]
|
|
70
|
+
allowed_paths:
|
|
71
|
+
- src/example/file.ts
|
|
72
|
+
goal: >
|
|
73
|
+
一句话说明这个 task 要做什么、为什么。
|
|
74
|
+
implementation:
|
|
75
|
+
- 具体步骤 1
|
|
76
|
+
- 具体步骤 2
|
|
77
|
+
acceptance:
|
|
78
|
+
- 可验证的验收条件 1
|
|
79
|
+
- 可验证的验收条件 2
|
|
80
|
+
verify:
|
|
81
|
+
- cd frontend && pnpm exec tsc --noEmit
|
|
82
|
+
constraints:
|
|
83
|
+
- 边界约束 1(如:不加测试)
|
|
84
|
+
- 边界约束 2(如:不修改传入参数)
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<!-- 骨架由 sillyspec taskcard 生成(LF 行尾 + frontmatter 已闭合 + 硬校验 9 字段齐全)。
|
|
88
|
+
用 Edit tool 填充上方占位符(allowed_paths/goal/implementation/acceptance/verify/constraints 等),
|
|
89
|
+
勿用 Write 整文件重写——会引入 CRLF 行尾/漏闭合 ---/漏字段回归。
|
|
90
|
+
可选字段按需插进上方 frontmatter(规则见 taskcard-rules):
|
|
91
|
+
provides: 仅当本 task 给其他 task 提供接口/DTO/响应时填
|
|
92
|
+
expects_from: 仅当本 task 消费其他 task 的契约时填
|
|
93
|
+
related_tests: 仅当本 task 改动导致既有测试断言失效时填(测试路径须同时进 allowed_paths) -->
|
|
94
|
+
`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* taskcard 命令主逻辑:定位变更目录 → 解析任务清单(--task 显式列表 / 'all' 从 plan.md 取)→
|
|
99
|
+
* 逐个写骨架。
|
|
100
|
+
*
|
|
101
|
+
* @param {string} changeName 变更名(.sillyspec/changes/ 下目录名)
|
|
102
|
+
* @param {{ cwd: string, specDir?: string|null, taskIds: string[]|'all', title?: string|null,
|
|
103
|
+
* titleZh?: string|null, force?: boolean }} opts
|
|
104
|
+
* taskIds:normalizeTaskId 归一后的数组;或字面 'all' 表示从 plan.md 取全部 checkbox 任务
|
|
105
|
+
* @returns {{ created: string[], skipped: string[], tasksDir: string }}
|
|
106
|
+
*/
|
|
107
|
+
export function cmdTaskcard(changeName, opts = {}) {
|
|
108
|
+
const { cwd, specDir = null, taskIds, title = null, titleZh = null, force = false } = opts
|
|
109
|
+
assertSafeChangeName(changeName, '变更名')
|
|
110
|
+
|
|
111
|
+
const changeDir = join(resolveSpecDir(cwd, { specDir }), 'changes', changeName)
|
|
112
|
+
if (!existsSync(changeDir)) {
|
|
113
|
+
throw new Error(`变更目录不存在: ${changeDir}(先完成 plan 前置步骤,或检查 --change 名)`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 任务注册表 tasks.md(2026-08-20-task-truth-unify 唯一真相;CRLF 归一后解析,
|
|
117
|
+
// --all 时强依赖)。tasks.md 缺失回退 plan.md(旧归档变更兼容读侧)
|
|
118
|
+
let planTasks = []
|
|
119
|
+
const tasksMdPath = join(changeDir, 'tasks.md')
|
|
120
|
+
const planPath = join(changeDir, 'plan.md')
|
|
121
|
+
const registryPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
|
|
122
|
+
if (existsSync(registryPath)) {
|
|
123
|
+
const registryContent = readFileSync(registryPath, 'utf8').replace(/\r\n/g, '\n')
|
|
124
|
+
planTasks = parseTaskNames(registryContent)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let ids
|
|
128
|
+
if (taskIds === 'all') {
|
|
129
|
+
if (planTasks.length === 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`--all 需要 tasks.md 中存在 checkbox 任务行(格式 "- [ ] task-XX: 任务名")` +
|
|
132
|
+
(existsSync(registryPath) ? ',当前任务清单未解析到任何任务行' : ',且变更目录下未找到 tasks.md/plan.md')
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
ids = planTasks.map(t => `task-${t.num}`)
|
|
136
|
+
} else if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
137
|
+
ids = taskIds
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error('缺少任务清单:需 --task task-01[,task-02...] 或 --all')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 标题优先级:--title/--title-zh 参数 > plan.md checkbox 行名称 > 占位符。
|
|
143
|
+
// 参数仅单任务时可用(多任务各卡标题不同,统一参数必错)。
|
|
144
|
+
if ((title || titleZh) && ids.length > 1) {
|
|
145
|
+
throw new Error('--title/--title-zh 仅在 --task 指定单个任务时可用(多任务标题自动取自 plan.md checkbox 行)')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const author = (() => {
|
|
149
|
+
try {
|
|
150
|
+
// QUAL-01 收口:原 execSync 字符串拼接(经 shell,违反自家注入规约)→ git-helper 数组形式
|
|
151
|
+
return git(cwd, ['config', 'user.name']) || 'unknown'
|
|
152
|
+
} catch {
|
|
153
|
+
return 'unknown'
|
|
154
|
+
}
|
|
155
|
+
})()
|
|
156
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
157
|
+
|
|
158
|
+
const tasksDir = join(changeDir, 'tasks')
|
|
159
|
+
mkdirSync(tasksDir, { recursive: true })
|
|
160
|
+
|
|
161
|
+
const created = []
|
|
162
|
+
const skipped = []
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const planMatch = planTasks.find(t => `task-${t.num}` === id)
|
|
165
|
+
const finalTitle = title || (planMatch ? planMatch.name : null) || `${id} fill-english-title`
|
|
166
|
+
const finalTitleZh = titleZh || (planMatch ? planMatch.name : null) || finalTitle
|
|
167
|
+
const filePath = join(tasksDir, `${id}.md`)
|
|
168
|
+
if (existsSync(filePath) && !force) {
|
|
169
|
+
skipped.push(filePath)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(filePath, buildTaskcardSkeleton({
|
|
173
|
+
taskId: id, title: finalTitle, titleZh: finalTitleZh, author, now,
|
|
174
|
+
}), 'utf8')
|
|
175
|
+
created.push(filePath)
|
|
176
|
+
}
|
|
177
|
+
return { created, skipped, tasksDir }
|
|
178
|
+
}
|
package/src/verify-postcheck.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* 测试,避免 monorepo 全量测试超 gate timeout。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { execSync
|
|
18
|
+
import { execSync } from 'child_process'
|
|
19
|
+
import { gitQuiet } from './git-helper.js'
|
|
19
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
20
21
|
import { join } from 'path'
|
|
21
22
|
import { verifyApiParity } from './contract-matrix.js'
|
|
@@ -26,6 +27,18 @@ import { filterDeliverableFiles } from './worktree-apply.js'
|
|
|
26
27
|
const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
|
|
27
28
|
const OUTPUT_TAIL_CHARS = 4000
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 行尾归一(坑 verify-modules-crlf-blanket-fallback,2026-08-20 实证):Windows 仓的
|
|
32
|
+
* local.yaml 常为 CRLF(编辑器/CLI 写入),手写行扫描器的逐行正则里 `.` 不匹配 `\r`、
|
|
33
|
+
* `$`(无 m 标志)又要求真串尾——`\r` 残留行尾导致条目正则整条失配。实证受害:
|
|
34
|
+
* extractModules 返回 null(modules 映射恒失效 → 永远回退全量 → 600s 默认超时必炸)、
|
|
35
|
+
* extractKnownFailures 块式只捕获第一条。统一在解析入口归一,JS 的 `\r?\n` 拆分与
|
|
36
|
+
* yaml 库均兼容 LF 文本(零回归)。
|
|
37
|
+
*/
|
|
38
|
+
function normalizeLineEndings(text) {
|
|
39
|
+
return String(text || '').replace(/\r\n?/g, '\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* 从 local.yaml 文本提取 commands.test。
|
|
31
44
|
* 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
|
|
@@ -123,7 +136,7 @@ export function computeFullFallbackReason({ strategy, modulesPresent, hitCount }
|
|
|
123
136
|
*/
|
|
124
137
|
export function extractModules(yamlText) {
|
|
125
138
|
if (!yamlText) return null
|
|
126
|
-
const lines = yamlText.split('\n')
|
|
139
|
+
const lines = normalizeLineEndings(yamlText).split('\n')
|
|
127
140
|
|
|
128
141
|
// 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
|
|
129
142
|
let startIdx = -1
|
|
@@ -167,8 +180,15 @@ function parseFlowValue(flowText, key) {
|
|
|
167
180
|
const m = flowText.match(re)
|
|
168
181
|
if (!m) return null
|
|
169
182
|
let v = m[1].trim()
|
|
170
|
-
if (
|
|
171
|
-
|
|
183
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
184
|
+
// 双引号值剥壳后解开转义(坑 verify-modules-crlf-blanket-fallback 次生发现):modules 的
|
|
185
|
+
// test 命令常含嵌套引号(node -e "…" / pytest "…"),yaml 源里写作 \";此前只剥外层引号
|
|
186
|
+
// 不解 \\ 与 \",解析结果残留字面反斜杠 → 命令直接坏。其余冷门转义(\n 等)按 YAML 规范
|
|
187
|
+
// 应真转义,但保守留字面(可见优于静默变换,测试命令不含它们)
|
|
188
|
+
v = v.slice(1, -1).replace(/\\(["\\])/g, '$1')
|
|
189
|
+
} else if (v.startsWith("'") && v.endsWith("'")) {
|
|
190
|
+
// 单引号值的 YAML 转义是 '' → '
|
|
191
|
+
v = v.slice(1, -1).replace(/''/g, "'")
|
|
172
192
|
}
|
|
173
193
|
return v.length > 0 ? v : null
|
|
174
194
|
}
|
|
@@ -227,11 +247,14 @@ export function aggregateStatus(results) {
|
|
|
227
247
|
*/
|
|
228
248
|
export function extractKnownFailures(yamlText) {
|
|
229
249
|
if (!yamlText) return []
|
|
230
|
-
|
|
250
|
+
// CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
|
|
251
|
+
// 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
|
|
252
|
+
const yaml = normalizeLineEndings(yamlText)
|
|
253
|
+
const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
|
|
231
254
|
if (inline) {
|
|
232
255
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
233
256
|
}
|
|
234
|
-
const block =
|
|
257
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
|
|
235
258
|
if (block) {
|
|
236
259
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
237
260
|
.map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
|
|
@@ -381,44 +404,29 @@ function assertSafeRefSpec(refSpec) {
|
|
|
381
404
|
/**
|
|
382
405
|
* 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
|
|
383
406
|
* git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
|
|
407
|
+
* QUAL-01 收口:本地 execFileSync 裸调 → git-helper gitQuiet(统一 safe.directory),timeout 30s 保留。
|
|
384
408
|
*/
|
|
385
409
|
function runGitDiffNameOnly(cwd, refSpec) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
encoding: 'utf8',
|
|
394
|
-
timeout: 30 * 1000,
|
|
395
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
396
|
-
})
|
|
397
|
-
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
398
|
-
} catch {
|
|
399
|
-
return null
|
|
400
|
-
}
|
|
410
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
411
|
+
if (refSpec && !ref) return null
|
|
412
|
+
const args = ['diff', '--name-only']
|
|
413
|
+
if (ref) args.push(ref)
|
|
414
|
+
const out = gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
|
|
415
|
+
if (out === null) return null
|
|
416
|
+
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
401
417
|
}
|
|
402
418
|
|
|
403
419
|
/**
|
|
404
420
|
* 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
|
|
405
421
|
* 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
|
|
422
|
+
* QUAL-01 收口:同上走 gitQuiet(trim:false 保留原始文本)。
|
|
406
423
|
*/
|
|
407
424
|
function runGitDiffNameStatus(cwd, refSpec) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return execFileSync('git', args, {
|
|
414
|
-
cwd,
|
|
415
|
-
encoding: 'utf8',
|
|
416
|
-
timeout: 30 * 1000,
|
|
417
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
418
|
-
})
|
|
419
|
-
} catch {
|
|
420
|
-
return null
|
|
421
|
-
}
|
|
425
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
426
|
+
if (refSpec && !ref) return null
|
|
427
|
+
const args = ['diff', '--name-status']
|
|
428
|
+
if (ref) args.push(ref)
|
|
429
|
+
return gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
/**
|
|
@@ -555,6 +563,11 @@ function resolveMainChangedFiles(cwd, changeName) {
|
|
|
555
563
|
* }}
|
|
556
564
|
*/
|
|
557
565
|
export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null }) {
|
|
566
|
+
// 信任边界声明(体检 SEC-02 核实):本函数三处 execSync(command) 的命令只来源于
|
|
567
|
+
// ① 主仓 specBase(resolveSpecDir → 主仓 .sillyspec/local.yaml)② 跨仓仓根
|
|
568
|
+
// <repo>/.sillyspec/local.yaml——均为仓库自有配置(与 `npm test` 同信任级:跑测试
|
|
569
|
+
// 本就是执行项目代码)。永不读取 agent 可写的 worktree 副本(对照 worktree-deps.js
|
|
570
|
+
// SEC-01 的源级分流);修改 yaml 来源时必须维持此边界。
|
|
558
571
|
const localYamlPath = join(specBase, 'local.yaml')
|
|
559
572
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
560
573
|
|
package/src/worktree-apply.js
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* 8. 成功后自动 cleanup
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { execFileSync } from 'child_process';
|
|
16
15
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
16
|
import { join, resolve } from 'path';
|
|
18
17
|
import { tmpdir } from 'os';
|
|
@@ -487,12 +486,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
487
486
|
return applyByMerge(result, changeName, projectRoot, wm);
|
|
488
487
|
}
|
|
489
488
|
|
|
490
|
-
// --- 4.5
|
|
491
|
-
// 分工:4.5
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
489
|
+
// --- 4.5 校验:主工作区「未提交」脏文件是否与本次变更重叠(overlap-only 拦截)---
|
|
490
|
+
// 分工:4.5(排除规则下 dirty∩changedFiles)+ 5a(更宽口径的同一交集)挡「未提交」dirty 重叠;
|
|
491
|
+
// 5b 管「已提交」HEAD 分叉(已放宽)。
|
|
492
|
+
// 2026-08-20 放宽(原全量拦截 → 只拦重叠):原「排除规则下任何未提交文件即整体拒绝」会把与本次
|
|
493
|
+
// 变更无关的脏文件也硬挡,用户被迫走 rescue cp 手动路径。重叠(dirtyFiles ∩ changedFiles,
|
|
494
|
+
// changedFiles 已含删除文件与 worktree 新增同名文件)才是 git apply 无法安全应用的实际危险区。
|
|
495
|
+
// 残余风险与兜底(Windows/autocrlf):实测 autocrlf on 时 git apply --3way 对 dirty 工作区可报
|
|
496
|
+
// `does not match index`(哪怕脏文件与 patch 不重叠;autocrlf off 时能干净应用,非 git 本质限制)。
|
|
497
|
+
// 交集空前提下该失败是安全的:step7 catch → rollbackApply 只 checkout/删除 patch 涉及文件
|
|
498
|
+
// (这些文件 apply 前无未提交修改,还原 = apply 前状态),无关脏文件不在 patch/rollback 范围
|
|
499
|
+
// 不受影响 → fail-safe,报错后 stash 重试即可。放行时 warning 提示此路径。
|
|
496
500
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
497
501
|
// 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
|
|
498
502
|
// 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
|
|
@@ -503,38 +507,47 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
503
507
|
const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
|
|
504
508
|
const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
|
|
505
509
|
const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
|
|
506
|
-
// 意图(与 computeBaselineHash
|
|
510
|
+
// 意图(与 computeBaselineHash 注释一致):只看「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
|
|
507
511
|
// 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
|
|
508
512
|
// hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
|
|
509
513
|
const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
|
|
510
514
|
if (hasUncommittedDirty) {
|
|
511
|
-
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
512
515
|
const dirtyFiles = [...new Set(
|
|
513
516
|
((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
514
517
|
.concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
515
518
|
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
519
|
+
const overlapDirty = dirtyFiles.filter(f => changedFiles.includes(f));
|
|
520
|
+
if (overlapDirty.length > 0) {
|
|
521
|
+
// 重叠拦截:只有与本次变更同文件的未提交改动才无法安全 apply(列重叠文件,非全部脏文件)
|
|
522
|
+
const rescueDirty = computeRescueDirtyFiles(projectRoot);
|
|
523
|
+
result.rescueCommands = generateRescueCommands({
|
|
524
|
+
changedFiles,
|
|
525
|
+
dirtyFiles: rescueDirty,
|
|
526
|
+
hashMismatchFiles: result.hashMismatchFiles,
|
|
527
|
+
deletedFiles: result.deletedFiles,
|
|
528
|
+
worktreePath,
|
|
529
|
+
projectRoot,
|
|
530
|
+
});
|
|
531
|
+
result.errors.push(
|
|
532
|
+
`主工作区以下未提交文件与本次 apply 的变更重叠,git apply 无法安全应用:\n ${overlapDirty.join('\n ')}\n` +
|
|
533
|
+
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
|
|
534
|
+
(result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
|
|
535
|
+
? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
|
|
536
|
+
result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
|
|
537
|
+
(result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
|
|
538
|
+
`\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
|
|
539
|
+
: '')
|
|
540
|
+
);
|
|
541
|
+
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
542
|
+
} else {
|
|
543
|
+
// 无关脏文件放行(只校验重叠文件):--3way 若因 autocrlf 报 does not match index 会被
|
|
544
|
+
// step7 catch 回滚(交集空前提下无损,见上注释),stash 后重试即可——不再硬挡 rescue 手动路径
|
|
545
|
+
result.warnings = (result.warnings || []).concat([
|
|
546
|
+
`主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
|
|
547
|
+
`${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
|
|
548
|
+
`——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
|
|
549
|
+
]);
|
|
550
|
+
}
|
|
538
551
|
}
|
|
539
552
|
}
|
|
540
553
|
|
|
@@ -656,6 +669,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
656
669
|
// FR-06:解析 git 原始 stderr 提取冲突文件列表(不静默吞掉)
|
|
657
670
|
const rawStderr = e.message || '';
|
|
658
671
|
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles, rawStderr);
|
|
672
|
+
// autocrlf 副作用单独点名:主仓无关未提交 dirty + CRLF 转换可触发 does not match index
|
|
673
|
+
// (非文件内容冲突,交集空时 step4.5 已放行到这里)——回滚无损,stash 后重试即可
|
|
674
|
+
if (/does not match index/i.test(rawStderr)) {
|
|
675
|
+
result.errors.push(
|
|
676
|
+
`apply --3way 失败(does not match index):多为主仓未提交文件 + Windows autocrlf 的 CRLF 副作用触发,非文件内容冲突。\n` +
|
|
677
|
+
`已回滚工作区到 apply 前状态(无损,无关未提交文件未受影响)。\n` +
|
|
678
|
+
`处理:git stash 后重试 sillyspec worktree apply ${changeName},或 --merge 兜底。`
|
|
679
|
+
);
|
|
680
|
+
if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
659
683
|
result.errors.push(
|
|
660
684
|
`apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
|
|
661
685
|
` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
|
|
@@ -1174,9 +1198,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
1174
1198
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
1175
1199
|
try {
|
|
1176
1200
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
1177
|
-
// execFileSync
|
|
1178
|
-
// 替代 `2>/dev/null
|
|
1179
|
-
const filesRaw =
|
|
1201
|
+
// QUAL-01 收口:裸 execFileSync → git-helper git(stderr 由 helper stdio 配置吞掉,
|
|
1202
|
+
// 替代 `2>/dev/null`——Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
|
|
1203
|
+
const filesRaw = git(meta.worktreePath, ['diff', '--name-only', diffBase], { timeout: 30000 });
|
|
1180
1204
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
1181
1205
|
if (files.length > 0) {
|
|
1182
1206
|
lines.push(``);
|