@zhuan-ai/zhuanspec 2.15.7 → 2.16.2

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.
Files changed (43) hide show
  1. package/dist/cli/hooks.d.ts +13 -1
  2. package/dist/cli/hooks.js +75 -2
  3. package/dist/cli/index.js +111 -4
  4. package/dist/commands/accuracy.js +5 -1
  5. package/dist/commands/knowledge.d.ts +21 -0
  6. package/dist/commands/knowledge.js +80 -0
  7. package/dist/commands/progress.d.ts +78 -0
  8. package/dist/commands/progress.js +349 -3
  9. package/dist/core/archive.js +28 -0
  10. package/dist/core/configurators/codex.d.ts +55 -1
  11. package/dist/core/configurators/codex.js +234 -2
  12. package/dist/core/corrections/select-candidates.d.ts +58 -0
  13. package/dist/core/corrections/select-candidates.js +357 -0
  14. package/dist/core/hooks/collect-knowledge.js +80 -5
  15. package/dist/core/hooks/deviation-check.d.ts +26 -1
  16. package/dist/core/hooks/deviation-check.js +32 -184
  17. package/dist/core/hooks/knowledge-index.d.ts +84 -0
  18. package/dist/core/hooks/knowledge-index.js +270 -0
  19. package/dist/core/hooks/post-archive.js +25 -13
  20. package/dist/core/hooks/record-progress.d.ts +64 -1
  21. package/dist/core/hooks/record-progress.js +224 -54
  22. package/dist/core/hooks/summarize.js +107 -20
  23. package/dist/core/hooks/user-input-hook.d.ts +38 -2
  24. package/dist/core/hooks/user-input-hook.js +346 -21
  25. package/dist/core/init.d.ts +16 -0
  26. package/dist/core/init.js +208 -11
  27. package/dist/core/metrics/code-accuracy.d.ts +152 -3
  28. package/dist/core/metrics/code-accuracy.js +323 -19
  29. package/dist/core/templates/agents-root-stub.d.ts +1 -1
  30. package/dist/core/templates/agents-root-stub.js +1 -0
  31. package/dist/core/templates/agents-template.d.ts +1 -1
  32. package/dist/core/templates/agents-template.js +27 -25
  33. package/dist/core/templates/codex-hooks-template.d.ts +37 -0
  34. package/dist/core/templates/codex-hooks-template.js +33 -2
  35. package/dist/core/templates/slash-command-templates.js +208 -66
  36. package/dist/core/templates/tasks-template.js +66 -14
  37. package/dist/core/update.js +17 -0
  38. package/dist/core/validation/strict-rules.d.ts +28 -0
  39. package/dist/core/validation/strict-rules.js +284 -0
  40. package/dist/utils/hook-merge.d.ts +6 -0
  41. package/dist/utils/hook-merge.js +33 -2
  42. package/dist/utils/phase-utils.js +5 -0
  43. package/package.json +22 -20
@@ -3,6 +3,7 @@ import { FileSystemUtils } from '../../utils/file-system.js';
3
3
  import { readdirSync, rmSync } from 'fs';
4
4
  import chalk from 'chalk';
5
5
  import { getBeijingTime, getBeijingTimeForFilename } from './record-progress.js';
6
+ import { scanKnowledgeEntries, rebuildIndexContent, } from './knowledge-index.js';
6
7
  export async function postArchiveHook(options) {
7
8
  const output = await runPostArchiveHook(options.archived || '');
8
9
  if (options.json) {
@@ -19,21 +20,11 @@ async function runPostArchiveHook(archivedChange) {
19
20
  const repoRoot = process.cwd();
20
21
  const zhuanspecDir = path.join(repoRoot, 'zhuanspec');
21
22
  const knowledgeDir = path.join(zhuanspecDir, 'knowledge');
22
- // 1. Update archive log
23
- console.log(chalk.gray(' → 更新知识索引...'));
24
- await FileSystemUtils.createDirectory(knowledgeDir);
25
23
  const indexPath = path.join(knowledgeDir, 'index.md');
26
- const line = `- ${getBeijingTime()} archived: ${archivedChange}`;
27
- if (await FileSystemUtils.fileExists(indexPath)) {
28
- const existing = await FileSystemUtils.readFile(indexPath);
29
- await FileSystemUtils.writeFile(indexPath, `${existing.trimEnd()}\n${line}\n`);
30
- }
31
- else {
32
- await FileSystemUtils.writeFile(indexPath, `# Knowledge Index\n\n## Archive Log\n${line}\n`);
33
- }
34
- // 2. Collect change knowledge (extract best practices, implicit conventions)
24
+ // 1. Collect change knowledge (extract best practices, implicit conventions)
35
25
  // 注意:知识库只保留三类目录 troubleshooting / best-practices / implicit-conventions,
36
26
  // 原“决策类”内容合并进 best-practices;踩坑类走 zhuanspec:knowledge skill 单独沉淀。
27
+ await FileSystemUtils.createDirectory(knowledgeDir);
37
28
  console.log(chalk.gray(' → 提取设计文档中的知识记录...'));
38
29
  const knowledgeResult = await collectChangeKnowledge(zhuanspecDir, archivedChange, knowledgeDir);
39
30
  if (knowledgeResult.bestPractices > 0) {
@@ -42,7 +33,28 @@ async function runPostArchiveHook(archivedChange) {
42
33
  if (knowledgeResult.implicitConventions > 0) {
43
34
  console.log(chalk.gray(` - 隐式约定:${knowledgeResult.implicitConventions} 条`));
44
35
  }
45
- // 3. Cleanup temp files
36
+ // 2. Reindex 三分区(修复:archive 前的 refreshBusinessTemplateFromRemote 会用远端 index.md
37
+ // 覆盖本地,导致 review/knowledge skill 期间 appendIndexEntry 的登记行丢失。
38
+ // 这里基于本地 troubleshooting/ best-practices/ implicit-conventions/ 实际文件重建三分区,
39
+ // 保留远端拉来的 ## Archive Log 既有内容。)
40
+ console.log(chalk.gray(' → 重建知识索引(三分区 + Archive Log)...'));
41
+ const scan = await scanKnowledgeEntries(knowledgeDir);
42
+ const existingIndex = (await FileSystemUtils.fileExists(indexPath))
43
+ ? await FileSystemUtils.readFile(indexPath)
44
+ : null;
45
+ const rebuilt = rebuildIndexContent(scan, existingIndex);
46
+ await FileSystemUtils.writeFile(indexPath, rebuilt);
47
+ console.log(chalk.gray(` - troubleshooting=${scan.entries.filter((e) => e.category === 'troubleshooting').length}, ` +
48
+ `best-practices=${scan.entries.filter((e) => e.category === 'best-practices').length}, ` +
49
+ `implicit-conventions=${scan.entries.filter((e) => e.category === 'implicit-conventions').length}`));
50
+ if (scan.warnings.length > 0) {
51
+ console.log(chalk.yellow(` ⚠ Front Matter 不完整 ${scan.warnings.length} 条,建议补齐关键词/适用场景`));
52
+ }
53
+ // 3. Append 本次 archive log 行
54
+ const archiveLogLine = `- ${getBeijingTime()} archived: ${archivedChange}`;
55
+ const afterReindex = await FileSystemUtils.readFile(indexPath);
56
+ await FileSystemUtils.writeFile(indexPath, `${afterReindex.trimEnd()}\n${archiveLogLine}\n`);
57
+ // 4. Cleanup temp files
46
58
  console.log(chalk.gray(' → 清理临时文件...'));
47
59
  const tempFilesCleaned = await cleanupTempFiles(zhuanspecDir);
48
60
  if (tempFilesCleaned > 0) {
@@ -7,6 +7,7 @@
7
7
  * 3. Track tool calls for metrics
8
8
  */
9
9
  import { Phase } from '../../utils/phase-utils.js';
10
+ import type { PhaseAccuracyEntry, TrackedKind } from '../metrics/code-accuracy.js';
10
11
  /**
11
12
  * Atomic write JSON file: write to temp file first, then rename
12
13
  * This prevents half-written files during crashes or concurrent writes
@@ -147,6 +148,7 @@ export interface PhaseDurationRecord {
147
148
  phase: Phase;
148
149
  startedAt: string;
149
150
  endedAt?: string;
151
+ lastUpdatedAt?: string;
150
152
  durationMs: number;
151
153
  taskCount: number;
152
154
  completedTaskCount: number;
@@ -174,7 +176,11 @@ export interface ProposalChangeRecord {
174
176
  relatedDeviationId?: string;
175
177
  }
176
178
  /**
177
- * 用户纠偏事件记录(由 deviation-check 写入 .correction-log)
179
+ * 用户纠偏事件记录
180
+ *
181
+ * v2.15.16 Sprint 3 起:.correction-log 已下线,纠偏事件由 user-input-hook
182
+ * 写入 user_inputs.json 的 correctionSignal / postBaseline / resolution 字段统一管理。
183
+ * 此类型仅保留给 progress.ts resolve-correction 命令的老 change 兼容路径使用。
178
184
  */
179
185
  export interface CorrectionRecord {
180
186
  ts: string;
@@ -197,8 +203,18 @@ export interface AccuracyData {
197
203
  originalRate: number;
198
204
  overriddenRate: number;
199
205
  reason?: string;
206
+ /** 纠正前的 raw 快照(完整保留,供事后差异分析;历史数据可能缺失) */
207
+ originalRateRaw?: number;
208
+ originalAiTotalLines?: number;
209
+ originalUserCorrectionLines?: number;
200
210
  }>;
201
211
  correctionEdits?: CorrectionEditRecord[];
212
+ /**
213
+ * 分阶段准确率桶(Task 3)。
214
+ * 顶层 aiLinesAdded / userCorrectionLines / aiTotalLines = 各 phase 桶求和(全生命周期聚合)。
215
+ * 纠偏明细不放在桶内;按 phase 查明细:correctionEdits.filter(e => e.phase === X)。
216
+ */
217
+ phaseAccuracy?: PhaseAccuracyEntry[];
202
218
  }
203
219
  export interface CorrectionEditRecord {
204
220
  filePath: string;
@@ -207,6 +223,17 @@ export interface CorrectionEditRecord {
207
223
  strategy: 'L1' | 'L2' | 'L3' | 'correction-context';
208
224
  agentType?: string;
209
225
  timestamp: string;
226
+ /** 该 edit 发生在哪个 phase(供按 phase 查明细时过滤用) */
227
+ phase?: string;
228
+ /** 本 edit 所属的文件白名单类别 */
229
+ trackedKind?: TrackedKind;
230
+ /** 纠偏触发源头信息(Task 6),用于 prompt ↔ edit 联关查询 */
231
+ triggeredBy?: {
232
+ source: 'correction-keyword' | 'post-baseline-prompt' | 'pending-correction-marker';
233
+ promptId?: string;
234
+ promptIndex?: number;
235
+ promptSnippet?: string;
236
+ };
210
237
  }
211
238
  /**
212
239
  * 纠偏上下文标记 — 替代 accuracy snapshot 的精准归因机制。
@@ -227,6 +254,41 @@ export interface CorrectionContext {
227
254
  path: 'A' | 'B' | 'C' | 'D';
228
255
  expiresAt: string;
229
256
  phase: string;
257
+ /**
258
+ * 纠偏触发来源:
259
+ * - 'correction-keyword' 用户 prompt 命中纠偏关键词 → deviation-check → resolve-correction CLI
260
+ * - 'pending-correction-marker' .pending-correction 文件兑底(并行竞态兼容)
261
+ * - 'post-baseline-prompt' phaseBaselines 打标后的 UserPromptSubmit(Task 0)
262
+ */
263
+ source?: 'correction-keyword' | 'post-baseline-prompt' | 'pending-correction-marker';
264
+ /** 当前 phase 内的 user prompt 序号(1-based),仅 source='post-baseline-prompt' 有意义 */
265
+ promptIndex?: number;
266
+ /** 关联的 user_inputs.json.inputId,用于查明细(Task 6) */
267
+ promptId?: string;
268
+ /** prompt 文本前 120 字符摘要,冗余存一份避免频繁 join user_inputs.json */
269
+ promptSnippet?: string;
270
+ }
271
+ /**
272
+ * Phase baseline 打标(Task 0)——“首版完成标识”。
273
+ *
274
+ * 语义:某 phase 的 markedAt 写入后,该 phase 首版已完成,
275
+ * 之后的 user prompt 触发修改才算纠偏。
276
+ *
277
+ * 打标路径(三路径 OR,先到先得,幂等):
278
+ * - auto-file-complete:Stop hook 中核心产出文件齐备
279
+ * - auto-stop:phase 切换时兜底打标(既然都切下一 phase 了,上一 phase 视为“首版完成”)
280
+ * - manual-cli:`zhuanspec progress mark-baseline` 手动打标
281
+ */
282
+ export interface PhaseBaselineEntry {
283
+ markedAt: string;
284
+ markedBy: 'auto-file-complete' | 'auto-stop' | 'manual-cli';
285
+ firstStopAt?: string;
286
+ }
287
+ export interface PhaseBaselines {
288
+ techDesign?: PhaseBaselineEntry;
289
+ propose?: PhaseBaselineEntry;
290
+ apply?: PhaseBaselineEntry;
291
+ review?: PhaseBaselineEntry;
230
292
  }
231
293
  export interface ProgressData {
232
294
  changeId: string;
@@ -271,6 +333,7 @@ export interface ProgressData {
271
333
  lastEvent?: string;
272
334
  accuracy?: AccuracyData;
273
335
  correctionContext?: CorrectionContext;
336
+ phaseBaselines?: PhaseBaselines;
274
337
  }
275
338
  export declare function recordProgressHook(options: RecordProgressOptions): Promise<void>;
276
339
  /**
@@ -11,7 +11,7 @@ import { promises as fsPromises, existsSync, readFileSync, unlinkSync } from 'fs
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
12
  import { PHASE_ORDER } from '../../utils/phase-utils.js';
13
13
  import { resolveZhuanSpecRoot } from '../../utils/resolve-root.js';
14
- import { isCodeFile } from '../metrics/code-accuracy.js';
14
+ import { isCodeFile, isTrackedFileForPhase, getOrCreatePhaseBucket, recomputeAccuracyRate, persistAccuracyJson, appendAccuracyDebugLog } from '../metrics/code-accuracy.js';
15
15
  import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
16
16
  const fs = fsPromises;
17
17
  /**
@@ -112,6 +112,7 @@ export async function recoverProgressJsonForWrite(filePath) {
112
112
  phaseDurations: [{
113
113
  phase: recoveredPhase,
114
114
  startedAt: '',
115
+ lastUpdatedAt: '',
115
116
  durationMs: 0,
116
117
  taskCount: 0,
117
118
  completedTaskCount: 0,
@@ -502,6 +503,37 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
502
503
  timestamp: getBeijingTime(),
503
504
  triggeredBy: currentTask || 'manual',
504
505
  });
506
+ // === Task 0 路径 2:phase 切换时给上一 phase 补 baseline(兜底打标)===
507
+ // previousPhase ∈ {techDesign, propose, apply, review} 才打标;idle/archive 排除
508
+ const BASELINE_PHASES = ['techDesign', 'propose', 'apply', 'review'];
509
+ if (BASELINE_PHASES.includes(previousPhase)) {
510
+ progress.phaseBaselines = progress.phaseBaselines || {};
511
+ const key = previousPhase;
512
+ const existedBefore = !!progress.phaseBaselines[key]?.markedAt;
513
+ if (!existedBefore) {
514
+ progress.phaseBaselines[key] = {
515
+ markedAt: getBeijingTime(),
516
+ markedBy: 'auto-stop',
517
+ };
518
+ }
519
+ // 埋点:baseline.mark
520
+ appendAccuracyDebugLog(changeDir, {
521
+ type: 'baseline.mark',
522
+ phase: key,
523
+ trigger: 'auto-stop',
524
+ changeId,
525
+ input: { toPhase: phase, previousPhase: key },
526
+ output: { markedBy: 'auto-stop', existed: existedBefore },
527
+ });
528
+ }
529
+ // === 跨 phase 清理 correctionContext:
530
+ // 旧 phase 的 active 不应泄漏到新 phase,新 phase 是否进入纠偏态
531
+ // 由新 phase 的 phaseBaselines + UserPromptSubmit 重新判定
532
+ if (progress.correctionContext
533
+ && progress.correctionContext.active
534
+ && progress.correctionContext.phase !== phase) {
535
+ progress.correctionContext.active = false;
536
+ }
505
537
  }
506
538
  progress.currentNode = process.env.ZHUANSPEC_CURRENT_NODE || phase;
507
539
  progress.currentTask = currentTask || progress.currentTask;
@@ -610,7 +642,20 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
610
642
  contentSource = toolInputEdits.length > 0 ? 'edits[]' : 'none';
611
643
  }
612
644
  let estimatedLines = 0;
613
- const inCorrectionContext = !!(progress.correctionContext?.active) || pendingExists;
645
+ // === P0 兜底:post-baseline-fallback ===
646
+ // 正常链路:UserPromptSubmit 的 user-input hook 会在 phaseBaseline 已打标时写入
647
+ // correctionContext.active=true。但若 .claude/settings.json 未挂 user-input hook
648
+ // (老版本模板),correctionContext 会保持 null,导致后续 Write 误入 linesAdded。
649
+ // 本兜底:只要当前 phase 的 phaseBaseline 已打标,就视同进入纠正上下文。
650
+ // 语义:Stop 打 baseline 代表该 phase 首版齐备,之后的同 phase Write 绝大多数
651
+ // 由后续 user prompt 驱动(AI 不会主动在无 prompt 情况下继续大改),归为 user 纠正。
652
+ const _phaseKeyForBaseline = progress.phase;
653
+ const _baselinePhases = ['techDesign', 'propose', 'apply', 'review'];
654
+ const baselineMarkedForPhase = _baselinePhases.includes(_phaseKeyForBaseline)
655
+ && !!progress.phaseBaselines?.[_phaseKeyForBaseline]?.markedAt;
656
+ const ccActiveRaw = !!(progress.correctionContext?.active);
657
+ const inCorrectionContext = ccActiveRaw || pendingExists || baselineMarkedForPhase;
658
+ const correctionFallbackReason = (!ccActiveRaw && !pendingExists && baselineMarkedForPhase) ? 'post-baseline-fallback' : 'none';
614
659
  // === 调试日志:在 changeDir 下写 .correction-debug.log,方便排查“为什么没记录上” ===
615
660
  const correctionDebugLog = (branch, addedTo, delta) => {
616
661
  try {
@@ -628,6 +673,8 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
628
673
  contentSource,
629
674
  changeLines,
630
675
  pendingExists,
676
+ baselineMarkedForPhase,
677
+ fallbackReason: correctionFallbackReason,
631
678
  correctionContext: progress.correctionContext
632
679
  ? { active: ccActive, path: ccPath, expiresAt: ccExpiresAt, phase: ccPhase, currentPhase: progress.phase }
633
680
  : null,
@@ -646,46 +693,175 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
646
693
  // 日志失败不阻断主流程
647
694
  }
648
695
  };
649
- if (changeLines > 0 && filePath && isCodeFile(filePath)) {
650
- estimatedLines = changeLines;
651
- if (inCorrectionContext) {
652
- // 纠偏上下文:行数计入 userCorrectionLines,不进 linesAdded
696
+ if (changeLines > 0 && filePath) {
697
+ // Task 3.3:按 phase 选白名单(techDesign/propose/apply/review),而非笼统用 isCodeFile
698
+ const track = isTrackedFileForPhase(filePath, progress.phase, changeDir);
699
+ if (!track.tracked) {
700
+ correctionDebugLog(`skipped/out-of-phase-whitelist`, 'skipped', 0);
701
+ appendAccuracyDebugLog(changeDir, {
702
+ type: 'skip.out-of-phase-whitelist',
703
+ phase: progress.phase,
704
+ trigger: 'hook',
705
+ changeId,
706
+ skipReason: 'out-of-phase-whitelist',
707
+ input: { tool: toolName, file: filePath, changeLines },
708
+ });
709
+ }
710
+ else {
711
+ estimatedLines = changeLines;
712
+ const trackedKind = track.kind;
713
+ const trackedPhase = progress.phase;
714
+ // 确保 accuracy 结构存在
653
715
  if (!progress.accuracy) {
654
716
  progress.accuracy = {
655
717
  aiLinesAdded: 0, aiLinesModified: 0, aiTotalLines: 0,
656
718
  userCorrectionLines: 0, accuracyRate: 1.0,
657
- correctionEdits: [],
719
+ correctionEdits: [], phaseAccuracy: [],
658
720
  };
659
721
  }
660
- progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + changeLines;
661
- progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
662
- progress.accuracy.correctionEdits.push({
663
- filePath,
664
- editLines: changeLines,
665
- source: 'user',
666
- strategy: 'correction-context',
667
- agentType: progress.correctionContext?.path,
668
- timestamp,
669
- });
670
- correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', changeLines);
671
- }
672
- else {
673
- // 正常 AI 产出
674
- progress.linesAdded += changeLines;
675
- correctionDebugLog(`${toolName}/normal`, 'linesAdded', changeLines);
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'。
748
+ 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。
756
+ let fallbackPromptId;
757
+ let fallbackPromptIndex;
758
+ let fallbackPromptSnippet;
759
+ if (!progress.correctionContext?.promptId && pendingExists) {
760
+ try {
761
+ const markerRaw = readFileSync(pendingMarkerPath, 'utf-8');
762
+ if (markerRaw) {
763
+ const markerData = JSON.parse(markerRaw);
764
+ fallbackPromptId = markerData.promptId;
765
+ fallbackPromptIndex = markerData.phaseInputIndex;
766
+ fallbackPromptSnippet = markerData.promptSnippet;
767
+ }
768
+ }
769
+ catch {
770
+ // marker 解析失败不阻断主流程
771
+ }
772
+ }
773
+ const promptId = progress.correctionContext?.promptId ?? fallbackPromptId;
774
+ const promptIndex = progress.correctionContext?.promptIndex ?? fallbackPromptIndex;
775
+ const promptSnippet = progress.correctionContext?.promptSnippet ?? fallbackPromptSnippet;
776
+ progress.accuracy.correctionEdits.push({
777
+ filePath,
778
+ editLines: changeLines,
779
+ source: 'user',
780
+ strategy: 'correction-context',
781
+ agentType: progress.correctionContext?.path,
782
+ timestamp,
783
+ phase: trackedPhase,
784
+ trackedKind,
785
+ triggeredBy: {
786
+ source: ccSource,
787
+ promptId,
788
+ promptIndex,
789
+ promptSnippet,
790
+ },
791
+ });
792
+ // 记录 phase 维度 promptId(去重)
793
+ if (promptId && !bucket.correctionPromptIds.includes(promptId)) {
794
+ bucket.correctionPromptIds.push(promptId);
795
+ }
796
+ correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', changeLines);
797
+ appendAccuracyDebugLog(changeDir, {
798
+ type: 'correction.record',
799
+ phase: trackedPhase,
800
+ kind: trackedKind,
801
+ trigger: 'hook',
802
+ changeId,
803
+ input: {
804
+ tool: toolName,
805
+ file: filePath,
806
+ changeLines,
807
+ contentSource,
808
+ ccSource,
809
+ promptId,
810
+ promptIndex,
811
+ },
812
+ output: {
813
+ bucketCorrection: bucket.userCorrectionLines,
814
+ topCorrection: progress.accuracy.userCorrectionLines,
815
+ promptIdsCount: bucket.correctionPromptIds.length,
816
+ },
817
+ });
818
+ }
819
+ }
820
+ 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
+ });
841
+ }
842
+ // 累加后重算 accuracyRate(顶层 + 各桶)
843
+ recomputeAccuracyRate(progress, { changeDir, trigger: 'hook' });
844
+ // 统一同步 accuracy.json
845
+ await persistAccuracyJson(changeDir, progress);
676
846
  }
677
847
  }
678
848
  else {
679
- // 未进入计量分支:记录原因(非代码文件 / 字段缺失 / 非写入工具)
849
+ // 未进入计量分支:记录原因(字段缺失 / 非写入工具)
680
850
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
681
851
  const reason = !filePath
682
852
  ? 'no-file-path'
683
- : !isCodeFile(filePath)
684
- ? 'non-code-file'
685
- : changeLines === 0
686
- ? `empty-${contentSource}`
687
- : 'unknown';
853
+ : changeLines === 0
854
+ ? `empty-${contentSource}`
855
+ : 'unknown';
688
856
  correctionDebugLog(`skipped/${reason}`, 'skipped', 0);
857
+ appendAccuracyDebugLog(changeDir, {
858
+ type: `skip.${reason}`,
859
+ phase: progress.phase,
860
+ trigger: 'hook',
861
+ changeId,
862
+ skipReason: reason,
863
+ input: { tool: toolName, file: filePath || null, changeLines, contentSource },
864
+ });
689
865
  }
690
866
  }
691
867
  const tokenUsageFromEnv = Number(process.env.ZHUANSPEC_TOKEN_USAGE || 0);
@@ -730,6 +906,15 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
730
906
  progress.stats.durationMs.review = elapsedMs;
731
907
  if (phase === 'archive')
732
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
+ }
733
918
  }
734
919
  }
735
920
  // === Auto-count tasks from tasks.md ===
@@ -756,31 +941,10 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
756
941
  // Ignore tasks.md read errors
757
942
  }
758
943
  }
759
- // === 合并 .correction-log 到 progress.corrections(供 Stop hook 沉淀询问使用)===
760
- try {
761
- const correctionLogPath = path.join(changeDir, '.correction-log');
762
- if (existsSync(correctionLogPath)) {
763
- const content = readFileSync(correctionLogPath, 'utf-8');
764
- const parsed = [];
765
- for (const line of content.split('\n')) {
766
- const trimmed = line.trim();
767
- if (!trimmed)
768
- continue;
769
- try {
770
- parsed.push(JSON.parse(trimmed));
771
- }
772
- catch {
773
- // skip malformed line
774
- }
775
- }
776
- if (parsed.length > 0) {
777
- progress.corrections = parsed;
778
- }
779
- }
780
- }
781
- catch {
782
- // ignore correction log read errors
783
- }
944
+ // v2.15.16 Sprint 3:.correction-log → progress.corrections 的合并逻辑已下线。
945
+ // 纠偏明细现由 user-input-hook 写入 user_inputs.json(correctionSignal / postBaseline /
946
+ // resolution 字段),review 步骤 0 与 knowledge skill 模式 B 均直接读 user_inputs.json,
947
+ // 不再依赖 progress.corrections。
784
948
  // Write progress file atomically
785
949
  await atomicWriteJson(progressPath, progress);
786
950
  // Also update tool_calls.json separately for detailed tracking
@@ -933,6 +1097,7 @@ function createNewProgress(changeId) {
933
1097
  phaseDurations: [{
934
1098
  phase: initialPhase,
935
1099
  startedAt: getBeijingTime(),
1100
+ lastUpdatedAt: getBeijingTime(),
936
1101
  durationMs: 0,
937
1102
  taskCount: 0,
938
1103
  completedTaskCount: 0,
@@ -1006,6 +1171,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1006
1171
  const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
1007
1172
  if (prevPhaseDuration) {
1008
1173
  prevPhaseDuration.endedAt = timestamp;
1174
+ prevPhaseDuration.lastUpdatedAt = timestamp;
1009
1175
  const startTime = new Date(prevPhaseDuration.startedAt.replace(' ', 'T')).getTime();
1010
1176
  const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1011
1177
  const ms = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
@@ -1027,6 +1193,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1027
1193
  phase: previousPhase,
1028
1194
  startedAt: inferredStart,
1029
1195
  endedAt: timestamp,
1196
+ lastUpdatedAt: timestamp,
1030
1197
  durationMs: ms,
1031
1198
  taskCount: 0,
1032
1199
  completedTaskCount: 0,
@@ -1040,6 +1207,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1040
1207
  progress.phaseDurations.push({
1041
1208
  phase: initialPhase,
1042
1209
  startedAt: timestamp,
1210
+ lastUpdatedAt: timestamp,
1043
1211
  durationMs: 0,
1044
1212
  taskCount: 0,
1045
1213
  completedTaskCount: 0,
@@ -1117,6 +1285,7 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1117
1285
  for (const entry of progress.phaseDurations) {
1118
1286
  if (!entry.endedAt && entry.phase !== initialPhase) {
1119
1287
  entry.endedAt = timestamp;
1288
+ entry.lastUpdatedAt = timestamp;
1120
1289
  const startTime = new Date((entry.startedAt || timestamp).replace(' ', 'T')).getTime();
1121
1290
  const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1122
1291
  entry.durationMs = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
@@ -1125,6 +1294,7 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1125
1294
  progress.phaseDurations.push({
1126
1295
  phase: initialPhase,
1127
1296
  startedAt: timestamp,
1297
+ lastUpdatedAt: timestamp,
1128
1298
  durationMs: 0,
1129
1299
  taskCount: 0,
1130
1300
  completedTaskCount: 0,