@paxnax/berliner 0.0.0-stage → 0.1.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/dist/args.js +42 -0
- package/dist/client.js +65 -0
- package/dist/commands/body.js +108 -0
- package/dist/commands/delete.js +35 -0
- package/dist/commands/query.js +25 -0
- package/dist/commands/report.js +40 -0
- package/dist/commands/session.js +33 -0
- package/dist/commands/sessions.js +17 -0
- package/dist/commands/skill.js +5 -0
- package/dist/commands/summary.js +15 -0
- package/dist/commands/update.js +12 -0
- package/dist/config.js +4 -0
- package/dist/index.js +59 -0
- package/dist/output.js +10 -0
- package/dist/upload.js +94 -0
- package/package.json +27 -4
- package/skill/SKILL.md +66 -0
- package/README.md +0 -3
package/dist/args.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
const config = {
|
|
3
|
+
options: {
|
|
4
|
+
"session-id": { type: "string" },
|
|
5
|
+
title: { type: "string" },
|
|
6
|
+
repo: { type: "string" },
|
|
7
|
+
cost: { type: "string" },
|
|
8
|
+
model: { type: "string" },
|
|
9
|
+
plan: { type: "string" },
|
|
10
|
+
task: { type: "string" },
|
|
11
|
+
"task-url": { type: "string" },
|
|
12
|
+
project: { type: "string" },
|
|
13
|
+
"started-at": { type: "string" },
|
|
14
|
+
"ended-at": { type: "string" },
|
|
15
|
+
commits: { type: "string" },
|
|
16
|
+
coverage: { type: "string" },
|
|
17
|
+
pr: { type: "string", multiple: true },
|
|
18
|
+
evidence: { type: "string", multiple: true },
|
|
19
|
+
json: { type: "boolean" },
|
|
20
|
+
strict: { type: "boolean" },
|
|
21
|
+
help: { type: "boolean" },
|
|
22
|
+
yes: { type: "boolean" },
|
|
23
|
+
ci: { type: "string" },
|
|
24
|
+
merge: { type: "string" },
|
|
25
|
+
since: { type: "string" },
|
|
26
|
+
from: { type: "string" },
|
|
27
|
+
to: { type: "string" },
|
|
28
|
+
limit: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
};
|
|
32
|
+
export function parse(argv) {
|
|
33
|
+
try {
|
|
34
|
+
const { values, positionals } = parseArgs({ ...config, args: argv });
|
|
35
|
+
return { command: positionals[0], positionals: positionals.slice(1), values, error: null };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// Parse again leniently so the caller still knows the command and --strict, which decide the exit code.
|
|
39
|
+
const { values, positionals } = parseArgs({ ...config, args: argv, strict: false });
|
|
40
|
+
return { command: positionals[0], positionals: [], values: { strict: values.strict === true }, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
status;
|
|
4
|
+
details;
|
|
5
|
+
constructor(message, kind, status, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function request(cfg, method, path, body, opts = {}) {
|
|
13
|
+
if (!cfg.token)
|
|
14
|
+
throw new CliError("BERLINER_TOKEN is not set", "usage");
|
|
15
|
+
const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000 } = opts;
|
|
16
|
+
// One budget for all attempts, so a hung server can't hold the agent's pipeline for long (anti-goal 1).
|
|
17
|
+
const deadline = Date.now() + deadlineMs;
|
|
18
|
+
const raw = body instanceof Uint8Array;
|
|
19
|
+
for (let attempt = 0;; attempt++) {
|
|
20
|
+
// 500 ms, 1 s, 2 s, ... A retry whose backoff would end at or after the deadline is not attempted.
|
|
21
|
+
const backoffMs = 500 * 2 ** attempt;
|
|
22
|
+
const canRetry = () => attempt < retries && Date.now() + backoffMs < deadline;
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(cfg.url.replace(/\/+$/, "") + path, {
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
29
|
+
...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
|
|
30
|
+
},
|
|
31
|
+
// A Uint8Array is sent as-is (evidence bytes); fetch sets its Content-Length.
|
|
32
|
+
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
|
33
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(timeoutMs, deadline - Date.now()))),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (!canRetry())
|
|
38
|
+
throw new CliError(`could not reach ${cfg.url} (${reason(err)})`, "network");
|
|
39
|
+
await sleep(backoffMs);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if ((res.status === 429 || res.status >= 500) && canRetry()) {
|
|
43
|
+
await res.body?.cancel();
|
|
44
|
+
await sleep(backoffMs);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const json = (await res.json().catch(() => null));
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new CliError(errorMessage(method, path, res.status, json), "http", res.status, json?.error?.details);
|
|
50
|
+
return { status: res.status, json };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
function reason(err) {
|
|
55
|
+
const e = err;
|
|
56
|
+
return e.cause?.code ?? e.cause?.message ?? e.message;
|
|
57
|
+
}
|
|
58
|
+
// Validation errors list the failing fields, so an agent can correct its flags.
|
|
59
|
+
function errorMessage(method, path, status, json) {
|
|
60
|
+
const error = json?.error;
|
|
61
|
+
if (typeof error?.message !== "string")
|
|
62
|
+
return `${method} ${path} returned HTTP ${status}`;
|
|
63
|
+
const fields = Object.entries(error.details?.fieldErrors ?? {}).map(([field, msgs]) => `${field}: ${[msgs].flat().join(", ")}`);
|
|
64
|
+
return [error.message, ...fields].join("; ");
|
|
65
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CliError, request } from "../client.js";
|
|
2
|
+
import { warn } from "../output.js";
|
|
3
|
+
// Shared by report (PUT) and update (PATCH): unset flags are undefined and drop out of the JSON.
|
|
4
|
+
export function sessionBody(values) {
|
|
5
|
+
return {
|
|
6
|
+
title: values.title,
|
|
7
|
+
repository: values.repo,
|
|
8
|
+
cost_usd: number(values, "cost"),
|
|
9
|
+
model: values.model,
|
|
10
|
+
plan: values.plan,
|
|
11
|
+
task_id: values.task,
|
|
12
|
+
task_url: values["task-url"],
|
|
13
|
+
project: values.project,
|
|
14
|
+
started_at: values["started-at"],
|
|
15
|
+
ended_at: values["ended-at"],
|
|
16
|
+
commits: number(values, "commits"),
|
|
17
|
+
coverage: number(values, "coverage"),
|
|
18
|
+
prs: values.pr?.map(parsePr),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function number(values, flag) {
|
|
22
|
+
const raw = values[flag];
|
|
23
|
+
if (raw === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
const n = Number(raw);
|
|
26
|
+
if (raw.trim() === "" || !Number.isFinite(n))
|
|
27
|
+
throw new CliError(`--${flag} must be a number, got "${raw}"`, "usage");
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
// <url>[,merge=<state>][,ci=<state>]. A malformed suffix is recorded as `problem`, not thrown, so prefilterPrs
|
|
31
|
+
// can drop that one entry instead of the command aborting and the session being lost.
|
|
32
|
+
function parsePr(spec) {
|
|
33
|
+
const [url, ...parts] = spec.split(",");
|
|
34
|
+
const pr = { url };
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
const m = /^(merge|ci)=(.+)$/.exec(part);
|
|
37
|
+
if (m)
|
|
38
|
+
pr[m[1] === "merge" ? "merge_state" : "ci_state"] = m[2];
|
|
39
|
+
else
|
|
40
|
+
pr.problem ??= `expected <url>[,merge=<state>][,ci=<state>], got ",${part}"`;
|
|
41
|
+
}
|
|
42
|
+
return pr;
|
|
43
|
+
}
|
|
44
|
+
// Mirrors the server's rules (parsePrUrl and the prs schema in app/src/worker), so bad entries never cost the session.
|
|
45
|
+
const PR_URL = /^https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/(\d+)(?:[/?#].*)?$/i;
|
|
46
|
+
export const isPrUrl = (url) => {
|
|
47
|
+
const n = Number(PR_URL.exec(url)?.[1]);
|
|
48
|
+
return n >= 1 && n <= 2147483647;
|
|
49
|
+
};
|
|
50
|
+
const MERGE_STATES = ["open", "merged", "closed"];
|
|
51
|
+
const CI_STATES = ["pending", "running", "passed", "failed", "none"];
|
|
52
|
+
const MAX_PRS = 50;
|
|
53
|
+
// Fields whose absence still leaves a trustworthy session; the rest are required (anti-goal 2).
|
|
54
|
+
const optional = ["task_id", "task_url", "project", "started_at", "ended_at", "commits", "coverage", "prs"];
|
|
55
|
+
// R49: drop the --pr entries the server would reject, so they never cost the session.
|
|
56
|
+
function prefilterPrs(body) {
|
|
57
|
+
if (!body.prs)
|
|
58
|
+
return { body, dropped: [] };
|
|
59
|
+
const dropped = [];
|
|
60
|
+
const drop = (url, why) => {
|
|
61
|
+
dropped.push({ field: "prs", reason: `${url}: ${why}` });
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
let valid = 0;
|
|
65
|
+
const kept = body.prs.filter((p) => {
|
|
66
|
+
if (p.problem)
|
|
67
|
+
return drop(p.url, p.problem);
|
|
68
|
+
if (!isPrUrl(p.url))
|
|
69
|
+
return drop(p.url, "Not a GitHub pull request URL");
|
|
70
|
+
if (p.merge_state !== undefined && !MERGE_STATES.includes(p.merge_state))
|
|
71
|
+
return drop(p.url, `merge must be ${MERGE_STATES.join(", ")}`);
|
|
72
|
+
if (p.ci_state !== undefined && !CI_STATES.includes(p.ci_state))
|
|
73
|
+
return drop(p.url, `ci must be ${CI_STATES.join(", ")}`);
|
|
74
|
+
// The cap counts valid entries only, so a bad entry never displaces a good one.
|
|
75
|
+
if (++valid > MAX_PRS)
|
|
76
|
+
return drop(p.url, `more than ${MAX_PRS} PRs`);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
// Nothing left: omit prs rather than send [], which could clear PRs already saved.
|
|
80
|
+
return { body: { ...body, prs: kept.length ? kept : undefined }, dropped };
|
|
81
|
+
}
|
|
82
|
+
// R46/R49: one invalid optional value must not lose the session. Bad PRs are filtered first; on a 422 naming
|
|
83
|
+
// only optional fields, drop them and retry once. Anything else is thrown as before. Warnings are printed
|
|
84
|
+
// only once the save succeeded, so a failed save never claims fields were dropped.
|
|
85
|
+
export async function saveSession(cfg, method, path, input) {
|
|
86
|
+
const first = prefilterPrs(input);
|
|
87
|
+
const dropped = first.dropped;
|
|
88
|
+
const finish = (res) => {
|
|
89
|
+
for (const d of dropped)
|
|
90
|
+
warn(`${d.field}: ${d.reason}`);
|
|
91
|
+
return { ...res, dropped };
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
return finish(await request(cfg, method, path, first.body));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const fieldErrors = err instanceof CliError && err.status === 422 ? err.details?.fieldErrors : undefined;
|
|
98
|
+
const names = Object.keys(fieldErrors ?? {});
|
|
99
|
+
if (!fieldErrors || !names.length || !names.every((n) => optional.includes(n)))
|
|
100
|
+
throw err;
|
|
101
|
+
const retry = { ...first.body };
|
|
102
|
+
for (const field of names) {
|
|
103
|
+
delete retry[field];
|
|
104
|
+
dropped.push({ field, reason: field === "prs" ? `all PRs dropped: ${[fieldErrors[field]].flat().join(", ")}` : [fieldErrors[field]].flat().join(", ") });
|
|
105
|
+
}
|
|
106
|
+
return finish(await request(cfg, method, path, retry));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { CliError, request } from "../client.js";
|
|
3
|
+
export async function del(cfg, values, positionals) {
|
|
4
|
+
if (positionals.length !== 1)
|
|
5
|
+
throw new CliError("usage: berliner delete <session-id> [--evidence <id>] [--yes]", "usage");
|
|
6
|
+
const id = positionals[0];
|
|
7
|
+
const evidenceId = values.evidence?.[0];
|
|
8
|
+
if (evidenceId) {
|
|
9
|
+
// Evidence ids are global, so the session named on the command line must actually hold this one.
|
|
10
|
+
const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(id)}`);
|
|
11
|
+
if (!json.session.evidence.some((e) => String(e.id) === evidenceId)) {
|
|
12
|
+
throw new CliError(`evidence ${evidenceId} is not part of session ${id}`, "usage");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const question = evidenceId ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
|
|
16
|
+
if (!values.yes) {
|
|
17
|
+
if (!process.stdin.isTTY)
|
|
18
|
+
throw new CliError("refusing to delete without confirmation; pass --yes to delete without a prompt", "usage");
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
const answer = await rl.question(question);
|
|
21
|
+
rl.close();
|
|
22
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
23
|
+
console.log("Cancelled.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (evidenceId) {
|
|
28
|
+
await request(cfg, "DELETE", `/v1/evidence/${encodeURIComponent(evidenceId)}`);
|
|
29
|
+
console.log(`Deleted evidence ${evidenceId}.`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
await request(cfg, "DELETE", `/v1/sessions/${encodeURIComponent(id)}`);
|
|
33
|
+
console.log(`Deleted session ${id}.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CliError } from "../client.js";
|
|
2
|
+
// Filters shared by `sessions` and `summary`, mapped to the API's query string.
|
|
3
|
+
export function filterQuery(values, extra = {}) {
|
|
4
|
+
const q = new URLSearchParams();
|
|
5
|
+
const set = (k, v) => v !== undefined && q.set(k, v);
|
|
6
|
+
set("repository", values.repo);
|
|
7
|
+
set("model", values.model);
|
|
8
|
+
set("project", values.project);
|
|
9
|
+
set("ci", values.ci);
|
|
10
|
+
set("merge", values.merge);
|
|
11
|
+
if (values.since !== undefined) {
|
|
12
|
+
const m = /^(\d+)d$/.exec(values.since);
|
|
13
|
+
if (!m)
|
|
14
|
+
throw new CliError(`--since must look like 30d, got "${values.since}"`, "usage");
|
|
15
|
+
const to = new Date();
|
|
16
|
+
q.set("to", to.toISOString());
|
|
17
|
+
q.set("from", new Date(to.getTime() - Number(m[1]) * 864e5).toISOString());
|
|
18
|
+
}
|
|
19
|
+
set("from", values.from);
|
|
20
|
+
set("to", values.to);
|
|
21
|
+
q.set("tz", Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
22
|
+
for (const [k, v] of Object.entries(extra))
|
|
23
|
+
set(k, v);
|
|
24
|
+
return `?${q}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CliError } from "../client.js";
|
|
2
|
+
import { print, warn } from "../output.js";
|
|
3
|
+
import { uploadEvidence } from "../upload.js";
|
|
4
|
+
import { saveSession, sessionBody } from "./body.js";
|
|
5
|
+
const required = ["session-id", "title", "repo", "cost", "model", "plan"];
|
|
6
|
+
export async function report(cfg, values, positionals) {
|
|
7
|
+
// Usually an unquoted value with spaces, e.g. --title Fix the bug.
|
|
8
|
+
if (positionals.length)
|
|
9
|
+
throw new CliError(`unexpected argument "${positionals[0]}"; quote values that contain spaces`, "usage");
|
|
10
|
+
const missing = required.filter((flag) => values[flag] === undefined);
|
|
11
|
+
if (missing.length)
|
|
12
|
+
throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
|
|
13
|
+
const body = sessionBody(values);
|
|
14
|
+
const id = values["session-id"];
|
|
15
|
+
const { json: saved, dropped } = await saveSession(cfg, "PUT", `/v1/sessions/${encodeURIComponent(id)}`, body);
|
|
16
|
+
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
17
|
+
const { created, session } = json;
|
|
18
|
+
const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.`;
|
|
19
|
+
const json_ = values.json === true;
|
|
20
|
+
// Human mode prints the session line before uploads start; --json waits so stdout is one document.
|
|
21
|
+
if (!json_)
|
|
22
|
+
print({ data: json, text }, false);
|
|
23
|
+
if (!values.evidence?.length) {
|
|
24
|
+
if (json_)
|
|
25
|
+
print({ data: json, text }, true);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const result = await uploadEvidence(cfg, id, values.evidence.map(parseEvidence));
|
|
29
|
+
if (json_)
|
|
30
|
+
print({ data: { ...json, evidence: result }, text }, true);
|
|
31
|
+
else
|
|
32
|
+
console.log(`Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`);
|
|
33
|
+
for (const s of result.skipped)
|
|
34
|
+
warn(`${s.path}: ${s.reason}`);
|
|
35
|
+
}
|
|
36
|
+
// <path>[::<caption>]
|
|
37
|
+
function parseEvidence(spec) {
|
|
38
|
+
const i = spec.indexOf("::");
|
|
39
|
+
return i < 0 ? { path: spec } : { path: spec.slice(0, i), caption: spec.slice(i + 2) || undefined };
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CliError, request } from "../client.js";
|
|
2
|
+
import { mergeLabel, print } from "../output.js";
|
|
3
|
+
export async function session(cfg, values, positionals) {
|
|
4
|
+
if (positionals.length !== 1)
|
|
5
|
+
throw new CliError("usage: berliner session <session-id> [--json]", "usage");
|
|
6
|
+
const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(positionals[0])}`);
|
|
7
|
+
const s = json.session;
|
|
8
|
+
const fields = [
|
|
9
|
+
["Session", s.id],
|
|
10
|
+
["Title", s.title],
|
|
11
|
+
["Repository", s.repository],
|
|
12
|
+
["Task", [s.task_id, s.task_url].filter(Boolean).join(" ")],
|
|
13
|
+
["Project", s.project],
|
|
14
|
+
["Estimated cost", `$${s.cost_usd.toFixed(2)}`],
|
|
15
|
+
["Model", s.model],
|
|
16
|
+
["Plan", s.plan],
|
|
17
|
+
["Started", s.started_at],
|
|
18
|
+
["Ended", s.ended_at],
|
|
19
|
+
["Duration (s)", s.duration_seconds],
|
|
20
|
+
["Commits", s.commits],
|
|
21
|
+
["Coverage", s.coverage],
|
|
22
|
+
["CI", s.ci],
|
|
23
|
+
["Merge", mergeLabel(s)],
|
|
24
|
+
];
|
|
25
|
+
const lines = fields.filter(([, v]) => v !== null && v !== undefined && v !== "").map(([k, v]) => `${k}: ${v}`);
|
|
26
|
+
lines.push(`Pull requests: ${s.prs.length || "none"}`);
|
|
27
|
+
for (const p of s.prs)
|
|
28
|
+
lines.push(` ${p.url} merge=${p.merge_state} ci=${p.ci_state}${p.tracked ? "" : " (not tracked)"}`);
|
|
29
|
+
lines.push(`Evidence: ${s.evidence.length || "none"}`);
|
|
30
|
+
for (const e of s.evidence)
|
|
31
|
+
lines.push(` #${e.id} ${e.kind} ${e.filename}${e.caption ? ` - ${e.caption}` : ""}${e.status === "ready" ? "" : ` (${e.status})`}`);
|
|
32
|
+
print({ data: json, text: lines.join("\n") }, values.json === true);
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { request } from "../client.js";
|
|
2
|
+
import { mergeLabel, print } from "../output.js";
|
|
3
|
+
import { filterQuery } from "./query.js";
|
|
4
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
5
|
+
export async function sessions(cfg, values) {
|
|
6
|
+
const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
|
|
7
|
+
const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
|
|
8
|
+
for (const s of json.sessions)
|
|
9
|
+
rows.push([s.task_id ?? s.id, s.title, s.repository, usd(s.cost_usd), String(s.pr_count), s.ci, mergeLabel(s)]);
|
|
10
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
11
|
+
const lines = rows.map((r) => r
|
|
12
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
13
|
+
.join(" ")
|
|
14
|
+
.trimEnd());
|
|
15
|
+
const text = json.sessions.length ? `${lines.join("\n")}\n${json.sessions.length} of ${json.total} session(s).` : "No sessions.";
|
|
16
|
+
print({ data: json, text }, values.json === true);
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { request } from "../client.js";
|
|
2
|
+
import { print } from "../output.js";
|
|
3
|
+
import { filterQuery } from "./query.js";
|
|
4
|
+
export async function summary(cfg, values) {
|
|
5
|
+
const { json } = await request(cfg, "GET", `/v1/summary${filterQuery(values)}`);
|
|
6
|
+
const s = json.summary;
|
|
7
|
+
const text = [
|
|
8
|
+
`Estimated spend: $${s.spend_usd.toFixed(2)}`,
|
|
9
|
+
`Sessions: ${s.sessions}`,
|
|
10
|
+
`PRs merged: ${s.prs_merged} of ${s.prs_opened} opened`,
|
|
11
|
+
`Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" : `$${s.cost_per_merged_pr.toFixed(2)}`}`,
|
|
12
|
+
`Commits: ${s.commits}`,
|
|
13
|
+
].join("\n");
|
|
14
|
+
print({ data: json, text }, values.json === true);
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CliError } from "../client.js";
|
|
2
|
+
import { print } from "../output.js";
|
|
3
|
+
import { saveSession, sessionBody } from "./body.js";
|
|
4
|
+
export async function update(cfg, values, positionals) {
|
|
5
|
+
if (positionals.length !== 1)
|
|
6
|
+
throw new CliError("usage: berliner update <session-id> [flags]; quote values that contain spaces", "usage");
|
|
7
|
+
if (values.evidence?.length)
|
|
8
|
+
throw new CliError("update does not upload evidence; use report --evidence", "usage");
|
|
9
|
+
const { json: saved, dropped } = await saveSession(cfg, "PATCH", `/v1/sessions/${encodeURIComponent(positionals[0])}`, sessionBody(values));
|
|
10
|
+
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
11
|
+
print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.` }, values.json === true);
|
|
12
|
+
}
|
package/dist/config.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parse } from "./args.js";
|
|
3
|
+
import { CliError } from "./client.js";
|
|
4
|
+
import { del } from "./commands/delete.js";
|
|
5
|
+
import { report } from "./commands/report.js";
|
|
6
|
+
import { session } from "./commands/session.js";
|
|
7
|
+
import { sessions } from "./commands/sessions.js";
|
|
8
|
+
import { skill } from "./commands/skill.js";
|
|
9
|
+
import { summary } from "./commands/summary.js";
|
|
10
|
+
import { update } from "./commands/update.js";
|
|
11
|
+
import { readConfig } from "./config.js";
|
|
12
|
+
import { warn } from "./output.js";
|
|
13
|
+
const usage = `Usage: berliner <command> [flags]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
report --session-id <id> --title <t> --repo <owner/name> --cost <usd> --model <m> --plan <p>
|
|
17
|
+
[--task --task-url --project --started-at --ended-at --commits --coverage]
|
|
18
|
+
[--pr <url>[,merge=<state>][,ci=<state>]]... [--evidence <path>[::caption]]... [--json] [--strict]
|
|
19
|
+
update <session-id> same flags as report, all optional
|
|
20
|
+
sessions [--repo --model --project --ci --merge --since <N>d | --from --to] [--limit <n>] [--json]
|
|
21
|
+
session <session-id> [--json]
|
|
22
|
+
summary same filters as sessions [--json]
|
|
23
|
+
delete <session-id> [--evidence <id>] [--yes]
|
|
24
|
+
skill print the bundled SKILL.md
|
|
25
|
+
|
|
26
|
+
Environment: BERLINER_TOKEN, BERLINER_URL`;
|
|
27
|
+
const args = parse(process.argv.slice(2));
|
|
28
|
+
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
29
|
+
const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
|
|
30
|
+
const handled = new Set(["report", "update", "sessions", "session", "summary", "delete", "skill"]);
|
|
31
|
+
const cfg = () => readConfig(process.env);
|
|
32
|
+
try {
|
|
33
|
+
if (args.values.help)
|
|
34
|
+
console.log(usage);
|
|
35
|
+
else if (args.error)
|
|
36
|
+
throw new CliError(args.error, "usage");
|
|
37
|
+
else if (args.command === "report")
|
|
38
|
+
await report(cfg(), args.values, args.positionals);
|
|
39
|
+
else if (args.command === "update")
|
|
40
|
+
await update(cfg(), args.values, args.positionals);
|
|
41
|
+
else if (args.command === "sessions")
|
|
42
|
+
await sessions(cfg(), args.values);
|
|
43
|
+
else if (args.command === "session")
|
|
44
|
+
await session(cfg(), args.values, args.positionals);
|
|
45
|
+
else if (args.command === "summary")
|
|
46
|
+
await summary(cfg(), args.values);
|
|
47
|
+
else if (args.command === "delete")
|
|
48
|
+
await del(cfg(), args.values, args.positionals);
|
|
49
|
+
else if (args.command === "skill")
|
|
50
|
+
skill();
|
|
51
|
+
else
|
|
52
|
+
throw new CliError(args.command ? `unknown command "${args.command}"` : "missing command", "usage");
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
warn(err instanceof Error ? err.message : String(err));
|
|
56
|
+
if (args.error === null && !handled.has(args.command ?? ""))
|
|
57
|
+
console.error(usage);
|
|
58
|
+
process.exitCode = softFail ? 0 : 1;
|
|
59
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function print(result, json) {
|
|
2
|
+
console.log(json ? JSON.stringify(result.data, null, 2) : result.text);
|
|
3
|
+
}
|
|
4
|
+
// Always a single line, so agents and CI logs see exactly one warning.
|
|
5
|
+
export function warn(msg) {
|
|
6
|
+
console.error(`berliner: ${msg.replace(/\s*\n\s*/g, " ")}`);
|
|
7
|
+
}
|
|
8
|
+
// Same wording as the web's mergeLabel (app/src/web/format.ts).
|
|
9
|
+
const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
|
|
10
|
+
export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CliError, request } from "./client.js";
|
|
6
|
+
const TYPES = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".gif": "image/gif",
|
|
11
|
+
".webp": "image/webp",
|
|
12
|
+
".mp4": "video/mp4",
|
|
13
|
+
".webm": "video/webm",
|
|
14
|
+
".mov": "video/quicktime",
|
|
15
|
+
};
|
|
16
|
+
// Evidence uploads are exempt from the 15 s report deadline (ruling R25).
|
|
17
|
+
const LONG = { timeoutMs: 120_000, deadlineMs: 120_000 };
|
|
18
|
+
export async function uploadEvidence(cfg, sessionId, files, budget = LONG) {
|
|
19
|
+
const result = { uploaded: 0, existing: 0, skipped: [] };
|
|
20
|
+
let unreachable = null;
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (unreachable) {
|
|
23
|
+
result.skipped.push({ path: file.path, reason: unreachable });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (await uploadOne(cfg, sessionId, file, budget))
|
|
28
|
+
result.uploaded++;
|
|
29
|
+
else
|
|
30
|
+
result.existing++;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
// One bad file never fails the report.
|
|
34
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
35
|
+
result.skipped.push({ path: file.path, reason });
|
|
36
|
+
// The next file would wait out the same budget against the same server (anti-goal 1).
|
|
37
|
+
if (err instanceof CliError && err.kind === "network")
|
|
38
|
+
unreachable = reason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
// Resolves true when uploaded, false when the server already had the content.
|
|
44
|
+
async function uploadOne(cfg, sessionId, file, budget) {
|
|
45
|
+
const contentType = TYPES[path.extname(file.path).toLowerCase()];
|
|
46
|
+
if (!contentType)
|
|
47
|
+
throw new Error("unsupported file type");
|
|
48
|
+
const size = await stat(file.path).then((s) => (s.isFile() ? s.size : 0), () => -1);
|
|
49
|
+
if (size < 0)
|
|
50
|
+
throw new Error("file not found");
|
|
51
|
+
if (size === 0)
|
|
52
|
+
throw new Error("file is empty");
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
for await (const chunk of createReadStream(file.path))
|
|
55
|
+
hash.update(chunk);
|
|
56
|
+
const { json } = await request(cfg, "POST", `/v1/sessions/${encodeURIComponent(sessionId)}/evidence`, {
|
|
57
|
+
filename: path.basename(file.path),
|
|
58
|
+
content_type: contentType,
|
|
59
|
+
size_bytes: size,
|
|
60
|
+
sha256: hash.digest("hex"),
|
|
61
|
+
caption: file.caption,
|
|
62
|
+
}, budget);
|
|
63
|
+
if (json.status === "exists")
|
|
64
|
+
return false;
|
|
65
|
+
const base = `/v1/evidence/${json.evidence.id}`;
|
|
66
|
+
const handle = await open(file.path);
|
|
67
|
+
try {
|
|
68
|
+
const slice = async (start, length) => {
|
|
69
|
+
const buf = Buffer.alloc(length);
|
|
70
|
+
const { bytesRead } = await handle.read(buf, 0, length, start);
|
|
71
|
+
if (bytesRead !== length)
|
|
72
|
+
throw new Error("file changed while uploading");
|
|
73
|
+
return buf;
|
|
74
|
+
};
|
|
75
|
+
if (json.upload.mode === "single") {
|
|
76
|
+
await request(cfg, "PUT", `${base}/content`, await slice(0, size), budget);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const parts = [];
|
|
80
|
+
const partSize = json.upload.part_size;
|
|
81
|
+
if (!Number.isInteger(partSize) || partSize < 1)
|
|
82
|
+
throw new Error("the server sent an invalid part size"); // 0 would loop forever
|
|
83
|
+
for (let start = 0, n = 1; start < size; start += partSize, n++) {
|
|
84
|
+
const res = await request(cfg, "PUT", `${base}/parts/${n}`, await slice(start, Math.min(partSize, size - start)), budget);
|
|
85
|
+
parts.push({ part_number: n, etag: res.json.etag });
|
|
86
|
+
}
|
|
87
|
+
await request(cfg, "POST", `${base}/complete`, { parts }, budget);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paxnax/berliner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Report coding-agent sessions to Berliner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"berliner": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"skill"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p .",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"test": "vitest run"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "24.19.2",
|
|
26
|
+
"typescript": "7.0.2",
|
|
27
|
+
"vitest": "5.0.5"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: berliner
|
|
3
|
+
description: Use at the end of an autonomous coding session, and again whenever its PRs or results change, to report cost, model, plan, PRs, commits, test coverage and screenshots or videos to Berliner with the berliner CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reporting to Berliner
|
|
7
|
+
|
|
8
|
+
Berliner records what an autonomous session cost and produced. You report with one command, `berliner report`.
|
|
9
|
+
|
|
10
|
+
## When to report
|
|
11
|
+
|
|
12
|
+
- Once, at the end of the session.
|
|
13
|
+
- Again with the **same `--session-id`** if anything changes afterwards (a PR is opened or merged, CI finishes, you add evidence). The new values replace the old ones, PR URLs are added to the list, and evidence already uploaded is skipped. It never double-counts.
|
|
14
|
+
|
|
15
|
+
## Required values and where each comes from
|
|
16
|
+
|
|
17
|
+
| Flag | Source |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `--session-id` | Your harness's session ID if it exposes one, otherwise generate a UUID at the start of the session and reuse it for every report. |
|
|
20
|
+
| `--title` | One short line describing the work. |
|
|
21
|
+
| `--repo` | `owner/name`, from `git remote get-url origin`. |
|
|
22
|
+
| `--cost` | Estimated USD. Your harness's reported session cost if it shows one, otherwise estimate from token usage at the model's API prices. |
|
|
23
|
+
| `--model` | The primary model ID you ran on. |
|
|
24
|
+
| `--plan` | One of `free`, `pro`, `max_x5`, `max_x20`, `team`, `enterprise`, `api`. |
|
|
25
|
+
|
|
26
|
+
## Optional values
|
|
27
|
+
|
|
28
|
+
- `--commits <n>`: `git rev-list --count <base>..HEAD`
|
|
29
|
+
- `--coverage <percent>`: from the test run's coverage summary.
|
|
30
|
+
- `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
|
|
31
|
+
- `--evidence <path>[::caption]`: repeat per screenshot or video.
|
|
32
|
+
- `--task <id>`, `--task-url <url>`, `--project <name>`, `--started-at <ISO>`, `--ended-at <ISO>`.
|
|
33
|
+
|
|
34
|
+
Quote any value that contains spaces.
|
|
35
|
+
|
|
36
|
+
## Environment
|
|
37
|
+
|
|
38
|
+
`BERLINER_TOKEN` must be set (an API token from the Settings page at https://berliner.app). `BERLINER_URL` overrides the server address.
|
|
39
|
+
|
|
40
|
+
## Timeouts
|
|
41
|
+
|
|
42
|
+
The session is saved first, then evidence is uploaded. Allow at least 10 minutes for the command when attaching videos.
|
|
43
|
+
|
|
44
|
+
## Reading the output
|
|
45
|
+
|
|
46
|
+
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.`
|
|
47
|
+
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work (`--strict` makes failures exit 1). Re-run the same command; it is safe to repeat.
|
|
48
|
+
- `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given.
|
|
49
|
+
- To change a field later without resending everything, use `berliner update <session-id> --commits 5`.
|
|
50
|
+
|
|
51
|
+
## Example
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
berliner report \
|
|
55
|
+
--session-id 3f1c2a9e-7b64-4d0a-9a51-2c8f0e6d1b77 \
|
|
56
|
+
--title "Fix recurring event off-by-one" \
|
|
57
|
+
--repo octo/app \
|
|
58
|
+
--cost 4.20 \
|
|
59
|
+
--model claude-opus-5-5 \
|
|
60
|
+
--plan max_x20 \
|
|
61
|
+
--task TEC-5521 \
|
|
62
|
+
--commits 3 \
|
|
63
|
+
--coverage 87.5 \
|
|
64
|
+
--pr "https://github.com/octo/app/pull/42,ci=running" \
|
|
65
|
+
--evidence "./shots/after.png::Calendar after the fix"
|
|
66
|
+
```
|
package/README.md
DELETED