sillyspec 3.18.3 → 3.18.5

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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * endpoint-extractor.js — 从代码中提取 HTTP 端点定义和调用
3
+ *
4
+ * provider 端:扫描 router 文件,提取注册的 API 路径
5
+ * consumer 端:扫描前端文件,提取 apiFetch/request 调用路径
6
+ *
7
+ * 契约对账时:provider 产出 ≠ consumer 消费 → gap
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
11
+ import { join, extname, basename, dirname, resolve } from 'path'
12
+ import { execSync } from 'child_process'
13
+
14
+ // ─── Provider: 扫描后端 router 注册的端点 ───────────────────────────────
15
+
16
+ /**
17
+ * 从单个文件提取 FastAPI router 端点
18
+ * 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
19
+ *
20
+ * @param {string} filePath - 文件绝对路径
21
+ * @returns {Array<{ method: string, path: string, source: string, line: number }>}
22
+ */
23
+ export function extractFastApiEndpoints(filePath) {
24
+ const content = readFileSync(filePath, 'utf8')
25
+ const lines = content.split('\n')
26
+ const endpoints = []
27
+
28
+ // 1. 提取 router prefix
29
+ let routerPrefix = ''
30
+ for (const line of lines) {
31
+ const prefixMatch = line.match(/(?:APIRouter|router)\s*\(\s*(?:prefix\s*=\s*)?["'`]([^"'`]+)["'`]/)
32
+ || line.match(/\.include_router\s*\([^)]*prefix\s*=\s*["'`]([^"'`]+)["'`]/)
33
+ if (prefixMatch) {
34
+ routerPrefix = prefixMatch[1]
35
+ }
36
+ }
37
+
38
+ // 2. 提取 @router.method("/path") 或分散式定义
39
+ // FastAPI 支持两种写法:
40
+ // a) @router.get("/path", ...) 下一行 def func():
41
+ // b) @router.get 下一行 ("/path", ...)
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i]
44
+ const decoratorMatch = line.match(
45
+ /@(?:router|api_router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/
46
+ )
47
+ if (decoratorMatch) {
48
+ const method = decoratorMatch[1].toUpperCase()
49
+ const rawPath = decoratorMatch[2]
50
+ endpoints.push({
51
+ method,
52
+ path: routerPrefix + rawPath,
53
+ source: filePath,
54
+ line: i + 1,
55
+ })
56
+ continue
57
+ }
58
+ // 分散式: @router.get\n ("/path",
59
+ const splitMatch = line.match(/@(?:router|api_router)\.(get|post|put|delete|patch)\s*$/)
60
+ if (splitMatch && i + 1 < lines.length) {
61
+ const nextLine = lines[i + 1]
62
+ const pathMatch = nextLine.match(/\(\s*["'`]([^"'`]+)["'`]/)
63
+ if (pathMatch) {
64
+ endpoints.push({
65
+ method: splitMatch[1].toUpperCase(),
66
+ path: routerPrefix + pathMatch[1],
67
+ source: filePath,
68
+ line: i + 1,
69
+ })
70
+ }
71
+ }
72
+ }
73
+
74
+ return endpoints
75
+ }
76
+
77
+ /**
78
+ * 从目录递归扫描所有 Python router 文件的端点
79
+ * @param {string} dir
80
+ * @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
81
+ * @returns {Array<{ method: string, path: string, source: string, line: number }>}
82
+ */
83
+ export function scanBackendEndpoints(dir, opts = {}) {
84
+ const filePattern = opts.filePattern || /(?:router|routes|api|endpoint|controller)\.py$/i
85
+ const excludePatterns = opts.excludePatterns || [/__pycache__/, /node_modules/, /\.venv/, /test/i]
86
+
87
+ const results = []
88
+ if (!existsSync(dir)) return results
89
+
90
+ function walk(d) {
91
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
92
+ const full = join(d, entry.name)
93
+ if (entry.isDirectory()) {
94
+ if (excludePatterns.some(p => p.test(entry.name))) continue
95
+ walk(full)
96
+ } else if (entry.isFile() && filePattern.test(entry.name)) {
97
+ try {
98
+ results.push(...extractFastApiEndpoints(full))
99
+ } catch {}
100
+ }
101
+ }
102
+ }
103
+ walk(dir)
104
+ return results
105
+ }
106
+
107
+ // ─── Consumer: 扫描前端 API 调用路径 ─────────────────────────────────────
108
+
109
+ /**
110
+ * 从前端文件提取 API 调用路径
111
+ * 支持:
112
+ * apiFetch("/api/xxx")
113
+ * request("/api/xxx")
114
+ * axios.get("/api/xxx")
115
+ * axios.post("/api/xxx")
116
+ * fetch("/api/xxx")
117
+ *
118
+ * @param {string} filePath
119
+ * @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
120
+ */
121
+ export function extractFrontendApiCalls(filePath) {
122
+ const content = readFileSync(filePath, 'utf8')
123
+ const lines = content.split('\n')
124
+ const results = []
125
+
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const line = lines[i]
128
+
129
+ // Pattern 1: apiFetch<T>("/path", { method: "POST" }) — default GET
130
+ const apiFetchMatch = line.match(/apiFetch\s*(?:<[^>]*>)?\s*\(\s*["'`]([^"'`]+)["'`]/)
131
+ if (apiFetchMatch) {
132
+ // 检查是否有 method 字段(在后续行或同一行)
133
+ let method = 'GET'
134
+ const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
135
+ const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
136
+ if (methodMatch) method = methodMatch[1].toUpperCase()
137
+
138
+ results.push({
139
+ method,
140
+ path: normalizePath(apiFetchMatch[1]),
141
+ source: filePath,
142
+ line: i + 1,
143
+ raw: apiFetchMatch[1],
144
+ })
145
+ continue
146
+ }
147
+
148
+ // Pattern 2: axios.get/post/put/delete("/path")
149
+ const axiosMatch = line.match(/axios\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/i)
150
+ if (axiosMatch) {
151
+ results.push({
152
+ method: axiosMatch[1].toUpperCase(),
153
+ path: normalizePath(axiosMatch[2]),
154
+ source: filePath,
155
+ line: i + 1,
156
+ raw: axiosMatch[2],
157
+ })
158
+ continue
159
+ }
160
+
161
+ // Pattern 3: fetch("/api/xxx", { method: "POST" })
162
+ const fetchMatch = line.match(/(?:api_?)?fetch\s*\(\s*["'`]([^"'`]+)["'`]/)
163
+ if (fetchMatch && !apiFetchMatch) {
164
+ let method = 'GET'
165
+ const snippet = lines.slice(i, Math.min(i + 3, lines.length)).join(' ')
166
+ const methodMatch = snippet.match(/method\s*:\s*["'`](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)["'`]/i)
167
+ if (methodMatch) method = methodMatch[1].toUpperCase()
168
+
169
+ results.push({
170
+ method,
171
+ path: normalizePath(fetchMatch[1]),
172
+ source: filePath,
173
+ line: i + 1,
174
+ raw: fetchMatch[1],
175
+ })
176
+ continue
177
+ }
178
+ }
179
+
180
+ return results
181
+ }
182
+
183
+ /**
184
+ * 递归扫描前端目录的 API 调用
185
+ * @param {string} dir
186
+ * @param {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
187
+ * @returns {Array<{ method: string, path: string, source: string, line: number, raw: string }>}
188
+ */
189
+ export function scanFrontendApiCalls(dir, opts = {}) {
190
+ const filePattern = opts.filePattern || /\.(ts|tsx|js|jsx)$/
191
+ const excludePatterns = opts.excludePatterns || [/node_modules/, /\.next/, /dist/, /__tests__/, /\.d\.ts$/]
192
+
193
+ const results = []
194
+ if (!existsSync(dir)) return results
195
+
196
+ function walk(d) {
197
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
198
+ const full = join(d, entry.name)
199
+ if (entry.isDirectory()) {
200
+ if (excludePatterns.some(p => p.test(entry.name))) continue
201
+ walk(full)
202
+ } else if (entry.isFile() && filePattern.test(entry.name)) {
203
+ try {
204
+ results.push(...extractFrontendApiCalls(full))
205
+ } catch {}
206
+ }
207
+ }
208
+ }
209
+ walk(dir)
210
+ return results
211
+ }
212
+
213
+ // ─── 路径归一化 ─────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * 将动态路径归一化为参数占位符
217
+ * /api/ppm/project-plan/${id}/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
218
+ * /api/ppm/project-plan/:planId/plan-nodes → /api/ppm/project-plan/{param}/plan-nodes
219
+ * @param {string} rawPath
220
+ * @returns {string}
221
+ */
222
+ export function normalizePath(rawPath) {
223
+ return rawPath
224
+ .replace(/\$\{[^}]+\}/g, '{param}')
225
+ .replace(/:\w+/g, '{param}')
226
+ .replace(/\$\w+/g, '{param}')
227
+ }
228
+
229
+ // ─── 对账 ───────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * 比较前端调用的路径和后端注册的端点,返回差异
233
+ *
234
+ * @param {Array<{ path: string, method: string, source: string }>} frontendCalls
235
+ * @param {Array<{ path: string, method: string, source: string }>} backendEndpoints
236
+ * @returns {{
237
+ * missingBackend: Array<{ path: string, method: string, consumerFile: string, consumerLine: number }>,
238
+ * unusedBackend: Array<{ path: string, method: string, providerFile: string }>
239
+ * }}
240
+ */
241
+ export function diffApiParity(frontendCalls, backendEndpoints) {
242
+ // 构建 backend 注册表:归一化 path + method → endpoint
243
+ const backendMap = new Map()
244
+ for (const ep of backendEndpoints) {
245
+ const key = `${ep.method}:${normalizePath(ep.path)}`
246
+ if (!backendMap.has(key)) backendMap.set(key, ep)
247
+ }
248
+
249
+ const missingBackend = []
250
+ for (const call of frontendCalls) {
251
+ const key = `${call.method}:${normalizePath(call.path)}`
252
+ if (!backendMap.has(key)) {
253
+ missingBackend.push({
254
+ path: normalizePath(call.path),
255
+ method: call.method,
256
+ consumerFile: call.source,
257
+ consumerLine: call.line,
258
+ })
259
+ }
260
+ }
261
+
262
+ // 构建 frontend 调用表
263
+ const frontendSet = new Set(
264
+ frontendCalls.map(c => `${c.method}:${normalizePath(c.path)}`)
265
+ )
266
+
267
+ const unusedBackend = []
268
+ for (const ep of backendEndpoints) {
269
+ const key = `${ep.method}:${normalizePath(ep.path)}`
270
+ if (!frontendSet.has(key)) {
271
+ unusedBackend.push({
272
+ path: normalizePath(ep.path),
273
+ method: ep.method,
274
+ providerFile: ep.source,
275
+ })
276
+ }
277
+ }
278
+
279
+ return { missingBackend, unusedBackend, ok: missingBackend.length === 0 }
280
+ }
281
+
282
+ // ─── CLI 入口 ────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * CLI 子命令入口:sillyspec contract scan [--backend dir] [--frontend dir]
286
+ * 输出 JSON 格式的端点清单和对账结果
287
+ */
288
+ export async function contractScan(args, cwd) {
289
+ const backendIdx = args.indexOf('--backend')
290
+ const frontendIdx = args.indexOf('--frontend')
291
+ const backendDir = backendIdx !== -1 && args[backendIdx + 1]
292
+ ? resolve(cwd, args[backendIdx + 1])
293
+ : resolve(cwd, 'backend')
294
+ const frontendDir = frontendIdx !== -1 && args[frontendIdx + 1]
295
+ ? resolve(cwd, args[frontendIdx + 1])
296
+ : resolve(cwd, 'frontend')
297
+
298
+ const backendEndpoints = scanBackendEndpoints(backendDir)
299
+ const frontendCalls = scanFrontendApiCalls(frontendDir)
300
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, backendEndpoints)
301
+
302
+ return {
303
+ backend: backendEndpoints.map(e => ({ method: e.method, path: normalizePath(e.path), file: e.source })),
304
+ frontend: frontendCalls.map(c => ({ method: c.method, path: normalizePath(c.path), file: c.source })),
305
+ missingBackend,
306
+ unusedBackend,
307
+ summary: {
308
+ backendEndpointCount: backendEndpoints.length,
309
+ frontendCallCount: frontendCalls.length,
310
+ missingBackendCount: missingBackend.length,
311
+ unusedBackendCount: unusedBackend.length,
312
+ ok: missingBackend.length === 0,
313
+ },
314
+ }
315
+ }
package/src/index.js CHANGED
@@ -145,6 +145,21 @@ async function main() {
145
145
  targetDir = resolve(filteredArgs[1]);
146
146
  filteredArgs.splice(1, 1);
147
147
  }
148
+ // ── 自动纠正 cwd ──
149
+ // 当 agent 在 worktree 内跑 pnpm 等工具后 shell cwd 可能被改变,
150
+ // 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
151
+ function resolveEffectiveDir(baseDir) {
152
+ if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
153
+ try {
154
+ const { execSync } = require('child_process')
155
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
156
+ cwd: baseDir, encoding: 'utf8', timeout: 5000
157
+ }).trim()
158
+ if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
159
+ } catch {}
160
+ return baseDir
161
+ }
162
+
148
163
  const dir = targetDir;
149
164
 
150
165
  if (command === 'init' && !existsSync(dir)) {
@@ -167,6 +182,7 @@ async function main() {
167
182
  break;
168
183
  case 'progress': {
169
184
  const pm = new ProgressManager();
185
+ const progDir = resolveEffectiveDir(dir);
170
186
  const subCommand = filteredArgs[1];
171
187
  const stageIdx = filteredArgs.indexOf('--stage');
172
188
  const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
@@ -176,18 +192,18 @@ async function main() {
176
192
 
177
193
  switch (subCommand) {
178
194
  case 'init':
179
- pm.init(dir);
195
+ pm.init(progDir);
180
196
  break;
181
197
  case 'status':
182
198
  case 'show':
183
- pm.show(dir, progChangeName);
199
+ pm.show(progDir, progChangeName);
184
200
  break;
185
201
  case 'check':
186
- await pm.checkConsistency(dir, progChangeName);
202
+ await pm.checkConsistency(progDir, progChangeName);
187
203
  break;
188
204
  case 'repair': {
189
205
  const repairApply = filteredArgs.includes('--apply');
190
- await pm.repairConsistency(dir, { apply: repairApply, changeName: progChangeName });
206
+ await pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
191
207
  break;
192
208
  }
193
209
  case 'validate':
@@ -270,7 +286,7 @@ async function main() {
270
286
  }
271
287
  case 'run': {
272
288
  const { runCommand } = await import('./run.js')
273
- await runCommand(filteredArgs.slice(1), dir, specDir)
289
+ await runCommand(filteredArgs.slice(1), resolveEffectiveDir(dir), specDir)
274
290
  break
275
291
  }
276
292
  case 'dashboard': {
@@ -329,7 +345,8 @@ SillySpec worktree — git worktree 隔离管理
329
345
  sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
330
346
  sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
331
347
  sillyspec worktree list 列出所有活跃 worktree
332
- sillyspec worktree cleanup <change-name> 强制清理 worktree
348
+ sillyspec worktree cleanup <change-name> [--force] 强制清理 worktree
349
+ sillyspec worktree doctor [--fix] [--stale-hours N] 健康检查 + 修复
333
350
 
334
351
  选项:
335
352
  --base <branch> create: 指定基础分支(默认当前 HEAD)
@@ -429,11 +446,13 @@ SillySpec worktree — git worktree 隔离管理
429
446
  const forceFlag = args.includes('--force');
430
447
  try {
431
448
  const result = wm.cleanup(wtName, { force: forceFlag });
432
- if (result.result === 'cleaned') {
449
+ if (result.result === 'cleaned' || result.result === 'force-cleaned') {
433
450
  console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
434
- } else if (result.result === 'force-cleaned') {
435
- console.log(`⚠️ worktree 已强制清理: ${wtName} (mode: ${result.mode})`);
436
- console.log(` 原因: git worktree remove 失败,通过直接删除目录完成`);
451
+ if (result.details?.length > 0) {
452
+ for (const d of result.details) {
453
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
454
+ }
455
+ }
437
456
  } else if (result.result === 'skipped') {
438
457
  console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
439
458
  console.log(` 原因: in-place 模式没有隔离目录需要清理`);
@@ -446,6 +465,34 @@ SillySpec worktree — git worktree 隔离管理
446
465
  }
447
466
  break;
448
467
  }
468
+ case 'doctor': {
469
+ const fixFlag = args.includes('--fix');
470
+ const staleIdx = args.indexOf('--stale-hours');
471
+ const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
472
+ const diag = wm.doctor({ fix: fixFlag, staleHours });
473
+ if (diag.issues.length === 0) {
474
+ console.log('✅ worktree 健康检查通过,无异常');
475
+ } else {
476
+ console.log(`🔍 发现 ${diag.issues.length} 个问题:\n`);
477
+ for (const issue of diag.issues) {
478
+ const icon = issue.fixable ? '⚠️' : '❌';
479
+ console.log(` ${icon} [${issue.type}] ${issue.name}: ${issue.detail}`);
480
+ }
481
+ if (fixFlag) {
482
+ console.log(`\n🔧 修复完成:`);
483
+ for (const f of diag.fixed) console.log(` ✅ ${f}`);
484
+ if (diag.unfixable.length > 0) {
485
+ for (const u of diag.unfixable) console.log(` ❌ ${u}`);
486
+ }
487
+ if (diag.fixed.length === 0 && diag.unfixable.length === 0) {
488
+ console.log(' 无需修复');
489
+ }
490
+ } else {
491
+ console.log(`\n💡 运行 sillyspec worktree doctor --fix 自动修复`);
492
+ }
493
+ }
494
+ break;
495
+ }
449
496
  default:
450
497
  console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
451
498
  console.log(' 运行 sillyspec worktree --help 查看帮助');
package/src/init.js CHANGED
@@ -108,12 +108,39 @@ async function doInstall(projectDir, tools, subprojects = [], specDir = null) {
108
108
  // projectDir: 源码项目根目录(用于工具检测、指令注入、.gitignore)
109
109
  const spec = specDir || join(projectDir, '.sillyspec');
110
110
 
111
- // 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)
111
+ // 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
112
+ // ⚠️ 必须保护真实资产:若本地 .sillyspec 含 changes/(非空)、projects/(非空)
113
+ // 或 sillyspec.db(进度库),说明该项目本身就用 SillySpec 管理,整体删除会丢资产。
114
+ // 此时只清运行时残留,拒绝整删;确无资产时才视为旧残留清理。
112
115
  const legacyDir = join(projectDir, '.sillyspec');
113
116
  if (specDir && existsSync(legacyDir)) {
114
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
115
- if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
116
- else console.error('⚠️ 清理残留 .sillyspec/ 失败');
117
+ let hasChanges = false;
118
+ try {
119
+ const changesDir = join(legacyDir, 'changes');
120
+ if (existsSync(changesDir)) hasChanges = readdirSync(changesDir).length > 0;
121
+ } catch {}
122
+ let hasProjects = false;
123
+ try {
124
+ const projectsDir = join(legacyDir, 'projects');
125
+ if (existsSync(projectsDir)) hasProjects = readdirSync(projectsDir).length > 0;
126
+ } catch {}
127
+ const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
128
+
129
+ if (hasChanges || hasProjects || hasDb) {
130
+ // 真实资产存在:拒绝整体删除,仅清理运行时残留
131
+ console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产(changes/、projects/ 或 sillyspec.db)。');
132
+ console.error(' 该项目似乎本身就用 SillySpec 管理。如需改用外部 spec 目录,请先手动迁移/备份。');
133
+ console.error(' 本次仅清理运行时残留(.runtime/、local.yaml、codebase/)。');
134
+ for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
135
+ const p = join(legacyDir, residue);
136
+ if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
137
+ }
138
+ } else {
139
+ // 无真实资产:确属旧版本残留,安全删除
140
+ try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
141
+ if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
142
+ else console.error('⚠️ 清理残留 .sillyspec/ 失败');
143
+ }
117
144
  }
118
145
 
119
146
  // 创建基础目录
package/src/run.js CHANGED
@@ -1148,11 +1148,32 @@ export async function runCommand(args, cwd, specDir = null) {
1148
1148
  // runCommand 后续所有 .sillyspec/ 操作必须用 specBase
1149
1149
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1150
1150
 
1151
- // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)
1151
+ // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
1152
+ // ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
1152
1153
  if (platformOpts.specRoot) {
1153
1154
  const legacyDir = join(cwd, '.sillyspec')
1154
1155
  if (existsSync(legacyDir)) {
1155
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
1156
+ let hasChanges = false;
1157
+ try {
1158
+ const cd = join(legacyDir, 'changes');
1159
+ if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
1160
+ } catch {}
1161
+ let hasProjects = false;
1162
+ try {
1163
+ const pd = join(legacyDir, 'projects');
1164
+ if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
1165
+ } catch {}
1166
+ const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
1167
+
1168
+ if (hasChanges || hasProjects || hasDb) {
1169
+ console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产。仅清理运行时残留。');
1170
+ for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
1171
+ const p = join(legacyDir, residue);
1172
+ if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
1173
+ }
1174
+ } else {
1175
+ try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
1176
+ }
1156
1177
  }
1157
1178
  }
1158
1179
 
@@ -1943,12 +1964,38 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1943
1964
  stageData.completedAt = now
1944
1965
  await pm._write(cwd, progress, changeName)
1945
1966
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
1967
+ // ── execute 阶段完成时条件性清理 worktree ──
1968
+ if (stageName === 'execute' && changeName) {
1969
+ try {
1970
+ const { WorktreeManager } = await import('./worktree.js');
1971
+ const wm = new WorktreeManager({ cwd });
1972
+ const meta = wm.getMeta(changeName);
1973
+ if (!meta) {
1974
+ console.log('🔗 Worktree: n/a (no meta)');
1975
+ } else if (meta.mode === 'native-worktree') {
1976
+ console.log('🔗 Worktree: kept (外部隔离环境)');
1977
+ } else if (meta.mode === 'in-place-fallback') {
1978
+ console.log('🔗 Worktree: n/a (in-place 模式)');
1979
+ } else {
1980
+ const check = wm.hasUnappliedChanges(changeName);
1981
+ if (check.hasChanges) {
1982
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
1983
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
1984
+ } else {
1985
+ const cleanResult = wm.cleanup(changeName);
1986
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
1987
+ }
1988
+ }
1989
+ } catch (e) {
1990
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
1991
+ }
1992
+ }
1946
1993
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
1947
1994
  }
1948
1995
 
1949
1996
  // 输出下一步
1950
- if (nextPendingIdx !== -1 && defSteps) {
1951
- console.log('')
1997
+ if (nextPendingIdx !== -1 && defSteps) {
1998
+ console.log('')
1952
1999
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
1953
2000
  } else if (nextWaitingIdx !== -1 && defSteps) {
1954
2001
  // 下一个步骤也在等待状态
@@ -2517,6 +2564,42 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2517
2564
  console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
2518
2565
  }
2519
2566
 
2567
+ // ── execute 阶段完成时条件性清理 worktree(不依赖 AI agent 的完成确认步骤)──
2568
+ if (stageName === 'execute' && changeName) {
2569
+ try {
2570
+ const { WorktreeManager } = await import('./worktree.js');
2571
+ const wm = new WorktreeManager({ cwd });
2572
+ const meta = wm.getMeta(changeName);
2573
+ if (!meta) {
2574
+ console.log('🔗 Worktree: n/a (no meta)');
2575
+ } else if (meta.mode === 'native-worktree') {
2576
+ console.log('🔗 Worktree: kept (外部隔离环境)');
2577
+ } else if (meta.mode === 'in-place-fallback') {
2578
+ console.log('🔗 Worktree: n/a (in-place 模式)');
2579
+ } else {
2580
+ const check = wm.hasUnappliedChanges(changeName);
2581
+ if (check.hasChanges) {
2582
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
2583
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
2584
+ } else {
2585
+ const cleanResult = wm.cleanup(changeName);
2586
+ if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
2587
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
2588
+ } else {
2589
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
2590
+ if (cleanResult.details?.length > 0) {
2591
+ for (const d of cleanResult.details) {
2592
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ } catch (e) {
2599
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
2600
+ }
2601
+ }
2602
+
2520
2603
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2521
2604
  }
2522
2605
 
@@ -165,6 +165,7 @@ const fixedPrefix = [
165
165
  - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
166
166
  - **后续所有子代理的 cwd 必须设为该 worktree 路径**
167
167
  - 如果 meta.json 不存在(说明创建失败),停止并报错
168
+ - **不要自行检查 git dirty/uncommitted 状态来判断是否可以进入 worktree,CLI 已自动处理**
168
169
 
169
170
  ### 输出
170
171
  worktree 路径 + 分支名 + 模式
@@ -406,6 +407,48 @@ function parseWavesFromPlan(planContent) {
406
407
  * 为 Wave 生成 prompt(强制子代理执行)
407
408
  */
408
409
  function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
410
+ // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
411
+ let contractInjection = ''
412
+ if (changeDir) {
413
+ try {
414
+ const { buildContractMatrix, buildConsumerInjection } = require('../contract-matrix.js')
415
+ const planFile = path.join(changeDir, 'plan.md')
416
+ if (existsSync(planFile)) {
417
+ const planContent = readFileSync(planFile, 'utf8')
418
+ const contracts = buildContractMatrix(planContent, changeDir)
419
+ if (contracts.length > 0) {
420
+ // 收集本 wave 所有 task 的注入内容
421
+ const waveTasks = wave.tasks.map((t, ti) => {
422
+ const num = String(t.index || (ti + 1)).padStart(2, '0')
423
+ return `task-${num}`
424
+ })
425
+ const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
426
+ if (relevantContracts.length > 0) {
427
+ contractInjection = `
428
+ ### API Contract Matrix
429
+ 本 Wave 存在前端/后端跨 task 契约:
430
+ ${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
431
+ `
432
+ // 为每个 consumer task 生成详细注入
433
+ for (const taskName of waveTasks) {
434
+ const injection = buildConsumerInjection(changeDir, join(changeDir, '..', '..'), taskName, contracts)
435
+ if (injection) {
436
+ contractInjection += `
437
+ ### 子代理 ${taskName} 的契约注入
438
+ 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
439
+
440
+ <contract-injection>
441
+ ${injection}
442
+ </contract-injection>
443
+ `
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ } catch {}
450
+ }
451
+
409
452
  // 构建任务摘要(不再内联完整蓝图,减少上下文污染)
410
453
  const taskSummary = wave.tasks.map((t, ti) => {
411
454
  const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
@@ -476,7 +519,7 @@ ${taskSummary}
476
519
  - 🔥 热上下文:design.md 编码铁律 + 当前 Wave 任务(必须加载)
477
520
  - 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
478
521
  - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
479
-
522
+ ${contractInjection}
480
523
  ### 本 Wave 任务
481
524
  ${taskList}
482
525
 
@@ -493,7 +536,11 @@ ${taskList}
493
536
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
494
537
 
495
538
  ### 完成后
496
- 运行 sillyspec run execute --done --input "用户原始反馈" --output "Wave ${waveIndex} 结果摘要"`
539
+ 1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
540
+ - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
541
+ - 将端点清单写入 .sillyspec/.runtime/contract-artifacts/<task-name>/endpoints.json
542
+ - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
543
+ 2. 运行 sillyspec run execute --done --input "用户原始反馈" --output "Wave ${waveIndex} 结果摘要"`
497
544
  }
498
545
 
499
546