sillyspec 3.28.11 → 3.28.13

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.
@@ -22,6 +22,7 @@ import { resolveRuntimeRoot } from './run/shared.js';
22
22
  import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
23
23
  import { validateTaskReviews, resolveLatestExecuteRunId } from './task-review.js';
24
24
  import { runVerifyTestCheck } from './verify-postcheck.js';
25
+ import { checkCode } from './diagnostic-codes.js';
25
26
 
26
27
  // ============ 退出码常量(D-004@v1)============
27
28
 
@@ -52,6 +53,9 @@ export const SCHEMA_VERSION = 1;
52
53
  * @param {string[]} [fields.warnings=[]]
53
54
  * @param {Array<object>|undefined} [fields.checks]
54
55
  * @param {object|undefined} [fields.data]
56
+ * @param {string[]|undefined} [fields.codes] - 诊断码聚合(2026-09-17-mi-diagnostic-codes
57
+ * 加法式:失败 check 的 code 按出现序去重;信封级错误路径单码。可选键遵循 optional-once:
58
+ * !== undefined 才挂。errors 仍 string[],中文 message 不变,schema_version 仍 1。
55
59
  * @returns {object} 可直接 JSON.stringify 的 envelope 对象
56
60
  */
57
61
  export function buildEnvelope({
@@ -64,6 +68,7 @@ export function buildEnvelope({
64
68
  warnings = [],
65
69
  checks,
66
70
  data,
71
+ codes,
67
72
  }) {
68
73
  const envelope = {
69
74
  schema_version: SCHEMA_VERSION,
@@ -80,6 +85,7 @@ export function buildEnvelope({
80
85
  if (facet !== undefined) envelope.facet = facet;
81
86
  if (checks !== undefined) envelope.checks = checks;
82
87
  if (data !== undefined) envelope.data = data;
88
+ if (codes !== undefined) envelope.codes = codes;
83
89
 
84
90
  return envelope;
85
91
  }
@@ -130,6 +136,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
130
136
  change: changeName,
131
137
  ok: false,
132
138
  errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 gate 不会为其建库`],
139
+ codes: ['db_missing'],
133
140
  });
134
141
  return { envelope, exitCode: EXIT_UNKNOWN };
135
142
  }
@@ -143,6 +150,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
143
150
  change: changeName,
144
151
  ok: false,
145
152
  errors: [`变更不存在: ${changeName}`],
153
+ codes: ['change_not_found'],
146
154
  });
147
155
  return { envelope, exitCode: EXIT_UNKNOWN };
148
156
  }
@@ -163,6 +171,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
163
171
  });
164
172
  checks.push({
165
173
  id: 'artifacts',
174
+ code: checkCode('artifacts'),
166
175
  ok: r.ok,
167
176
  errors: r.errors || [],
168
177
  warnings: r.warnings || [],
@@ -178,6 +187,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
178
187
  const dfl = validateDesignFileList({ changeDir: join(specRoot, 'changes', changeName), cwd });
179
188
  checks.push({
180
189
  id: 'design-file-list',
190
+ code: checkCode('design-file-list'),
181
191
  ok: (dfl.errors || []).length === 0,
182
192
  // errors 元素是 {path, message} 对象(与 complete.js --done 打印侧同源),gate check 面拍平为 message 字符串
183
193
  errors: (dfl.errors || []).map(e => e && e.message ? e.message : String(e)),
@@ -187,6 +197,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
187
197
  // fail-open 与 complete.js 同口径:核验自身异常不误拦,warning 留痕
188
198
  checks.push({
189
199
  id: 'design-file-list',
200
+ code: checkCode('design-file-list'),
190
201
  ok: true,
191
202
  errors: [],
192
203
  warnings: [`design 清单核验自身异常,跳过(${e && e.message ? e.message : e})`],
@@ -202,6 +213,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
202
213
  const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
203
214
  checks.push({
204
215
  id: 'transition',
216
+ code: checkCode('transition'),
205
217
  ok: t.allowed,
206
218
  errors: t.allowed ? [] : [t.reason].filter(Boolean),
207
219
  warnings: [],
@@ -214,6 +226,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
214
226
  const ev = sharedEvidence;
215
227
  checks.push({
216
228
  id: 'execute-evidence',
229
+ code: checkCode('execute-evidence'),
217
230
  ok: ev.status !== 'unchanged',
218
231
  errors: ev.status === 'unchanged' ? [`base..head 无代码变更: ${ev.detail}`] : [],
219
232
  warnings: ev.status === 'unknown' ? [`无法判定代码变更: ${ev.detail}`] : [],
@@ -267,6 +280,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
267
280
  });
268
281
  checks.push({
269
282
  id: 'task-reviews',
283
+ code: checkCode('task-reviews'),
270
284
  ok: tr.ok,
271
285
  errors: tr.errors || [],
272
286
  warnings: tr.warnings || [],
@@ -291,6 +305,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
291
305
  }
292
306
  checks.push({
293
307
  id: 'verify-test',
308
+ code: checkCode('verify-test'),
294
309
  ok: vt.status !== 'failed',
295
310
  errors: vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [],
296
311
  warnings: vtWarnings,
@@ -309,6 +324,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
309
324
  const ok = checks.filter((c) => !c.informational).every((c) => c.ok);
310
325
  const exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
311
326
 
327
+ // 诊断码聚合(加法式):失败 check 的 code 按出现序(push 序)去重;成功为空数组。
328
+ // 非 1:1:多条 errors 可共享一个 check 级码,逐条归因请读 checks[].code。
329
+ const codes = [...new Set(checks.filter((c) => !c.ok && c.code).map((c) => c.code))];
330
+
312
331
  const errors = [];
313
332
  const warnings = [];
314
333
  for (const c of checks) {
@@ -324,6 +343,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
324
343
  errors,
325
344
  warnings,
326
345
  checks,
346
+ codes,
327
347
  });
328
348
 
329
349
  return { envelope, exitCode };
@@ -335,6 +355,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
335
355
  change: changeName,
336
356
  ok: false,
337
357
  errors: [`internal: ${e.message}`],
358
+ codes: ['internal_error'],
338
359
  });
339
360
  return { envelope, exitCode: EXIT_UNKNOWN };
340
361
  }
@@ -379,6 +400,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
379
400
  change: changeName,
380
401
  ok: false,
381
402
  errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
403
+ codes: ['unknown_facet'],
382
404
  });
383
405
  return { envelope, exitCode: EXIT_UNKNOWN };
384
406
  }
@@ -397,6 +419,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
397
419
  change: changeName,
398
420
  ok: false,
399
421
  errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 derive 不会为其建库`],
422
+ codes: ['db_missing'],
400
423
  });
401
424
  return { envelope, exitCode: EXIT_UNKNOWN };
402
425
  }
@@ -410,6 +433,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
410
433
  change: changeName,
411
434
  ok: false,
412
435
  errors: [`变更不存在: ${changeName}`],
436
+ codes: ['change_not_found'],
413
437
  });
414
438
  return { envelope, exitCode: EXIT_UNKNOWN };
415
439
  }
@@ -537,12 +561,20 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
537
561
  change: changeName,
538
562
  ok: false,
539
563
  errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
564
+ codes: ['unknown_facet'],
540
565
  });
541
566
  return { envelope, exitCode: EXIT_UNKNOWN };
542
567
  }
543
568
  }
544
569
 
545
570
  // stage 仅 artifacts 时出现(产物校验绑定阶段语义);其余 facet 不传 stage。
571
+ // 诊断码(加法式):facet 失败面单码(execute-test 类 facet 失败即对应码),成功为空数组。
572
+ const FACET_FAILURE_CODE = {
573
+ 'execute-evidence': 'execute_evidence_unchanged',
574
+ 'verify-test': 'verify_test_failed',
575
+ 'task-reviews': 'task_reviews_invalid',
576
+ artifacts: 'artifacts_invalid',
577
+ };
546
578
  const envelope = buildEnvelope({
547
579
  command: 'derive',
548
580
  facet,
@@ -551,6 +583,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
551
583
  errors,
552
584
  warnings,
553
585
  data,
586
+ codes: ok ? [] : [FACET_FAILURE_CODE[facet]].filter(Boolean),
554
587
  stage: facet === 'artifacts' ? currentStage : undefined,
555
588
  });
556
589
 
@@ -563,6 +596,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
563
596
  change: changeName,
564
597
  ok: false,
565
598
  errors: [`internal: ${e.message}`],
599
+ codes: ['internal_error'],
566
600
  });
567
601
  return { envelope, exitCode: EXIT_UNKNOWN };
568
602
  }
@@ -597,6 +631,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
597
631
  command: 'progress show',
598
632
  ok: false,
599
633
  errors: [`无法核验:进度库不存在(${dbPath})——项目尚未初始化进度,只读总览不会为其建库`],
634
+ codes: ['db_missing'],
600
635
  });
601
636
  return { envelope, exitCode: EXIT_UNKNOWN };
602
637
  }
@@ -618,6 +653,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
618
653
  ok: true,
619
654
  warnings,
620
655
  data,
656
+ codes: [],
621
657
  });
622
658
  return { envelope, exitCode: EXIT_OK };
623
659
  } catch (e) {
@@ -626,6 +662,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
626
662
  command: 'progress show',
627
663
  ok: false,
628
664
  errors: [`internal: ${e.message}`],
665
+ codes: ['internal_error'],
629
666
  });
630
667
  return { envelope, exitCode: EXIT_UNKNOWN };
631
668
  }
@@ -45,4 +45,33 @@ export async function executeArchiveDistill({ cwd, specBase, changeName }) {
45
45
  } catch (e) {
46
46
  console.warn(`\n⚠️ 决策提炼执行异常,降级跳过(best-effort,不阻断归档——同旧步降级语义): ${e && e.message ? e.message : e}`)
47
47
  }
48
+
49
+ // ── FR 索引提炼(2026-09-18-fr-index-l1 L1:稳定 id 发号+承接翻链;同 best-effort 降级语义)──
50
+ try {
51
+ const { indexRequirements } = await import('../fr-index.js')
52
+ const fr = indexRequirements({ changeDir, knowledgeRoot, headHash })
53
+ const frWritten = Array.isArray(fr && fr.written) ? fr.written : []
54
+ if (fr && fr.skipped) {
55
+ console.log(`\nℹ️ FR 索引零输出:${fr.skipped}`)
56
+ } else if (frWritten.length > 0) {
57
+ console.log(`\n📌 FR 索引(CLI 机械执行,幂等):${frWritten.length} 条已发号入库——`)
58
+ for (const w of frWritten) console.log(` - ${w.id} → ${w.file}`)
59
+ }
60
+ for (const w of (fr && fr.warnings) || []) {
61
+ console.warn(` ⚠️ ${w}`)
62
+ }
63
+ // 遥测(L3 证据发生器指标,D-006/D-008):取代链跟随事件
64
+ const { appendKnowledgeHit } = await import('../knowledge-hits.js')
65
+ const runtimeRoot = join(specBase, '.runtime')
66
+ for (const s of (fr && fr.superseded) || []) {
67
+ appendKnowledgeHit(runtimeRoot, { type: 'fr-supersede', change: changeName, from: s.from, to: s.to })
68
+ }
69
+ // 删除缺口探针(D-008 护栏③):观察信号,不算 L3 门禁——L1 无删除声明义务,只采趋势数据
70
+ for (const u of (fr && fr.unreferenced) || []) {
71
+ appendKnowledgeHit(runtimeRoot, { type: 'fr-unreferenced', change: changeName, domain: u.domain, count: u.count })
72
+ console.log(` ℹ️ [观察信号·不算 L3 门禁] 触达域 ${u.domain} 有 ${u.count} 条 active FR 未被本次承接引用(fr-unreferenced 遥测,L3 裁决趋势数据)`)
73
+ }
74
+ } catch (e) {
75
+ console.warn(`\n⚠️ FR 索引执行异常,降级跳过(best-effort,不阻断归档): ${e && e.message ? e.message : e}`)
76
+ }
48
77
  }
package/src/run/prompt.js CHANGED
@@ -831,7 +831,38 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
831
831
  // fail-soft 同三件套;无 quick 占位符自然零输出。
832
832
  if (stageName === 'quick' && promptText.includes('{QUICK_CONTEXT_DIGEST}')) {
833
833
  const digestSpecBase = resolvePromptSpecBase(platformOpts, cwd)
834
- promptText = promptText.replace(/\{QUICK_CONTEXT_DIGEST\}/g, buildQuickContextDigest(digestSpecBase, projectName))
834
+ promptText = promptText.replace(/\{QUICK_CONTEXT_DIGEST}/g, buildQuickContextDigest(digestSpecBase, projectName))
835
+ }
836
+
837
+ // ①c {FR_INDEX_DIGEST}(2026-09-18-fr-index-l1 L1,D-004):brainstorm step8 的触达域现行 FR 注入
838
+ // ——写作期防重复 FR 的确定性清单(superseded 默认藏);无触达域索引/索引空 → 段消隐。
839
+ // fr-inject 遥测(L3 证据发生器指标①)fail-soft;token 本体在 stages/brainstorm.js step8 模板。
840
+ if (stageName === 'brainstorm' && promptText.includes('{FR_INDEX_DIGEST}')) {
841
+ try {
842
+ const frSpecBase = resolvePromptSpecBase(platformOpts, cwd)
843
+ const { discoverModuleIndex } = await import('../decision-distill.js')
844
+ const { resolveTouchedDomains, readActiveFrDigest } = await import('../fr-index.js')
845
+ const frChangeDir = join(frSpecBase, 'changes', String(changeName || ''))
846
+ const knowledgeRoot = join(frSpecBase, 'knowledge')
847
+ const domains = resolveTouchedDomains(frChangeDir, discoverModuleIndex(knowledgeRoot))
848
+ const entries = readActiveFrDigest(knowledgeRoot, domains)
849
+ if (entries.length === 0) {
850
+ promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, '(触达域暂无 active FR 索引条目——本变更大概率是这些域的首批需求,照常写作)')
851
+ } else {
852
+ const lines = entries.map((e) => `- ${e.id} ${e.title}(来源 ${e.change}${e.scenarios.length ? ';场景:' + e.scenarios.slice(0, 3).join(',') : ''})`)
853
+ lines.push('')
854
+ lines.push('> 域解析自本变更 design.md 文件清单(漏域先核对清单)。改写/取代已有行为 → 对应 FR 块加承接行;新行为 → 新 FR 块。superseded 条目默认不列(历史回溯自行读 knowledge/fr/)。')
855
+ promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, lines.join('\n'))
856
+ }
857
+ try {
858
+ const { appendKnowledgeHit } = await import('../knowledge-hits.js')
859
+ appendKnowledgeHit(join(frSpecBase, '.runtime'), {
860
+ type: 'fr-inject', change: changeName, domains, count: entries.length,
861
+ })
862
+ } catch { /* 遥测 fail-soft */ }
863
+ } catch (e) {
864
+ promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, `(FR 索引注入失败:${e && e.message ? e.message : e}——可自行读 {SPEC_ROOT}/knowledge/fr/)`)
865
+ }
835
866
  }
836
867
 
837
868
  // ② {GIT_DIRTY}:工作区脏文件清单(git status --porcelain,quick 收尾步消费)
@@ -20,10 +20,15 @@
20
20
  * 4. evaluateConclusionDraft(前置三·结论草稿不进判定链):机械事实全绿(实测通过 + lint
21
21
  * 非 failed + 探针 1/3/5/6 干净 + 风险门非 integration/deployment-critical)才给 PASS
22
22
  * 草稿;gate 对结论槽的独立复核逻辑不动——草稿只省打字成本,槽行仍由 agent 复核可改写。
23
+ * 5. smoke 亲跑执行段(2026-09-17-api-coverage-smoke task-01 / FR-01):commands.smoke
24
+ * 显式配置才执行(300s 超时帽/快照内超时回退主仓复跑一次),stdout+stderr tee 实录落
25
+ * .runtime/verify-logs/smoke-<change>.log;失败/超时只记 additive smokeResult 失败态
26
+ * **不 throw**(封顶信号归 --done PASS 封顶消费,非崩溃——与 test failed 的 throw 语义
27
+ * 刻意区分)。指纹已含 commands 段原文——配置 smoke 前后指纹自动变化,代码未变不重跑。
23
28
  */
24
29
  import { createHash } from 'node:crypto'
25
- import { execSync } from 'node:child_process'
26
- import { existsSync, readFileSync, mkdirSync } from 'node:fs'
30
+ import { execSync, spawnSync } from 'node:child_process'
31
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
27
32
  import { join, dirname } from 'node:path'
28
33
  import { writeAtomicSync } from '../fs-atomic.js'
29
34
  import { gitQuiet } from '../git-helper.js'
@@ -88,8 +93,11 @@ export function computeQualityScanFingerprint({ cwd, specBase }) {
88
93
  /**
89
94
  * 实测结果 + 指纹落盘(noAI 动作调用;--done 复用读回)。fail-soft:落盘失败只 warn,
90
95
  * --done 将照旧亲测(复用是优化不是正确性依赖)。
96
+ * smokeResult(2026-09-17-api-coverage-smoke task-01)为 additive 段:RECORD_SCHEMA_VERSION
97
+ * 保持 1——存量记录无 smoke 段照常读回(smokeResult 兜底 null);未配置 smoke 时段形态为
98
+ * configured=false(记录仍写,供 --done PASS 封顶判 not-configured)。
91
99
  */
92
- export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult }) {
100
+ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult }) {
93
101
  const path = qualityScanRecordPath(specBase, changeName)
94
102
  if (!path) return null
95
103
  const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
@@ -103,6 +111,7 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
103
111
  ranAt: new Date().toISOString(),
104
112
  testResult,
105
113
  lintResult: lintResult || null,
114
+ smokeResult: smokeResult || null,
106
115
  }, null, 2) + '\n')
107
116
  return { path, fingerprint }
108
117
  } catch (e) {
@@ -114,6 +123,7 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
114
123
  /**
115
124
  * 读回复用记录:schema/source 校验 + 指纹当前匹配 + testResult 有效(passed/skipped;
116
125
  * failed 不复用——失败态指纹下步骤本就未完成,防御性兜底)。任一不满足 → null。
126
+ * 复用条件只判 testResult(smoke 段随整条记录复用,additive 读回 smokeResult)。
117
127
  */
118
128
  export function loadReusableQualityScan({ specBase, cwd, changeName }) {
119
129
  const path = qualityScanRecordPath(specBase, changeName)
@@ -125,7 +135,7 @@ export function loadReusableQualityScan({ specBase, cwd, changeName }) {
125
135
  if (rec.testResult.status === 'failed') return null
126
136
  const fp = computeQualityScanFingerprint({ cwd, specBase })
127
137
  if (!fp || fp !== rec.fingerprint) return null
128
- return { testResult: rec.testResult, lintResult: rec.lintResult || null, ranAt: rec.ranAt || null, fingerprint: fp }
138
+ return { testResult: rec.testResult, lintResult: rec.lintResult || null, smokeResult: rec.smokeResult || null, ranAt: rec.ranAt || null, fingerprint: fp }
129
139
  } catch {
130
140
  return null
131
141
  }
@@ -245,6 +255,131 @@ export function renderCoverageExistenceReport(cov) {
245
255
  }
246
256
 
247
257
 
258
+ // ── smoke 亲跑执行段(2026-09-17-api-coverage-smoke task-01,FR-01 / D-001@v1 / D-010@v1)──
259
+ // 300s 超时帽与 gate-snapshot.js runGateSnapshotCommands 同款(300_000 先例)。
260
+ const SMOKE_TIMEOUT_MS = 300_000
261
+
262
+ /**
263
+ * 从 local.yaml 文本提取 commands.smoke。
264
+ * 与 extractTestCommand 同风格同容错(带引号/不带引号;'unavailable' 视为未配置——
265
+ * 未配置/unavailable 口径对齐 coverage 先例:执行段休眠,记录 configured=false)。
266
+ * 模块内私有(runSmokeCheck 消费)。
267
+ */
268
+ function extractSmokeCommand(yamlText) {
269
+ if (!yamlText) return null
270
+ const doubleQuoted = yamlText.match(/^\s*smoke:\s*"([^"]+)"\s*(?:#.*)?$/m)
271
+ const singleQuoted = yamlText.match(/^\s*smoke:\s*'([^']+)'\s*(?:#.*)?$/m)
272
+ const quoted = doubleQuoted || singleQuoted
273
+ if (quoted && quoted[1]) {
274
+ return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
275
+ }
276
+ // bare 值不排除引号字符(extractTestCommand 坑 verify-bare-quote-miss 同款修正):
277
+ // 全引号形态已被上方 quoted 分支先行消费,此处引号只可能是嵌在值中部的。
278
+ const bare = yamlText.match(/^\s*smoke:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
279
+ if (bare && bare[1]) {
280
+ const cmd = bare[1].trim()
281
+ return cmd.toLowerCase() === 'unavailable' ? null : cmd
282
+ }
283
+ return null
284
+ }
285
+
286
+ /** spawnSync 结果的超时判定(gate-snapshot.js runGateSnapshotCommands 同款口径)。 */
287
+ function isSyncTimeout(r) {
288
+ return Boolean(r && r.error && (r.error.code === 'ETIMEDOUT' || /ETIMEDOUT/i.test(String(r.error.message || ''))))
289
+ }
290
+
291
+ /**
292
+ * noAI smoke 亲跑:local.yaml 显式配置 commands.smoke 才执行(detect 不自动写,缺省零行为)。
293
+ * 命令脚本自理服务生命周期(后台起服+轮询就绪+finally 杀),CLI 只施加 300s 超时帽并 tee
294
+ * 实录 stdout+stderr 落 .runtime/verify-logs/smoke-<change>.log。失败语义与 test/lint 刻意
295
+ * 区分:非零退出/超时只记 smokeResult 失败态**不 throw**(封顶信号非崩溃——PASS 封顶消费
296
+ * 归 --done 第五条件 / task-03),本步推进语义不动。快照内超时 → 主仓 cwd 复跑一次
297
+ * (lint 快照超时回退同款策略:junction I/O 病态慢时保实测不假败)。
298
+ * 返回纯 JSON 可序列化(落进质量扫描记录 additive smokeResult 段)。
299
+ */
300
+ function runSmokeCheck({ gateCwd, mainCwd, specBase, changeName, inSnapshot }) {
301
+ const ranAt = new Date().toISOString()
302
+ let yamlText = null
303
+ try { yamlText = readFileSync(join(specBase, 'local.yaml'), 'utf8') } catch { /* 无 local.yaml → 未配置 */ }
304
+ const command = extractSmokeCommand(yamlText)
305
+ if (!command) {
306
+ return {
307
+ configured: false, status: 'skipped', command: null,
308
+ exitCode: null, durationMs: null, logPath: null, ranAt,
309
+ timeout: false, fallbackMainRepo: false, source: 'cli-noai-smoke',
310
+ reason: yamlText ? '未配置 commands.smoke(或标记 unavailable)——执行段跳过(配置后 verify 自动亲跑)' : `local.yaml 不可读(${join(specBase, 'local.yaml')})`,
311
+ }
312
+ }
313
+ const startedAt = Date.now()
314
+ let r = null
315
+ let spawnError = null
316
+ try {
317
+ r = spawnSync(command, { shell: true, cwd: gateCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
318
+ } catch (e) { spawnError = e }
319
+ let timedOut = isSyncTimeout(r)
320
+ let fallbackMainRepo = false
321
+ let ranCwd = gateCwd
322
+ // 快照内超时回退主仓复跑一次(lint 快照超时回退先例同款策略,主仓复跑保实测不假败)
323
+ if (inSnapshot && timedOut) {
324
+ fallbackMainRepo = true
325
+ console.warn('⚠️ 快照 smoke 超时(node_modules junction I/O 慢)→ 主仓复跑 smoke(并行噪声可能混入——失败先做归属鉴定)')
326
+ try {
327
+ r = spawnSync(command, { shell: true, cwd: mainCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
328
+ spawnError = null
329
+ ranCwd = mainCwd
330
+ timedOut = isSyncTimeout(r)
331
+ } catch (e) { spawnError = e; timedOut = false }
332
+ }
333
+ const durationMs = Date.now() - startedAt
334
+ const exitCode = r && typeof r.status === 'number' ? r.status : null
335
+ const failed = timedOut || Boolean(spawnError) || (r ? r.status !== 0 : true)
336
+ const reason = timedOut
337
+ ? `超时(>300s 帽${fallbackMainRepo ? ',主仓复跑仍超时' : ''})`
338
+ : spawnError
339
+ ? `spawn 异常:${spawnError.message || spawnError}`
340
+ : (exitCode !== 0 ? `smoke 命令退出码 ${exitCode}` : null)
341
+ // log 实录落盘(fail-soft:写失败只 warn 置 logPath=null,不影响判定与步骤推进)
342
+ let logPath = null
343
+ if (changeName) {
344
+ try {
345
+ const logDir = join(specBase, '.runtime', 'verify-logs')
346
+ mkdirSync(logDir, { recursive: true })
347
+ logPath = join(logDir, `smoke-${changeName}.log`)
348
+ const head = [
349
+ '# smoke 实录(source: cli-noai-smoke)',
350
+ `# command: ${command}`,
351
+ `# cwd: ${ranCwd}${fallbackMainRepo ? '(快照超时回退主仓)' : ''}`,
352
+ `# ranAt: ${ranAt} durationMs: ${durationMs} timeout: ${timedOut}`,
353
+ `# exitCode: ${exitCode === null ? '(无——spawn 异常/被信号杀)' : exitCode}`,
354
+ '---- stdout + stderr ----',
355
+ ].join('\n')
356
+ writeFileSync(logPath, `${head}\n${String(r && r.stdout || '')}${String(r && r.stderr || '')}\n`)
357
+ } catch (e) {
358
+ logPath = null
359
+ console.warn(`⚠️ smoke 实录 log 落盘失败(fail-soft,判定不受影响): ${e && e.message ? e.message : e}`)
360
+ }
361
+ }
362
+ return {
363
+ configured: true, status: failed ? 'failed' : 'passed', command,
364
+ exitCode, durationMs, logPath, ranAt,
365
+ timeout: timedOut, fallbackMainRepo, source: 'cli-noai-smoke',
366
+ reason,
367
+ }
368
+ }
369
+
370
+ /** smoke 结果一行报告(语义边界内建:失败=封顶信号非崩溃,不阻断本步) */
371
+ function renderSmokeReport(s) {
372
+ if (!s || !s.configured) {
373
+ return `ℹ️ smoke 冒烟:${s && s.reason ? s.reason : '未配置 commands.smoke'}`
374
+ }
375
+ const logNote = s.logPath ? `——实录 ${s.logPath}` : ''
376
+ if (s.status === 'passed') {
377
+ return `✅ smoke 冒烟通过(exit 0,${s.durationMs}ms${s.fallbackMainRepo ? ';快照超时已回退主仓复跑' : ''})${logNote}`
378
+ }
379
+ return `⚠️ smoke 冒烟失败(${s.reason || `退出码 ${s.exitCode}`})——记失败态不阻断本步(PASS 封顶信号,--done 消费)${logNote}`
380
+ }
381
+
382
+
248
383
  /** fallback 口径可见性(2026-09-11 驾驭第十二批):快照不可用回退主仓且主仓存在非 .sillyspec
249
384
  * 脏文件时,明示实测判定面含并行 WIP——第三撞根因是「静默 fallback 看不出走了哪个口径」。 */
250
385
  function warnIfMainRepoDirtyForGate(cwd) {
@@ -289,6 +424,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
289
424
  let testCheck
290
425
  let lintCheck
291
426
  let coverageCheck = null
427
+ let smokeResult = null
292
428
  try {
293
429
  testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
294
430
  printVerifyTestCheck(testCheck)
@@ -304,6 +440,16 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
304
440
  console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
305
441
  }
306
442
  printVerifyLintCheck(lintCheck)
443
+ // smoke 亲跑(2026-09-17-api-coverage-smoke task-01 / FR-01):commands.smoke 配置才执行
444
+ // ——失败记失败态不 throw(与 test failed 的 throw 语义刻意区分:封顶信号非崩溃,PASS
445
+ // 封顶消费归 --done 第五条件 / task-03)。在快照 cleanup 前跑(cwd=gateCwd 隔离快照内),
446
+ // 配置从主仓 local.yaml 读(与 coverage 同口径);未配置一行 ℹ️(coverage 同款噪音面)。
447
+ try {
448
+ smokeResult = runSmokeCheck({ gateCwd, mainCwd: cwd, specBase, changeName, inSnapshot: Boolean(snap) })
449
+ console.log(renderSmokeReport(smokeResult))
450
+ } catch (e) {
451
+ console.warn(`⚠️ smoke 冒烟执行异常(fail-soft,不影响本步推进): ${e && e.message ? e.message : e}`)
452
+ }
307
453
  // P2(noai-ir-roadmap §5):coverage 存在性事实——commands.coverage 显式配置才采集,
308
454
  // 只证「有覆盖记录」不证「覆盖了行为」(渲染自带语义边界),fail-soft 不阻断。
309
455
  try {
@@ -315,7 +461,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
315
461
  } finally {
316
462
  if (snap) { try { snap.cleanup() } catch {} }
317
463
  }
318
- storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, coverageCheck })
464
+ storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck })
319
465
  const testFailed = testCheck.status === 'failed'
320
466
  const lintBlocked = shouldBlockVerifyLint(lintCheck)
321
467
  // 摩擦计数(friction-signal-hint task-03):记录条件与 throw 条件**刻意解耦**——advisory 档