sillyspec 3.17.7 → 3.17.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.17.7",
3
+ "version": "3.17.9",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/modules.js CHANGED
@@ -47,7 +47,18 @@ function parseModuleCardFrontmatter(content) {
47
47
  }
48
48
 
49
49
  /**
50
- * rebuild: 从模块卡片 + 源码重建 _module-map.yaml
50
+ * 从模块卡片 + 源码重建 _module-map.yaml
51
+ *
52
+ * schema_version 2 扩展字段:
53
+ * - role: 模块职责描述
54
+ * - core_files: 核心源文件列表
55
+ * - test_files: 测试文件列表
56
+ * - entrypoints: 入口点
57
+ * - depends_on: 依赖的其他模块
58
+ * - used_by: 被哪些模块使用
59
+ * - risk_level: 风险等级(low/medium/high)
60
+ * - verify_commands: 验证命令(build/test/lint)
61
+ * - related_docs: 关联文档路径
51
62
  */
52
63
  export async function rebuildModuleMap(cwd) {
53
64
  const mapPath = findModuleMapPath(cwd);
@@ -107,10 +118,12 @@ export async function rebuildModuleMap(cwd) {
107
118
  headCommit = execSync('git rev-parse --short HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
108
119
  } catch { /* ignore */ }
109
120
 
110
- let yaml = `schema_version: 1\n`;
121
+ let yaml = `schema_version: 2\n`;
111
122
  yaml += `generated_at: ${now}\n`;
112
123
  yaml += `generator: sillyspec-modules-rebuild\n`;
113
124
  if (headCommit) yaml += `source_commit: ${headCommit}\n`;
125
+ yaml += `\n# Module Context Index — 机器可读的模块上下文索引\n`;
126
+ yaml += `# scan 阶段自动生成,brainstorm/plan/execute 阶段按任务命中模块精准注入上下文\n`;
114
127
  yaml += `\nmodules:\n`;
115
128
 
116
129
  // 合并:已有映射 + 新卡片
@@ -121,13 +134,31 @@ export async function rebuildModuleMap(cwd) {
121
134
 
122
135
  for (const moduleId of allModuleIds) {
123
136
  const card = cards.find(c => c.moduleId === moduleId);
137
+ const cardContent = card ? readFileSync(join(modulesDir, card.filename), 'utf8') : '';
138
+
139
+ // 从模块卡片提取 context index 字段
140
+ const role = extractSection(cardContent, '定位') || '';
141
+ const contract = extractSection(cardContent, '契约摘要') || '';
142
+ const logic = extractSection(cardContent, '关键逻辑') || '';
143
+ const notes = extractSection(cardContent, '注意事项') || '';
144
+
124
145
  yaml += ` ${moduleId}:\n`;
125
146
  yaml += ` status: active\n`;
126
147
  if (card) yaml += ` doc: modules/${card.filename}\n`;
127
148
  else yaml += ` doc: modules/${moduleId}.md\n`;
128
- // 保留已有 _module-map.yaml 中的 paths 等字段(如果有的话)
129
149
  yaml += ` needs_review: false\n`;
130
150
  yaml += ` review_reasons: []\n`;
151
+ // context index 字段(v2)
152
+ if (role) yaml += ` role: "${escapeYamlString(role)}"\n`;
153
+ // core_files / test_files / entrypoints 从已有 _module-map 保留,或从卡片文件名推导
154
+ const existingPaths = existingModules[moduleId]?.paths || [];
155
+ if (existingPaths.length > 0) {
156
+ yaml += ` core_files:\n`;
157
+ for (const p of existingPaths) yaml += ` - ${p}\n`;
158
+ }
159
+ if (card) yaml += ` verify_commands: []\n`;
160
+ yaml += ` risk_level: low\n`;
161
+ if (contract || logic) yaml += ` related_docs: []\n`;
131
162
  yaml += `\n`;
132
163
  }
133
164
 
@@ -138,6 +169,7 @@ export async function rebuildModuleMap(cwd) {
138
169
  console.log(` - ${id}`);
139
170
  }
140
171
  console.log(`\n⚠️ 注意:rebuild 只重建骨架。tags/entrypoints/main_symbols/depends_on/used_by 需要重新运行 scan 或手动补充。`);
172
+ console.log(`ℹ️ schema 已升级到 v2,支持 role/core_files/test_files/entrypoints/depends_on/risk_level/verify_commands 等字段。`);
141
173
  }
142
174
 
143
175
  /**
@@ -163,13 +195,17 @@ export async function showModuleStatus(cwd) {
163
195
  const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/);
164
196
  if (moduleMatch) {
165
197
  if (currentModule) modules.push(currentModule);
166
- currentModule = { id: moduleMatch[1], hasTags: false, hasEntryPoints: false, hasDepends: false, needsReview: false };
198
+ currentModule = { id: moduleMatch[1], hasTags: false, hasEntryPoints: false, hasDepends: false, needsReview: false, hasRole: false, hasCoreFiles: false, hasRisk: false, hasVerify: false };
167
199
  }
168
200
  if (currentModule) {
169
201
  if (line.includes('tags:')) currentModule.hasTags = true;
170
202
  if (line.includes('entrypoints:')) currentModule.hasEntryPoints = true;
171
203
  if (line.includes('depends_on:')) currentModule.hasDepends = true;
172
204
  if (line.includes('needs_review: true')) { currentModule.needsReview = true; needsReview = true; }
205
+ if (line.includes('role:')) currentModule.hasRole = true;
206
+ if (line.includes('core_files:')) currentModule.hasCoreFiles = true;
207
+ if (line.includes('risk_level:')) currentModule.hasRisk = true;
208
+ if (line.includes('verify_commands:')) currentModule.hasVerify = true;
173
209
  }
174
210
  }
175
211
  if (currentModule) modules.push(currentModule);
@@ -178,14 +214,18 @@ export async function showModuleStatus(cwd) {
178
214
  console.log(`模块数量:${modules.length}\n`);
179
215
 
180
216
  const maxId = Math.max(5, ...modules.map(m => m.id.length));
181
- console.log(` ${'模块'.padEnd(maxId)} tags entry deps review`);
182
- console.log(` ${''.repeat(maxId)} ──── ───── ──── ──────`);
217
+ console.log(` ${'模块'.padEnd(maxId)} tags entry deps role core risk review`);
218
+ console.log(` ${''.padEnd(maxId, '─')} ──── ───── ──── ──── ──── ──── ──────`);
183
219
  for (const m of modules) {
184
220
  const tags = m.hasTags ? '✅' : '⬜';
185
221
  const entry = m.hasEntryPoints ? '✅' : '⬜';
186
222
  const deps = m.hasDepends ? '✅' : '⬜';
223
+ const role = m.hasRole ? '✅' : '⬜';
224
+ const core = m.hasCoreFiles ? '✅' : '⬜';
225
+ const risk = m.hasRisk ? '✅' : '⬜';
226
+ const verify = m.hasVerify ? '✅' : '⬜';
187
227
  const review = m.needsReview ? '⚠️' : '✅';
188
- console.log(` ${m.id.padEnd(maxId)} ${tags} ${entry} ${deps} ${review}`);
228
+ console.log(` ${m.id.padEnd(maxId)} ${tags} ${entry} ${deps} ${role} ${core} ${risk} ${verify} ${review}`);
189
229
  }
190
230
 
191
231
  if (needsReview) {
@@ -194,7 +234,22 @@ export async function showModuleStatus(cwd) {
194
234
  }
195
235
 
196
236
  /**
197
- * _module-map.yaml 聚合生成 dependencies.md
237
+ * 从模块卡片提取指定 section 的内容
238
+ */
239
+ function extractSection(content, sectionName) {
240
+ const match = content.match(new RegExp(`## ${escapeRegExp(sectionName)}\\n([\\s\\S]*?)(?=\\n##|$)`));
241
+ return match ? match[1].trim().split('\n')[0] : ''; // 只取第一行作为摘要
242
+ }
243
+
244
+ function escapeRegExp(s) {
245
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ }
247
+
248
+ function escapeYamlString(s) {
249
+ return s.replace(/"/g, '\\"').replace(/\n/g, ' ').slice(0, 200); // 截断避免过长
250
+ }
251
+
252
+ /**
198
253
  */
199
254
  export async function generateDependenciesMd(cwd) {
200
255
  const mapPath = findModuleMapPath(cwd);
package/src/run.js CHANGED
@@ -10,6 +10,24 @@ import { createRequire } from 'module'
10
10
  const require = createRequire(import.meta.url)
11
11
  import { ProgressManager } from './progress.js'
12
12
 
13
+ /**
14
+ * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
15
+ * 使用 -c safe.directory= 临时参数,不污染全局 git config。
16
+ * @param {string} cwd - 仓库根目录
17
+ * @param {string[]} args - git 子命令及参数,如 ['rev-parse', 'HEAD']
18
+ * @returns {{ value: string, error: string|null }}
19
+ */
20
+ function safeGit(cwd, args) {
21
+ const { execSync } = require('child_process')
22
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
23
+ try {
24
+ const value = execSync(['git', ...fullArgs].join(' '), { encoding: 'utf8', timeout: 5000 }).trim()
25
+ return { value, error: null }
26
+ } catch (e) {
27
+ return { value: null, error: e.message.split('\n')[0] }
28
+ }
29
+ }
30
+
13
31
  // ── Wait State Constants ──
14
32
  // 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
15
33
  const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
@@ -46,6 +64,160 @@ import { buildExecuteSteps } from './stages/execute.js'
46
64
  import { buildPlanSteps } from './stages/plan.js'
47
65
  import { formatExecuteSummary } from './worktree-apply.js'
48
66
 
67
+ /**
68
+ * 从 _module-map.yaml 读取模块上下文索引
69
+ * 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
70
+ *
71
+ * @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
72
+ * @param {string} projectName - 项目名
73
+ * @returns {object|null} 解析后的模块索引,null 表示无索引
74
+ */
75
+ function loadModuleContextIndex(specBase, projectName) {
76
+ try {
77
+ const { existsSync, readFileSync } = require('fs')
78
+ const { join } = require('path')
79
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
80
+ if (!existsSync(mapPath)) return null
81
+ const content = readFileSync(mapPath, 'utf8')
82
+ return parseModuleMapSimple(content)
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
90
+ * 匹配策略:模块 id / role / doc 路径中的关键词
91
+ *
92
+ * @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
93
+ * @param {object} moduleIndex - loadModuleContextIndex 返回值
94
+ * @param {string} specBase - 规范目录
95
+ * @param {string} projectName - 项目名
96
+ * @returns {string} 上下文注入文本,空字符串表示无匹配模块
97
+ */
98
+ const MAX_MODULE_CONTEXT_CHARS = 4096 // 上下文注入硬限制(字节),防止 prompt 膨胀
99
+ const MAX_MODULES_PER_INJECTION = 3 // 最多注入的模块数
100
+ const MAX_FILES_PER_MODULE = 8 // 每个模块最多展示的文件数
101
+
102
+ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
103
+ if (!moduleIndex || !taskDescription) return ''
104
+ const { existsSync } = require('fs')
105
+ const { join } = require('path')
106
+
107
+ const taskLower = taskDescription.toLowerCase()
108
+ const matched = []
109
+
110
+ for (const [moduleId, data] of Object.entries(moduleIndex)) {
111
+ let score = 0
112
+ let matchReasons = []
113
+ // 模块 id 匹配
114
+ if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
115
+ // role 描述匹配
116
+ if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
117
+ // core_files 路径匹配
118
+ const coreFiles = data.paths || data.core_files || []
119
+ for (const p of coreFiles) {
120
+ if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break } // 文件匹配只计一次
121
+ }
122
+ if (score > 0) matched.push({ moduleId, data, score, matchReasons })
123
+ }
124
+
125
+ if (matched.length === 0) return ''
126
+
127
+ matched.sort((a, b) => b.score - a.score)
128
+ const top = matched.slice(0, MAX_MODULES_PER_INJECTION)
129
+
130
+ let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
131
+ injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
132
+ injection += `> Matched modules: ${top.map(m => m.moduleId).join(', ')}\n`
133
+ injection += `> Reasons: ${top.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
134
+
135
+ for (const { moduleId, data } of top) {
136
+ injection += `#### ${moduleId}\n`
137
+ if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
138
+ const riskLevel = data.risk_level || 'medium'
139
+ injection += `- **风险等级**: ${riskLevel}\n`
140
+ const coreFiles = (data.paths || data.core_files || []).slice(0, MAX_FILES_PER_MODULE)
141
+ if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
142
+ if (data.doc) {
143
+ const docPath = join(specBase, 'docs', projectName, data.doc)
144
+ const exists = existsSync(docPath)
145
+ injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
146
+ }
147
+ const deps = data.depends_on || []
148
+ if (deps.length > 0) injection += `- **依赖**: ${deps.slice(0, 5).join(', ')}\n`
149
+ const usedBy = data.used_by || []
150
+ if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.slice(0, 5).join(', ')}\n`
151
+ injection += '\n'
152
+
153
+ // 长度硬限制:超过阈值截断
154
+ if (injection.length > MAX_MODULE_CONTEXT_CHARS) {
155
+ injection += `<!-- Module context truncated: exceeded ${MAX_MODULE_CONTEXT_CHARS} chars -->\n`
156
+ break
157
+ }
158
+ }
159
+
160
+ return injection
161
+ }
162
+
163
+ // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
164
+ function parseModuleMapSimple(content) {
165
+ const modules = {}
166
+ let currentModule = null
167
+ let currentKey = null
168
+ let currentArray = null
169
+
170
+ for (const line of content.split('\n')) {
171
+ const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
172
+ if (moduleMatch) {
173
+ if (currentArray && currentModule && currentKey) {
174
+ modules[currentModule][currentKey] = currentArray
175
+ }
176
+ currentModule = moduleMatch[1]
177
+ modules[currentModule] = {}
178
+ currentKey = null
179
+ currentArray = null
180
+ continue
181
+ }
182
+ if (!currentModule) continue
183
+
184
+ const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
185
+ if (arrayFieldMatch) {
186
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
187
+ currentKey = arrayFieldMatch[1]
188
+ currentArray = []
189
+ continue
190
+ }
191
+
192
+ const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
193
+ if (inlineArrayMatch) {
194
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
195
+ const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
196
+ modules[currentModule][inlineArrayMatch[1]] = vals
197
+ currentKey = null
198
+ currentArray = null
199
+ continue
200
+ }
201
+
202
+ const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
203
+ if (scalarMatch) {
204
+ if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
205
+ modules[currentModule][scalarMatch[1]] = scalarMatch[2]
206
+ continue
207
+ }
208
+
209
+ const itemMatch = line.match(/^ - (.+)$/)
210
+ if (itemMatch && currentArray !== null) {
211
+ currentArray.push(itemMatch[1].trim())
212
+ continue
213
+ }
214
+ }
215
+ if (currentArray && currentModule && currentKey) {
216
+ modules[currentModule][currentKey] = currentArray
217
+ }
218
+ return modules
219
+ }
220
+
49
221
  /**
50
222
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
51
223
  */
@@ -129,6 +301,21 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
129
301
  result.status = 'warning'
130
302
  }
131
303
 
304
+ // quicklog 存在性检查
305
+ try {
306
+ const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
307
+ if (existsSync(quicklogDir)) {
308
+ const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
309
+ if (qlFiles.length === 0) {
310
+ result.reasons.push('quicklog 目录为空(无任务记录)')
311
+ if (result.status === 'safe') result.status = 'warning'
312
+ }
313
+ } else {
314
+ result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
315
+ if (result.status === 'safe') result.status = 'warning'
316
+ }
317
+ } catch {}
318
+
132
319
  // --confirm 模式:展示 diff 并等待确认
133
320
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
134
321
  console.log(`\n📋 quick 变更概览:`)
@@ -351,7 +538,7 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
351
538
  if (promptText.includes('<git-user>')) {
352
539
  const { execSync } = await import('child_process')
353
540
  try {
354
- const gitUser = execSync('git config user.name', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
541
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
355
542
  promptText = promptText.replace(/<git-user>/g, gitUser)
356
543
  } catch {
357
544
  promptText = promptText.replace(/<git-user>/g, 'unknown')
@@ -377,9 +564,14 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
377
564
  const docsRoot = join(specSillyspec, 'docs', projectName)
378
565
  const projectsRoot = join(specSillyspec, 'projects')
379
566
  const changesRoot = join(specSillyspec, 'changes')
567
+ const workflowsRoot = join(specSillyspec, 'workflows')
568
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
380
569
 
381
570
  promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
382
571
  promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
572
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
573
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
574
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
383
575
 
384
576
  const platformDirectives = []
385
577
  platformDirectives.push(
@@ -389,6 +581,8 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
389
581
  `- 文档根目录: \`${docsRoot}/\`\n` +
390
582
  `- 项目注册表: \`${projectsRoot}/\`\n` +
391
583
  `- 变更目录: \`${changesRoot}/\`\n` +
584
+ `- 工作流目录: \`${workflowsRoot}/\`\n` +
585
+ `- 术语目录: \`${knowledgeRoot}/\`\n` +
392
586
  `\n` +
393
587
  `### ⛔ 写入规则\n` +
394
588
  `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
@@ -437,8 +631,38 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
437
631
  const specSillyspec = join(cwd, '.sillyspec')
438
632
  const docsRoot = join(specSillyspec, 'docs', projectName)
439
633
  const projectsRoot = join(specSillyspec, 'projects')
634
+ const workflowsRoot = join(specSillyspec, 'workflows')
635
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
440
636
  promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
441
637
  promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
638
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
639
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
640
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
641
+ }
642
+ }
643
+
644
+ // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
645
+ if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
646
+ const moduleIndex = loadModuleContextIndex(specBase || join(cwd, '.sillyspec'), projectName)
647
+ if (moduleIndex && Object.keys(moduleIndex).length > 0) {
648
+ // 尝试从 step prompt / changeName 匹配模块
649
+ const taskDesc = step.prompt || changeName || ''
650
+ const injection = buildModuleContextInjection(taskDesc, moduleIndex, specBase || join(cwd, '.sillyspec'), projectName)
651
+ if (injection) {
652
+ promptText = injection + '\n' + promptText
653
+ }
654
+ }
655
+ }
656
+
657
+ // 平台模式 prompt 自检:确保没有裸相对输出路径
658
+ // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
659
+ if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
660
+ const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
661
+ if (writeCtx.test(promptText)) {
662
+ console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
663
+ console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
664
+ console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
665
+ process.exit(1)
442
666
  }
443
667
  }
444
668
 
@@ -651,7 +875,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
651
875
  const manifestDir = platformOpts.specRoot
652
876
  let sourceCommit = null
653
877
  try {
654
- sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
878
+ const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
655
879
  } catch {}
656
880
  mkdirSync(manifestDir, { recursive: true })
657
881
  const manifest = {
@@ -665,6 +889,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
665
889
  workspace_id: platformOpts.workspaceId || null,
666
890
  scan_run_id: platformOpts.scanRunId || null,
667
891
  source_commit: sourceCommit,
892
+ source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
668
893
  generated_at: new Date().toISOString(),
669
894
  schema_version: 2,
670
895
  }
@@ -1074,7 +1299,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1074
1299
  const allowNew = quickOpts?.isAllowNew || false
1075
1300
  const forceBaseline = quickOpts?.isForceBaseline || false
1076
1301
  progress.quickGuard = {
1077
- baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
1302
+ baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
1078
1303
  baselineFiles,
1079
1304
  allowedFiles,
1080
1305
  allowNew,
@@ -1668,12 +1893,13 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1668
1893
  mkdirSync(manifestDir, { recursive: true })
1669
1894
  let sourceCommit = null
1670
1895
  try {
1671
- sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
1896
+ const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
1672
1897
  } catch {}
1673
1898
  const manifest = {
1674
1899
  workspace_id: platformOpts.workspaceId || null,
1675
1900
  scan_run_id: platformOpts.scanRunId || null,
1676
1901
  source_commit: sourceCommit,
1902
+ source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
1677
1903
  generated_at: new Date().toISOString(),
1678
1904
  schema_version: 1,
1679
1905
  }
@@ -1690,7 +1916,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1690
1916
  try { unlinkSync(platformOptsFile) } catch {}
1691
1917
 
1692
1918
  // CLI 层 post-check(替代旧的简单检查)
1693
- const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1919
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
1694
1920
  const postResult = runScanPostCheck({
1695
1921
  cwd,
1696
1922
  specDir: platformOpts.specRoot,
@@ -1702,6 +1928,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1702
1928
  })
1703
1929
  printScanPostCheckResult(postResult)
1704
1930
 
1931
+ // 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
1932
+ const structured = formatStructuredResult(postResult, {
1933
+ workspace_id: platformOpts.workspaceId,
1934
+ scan_run_id: platformOpts.scanRunId,
1935
+ source_root: cwd,
1936
+ spec_root: platformOpts.specRoot,
1937
+ runtime_root: platformOpts.runtimeRoot,
1938
+ })
1939
+ const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
1940
+ runtimeRoot: platformOpts.runtimeRoot,
1941
+ scanRunId: platformOpts.scanRunId,
1942
+ })
1943
+ if (postcheckJsonPath) {
1944
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
1945
+ }
1946
+
1705
1947
  // 将 post-check 结果写入 manifest
1706
1948
  manifest.scan_post_check = {
1707
1949
  status: postResult.status,
@@ -1729,11 +1971,17 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1729
1971
  }
1730
1972
  }
1731
1973
 
1732
- // 非 platform 模式 scan 也做轻量 post-check
1974
+ // 非 platform 模式 scan 也做轻量 post-check + 结构化输出
1733
1975
  if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
1734
- const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1976
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
1735
1977
  const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
1736
1978
  printScanPostCheckResult(postResult)
1979
+ // 结构化结果写入 .sillyspec/.runtime/
1980
+ const structured = formatStructuredResult(postResult, { source_root: cwd })
1981
+ const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
1982
+ if (postcheckJsonPath) {
1983
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
1984
+ }
1737
1985
  }
1738
1986
 
1739
1987
  validateMetadata(cwd, stageName, specBase)
@@ -5,7 +5,7 @@
5
5
  * 平台模式下必须通过所有 check 才能 success,否则降级。
6
6
  */
7
7
 
8
- import { existsSync, readdirSync, readFileSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
 
11
11
  const REQUIRED_SCAN_DOCS = [
@@ -52,19 +52,33 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
52
52
 
53
53
  const projectName = basename(cwd)
54
54
 
55
- // 1. source_root 污染检查
56
- const localDocsDir = join(cwd, '.sillyspec', 'docs')
57
- if (existsSync(localDocsDir)) {
58
- try {
59
- const leaked = readdirSync(localDocsDir, { recursive: true }).filter(e => String(e).endsWith('.md'))
60
- if (leaked.length > 0) {
61
- checks.push({
62
- name: 'source_root_docs_leak',
63
- severity: 'failed',
64
- detail: `source_root 下存在 ${leaked.length} 个文档文件(${localDocsDir}/),agent 可能写入到了错误路径`
65
- })
66
- }
67
- } catch {}
55
+ // 1. source_root 污染检查(docs/projects/workflows/knowledge/manifest/local)
56
+ const pollutePaths = ['docs', 'projects', 'workflows', 'knowledge']
57
+ const polluteFiles = ['manifest.json', 'local.yaml']
58
+ for (const sub of pollutePaths) {
59
+ const localSub = join(cwd, '.sillyspec', sub)
60
+ if (existsSync(localSub)) {
61
+ try {
62
+ const leaked = readdirSync(localSub, { recursive: true }).filter(e => String(e).endsWith('.md') || String(e).endsWith('.yaml') || String(e).endsWith('.json'))
63
+ if (leaked.length > 0) {
64
+ checks.push({
65
+ name: 'source_root_leak',
66
+ severity: 'failed',
67
+ detail: `source_root/.sillyspec/${sub}/ 下存在 ${leaked.length} 个文件(${localSub}/),agent 写入到了错误路径`
68
+ })
69
+ }
70
+ } catch {}
71
+ }
72
+ }
73
+ for (const file of polluteFiles) {
74
+ const filePath = join(cwd, '.sillyspec', file)
75
+ if (existsSync(filePath)) {
76
+ checks.push({
77
+ name: 'source_root_leak',
78
+ severity: 'failed',
79
+ detail: `source_root/.sillyspec/${file} 存在,agent 写入到了错误路径(${filePath})`
80
+ })
81
+ }
68
82
  }
69
83
 
70
84
  // 2. spec_root 检查 7 份必需文档
@@ -184,6 +198,138 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
184
198
  return { status, checks }
185
199
  }
186
200
 
201
+ /**
202
+ * 将 postcheck 结果转换为结构化 JSON(SillyHub 可消费格式)
203
+ *
204
+ * failure_category 标准化:
205
+ * - warning : 非致命问题,不阻塞流程
206
+ * - error : 文档缺失/内容不完整,需要修复
207
+ * - critical : 安全问题(source_root 泄漏/路径污染)
208
+ *
209
+ * 结构化字段:
210
+ * - violations : 明确违反约束的条目(source_root 泄漏等)
211
+ * - missing_outputs : 预期文件不存在
212
+ * - path_pollution : 产物写入了错误路径
213
+ * - bad_references : 引用了不存在的命令/资源
214
+ * - quality_warnings: AI 输出中包含错误标记等质量信号
215
+ *
216
+ * @param {object} result - runScanPostCheck 返回值
217
+ * @param {object} [meta] - 附带元数据(workspace_id, scan_run_id, timestamp 等)
218
+ * @returns {object} 结构化 JSON
219
+ */
220
+ export function formatStructuredResult(result, meta = {}) {
221
+ const structured = {
222
+ schema_version: 1,
223
+ generated_at: new Date().toISOString(),
224
+ overall_status: result.status,
225
+ // 路径溯源(供平台消费)
226
+ ...(meta.workspace_id ? { workspace_id: meta.workspace_id } : {}),
227
+ ...(meta.scan_run_id ? { scan_run_id: meta.scan_run_id } : {}),
228
+ ...(meta.source_root ? { source_root: meta.source_root } : {}),
229
+ ...(meta.spec_root ? { spec_root: meta.spec_root } : {}),
230
+ ...(meta.runtime_root ? { runtime_root: meta.runtime_root } : {}),
231
+ summary: {
232
+ total_checks: result.checks.length,
233
+ critical: 0,
234
+ error: 0,
235
+ warning: 0,
236
+ },
237
+ failure_categories: {
238
+ violations: [],
239
+ missing_outputs: [],
240
+ path_pollution: [],
241
+ bad_references: [],
242
+ quality_warnings: [],
243
+ },
244
+ checks: result.checks.map(c => ({
245
+ name: c.name,
246
+ severity: c.severity === 'failed' ? 'critical' : c.severity,
247
+ detail: c.detail,
248
+ })),
249
+ }
250
+
251
+ // 分类到 failure_categories
252
+ for (const check of result.checks) {
253
+ const severity = check.severity === 'failed' ? 'critical' : check.severity
254
+ const entry = { name: check.name, detail: check.detail, severity }
255
+
256
+ // 路径污染类
257
+ if (check.name === 'source_root_leak') {
258
+ structured.failure_categories.path_pollution.push(entry)
259
+ structured.failure_categories.violations.push(entry)
260
+ }
261
+ // 文档缺失类
262
+ else if (check.name === 'all_docs_missing' || check.name === 'partial_docs_missing' || check.name === 'missing_docs') {
263
+ structured.failure_categories.missing_outputs.push(entry)
264
+ }
265
+ // 引用无效类
266
+ else if (check.name === 'local_config_invalid') {
267
+ structured.failure_categories.bad_references.push(entry)
268
+ }
269
+ // AI 输出质量类
270
+ else if (['tool_use_error', 'api_error_529', 'rate_limit_exhausted', 'fallback_or_skip'].includes(check.name)) {
271
+ structured.failure_categories.quality_warnings.push(entry)
272
+ }
273
+ // manifest/project 列表问题
274
+ else if (check.name === 'manifest_write_failed' || check.name === 'project_list_parse_failed') {
275
+ structured.failure_categories.violations.push(entry)
276
+ }
277
+ // 文档缺少 header
278
+ else if (check.name === 'docs_missing_header') {
279
+ structured.failure_categories.quality_warnings.push(entry)
280
+ }
281
+ // 兜底:归入 violations
282
+ else {
283
+ structured.failure_categories.violations.push(entry)
284
+ }
285
+ }
286
+
287
+ // 汇总计数
288
+ for (const check of result.checks) {
289
+ if (check.severity === 'failed') structured.summary.critical++
290
+ else structured.summary.warning++
291
+ }
292
+
293
+ return structured
294
+ }
295
+
296
+ /**
297
+ * 将结构化结果写入 JSON 文件(平台模式供 SillyHub 消费)
298
+ *
299
+ * 本地模式:写入 specDir/.runtime/postcheck-result.json
300
+ * 平台模式:写入 runtimeRoot/scan-runs/{scan_run_id}/postcheck-result.json
301
+ *
302
+ * @param {object} structured - formatStructuredResult 返回值
303
+ * @param {string} specDir - 规范目录(本地模式使用)
304
+ * @param {object} [opts] - 平台模式选项
305
+ * @param {string} [opts.runtimeRoot] - 平台模式运行时根目录
306
+ * @param {string} [opts.scanRunId] - scan run ID
307
+ * @returns {string|null} 写入的文件路径,失败时返回 null
308
+ */
309
+ export function writeStructuredResult(structured, specDir, opts = {}) {
310
+ if (!specDir && !opts.runtimeRoot) return null
311
+ try {
312
+ let outPath
313
+ if (opts.runtimeRoot && opts.scanRunId) {
314
+ const scanRunDir = join(opts.runtimeRoot, 'scan-runs', opts.scanRunId)
315
+ mkdirSync(scanRunDir, { recursive: true })
316
+ outPath = join(scanRunDir, 'postcheck-result.json')
317
+ } else if (specDir) {
318
+ const runtimeDir = join(specDir, '.runtime')
319
+ mkdirSync(runtimeDir, { recursive: true })
320
+ outPath = join(runtimeDir, 'postcheck-result.json')
321
+ } else {
322
+ return null
323
+ }
324
+
325
+ writeFileSync(outPath, JSON.stringify(structured, null, 2) + '\n')
326
+ return outPath
327
+ } catch (e) {
328
+ console.warn(` ⚠️ postcheck-result.json 写入失败: ${e.message}`)
329
+ return null
330
+ }
331
+ }
332
+
187
333
  /**
188
334
  * 打印 post-check 结果到 stdout
189
335
  */
@@ -22,8 +22,8 @@ export const definition = {
22
22
  9. 根据任务描述初步判断可能涉及的模块
23
23
  10. 读取匹配到的 \`.sillyspec/docs/<project>/modules/<module>.md\`
24
24
 
25
- ### 创建任务记录(必须执行)
26
- 理解完任务后,立即创建记录文件:
25
+ ### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
26
+ 理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
27
27
  1. 使用预注入的 git 用户名:\`<git-user>\`
28
28
  2. 无 \`--change\`:创建 .sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`(已存在则追加),写入:
29
29
  \`\`\`
@@ -41,7 +41,9 @@ export const definition = {
41
41
  这样 Gate 检测到 .sillyspec/\` 下有变更,就不会拦截后续的代码修改。
42
42
 
43
43
  ### 输出
44
- 任务理解 + 上下文摘要 + quicklog 已创建`,
44
+ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
45
+
46
+ ⚠️ **先创建 quicklog,再输出任务理解。** 如果 quicklog 未创建,CLI post-check 会报 warning。`,
45
47
  outputHint: '任务理解',
46
48
  optional: false
47
49
  },
@@ -103,13 +103,13 @@ export const definition = {
103
103
  {
104
104
  name: '深度扫描 — 7 份文档(子代理并行)',
105
105
  perProject: true,
106
- prompt: `按照 \`.sillyspec/workflows/scan-docs.yaml\` 中定义的角色和检查规则,使用子代理并行生成当前项目的 7 份扫描文档。
106
+ prompt: `按照 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 中定义的角色和检查规则,使用子代理并行生成当前项目的 7 份扫描文档。
107
107
 
108
108
  **你必须使用子代理执行,不要自己写文档。**
109
109
  **对扫描列表中的每个项目分别执行以下流程。**
110
110
 
111
111
  ### 操作
112
- 1. 读取 \`.sillyspec/workflows/scan-docs.yaml\`,了解角色定义、输出要求和检查规则
112
+ 1. 读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\`,了解角色定义、输出要求和检查规则
113
113
  2. 对每个项目(扫描列表中标记为需生成/覆盖的项目):
114
114
  a. 将 \`<project>\` 替换为实际项目名,得到该项目的目标文件路径
115
115
  b. 为每个角色启动独立子代理(可并行),每个子代理负责 1-2 份文档
@@ -141,16 +141,16 @@ export const definition = {
141
141
  },
142
142
  {
143
143
  name: '生成本地配置',
144
- prompt: `自动生成 .sillyspec/local.yaml 本地配置文件。
144
+ prompt: `自动生成 local.yaml 本地配置文件。
145
145
 
146
146
  ### 操作
147
- 1. 检查 .sillyspec/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
147
+ 1. 检查 {SPEC_ROOT}/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
148
148
  2. 根据项目类型生成默认配置:
149
149
  - **Node.js**(有 package.json):build: "npm run build", test: "npm test", lint: "npm run lint", type: nodejs
150
150
  - **Maven**(有 pom.xml):build: "mvn compile", test: "mvn test", lint: "mvn checkstyle:check", type: maven
151
151
  - **Gradle**(有 build.gradle):build: "./gradlew build", test: "./gradlew test", type: gradle
152
152
  - **通用项目**:只写注释模板, type: generic
153
- 3. 确保目录存在:mkdir -p .sillyspec
153
+ 3. 确保目录存在:mkdir -p {SPEC_ROOT}
154
154
  4. 原子写入(先写 tmp 文件再 rename)
155
155
 
156
156
  ### 文件格式
@@ -454,7 +454,7 @@ step1 → step2 → step3
454
454
  3. 自检门控:ARCHITECTURE(技术栈+Schema摘要)、CONVENTIONS(隐形规则+代码风格)、STRUCTURE(目录结构)、INTEGRATIONS(外部依赖)、TESTING(测试现状)、CONCERNS(技术债务)、PROJECT(项目概览)
455
455
  4. 检查 flows/ 和 glossary.md 是否已生成(如有)
456
456
  5. 清理:\`rm -f {DOCS_ROOT}/scan/_env-detect.md\`
457
- 6. \`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)
457
+ 6. 如果非平台模式:\`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)。如果平台模式:跳过 git add(specRoot 不在 sourceRoot 的 git repo 内)。
458
458
 
459
459
  ### ⛔ 路径合规检查(平台模式下必须执行)
460
460
  7. 确认所有文档都写入 \`{DOCS_ROOT}/\`(spec-root 下),**而非源码目录下的 .sillyspec/**
@@ -0,0 +1,172 @@
1
+ /**
2
+ * P0 补丁验证 — 4 类测试
3
+ *
4
+ * 1. 平台 scan prompt 不含正向写入 .sillyspec/ 指令
5
+ * 2. 安全说明文字不被 prompt 自检误杀
6
+ * 3. 平台模式不执行 git add {SPEC_ROOT}
7
+ * 4. source_root 污染检查覆盖所有子目录/文件
8
+ * 5. run.js 占位符替换补齐(平台+本地)
9
+ *
10
+ * 跑法: node test/platform-scan-p0.test.mjs
11
+ */
12
+
13
+ import { join, basename } from 'path'
14
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readdirSync } from 'fs'
15
+ import { execSync } from 'child_process'
16
+ import { readFile } from 'fs/promises'
17
+ import { fileURLToPath } from 'url'
18
+
19
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
20
+ const passed = []
21
+ const failed = []
22
+
23
+ function assert(label, condition, detail) {
24
+ if (condition) {
25
+ passed.push(label)
26
+ } else {
27
+ failed.push({ label, detail })
28
+ }
29
+ }
30
+
31
+ // ── 测试 1:scan.js 模板不含裸 .sillyspec 输出路径 ──
32
+ {
33
+ const { definition } = await import('../src/stages/scan.js')
34
+ const prompts = definition.steps.map(s => s.prompt).join('\n')
35
+
36
+ // 所有 .sillyspec 出现都应该是「禁止说明」,不是写入指令
37
+ const writePatterns = [
38
+ /write.*\.sillyspec\/docs/i,
39
+ /save.*\.sillyspec\/docs/i,
40
+ /create.*\.sillyspec\/docs/i,
41
+ /mkdir.*\.sillyspec\/docs/i,
42
+ ]
43
+ for (const p of writePatterns) {
44
+ assert(`scan 模板无写入 .sillyspec/docs: ${p}`, !p.test(prompts),
45
+ p.test(prompts) ? `命中: ${prompts.match(p)[0]}` : '')
46
+ }
47
+
48
+ // 应该使用占位符
49
+ assert('scan 模板使用 {WORKFLOWS_ROOT}', prompts.includes('{WORKFLOWS_ROOT}'))
50
+ assert('scan 模板使用 {SPEC_ROOT}', prompts.includes('{SPEC_ROOT}'))
51
+ assert('scan 模板使用 {DOCS_ROOT}', prompts.includes('{DOCS_ROOT}'))
52
+
53
+ // 平台模式下 git add 应该是条件判断
54
+ assert('scan 模板平台模式跳过 git add', prompts.includes('如果平台模式:跳过 git add'))
55
+ assert('scan 模板非平台模式 git add .sillyspec/', prompts.includes('git add .sillyspec/'))
56
+ }
57
+
58
+ // ── 测试 2:prompt 自检不误杀安全说明 ──
59
+ {
60
+ // 导入 run.js 中的正则
61
+ const writeCtxRe = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
62
+
63
+ // 安全说明 — 不应命中
64
+ const safeLines = [
65
+ '严禁写入源码目录或相对路径 `.sillyspec/`',
66
+ '⚠️ 不要写入 .sillyspec/docs',
67
+ 'source_root 下存在 .sillyspec/docs 文件',
68
+ '不允许从 cwd 推导 .sillyspec 路径',
69
+ ]
70
+ for (const line of safeLines) {
71
+ assert(`安全说明不误杀: "${line.slice(0, 40)}"`, !writeCtxRe.test(line),
72
+ '误杀! 命中了安全说明文字')
73
+ }
74
+
75
+ // 写入指令 — 应该命中
76
+ const badLines = [
77
+ '写入 `.sillyspec/docs/ARCHITECTURE.md`',
78
+ 'save to `.sillyspec/docs/ARCHITECTURE.md`',
79
+ 'create `.sillyspec/docs/ARCH.md`',
80
+ 'git add .sillyspec/docs/',
81
+ 'write 到 .sillyspec/docs/',
82
+ ]
83
+ for (const line of badLines) {
84
+ assert(`写入指令应命中: "${line.slice(0, 40)}"`, writeCtxRe.test(line),
85
+ '漏杀! 没有捕获写入指令')
86
+ }
87
+ }
88
+
89
+ // ── 测试 3:safeGit 使用 -c safe.directory(不污染全局 config) ──
90
+ {
91
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
92
+
93
+ assert('safeGit 不含 --global', !runSrc.includes('git config --global'),
94
+ '发现 --global,会污染容器 git config')
95
+
96
+ assert('safeGit 使用 -c safe.directory', runSrc.includes("safe.directory=${cwd}"),
97
+ '未发现 -c safe.directory per-command 参数')
98
+
99
+ assert('safeGit 使用 -C cwd', runSrc.includes("-C', cwd"),
100
+ '未发现 -C cwd 参数')
101
+
102
+ assert('safeGit 返回 { value, error }', runSrc.includes("return { value, error: "),
103
+ 'safeGit 返回值不是 { value, error } 结构')
104
+
105
+ assert('manifest 包含 source_commit_error 字段',
106
+ runSrc.includes("source_commit_error:"), 'manifest 缺少 source_commit_error')
107
+ }
108
+
109
+ // ── 测试 4:postcheck 污染检查覆盖所有子目录 ──
110
+ {
111
+ const postcheckSrc = await readFile(join(__dirname, '..', 'src', 'scan-postcheck.js'), 'utf8')
112
+
113
+ const requiredSubs = ['docs', 'projects', 'workflows', 'knowledge']
114
+ for (const sub of requiredSubs) {
115
+ assert(`postcheck 检查 ${sub} 污染`,
116
+ postcheckSrc.includes(`'${sub}'`) && postcheckSrc.includes("'.sillyspec', sub)"),
117
+ `postcheck 未检查 .sillyspec/${sub}/ 污染`)
118
+ }
119
+
120
+ assert('postcheck 检查 manifest.json', postcheckSrc.includes('manifest.json'))
121
+ assert('postcheck 检查 local.yaml', postcheckSrc.includes('local.yaml'))
122
+ assert('污染 severity 为 failed', postcheckSrc.includes("severity: 'failed'"))
123
+ }
124
+
125
+ // ── 测试 5:run.js 占位符替换补齐 ──
126
+ {
127
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
128
+
129
+ // 平台模式块
130
+ const platformMarker = 'promptText.replace(/\\{WORKFLOWS_ROOT\\}'
131
+ assert('平台模式替换 {WORKFLOWS_ROOT}', runSrc.includes('{WORKFLOWS_ROOT}') && runSrc.includes('workflowsRoot'))
132
+ assert('平台模式替换 {KNOWLEDGE_ROOT}', runSrc.includes('{KNOWLEDGE_ROOT}') && runSrc.includes('knowledgeRoot'))
133
+ assert('平台模式替换 {SPEC_ROOT}', runSrc.includes('{SPEC_ROOT}') && runSrc.includes("specSillyspec"))
134
+
135
+ // 非平台模式块
136
+ assert('非平台模式替换 {WORKFLOWS_ROOT}', runSrc.includes('workflowsRoot'))
137
+ assert('非平台模式替换 {KNOWLEDGE_ROOT}', runSrc.includes('knowledgeRoot'))
138
+ assert('非平台模式替换 {SPEC_ROOT}', runSrc.includes('{SPEC_ROOT}'))
139
+ }
140
+
141
+ // ── 测试 6:quick step 1 prompt 强制要求 quicklog ──
142
+ {
143
+ const { definition } = await import('../src/stages/quick.js')
144
+ const step1Prompt = definition.steps[0].prompt
145
+
146
+ assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
147
+ assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
148
+ assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
149
+ assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
150
+
151
+ // run.js 审计包含 quicklog 检查
152
+ const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
153
+ assert('quick 审计检查 quicklog 目录存在', runSrc.includes('quicklog 目录不存在'))
154
+ assert('quick 审计检查 quicklog 为空', runSrc.includes('quicklog 目录为空'))
155
+ }
156
+
157
+ // ── 结果 ──
158
+ console.log(`\n${'='.repeat(50)}`)
159
+ console.log(`✅ 通过: ${passed.length}`)
160
+ console.log(`❌ 失败: ${failed.length}`)
161
+ console.log(`${'='.repeat(50)}`)
162
+
163
+ if (failed.length > 0) {
164
+ console.log('\n失败详情:')
165
+ for (const f of failed) {
166
+ console.log(` ❌ ${f.label}`)
167
+ if (f.detail) console.log(` ${f.detail}`)
168
+ }
169
+ process.exit(1)
170
+ } else {
171
+ console.log('\n🎉 全部 P0 测试通过!')
172
+ }