@paxnax/berliner 0.5.3 → 0.7.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 CHANGED
@@ -117,6 +117,7 @@ Other commands:
117
117
  - `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription.
118
118
  - `berliner projects [--json]`: the project names in use, then each repository's default project.
119
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.
120
+ - `berliner team [<uuid>] [--none]`: with no argument, print the team your sessions report to and where it came from; with a uuid, save it for this machine; `--none` clears it. A session reports to a team, or to your own plan, from the first of these that is set: `--team <uuid>` or `--no-team` on `report` and `update`, `team` in `.berliner.json` at the repository root (`{ "team": "<uuid>" }`, meant to be committed so the whole team shares it), `BERLINER_TEAM`, then the saved setting. `--no-team` beats all of them. Only the report that creates a session sends the team; it stays fixed after that, and a later setting that differs prints a warning. The server refuses a team report with `team_not_found`, `team_not_member`, `team_not_eligible` (the repository's owner is not an organization the team covers) or `team_inactive` (the team has no active plan), and the message says how to fix it. Inside Claude Code, Codex or OpenCode the hook cannot print, so the next `berliner report`, `update`, `session` or `sessions` in that repository prints it once. The team's owner sees every session and file reported to the team; members never see each other's.
120
121
  - `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
121
122
  - `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.
122
123
  - `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
@@ -1,8 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
3
+ import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
4
4
  // Subagent transcripts sit next to the session's: <project>/<session-id>/subagents/agent-*.jsonl.
5
- async function subagentFiles(transcriptPath) {
5
+ export async function subagentFiles(transcriptPath) {
6
6
  const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
7
7
  const names = await readdir(dir).catch(() => []);
8
8
  return names
@@ -26,7 +26,9 @@ export async function readClaudeCode(transcriptPath) {
26
26
  let firstPrompt = null;
27
27
  let taskUrl = null;
28
28
  for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
29
+ let line = -1;
29
30
  for await (const e of jsonl(file)) {
31
+ line++;
30
32
  span.add(e.timestamp);
31
33
  if (file === transcriptPath) {
32
34
  const prompt = promptText(e);
@@ -40,7 +42,10 @@ export async function readClaudeCode(transcriptPath) {
40
42
  const u = m.usage;
41
43
  // cache_creation_input_tokens counts both kinds of write; the 1-hour ones cost more.
42
44
  const writes1h = num(u.cache_creation?.ephemeral_1h_input_tokens);
43
- responses.set(m.id ?? Symbol(), {
45
+ // The line index counts parsed lines only, which is stable as the file is only appended to.
46
+ const id = typeof m.id === "string" ? m.id : typeof e.uuid === "string" ? e.uuid : `${file}:${line}`;
47
+ responses.set(id, {
48
+ id,
44
49
  model: m.model,
45
50
  tokens: {
46
51
  input: num(u.input_tokens),
@@ -52,14 +57,12 @@ export async function readClaudeCode(transcriptPath) {
52
57
  });
53
58
  }
54
59
  }
55
- const tokensByModel = {};
56
- for (const { model, tokens } of responses.values()) {
57
- tokensByModel[model] = tokensByModel[model] ? addTokens(tokensByModel[model], tokens) : tokens;
58
- }
60
+ const replies = [...responses.values()];
61
+ const tokensByModel = sumByModel(replies);
59
62
  // The session's model is the one that wrote the most.
60
63
  let model = null;
61
64
  for (const [id, t] of Object.entries(tokensByModel))
62
65
  if (model === null || t.output > tokensByModel[model].output)
63
66
  model = id;
64
- return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
67
+ return { model, tokensByModel, replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
65
68
  }
@@ -1,4 +1,4 @@
1
- import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
1
+ import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
2
2
  const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
3
3
  const tokensOf = (u) => ({
4
4
  input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
@@ -17,23 +17,22 @@ export async function readCodex(rolloutPath) {
17
17
  let latest = zero;
18
18
  let latestTotal = 0;
19
19
  let latestLast = 0;
20
- const byModel = {};
21
20
  // Codex 0.148+ writes a token_usage_record per model response, before its token_count; where present they are the usage.
22
- let beforeRecords = null;
23
- const records = {};
24
- const responses = new Set();
21
+ // Replies from token_count growth come first; a rollout begun on an older Codex and resumed on a newer one keeps those before its first record.
22
+ const counted = [];
23
+ let countedBeforeRecords = null;
24
+ const records = new Map();
25
25
  for await (const e of jsonl(rolloutPath)) {
26
26
  span.add(e.timestamp);
27
27
  const p = e.payload;
28
28
  if (e.type === "turn_context" && typeof p?.model === "string")
29
29
  model = p.model;
30
30
  if (e.type === "token_usage_record" && p?.usage) {
31
- // A rollout begun on an older Codex and resumed on a newer one counts its earlier part from token_count.
32
- beforeRecords ??= { ...byModel };
33
- if (model && !(typeof p.response_id === "string" && responses.has(p.response_id))) {
34
- responses.add(p.response_id);
35
- records[model] = addTokens(records[model] ?? zero, tokensOf(p.usage));
36
- }
31
+ countedBeforeRecords ??= counted.length;
32
+ const id = typeof p.response_id === "string" ? p.response_id : `tr:${e.timestamp}:${num(p.usage.total_tokens)}`;
33
+ // A response id repeated in the rollout counts once, whatever model is in effect.
34
+ if (model && !(typeof p.response_id === "string" && records.has(id)))
35
+ records.set(id, { id, model, tokens: tokensOf(p.usage) });
37
36
  }
38
37
  if (e.type !== "event_msg")
39
38
  continue;
@@ -57,14 +56,12 @@ export async function readCodex(rolloutPath) {
57
56
  const cur = tokensOf(u);
58
57
  // Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
59
58
  const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
60
- if (model)
61
- byModel[model] = addTokens(byModel[model] ?? zero, delta);
59
+ if (model && Object.values(delta).some((v) => v > 0))
60
+ counted.push({ id: `tc:${e.timestamp}:${total}`, model, tokens: delta });
62
61
  latestTotal = total;
63
62
  latestLast = last;
64
63
  latest = cur;
65
64
  }
66
- if (beforeRecords)
67
- for (const [m, t] of Object.entries(records))
68
- beforeRecords[m] = addTokens(beforeRecords[m] ?? zero, t);
69
- return { model, tokensByModel: beforeRecords ?? byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
65
+ const replies = countedBeforeRecords === null ? counted : [...counted.slice(0, countedBeforeRecords), ...records.values()];
66
+ return { model, tokensByModel: sumByModel(replies), replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
70
67
  }
@@ -7,7 +7,9 @@ const iso = (ms) => {
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;
10
- const t = s.tokens;
10
+ const own = s.own && typeof s.own.cost === "number" && Number.isFinite(s.own.cost) && s.own.tokens ? s.own : null;
11
+ const t = own ? own.tokens : s.tokens;
12
+ const cost = own ? own.cost : s.cost;
11
13
  const tokens = {
12
14
  input: num(t?.input),
13
15
  // OpenCode counts reasoning apart from output and bills it at the output price.
@@ -19,7 +21,9 @@ export function readOpenCode(json) {
19
21
  return {
20
22
  model,
21
23
  tokensByModel: model && t ? { [model]: tokens } : {},
22
- costUsd: typeof s.cost === "number" && Number.isFinite(s.cost) ? s.cost : null,
24
+ // A session row has no per-reply records.
25
+ replies: [],
26
+ costUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
23
27
  startedAt: iso(s.time?.created),
24
28
  endedAt: iso(s.time?.updated),
25
29
  firstPrompt: null,
@@ -1,18 +1,54 @@
1
1
  import { createReadStream } from "node:fs";
2
+ import { open } from "node:fs/promises";
2
3
  import { createInterface } from "node:readline";
3
- // One object per line, streamed. A line that doesn't parse (one cut off mid-write, say) is skipped.
4
+ // A line that doesn't parse (one cut off mid-write, say) is skipped.
5
+ function parseLine(line) {
6
+ let value;
7
+ try {
8
+ value = JSON.parse(line);
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ return typeof value === "object" && value !== null ? value : null;
14
+ }
15
+ // One object per line, streamed.
4
16
  export async function* jsonl(path) {
5
17
  for await (const line of createInterface({ input: createReadStream(path), crlfDelay: Infinity })) {
6
- let value;
7
- try {
8
- value = JSON.parse(line);
9
- }
10
- catch {
11
- continue;
18
+ const value = parseLine(line);
19
+ if (value)
20
+ yield value;
21
+ }
22
+ }
23
+ // The same, last line first, read from the end in chunks: the latest entries of a large file without reading all of it.
24
+ export async function* jsonlBackward(path) {
25
+ const file = await open(path, "r");
26
+ try {
27
+ let pos = (await file.stat()).size;
28
+ // The start of the line after this chunk, kept as pieces so a long line isn't copied once per chunk.
29
+ let carry = [];
30
+ while (pos > 0) {
31
+ const size = Math.min(64 * 1024, pos);
32
+ pos -= size;
33
+ const chunk = Buffer.alloc(size);
34
+ await file.read(chunk, 0, size, pos);
35
+ let end = size;
36
+ for (let nl = chunk.lastIndexOf(10); nl >= 0; nl = chunk.subarray(0, end).lastIndexOf(10)) {
37
+ const value = parseLine(Buffer.concat([chunk.subarray(nl + 1, end), ...carry]).toString());
38
+ carry = [];
39
+ end = nl;
40
+ if (value)
41
+ yield value;
42
+ }
43
+ carry.unshift(chunk.subarray(0, end));
12
44
  }
13
- if (typeof value === "object" && value !== null)
45
+ const value = parseLine(Buffer.concat(carry).toString());
46
+ if (value)
14
47
  yield value;
15
48
  }
49
+ finally {
50
+ await file.close();
51
+ }
16
52
  }
17
53
  // Full issue URLs only: a bare ID is ambiguous (UTF-8). Cut at the ID, so a slug or query never follows it.
18
54
  const TICKET_URL = /https?:\/\/(?:linear\.app\/[\w-]+\/issue|[\w-]+\.atlassian\.net\/browse)\/[A-Za-z][A-Za-z0-9]*-\d+\b/;
@@ -38,3 +74,9 @@ export class Span {
38
74
  this.last = ts;
39
75
  }
40
76
  }
77
+ export function sumByModel(replies) {
78
+ const sums = {};
79
+ for (const { model, tokens } of replies)
80
+ sums[model] = sums[model] ? addTokens(sums[model], tokens) : tokens;
81
+ return sums;
82
+ }
package/dist/args.js CHANGED
@@ -5,11 +5,15 @@ const config = {
5
5
  title: { type: "string" },
6
6
  repo: { type: "string" },
7
7
  cost: { type: "string" },
8
+ // Set only by fromTool, from the ledger; not a documented flag.
9
+ "cost-status": { type: "string" },
8
10
  model: { type: "string" },
9
11
  plan: { type: "string" },
10
12
  task: { type: "string" },
11
13
  "task-url": { type: "string" },
12
14
  project: { type: "string" },
15
+ team: { type: "string" },
16
+ "no-team": { type: "boolean" },
13
17
  "started-at": { type: "string" },
14
18
  "ended-at": { type: "string" },
15
19
  commits: { type: "string" },
@@ -25,6 +29,7 @@ const config = {
25
29
  "no-browser": { type: "boolean" },
26
30
  "no-skill": { type: "boolean" },
27
31
  none: { type: "boolean" },
32
+ all: { type: "boolean" },
28
33
  run: { type: "string" },
29
34
  ci: { type: "string" },
30
35
  merge: { type: "string" },
package/dist/client.js CHANGED
@@ -2,11 +2,13 @@ export class CliError extends Error {
2
2
  kind;
3
3
  status;
4
4
  details;
5
- constructor(message, kind, status, details) {
5
+ code;
6
+ constructor(message, kind, status, details, code) {
6
7
  super(message);
7
8
  this.kind = kind;
8
9
  this.status = status;
9
10
  this.details = details;
11
+ this.code = code;
10
12
  }
11
13
  }
12
14
  export const absolute = (cfg, path) => cfg.url.replace(/\/+$/, "") + path;
@@ -49,7 +51,7 @@ export async function request(cfg, method, path, body, opts = {}) {
49
51
  const json = await res.json().catch(() => null);
50
52
  if (!res.ok) {
51
53
  const err = json;
52
- throw new CliError(errorMessage(method, path, res.status, err), "http", res.status, err?.error?.details);
54
+ throw new CliError(errorMessage(method, path, res.status, err), "http", res.status, err?.error?.details, typeof err?.error?.code === "string" ? err.error.code : undefined);
53
55
  }
54
56
  return { status: res.status, json: json };
55
57
  }
@@ -1,7 +1,14 @@
1
1
  import { CliError, request } from "../client.js";
2
2
  import { gitFacts } from "../git.js";
3
3
  import { warn } from "../output.js";
4
+ import { teamWarning } from "../team.js";
4
5
  import { derivedTitle, sessionFacts } from "./hook.js";
6
+ // One line when the session is on another plan than the settings now say.
7
+ export function warnTeam(session, team) {
8
+ const text = teamWarning(session.team, team);
9
+ if (text)
10
+ warn(text);
11
+ }
5
12
  export const projectHint = (s) => s.repository_project_set === false && s.project_source !== "session"
6
13
  ? `\nProject: none saved for ${s.repository}. Suggest one to the user, then save it with: berliner project "<name>"`
7
14
  : "";
@@ -11,6 +18,7 @@ export function sessionBody(values) {
11
18
  title: values.title,
12
19
  repository: values.repo,
13
20
  cost_usd: number(values, "cost"),
21
+ cost_status: values["cost-status"],
14
22
  model: values.model,
15
23
  plan: values.plan,
16
24
  task_id: values.task,
@@ -35,6 +43,7 @@ export async function fromTool(tool, values, env, command) {
35
43
  if (facts) {
36
44
  // Unknown without prices: left out, so a cost already saved stays (report creates a new session at 0).
37
45
  filled.cost = facts.costUsd === null ? undefined : String(facts.costUsd);
46
+ filled["cost-status"] = facts.costStatus ?? undefined;
38
47
  filled.model = facts.model ?? values.model;
39
48
  }
40
49
  const ignored = ["session-id", "cost", "model"].filter((f) => values[f] !== undefined && values[f] !== filled[f]);
@@ -51,7 +60,7 @@ export async function fromTool(tool, values, env, command) {
51
60
  // 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
61
  // 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
62
  // Create-only: when another report created the session since the 404 (412), the PATCH after all, so its title and cost stay.
54
- export async function patchFirst(cfg, path, body, titled, guess) {
63
+ export async function patchFirst(cfg, path, body, titled, guess, team = null) {
55
64
  const patch = async () => {
56
65
  const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
57
66
  return { ...res, json: { created: false, ...res.json } };
@@ -62,7 +71,7 @@ export async function patchFirst(cfg, path, body, titled, guess) {
62
71
  catch (err) {
63
72
  if (!(err instanceof CliError && err.status === 404))
64
73
  throw err;
65
- const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined };
74
+ const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined, team: team ?? undefined };
66
75
  return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
67
76
  if (err instanceof CliError && err.status === 412)
68
77
  return patch();
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { appendFileSync, existsSync, linkSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, 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";
@@ -7,7 +7,12 @@ import { CliError, request } from "../client.js";
7
7
  import { readConfig, stateDir } from "../config.js";
8
8
  import { viewPr } from "../gh.js";
9
9
  import { gitFacts } from "../git.js";
10
+ import { priceSession, sessionTime } from "../ledger.js";
11
+ import { takeLock } from "../lock.js";
10
12
  import { estimateCost, loadPrices } from "../prices.js";
13
+ import { noteRefusal, resolveTeam, teamWarning } from "../team.js";
14
+ import { checkTests } from "../tests/check.js";
15
+ import { nudge } from "../tests/nudge.js";
11
16
  // Same text in the OpenCode plugin (cli/opencode/berliner.js).
12
17
  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.';
13
18
  const TOOLS = ["claude-code", "codex", "opencode"];
@@ -15,7 +20,6 @@ const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
15
20
  const OPENCODE_EVENTS = ["idle", "compacting"];
16
21
  const SESSION_STARTS = ["startup", "resume", "compact", "clear"];
17
22
  const MAX_PAYLOAD = 1024 * 1024;
18
- const LOCK_MS = 60_000;
19
23
  const DEADLINE_MS = 15_000;
20
24
  const MAX_GH = 5;
21
25
  export function inTool(env) {
@@ -27,8 +31,8 @@ export function inTool(env) {
27
31
  return { tool: "opencode", sessionId: env.OPENCODE_SESSION_ID };
28
32
  return null;
29
33
  }
30
- // The session's facts from the tool's own record, with a cost estimated wherever the tool stores none; the cost stays null when
31
- // no prices load. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
34
+ // The session's facts from the tool's own record, with a cost priced once per reply in the ledger wherever the tool stores none.
35
+ // The cost and its status stay null when no prices load, the ledger is busy or its backfill is unfinished. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
32
36
  // the session itself too, used only without a saved record. Null without a record.
33
37
  export async function sessionFacts(tool, sessionId, env, payload = {}) {
34
38
  let facts;
@@ -50,17 +54,29 @@ export async function sessionFacts(tool, sessionId, env, payload = {}) {
50
54
  // Codex's payload names the model the session runs on now.
51
55
  if (tool === "codex" && typeof payload.model === "string" && payload.model)
52
56
  facts = { ...facts, model: payload.model };
53
- if (facts.costUsd !== null)
54
- return facts;
55
57
  const prices = await loadPrices(env);
56
- return prices ? { ...facts, costUsd: estimateCost(facts.tokensByModel, prices).costUsd } : facts;
58
+ // OpenCode stays out of the ledger: its own figure, else an estimate. A 0 is a session nobody priced.
59
+ if (tool === "opencode") {
60
+ if (facts.costUsd !== null)
61
+ return { ...facts, costStatus: facts.costUsd > 0 ? "exact" : "unknown" };
62
+ if (!prices)
63
+ return { ...facts, costStatus: null };
64
+ const { costUsd, unpriced } = estimateCost(facts.tokensByModel, prices);
65
+ // As in the ledger: partial while some model has no price.
66
+ return { ...facts, costUsd, costStatus: costUsd === 0 ? "unknown" : unpriced.length ? "partial" : "exact" };
67
+ }
68
+ if (!prices || !file)
69
+ return { ...facts, costStatus: null };
70
+ // The record file's creation time, not the first entry's: the backfill orders sessions by it too.
71
+ const priced = await priceSession(env, { id: sessionId, startedAt: await sessionTime(file, facts.startedAt) }, facts.replies, prices);
72
+ return { ...facts, costUsd: priced?.costUsd ?? null, costStatus: priced?.status ?? null };
57
73
  }
58
74
  // The first prompt's first line cut to 80 characters, else the branch. The repository is the last resort: a new session needs a title.
59
75
  export function derivedTitle(firstPrompt, git) {
60
76
  const line = firstPrompt?.split("\n")[0].trim();
61
77
  return (line && Array.from(line).slice(0, 80).join("")) || git.branch || git.repository;
62
78
  }
63
- // `berliner hook <tool>` runs inside the agent tool: it never prints (bar the reminder), never fails and never makes the agent
79
+ // `berliner hook <tool>` runs inside the agent tool: it never prints (bar the reminder and the test nudge), never fails and never makes the agent
64
80
  // wait. The work happens in a detached child (`--run`), and every error goes to hook.log.
65
81
  export async function hook(env, positionals, values) {
66
82
  const tool = positionals[0];
@@ -97,6 +113,17 @@ async function start(tool, env) {
97
113
  // The id names the payload file.
98
114
  if (typeof id !== "string" || !SAFE_ID.test(id))
99
115
  throw new Error("the payload has no usable session id");
116
+ // Before the report: the note has to reach the agent while its turn can still continue. It never stops the report.
117
+ if (p.hook_event_name === "Stop") {
118
+ try {
119
+ const line = await nudge(tool, { ...p, session_id: id }, env);
120
+ if (line)
121
+ console.log(line);
122
+ }
123
+ catch (err) {
124
+ log(env, `${tool} ${id}: nudge: ${err.message}`);
125
+ }
126
+ }
100
127
  const dir = join(stateDir(env), "hook");
101
128
  // Payloads name the user's projects and sessions: only the user may read them.
102
129
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -155,49 +182,6 @@ async function child(tool, file, env) {
155
182
  clearTimeout(timer);
156
183
  }
157
184
  }
158
- // 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
159
- // removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
160
- export function takeLock(lock) {
161
- if (createLock(lock))
162
- return true;
163
- const seen = statSync(lock, { throwIfNoEntry: false });
164
- // Gone since: its child just unlocked, so it is free, not stale.
165
- if (!seen)
166
- return createLock(lock);
167
- if (Date.now() - seen.mtimeMs < LOCK_MS)
168
- return false;
169
- const aside = `${lock}.${process.pid}`;
170
- try {
171
- renameSync(lock, aside);
172
- }
173
- catch (err) {
174
- if (err.code === "ENOENT")
175
- return false;
176
- throw err;
177
- }
178
- if (statSync(aside).ino !== seen.ino) {
179
- // shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
180
- try {
181
- linkSync(aside, lock);
182
- }
183
- catch { }
184
- rmSync(aside, { force: true });
185
- return false;
186
- }
187
- rmSync(aside, { force: true });
188
- return createLock(lock);
189
- }
190
- function createLock(lock) {
191
- try {
192
- writeFileSync(lock, String(process.pid), { flag: "wx" });
193
- return true;
194
- }
195
- catch (err) {
196
- if (err.code === "EEXIST")
197
- return false;
198
- throw err;
199
- }
200
- }
201
185
  // Only the fields the machine knows. The task, PRs, coverage, evidence, project, an existing title and the plan are the agent's.
202
186
  async function send(tool, file, env) {
203
187
  const payload = JSON.parse(await readFile(file, "utf8"));
@@ -212,9 +196,30 @@ async function send(tool, file, env) {
212
196
  // The repository is required, and it is how Berliner groups work.
213
197
  if (!git.repository)
214
198
  throw new Error(`no GitHub repository at ${cwd}, not reported`);
199
+ const repository = git.repository;
200
+ // A bad .berliner.json throws: logged, and the report waits for it to be fixed.
201
+ const team = (await resolveTeam({}, cwd, env)) ?? undefined;
202
+ // The full check, whatever the nudge's opt-out. Nothing to check sends nulls, which clear an earlier verdict. A check that
203
+ // throws (logged) or runs out of time leaves the four fields out, so the stored verdict stays. OpenCode's payload has no
204
+ // transcript.
205
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
206
+ const deadline = Date.now() + 5000;
207
+ const check = transcriptPath
208
+ ? await checkTests({ tool, transcriptPath, cwd, startedAt: facts.startedAt, deadline }).then(
209
+ // checkTests also gives null when time runs out. A git call killed by its timeout can return a few ms before the
210
+ // wall clock reaches the deadline, hence the margin: a null that close to it is taken as a timeout.
211
+ (c) => c ?? (Date.now() >= deadline - 50 ? undefined : null), (err) => {
212
+ log(env, `${tool} ${sessionId}: test check: ${err.message}`);
213
+ return undefined;
214
+ })
215
+ : null;
216
+ const tests = check === undefined
217
+ ? {}
218
+ : { test_check: check?.verdict ?? null, test_command: check?.command ?? null, head_sha: check?.headSha ?? null, tree_clean: check?.treeClean ?? false };
215
219
  const body = {
216
220
  // Unknown without prices: left out, so a cost already saved stays.
217
221
  cost_usd: facts.costUsd ?? undefined,
222
+ cost_status: facts.costStatus ?? undefined,
218
223
  model: facts.model ?? undefined,
219
224
  commits: git.commits ?? undefined,
220
225
  started_at: facts.startedAt ?? undefined,
@@ -223,6 +228,7 @@ async function send(tool, file, env) {
223
228
  // The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
224
229
  detected_task_id: facts.taskUrl?.split("/").pop(),
225
230
  detected_task_url: facts.taskUrl ?? undefined,
231
+ ...tests,
226
232
  };
227
233
  const cfg = readConfig(env);
228
234
  const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
@@ -236,9 +242,12 @@ async function send(tool, file, env) {
236
242
  // The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
237
243
  // session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
238
244
  // only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
239
- const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan }, { headers: { "If-None-Match": "*" } }).catch((err) => {
245
+ const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan, team }, { headers: { "If-None-Match": "*" } }).catch((err) => {
240
246
  if (err instanceof CliError && err.status === 412)
241
247
  return request(cfg, "PATCH", path, body);
248
+ // Never a personal session instead: the next foreground command in this repository prints the refusal.
249
+ if (err instanceof CliError && err.status === 422 && err.code?.startsWith("team_"))
250
+ noteRefusal(env, repository, err.message);
242
251
  throw err;
243
252
  });
244
253
  try {
@@ -252,6 +261,9 @@ async function send(tool, file, env) {
252
261
  log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
253
262
  }
254
263
  }
264
+ const mismatch = teamWarning(saved.json.session.team, team ?? null);
265
+ if (mismatch)
266
+ log(env, `${tool} ${sessionId}: ${mismatch}`);
255
267
  await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
256
268
  }
257
269
  // Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
@@ -266,7 +278,7 @@ async function refreshPrs(cfg, path, prs, env, where) {
266
278
  })));
267
279
  const changed = open.flatMap((p, i) => {
268
280
  const s = seen[i];
269
- return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state) ? [{ url: p.url, ...s }] : [];
281
+ return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state || s.head_sha !== p.head_sha) ? [{ url: p.url, ...s }] : [];
270
282
  });
271
283
  if (changed.length)
272
284
  await request(cfg, "PATCH", path, { prs: changed });
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { CliError } from "../client.js";
3
+ import { readConfigFile, saveConfig } from "../config.js";
4
+ import { print } from "../output.js";
5
+ import { confirm } from "./setup.js";
6
+ // Plain synchronous read for the Stop hook's time budget; an unreadable config reads as enabled.
7
+ export function nudgeEnabled(env, repoRoot) {
8
+ const nudge = readConfigFile(env)?.nudge;
9
+ return !(nudge?.off_all || nudge?.off?.includes(repoRoot));
10
+ }
11
+ // Keeps whether the question was asked, whatever the new setting.
12
+ function save(env, nudge) {
13
+ const file = readConfigFile(env);
14
+ saveConfig(env, { ...file, nudge: { ...(file?.nudge?.asked && { asked: true }), ...nudge } });
15
+ }
16
+ // Setup and upgrade ask this once, after their own Continue; "no" turns the nudge off everywhere.
17
+ export async function askNudge(env) {
18
+ const nudge = readConfigFile(env)?.nudge;
19
+ if (nudge?.asked || nudge?.off_all)
20
+ return;
21
+ const yes = await confirm(process.stdin, process.stdout, "Let Berliner ask agents to run the tests before they hand work back? [Y/n] ");
22
+ save(env, { ...nudge, asked: true, ...(!yes && { off_all: true }) });
23
+ }
24
+ function repoRoot() {
25
+ try {
26
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ // Shows the nudge setting, or turns it off or on for this repository, or with --all for every one.
33
+ export async function nudge(env, values, positionals) {
34
+ const usage = "usage: berliner nudge [on|off] [--all] [--json]";
35
+ const [mode] = positionals;
36
+ if (positionals.length > 1 || (mode !== undefined && mode !== "on" && mode !== "off"))
37
+ throw new CliError(usage, "usage");
38
+ const root = repoRoot();
39
+ if (mode === undefined) {
40
+ const enabled = root ? nudgeEnabled(env, root) : !readConfigFile(env)?.nudge?.off_all;
41
+ return print({ data: { enabled }, text: `The test nudge is ${enabled ? "on" : "off"}${root ? ` in ${root}` : ""}.` }, values.json === true);
42
+ }
43
+ if (!values.all && !root)
44
+ throw new CliError("not in a git repository: run this inside one, or add --all", "usage");
45
+ const { off_all, off = [] } = readConfigFile(env)?.nudge ?? {};
46
+ const turnOff = mode === "off";
47
+ const next = values.all
48
+ ? turnOff
49
+ ? { off_all: true, off }
50
+ : {}
51
+ : { ...(off_all && { off_all }), off: turnOff ? [...new Set([...off, root])] : off.filter((r) => r !== root) };
52
+ save(env, next.off?.length === 0 ? { ...next, off: undefined } : next);
53
+ const where = values.all ? "any repository" : root;
54
+ // Turning it on for one repository changes nothing while it is off for all of them.
55
+ const stillOff = !turnOff && !values.all && next.off_all === true;
56
+ print({
57
+ data: { enabled: !turnOff && !stillOff },
58
+ text: stillOff
59
+ ? `Removed ${root} from the list, but the nudge is still off for every repository. Run \`berliner nudge on --all\` to turn it on.`
60
+ : turnOff
61
+ ? `Berliner won't ask agents to run the tests in ${where}.`
62
+ : `Berliner will ask agents to run the tests in ${where}.`,
63
+ }, values.json === true);
64
+ }