@paxnax/berliner 0.4.0 → 0.5.1

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 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 and the skill to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
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 task, pull requests, coverage and evidence with `berliner report`, 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.
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 skill file) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. Your token, config and hooks are never touched.
122
- - `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never prints, never fails and never makes the agent wait; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
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.
@@ -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
- firstPrompt ??= promptText(e);
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
  }
@@ -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,10 +6,10 @@ 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
- // A resumed session restarts the running total in the same rollout, so the totals before each restart are carried over.
12
- let carried = zero;
12
+ const byModel = {};
13
13
  for await (const e of jsonl(rolloutPath)) {
14
14
  span.add(e.timestamp);
15
15
  const p = e.payload;
@@ -17,27 +17,32 @@ export async function readCodex(rolloutPath) {
17
17
  model = p.model;
18
18
  if (e.type !== "event_msg")
19
19
  continue;
20
- if (firstPrompt === null && p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
20
+ if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
21
21
  const text = p.item.content.find((c) => c?.type === "text")?.text;
22
- if (typeof text === "string" && text.trim())
23
- firstPrompt = text.trim();
22
+ if (typeof text === "string" && text.trim()) {
23
+ firstPrompt ??= text.trim();
24
+ taskUrl ??= ticketUrl(text);
25
+ }
24
26
  }
25
27
  const u = p?.type === "token_count" ? p.info?.total_token_usage : undefined;
26
28
  if (!u)
27
29
  continue;
28
30
  const total = num(u.total_tokens);
29
- // shortcut: an overflow reset whose first total exceeds the running total is missed; compare every count instead of total_tokens if that shows up.
30
- if (total < latestTotal)
31
- carried = addTokens(carried, latest);
32
- latestTotal = total;
33
- latest = {
31
+ // A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
32
+ const restart = total < latestTotal || (total > latestTotal && latestTotal > 0 && total === num(p?.info?.last_token_usage?.total_tokens));
33
+ const cur = {
34
34
  input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
35
35
  output: num(u.output_tokens),
36
36
  cacheRead: num(u.cached_input_tokens),
37
37
  cacheWrite: num(u.cache_write_input_tokens),
38
38
  cacheWrite1h: 0,
39
39
  };
40
+ // Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
41
+ const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
42
+ if (model)
43
+ byModel[model] = addTokens(byModel[model] ?? zero, delta);
44
+ latestTotal = total;
45
+ latest = cur;
40
46
  }
41
- const tokens = addTokens(carried, latest);
42
- return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt };
47
+ return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
43
48
  }
@@ -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
  }
@@ -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
@@ -24,6 +24,7 @@ const config = {
24
24
  "no-install": { type: "boolean" },
25
25
  "no-browser": { type: "boolean" },
26
26
  "no-skill": { type: "boolean" },
27
+ none: { type: "boolean" },
27
28
  run: { type: "string" },
28
29
  ci: { type: "string" },
29
30
  merge: { type: "string" },
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 = (await res.json().catch(() => null));
49
- if (!res.ok)
50
- throw new CliError(errorMessage(method, path, res.status, json), "http", res.status, json?.error?.details);
51
- return { status: res.status, json };
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));
@@ -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
- if (filled.repo === undefined || (command === "report" && filled.title === undefined)) {
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
- if (command === "report")
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)
@@ -1,18 +1,20 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { CliError, request } from "../client.js";
3
3
  export async function del(cfg, values, positionals) {
4
- if (positionals.length !== 1)
4
+ // A blank --evidence must not fall through to deleting the whole session.
5
+ const { evidence } = values;
6
+ if (positionals.length !== 1 || (evidence && (evidence.length !== 1 || !evidence[0].trim())))
5
7
  throw new CliError("usage: berliner delete <session-id> [--evidence <id>] [--yes]", "usage");
6
8
  const id = positionals[0];
7
- const evidenceId = values.evidence?.[0];
8
- if (evidenceId) {
9
+ const evidenceId = evidence?.[0];
10
+ if (evidenceId !== undefined) {
9
11
  // Evidence ids are global, so the session named on the command line must actually hold this one.
10
12
  const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(id)}`);
11
13
  if (!json.session.evidence.some((e) => e.id === evidenceId)) {
12
14
  throw new CliError(`evidence ${evidenceId} is not part of session ${id}`, "usage");
13
15
  }
14
16
  }
15
- const question = evidenceId ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
17
+ const question = evidenceId !== undefined ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
16
18
  if (!values.yes) {
17
19
  if (!process.stdin.isTTY)
18
20
  throw new CliError("refusing to delete without confirmation; pass --yes to delete without a prompt", "usage");
@@ -24,7 +26,7 @@ export async function del(cfg, values, positionals) {
24
26
  return;
25
27
  }
26
28
  }
27
- if (evidenceId) {
29
+ if (evidenceId !== undefined) {
28
30
  await request(cfg, "DELETE", `/v1/evidence/${encodeURIComponent(evidenceId)}`);
29
31
  console.log(`Deleted evidence ${evidenceId}.`);
30
32
  }
@@ -1,19 +1,23 @@
1
1
  import { spawn } from "node:child_process";
2
- import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, linkSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  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
- const REMINDER = "This session reports to Berliner: run `berliner report` with the task and PRs before your final message.";
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
- // A compaction or /clear may have lost the task and PRs the agent hasn't reported yet.
85
- if (p.hook_event_name === "SessionStart" && (p.source === "compact" || p.source === "clear"))
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;
@@ -147,15 +152,36 @@ async function child(tool, file, env) {
147
152
  clearTimeout(timer);
148
153
  }
149
154
  }
150
- // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is removed and
151
- // created again with wx, so of two children that find it stale only one gets it, and the other exits as for a held lock.
152
- function takeLock(lock) {
155
+ // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is moved aside and
156
+ // removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
157
+ export function takeLock(lock) {
153
158
  if (createLock(lock))
154
159
  return true;
155
- if (Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? 0) < LOCK_MS)
160
+ const seen = statSync(lock, { throwIfNoEntry: false });
161
+ // Gone since: its child just unlocked, so it is free, not stale.
162
+ if (!seen)
163
+ return createLock(lock);
164
+ if (Date.now() - seen.mtimeMs < LOCK_MS)
156
165
  return false;
157
- // shortcut: a child that found the lock stale just before another one replaced it removes the new lock; this needs a child to have died.
158
- rmSync(lock, { force: true });
166
+ const aside = `${lock}.${process.pid}`;
167
+ try {
168
+ renameSync(lock, aside);
169
+ }
170
+ catch (err) {
171
+ if (err.code === "ENOENT")
172
+ return false;
173
+ throw err;
174
+ }
175
+ if (statSync(aside).ino !== seen.ino) {
176
+ // shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
177
+ try {
178
+ linkSync(aside, lock);
179
+ }
180
+ catch { }
181
+ rmSync(aside, { force: true });
182
+ return false;
183
+ }
184
+ rmSync(aside, { force: true });
159
185
  return createLock(lock);
160
186
  }
161
187
  function createLock(lock) {
@@ -191,11 +217,15 @@ async function send(tool, file, env) {
191
217
  started_at: facts.startedAt ?? undefined,
192
218
  ended_at: facts.endedAt ?? undefined,
193
219
  repository: git.repository,
220
+ // The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
221
+ detected_task_id: facts.taskUrl?.split("/").pop(),
222
+ detected_task_url: facts.taskUrl ?? undefined,
194
223
  };
195
224
  const cfg = readConfig(env);
196
225
  const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
226
+ let saved;
197
227
  try {
198
- await request(cfg, "PATCH", path, body);
228
+ saved = await request(cfg, "PATCH", path, body);
199
229
  }
200
230
  catch (err) {
201
231
  if (!(err instanceof CliError && err.status === 404))
@@ -205,16 +235,34 @@ async function send(tool, file, env) {
205
235
  // session needs a cost: 0 until prices load.
206
236
  const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan });
207
237
  try {
208
- await create(cfg.plan ?? undefined);
238
+ saved = await create(cfg.plan ?? undefined);
209
239
  }
210
240
  catch (err) {
211
241
  // A BERLINER_PLAN the server rejects must not lose the session: the server picks the plan instead.
212
242
  if (!(cfg.plan && err instanceof CliError && err.status === 422 && err.details?.fieldErrors?.plan))
213
243
  throw err;
214
- await create(undefined);
244
+ saved = await create(undefined);
215
245
  log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
216
246
  }
217
247
  }
248
+ await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
249
+ }
250
+ // Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
251
+ // In parallel, 5 seconds each, inside the child's deadline. Only changes are sent.
252
+ async function refreshPrs(cfg, path, prs, env, where) {
253
+ const open = prs.filter((p) => p.tracked === false && p.merge_state === "open").slice(0, MAX_GH);
254
+ const seen = await Promise.all(open.map((p) => viewPr(p.url).catch((err) => {
255
+ // 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.
256
+ if (err.code !== "ENOENT" && err.code !== 4)
257
+ log(env, `${where}: gh pr view ${p.url}: ${err.message}`);
258
+ return null;
259
+ })));
260
+ const changed = open.flatMap((p, i) => {
261
+ const s = seen[i];
262
+ return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state) ? [{ url: p.url, ...s }] : [];
263
+ });
264
+ if (changed.length)
265
+ await request(cfg, "PATCH", path, { prs: changed });
218
266
  }
219
267
  // shortcut: hook.log is never rotated (one short line per skipped or failed report); rotate it if it grows large in practice.
220
268
  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
+ }
@@ -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
- // The same fields, minus a derived title, for a session that exists; on a 404, the PUT that creates it, at cost 0 while the
54
- // cost is unknown.
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) {
@@ -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, join } from "node:path";
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 { claudeDir, codexDir, detectTools, onPath, opencodeDir, skillTargets } from "../setup/targets.js";
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": { path: join(claudeDir(env), "settings.json"), describe: describeClaudeHooks, install: installClaudeHooks },
51
- codex: { path: join(codexDir(env), "hooks.json"), describe: describeCodexHooks, install: installCodexHooks },
52
- opencode: { path: join(opencodeDir(env), "plugins", "berliner.js"), describe: describeOpenCodePlugin, install: installOpenCodePlugin },
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.
@@ -114,7 +114,8 @@ export async function setup(env, values) {
114
114
  }
115
115
  let installFailed = false;
116
116
  if (!values["no-install"]) {
117
- const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
117
+ // From home, so the install never picks up the cwd's .npmrc.
118
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
118
119
  installFailed = run.status !== 0;
119
120
  if (!installFailed)
120
121
  console.log(`✓ Installed berliner ${version}`);
@@ -152,7 +153,7 @@ export async function setup(env, values) {
152
153
  if (result === "unreadable")
153
154
  console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be read, so it was left unchanged`);
154
155
  else
155
- console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : ""}`);
156
+ console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : result === "updated" ? " (updated)" : ""}`);
156
157
  codexAdded ||= h.id === "codex" && result === "added";
157
158
  }
158
159
  const summary = [
@@ -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 { json: saved, dropped } = await saveSession(cfg, "PATCH", `/v1/sessions/${encodeURIComponent(id)}`, body, values.plan);
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.` }, values.json === true);
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
  }
@@ -1,11 +1,15 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { dirname } from "node:path";
5
+ import { gunzipSync } from "node:zlib";
4
6
  import { CliError } from "../client.js";
7
+ import { hookPath, updatedHooks } from "../setup/hooks.js";
5
8
  import { detectTools, skillTargets } from "../setup/targets.js";
6
9
  import { confirm, installCommand, showPlan, tilde } from "./setup.js";
10
+ const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
7
11
  async function latestVersion(env) {
8
- const registry = (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
12
+ const registry = registryOf(env);
9
13
  try {
10
14
  const res = await fetch(`${registry}/@paxnax/berliner/latest`);
11
15
  if (!res.ok)
@@ -20,15 +24,35 @@ async function latestVersion(env) {
20
24
  }
21
25
  }
22
26
  // The skill text comes from the release being installed, so the plan's "already up to date" and what is written match it.
23
- function skillOf(version, bundled) {
27
+ // It is read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc and node_modules.
28
+ async function skillOf(version, bundled, env) {
24
29
  if (bundled)
25
30
  return readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
26
- // --bun keeps the CLI on Bun: its shebang prefers node when both exist.
27
- const [cmd, ...pre] = process.versions.bun ? ["bunx", "--bun"] : ["npx", "--yes"];
28
- const run = spawnSync(cmd, [...pre, `@paxnax/berliner@${version}`, "skill"], { encoding: "utf8" });
29
- if (run.status !== 0 || !run.stdout)
30
- throw new CliError(`Could not read the skill of berliner ${version} (${(run.error?.message ?? run.stderr).trim() || `exited with ${run.status}`}). Nothing was changed.`, "network");
31
- return run.stdout;
31
+ try {
32
+ const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
33
+ if (!meta.ok)
34
+ throw new Error(`HTTP ${meta.status}`);
35
+ const { dist } = (await meta.json());
36
+ if (typeof dist?.tarball !== "string")
37
+ throw new Error("no tarball in the reply");
38
+ const res = await fetch(dist.tarball);
39
+ if (!res.ok)
40
+ throw new Error(`HTTP ${res.status}`);
41
+ const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
42
+ // shortcut: plain ustar names only (no pax or GNU long names), enough for npm's short paths; parse pax if a path passes 100 bytes.
43
+ const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
44
+ for (let at = 0; at + 512 <= tar.length && tar[at];) {
45
+ const prefix = field(at + 345, at + 500);
46
+ const size = Number.parseInt(field(at + 124, at + 136).trim() || "0", 8);
47
+ if (`${prefix && `${prefix}/`}${field(at, at + 100)}` === "package/skill/SKILL.md")
48
+ return tar.toString("utf8", at + 512, at + 512 + size);
49
+ at += 512 + Math.ceil(size / 512) * 512;
50
+ }
51
+ throw new Error("no skill/SKILL.md in the package");
52
+ }
53
+ catch (err) {
54
+ throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
55
+ }
32
56
  }
33
57
  // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
34
58
  const newer = (a, b) => {
@@ -44,7 +68,13 @@ const sameAs = (path, text) => {
44
68
  return false;
45
69
  }
46
70
  };
47
- // Only the command and the skill: the token, config and hooks are setup's, and stay as they are.
71
+ // What a hook gains from this release, for the plan.
72
+ const HOOK_CHANGE = {
73
+ "claude-code": "the reminder also runs on startup and resume",
74
+ codex: "the reminder also runs on startup and resume",
75
+ opencode: "the new reminder, and totals saved in order",
76
+ };
77
+ // The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
48
78
  export async function upgrade(env, values) {
49
79
  const current = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
50
80
  const latest = await latestVersion(env);
@@ -54,9 +84,15 @@ export async function upgrade(env, values) {
54
84
  const install = installCommand(latest);
55
85
  const tools = detectTools(env);
56
86
  const targets = values["no-skill"] ? [] : skillTargets(tools, env);
57
- const skill = targets.length ? skillOf(target, !installing) : "";
87
+ const skill = targets.length ? await skillOf(target, !installing, env) : "";
58
88
  let stale = targets.filter((t) => !sameAs(t.path, skill));
59
- if (!installing && !stale.length) {
89
+ // Computed now, so what is written is what the plan showed.
90
+ const hooks = tools.flatMap((tool) => {
91
+ const path = hookPath(tool.id, env);
92
+ const text = updatedHooks(tool.id, path);
93
+ return text === null ? [] : [{ ...tool, path, text }];
94
+ });
95
+ if (!installing && !stale.length && !hooks.length) {
60
96
  console.log(`Already up to date. berliner ${current} is ${current === latest ? "the latest release" : `newer than the latest release (${latest})`}.`);
61
97
  return;
62
98
  }
@@ -79,6 +115,9 @@ export async function upgrade(env, values) {
79
115
  return `${tool.name.padEnd(14)}${where}`;
80
116
  }),
81
117
  },
118
+ ...(hooks.length
119
+ ? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
120
+ : []),
82
121
  ]);
83
122
  if (!values.yes) {
84
123
  if (!process.stdin.isTTY)
@@ -91,7 +130,8 @@ export async function upgrade(env, values) {
91
130
  console.log();
92
131
  }
93
132
  if (installing) {
94
- const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
133
+ // From home, so the install never picks up the cwd's .npmrc.
134
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
95
135
  if (run.status !== 0) {
96
136
  console.error(`✗ Could not install the berliner command:\n${(run.error?.message ?? run.stdout + run.stderr).trimEnd()}`);
97
137
  throw new CliError(`The skill was left as it is. Install the command yourself, then run upgrade again: ${install.join(" ")}`, "usage");
@@ -112,5 +152,16 @@ export async function upgrade(env, values) {
112
152
  console.error(`✗ ${what}: ${tilde(t.path)} can't be written (${err.message.split(",")[0]})`);
113
153
  }
114
154
  }
155
+ for (const h of hooks) {
156
+ try {
157
+ // In place, so a settings file that is a symlink stays one.
158
+ writeFileSync(h.path, h.text);
159
+ console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
160
+ }
161
+ catch (err) {
162
+ unwritten = true;
163
+ console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be written (${err.message.split(",")[0]})`);
164
+ }
165
+ }
115
166
  console.log(unwritten ? "\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again." : "\nDone.");
116
167
  }
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(["setup", "report", "update", "sessions", "session", "summary", "delete", "evidence", "skill", "hook", "upgrade"]);
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")
@@ -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, or null when nothing is missing. "unreadable" when it
19
- // isn't a JSON object whose hooks we can add to: then it is the user's to fix, and is never rewritten.
20
- function merged(file, add) {
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 changed = false;
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
- if (list.some(ours))
42
- continue;
43
- hooks[event] = [...list, entry];
44
- changed = true;
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 (!changed)
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 text = merged(file, add);
53
- if (text === "unreadable")
54
- return text;
55
- if (text === null)
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 = (text) => (text === "unreadable" ? "can't be read, left unchanged" : text === null ? "already installed" : "adds a hook");
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) => {
@@ -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
- const REMINDER = "This session reports to Berliner: run `berliner report` with the task and PRs before your final message.";
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.
@@ -75,20 +77,33 @@ async function save(client, sessionID) {
75
77
  return session;
76
78
  }
77
79
 
78
- async function report(client, event, sessionID) {
79
- try {
80
- const session = await save(client, sessionID);
81
- if (!session) return;
82
- // Detached and unreferenced: OpenCode never waits on it.
83
- const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
84
- child.on("error", () => {});
85
- child.stdin.on("error", () => {});
86
- child.stdin.end(JSON.stringify({ event, session }));
87
- child.unref();
88
- } catch {
89
- // A report that can't be sent is skipped; the next idle sends the latest totals.
90
- }
91
- }
80
+ // One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one.
81
+ // shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
82
+ const queues = new Map();
83
+ const serial = (id, fn) => {
84
+ const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
85
+ queues.set(
86
+ id,
87
+ p.catch(() => {}),
88
+ );
89
+ return p;
90
+ };
91
+
92
+ const report = (client, event, sessionID) =>
93
+ serial(sessionID, async () => {
94
+ try {
95
+ const session = await save(client, sessionID);
96
+ if (!session) return;
97
+ // Detached and unreferenced: OpenCode never waits on it.
98
+ const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
99
+ child.on("error", () => {});
100
+ child.stdin.on("error", () => {});
101
+ child.stdin.end(JSON.stringify({ event, session }));
102
+ child.unref();
103
+ } catch {
104
+ // A report that can't be sent is skipped; the next idle sends the latest totals.
105
+ }
106
+ });
92
107
 
93
108
  export const BerlinerPlugin = async ({ client }) => ({
94
109
  "shell.env": async (input, output) => {
@@ -100,7 +115,7 @@ export const BerlinerPlugin = async ({ client }) => ({
100
115
  if (!top) return;
101
116
  output.env.OPENCODE_SESSION_ID = top;
102
117
  // So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
103
- await save(client, top);
118
+ await serial(top, () => save(client, top));
104
119
  } catch {
105
120
  // Never fail the agent's shell.
106
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "type": "module",
6
6
  "engines": {
package/skill/SKILL.md CHANGED
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  name: berliner
3
- description: Use when you commit, open or update a pull request, or finish a task, and whenever the user asks you to report to Berliner. Reports the task, pull requests, test coverage and screenshots or videos with the berliner CLI.
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 one command, `berliner report`.
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>`, `--project <name>`, `--started-at <ISO>`, `--ended-at <ISO>`.
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: `![Login page after the fix](https://berliner.app/v1/public/evidence/<id>)` 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: `![Login page after the fix](https://berliner.app/v1/public/evidence/<id>)` 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, ask the user to run `npx @paxnax/berliner@latest setup`. Do not try to install it yourself.
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 (`--strict` makes failures exit 1). Re-run the same command; it is safe to repeat.
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