agentic-retro 0.0.0-stage → 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.
@@ -0,0 +1,30 @@
1
+ // agentic-retro Pi extension, written by `agentic-retro install-pi`.
2
+ // Queues each finished session for a retro and shows new proposals at session start.
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { execFile, spawn } from "node:child_process";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ pi.on("session_shutdown", (event, ctx) => {
8
+ if (event.reason !== "quit") return; // "new", "resume", and "fork" only switch sessions
9
+ if (process.env.AGENTIC_RETRO_RUN === "1") return; // batch agents never queue themselves
10
+
11
+ const transcript = ctx.sessionManager.getSessionFile();
12
+ if (!transcript) return; // nothing was said, so nothing was saved
13
+
14
+ const child = spawn("agentic-retro", [
15
+ "enqueue", "--harness", "pi",
16
+ "--session", ctx.sessionManager.getSessionId(),
17
+ "--transcript", transcript,
18
+ "--cwd", ctx.cwd,
19
+ ], { detached: true, stdio: "ignore" });
20
+ child.on("error", () => {}); // CLI not installed: never break Pi
21
+ child.unref();
22
+ });
23
+
24
+ pi.on("session_start", (_event, ctx) => {
25
+ execFile("agentic-retro", ["notice"], (error, stdout) => {
26
+ const line = stdout?.trim();
27
+ if (!error && line) ctx.ui.notify(line, "info");
28
+ });
29
+ });
30
+ }
package/cli/lib/pi.js ADDED
@@ -0,0 +1,15 @@
1
+ // Pi has no hook config, so a small extension file stands in for the hooks.
2
+ import { mkdirSync, copyFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), 'pi-extension.ts');
8
+
9
+ export function installPi() {
10
+ const dir = join(process.env.PI_HOME || join(homedir(), '.pi'), 'agent', 'extensions');
11
+ mkdirSync(dir, { recursive: true });
12
+ const file = join(dir, 'agentic-retro.ts');
13
+ copyFileSync(TEMPLATE, file);
14
+ console.log(`agentic-retro: wrote ${file}`);
15
+ }
@@ -0,0 +1,61 @@
1
+ // The queue: finished sessions wait here until their group fills a batch.
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readdirSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join, basename } from 'node:path';
6
+ import { readJson, writeJson } from './paths.js';
7
+ import { loadConfig } from './config.js';
8
+ import { startBatch } from './runner.js';
9
+
10
+ function repoOf(cwd) {
11
+ try {
12
+ return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'],
13
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
14
+ } catch {
15
+ return cwd;
16
+ }
17
+ }
18
+
19
+ // Codex sometimes ends a session with a null transcript_path, so look for its rollout file.
20
+ function findCodexRollout(sessionId) {
21
+ const sessions = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
22
+ try {
23
+ const match = readdirSync(sessions, { recursive: true }).find((f) => f.endsWith(`-${sessionId}.jsonl`));
24
+ return match ? join(sessions, match) : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // Claude Code and Codex share one hook, so the transcript path tells them apart.
31
+ // Only Codex ends a session with no transcript path.
32
+ function harnessOf(transcript) {
33
+ if (!transcript) return 'codex';
34
+ if (transcript.includes('/.codex/') || basename(transcript).startsWith('rollout-')) return 'codex';
35
+ if (transcript.includes('/.claude/')) return 'claude';
36
+ if (transcript.includes('/.pi/')) return 'pi';
37
+ return undefined;
38
+ }
39
+
40
+ export async function enqueue({ harness, sessionId, transcript, cwd }) {
41
+ if (process.env.AGENTIC_RETRO_RUN === '1') return; // batch agents never queue themselves
42
+ transcript ||= findCodexRollout(sessionId) ?? null;
43
+ harness ||= harnessOf(transcript);
44
+ if (!harness || !sessionId || !cwd) throw new Error('enqueue needs a harness, session, and cwd');
45
+
46
+ const queue = readJson('queue.json', []);
47
+ if (queue.some((s) => s.sessionId === sessionId)) return;
48
+ const entry = { harness, sessionId, transcript, cwd, repo: repoOf(cwd), endedAt: new Date().toISOString() };
49
+ queue.push(entry);
50
+
51
+ const { batch } = loadConfig();
52
+ const sameGroup = (s) => batch.group === 'global' || s.repo === entry.repo;
53
+ const group = queue.filter(sameGroup);
54
+ if (group.length < batch.size) return writeJson('queue.json', queue);
55
+
56
+ // Save first and remove after, so a failed hand-off leaves the sessions queued.
57
+ writeJson('queue.json', queue);
58
+ const sessions = group.slice(0, batch.size);
59
+ await startBatch(sessions);
60
+ writeJson('queue.json', queue.filter((s) => !sessions.includes(s)));
61
+ }
@@ -0,0 +1,48 @@
1
+ // Starts a batch run: writes the batch manifest, syncs outcomes and digests, then launches the batch agent.
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { openSync, appendFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { writeJson, statePath } from './paths.js';
6
+ import { nextBatch } from './ledger.js';
7
+ import { loadConfig } from './config.js';
8
+ import { syncOutcomes } from './outcomes.js';
9
+ import { writeBatchDigests } from './digest.js';
10
+ import { buildCommand, PANE_ID } from './commands.js';
11
+
12
+ export { buildCommand };
13
+
14
+ function runHerdr([create, paneRun]) {
15
+ let output;
16
+ try {
17
+ output = execFileSync(create.file, create.args, { cwd: create.cwd, encoding: 'utf8' });
18
+ } catch (error) {
19
+ throw new Error(`herdr could not open a workspace – is the herdr server running? (${error.message})`);
20
+ }
21
+ const paneId = JSON.parse(output).result.root_pane.pane_id;
22
+ const args = paneRun.args.map((arg) => (arg === PANE_ID ? paneId : arg));
23
+ execFileSync(paneRun.file, args, { cwd: paneRun.cwd });
24
+ }
25
+
26
+ // Detached with output in run.log, so enqueue (a session-end hook) returns at once.
27
+ function launch({ file, args, env, cwd }) {
28
+ const log = join(cwd, 'run.log');
29
+ const out = openSync(log, 'a');
30
+ const child = spawn(process.env.AGENTIC_RETRO_LAUNCHER || file, args, {
31
+ cwd, env: { ...process.env, ...env }, detached: true, stdio: ['ignore', out, out],
32
+ });
33
+ child.on('error', (error) => appendFileSync(log, `could not start ${file}: ${error.message}\n`));
34
+ child.unref();
35
+ }
36
+
37
+ export async function startBatch(sessions) {
38
+ const n = nextBatch();
39
+ writeJson(`batches/${n}/batch.json`, { sessions });
40
+ if (process.env.AGENTIC_RETRO_DRY_RUN === '1') return n;
41
+
42
+ syncOutcomes();
43
+ await writeBatchDigests(n, sessions);
44
+ const command = buildCommand(loadConfig(), statePath('batches', String(n)), n, sessions);
45
+ if (Array.isArray(command)) runHerdr(command);
46
+ else launch(command);
47
+ return n;
48
+ }
@@ -0,0 +1,52 @@
1
+ // Interactive setup: one question per config setting, each with presets. Enter keeps the current value.
2
+ import { createInterface } from 'node:readline';
3
+ import { existsSync } from 'node:fs';
4
+ import { delimiter, join } from 'node:path';
5
+ import { loadConfig, saveConfig, getKey, parseValue } from './config.js';
6
+
7
+ const onPath = (bin) => (process.env.PATH || '').split(delimiter).some((dir) => existsSync(join(dir, bin)));
8
+
9
+ function questions() {
10
+ const harnesses = ['claude', 'codex', 'pi', ...(onPath('herdr') ? ['herdr'] : [])];
11
+ return [
12
+ ['batch.size', 'Sessions per batch', ['5', '10']],
13
+ ['batch.group', 'Group sessions by', ['repo', 'global']],
14
+ ['runner.harness', 'Harness that runs batch agents', harnesses],
15
+ ['runner.model', 'Model for batch agents (opus applies to Claude; Codex/Pi use their own default unless given a model name)', ['opus', 'default']],
16
+ ['runner.permissions', 'Batch agent permissions', ['auto', 'scoped', 'bypass']],
17
+ ['categories.clarity', 'Propose code-clarity changes?', ['true', 'false']],
18
+ ['categories.agent-setup', 'Propose agent-setup changes (skills, hooks, instructions)?', ['true', 'false']],
19
+ ['categories.repo-for-agents', 'Propose changes that make repos easier for agents?', ['true', 'false']],
20
+ ['scope', 'Code-clarity scope', ['touched', 'neighbours', 'two-layers']],
21
+ ['output', 'Output', ['pr', 'report']],
22
+ ['autoMergeLowRisk', 'Auto-merge low-risk agent-setup PRs?', ['false', 'true']],
23
+ ['expireAfterBatches', 'Expire open proposals after how many batches', ['3', '5']],
24
+ ['editable', 'Writable paths (JSON list)', ['[]']],
25
+ ['targets.preset', 'Where agent-setup changes go', ['~/.claude/skills', '~/.codex/skills', '<plugin repo path>']],
26
+ ['targets.note', 'Note for agents about targets', []],
27
+ ['notes', 'Anything else batch agents should know', []],
28
+ ];
29
+ }
30
+
31
+ const show = (value) => (typeof value === 'string' ? value : JSON.stringify(value));
32
+
33
+ export async function setup() {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
35
+ const lines = rl[Symbol.asyncIterator]();
36
+ const config = loadConfig();
37
+
38
+ for (const [key, label, presets] of questions()) {
39
+ const current = show(getKey(config, key));
40
+ const choices = presets.length ? ` (${presets.join(' / ')})` : '';
41
+ process.stdout.write(`${label}${choices} [${current}]: `);
42
+ const { value, done } = await lines.next();
43
+ const answer = done ? '' : value.trim();
44
+ const parts = key.split('.');
45
+ const last = parts.pop();
46
+ parts.reduce((node, part) => node[part], config)[last] = parseValue(key, answer || current);
47
+ }
48
+
49
+ rl.close();
50
+ saveConfig(config);
51
+ process.stdout.write('\nSaved config.json\n');
52
+ }
@@ -0,0 +1,57 @@
1
+ // Stats: how often each kind of proposal lands, and how many tokens each changed line costs.
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { statePath } from './paths.js';
4
+ import { CATEGORIES, loadLedger } from './ledger.js';
5
+
6
+ function categoryStats(proposals) {
7
+ const counts = { accepted: 0, rejected: 0, expired: 0, open: 0 };
8
+ for (const p of proposals) counts[p.status] += 1;
9
+ // Share of settled proposals only: open ones have no verdict yet.
10
+ const settled = counts.accepted + counts.rejected + counts.expired;
11
+ return { ...counts, acceptedShare: settled ? counts.accepted / settled : null };
12
+ }
13
+
14
+ function readMetrics() {
15
+ const path = statePath('metrics.jsonl');
16
+ if (!existsSync(path)) return [];
17
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
18
+ }
19
+
20
+ // A session counts only when it has token data and changed at least one line.
21
+ function batchRatios(metrics) {
22
+ const totals = new Map();
23
+ for (const m of metrics) {
24
+ const lines = (m.linesAdded ?? 0) + (m.linesRemoved ?? 0);
25
+ if (m.inputTokens == null || m.outputTokens == null || !lines) continue;
26
+ const total = totals.get(m.batch) ?? { tokens: 0, lines: 0 };
27
+ total.tokens += m.inputTokens + m.outputTokens;
28
+ total.lines += lines;
29
+ totals.set(m.batch, total);
30
+ }
31
+ return [...totals]
32
+ .sort(([a], [b]) => a - b)
33
+ .map(([batch, { tokens, lines }]) => ({ batch, tokensPerLine: tokens / lines }));
34
+ }
35
+
36
+ export function collectStats() {
37
+ const { proposals } = loadLedger();
38
+ const categories = Object.fromEntries(CATEGORIES.map((c) =>
39
+ [c, categoryStats(proposals.filter((p) => p.category === c))]));
40
+ return { categories, batches: batchRatios(readMetrics()) };
41
+ }
42
+
43
+ function formatStats({ categories, batches }) {
44
+ const lines = Object.entries(categories).map(([name, c]) => {
45
+ const share = c.acceptedShare === null ? '-' : `${Math.round(c.acceptedShare * 100)}%`;
46
+ return `${name}: ${c.accepted} accepted, ${c.rejected} rejected, ${c.expired} expired, ${c.open} open, ${share} accepted`;
47
+ });
48
+ lines.push('tokens per line changed:');
49
+ if (!batches.length) lines.push(' no metrics yet');
50
+ for (const b of batches) lines.push(` batch ${b.batch}: ${Math.round(b.tokensPerLine)}`);
51
+ return lines.join('\n');
52
+ }
53
+
54
+ export function stats({ json }) {
55
+ const result = collectStats();
56
+ console.log(json ? JSON.stringify(result, null, 2) : formatStats(result));
57
+ }
@@ -0,0 +1,84 @@
1
+ // Reads a Claude Code transcript (~/.claude/projects/<slug>/<session>.jsonl).
2
+ import { eachLine, countLines, emptySession, stamp } from './lines.js';
3
+
4
+ const SUBAGENT_TOOLS = new Set(['Task', 'Agent']);
5
+
6
+ // Claude Code writes these into the transcript as user messages; the person never typed them.
7
+ // A relayed "Another Claude session sent a message:" line is agent traffic too, not a user turn.
8
+ const INJECTED = /^\s*(<command-name>|<command-message>|<local-command-stdout>|<task-notification>|<teammate-message|<system-reminder>|Another Claude session sent a message:)/;
9
+
10
+ function target(input = {}) {
11
+ return input.file_path ?? input.command ?? input.pattern ?? input.url ?? input.description ?? input.path ?? '';
12
+ }
13
+
14
+ function edit(name, input = {}) {
15
+ const file = input.file_path;
16
+ if (name === 'Write') return { file, added: countLines(input.content), removed: 0 };
17
+ const pairs = name === 'Edit' ? [input] : name === 'MultiEdit' ? input.edits ?? [] : null;
18
+ if (!pairs) return null;
19
+ return {
20
+ file,
21
+ added: pairs.reduce((sum, e) => sum + countLines(e.new_string), 0),
22
+ removed: pairs.reduce((sum, e) => sum + countLines(e.old_string), 0),
23
+ };
24
+ }
25
+
26
+ function resultText(content) {
27
+ if (typeof content === 'string') return content;
28
+ if (Array.isArray(content)) return content.map((c) => c.text ?? '').join(' ');
29
+ return '';
30
+ }
31
+
32
+ // Input counts every prompt token: fresh, read from cache, and written to cache.
33
+ function totalUsage(usages) {
34
+ if (!usages.length) return null;
35
+ const sum = (key) => usages.reduce((total, u) => total + (u[key] ?? 0), 0);
36
+ return {
37
+ input: sum('input_tokens') + sum('cache_read_input_tokens') + sum('cache_creation_input_tokens'),
38
+ output: sum('output_tokens'),
39
+ };
40
+ }
41
+
42
+ export async function readSession(path) {
43
+ const session = emptySession();
44
+ const usages = new Map(); // one assistant message streams as several lines sharing message.id; the last line's usage is final
45
+ const calls = new Map(); // tool_use id -> toolCall, so results can mark errors
46
+
47
+ await eachLine(path, (line) => {
48
+ const message = line.message;
49
+ if (!message || (line.type !== 'user' && line.type !== 'assistant')) return;
50
+ session.cwd ??= line.cwd ?? null;
51
+ stamp(session, line.timestamp);
52
+ const blocks = Array.isArray(message.content) ? message.content : [];
53
+
54
+ if (line.type === 'user') {
55
+ const results = blocks.filter((b) => b.type === 'tool_result');
56
+ for (const r of results) {
57
+ const call = calls.get(r.tool_use_id);
58
+ if (call && r.is_error) call.error = resultText(r.content) || 'error';
59
+ }
60
+ if (results.length || line.isMeta) return;
61
+ const text = typeof message.content === 'string' ? message.content : resultText(blocks);
62
+ if (INJECTED.test(text)) return;
63
+ session.turns.user++;
64
+ session.userMessages.push(text);
65
+ return;
66
+ }
67
+
68
+ const id = message.id ?? line.uuid;
69
+ if (!usages.has(id)) session.turns.assistant++;
70
+ usages.set(id, message.usage ?? usages.get(id) ?? null);
71
+ for (const block of blocks) {
72
+ if (block.type !== 'tool_use') continue;
73
+ const call = { tool: block.name, target: String(target(block.input)), error: null };
74
+ session.toolCalls.push(call);
75
+ calls.set(block.id, call);
76
+ if (SUBAGENT_TOOLS.has(block.name)) session.subagents++;
77
+ const change = edit(block.name, block.input);
78
+ if (change) session.edits.push(change);
79
+ }
80
+ });
81
+
82
+ session.usage = totalUsage([...usages.values()].filter(Boolean));
83
+ return session;
84
+ }
@@ -0,0 +1,76 @@
1
+ // Turns Codex tool records into tool calls and edits, for both transcript formats.
2
+ // Legacy: function_call "shell" with a command array. Current (0.162+): custom_tool_call "exec" whose input is a JS script.
3
+
4
+ // Counts +/- lines per file in an apply_patch body ("*** Update File: x" headers).
5
+ function patchEdits(patch) {
6
+ const edits = [];
7
+ let current = null;
8
+ for (const line of patch.split('\n')) {
9
+ const header = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
10
+ if (header) edits.push((current = { file: header[1].trim(), added: 0, removed: 0 }));
11
+ else if (!current || line.startsWith('***')) continue;
12
+ else if (line.startsWith('+') && !line.startsWith('+++')) current.added++;
13
+ else if (line.startsWith('-') && !line.startsWith('---')) current.removed++;
14
+ }
15
+ return edits;
16
+ }
17
+
18
+ function parse(json) {
19
+ try { return JSON.parse(json); } catch { return null; }
20
+ }
21
+
22
+ function patchCall(patch) {
23
+ const edits = patchEdits(patch);
24
+ return { call: { tool: 'apply_patch', target: edits.map((e) => e.file).join(', '), error: null }, edits };
25
+ }
26
+
27
+ // Returns the patch text from ["apply_patch", patch] or ["bash", "-lc", "apply_patch <<'EOF' ... EOF"].
28
+ function patchOf(command) {
29
+ if (command[0] === 'apply_patch') return command[1] ?? '';
30
+ const script = command.at(-1) ?? '';
31
+ return /^\s*apply_patch\b/.test(script) ? script : null;
32
+ }
33
+
34
+ function legacyShell(name, args) {
35
+ const command = Array.isArray(args.command) ? args.command : [String(args.command ?? '')];
36
+ const patch = patchOf(command);
37
+ if (patch !== null) return [patchCall(patch)];
38
+ const target = command.length > 2 && command[1] === '-lc' ? command[2] : command.join(' ');
39
+ return [{ call: { tool: name, target, error: null }, edits: [] }];
40
+ }
41
+
42
+ // A JS string literal body like `npm test` or `*** Begin Patch\n...`, unescaped.
43
+ const STRING = '"((?:[^"\\\\]|\\\\.)*)"';
44
+ const SCRIPT_CALLS = new RegExp(`tools\\.(?:exec_command\\(\\{\\s*cmd\\s*:\\s*${STRING}|apply_patch\\(\\s*${STRING})`, 'g');
45
+
46
+ // One script can run several tools; each becomes its own call. Unknown scripts show as themselves.
47
+ function execScript(script) {
48
+ const found = [...script.matchAll(SCRIPT_CALLS)].map(([, cmd, patch]) =>
49
+ cmd !== undefined
50
+ ? { call: { tool: 'exec', target: parse(`"${cmd}"`) ?? cmd, error: null }, edits: [] }
51
+ : patchCall(parse(`"${patch}"`) ?? ''));
52
+ return found.length ? found : [{ call: { tool: 'exec', target: script, error: null }, edits: [] }];
53
+ }
54
+
55
+ // Returns [{call, edits}] for a tool record, or null when the record is not a tool call.
56
+ export function toolCalls(p) {
57
+ const plain = (target) => [{ call: { tool: p.name, target: String(target ?? ''), error: null }, edits: [] }];
58
+ if (p.type === 'custom_tool_call') return p.name === 'exec' ? execScript(String(p.input ?? '')) : plain(p.input);
59
+ if (p.type !== 'function_call') return null;
60
+ const args = parse(p.arguments) ?? {};
61
+ if (p.name === 'spawn_agent') return plain(args.task_name);
62
+ return legacyShell(p.name, args.command === undefined ? { command: p.arguments ?? '' } : args);
63
+ }
64
+
65
+ // Returns "exit N: output" for a failed command, or null.
66
+ export function failure(p) {
67
+ if (p.type === 'function_call_output') {
68
+ const result = parse(p.output);
69
+ const code = result?.metadata?.exit_code;
70
+ return code ? `exit ${code}: ${result.output ?? ''}` : null;
71
+ }
72
+ if (p.type !== 'custom_tool_call_output') return null;
73
+ const texts = Array.isArray(p.output) ? p.output.map((o) => o.text ?? '') : [String(p.output ?? '')];
74
+ const result = texts.map((t) => parse(t)).find((r) => r && 'exit_code' in r);
75
+ return result?.exit_code ? `exit ${result.exit_code}: ${result.output ?? ''}` : null;
76
+ }
@@ -0,0 +1,55 @@
1
+ // Reads a Codex transcript (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl).
2
+ import { eachLine, emptySession, stamp } from './lines.js';
3
+ import { toolCalls, failure } from './codex-tools.js';
4
+
5
+ // Codex injects these as user messages; the person never typed them.
6
+ const INJECTED = /^\s*(<environment_context>|<user_instructions>|<permissions instructions>|# AGENTS\.md instructions)/;
7
+
8
+ function messageText(content) {
9
+ return Array.isArray(content) ? content.map((c) => c.text ?? '').join('\n') : String(content ?? '');
10
+ }
11
+
12
+ export async function readSession(path) {
13
+ const session = emptySession();
14
+ const calls = new Map(); // call_id -> last toolCall, so outputs can mark errors
15
+ // Older Codex logs turns as event_msg records and mirrors them as response_items; newer Codex only has the response_items.
16
+ const events = { user: [], assistant: 0 };
17
+ const items = { user: [], assistant: 0 };
18
+ let usage = null;
19
+
20
+ await eachLine(path, (line) => {
21
+ const p = line.payload ?? {};
22
+ stamp(session, line.timestamp);
23
+ if (line.type === 'session_meta' || line.type === 'turn_context') session.cwd ??= p.cwd ?? null;
24
+ if (line.type === 'event_msg') {
25
+ if (p.type === 'user_message') events.user.push(String(p.message ?? ''));
26
+ else if (p.type === 'agent_message') events.assistant++;
27
+ else if (p.type === 'token_count' && p.info?.total_token_usage) {
28
+ const total = p.info.total_token_usage;
29
+ usage = { input: total.input_tokens ?? 0, output: total.output_tokens ?? 0 };
30
+ }
31
+ }
32
+ if (line.type !== 'response_item') return;
33
+
34
+ if (p.type === 'message') {
35
+ const text = messageText(p.content);
36
+ if (p.role === 'user' && !INJECTED.test(text)) items.user.push(text);
37
+ else if (p.role === 'assistant') items.assistant++;
38
+ return;
39
+ }
40
+ const error = failure(p);
41
+ if (error && calls.has(p.call_id)) calls.get(p.call_id).error = error;
42
+ for (const { call, edits } of toolCalls(p) ?? []) {
43
+ session.toolCalls.push(call);
44
+ session.edits.push(...edits);
45
+ calls.set(p.call_id, call);
46
+ if (call.tool === 'spawn_agent') session.subagents++;
47
+ }
48
+ });
49
+
50
+ const turns = events.user.length || events.assistant ? events : items;
51
+ session.userMessages = turns.user;
52
+ session.turns = { user: turns.user.length, assistant: turns.assistant };
53
+ session.usage = usage;
54
+ return session;
55
+ }
@@ -0,0 +1,33 @@
1
+ // Shared helpers for the transcript readers: stream JSONL and count lines in edit text.
2
+ import { createReadStream } from 'node:fs';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ // Calls fn on each parsed line; skips lines that are not JSON. Streams, so 100MB+ transcripts stay cheap.
6
+ export async function eachLine(path, fn) {
7
+ const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
8
+ for await (const line of lines) {
9
+ let entry;
10
+ try { entry = JSON.parse(line); } catch { continue; }
11
+ if (entry && typeof entry === 'object') fn(entry);
12
+ }
13
+ }
14
+
15
+ // "a\nb\n" is two lines; "" is none.
16
+ export function countLines(text) {
17
+ if (typeof text !== 'string' || text === '') return 0;
18
+ return text.replace(/\n$/, '').split('\n').length;
19
+ }
20
+
21
+ export function emptySession() {
22
+ return {
23
+ cwd: null, start: null, end: null, userMessages: [], toolCalls: [], edits: [],
24
+ usage: null, turns: { user: 0, assistant: 0 }, subagents: 0,
25
+ };
26
+ }
27
+
28
+ // Keeps the first and last timestamp seen.
29
+ export function stamp(session, timestamp) {
30
+ if (!timestamp) return;
31
+ session.start ??= timestamp;
32
+ session.end = timestamp;
33
+ }
@@ -0,0 +1,62 @@
1
+ // Reads a Pi transcript (~/.pi/agent/sessions/<cwd-slug>/<time>_<id>.jsonl).
2
+ import { eachLine, countLines, emptySession, stamp } from './lines.js';
3
+
4
+ const SUBAGENT_TOOLS = new Set(['subagent', 'delegate', 'task']);
5
+
6
+ function target(args = {}) {
7
+ return args.path ?? args.command ?? args.pattern ?? args.url ?? '';
8
+ }
9
+
10
+ function edit(name, args = {}) {
11
+ if (name === 'write') return { file: args.path, added: countLines(args.content), removed: 0 };
12
+ if (name !== 'edit') return null;
13
+ const edits = args.edits ?? [{ oldText: args.oldText, newText: args.newText }];
14
+ return {
15
+ file: args.path,
16
+ added: edits.reduce((sum, e) => sum + countLines(e.newText), 0),
17
+ removed: edits.reduce((sum, e) => sum + countLines(e.oldText), 0),
18
+ };
19
+ }
20
+
21
+ const text = (content) => (Array.isArray(content) ? content.map((c) => c.text ?? '').join(' ') : String(content ?? ''));
22
+
23
+ export async function readSession(path) {
24
+ const session = emptySession();
25
+ const calls = new Map(); // toolCall id -> toolCall, so results can mark errors
26
+ const usage = { input: 0, output: 0 };
27
+ let hasUsage = false;
28
+
29
+ await eachLine(path, (line) => {
30
+ if (line.type === 'session') session.cwd ??= line.cwd ?? null;
31
+ if (line.type !== 'message' || !line.message) return;
32
+ const message = line.message;
33
+ stamp(session, line.timestamp);
34
+
35
+ if (message.role === 'user') {
36
+ session.turns.user++;
37
+ session.userMessages.push(text(message.content));
38
+ } else if (message.role === 'toolResult') {
39
+ const call = calls.get(message.toolCallId);
40
+ if (call && message.isError) call.error = text(message.content) || 'error';
41
+ } else if (message.role === 'assistant') {
42
+ session.turns.assistant++;
43
+ if (message.usage) {
44
+ hasUsage = true;
45
+ usage.input += message.usage.input ?? 0;
46
+ usage.output += message.usage.output ?? 0;
47
+ }
48
+ for (const item of message.content ?? []) {
49
+ if (item.type !== 'toolCall') continue;
50
+ const call = { tool: item.name, target: String(target(item.arguments)), error: null };
51
+ session.toolCalls.push(call);
52
+ calls.set(item.id, call);
53
+ if (SUBAGENT_TOOLS.has(item.name)) session.subagents++;
54
+ const change = edit(item.name, item.arguments);
55
+ if (change) session.edits.push(change);
56
+ }
57
+ }
58
+ });
59
+
60
+ session.usage = hasUsage ? usage : null;
61
+ return session;
62
+ }
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "agentic-retro",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "agentic-retrospective: reviews finished agent coding sessions and proposes improvements",
5
+ "type": "module",
6
+ "bin": { "agentic-retro": "cli/agentic-retro.js" },
7
+ "files": ["cli/", "skills/"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": { "test": "node --test test/*.test.js" },
10
+ "license": "MIT",
11
+ "repository": "github:ac8318740/agentic-retro"
12
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: retro
3
+ description: Thin wrapper that runs `agentic-retro stats` and explains the result in plain words. Invoke when the user asks for a retrospective, or wants to see how their agentic-retro proposals are landing.
4
+ ---
5
+
6
+ # Retro
7
+
8
+ Run `agentic-retro stats` and explain the result in plain words.
9
+
10
+ ## Run it
11
+
12
+ ```
13
+ agentic-retro stats
14
+ ```
15
+
16
+ This reads the ledger (every proposal's outcome: accepted, rejected, or expired) and the metrics stored per session.
17
+
18
+ ## Explain the result
19
+
20
+ Cover two things, both from `agentic-retro stats`:
21
+
22
+ - **Accepted share per category** – of every proposal in `clarity`, `agent-setup`, and `repo-for-agents`, the share the user accepted
23
+ - A low share in one category means the user keeps rejecting that kind of change
24
+ - **Tokens per line of code over time** – how many tokens each batch spends per line changed, tracked across batches
25
+ - A rising trend means sessions are costing more for the same amount of code
26
+
27
+ Keep the explanation short.
28
+
29
+ - Lead with the number or trend that matters most
30
+ - Example: "Agent-setup proposals: 80% accepted. Clarity proposals: 20% accepted"
31
+ - Say what it suggests
32
+ - Example: "Agents keep proposing clarity changes you reject - consider turning that category off"
33
+ - Skip any category with no proposals yet
34
+
35
+ ## Why this matters
36
+
37
+ Batch agents read the accepted share before proposing changes. They propose less of what the user keeps rejecting. This skill shows the user the same numbers.