agent-postmortem 0.3.4__py3-none-any.whl
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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook for the Workflow tool. Backs rule R-MODEL-ALLOCATION (see
|
|
3
|
+
// rules.py): every agent() call in a workflow script must set a concrete
|
|
4
|
+
// top-level opts.model or opts.agentType, or carry an intentional
|
|
5
|
+
// "// model-ok: <reason>" comment on the same or an adjacent line. A
|
|
6
|
+
// violation denies the tool call with a message naming the lines to fix.
|
|
7
|
+
//
|
|
8
|
+
// Fails open only when the event is confirmed not to be a Workflow call, or
|
|
9
|
+
// the call names a pre-registered workflow with no script text to check
|
|
10
|
+
// (there is nothing here to inspect either way). Any other failure to
|
|
11
|
+
// inspect the call (the hook payload is not valid JSON, scriptPath cannot
|
|
12
|
+
// be read, or an internal error while scanning the script) denies with a
|
|
13
|
+
// diagnostic instead of silently letting an unchecked script through.
|
|
14
|
+
//
|
|
15
|
+
// Install: copy this file anywhere readable (for example
|
|
16
|
+
// ~/.claude/hooks/model_allocation_guard.mjs), then add to settings.json:
|
|
17
|
+
// {"hooks": {"PreToolUse": [{"matcher": "Workflow", "hooks": [
|
|
18
|
+
// {"type": "command", "command": "node /path/to/model_allocation_guard.mjs"}
|
|
19
|
+
// ]}]}}
|
|
20
|
+
// `postmortem apply R-MODEL-ALLOCATION --target FILE` prints this exact
|
|
21
|
+
// snippet with the path filled in; it does not edit settings.json for you.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
async function readStdin() {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
29
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Blank out strings and comments with spaces, preserving length and
|
|
33
|
+
// newlines, so paren matching and line numbers stay accurate.
|
|
34
|
+
function stripStringsAndComments(src) {
|
|
35
|
+
let out = "";
|
|
36
|
+
let i = 0;
|
|
37
|
+
let mode = "code"; // code | line | block | squote | dquote | template
|
|
38
|
+
const n = src.length;
|
|
39
|
+
while (i < n) {
|
|
40
|
+
const ch = src[i];
|
|
41
|
+
const next = src[i + 1];
|
|
42
|
+
if (mode === "code") {
|
|
43
|
+
if (ch === "/" && next === "/") { mode = "line"; out += " "; i += 2; continue; }
|
|
44
|
+
if (ch === "/" && next === "*") { mode = "block"; out += " "; i += 2; continue; }
|
|
45
|
+
if (ch === "'") { mode = "squote"; out += " "; i++; continue; }
|
|
46
|
+
if (ch === '"') { mode = "dquote"; out += " "; i++; continue; }
|
|
47
|
+
if (ch === "`") { mode = "template"; out += " "; i++; continue; }
|
|
48
|
+
out += ch; i++; continue;
|
|
49
|
+
}
|
|
50
|
+
if (mode === "line") {
|
|
51
|
+
if (ch === "\n") { mode = "code"; out += "\n"; } else { out += " "; }
|
|
52
|
+
i++; continue;
|
|
53
|
+
}
|
|
54
|
+
if (mode === "block") {
|
|
55
|
+
if (ch === "*" && next === "/") { mode = "code"; out += " "; i += 2; continue; }
|
|
56
|
+
out += ch === "\n" ? "\n" : " "; i++; continue;
|
|
57
|
+
}
|
|
58
|
+
// string modes
|
|
59
|
+
if (ch === "\\") { out += " "; i += 2; continue; }
|
|
60
|
+
if (
|
|
61
|
+
(mode === "squote" && ch === "'") ||
|
|
62
|
+
(mode === "dquote" && ch === '"') ||
|
|
63
|
+
(mode === "template" && ch === "`")
|
|
64
|
+
) {
|
|
65
|
+
mode = "code"; out += " "; i++; continue;
|
|
66
|
+
}
|
|
67
|
+
out += ch === "\n" ? "\n" : " "; i++; continue;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lineOf(text, idx) {
|
|
73
|
+
let line = 1;
|
|
74
|
+
for (let i = 0; i < idx && i < text.length; i++) if (text[i] === "\n") line++;
|
|
75
|
+
return line;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Find real agent(...) call sites: identifier boundary before "agent", not a
|
|
79
|
+
// property access (skips foo.agent(...)), must be a call (skips bare
|
|
80
|
+
// references like pipeline.map(agent)). Carries both the cleaned argument
|
|
81
|
+
// text (safe for structural parsing: no stray brackets or commas hiding
|
|
82
|
+
// inside a string) and the same slice of the original, unblanked source
|
|
83
|
+
// (needed to tell a real value like model: "opus" apart from a blanked-out
|
|
84
|
+
// one), aligned index for index since blanking preserves length exactly.
|
|
85
|
+
function findAgentCalls(cleaned, src) {
|
|
86
|
+
const calls = [];
|
|
87
|
+
const re = /(?<![\w$.])agent\s*\(/g;
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(cleaned))) {
|
|
90
|
+
const open = cleaned.indexOf("(", m.index);
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let j = open;
|
|
93
|
+
for (; j < cleaned.length; j++) {
|
|
94
|
+
if (cleaned[j] === "(") depth++;
|
|
95
|
+
else if (cleaned[j] === ")") { depth--; if (depth === 0) break; }
|
|
96
|
+
}
|
|
97
|
+
if (depth !== 0) continue; // unbalanced call — skip this site, fail open
|
|
98
|
+
calls.push({
|
|
99
|
+
start: m.index,
|
|
100
|
+
end: j,
|
|
101
|
+
args: cleaned.slice(open + 1, j),
|
|
102
|
+
rawArgs: src.slice(open + 1, j),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return calls;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Split text at top-level commas only (ignoring commas nested inside (),
|
|
109
|
+
// [], or {}). Operates on cleaned text so a bracket or comma that only
|
|
110
|
+
// exists inside a string literal never confuses depth counting.
|
|
111
|
+
function splitTopLevel(text) {
|
|
112
|
+
const parts = [];
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let start = 0;
|
|
115
|
+
for (let i = 0; i < text.length; i++) {
|
|
116
|
+
const ch = text[i];
|
|
117
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
118
|
+
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
119
|
+
else if (ch === "," && depth === 0) {
|
|
120
|
+
parts.push({ start, end: i });
|
|
121
|
+
start = i + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
parts.push({ start, end: text.length });
|
|
125
|
+
return parts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Does one call argument set model or agentType to a concrete value, right
|
|
129
|
+
// on its own top level? Only an object-literal argument is inspected, and
|
|
130
|
+
// only that argument's own top-level properties: a value nested inside
|
|
131
|
+
// another object (for example { onError: { model: "x" } }) does not count,
|
|
132
|
+
// and neither does model: undefined or an empty value.
|
|
133
|
+
function argSetsModelOrAgentType(cleanedArg, rawArg) {
|
|
134
|
+
if (!cleanedArg.trim().startsWith("{")) return false;
|
|
135
|
+
const open = cleanedArg.indexOf("{");
|
|
136
|
+
const close = cleanedArg.lastIndexOf("}");
|
|
137
|
+
if (open === -1 || close === -1 || close <= open) return false;
|
|
138
|
+
|
|
139
|
+
const body = cleanedArg.slice(open + 1, close);
|
|
140
|
+
const rawBody = rawArg.slice(open + 1, close);
|
|
141
|
+
for (const { start, end } of splitTopLevel(body)) {
|
|
142
|
+
const prop = body.slice(start, end);
|
|
143
|
+
// Match only through the colon, not the value: a string value that was
|
|
144
|
+
// blanked to spaces in `prop` would otherwise let a greedy \s* here
|
|
145
|
+
// swallow the whole thing, leaving nothing for rawValue to see.
|
|
146
|
+
const keyMatch = prop.match(/^\s*([\w$]+)\s*:/);
|
|
147
|
+
if (!keyMatch) continue;
|
|
148
|
+
const key = keyMatch[1];
|
|
149
|
+
if (key !== "model" && key !== "agentType") continue;
|
|
150
|
+
const rawValue = rawBody.slice(start + keyMatch[0].length, end).trim();
|
|
151
|
+
if (rawValue.length > 0 && rawValue !== "undefined") return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The reason this hook prints is shown at the default (share) level, so it never carries a path
|
|
157
|
+
// or a raw error message: Node's messages quote the path and a JSON parse error quotes the
|
|
158
|
+
// payload. The only thing taken from a failure is its class, and only from this closed list.
|
|
159
|
+
const ERROR_CLASSES = new Set([
|
|
160
|
+
"ENOENT", "EACCES", "EPERM", "EISDIR", "ENOTDIR", "ELOOP", "EMFILE", "ENFILE",
|
|
161
|
+
"ENAMETOOLONG", "EIO", "EBUSY", "SyntaxError", "RangeError", "TypeError", "ReferenceError",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
function errorClass(err) {
|
|
165
|
+
const code = err && typeof err.code === "string" ? err.code : "";
|
|
166
|
+
if (ERROR_CLASSES.has(code)) return code;
|
|
167
|
+
const name = err && typeof err.name === "string" ? err.name : "";
|
|
168
|
+
return ERROR_CLASSES.has(name) ? name : "error";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function denyUninspectable(reason) {
|
|
172
|
+
process.stdout.write(
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
hookSpecificOutput: {
|
|
175
|
+
hookEventName: "PreToolUse",
|
|
176
|
+
permissionDecision: "deny",
|
|
177
|
+
permissionDecisionReason:
|
|
178
|
+
`model_allocation_guard: could not inspect this Workflow call (${reason}); ` +
|
|
179
|
+
"denying rather than silently allowing an unchecked script. Fix the " +
|
|
180
|
+
"underlying issue and re-invoke Workflow.",
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let input;
|
|
188
|
+
try {
|
|
189
|
+
input = JSON.parse((await readStdin()) || "{}");
|
|
190
|
+
} catch (err) {
|
|
191
|
+
denyUninspectable(`the hook payload is not valid JSON (${errorClass(err)})`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (input.tool_name && input.tool_name !== "Workflow") process.exit(0); // confirmed non-Workflow
|
|
195
|
+
|
|
196
|
+
const ti = input.tool_input || {};
|
|
197
|
+
let src = typeof ti.script === "string" && ti.script.length ? ti.script : null;
|
|
198
|
+
if (!src && typeof ti.scriptPath === "string" && ti.scriptPath.length) {
|
|
199
|
+
try {
|
|
200
|
+
src = readFileSync(resolve(input.cwd || process.cwd(), ti.scriptPath), "utf8");
|
|
201
|
+
} catch (err) {
|
|
202
|
+
denyUninspectable(`could not read scriptPath: ${errorClass(err)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!src) process.exit(0); // named workflow or nothing to check
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const cleaned = stripStringsAndComments(src);
|
|
209
|
+
const calls = findAgentCalls(cleaned, src);
|
|
210
|
+
if (calls.length === 0) process.exit(0);
|
|
211
|
+
|
|
212
|
+
const srcLines = src.split("\n");
|
|
213
|
+
const violations = [];
|
|
214
|
+
for (const c of calls) {
|
|
215
|
+
const setsModelOrAgentType = splitTopLevel(c.args).some(({ start, end }) =>
|
|
216
|
+
argSetsModelOrAgentType(c.args.slice(start, end), c.rawArgs.slice(start, end))
|
|
217
|
+
);
|
|
218
|
+
if (setsModelOrAgentType) continue;
|
|
219
|
+
const startLine = lineOf(cleaned, c.start);
|
|
220
|
+
const endLine = lineOf(cleaned, c.end);
|
|
221
|
+
let exempt = false;
|
|
222
|
+
for (const ln of [startLine - 1, startLine, endLine, endLine + 1]) {
|
|
223
|
+
const text = srcLines[ln - 1];
|
|
224
|
+
if (text && /model-ok\s*:/i.test(text)) { exempt = true; break; }
|
|
225
|
+
}
|
|
226
|
+
if (!exempt) violations.push(startLine);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (violations.length === 0) process.exit(0);
|
|
230
|
+
|
|
231
|
+
process.stdout.write(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
hookSpecificOutput: {
|
|
234
|
+
hookEventName: "PreToolUse",
|
|
235
|
+
permissionDecision: "deny",
|
|
236
|
+
permissionDecisionReason:
|
|
237
|
+
`model_allocation_guard: ${violations.length} agent() call(s) at line(s) ` +
|
|
238
|
+
`${violations.join(", ")} set neither opts.model nor opts.agentType. Set one ` +
|
|
239
|
+
`explicitly, or add a "// model-ok: <reason>" comment on the same or an ` +
|
|
240
|
+
`adjacent line to intentionally inherit the session model. Fix the script ` +
|
|
241
|
+
`and re-invoke Workflow.`,
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
process.exit(0);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
denyUninspectable(`internal error while scanning the script (${errorClass(err)})`);
|
|
248
|
+
}
|