@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.
- package/dist/cli/index.js +20 -0
- package/dist/commands/progress.js +15 -3
- package/dist/commands/validate-reports.d.ts +17 -0
- package/dist/commands/validate-reports.js +76 -0
- package/dist/core/configurators/codex.js +11 -1
- package/dist/core/hooks/record-progress.d.ts +45 -0
- package/dist/core/hooks/record-progress.js +345 -128
- package/dist/core/hooks/user-input-hook.js +4 -1
- package/dist/core/metrics/code-accuracy.d.ts +76 -0
- package/dist/core/metrics/code-accuracy.js +130 -0
- package/dist/core/templates/codex-hooks-template.d.ts +27 -0
- package/dist/core/templates/codex-hooks-template.js +81 -0
- package/dist/core/templates/slash-command-templates.js +20 -1
- package/dist/core/templates/tasks-template.js +6 -0
- package/dist/core/templates/tdd-tasks-template.js +8 -0
- package/dist/core/validation/report-schema.d.ts +74 -0
- package/dist/core/validation/report-schema.js +254 -0
- package/dist/utils/line-diff.d.ts +33 -0
- package/dist/utils/line-diff.js +85 -0
- package/dist/utils/phase-utils.d.ts +13 -0
- package/dist/utils/phase-utils.js +34 -6
- package/package.json +20 -22
|
@@ -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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
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:
|
|
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
|
-
// 记录
|
|
793
|
-
if (promptId && !
|
|
794
|
-
|
|
873
|
+
// 记录 apply bucket 的 promptId(去重)
|
|
874
|
+
if (promptId && !applyBucket.correctionPromptIds.includes(promptId)) {
|
|
875
|
+
applyBucket.correctionPromptIds.push(promptId);
|
|
795
876
|
}
|
|
796
|
-
correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines',
|
|
877
|
+
correctionDebugLog(`${toolName}/review-user-correction→apply`, 'userCorrectionLines', dedupDelta);
|
|
797
878
|
appendAccuracyDebugLog(changeDir, {
|
|
798
879
|
type: 'correction.record',
|
|
799
|
-
phase:
|
|
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:
|
|
898
|
+
bucketCorrection: applyBucket.userCorrectionLines,
|
|
814
899
|
topCorrection: progress.accuracy.userCorrectionLines,
|
|
815
|
-
promptIdsCount:
|
|
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
|
-
//
|
|
822
|
-
bucket
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
|
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
|
-
|
|
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',
|