sillyspec 3.20.5 → 3.20.7
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/CLAUDE.md +23 -0
- package/.claude/skills/sillyspec-archive/SKILL.md +86 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +98 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +78 -44
- package/.claude/skills/sillyspec-doctor/SKILL.md +61 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +90 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +96 -109
- package/.claude/skills/sillyspec-export/SKILL.md +4 -0
- package/.claude/skills/sillyspec-init/SKILL.md +7 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +61 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +84 -21
- package/.claude/skills/sillyspec-scan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-state/SKILL.md +11 -1
- package/.claude/skills/sillyspec-status/SKILL.md +55 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +82 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +12 -0
- package/CLAUDE.md +4 -0
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
- package/docs/sillyspec/file-lifecycle.md +17 -5
- package/docs/worktree-isolation.md +1 -1
- package/package.json +2 -2
- package/src/doctor-diagnostics.js +575 -0
- package/src/hooks/worktree-guard.js +111 -111
- package/src/index.js +158 -86
- package/src/progress.js +68 -0
- package/src/quick-recommend.js +115 -0
- package/src/run.js +272 -51
- package/src/stage-contract.js +23 -5
- package/src/stages/quick.js +36 -26
- package/src/sync.js +14 -3
- package/src/worktree.js +69 -28
- package/test/decision-ref-version.mjs +85 -0
- package/test/platform-scan-p0.test.mjs +18 -7
- package/test/quick-recommend.test.mjs +146 -0
- package/test/stage-contract.test.mjs +5 -3
package/src/stage-contract.js
CHANGED
|
@@ -144,8 +144,14 @@ function readIfExists(file) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
|
|
147
|
+
// 剥版本后缀(@vN)按基号词边界匹配:target 里写裸号 D-001 即视为引用了 D-001@V1
|
|
148
|
+
// 的当前版本(prompt 常裸号引用,旧版字面 includes("D-001@V1") 会批量误报)。
|
|
149
|
+
// 大小写不敏感:target 整体大写后比对。
|
|
150
|
+
const targetUpper = targetContent.toUpperCase()
|
|
147
151
|
for (const id of ids) {
|
|
148
|
-
|
|
152
|
+
const base = id.replace(/@V\d+$/, '')
|
|
153
|
+
const re = new RegExp(`\\b${base}\\b`)
|
|
154
|
+
if (!re.test(targetUpper)) {
|
|
149
155
|
warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
|
|
150
156
|
}
|
|
151
157
|
}
|
|
@@ -281,9 +287,9 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
|
|
|
281
287
|
const design = readIfExists(join(changeDir, 'design.md'))
|
|
282
288
|
const requirements = readIfExists(join(changeDir, 'requirements.md'))
|
|
283
289
|
const tasks = readIfExists(join(changeDir, 'tasks.md'))
|
|
290
|
+
// decision 的天然引用落点是 design.md;requirements(需求按 FR 组织)与
|
|
291
|
+
// tasks(骨架,待 plan 展开)不强求逐条引用每个架构决策,否则批量误报。
|
|
284
292
|
warnMissingIds(warnings, decisionIds, design, 'design.md', 'decisions.md')
|
|
285
|
-
warnMissingIds(warnings, decisionIds, requirements, 'requirements.md', 'decisions.md')
|
|
286
|
-
warnMissingIds(warnings, decisionIds, tasks, 'tasks.md', 'decisions.md')
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
|
@@ -385,10 +391,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
|
385
391
|
return { ok: false, errors, warnings }
|
|
386
392
|
}
|
|
387
393
|
|
|
388
|
-
// verify
|
|
394
|
+
// verify 阶段必须产出 verify-result.md —— 不存在则不能完成。
|
|
395
|
+
// 历史教训:AI 可能跳过报告直接 --done,导致"假完成"。此处提级为 error 强制阻断。
|
|
389
396
|
const verifyResult = join(changeDir, 'verify-result.md')
|
|
390
397
|
if (!existsSync(verifyResult)) {
|
|
391
|
-
|
|
398
|
+
errors.push(`verify-result.md 不存在 — verify 阶段必须产出验证报告才能完成(${verifyResult})`)
|
|
392
399
|
}
|
|
393
400
|
|
|
394
401
|
// 确保核心规范文件仍然存在
|
|
@@ -409,6 +416,17 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
|
409
416
|
const decisionIds = extractCurrentDecisionIds(decisions)
|
|
410
417
|
warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
|
|
411
418
|
|
|
419
|
+
// ── FAIL 结论门控(适用于所有变更,不限风险等级)──
|
|
420
|
+
// verify-result.md 结论为 FAIL 时,verify 阶段不能 completed。
|
|
421
|
+
// 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
|
|
422
|
+
const conclusionLine = verify.match(/^##\s*结论\s*\n\s*(PASS(?:\s+WITH\s+NOTES)?|FAIL)/im)
|
|
423
|
+
const conclusionStr = conclusionLine ? conclusionLine[1].toUpperCase().replace(/\s+/g, ' ') : ''
|
|
424
|
+
if (conclusionStr === 'FAIL') {
|
|
425
|
+
errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
|
|
426
|
+
} else if (!conclusionStr) {
|
|
427
|
+
warnings.push('verify-result.md 未识别到「## 结论」章节(应为 PASS / PASS WITH NOTES / FAIL)')
|
|
428
|
+
}
|
|
429
|
+
|
|
412
430
|
// ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
|
|
413
431
|
const changeRiskProfile = detectChangeRisk({
|
|
414
432
|
designContent: readIfExists(join(changeDir, 'design.md')),
|
package/src/stages/quick.js
CHANGED
|
@@ -9,12 +9,12 @@ export const definition = {
|
|
|
9
9
|
prompt: `解析任务参数,加载项目上下文。
|
|
10
10
|
|
|
11
11
|
### 操作
|
|
12
|
-
1.
|
|
12
|
+
1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式
|
|
13
13
|
2. 理解任务:模糊则问一个问题确认
|
|
14
14
|
3. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`(了解项目结构和技术栈)
|
|
15
15
|
4. 加载上下文:\`cat .sillyspec/docs/<project>/scan/CONVENTIONS.md 2>/dev/null\`
|
|
16
16
|
5. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`(构建命令、测试命令、环境变量等)
|
|
17
|
-
6.
|
|
17
|
+
6. 若有关联变更,加载每个变更的设计文档:\`cat .sillyspec/changes/<c>/design.md 2>/dev/null\`(理解设计意图)
|
|
18
18
|
7. 如有需要,查询知识库:\`cat .sillyspec/knowledge/INDEX.md 2>/dev/null\`
|
|
19
19
|
|
|
20
20
|
### 模块文档加载
|
|
@@ -24,11 +24,14 @@ export const definition = {
|
|
|
24
24
|
|
|
25
25
|
### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
|
|
26
26
|
理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
|
|
27
|
+
|
|
28
|
+
**A. 始终创建 QUICKLOG(无论是否关联变更)**
|
|
27
29
|
1. 使用预注入的 git 用户名:\`<git-user>\`
|
|
28
|
-
2.
|
|
30
|
+
2. 创建/追加 \`.sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`,写入:
|
|
29
31
|
\`\`\`
|
|
30
32
|
## ql-<YYYYMMDD>-<NNN>-<XXXX> | <now-datetime> | <一句话任务描述>
|
|
31
33
|
状态:进行中
|
|
34
|
+
关联变更:<linked-changes>
|
|
32
35
|
文件:<预估要改的文件>
|
|
33
36
|
\`\`\`
|
|
34
37
|
- ID 格式:\`ql-YYYYMMDD-NNN-XXXX\`
|
|
@@ -36,9 +39,16 @@ export const definition = {
|
|
|
36
39
|
- 追加前扫描文件中已有的 \`ql-<当天日期>-\` 前缀的最大序号,+1 作为新序号
|
|
37
40
|
- 每天从 001 开始,跨日重新计数
|
|
38
41
|
- 此 ID 可被 design.md / plan.md / archive / module 变更索引引用
|
|
39
|
-
3. 有 \`--change\`:在 \`.sillyspec/changes/<change-name>/tasks.md\` 追加未勾选的 task
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
**B. 若关联变更(\`<linked-changes>\` 不为「(无)」)**
|
|
44
|
+
3. 解析 \`<linked-changes>\`(逗号分隔的变更名列表,可能有多个 = 一次 quick 同时归属多个变更)
|
|
45
|
+
4. 对**每个**变更 \`<c>\`:在 \`.sillyspec/changes/<c>/tasks.md\` 追加一条未勾选 task:
|
|
46
|
+
\`- [ ] <ql-ID> <一句话任务描述>\`(复用步骤 A 生成的同一个 ql-ID)
|
|
47
|
+
- 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p .sillyspec/changes/<c>\` 再创建 tasks.md
|
|
48
|
+
- 同一个 ql-ID 出现在 QUICKLOG 和所有关联变更的 tasks.md 中,便于交叉引用
|
|
49
|
+
- \`<linked-changes>\` 为「(无)」时跳过本步,仅保留 QUICKLOG
|
|
50
|
+
|
|
51
|
+
这样 Gate 检测到 \`.sillyspec/\` 下有变更,就不会拦截后续的代码修改。
|
|
42
52
|
|
|
43
53
|
### 输出
|
|
44
54
|
quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
|
|
@@ -72,27 +82,27 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
|
|
|
72
82
|
prompt: `Git 暂存并更新任务记录。
|
|
73
83
|
|
|
74
84
|
### 操作
|
|
75
|
-
1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
|
|
76
|
-
2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
|
|
77
|
-
- 禁止使用 \`git add -A\`
|
|
78
|
-
- 不要暂存 quick 开始前就已存在的无关改动
|
|
79
|
-
3. 更新 Step 1 创建的记录:
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
|
|
83
|
-
5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
|
|
84
|
-
6. 任务比预期复杂 → 建议用完整流程
|
|
85
|
-
|
|
86
|
-
### 模块文档同步
|
|
87
|
-
7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
88
|
-
8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
|
|
89
|
-
9. 如果命中模块 → 直接同步模块文档:
|
|
90
|
-
- 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
|
|
91
|
-
- 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
|
|
92
|
-
- 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
|
|
93
|
-
- 写入模块文档
|
|
94
|
-
- 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
|
|
95
|
-
10. 未命中任何模块 → 跳过,不做额外操作
|
|
85
|
+
1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
|
|
86
|
+
2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
|
|
87
|
+
- 禁止使用 \`git add -A\`
|
|
88
|
+
- 不要暂存 quick 开始前就已存在的无关改动
|
|
89
|
+
3. 更新 Step 1 创建的记录:
|
|
90
|
+
- QUICKLOG:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要(**始终做**,无论是否关联变更)
|
|
91
|
+
- 关联变更:对 Step 1 在每个 \`changes/<c>/tasks.md\` 写入的 ql-ID task,将其 checkbox 由 \`- [ ]\` 勾选为 \`- [x]\`;若 Step 1 未写入(用户事后才决定关联),则按 Step 1 的格式补写并直接勾选
|
|
92
|
+
4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
|
|
93
|
+
5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
|
|
94
|
+
6. 任务比预期复杂 → 建议用完整流程
|
|
95
|
+
|
|
96
|
+
### 模块文档同步
|
|
97
|
+
7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
|
|
98
|
+
8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
|
|
99
|
+
9. 如果命中模块 → 直接同步模块文档:
|
|
100
|
+
- 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
|
|
101
|
+
- 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
|
|
102
|
+
- 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
|
|
103
|
+
- 写入模块文档
|
|
104
|
+
- 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
|
|
105
|
+
10. 未命中任何模块 → 跳过,不做额外操作
|
|
96
106
|
|
|
97
107
|
### 输出
|
|
98
108
|
暂存确认 + 记录路径 + 模块文档同步结果(如有)`,
|
package/src/sync.js
CHANGED
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
|
+
import { resolvePlatformSpecDir } from './progress.js';
|
|
14
|
+
|
|
15
|
+
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
16
|
+
function safePlatformSpecDir(cwd) {
|
|
17
|
+
try {
|
|
18
|
+
return resolvePlatformSpecDir(cwd);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.warn(`[sync] 平台指针不可用,跳过平台同步:${String(e.message).split('\n')[0]}`);
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
const LOCAL_YAML = '.sillyspec/local.yaml';
|
|
15
26
|
const CHANGES_DIR = '.sillyspec/changes';
|
|
@@ -200,7 +211,7 @@ export class SyncManager {
|
|
|
200
211
|
let progressData;
|
|
201
212
|
try {
|
|
202
213
|
const { ProgressManager } = await import('./progress.js');
|
|
203
|
-
const pm = new ProgressManager();
|
|
214
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
204
215
|
progressData = await pm.read(this.cwd, changeName);
|
|
205
216
|
} catch (err) {
|
|
206
217
|
console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
|
|
@@ -225,7 +236,7 @@ export class SyncManager {
|
|
|
225
236
|
// 更新 platform_last_sync
|
|
226
237
|
try {
|
|
227
238
|
const { ProgressManager } = await import('./progress.js');
|
|
228
|
-
const pm = new ProgressManager();
|
|
239
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
229
240
|
await pm._updatePlatformLastSync(this.cwd, changeName);
|
|
230
241
|
} catch (err) {
|
|
231
242
|
console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
|
|
@@ -326,7 +337,7 @@ export class SyncManager {
|
|
|
326
337
|
// 更新本地 approvals 表
|
|
327
338
|
try {
|
|
328
339
|
const { ProgressManager } = await import('./progress.js');
|
|
329
|
-
const pm = new ProgressManager();
|
|
340
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
330
341
|
await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
|
|
331
342
|
} catch (err) {
|
|
332
343
|
console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
|
package/src/worktree.js
CHANGED
|
@@ -73,6 +73,17 @@ function parseJSON(raw) {
|
|
|
73
73
|
try { return JSON.parse(raw); } catch { return null; }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 跨平台同步等待(cleanup 重试退避用)
|
|
78
|
+
* 不依赖外部 sleep 命令——Windows cmd.exe 无 sleep;当 Git for Windows 未把 usr/bin 加入
|
|
79
|
+
* PATH 时,execSync('sleep 0.5') 会抛错并从 catch 块冒泡,直接中断整个 cleanup()。
|
|
80
|
+
* 改用 busy-wait 规避,保证 retry 之间真有间隔且不依赖环境。
|
|
81
|
+
*/
|
|
82
|
+
function sleepMs(ms) {
|
|
83
|
+
const end = Date.now() + ms;
|
|
84
|
+
while (Date.now() < end) { /* spin */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
function computeBaselineHash(cwd) {
|
|
77
88
|
// 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
|
|
78
89
|
const exclude = '-- . ":(exclude).sillyspec/"';
|
|
@@ -198,7 +209,7 @@ export class WorktreeManager {
|
|
|
198
209
|
if (isolation.inSubmodule) {
|
|
199
210
|
throw new Error(
|
|
200
211
|
'当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
|
|
201
|
-
'\n
|
|
212
|
+
'\n请在主仓库根目录执行 execute。'
|
|
202
213
|
);
|
|
203
214
|
}
|
|
204
215
|
if (isolation.inWorktree) {
|
|
@@ -237,6 +248,10 @@ export class WorktreeManager {
|
|
|
237
248
|
if (!this.getMeta(name)) {
|
|
238
249
|
console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
|
|
239
250
|
try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
|
251
|
+
// 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
|
|
252
|
+
// 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
|
|
253
|
+
try { gitQuiet(this.cwd, 'worktree prune'); } catch {}
|
|
254
|
+
try { gitQuiet(this.cwd, `branch -D ${branch}`); } catch {}
|
|
240
255
|
} else {
|
|
241
256
|
throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
|
|
242
257
|
}
|
|
@@ -269,7 +284,7 @@ export class WorktreeManager {
|
|
|
269
284
|
} catch (e) {
|
|
270
285
|
const check = isGitWorktreeSupported(this.cwd);
|
|
271
286
|
if (!check.supported) {
|
|
272
|
-
throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议:
|
|
287
|
+
throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 升级 git 到 >= 2.15;或运行 \`sillyspec worktree doctor --fix\` 检查 worktree 状态。`);
|
|
273
288
|
}
|
|
274
289
|
// sandbox/permission fallback: 降级为 in-place + baseline protection
|
|
275
290
|
console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
|
|
@@ -546,7 +561,10 @@ export class WorktreeManager {
|
|
|
546
561
|
* 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
|
|
547
562
|
* @param {string} changeName
|
|
548
563
|
* @param {{ force?: boolean, maxRetries?: number }} opts
|
|
549
|
-
* @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
|
|
564
|
+
* @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept'|'partial', mode: string|null, details: string[], residual: string[] }}
|
|
565
|
+
* result 取值:cleaned=git remove 成功;force-cleaned=git remove 失败但 fallback 清理完成;
|
|
566
|
+
* partial=有残留(目录/meta/git 注册未清);skipped=无需清理;kept=native-worktree 保留。
|
|
567
|
+
* residual:未清干净的路径/引用列表(空数组表示干净)。
|
|
550
568
|
*/
|
|
551
569
|
cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
|
|
552
570
|
const name = validateChangeName(changeName);
|
|
@@ -561,22 +579,20 @@ export class WorktreeManager {
|
|
|
561
579
|
}
|
|
562
580
|
|
|
563
581
|
const mode = meta?.mode || 'worktree';
|
|
582
|
+
// in-place 模式:worktreePath === 主工作区,绝对禁止删除目录本身,但 meta 目录仍应清理
|
|
583
|
+
// (否则永久残留)。native-worktree:外部隔离环境,整体跳过。
|
|
584
|
+
const isInPlace = mode === 'in-place-fallback';
|
|
564
585
|
|
|
565
|
-
//
|
|
566
|
-
if (!force) {
|
|
567
|
-
|
|
568
|
-
return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
|
|
569
|
-
}
|
|
570
|
-
if (mode === 'in-place-fallback') {
|
|
571
|
-
return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
|
|
572
|
-
}
|
|
586
|
+
// 安全检查:native-worktree 是外部隔离环境,非 force 不碰
|
|
587
|
+
if (!force && mode === 'native-worktree') {
|
|
588
|
+
return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'], residual: [] };
|
|
573
589
|
}
|
|
574
590
|
|
|
575
591
|
const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
|
|
576
592
|
|
|
577
|
-
// 1. git worktree remove(带 retry
|
|
593
|
+
// 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
|
|
578
594
|
let gitRemoveOk = false;
|
|
579
|
-
if (existsSync(worktreePath)) {
|
|
595
|
+
if (!isInPlace && existsSync(worktreePath)) {
|
|
580
596
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
581
597
|
try {
|
|
582
598
|
git(this.cwd, `worktree remove ${worktreePath} --force`);
|
|
@@ -586,15 +602,15 @@ export class WorktreeManager {
|
|
|
586
602
|
} catch (e) {
|
|
587
603
|
details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
|
|
588
604
|
if (attempt < maxRetries) {
|
|
589
|
-
//
|
|
590
|
-
|
|
605
|
+
// 短暂等待后重试(跨平台 busy-wait,见 sleepMs)
|
|
606
|
+
sleepMs(500);
|
|
591
607
|
}
|
|
592
608
|
}
|
|
593
609
|
}
|
|
594
610
|
}
|
|
595
611
|
|
|
596
|
-
// 2. fallback: 确保 worktree
|
|
597
|
-
if (existsSync(worktreePath)) {
|
|
612
|
+
// 2. fallback: 确保 worktree 目录已删除(in-place 跳过——worktreePath 是主工作区)
|
|
613
|
+
if (!isInPlace && existsSync(worktreePath)) {
|
|
598
614
|
try {
|
|
599
615
|
rmSync(worktreePath, { recursive: true, force: true });
|
|
600
616
|
details.push('worktree directory force-removed (fallback)');
|
|
@@ -618,7 +634,7 @@ export class WorktreeManager {
|
|
|
618
634
|
// 分支可能已被删除,幂等跳过
|
|
619
635
|
}
|
|
620
636
|
|
|
621
|
-
// 5. 清除 meta
|
|
637
|
+
// 5. 清除 meta 目录(in-place 模式也执行——这是 in-place meta 残留的修复点)
|
|
622
638
|
if (existsSync(metaDir)) {
|
|
623
639
|
try {
|
|
624
640
|
rmSync(metaDir, { recursive: true, force: true });
|
|
@@ -628,18 +644,27 @@ export class WorktreeManager {
|
|
|
628
644
|
}
|
|
629
645
|
}
|
|
630
646
|
|
|
631
|
-
// 6.
|
|
647
|
+
// 6. 最终验证:确认清理完成(in-place 模式 worktreePath=主工作区,不纳入残留检查)
|
|
632
648
|
const residual = [];
|
|
633
|
-
if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
|
|
649
|
+
if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
|
|
634
650
|
if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
|
|
635
|
-
if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
|
|
651
|
+
if (!isInPlace && gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
|
|
636
652
|
residual.push('git worktree list still references this worktree');
|
|
637
653
|
}
|
|
638
654
|
if (residual.length > 0) {
|
|
639
655
|
details.push(`⚠️ 残留: ${residual.join('; ')}`);
|
|
640
656
|
}
|
|
641
657
|
|
|
642
|
-
|
|
658
|
+
// result:有残留→partial;in-place(无隔离目录可删,只清了 meta)→cleaned;否则按 git remove 成败
|
|
659
|
+
let result;
|
|
660
|
+
if (residual.length > 0) {
|
|
661
|
+
result = 'partial';
|
|
662
|
+
} else if (isInPlace) {
|
|
663
|
+
result = 'cleaned';
|
|
664
|
+
} else {
|
|
665
|
+
result = gitRemoveOk ? 'cleaned' : 'force-cleaned';
|
|
666
|
+
}
|
|
667
|
+
return { result, mode, details, residual };
|
|
643
668
|
}
|
|
644
669
|
|
|
645
670
|
/**
|
|
@@ -680,8 +705,10 @@ export class WorktreeManager {
|
|
|
680
705
|
for (const entry of entries) {
|
|
681
706
|
const lines = entry.split('\n');
|
|
682
707
|
const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
|
|
708
|
+
// git 2.20+ porcelain 在目录缺失时输出 `missing` 行;作为 existsSync 的权威交叉验证
|
|
709
|
+
const missing = lines.some(l => l === 'missing');
|
|
683
710
|
if (wtPath && wtPath !== this.cwd) { // 排除主工作区
|
|
684
|
-
gitWorktreeList.push({ path: wtPath, raw: entry });
|
|
711
|
+
gitWorktreeList.push({ path: wtPath, missing, raw: entry });
|
|
685
712
|
}
|
|
686
713
|
}
|
|
687
714
|
} catch {
|
|
@@ -693,12 +720,25 @@ export class WorktreeManager {
|
|
|
693
720
|
const metaNames = new Set(metaEntries.map(m => m.changeName));
|
|
694
721
|
|
|
695
722
|
// 3. 检查 git worktree list 中的孤儿条目
|
|
723
|
+
// ⚠️ 只在 git 自己标记 missing 时自动 prune。目录不可见但 git 未标记 missing 的
|
|
724
|
+
// 情况(平台模式/容器路径不一致、符号链接、Windows 路径格式、旧 git 无 missing 标记)
|
|
725
|
+
// 只告警不自动 prune —— existsSync 在这些场景会误判,自动 prune 会杀死实际在用的
|
|
726
|
+
// worktree(sillyspec-worktree-platform-mode-bug)。git 内部状态是 worktree 生命周期的
|
|
727
|
+
// 权威来源,用它做 prune 前提可杜绝误杀。
|
|
696
728
|
for (const wt of gitWorktreeList) {
|
|
697
729
|
if (!existsSync(wt.path)) {
|
|
698
730
|
const name = this._pathToChangeName(wt.path);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
731
|
+
if (wt.missing === true) {
|
|
732
|
+
// git 明确标记目录缺失(git 2.20+)→ 真 orphan,prune 安全
|
|
733
|
+
issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 标记 missing: ${wt.path}`, fixable: true });
|
|
734
|
+
if (fix) {
|
|
735
|
+
try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
// 目录不可见但 git 未标记 missing → 可能 existsSync 误判,保守不自动 prune
|
|
739
|
+
issues.push({ type: 'orphan-git-entry', name: name || wt.path,
|
|
740
|
+
detail: `git worktree 引用 ${wt.path} 目录不可见但 git 未标记 missing——可能路径/权限导致误判,未自动 prune(避免误杀在用的 worktree)。确认废弃后手动: git worktree prune`,
|
|
741
|
+
fixable: false });
|
|
702
742
|
}
|
|
703
743
|
}
|
|
704
744
|
}
|
|
@@ -761,9 +801,10 @@ export class WorktreeManager {
|
|
|
761
801
|
if (meta && meta.createdAt) {
|
|
762
802
|
const ageMs = Date.now() - new Date(meta.createdAt).getTime();
|
|
763
803
|
const ageHours = ageMs / (1000 * 60 * 60);
|
|
804
|
+
const staleFixable = meta.mode !== 'native-worktree';
|
|
764
805
|
if (ageHours > staleHours) {
|
|
765
|
-
issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h
|
|
766
|
-
if (fix &&
|
|
806
|
+
issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)${staleFixable ? '' : '(native-worktree 外部环境,不可自动清理)'}`, fixable: staleFixable });
|
|
807
|
+
if (fix && staleFixable) {
|
|
767
808
|
try {
|
|
768
809
|
const result = this.cleanup(name);
|
|
769
810
|
if (result.result === 'cleaned' || result.result === 'force-cleaned') {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回归:decision 引用校验不应因「版本号有无 / 大小写」误报
|
|
3
|
+
*
|
|
4
|
+
* 现象:prompt 让用 D-xxx@v1,但 design/requirements 里常裸号引用 D-xxx。
|
|
5
|
+
* 旧校验器用 targetContent.includes("D-xxx@V1") 字面匹配 → 批量误报。
|
|
6
|
+
* 另:强制 decision 在 design+requirements+tasks 三处都引用,tasks 骨架不自然。
|
|
7
|
+
*
|
|
8
|
+
* 修 A:warnMissingIds 剥 @vN 后缀,按基号词边界匹配(裸号 D-001 视为引用 D-001@V1)。
|
|
9
|
+
* 修 B:brainstorm 阶段不再强制 requirements.md / tasks.md 引用每个 decision(decision 天然落点在 design)。
|
|
10
|
+
*/
|
|
11
|
+
import { runValidators } from '../src/stage-contract.js'
|
|
12
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
import os from 'os'
|
|
15
|
+
|
|
16
|
+
let failed = 0
|
|
17
|
+
const fail = m => { failed++; console.log(` ❌ FAIL: ${m}`) }
|
|
18
|
+
const pass = m => console.log(` ✅ PASS: ${m}`)
|
|
19
|
+
|
|
20
|
+
function setup() {
|
|
21
|
+
const tmp = join(os.tmpdir(), `ss-ref-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`)
|
|
22
|
+
const cd = join(tmp, '.sillyspec', 'changes', 'tc')
|
|
23
|
+
mkdirSync(cd, { recursive: true })
|
|
24
|
+
return { tmp, cd }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('=== decision 引用校验:版本号 / 裸号 / 三文件放宽 ===\n')
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
const { tmp, cd } = setup()
|
|
31
|
+
// decisions:小写 @v1(测大小写不敏感)+ 一条 supersede 链
|
|
32
|
+
writeFileSync(join(cd, 'decisions.md'), [
|
|
33
|
+
'# Decisions', '',
|
|
34
|
+
'## D-001@v1: 决策一', 'status: accepted', 'priority: P2', '',
|
|
35
|
+
'## D-002@v1: 决策二', 'status: accepted', 'priority: P2', '',
|
|
36
|
+
'## D-003@v1: 旧', 'status: superseded', 'supersedes: ', '',
|
|
37
|
+
'## D-003@v2: 新', 'status: accepted', 'supersedes: D-003@v1', '',
|
|
38
|
+
].join('\n'))
|
|
39
|
+
// design:裸号引用 D-001(小写原文);不引用 D-002(真实缺口);D-003@v2 裸号 D-003
|
|
40
|
+
writeFileSync(join(cd, 'design.md'), [
|
|
41
|
+
'# Design', '',
|
|
42
|
+
'## 文件变更清单', '- src/a.js 覆盖 D-001', '- src/b.py 涉及 D-003', '',
|
|
43
|
+
'## 风险登记', '- 风险', '',
|
|
44
|
+
'## 自审', '- 已审', '',
|
|
45
|
+
].join('\n'))
|
|
46
|
+
writeFileSync(join(cd, 'proposal.md'), '# P\n\n## 不在范围内\n- x\n')
|
|
47
|
+
writeFileSync(join(cd, 'requirements.md'), '# Req\n\n- FR-01: 功能\n')
|
|
48
|
+
// tasks:骨架,不引用任何 decision
|
|
49
|
+
writeFileSync(join(cd, 'tasks.md'), '# Tasks\n\n## W1\n- [ ] (待 plan 展开)\n')
|
|
50
|
+
|
|
51
|
+
const r = runValidators('brainstorm', tmp, 'tc', {})
|
|
52
|
+
const ref = r.warnings.filter(w => w.includes('未引用'))
|
|
53
|
+
console.log(' 产出未引用警告:')
|
|
54
|
+
for (const w of ref) console.log(` - ${w}`)
|
|
55
|
+
|
|
56
|
+
// 修 A:裸号 D-001(小写)应被识别,design 不报 D-001
|
|
57
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-001'))) {
|
|
58
|
+
fail('design 裸号 D-001(小写)仍被报 —— 修A 未生效(版本号/大小写)')
|
|
59
|
+
} else pass('design 裸号 D-001 被识别(修A:剥版本号 + 大小写不敏感)')
|
|
60
|
+
|
|
61
|
+
// 修 A:D-003 裸号应匹配 active 的 D-003@V2(V1 被 supersede 排除)
|
|
62
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-003'))) {
|
|
63
|
+
fail('design 裸号 D-003 未匹配到 active D-003@V2')
|
|
64
|
+
} else pass('design 裸号 D-003 匹配 active D-003@V2(supersede 链正确)')
|
|
65
|
+
|
|
66
|
+
// 真实缺口保留:design 确实没引用 D-002
|
|
67
|
+
if (ref.some(w => w.includes('design.md') && w.includes('D-002'))) {
|
|
68
|
+
pass('design 未引用 D-002 正确报警(真实缺口保留)')
|
|
69
|
+
} else fail('design D-002 缺口未报警 —— 校验器被改得过松')
|
|
70
|
+
|
|
71
|
+
// 修 B:requirements / tasks 不再被强制引用每个 decision
|
|
72
|
+
if (ref.some(w => w.includes('requirements.md'))) {
|
|
73
|
+
fail('requirements 仍被强制引用 decision —— 修B 未生效')
|
|
74
|
+
} else pass('requirements 不再强制引用 decision(修B)')
|
|
75
|
+
if (ref.some(w => w.includes('tasks.md'))) {
|
|
76
|
+
fail('tasks 骨架仍被强制引用 decision —— 修B 未生效')
|
|
77
|
+
} else pass('tasks 骨架不再强制引用 decision(修B)')
|
|
78
|
+
|
|
79
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('\n' + '='.repeat(50))
|
|
83
|
+
console.log(failed === 0 ? '✅ 全部通过' : `❌ 失败 ${failed}`)
|
|
84
|
+
console.log('='.repeat(50))
|
|
85
|
+
if (failed > 0) process.exit(1)
|
|
@@ -140,21 +140,32 @@ function assert(label, condition, detail) {
|
|
|
140
140
|
|
|
141
141
|
// ── 测试 6:quick step 1 prompt 强制要求 quicklog ──
|
|
142
142
|
{
|
|
143
|
-
const { definition } = await import('../src/stages/quick.js')
|
|
144
|
-
const step1Prompt = definition.steps[0].prompt
|
|
145
|
-
const step3Prompt = definition.steps[2].prompt
|
|
143
|
+
const { definition } = await import('../src/stages/quick.js')
|
|
144
|
+
const step1Prompt = definition.steps[0].prompt
|
|
145
|
+
const step3Prompt = definition.steps[2].prompt
|
|
146
146
|
|
|
147
147
|
assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
|
|
148
148
|
assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
|
|
149
|
-
assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
|
|
150
|
-
assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
|
|
151
|
-
assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
|
|
152
|
-
assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
|
|
149
|
+
assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
|
|
150
|
+
assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
|
|
151
|
+
assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
|
|
152
|
+
assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
|
|
153
|
+
|
|
154
|
+
// 新语义:QUICKLOG 始终记录 + 多变更关联
|
|
155
|
+
assert('quick step 1 含 <linked-changes> 占位符', step1Prompt.includes('<linked-changes>'))
|
|
156
|
+
assert('quick step 1 含「关联变更」措辞', step1Prompt.includes('关联变更'))
|
|
157
|
+
assert('quick step 1 QUICKLOG 始终创建', step1Prompt.includes('始终创建 QUICKLOG'))
|
|
158
|
+
assert('quick step 3 QUICKLOG 始终更新', step3Prompt.includes('始终做'))
|
|
153
159
|
|
|
154
160
|
// run.js 审计包含 quicklog 检查
|
|
155
161
|
const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
|
|
156
162
|
assert('quick 审计检查 quicklog 目录存在', runSrc.includes('quicklog 目录不存在'))
|
|
157
163
|
assert('quick 审计检查 quicklog 为空', runSrc.includes('quicklog 目录为空'))
|
|
164
|
+
|
|
165
|
+
// run.js 多变更交互入口 + 推荐打分
|
|
166
|
+
assert('run.js 含 resolveQuickLinkedChanges 交互入口', runSrc.includes('resolveQuickLinkedChanges'))
|
|
167
|
+
assert('run.js 含 recommendChanges 推荐打分', runSrc.includes('recommendChanges'))
|
|
168
|
+
assert('run.js 含 <linked-changes> 占位符注入', runSrc.includes('<linked-changes>'))
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
// ── 结果 ──
|