sillyspec 3.22.9 → 3.23.1

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,471 @@
1
+ /**
2
+ * Machine Interface v1 — 机器接口层(SillyHub driver 模式地基)
3
+ *
4
+ * 把 SillySpec 既有的门控与事实核验从「埋在 run <stage> --done 人类可读输出流里」
5
+ * 抽象成可被程序化消费的统一 JSON envelope + 退出码契约。
6
+ *
7
+ * 设计原则(见 change 2026-07-09-machine-interface-v1 design.md §2/§3):
8
+ * - 方案 B:独立模块单点封装,不污染既有 run/progress 输出
9
+ * - 只聚合不新增校验:复用 stage-contract / task-review / verify-postcheck 既有策略引擎
10
+ * - 只读语义边界(D-002@v1):只调 ProgressManager 读路径,不写 sillyspec.db / gate-status.json
11
+ * - stdout 纯 JSON(D-005@v1):--json 模式下由 CLI 层(src/index.js 的 withJsonOutput)
12
+ * 在调用 runGate/runDerive 期间劫持 console.log 到 stderr,防被调模块污染机器输出
13
+ * - 退出码 0/1/2 三段语义(D-004@v1):0=通过,1=事实阻断,2=无法核验
14
+ *
15
+ * 仅依赖 Node 18+ 原生 API,零新增外部依赖。
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { ProgressManager, resolveSpecDir } from './progress.js';
21
+ import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
22
+ import { validateTaskReviews } from './task-review.js';
23
+ import { runVerifyTestCheck } from './verify-postcheck.js';
24
+
25
+ // ============ 退出码常量(D-004@v1)============
26
+
27
+ export const EXIT_OK = 0; // 核验通过(可含 warnings)
28
+ export const EXIT_BLOCKED = 1; // 事实性阻断(JSON 含 errors)
29
+ export const EXIT_UNKNOWN = 2; // 无法核验(用法错/变更不存在/环境错/内部异常)
30
+
31
+ // ============ envelope schema 版本(D-005@v1)============
32
+
33
+ export const SCHEMA_VERSION = 1;
34
+
35
+ // ============ envelope 组装 ============
36
+
37
+ /**
38
+ * 组装统一的 JSON envelope。
39
+ *
40
+ * 顶层固定字段:schema_version / command / change / ok / errors / warnings / generated_at。
41
+ * stage / facet / checks / data 仅在显式传参(!== undefined)时出现——保持 envelope 紧凑,
42
+ * daemon 可只看顶层 ok/errors/warnings。
43
+ *
44
+ * @param {object} fields
45
+ * @param {string|undefined} [fields.command]
46
+ * @param {string|undefined} [fields.stage]
47
+ * @param {string|undefined} [fields.facet]
48
+ * @param {string|undefined} [fields.change]
49
+ * @param {boolean} fields.ok
50
+ * @param {string[]} [fields.errors=[]]
51
+ * @param {string[]} [fields.warnings=[]]
52
+ * @param {Array<object>|undefined} [fields.checks]
53
+ * @param {object|undefined} [fields.data]
54
+ * @returns {object} 可直接 JSON.stringify 的 envelope 对象
55
+ */
56
+ export function buildEnvelope({
57
+ command,
58
+ stage,
59
+ facet,
60
+ change,
61
+ ok,
62
+ errors = [],
63
+ warnings = [],
64
+ checks,
65
+ data,
66
+ }) {
67
+ const envelope = {
68
+ schema_version: SCHEMA_VERSION,
69
+ command,
70
+ change,
71
+ ok,
72
+ errors,
73
+ warnings,
74
+ generated_at: new Date().toISOString(),
75
+ };
76
+
77
+ // 按需字段:仅当显式传参时挂上,避免空 null 污染
78
+ if (stage !== undefined) envelope.stage = stage;
79
+ if (facet !== undefined) envelope.facet = facet;
80
+ if (checks !== undefined) envelope.checks = checks;
81
+ if (data !== undefined) envelope.data = data;
82
+
83
+ return envelope;
84
+ }
85
+
86
+ // ============ 输出纪律(design §8)============
87
+ // --json 模式的 stdout 劫持(console.log/info → stderr)由 CLI 层 src/index.js 的
88
+ // withJsonOutput 在调用 runGate/runDerive 期间统一处理(覆盖整个调用期,非仅输出瞬间)。
89
+ // 本模块只返回 { envelope, exitCode },不直接写 stdout。
90
+
91
+ // ============ gate 聚合门控 ============
92
+
93
+ /**
94
+ * 聚合「变更 <changeName> 的 <stage> 阶段此刻能否被标记完成」的综合结论。
95
+ *
96
+ * 只读:仅调 ProgressManager.read,不写状态(D-002@v1)。
97
+ * 只聚合:每个 check 复用既有策略引擎,不在本模块重写校验逻辑(design §2)。
98
+ *
99
+ * @param {string} stage - 目标阶段(brainstorm/plan/execute/verify/...)
100
+ * @param {string} changeName - 变更名
101
+ * @param {object} opts
102
+ * @param {string} opts.cwd - 项目根目录
103
+ * @param {string} [opts.specBase] - .sillyspec(或平台 specRoot)目录;默认 resolveSpecDir(cwd)
104
+ * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 join(specBase, '.runtime')
105
+ * @returns {Promise<{ envelope: object, exitCode: number }>}
106
+ */
107
+ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot } = {}) {
108
+ const specRoot = specBase || resolveSpecDir(cwd);
109
+ const pm = new ProgressManager();
110
+
111
+ try {
112
+ // ── 读进度:变更不存在 → exit 2(D-004@v1)──
113
+ const progress = await pm.read(cwd, changeName);
114
+ if (!progress) {
115
+ const envelope = buildEnvelope({
116
+ command: 'gate',
117
+ stage,
118
+ change: changeName,
119
+ ok: false,
120
+ errors: [`变更不存在: ${changeName}`],
121
+ });
122
+ return { envelope, exitCode: EXIT_UNKNOWN };
123
+ }
124
+
125
+ const currentStage = progress.currentStage || '';
126
+ const checks = [];
127
+
128
+ // ── a. artifacts:阶段产物校验(全部阶段)──
129
+ const r = runValidators(stage, cwd, changeName, {
130
+ projectName: progress.project,
131
+ specRoot,
132
+ });
133
+ checks.push({
134
+ id: 'artifacts',
135
+ ok: r.ok,
136
+ errors: r.errors || [],
137
+ warnings: r.warnings || [],
138
+ });
139
+
140
+ // ── b. transition:状态转换合法性(信息性,不参与综合 ok)──
141
+ // 与 run.js:1727 同源:传 fromStageData 触发 failed_post_check 门控,避免 gate 的
142
+ // transition 结论与 completeStep 漂移(design §8「聚合语义漂移」风险对策)。
143
+ const fromStageData = (progress.stages && currentStage && progress.stages[currentStage]) || undefined;
144
+ const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
145
+ checks.push({
146
+ id: 'transition',
147
+ ok: t.allowed,
148
+ informational: true,
149
+ errors: t.allowed ? [] : [t.reason].filter(Boolean),
150
+ warnings: [],
151
+ });
152
+
153
+ // ── c. execute 阶段追加 execute-evidence + task-reviews ──
154
+ // D-008@v1 去重:checkExecuteCodeEvidence 整个 runGate 只调一次,
155
+ // 结果同时供 execute-evidence check 的 data 与综合结论。
156
+ if (stage === 'execute') {
157
+ const ev = checkExecuteCodeEvidence(cwd, changeName);
158
+ checks.push({
159
+ id: 'execute-evidence',
160
+ ok: ev.status !== 'unchanged',
161
+ errors: ev.status === 'unchanged' ? [`base..head 无代码变更: ${ev.detail}`] : [],
162
+ warnings: ev.status === 'unknown' ? [`无法判定代码变更: ${ev.detail}`] : [],
163
+ data: { status: ev.status, detail: ev.detail },
164
+ });
165
+
166
+ // task-reviews:参数组装照抄 run.js:3223-3249 现成范式
167
+ const planDir = join(specRoot, 'changes', changeName);
168
+ const planPath = join(planDir, 'plan.md');
169
+ let planContent = '';
170
+ if (existsSync(planPath)) {
171
+ try {
172
+ planContent = readFileSync(planPath, 'utf8');
173
+ } catch {
174
+ planContent = '';
175
+ }
176
+ }
177
+
178
+ const rtRoot = runtimeRoot || join(specRoot, '.runtime');
179
+ const runIdFile = join(rtRoot, `current-execute-run-id-${changeName}`);
180
+ let executeRunId = '';
181
+ try {
182
+ if (existsSync(runIdFile)) {
183
+ executeRunId = readFileSync(runIdFile, 'utf8').trim();
184
+ }
185
+ } catch {
186
+ executeRunId = '';
187
+ }
188
+
189
+ // git 真实性交叉校验目录:worktree 存在用 worktreePath,否则 null(交由 task-review 降级 warning)
190
+ let gitDir = null;
191
+ try {
192
+ const { WorktreeManager } = await import('./worktree.js');
193
+ const wm = new WorktreeManager({ cwd });
194
+ const meta = wm.getMeta(changeName);
195
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
196
+ gitDir = meta.worktreePath;
197
+ }
198
+ } catch {
199
+ gitDir = null;
200
+ }
201
+
202
+ const tr = validateTaskReviews({
203
+ planContent,
204
+ runtimeRoot: rtRoot,
205
+ executeRunId,
206
+ changeDir: planDir,
207
+ gitDir,
208
+ });
209
+ checks.push({
210
+ id: 'task-reviews',
211
+ ok: tr.ok,
212
+ errors: tr.errors || [],
213
+ warnings: tr.warnings || [],
214
+ });
215
+ }
216
+
217
+ // ── d. verify 阶段追加 verify-test(CLI 实测 local.yaml commands.test)──
218
+ if (stage === 'verify') {
219
+ const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName });
220
+ checks.push({
221
+ id: 'verify-test',
222
+ ok: vt.status !== 'failed',
223
+ errors: vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [],
224
+ warnings: vt.status === 'skipped' ? ['⚠️ verify-test SKIPPED — gate 未核验测试(local.yaml 未配置 commands.test 或显式无测试)。本次 gate 结论不含测试客观核验,driver 不应据 exit 0 判定测试通过;integration-critical 变更应在 verify 阶段降级 FAIL'] : [],
225
+ data: {
226
+ status: vt.status,
227
+ exitCode: vt.exitCode,
228
+ durationMs: vt.durationMs,
229
+ resultPath: vt.resultPath,
230
+ },
231
+ });
232
+ }
233
+
234
+ // ── 综合结论:所有非 informational check 均 ok ──
235
+ const ok = checks.filter((c) => !c.informational).every((c) => c.ok);
236
+ const exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
237
+
238
+ const errors = [];
239
+ const warnings = [];
240
+ for (const c of checks) {
241
+ if (c.errors && c.errors.length) errors.push(...c.errors);
242
+ if (c.warnings && c.warnings.length) warnings.push(...c.warnings);
243
+ }
244
+
245
+ const envelope = buildEnvelope({
246
+ command: 'gate',
247
+ stage,
248
+ change: changeName,
249
+ ok,
250
+ errors,
251
+ warnings,
252
+ checks,
253
+ });
254
+
255
+ return { envelope, exitCode };
256
+ } catch (e) {
257
+ // 内部异常兜底(D-004@v1 / design §3.5):保证 stdout 永远是合法 JSON 结构,exit 2
258
+ const envelope = buildEnvelope({
259
+ command: 'gate',
260
+ stage,
261
+ change: changeName,
262
+ ok: false,
263
+ errors: [`internal: ${e.message}`],
264
+ });
265
+ return { envelope, exitCode: EXIT_UNKNOWN };
266
+ }
267
+ }
268
+
269
+ // ============ derive 单项事实核验 ============
270
+
271
+ /**
272
+ * facet 枚举(D-003@v1)。machine 接口对外只接受这四个值。
273
+ */
274
+ export const FACETS = ['execute-evidence', 'verify-test', 'task-reviews', 'artifacts'];
275
+
276
+ /**
277
+ * 单项事实核验:针对变更 <changeName> 查询某一 facet 的真实状态(design §3.2)。
278
+ *
279
+ * 与 gate 的差异:gate 聚合某阶段所有 check 的综合结论;derive 只返回单一 facet 的结构化 data,
280
+ * daemon 用来做细粒度事实采集(如轮询 execute-evidence 判断代码是否变更)。
281
+ *
282
+ * 只读语义边界同 gate(D-002@v1):仅调 ProgressManager.read,不写 sillyspec.db / gate-status.json。
283
+ * 唯一例外是 verify-test 会真实执行测试并落盘 test-result.json(产物取证,非状态写入,design §3.3)。
284
+ *
285
+ * 退出码语义(D-004@v1):0=事实通过,1=事实性阻断,2=无法核验(用法错/变更不存在/内部异常)。
286
+ *
287
+ * @param {string} facet - 必须 ∈ FACETS
288
+ * @param {string} changeName - 变更名
289
+ * @param {object} opts
290
+ * @param {string} opts.cwd - 项目根目录
291
+ * @param {string} [opts.specBase] - .sillyspec(或平台 specRoot)目录;默认 resolveSpecDir(cwd)
292
+ * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 join(specBase, '.runtime')
293
+ * @returns {Promise<{ envelope: object, exitCode: number }>}
294
+ */
295
+ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot } = {}) {
296
+ // ── 非法 facet:用法错 → exit 2(D-004@v1)──
297
+ if (!FACETS.includes(facet)) {
298
+ const envelope = buildEnvelope({
299
+ command: 'derive',
300
+ facet,
301
+ change: changeName,
302
+ ok: false,
303
+ errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
304
+ });
305
+ return { envelope, exitCode: EXIT_UNKNOWN };
306
+ }
307
+
308
+ const specRoot = specBase || resolveSpecDir(cwd);
309
+ const pm = new ProgressManager();
310
+
311
+ try {
312
+ // ── 读进度:变更不存在 → exit 2 ──
313
+ const progress = await pm.read(cwd, changeName);
314
+ if (!progress) {
315
+ const envelope = buildEnvelope({
316
+ command: 'derive',
317
+ facet,
318
+ change: changeName,
319
+ ok: false,
320
+ errors: [`变更不存在: ${changeName}`],
321
+ });
322
+ return { envelope, exitCode: EXIT_UNKNOWN };
323
+ }
324
+
325
+ const currentStage = progress.currentStage || '';
326
+
327
+ let data;
328
+ let ok;
329
+ let errors = [];
330
+ let warnings = [];
331
+ let exitCode;
332
+
333
+ switch (facet) {
334
+ // ── a. execute-evidence:base..head 代码变更判定 ──
335
+ // 语义与 validateExecuteOutputs 一致:unknown 不等于失败(无法判定不应阻断)。
336
+ case 'execute-evidence': {
337
+ const ev = checkExecuteCodeEvidence(cwd, changeName);
338
+ data = { status: ev.status, detail: ev.detail };
339
+ ok = ev.status !== 'unchanged';
340
+ errors = ev.status === 'unchanged' ? [`base..head 无代码变更: ${ev.detail}`] : [];
341
+ warnings = ev.status === 'unknown' ? [`无法判定代码变更: ${ev.detail}`] : [];
342
+ exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
343
+ break;
344
+ }
345
+
346
+ // ── b. verify-test:真实执行测试命令并取证 ──
347
+ case 'verify-test': {
348
+ const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName });
349
+ data = {
350
+ status: vt.status,
351
+ exitCode: vt.exitCode,
352
+ durationMs: vt.durationMs,
353
+ resultPath: vt.resultPath,
354
+ };
355
+ ok = vt.status !== 'failed';
356
+ errors = vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [];
357
+ warnings = vt.status === 'skipped' ? ['测试被跳过'] : [];
358
+ exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
359
+ break;
360
+ }
361
+
362
+ // ── c. task-reviews:plan 任务评审核验(参数组装照抄 runGate execute 段)──
363
+ case 'task-reviews': {
364
+ const changeDir = join(specRoot, 'changes', changeName);
365
+ const planPath = join(changeDir, 'plan.md');
366
+ let planContent = '';
367
+ if (existsSync(planPath)) {
368
+ try {
369
+ planContent = readFileSync(planPath, 'utf8');
370
+ } catch {
371
+ planContent = '';
372
+ }
373
+ }
374
+
375
+ const rtRoot = runtimeRoot || join(specRoot, '.runtime');
376
+ const runIdFile = join(rtRoot, `current-execute-run-id-${changeName}`);
377
+ let executeRunId = '';
378
+ try {
379
+ if (existsSync(runIdFile)) {
380
+ executeRunId = readFileSync(runIdFile, 'utf8').trim();
381
+ }
382
+ } catch {
383
+ executeRunId = '';
384
+ }
385
+
386
+ // git 真实性交叉校验目录:worktree 存在用 worktreePath,否则 null(交由 task-review 降级 warning)
387
+ let gitDir = null;
388
+ try {
389
+ const { WorktreeManager } = await import('./worktree.js');
390
+ const wm = new WorktreeManager({ cwd });
391
+ const meta = wm.getMeta(changeName);
392
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
393
+ gitDir = meta.worktreePath;
394
+ }
395
+ } catch {
396
+ gitDir = null;
397
+ }
398
+
399
+ const tr = validateTaskReviews({
400
+ planContent,
401
+ runtimeRoot: rtRoot,
402
+ executeRunId,
403
+ changeDir,
404
+ gitDir,
405
+ });
406
+ data = {
407
+ ok: tr.ok,
408
+ errors: tr.errors,
409
+ warnings: tr.warnings,
410
+ requiredEvidence: tr.requiredEvidence,
411
+ };
412
+ ok = tr.ok;
413
+ errors = tr.errors || [];
414
+ warnings = tr.warnings || [];
415
+ exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
416
+ break;
417
+ }
418
+
419
+ // ── d. artifacts:当前阶段产物校验 ──
420
+ // runValidators 需要 stage:用 progress.currentStage(若为空串则校验器内部处理)。
421
+ case 'artifacts': {
422
+ const r = runValidators(currentStage, cwd, changeName, {
423
+ projectName: progress.project,
424
+ specRoot,
425
+ });
426
+ data = { ok: r.ok, errors: r.errors, warnings: r.warnings };
427
+ ok = r.ok;
428
+ errors = r.errors || [];
429
+ warnings = r.warnings || [];
430
+ exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
431
+ break;
432
+ }
433
+
434
+ // 不可达:facet 已在入口白名单校验
435
+ default: {
436
+ const envelope = buildEnvelope({
437
+ command: 'derive',
438
+ facet,
439
+ change: changeName,
440
+ ok: false,
441
+ errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
442
+ });
443
+ return { envelope, exitCode: EXIT_UNKNOWN };
444
+ }
445
+ }
446
+
447
+ // stage 仅 artifacts 时出现(产物校验绑定阶段语义);其余 facet 不传 stage。
448
+ const envelope = buildEnvelope({
449
+ command: 'derive',
450
+ facet,
451
+ change: changeName,
452
+ ok,
453
+ errors,
454
+ warnings,
455
+ data,
456
+ stage: facet === 'artifacts' ? currentStage : undefined,
457
+ });
458
+
459
+ return { envelope, exitCode };
460
+ } catch (e) {
461
+ // 内部异常兜底(D-004@v1 / design §3.5):保证 stdout 永远是合法 JSON 结构,exit 2
462
+ const envelope = buildEnvelope({
463
+ command: 'derive',
464
+ facet,
465
+ change: changeName,
466
+ ok: false,
467
+ errors: [`internal: ${e.message}`],
468
+ });
469
+ return { envelope, exitCode: EXIT_UNKNOWN };
470
+ }
471
+ }
package/src/progress.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * 历史迁移:v1/v2 使用 progress.json 文件,v3 已全部迁移至 SQLite。
12
12
  */
13
13
 
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync } from 'fs';
15
15
  import { join, basename, dirname, resolve } from 'path';
16
16
  import { DB } from './db.js';
17
17
 
@@ -893,6 +893,7 @@ export class ProgressManager {
893
893
  const stepId = stepRow[0].values[0][0];
894
894
 
895
895
  // UPDATE steps
896
+ let stageCompletionCandidateId = null;
896
897
  db.transaction((tDb) => {
897
898
  const now = new Date().toISOString();
898
899
  if (status) {
@@ -902,7 +903,8 @@ export class ProgressManager {
902
903
  tDb.run('UPDATE steps SET output = ? WHERE id = ? AND name = ?', [output, stepId, stepName]);
903
904
  }
904
905
 
905
- // 自动完成检测:同 stage_id 下所有 steps 都 completed 时,标记 stage completed
906
+ // 自动完成检测:同 stage_id 下所有 steps 都 completed 时,候选标记 stage completed
907
+ // (实际标记延后到事务外,先过产物校验门,防止 update-step 成为绕过 validator 的后门)
906
908
  if (status === 'completed') {
907
909
  // 获取 stage_id
908
910
  const stRow = tDb.exec('SELECT stage_id FROM steps WHERE id = ?', [stepId]);
@@ -910,8 +912,7 @@ export class ProgressManager {
910
912
  const stId = stRow[0].values[0][0];
911
913
  const pendingRows = tDb.exec('SELECT COUNT(*) FROM steps WHERE stage_id = ? AND status != "completed"', [stId]);
912
914
  if (pendingRows && pendingRows.length > 0 && pendingRows[0].values[0][0] === 0) {
913
- tDb.run('UPDATE stages SET status = "completed", completed_at = ? WHERE id = ?', [now, stId]);
914
- console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
915
+ stageCompletionCandidateId = stId;
915
916
  }
916
917
  }
917
918
  }
@@ -919,10 +920,70 @@ export class ProgressManager {
919
920
  tDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [now, cn]);
920
921
  });
921
922
 
923
+ if (stageCompletionCandidateId !== null) {
924
+ const { force = false } = options;
925
+ const validation = await this._validateStageArtifacts(cwd, stage, cn);
926
+ if (!validation.ok && !force) {
927
+ console.error(`⚠️ 阶段 ${stage} 所有步骤已完成,但产物校验未通过,阶段不标记为 completed:`);
928
+ for (const err of validation.errors) console.error(` - ${err}`);
929
+ console.error(` 请修复产物后走正常流程 sillyspec run ${stage} --done,或使用 --force(将记录审计日志)。`);
930
+ } else {
931
+ if (!validation.ok && force) {
932
+ console.warn(`⚠️ --force 强制标记阶段 ${stage} completed(校验未通过,已记录审计日志)`);
933
+ this._appendAuditLog(cwd, {
934
+ action: 'update-step --force (stage auto-complete)',
935
+ stage,
936
+ change: cn,
937
+ validationErrors: validation.errors,
938
+ });
939
+ }
940
+ db.transaction((tDb) => {
941
+ tDb.run('UPDATE stages SET status = "completed", completed_at = ? WHERE id = ?', [new Date().toISOString(), stageCompletionCandidateId]);
942
+ });
943
+ console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
944
+ }
945
+ }
946
+
922
947
  console.log(`✅ 步骤已更新: ${stage}/${stepName} → ${status || '(仅更新 output)'}`);
923
948
  }
924
949
 
925
- async completeStage(cwd, stage, changeName = null) {
950
+ /**
951
+ * 强制状态变更的审计记录(--force 逃生口专用)。
952
+ * 追加到 .runtime/audit.log,供人工/doctor 追溯"谁在什么时候绕过了校验"。
953
+ */
954
+ _appendAuditLog(cwd, entry) {
955
+ try {
956
+ this._ensureRuntimeDir(cwd);
957
+ const auditPath = this._runtimePath(cwd, 'audit.log');
958
+ const line = JSON.stringify({ at: new Date().toISOString(), ...entry });
959
+ appendFileSync(auditPath, line + '\n');
960
+ } catch (e) {
961
+ console.warn(`⚠️ 审计日志写入失败: ${e.message}`);
962
+ }
963
+ }
964
+
965
+ /**
966
+ * 阶段产物校验门:progress complete-stage / update-step 自动完成时复用
967
+ * stage-contract 的 validator,防止零产物阶段被直接标 completed。
968
+ */
969
+ async _validateStageArtifacts(cwd, stage, changeName) {
970
+ const { runValidators } = await import('./stage-contract.js');
971
+ const specDir = this._getSpecDir(cwd);
972
+ const defaultSpec = join(resolve(cwd), SPEC_DIR_NAME);
973
+ const specRoot = resolve(specDir) === defaultSpec ? null : specDir;
974
+ let projectName = null;
975
+ try {
976
+ const g = await this.readGlobal(cwd);
977
+ projectName = g?.project || null;
978
+ } catch {}
979
+ return runValidators(stage, cwd, changeName, {
980
+ projectName: projectName || basename(resolve(cwd)),
981
+ specRoot,
982
+ });
983
+ }
984
+
985
+ async completeStage(cwd, stage, changeName = null, opts = {}) {
986
+ const { force = false } = opts;
926
987
  if (!VALID_STAGES.includes(stage)) {
927
988
  console.log(`❌ 未知阶段: ${stage}`);
928
989
  return;
@@ -939,6 +1000,30 @@ export class ProgressManager {
939
1000
  if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
940
1001
  }
941
1002
 
1003
+ // ── 产物校验门:complete-stage 不再是零校验后门 ──
1004
+ // 与 run.js completeStep 的 validator 同源;--force 为显式逃生口(留审计)。
1005
+ const validation = await this._validateStageArtifacts(cwd, stage, cn);
1006
+ if (!validation.ok) {
1007
+ if (!force) {
1008
+ console.error(`❌ complete-stage 被拒绝:阶段 ${stage} 产物校验未通过`);
1009
+ for (const err of validation.errors) console.error(` - ${err}`);
1010
+ console.error(` 请修复产物后重试,或走正常流程 sillyspec run ${stage} --done。`);
1011
+ console.error(` 确需强制标记(如 doctor 修复),使用 --force(将记录审计日志)。`);
1012
+ return;
1013
+ }
1014
+ console.warn(`⚠️ --force 强制完成阶段 ${stage}(校验未通过,已记录审计日志)`);
1015
+ for (const err of validation.errors) console.warn(` - ${err}`);
1016
+ this._appendAuditLog(cwd, {
1017
+ action: 'complete-stage --force',
1018
+ stage,
1019
+ change: cn,
1020
+ validationErrors: validation.errors,
1021
+ });
1022
+ } else if (force) {
1023
+ // 校验通过但仍显式 --force:也留一条审计记录,保持行为可追溯
1024
+ this._appendAuditLog(cwd, { action: 'complete-stage --force', stage, change: cn, validationErrors: [] });
1025
+ }
1026
+
942
1027
  db.transaction((sqlDb) => {
943
1028
  const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
944
1029
  if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
@@ -1880,6 +1965,26 @@ export class ProgressManager {
1880
1965
  };
1881
1966
  }
1882
1967
 
1968
+ // 2.5 最低事实核验:plan.md checkbox 可被手动勾选伪造,
1969
+ // 对齐前用 git 客观核验是否存在真实代码变更(能确证零变更时才拒绝,
1970
+ // unknown 不阻断——worktree 已清理且变更已提交的正常场景无法对账)。
1971
+ try {
1972
+ const { checkExecuteCodeEvidence } = await import('./stage-contract.js');
1973
+ const evidence = checkExecuteCodeEvidence(cwd, changeName);
1974
+ if (evidence.status === 'unchanged') {
1975
+ return {
1976
+ ok: false,
1977
+ aligned: 0,
1978
+ skipped: 0,
1979
+ planTotal,
1980
+ planChecked,
1981
+ reason: `plan.md 全勾但代码零变更(${evidence.detail}),拒绝对齐 — 请先运行 sillyspec doctor --json 诊断`,
1982
+ };
1983
+ }
1984
+ } catch (e) {
1985
+ console.warn(`⚠️ 代码变更核验异常(不阻断对齐): ${e.message}`);
1986
+ }
1987
+
1883
1988
  // 3. 全勾:计算将补哪些 step
1884
1989
  const now = new Date().toISOString();
1885
1990
  let aligned = 0;