agentwrangler 0.1.0
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 +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code PreToolUse guard for catastrophic Bash commands.
|
|
3
|
+
*
|
|
4
|
+
* Warn-first by design: it hard-DENIES only an unambiguous catastrophe list and
|
|
5
|
+
* ASKS for the ambiguous shapes. It has no package dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Failure asymmetry is deliberate:
|
|
8
|
+
* - malformed stdin fails OPEN (allow) — a guard must never brick a session;
|
|
9
|
+
* - a missing / empty / unparseable deny-list data file fails CLOSED to the
|
|
10
|
+
* embedded baseline below, so deleting one file cannot silently downgrade
|
|
11
|
+
* every hard-deny to ask.
|
|
12
|
+
*
|
|
13
|
+
* Deny responses use the JSON `permissionDecision:"deny"` form, which holds even
|
|
14
|
+
* under bypass permissions mode — not an exit-code-only block. The hook always
|
|
15
|
+
* exits 0 and never echoes the raw command back (SEC-101).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The release-time catastrophe baseline, embedded so the guard still denies the
|
|
25
|
+
* worst commands when the on-disk deny-list is absent or corrupt. The shipped
|
|
26
|
+
* data file (`danger-guard-denylist.json`) is the extensible copy; users edit
|
|
27
|
+
* THAT, not this. Patterns are JS RegExp sources (no flags); each rule carries a
|
|
28
|
+
* static reason — never the command text.
|
|
29
|
+
*/
|
|
30
|
+
export const EMBEDDED_BASELINE = {
|
|
31
|
+
deny: [
|
|
32
|
+
{
|
|
33
|
+
pattern:
|
|
34
|
+
"(?:^|[\\s;&|(])rm\\s+(?:-\\S*\\s+)*-\\S*r\\S*\\s+(?:[^;&|]*\\s)?(?:/|~|\\$HOME)(?:\\s|/|\\*|$)",
|
|
35
|
+
reason: "recursive delete targeting your home directory or the filesystem root",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
pattern: "git\\s+push\\b(?=[^;&|]*(?:--force|-f)\\b)(?=[^;&|]*\\b(?:main|master)\\b)",
|
|
39
|
+
reason: "force-push to a protected branch (main/master)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
pattern: "\\b(?:curl|wget)\\b[^;&|]*\\|\\s*(?:sudo\\s+)?(?:sh|bash|zsh|dash|ksh)\\b",
|
|
43
|
+
reason: "piping a downloaded script straight into a shell",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
ask: [
|
|
47
|
+
{
|
|
48
|
+
pattern: "(?:^|[\\s;&|(])rm\\s+(?:-\\S*\\s+)*-\\S*r\\S*\\b",
|
|
49
|
+
reason: "recursive delete — confirm the target before running",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
pattern: "git\\s+push\\b(?=[^;&|]*(?:--force|-f)\\b)",
|
|
53
|
+
reason: "force-push — confirm the branch before running",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Compile a raw ruleset ({deny,ask} of {pattern,reason}) to RegExp rules. Throws on bad shape. */
|
|
59
|
+
export function compileRuleset(raw) {
|
|
60
|
+
if (raw === null || typeof raw !== "object") throw new Error("deny-list must be an object");
|
|
61
|
+
const compileList = (list) => {
|
|
62
|
+
if (!Array.isArray(list)) throw new Error("deny-list section must be an array");
|
|
63
|
+
return list.map((item) => {
|
|
64
|
+
if (item === null || typeof item !== "object") throw new Error("rule must be an object");
|
|
65
|
+
const { pattern, reason } = item;
|
|
66
|
+
if (typeof pattern !== "string" || typeof reason !== "string") {
|
|
67
|
+
throw new Error("rule needs string pattern and reason");
|
|
68
|
+
}
|
|
69
|
+
return { re: new RegExp(pattern), reason };
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
return { deny: compileList(raw.deny ?? []), ask: compileList(raw.ask ?? []) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load and compile the on-disk deny-list. Any failure (missing file, unreadable,
|
|
77
|
+
* unparseable JSON, bad shape, invalid regex) falls back to the embedded baseline
|
|
78
|
+
* — fail-closed on the deny tier.
|
|
79
|
+
*/
|
|
80
|
+
export function loadDenyList(filePath) {
|
|
81
|
+
try {
|
|
82
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
83
|
+
return compileRuleset(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
return compileRuleset(EMBEDDED_BASELINE);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Evaluate a command against a compiled ruleset. Deny wins over ask; otherwise allow. */
|
|
90
|
+
export function evaluateCommand(command, ruleset) {
|
|
91
|
+
if (typeof command !== "string" || command.length === 0) return { action: "allow" };
|
|
92
|
+
for (const rule of ruleset.deny)
|
|
93
|
+
if (rule.re.test(command)) return { action: "deny", reason: rule.reason };
|
|
94
|
+
for (const rule of ruleset.ask)
|
|
95
|
+
if (rule.re.test(command)) return { action: "ask", reason: rule.reason };
|
|
96
|
+
return { action: "allow" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Render a decision as the only PreToolUse output it may emit (allow → silence). */
|
|
100
|
+
export function decisionToStdout(decision) {
|
|
101
|
+
if (decision.action === "deny") {
|
|
102
|
+
return JSON.stringify({
|
|
103
|
+
hookSpecificOutput: {
|
|
104
|
+
hookEventName: "PreToolUse",
|
|
105
|
+
permissionDecision: "deny",
|
|
106
|
+
permissionDecisionReason: `AgentWrangler blocked a dangerous command: ${decision.reason}.`,
|
|
107
|
+
},
|
|
108
|
+
systemMessage: `AgentWrangler blocked a dangerous command: ${decision.reason}.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (decision.action === "ask") {
|
|
112
|
+
return JSON.stringify({
|
|
113
|
+
hookSpecificOutput: {
|
|
114
|
+
hookEventName: "PreToolUse",
|
|
115
|
+
permissionDecision: "ask",
|
|
116
|
+
permissionDecisionReason: `AgentWrangler flagged a risky command: ${decision.reason}.`,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when running under CI / headless automation, where the guard must no-op. */
|
|
124
|
+
function inCI() {
|
|
125
|
+
const ci = process.env.CI;
|
|
126
|
+
return typeof ci === "string" && ci !== "" && ci !== "false" && ci !== "0";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function denyListPath() {
|
|
130
|
+
return (
|
|
131
|
+
process.env.AW_DANGER_DENYLIST ??
|
|
132
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "danger-guard-denylist.json")
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readStdin() {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let raw = "";
|
|
139
|
+
process.stdin.setEncoding("utf8");
|
|
140
|
+
process.stdin.on("data", (chunk) => {
|
|
141
|
+
raw += chunk;
|
|
142
|
+
});
|
|
143
|
+
process.stdin.on("end", () => resolve(raw));
|
|
144
|
+
process.stdin.on("error", reject);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function main() {
|
|
149
|
+
// No-op in CI: block nothing in headless runs (a top researched backfire).
|
|
150
|
+
if (inCI()) return;
|
|
151
|
+
try {
|
|
152
|
+
const input = JSON.parse(await readStdin());
|
|
153
|
+
// Defensive: the matcher scopes us to Bash, but ignore anything else.
|
|
154
|
+
if (typeof input?.tool_name === "string" && input.tool_name !== "Bash") return;
|
|
155
|
+
const command = input?.tool_input?.command;
|
|
156
|
+
if (typeof command !== "string") return;
|
|
157
|
+
const decision = evaluateCommand(command, loadDenyList(denyListPath()));
|
|
158
|
+
const output = decisionToStdout(decision);
|
|
159
|
+
if (output) process.stdout.write(output);
|
|
160
|
+
} catch {
|
|
161
|
+
// Fail open: a malformed payload must never block Claude Code.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const invoked =
|
|
166
|
+
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
167
|
+
if (invoked) void main();
|
|
Binary file
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-open Claude Code PreToolUse hook for AgentWrangler rate-limit burn.
|
|
3
|
+
* It intentionally has no package dependencies and never exits non-zero.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
const WARN_PCT = 0.15;
|
|
11
|
+
const CHECKPOINT_PCT = 0.5;
|
|
12
|
+
const DEBOUNCE_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
/** Return the warning stage for a burn-status payload without side effects. */
|
|
15
|
+
export function stageFromStatus(status) {
|
|
16
|
+
if (!status || status.available === false) return null;
|
|
17
|
+
|
|
18
|
+
const fiveHour = status.five_hour?.utilization;
|
|
19
|
+
const sevenDay = status.seven_day?.utilization;
|
|
20
|
+
const atCheckpoint = fiveHour >= CHECKPOINT_PCT || sevenDay >= CHECKPOINT_PCT;
|
|
21
|
+
if (atCheckpoint) return "checkpoint";
|
|
22
|
+
if (fiveHour >= WARN_PCT || sevenDay >= WARN_PCT) return "soft";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Convert an endpoint stage to the only hook output it may emit. */
|
|
27
|
+
export function stageToStdout(stage) {
|
|
28
|
+
if (stage === "checkpoint") {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
hookSpecificOutput: {
|
|
31
|
+
hookEventName: "PreToolUse",
|
|
32
|
+
permissionDecision: "allow",
|
|
33
|
+
},
|
|
34
|
+
additionalContext:
|
|
35
|
+
"AgentWrangler: 5h or 7d rate-limit cap >=50% consumed. Checkpoint your work now.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (stage === "soft") {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
hookSpecificOutput: {
|
|
41
|
+
hookEventName: "PreToolUse",
|
|
42
|
+
permissionDecision: "allow",
|
|
43
|
+
},
|
|
44
|
+
additionalContext:
|
|
45
|
+
"AgentWrangler: 5h or 7d rate-limit cap >=15% consumed. Consider checkpointing.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function markerPath(sessionId) {
|
|
52
|
+
const markerRoot = path.join(os.homedir(), ".agentwrangler", "hook-markers");
|
|
53
|
+
return path.join(markerRoot, `limit-burn-${encodeURIComponent(sessionId)}.marker`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shouldEmit(sessionId) {
|
|
57
|
+
const file = markerPath(sessionId);
|
|
58
|
+
const now = Math.floor(Date.now() / 1_000);
|
|
59
|
+
try {
|
|
60
|
+
const previous = Number(fs.readFileSync(file, "utf8"));
|
|
61
|
+
if (Number.isFinite(previous) && now - previous < DEBOUNCE_SECONDS) return false;
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
fs.writeFileSync(file, String(now), { encoding: "utf8", mode: 0o600 });
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fs.writeFileSync(file, String(now), { encoding: "utf8", mode: 0o600 });
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
// Marker write failed — degrade to no-debounce so warnings are never silently dropped.
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Unexpected error — degrade to no-debounce so warnings are never silently dropped.
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readStdin() {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let raw = "";
|
|
84
|
+
process.stdin.setEncoding("utf8");
|
|
85
|
+
process.stdin.on("data", (chunk) => {
|
|
86
|
+
raw += chunk;
|
|
87
|
+
});
|
|
88
|
+
process.stdin.on("end", () => resolve(raw));
|
|
89
|
+
process.stdin.on("error", reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchBurnStatus() {
|
|
94
|
+
const port = process.env.AW_PORT ?? 47821;
|
|
95
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/burn-status`, {
|
|
96
|
+
signal: AbortSignal.timeout(500),
|
|
97
|
+
});
|
|
98
|
+
if (response.status !== 200) return null;
|
|
99
|
+
return response.json();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function main() {
|
|
103
|
+
try {
|
|
104
|
+
const input = JSON.parse(await readStdin());
|
|
105
|
+
const sessionId = input?.session_id;
|
|
106
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
107
|
+
|
|
108
|
+
const status = await fetchBurnStatus();
|
|
109
|
+
if (!status || status.available === false) return;
|
|
110
|
+
|
|
111
|
+
const stage = stageFromStatus(status);
|
|
112
|
+
if (!stage || !shouldEmit(sessionId)) return;
|
|
113
|
+
|
|
114
|
+
const output = stageToStdout(stage);
|
|
115
|
+
if (output) process.stdout.write(output);
|
|
116
|
+
} catch {
|
|
117
|
+
// Hooks must never block Claude Code if AgentWrangler is unavailable.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const invoked =
|
|
122
|
+
process.argv[1] !== undefined &&
|
|
123
|
+
path.resolve(process.argv[1]) ===
|
|
124
|
+
path.normalize(
|
|
125
|
+
decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"),
|
|
126
|
+
);
|
|
127
|
+
if (invoked) void main();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-open Claude Code PreToolUse hook for AgentWrangler's D7 loop guard.
|
|
3
|
+
* It deliberately exposes neither tool input nor tool output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as http from "node:http";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 500;
|
|
12
|
+
|
|
13
|
+
/** Convert a loop-guard stage to the only hook output it may emit. */
|
|
14
|
+
export function stageToStdout(stage, reason) {
|
|
15
|
+
if (stage === "block") {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
hookSpecificOutput: {
|
|
18
|
+
hookEventName: "PreToolUse",
|
|
19
|
+
permissionDecision: "deny",
|
|
20
|
+
permissionDecisionReason:
|
|
21
|
+
"Repeated identical failures detected. Restate the latest failure and change the approach before retrying.",
|
|
22
|
+
},
|
|
23
|
+
systemMessage:
|
|
24
|
+
"AgentWrangler: repeated identical failures detected. Restate the latest failure and change the approach before retrying.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (stage === "warn") {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
hookSpecificOutput: {
|
|
30
|
+
hookEventName: "PreToolUse",
|
|
31
|
+
permissionDecision: "allow",
|
|
32
|
+
permissionDecisionReason:
|
|
33
|
+
"Repeated identical failures are approaching the guard threshold.",
|
|
34
|
+
},
|
|
35
|
+
additionalContext:
|
|
36
|
+
"Repeated identical failures are approaching the guard threshold. Restate the latest failure and change the approach before retrying.",
|
|
37
|
+
systemMessage:
|
|
38
|
+
"AgentWrangler: repeated identical failures are approaching the guard threshold. Change the approach before retrying.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
void reason;
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readStdin() {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let raw = "";
|
|
48
|
+
process.stdin.setEncoding("utf8");
|
|
49
|
+
process.stdin.on("data", (chunk) => {
|
|
50
|
+
raw += chunk;
|
|
51
|
+
});
|
|
52
|
+
process.stdin.on("end", () => resolve(raw));
|
|
53
|
+
process.stdin.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fetchGuard(sessionId) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const port = process.env.AW_PORT ?? "47821";
|
|
60
|
+
const request = http.get(
|
|
61
|
+
`http://127.0.0.1:${port}/api/loop-guard?session_id=${encodeURIComponent(sessionId)}`,
|
|
62
|
+
(response) => {
|
|
63
|
+
if (response.statusCode !== 200) {
|
|
64
|
+
response.resume();
|
|
65
|
+
reject(new Error("non-200 response"));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let raw = "";
|
|
69
|
+
response.setEncoding("utf8");
|
|
70
|
+
response.on("data", (chunk) => {
|
|
71
|
+
raw += chunk;
|
|
72
|
+
});
|
|
73
|
+
response.on("end", () => {
|
|
74
|
+
try {
|
|
75
|
+
resolve(JSON.parse(raw)?.data);
|
|
76
|
+
} catch {
|
|
77
|
+
reject(new Error("invalid response"));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error("timeout")));
|
|
83
|
+
request.on("error", reject);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
try {
|
|
89
|
+
const input = JSON.parse(await readStdin());
|
|
90
|
+
const sessionId = input?.session_id;
|
|
91
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
92
|
+
const guard = await fetchGuard(sessionId);
|
|
93
|
+
const output = stageToStdout(guard?.stage, guard?.reason);
|
|
94
|
+
if (output) await new Promise((resolve) => process.stdout.write(output, resolve));
|
|
95
|
+
} catch {
|
|
96
|
+
// Hooks must never block Claude Code when AgentWrangler is unavailable.
|
|
97
|
+
} finally {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const invoked =
|
|
103
|
+
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
104
|
+
if (invoked) void main();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code PreCompact hook: snapshot the transcript before compaction discards
|
|
3
|
+
* detail. Copies the transcript to ~/.agentwrangler/checkpoints/<session>-<ts>.jsonl
|
|
4
|
+
* (mode 0o600), enforces a retention cap by count and total bytes, and ALWAYS exits 0
|
|
5
|
+
* so it can never block compaction. No package dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Known gap (GH #13572): PreCompact may not fire on a manual `/compact`. No fallback in
|
|
8
|
+
* v1 — this covers auto-compaction, which is the lossy path that matters most.
|
|
9
|
+
*
|
|
10
|
+
* SEC-101: snapshots live under the user-profile checkpoints dir (local runtime, never
|
|
11
|
+
* committed). The hook writes no transcript content to stdout/stderr.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const MAX_COUNT = 20;
|
|
21
|
+
const MAX_BYTES = 500 * 1024 * 1024; // 500 MiB across all snapshots
|
|
22
|
+
|
|
23
|
+
// Monotonic counter so two firings in the same millisecond get distinct filenames
|
|
24
|
+
// that still sort in write order (the timestamp dominates; this breaks same-ms ties).
|
|
25
|
+
let snapshotCounter = 0;
|
|
26
|
+
|
|
27
|
+
/** The directory snapshots are written to (overridable for tests). */
|
|
28
|
+
export function checkpointDir() {
|
|
29
|
+
return process.env.AW_CHECKPOINT_DIR ?? path.join(os.homedir(), ".agentwrangler", "checkpoints");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Filename-safe snapshot name for a session at a given time. */
|
|
33
|
+
export function snapshotName(sessionId, now) {
|
|
34
|
+
snapshotCounter = (snapshotCounter + 1) % 1_000_000;
|
|
35
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
36
|
+
return `${encodeURIComponent(sessionId)}-${stamp}-${String(snapshotCounter).padStart(6, "0")}.jsonl`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Copy the transcript into `dir` with mode 0o600. Returns the snapshot path, or null if
|
|
41
|
+
* the transcript is missing/unreadable (a missing transcript is not an error to report).
|
|
42
|
+
*/
|
|
43
|
+
export function writeCheckpoint(transcriptPath, sessionId, dir, now = new Date()) {
|
|
44
|
+
if (typeof transcriptPath !== "string" || !fs.existsSync(transcriptPath)) return null;
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
const dest = path.join(dir, snapshotName(sessionId, now));
|
|
47
|
+
fs.copyFileSync(transcriptPath, dest);
|
|
48
|
+
try {
|
|
49
|
+
fs.chmodSync(dest, 0o600);
|
|
50
|
+
} catch {
|
|
51
|
+
// chmod is a no-op boundary on Windows; the user-profile ACL is the equivalent guard.
|
|
52
|
+
}
|
|
53
|
+
return dest;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prune oldest snapshots beyond the count cap, then beyond the total-byte cap. */
|
|
57
|
+
export function enforceRetention(dir, maxCount = MAX_COUNT, maxBytes = MAX_BYTES) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = fs
|
|
61
|
+
.readdirSync(dir)
|
|
62
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
63
|
+
.sort() // timestamp-derived names sort chronologically
|
|
64
|
+
.map((name) => {
|
|
65
|
+
const full = path.join(dir, name);
|
|
66
|
+
return { full, size: fs.statSync(full).size };
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
return; // dir missing or unreadable — nothing to prune
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const removeOldest = () => {
|
|
73
|
+
const victim = entries.shift();
|
|
74
|
+
if (!victim) return;
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(victim.full);
|
|
77
|
+
} catch {
|
|
78
|
+
// A locked or already-removed snapshot is not worth failing for.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
while (entries.length > maxCount) removeOldest();
|
|
83
|
+
let total = entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
84
|
+
while (entries.length > 0 && total > maxBytes) {
|
|
85
|
+
total -= entries[0].size;
|
|
86
|
+
removeOldest();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when running under CI / headless automation, where the hook must no-op. */
|
|
91
|
+
function inCI() {
|
|
92
|
+
const ci = process.env.CI;
|
|
93
|
+
return typeof ci === "string" && ci !== "" && ci !== "false" && ci !== "0";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readStdin() {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
let raw = "";
|
|
99
|
+
process.stdin.setEncoding("utf8");
|
|
100
|
+
process.stdin.on("data", (chunk) => {
|
|
101
|
+
raw += chunk;
|
|
102
|
+
});
|
|
103
|
+
process.stdin.on("end", () => resolve(raw));
|
|
104
|
+
process.stdin.on("error", reject);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function main() {
|
|
109
|
+
if (inCI()) return;
|
|
110
|
+
try {
|
|
111
|
+
const input = JSON.parse(await readStdin());
|
|
112
|
+
const sessionId = typeof input?.session_id === "string" ? input.session_id : "unknown";
|
|
113
|
+
const transcriptPath = input?.transcript_path;
|
|
114
|
+
const dir = checkpointDir();
|
|
115
|
+
if (writeCheckpoint(transcriptPath, sessionId, dir)) enforceRetention(dir);
|
|
116
|
+
} catch {
|
|
117
|
+
// Never block compaction, never emit noise.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const invoked =
|
|
122
|
+
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
123
|
+
if (invoked) void main();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const WINDOW_DAYS = 14;
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
5
|
+
const PORCELAIN_HEADER = /^([0-9a-f]{40})\s/;
|
|
6
|
+
export function collectSessionChurn(db, opts = {}) {
|
|
7
|
+
const now = opts.now ?? new Date();
|
|
8
|
+
const measuredAt = now.toISOString();
|
|
9
|
+
const sessions = db
|
|
10
|
+
.prepare(`SELECT sessions.session_id, workspaces.repo_path
|
|
11
|
+
FROM sessions
|
|
12
|
+
LEFT JOIN workspaces ON workspaces.workspace_id = sessions.workspace_id`)
|
|
13
|
+
.all();
|
|
14
|
+
const authoredCommits = db.prepare(`SELECT DISTINCT commit_sha
|
|
15
|
+
FROM tool_events
|
|
16
|
+
WHERE session_id = ? AND commit_sha IS NOT NULL`);
|
|
17
|
+
const upsert = db.prepare(`INSERT INTO session_churn
|
|
18
|
+
(session_id, status, window_days, authored_lines, churned_lines, churn_ratio,
|
|
19
|
+
commit_count, commit_shas, measured_at)
|
|
20
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
21
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
22
|
+
status=excluded.status,
|
|
23
|
+
window_days=excluded.window_days,
|
|
24
|
+
authored_lines=excluded.authored_lines,
|
|
25
|
+
churned_lines=excluded.churned_lines,
|
|
26
|
+
churn_ratio=excluded.churn_ratio,
|
|
27
|
+
commit_count=excluded.commit_count,
|
|
28
|
+
commit_shas=excluded.commit_shas,
|
|
29
|
+
measured_at=excluded.measured_at`);
|
|
30
|
+
for (const session of sessions) {
|
|
31
|
+
if (session.repo_path === null || session.repo_path === "") {
|
|
32
|
+
upsert.run(session.session_id, "NO_REPO", WINDOW_DAYS, 0, 0, null, 0, "[]", measuredAt);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const git = createGitRunner(session.repo_path, opts.git);
|
|
36
|
+
let authoredLines = 0;
|
|
37
|
+
let churnedLines = 0;
|
|
38
|
+
let immature = false;
|
|
39
|
+
const maturedShas = [];
|
|
40
|
+
const commits = authoredCommits.all(session.session_id);
|
|
41
|
+
for (const { commit_sha: sha } of commits) {
|
|
42
|
+
if (!SHA_PATTERN.test(sha) || git(["cat-file", "-e", `${sha}^{commit}`]) === null)
|
|
43
|
+
continue;
|
|
44
|
+
const merge = git(["rev-list", "--no-walk", "--merges", sha]);
|
|
45
|
+
if (merge === null || merge.stdout.trim() !== "")
|
|
46
|
+
continue;
|
|
47
|
+
const timestamp = git(["show", "-s", "--format=%cI", sha]);
|
|
48
|
+
if (timestamp === null)
|
|
49
|
+
continue;
|
|
50
|
+
const committedAt = new Date(timestamp.stdout.trim());
|
|
51
|
+
if (Number.isNaN(committedAt.getTime()))
|
|
52
|
+
continue;
|
|
53
|
+
const horizonAt = new Date(committedAt.getTime() + WINDOW_DAYS * DAY_MS);
|
|
54
|
+
if (horizonAt > now) {
|
|
55
|
+
immature = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const numstat = git(["show", "--numstat", "--no-renames", "--format=", sha]);
|
|
59
|
+
if (numstat === null)
|
|
60
|
+
continue;
|
|
61
|
+
let authoredForCommit = 0;
|
|
62
|
+
const touchedPaths = new Set();
|
|
63
|
+
for (const line of numstat.stdout.split(/\r?\n/)) {
|
|
64
|
+
const [added, , filePath] = line.split("\t", 3);
|
|
65
|
+
if (added === undefined || filePath === undefined || added === "-")
|
|
66
|
+
continue;
|
|
67
|
+
const count = Number(added);
|
|
68
|
+
if (Number.isInteger(count) && count >= 0) {
|
|
69
|
+
authoredForCommit += count;
|
|
70
|
+
touchedPaths.add(filePath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const horizon = git([
|
|
74
|
+
"rev-list",
|
|
75
|
+
"-1",
|
|
76
|
+
"--first-parent",
|
|
77
|
+
`--before=${horizonAt.toISOString()}`,
|
|
78
|
+
"HEAD",
|
|
79
|
+
]);
|
|
80
|
+
if (horizon === null)
|
|
81
|
+
continue;
|
|
82
|
+
const horizonSha = horizon.stdout.trim();
|
|
83
|
+
let survivedForCommit = authoredForCommit;
|
|
84
|
+
if (horizonSha !== sha) {
|
|
85
|
+
survivedForCommit = 0;
|
|
86
|
+
for (const filePath of touchedPaths) {
|
|
87
|
+
const blame = git(["blame", "-w", "--line-porcelain", horizonSha, "--", filePath]);
|
|
88
|
+
if (blame === null)
|
|
89
|
+
continue;
|
|
90
|
+
for (const line of blame.stdout.split(/\r?\n/)) {
|
|
91
|
+
if (PORCELAIN_HEADER.exec(line)?.[1]?.toLowerCase() === sha.toLowerCase()) {
|
|
92
|
+
survivedForCommit += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
authoredLines += authoredForCommit;
|
|
98
|
+
churnedLines += Math.max(0, authoredForCommit - survivedForCommit);
|
|
99
|
+
maturedShas.push(sha);
|
|
100
|
+
}
|
|
101
|
+
upsert.run(session.session_id, immature ? "PARTIAL" : "MEASURED", WINDOW_DAYS, authoredLines, churnedLines, authoredLines === 0 ? null : churnedLines / authoredLines, maturedShas.length, JSON.stringify(maturedShas), measuredAt);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function createGitRunner(repoPath, injected) {
|
|
105
|
+
if (injected !== undefined) {
|
|
106
|
+
return (args) => injected(["-C", repoPath, ...args]);
|
|
107
|
+
}
|
|
108
|
+
return (args) => {
|
|
109
|
+
try {
|
|
110
|
+
const result = spawnSync("git", ["-C", repoPath, ...args], {
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
});
|
|
114
|
+
if (result.error !== undefined || result.status !== 0)
|
|
115
|
+
return null;
|
|
116
|
+
return { status: result.status, stdout: result.stdout };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|