@coldtea/abide 0.0.1
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/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/abide-hook.d.ts +2 -0
- package/dist/abide-hook.d.ts.map +1 -0
- package/dist/abide-hook.js +23 -0
- package/dist/abide-hook.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +64 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +79 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/bench.d.ts +3 -0
- package/dist/commands/bench.d.ts.map +1 -0
- package/dist/commands/bench.js +206 -0
- package/dist/commands/bench.js.map +1 -0
- package/dist/commands/calibrate.d.ts +3 -0
- package/dist/commands/calibrate.d.ts.map +1 -0
- package/dist/commands/calibrate.js +159 -0
- package/dist/commands/calibrate.js.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +106 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/compile.d.ts +3 -0
- package/dist/commands/compile.d.ts.map +1 -0
- package/dist/commands/compile.js +116 -0
- package/dist/commands/compile.js.map +1 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +93 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +58 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/replay.d.ts +4 -0
- package/dist/commands/replay.d.ts.map +1 -0
- package/dist/commands/replay.js +118 -0
- package/dist/commands/replay.js.map +1 -0
- package/dist/commands/report.d.ts +5 -0
- package/dist/commands/report.d.ts.map +1 -0
- package/dist/commands/report.js +78 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/rubric.d.ts +3 -0
- package/dist/commands/rubric.d.ts.map +1 -0
- package/dist/commands/rubric.js +40 -0
- package/dist/commands/rubric.js.map +1 -0
- package/dist/commands/uninstall.d.ts +2 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +31 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/hooks/postToolUse.d.ts +3 -0
- package/dist/hooks/postToolUse.d.ts.map +1 -0
- package/dist/hooks/postToolUse.js +145 -0
- package/dist/hooks/postToolUse.js.map +1 -0
- package/dist/hooks/sessionStart.d.ts +11 -0
- package/dist/hooks/sessionStart.d.ts.map +1 -0
- package/dist/hooks/sessionStart.js +83 -0
- package/dist/hooks/sessionStart.js.map +1 -0
- package/dist/hooks/stop.d.ts +26 -0
- package/dist/hooks/stop.d.ts.map +1 -0
- package/dist/hooks/stop.js +227 -0
- package/dist/hooks/stop.js.map +1 -0
- package/dist/hooks/turnStart.d.ts +8 -0
- package/dist/hooks/turnStart.d.ts.map +1 -0
- package/dist/hooks/turnStart.js +40 -0
- package/dist/hooks/turnStart.js.map +1 -0
- package/dist/lib/applyPatch.d.ts +22 -0
- package/dist/lib/applyPatch.d.ts.map +1 -0
- package/dist/lib/applyPatch.js +63 -0
- package/dist/lib/applyPatch.js.map +1 -0
- package/dist/lib/audit.d.ts +40 -0
- package/dist/lib/audit.d.ts.map +1 -0
- package/dist/lib/audit.js +215 -0
- package/dist/lib/audit.js.map +1 -0
- package/dist/lib/band.d.ts +20 -0
- package/dist/lib/band.d.ts.map +1 -0
- package/dist/lib/band.js +40 -0
- package/dist/lib/band.js.map +1 -0
- package/dist/lib/calibration.d.ts +13 -0
- package/dist/lib/calibration.d.ts.map +1 -0
- package/dist/lib/calibration.js +25 -0
- package/dist/lib/calibration.js.map +1 -0
- package/dist/lib/checkRunner.d.ts +34 -0
- package/dist/lib/checkRunner.d.ts.map +1 -0
- package/dist/lib/checkRunner.js +76 -0
- package/dist/lib/checkRunner.js.map +1 -0
- package/dist/lib/claude.d.ts +4 -0
- package/dist/lib/claude.d.ts.map +1 -0
- package/dist/lib/claude.js +28 -0
- package/dist/lib/claude.js.map +1 -0
- package/dist/lib/compilePrompt.d.ts +22 -0
- package/dist/lib/compilePrompt.d.ts.map +1 -0
- package/dist/lib/compilePrompt.js +33 -0
- package/dist/lib/compilePrompt.js.map +1 -0
- package/dist/lib/constants.d.ts +33 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +33 -0
- package/dist/lib/constants.js.map +1 -0
- package/dist/lib/coverage.d.ts +7 -0
- package/dist/lib/coverage.d.ts.map +1 -0
- package/dist/lib/coverage.js +17 -0
- package/dist/lib/coverage.js.map +1 -0
- package/dist/lib/credentials.d.ts +27 -0
- package/dist/lib/credentials.d.ts.map +1 -0
- package/dist/lib/credentials.js +89 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/diff.d.ts +30 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +139 -0
- package/dist/lib/diff.js.map +1 -0
- package/dist/lib/events.d.ts +5 -0
- package/dist/lib/events.d.ts.map +1 -0
- package/dist/lib/events.js +38 -0
- package/dist/lib/events.js.map +1 -0
- package/dist/lib/git.d.ts +37 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +163 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/hookRunner.d.ts +10 -0
- package/dist/lib/hookRunner.d.ts.map +1 -0
- package/dist/lib/hookRunner.js +52 -0
- package/dist/lib/hookRunner.js.map +1 -0
- package/dist/lib/hosts.d.ts +20 -0
- package/dist/lib/hosts.d.ts.map +1 -0
- package/dist/lib/hosts.js +101 -0
- package/dist/lib/hosts.js.map +1 -0
- package/dist/lib/jev.d.ts +24 -0
- package/dist/lib/jev.d.ts.map +1 -0
- package/dist/lib/jev.js +169 -0
- package/dist/lib/jev.js.map +1 -0
- package/dist/lib/lintConfig.d.ts +3 -0
- package/dist/lib/lintConfig.d.ts.map +1 -0
- package/dist/lib/lintConfig.js +55 -0
- package/dist/lib/lintConfig.js.map +1 -0
- package/dist/lib/loadRules.d.ts +12 -0
- package/dist/lib/loadRules.d.ts.map +1 -0
- package/dist/lib/loadRules.js +31 -0
- package/dist/lib/loadRules.js.map +1 -0
- package/dist/lib/opencodePlugin.d.ts +10 -0
- package/dist/lib/opencodePlugin.d.ts.map +1 -0
- package/dist/lib/opencodePlugin.js +36 -0
- package/dist/lib/opencodePlugin.js.map +1 -0
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +35 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/packageRoot.d.ts +12 -0
- package/dist/lib/packageRoot.d.ts.map +1 -0
- package/dist/lib/packageRoot.js +50 -0
- package/dist/lib/packageRoot.js.map +1 -0
- package/dist/lib/paths.d.ts +24 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +75 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/reason.d.ts +11 -0
- package/dist/lib/reason.d.ts.map +1 -0
- package/dist/lib/reason.js +31 -0
- package/dist/lib/reason.js.map +1 -0
- package/dist/lib/regularFile.d.ts +26 -0
- package/dist/lib/regularFile.d.ts.map +1 -0
- package/dist/lib/regularFile.js +77 -0
- package/dist/lib/regularFile.js.map +1 -0
- package/dist/lib/replay.d.ts +74 -0
- package/dist/lib/replay.d.ts.map +1 -0
- package/dist/lib/replay.js +250 -0
- package/dist/lib/replay.js.map +1 -0
- package/dist/lib/replayCodex.d.ts +6 -0
- package/dist/lib/replayCodex.d.ts.map +1 -0
- package/dist/lib/replayCodex.js +132 -0
- package/dist/lib/replayCodex.js.map +1 -0
- package/dist/lib/replayOpencode.d.ts +15 -0
- package/dist/lib/replayOpencode.d.ts.map +1 -0
- package/dist/lib/replayOpencode.js +154 -0
- package/dist/lib/replayOpencode.js.map +1 -0
- package/dist/lib/rubricFile.d.ts +31 -0
- package/dist/lib/rubricFile.d.ts.map +1 -0
- package/dist/lib/rubricFile.js +75 -0
- package/dist/lib/rubricFile.js.map +1 -0
- package/dist/lib/scope.d.ts +4 -0
- package/dist/lib/scope.d.ts.map +1 -0
- package/dist/lib/scope.js +14 -0
- package/dist/lib/scope.js.map +1 -0
- package/dist/lib/session.d.ts +48 -0
- package/dist/lib/session.d.ts.map +1 -0
- package/dist/lib/session.js +182 -0
- package/dist/lib/session.js.map +1 -0
- package/dist/lib/settings.d.ts +26 -0
- package/dist/lib/settings.d.ts.map +1 -0
- package/dist/lib/settings.js +104 -0
- package/dist/lib/settings.js.map +1 -0
- package/dist/lib/sources.d.ts +28 -0
- package/dist/lib/sources.d.ts.map +1 -0
- package/dist/lib/sources.js +137 -0
- package/dist/lib/sources.js.map +1 -0
- package/dist/lib/stdin.d.ts +3 -0
- package/dist/lib/stdin.d.ts.map +1 -0
- package/dist/lib/stdin.js +24 -0
- package/dist/lib/stdin.js.map +1 -0
- package/dist/lib/transcript.d.ts +3 -0
- package/dist/lib/transcript.d.ts.map +1 -0
- package/dist/lib/transcript.js +57 -0
- package/dist/lib/transcript.js.map +1 -0
- package/dist/lib/ui.d.ts +8 -0
- package/dist/lib/ui.d.ts.map +1 -0
- package/dist/lib/ui.js +34 -0
- package/dist/lib/ui.js.map +1 -0
- package/dist/ui/components/Buckets.d.ts +6 -0
- package/dist/ui/components/Buckets.d.ts.map +1 -0
- package/dist/ui/components/Buckets.js +11 -0
- package/dist/ui/components/Buckets.js.map +1 -0
- package/dist/ui/components/Callout.d.ts +11 -0
- package/dist/ui/components/Callout.d.ts.map +1 -0
- package/dist/ui/components/Callout.js +10 -0
- package/dist/ui/components/Callout.js.map +1 -0
- package/dist/ui/components/Checklist.d.ts +10 -0
- package/dist/ui/components/Checklist.d.ts.map +1 -0
- package/dist/ui/components/Checklist.js +8 -0
- package/dist/ui/components/Checklist.js.map +1 -0
- package/dist/ui/components/Columns.d.ts +28 -0
- package/dist/ui/components/Columns.d.ts.map +1 -0
- package/dist/ui/components/Columns.js +20 -0
- package/dist/ui/components/Columns.js.map +1 -0
- package/dist/ui/components/Header.d.ts +9 -0
- package/dist/ui/components/Header.d.ts.map +1 -0
- package/dist/ui/components/Header.js +7 -0
- package/dist/ui/components/Header.js.map +1 -0
- package/dist/ui/components/Live.d.ts +16 -0
- package/dist/ui/components/Live.d.ts.map +1 -0
- package/dist/ui/components/Live.js +40 -0
- package/dist/ui/components/Live.js.map +1 -0
- package/dist/ui/components/RuleTable.d.ts +15 -0
- package/dist/ui/components/RuleTable.d.ts.map +1 -0
- package/dist/ui/components/RuleTable.js +81 -0
- package/dist/ui/components/RuleTable.js.map +1 -0
- package/dist/ui/components/Section.d.ts +10 -0
- package/dist/ui/components/Section.d.ts.map +1 -0
- package/dist/ui/components/Section.js +8 -0
- package/dist/ui/components/Section.js.map +1 -0
- package/dist/ui/components/Verdicts.d.ts +9 -0
- package/dist/ui/components/Verdicts.d.ts.map +1 -0
- package/dist/ui/components/Verdicts.js +12 -0
- package/dist/ui/components/Verdicts.js.map +1 -0
- package/dist/ui/render.d.ts +20 -0
- package/dist/ui/render.d.ts.map +1 -0
- package/dist/ui/render.js +32 -0
- package/dist/ui/render.js.map +1 -0
- package/dist/ui/theme.d.ts +37 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +63 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/ui/views/AuditView.d.ts +18 -0
- package/dist/ui/views/AuditView.d.ts.map +1 -0
- package/dist/ui/views/AuditView.js +48 -0
- package/dist/ui/views/AuditView.js.map +1 -0
- package/dist/ui/views/BenchView.d.ts +33 -0
- package/dist/ui/views/BenchView.d.ts.map +1 -0
- package/dist/ui/views/BenchView.js +19 -0
- package/dist/ui/views/BenchView.js.map +1 -0
- package/dist/ui/views/CalibrateView.d.ts +26 -0
- package/dist/ui/views/CalibrateView.d.ts.map +1 -0
- package/dist/ui/views/CalibrateView.js +42 -0
- package/dist/ui/views/CalibrateView.js.map +1 -0
- package/dist/ui/views/CheckView.d.ts +19 -0
- package/dist/ui/views/CheckView.d.ts.map +1 -0
- package/dist/ui/views/CheckView.js +15 -0
- package/dist/ui/views/CheckView.js.map +1 -0
- package/dist/ui/views/ErrorView.d.ts +4 -0
- package/dist/ui/views/ErrorView.d.ts.map +1 -0
- package/dist/ui/views/ErrorView.js +26 -0
- package/dist/ui/views/ErrorView.js.map +1 -0
- package/dist/ui/views/HelpView.d.ts +2 -0
- package/dist/ui/views/HelpView.d.ts.map +1 -0
- package/dist/ui/views/HelpView.js +25 -0
- package/dist/ui/views/HelpView.js.map +1 -0
- package/dist/ui/views/InitView.d.ts +27 -0
- package/dist/ui/views/InitView.d.ts.map +1 -0
- package/dist/ui/views/InitView.js +24 -0
- package/dist/ui/views/InitView.js.map +1 -0
- package/dist/ui/views/ReplayView.d.ts +16 -0
- package/dist/ui/views/ReplayView.d.ts.map +1 -0
- package/dist/ui/views/ReplayView.js +44 -0
- package/dist/ui/views/ReplayView.js.map +1 -0
- package/dist/ui/views/ReportView.d.ts +14 -0
- package/dist/ui/views/ReportView.d.ts.map +1 -0
- package/dist/ui/views/ReportView.js +86 -0
- package/dist/ui/views/ReportView.js.map +1 -0
- package/dist/ui/views/RubricView.d.ts +18 -0
- package/dist/ui/views/RubricView.d.ts.map +1 -0
- package/dist/ui/views/RubricView.js +18 -0
- package/dist/ui/views/RubricView.js.map +1 -0
- package/opencode/abide.mjs +246 -0
- package/package.json +50 -0
- package/skills/abide-compile/SKILL.md +173 -0
- package/src/abide-hook.ts +23 -0
- package/src/bin.ts +64 -0
- package/src/commands/audit.ts +97 -0
- package/src/commands/bench.ts +226 -0
- package/src/commands/calibrate.ts +165 -0
- package/src/commands/check.ts +114 -0
- package/src/commands/compile.ts +127 -0
- package/src/commands/init.ts +109 -0
- package/src/commands/login.ts +61 -0
- package/src/commands/replay.ts +148 -0
- package/src/commands/report.ts +83 -0
- package/src/commands/rubric.ts +45 -0
- package/src/commands/uninstall.ts +33 -0
- package/src/hooks/postToolUse.ts +171 -0
- package/src/hooks/sessionStart.ts +100 -0
- package/src/hooks/stop.ts +275 -0
- package/src/hooks/turnStart.ts +36 -0
- package/src/lib/applyPatch.ts +75 -0
- package/src/lib/audit.ts +265 -0
- package/src/lib/band.ts +49 -0
- package/src/lib/calibration.ts +35 -0
- package/src/lib/checkRunner.ts +124 -0
- package/src/lib/claude.ts +35 -0
- package/src/lib/compilePrompt.ts +67 -0
- package/src/lib/constants.ts +39 -0
- package/src/lib/coverage.ts +21 -0
- package/src/lib/credentials.ts +104 -0
- package/src/lib/diff.ts +209 -0
- package/src/lib/events.ts +34 -0
- package/src/lib/git.ts +206 -0
- package/src/lib/hookRunner.ts +55 -0
- package/src/lib/hosts.ts +112 -0
- package/src/lib/jev.ts +223 -0
- package/src/lib/lintConfig.ts +53 -0
- package/src/lib/loadRules.ts +39 -0
- package/src/lib/opencodePlugin.ts +38 -0
- package/src/lib/output.ts +44 -0
- package/src/lib/packageRoot.ts +48 -0
- package/src/lib/paths.ts +96 -0
- package/src/lib/reason.ts +46 -0
- package/src/lib/regularFile.ts +96 -0
- package/src/lib/replay.ts +314 -0
- package/src/lib/replayCodex.ts +130 -0
- package/src/lib/replayOpencode.ts +186 -0
- package/src/lib/rubricFile.ts +99 -0
- package/src/lib/scope.ts +18 -0
- package/src/lib/session.ts +223 -0
- package/src/lib/settings.ts +112 -0
- package/src/lib/sources.ts +154 -0
- package/src/lib/stdin.ts +23 -0
- package/src/lib/transcript.ts +49 -0
- package/src/lib/ui.ts +38 -0
- package/src/ui/components/Buckets.tsx +30 -0
- package/src/ui/components/Callout.tsx +26 -0
- package/src/ui/components/Checklist.tsx +29 -0
- package/src/ui/components/Columns.tsx +62 -0
- package/src/ui/components/Header.tsx +35 -0
- package/src/ui/components/Live.tsx +73 -0
- package/src/ui/components/RuleTable.tsx +94 -0
- package/src/ui/components/Section.tsx +24 -0
- package/src/ui/components/Verdicts.tsx +40 -0
- package/src/ui/render.tsx +51 -0
- package/src/ui/theme.ts +65 -0
- package/src/ui/views/AuditView.tsx +140 -0
- package/src/ui/views/BenchView.tsx +87 -0
- package/src/ui/views/CalibrateView.tsx +108 -0
- package/src/ui/views/CheckView.tsx +56 -0
- package/src/ui/views/ErrorView.tsx +32 -0
- package/src/ui/views/HelpView.tsx +51 -0
- package/src/ui/views/InitView.tsx +103 -0
- package/src/ui/views/ReplayView.tsx +100 -0
- package/src/ui/views/ReportView.tsx +225 -0
- package/src/ui/views/RubricView.tsx +82 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CalibrationVerdict, Thresholds } from "@coldtea/abide-schema";
|
|
2
|
+
import { median } from "./ui.js";
|
|
3
|
+
|
|
4
|
+
export const MIN_CALIBRATION_STATES = 5;
|
|
5
|
+
/** Below this a "no" is confident. A rule whose median sits above it and whose max never reaches CLEAR_YES is dead. */
|
|
6
|
+
const CONFIDENT_NO = 0.25;
|
|
7
|
+
const CLEAR_YES = 0.7;
|
|
8
|
+
const NOISY_FIRE_RATE = 0.6;
|
|
9
|
+
|
|
10
|
+
export type CalibrationSummary = {
|
|
11
|
+
hunks: number;
|
|
12
|
+
median: number;
|
|
13
|
+
min: number;
|
|
14
|
+
max: number;
|
|
15
|
+
fired: number;
|
|
16
|
+
verdict: CalibrationVerdict;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Decisive rules answer near 0 or near 1 on real hunks. Weak ones sit in the middle. Noisy ones fire on most. */
|
|
20
|
+
export const summarizeCalibration = (
|
|
21
|
+
probabilities: readonly number[],
|
|
22
|
+
thresholds: Thresholds,
|
|
23
|
+
): CalibrationSummary => {
|
|
24
|
+
const n = probabilities.length;
|
|
25
|
+
const med = median(probabilities) ?? 0;
|
|
26
|
+
const min = n === 0 ? 0 : Math.min(...probabilities);
|
|
27
|
+
const max = n === 0 ? 0 : Math.max(...probabilities);
|
|
28
|
+
const fired = probabilities.filter((p) => p >= thresholds.act).length;
|
|
29
|
+
let verdict: CalibrationVerdict;
|
|
30
|
+
if (n < MIN_CALIBRATION_STATES) verdict = "skipped";
|
|
31
|
+
else if (fired / n >= NOISY_FIRE_RATE) verdict = "noisy";
|
|
32
|
+
else if (max < CLEAR_YES && med >= CONFIDENT_NO) verdict = "weak";
|
|
33
|
+
else verdict = "decisive";
|
|
34
|
+
return { hunks: n, median: med, min, max, fired, verdict };
|
|
35
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { CheckPhase, Rule, Thresholds, Usage, Verdict } from "@coldtea/abide-schema";
|
|
2
|
+
import type { FileDiff } from "./git.js";
|
|
3
|
+
import { checkWithModel, isModelRule, type ModelRule } from "./jev.js";
|
|
4
|
+
import { ruleAppliesTo } from "./scope.js";
|
|
5
|
+
|
|
6
|
+
export type CheckRequest = {
|
|
7
|
+
phase: CheckPhase;
|
|
8
|
+
/** The change, one entry per touched file, paths repo-relative. */
|
|
9
|
+
fileDiffs: readonly FileDiff[];
|
|
10
|
+
task?: string;
|
|
11
|
+
rules: readonly Rule[];
|
|
12
|
+
thresholds: Thresholds;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
/** Transient gateway failures to retry. Hooks leave it at zero. */
|
|
15
|
+
retries?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CheckOutcome = {
|
|
19
|
+
verdicts: Verdict[];
|
|
20
|
+
modelRules: ModelRule[];
|
|
21
|
+
/** How many model calls the check took: one per distinct set of in-scope files. */
|
|
22
|
+
calls: number;
|
|
23
|
+
usage: Usage;
|
|
24
|
+
modelLatencyMs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Only model rules run. A lint-shaped rule is the linter's to enforce, and abide only reports it. */
|
|
28
|
+
const runsInPhase = (rule: Rule, phase: CheckPhase): boolean =>
|
|
29
|
+
rule.status === "active" && rule.check.type === "model" && rule.when === phase;
|
|
30
|
+
|
|
31
|
+
/** Rules that are active, belong to this phase, and apply to at least one touched file. */
|
|
32
|
+
export const selectRules = (
|
|
33
|
+
rules: readonly Rule[],
|
|
34
|
+
phase: CheckPhase,
|
|
35
|
+
files: readonly string[],
|
|
36
|
+
): Rule[] =>
|
|
37
|
+
rules.filter((rule) => runsInPhase(rule, phase) && files.some((f) => ruleAppliesTo(rule, f)));
|
|
38
|
+
|
|
39
|
+
const renderFiles = (fileDiffs: readonly FileDiff[]): string =>
|
|
40
|
+
fileDiffs.map((f) => `--- a/${f.file}\n+++ b/${f.file}\n${f.text}`).join("\n");
|
|
41
|
+
|
|
42
|
+
/** Rules grouped by the files each one applies to, so no rule ever sees a file outside its scope. */
|
|
43
|
+
export const groupByScope = (
|
|
44
|
+
rules: readonly ModelRule[],
|
|
45
|
+
fileDiffs: readonly FileDiff[],
|
|
46
|
+
): { rules: ModelRule[]; fileDiffs: FileDiff[] }[] => {
|
|
47
|
+
const groups = new Map<string, { rules: ModelRule[]; fileDiffs: FileDiff[] }>();
|
|
48
|
+
for (const rule of rules) {
|
|
49
|
+
const inScope = fileDiffs.filter((f) => ruleAppliesTo(rule, f.file));
|
|
50
|
+
if (inScope.length === 0) continue;
|
|
51
|
+
const key = inScope.map((f) => f.file).join("\n");
|
|
52
|
+
const group = groups.get(key) ?? { rules: [], fileDiffs: inScope };
|
|
53
|
+
group.rules.push(rule);
|
|
54
|
+
groups.set(key, group);
|
|
55
|
+
}
|
|
56
|
+
return [...groups.values()];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const addUsage = (a: Usage, b: Usage): Usage => ({
|
|
60
|
+
inputTokens: (a.inputTokens ?? 0) + (b.inputTokens ?? 0),
|
|
61
|
+
outputTokens: (a.outputTokens ?? 0) + (b.outputTokens ?? 0),
|
|
62
|
+
costUsd: (a.costUsd ?? 0) + (b.costUsd ?? 0),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** One verdict per rule: the loudest. */
|
|
66
|
+
export const loudestVerdicts = (verdicts: readonly Verdict[]): Verdict[] => {
|
|
67
|
+
const best = new Map<string, Verdict>();
|
|
68
|
+
for (const v of verdicts) {
|
|
69
|
+
const have = best.get(v.ruleId);
|
|
70
|
+
if (have === undefined || v.probability > have.probability) best.set(v.ruleId, v);
|
|
71
|
+
}
|
|
72
|
+
return [...best.values()];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const mergeOutcomes = (outcomes: readonly CheckOutcome[]): CheckOutcome => {
|
|
76
|
+
const modelRules: ModelRule[] = [];
|
|
77
|
+
for (const o of outcomes) {
|
|
78
|
+
for (const rule of o.modelRules) if (!modelRules.includes(rule)) modelRules.push(rule);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
verdicts: loudestVerdicts(outcomes.flatMap((o) => o.verdicts)),
|
|
82
|
+
modelRules,
|
|
83
|
+
calls: outcomes.reduce((sum, o) => sum + o.calls, 0),
|
|
84
|
+
usage: outcomes.reduce<Usage>((sum, o) => addUsage(sum, o.usage), {}),
|
|
85
|
+
modelLatencyMs: Math.max(0, ...outcomes.map((o) => o.modelLatencyMs)),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const runCheck = async (request: CheckRequest): Promise<CheckOutcome> => {
|
|
90
|
+
const files = request.fileDiffs.map((f) => f.file);
|
|
91
|
+
const modelRules = selectRules(request.rules, request.phase, files).filter(isModelRule);
|
|
92
|
+
|
|
93
|
+
const groups = groupByScope(modelRules, request.fileDiffs);
|
|
94
|
+
if (groups.length === 0) {
|
|
95
|
+
return { verdicts: [], modelRules, calls: 0, usage: {}, modelLatencyMs: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const started = performance.now();
|
|
99
|
+
const results = await Promise.all(
|
|
100
|
+
groups.map((group) =>
|
|
101
|
+
checkWithModel(
|
|
102
|
+
group.rules,
|
|
103
|
+
{
|
|
104
|
+
...(request.task === undefined ? {} : { task: request.task }),
|
|
105
|
+
...(request.phase === "edit" &&
|
|
106
|
+
group.fileDiffs.length === 1 &&
|
|
107
|
+
group.fileDiffs[0] !== undefined
|
|
108
|
+
? { file: group.fileDiffs[0].file, diff: group.fileDiffs[0].text }
|
|
109
|
+
: { files: group.fileDiffs.map((f) => f.file), diff: renderFiles(group.fileDiffs) }),
|
|
110
|
+
},
|
|
111
|
+
request.thresholds,
|
|
112
|
+
request.timeoutMs,
|
|
113
|
+
request.retries ?? 0,
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
verdicts: results.flatMap((r) => r.verdicts),
|
|
119
|
+
modelRules,
|
|
120
|
+
calls: groups.length,
|
|
121
|
+
usage: results.reduce<Usage>((sum, r) => addUsage(sum, r.usage), {}),
|
|
122
|
+
modelLatencyMs: Math.round(performance.now() - started),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalAbideDir } from "./paths.js";
|
|
4
|
+
import { discoverGlobalSources } from "./sources.js";
|
|
5
|
+
|
|
6
|
+
export const claudeAvailable = (): boolean =>
|
|
7
|
+
spawnSync("claude", ["--version"], { encoding: "utf8" }).status === 0;
|
|
8
|
+
|
|
9
|
+
/** Runs one headless Claude Code turn in the repo and streams its output. */
|
|
10
|
+
export const runClaude = (root: string, prompt: string): Promise<number> =>
|
|
11
|
+
new Promise((resolve) => {
|
|
12
|
+
const extraDirs = [
|
|
13
|
+
globalAbideDir(),
|
|
14
|
+
...discoverGlobalSources().map((s) => path.dirname(s.absolute)),
|
|
15
|
+
];
|
|
16
|
+
const child = spawn(
|
|
17
|
+
"claude",
|
|
18
|
+
[
|
|
19
|
+
"-p",
|
|
20
|
+
prompt,
|
|
21
|
+
"--permission-mode",
|
|
22
|
+
"acceptEdits",
|
|
23
|
+
"--allowedTools",
|
|
24
|
+
"Bash(node *)",
|
|
25
|
+
"Bash(abide *)",
|
|
26
|
+
"--add-dir",
|
|
27
|
+
...extraDirs,
|
|
28
|
+
"--output-format",
|
|
29
|
+
"text",
|
|
30
|
+
],
|
|
31
|
+
{ cwd: root, stdio: "inherit" },
|
|
32
|
+
);
|
|
33
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
34
|
+
child.on("error", () => resolve(1));
|
|
35
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Rubric } from "@coldtea/abide-schema";
|
|
2
|
+
import { binScriptPath } from "./packageRoot.js";
|
|
3
|
+
import { globalRubricPath, rubricPath } from "./paths.js";
|
|
4
|
+
import { describeStaleness, type SourceCandidate, type Staleness } from "./sources.js";
|
|
5
|
+
|
|
6
|
+
export type CompileTarget = {
|
|
7
|
+
which: "project" | "global";
|
|
8
|
+
root: string;
|
|
9
|
+
candidates: SourceCandidate[];
|
|
10
|
+
staleness: Staleness;
|
|
11
|
+
lintConfigs: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TuneStats = {
|
|
15
|
+
rule: string;
|
|
16
|
+
status: string;
|
|
17
|
+
median: number | undefined;
|
|
18
|
+
fired: number;
|
|
19
|
+
checks: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const sourceLine = (c: SourceCandidate): string =>
|
|
23
|
+
`${c.path} (rules apply to ${c.scope}${c.required ? "" : "; include only if it carries imperative rules"})`;
|
|
24
|
+
|
|
25
|
+
const targetBlock = (t: CompileTarget): string => {
|
|
26
|
+
const file = t.which === "project" ? rubricPath(t.root) : globalRubricPath();
|
|
27
|
+
const lines = [
|
|
28
|
+
`${t.which === "project" ? "Project" : "Global"} rubric: ${file} (${describeStaleness(t.staleness)})`,
|
|
29
|
+
` repo root: ${t.root}`,
|
|
30
|
+
...t.candidates.map((c) => ` source: ${sourceLine(c)}`),
|
|
31
|
+
];
|
|
32
|
+
if (t.lintConfigs.length > 0)
|
|
33
|
+
lines.push(` lint config to read for overlaps: ${t.lintConfigs.join(", ")}`);
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The text SessionStart hands the agent, and what `abide compile` sends headless. */
|
|
38
|
+
export const compilePrompt = (
|
|
39
|
+
skillPath: string,
|
|
40
|
+
targets: readonly CompileTarget[],
|
|
41
|
+
tune?: { rubric: Rubric; stats: TuneStats[] },
|
|
42
|
+
): string => {
|
|
43
|
+
const head =
|
|
44
|
+
tune === undefined
|
|
45
|
+
? "Abide is installed here and its rubric is missing or out of date. Compile it before you start on the user's request."
|
|
46
|
+
: "Abide is installed here. Some rules in its rubric never fire or fire on everything. Rewrite those rules before you start on the user's request.";
|
|
47
|
+
const parts = [
|
|
48
|
+
head,
|
|
49
|
+
`Read ${skillPath} and follow it to the end. Do not skip the validate and calibrate steps.`,
|
|
50
|
+
`Run the abide CLI as: node "${binScriptPath()}" <command>`,
|
|
51
|
+
...targets.map(targetBlock),
|
|
52
|
+
];
|
|
53
|
+
if (tune !== undefined) {
|
|
54
|
+
parts.push(
|
|
55
|
+
"Calibration and firing statistics per rule (median violation probability across recent real hunks, how often it fired, how many checks it saw):",
|
|
56
|
+
...tune.stats.map(
|
|
57
|
+
(s) =>
|
|
58
|
+
` ${s.rule}: status ${s.status}, median ${s.median === undefined ? "n/a" : s.median.toFixed(2)}, fired ${s.fired} of ${s.checks}`,
|
|
59
|
+
),
|
|
60
|
+
"A rule stuck between 0.3 and 0.7 is underspecified: split it, name the concrete shape it should catch, or add a criteria example. A rule that fires on most hunks is too broad: narrow its scope or its wording. Keep every other rule exactly as it is.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
parts.push(
|
|
64
|
+
"When you are done, tell the user in two or three plain sentences how many rules were compiled into which buckets and which rules, if any, came out weak or noisy. Then continue with their request.",
|
|
65
|
+
);
|
|
66
|
+
return parts.join("\n\n");
|
|
67
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Jev, called directly with a TypeSafe key, or through the Vercel AI Gateway with a gateway key. */
|
|
2
|
+
export const TYPESAFE_MODEL_ID = "jev-latest";
|
|
3
|
+
export const GATEWAY_MODEL_ID = "typesafe-ai/jev";
|
|
4
|
+
export const TYPESAFE_KEY_ENV = "TYPESAFE_AI_API_KEY";
|
|
5
|
+
export const GATEWAY_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
6
|
+
|
|
7
|
+
/** List price observed 2026-09-17: $0.042 per million input tokens, output free. */
|
|
8
|
+
export const JEV_USD_PER_INPUT_TOKEN = 0.042 / 1_000_000;
|
|
9
|
+
|
|
10
|
+
export const EDIT_CHECK_TIMEOUT_MS = 8_000;
|
|
11
|
+
export const TURN_CHECK_TIMEOUT_MS = 15_000;
|
|
12
|
+
export const STDIN_TIMEOUT_MS = 2_000;
|
|
13
|
+
|
|
14
|
+
/** Largest diff sent as state. Beyond this the diff is cut and marked. */
|
|
15
|
+
export const MAX_STATE_CHARS = 24_000;
|
|
16
|
+
export const MAX_TASK_CHARS = 600;
|
|
17
|
+
|
|
18
|
+
/** How many times one rule may block the same file within one turn before it only flags. */
|
|
19
|
+
export const MAX_BLOCKS_PER_RULE_PER_TURN = 2;
|
|
20
|
+
/** How many Stop checks one turn gets: the first, plus one re-check after a repair. */
|
|
21
|
+
export const MAX_STOP_CHECKS_PER_TURN = 2;
|
|
22
|
+
|
|
23
|
+
export const SESSION_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/** How long a single git call may run before it is killed. Well under every hook budget. */
|
|
26
|
+
export const GIT_TIMEOUT_MS = 5_000;
|
|
27
|
+
/** How long turn-start may spend snapshotting the working tree with git, all calls together. */
|
|
28
|
+
export const TURN_START_TIMEOUT_MS = 5_000;
|
|
29
|
+
/** How long Stop may spend on its own snapshot and the diff against the baseline. */
|
|
30
|
+
export const STOP_GIT_TIMEOUT_MS = 8_000;
|
|
31
|
+
|
|
32
|
+
/** How long computing one diff may take. Past this the check is skipped and logged rather than the hook held. */
|
|
33
|
+
export const DIFF_TIMEOUT_MS = 2_000;
|
|
34
|
+
/** Largest before-plus-after text a diff is attempted on at all. */
|
|
35
|
+
export const MAX_DIFF_INPUT_CHARS = 1_000_000;
|
|
36
|
+
/** Past this a file counts as unreadable. */
|
|
37
|
+
export const MAX_FILE_READ_BYTES = 16 * 1024 * 1024;
|
|
38
|
+
/** How long Stop's per-file fallback may spend on all of its diffs together. */
|
|
39
|
+
export const STOP_FALLBACK_DIFF_TIMEOUT_MS = 6_000;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CheckedEdit } from "./session.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Judged edits must chain from the turn's start content to the disk now.
|
|
5
|
+
* A change made by a shell, or while a judge was looking, breaks the chain.
|
|
6
|
+
*/
|
|
7
|
+
export const editsCoverFile = (
|
|
8
|
+
start: string | null,
|
|
9
|
+
edits: readonly CheckedEdit[],
|
|
10
|
+
now: string,
|
|
11
|
+
): boolean => {
|
|
12
|
+
const unused = [...edits];
|
|
13
|
+
let at = start;
|
|
14
|
+
while (at !== now) {
|
|
15
|
+
const index = unused.findIndex((e) => e.before === at);
|
|
16
|
+
const next = index === -1 ? undefined : unused.splice(index, 1)[0];
|
|
17
|
+
if (next === undefined) return false;
|
|
18
|
+
at = next.after;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GATEWAY_KEY_ENV, TYPESAFE_KEY_ENV } from "./constants.js";
|
|
4
|
+
import { globalAbideDir } from "./paths.js";
|
|
5
|
+
import { readRegularText } from "./regularFile.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which key abide has, and where it goes. A TypeSafe key talks to Jev
|
|
9
|
+
* directly; a gateway key goes through the user's Vercel AI Gateway.
|
|
10
|
+
*/
|
|
11
|
+
export type Credentials =
|
|
12
|
+
| { kind: "typesafe"; key: string; from: string }
|
|
13
|
+
| { kind: "gateway"; key: string; from: string }
|
|
14
|
+
| { kind: "none" };
|
|
15
|
+
|
|
16
|
+
export const KEY_NAMES: readonly string[] = [TYPESAFE_KEY_ENV, GATEWAY_KEY_ENV];
|
|
17
|
+
|
|
18
|
+
export const userEnvPath = (): string => path.join(globalAbideDir(), ".env");
|
|
19
|
+
|
|
20
|
+
/** Only the two abide keys are read from a file; nothing else in it is touched or loaded. */
|
|
21
|
+
export const parseEnvFile = (text: string): Map<string, string> => {
|
|
22
|
+
const found = new Map<string, string>();
|
|
23
|
+
for (const raw of text.split("\n")) {
|
|
24
|
+
const line = raw.trim();
|
|
25
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
26
|
+
const m = /^(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
27
|
+
if (m === null) continue;
|
|
28
|
+
const name = m[1];
|
|
29
|
+
let value = (m[2] ?? "").trim();
|
|
30
|
+
if (name === undefined || !KEY_NAMES.includes(name)) continue;
|
|
31
|
+
if (
|
|
32
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
33
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
34
|
+
) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
if (value !== "") found.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const readEnvFile = (file: string): Map<string, string> => {
|
|
43
|
+
const text = readRegularText(file);
|
|
44
|
+
return text === undefined ? new Map() : parseEnvFile(text);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const pick = (vars: Map<string, string>, from: string): Credentials => {
|
|
48
|
+
const typesafe = vars.get(TYPESAFE_KEY_ENV);
|
|
49
|
+
if (typesafe !== undefined) return { kind: "typesafe", key: typesafe, from };
|
|
50
|
+
const gateway = vars.get(GATEWAY_KEY_ENV);
|
|
51
|
+
if (gateway !== undefined) return { kind: "gateway", key: gateway, from };
|
|
52
|
+
return { kind: "none" };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const fromProcessEnv = (): Map<string, string> => {
|
|
56
|
+
const vars = new Map<string, string>();
|
|
57
|
+
for (const name of KEY_NAMES) {
|
|
58
|
+
const value = (process.env[name] ?? "").trim();
|
|
59
|
+
if (value !== "") vars.set(name, value);
|
|
60
|
+
}
|
|
61
|
+
return vars;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const findCredentials = (root: string): Credentials => {
|
|
65
|
+
const places: [string, () => Map<string, string>][] = [
|
|
66
|
+
["the environment", fromProcessEnv],
|
|
67
|
+
[".env.local", () => readEnvFile(path.join(root, ".env.local"))],
|
|
68
|
+
[".env", () => readEnvFile(path.join(root, ".env"))],
|
|
69
|
+
[userEnvPath(), () => readEnvFile(userEnvPath())],
|
|
70
|
+
];
|
|
71
|
+
for (const [from, read] of places) {
|
|
72
|
+
const picked = pick(read(), from);
|
|
73
|
+
if (picked.kind !== "none") return picked;
|
|
74
|
+
}
|
|
75
|
+
return { kind: "none" };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let current: Credentials | undefined;
|
|
79
|
+
|
|
80
|
+
export const resolveCredentials = (root: string): Credentials => {
|
|
81
|
+
current ??= findCredentials(root);
|
|
82
|
+
return current;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const credentials = (): Credentials => {
|
|
86
|
+
current ??= pick(fromProcessEnv(), "the environment");
|
|
87
|
+
return current;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const hasApiKey = (root: string): boolean => resolveCredentials(root).kind !== "none";
|
|
91
|
+
|
|
92
|
+
/** The one file abide may write a key to; see AGENTS.md. */
|
|
93
|
+
export const saveUserKey = (name: string, key: string): string => {
|
|
94
|
+
const file = userEnvPath();
|
|
95
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
96
|
+
const existing = readEnvFile(file);
|
|
97
|
+
existing.set(name, key);
|
|
98
|
+
const body = [...existing.entries()].map(([k, v]) => `${k}=${v}`).join("\n");
|
|
99
|
+
writeFileSync(file, `${body}\n`, { mode: 0o600 });
|
|
100
|
+
chmodSync(file, 0o600);
|
|
101
|
+
return file;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const NO_KEY_HINT = `No API key found. Run "abide login" with your TypeSafe key, or put ${TYPESAFE_KEY_ENV} in the environment or a .env file at the repo root.`;
|
package/src/lib/diff.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createTwoFilesPatch, structuredPatch } from "diff";
|
|
3
|
+
import type { PatchHunk, PostToolUseInput } from "@coldtea/abide-schema";
|
|
4
|
+
import { assertNever } from "@coldtea/abide-schema";
|
|
5
|
+
import { parseApplyPatch } from "./applyPatch.js";
|
|
6
|
+
import { DIFF_TIMEOUT_MS, MAX_DIFF_INPUT_CHARS, MAX_STATE_CHARS } from "./constants.js";
|
|
7
|
+
import { readRegularText } from "./regularFile.js";
|
|
8
|
+
|
|
9
|
+
export const renderHunks = (hunks: readonly PatchHunk[]): string =>
|
|
10
|
+
hunks
|
|
11
|
+
.map(
|
|
12
|
+
(h) =>
|
|
13
|
+
`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.lines.join("\n")}`,
|
|
14
|
+
)
|
|
15
|
+
.join("\n");
|
|
16
|
+
|
|
17
|
+
/** Undefined when the diff would take longer than a hook may spend on it, or the inputs are too big to try. */
|
|
18
|
+
const hunksBetween = (
|
|
19
|
+
before: string,
|
|
20
|
+
after: string,
|
|
21
|
+
timeoutMs = DIFF_TIMEOUT_MS,
|
|
22
|
+
): PatchHunk[] | undefined => {
|
|
23
|
+
if (timeoutMs <= 0 || before.length + after.length > MAX_DIFF_INPUT_CHARS) return undefined;
|
|
24
|
+
const patch = structuredPatch("a", "b", before, after, undefined, undefined, {
|
|
25
|
+
context: 3,
|
|
26
|
+
timeout: timeoutMs,
|
|
27
|
+
});
|
|
28
|
+
return patch?.hunks.map((h) => ({
|
|
29
|
+
oldStart: h.oldStart,
|
|
30
|
+
oldLines: h.oldLines,
|
|
31
|
+
newStart: h.newStart,
|
|
32
|
+
newLines: h.newLines,
|
|
33
|
+
lines: h.lines,
|
|
34
|
+
}));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const allAdded = (content: string): string => {
|
|
38
|
+
const lines = content.split("\n");
|
|
39
|
+
if (lines.at(-1) === "") lines.pop();
|
|
40
|
+
return `@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One file's change from a PostToolUse payload. `text` is undefined when the
|
|
45
|
+
* diff could not be computed within the time a hook may spend on it; the
|
|
46
|
+
* caller skips that file and says so rather than holding the agent.
|
|
47
|
+
* `original` is the file's content before the edit when the host said, for
|
|
48
|
+
* a Stop check without a git baseline. `after` is derived from the payload
|
|
49
|
+
* alone, so it is exactly what the judge saw; null when it cannot be.
|
|
50
|
+
*/
|
|
51
|
+
export type EditHunk = {
|
|
52
|
+
filePath: string;
|
|
53
|
+
text: string | undefined;
|
|
54
|
+
isNewFile: boolean;
|
|
55
|
+
original: string | null;
|
|
56
|
+
after: string | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type Replacement = { old: string; new: string; all?: boolean };
|
|
60
|
+
|
|
61
|
+
// A function replacement keeps "$" in the new text literal.
|
|
62
|
+
const applyEdit = (content: string, edit: Replacement): string =>
|
|
63
|
+
edit.all
|
|
64
|
+
? content.replaceAll(edit.old, () => edit.new)
|
|
65
|
+
: content.replace(edit.old, () => edit.new);
|
|
66
|
+
|
|
67
|
+
const applyEdits = (content: string | null, edits: readonly Replacement[]): string | null =>
|
|
68
|
+
content === null ? null : edits.reduce(applyEdit, content);
|
|
69
|
+
|
|
70
|
+
const synthesized = (
|
|
71
|
+
before: string,
|
|
72
|
+
after: string,
|
|
73
|
+
timeoutMs = DIFF_TIMEOUT_MS,
|
|
74
|
+
): string | undefined => {
|
|
75
|
+
const hunks = hunksBetween(before, after, timeoutMs);
|
|
76
|
+
return hunks === undefined ? undefined : renderHunks(hunks);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Time left on a budget that several diffs share. */
|
|
80
|
+
export const remainingMs = (deadline: number): number => Math.floor(deadline - performance.now());
|
|
81
|
+
|
|
82
|
+
/** Reverses the edit on the file as it is now, since the host did not send the original. */
|
|
83
|
+
const reversed = (filePath: string, edits: readonly Replacement[]): string | null =>
|
|
84
|
+
applyEdits(
|
|
85
|
+
readRegularText(filePath) ?? null,
|
|
86
|
+
[...edits].reverse().map((e) => ({ old: e.new, new: e.old, ...(e.all ? { all: true } : {}) })),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Every file one PostToolUse payload changed. Claude-style tools name one file; a Codex patch may name several. */
|
|
90
|
+
export const editsFromPostToolUse = (input: PostToolUseInput): EditHunk[] => {
|
|
91
|
+
switch (input.tool_name) {
|
|
92
|
+
case "Edit": {
|
|
93
|
+
const hostPatch = input.tool_response?.structuredPatch;
|
|
94
|
+
const fromHost = hostPatch && hostPatch.length > 0 ? renderHunks(hostPatch) : undefined;
|
|
95
|
+
const filePath = input.tool_input.file_path;
|
|
96
|
+
const text =
|
|
97
|
+
fromHost ?? synthesized(input.tool_input.old_string, input.tool_input.new_string);
|
|
98
|
+
const edits: Replacement[] = [
|
|
99
|
+
{
|
|
100
|
+
old: input.tool_input.old_string,
|
|
101
|
+
new: input.tool_input.new_string,
|
|
102
|
+
...(input.tool_input.replace_all ? { all: true } : {}),
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const original = input.tool_response?.originalFile ?? reversed(filePath, edits);
|
|
106
|
+
return [{ filePath, text, isNewFile: false, original, after: applyEdits(original, edits) }];
|
|
107
|
+
}
|
|
108
|
+
case "Write": {
|
|
109
|
+
const hostPatch = input.tool_response?.structuredPatch;
|
|
110
|
+
const fromHost = hostPatch && hostPatch.length > 0 ? renderHunks(hostPatch) : undefined;
|
|
111
|
+
const filePath = input.tool_input.file_path;
|
|
112
|
+
const original = input.tool_response?.originalFile;
|
|
113
|
+
const after = input.tool_input.content;
|
|
114
|
+
if (original === null || original === undefined) {
|
|
115
|
+
const text = after.length > MAX_DIFF_INPUT_CHARS ? undefined : allAdded(after);
|
|
116
|
+
return [{ filePath, text, isNewFile: true, original: null, after }];
|
|
117
|
+
}
|
|
118
|
+
const text = fromHost ?? synthesized(original, after);
|
|
119
|
+
return [{ filePath, text, isNewFile: false, original, after }];
|
|
120
|
+
}
|
|
121
|
+
case "MultiEdit": {
|
|
122
|
+
const hostPatch = input.tool_response?.structuredPatch;
|
|
123
|
+
const fromHost = hostPatch && hostPatch.length > 0 ? renderHunks(hostPatch) : undefined;
|
|
124
|
+
const filePath = input.tool_input.file_path;
|
|
125
|
+
const edits: Replacement[] = input.tool_input.edits.map((e) => ({
|
|
126
|
+
old: e.old_string,
|
|
127
|
+
new: e.new_string,
|
|
128
|
+
...(e.replace_all ? { all: true } : {}),
|
|
129
|
+
}));
|
|
130
|
+
const original = input.tool_response?.originalFile ?? reversed(filePath, edits);
|
|
131
|
+
const after = applyEdits(original, edits);
|
|
132
|
+
if (fromHost !== undefined) {
|
|
133
|
+
return [{ filePath, text: fromHost, isNewFile: false, original, after }];
|
|
134
|
+
}
|
|
135
|
+
// One budget for every part: each on its own could pass while together they hold the hook.
|
|
136
|
+
const deadline = performance.now() + DIFF_TIMEOUT_MS;
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
let text: string | undefined = undefined;
|
|
139
|
+
for (const edit of input.tool_input.edits) {
|
|
140
|
+
const part = synthesized(edit.old_string, edit.new_string, remainingMs(deadline));
|
|
141
|
+
if (part === undefined) {
|
|
142
|
+
parts.length = 0;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
parts.push(part);
|
|
146
|
+
}
|
|
147
|
+
if (parts.length > 0) text = parts.join("\n");
|
|
148
|
+
return [{ filePath, text, isNewFile: false, original, after }];
|
|
149
|
+
}
|
|
150
|
+
case "apply_patch":
|
|
151
|
+
return parseApplyPatch(input.tool_input.command).flatMap((file): EditHunk[] => {
|
|
152
|
+
const filePath = path.resolve(
|
|
153
|
+
input.cwd,
|
|
154
|
+
file.kind === "update" ? (file.movedTo ?? file.path) : file.path,
|
|
155
|
+
);
|
|
156
|
+
switch (file.kind) {
|
|
157
|
+
case "add":
|
|
158
|
+
return [{ filePath, text: file.text, isNewFile: true, original: null, after: null }];
|
|
159
|
+
case "update":
|
|
160
|
+
return [{ filePath, text: file.text, isNewFile: false, original: null, after: null }];
|
|
161
|
+
case "delete":
|
|
162
|
+
return [];
|
|
163
|
+
default:
|
|
164
|
+
return assertNever(file);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
default:
|
|
168
|
+
return assertNever(input);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Undefined when the diff could not be computed within the time a hook may spend on it. */
|
|
173
|
+
export const unifiedDiff = (
|
|
174
|
+
relativePath: string,
|
|
175
|
+
before: string,
|
|
176
|
+
after: string,
|
|
177
|
+
timeoutMs = DIFF_TIMEOUT_MS,
|
|
178
|
+
): string | undefined =>
|
|
179
|
+
timeoutMs <= 0 || before.length + after.length > MAX_DIFF_INPUT_CHARS
|
|
180
|
+
? undefined
|
|
181
|
+
: createTwoFilesPatch(
|
|
182
|
+
`a/${relativePath}`,
|
|
183
|
+
`b/${relativePath}`,
|
|
184
|
+
before,
|
|
185
|
+
after,
|
|
186
|
+
undefined,
|
|
187
|
+
undefined,
|
|
188
|
+
{
|
|
189
|
+
context: 3,
|
|
190
|
+
timeout: timeoutMs,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
export const addedLines = (hunkText: string): string[] =>
|
|
195
|
+
hunkText
|
|
196
|
+
.split("\n")
|
|
197
|
+
.filter((l) => l.startsWith("+") && !l.startsWith("+++"))
|
|
198
|
+
.map((l) => l.slice(1));
|
|
199
|
+
|
|
200
|
+
export const changedLineCount = (hunkText: string): number =>
|
|
201
|
+
hunkText.split("\n").filter((l) => /^[+-](?![+-]{2})/.test(l)).length;
|
|
202
|
+
|
|
203
|
+
export const boundState = (
|
|
204
|
+
text: string,
|
|
205
|
+
max = MAX_STATE_CHARS,
|
|
206
|
+
): { text: string; truncated: boolean } =>
|
|
207
|
+
text.length <= max
|
|
208
|
+
? { text, truncated: false }
|
|
209
|
+
: { text: `${text.slice(0, max)}\n[abide: diff cut at ${max} characters]`, truncated: true };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { eventSchema, type AbideEvent } from "@coldtea/abide-schema";
|
|
3
|
+
import { abideDir, eventsPath } from "./paths.js";
|
|
4
|
+
import { writeRegularFile } from "./regularFile.js";
|
|
5
|
+
|
|
6
|
+
/** Best effort. The log must never take the hook down with it, or hold it. */
|
|
7
|
+
export const appendEvent = (root: string, event: AbideEvent): void => {
|
|
8
|
+
try {
|
|
9
|
+
mkdirSync(abideDir(root), { recursive: true });
|
|
10
|
+
writeRegularFile(eventsPath(root), `${JSON.stringify(event)}\n`, { use: "append" });
|
|
11
|
+
} catch {
|
|
12
|
+
// nothing to do: logging is optional
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const readEvents = (root: string): AbideEvent[] => {
|
|
17
|
+
let raw: string;
|
|
18
|
+
try {
|
|
19
|
+
raw = readFileSync(eventsPath(root), "utf8");
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const events: AbideEvent[] = [];
|
|
24
|
+
for (const line of raw.split("\n")) {
|
|
25
|
+
if (line.trim() === "") continue;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = eventSchema.safeParse(JSON.parse(line));
|
|
28
|
+
if (parsed.success) events.push(parsed.data);
|
|
29
|
+
} catch {
|
|
30
|
+
// skip a torn line
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return events;
|
|
34
|
+
};
|