@zhuan-ai/zhuanspec 2.16.3 → 2.16.5

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.
@@ -38,7 +38,7 @@ import path from 'path';
38
38
  import os from 'os';
39
39
  import { promises as fs } from 'fs';
40
40
  import { FileSystemUtils } from '../../utils/file-system.js';
41
- import { getCodexHooksTomlBlock, getCodexDefaultHookSlots } from '../templates/codex-hooks-template.js';
41
+ import { getCodexHooksTomlBlock, getCodexDefaultHookSlots, getCodexDefaultHookHashes } from '../templates/codex-hooks-template.js';
42
42
  import { getCodexAgentTomls, } from '../templates/codex-agents-template.js';
43
43
  import { getCodexSkillFiles } from '../templates/codex-skills-template.js';
44
44
  import { CodexSlashCommandConfigurator } from './slash/codex.js';
@@ -374,6 +374,16 @@ export class CodexConfigurator {
374
374
  seedBySuffix.set(split.suffix, entry.hash);
375
375
  }
376
376
  }
377
+ // 2b. Computed hashes take priority over cross-project seeds because
378
+ // seeds from a different source type (e.g. ~/.codex/hooks.json vs
379
+ // .codex/config.toml) may have different hook content and thus wrong
380
+ // hashes. Our computed hashes replicate the exact Codex CLI fingerprint
381
+ // algorithm (NormalizedHookIdentity → canonical JSON → SHA-256) and are
382
+ // guaranteed correct for the current ZhuanSpec default hooks.
383
+ const computedHashes = getCodexDefaultHookHashes();
384
+ for (const [suffix, hash] of computedHashes) {
385
+ seedBySuffix.set(suffix, hash); // always overwrite cross-project seeds
386
+ }
377
387
  const seedAvailable = seedBySuffix.size > 0;
378
388
  // 3. Plan per-slot writes.
379
389
  const written = [];
@@ -11,8 +11,9 @@ import { promises as fsPromises, existsSync, readFileSync, unlinkSync } from 'fs
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
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
@@ -404,6 +405,55 @@ function generateProposalChangeSummary(toolInput) {
404
405
  // Truncate to 120 chars
405
406
  return firstLine.length > 120 ? firstLine.substring(0, 117) + '...' : firstLine;
406
407
  }
408
+ function parseApplyPatch(command) {
409
+ const result = { files: [], totalLinesAdded: 0, totalLinesRemoved: 0 };
410
+ if (!command)
411
+ return result;
412
+ const lines = command.split('\n');
413
+ let currentFile = null;
414
+ for (const line of lines) {
415
+ if (line.startsWith('*** Add File: ') || line.startsWith('*** Update File: ')) {
416
+ if (currentFile)
417
+ result.files.push(currentFile);
418
+ const filePath = line.replace(/^\*\*\* (?:Add|Update) File:\s*/, '').trim();
419
+ currentFile = { path: filePath, linesAdded: 0, linesRemoved: 0 };
420
+ }
421
+ else if (line.startsWith('*** Delete File: ')) {
422
+ if (currentFile)
423
+ result.files.push(currentFile);
424
+ const filePath = line.replace(/^\*\*\* Delete File:\s*/, '').trim();
425
+ currentFile = { path: filePath, linesAdded: 0, linesRemoved: 0 };
426
+ }
427
+ else if (line === '*** End Patch' || line === '*** Begin Patch') {
428
+ if (currentFile) {
429
+ result.files.push(currentFile);
430
+ currentFile = null;
431
+ }
432
+ }
433
+ else if (line.startsWith('*** ')) {
434
+ // Other patch directives (e.g., *** Rename File:) — push current and skip
435
+ if (currentFile) {
436
+ result.files.push(currentFile);
437
+ currentFile = null;
438
+ }
439
+ }
440
+ else if (currentFile) {
441
+ if (line.startsWith('+'))
442
+ currentFile.linesAdded++;
443
+ else if (line.startsWith('-'))
444
+ currentFile.linesRemoved++;
445
+ // Context lines (space prefix or @@ markers) are ignored for counting
446
+ }
447
+ }
448
+ if (currentFile)
449
+ result.files.push(currentFile);
450
+ // Calculate totals
451
+ for (const f of result.files) {
452
+ result.totalLinesAdded += f.linesAdded;
453
+ result.totalLinesRemoved += f.linesRemoved;
454
+ }
455
+ return result;
456
+ }
407
457
  async function runRecordProgress(filePath, toolName, success, stdinData) {
408
458
  const cwd = resolveZhuanSpecRoot();
409
459
  // Priority: filePath-derived > progress.json scan > environment variables
@@ -619,8 +669,31 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
619
669
  progress.currentNode = process.env.ZHUANSPEC_CURRENT_NODE || phase;
620
670
  progress.currentTask = currentTask || progress.currentTask;
621
671
  progress.toolCalls.push(toolCall);
622
- const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
623
- if (filePath && WRITE_TOOLS.has(toolName) && !progress.filesModified.includes(filePath)) {
672
+ // === Codex apply_patch 支持 ===
673
+ // Codex 使用 apply_patch 工具,file_path 为空,实际内容在 command 字段的 patch 格式中
674
+ let patchResult = null;
675
+ if (toolName === 'apply_patch') {
676
+ const command = stdinData.tool_input?.command || '';
677
+ patchResult = parseApplyPatch(command);
678
+ // 用第一个文件路径作为 filePath(用于 accuracy 追踪和 change detection)
679
+ if (patchResult.files.length > 0 && !filePath) {
680
+ filePath = patchResult.files[0].path;
681
+ }
682
+ }
683
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
684
+ if (toolName === 'apply_patch' && patchResult) {
685
+ // apply_patch 可修改多文件,逐一加入 filesModified
686
+ const zhuanspecDirPrefix = path.resolve(process.cwd(), 'zhuanspec') + path.sep;
687
+ for (const f of patchResult.files) {
688
+ if (!f.path || progress.filesModified.includes(f.path))
689
+ continue;
690
+ const absF = path.isAbsolute(f.path) ? f.path : path.resolve(process.cwd(), f.path);
691
+ if (!absF.startsWith(zhuanspecDirPrefix)) {
692
+ progress.filesModified.push(f.path);
693
+ }
694
+ }
695
+ }
696
+ else if (filePath && WRITE_TOOLS.has(toolName) && !progress.filesModified.includes(filePath)) {
624
697
  // Exclude ZhuanSpec internal files (spec/metrics/doc files inside zhuanspec/ dir)
625
698
  const absFilePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
626
699
  const zhuanspecDirPrefix = path.resolve(process.cwd(), 'zhuanspec') + path.sep;
@@ -630,7 +703,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
630
703
  }
631
704
  // Detect proposal file changes and record them
632
705
  const changePath = path.join(zhuanspecDir, 'changes', changeId);
633
- const WRITE_EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
706
+ const WRITE_EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'apply_patch']);
634
707
  if (filePath && WRITE_EDIT_TOOLS.has(toolName) && isProposalFile(filePath, changePath)) {
635
708
  const relativeProposalPath = path.relative(changePath, path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath));
636
709
  // 提案文件变更行数:按工具类型取不同字段,避免 Edit/MultiEdit 全记为 0
@@ -651,6 +724,16 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
651
724
  contentLines += Math.max(ns, os);
652
725
  }
653
726
  }
727
+ else if (toolName === 'apply_patch' && patchResult) {
728
+ // apply_patch 的提案文件变更行数:用 patch 中涉及该提案文件的行数
729
+ const absChangePath = path.isAbsolute(changePath) ? changePath : path.resolve(cwd, changePath);
730
+ for (const f of patchResult.files) {
731
+ const absF = path.isAbsolute(f.path) ? f.path : path.resolve(cwd, f.path);
732
+ if (absF.startsWith(absChangePath + path.sep)) {
733
+ contentLines += f.linesAdded + f.linesRemoved;
734
+ }
735
+ }
736
+ }
654
737
  const record = {
655
738
  changeRecordId: `pc-${Date.now()}`,
656
739
  timestamp,
@@ -699,6 +782,11 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
699
782
  const toolInputOldString = stdinData.tool_input?.old_string;
700
783
  const toolInputEdits = stdinData.tool_input?.edits;
701
784
  // 按工具类型计算本次变更涉及的行数
785
+ // 口径:
786
+ // Write → 全文行数(无旧本可参考,以完整写入量为准)
787
+ // Edit → countLineDiff(old, new):仅计增+删净改动行(不含上下文锚点)
788
+ // MultiEdit → 多个 edit 的 diff 累加
789
+ // 此口径对 techDesign / propose / apply / review 四阶段统一生效(均走 isTrackedFileForPhase 白名单)
702
790
  let changeLines = 0;
703
791
  let contentSource = 'none';
704
792
  if (toolName === 'Write') {
@@ -708,20 +796,22 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
708
796
  }
709
797
  }
710
798
  else if (toolName === 'Edit') {
711
- const newLines = toolInputNewString ? toolInputNewString.split('\n').length : 0;
712
- const oldLines = toolInputOldString ? toolInputOldString.split('\n').length : 0;
713
- // max(new, old):体现本次编辑触及的代码量,兼容纯新增/纯删除/替换
714
- changeLines = Math.max(newLines, oldLines);
799
+ // 以前:Math.max(newLines, oldLines) 含上下文锚点,虚高计数
800
+ // 现在:只计真正变动的行(增+删),跳过未变上下文
801
+ changeLines = countLineDiff(toolInputOldString, toolInputNewString);
715
802
  contentSource = (toolInputNewString || toolInputOldString) ? 'new_string' : 'none';
716
803
  }
717
804
  else if (toolName === 'MultiEdit' && Array.isArray(toolInputEdits)) {
718
805
  for (const edit of toolInputEdits) {
719
- const newLines = edit?.new_string ? edit.new_string.split('\n').length : 0;
720
- const oldLines = edit?.old_string ? edit.old_string.split('\n').length : 0;
721
- changeLines += Math.max(newLines, oldLines);
806
+ changeLines += countLineDiff(edit?.old_string, edit?.new_string);
722
807
  }
723
808
  contentSource = toolInputEdits.length > 0 ? 'edits[]' : 'none';
724
809
  }
810
+ else if (toolName === 'apply_patch' && patchResult) {
811
+ // Codex apply_patch:统计 patch 中所有 +/- 行
812
+ changeLines = patchResult.totalLinesAdded + patchResult.totalLinesRemoved;
813
+ contentSource = patchResult.files.length > 0 ? 'command' : 'none';
814
+ }
725
815
  let estimatedLines = 0;
726
816
  // === P0 兜底:post-baseline-fallback ===
727
817
  // 正常链路:UserPromptSubmit 的 user-input hook 会在 phaseBaseline 已打标时写入
@@ -800,40 +890,30 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
800
890
  correctionEdits: [], phaseAccuracy: [],
801
891
  };
802
892
  }
803
- const bucket = getOrCreatePhaseBucket(progress, trackedPhase, trackedKind);
804
- if (inCorrectionContext) {
805
- // 工具白名单守卫:techDesign / propose 阶段的纠偏统计只接受增量编辑(Edit/MultiEdit),
806
- // 禁止 Write。原因:tech-spec.md / proposal.md 通常是整文件 Write 产出,一旦被误判为纠偏,
807
- // 会把整份文件行数当成 userCorrectionLines 记入,导致分子被严重放大、accuracyRate 失真。
808
- // apply / review 阶段不受此限制(apply 可 Write 新增代码文件,review-report.md 也常由 Write 生成)。
809
- if ((trackedPhase === 'techDesign' || trackedPhase === 'propose') && toolName === 'Write') {
810
- correctionDebugLog(`skipped/write-blocked-${trackedPhase}-correction`, 'skipped', 0);
811
- appendAccuracyDebugLog(changeDir, {
812
- type: `skip.write-blocked-${trackedPhase}-correction`,
813
- phase: trackedPhase,
814
- kind: trackedKind,
815
- trigger: 'hook',
816
- changeId,
817
- skipReason: `write-blocked-${trackedPhase}-correction`,
818
- input: { tool: toolName, file: filePath, changeLines },
819
- });
820
- }
821
- else {
822
- // 纠偏上下文:桶 + 顶层 userCorrectionLines 同步累加
823
- bucket.userCorrectionLines += changeLines;
824
- bucket.lastUpdatedAt = timestamp;
825
- progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + changeLines;
826
- progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
827
- // 提取纠偏触发源头信息(Task 6)
828
- // post-baseline-fallback 兜底命中时,语义等价于 user-input hook 写入的 'post-baseline-prompt'。
893
+ // === Review 阶段特殊处理 ===
894
+ // 规则:
895
+ // 1. CR/单测 Skill 自动修复 → 不计入准确率(完全跳过)
896
+ // 2. 用户提示词触发的纠正 归属到 apply 阶段的准确率桶
897
+ // 3. review 阶段不产生独立的准确率 bucket
898
+ //
899
+ // 用户提示词识别(与 apply 阶段一致):
900
+ // - correction-keyword:命中显式纠偏关键词("不对"、"应该是"等)
901
+ // - post-baseline-prompt + 非琐碎确认:baseline 后的任何用户提示,
902
+ // 只要 promptSnippet 不是"继续"、"好的"等琐碎确认词。
903
+ // 斜杠/感叹号命令(/zhuanspec:、!ls)已在 user-input-hook 过滤。
904
+ // - .pending-correction marker 存在
905
+ //
906
+ // 自动修复 = 最近有 review autofix skill 调用(code-review-expert / generate-mockito-unit-test)
907
+ if (trackedPhase === 'review') {
908
+ // 判断是否为用户提示词触发的纠正
909
+ const meaningfulCC = isMeaningfulUserCorrection(progress.correctionContext ?? undefined);
910
+ const isExplicitUserCorrection = (ccActiveRaw && meaningfulCC) || pendingExists;
911
+ if (isExplicitUserCorrection) {
912
+ // 用户在 review 阶段的纠正 归到 apply/code bucket,影响 apply 准确率
913
+ const applyBucket = getOrCreatePhaseBucket(progress, 'apply', trackedKind);
914
+ // 提取纠偏触发源头信息
829
915
  const ccSource = progress.correctionContext?.source
830
- || (pendingExists
831
- ? 'pending-correction-marker'
832
- : (correctionFallbackReason === 'post-baseline-fallback'
833
- ? 'post-baseline-prompt'
834
- : 'correction-keyword'));
835
- // Task 6:若 correctionContext 无 prompt 元数据(典型于 pending-correction-marker 兑底路径),
836
- // 从 .pending-correction marker JSON 中回填 promptId / phaseInputIndex / promptSnippet。
916
+ || (pendingExists ? 'pending-correction-marker' : 'correction-keyword');
837
917
  let fallbackPromptId;
838
918
  let fallbackPromptIndex;
839
919
  let fallbackPromptSnippet;
@@ -854,6 +934,13 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
854
934
  const promptId = progress.correctionContext?.promptId ?? fallbackPromptId;
855
935
  const promptIndex = progress.correctionContext?.promptIndex ?? fallbackPromptIndex;
856
936
  const promptSnippet = progress.correctionContext?.promptSnippet ?? fallbackPromptSnippet;
937
+ // 去重:同 (promptId + filePath + apply phase) 取历史 max,只累加 delta
938
+ progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
939
+ const prevMax = computePromptFileMaxLines(progress.accuracy.correctionEdits, promptId, filePath, 'apply');
940
+ const dedupDelta = Math.max(0, changeLines - prevMax);
941
+ applyBucket.userCorrectionLines += dedupDelta;
942
+ applyBucket.lastUpdatedAt = timestamp;
943
+ progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + dedupDelta;
857
944
  progress.accuracy.correctionEdits.push({
858
945
  filePath,
859
946
  editLines: changeLines,
@@ -861,7 +948,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
861
948
  strategy: 'correction-context',
862
949
  agentType: progress.correctionContext?.path,
863
950
  timestamp,
864
- phase: trackedPhase,
951
+ phase: 'apply', // 归属到 apply(非 review)
865
952
  trackedKind,
866
953
  triggeredBy: {
867
954
  source: ccSource,
@@ -870,14 +957,14 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
870
957
  promptSnippet,
871
958
  },
872
959
  });
873
- // 记录 phase 维度 promptId(去重)
874
- if (promptId && !bucket.correctionPromptIds.includes(promptId)) {
875
- bucket.correctionPromptIds.push(promptId);
960
+ // 记录 apply bucket promptId(去重)
961
+ if (promptId && !applyBucket.correctionPromptIds.includes(promptId)) {
962
+ applyBucket.correctionPromptIds.push(promptId);
876
963
  }
877
- correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', changeLines);
964
+ correctionDebugLog(`${toolName}/review-user-correction→apply`, 'userCorrectionLines', dedupDelta);
878
965
  appendAccuracyDebugLog(changeDir, {
879
966
  type: 'correction.record',
880
- phase: trackedPhase,
967
+ phase: 'apply', // 归属到 apply
881
968
  kind: trackedKind,
882
969
  trigger: 'hook',
883
970
  changeId,
@@ -886,39 +973,183 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
886
973
  file: filePath,
887
974
  changeLines,
888
975
  contentSource,
976
+ actualPhase: 'review',
977
+ redirectedTo: 'apply',
889
978
  ccSource,
890
979
  promptId,
891
980
  promptIndex,
981
+ prevMax,
982
+ dedupDelta,
892
983
  },
893
984
  output: {
894
- bucketCorrection: bucket.userCorrectionLines,
985
+ bucketCorrection: applyBucket.userCorrectionLines,
895
986
  topCorrection: progress.accuracy.userCorrectionLines,
896
- promptIdsCount: bucket.correctionPromptIds.length,
987
+ promptIdsCount: applyBucket.correctionPromptIds.length,
897
988
  },
898
989
  });
899
990
  }
991
+ else {
992
+ // 非用户提示词触发 → review 阶段自动修复、琐碎确认后的编辑、或 baseline fallback
993
+ // 判断是否为 Skill 自动修复(CR/单测)
994
+ const isAutoFix = isReviewAutoFixActive(progress.skillCalls || [], progress.correctionContext ?? undefined);
995
+ const ccSourceRaw = progress.correctionContext?.source;
996
+ const ccPromptSnippet = progress.correctionContext?.promptSnippet;
997
+ const ccTrivialAck = ccSourceRaw === 'post-baseline-prompt'
998
+ && isTrivialAckPrompt(ccPromptSnippet);
999
+ const skipReason = isAutoFix
1000
+ ? 'review-skill-autofix'
1001
+ : (ccTrivialAck ? 'review-trivial-ack' : 'review-non-correction');
1002
+ correctionDebugLog(`skipped/${skipReason}`, 'skipped', 0);
1003
+ appendAccuracyDebugLog(changeDir, {
1004
+ type: `skip.${skipReason}`,
1005
+ phase: trackedPhase,
1006
+ kind: trackedKind,
1007
+ trigger: 'hook',
1008
+ changeId,
1009
+ skipReason,
1010
+ input: {
1011
+ tool: toolName,
1012
+ file: filePath,
1013
+ changeLines,
1014
+ inCorrectionContext,
1015
+ baselineMarkedForPhase,
1016
+ ccActiveRaw,
1017
+ ccSource: ccSourceRaw,
1018
+ ccTrivialAck,
1019
+ isAutoFix,
1020
+ },
1021
+ });
1022
+ // review 阶段自动修复、琐碎确认、非纠正编辑都完全跳过准确率统计
1023
+ }
900
1024
  }
901
1025
  else {
902
- // 正常 AI 产出:桶 + 顶层 aiLinesAdded/aiTotalLines 同步累加,同时顶层 progress.linesAdded 仍需累加以兼容旧逻辑
903
- bucket.aiLinesAdded += changeLines;
904
- bucket.aiTotalLines += changeLines;
905
- bucket.lastUpdatedAt = timestamp;
906
- progress.accuracy.aiLinesAdded = (progress.accuracy.aiLinesAdded || 0) + changeLines;
907
- progress.accuracy.aiTotalLines = (progress.accuracy.aiTotalLines || 0) + changeLines;
908
- progress.linesAdded += changeLines;
909
- correctionDebugLog(`${toolName}/normal`, 'linesAdded', changeLines);
910
- appendAccuracyDebugLog(changeDir, {
911
- type: 'ai.record',
912
- phase: trackedPhase,
913
- kind: trackedKind,
914
- trigger: 'hook',
915
- changeId,
916
- input: { tool: toolName, file: filePath, changeLines, contentSource },
917
- output: {
918
- bucketAiTotal: bucket.aiTotalLines,
919
- topAiTotal: progress.accuracy.aiTotalLines,
920
- },
921
- });
1026
+ // === review 阶段:原有逻辑 ===
1027
+ const bucket = getOrCreatePhaseBucket(progress, trackedPhase, trackedKind);
1028
+ if (inCorrectionContext) {
1029
+ // 工具白名单守卫:techDesign / propose 阶段的纠偏统计只接受增量编辑(Edit/MultiEdit),
1030
+ // 禁止 Write。原因:tech-spec.md / proposal.md 通常是整文件 Write 产出,一旦被误判为纠偏,
1031
+ // 会把整份文件行数当成 userCorrectionLines 记入,导致分子被严重放大、accuracyRate 失真。
1032
+ // apply 阶段不受此限制(apply 可 Write 新增代码文件)。
1033
+ if ((trackedPhase === 'techDesign' || trackedPhase === 'propose') && toolName === 'Write') {
1034
+ correctionDebugLog(`skipped/write-blocked-${trackedPhase}-correction`, 'skipped', 0);
1035
+ appendAccuracyDebugLog(changeDir, {
1036
+ type: `skip.write-blocked-${trackedPhase}-correction`,
1037
+ phase: trackedPhase,
1038
+ kind: trackedKind,
1039
+ trigger: 'hook',
1040
+ changeId,
1041
+ skipReason: `write-blocked-${trackedPhase}-correction`,
1042
+ input: { tool: toolName, file: filePath, changeLines },
1043
+ });
1044
+ }
1045
+ else {
1046
+ // 纠偏上下文:提取 promptId,同 (promptId+file+phase) 取 max 计算 delta,桶 + 顶层 userCorrectionLines 同步累加 delta
1047
+ // 提取纠偏触发源头信息(Task 6)
1048
+ // post-baseline-fallback 兜底命中时,语义等价于 user-input hook 写入的 'post-baseline-prompt'。
1049
+ const ccSource = progress.correctionContext?.source
1050
+ || (pendingExists
1051
+ ? 'pending-correction-marker'
1052
+ : (correctionFallbackReason === 'post-baseline-fallback'
1053
+ ? 'post-baseline-prompt'
1054
+ : 'correction-keyword'));
1055
+ // Task 6:若 correctionContext 无 prompt 元数据(典型于 pending-correction-marker 兑底路径),
1056
+ // 从 .pending-correction marker JSON 中回填 promptId / phaseInputIndex / promptSnippet。
1057
+ let fallbackPromptId;
1058
+ let fallbackPromptIndex;
1059
+ let fallbackPromptSnippet;
1060
+ if (!progress.correctionContext?.promptId && pendingExists) {
1061
+ try {
1062
+ const markerRaw = readFileSync(pendingMarkerPath, 'utf-8');
1063
+ if (markerRaw) {
1064
+ const markerData = JSON.parse(markerRaw);
1065
+ fallbackPromptId = markerData.promptId;
1066
+ fallbackPromptIndex = markerData.phaseInputIndex;
1067
+ fallbackPromptSnippet = markerData.promptSnippet;
1068
+ }
1069
+ }
1070
+ catch {
1071
+ // marker 解析失败不阻断主流程
1072
+ }
1073
+ }
1074
+ const promptId = progress.correctionContext?.promptId ?? fallbackPromptId;
1075
+ const promptIndex = progress.correctionContext?.promptIndex ?? fallbackPromptIndex;
1076
+ const promptSnippet = progress.correctionContext?.promptSnippet ?? fallbackPromptSnippet;
1077
+ // 去重:同 (promptId + filePath + phase) 取历史 max,只累加 delta。
1078
+ // 语义:同一 prompt 中 AI 对同一文件的多次 Edit(重复修正同区域)不重复计数。
1079
+ progress.accuracy.correctionEdits = progress.accuracy.correctionEdits || [];
1080
+ const prevMax = computePromptFileMaxLines(progress.accuracy.correctionEdits, promptId, filePath, trackedPhase);
1081
+ const dedupDelta = Math.max(0, changeLines - prevMax);
1082
+ bucket.userCorrectionLines += dedupDelta;
1083
+ bucket.lastUpdatedAt = timestamp;
1084
+ progress.accuracy.userCorrectionLines = (progress.accuracy.userCorrectionLines || 0) + dedupDelta;
1085
+ progress.accuracy.correctionEdits.push({
1086
+ filePath,
1087
+ editLines: changeLines,
1088
+ source: 'user',
1089
+ strategy: 'correction-context',
1090
+ agentType: progress.correctionContext?.path,
1091
+ timestamp,
1092
+ phase: trackedPhase,
1093
+ trackedKind,
1094
+ triggeredBy: {
1095
+ source: ccSource,
1096
+ promptId,
1097
+ promptIndex,
1098
+ promptSnippet,
1099
+ },
1100
+ });
1101
+ // 记录 phase 维度 promptId(去重)
1102
+ if (promptId && !bucket.correctionPromptIds.includes(promptId)) {
1103
+ bucket.correctionPromptIds.push(promptId);
1104
+ }
1105
+ correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines', dedupDelta);
1106
+ appendAccuracyDebugLog(changeDir, {
1107
+ type: 'correction.record',
1108
+ phase: trackedPhase,
1109
+ kind: trackedKind,
1110
+ trigger: 'hook',
1111
+ changeId,
1112
+ input: {
1113
+ tool: toolName,
1114
+ file: filePath,
1115
+ changeLines,
1116
+ contentSource,
1117
+ ccSource,
1118
+ promptId,
1119
+ promptIndex,
1120
+ prevMax,
1121
+ dedupDelta,
1122
+ },
1123
+ output: {
1124
+ bucketCorrection: bucket.userCorrectionLines,
1125
+ topCorrection: progress.accuracy.userCorrectionLines,
1126
+ promptIdsCount: bucket.correctionPromptIds.length,
1127
+ },
1128
+ });
1129
+ }
1130
+ }
1131
+ else {
1132
+ // 正常 AI 产出:桶 + 顶层 aiLinesAdded/aiTotalLines 同步累加,同时顶层 progress.linesAdded 仍需累加以兼容旧逻辑
1133
+ bucket.aiLinesAdded += changeLines;
1134
+ bucket.aiTotalLines += changeLines;
1135
+ bucket.lastUpdatedAt = timestamp;
1136
+ progress.accuracy.aiLinesAdded = (progress.accuracy.aiLinesAdded || 0) + changeLines;
1137
+ progress.accuracy.aiTotalLines = (progress.accuracy.aiTotalLines || 0) + changeLines;
1138
+ progress.linesAdded += changeLines;
1139
+ correctionDebugLog(`${toolName}/normal`, 'linesAdded', changeLines);
1140
+ appendAccuracyDebugLog(changeDir, {
1141
+ type: 'ai.record',
1142
+ phase: trackedPhase,
1143
+ kind: trackedKind,
1144
+ trigger: 'hook',
1145
+ changeId,
1146
+ input: { tool: toolName, file: filePath, changeLines, contentSource },
1147
+ output: {
1148
+ bucketAiTotal: bucket.aiTotalLines,
1149
+ topAiTotal: progress.accuracy.aiTotalLines,
1150
+ },
1151
+ });
1152
+ }
922
1153
  }
923
1154
  // 累加后重算 accuracyRate(顶层 + 各桶)
924
1155
  recomputeAccuracyRate(progress, { changeDir, trigger: 'hook' });
@@ -185,17 +185,19 @@ export async function userInputHook(content, responseTimeMs) {
185
185
  logDebug(cwd, { event: 'skip', reason: 'content-too-short', changeId, phase, phaseSource, contentLen });
186
186
  return { continue: true };
187
187
  }
188
- // Skip slash/bang commands (e.g. /zhuanspec:proposal, /compact, /clear, !ls).
189
- // 这类 prompt 是命令/workflow 触发,非“用户对 AI 产出的纠偏/濄清/补充”,
188
+ // Skip slash/bang/dollar commands:
189
+ // Claude Code: /zhuanspec:proposal, /compact, /clear, !ls
190
+ // Codex: $zhuanspec-proposal, $zhuanspec-techDesign 等 skill 触发
191
+ // 这类 prompt 是命令/workflow 触发,非"用户对 AI 产出的纠偏/澄清/补充",
190
192
  // 不应计入 user_inputs.json,更不应放进纠偏溯源池影响 accuracyRate。
191
193
  // 注意:Claude Code 在 slash 命令前会注入 NAK(U+0015) 等 C0 控制字符做标记,
192
194
  // 普通 trim() 不会剥控制字符,必须显式剥掉前缀的 C0/DEL 再做正则判定,
193
195
  // 否则像 "\u0015/zhuanspec:proposal" 这种命令会漏过过滤被记成普通用户输入。
194
196
  const trimmedContent = content.trim().replace(/^[\x00-\x1F\x7F]+/, '');
195
- if (/^[/!][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
197
+ if (/^[/!$][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
196
198
  logDebug(cwd, {
197
199
  event: 'skip',
198
- reason: 'slash-command',
200
+ reason: 'skill-command',
199
201
  changeId,
200
202
  phase,
201
203
  phaseSource,
@@ -38,6 +38,66 @@ export declare function appendAccuracyDebugLog(changeDir: string, entry: Accurac
38
38
  * 判断是否为代码文件(需纳入准确率统计)
39
39
  */
40
40
  export declare function isCodeFile(filePath: string): boolean;
41
+ /**
42
+ * Review 阶段自动修复 Skill 列表。
43
+ * 这些 Skill 在 review 阶段触发的代码编辑属于自动修复,
44
+ * 不应计入用户纠正(不影响 apply 阶段准确率)。
45
+ *
46
+ * 匹配规则:skill name 包含列表中任一子串即视为 autofix skill。
47
+ */
48
+ export declare const REVIEW_AUTOFIX_SKILLS: string[];
49
+ /**
50
+ * 纯确认/招呼类 prompt,不具备纠偏意图,不应触发用户纠正归因。
51
+ * 与 src/core/corrections/select-candidates.ts 中的 TRIVIAL_ACK_SET 保持同步。
52
+ */
53
+ export declare const TRIVIAL_ACK_WORDS: ReadonlySet<string>;
54
+ /**
55
+ * 判定 prompt 是否为琐碎确认/招呼(如"继续"、"好的"、"ok"等)。
56
+ *
57
+ * 规则:
58
+ * 1. 空字符串视为琐碎;
59
+ * 2. 规范化后命中 TRIVIAL_ACK_WORDS 视为琐碎;
60
+ * 3. 规范化后字符长度 ≤ 2 视为琐碎(如"好。"、"!!")。
61
+ *
62
+ * 注:斜杠/感叹号开头的命令词(/zhuanspec:、!ls 等)已由 user-input-hook
63
+ * 在写入 correctionContext 前过滤,无需在此重复处理。
64
+ */
65
+ export declare function isTrivialAckPrompt(text: string | null | undefined): boolean;
66
+ /**
67
+ * 判断 correctionContext 是否代表一次有意义的用户纠正(非琐碎确认)。
68
+ *
69
+ * 接受的 source:
70
+ * - correction-keyword:命中显式纠偏关键词,始终视为用户纠正;
71
+ * - post-baseline-prompt:baseline 后任何用户提示,但 promptSnippet
72
+ * 必须不是琐碎确认词(如"继续"、"好的")。
73
+ *
74
+ * 该函数用于 review 阶段判定 "用户提示词触发的纠正",使其覆盖范围与 apply 阶段一致。
75
+ */
76
+ export declare function isMeaningfulUserCorrection(correctionContext?: {
77
+ active: boolean;
78
+ source?: string;
79
+ promptSnippet?: string;
80
+ } | null): boolean;
81
+ /**
82
+ * 判定当前 review 阶段的编辑是否属于 Skill 自动修复(CR/单测),不应计入准确率。
83
+ *
84
+ * 判定逻辑:取最近一次 review 阶段的 skill 调用,若 skill name 匹配 REVIEW_AUTOFIX_SKILLS
85
+ * 且该 skill 调用晚于最近的有意义用户纠偏上下文(correctionContext.resolvedAt),则视为自动修复。
86
+ *
87
+ * "有意义的用户纠偏" 定义:
88
+ * - source = 'correction-keyword',或
89
+ * - source = 'post-baseline-prompt' 且 promptSnippet 不是琐碎确认词
90
+ */
91
+ export declare function isReviewAutoFixActive(skillCalls: Array<{
92
+ skill: string;
93
+ triggeredAt: string;
94
+ phase: string;
95
+ }>, correctionContext?: {
96
+ active: boolean;
97
+ resolvedAt?: string;
98
+ source?: string;
99
+ promptSnippet?: string;
100
+ }): boolean;
41
101
  export type TrackedPhase = 'techDesign' | 'propose' | 'apply' | 'review';
42
102
  export type TrackedKind = 'techSpec' | 'proposalDoc' | 'code';
43
103
  /**
@@ -150,6 +210,22 @@ export declare function snapshotAiBaseline(progress: ProgressData, changeDir: st
150
210
  * @returns true 表示本次调用创建了新快照,false 表示快照已存在或跳过
151
211
  */
152
212
  export declare function ensureAccuracySnapshot(changeDir: string): Promise<boolean>;
213
+ /**
214
+ * 同 (promptId + filePath + phase) 历史 record 中的最大 editLines。
215
+ *
216
+ * 背景:用户一次 prompt 可能触发 AI 多次 Edit(重复修正同一区域)。
217
+ * 直接累加会造成虚高;取 max 反映“最终保留下来的最大一次改动”。
218
+ *
219
+ * promptId 为空时返回 0(冷启动,当前是首条,取 0 后累加全量 changeLines)。
220
+ */
221
+ export declare function computePromptFileMaxLines(correctionEdits: ReadonlyArray<{
222
+ filePath: string;
223
+ editLines: number;
224
+ phase?: string;
225
+ triggeredBy?: {
226
+ promptId?: string;
227
+ };
228
+ }>, promptId: string | undefined, filePath: string, phase: TrackedPhase): number;
153
229
  /**
154
230
  * 用户纠正行数累计(legacy——新代码优先用 record-progress.ts 内联的 correctionContext 逻辑)。
155
231
  *
@@ -62,6 +62,110 @@ export function isCodeFile(filePath) {
62
62
  const ext = path.extname(filePath).toLowerCase();
63
63
  return CODE_FILE_EXTENSIONS.has(ext);
64
64
  }
65
+ // ============================================================
66
+ // Review 阶段自动修复 Skill 白名单
67
+ // ============================================================
68
+ /**
69
+ * Review 阶段自动修复 Skill 列表。
70
+ * 这些 Skill 在 review 阶段触发的代码编辑属于自动修复,
71
+ * 不应计入用户纠正(不影响 apply 阶段准确率)。
72
+ *
73
+ * 匹配规则:skill name 包含列表中任一子串即视为 autofix skill。
74
+ */
75
+ export const REVIEW_AUTOFIX_SKILLS = [
76
+ 'code-review-expert',
77
+ 'generate-mockito-unit-test',
78
+ ];
79
+ // ============================================================
80
+ // 琐碎确认词识别(与 select-candidates.ts 保持一致)
81
+ // ============================================================
82
+ /**
83
+ * 纯确认/招呼类 prompt,不具备纠偏意图,不应触发用户纠正归因。
84
+ * 与 src/core/corrections/select-candidates.ts 中的 TRIVIAL_ACK_SET 保持同步。
85
+ */
86
+ export const TRIVIAL_ACK_WORDS = new Set([
87
+ '是', '是的', '好', '好的', '嗯', '嗯嗯', '对', '行', '可以', '确认',
88
+ '同意', '继续', '没问题', '明白', '了解', '知道了', '收到', '辛苦', '辛苦了',
89
+ 'ok', 'okay', 'k', 'yes', 'y', 'yep', 'yeah', 'sure', 'fine', 'cool',
90
+ 'go', 'go on', 'plan approved', 'continue', 'thanks', 'thx',
91
+ ]);
92
+ /** 规范化文本:小写 + 中英文标点/空白统一压成单空格。 */
93
+ function normalizeAckText(text) {
94
+ return text
95
+ .toLowerCase()
96
+ .replace(/[\s\p{P}\p{S}]+/gu, ' ')
97
+ .trim();
98
+ }
99
+ /**
100
+ * 判定 prompt 是否为琐碎确认/招呼(如"继续"、"好的"、"ok"等)。
101
+ *
102
+ * 规则:
103
+ * 1. 空字符串视为琐碎;
104
+ * 2. 规范化后命中 TRIVIAL_ACK_WORDS 视为琐碎;
105
+ * 3. 规范化后字符长度 ≤ 2 视为琐碎(如"好。"、"!!")。
106
+ *
107
+ * 注:斜杠/感叹号开头的命令词(/zhuanspec:、!ls 等)已由 user-input-hook
108
+ * 在写入 correctionContext 前过滤,无需在此重复处理。
109
+ */
110
+ export function isTrivialAckPrompt(text) {
111
+ if (!text)
112
+ return true;
113
+ const norm = normalizeAckText(text);
114
+ if (norm.length === 0)
115
+ return true;
116
+ if (TRIVIAL_ACK_WORDS.has(norm))
117
+ return true;
118
+ if (norm.length <= 2)
119
+ return true;
120
+ return false;
121
+ }
122
+ /**
123
+ * 判断 correctionContext 是否代表一次有意义的用户纠正(非琐碎确认)。
124
+ *
125
+ * 接受的 source:
126
+ * - correction-keyword:命中显式纠偏关键词,始终视为用户纠正;
127
+ * - post-baseline-prompt:baseline 后任何用户提示,但 promptSnippet
128
+ * 必须不是琐碎确认词(如"继续"、"好的")。
129
+ *
130
+ * 该函数用于 review 阶段判定 "用户提示词触发的纠正",使其覆盖范围与 apply 阶段一致。
131
+ */
132
+ export function isMeaningfulUserCorrection(correctionContext) {
133
+ if (!correctionContext?.active)
134
+ return false;
135
+ const source = correctionContext.source;
136
+ if (source === 'correction-keyword')
137
+ return true;
138
+ if (source === 'post-baseline-prompt') {
139
+ return !isTrivialAckPrompt(correctionContext.promptSnippet);
140
+ }
141
+ return false;
142
+ }
143
+ /**
144
+ * 判定当前 review 阶段的编辑是否属于 Skill 自动修复(CR/单测),不应计入准确率。
145
+ *
146
+ * 判定逻辑:取最近一次 review 阶段的 skill 调用,若 skill name 匹配 REVIEW_AUTOFIX_SKILLS
147
+ * 且该 skill 调用晚于最近的有意义用户纠偏上下文(correctionContext.resolvedAt),则视为自动修复。
148
+ *
149
+ * "有意义的用户纠偏" 定义:
150
+ * - source = 'correction-keyword',或
151
+ * - source = 'post-baseline-prompt' 且 promptSnippet 不是琐碎确认词
152
+ */
153
+ export function isReviewAutoFixActive(skillCalls, correctionContext) {
154
+ // 取 review 阶段中匹配 autofix skill 的最近一次调用
155
+ const recentAutofix = [...skillCalls]
156
+ .reverse()
157
+ .find(sc => sc.phase === 'review'
158
+ && REVIEW_AUTOFIX_SKILLS.some(s => sc.skill.includes(s)));
159
+ if (!recentAutofix)
160
+ return false;
161
+ // 如果存在有意义的用户纠偏上下文且 resolvedAt 晚于 skill 触发时间,说明用户已介入
162
+ if (isMeaningfulUserCorrection(correctionContext)
163
+ && correctionContext?.resolvedAt
164
+ && correctionContext.resolvedAt > recentAutofix.triggeredAt) {
165
+ return false;
166
+ }
167
+ return true;
168
+ }
65
169
  /**
66
170
  * 判定 filePath 在当前 phase 下是否属于白名单,并返回文件分类。
67
171
  *
@@ -322,6 +426,32 @@ export async function ensureAccuracySnapshot(changeDir) {
322
426
  }
323
427
  }
324
428
  // ============================================================
429
+ // 纠偏去重计算(同 promptId+filePath+phase 取 max)
430
+ // ============================================================
431
+ /**
432
+ * 同 (promptId + filePath + phase) 历史 record 中的最大 editLines。
433
+ *
434
+ * 背景:用户一次 prompt 可能触发 AI 多次 Edit(重复修正同一区域)。
435
+ * 直接累加会造成虚高;取 max 反映“最终保留下来的最大一次改动”。
436
+ *
437
+ * promptId 为空时返回 0(冷启动,当前是首条,取 0 后累加全量 changeLines)。
438
+ */
439
+ export function computePromptFileMaxLines(correctionEdits, promptId, filePath, phase) {
440
+ if (!promptId || !filePath)
441
+ return 0;
442
+ let maxLines = 0;
443
+ for (const e of correctionEdits) {
444
+ if (e.phase === phase
445
+ && e.filePath === filePath
446
+ && e.triggeredBy?.promptId === promptId
447
+ && typeof e.editLines === 'number'
448
+ && e.editLines > maxLines) {
449
+ maxLines = e.editLines;
450
+ }
451
+ }
452
+ return maxLines;
453
+ }
454
+ // ============================================================
325
455
  // 用户纠正行数累计
326
456
  // ============================================================
327
457
  /**
@@ -85,5 +85,32 @@ export interface CodexHookSlot {
85
85
  * the same event (matching Codex's array-of-tables semantics).
86
86
  */
87
87
  export declare function getCodexDefaultHookSlots(): readonly CodexHookSlot[];
88
+ /**
89
+ * Compute the content-trust SHA-256 hash for a hook handler, replicating
90
+ * the algorithm from Codex CLI's `codex-rs/hooks/src/engine/discovery.rs`:
91
+ *
92
+ * Codex hashes a `NormalizedHookIdentity` struct, which is serialized to
93
+ * TOML then converted to canonical JSON. The structure is:
94
+ *
95
+ * { event_name, matcher?, hooks: [{ type, command, async, timeout, statusMessage? }] }
96
+ *
97
+ * Key normalization details:
98
+ * - `timeout` defaults to 600 when absent in source TOML
99
+ * - `async` is always false (we don't support async hooks)
100
+ * - `command_windows` (None) is omitted in TOML serialization
101
+ * - `matcher` is only included when present
102
+ * - Keys are recursively sorted (canonical JSON) then SHA-256 hashed
103
+ *
104
+ * This allows ZhuanSpec to pre-seed `trusted_hash` entries directly without
105
+ * relying on cross-project hash grafting, eliminating the "Hooks need review"
106
+ * prompt on first use or after upgrades.
107
+ */
108
+ export declare function computeHookContentHash(entry: CodexHookEntry): string;
109
+ /**
110
+ * Get the computed content-trust hashes for all default hook handlers.
111
+ * Returns a map from slot suffix (`<event_snake>:<group>:<handler>`) to
112
+ * the SHA-256 hash string that Codex CLI would compute.
113
+ */
114
+ export declare function getCodexDefaultHookHashes(): Map<string, string>;
88
115
  export {};
89
116
  //# sourceMappingURL=codex-hooks-template.d.ts.map
@@ -25,6 +25,7 @@
25
25
  * ZhuanSpec markers of `<repo>/.codex/config.toml` so that `zhuanspec update`
26
26
  * can regenerate it idempotently without clobbering user-owned config.
27
27
  */
28
+ import { createHash } from 'crypto';
28
29
  /**
29
30
  * ZhuanSpec default hooks, in logical order. Keep this list in sync with the
30
31
  * Claude settings.json generator in init.ts so behavior stays consistent
@@ -49,7 +50,7 @@ const CODEX_DEFAULT_HOOKS = [
49
50
  },
50
51
  {
51
52
  event: 'PostToolUse',
52
- matcher: 'Write|Edit|Read|Grep|Glob|Bash|Skill|Agent|mcp__.*',
53
+ matcher: 'Write|Edit|apply_patch|Read|Grep|Glob|Bash|Skill|Agent|mcp__.*',
53
54
  command: 'ZHUANSPEC_HOST=codex zhuanspec-hook record-progress --json',
54
55
  statusMessage: '📝 Recording progress...',
55
56
  },
@@ -142,4 +143,84 @@ export function getCodexDefaultHookSlots() {
142
143
  return { event: entry.event, eventSnake, groupIndex, handlerIndex: 0 };
143
144
  });
144
145
  }
146
+ /**
147
+ * Compute the content-trust SHA-256 hash for a hook handler, replicating
148
+ * the algorithm from Codex CLI's `codex-rs/hooks/src/engine/discovery.rs`:
149
+ *
150
+ * Codex hashes a `NormalizedHookIdentity` struct, which is serialized to
151
+ * TOML then converted to canonical JSON. The structure is:
152
+ *
153
+ * { event_name, matcher?, hooks: [{ type, command, async, timeout, statusMessage? }] }
154
+ *
155
+ * Key normalization details:
156
+ * - `timeout` defaults to 600 when absent in source TOML
157
+ * - `async` is always false (we don't support async hooks)
158
+ * - `command_windows` (None) is omitted in TOML serialization
159
+ * - `matcher` is only included when present
160
+ * - Keys are recursively sorted (canonical JSON) then SHA-256 hashed
161
+ *
162
+ * This allows ZhuanSpec to pre-seed `trusted_hash` entries directly without
163
+ * relying on cross-project hash grafting, eliminating the "Hooks need review"
164
+ * prompt on first use or after upgrades.
165
+ */
166
+ export function computeHookContentHash(entry) {
167
+ // Build the normalized handler object matching Codex's HookHandlerConfig::Command
168
+ // after normalization (timeout defaults to 600, async always present).
169
+ const handlerObj = {
170
+ type: 'command',
171
+ command: entry.command,
172
+ async: false,
173
+ timeout: 600,
174
+ };
175
+ if (entry.statusMessage) {
176
+ handlerObj.statusMessage = entry.statusMessage;
177
+ }
178
+ // Build the NormalizedHookIdentity: event_name + flattened MatcherGroup
179
+ const identity = {
180
+ event_name: toEventSnake(entry.event),
181
+ hooks: [handlerObj],
182
+ };
183
+ if (entry.matcher) {
184
+ identity.matcher = entry.matcher;
185
+ }
186
+ const canonical = canonicalJson(identity);
187
+ const bytes = Buffer.from(JSON.stringify(canonical), 'utf-8');
188
+ const hex = createHash('sha256').update(bytes).digest('hex');
189
+ return `sha256:${hex}`;
190
+ }
191
+ /**
192
+ * Recursively sort object keys alphabetically to produce canonical JSON,
193
+ * matching Codex CLI's `canonical_json()` in fingerprint.rs.
194
+ */
195
+ function canonicalJson(value) {
196
+ if (value === null || value === undefined)
197
+ return value;
198
+ if (Array.isArray(value))
199
+ return value.map(canonicalJson);
200
+ if (typeof value === 'object') {
201
+ const obj = value;
202
+ const sorted = {};
203
+ for (const key of Object.keys(obj).sort()) {
204
+ sorted[key] = canonicalJson(obj[key]);
205
+ }
206
+ return sorted;
207
+ }
208
+ return value;
209
+ }
210
+ /**
211
+ * Get the computed content-trust hashes for all default hook handlers.
212
+ * Returns a map from slot suffix (`<event_snake>:<group>:<handler>`) to
213
+ * the SHA-256 hash string that Codex CLI would compute.
214
+ */
215
+ export function getCodexDefaultHookHashes() {
216
+ const hashes = new Map();
217
+ const slots = getCodexDefaultHookSlots();
218
+ for (let i = 0; i < CODEX_DEFAULT_HOOKS.length; i++) {
219
+ const entry = CODEX_DEFAULT_HOOKS[i];
220
+ const slot = slots[i];
221
+ const suffix = `${slot.eventSnake}:${slot.groupIndex}:${slot.handlerIndex}`;
222
+ hashes.set(suffix, computeHookContentHash(entry));
223
+ }
224
+ return hashes;
225
+ }
145
226
  //# sourceMappingURL=codex-hooks-template.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 行级 diff 计算工具
3
+ *
4
+ * 用途:替代 record-progress.ts 中的 Math.max(newLines, oldLines),
5
+ * 只统计真正发生变化的行数(增 + 删),不计入未修改的上下文锚点。
6
+ *
7
+ * 例:
8
+ * old = "A\nB\nC\nD"
9
+ * new = "A\nX\nC\nD"
10
+ * max(4,4) = 4 ← 旧口径(含上下文)
11
+ * diff = 2 ← 新口径(B 删除 + X 新增)
12
+ *
13
+ * 算法:基于 LCS(Longest Common Subsequence)的行级最小编辑距离。
14
+ * 对小规模编辑(< 1000 行)足够快,且无外部依赖。
15
+ */
16
+ /**
17
+ * 计算两段文本的行级净改动行数(添加行数 + 删除行数)。
18
+ *
19
+ * - 仅有 oldText:纯删除,返回旧文本行数
20
+ * - 仅有 newText:纯新增,返回新文本行数
21
+ * - 两者都有:基于 LCS 的最小编辑距离
22
+ *
23
+ * 注意:
24
+ * - 完全相同(无变化)返回 0
25
+ * - 修改一行 = 1 删 + 1 增 = 2
26
+ * - 与 unified diff 的 +/- 行数概念一致
27
+ *
28
+ * @param oldText 旧文本(Edit 的 old_string)
29
+ * @param newText 新文本(Edit 的 new_string)
30
+ * @returns 净改动行数(>= 0)
31
+ */
32
+ export declare function countLineDiff(oldText: string | undefined | null, newText: string | undefined | null): number;
33
+ //# sourceMappingURL=line-diff.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 行级 diff 计算工具
3
+ *
4
+ * 用途:替代 record-progress.ts 中的 Math.max(newLines, oldLines),
5
+ * 只统计真正发生变化的行数(增 + 删),不计入未修改的上下文锚点。
6
+ *
7
+ * 例:
8
+ * old = "A\nB\nC\nD"
9
+ * new = "A\nX\nC\nD"
10
+ * max(4,4) = 4 ← 旧口径(含上下文)
11
+ * diff = 2 ← 新口径(B 删除 + X 新增)
12
+ *
13
+ * 算法:基于 LCS(Longest Common Subsequence)的行级最小编辑距离。
14
+ * 对小规模编辑(< 1000 行)足够快,且无外部依赖。
15
+ */
16
+ /**
17
+ * 计算两段文本的行级净改动行数(添加行数 + 删除行数)。
18
+ *
19
+ * - 仅有 oldText:纯删除,返回旧文本行数
20
+ * - 仅有 newText:纯新增,返回新文本行数
21
+ * - 两者都有:基于 LCS 的最小编辑距离
22
+ *
23
+ * 注意:
24
+ * - 完全相同(无变化)返回 0
25
+ * - 修改一行 = 1 删 + 1 增 = 2
26
+ * - 与 unified diff 的 +/- 行数概念一致
27
+ *
28
+ * @param oldText 旧文本(Edit 的 old_string)
29
+ * @param newText 新文本(Edit 的 new_string)
30
+ * @returns 净改动行数(>= 0)
31
+ */
32
+ export function countLineDiff(oldText, newText) {
33
+ const oldStr = oldText ?? '';
34
+ const newStr = newText ?? '';
35
+ if (oldStr === newStr)
36
+ return 0;
37
+ if (!oldStr)
38
+ return newStr.split('\n').length;
39
+ if (!newStr)
40
+ return oldStr.split('\n').length;
41
+ const oldLines = oldStr.split('\n');
42
+ const newLines = newStr.split('\n');
43
+ const lcs = lcsLength(oldLines, newLines);
44
+ // 删除行数 = 旧行 - LCS;新增行数 = 新行 - LCS
45
+ return (oldLines.length - lcs) + (newLines.length - lcs);
46
+ }
47
+ /**
48
+ * 计算两个字符串数组的 LCS 长度(动态规划,O(n*m) 时间,O(min) 空间)。
49
+ *
50
+ * 内部辅助函数。使用滚动数组避免 O(n*m) 空间开销。
51
+ *
52
+ * @param a 数组 A(旧行)
53
+ * @param b 数组 B(新行)
54
+ * @returns 最长公共子序列长度
55
+ */
56
+ function lcsLength(a, b) {
57
+ const n = a.length;
58
+ const m = b.length;
59
+ if (n === 0 || m === 0)
60
+ return 0;
61
+ // 让 b 作为短边,节省空间
62
+ const [shortArr, longArr] = m <= n ? [b, a] : [a, b];
63
+ const shortLen = shortArr.length;
64
+ const longLen = longArr.length;
65
+ // 滚动数组:prev / curr,长度 = shortLen + 1
66
+ let prev = new Array(shortLen + 1).fill(0);
67
+ let curr = new Array(shortLen + 1).fill(0);
68
+ for (let i = 1; i <= longLen; i++) {
69
+ for (let j = 1; j <= shortLen; j++) {
70
+ if (longArr[i - 1] === shortArr[j - 1]) {
71
+ curr[j] = prev[j - 1] + 1;
72
+ }
73
+ else {
74
+ curr[j] = Math.max(prev[j], curr[j - 1]);
75
+ }
76
+ }
77
+ // 滚动:把 curr 拷给 prev,curr 清零
78
+ const tmp = prev;
79
+ prev = curr;
80
+ curr = tmp;
81
+ curr.fill(0);
82
+ }
83
+ return prev[shortLen];
84
+ }
85
+ //# sourceMappingURL=line-diff.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.16.3",
3
+ "version": "2.16.5",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",
@@ -74,7 +74,6 @@
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",
78
77
  "chalk": "^5.5.0",
79
78
  "commander": "^14.0.0",
80
79
  "fast-glob": "^3.3.3",