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.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/cli/agentic-retro.js +85 -0
- package/cli/lib/commands.js +89 -0
- package/cli/lib/config.js +56 -0
- package/cli/lib/digest.js +107 -0
- package/cli/lib/ledger.js +78 -0
- package/cli/lib/notice.js +36 -0
- package/cli/lib/outcomes.js +46 -0
- package/cli/lib/paths.js +23 -0
- package/cli/lib/pi-extension.ts +30 -0
- package/cli/lib/pi.js +15 -0
- package/cli/lib/queue.js +61 -0
- package/cli/lib/runner.js +48 -0
- package/cli/lib/setup.js +52 -0
- package/cli/lib/stats.js +57 -0
- package/cli/lib/transcripts/claude.js +84 -0
- package/cli/lib/transcripts/codex-tools.js +76 -0
- package/cli/lib/transcripts/codex.js +55 -0
- package/cli/lib/transcripts/lines.js +33 -0
- package/cli/lib/transcripts/pi.js +62 -0
- package/package.json +10 -4
- package/skills/retro/SKILL.md +37 -0
- package/skills/review/SKILL.md +55 -0
- package/skills/run/SKILL.md +133 -0
- package/skills/run/agent-setup.md +72 -0
- package/skills/run/clarity.md +74 -0
- package/skills/run/repo-for-agents.md +74 -0
- package/skills/run/writing-proposals.md +101 -0
- package/skills/setup/SKILL.md +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Coote
|
|
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
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agentic-retro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
agentic-retro ("agentic-retrospective") learns from your finished coding sessions and proposes improvements, as a plugin for Claude Code, Codex, and Pi.
|
|
4
|
+
|
|
5
|
+
## How a batch runs
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TD
|
|
9
|
+
A["Session ends<br/>(hook in each harness)"] --> B["CLI adds session to queue"]
|
|
10
|
+
B --> C{"5 sessions queued for this repo?"}
|
|
11
|
+
C -->|no| Z["Stop"]
|
|
12
|
+
C -->|yes| D["CLI writes digests and metrics,<br/>and updates past proposal outcomes"]
|
|
13
|
+
D --> E["Runner starts one lead agent;<br/>it runs one subagent per ticked category<br/>(Claude, Codex, Pi, or herdr pane)"]
|
|
14
|
+
E --> F["Subagents write proposals,<br/>skipping anything rejected or expired before"]
|
|
15
|
+
F --> G["Output: one PR per repo, or a report file"]
|
|
16
|
+
G --> H["Next session start: hook prints<br/>'3 new proposals: link'"]
|
|
17
|
+
H --> I["User reviews proposals"]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- A session-end hook in each harness queues the session (`agentic-retro enqueue`)
|
|
21
|
+
- Once a repo has 5 queued sessions (configurable), the CLI starts a batch
|
|
22
|
+
- One lead agent runs per batch, and it runs one subagent per ticked category: code clarity, agent setup, repo changes
|
|
23
|
+
- Agents write proposals as a PR (one per repo) or a report file, per your setup choice
|
|
24
|
+
|
|
25
|
+
## The life of a proposal
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
stateDiagram-v2
|
|
29
|
+
[*] --> open
|
|
30
|
+
open --> accepted: merged, or user says yes
|
|
31
|
+
open --> rejected: closed, removed, or user says no
|
|
32
|
+
open --> expired: 3 batches with no action
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Every proposal starts `open` in the ledger (`~/.agentic-retro/ledger.json`)
|
|
36
|
+
- It becomes `accepted` or `rejected` once you act on it, or `expired` after 3 batches with no action (configurable)
|
|
37
|
+
- agentic-retro never re-proposes a rejected or expired item
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
You need Node.js 20 or later.
|
|
42
|
+
|
|
43
|
+
Add the marketplace and install the plugin:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
/plugin marketplace add ac8318740/ac-agentic-coding
|
|
47
|
+
/plugin install agentic-retro@ac-agentic-coding
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Install the CLI the plugin's hooks call into:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
npm install -g agentic-retro
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Run setup, either from the CLI or with the bundled skill:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
agentic-retro setup
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
/agentic-retro:setup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- The `setup` skill asks every question itself and writes each answer with `agentic-retro config set`
|
|
67
|
+
- `agentic-retro setup` runs the same questions as plain interactive prompts – the human alternative to the skill
|
|
68
|
+
|
|
69
|
+
Both write the same `~/.agentic-retro/config.json`:
|
|
70
|
+
|
|
71
|
+
- batch size and grouping
|
|
72
|
+
- runner harness and permissions (`auto`, `scoped`, or `bypass`)
|
|
73
|
+
- which categories run, and the output mode (PR or report)
|
|
74
|
+
- where agent-setup proposals land
|
|
75
|
+
|
|
76
|
+
Using Pi? Run `agentic-retro install-pi` once. Pi has no hook config, so this extension queues finished Pi sessions and shows the notice.
|
|
77
|
+
|
|
78
|
+
Using Codex? It asks you to trust the new session-end hook the first time a session ends after install.
|
|
79
|
+
|
|
80
|
+
## Skills
|
|
81
|
+
|
|
82
|
+
- `run` – the batch agent's own skill; the runner starts it headless once a batch fills, and you can also invoke it to rerun a batch
|
|
83
|
+
- `review` – walks a report, applies each change you accept, and marks the ledger
|
|
84
|
+
- `setup` – drives the conversational config walkthrough
|
|
85
|
+
- `retro` – reads ledger outcomes and metrics over time into a retrospective
|
|
86
|
+
|
|
87
|
+
## CLI commands
|
|
88
|
+
|
|
89
|
+
- `agentic-retro setup` – interactive config, the human alternative to the setup skill
|
|
90
|
+
- `agentic-retro config list | get <key> | set <key> <value>` – read or write one config key
|
|
91
|
+
- `agentic-retro enqueue` – add a finished session to the queue (hooks call this)
|
|
92
|
+
- `agentic-retro propose` – record a new proposal (batch agents call this)
|
|
93
|
+
- `agentic-retro link <id>` – attach a PR and commit to a proposal
|
|
94
|
+
- `agentic-retro mark <id> <accepted|rejected>` – record a decision on a proposal
|
|
95
|
+
- `agentic-retro ledger` – list proposals, filtered by status
|
|
96
|
+
- `agentic-retro sync` – refresh proposal outcomes from GitHub, then expire stale ones
|
|
97
|
+
- `agentic-retro notice` – the session-start one-line proposal count (hooks call this)
|
|
98
|
+
- `agentic-retro stats` – acceptance rate per category, and tokens per changed line
|
|
99
|
+
- `agentic-retro install-pi` – write the Pi extension that stands in for Pi's missing hooks
|
|
100
|
+
|
|
101
|
+
## State
|
|
102
|
+
|
|
103
|
+
Everything lives under `~/.agentic-retro/` (override with `AGENTIC_RETRO_HOME`): `config.json`, `queue.json`, `ledger.json`, `metrics.jsonl`, per-batch digests, and report files.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agentic-retro: parses argv and routes each command to its module.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { loadConfig, getKey, setKey } from './lib/config.js';
|
|
6
|
+
import { setup } from './lib/setup.js';
|
|
7
|
+
import { enqueue } from './lib/queue.js';
|
|
8
|
+
import { propose, mark, link, listProposals, formatProposal } from './lib/ledger.js';
|
|
9
|
+
import { syncOutcomes } from './lib/outcomes.js';
|
|
10
|
+
import { notice } from './lib/notice.js';
|
|
11
|
+
import { stats } from './lib/stats.js';
|
|
12
|
+
import { installPi } from './lib/pi.js';
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage: agentic-retro <command>
|
|
15
|
+
setup
|
|
16
|
+
config list | get <key> | set <key> <value>
|
|
17
|
+
enqueue [--harness <h>] [--session <id> --transcript <path> --cwd <dir>] (or hook JSON on stdin)
|
|
18
|
+
propose --repo <path> --category <c> [--risk low|high] --title <t> [--pr <pr> --commit <msg>]
|
|
19
|
+
mark <id> <accepted|rejected>
|
|
20
|
+
link <id> [--pr <url>] [--commit <sha>]
|
|
21
|
+
ledger [--status <s>[,<s>...]]
|
|
22
|
+
notice [--json]
|
|
23
|
+
stats [--json]
|
|
24
|
+
sync
|
|
25
|
+
install-pi`;
|
|
26
|
+
|
|
27
|
+
const FLAGS = ['harness', 'session', 'transcript', 'cwd', 'repo', 'category', 'risk', 'title', 'pr', 'commit', 'status'];
|
|
28
|
+
const options = Object.fromEntries(FLAGS.map((flag) => [flag, { type: 'string' }]));
|
|
29
|
+
options.json = { type: 'boolean' };
|
|
30
|
+
|
|
31
|
+
function config([action, key, value]) {
|
|
32
|
+
const current = loadConfig();
|
|
33
|
+
if (action === 'list') return console.log(JSON.stringify(current, null, 2));
|
|
34
|
+
if (action === 'set') return setKey(key, value);
|
|
35
|
+
if (action !== 'get') throw new Error(USAGE);
|
|
36
|
+
const found = getKey(current, key);
|
|
37
|
+
if (found === undefined) throw new Error(`unknown config key: ${key}`);
|
|
38
|
+
console.log(typeof found === 'object' ? JSON.stringify(found) : String(found));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Harness hooks pipe JSON like {"session_id", "transcript_path", "cwd"} on stdin.
|
|
42
|
+
// Bad JSON reads as no hook: a session-end hook must not fail the session.
|
|
43
|
+
function readHook() {
|
|
44
|
+
if (process.stdin.isTTY) return {};
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(0, 'utf8')) ?? {};
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function enqueueCommand(flags) {
|
|
53
|
+
const hook = flags.session ? {} : readHook();
|
|
54
|
+
if (!flags.session && !hook.session_id) return; // nothing to queue, and a hook must not fail the session
|
|
55
|
+
return enqueue({
|
|
56
|
+
harness: flags.harness,
|
|
57
|
+
sessionId: flags.session ?? hook.session_id,
|
|
58
|
+
transcript: flags.transcript ?? hook.transcript_path,
|
|
59
|
+
cwd: flags.cwd ?? hook.cwd,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
65
|
+
if (command === 'help' || command === '--help') return console.log(USAGE);
|
|
66
|
+
const { values: flags, positionals } = parseArgs({ args: rest, options, allowPositionals: true });
|
|
67
|
+
|
|
68
|
+
if (command === 'setup') return setup();
|
|
69
|
+
if (command === 'config') return config(positionals);
|
|
70
|
+
if (command === 'enqueue') return enqueueCommand(flags);
|
|
71
|
+
if (command === 'propose') return console.log(propose(flags));
|
|
72
|
+
if (command === 'mark') return mark(...positionals);
|
|
73
|
+
if (command === 'link') return link(positionals[0], flags);
|
|
74
|
+
if (command === 'ledger') return listProposals(flags.status).forEach((p) => console.log(formatProposal(p)));
|
|
75
|
+
if (command === 'sync') return syncOutcomes();
|
|
76
|
+
if (command === 'notice') return notice(flags);
|
|
77
|
+
if (command === 'stats') return stats(flags);
|
|
78
|
+
if (command === 'install-pi') return installPi();
|
|
79
|
+
throw new Error(USAGE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main().catch((error) => {
|
|
83
|
+
console.error(error.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Builds the command that starts the batch agent in each harness.
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { stateDir } from './paths.js';
|
|
7
|
+
|
|
8
|
+
const ENV = { AGENTIC_RETRO_RUN: '1' };
|
|
9
|
+
const SCOPED_TOOLS = 'Bash(git:*),Bash(gh:*),Bash(agentic-retro:*)';
|
|
10
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
|
+
export const PANE_ID = '<pane_id>';
|
|
12
|
+
|
|
13
|
+
const prompt = (n) => `Use the agentic-retro run skill on batch ${n} in this folder.`;
|
|
14
|
+
// opus is a Claude model name, not a codex/pi one, so for those harnesses treat it like
|
|
15
|
+
// "default" (no flag) rather than passing it through unrecognized. Claude keeps it as-is.
|
|
16
|
+
const modelFlag = (flag, model, isClaude = false) =>
|
|
17
|
+
(model === 'default' || (!isClaude && model === 'opus') ? [] : [flag, model]);
|
|
18
|
+
const expandHome = (path) => (path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path);
|
|
19
|
+
|
|
20
|
+
const claudePermissions = {
|
|
21
|
+
auto: ['--permission-mode', 'auto'],
|
|
22
|
+
scoped: ['--permission-mode', 'acceptEdits', '--allowedTools', SCOPED_TOOLS],
|
|
23
|
+
bypass: ['--permission-mode', 'bypassPermissions'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const codexPermissions = {
|
|
27
|
+
auto: ['-s', 'workspace-write', '--approve-for-me'],
|
|
28
|
+
scoped: ['-s', 'workspace-write'],
|
|
29
|
+
bypass: ['--dangerously-bypass-approvals-and-sandbox'],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The batch agent edits the sessions' repos and the user's editable paths.
|
|
33
|
+
// A headless run cannot ask for access, so each folder is granted up front.
|
|
34
|
+
function sessionsOf(batchDir) {
|
|
35
|
+
const manifest = join(batchDir, 'batch.json');
|
|
36
|
+
return existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')).sessions : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function workDirs(config, sessions) {
|
|
40
|
+
const paths = [...sessions.map((s) => s.repo), ...(config.editable ?? []).map(expandHome)];
|
|
41
|
+
return [...new Set(paths.filter(Boolean))];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const addDirs = (dirs) => dirs.flatMap((dir) => ['--add-dir', dir]);
|
|
45
|
+
|
|
46
|
+
// The prompt sits right after -p: --allowedTools and --add-dir take variadic lists that would swallow a later prompt.
|
|
47
|
+
function claudeArgs({ model, permissions }, n, dirs) {
|
|
48
|
+
return ['-p', prompt(n), ...modelFlag('--model', model, true), ...claudePermissions[permissions], ...addDirs(dirs)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Codex's sandbox blocks the network by default, and the run skill needs gh.
|
|
52
|
+
function codexArgs({ model, permissions }, n, dirs, batchDir) {
|
|
53
|
+
const network = permissions === 'bypass' ? [] : ['-c', 'sandbox_workspace_write.network_access=true'];
|
|
54
|
+
return ['exec', '-c', 'shell_environment_policy.inherit=all', ...network, ...modelFlag('-m', model),
|
|
55
|
+
...codexPermissions[permissions], ...addDirs([stateDir(), ...dirs]), '--skip-git-repo-check', '-C', batchDir, prompt(n)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pi never asks before a tool call, so every permissions mode runs the same way.
|
|
59
|
+
// Pi has no plugin install, so it loads the run skill from this package.
|
|
60
|
+
function piArgs({ model }, n) {
|
|
61
|
+
return ['-p', ...modelFlag('--model', model), '--skill', join(PACKAGE_ROOT, 'skills', 'run'), prompt(n)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const shellQuote = (arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
|
|
65
|
+
|
|
66
|
+
// herdr types the command into a new pane's shell, so it gets one quoted string (see #11).
|
|
67
|
+
function herdrSteps(runner, dir, n, dirs) {
|
|
68
|
+
const line = ['claude', ...claudeArgs(runner, n, dirs)].map(shellQuote).join(' ');
|
|
69
|
+
return [
|
|
70
|
+
{ file: 'herdr', args: ['workspace', 'create', '--cwd', dir, '--env', 'AGENTIC_RETRO_RUN=1', '--no-focus'], env: ENV, cwd: dir },
|
|
71
|
+
{ file: 'herdr', args: ['pane', 'run', PANE_ID, line], env: ENV, cwd: dir },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const harnesses = {
|
|
76
|
+
claude: (runner, n, dirs) => ({ file: 'claude', args: claudeArgs(runner, n, dirs) }),
|
|
77
|
+
codex: (runner, n, dirs, batchDir) => ({ file: 'codex', args: codexArgs(runner, n, dirs, batchDir) }),
|
|
78
|
+
pi: (runner, n) => ({ file: 'pi', args: piArgs(runner, n) }),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Returns { file, args, env, cwd }, or for herdr a list of such steps.
|
|
82
|
+
export function buildCommand(config, batchDir, n, sessions = sessionsOf(batchDir)) {
|
|
83
|
+
const runner = { permissions: 'auto', ...config.runner };
|
|
84
|
+
const dirs = workDirs(config, sessions);
|
|
85
|
+
if (runner.harness === 'herdr') return herdrSteps(runner, batchDir, n, dirs);
|
|
86
|
+
const build = harnesses[runner.harness];
|
|
87
|
+
if (!build) throw new Error(`unknown runner.harness: ${runner.harness}`);
|
|
88
|
+
return { ...build(runner, n, dirs, batchDir), env: ENV, cwd: batchDir };
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Config: defaults merged with config.json, read and written through dotted keys.
|
|
2
|
+
import { readJson, writeJson } from './paths.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
batch: { size: 5, group: 'repo' },
|
|
6
|
+
runner: { harness: 'claude', model: 'opus', permissions: 'auto' },
|
|
7
|
+
categories: { clarity: true, 'agent-setup': true, 'repo-for-agents': true },
|
|
8
|
+
scope: 'touched',
|
|
9
|
+
output: 'pr',
|
|
10
|
+
autoMergeLowRisk: false,
|
|
11
|
+
expireAfterBatches: 3,
|
|
12
|
+
editable: [],
|
|
13
|
+
targets: { preset: '~/.claude/skills', note: '' },
|
|
14
|
+
notes: '',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
function merge(base, over) {
|
|
20
|
+
const result = { ...base };
|
|
21
|
+
for (const [key, value] of Object.entries(over)) {
|
|
22
|
+
result[key] = isObject(base[key]) && isObject(value) ? merge(base[key], value) : value;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
return merge(DEFAULTS, readJson('config.json', {}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveConfig(config) {
|
|
32
|
+
writeJson('config.json', config);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getKey(config, key) {
|
|
36
|
+
return key.split('.').reduce((node, part) => (isObject(node) ? node[part] : undefined), config);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// "5" -> 5, "true" -> true, '["a"]' -> ["a"]; keys whose default is text stay text.
|
|
40
|
+
export function parseValue(key, text) {
|
|
41
|
+
if (typeof getKey(DEFAULTS, key) === 'string') return text;
|
|
42
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setKey(key, text) {
|
|
46
|
+
const current = getKey(DEFAULTS, key);
|
|
47
|
+
if (current === undefined) throw new Error(`unknown config key: ${key}`);
|
|
48
|
+
// Setting a whole section, e.g. "batch", would replace every key inside it.
|
|
49
|
+
if (isObject(current)) throw new Error(`${key} is a section; set one of its keys, e.g. ${key}.<key>`);
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
const parts = key.split('.');
|
|
52
|
+
const last = parts.pop();
|
|
53
|
+
const parent = parts.reduce((node, part) => node[part], config);
|
|
54
|
+
parent[last] = parseValue(key, text);
|
|
55
|
+
saveConfig(config);
|
|
56
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Writes a short markdown digest and one metrics.jsonl line per session in a batch.
|
|
2
|
+
import { mkdirSync, writeFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, basename } from 'node:path';
|
|
4
|
+
import { statePath } from './paths.js';
|
|
5
|
+
import * as claude from './transcripts/claude.js';
|
|
6
|
+
import * as codex from './transcripts/codex.js';
|
|
7
|
+
import * as pi from './transcripts/pi.js';
|
|
8
|
+
|
|
9
|
+
const readers = { claude, codex, pi };
|
|
10
|
+
|
|
11
|
+
// Caps keep a digest under 200 lines however long the session ran.
|
|
12
|
+
const MAX_MESSAGE = 500;
|
|
13
|
+
const MAX_TARGET = 120;
|
|
14
|
+
const MAX_USER = 50;
|
|
15
|
+
const MAX_TIMELINE = 60;
|
|
16
|
+
const MAX_ERRORS = 20;
|
|
17
|
+
const MAX_FILES = 30;
|
|
18
|
+
|
|
19
|
+
const oneLine = (text, max) => {
|
|
20
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
21
|
+
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The first `max` items as lines, plus a note for the rest.
|
|
25
|
+
function capped(items, max, format) {
|
|
26
|
+
const lines = items.slice(0, max).map(format);
|
|
27
|
+
if (items.length > max) lines.push(`- ... ${items.length - max} more`);
|
|
28
|
+
return lines.length ? lines : ['- none'];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readTranscript(harness, transcript) {
|
|
32
|
+
const reader = readers[harness];
|
|
33
|
+
if (!reader || !transcript || !existsSync(transcript)) return null;
|
|
34
|
+
try {
|
|
35
|
+
return await reader.readSession(transcript);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function metricsOf(n, s, parsed) {
|
|
42
|
+
const sum = (key) => (parsed ? parsed.edits.reduce((total, e) => total + e[key], 0) : null);
|
|
43
|
+
return {
|
|
44
|
+
batch: n, harness: s.harness, sessionId: s.sessionId, repo: s.repo ?? null,
|
|
45
|
+
userTurns: parsed?.turns.user ?? null,
|
|
46
|
+
assistantTurns: parsed?.turns.assistant ?? null,
|
|
47
|
+
inputTokens: parsed?.usage?.input ?? null,
|
|
48
|
+
outputTokens: parsed?.usage?.output ?? null,
|
|
49
|
+
linesAdded: sum('added'),
|
|
50
|
+
linesRemoved: sum('removed'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sums edits per file, in the order files were first touched.
|
|
55
|
+
function filesEdited(edits) {
|
|
56
|
+
const files = new Map();
|
|
57
|
+
for (const { file, added, removed } of edits) {
|
|
58
|
+
const total = files.get(file) ?? { file, added: 0, removed: 0 };
|
|
59
|
+
files.set(file, { file, added: total.added + added, removed: total.removed + removed });
|
|
60
|
+
}
|
|
61
|
+
return [...files.values()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function digestOf(s, parsed, m) {
|
|
65
|
+
const header = [
|
|
66
|
+
`# Session ${s.sessionId}`, '',
|
|
67
|
+
`- harness: ${s.harness}`,
|
|
68
|
+
`- repo: ${s.repo ?? 'unknown'}`,
|
|
69
|
+
`- cwd: ${parsed?.cwd ?? s.cwd ?? 'unknown'}`,
|
|
70
|
+
`- transcript: ${s.transcript ?? 'none'}`,
|
|
71
|
+
];
|
|
72
|
+
if (!parsed) return [...header, '', 'Transcript missing: no transcript was found for this session, so there is no digest or metrics.'];
|
|
73
|
+
|
|
74
|
+
const show = (v) => (v === null ? 'n/a' : v);
|
|
75
|
+
const calls = parsed.toolCalls;
|
|
76
|
+
const errors = calls.filter((c) => c.error);
|
|
77
|
+
return [
|
|
78
|
+
...header,
|
|
79
|
+
`- start: ${parsed.start ?? 'unknown'}`,
|
|
80
|
+
`- end: ${s.endedAt ?? parsed.end ?? 'unknown'}`,
|
|
81
|
+
`- turns: ${m.userTurns} user, ${m.assistantTurns} assistant`,
|
|
82
|
+
`- tokens: ${show(m.inputTokens)} in, ${show(m.outputTokens)} out`,
|
|
83
|
+
`- lines: +${m.linesAdded} -${m.linesRemoved}`,
|
|
84
|
+
`- subagents: ${parsed.subagents}`,
|
|
85
|
+
'', `## User messages (${parsed.userMessages.length})`, '',
|
|
86
|
+
...capped(parsed.userMessages, MAX_USER, (text, i) => `${i + 1}. ${oneLine(text, MAX_MESSAGE)}`),
|
|
87
|
+
'', `## Tool calls (${calls.length})`, '',
|
|
88
|
+
...capped(calls, MAX_TIMELINE, (c) => `- ${c.tool} ${oneLine(c.target, MAX_TARGET)}${c.error ? ' [error]' : ''}`),
|
|
89
|
+
'', `## Errors (${errors.length})`, '',
|
|
90
|
+
...capped(errors, MAX_ERRORS, (c) => `- ${c.tool} ${oneLine(c.target, 60)}: ${oneLine(c.error, MAX_TARGET)}`),
|
|
91
|
+
'', '## Files edited', '',
|
|
92
|
+
...capped(filesEdited(parsed.edits), MAX_FILES, (f) => `- ${basename(f.file ?? '?')} +${f.added} -${f.removed} (${f.file})`),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function writeBatchDigests(n, sessions) {
|
|
97
|
+
const metricsPath = statePath('metrics.jsonl');
|
|
98
|
+
mkdirSync(dirname(metricsPath), { recursive: true });
|
|
99
|
+
for (const s of sessions) {
|
|
100
|
+
const parsed = await readTranscript(s.harness, s.transcript);
|
|
101
|
+
const m = metricsOf(n, s, parsed);
|
|
102
|
+
const path = statePath('batches', String(n), `${s.sessionId}.md`);
|
|
103
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
104
|
+
writeFileSync(path, digestOf(s, parsed, m).join('\n') + '\n');
|
|
105
|
+
appendFileSync(metricsPath, JSON.stringify(m) + '\n');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The ledger: every proposal a batch agent makes, and what became of it.
|
|
2
|
+
import { readJson, writeJson } from './paths.js';
|
|
3
|
+
|
|
4
|
+
export const CATEGORIES = ['clarity', 'agent-setup', 'repo-for-agents'];
|
|
5
|
+
const RISKS = ['low', 'high'];
|
|
6
|
+
const MARKABLE = ['accepted', 'rejected'];
|
|
7
|
+
|
|
8
|
+
export function loadLedger() {
|
|
9
|
+
return readJson('ledger.json', { nextId: 1, batchCount: 0, proposals: [] });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function saveLedger(ledger) {
|
|
13
|
+
writeJson('ledger.json', ledger);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setStatus(proposal, status) {
|
|
17
|
+
proposal.status = status;
|
|
18
|
+
proposal.statusAt = new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function propose({ repo, category, risk, title, pr, commit }) {
|
|
22
|
+
if (!repo || !title) throw new Error('propose needs --repo and --title');
|
|
23
|
+
if (!CATEGORIES.includes(category)) throw new Error(`unknown category: ${category}`);
|
|
24
|
+
if (risk && category !== 'agent-setup') throw new Error('only agent-setup proposals carry a risk');
|
|
25
|
+
if (risk && !RISKS.includes(risk)) throw new Error(`risk must be low or high: ${risk}`);
|
|
26
|
+
|
|
27
|
+
const ledger = loadLedger();
|
|
28
|
+
const id = `P${ledger.nextId++}`;
|
|
29
|
+
ledger.proposals.push({
|
|
30
|
+
id, batch: ledger.batchCount, repo, category, risk: risk ?? null, title, status: 'open',
|
|
31
|
+
createdAt: new Date().toISOString(), statusAt: null, pr: pr ?? null, commit: commit ?? null,
|
|
32
|
+
});
|
|
33
|
+
saveLedger(ledger);
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findProposal(ledger, id) {
|
|
38
|
+
const proposal = ledger.proposals.find((p) => p.id === id);
|
|
39
|
+
if (!proposal) throw new Error(`no proposal ${id}`);
|
|
40
|
+
return proposal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mark(id, status) {
|
|
44
|
+
if (!MARKABLE.includes(status)) throw new Error(`status must be accepted or rejected: ${status}`);
|
|
45
|
+
const ledger = loadLedger();
|
|
46
|
+
setStatus(findProposal(ledger, id), status);
|
|
47
|
+
saveLedger(ledger);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The run skill links a proposal to its PR and commit once they exist.
|
|
51
|
+
export function link(id, { pr, commit }) {
|
|
52
|
+
const ledger = loadLedger();
|
|
53
|
+
const proposal = findProposal(ledger, id);
|
|
54
|
+
if (pr) proposal.pr = pr;
|
|
55
|
+
if (commit) proposal.commit = commit;
|
|
56
|
+
saveLedger(ledger);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Each batch run takes the next number; proposals remember it so they can expire.
|
|
60
|
+
export function nextBatch() {
|
|
61
|
+
const ledger = loadLedger();
|
|
62
|
+
ledger.batchCount += 1;
|
|
63
|
+
saveLedger(ledger);
|
|
64
|
+
return ledger.batchCount;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// status may be a comma list, e.g. "rejected,expired".
|
|
68
|
+
export function listProposals(status) {
|
|
69
|
+
const { proposals } = loadLedger();
|
|
70
|
+
if (!status) return proposals;
|
|
71
|
+
const wanted = status.split(',');
|
|
72
|
+
return proposals.filter((p) => wanted.includes(p.status));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatProposal(p) {
|
|
76
|
+
const risk = p.risk ? ` (${p.risk} risk)` : '';
|
|
77
|
+
return `${p.id} ${p.status} ${p.category}${risk} ${p.title} [${p.repo}]`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Session-start notice: one line naming the proposals not yet shown.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { stateDir, statePath, readJson, writeJson } from './paths.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { listProposals } from './ledger.js';
|
|
6
|
+
|
|
7
|
+
function linkFor(proposal, output) {
|
|
8
|
+
return output === 'report' ? statePath('reports', `${proposal.batch}.md`) : proposal.pr;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// notice.json remembers which open proposals were already shown with their link.
|
|
12
|
+
// In PR mode a proposal without a PR stays new, so a later notice can name its PR.
|
|
13
|
+
export function noticeLine() {
|
|
14
|
+
if (process.env.AGENTIC_RETRO_RUN === '1' || !existsSync(stateDir())) return '';
|
|
15
|
+
|
|
16
|
+
const { output } = loadConfig();
|
|
17
|
+
const { noticed = [] } = readJson('notice.json', {});
|
|
18
|
+
const open = listProposals('open');
|
|
19
|
+
const fresh = open.filter((p) => !noticed.includes(p.id));
|
|
20
|
+
if (!fresh.length) return '';
|
|
21
|
+
|
|
22
|
+
const shown = open.filter((p) => noticed.includes(p.id) || (fresh.includes(p) && linkFor(p, output)));
|
|
23
|
+
writeJson('notice.json', { noticed: shown.map((p) => p.id) });
|
|
24
|
+
const links = [...new Set(fresh.map((p) => linkFor(p, output)).filter(Boolean))];
|
|
25
|
+
const count = `${fresh.length} new proposal${fresh.length === 1 ? '' : 's'}`;
|
|
26
|
+
if (!links.length) return `agentic-retro: ${count} – ask your agent to review them`;
|
|
27
|
+
return `agentic-retro: ${count}: ${links.join(', ')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A session-start hook must never break the session, so any failure prints nothing.
|
|
31
|
+
export function notice({ json }) {
|
|
32
|
+
let line = '';
|
|
33
|
+
try { line = noticeLine(); } catch {}
|
|
34
|
+
if (!line) return;
|
|
35
|
+
console.log(json ? JSON.stringify({ systemMessage: line }) : line);
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Sync: settle open proposals from their PRs, then expire the stale ones.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { loadLedger, saveLedger, setStatus } from './ledger.js';
|
|
5
|
+
|
|
6
|
+
// A failed lookup (network, deleted repo) leaves the proposal open for the next sync.
|
|
7
|
+
function viewPr(pr) {
|
|
8
|
+
const gh = process.env.AGENTIC_RETRO_GH || 'gh';
|
|
9
|
+
try {
|
|
10
|
+
const output = execFileSync(gh, ['pr', 'view', pr, '--json', 'state,commits'], { encoding: 'utf8' });
|
|
11
|
+
return JSON.parse(output);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`agentic-retro: could not check ${pr}: ${error.message.split('\n')[0]}`);
|
|
14
|
+
return { state: 'OPEN', commits: [] };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Each proposal is its own commit with its ID in the headline, e.g. "P12: shorten the map skill".
|
|
19
|
+
function commitSurvived(view, id) {
|
|
20
|
+
const pattern = new RegExp(`\\b${id}\\b`);
|
|
21
|
+
return view.commits.some((c) => pattern.test(c.messageHeadline));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function outcome(view, id) {
|
|
25
|
+
if (view.state === 'MERGED') return commitSurvived(view, id) ? 'accepted' : 'rejected';
|
|
26
|
+
if (view.state === 'CLOSED') return 'rejected';
|
|
27
|
+
return 'open';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function syncOutcomes() {
|
|
31
|
+
const ledger = loadLedger();
|
|
32
|
+
const { expireAfterBatches } = loadConfig();
|
|
33
|
+
const open = () => ledger.proposals.filter((p) => p.status === 'open');
|
|
34
|
+
|
|
35
|
+
const views = new Map();
|
|
36
|
+
for (const p of open().filter((p) => p.pr)) {
|
|
37
|
+
if (!views.has(p.pr)) views.set(p.pr, viewPr(p.pr));
|
|
38
|
+
const status = outcome(views.get(p.pr), p.id);
|
|
39
|
+
if (status !== 'open') setStatus(p, status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const p of open()) {
|
|
43
|
+
if (ledger.batchCount - p.batch >= expireAfterBatches) setStatus(p, 'expired');
|
|
44
|
+
}
|
|
45
|
+
saveLedger(ledger);
|
|
46
|
+
}
|
package/cli/lib/paths.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Where state lives, and how to read and write its JSON files.
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function stateDir() {
|
|
7
|
+
return process.env.AGENTIC_RETRO_HOME || join(homedir(), '.agentic-retro');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function statePath(...parts) {
|
|
11
|
+
return join(stateDir(), ...parts);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readJson(name, fallback) {
|
|
15
|
+
const path = statePath(name);
|
|
16
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeJson(name, data) {
|
|
20
|
+
const path = statePath(name);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
23
|
+
}
|