@paxnax/berliner 0.5.2 → 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 +1 -0
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +25 -9
- package/dist/agents/read.js +44 -8
- package/dist/args.js +3 -0
- package/dist/client.js +4 -2
- package/dist/commands/body.js +22 -6
- package/dist/commands/hook.js +48 -5
- package/dist/commands/nudge.js +64 -0
- package/dist/commands/report.js +7 -3
- package/dist/commands/session.js +3 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup.js +4 -1
- package/dist/commands/team.js +21 -0
- package/dist/commands/update.js +6 -2
- package/dist/commands/upgrade.js +25 -13
- package/dist/config.js +17 -2
- package/dist/gh.js +6 -4
- package/dist/index.js +16 -2
- package/dist/setup/hooks.js +36 -18
- package/dist/team.js +99 -0
- package/dist/tests/check.js +108 -0
- package/dist/tests/command.js +72 -0
- package/dist/tests/nudge.js +68 -0
- package/dist/tests/runs.js +195 -0
- package/opencode/berliner.js +10 -2
- package/package.json +10 -1
- package/skill/SKILL.md +32 -0
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.
|
package/dist/agents/claude.js
CHANGED
|
@@ -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
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
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
|
+
const tokensOf = (u) => ({
|
|
4
|
+
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
5
|
+
output: num(u.output_tokens),
|
|
6
|
+
cacheRead: num(u.cached_input_tokens),
|
|
7
|
+
cacheWrite: num(u.cache_write_input_tokens),
|
|
8
|
+
cacheWrite1h: 0,
|
|
9
|
+
});
|
|
3
10
|
// Line shapes as Codex 0.162.1 writes them (codex-rs/protocol). input_tokens includes both cached reads and cache writes,
|
|
4
11
|
// as in Codex's own parsing test (100 input = 40 cached + 60 written); reasoning_output_tokens is part of output_tokens.
|
|
5
12
|
export async function readCodex(rolloutPath) {
|
|
@@ -11,11 +18,23 @@ export async function readCodex(rolloutPath) {
|
|
|
11
18
|
let latestTotal = 0;
|
|
12
19
|
let latestLast = 0;
|
|
13
20
|
const byModel = {};
|
|
21
|
+
// 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();
|
|
14
25
|
for await (const e of jsonl(rolloutPath)) {
|
|
15
26
|
span.add(e.timestamp);
|
|
16
27
|
const p = e.payload;
|
|
17
28
|
if (e.type === "turn_context" && typeof p?.model === "string")
|
|
18
29
|
model = p.model;
|
|
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
|
+
}
|
|
37
|
+
}
|
|
19
38
|
if (e.type !== "event_msg")
|
|
20
39
|
continue;
|
|
21
40
|
if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
|
|
@@ -32,16 +51,10 @@ export async function readCodex(rolloutPath) {
|
|
|
32
51
|
const last = num(p?.info?.last_token_usage?.total_tokens);
|
|
33
52
|
// A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
|
|
34
53
|
// A repeat of the previous event, last request included, is a duplicate notification.
|
|
35
|
-
// shortcut: a restart after a one-request run of the same size reads as a duplicate;
|
|
54
|
+
// shortcut: without usage records, a restart after a one-request run of the same size reads as a duplicate; drop this path once pre-0.148 rollouts no longer matter.
|
|
36
55
|
const dup = total === latestTotal && last === latestLast;
|
|
37
56
|
const restart = total < latestTotal || (latestTotal > 0 && total === last && !dup);
|
|
38
|
-
const cur =
|
|
39
|
-
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
40
|
-
output: num(u.output_tokens),
|
|
41
|
-
cacheRead: num(u.cached_input_tokens),
|
|
42
|
-
cacheWrite: num(u.cache_write_input_tokens),
|
|
43
|
-
cacheWrite1h: 0,
|
|
44
|
-
};
|
|
57
|
+
const cur = tokensOf(u);
|
|
45
58
|
// Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
|
|
46
59
|
const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
|
|
47
60
|
if (model)
|
|
@@ -50,5 +63,8 @@ export async function readCodex(rolloutPath) {
|
|
|
50
63
|
latestLast = last;
|
|
51
64
|
latest = cur;
|
|
52
65
|
}
|
|
53
|
-
|
|
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 };
|
|
54
70
|
}
|
package/dist/agents/read.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
value
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/body.js
CHANGED
|
@@ -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
|
: "";
|
|
@@ -50,15 +57,24 @@ export async function fromTool(tool, values, env, command) {
|
|
|
50
57
|
}
|
|
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
|
-
|
|
54
|
-
|
|
60
|
+
// Create-only: when another report created the session since the 404 (412), the PATCH after all, so its title and cost stay.
|
|
61
|
+
export async function patchFirst(cfg, path, body, titled, guess, team = null) {
|
|
62
|
+
const patch = async () => {
|
|
55
63
|
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
56
64
|
return { ...res, json: { created: false, ...res.json } };
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
return await patch();
|
|
57
68
|
}
|
|
58
69
|
catch (err) {
|
|
59
70
|
if (!(err instanceof CliError && err.status === 404))
|
|
60
71
|
throw err;
|
|
61
|
-
|
|
72
|
+
const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined, team: team ?? undefined };
|
|
73
|
+
return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
|
|
74
|
+
if (err instanceof CliError && err.status === 412)
|
|
75
|
+
return patch();
|
|
76
|
+
throw err;
|
|
77
|
+
});
|
|
62
78
|
}
|
|
63
79
|
}
|
|
64
80
|
function number(values, flag) {
|
|
@@ -127,7 +143,7 @@ function prefilterPrs(body) {
|
|
|
127
143
|
// `guess`, the agent's --plan, in that same retry; without --plan it is left out, so a new session gets the server's pick.
|
|
128
144
|
// Anything else is thrown as before. Warnings are printed only once the save succeeded, so a failed save never claims
|
|
129
145
|
// fields were dropped.
|
|
130
|
-
export async function saveSession(cfg, method, path, input, guess) {
|
|
146
|
+
export async function saveSession(cfg, method, path, input, guess, headers) {
|
|
131
147
|
const first = prefilterPrs(input);
|
|
132
148
|
const dropped = first.dropped;
|
|
133
149
|
const fromEnv = cfg.plan !== null && input.plan === cfg.plan && guess !== cfg.plan;
|
|
@@ -142,7 +158,7 @@ export async function saveSession(cfg, method, path, input, guess) {
|
|
|
142
158
|
return { ...res, dropped };
|
|
143
159
|
};
|
|
144
160
|
try {
|
|
145
|
-
return finish(await request(cfg, method, path, first.body));
|
|
161
|
+
return finish(await request(cfg, method, path, first.body, { headers }));
|
|
146
162
|
}
|
|
147
163
|
catch (err) {
|
|
148
164
|
const fieldErrors = err instanceof CliError && err.status === 422 ? err.details?.fieldErrors : undefined;
|
|
@@ -155,6 +171,6 @@ export async function saveSession(cfg, method, path, input, guess) {
|
|
|
155
171
|
delete retry[field];
|
|
156
172
|
dropped.push({ field, reason: field === "prs" ? `all PRs dropped: ${[fieldErrors[field]].flat().join(", ")}` : [fieldErrors[field]].flat().join(", ") });
|
|
157
173
|
}
|
|
158
|
-
return finish(await request(cfg, method, path, retry));
|
|
174
|
+
return finish(await request(cfg, method, path, retry, { headers }));
|
|
159
175
|
}
|
|
160
176
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -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 });
|
|
@@ -105,8 +119,10 @@ async function start(tool, env) {
|
|
|
105
119
|
// shortcut: payload files are kept, one small file per session, as a child can't tell whether a newer one is on its way.
|
|
106
120
|
writeFileSync(`${file}.${process.pid}`, text);
|
|
107
121
|
renameSync(`${file}.${process.pid}`, file);
|
|
108
|
-
// No stdio and its own process group: the tool waits on this process only, and the child outlives it.
|
|
109
|
-
|
|
122
|
+
// No stdio and its own process group: the tool waits on this process only, and the child outlives it. Bun gets the
|
|
123
|
+
// launcher's flags, so the cwd's .env and bunfig.toml stay out of it (see index.ts).
|
|
124
|
+
const bun = process.versions.bun ? ["--config=/dev/null", "--env-file=/dev/null"] : [];
|
|
125
|
+
spawn(process.execPath, [...bun, process.argv[1], "hook", tool, "--run", file], { detached: true, stdio: "ignore" })
|
|
110
126
|
.on("error", (err) => log(env, `${tool} ${id}: ${err.message}`))
|
|
111
127
|
.unref();
|
|
112
128
|
}
|
|
@@ -210,6 +226,26 @@ async function send(tool, file, env) {
|
|
|
210
226
|
// The repository is required, and it is how Berliner groups work.
|
|
211
227
|
if (!git.repository)
|
|
212
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 };
|
|
213
249
|
const body = {
|
|
214
250
|
// Unknown without prices: left out, so a cost already saved stays.
|
|
215
251
|
cost_usd: facts.costUsd ?? undefined,
|
|
@@ -221,6 +257,7 @@ async function send(tool, file, env) {
|
|
|
221
257
|
// The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
|
|
222
258
|
detected_task_id: facts.taskUrl?.split("/").pop(),
|
|
223
259
|
detected_task_url: facts.taskUrl ?? undefined,
|
|
260
|
+
...tests,
|
|
224
261
|
};
|
|
225
262
|
const cfg = readConfig(env);
|
|
226
263
|
const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
|
|
@@ -234,9 +271,12 @@ async function send(tool, file, env) {
|
|
|
234
271
|
// The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
|
|
235
272
|
// session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
|
|
236
273
|
// only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
|
|
237
|
-
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) => {
|
|
238
275
|
if (err instanceof CliError && err.status === 412)
|
|
239
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);
|
|
240
280
|
throw err;
|
|
241
281
|
});
|
|
242
282
|
try {
|
|
@@ -250,6 +290,9 @@ async function send(tool, file, env) {
|
|
|
250
290
|
log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
|
|
251
291
|
}
|
|
252
292
|
}
|
|
293
|
+
const mismatch = teamWarning(saved.json.session.team, team ?? null);
|
|
294
|
+
if (mismatch)
|
|
295
|
+
log(env, `${tool} ${sessionId}: ${mismatch}`);
|
|
253
296
|
await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
|
|
254
297
|
}
|
|
255
298
|
// Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
|
|
@@ -264,7 +307,7 @@ async function refreshPrs(cfg, path, prs, env, where) {
|
|
|
264
307
|
})));
|
|
265
308
|
const changed = open.flatMap((p, i) => {
|
|
266
309
|
const s = seen[i];
|
|
267
|
-
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 }] : [];
|
|
268
311
|
});
|
|
269
312
|
if (changed.length)
|
|
270
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
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -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 {
|
|
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.
|
package/dist/commands/session.js
CHANGED
|
@@ -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)
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { CliError } from "../client.js";
|
|
2
2
|
import { print } from "../output.js";
|
|
3
|
-
import {
|
|
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
|
}
|