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.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. 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
+ }