@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.
- package/dist/core/configurators/codex.js +11 -1
- package/dist/core/hooks/record-progress.js +303 -72
- package/dist/core/hooks/user-input-hook.js +6 -4
- 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 +82 -1
- package/dist/utils/line-diff.d.ts +33 -0
- package/dist/utils/line-diff.js +85 -0
- package/package.json +1 -2
|
@@ -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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
|
|
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:
|
|
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
|
-
// 记录
|
|
874
|
-
if (promptId && !
|
|
875
|
-
|
|
960
|
+
// 记录 apply bucket 的 promptId(去重)
|
|
961
|
+
if (promptId && !applyBucket.correctionPromptIds.includes(promptId)) {
|
|
962
|
+
applyBucket.correctionPromptIds.push(promptId);
|
|
876
963
|
}
|
|
877
|
-
correctionDebugLog(`${toolName}/correction`, 'userCorrectionLines',
|
|
964
|
+
correctionDebugLog(`${toolName}/review-user-correction→apply`, 'userCorrectionLines', dedupDelta);
|
|
878
965
|
appendAccuracyDebugLog(changeDir, {
|
|
879
966
|
type: 'correction.record',
|
|
880
|
-
phase:
|
|
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:
|
|
985
|
+
bucketCorrection: applyBucket.userCorrectionLines,
|
|
895
986
|
topCorrection: progress.accuracy.userCorrectionLines,
|
|
896
|
-
promptIdsCount:
|
|
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
|
-
//
|
|
903
|
-
bucket
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
|
189
|
-
//
|
|
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 (/^[
|
|
197
|
+
if (/^[/!$][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
|
|
196
198
|
logDebug(cwd, {
|
|
197
199
|
event: 'skip',
|
|
198
|
-
reason: '
|
|
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
|
+
"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",
|