@paxnax/berliner 0.5.3 → 0.6.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.
@@ -2,7 +2,7 @@ import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
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
- 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
@@ -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/;
package/dist/args.js CHANGED
@@ -10,6 +10,8 @@ const config = {
10
10
  task: { type: "string" },
11
11
  "task-url": { type: "string" },
12
12
  project: { type: "string" },
13
+ team: { type: "string" },
14
+ "no-team": { type: "boolean" },
13
15
  "started-at": { type: "string" },
14
16
  "ended-at": { type: "string" },
15
17
  commits: { type: "string" },
@@ -25,6 +27,7 @@ const config = {
25
27
  "no-browser": { type: "boolean" },
26
28
  "no-skill": { type: "boolean" },
27
29
  none: { type: "boolean" },
30
+ all: { type: "boolean" },
28
31
  run: { type: "string" },
29
32
  ci: { type: "string" },
30
33
  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
  : "";
@@ -51,7 +58,7 @@ export async function fromTool(tool, values, env, command) {
51
58
  // 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
59
  // 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
60
  // 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) {
61
+ export async function patchFirst(cfg, path, body, titled, guess, team = null) {
55
62
  const patch = async () => {
56
63
  const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
57
64
  return { ...res, json: { created: false, ...res.json } };
@@ -62,7 +69,7 @@ export async function patchFirst(cfg, path, body, titled, guess) {
62
69
  catch (err) {
63
70
  if (!(err instanceof CliError && err.status === 404))
64
71
  throw err;
65
- const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined };
72
+ const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined, team: team ?? undefined };
66
73
  return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
67
74
  if (err instanceof CliError && err.status === 412)
68
75
  return patch();
@@ -8,6 +8,9 @@ import { readConfig, stateDir } from "../config.js";
8
8
  import { viewPr } from "../gh.js";
9
9
  import { gitFacts } from "../git.js";
10
10
  import { estimateCost, loadPrices } from "../prices.js";
11
+ import { noteRefusal, resolveTeam, teamWarning } from "../team.js";
12
+ import { checkTests } from "../tests/check.js";
13
+ import { nudge } from "../tests/nudge.js";
11
14
  // Same text in the OpenCode plugin (cli/opencode/berliner.js).
12
15
  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
16
  const TOOLS = ["claude-code", "codex", "opencode"];
@@ -60,7 +63,7 @@ export function derivedTitle(firstPrompt, git) {
60
63
  const line = firstPrompt?.split("\n")[0].trim();
61
64
  return (line && Array.from(line).slice(0, 80).join("")) || git.branch || git.repository;
62
65
  }
63
- // `berliner hook <tool>` runs inside the agent tool: it never prints (bar the reminder), never fails and never makes the agent
66
+ // `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
67
  // wait. The work happens in a detached child (`--run`), and every error goes to hook.log.
65
68
  export async function hook(env, positionals, values) {
66
69
  const tool = positionals[0];
@@ -97,6 +100,17 @@ async function start(tool, env) {
97
100
  // The id names the payload file.
98
101
  if (typeof id !== "string" || !SAFE_ID.test(id))
99
102
  throw new Error("the payload has no usable session id");
103
+ // Before the report: the note has to reach the agent while its turn can still continue. It never stops the report.
104
+ if (p.hook_event_name === "Stop") {
105
+ try {
106
+ const line = await nudge(tool, { ...p, session_id: id }, env);
107
+ if (line)
108
+ console.log(line);
109
+ }
110
+ catch (err) {
111
+ log(env, `${tool} ${id}: nudge: ${err.message}`);
112
+ }
113
+ }
100
114
  const dir = join(stateDir(env), "hook");
101
115
  // Payloads name the user's projects and sessions: only the user may read them.
102
116
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -212,6 +226,26 @@ async function send(tool, file, env) {
212
226
  // The repository is required, and it is how Berliner groups work.
213
227
  if (!git.repository)
214
228
  throw new Error(`no GitHub repository at ${cwd}, not reported`);
229
+ const repository = git.repository;
230
+ // A bad .berliner.json throws: logged, and the report waits for it to be fixed.
231
+ const team = (await resolveTeam({}, cwd, env)) ?? undefined;
232
+ // The full check, whatever the nudge's opt-out. Nothing to check sends nulls, which clear an earlier verdict. A check that
233
+ // throws (logged) or runs out of time leaves the four fields out, so the stored verdict stays. OpenCode's payload has no
234
+ // transcript.
235
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
236
+ const deadline = Date.now() + 5000;
237
+ const check = transcriptPath
238
+ ? await checkTests({ tool, transcriptPath, cwd, startedAt: facts.startedAt, deadline }).then(
239
+ // checkTests also gives null when time runs out. A git call killed by its timeout can return a few ms before the
240
+ // wall clock reaches the deadline, hence the margin: a null that close to it is taken as a timeout.
241
+ (c) => c ?? (Date.now() >= deadline - 50 ? undefined : null), (err) => {
242
+ log(env, `${tool} ${sessionId}: test check: ${err.message}`);
243
+ return undefined;
244
+ })
245
+ : null;
246
+ const tests = check === undefined
247
+ ? {}
248
+ : { test_check: check?.verdict ?? null, test_command: check?.command ?? null, head_sha: check?.headSha ?? null, tree_clean: check?.treeClean ?? false };
215
249
  const body = {
216
250
  // Unknown without prices: left out, so a cost already saved stays.
217
251
  cost_usd: facts.costUsd ?? undefined,
@@ -223,6 +257,7 @@ async function send(tool, file, env) {
223
257
  // The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
224
258
  detected_task_id: facts.taskUrl?.split("/").pop(),
225
259
  detected_task_url: facts.taskUrl ?? undefined,
260
+ ...tests,
226
261
  };
227
262
  const cfg = readConfig(env);
228
263
  const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
@@ -236,9 +271,12 @@ async function send(tool, file, env) {
236
271
  // The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
237
272
  // session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
238
273
  // 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) => {
274
+ 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
275
  if (err instanceof CliError && err.status === 412)
241
276
  return request(cfg, "PATCH", path, body);
277
+ // Never a personal session instead: the next foreground command in this repository prints the refusal.
278
+ if (err instanceof CliError && err.status === 422 && err.code?.startsWith("team_"))
279
+ noteRefusal(env, repository, err.message);
242
280
  throw err;
243
281
  });
244
282
  try {
@@ -252,6 +290,9 @@ async function send(tool, file, env) {
252
290
  log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
253
291
  }
254
292
  }
293
+ const mismatch = teamWarning(saved.json.session.team, team ?? null);
294
+ if (mismatch)
295
+ log(env, `${tool} ${sessionId}: ${mismatch}`);
255
296
  await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
256
297
  }
257
298
  // Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
@@ -266,7 +307,7 @@ async function refreshPrs(cfg, path, prs, env, where) {
266
307
  })));
267
308
  const changed = open.flatMap((p, i) => {
268
309
  const s = seen[i];
269
- return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state) ? [{ url: p.url, ...s }] : [];
310
+ 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
311
  });
271
312
  if (changed.length)
272
313
  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
+ }
@@ -1,10 +1,12 @@
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, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
4
+ import { announceRefusal, resolveTeam } from "../team.js";
5
+ import { fromTool, patchFirst, projectHint, saveSession, sessionBody, warnTeam } from "./body.js";
5
6
  import { inTool } from "./hook.js";
6
7
  const required = ["session-id", "title", "repo", "cost", "model", "plan"];
7
8
  export async function report(cfg, values, positionals, env) {
9
+ await announceRefusal(env);
8
10
  // Usually an unquoted value with spaces, e.g. --title Fix the bug.
9
11
  if (positionals.length)
10
12
  throw new CliError(`unexpected argument "${positionals[0]}"; quote values that contain spaces`, "usage");
@@ -19,16 +21,18 @@ export async function report(cfg, values, positionals, env) {
19
21
  throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
20
22
  if (values.public && !values.evidence?.length)
21
23
  throw new CliError("--public needs --evidence", "usage");
24
+ const team = await resolveTeam(values, process.cwd(), env);
22
25
  const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
23
26
  const id = values["session-id"];
24
27
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
25
28
  // Inside a tool a report without --title is usually a follow-up: the derived title is only for a new session. A report
26
29
  // without a cost must not replace the saved one either.
27
30
  const { json: saved, dropped } = await (tool && (!titled || body.cost_usd === undefined)
28
- ? patchFirst(cfg, path, body, titled, values.plan)
29
- : saveSession(cfg, "PUT", path, body, values.plan));
31
+ ? patchFirst(cfg, path, body, titled, values.plan, team)
32
+ : saveSession(cfg, "PUT", path, { ...body, team: team ?? undefined }, values.plan));
30
33
  const json = dropped.length ? { ...saved, dropped } : saved;
31
34
  const { created, session } = json;
35
+ warnTeam(session, team);
32
36
  const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.${projectHint(session)}`;
33
37
  const json_ = values.json === true;
34
38
  // Human mode prints the session line before uploads start; --json waits so stdout is one document.
@@ -1,6 +1,8 @@
1
1
  import { absolute, CliError, request } from "../client.js";
2
2
  import { mergeLabel, print } from "../output.js";
3
+ import { announceRefusal } from "../team.js";
3
4
  export async function session(cfg, values, positionals) {
5
+ await announceRefusal(process.env);
4
6
  if (positionals.length !== 1)
5
7
  throw new CliError("usage: berliner session <session-id> [--json]", "usage");
6
8
  const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(positionals[0])}`);
@@ -19,6 +21,7 @@ export async function session(cfg, values, positionals) {
19
21
  ["Duration (s)", s.duration_seconds],
20
22
  ["Commits", s.commits],
21
23
  ["Coverage", s.coverage],
24
+ ["Tests", s.tests && `${s.tests.check}${s.tests.command ? ` (${s.tests.command})` : ""}${s.tests.by_ci ? " via CI" : ""}`],
22
25
  ["CI", s.ci],
23
26
  ["Merge", mergeLabel(s)],
24
27
  ];
@@ -1,8 +1,10 @@
1
1
  import { request } from "../client.js";
2
2
  import { mergeLabel, print } from "../output.js";
3
+ import { announceRefusal } from "../team.js";
3
4
  import { filterQuery } from "./query.js";
4
5
  const usd = (n) => `$${n.toFixed(2)}`;
5
6
  export async function sessions(cfg, values) {
7
+ await announceRefusal(process.env);
6
8
  const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
7
9
  const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
8
10
  for (const s of json.sessions)
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { CliError, request } from "../client.js";
7
+ import { askNudge } from "./nudge.js";
7
8
  import { configPath, DEFAULT_URL, readConfig, readConfigFile, saveConfig } from "../config.js";
8
9
  import { machineName, openBrowser, startConnect, waitForApproval } from "../setup/connect.js";
9
10
  import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, hookPath, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
@@ -90,6 +91,7 @@ export async function setup(env, values) {
90
91
  throw new CliError("There is no terminal to ask on. Run setup again with --yes to continue without asking.", "usage");
91
92
  if (!(await confirm(process.stdin, process.stdout)))
92
93
  throw new CliError("Cancelled. Nothing was changed.", "usage");
94
+ await askNudge(env);
93
95
  console.log();
94
96
  }
95
97
  if (!login) {
@@ -109,7 +111,8 @@ export async function setup(env, values) {
109
111
  throw new CliError("The request expired. Run setup again.", "http");
110
112
  const me = await request({ url, token: req.token }, "GET", "/v1/me");
111
113
  console.log(`✓ Approved by ${me.json.github_login}`);
112
- const file = saveConfig(env, url === DEFAULT_URL ? { token: req.token } : { token: req.token, url });
114
+ const { team, nudge } = readConfigFile(env) ?? {};
115
+ const file = saveConfig(env, { token: req.token, url: url === DEFAULT_URL ? undefined : url, team, nudge });
113
116
  console.log(`✓ Saved token "${name}" to ${tilde(file)}`);
114
117
  }
115
118
  let installFailed = false;
@@ -0,0 +1,21 @@
1
+ import { CliError } from "../client.js";
2
+ import { readConfigFile, saveConfig } from "../config.js";
3
+ import { print } from "../output.js";
4
+ import { resolveTeamSource, UUID } from "../team.js";
5
+ const FROM = { flag: "--team", repo: ".berliner.json", env: "BERLINER_TEAM", config: "the global config", none: "" };
6
+ // The team for this directory: shown without an argument, saved in the global config with a uuid, removed with --none.
7
+ export async function team(env, values, positionals) {
8
+ if (positionals.length > 1 || (positionals.length && values.none))
9
+ throw new CliError("usage: berliner team [<uuid> | --none] [--json]", "usage");
10
+ if (positionals.length || values.none) {
11
+ const id = positionals[0];
12
+ if (id !== undefined && !UUID.test(id))
13
+ throw new CliError(`"${id}" is not a team id (a uuid)`, "usage");
14
+ const file = readConfigFile(env) ?? {};
15
+ saveConfig(env, { ...file, team: id });
16
+ return print({ data: { team: id ?? null }, text: id ? `Saved team ${id} in your global config.` : "Removed the team from your global config." }, values.json === true);
17
+ }
18
+ const resolved = await resolveTeamSource(values, process.cwd(), env);
19
+ const text = resolved.team ? `Team ${resolved.team} (from ${FROM[resolved.source]})` : "No team: sessions go on your own plan.";
20
+ print({ data: resolved, text }, values.json === true);
21
+ }
@@ -1,8 +1,10 @@
1
1
  import { CliError } from "../client.js";
2
2
  import { print } from "../output.js";
3
- import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
3
+ import { announceRefusal, resolveTeam } from "../team.js";
4
+ import { fromTool, patchFirst, projectHint, saveSession, sessionBody, warnTeam } from "./body.js";
4
5
  import { inTool } from "./hook.js";
5
6
  export async function update(cfg, values, positionals, env) {
7
+ await announceRefusal(env);
6
8
  const tool = inTool(env);
7
9
  // Inside a tool the session id is the tool's, so a positional one is ignored like --session-id.
8
10
  if (positionals.length !== 1 && !(tool && positionals.length === 0)) {
@@ -18,8 +20,10 @@ export async function update(cfg, values, positionals, env) {
18
20
  const id = tool ? tool.sessionId : positionals[0];
19
21
  // A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
20
22
  const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
23
+ const team = await resolveTeam(values, process.cwd(), env);
21
24
  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));
25
+ const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan, team) : saveSession(cfg, "PATCH", path, body, values.plan));
23
26
  const json = dropped.length ? { ...saved, dropped } : saved;
27
+ warnTeam(json.session, team);
24
28
  print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.${projectHint(json.session)}` }, values.json === true);
25
29
  }
@@ -6,6 +6,7 @@ import { gunzipSync } from "node:zlib";
6
6
  import { CliError } from "../client.js";
7
7
  import { hookPath, updatedHooks } from "../setup/hooks.js";
8
8
  import { detectTools, skillTargets } from "../setup/targets.js";
9
+ import { askNudge } from "./nudge.js";
9
10
  import { confirm, installCommand, showPlan, tilde } from "./setup.js";
10
11
  const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
11
12
  async function latestVersion(env) {
@@ -78,7 +79,7 @@ const sameAs = (path, text) => {
78
79
  };
79
80
  // What a hook gains from this release, for the plan.
80
81
  const HOOK_CHANGE = {
81
- "claude-code": "the reminder also runs on startup and resume",
82
+ "claude-code": "Stop hook no longer async, so Berliner can ask the agent to run the tests",
82
83
  codex: "the reminder also runs on startup and resume",
83
84
  opencode: "the new reminder, and totals saved in order",
84
85
  };
@@ -137,6 +138,7 @@ export async function upgrade(env, values) {
137
138
  // Without an install, the skill is all Continue covered.
138
139
  if (installing && stale.length && !(await confirm(process.stdin, process.stdout, "Upgrade the skill too? [Y/n] ")))
139
140
  stale = [];
141
+ await askNudge(env);
140
142
  console.log();
141
143
  }
142
144
  if (installing) {
package/dist/config.js CHANGED
@@ -7,14 +7,29 @@ export function configPath(env) {
7
7
  }
8
8
  export const stateDir = (env) => join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner");
9
9
  export const cacheDir = (env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "berliner");
10
+ function readNudge(raw) {
11
+ if (typeof raw !== "object" || raw === null)
12
+ return undefined;
13
+ const { off_all, off, asked } = raw;
14
+ return {
15
+ ...(asked === true && { asked: true }),
16
+ ...(off_all === true && { off_all: true }),
17
+ ...(Array.isArray(off) && { off: off.filter((r) => typeof r === "string") }),
18
+ };
19
+ }
10
20
  // A missing or unreadable file is the same as no file: the environment may still hold the token.
11
21
  export function readConfigFile(env) {
12
22
  try {
13
23
  const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
14
24
  if (typeof parsed !== "object" || parsed === null)
15
25
  return null;
16
- const { token, url } = parsed;
17
- return { token: typeof token === "string" ? token : undefined, url: typeof url === "string" ? url : undefined };
26
+ const { token, url, team, nudge } = parsed;
27
+ return {
28
+ token: typeof token === "string" ? token : undefined,
29
+ url: typeof url === "string" ? url : undefined,
30
+ team: typeof team === "string" ? team : undefined,
31
+ nudge: readNudge(nudge),
32
+ };
18
33
  }
19
34
  catch {
20
35
  return null;
package/dist/gh.js CHANGED
@@ -5,8 +5,8 @@ const MERGE = { OPEN: "open", MERGED: "merged", CLOSED: "closed" };
5
5
  // CheckRun conclusions and StatusContext states.
6
6
  const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
7
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).
8
+ // `gh pr view --json state,statusCheckRollup,headRefOid` as Berliner's merge and CI states and the head commit they belong to.
9
+ // statusCheckRollup mixes CheckRuns (status, conclusion; conclusion is "" until completed) and commit StatusContexts (state).
10
10
  export function prState(view) {
11
11
  const merge_state = MERGE[String(view.state)];
12
12
  if (!merge_state)
@@ -19,10 +19,12 @@ export function prState(view) {
19
19
  : checks.length
20
20
  ? "passed"
21
21
  : "none";
22
- return { merge_state, ci_state };
22
+ // Anything else the server would refuse, losing the states with it.
23
+ const head_sha = typeof view.headRefOid === "string" && /^[0-9a-f]{40}$/.test(view.headRefOid) ? view.headRefOid : null;
24
+ return { merge_state, ci_state, head_sha };
23
25
  }
24
26
  // The machine's gh, found on PATH; it rejects when gh is missing (code ENOENT), fails or takes over 5 seconds.
25
27
  export async function viewPr(url) {
26
- const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup"], { timeout: 5000 });
28
+ const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup,headRefOid"], { timeout: 5000 });
27
29
  return prState(JSON.parse(stdout));
28
30
  }
package/dist/index.js CHANGED
@@ -6,12 +6,14 @@ import { CliError } from "./client.js";
6
6
  import { del } from "./commands/delete.js";
7
7
  import { evidence } from "./commands/evidence.js";
8
8
  import { hook } from "./commands/hook.js";
9
+ import { nudge } from "./commands/nudge.js";
9
10
  import { project, projects } from "./commands/projects.js";
10
11
  import { report } from "./commands/report.js";
11
12
  import { session } from "./commands/session.js";
12
13
  import { sessions } from "./commands/sessions.js";
13
14
  import { setup } from "./commands/setup.js";
14
15
  import { skill } from "./commands/skill.js";
16
+ import { team } from "./commands/team.js";
15
17
  import { summary } from "./commands/summary.js";
16
18
  import { update } from "./commands/update.js";
17
19
  import { upgrade } from "./commands/upgrade.js";
@@ -33,6 +35,11 @@ Commands:
33
35
  projects the project names in use, then each repository's default [--json]
34
36
  project [<name>] show or save this repository's default project; --none saves "no project"
35
37
  [--none --repo <owner/name> --json]
38
+ team [<uuid>] show or save the team new sessions are reported to; --none clears it
39
+ a team can also come from --team <uuid> / --no-team, .berliner.json {"team": "<uuid>"}, BERLINER_TEAM
40
+ [--none --json]
41
+ nudge [on|off] show or set whether Berliner asks agents to run the tests before they hand work back, for this repository
42
+ [--all for every repository]
36
43
  delete <session-id> [--evidence <id>] [--yes]
37
44
  evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
38
45
  evidence private <id> make it viewable only by you again [--json]
@@ -40,7 +47,7 @@ Commands:
40
47
  skill print the bundled SKILL.md
41
48
  hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
42
49
 
43
- Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
50
+ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan), BERLINER_TEAM`;
44
51
  const args = parse(process.argv.slice(2));
45
52
  // Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
46
53
  const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
@@ -53,6 +60,8 @@ const handled = new Set([
53
60
  "summary",
54
61
  "projects",
55
62
  "project",
63
+ "team",
64
+ "nudge",
56
65
  "delete",
57
66
  "evidence",
58
67
  "skill",
@@ -84,6 +93,10 @@ try {
84
93
  await projects(cfg(), args.values);
85
94
  else if (args.command === "project")
86
95
  await project(cfg(), args.values, args.positionals);
96
+ else if (args.command === "team")
97
+ await team(process.env, args.values, args.positionals);
98
+ else if (args.command === "nudge")
99
+ await nudge(process.env, args.values, args.positionals);
87
100
  else if (args.command === "delete")
88
101
  await del(cfg(), args.values, args.positionals);
89
102
  else if (args.command === "evidence")