agent-postflight 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 +21 -0
- package/README.md +84 -0
- package/bin/postflight.js +108 -0
- package/lib/analyze.js +127 -0
- package/lib/parse.js +135 -0
- package/lib/report.js +110 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bharath Natarajan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# postflight
|
|
2
|
+
|
|
3
|
+
**read your agent's run back.**
|
|
4
|
+
|
|
5
|
+
vercel shipped agent observability into deploys this week. langsmith, langfuse, braintrust,
|
|
6
|
+
helicone all did it before them. the dashboard is table stakes now. it was never the point.
|
|
7
|
+
|
|
8
|
+
the point of a run is to make the *next* run better. postflight is a tiny, local, harness-neutral
|
|
9
|
+
CLI that turns an agent transcript into a **flight review + the skill to extract** — not a chart to
|
|
10
|
+
stare at.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npx agent-postflight
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
no signup, no SDK, no platform. it reads your most recent Claude Code run and tells you where the
|
|
17
|
+
loop leaked.
|
|
18
|
+
|
|
19
|
+
## the demo is the argument
|
|
20
|
+
|
|
21
|
+
run it on a real session:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
$ npx agent-postflight
|
|
25
|
+
|
|
26
|
+
# postflight — flight review
|
|
27
|
+
|
|
28
|
+
**725 tool calls** across 1,434 turns · 20 distinct tools · 3d 8h (resumed) · 1,319,024 tokens generated
|
|
29
|
+
|
|
30
|
+
> worth a look: 26 tool errors (4%) · ~57,015 tokens re-reading the same things · 8 redundant read patterns · 8 retry loops
|
|
31
|
+
|
|
32
|
+
## redundant work — the agent re-derived the same thing
|
|
33
|
+
- `Read` × 27 — `site/agent-worker/src/index.ts` · ~29,128 tokens
|
|
34
|
+
- `Read` × 8 — `README.md` · ~1,344 tokens
|
|
35
|
+
- `Read` × 7 — `site/index.html` · ~9,396 tokens
|
|
36
|
+
|
|
37
|
+
## the skill to extract →
|
|
38
|
+
the agent ran `Read` on index.ts 27× — re-deriving the same thing instead of remembering it.
|
|
39
|
+
capture its durable facts once (a skill, or a line in CLAUDE.md), and the next run reads it once.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
that `Read × 27` is a real number from a real session. the agent read the same file twenty-seven
|
|
43
|
+
times because nothing told it to remember. that's not a model problem. it's a missing loop — and a
|
|
44
|
+
missing loop is a skill waiting to be written.
|
|
45
|
+
|
|
46
|
+
## what it finds
|
|
47
|
+
|
|
48
|
+
- **redundant work** — the same file read, the same command run, the same search repeated. with a
|
|
49
|
+
rough token cost for the re-reads.
|
|
50
|
+
- **retry loops** — a tool that errored, then got tried again (and whether it ever recovered).
|
|
51
|
+
- **heaviest turns** — where the output tokens actually went.
|
|
52
|
+
- **run shape** — the dominant tool-to-tool pattern.
|
|
53
|
+
- **the skill to extract** — the one thing repeated enough that it should be captured once.
|
|
54
|
+
`--skill` writes a `SKILL.md` scaffold for it. that's the loop, closed.
|
|
55
|
+
|
|
56
|
+
## usage
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
postflight review your latest Claude Code run
|
|
60
|
+
postflight path/to/run.jsonl review a specific transcript
|
|
61
|
+
postflight --list list recent runs
|
|
62
|
+
postflight --json findings as JSON (pipe it anywhere)
|
|
63
|
+
postflight --skill write the proposed skill to .claude/skills/<name>/SKILL.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## harness-neutral by design
|
|
67
|
+
|
|
68
|
+
the Claude Code JSONL reader is the first adapter. the analysis never sees the format — so
|
|
69
|
+
OpenAI Agents SDK, LangGraph, and raw-log adapters are additive, not rewrites. (Claude Code
|
|
70
|
+
first because it's where the transcripts already live on disk. more adapters next.)
|
|
71
|
+
|
|
72
|
+
## the honest caveat
|
|
73
|
+
|
|
74
|
+
if your runs are short and tight, postflight will tell you so and find nothing to fix — that's a
|
|
75
|
+
good run, not a failure. and the loop-closing (`--skill`) gives you a *scaffold*, not a finished
|
|
76
|
+
skill; you still write the durable facts. the value is that it points at exactly the right thing to
|
|
77
|
+
capture. it will not fabricate a problem to look useful.
|
|
78
|
+
|
|
79
|
+
## why this exists
|
|
80
|
+
|
|
81
|
+
built by [Bharath Natarajan](https://bharath.sh) — this is loop engineering as a tool: a run should
|
|
82
|
+
teach the next run. observability shows you the run. postflight closes the loop.
|
|
83
|
+
|
|
84
|
+
MIT.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// postflight — read your agent's run back.
|
|
3
|
+
// usage: postflight [transcript.jsonl] [--json] [--skill] [--list]
|
|
4
|
+
// with no path, it finds your most recent Claude Code transcript.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { parseTranscript } from "../lib/parse.js";
|
|
10
|
+
import { analyze } from "../lib/analyze.js";
|
|
11
|
+
import { toMarkdown, skillMarkdown } from "../lib/report.js";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
15
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
16
|
+
|
|
17
|
+
function findTranscripts() {
|
|
18
|
+
const base = join(homedir(), ".claude", "projects");
|
|
19
|
+
if (!existsSync(base)) return [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const proj of readdirSync(base)) {
|
|
22
|
+
const dir = join(base, proj);
|
|
23
|
+
let st;
|
|
24
|
+
try {
|
|
25
|
+
st = statSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!st.isDirectory()) continue;
|
|
30
|
+
for (const f of readdirSync(dir)) {
|
|
31
|
+
if (f.endsWith(".jsonl")) {
|
|
32
|
+
const p = join(dir, f);
|
|
33
|
+
try {
|
|
34
|
+
out.push({ path: p, mtime: statSync(p).mtimeMs, project: proj });
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function main() {
|
|
43
|
+
if (flags.has("--help") || flags.has("-h")) {
|
|
44
|
+
console.log(
|
|
45
|
+
`postflight — read your agent's run back.\n\n` +
|
|
46
|
+
`usage:\n` +
|
|
47
|
+
` postflight [transcript.jsonl] review a run (default: your latest Claude Code run)\n` +
|
|
48
|
+
` postflight --list list recent runs\n` +
|
|
49
|
+
` postflight --json emit findings as JSON\n` +
|
|
50
|
+
` postflight --skill write the proposed skill to .claude/skills/<name>/SKILL.md\n`
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const transcripts = findTranscripts();
|
|
56
|
+
|
|
57
|
+
if (flags.has("--list")) {
|
|
58
|
+
if (!transcripts.length) return console.log("no Claude Code transcripts found under ~/.claude/projects");
|
|
59
|
+
for (const t of transcripts.slice(0, 15)) {
|
|
60
|
+
console.log(`${new Date(t.mtime).toISOString().slice(0, 16).replace("T", " ")} ${t.project}`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let path = positional[0];
|
|
66
|
+
if (!path) {
|
|
67
|
+
if (!transcripts.length) {
|
|
68
|
+
console.error(
|
|
69
|
+
"postflight: no transcript given and none found under ~/.claude/projects.\n" +
|
|
70
|
+
"pass a path: postflight path/to/run.jsonl"
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
path = transcripts[0].path;
|
|
75
|
+
}
|
|
76
|
+
if (!existsSync(path)) {
|
|
77
|
+
console.error(`postflight: no such file: ${path}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = parseTranscript(path);
|
|
82
|
+
if (!parsed.toolCalls.length) {
|
|
83
|
+
console.error("postflight: no tool calls found in that transcript (nothing to review).");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const a = analyze(parsed);
|
|
87
|
+
|
|
88
|
+
if (flags.has("--json")) {
|
|
89
|
+
console.log(JSON.stringify(a, null, 2));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
process.stdout.write(toMarkdown(a, { source: path }));
|
|
94
|
+
|
|
95
|
+
if (flags.has("--skill")) {
|
|
96
|
+
if (!a.skill) {
|
|
97
|
+
console.error("\npostflight: nothing repeated enough to extract a skill from this run.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const dir = join(process.cwd(), ".claude", "skills", a.skill.name);
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
const out = join(dir, "SKILL.md");
|
|
103
|
+
writeFileSync(out, skillMarkdown(a.skill) + "\n");
|
|
104
|
+
console.error(`\npostflight: wrote proposed skill → ${out} (review before using)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main();
|
package/lib/analyze.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// analyze.js — the flight review. Takes the harness-neutral parse output and finds
|
|
2
|
+
// the loops: redundant read-like calls, error/retry chains, and the dominant
|
|
3
|
+
// repeated workflow worth extracting into a skill. No dashboards — findings + a fix.
|
|
4
|
+
|
|
5
|
+
const APPROX_TOKENS_PER_CHAR = 0.27; // ~4 chars/token, for rough "re-read" cost
|
|
6
|
+
|
|
7
|
+
function slug(s) {
|
|
8
|
+
return (
|
|
9
|
+
(s || "pattern")
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "")
|
|
13
|
+
.slice(0, 40) || "pattern"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function analyze(parsed) {
|
|
18
|
+
const { toolCalls } = parsed;
|
|
19
|
+
|
|
20
|
+
// --- tool histogram ---
|
|
21
|
+
const histogram = {};
|
|
22
|
+
for (const c of toolCalls) histogram[c.name] = (histogram[c.name] || 0) + 1;
|
|
23
|
+
|
|
24
|
+
// --- redundant read-like calls (same tool + same target, seen >1x) ---
|
|
25
|
+
const sigCount = new Map(); // sig -> { name, target, count, wastedChars }
|
|
26
|
+
for (const c of toolCalls) {
|
|
27
|
+
if (!c.readLike || !c.target) continue;
|
|
28
|
+
const sig = `${c.name}::${c.target}`;
|
|
29
|
+
const e = sigCount.get(sig) || { name: c.name, target: c.target, count: 0, chars: 0 };
|
|
30
|
+
e.count++;
|
|
31
|
+
e.chars += c.resultLen || 0;
|
|
32
|
+
sigCount.set(sig, e);
|
|
33
|
+
}
|
|
34
|
+
const redundant = [...sigCount.values()]
|
|
35
|
+
.filter((e) => e.count > 1)
|
|
36
|
+
.map((e) => ({
|
|
37
|
+
name: e.name,
|
|
38
|
+
target: e.target,
|
|
39
|
+
count: e.count,
|
|
40
|
+
// wasted = the repeats (count-1) worth of re-read result payload
|
|
41
|
+
wastedTokens: Math.round(((e.chars * (e.count - 1)) / e.count) * APPROX_TOKENS_PER_CHAR),
|
|
42
|
+
}))
|
|
43
|
+
.sort((a, b) => b.count - a.count || b.wastedTokens - a.wastedTokens);
|
|
44
|
+
|
|
45
|
+
// --- error / retry chains: an errored call followed by same-tool calls nearby ---
|
|
46
|
+
const errors = toolCalls.filter((c) => c.isError);
|
|
47
|
+
const retryChains = [];
|
|
48
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
49
|
+
const c = toolCalls[i];
|
|
50
|
+
if (!c.isError) continue;
|
|
51
|
+
// look ahead up to 3 calls for a same-tool retry
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
const chain = [c];
|
|
54
|
+
while (j < toolCalls.length && j <= i + 4) {
|
|
55
|
+
const n = toolCalls[j];
|
|
56
|
+
if (n.name === c.name) {
|
|
57
|
+
chain.push(n);
|
|
58
|
+
if (!n.isError) break; // recovered
|
|
59
|
+
}
|
|
60
|
+
j++;
|
|
61
|
+
}
|
|
62
|
+
if (chain.length > 1) {
|
|
63
|
+
retryChains.push({
|
|
64
|
+
name: c.name,
|
|
65
|
+
target: c.target,
|
|
66
|
+
attempts: chain.length,
|
|
67
|
+
recovered: !chain[chain.length - 1].isError,
|
|
68
|
+
atSeq: c.seq,
|
|
69
|
+
});
|
|
70
|
+
i = j; // skip past this chain
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- heaviest turns by output tokens ---
|
|
75
|
+
const heaviest = [...parsed.heavy].sort((a, b) => b.outputTokens - a.outputTokens).slice(0, 3);
|
|
76
|
+
|
|
77
|
+
// --- run shape: the most common consecutive tool bigram (a separate stat) ---
|
|
78
|
+
const bigrams = new Map();
|
|
79
|
+
for (let i = 1; i < toolCalls.length; i++) {
|
|
80
|
+
const k = `${toolCalls[i - 1].name} → ${toolCalls[i].name}`;
|
|
81
|
+
bigrams.set(k, (bigrams.get(k) || 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
const topBigram = [...bigrams.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
84
|
+
|
|
85
|
+
// --- the skill to extract: the most re-derived read target, framed coherently ---
|
|
86
|
+
// if the agent re-read the same thing 3+ times, it never remembered it — that's a
|
|
87
|
+
// skill (or a CLAUDE.md fact) waiting to be written. This is the loop-closing move.
|
|
88
|
+
let skill = null;
|
|
89
|
+
const topRepeat = redundant[0];
|
|
90
|
+
if (topRepeat && topRepeat.count >= 3) {
|
|
91
|
+
const subject = topRepeat.target.split(/[\/\s]/).filter(Boolean).slice(-1)[0] || topRepeat.target;
|
|
92
|
+
skill = {
|
|
93
|
+
name: slug(`${subject}-context`),
|
|
94
|
+
kind: topRepeat.name,
|
|
95
|
+
target: topRepeat.target,
|
|
96
|
+
count: topRepeat.count,
|
|
97
|
+
why:
|
|
98
|
+
`the agent ran \`${topRepeat.name}\` on this **${topRepeat.count}×** — re-deriving the same thing ` +
|
|
99
|
+
`instead of remembering it. capture its durable facts once (a skill, or a line in CLAUDE.md/AGENTS.md), ` +
|
|
100
|
+
`and the next run reads it once.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const wastedTokens = redundant.reduce((a, r) => a + r.wastedTokens, 0);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
summary: {
|
|
108
|
+
toolCalls: toolCalls.length,
|
|
109
|
+
distinctTools: Object.keys(histogram).length,
|
|
110
|
+
assistantTurns: parsed.assistantTurns,
|
|
111
|
+
errorCount: errors.length,
|
|
112
|
+
errorRate: toolCalls.length ? errors.length / toolCalls.length : 0,
|
|
113
|
+
// honest token accounting: output = real generation. context re-read (cache)
|
|
114
|
+
// is reported separately — you can't sum per-turn input, it re-sends context.
|
|
115
|
+
outputTokens: parsed.usage.output,
|
|
116
|
+
contextReadTokens: parsed.usage.cacheRead,
|
|
117
|
+
wastedTokens,
|
|
118
|
+
durationMs: parsed.durationMs,
|
|
119
|
+
runShape: topBigram ? { pair: topBigram[0], count: topBigram[1] } : null,
|
|
120
|
+
},
|
|
121
|
+
histogram,
|
|
122
|
+
redundant: redundant.slice(0, 8),
|
|
123
|
+
retryChains: retryChains.slice(0, 8),
|
|
124
|
+
heaviest,
|
|
125
|
+
skill,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/lib/parse.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// parse.js — turn a Claude Code JSONL transcript into an ordered list of tool
|
|
2
|
+
// calls + token usage. Harness-neutral by design: the Claude Code reader is the
|
|
3
|
+
// first adapter; the shape it returns (a flat `events`/`toolCalls` model) is what
|
|
4
|
+
// every other adapter will produce, so analyze/report never learn the format.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// Normalize a tool call's input into a stable signature target, so "read the same
|
|
9
|
+
// file twice" collapses to one signature regardless of incidental key order.
|
|
10
|
+
function targetOf(name, input) {
|
|
11
|
+
input = input || {};
|
|
12
|
+
switch (name) {
|
|
13
|
+
case "Read":
|
|
14
|
+
case "Edit":
|
|
15
|
+
case "Write":
|
|
16
|
+
case "NotebookEdit":
|
|
17
|
+
return input.file_path || input.path || "";
|
|
18
|
+
case "Bash":
|
|
19
|
+
return (input.command || "").trim().replace(/\s+/g, " ");
|
|
20
|
+
case "Grep":
|
|
21
|
+
return `${input.pattern || ""} :: ${input.path || input.glob || ""}`;
|
|
22
|
+
case "Glob":
|
|
23
|
+
return input.pattern || "";
|
|
24
|
+
case "WebFetch":
|
|
25
|
+
return input.url || "";
|
|
26
|
+
case "WebSearch":
|
|
27
|
+
return input.query || "";
|
|
28
|
+
case "Task":
|
|
29
|
+
case "Agent":
|
|
30
|
+
return (input.description || input.subagent_type || "").trim();
|
|
31
|
+
default: {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(input).slice(0, 200);
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tools that READ state and should be idempotent — repeating them verbatim is the
|
|
42
|
+
// smell postflight looks for. Repeating an Edit/Write is often legitimate.
|
|
43
|
+
const READ_LIKE = new Set(["Read", "Grep", "Glob", "Bash", "WebFetch", "WebSearch"]);
|
|
44
|
+
|
|
45
|
+
export function parseTranscript(path) {
|
|
46
|
+
const raw = readFileSync(path, "utf8");
|
|
47
|
+
const lines = raw.split("\n");
|
|
48
|
+
|
|
49
|
+
const toolCalls = []; // ordered
|
|
50
|
+
const resultsById = new Map(); // tool_use_id -> { isError, len }
|
|
51
|
+
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
52
|
+
let assistantTurns = 0;
|
|
53
|
+
let userTurns = 0;
|
|
54
|
+
let firstTs = null;
|
|
55
|
+
let lastTs = null;
|
|
56
|
+
let seq = 0;
|
|
57
|
+
const heavy = []; // { seq, outputTokens, firstToolAfter }
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const s = line.trim();
|
|
61
|
+
if (!s) continue;
|
|
62
|
+
let d;
|
|
63
|
+
try {
|
|
64
|
+
d = JSON.parse(s);
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (d.timestamp) {
|
|
69
|
+
const t = Date.parse(d.timestamp);
|
|
70
|
+
if (!Number.isNaN(t)) {
|
|
71
|
+
firstTs = firstTs === null ? t : Math.min(firstTs, t);
|
|
72
|
+
lastTs = lastTs === null ? t : Math.max(lastTs, t);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const msg = d.message;
|
|
76
|
+
if (!msg || typeof msg !== "object") continue;
|
|
77
|
+
|
|
78
|
+
if (d.type === "assistant") {
|
|
79
|
+
assistantTurns++;
|
|
80
|
+
const u = msg.usage;
|
|
81
|
+
if (u) {
|
|
82
|
+
usage.input += u.input_tokens || 0;
|
|
83
|
+
usage.output += u.output_tokens || 0;
|
|
84
|
+
usage.cacheRead += u.cache_read_input_tokens || 0;
|
|
85
|
+
usage.cacheWrite += u.cache_creation_input_tokens || 0;
|
|
86
|
+
heavy.push({ turn: assistantTurns, outputTokens: u.output_tokens || 0 });
|
|
87
|
+
}
|
|
88
|
+
} else if (d.type === "user") {
|
|
89
|
+
userTurns++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const content = msg.content;
|
|
93
|
+
if (!Array.isArray(content)) continue;
|
|
94
|
+
for (const b of content) {
|
|
95
|
+
if (!b || typeof b !== "object") continue;
|
|
96
|
+
if (b.type === "tool_use") {
|
|
97
|
+
toolCalls.push({
|
|
98
|
+
seq: seq++,
|
|
99
|
+
id: b.id || null,
|
|
100
|
+
name: b.name || "?",
|
|
101
|
+
target: targetOf(b.name, b.input),
|
|
102
|
+
readLike: READ_LIKE.has(b.name),
|
|
103
|
+
ts: d.timestamp ? Date.parse(d.timestamp) : null,
|
|
104
|
+
});
|
|
105
|
+
} else if (b.type === "tool_result") {
|
|
106
|
+
const id = b.tool_use_id;
|
|
107
|
+
if (id) {
|
|
108
|
+
let len = 0;
|
|
109
|
+
const c = b.content;
|
|
110
|
+
if (typeof c === "string") len = c.length;
|
|
111
|
+
else if (Array.isArray(c))
|
|
112
|
+
len = c.reduce((a, x) => a + (typeof x?.text === "string" ? x.text.length : 0), 0);
|
|
113
|
+
resultsById.set(id, { isError: !!b.is_error, len });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// attach results to their calls
|
|
120
|
+
for (const call of toolCalls) {
|
|
121
|
+
const r = call.id ? resultsById.get(call.id) : null;
|
|
122
|
+
call.isError = r ? r.isError : false;
|
|
123
|
+
call.resultLen = r ? r.len : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
path,
|
|
128
|
+
toolCalls,
|
|
129
|
+
usage,
|
|
130
|
+
assistantTurns,
|
|
131
|
+
userTurns,
|
|
132
|
+
durationMs: firstTs !== null && lastTs !== null ? lastTs - firstTs : null,
|
|
133
|
+
heavy,
|
|
134
|
+
};
|
|
135
|
+
}
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// report.js — render the flight review as markdown (the human-readable argument)
|
|
2
|
+
// and the proposed skill as an extractable SKILL.md scaffold.
|
|
3
|
+
|
|
4
|
+
function fmt(n) {
|
|
5
|
+
return Math.round(n).toLocaleString("en-US");
|
|
6
|
+
}
|
|
7
|
+
function dur(ms) {
|
|
8
|
+
if (ms == null) return "unknown";
|
|
9
|
+
const s = Math.round(ms / 1000);
|
|
10
|
+
if (s < 60) return `${s}s`;
|
|
11
|
+
const m = Math.floor(s / 60);
|
|
12
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
13
|
+
const h = Math.floor(m / 60);
|
|
14
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
15
|
+
const d = Math.floor(h / 24);
|
|
16
|
+
return `${d}d ${h % 24}h (resumed)`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toMarkdown(a, meta = {}) {
|
|
20
|
+
const s = a.summary;
|
|
21
|
+
const L = [];
|
|
22
|
+
L.push(`# postflight — flight review`);
|
|
23
|
+
if (meta.source) L.push(`\n\`${meta.source}\``);
|
|
24
|
+
L.push("");
|
|
25
|
+
L.push(
|
|
26
|
+
`**${fmt(s.toolCalls)} tool calls** across ${fmt(s.assistantTurns)} turns · ` +
|
|
27
|
+
`${fmt(s.distinctTools)} distinct tools · ${dur(s.durationMs)} · ` +
|
|
28
|
+
`${fmt(s.outputTokens)} tokens generated`
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const flags = [];
|
|
32
|
+
if (s.errorCount) flags.push(`${s.errorCount} tool errors (${(s.errorRate * 100).toFixed(0)}%)`);
|
|
33
|
+
if (s.wastedTokens) flags.push(`~${fmt(s.wastedTokens)} tokens re-reading the same things`);
|
|
34
|
+
if (a.redundant.length) flags.push(`${a.redundant.length} redundant read patterns`);
|
|
35
|
+
if (a.retryChains.length) flags.push(`${a.retryChains.length} retry loops`);
|
|
36
|
+
if (flags.length) L.push(`\n> **worth a look:** ${flags.join(" · ")}`);
|
|
37
|
+
else L.push(`\n> clean run — no redundant reads or retry loops detected.`);
|
|
38
|
+
|
|
39
|
+
if (a.redundant.length) {
|
|
40
|
+
L.push(`\n## redundant work — the agent re-derived the same thing`);
|
|
41
|
+
for (const r of a.redundant) {
|
|
42
|
+
const w = r.wastedTokens ? ` · ~${fmt(r.wastedTokens)} tokens` : "";
|
|
43
|
+
L.push(`- \`${r.name}\` × **${r.count}** — \`${trunc(r.target, 76)}\`${w}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (a.retryChains.length) {
|
|
48
|
+
L.push(`\n## retry loops — errored, then tried again`);
|
|
49
|
+
for (const c of a.retryChains) {
|
|
50
|
+
const ok = c.recovered ? "recovered" : "**never recovered**";
|
|
51
|
+
L.push(`- \`${c.name}\` — ${c.attempts} attempts, ${ok} — \`${trunc(c.target, 60)}\``);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (a.heaviest.length) {
|
|
56
|
+
L.push(`\n## heaviest turns (by output tokens)`);
|
|
57
|
+
for (const h of a.heaviest) L.push(`- turn ${h.turn} — ${fmt(h.outputTokens)} tokens`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const shape = s.runShape ? ` · run shape: \`${s.runShape.pair}\` ×${s.runShape.count}` : "";
|
|
61
|
+
L.push(`\n## tool mix`);
|
|
62
|
+
L.push(
|
|
63
|
+
Object.entries(a.histogram)
|
|
64
|
+
.sort((x, y) => y[1] - x[1])
|
|
65
|
+
.map(([n, c]) => `${n} ${c}`)
|
|
66
|
+
.join(" · ") + shape
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
L.push(`\n## the skill to extract →`);
|
|
70
|
+
if (a.skill) {
|
|
71
|
+
L.push(a.skill.why);
|
|
72
|
+
L.push("\n```markdown");
|
|
73
|
+
L.push(skillMarkdown(a.skill));
|
|
74
|
+
L.push("```");
|
|
75
|
+
L.push(
|
|
76
|
+
`\n_close the loop: save that as \`.claude/skills/${a.skill.name}/SKILL.md\` (or drop the facts into ` +
|
|
77
|
+
`CLAUDE.md), and the next run stops rediscovering it._`
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
L.push(`nothing repeated ≥3× this run — nothing worth extracting. that's a tight run.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return L.join("\n") + "\n";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function skillMarkdown(skill) {
|
|
87
|
+
const subject = skill.target.split(/[\/\s]/).filter(Boolean).slice(-1)[0] || skill.target;
|
|
88
|
+
return [
|
|
89
|
+
`---`,
|
|
90
|
+
`name: ${skill.name}`,
|
|
91
|
+
`description: Use when you need to know about ${subject}. postflight saw the agent ${skill.kind} it ${skill.count}× in one run — capture it once here so it isn't re-derived.`,
|
|
92
|
+
`---`,
|
|
93
|
+
``,
|
|
94
|
+
`# ${skill.name}`,
|
|
95
|
+
``,
|
|
96
|
+
`\`${skill.target}\``,
|
|
97
|
+
``,
|
|
98
|
+
`The agent re-read this ${skill.count} times in a single run. Write its durable facts here —`,
|
|
99
|
+
`what it is, the 3-5 things worth knowing, the gotchas — so the next run reads it once:`,
|
|
100
|
+
``,
|
|
101
|
+
`- <key fact 1>`,
|
|
102
|
+
`- <key fact 2>`,
|
|
103
|
+
`- <gotcha>`,
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function trunc(s, n) {
|
|
108
|
+
s = String(s || "");
|
|
109
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-postflight",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read your agent's run back. A harness-neutral CLI that turns an agent transcript into a flight review + a skill to extract — so the next run is better, not just observed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-postflight": "bin/postflight.js",
|
|
8
|
+
"postflight": "bin/postflight.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node test/run.mjs"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"agents",
|
|
15
|
+
"ai-agents",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"observability",
|
|
18
|
+
"loop-engineering",
|
|
19
|
+
"agent-experience",
|
|
20
|
+
"cli",
|
|
21
|
+
"skills",
|
|
22
|
+
"mcp"
|
|
23
|
+
],
|
|
24
|
+
"author": "Bharath Natarajan <hi@bharath.sh> (https://bharath.sh)",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"lib/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
]
|
|
35
|
+
}
|