@zhuan-ai/zhuanspec 2.16.2 → 2.16.3

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/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { ViewCommand } from '../core/view.js';
11
11
  import { registerSpecCommand } from '../commands/spec.js';
12
12
  import { ChangeCommand } from '../commands/change.js';
13
13
  import { ValidateCommand } from '../commands/validate.js';
14
+ import { ValidateReportsCommand } from '../commands/validate-reports.js';
14
15
  import { ShowCommand } from '../commands/show.js';
15
16
  import { CompletionCommand } from '../commands/completion.js';
16
17
  import { ReviewCommand } from '../commands/review.js';
@@ -524,6 +525,25 @@ program
524
525
  process.exit(1);
525
526
  }
526
527
  });
528
+ // Top-level validate-reports command
529
+ program
530
+ .command('validate-reports <change-id>')
531
+ .description('Validate Apply-phase task reports against apply-agent / tdd-apply-agent templates')
532
+ .option('--json', 'Output validation summary as JSON')
533
+ .action(async (changeId, options) => {
534
+ try {
535
+ const cmd = new ValidateReportsCommand();
536
+ await cmd.execute(changeId, options);
537
+ if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
538
+ process.exit(process.exitCode);
539
+ }
540
+ }
541
+ catch (error) {
542
+ console.log();
543
+ ora().fail(`Error: ${error.message}`);
544
+ process.exit(1);
545
+ }
546
+ });
527
547
  // Top-level show command
528
548
  program
529
549
  .command('show [item-name]')
@@ -295,9 +295,13 @@ export class ProgressCommand {
295
295
  // Progress percentage
296
296
  const percentage = totalTasks > 0 ? (completedCount / totalTasks) * 100 : 0;
297
297
  const progressBar = this.generateProgressBar(percentage, 12);
298
- // Duration
298
+ // Duration (active time)
299
299
  const durationMs = progress?.stats?.durationMs?.[phase] || 0;
300
300
  const durationMin = Math.round(durationMs / 60000);
301
+ const wallClockMs = progress?.stats?.wallClockMs?.[phase] || 0;
302
+ const wallClockMin = Math.round(wallClockMs / 60000);
303
+ const idleMs = progress?.stats?.idleMs?.[phase] || 0;
304
+ const idleMin = Math.round(idleMs / 60000);
301
305
  const estimatedRemaining = totalTasks > 0 && completedCount > 0
302
306
  ? Math.round((durationMin / completedCount) * (totalTasks - completedCount))
303
307
  : 0;
@@ -319,8 +323,16 @@ export class ProgressCommand {
319
323
  console.log('');
320
324
  }
321
325
  // Duration info
322
- if (durationMin > 0) {
323
- console.log(`耗时: ${durationMin}m | 预计剩余: ~${estimatedRemaining}m`);
326
+ if (durationMin > 0 || wallClockMin > 0) {
327
+ const parts = [];
328
+ parts.push(`活跃: ${durationMin}m`);
329
+ if (idleMin > 0)
330
+ parts.push(`空闲: ${idleMin}m`);
331
+ if (wallClockMin > 0)
332
+ parts.push(`墙钟: ${wallClockMin}m`);
333
+ if (estimatedRemaining > 0)
334
+ parts.push(`预计剩余: ~${estimatedRemaining}m`);
335
+ console.log(parts.join(' | '));
324
336
  }
325
337
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
326
338
  console.log('');
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `zhuanspec validate-reports <change-id>` — 校验某个 change 下所有
3
+ * Apply 任务报告是否符合 apply-agent.md / tdd-apply-agent.md 模板。
4
+ *
5
+ * 设计要点:
6
+ * - 仅做静态文本/章节检测,不读取代码,零外部依赖。
7
+ * - JSON 输出与人类可读输出双通道,便于 hooks/CI 程序化消费。
8
+ * - 退出码:所有报告通过且无缺失 → 0;否则 1。
9
+ */
10
+ interface ExecuteOptions {
11
+ json?: boolean;
12
+ }
13
+ export declare class ValidateReportsCommand {
14
+ execute(changeId: string | undefined, options?: ExecuteOptions): Promise<void>;
15
+ }
16
+ export {};
17
+ //# sourceMappingURL=validate-reports.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `zhuanspec validate-reports <change-id>` — 校验某个 change 下所有
3
+ * Apply 任务报告是否符合 apply-agent.md / tdd-apply-agent.md 模板。
4
+ *
5
+ * 设计要点:
6
+ * - 仅做静态文本/章节检测,不读取代码,零外部依赖。
7
+ * - JSON 输出与人类可读输出双通道,便于 hooks/CI 程序化消费。
8
+ * - 退出码:所有报告通过且无缺失 → 0;否则 1。
9
+ */
10
+ import path from 'path';
11
+ import chalk from 'chalk';
12
+ import { promises as fs } from 'fs';
13
+ import { validateTaskReports, } from '../core/validation/report-schema.js';
14
+ export class ValidateReportsCommand {
15
+ async execute(changeId, options = {}) {
16
+ if (!changeId) {
17
+ console.error('Usage: zhuanspec validate-reports <change-id> [--json]');
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const changeDir = path.join(process.cwd(), 'zhuanspec', 'changes', changeId);
22
+ try {
23
+ await fs.access(changeDir);
24
+ }
25
+ catch {
26
+ console.error(`未找到 change 目录:${changeDir}`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ const summary = await validateTaskReports(changeDir);
31
+ if (options.json) {
32
+ console.log(JSON.stringify(summary, null, 2));
33
+ }
34
+ else {
35
+ printHumanReadable(summary);
36
+ }
37
+ process.exitCode = summary.valid ? 0 : 1;
38
+ }
39
+ }
40
+ function printHumanReadable(summary) {
41
+ const { changeId, totalReports, passedReports, failedReports, missingReports } = summary;
42
+ console.log(`Change: ${changeId}`);
43
+ console.log(`Reports: total=${totalReports} passed=${passedReports} failed=${failedReports} missing=${missingReports.length}`);
44
+ console.log('');
45
+ for (const report of summary.reports) {
46
+ if (report.valid) {
47
+ console.log(` ${chalk.green('✓')} task-${report.taskId}-report.md (${report.declaredAgentType})`);
48
+ continue;
49
+ }
50
+ console.log(` ${chalk.red('✗')} task-${report.taskId}-report.md (declared=${report.declaredAgentType}, expected=${report.expectedAgentType})`);
51
+ for (const issue of report.issues) {
52
+ const tag = issue.level === 'ERROR' ? chalk.red('ERROR') : chalk.yellow('WARN');
53
+ console.log(` [${tag}] ${issue.message}`);
54
+ }
55
+ }
56
+ if (missingReports.length > 0) {
57
+ console.log('');
58
+ console.log(chalk.red(`缺失报告(tasks.md 列出但 reports/ 中未找到):`));
59
+ for (const taskId of missingReports) {
60
+ console.log(` - task ${taskId}`);
61
+ }
62
+ }
63
+ console.log('');
64
+ if (summary.valid) {
65
+ console.log(chalk.green('All task reports conform to schema.'));
66
+ }
67
+ else {
68
+ console.log(chalk.red('Some task reports do NOT conform to schema. See errors above.'));
69
+ console.log('');
70
+ console.log('Hints:');
71
+ console.log(' - 缺章节通常意味着报告由主 agent 直写而非 subagent 产出');
72
+ console.log(' - 重新通过 Agent tool / spawn_agent 启动 subagent 重跑对应任务');
73
+ console.log(' - 如果是合理降级,请在 "Agent 选择决策" 中显式写 "TDD 适用性: 不适合(降级原因:...)"');
74
+ }
75
+ }
76
+ //# sourceMappingURL=validate-reports.js.map
@@ -32,6 +32,20 @@ export declare function getBeijingTime(): string;
32
32
  * Returns format: "2026-04-21 18-35-22" (文件名安全格式)
33
33
  */
34
34
  export declare function getBeijingTimeForFilename(): string;
35
+ /**
36
+ * v2.16+ 活跃耗时累加器
37
+ *
38
+ * 每次 PostToolUse 心跳到达时调用,根据 gap = now - lastActiveAt 判断:
39
+ * gap < PHASE_IDLE_THRESHOLD_MS → 活跃,累加到 durationMs
40
+ * gap >= PHASE_IDLE_THRESHOLD_MS → 空闲,累加到 idleMs 并 push idleSegments
41
+ *
42
+ * 同步刷新 wallClockMs、lastActiveAt、lastUpdatedAt。
43
+ * 返回本次增量便于调用方决定是否刷新 stats。
44
+ */
45
+ export declare function tickActivePhaseDuration(entry: PhaseDurationRecord, nowEpoch?: number, nowTimestamp?: string): {
46
+ activeDelta: number;
47
+ idleDelta: number;
48
+ };
35
49
  interface RecordProgressOptions {
36
50
  json?: boolean;
37
51
  file?: string;
@@ -143,6 +157,14 @@ interface ClarificationRecord {
143
157
  }
144
158
  /**
145
159
  * Phase duration record for tracking time spent in each phase
160
+ *
161
+ * v2.16+:字段语义反转
162
+ * - durationMs:覆盖为“活跃耗时”(剔除空闲段,PHASE_IDLE_THRESHOLD_MS 内累加),
163
+ * 业务主指标,与上报/采集口径保持一致。
164
+ * - wallClockMs:原 durationMs 的语义(endedAt|lastUpdatedAt - startedAt 墙钟差),
165
+ * 仅作审计/调试参考,迁移期老 progress.json 会把老的 durationMs 复制到此字段。
166
+ * - idleMs / idleSegments:活跃耗时之外被剔除的空闲明细(可观测)。
167
+ * - lastActiveAt:最后一次 hook 心跳的北京时间字符串,下次心跳到达时计算 gap。
146
168
  */
147
169
  export interface PhaseDurationRecord {
148
170
  phase: Phase;
@@ -152,6 +174,15 @@ export interface PhaseDurationRecord {
152
174
  durationMs: number;
153
175
  taskCount: number;
154
176
  completedTaskCount: number;
177
+ wallClockMs?: number;
178
+ idleMs?: number;
179
+ lastActiveAt?: string;
180
+ _lastActiveEpoch?: number;
181
+ idleSegments?: Array<{
182
+ from: string;
183
+ to: string;
184
+ durationMs: number;
185
+ }>;
155
186
  }
156
187
  /**
157
188
  * Proposal change record for tracking modifications to proposal files
@@ -322,6 +353,20 @@ export interface ProgressData {
322
353
  review: number;
323
354
  archive: number;
324
355
  };
356
+ wallClockMs?: {
357
+ techDesign: number;
358
+ propose: number;
359
+ apply: number;
360
+ review: number;
361
+ archive: number;
362
+ };
363
+ idleMs?: {
364
+ techDesign: number;
365
+ propose: number;
366
+ apply: number;
367
+ review: number;
368
+ archive: number;
369
+ };
325
370
  };
326
371
  proposalChanges: ProposalChangeRecord[];
327
372
  corrections?: CorrectionRecord[];
@@ -9,7 +9,7 @@
9
9
  import path from 'path';
10
10
  import { promises as fsPromises, existsSync, readFileSync, unlinkSync } from 'fs';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
- import { PHASE_ORDER } from '../../utils/phase-utils.js';
12
+ import { PHASE_ORDER, PHASE_IDLE_THRESHOLD_MS } from '../../utils/phase-utils.js';
13
13
  import { resolveZhuanSpecRoot } from '../../utils/resolve-root.js';
14
14
  import { isCodeFile, isTrackedFileForPhase, getOrCreatePhaseBucket, recomputeAccuracyRate, persistAccuracyJson, appendAccuracyDebugLog } from '../metrics/code-accuracy.js';
15
15
  import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
@@ -116,6 +116,10 @@ export async function recoverProgressJsonForWrite(filePath) {
116
116
  durationMs: 0,
117
117
  taskCount: 0,
118
118
  completedTaskCount: 0,
119
+ wallClockMs: 0,
120
+ idleMs: 0,
121
+ lastActiveAt: '',
122
+ _lastActiveEpoch: 0,
119
123
  }],
120
124
  stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 } },
121
125
  };
@@ -163,6 +167,69 @@ export function getBeijingTimeForFilename() {
163
167
  const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
164
168
  return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
165
169
  }
170
+ /**
171
+ * v2.16+ 活跃耗时累加器
172
+ *
173
+ * 每次 PostToolUse 心跳到达时调用,根据 gap = now - lastActiveAt 判断:
174
+ * gap < PHASE_IDLE_THRESHOLD_MS → 活跃,累加到 durationMs
175
+ * gap >= PHASE_IDLE_THRESHOLD_MS → 空闲,累加到 idleMs 并 push idleSegments
176
+ *
177
+ * 同步刷新 wallClockMs、lastActiveAt、lastUpdatedAt。
178
+ * 返回本次增量便于调用方决定是否刷新 stats。
179
+ */
180
+ export function tickActivePhaseDuration(entry, nowEpoch = Date.now(), nowTimestamp = getBeijingTime()) {
181
+ // 初始化可选字段
182
+ if (entry.idleMs === undefined)
183
+ entry.idleMs = 0;
184
+ if (entry.wallClockMs === undefined)
185
+ entry.wallClockMs = 0;
186
+ const parse = (s) => {
187
+ if (!s)
188
+ return null;
189
+ const ms = new Date(s.replace(' ', 'T')).getTime();
190
+ return Number.isFinite(ms) ? ms : null;
191
+ };
192
+ // 优先使用毫秒级 epoch(精确),fallback 到秒级字符串解析(兼容老数据)
193
+ const lastTickStr = entry.lastActiveAt || entry.lastUpdatedAt || entry.startedAt;
194
+ const lastTickEpoch = entry._lastActiveEpoch ?? parse(lastTickStr);
195
+ const startEpoch = parse(entry.startedAt);
196
+ // 无法解析时间 → 仅刷新心跳时间
197
+ if (lastTickEpoch == null || startEpoch == null) {
198
+ entry.lastActiveAt = nowTimestamp;
199
+ entry.lastUpdatedAt = nowTimestamp;
200
+ entry._lastActiveEpoch = nowEpoch;
201
+ return { activeDelta: 0, idleDelta: 0 };
202
+ }
203
+ const gap = nowEpoch - lastTickEpoch;
204
+ let activeDelta = 0;
205
+ let idleDelta = 0;
206
+ if (gap > 0) {
207
+ if (gap < PHASE_IDLE_THRESHOLD_MS) {
208
+ // 活跃:累加到 durationMs
209
+ activeDelta = gap;
210
+ entry.durationMs = (entry.durationMs || 0) + gap;
211
+ }
212
+ else {
213
+ // 空闲:累加到 idleMs + 记录段
214
+ idleDelta = gap;
215
+ entry.idleMs = (entry.idleMs || 0) + gap;
216
+ entry.idleSegments = entry.idleSegments || [];
217
+ entry.idleSegments.push({
218
+ from: lastTickStr,
219
+ to: nowTimestamp,
220
+ durationMs: gap,
221
+ });
222
+ }
223
+ }
224
+ // 刷新 wallClockMs = now - startedAt
225
+ const wall = nowEpoch - startEpoch;
226
+ entry.wallClockMs = wall > 0 ? wall : (entry.wallClockMs || 0);
227
+ // 刷新心跳(字符串 + epoch 双写,epoch 用于下次 tick 精确计算)
228
+ entry.lastActiveAt = nowTimestamp;
229
+ entry.lastUpdatedAt = nowTimestamp;
230
+ entry._lastActiveEpoch = nowEpoch;
231
+ return { activeDelta, idleDelta };
232
+ }
166
233
  export async function recordProgressHook(options) {
167
234
  // Read stdin for Claude Code hook input
168
235
  let stdinData = {};
@@ -420,6 +487,20 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
420
487
  if (progress.stats.durationMs.techDesign === undefined) {
421
488
  progress.stats.durationMs.techDesign = 0;
422
489
  }
490
+ // v2.16+ 迁移:老 progress.json 的 phaseDurations entry 缺少 wallClockMs/idleMs/lastActiveAt
491
+ for (const entry of progress.phaseDurations) {
492
+ if (entry.wallClockMs === undefined) {
493
+ // 老版本 durationMs 是墙钟差,复制到 wallClockMs,durationMs 保留作为活跃近似
494
+ entry.wallClockMs = entry.durationMs || 0;
495
+ }
496
+ if (entry.idleMs === undefined) {
497
+ entry.idleMs = 0;
498
+ }
499
+ if (!entry.lastActiveAt) {
500
+ // 进行中 entry 用 lastUpdatedAt 兆底,已结束用 endedAt
501
+ entry.lastActiveAt = entry.endedAt || entry.lastUpdatedAt || entry.startedAt;
502
+ }
503
+ }
423
504
  }
424
505
  else {
425
506
  progress = createNewProgress(changeId);
@@ -870,51 +951,34 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
870
951
  const contextLoadFromStdIn = Number(stdinData.tool_result?.contextLoad || 0);
871
952
  progress.stats.tokenUsageTotal += tokenUsageFromEnv + tokenUsageFromStdIn;
872
953
  progress.stats.contextLoad += contextLoadFromEnv + contextLoadFromStdIn;
873
- // Calculate duration for current phase from phaseTransitions
874
- // Find the last transition to current phase; if none exists (e.g. initial
875
- // phase such as techDesign started via initializeProgress without a
876
- // transition record), fall back to phaseDurations[].startedAt, then to
877
- // progress.startedAt so every phase gets an accurate elapsed time.
878
- const transitionsToCurrentPhase = progress.phaseTransitions.filter((t) => t.to === phase);
879
- const currentPhaseStart = transitionsToCurrentPhase[transitionsToCurrentPhase.length - 1];
880
- let phaseStartTimestamp;
881
- if (currentPhaseStart) {
882
- phaseStartTimestamp = currentPhaseStart.timestamp;
883
- }
884
- else {
885
- const activePhaseDuration = [...progress.phaseDurations]
886
- .reverse()
887
- .find(pd => pd.phase === phase && !pd.endedAt);
888
- if (activePhaseDuration?.startedAt) {
889
- phaseStartTimestamp = activePhaseDuration.startedAt;
890
- }
891
- else if (progress.startedAt) {
892
- phaseStartTimestamp = progress.startedAt;
893
- }
894
- }
895
- if (phaseStartTimestamp) {
896
- const startTime = new Date(phaseStartTimestamp.replace(' ', 'T')).getTime();
897
- const elapsedMs = Date.now() - startTime;
898
- if (Number.isFinite(startTime) && elapsedMs > 0) {
899
- if (phase === 'techDesign')
900
- progress.stats.durationMs.techDesign = elapsedMs;
901
- if (phase === 'propose')
902
- progress.stats.durationMs.propose = elapsedMs;
903
- if (phase === 'apply')
904
- progress.stats.durationMs.apply = elapsedMs;
905
- if (phase === 'review')
906
- progress.stats.durationMs.review = elapsedMs;
907
- if (phase === 'archive')
908
- progress.stats.durationMs.archive = elapsedMs;
909
- // v2.15.16:同步刷新活跃段的 phaseDurations 记录,进行中也能从单条直接读出
910
- // 实时 durationMs / lastUpdatedAt。endedAt 仍仅在 phase 切换时填。
911
- const activeEntry = [...progress.phaseDurations]
912
- .reverse()
913
- .find(pd => pd.phase === phase && !pd.endedAt);
914
- if (activeEntry) {
915
- activeEntry.durationMs = elapsedMs;
916
- activeEntry.lastUpdatedAt = getBeijingTime();
917
- }
954
+ // v2.16+:使用 tickActivePhaseDuration 累加活跃耗时
955
+ // phaseDurations 中找到当前进行中的 entry,tick 一次心跳
956
+ const activeEntry = [...progress.phaseDurations]
957
+ .reverse()
958
+ .find(pd => pd.phase === phase && !pd.endedAt);
959
+ if (activeEntry) {
960
+ const nowEpoch = Date.now();
961
+ const nowTs = getBeijingTime();
962
+ tickActivePhaseDuration(activeEntry, nowEpoch, nowTs);
963
+ // 同步刷新 stats.durationMs(活跃耗时)
964
+ if (phase === 'techDesign')
965
+ progress.stats.durationMs.techDesign = activeEntry.durationMs;
966
+ if (phase === 'propose')
967
+ progress.stats.durationMs.propose = activeEntry.durationMs;
968
+ if (phase === 'apply')
969
+ progress.stats.durationMs.apply = activeEntry.durationMs;
970
+ if (phase === 'review')
971
+ progress.stats.durationMs.review = activeEntry.durationMs;
972
+ if (phase === 'archive')
973
+ progress.stats.durationMs.archive = activeEntry.durationMs;
974
+ // 同步 stats 的可选双轨字段
975
+ if (!progress.stats.wallClockMs)
976
+ progress.stats.wallClockMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
977
+ if (!progress.stats.idleMs)
978
+ progress.stats.idleMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
979
+ if (phase !== 'idle') {
980
+ progress.stats.wallClockMs[phase] = activeEntry.wallClockMs || 0;
981
+ progress.stats.idleMs[phase] = activeEntry.idleMs || 0;
918
982
  }
919
983
  }
920
984
  // === Auto-count tasks from tasks.md ===
@@ -1101,6 +1165,10 @@ function createNewProgress(changeId) {
1101
1165
  durationMs: 0,
1102
1166
  taskCount: 0,
1103
1167
  completedTaskCount: 0,
1168
+ wallClockMs: 0,
1169
+ idleMs: 0,
1170
+ lastActiveAt: getBeijingTime(),
1171
+ _lastActiveEpoch: Date.now(),
1104
1172
  }],
1105
1173
  stats: {
1106
1174
  tokenUsageTotal: 0,
@@ -1167,17 +1235,16 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1167
1235
  timestamp,
1168
1236
  triggeredBy: 'initializeProgress',
1169
1237
  });
1170
- // Update previous phase duration
1238
+ // Update previous phase duration with tick
1171
1239
  const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
1172
1240
  if (prevPhaseDuration) {
1241
+ // 封段前 tick,确保最后活跃时间被累加
1242
+ const nowEpoch = Date.now();
1243
+ tickActivePhaseDuration(prevPhaseDuration, nowEpoch, timestamp);
1173
1244
  prevPhaseDuration.endedAt = timestamp;
1174
- prevPhaseDuration.lastUpdatedAt = timestamp;
1175
- const startTime = new Date(prevPhaseDuration.startedAt.replace(' ', 'T')).getTime();
1176
- const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1177
- const ms = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
1178
- prevPhaseDuration.durationMs = ms;
1179
- if (previousPhase && previousPhase !== 'idle' && ms > 0 && progress.stats?.durationMs) {
1180
- progress.stats.durationMs[previousPhase] = ms;
1245
+ // 同步 stats
1246
+ if (previousPhase && previousPhase !== 'idle' && progress.stats?.durationMs) {
1247
+ progress.stats.durationMs[previousPhase] = prevPhaseDuration.durationMs;
1181
1248
  }
1182
1249
  }
1183
1250
  else if (previousPhase && previousPhase !== 'idle') {
@@ -1211,6 +1278,10 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1211
1278
  durationMs: 0,
1212
1279
  taskCount: 0,
1213
1280
  completedTaskCount: 0,
1281
+ wallClockMs: 0,
1282
+ idleMs: 0,
1283
+ lastActiveAt: timestamp,
1284
+ _lastActiveEpoch: Date.now(),
1214
1285
  });
1215
1286
  }
1216
1287
  progress.phase = initialPhase;
@@ -1281,14 +1352,12 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1281
1352
  progress.phaseDurations = progress.phaseDurations || [];
1282
1353
  const activeEntry = progress.phaseDurations.find(pd => pd.phase === initialPhase && !pd.endedAt);
1283
1354
  if (!activeEntry) {
1284
- // Close any still-open legacy entry (e.g. the placeholder 'idle' entry created in createNewProgress)
1355
+ // Close any still-open legacy entry with tick
1285
1356
  for (const entry of progress.phaseDurations) {
1286
1357
  if (!entry.endedAt && entry.phase !== initialPhase) {
1358
+ const nowEpoch = Date.now();
1359
+ tickActivePhaseDuration(entry, nowEpoch, timestamp);
1287
1360
  entry.endedAt = timestamp;
1288
- entry.lastUpdatedAt = timestamp;
1289
- const startTime = new Date((entry.startedAt || timestamp).replace(' ', 'T')).getTime();
1290
- const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1291
- entry.durationMs = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
1292
1361
  }
1293
1362
  }
1294
1363
  progress.phaseDurations.push({
@@ -1298,6 +1367,10 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1298
1367
  durationMs: 0,
1299
1368
  taskCount: 0,
1300
1369
  completedTaskCount: 0,
1370
+ wallClockMs: 0,
1371
+ idleMs: 0,
1372
+ lastActiveAt: timestamp,
1373
+ _lastActiveEpoch: Date.now(),
1301
1374
  });
1302
1375
  }
1303
1376
  }
@@ -188,7 +188,10 @@ export async function userInputHook(content, responseTimeMs) {
188
188
  // Skip slash/bang commands (e.g. /zhuanspec:proposal, /compact, /clear, !ls).
189
189
  // 这类 prompt 是命令/workflow 触发,非“用户对 AI 产出的纠偏/濄清/补充”,
190
190
  // 不应计入 user_inputs.json,更不应放进纠偏溯源池影响 accuracyRate。
191
- const trimmedContent = content.trim();
191
+ // 注意:Claude Code 在 slash 命令前会注入 NAK(U+0015) 等 C0 控制字符做标记,
192
+ // 普通 trim() 不会剥控制字符,必须显式剥掉前缀的 C0/DEL 再做正则判定,
193
+ // 否则像 "\u0015/zhuanspec:proposal" 这种命令会漏过过滤被记成普通用户输入。
194
+ const trimmedContent = content.trim().replace(/^[\x00-\x1F\x7F]+/, '');
192
195
  if (/^[/!][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
193
196
  logDebug(cwd, {
194
197
  event: 'skip',
@@ -432,6 +432,9 @@ const applySteps = `**步骤**
432
432
  - 阅读 \`changes/<id>/proposal.md\`、\`design.md\`(如果存在)和 \`tasks.md\` 以确认范围和验收标准。
433
433
  2. **Wave 并行执行(默认且唯一执行模式)**
434
434
  - **执行模型**:按 Wave 编号串行,Wave 内任务并行启动 subagent。
435
+ - **⚠️ 每个 Wave 启动前 MUST 重读核心机制(防长任务指令衰减)**:在为新 Wave 启动 subagent 前,编排 Agent **必须**在自己的输出里**逐字复述**以下两句话,作为执行前置自检(缺失即流程故障):
436
+ 1. "本 Wave 的每个任务 MUST 通过 Agent tool / spawn_agent 启动 subagent,禁止主线程串行手写代码或手写报告。"
437
+ 2. "任务报告必须由 subagent 生成,且必须符合 .claude/agents/tdd-apply-agent.md 或 apply-agent.md 的模板章节。"
435
438
  - **并行度限制**:每个 Wave 内最多同时启动 3 个 subagent。超过 3 个任务时,按批次(batch)执行,每批最多 3 个并行 subagent,批次间串行等待。
436
439
  - **Subagent 调用 @skill 后的行为**(任务标注 \`@skill:<skill-name>\` 时):
437
440
  1. 执行 @skill 标注的 Skill
@@ -512,6 +515,15 @@ const applySteps = `**步骤**
512
515
  - 同 Wave 所有任务(含阻塞任务)全部处理完成后 → 进入集成测试阶段
513
516
 
514
517
  **B. 集成测试阶段(MANDATORY,同 Wave 所有任务处理完成后)**:
518
+ B0. **任务报告 schema 校验(MANDATORY GATE,最先执行,不得跳过)**:
519
+ - 运行 \`zhuanspec validate-reports <change-id> --json\` 校验本 Wave 所有 \`reports/task-*.md\`
520
+ - 检查内容(基于 \`.claude/agents/tdd-apply-agent.md\` / \`apply-agent.md\` 模板):
521
+ * 公共字段:\`## 状态报告\` / Status / Task ID / Report File / \`### Agent 选择决策\` / Agent 类型
522
+ * tddApplyAgent 报告必须有:TDD Phase / \`### 上下文就绪摘要\` / \`### 验收点映射表\` / \`#### Verify RED\` / \`#### Verify GREEN\` / \`#### Mock Gate\` / \`### 测试运行结果\` / \`### Self-Review 发现\` / \`### Issues/Concerns\`
523
+ * applyAgent 报告必须有:\`### 实施内容\` / \`### 修改文件\` / \`### Self-Review 发现\` / \`### Issues/Concerns\`
524
+ * 交叉对账:tasks.md 中带 \`@test-case:TC-XXX\` 的任务,报告必须为 tddApplyAgent,否则必须显式声明 "TDD 适用性: 不适合(降级原因:...)"
525
+ - 任意报告校验失败 → **该任务强制视为 BLOCKED**,按 B5 流程以 Execution Mode = RETRY 重新 spawn subagent 产出合规报告;禁止跳过本步直接进入 B1
526
+ - 校验失败的根因通常是:编排 Agent 在主线程手写代码 + 手写偷工报告,没有真的 spawn subagent。修复方式:通过 Agent tool / spawn_agent 重新下发任务
515
527
  B1. **编译检查**:运行项目编译命令,确保无编译错误
516
528
  - Java: \`mvn compile -q\` / \`gradle compileJava\`
517
529
  - TypeScript: \`tsc --noEmit\`
@@ -588,6 +600,7 @@ const applySteps = `**步骤**
588
600
  B6. **生成集成测试报告(MANDATORY,不得跳过)**:
589
601
  - 报告路径:\`changes/<change-id>/reports/wave-{N}-integration-report.md\`
590
602
  - 记录所有任务执行结果、编译检查结果、阻塞任务重试结果
603
+ - **MUST 记录 B0 报告 schema 校验结果**(通过/失败任务列表,失败时附 \`validate-reports --json\` 输出)
591
604
  - **MUST 包含 Concerns 追踪章节**,列出所有 DONE_WITH_CONCERNS 和 BLOCKED 任务及其疑虑
592
605
  - **MUST 包含 \`## 🔁 待重试任务清单\` 段**(即使为空也要写 \`— 无需重跑\`),与 B4.1 第五步、B5 重试结果汇总一致
593
606
  - 整体状态:所有检查通过且无未解决的阻塞任务 → PASS
@@ -713,6 +726,7 @@ const archiveReferences = `**参考**
713
726
  - **AI 手动**(archive 命令前):调用 \`zhuanspec:knowledge\` skill 从会话记忆补充隐式约定、调试发现等。**必须在 \`zhuanspec archive\` 命令之前**调用,否则补充内容仅落本地、不会进入远端推送
714
727
  - 会话分析 (\`session-analytics\`):记录本次会话的效率指标,用于改进工作流。`;
715
728
  const designGuardrails = `${baseGuardrails}\n- **独立设计阶段**:techDesign 命令用于在 proposal 之前生成技术设计请求文档,不依赖变更提案。设计文档可作为后续提案的输入。
729
+ - **Skill 调用连续性(红线)**:在 techDesign 流程中调用任何辅助类 Skill(如 \`@skill:load-project-knowledge\`)后,**必须立即推进到下一编号步骤**,禁止把 Skill 的输出当作流程终态,禁止停顿等待用户输入“继续/下一步”等确认词。只有在显式标注的 AskUserQuestion 步骤(如开发范围确认、需求来源确认、外部依赖确认)才允许暂停等待用户。
716
730
  - **需求澄清优先**:在生成设计请求前,必须确认需求来源(大神页面、需求描述文本等)。
717
731
  - **开发范围前置确认(硬约束)**:在调用技术方案 Skill 前,**必须**先通过 AskUserQuestion 确认开发范围是“仅后端开发”还是“全栈开发”,根据答复选择对应 Skill(仅后端=\`generate-tech-spec-md-skill\`,全栈=\`generate-fullstack-tech-spec-skill\`),禁止默认或跳过此确认环节。
718
732
  - **Skill 可用性前置检查(硬约束)**:在实际调用技术方案 Skill 前,**必须**先校验目标 Skill 是否已安装且可用;**若不可用,立即中断流程**并提示用户到 Skill 市场安装对应 Skill,禁止以人工编写/其他 Skill 代替。
@@ -722,8 +736,13 @@ const designGuardrails = `${baseGuardrails}\n- **独立设计阶段**:techDesi
722
736
  - **禁止创建提案文件**:禁止创建 .tech-design、design.md、proposal.md、tasks.md、specs/ 等。
723
737
  - **Proposal 复用**:proposal 阶段通过 progress.json 的 phase 字段识别 techDesign 目录。`;
724
738
  const designSteps = `**步骤**
725
- 0. **检查知识库并生成 change-id**:
739
+ 0. **检查知识库、加载项目知识并生成 change-id**:
726
740
  - 运行 \`rg "[需求关键词]" zhuanspec/knowledge/\` 搜索相关陷阱和最佳实践,避免重复踩坑。阅读 \`zhuanspec/knowledge/index.md\` 了解项目级知识摘要。
741
+ - **加载项目知识(必选)**:调用 \`@skill:load-project-knowledge\` 进行渐进式加载:
742
+ * 输入:domain(当前项目)、keywords(从需求描述提取的核心关键词)
743
+ * 获取:matched_services(涉及的服务列表)、search_priority(检索优先路径)、architecture_constraints(架构约束)
744
+ * 使用:后续技术方案设计需引用 matched_services 作为服务定位,方案设计需检查 architecture_constraints
745
+ * ⚠️ **连续性约束(红线)**:\`load-project-knowledge\` 完成并输出 matched_services 表后,**必须立即继续执行下面的“生成 change-id”子步骤以及步骤 1(Phase 初始化)**,禁止停顿等待用户输入“继续”。该 Skill 只是辅助加载,不是流程门禁。
727
746
  - **生成 change-id**(与 proposal 阶段命名规则一致):
728
747
  * 从用户需求描述中提取核心动词和关键词
729
748
  * 格式:动词开头 + kebab-case 关词组合
@@ -191,18 +191,24 @@ Answer: <!-- user answer -->
191
191
 
192
192
  ### Wave 1(底层:DAO / 外部 Assemble)
193
193
 
194
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent 执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
195
+
194
196
  <!-- Wave 1: Tasks with no dependencies, 层归属限定 dao/assemble/ddl/config -->
195
197
 
196
198
  - [ ] 1.1 <!-- Task description --> @layer:dao @skill:none <!-- 纯配置变更或手动操作 -->
197
199
 
198
200
  ### Wave 2(中间层:Domain / Application)
199
201
 
202
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent 执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
203
+
200
204
  <!-- Wave 2: Tasks depending on Wave 1, 层归属限定 domain/application -->
201
205
 
202
206
  - [ ] 2.1 <!-- Task description --> @depends:1.1 @layer:domain @skill:none <!-- 无需特定 skill -->
203
207
 
204
208
  ### Wave 3(顶层:SCF / MQ / 定时任务 / 前端)
205
209
 
210
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent 执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
211
+
206
212
  <!-- Wave 3: Tasks depending on Wave 2, 层归属限定 entry-scf/entry-mq-*/entry-job/fe -->
207
213
 
208
214
  - [ ] 3.1 <!-- Task description --> @depends:2.1 @layer:entry-scf @skill:none <!-- 无需特定 skill -->
@@ -153,6 +153,8 @@ Answer: <!-- user answer -->
153
153
 
154
154
  ### Wave 2
155
155
 
156
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent(优先 tddApplyAgent)执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
157
+
156
158
  <!-- Wave 2: Red Phase - Write failing tests that define expected behavior -->
157
159
  <!-- 先写测试,测试应当初始失败 — 证明测试确实在验证有意义的行为 -->
158
160
  <!-- 每个测试任务必须使用类型A/B模板,明确测试文件路径、测试方法签名、断言内容 -->
@@ -162,6 +164,8 @@ Answer: <!-- user answer -->
162
164
 
163
165
  ### Wave 3
164
166
 
167
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent(优先 tddApplyAgent)执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
168
+
165
169
  <!-- Wave 3: Green Phase - Write minimal code to make tests pass -->
166
170
  <!-- 编写最少代码使测试通过,每个任务必须使用类型A(新增)或类型B(修改)模板 -->
167
171
 
@@ -170,6 +174,8 @@ Answer: <!-- user answer -->
170
174
 
171
175
  ### Wave 4
172
176
 
177
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent(优先 tddApplyAgent)执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
178
+
173
179
  <!-- Wave 4: Refactor Phase - Clean up code while keeping tests green -->
174
180
  <!-- 在保持测试通过的前提下重构,每个任务使用类型B模板标明修改位置 -->
175
181
 
@@ -178,6 +184,8 @@ Answer: <!-- user answer -->
178
184
 
179
185
  ### Wave 5
180
186
 
187
+ > ⚠️ **MUST (subagent gate)**: 本 Wave 每个任务必须通过 Agent tool / spawn_agent 启动 subagent 执行;主线程直接施工产生的报告会被 B0 schema 校验(\`zhuanspec validate-reports <change-id>\`)拦截并触发 RETRY。
188
+
181
189
  <!-- Wave 5: Documentation - Document the implemented feature -->
182
190
 
183
191
  - [ ] 5.1 <!-- 更新 API 文档 --> @depends:4.2 @skill:none <!-- justification: documentation task -->
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Apply 阶段任务报告 schema 校验。
3
+ *
4
+ * 背景:编排 Agent 在长任务尾部容易"指令衰减",跳过 Agent tool / spawn_agent
5
+ * 的 subagent 调用,自己在主线程顺手把任务做掉再补一份偷工的报告。这种偷工
6
+ * 报告会缺失 tdd-apply-agent.md / apply-agent.md 模板里强制的章节
7
+ * (TDD Phase / Verify RED / Verify GREEN / Mock Gate / Skill 调用记录 ...)。
8
+ *
9
+ * 本模块对 `zhuanspec/changes/<id>/reports/task-*.md` 做轻量结构校验:
10
+ * - 必备公共字段:`## 状态报告` / Status / Task ID / Report File / Agent 选择决策 / Agent 类型
11
+ * - 当 Agent 类型 = `tddApplyAgent`:再校验 TDD Phase / 上下文就绪摘要 /
12
+ * 验收点映射表 / TDD 执行过程(Verify RED + Verify GREEN)/ 测试运行结果 /
13
+ * Self-Review 发现 / Issues
14
+ * - 当 Agent 类型 = `applyAgent`:校验实施内容 / 修改文件 / Self-Review 发现 / Issues
15
+ *
16
+ * 与 tasks.md 交叉对账:若任务在 tasks.md 里带 `@test-case:TC-XXX`,则该任务的
17
+ * 报告原则上必须由 `tddApplyAgent` 产出(除非报告里显式声明降级原因,对应
18
+ * apply-agent.md 中的"降级使用 applyAgent"路径)。
19
+ */
20
+ export type ReportAgentType = 'tddApplyAgent' | 'applyAgent' | 'unknown';
21
+ export interface ReportIssue {
22
+ level: 'ERROR' | 'WARNING';
23
+ taskId: string;
24
+ message: string;
25
+ }
26
+ export interface SingleReportResult {
27
+ /** Task id parsed from filename, e.g. "1.1". */
28
+ taskId: string;
29
+ /** Absolute path to the report file. */
30
+ filePath: string;
31
+ /** Agent type declared inside report, or 'unknown' when absent. */
32
+ declaredAgentType: ReportAgentType;
33
+ /** Agent type expected from tasks.md (`@test-case` ⇒ tddApplyAgent). */
34
+ expectedAgentType: ReportAgentType;
35
+ /** Whether the report has a downgrade justification (i.e. claims "降级"/"不适合 TDD"). */
36
+ hasDowngradeJustification: boolean;
37
+ /** Section / field names that were required but not found. */
38
+ missing: string[];
39
+ /** Issues collected for this report. */
40
+ issues: ReportIssue[];
41
+ /** Whether the report passes schema validation. */
42
+ valid: boolean;
43
+ }
44
+ export interface ReportSchemaSummary {
45
+ changeId: string;
46
+ reportsDir: string;
47
+ /** Total reports inspected. */
48
+ totalReports: number;
49
+ /** Reports passing all schema checks. */
50
+ passedReports: number;
51
+ /** Reports failing schema checks. */
52
+ failedReports: number;
53
+ /** Tasks in tasks.md that have no report file at all. */
54
+ missingReports: string[];
55
+ /** Per-report results. */
56
+ reports: SingleReportResult[];
57
+ /** Overall validity (no ERROR-level issues + no missing reports). */
58
+ valid: boolean;
59
+ }
60
+ /**
61
+ * Inspect a single report file. Pure string-level checks — no side effects.
62
+ *
63
+ * @param taskId The task id (derived from filename).
64
+ * @param content The raw markdown content of the report file.
65
+ * @param expected The agent type expected from tasks.md (default 'unknown').
66
+ */
67
+ export declare function checkReportContent(taskId: string, filePath: string, content: string, expected: ReportAgentType): SingleReportResult;
68
+ /**
69
+ * Validate all task reports under `<changeDir>/reports/`.
70
+ * Cross-checks against `<changeDir>/tasks.md` to detect missing reports and
71
+ * agent-type mismatches.
72
+ */
73
+ export declare function validateTaskReports(changeDir: string): Promise<ReportSchemaSummary>;
74
+ //# sourceMappingURL=report-schema.d.ts.map
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Apply 阶段任务报告 schema 校验。
3
+ *
4
+ * 背景:编排 Agent 在长任务尾部容易"指令衰减",跳过 Agent tool / spawn_agent
5
+ * 的 subagent 调用,自己在主线程顺手把任务做掉再补一份偷工的报告。这种偷工
6
+ * 报告会缺失 tdd-apply-agent.md / apply-agent.md 模板里强制的章节
7
+ * (TDD Phase / Verify RED / Verify GREEN / Mock Gate / Skill 调用记录 ...)。
8
+ *
9
+ * 本模块对 `zhuanspec/changes/<id>/reports/task-*.md` 做轻量结构校验:
10
+ * - 必备公共字段:`## 状态报告` / Status / Task ID / Report File / Agent 选择决策 / Agent 类型
11
+ * - 当 Agent 类型 = `tddApplyAgent`:再校验 TDD Phase / 上下文就绪摘要 /
12
+ * 验收点映射表 / TDD 执行过程(Verify RED + Verify GREEN)/ 测试运行结果 /
13
+ * Self-Review 发现 / Issues
14
+ * - 当 Agent 类型 = `applyAgent`:校验实施内容 / 修改文件 / Self-Review 发现 / Issues
15
+ *
16
+ * 与 tasks.md 交叉对账:若任务在 tasks.md 里带 `@test-case:TC-XXX`,则该任务的
17
+ * 报告原则上必须由 `tddApplyAgent` 产出(除非报告里显式声明降级原因,对应
18
+ * apply-agent.md 中的"降级使用 applyAgent"路径)。
19
+ */
20
+ import path from 'path';
21
+ import { promises as fs } from 'fs';
22
+ /** Common required fields for ANY task report (applyAgent or tddApplyAgent). */
23
+ const COMMON_REQUIRED = [
24
+ '## 状态报告',
25
+ 'Status',
26
+ 'Task ID',
27
+ 'Report File',
28
+ '### Agent 选择决策',
29
+ 'Agent 类型',
30
+ ];
31
+ /** Additional required sections when declaredAgentType === 'tddApplyAgent'. */
32
+ const TDD_REQUIRED = [
33
+ 'Wave',
34
+ 'Execution Mode',
35
+ 'TDD Phase',
36
+ '### 上下文就绪摘要',
37
+ '### Skill 调用记录',
38
+ '### 验收点映射表',
39
+ '### TDD 执行过程',
40
+ '#### Verify RED',
41
+ '#### Verify GREEN',
42
+ '#### Mock Gate',
43
+ '### 测试内容',
44
+ '### 测试运行结果',
45
+ '### Self-Review 发现',
46
+ '### Issues/Concerns',
47
+ ];
48
+ /** Additional required sections when declaredAgentType === 'applyAgent'. */
49
+ const APPLY_REQUIRED = [
50
+ '### 实施内容',
51
+ '### 修改文件',
52
+ '### Self-Review 发现',
53
+ '### Issues/Concerns',
54
+ ];
55
+ const TASK_REPORT_FILE_RE = /^task-(\d+\.\d+(?:[a-z])?)-report\.md$/i;
56
+ const AGENT_TYPE_LINE_RE = /\*\*Agent\s+类型\*\*\s*[::]\s*([A-Za-z]+)/;
57
+ const TASK_LINE_RE = /^-\s+\[[ xX]\]\s+(\d+\.\d+)\s+/;
58
+ /**
59
+ * Inspect a single report file. Pure string-level checks — no side effects.
60
+ *
61
+ * @param taskId The task id (derived from filename).
62
+ * @param content The raw markdown content of the report file.
63
+ * @param expected The agent type expected from tasks.md (default 'unknown').
64
+ */
65
+ export function checkReportContent(taskId, filePath, content, expected) {
66
+ const declaredAgentType = detectAgentType(content);
67
+ const hasDowngradeJustification = /降级|不适合\s*TDD|TDD\s*适用性\s*[::]\s*不适合/.test(content);
68
+ const missing = [];
69
+ const issues = [];
70
+ // 1) Common required fields/sections.
71
+ for (const token of COMMON_REQUIRED) {
72
+ if (!content.includes(token))
73
+ missing.push(token);
74
+ }
75
+ // 2) Type-specific required sections.
76
+ const typeRequired = declaredAgentType === 'tddApplyAgent'
77
+ ? TDD_REQUIRED
78
+ : declaredAgentType === 'applyAgent'
79
+ ? APPLY_REQUIRED
80
+ : [];
81
+ for (const token of typeRequired) {
82
+ if (!content.includes(token))
83
+ missing.push(token);
84
+ }
85
+ // 3) Aggregate ERROR-level issues from missing sections.
86
+ if (missing.length > 0) {
87
+ issues.push({
88
+ level: 'ERROR',
89
+ taskId,
90
+ message: `报告缺失必需章节/字段:${missing.join(', ')}`,
91
+ });
92
+ }
93
+ // 4) Cross-check against expected agent type.
94
+ if (expected === 'tddApplyAgent' &&
95
+ declaredAgentType === 'applyAgent' &&
96
+ !hasDowngradeJustification) {
97
+ issues.push({
98
+ level: 'ERROR',
99
+ taskId,
100
+ message: 'tasks.md 标注 @test-case 应使用 tddApplyAgent;报告声明 applyAgent 但未给出降级理由("TDD 适用性: 不适合(...)")',
101
+ });
102
+ }
103
+ if (declaredAgentType === 'unknown') {
104
+ issues.push({
105
+ level: 'ERROR',
106
+ taskId,
107
+ message: '报告未声明 Agent 类型(缺失 "Agent 类型: applyAgent | tddApplyAgent")',
108
+ });
109
+ }
110
+ // 5) Heuristic: tddApplyAgent must show real test command output evidence.
111
+ if (declaredAgentType === 'tddApplyAgent') {
112
+ const hasTestEvidence = /mvn\s+test|npm\s+(?:test|run\s+test)|pnpm\s+test|yarn\s+test|gradle\s+test|vitest|jest/i.test(content);
113
+ if (!hasTestEvidence) {
114
+ issues.push({
115
+ level: 'ERROR',
116
+ taskId,
117
+ message: 'tddApplyAgent 报告缺少测试运行证据(未发现 mvn test / npm test / vitest / jest 等命令痕迹)',
118
+ });
119
+ }
120
+ }
121
+ const valid = issues.every((issue) => issue.level !== 'ERROR');
122
+ return {
123
+ taskId,
124
+ filePath,
125
+ declaredAgentType,
126
+ expectedAgentType: expected,
127
+ hasDowngradeJustification,
128
+ missing,
129
+ issues,
130
+ valid,
131
+ };
132
+ }
133
+ /**
134
+ * Validate all task reports under `<changeDir>/reports/`.
135
+ * Cross-checks against `<changeDir>/tasks.md` to detect missing reports and
136
+ * agent-type mismatches.
137
+ */
138
+ export async function validateTaskReports(changeDir) {
139
+ const changeId = path.basename(changeDir);
140
+ const reportsDir = path.join(changeDir, 'reports');
141
+ // Read tasks.md to figure out the expected task list and agent type per task.
142
+ const expectedByTaskId = await parseExpectedAgentTypes(path.join(changeDir, 'tasks.md'));
143
+ let entries = [];
144
+ try {
145
+ entries = await fs.readdir(reportsDir);
146
+ }
147
+ catch {
148
+ // No reports directory at all — report every expected task as missing.
149
+ return {
150
+ changeId,
151
+ reportsDir,
152
+ totalReports: 0,
153
+ passedReports: 0,
154
+ failedReports: 0,
155
+ missingReports: Array.from(expectedByTaskId.keys()),
156
+ reports: [],
157
+ valid: expectedByTaskId.size === 0,
158
+ };
159
+ }
160
+ const reportFiles = entries
161
+ .filter((e) => TASK_REPORT_FILE_RE.test(e))
162
+ .sort();
163
+ const seenTaskIds = new Set();
164
+ const results = [];
165
+ for (const file of reportFiles) {
166
+ const match = file.match(TASK_REPORT_FILE_RE);
167
+ if (!match)
168
+ continue;
169
+ const taskId = match[1];
170
+ seenTaskIds.add(taskId);
171
+ const filePath = path.join(reportsDir, file);
172
+ let content;
173
+ try {
174
+ content = await fs.readFile(filePath, 'utf-8');
175
+ }
176
+ catch {
177
+ results.push({
178
+ taskId,
179
+ filePath,
180
+ declaredAgentType: 'unknown',
181
+ expectedAgentType: expectedByTaskId.get(taskId) ?? 'unknown',
182
+ hasDowngradeJustification: false,
183
+ missing: ['<file unreadable>'],
184
+ issues: [
185
+ {
186
+ level: 'ERROR',
187
+ taskId,
188
+ message: '报告文件无法读取',
189
+ },
190
+ ],
191
+ valid: false,
192
+ });
193
+ continue;
194
+ }
195
+ const expected = expectedByTaskId.get(taskId) ?? 'unknown';
196
+ results.push(checkReportContent(taskId, filePath, content, expected));
197
+ }
198
+ // Tasks declared in tasks.md but never produced a report.
199
+ const missingReports = [];
200
+ for (const taskId of expectedByTaskId.keys()) {
201
+ if (!seenTaskIds.has(taskId))
202
+ missingReports.push(taskId);
203
+ }
204
+ const passedReports = results.filter((r) => r.valid).length;
205
+ const failedReports = results.length - passedReports;
206
+ return {
207
+ changeId,
208
+ reportsDir,
209
+ totalReports: results.length,
210
+ passedReports,
211
+ failedReports,
212
+ missingReports,
213
+ reports: results,
214
+ valid: failedReports === 0 && missingReports.length === 0,
215
+ };
216
+ }
217
+ /** Detect declared agent type from a report's body. */
218
+ function detectAgentType(content) {
219
+ const m = content.match(AGENT_TYPE_LINE_RE);
220
+ if (!m)
221
+ return 'unknown';
222
+ const v = m[1].trim();
223
+ if (v === 'tddApplyAgent')
224
+ return 'tddApplyAgent';
225
+ if (v === 'applyAgent')
226
+ return 'applyAgent';
227
+ return 'unknown';
228
+ }
229
+ /**
230
+ * Parse `tasks.md` to determine the expected agent type for each task.
231
+ * Tasks tagged with `@test-case:TC-XXX` are expected to use `tddApplyAgent`;
232
+ * everything else defaults to `applyAgent`.
233
+ */
234
+ async function parseExpectedAgentTypes(tasksFile) {
235
+ const result = new Map();
236
+ let content;
237
+ try {
238
+ content = await fs.readFile(tasksFile, 'utf-8');
239
+ }
240
+ catch {
241
+ return result;
242
+ }
243
+ const lines = content.split(/\r?\n/);
244
+ for (const line of lines) {
245
+ const m = line.match(TASK_LINE_RE);
246
+ if (!m)
247
+ continue;
248
+ const taskId = m[1];
249
+ const hasTestCase = /@test-case\s*:/.test(line);
250
+ result.set(taskId, hasTestCase ? 'tddApplyAgent' : 'applyAgent');
251
+ }
252
+ return result;
253
+ }
254
+ //# sourceMappingURL=report-schema.js.map
@@ -9,6 +9,19 @@
9
9
  */
10
10
  export type Phase = 'idle' | 'techDesign' | 'propose' | 'apply' | 'review' | 'archive';
11
11
  export declare const PHASE_ORDER: Phase[];
12
+ /**
13
+ * v2.16+:阶段“活跃耗时”计算的空闲阈值。
14
+ *
15
+ * 设计背景:原本 progress.json 中的 phaseDurations[].durationMs / stats.durationMs 都是
16
+ * 墙钟差,中断/午休/过夜/AskUserQuestion 长时间挂起都会被当成阶段耗时。
17
+ * 从 v2.16 起,相邻两次 record-progress 心跳间隔 < PHASE_IDLE_THRESHOLD_MS 才计入
18
+ * 活跃耗时(durationMs);超过阈值的 gap 被归类为空闲,计入 idleMs / idleSegments,
19
+ * 不计入 durationMs。
20
+ *
21
+ * 30 分钟阈值是取舍结果:避免误伤正常的本地阅读 / 思考 / 短暂离开,只剔除
22
+ * 明显的长时间中断、过夜、确认久未响应等场景。
23
+ */
24
+ export declare const PHASE_IDLE_THRESHOLD_MS: number;
12
25
  export declare const PHASE_MARKERS: Partial<Record<Phase, string>>;
13
26
  /**
14
27
  * Set phase in progress.json with full data preservation
@@ -9,8 +9,21 @@
9
9
  */
10
10
  import path from 'path';
11
11
  import { FileSystemUtils } from './file-system.js';
12
- import { recoverProgressJsonForWrite, getBeijingTime, atomicWriteJson, } from '../core/hooks/record-progress.js';
12
+ import { recoverProgressJsonForWrite, getBeijingTime, atomicWriteJson, tickActivePhaseDuration, } from '../core/hooks/record-progress.js';
13
13
  export const PHASE_ORDER = ['idle', 'techDesign', 'propose', 'apply', 'review', 'archive'];
14
+ /**
15
+ * v2.16+:阶段“活跃耗时”计算的空闲阈值。
16
+ *
17
+ * 设计背景:原本 progress.json 中的 phaseDurations[].durationMs / stats.durationMs 都是
18
+ * 墙钟差,中断/午休/过夜/AskUserQuestion 长时间挂起都会被当成阶段耗时。
19
+ * 从 v2.16 起,相邻两次 record-progress 心跳间隔 < PHASE_IDLE_THRESHOLD_MS 才计入
20
+ * 活跃耗时(durationMs);超过阈值的 gap 被归类为空闲,计入 idleMs / idleSegments,
21
+ * 不计入 durationMs。
22
+ *
23
+ * 30 分钟阈值是取舍结果:避免误伤正常的本地阅读 / 思考 / 短暂离开,只剔除
24
+ * 明显的长时间中断、过夜、确认久未响应等场景。
25
+ */
26
+ export const PHASE_IDLE_THRESHOLD_MS = 30 * 60 * 1000;
14
27
  export const PHASE_MARKERS = {
15
28
  techDesign: '.tech-design',
16
29
  apply: '.approved',
@@ -83,6 +96,10 @@ export async function setPhase(changeDir, phase) {
83
96
  durationMs: 0,
84
97
  taskCount: 0,
85
98
  completedTaskCount: 0,
99
+ wallClockMs: 0,
100
+ idleMs: 0,
101
+ lastActiveAt: timestamp,
102
+ _lastActiveEpoch: Date.now(),
86
103
  }],
87
104
  proposalChanges: [],
88
105
  stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 } },
@@ -119,6 +136,10 @@ export async function setPhase(changeDir, phase) {
119
136
  durationMs: 0,
120
137
  taskCount: 0,
121
138
  completedTaskCount: 0,
139
+ wallClockMs: 0,
140
+ idleMs: 0,
141
+ lastActiveAt: timestamp,
142
+ _lastActiveEpoch: Date.now(),
122
143
  }],
123
144
  proposalChanges: [],
124
145
  stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 } },
@@ -135,6 +156,10 @@ export async function setPhase(changeDir, phase) {
135
156
  durationMs: 0,
136
157
  taskCount: 0,
137
158
  completedTaskCount: 0,
159
+ wallClockMs: 0,
160
+ idleMs: 0,
161
+ lastActiveAt: timestamp,
162
+ _lastActiveEpoch: Date.now(),
138
163
  });
139
164
  }
140
165
  if (previousPhase !== phase) {
@@ -144,14 +169,13 @@ export async function setPhase(changeDir, phase) {
144
169
  timestamp,
145
170
  triggeredBy: 'setPhase',
146
171
  });
147
- // Update phaseDurations: fill endedAt for previous phase
172
+ // Update phaseDurations: close previous phase with tick
148
173
  const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
149
174
  if (prevPhaseDuration) {
175
+ // 封段前 tick 一次,确保最后一段活跃时间被累加
176
+ const nowEpoch = Date.now();
177
+ tickActivePhaseDuration(prevPhaseDuration, nowEpoch, timestamp);
150
178
  prevPhaseDuration.endedAt = timestamp;
151
- prevPhaseDuration.lastUpdatedAt = timestamp;
152
- const startTime = new Date(prevPhaseDuration.startedAt).getTime();
153
- const endTime = new Date(timestamp).getTime();
154
- prevPhaseDuration.durationMs = endTime > startTime ? endTime - startTime : 0;
155
179
  }
156
180
  // Add new phase duration record
157
181
  progress.phaseDurations.push({
@@ -161,6 +185,10 @@ export async function setPhase(changeDir, phase) {
161
185
  durationMs: 0,
162
186
  taskCount: 0,
163
187
  completedTaskCount: 0,
188
+ wallClockMs: 0,
189
+ idleMs: 0,
190
+ lastActiveAt: timestamp,
191
+ _lastActiveEpoch: Date.now(),
164
192
  });
165
193
  }
166
194
  progress.phase = phase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",
@@ -74,6 +74,7 @@
74
74
  "dependencies": {
75
75
  "@inquirer/core": "^10.2.2",
76
76
  "@inquirer/prompts": "^7.8.0",
77
+ "@rollup/rollup-darwin-x64": "^4.60.4",
77
78
  "chalk": "^5.5.0",
78
79
  "commander": "^14.0.0",
79
80
  "fast-glob": "^3.3.3",