@zhuan-ai/zhuanspec 2.16.2 → 2.16.4

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.
@@ -9,10 +9,11 @@
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
- import { isCodeFile, isTrackedFileForPhase, getOrCreatePhaseBucket, recomputeAccuracyRate, persistAccuracyJson, appendAccuracyDebugLog } from '../metrics/code-accuracy.js';
14
+ import { isCodeFile, isTrackedFileForPhase, getOrCreatePhaseBucket, recomputeAccuracyRate, persistAccuracyJson, appendAccuracyDebugLog, isReviewAutoFixActive, isMeaningfulUserCorrection, isTrivialAckPrompt, computePromptFileMaxLines } from '../metrics/code-accuracy.js';
15
15
  import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
16
+ import { countLineDiff } from '../../utils/line-diff.js';
16
17
  const fs = fsPromises;
17
18
  /**
18
19
  * Atomic write JSON file: write to temp file first, then rename
@@ -116,6 +117,10 @@ export async function recoverProgressJsonForWrite(filePath) {
116
117
  durationMs: 0,
117
118
  taskCount: 0,
118
119
  completedTaskCount: 0,
120
+ wallClockMs: 0,
121
+ idleMs: 0,
122
+ lastActiveAt: '',
123
+ _lastActiveEpoch: 0,
119
124
  }],
120
125
  stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 } },
121
126
  };
@@ -163,6 +168,69 @@ export function getBeijingTimeForFilename() {
163
168
  const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
164
169
  return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
165
170
  }
171
+ /**
172
+ * v2.16+ 活跃耗时累加器
173
+ *
174
+ * 每次 PostToolUse 心跳到达时调用,根据 gap = now - lastActiveAt 判断:
175
+ * gap < PHASE_IDLE_THRESHOLD_MS → 活跃,累加到 durationMs
176
+ * gap >= PHASE_IDLE_THRESHOLD_MS → 空闲,累加到 idleMs 并 push idleSegments
177
+ *
178
+ * 同步刷新 wallClockMs、lastActiveAt、lastUpdatedAt。
179
+ * 返回本次增量便于调用方决定是否刷新 stats。
180
+ */
181
+ export function tickActivePhaseDuration(entry, nowEpoch = Date.now(), nowTimestamp = getBeijingTime()) {
182
+ // 初始化可选字段
183
+ if (entry.idleMs === undefined)
184
+ entry.idleMs = 0;
185
+ if (entry.wallClockMs === undefined)
186
+ entry.wallClockMs = 0;
187
+ const parse = (s) => {
188
+ if (!s)
189
+ return null;
190
+ const ms = new Date(s.replace(' ', 'T')).getTime();
191
+ return Number.isFinite(ms) ? ms : null;
192
+ };
193
+ // 优先使用毫秒级 epoch(精确),fallback 到秒级字符串解析(兼容老数据)
194
+ const lastTickStr = entry.lastActiveAt || entry.lastUpdatedAt || entry.startedAt;
195
+ const lastTickEpoch = entry._lastActiveEpoch ?? parse(lastTickStr);
196
+ const startEpoch = parse(entry.startedAt);
197
+ // 无法解析时间 → 仅刷新心跳时间
198
+ if (lastTickEpoch == null || startEpoch == null) {
199
+ entry.lastActiveAt = nowTimestamp;
200
+ entry.lastUpdatedAt = nowTimestamp;
201
+ entry._lastActiveEpoch = nowEpoch;
202
+ return { activeDelta: 0, idleDelta: 0 };
203
+ }
204
+ const gap = nowEpoch - lastTickEpoch;
205
+ let activeDelta = 0;
206
+ let idleDelta = 0;
207
+ if (gap > 0) {
208
+ if (gap < PHASE_IDLE_THRESHOLD_MS) {
209
+ // 活跃:累加到 durationMs
210
+ activeDelta = gap;
211
+ entry.durationMs = (entry.durationMs || 0) + gap;
212
+ }
213
+ else {
214
+ // 空闲:累加到 idleMs + 记录段
215
+ idleDelta = gap;
216
+ entry.idleMs = (entry.idleMs || 0) + gap;
217
+ entry.idleSegments = entry.idleSegments || [];
218
+ entry.idleSegments.push({
219
+ from: lastTickStr,
220
+ to: nowTimestamp,
221
+ durationMs: gap,
222
+ });
223
+ }
224
+ }
225
+ // 刷新 wallClockMs = now - startedAt
226
+ const wall = nowEpoch - startEpoch;
227
+ entry.wallClockMs = wall > 0 ? wall : (entry.wallClockMs || 0);
228
+ // 刷新心跳(字符串 + epoch 双写,epoch 用于下次 tick 精确计算)
229
+ entry.lastActiveAt = nowTimestamp;
230
+ entry.lastUpdatedAt = nowTimestamp;
231
+ entry._lastActiveEpoch = nowEpoch;
232
+ return { activeDelta, idleDelta };
233
+ }
166
234
  export async function recordProgressHook(options) {
167
235
  // Read stdin for Claude Code hook input
168
236
  let stdinData = {};
@@ -420,6 +488,20 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
420
488
  if (progress.stats.durationMs.techDesign === undefined) {
421
489
  progress.stats.durationMs.techDesign = 0;
422
490
  }
491
+ // v2.16+ 迁移:老 progress.json 的 phaseDurations entry 缺少 wallClockMs/idleMs/lastActiveAt
492
+ for (const entry of progress.phaseDurations) {
493
+ if (entry.wallClockMs === undefined) {
494
+ // 老版本 durationMs 是墙钟差,复制到 wallClockMs,durationMs 保留作为活跃近似
495
+ entry.wallClockMs = entry.durationMs || 0;
496
+ }
497
+ if (entry.idleMs === undefined) {
498
+ entry.idleMs = 0;
499
+ }
500
+ if (!entry.lastActiveAt) {
501
+ // 进行中 entry 用 lastUpdatedAt 兆底,已结束用 endedAt
502
+ entry.lastActiveAt = entry.endedAt || entry.lastUpdatedAt || entry.startedAt;
503
+ }
504
+ }
423
505
  }
424
506
  else {
425
507
  progress = createNewProgress(changeId);
@@ -618,6 +700,11 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
618
700
  const toolInputOldString = stdinData.tool_input?.old_string;
619
701
  const toolInputEdits = stdinData.tool_input?.edits;
620
702
  // 按工具类型计算本次变更涉及的行数
703
+ // 口径:
704
+ // Write → 全文行数(无旧本可参考,以完整写入量为准)
705
+ // Edit → countLineDiff(old, new):仅计增+删净改动行(不含上下文锚点)
706
+ // MultiEdit → 多个 edit 的 diff 累加
707
+ // 此口径对 techDesign / propose / apply / review 四阶段统一生效(均走 isTrackedFileForPhase 白名单)
621
708
  let changeLines = 0;
622
709
  let contentSource = 'none';
623
710
  if (toolName === 'Write') {
@@ -627,17 +714,14 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
627
714
  }
628
715
  }
629
716
  else if (toolName === 'Edit') {
630
- const newLines = toolInputNewString ? toolInputNewString.split('\n').length : 0;
631
- const oldLines = toolInputOldString ? toolInputOldString.split('\n').length : 0;
632
- // 取 max(new, old):体现本次编辑触及的代码量,兼容纯新增/纯删除/替换
633
- changeLines = Math.max(newLines, oldLines);
717
+ // 以前:Math.max(newLines, oldLines) — 含上下文锚点,虚高计数
718
+ // 现在:只计真正变动的行(增+删),跳过未变上下文
719
+ changeLines = countLineDiff(toolInputOldString, toolInputNewString);
634
720
  contentSource = (toolInputNewString || toolInputOldString) ? 'new_string' : 'none';
635
721
  }
636
722
  else if (toolName === 'MultiEdit' && Array.isArray(toolInputEdits)) {
637
723
  for (const edit of toolInputEdits) {
638
- const newLines = edit?.new_string ? edit.new_string.split('\n').length : 0;
639
- const oldLines = edit?.old_string ? edit.old_string.split('\n').length : 0;
640
- changeLines += Math.max(newLines, oldLines);
724
+ changeLines += countLineDiff(edit?.old_string, edit?.new_string);
641
725
  }
642
726
  contentSource = toolInputEdits.length > 0 ? 'edits[]' : 'none';
643
727
  }
@@ -719,40 +803,30 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
719
803
  correctionEdits: [], phaseAccuracy: [],
720
804
  };
721
805
  }
722
- const bucket = getOrCreatePhaseBucket(progress, trackedPhase, trackedKind);
723
- if (inCorrectionContext) {
724
- // 工具白名单守卫:techDesign / propose 阶段的纠偏统计只接受增量编辑(Edit/MultiEdit),
725
- // 禁止 Write。原因:tech-spec.md / proposal.md 通常是整文件 Write 产出,一旦被误判为纠偏,
726
- // 会把整份文件行数当成 userCorrectionLines 记入,导致分子被严重放大、accuracyRate 失真。
727
- // apply / review 阶段不受此限制(apply 可 Write 新增代码文件,review-report.md 也常由 Write 生成)。
728
- if ((trackedPhase === 'techDesign' || trackedPhase === 'propose') && toolName === 'Write') {
729
- correctionDebugLog(`skipped/write-blocked-${trackedPhase}-correction`, 'skipped', 0);
730
- appendAccuracyDebugLog(changeDir, {
731
- type: `skip.write-blocked-${trackedPhase}-correction`,
732
- phase: trackedPhase,
733
- kind: trackedKind,
734
- trigger: 'hook',
735
- changeId,
736
- skipReason: `write-blocked-${trackedPhase}-correction`,
737
- input: { tool: toolName, file: filePath, changeLines },
738
- });
739
- }
740
- else {
741
- // 纠偏上下文:桶 + 顶层 userCorrectionLines 同步累加
742
- bucket.userCorrectionLines += changeLines;
743
- bucket.lastUpdatedAt = timestamp;
744
- progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + changeLines;
745
- progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
746
- // 提取纠偏触发源头信息(Task 6)
747
- // post-baseline-fallback 兜底命中时,语义等价于 user-input hook 写入的 'post-baseline-prompt'。
806
+ // === Review 阶段特殊处理 ===
807
+ // 规则:
808
+ // 1. CR/单测 Skill 自动修复 → 不计入准确率(完全跳过)
809
+ // 2. 用户提示词触发的纠正 → 归属到 apply 阶段的准确率桶
810
+ // 3. review 阶段不产生独立的准确率 bucket
811
+ //
812
+ // 用户提示词识别(与 apply 阶段一致):
813
+ // - correction-keyword:命中显式纠偏关键词("不对"、"应该是"等)
814
+ // - post-baseline-prompt + 非琐碎确认:baseline 后的任何用户提示,
815
+ // 只要 promptSnippet 不是"继续"、"好的"等琐碎确认词。
816
+ // 斜杠/感叹号命令(/zhuanspec:、!ls)已在 user-input-hook 过滤。
817
+ // - .pending-correction marker 存在
818
+ //
819
+ // 自动修复 = 最近有 review autofix skill 调用(code-review-expert / generate-mockito-unit-test)
820
+ if (trackedPhase === 'review') {
821
+ // 判断是否为用户提示词触发的纠正
822
+ const meaningfulCC = isMeaningfulUserCorrection(progress.correctionContext ?? undefined);
823
+ const isExplicitUserCorrection = (ccActiveRaw && meaningfulCC) || pendingExists;
824
+ if (isExplicitUserCorrection) {
825
+ // 用户在 review 阶段的纠正 → 归到 apply/code bucket,影响 apply 准确率
826
+ const applyBucket = getOrCreatePhaseBucket(progress, 'apply', trackedKind);
827
+ // 提取纠偏触发源头信息
748
828
  const ccSource = progress.correctionContext?.source
749
- || (pendingExists
750
- ? 'pending-correction-marker'
751
- : (correctionFallbackReason === 'post-baseline-fallback'
752
- ? 'post-baseline-prompt'
753
- : 'correction-keyword'));
754
- // Task 6:若 correctionContext 无 prompt 元数据(典型于 pending-correction-marker 兑底路径),
755
- // 从 .pending-correction marker JSON 中回填 promptId / phaseInputIndex / promptSnippet。
829
+ || (pendingExists ? 'pending-correction-marker' : 'correction-keyword');
756
830
  let fallbackPromptId;
757
831
  let fallbackPromptIndex;
758
832
  let fallbackPromptSnippet;
@@ -773,6 +847,13 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
773
847
  const promptId = progress.correctionContext?.promptId ?? fallbackPromptId;
774
848
  const promptIndex = progress.correctionContext?.promptIndex ?? fallbackPromptIndex;
775
849
  const promptSnippet = progress.correctionContext?.promptSnippet ?? fallbackPromptSnippet;
850
+ // 去重:同 (promptId + filePath + apply phase) 取历史 max,只累加 delta
851
+ progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
852
+ const prevMax = computePromptFileMaxLines(progress.accuracy.correctionEdits, promptId, filePath, 'apply');
853
+ const dedupDelta = Math.max(0, changeLines - prevMax);
854
+ applyBucket.userCorrectionLines += dedupDelta;
855
+ applyBucket.lastUpdatedAt = timestamp;
856
+ progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + dedupDelta;
776
857
  progress.accuracy.correctionEdits.push({
777
858
  filePath,
778
859
  editLines: changeLines,
@@ -780,7 +861,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
780
861
  strategy: 'correction-context',
781
862
  agentType: progress.correctionContext?.path,
782
863
  timestamp,
783
- phase: trackedPhase,
864
+ phase: 'apply', // 归属到 apply(非 review)
784
865
  trackedKind,
785
866
  triggeredBy: {
786
867
  source: ccSource,
@@ -789,14 +870,14 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
789
870
  promptSnippet,
790
871
  },
791
872
  });
792
- // 记录 phase 维度 promptId(去重)
793
- if (promptId && !bucket.correctionPromptIds.includes(promptId)) {
794
- bucket.correctionPromptIds.push(promptId);
873
+ // 记录 apply bucket 的 promptId(去重)
874
+ if (promptId && !applyBucket.correctionPromptIds.includes(promptId)) {
875
+ applyBucket.correctionPromptIds.push(promptId);
795
876
  }
796
- correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', changeLines);
877
+ correctionDebugLog(`${toolName}/review-user-correction→apply`, 'userCorrectionLines', dedupDelta);
797
878
  appendAccuracyDebugLog(changeDir, {
798
879
  type: 'correction.record',
799
- phase: trackedPhase,
880
+ phase: 'apply', // 归属到 apply
800
881
  kind: trackedKind,
801
882
  trigger: 'hook',
802
883
  changeId,
@@ -805,39 +886,183 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
805
886
  file: filePath,
806
887
  changeLines,
807
888
  contentSource,
889
+ actualPhase: 'review',
890
+ redirectedTo: 'apply',
808
891
  ccSource,
809
892
  promptId,
810
893
  promptIndex,
894
+ prevMax,
895
+ dedupDelta,
811
896
  },
812
897
  output: {
813
- bucketCorrection: bucket.userCorrectionLines,
898
+ bucketCorrection: applyBucket.userCorrectionLines,
814
899
  topCorrection: progress.accuracy.userCorrectionLines,
815
- promptIdsCount: bucket.correctionPromptIds.length,
900
+ promptIdsCount: applyBucket.correctionPromptIds.length,
816
901
  },
817
902
  });
818
903
  }
904
+ else {
905
+ // 非用户提示词触发 → review 阶段自动修复、琐碎确认后的编辑、或 baseline fallback
906
+ // 判断是否为 Skill 自动修复(CR/单测)
907
+ const isAutoFix = isReviewAutoFixActive(progress.skillCalls || [], progress.correctionContext ?? undefined);
908
+ const ccSourceRaw = progress.correctionContext?.source;
909
+ const ccPromptSnippet = progress.correctionContext?.promptSnippet;
910
+ const ccTrivialAck = ccSourceRaw === 'post-baseline-prompt'
911
+ && isTrivialAckPrompt(ccPromptSnippet);
912
+ const skipReason = isAutoFix
913
+ ? 'review-skill-autofix'
914
+ : (ccTrivialAck ? 'review-trivial-ack' : 'review-non-correction');
915
+ correctionDebugLog(`skipped/${skipReason}`, 'skipped', 0);
916
+ appendAccuracyDebugLog(changeDir, {
917
+ type: `skip.${skipReason}`,
918
+ phase: trackedPhase,
919
+ kind: trackedKind,
920
+ trigger: 'hook',
921
+ changeId,
922
+ skipReason,
923
+ input: {
924
+ tool: toolName,
925
+ file: filePath,
926
+ changeLines,
927
+ inCorrectionContext,
928
+ baselineMarkedForPhase,
929
+ ccActiveRaw,
930
+ ccSource: ccSourceRaw,
931
+ ccTrivialAck,
932
+ isAutoFix,
933
+ },
934
+ });
935
+ // review 阶段自动修复、琐碎确认、非纠正编辑都完全跳过准确率统计
936
+ }
819
937
  }
820
938
  else {
821
- // 正常 AI 产出:桶 + 顶层 aiLinesAdded/aiTotalLines 同步累加,同时顶层 progress.linesAdded 仍需累加以兼容旧逻辑
822
- bucket.aiLinesAdded += changeLines;
823
- bucket.aiTotalLines += changeLines;
824
- bucket.lastUpdatedAt = timestamp;
825
- progress.accuracy.aiLinesAdded = (progress.accuracy.aiLinesAdded || 0) + changeLines;
826
- progress.accuracy.aiTotalLines = (progress.accuracy.aiTotalLines || 0) + changeLines;
827
- progress.linesAdded += changeLines;
828
- correctionDebugLog(`${toolName}/normal`, 'linesAdded', changeLines);
829
- appendAccuracyDebugLog(changeDir, {
830
- type: 'ai.record',
831
- phase: trackedPhase,
832
- kind: trackedKind,
833
- trigger: 'hook',
834
- changeId,
835
- input: { tool: toolName, file: filePath, changeLines, contentSource },
836
- output: {
837
- bucketAiTotal: bucket.aiTotalLines,
838
- topAiTotal: progress.accuracy.aiTotalLines,
839
- },
840
- });
939
+ // === 非 review 阶段:原有逻辑 ===
940
+ const bucket = getOrCreatePhaseBucket(progress, trackedPhase, trackedKind);
941
+ if (inCorrectionContext) {
942
+ // 工具白名单守卫:techDesign / propose 阶段的纠偏统计只接受增量编辑(Edit/MultiEdit),
943
+ // 禁止 Write。原因:tech-spec.md / proposal.md 通常是整文件 Write 产出,一旦被误判为纠偏,
944
+ // 会把整份文件行数当成 userCorrectionLines 记入,导致分子被严重放大、accuracyRate 失真。
945
+ // apply 阶段不受此限制(apply 可 Write 新增代码文件)。
946
+ if ((trackedPhase === 'techDesign' || trackedPhase === 'propose') && toolName === 'Write') {
947
+ correctionDebugLog(`skipped/write-blocked-${trackedPhase}-correction`, 'skipped', 0);
948
+ appendAccuracyDebugLog(changeDir, {
949
+ type: `skip.write-blocked-${trackedPhase}-correction`,
950
+ phase: trackedPhase,
951
+ kind: trackedKind,
952
+ trigger: 'hook',
953
+ changeId,
954
+ skipReason: `write-blocked-${trackedPhase}-correction`,
955
+ input: { tool: toolName, file: filePath, changeLines },
956
+ });
957
+ }
958
+ else {
959
+ // 纠偏上下文:提取 promptId,同 (promptId+file+phase) 取 max 计算 delta,桶 + 顶层 userCorrectionLines 同步累加 delta
960
+ // 提取纠偏触发源头信息(Task 6)
961
+ // post-baseline-fallback 兜底命中时,语义等价于 user-input hook 写入的 'post-baseline-prompt'。
962
+ const ccSource = progress.correctionContext?.source
963
+ || (pendingExists
964
+ ? 'pending-correction-marker'
965
+ : (correctionFallbackReason === 'post-baseline-fallback'
966
+ ? 'post-baseline-prompt'
967
+ : 'correction-keyword'));
968
+ // Task 6:若 correctionContext 无 prompt 元数据(典型于 pending-correction-marker 兑底路径),
969
+ // 从 .pending-correction marker JSON 中回填 promptId / phaseInputIndex / promptSnippet。
970
+ let fallbackPromptId;
971
+ let fallbackPromptIndex;
972
+ let fallbackPromptSnippet;
973
+ if (!progress.correctionContext?.promptId && pendingExists) {
974
+ try {
975
+ const markerRaw = readFileSync(pendingMarkerPath, 'utf-8');
976
+ if (markerRaw) {
977
+ const markerData = JSON.parse(markerRaw);
978
+ fallbackPromptId = markerData.promptId;
979
+ fallbackPromptIndex = markerData.phaseInputIndex;
980
+ fallbackPromptSnippet = markerData.promptSnippet;
981
+ }
982
+ }
983
+ catch {
984
+ // marker 解析失败不阻断主流程
985
+ }
986
+ }
987
+ const promptId = progress.correctionContext?.promptId ?? fallbackPromptId;
988
+ const promptIndex = progress.correctionContext?.promptIndex ?? fallbackPromptIndex;
989
+ const promptSnippet = progress.correctionContext?.promptSnippet ?? fallbackPromptSnippet;
990
+ // 去重:同 (promptId + filePath + phase) 取历史 max,只累加 delta。
991
+ // 语义:同一 prompt 中 AI 对同一文件的多次 Edit(重复修正同区域)不重复计数。
992
+ progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
993
+ const prevMax = computePromptFileMaxLines(progress.accuracy.correctionEdits, promptId, filePath, trackedPhase);
994
+ const dedupDelta = Math.max(0, changeLines - prevMax);
995
+ bucket.userCorrectionLines += dedupDelta;
996
+ bucket.lastUpdatedAt = timestamp;
997
+ progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + dedupDelta;
998
+ progress.accuracy.correctionEdits.push({
999
+ filePath,
1000
+ editLines: changeLines,
1001
+ source: 'user',
1002
+ strategy: 'correction-context',
1003
+ agentType: progress.correctionContext?.path,
1004
+ timestamp,
1005
+ phase: trackedPhase,
1006
+ trackedKind,
1007
+ triggeredBy: {
1008
+ source: ccSource,
1009
+ promptId,
1010
+ promptIndex,
1011
+ promptSnippet,
1012
+ },
1013
+ });
1014
+ // 记录 phase 维度 promptId(去重)
1015
+ if (promptId && !bucket.correctionPromptIds.includes(promptId)) {
1016
+ bucket.correctionPromptIds.push(promptId);
1017
+ }
1018
+ correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', dedupDelta);
1019
+ appendAccuracyDebugLog(changeDir, {
1020
+ type: 'correction.record',
1021
+ phase: trackedPhase,
1022
+ kind: trackedKind,
1023
+ trigger: 'hook',
1024
+ changeId,
1025
+ input: {
1026
+ tool: toolName,
1027
+ file: filePath,
1028
+ changeLines,
1029
+ contentSource,
1030
+ ccSource,
1031
+ promptId,
1032
+ promptIndex,
1033
+ prevMax,
1034
+ dedupDelta,
1035
+ },
1036
+ output: {
1037
+ bucketCorrection: bucket.userCorrectionLines,
1038
+ topCorrection: progress.accuracy.userCorrectionLines,
1039
+ promptIdsCount: bucket.correctionPromptIds.length,
1040
+ },
1041
+ });
1042
+ }
1043
+ }
1044
+ else {
1045
+ // 正常 AI 产出:桶 + 顶层 aiLinesAdded/aiTotalLines 同步累加,同时顶层 progress.linesAdded 仍需累加以兼容旧逻辑
1046
+ bucket.aiLinesAdded += changeLines;
1047
+ bucket.aiTotalLines += changeLines;
1048
+ bucket.lastUpdatedAt = timestamp;
1049
+ progress.accuracy.aiLinesAdded = (progress.accuracy.aiLinesAdded || 0) + changeLines;
1050
+ progress.accuracy.aiTotalLines = (progress.accuracy.aiTotalLines || 0) + changeLines;
1051
+ progress.linesAdded += changeLines;
1052
+ correctionDebugLog(`${toolName}/normal`, 'linesAdded', changeLines);
1053
+ appendAccuracyDebugLog(changeDir, {
1054
+ type: 'ai.record',
1055
+ phase: trackedPhase,
1056
+ kind: trackedKind,
1057
+ trigger: 'hook',
1058
+ changeId,
1059
+ input: { tool: toolName, file: filePath, changeLines, contentSource },
1060
+ output: {
1061
+ bucketAiTotal: bucket.aiTotalLines,
1062
+ topAiTotal: progress.accuracy.aiTotalLines,
1063
+ },
1064
+ });
1065
+ }
841
1066
  }
842
1067
  // 累加后重算 accuracyRate(顶层 + 各桶)
843
1068
  recomputeAccuracyRate(progress, { changeDir, trigger: 'hook' });
@@ -870,51 +1095,34 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
870
1095
  const contextLoadFromStdIn = Number(stdinData.tool_result?.contextLoad || 0);
871
1096
  progress.stats.tokenUsageTotal += tokenUsageFromEnv + tokenUsageFromStdIn;
872
1097
  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
- }
1098
+ // v2.16+:使用 tickActivePhaseDuration 累加活跃耗时
1099
+ // 在 phaseDurations 中找到当前进行中的 entry,tick 一次心跳
1100
+ const activeEntry = [...progress.phaseDurations]
1101
+ .reverse()
1102
+ .find(pd => pd.phase === phase && !pd.endedAt);
1103
+ if (activeEntry) {
1104
+ const nowEpoch = Date.now();
1105
+ const nowTs = getBeijingTime();
1106
+ tickActivePhaseDuration(activeEntry, nowEpoch, nowTs);
1107
+ // 同步刷新 stats.durationMs(活跃耗时)
1108
+ if (phase === 'techDesign')
1109
+ progress.stats.durationMs.techDesign = activeEntry.durationMs;
1110
+ if (phase === 'propose')
1111
+ progress.stats.durationMs.propose = activeEntry.durationMs;
1112
+ if (phase === 'apply')
1113
+ progress.stats.durationMs.apply = activeEntry.durationMs;
1114
+ if (phase === 'review')
1115
+ progress.stats.durationMs.review = activeEntry.durationMs;
1116
+ if (phase === 'archive')
1117
+ progress.stats.durationMs.archive = activeEntry.durationMs;
1118
+ // 同步 stats 的可选双轨字段
1119
+ if (!progress.stats.wallClockMs)
1120
+ progress.stats.wallClockMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
1121
+ if (!progress.stats.idleMs)
1122
+ progress.stats.idleMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
1123
+ if (phase !== 'idle') {
1124
+ progress.stats.wallClockMs[phase] = activeEntry.wallClockMs || 0;
1125
+ progress.stats.idleMs[phase] = activeEntry.idleMs || 0;
918
1126
  }
919
1127
  }
920
1128
  // === Auto-count tasks from tasks.md ===
@@ -1101,6 +1309,10 @@ function createNewProgress(changeId) {
1101
1309
  durationMs: 0,
1102
1310
  taskCount: 0,
1103
1311
  completedTaskCount: 0,
1312
+ wallClockMs: 0,
1313
+ idleMs: 0,
1314
+ lastActiveAt: getBeijingTime(),
1315
+ _lastActiveEpoch: Date.now(),
1104
1316
  }],
1105
1317
  stats: {
1106
1318
  tokenUsageTotal: 0,
@@ -1167,17 +1379,16 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1167
1379
  timestamp,
1168
1380
  triggeredBy: 'initializeProgress',
1169
1381
  });
1170
- // Update previous phase duration
1382
+ // Update previous phase duration with tick
1171
1383
  const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
1172
1384
  if (prevPhaseDuration) {
1385
+ // 封段前 tick,确保最后活跃时间被累加
1386
+ const nowEpoch = Date.now();
1387
+ tickActivePhaseDuration(prevPhaseDuration, nowEpoch, timestamp);
1173
1388
  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;
1389
+ // 同步 stats
1390
+ if (previousPhase && previousPhase !== 'idle' && progress.stats?.durationMs) {
1391
+ progress.stats.durationMs[previousPhase] = prevPhaseDuration.durationMs;
1181
1392
  }
1182
1393
  }
1183
1394
  else if (previousPhase && previousPhase !== 'idle') {
@@ -1211,6 +1422,10 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1211
1422
  durationMs: 0,
1212
1423
  taskCount: 0,
1213
1424
  completedTaskCount: 0,
1425
+ wallClockMs: 0,
1426
+ idleMs: 0,
1427
+ lastActiveAt: timestamp,
1428
+ _lastActiveEpoch: Date.now(),
1214
1429
  });
1215
1430
  }
1216
1431
  progress.phase = initialPhase;
@@ -1281,14 +1496,12 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1281
1496
  progress.phaseDurations = progress.phaseDurations || [];
1282
1497
  const activeEntry = progress.phaseDurations.find(pd => pd.phase === initialPhase && !pd.endedAt);
1283
1498
  if (!activeEntry) {
1284
- // Close any still-open legacy entry (e.g. the placeholder 'idle' entry created in createNewProgress)
1499
+ // Close any still-open legacy entry with tick
1285
1500
  for (const entry of progress.phaseDurations) {
1286
1501
  if (!entry.endedAt && entry.phase !== initialPhase) {
1502
+ const nowEpoch = Date.now();
1503
+ tickActivePhaseDuration(entry, nowEpoch, timestamp);
1287
1504
  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
1505
  }
1293
1506
  }
1294
1507
  progress.phaseDurations.push({
@@ -1298,6 +1511,10 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1298
1511
  durationMs: 0,
1299
1512
  taskCount: 0,
1300
1513
  completedTaskCount: 0,
1514
+ wallClockMs: 0,
1515
+ idleMs: 0,
1516
+ lastActiveAt: timestamp,
1517
+ _lastActiveEpoch: Date.now(),
1301
1518
  });
1302
1519
  }
1303
1520
  }
@@ -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',