@paxnax/berliner 0.4.0 → 0.5.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/README.md +7 -5
- package/dist/agents/claude.js +8 -4
- package/dist/agents/codex.js +8 -5
- package/dist/agents/opencode.js +2 -1
- package/dist/agents/read.js +3 -0
- package/dist/args.js +1 -0
- package/dist/client.js +6 -4
- package/dist/commands/body.js +19 -3
- package/dist/commands/hook.js +33 -6
- package/dist/commands/projects.js +37 -0
- package/dist/commands/report.js +5 -15
- package/dist/commands/setup.js +8 -8
- package/dist/commands/update.js +5 -3
- package/dist/commands/upgrade.js +29 -2
- package/dist/gh.js +28 -0
- package/dist/index.js +23 -1
- package/dist/setup/hooks.js +41 -20
- package/opencode/berliner.js +3 -1
- package/package.json +1 -1
- package/skill/SKILL.md +78 -10
package/README.md
CHANGED
|
@@ -21,9 +21,9 @@ With Bun, run `bunx --bun @paxnax/berliner@latest setup`. To install only the co
|
|
|
21
21
|
npx @paxnax/berliner@latest setup
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI
|
|
24
|
+
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
|
|
25
25
|
|
|
26
|
-
3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. Your agent adds the
|
|
26
|
+
3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. They also fill in a ticket from a Linear or Jira URL in your prompts while the session has none, and refresh up to 5 open pull requests the Berliner GitHub App doesn't track with your `gh` (skipped when `gh` is missing or signed out). When a session starts, the hook reminds your agent to name the session and report its ticket. Your agent adds the title, ticket, pull requests, coverage and evidence with `berliner report` and `berliner update`, as the skill tells it to. Codex runs a new hook only once you trust it: open `codex` once in a terminal and trust the Berliner hook.
|
|
27
27
|
4. On **Settings**, list the subscriptions you pay for. Overview then compares each one with the sessions that ran on it.
|
|
28
28
|
|
|
29
29
|
Without setup: create an API token on **Settings**, `export BERLINER_TOKEN=brl_...`, and save the output of `berliner skill` where your agent reads skills (for Claude Code, `~/.claude/skills/berliner/SKILL.md`). Your agent then reports each session with `berliner report` and the flags under [Commands](#commands).
|
|
@@ -115,10 +115,12 @@ Other commands:
|
|
|
115
115
|
- `berliner sessions [filters]`: list sessions. Filters: `--repo --model --project --ci --merge --since <N>d` or `--from --to`, plus `--limit` and `--json`.
|
|
116
116
|
- `berliner session <session-id>`: show one session.
|
|
117
117
|
- `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription.
|
|
118
|
+
- `berliner projects [--json]`: the project names in use, then each repository's default project.
|
|
119
|
+
- `berliner project [<name>] [--none] [--repo owner/name] [--json]`: with no name, show this repository's default project; with a name, save it; `--none` saves that the repository has no project. The repository comes from `git remote get-url origin` unless you pass `--repo`. A session's project is its own `--project`, else its repository's default, else its ticket's prefix (`SMTNC-3268` gives `SMTNC`). Until a repository has a default, `report` and `update` print a `Project: none saved` line, which asks your agent to suggest one to you.
|
|
118
120
|
- `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
|
|
119
121
|
- `berliner evidence public <id>`: let anyone with the link view one file, and print its public URL and Markdown line. `berliner evidence private <id>` makes it private again. `berliner session` shows the ids, and the URL of each public file.
|
|
120
122
|
- `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
|
|
121
|
-
- `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command and each
|
|
122
|
-
- `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never
|
|
123
|
+
- `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched.
|
|
124
|
+
- `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never fails and never makes the agent wait, and prints only the reminder when a session starts; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
|
|
123
125
|
|
|
124
|
-
`report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure. The other commands exit 1 on failure.
|
|
126
|
+
`report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure, including a file that could not be uploaded or made public; the session is still saved. The other commands exit 1 on failure.
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { addTokens, jsonl, num, Span } from "./read.js";
|
|
3
|
+
import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
|
|
4
4
|
// Subagent transcripts sit next to the session's: <project>/<session-id>/subagents/agent-*.jsonl.
|
|
5
5
|
async function subagentFiles(transcriptPath) {
|
|
6
6
|
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
@@ -24,11 +24,15 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
24
24
|
const responses = new Map();
|
|
25
25
|
const span = new Span();
|
|
26
26
|
let firstPrompt = null;
|
|
27
|
+
let taskUrl = null;
|
|
27
28
|
for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
|
|
28
29
|
for await (const e of jsonl(file)) {
|
|
29
30
|
span.add(e.timestamp);
|
|
30
|
-
if (file === transcriptPath)
|
|
31
|
-
|
|
31
|
+
if (file === transcriptPath) {
|
|
32
|
+
const prompt = promptText(e);
|
|
33
|
+
firstPrompt ??= prompt;
|
|
34
|
+
taskUrl ??= ticketUrl(prompt);
|
|
35
|
+
}
|
|
32
36
|
const m = e.message;
|
|
33
37
|
// Claude Code's own placeholder replies are written as model "<synthetic>" with zero usage.
|
|
34
38
|
if (e.type !== "assistant" || typeof m?.model !== "string" || m.model === "<synthetic>" || !m.usage)
|
|
@@ -57,5 +61,5 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
57
61
|
for (const [id, t] of Object.entries(tokensByModel))
|
|
58
62
|
if (model === null || t.output > tokensByModel[model].output)
|
|
59
63
|
model = id;
|
|
60
|
-
return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt };
|
|
64
|
+
return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
61
65
|
}
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addTokens, jsonl, num, Span } from "./read.js";
|
|
1
|
+
import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
|
|
2
2
|
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
3
3
|
// Line shapes as Codex 0.162.1 writes them (codex-rs/protocol). input_tokens includes both cached reads and cache writes,
|
|
4
4
|
// as in Codex's own parsing test (100 input = 40 cached + 60 written); reasoning_output_tokens is part of output_tokens.
|
|
@@ -6,6 +6,7 @@ export async function readCodex(rolloutPath) {
|
|
|
6
6
|
const span = new Span();
|
|
7
7
|
let model = null;
|
|
8
8
|
let firstPrompt = null;
|
|
9
|
+
let taskUrl = null;
|
|
9
10
|
let latest = zero;
|
|
10
11
|
let latestTotal = 0;
|
|
11
12
|
// A resumed session restarts the running total in the same rollout, so the totals before each restart are carried over.
|
|
@@ -17,10 +18,12 @@ export async function readCodex(rolloutPath) {
|
|
|
17
18
|
model = p.model;
|
|
18
19
|
if (e.type !== "event_msg")
|
|
19
20
|
continue;
|
|
20
|
-
if (
|
|
21
|
+
if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
|
|
21
22
|
const text = p.item.content.find((c) => c?.type === "text")?.text;
|
|
22
|
-
if (typeof text === "string" && text.trim())
|
|
23
|
-
firstPrompt
|
|
23
|
+
if (typeof text === "string" && text.trim()) {
|
|
24
|
+
firstPrompt ??= text.trim();
|
|
25
|
+
taskUrl ??= ticketUrl(text);
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
28
|
const u = p?.type === "token_count" ? p.info?.total_token_usage : undefined;
|
|
26
29
|
if (!u)
|
|
@@ -39,5 +42,5 @@ export async function readCodex(rolloutPath) {
|
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
const tokens = addTokens(carried, latest);
|
|
42
|
-
return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt };
|
|
45
|
+
return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
43
46
|
}
|
package/dist/agents/opencode.js
CHANGED
|
@@ -3,7 +3,7 @@ const iso = (ms) => {
|
|
|
3
3
|
const d = new Date(typeof ms === "number" ? ms : Number.NaN);
|
|
4
4
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
5
5
|
};
|
|
6
|
-
// The session has no prompt text, so there is no first prompt.
|
|
6
|
+
// The session has no prompt text, so there is no first prompt and no ticket.
|
|
7
7
|
export function readOpenCode(json) {
|
|
8
8
|
const s = (typeof json === "object" && json !== null ? json : {});
|
|
9
9
|
const model = typeof s.model?.id === "string" ? s.model.id : null;
|
|
@@ -23,5 +23,6 @@ export function readOpenCode(json) {
|
|
|
23
23
|
startedAt: iso(s.time?.created),
|
|
24
24
|
endedAt: iso(s.time?.updated),
|
|
25
25
|
firstPrompt: null,
|
|
26
|
+
taskUrl: null,
|
|
26
27
|
};
|
|
27
28
|
}
|
package/dist/agents/read.js
CHANGED
|
@@ -14,6 +14,9 @@ export async function* jsonl(path) {
|
|
|
14
14
|
yield value;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
// Full issue URLs only: a bare ID is ambiguous (UTF-8). Cut at the ID, so a slug or query never follows it.
|
|
18
|
+
const TICKET_URL = /https?:\/\/(?:linear\.app\/[\w-]+\/issue|[\w-]+\.atlassian\.net\/browse)\/[A-Za-z][A-Za-z0-9]*-\d+\b/;
|
|
19
|
+
export const ticketUrl = (text) => text?.match(TICKET_URL)?.[0] ?? null;
|
|
17
20
|
export const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
18
21
|
export const addTokens = (a, b) => ({
|
|
19
22
|
input: a.input + b.input,
|
package/dist/args.js
CHANGED
package/dist/client.js
CHANGED
|
@@ -45,10 +45,12 @@ export async function request(cfg, method, path, body, opts = {}) {
|
|
|
45
45
|
await sleep(backoffMs);
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
|
-
const json =
|
|
49
|
-
if (!res.ok)
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const json = await res.json().catch(() => null);
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const err = json;
|
|
51
|
+
throw new CliError(errorMessage(method, path, res.status, err), "http", res.status, err?.error?.details);
|
|
52
|
+
}
|
|
53
|
+
return { status: res.status, json: json };
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
package/dist/commands/body.js
CHANGED
|
@@ -2,6 +2,9 @@ import { CliError, request } from "../client.js";
|
|
|
2
2
|
import { gitFacts } from "../git.js";
|
|
3
3
|
import { warn } from "../output.js";
|
|
4
4
|
import { derivedTitle, sessionFacts } from "./hook.js";
|
|
5
|
+
export const projectHint = (s) => s.repository_project_set === false && s.project_source !== "session"
|
|
6
|
+
? `\nProject: none saved for ${s.repository}. Suggest one to the user, then save it with: berliner project "<name>"`
|
|
7
|
+
: "";
|
|
5
8
|
// Shared by report (PUT) and update (PATCH): unset flags are undefined and drop out of the JSON.
|
|
6
9
|
export function sessionBody(values) {
|
|
7
10
|
return {
|
|
@@ -37,14 +40,27 @@ export async function fromTool(tool, values, env, command) {
|
|
|
37
40
|
const ignored = ["session-id", "cost", "model"].filter((f) => values[f] !== undefined && values[f] !== filled[f]);
|
|
38
41
|
if (ignored.length)
|
|
39
42
|
warn(`ignored ${ignored.map((f) => `--${f}`).join(", ")}: inside ${tool.tool}, this session's own values are used`);
|
|
40
|
-
|
|
43
|
+
// The derived title only names a session the hook hasn't created yet: see patchFirst.
|
|
44
|
+
if (filled.repo === undefined || filled.title === undefined) {
|
|
41
45
|
const git = await gitFacts(process.cwd(), null);
|
|
42
46
|
filled.repo ??= git.repository ?? undefined;
|
|
43
|
-
|
|
44
|
-
filled.title ??= derivedTitle(facts?.firstPrompt ?? null, git) ?? undefined;
|
|
47
|
+
filled.title ??= derivedTitle(facts?.firstPrompt ?? null, git) ?? undefined;
|
|
45
48
|
}
|
|
46
49
|
return filled;
|
|
47
50
|
}
|
|
51
|
+
// Inside a tool: the same fields, minus a derived title, for a session that exists; on a 404 (the first turn, before the hook
|
|
52
|
+
// has reported), the PUT that creates it, at cost 0 while the cost is unknown and on BERLINER_PLAN when no plan is given.
|
|
53
|
+
export async function patchFirst(cfg, path, body, titled, guess) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
56
|
+
return { ...res, json: { created: false, ...res.json } };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (!(err instanceof CliError && err.status === 404))
|
|
60
|
+
throw err;
|
|
61
|
+
return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined }, guess);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
48
64
|
function number(values, flag) {
|
|
49
65
|
const raw = values[flag];
|
|
50
66
|
if (raw === undefined)
|
package/dist/commands/hook.js
CHANGED
|
@@ -5,15 +5,19 @@ import { basename, join } from "node:path";
|
|
|
5
5
|
import { findRecord, readClaudeCode, readCodex, readOpenCode, SAFE_ID } from "../agents/index.js";
|
|
6
6
|
import { CliError, request } from "../client.js";
|
|
7
7
|
import { readConfig, stateDir } from "../config.js";
|
|
8
|
+
import { viewPr } from "../gh.js";
|
|
8
9
|
import { gitFacts } from "../git.js";
|
|
9
10
|
import { estimateCost, loadPrices } from "../prices.js";
|
|
10
|
-
|
|
11
|
+
// Same text in the OpenCode plugin (cli/opencode/berliner.js).
|
|
12
|
+
const REMINDER = 'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
|
|
11
13
|
const TOOLS = ["claude-code", "codex", "opencode"];
|
|
12
14
|
const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
|
|
13
15
|
const OPENCODE_EVENTS = ["idle", "compacting"];
|
|
16
|
+
const SESSION_STARTS = ["startup", "resume", "compact", "clear"];
|
|
14
17
|
const MAX_PAYLOAD = 1024 * 1024;
|
|
15
18
|
const LOCK_MS = 60_000;
|
|
16
19
|
const DEADLINE_MS = 15_000;
|
|
20
|
+
const MAX_GH = 5;
|
|
17
21
|
export function inTool(env) {
|
|
18
22
|
if (env.CLAUDE_CODE_SESSION_ID)
|
|
19
23
|
return { tool: "claude-code", sessionId: env.CLAUDE_CODE_SESSION_ID };
|
|
@@ -81,8 +85,9 @@ async function start(tool, env) {
|
|
|
81
85
|
id = p.session?.id;
|
|
82
86
|
}
|
|
83
87
|
else {
|
|
84
|
-
//
|
|
85
|
-
|
|
88
|
+
// In the agent's context from the start, whether or not the skill triggers; after a compaction or /clear it may have lost
|
|
89
|
+
// the task and PRs it hasn't reported yet.
|
|
90
|
+
if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source))
|
|
86
91
|
return console.log(REMINDER);
|
|
87
92
|
if (!REPORT_EVENTS.includes(p.hook_event_name))
|
|
88
93
|
return;
|
|
@@ -191,11 +196,15 @@ async function send(tool, file, env) {
|
|
|
191
196
|
started_at: facts.startedAt ?? undefined,
|
|
192
197
|
ended_at: facts.endedAt ?? undefined,
|
|
193
198
|
repository: git.repository,
|
|
199
|
+
// The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
|
|
200
|
+
detected_task_id: facts.taskUrl?.split("/").pop(),
|
|
201
|
+
detected_task_url: facts.taskUrl ?? undefined,
|
|
194
202
|
};
|
|
195
203
|
const cfg = readConfig(env);
|
|
196
204
|
const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
|
|
205
|
+
let saved;
|
|
197
206
|
try {
|
|
198
|
-
await request(cfg, "PATCH", path, body);
|
|
207
|
+
saved = await request(cfg, "PATCH", path, body);
|
|
199
208
|
}
|
|
200
209
|
catch (err) {
|
|
201
210
|
if (!(err instanceof CliError && err.status === 404))
|
|
@@ -205,16 +214,34 @@ async function send(tool, file, env) {
|
|
|
205
214
|
// session needs a cost: 0 until prices load.
|
|
206
215
|
const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan });
|
|
207
216
|
try {
|
|
208
|
-
await create(cfg.plan ?? undefined);
|
|
217
|
+
saved = await create(cfg.plan ?? undefined);
|
|
209
218
|
}
|
|
210
219
|
catch (err) {
|
|
211
220
|
// A BERLINER_PLAN the server rejects must not lose the session: the server picks the plan instead.
|
|
212
221
|
if (!(cfg.plan && err instanceof CliError && err.status === 422 && err.details?.fieldErrors?.plan))
|
|
213
222
|
throw err;
|
|
214
|
-
await create(undefined);
|
|
223
|
+
saved = await create(undefined);
|
|
215
224
|
log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
|
|
216
225
|
}
|
|
217
226
|
}
|
|
227
|
+
await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
|
|
228
|
+
}
|
|
229
|
+
// Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
|
|
230
|
+
// In parallel, 5 seconds each, inside the child's deadline. Only changes are sent.
|
|
231
|
+
async function refreshPrs(cfg, path, prs, env, where) {
|
|
232
|
+
const open = prs.filter((p) => p.tracked === false && p.merge_state === "open").slice(0, MAX_GH);
|
|
233
|
+
const seen = await Promise.all(open.map((p) => viewPr(p.url).catch((err) => {
|
|
234
|
+
// No gh on this machine, or one that isn't signed in (exit code 4), is not an error: logging it would add lines every reply.
|
|
235
|
+
if (err.code !== "ENOENT" && err.code !== 4)
|
|
236
|
+
log(env, `${where}: gh pr view ${p.url}: ${err.message}`);
|
|
237
|
+
return null;
|
|
238
|
+
})));
|
|
239
|
+
const changed = open.flatMap((p, i) => {
|
|
240
|
+
const s = seen[i];
|
|
241
|
+
return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state) ? [{ url: p.url, ...s }] : [];
|
|
242
|
+
});
|
|
243
|
+
if (changed.length)
|
|
244
|
+
await request(cfg, "PATCH", path, { prs: changed });
|
|
218
245
|
}
|
|
219
246
|
// shortcut: hook.log is never rotated (one short line per skipped or failed report); rotate it if it grows large in practice.
|
|
220
247
|
function log(env, line) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CliError, request } from "../client.js";
|
|
2
|
+
import { gitFacts } from "../git.js";
|
|
3
|
+
import { print } from "../output.js";
|
|
4
|
+
export async function projects(cfg, values) {
|
|
5
|
+
const { json } = await request(cfg, "GET", "/v1/projects");
|
|
6
|
+
const lines = json.projects.length ? ["Projects:", ...json.projects.map((p) => ` ${p}`)] : ["No projects yet."];
|
|
7
|
+
if (json.repositories.length)
|
|
8
|
+
lines.push("Repository defaults:", ...json.repositories.map((r) => ` ${r.repository} ${r.project ?? "(none)"}`));
|
|
9
|
+
print({ data: json, text: lines.join("\n") }, values.json === true);
|
|
10
|
+
}
|
|
11
|
+
// The default project of one repository: shown without a name, saved with one, saved as "no project" with --none.
|
|
12
|
+
export async function project(cfg, values, positionals) {
|
|
13
|
+
if (positionals.length > 1 || (positionals.length && values.none)) {
|
|
14
|
+
throw new CliError("usage: berliner project [<name> | --none] [--repo owner/name] [--json]; quote names that contain spaces", "usage");
|
|
15
|
+
}
|
|
16
|
+
const repository = values.repo ?? (await gitFacts(process.cwd(), null)).repository;
|
|
17
|
+
if (!repository)
|
|
18
|
+
throw new CliError("no GitHub repository here; pass --repo owner/name", "usage");
|
|
19
|
+
const [owner, name, ...rest] = repository.split("/");
|
|
20
|
+
if (!owner || !name || rest.length)
|
|
21
|
+
throw new CliError(`--repo must be owner/name, got "${repository}"`, "usage");
|
|
22
|
+
if (!positionals.length && !values.none) {
|
|
23
|
+
const { json } = await request(cfg, "GET", "/v1/projects");
|
|
24
|
+
// Repositories are matched without case, as the server stores them.
|
|
25
|
+
const row = json.repositories.find((r) => r.repository.toLowerCase() === repository.toLowerCase());
|
|
26
|
+
const text = !row
|
|
27
|
+
? `No project saved for ${repository}.`
|
|
28
|
+
: row.project === null
|
|
29
|
+
? `${row.repository} has no project.`
|
|
30
|
+
: `${row.repository}: ${row.project}`;
|
|
31
|
+
return print({ data: { repository: row?.repository ?? repository, project: row?.project ?? null, saved: row !== undefined }, text }, values.json === true);
|
|
32
|
+
}
|
|
33
|
+
const path = `/v1/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/project`;
|
|
34
|
+
const { json } = await request(cfg, "PUT", path, { project: values.none ? null : positionals[0] });
|
|
35
|
+
const text = json.project === null ? `Saved: ${json.repository} has no project.` : `Saved project "${json.project}" for ${json.repository}.`;
|
|
36
|
+
print({ data: json, text }, values.json === true);
|
|
37
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "../client.js";
|
|
2
2
|
import { print, warn } from "../output.js";
|
|
3
3
|
import { uploadEvidence } from "../upload.js";
|
|
4
|
-
import { fromTool, saveSession, sessionBody } from "./body.js";
|
|
4
|
+
import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
|
|
5
5
|
import { inTool } from "./hook.js";
|
|
6
6
|
const required = ["session-id", "title", "repo", "cost", "model", "plan"];
|
|
7
7
|
export async function report(cfg, values, positionals, env) {
|
|
@@ -29,7 +29,7 @@ export async function report(cfg, values, positionals, env) {
|
|
|
29
29
|
: saveSession(cfg, "PUT", path, body, values.plan));
|
|
30
30
|
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
31
31
|
const { created, session } = json;
|
|
32
|
-
const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked
|
|
32
|
+
const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.${projectHint(session)}`;
|
|
33
33
|
const json_ = values.json === true;
|
|
34
34
|
// Human mode prints the session line before uploads start; --json waits so stdout is one document.
|
|
35
35
|
if (!json_)
|
|
@@ -49,19 +49,9 @@ export async function report(cfg, values, positionals, env) {
|
|
|
49
49
|
}
|
|
50
50
|
for (const s of result.skipped)
|
|
51
51
|
warn(`${s.path}: ${s.reason}`);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function patchFirst(cfg, path, body, titled, guess) {
|
|
56
|
-
try {
|
|
57
|
-
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
58
|
-
return { ...res, json: { created: false, ...res.json } };
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
if (!(err instanceof CliError && err.status === 404))
|
|
62
|
-
throw err;
|
|
63
|
-
return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0 }, guess);
|
|
64
|
-
}
|
|
52
|
+
// The session is saved either way; --strict asks for a skipped file to count as a failure.
|
|
53
|
+
if (values.strict && result.skipped.length)
|
|
54
|
+
process.exitCode = 1;
|
|
65
55
|
}
|
|
66
56
|
// <path>[::<caption>]
|
|
67
57
|
function parseEvidence(spec) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { CliError, request } from "../client.js";
|
|
7
7
|
import { configPath, DEFAULT_URL, readConfig, readConfigFile, saveConfig } from "../config.js";
|
|
8
8
|
import { machineName, openBrowser, startConnect, waitForApproval } from "../setup/connect.js";
|
|
9
|
-
import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
|
|
10
|
-
import {
|
|
9
|
+
import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, hookPath, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
|
|
10
|
+
import { detectTools, onPath, skillTargets } from "../setup/targets.js";
|
|
11
11
|
// The same command the web guide shows.
|
|
12
12
|
const TEST_REPORT = 'berliner report --session-id try-1 --title "First report" --repo you/app --cost 0.10 --model test --plan pro';
|
|
13
13
|
export const tilde = (p) => (p.startsWith(`${homedir()}/`) ? `~${p.slice(homedir().length)}` : p);
|
|
@@ -47,11 +47,11 @@ export async function setup(env, values) {
|
|
|
47
47
|
const targets = skillTargets(tools, env);
|
|
48
48
|
// One reporting hook per tool found, where the spec's "Hooks" table puts it.
|
|
49
49
|
const hookFiles = {
|
|
50
|
-
"claude-code": {
|
|
51
|
-
codex: {
|
|
52
|
-
opencode: {
|
|
50
|
+
"claude-code": { describe: describeClaudeHooks, install: installClaudeHooks },
|
|
51
|
+
codex: { describe: describeCodexHooks, install: installCodexHooks },
|
|
52
|
+
opencode: { describe: describeOpenCodePlugin, install: installOpenCodePlugin },
|
|
53
53
|
};
|
|
54
|
-
const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id] }));
|
|
54
|
+
const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id], path: hookPath(t.id, env) }));
|
|
55
55
|
const version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
56
56
|
const install = installCommand(version);
|
|
57
57
|
// The plan is data, built and shown before anything changes.
|
|
@@ -152,7 +152,7 @@ export async function setup(env, values) {
|
|
|
152
152
|
if (result === "unreadable")
|
|
153
153
|
console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be read, so it was left unchanged`);
|
|
154
154
|
else
|
|
155
|
-
console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : ""}`);
|
|
155
|
+
console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : result === "updated" ? " (updated)" : ""}`);
|
|
156
156
|
codexAdded ||= h.id === "codex" && result === "added";
|
|
157
157
|
}
|
|
158
158
|
const summary = [
|
package/dist/commands/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "../client.js";
|
|
2
2
|
import { print } from "../output.js";
|
|
3
|
-
import { fromTool, saveSession, sessionBody } from "./body.js";
|
|
3
|
+
import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
|
|
4
4
|
import { inTool } from "./hook.js";
|
|
5
5
|
export async function update(cfg, values, positionals, env) {
|
|
6
6
|
const tool = inTool(env);
|
|
@@ -12,12 +12,14 @@ export async function update(cfg, values, positionals, env) {
|
|
|
12
12
|
throw new CliError("update does not upload evidence; use report --evidence", "usage");
|
|
13
13
|
if (values.public)
|
|
14
14
|
throw new CliError("update does not publish evidence; use report --evidence --public, or berliner evidence public <id>", "usage");
|
|
15
|
+
const titled = values.title !== undefined;
|
|
15
16
|
if (tool)
|
|
16
17
|
values = await fromTool(tool, { ...values, "session-id": positionals[0] ?? values["session-id"] }, env, "update");
|
|
17
18
|
const id = tool ? tool.sessionId : positionals[0];
|
|
18
19
|
// A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
|
|
19
20
|
const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
|
|
20
|
-
const
|
|
21
|
+
const path = `/v1/sessions/${encodeURIComponent(id)}`;
|
|
22
|
+
const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan) : saveSession(cfg, "PATCH", path, body, values.plan));
|
|
21
23
|
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
22
|
-
print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked
|
|
24
|
+
print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.${projectHint(json.session)}` }, values.json === true);
|
|
23
25
|
}
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { CliError } from "../client.js";
|
|
5
|
+
import { hookPath, updatedHooks } from "../setup/hooks.js";
|
|
5
6
|
import { detectTools, skillTargets } from "../setup/targets.js";
|
|
6
7
|
import { confirm, installCommand, showPlan, tilde } from "./setup.js";
|
|
7
8
|
async function latestVersion(env) {
|
|
@@ -44,7 +45,13 @@ const sameAs = (path, text) => {
|
|
|
44
45
|
return false;
|
|
45
46
|
}
|
|
46
47
|
};
|
|
47
|
-
//
|
|
48
|
+
// What a hook gains from this release, for the plan.
|
|
49
|
+
const HOOK_CHANGE = {
|
|
50
|
+
"claude-code": "the reminder also runs on startup and resume",
|
|
51
|
+
codex: "the reminder also runs on startup and resume",
|
|
52
|
+
opencode: "the new reminder",
|
|
53
|
+
};
|
|
54
|
+
// The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
|
|
48
55
|
export async function upgrade(env, values) {
|
|
49
56
|
const current = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
50
57
|
const latest = await latestVersion(env);
|
|
@@ -56,7 +63,13 @@ export async function upgrade(env, values) {
|
|
|
56
63
|
const targets = values["no-skill"] ? [] : skillTargets(tools, env);
|
|
57
64
|
const skill = targets.length ? skillOf(target, !installing) : "";
|
|
58
65
|
let stale = targets.filter((t) => !sameAs(t.path, skill));
|
|
59
|
-
|
|
66
|
+
// Computed now, so what is written is what the plan showed.
|
|
67
|
+
const hooks = tools.flatMap((tool) => {
|
|
68
|
+
const path = hookPath(tool.id, env);
|
|
69
|
+
const text = updatedHooks(tool.id, path);
|
|
70
|
+
return text === null ? [] : [{ ...tool, path, text }];
|
|
71
|
+
});
|
|
72
|
+
if (!installing && !stale.length && !hooks.length) {
|
|
60
73
|
console.log(`Already up to date. berliner ${current} is ${current === latest ? "the latest release" : `newer than the latest release (${latest})`}.`);
|
|
61
74
|
return;
|
|
62
75
|
}
|
|
@@ -79,6 +92,9 @@ export async function upgrade(env, values) {
|
|
|
79
92
|
return `${tool.name.padEnd(14)}${where}`;
|
|
80
93
|
}),
|
|
81
94
|
},
|
|
95
|
+
...(hooks.length
|
|
96
|
+
? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
|
|
97
|
+
: []),
|
|
82
98
|
]);
|
|
83
99
|
if (!values.yes) {
|
|
84
100
|
if (!process.stdin.isTTY)
|
|
@@ -112,5 +128,16 @@ export async function upgrade(env, values) {
|
|
|
112
128
|
console.error(`✗ ${what}: ${tilde(t.path)} can't be written (${err.message.split(",")[0]})`);
|
|
113
129
|
}
|
|
114
130
|
}
|
|
131
|
+
for (const h of hooks) {
|
|
132
|
+
try {
|
|
133
|
+
// In place, so a settings file that is a symlink stays one.
|
|
134
|
+
writeFileSync(h.path, h.text);
|
|
135
|
+
console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
unwritten = true;
|
|
139
|
+
console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be written (${err.message.split(",")[0]})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
115
142
|
console.log(unwritten ? "\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again." : "\nDone.");
|
|
116
143
|
}
|
package/dist/gh.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const MERGE = { OPEN: "open", MERGED: "merged", CLOSED: "closed" };
|
|
5
|
+
// CheckRun conclusions and StatusContext states.
|
|
6
|
+
const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
|
|
7
|
+
const PENDING = ["PENDING", "EXPECTED"];
|
|
8
|
+
// `gh pr view --json state,statusCheckRollup` as Berliner's merge and CI states. statusCheckRollup mixes CheckRuns (status,
|
|
9
|
+
// conclusion; conclusion is "" until completed) and commit StatusContexts (state).
|
|
10
|
+
export function prState(view) {
|
|
11
|
+
const merge_state = MERGE[String(view.state)];
|
|
12
|
+
if (!merge_state)
|
|
13
|
+
throw new Error(`unexpected PR state ${JSON.stringify(view.state)}`);
|
|
14
|
+
const checks = (Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : []);
|
|
15
|
+
const ci_state = checks.some((c) => FAILED.includes(c?.conclusion || c?.state))
|
|
16
|
+
? "failed"
|
|
17
|
+
: checks.some((c) => (typeof c?.status === "string" ? c.status !== "COMPLETED" : PENDING.includes(c?.state)))
|
|
18
|
+
? "running"
|
|
19
|
+
: checks.length
|
|
20
|
+
? "passed"
|
|
21
|
+
: "none";
|
|
22
|
+
return { merge_state, ci_state };
|
|
23
|
+
}
|
|
24
|
+
// The machine's gh, found on PATH; it rejects when gh is missing (code ENOENT), fails or takes over 5 seconds.
|
|
25
|
+
export async function viewPr(url) {
|
|
26
|
+
const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup"], { timeout: 5000 });
|
|
27
|
+
return prState(JSON.parse(stdout));
|
|
28
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CliError } from "./client.js";
|
|
|
5
5
|
import { del } from "./commands/delete.js";
|
|
6
6
|
import { evidence } from "./commands/evidence.js";
|
|
7
7
|
import { hook } from "./commands/hook.js";
|
|
8
|
+
import { project, projects } from "./commands/projects.js";
|
|
8
9
|
import { report } from "./commands/report.js";
|
|
9
10
|
import { session } from "./commands/session.js";
|
|
10
11
|
import { sessions } from "./commands/sessions.js";
|
|
@@ -28,6 +29,9 @@ Commands:
|
|
|
28
29
|
sessions [--repo --model --project --ci --merge --since <N>d | --from --to] [--limit <n>] [--json]
|
|
29
30
|
session <session-id> [--json]
|
|
30
31
|
summary same filters as sessions [--json]
|
|
32
|
+
projects the project names in use, then each repository's default [--json]
|
|
33
|
+
project [<name>] show or save this repository's default project; --none saves "no project"
|
|
34
|
+
[--none --repo <owner/name> --json]
|
|
31
35
|
delete <session-id> [--evidence <id>] [--yes]
|
|
32
36
|
evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
|
|
33
37
|
evidence private <id> make it viewable only by you again [--json]
|
|
@@ -39,7 +43,21 @@ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
|
|
|
39
43
|
const args = parse(process.argv.slice(2));
|
|
40
44
|
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
41
45
|
const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
|
|
42
|
-
const handled = new Set([
|
|
46
|
+
const handled = new Set([
|
|
47
|
+
"setup",
|
|
48
|
+
"report",
|
|
49
|
+
"update",
|
|
50
|
+
"sessions",
|
|
51
|
+
"session",
|
|
52
|
+
"summary",
|
|
53
|
+
"projects",
|
|
54
|
+
"project",
|
|
55
|
+
"delete",
|
|
56
|
+
"evidence",
|
|
57
|
+
"skill",
|
|
58
|
+
"hook",
|
|
59
|
+
"upgrade",
|
|
60
|
+
]);
|
|
43
61
|
const cfg = () => readConfig(process.env);
|
|
44
62
|
try {
|
|
45
63
|
if (args.values.help)
|
|
@@ -61,6 +79,10 @@ try {
|
|
|
61
79
|
await session(cfg(), args.values, args.positionals);
|
|
62
80
|
else if (args.command === "summary")
|
|
63
81
|
await summary(cfg(), args.values);
|
|
82
|
+
else if (args.command === "projects")
|
|
83
|
+
await projects(cfg(), args.values);
|
|
84
|
+
else if (args.command === "project")
|
|
85
|
+
await project(cfg(), args.values, args.positionals);
|
|
64
86
|
else if (args.command === "delete")
|
|
65
87
|
await del(cfg(), args.values, args.positionals);
|
|
66
88
|
else if (args.command === "evidence")
|
package/dist/setup/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { claudeDir, codexDir, opencodeDir } from "./targets.js";
|
|
3
4
|
const entries = (tool, asyncStop) => {
|
|
4
5
|
const hooks = (extra = {}) => [{ type: "command", command: `berliner hook ${tool}`, ...extra }];
|
|
5
6
|
return {
|
|
@@ -8,16 +9,17 @@ const entries = (tool, asyncStop) => {
|
|
|
8
9
|
PreCompact: { hooks: hooks() },
|
|
9
10
|
SessionEnd: { hooks: hooks() },
|
|
10
11
|
// Not async: what it prints is the reminder, which the tool adds to the agent's context.
|
|
11
|
-
SessionStart: { matcher: "compact|clear", hooks: hooks() },
|
|
12
|
+
SessionStart: { matcher: "startup|resume|compact|clear", hooks: hooks() },
|
|
12
13
|
};
|
|
13
14
|
};
|
|
14
15
|
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
16
|
const ours = (entry) => isObject(entry) &&
|
|
16
17
|
Array.isArray(entry.hooks) &&
|
|
17
18
|
entry.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.startsWith("berliner hook "));
|
|
18
|
-
// The file with our entries added to the events that lack one
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// The file with our entries added to the events that lack one (unless addMissing is false) and an older matcher of ours
|
|
20
|
+
// brought up to date in place, or null when nothing changes. "unreadable" when it isn't a JSON object whose hooks we can add
|
|
21
|
+
// to: then it is the user's to fix, and is never rewritten.
|
|
22
|
+
function merged(file, add, addMissing = true) {
|
|
21
23
|
let text = null;
|
|
22
24
|
let json = {};
|
|
23
25
|
try {
|
|
@@ -33,37 +35,56 @@ function merged(file, add) {
|
|
|
33
35
|
const hooks = json.hooks ?? {};
|
|
34
36
|
if (!isObject(hooks))
|
|
35
37
|
return "unreadable";
|
|
36
|
-
let
|
|
38
|
+
let added = false;
|
|
39
|
+
let updated = false;
|
|
37
40
|
for (const [event, entry] of Object.entries(add)) {
|
|
38
41
|
const list = hooks[event] ?? [];
|
|
39
42
|
if (!Array.isArray(list))
|
|
40
43
|
return "unreadable";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const i = list.findIndex(ours);
|
|
45
|
+
if (i < 0 && addMissing) {
|
|
46
|
+
hooks[event] = [...list, entry];
|
|
47
|
+
added = true;
|
|
48
|
+
}
|
|
49
|
+
else if (i >= 0 && entry.matcher !== undefined && list[i].matcher !== entry.matcher) {
|
|
50
|
+
hooks[event] = list.map((e, j) => (j === i ? { ...e, matcher: entry.matcher } : e));
|
|
51
|
+
updated = true;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
|
-
if (!
|
|
54
|
+
if (!added && !updated)
|
|
47
55
|
return null;
|
|
48
56
|
const indent = text?.match(/^[ \t]+(?=\S)/m)?.[0] ?? " ";
|
|
49
|
-
return `${JSON.stringify({ ...json, hooks }, null, indent)}\n
|
|
57
|
+
return { text: `${JSON.stringify({ ...json, hooks }, null, indent)}\n`, added };
|
|
50
58
|
}
|
|
51
59
|
function install(file, add) {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
55
|
-
if (
|
|
60
|
+
const result = merged(file, add);
|
|
61
|
+
if (result === "unreadable")
|
|
62
|
+
return result;
|
|
63
|
+
if (result === null)
|
|
56
64
|
return "present";
|
|
57
65
|
mkdirSync(dirname(file), { recursive: true });
|
|
58
66
|
// In place, so a settings file that is a symlink stays one.
|
|
59
|
-
writeFileSync(file, text);
|
|
60
|
-
return "added";
|
|
67
|
+
writeFileSync(file, result.text);
|
|
68
|
+
return result.added ? "added" : "updated";
|
|
61
69
|
}
|
|
62
|
-
const describe = (
|
|
70
|
+
const describe = (result) => result === "unreadable" ? "can't be read, left unchanged" : result === null ? "already installed" : result.added ? "adds a hook" : "updates the hook";
|
|
63
71
|
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code", true));
|
|
64
72
|
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code", true)));
|
|
65
73
|
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex", false));
|
|
66
74
|
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex", false)));
|
|
75
|
+
export const hookPath = (tool, env) => tool === "claude-code"
|
|
76
|
+
? join(claudeDir(env), "settings.json")
|
|
77
|
+
: tool === "codex"
|
|
78
|
+
? join(codexDir(env), "hooks.json")
|
|
79
|
+
: join(opencodeDir(env), "plugins", "berliner.js");
|
|
80
|
+
// For upgrade: the hook file's new text when a Berliner hook is installed but older than this release, else null. Hooks that
|
|
81
|
+
// are missing stay missing: adding them is setup's.
|
|
82
|
+
export function updatedHooks(tool, path) {
|
|
83
|
+
if (tool === "opencode")
|
|
84
|
+
return existsSync(path) && !current(path) ? plugin() : null;
|
|
85
|
+
const result = merged(path, entries(tool, tool === "claude-code"), false);
|
|
86
|
+
return result === "unreadable" ? null : (result?.text ?? null);
|
|
87
|
+
}
|
|
67
88
|
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
68
89
|
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
69
90
|
const current = (pluginPath) => {
|
package/opencode/berliner.js
CHANGED
|
@@ -16,7 +16,9 @@ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Same text as the CLI's SessionStart reminder (src/commands/hook.ts).
|
|
20
|
+
const REMINDER =
|
|
21
|
+
'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
|
|
20
22
|
// The id names a file, so only the characters OpenCode's own ids use.
|
|
21
23
|
const SAFE_ID = /^[\w-]+$/;
|
|
22
24
|
// Bounds a walk up or down the session tree, which subagents keep shallow.
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: berliner
|
|
3
|
-
description: Use when you commit, open or
|
|
3
|
+
description: Use as soon as you know what the task is, whenever a Linear or Jira ticket comes up, when you commit, open, update, merge or close a pull request, or finish a task, and whenever the user asks you to report to Berliner. Names the session and reports the ticket, project, pull requests, test coverage and screenshots or videos with the berliner CLI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Reporting to Berliner
|
|
7
7
|
|
|
8
|
-
Berliner records what an autonomous session cost and produced. You report with
|
|
8
|
+
Berliner records what an autonomous session cost and produced. You report with `berliner report`, and change single fields with `berliner update`.
|
|
9
9
|
|
|
10
10
|
## When to report
|
|
11
11
|
|
|
12
|
+
- As soon as you know the task: name the session (below).
|
|
13
|
+
- Right away when a ticket comes up.
|
|
12
14
|
- Before your final message, whenever you committed, opened or updated a pull request, or finished a task.
|
|
13
|
-
- Again whenever a PR changes (opened, merged, CI finishes) or you add evidence.
|
|
15
|
+
- Again whenever a PR changes (opened, merged, closed, CI finishes) or you add evidence.
|
|
14
16
|
- Right away when the user asks you to report to Berliner.
|
|
15
17
|
|
|
16
18
|
Reporting again is safe. The new values replace the old ones, PR URLs are added to the list, and evidence already uploaded is skipped. It never double-counts.
|
|
@@ -21,14 +23,78 @@ Hooks installed by `berliner setup` already report the session's cost, model, re
|
|
|
21
23
|
|
|
22
24
|
| Flag | What to send |
|
|
23
25
|
|---|---|
|
|
24
|
-
| `--title` | A short line naming the task. |
|
|
25
|
-
| `--task`, `--task-url` | The ticket ID and its URL, if there is one. |
|
|
26
|
+
| `--title` | A short line naming the task. See [Name the session](#name-the-session). |
|
|
27
|
+
| `--task`, `--task-url` | The ticket ID and its URL, if there is one. See [Tickets](#tickets). |
|
|
26
28
|
| `--pr` | Every pull request, one flag each. |
|
|
27
29
|
| `--coverage` | Percent, from the test run's coverage summary. |
|
|
28
30
|
| `--evidence` | Each screenshot or video. |
|
|
29
31
|
|
|
30
32
|
Do not pass `--session-id`, `--cost` or `--model`: the CLI fills them in from the tool's own records, and an explicit value is ignored with a warning. `--repo` and `--plan` are not needed either: the plan comes from the user's `BERLINER_PLAN`, or from their subscriptions in Settings. A report without `--title` keeps the session's existing title. `berliner update` ignores a session ID here.
|
|
31
33
|
|
|
34
|
+
## Name the session
|
|
35
|
+
|
|
36
|
+
As soon as you know what the task is, name the session:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
berliner update --title "Fix login redirect loop"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Keep it under about 60 characters, and name the work, not the prompt: "Fix login redirect loop", not "can you look at why login keeps redirecting". Name it again if the task changes. Write the title yourself; don't spawn a subagent for it. Until you do, the session is titled with the first line of the user's first prompt.
|
|
43
|
+
|
|
44
|
+
Outside Claude Code, Codex and OpenCode, the first `berliner report` creates the session, so pass `--title` there; `berliner update <session-id> --title "..."` renames it later.
|
|
45
|
+
|
|
46
|
+
## Tickets
|
|
47
|
+
|
|
48
|
+
When a ticket is mentioned, created, or found in the branch name, report it right away, without waiting for the end of the session:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
berliner update --task ENG-5521 --task-url https://linear.app/octo/issue/ENG-5521
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The hook fills in a ticket from a Linear or Jira URL in the user's prompts, but only while the session has none. A ticket you report always wins, so report the one the work is really for.
|
|
55
|
+
|
|
56
|
+
## Projects
|
|
57
|
+
|
|
58
|
+
A project is the body of work the user would report on: maintenance work for a client, a stream of recurring tickets that lives in a tracker project, or a product of the user's own. A session's project is, in order:
|
|
59
|
+
|
|
60
|
+
1. the session's own `--project`;
|
|
61
|
+
2. the user's saved default for the repository;
|
|
62
|
+
3. the ticket ID's prefix (`SMTNC-3268` gives `SMTNC`).
|
|
63
|
+
|
|
64
|
+
Send `--project` only for the ticket's tracker project (its Linear project or Jira epic), or a project the user named for this session. Never send a team name: the ticket prefix already covers it, and an explicit team name splits the repository's sessions away from its default.
|
|
65
|
+
|
|
66
|
+
`report` and `update` print this line when the repository has no saved default:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
Project: none saved for octo/app. Suggest one to the user, then save it with: berliner project "<name>"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
In an interactive session, when you see it:
|
|
73
|
+
|
|
74
|
+
1. Run `berliner projects` and reuse an existing name where one fits. Otherwise suggest the ticket's tracker project, else the repository or org name.
|
|
75
|
+
2. Ask the user once, in one line, without stopping your work: "This looks like part of **Acme maintenance**. Is that right?"
|
|
76
|
+
3. Save their answer with `berliner project "Acme maintenance"`, or `berliner project --none` if they say it has no project. Either way, you are not asked again for that repository.
|
|
77
|
+
|
|
78
|
+
In a non-interactive session (no user to answer), never ask and never save.
|
|
79
|
+
|
|
80
|
+
| Command | What it does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `berliner projects` | The project names in use, then each repository's default. |
|
|
83
|
+
| `berliner project` | This repository's default, or that none is saved. |
|
|
84
|
+
| `berliner project "<name>"` | Saves `<name>` as this repository's default. |
|
|
85
|
+
| `berliner project --none` | Saves that this repository has no project. |
|
|
86
|
+
|
|
87
|
+
The repository comes from `git remote get-url origin`; `--repo owner/name` names another one.
|
|
88
|
+
|
|
89
|
+
## Keep PR states current
|
|
90
|
+
|
|
91
|
+
A PR in a repository without the Berliner GitHub App keeps the state you last reported. So:
|
|
92
|
+
|
|
93
|
+
- When you merge a PR, or see one merged or closed, report it at once: `berliner update --pr "<url>,merge=merged"` (or `merge=closed`).
|
|
94
|
+
- Before your final message, check each open PR you reported with `gh pr view <url> --json state`, and report any change.
|
|
95
|
+
|
|
96
|
+
The hook also refreshes open PRs like these with `gh` after every reply, but don't rely on it: `gh` may be missing or signed out.
|
|
97
|
+
|
|
32
98
|
## Outside those tools
|
|
33
99
|
|
|
34
100
|
Without a hook, `report` needs these flags:
|
|
@@ -51,7 +117,9 @@ Report again with the same `--session-id` whenever something changes.
|
|
|
51
117
|
- `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
|
|
52
118
|
- `--evidence <path>[::caption]`: repeat per screenshot or video.
|
|
53
119
|
- `--public`: with `--evidence`, also make those files viewable without sign-in. See below.
|
|
54
|
-
- `--task <id>`, `--task-url <url
|
|
120
|
+
- `--task <id>`, `--task-url <url>`: the ticket. See [Tickets](#tickets).
|
|
121
|
+
- `--project <name>`: only the ticket's tracker project or a project the user named. See [Projects](#projects).
|
|
122
|
+
- `--started-at <ISO>`, `--ended-at <ISO>`.
|
|
55
123
|
|
|
56
124
|
Quote any value that contains spaces.
|
|
57
125
|
|
|
@@ -63,13 +131,13 @@ Evidence is private by default: only the user can see it, signed in. GitHub can
|
|
|
63
131
|
berliner report --evidence "./shots/after.png::Login page after the fix" --public
|
|
64
132
|
```
|
|
65
133
|
|
|
66
|
-
After the evidence line, it prints one line per file: `` for an image, the bare URL for a video. Paste those lines into the PR body as printed. Anyone with a link can view the file, so publish only what belongs in the PR. `berliner evidence private <id>` makes one private again; `berliner evidence public <id>` publishes one already uploaded. `berliner session <id>` lists the ids.
|
|
134
|
+
After the evidence line, it prints one line per file: `` for an image, the bare URL for a video. Paste those lines into the PR body as printed. Anyone with a link can view the file, so publish only what belongs in the PR, and never a capture that shows a password, a token, a cookie or a real customer's data. Before pasting, check that each link opens without signing in: `curl -s -o /dev/null -w '%{http_code}' <url>` prints `200`. `berliner evidence private <id>` makes one private again; `berliner evidence public <id>` publishes one already uploaded. `berliner session <id>` lists the ids.
|
|
67
135
|
|
|
68
136
|
## Environment
|
|
69
137
|
|
|
70
138
|
`berliner setup` stores the API token in a config file, so `BERLINER_TOKEN` is only needed to override it (a token from the Settings page at https://berliner.app). `BERLINER_URL` overrides the server address. `BERLINER_PLAN`, when the user sets it, replaces any `--plan`.
|
|
71
139
|
|
|
72
|
-
If the `berliner` command is not found,
|
|
140
|
+
If the `berliner` command is not found, carry on with your work and say in your final message that `npx @paxnax/berliner@latest setup` needs to run. Do not try to install it yourself.
|
|
73
141
|
|
|
74
142
|
## Timeouts
|
|
75
143
|
|
|
@@ -77,8 +145,8 @@ The session is saved first, then evidence is uploaded. Allow at least 10 minutes
|
|
|
77
145
|
|
|
78
146
|
## Reading the output
|
|
79
147
|
|
|
80
|
-
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.`
|
|
81
|
-
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work
|
|
148
|
+
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.` A `Project: none saved` line asks you for a project: see [Projects](#projects).
|
|
149
|
+
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work. With `--strict`, any failure exits 1, a skipped file included; the session is still saved. Re-run the same command; it is safe to repeat.
|
|
82
150
|
- `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given, and `published` (`path`, `id`, `url`, `markdown`) with `--public`.
|
|
83
151
|
- To change a field later without resending everything, use `berliner update --commits 5` inside the tools, or `berliner update <session-id> --commits 5` outside them.
|
|
84
152
|
|