@paxnax/berliner 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/agents/claude.js +8 -4
- package/dist/agents/codex.js +18 -13
- package/dist/agents/opencode.js +2 -1
- package/dist/agents/read.js +3 -0
- package/dist/args.js +1 -0
- package/dist/client.js +6 -4
- package/dist/commands/body.js +19 -3
- package/dist/commands/delete.js +7 -5
- package/dist/commands/hook.js +61 -13
- package/dist/commands/projects.js +37 -0
- package/dist/commands/report.js +5 -15
- package/dist/commands/setup.js +10 -9
- package/dist/commands/update.js +5 -3
- package/dist/commands/upgrade.js +63 -12
- package/dist/gh.js +28 -0
- package/dist/index.js +23 -1
- package/dist/setup/hooks.js +41 -20
- package/opencode/berliner.js +31 -16
- package/package.json +1 -1
- package/skill/SKILL.md +78 -10
package/README.md
CHANGED
|
@@ -21,9 +21,9 @@ With Bun, run `bunx --bun @paxnax/berliner@latest setup`. To install only the co
|
|
|
21
21
|
npx @paxnax/berliner@latest setup
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI
|
|
24
|
+
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
|
|
25
25
|
|
|
26
|
-
3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. Your agent adds the
|
|
26
|
+
3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. They also fill in a ticket from a Linear or Jira URL in your prompts while the session has none, and refresh up to 5 open pull requests the Berliner GitHub App doesn't track with your `gh` (skipped when `gh` is missing or signed out). When a session starts, the hook reminds your agent to name the session and report its ticket. Your agent adds the title, ticket, pull requests, coverage and evidence with `berliner report` and `berliner update`, as the skill tells it to. Codex runs a new hook only once you trust it: open `codex` once in a terminal and trust the Berliner hook.
|
|
27
27
|
4. On **Settings**, list the subscriptions you pay for. Overview then compares each one with the sessions that ran on it.
|
|
28
28
|
|
|
29
29
|
Without setup: create an API token on **Settings**, `export BERLINER_TOKEN=brl_...`, and save the output of `berliner skill` where your agent reads skills (for Claude Code, `~/.claude/skills/berliner/SKILL.md`). Your agent then reports each session with `berliner report` and the flags under [Commands](#commands).
|
|
@@ -115,10 +115,12 @@ Other commands:
|
|
|
115
115
|
- `berliner sessions [filters]`: list sessions. Filters: `--repo --model --project --ci --merge --since <N>d` or `--from --to`, plus `--limit` and `--json`.
|
|
116
116
|
- `berliner session <session-id>`: show one session.
|
|
117
117
|
- `berliner summary [filters]`: totals for the same filters as `sessions`, then one line per subscription.
|
|
118
|
+
- `berliner projects [--json]`: the project names in use, then each repository's default project.
|
|
119
|
+
- `berliner project [<name>] [--none] [--repo owner/name] [--json]`: with no name, show this repository's default project; with a name, save it; `--none` saves that the repository has no project. The repository comes from `git remote get-url origin` unless you pass `--repo`. A session's project is its own `--project`, else its repository's default, else its ticket's prefix (`SMTNC-3268` gives `SMTNC`). Until a repository has a default, `report` and `update` print a `Project: none saved` line, which asks your agent to suggest one to you.
|
|
118
120
|
- `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
|
|
119
121
|
- `berliner evidence public <id>`: let anyone with the link view one file, and print its public URL and Markdown line. `berliner evidence private <id>` makes it private again. `berliner session` shows the ids, and the URL of each public file.
|
|
120
122
|
- `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
|
|
121
|
-
- `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command and each
|
|
122
|
-
- `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never
|
|
123
|
+
- `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched.
|
|
124
|
+
- `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never fails and never makes the agent wait, and prints only the reminder when a session starts; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
|
|
123
125
|
|
|
124
|
-
`report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure. The other commands exit 1 on failure.
|
|
126
|
+
`report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure, including a file that could not be uploaded or made public; the session is still saved. The other commands exit 1 on failure.
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { addTokens, jsonl, num, Span } from "./read.js";
|
|
3
|
+
import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
|
|
4
4
|
// Subagent transcripts sit next to the session's: <project>/<session-id>/subagents/agent-*.jsonl.
|
|
5
5
|
async function subagentFiles(transcriptPath) {
|
|
6
6
|
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
@@ -24,11 +24,15 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
24
24
|
const responses = new Map();
|
|
25
25
|
const span = new Span();
|
|
26
26
|
let firstPrompt = null;
|
|
27
|
+
let taskUrl = null;
|
|
27
28
|
for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
|
|
28
29
|
for await (const e of jsonl(file)) {
|
|
29
30
|
span.add(e.timestamp);
|
|
30
|
-
if (file === transcriptPath)
|
|
31
|
-
|
|
31
|
+
if (file === transcriptPath) {
|
|
32
|
+
const prompt = promptText(e);
|
|
33
|
+
firstPrompt ??= prompt;
|
|
34
|
+
taskUrl ??= ticketUrl(prompt);
|
|
35
|
+
}
|
|
32
36
|
const m = e.message;
|
|
33
37
|
// Claude Code's own placeholder replies are written as model "<synthetic>" with zero usage.
|
|
34
38
|
if (e.type !== "assistant" || typeof m?.model !== "string" || m.model === "<synthetic>" || !m.usage)
|
|
@@ -57,5 +61,5 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
57
61
|
for (const [id, t] of Object.entries(tokensByModel))
|
|
58
62
|
if (model === null || t.output > tokensByModel[model].output)
|
|
59
63
|
model = id;
|
|
60
|
-
return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt };
|
|
64
|
+
return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
61
65
|
}
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addTokens, jsonl, num, Span } from "./read.js";
|
|
1
|
+
import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
|
|
2
2
|
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
3
3
|
// Line shapes as Codex 0.162.1 writes them (codex-rs/protocol). input_tokens includes both cached reads and cache writes,
|
|
4
4
|
// as in Codex's own parsing test (100 input = 40 cached + 60 written); reasoning_output_tokens is part of output_tokens.
|
|
@@ -6,10 +6,10 @@ export async function readCodex(rolloutPath) {
|
|
|
6
6
|
const span = new Span();
|
|
7
7
|
let model = null;
|
|
8
8
|
let firstPrompt = null;
|
|
9
|
+
let taskUrl = null;
|
|
9
10
|
let latest = zero;
|
|
10
11
|
let latestTotal = 0;
|
|
11
|
-
|
|
12
|
-
let carried = zero;
|
|
12
|
+
const byModel = {};
|
|
13
13
|
for await (const e of jsonl(rolloutPath)) {
|
|
14
14
|
span.add(e.timestamp);
|
|
15
15
|
const p = e.payload;
|
|
@@ -17,27 +17,32 @@ export async function readCodex(rolloutPath) {
|
|
|
17
17
|
model = p.model;
|
|
18
18
|
if (e.type !== "event_msg")
|
|
19
19
|
continue;
|
|
20
|
-
if (
|
|
20
|
+
if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
|
|
21
21
|
const text = p.item.content.find((c) => c?.type === "text")?.text;
|
|
22
|
-
if (typeof text === "string" && text.trim())
|
|
23
|
-
firstPrompt
|
|
22
|
+
if (typeof text === "string" && text.trim()) {
|
|
23
|
+
firstPrompt ??= text.trim();
|
|
24
|
+
taskUrl ??= ticketUrl(text);
|
|
25
|
+
}
|
|
24
26
|
}
|
|
25
27
|
const u = p?.type === "token_count" ? p.info?.total_token_usage : undefined;
|
|
26
28
|
if (!u)
|
|
27
29
|
continue;
|
|
28
30
|
const total = num(u.total_tokens);
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
latestTotal = total;
|
|
33
|
-
latest = {
|
|
31
|
+
// A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
|
|
32
|
+
const restart = total < latestTotal || (total > latestTotal && latestTotal > 0 && total === num(p?.info?.last_token_usage?.total_tokens));
|
|
33
|
+
const cur = {
|
|
34
34
|
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
35
35
|
output: num(u.output_tokens),
|
|
36
36
|
cacheRead: num(u.cached_input_tokens),
|
|
37
37
|
cacheWrite: num(u.cache_write_input_tokens),
|
|
38
38
|
cacheWrite1h: 0,
|
|
39
39
|
};
|
|
40
|
+
// Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
|
|
41
|
+
const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
|
|
42
|
+
if (model)
|
|
43
|
+
byModel[model] = addTokens(byModel[model] ?? zero, delta);
|
|
44
|
+
latestTotal = total;
|
|
45
|
+
latest = cur;
|
|
40
46
|
}
|
|
41
|
-
|
|
42
|
-
return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt };
|
|
47
|
+
return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
43
48
|
}
|
package/dist/agents/opencode.js
CHANGED
|
@@ -3,7 +3,7 @@ const iso = (ms) => {
|
|
|
3
3
|
const d = new Date(typeof ms === "number" ? ms : Number.NaN);
|
|
4
4
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
5
5
|
};
|
|
6
|
-
// The session has no prompt text, so there is no first prompt.
|
|
6
|
+
// The session has no prompt text, so there is no first prompt and no ticket.
|
|
7
7
|
export function readOpenCode(json) {
|
|
8
8
|
const s = (typeof json === "object" && json !== null ? json : {});
|
|
9
9
|
const model = typeof s.model?.id === "string" ? s.model.id : null;
|
|
@@ -23,5 +23,6 @@ export function readOpenCode(json) {
|
|
|
23
23
|
startedAt: iso(s.time?.created),
|
|
24
24
|
endedAt: iso(s.time?.updated),
|
|
25
25
|
firstPrompt: null,
|
|
26
|
+
taskUrl: null,
|
|
26
27
|
};
|
|
27
28
|
}
|
package/dist/agents/read.js
CHANGED
|
@@ -14,6 +14,9 @@ export async function* jsonl(path) {
|
|
|
14
14
|
yield value;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
// Full issue URLs only: a bare ID is ambiguous (UTF-8). Cut at the ID, so a slug or query never follows it.
|
|
18
|
+
const TICKET_URL = /https?:\/\/(?:linear\.app\/[\w-]+\/issue|[\w-]+\.atlassian\.net\/browse)\/[A-Za-z][A-Za-z0-9]*-\d+\b/;
|
|
19
|
+
export const ticketUrl = (text) => text?.match(TICKET_URL)?.[0] ?? null;
|
|
17
20
|
export const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
18
21
|
export const addTokens = (a, b) => ({
|
|
19
22
|
input: a.input + b.input,
|
package/dist/args.js
CHANGED
package/dist/client.js
CHANGED
|
@@ -45,10 +45,12 @@ export async function request(cfg, method, path, body, opts = {}) {
|
|
|
45
45
|
await sleep(backoffMs);
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
|
-
const json =
|
|
49
|
-
if (!res.ok)
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const json = await res.json().catch(() => null);
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const err = json;
|
|
51
|
+
throw new CliError(errorMessage(method, path, res.status, err), "http", res.status, err?.error?.details);
|
|
52
|
+
}
|
|
53
|
+
return { status: res.status, json: json };
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
package/dist/commands/body.js
CHANGED
|
@@ -2,6 +2,9 @@ import { CliError, request } from "../client.js";
|
|
|
2
2
|
import { gitFacts } from "../git.js";
|
|
3
3
|
import { warn } from "../output.js";
|
|
4
4
|
import { derivedTitle, sessionFacts } from "./hook.js";
|
|
5
|
+
export const projectHint = (s) => s.repository_project_set === false && s.project_source !== "session"
|
|
6
|
+
? `\nProject: none saved for ${s.repository}. Suggest one to the user, then save it with: berliner project "<name>"`
|
|
7
|
+
: "";
|
|
5
8
|
// Shared by report (PUT) and update (PATCH): unset flags are undefined and drop out of the JSON.
|
|
6
9
|
export function sessionBody(values) {
|
|
7
10
|
return {
|
|
@@ -37,14 +40,27 @@ export async function fromTool(tool, values, env, command) {
|
|
|
37
40
|
const ignored = ["session-id", "cost", "model"].filter((f) => values[f] !== undefined && values[f] !== filled[f]);
|
|
38
41
|
if (ignored.length)
|
|
39
42
|
warn(`ignored ${ignored.map((f) => `--${f}`).join(", ")}: inside ${tool.tool}, this session's own values are used`);
|
|
40
|
-
|
|
43
|
+
// The derived title only names a session the hook hasn't created yet: see patchFirst.
|
|
44
|
+
if (filled.repo === undefined || filled.title === undefined) {
|
|
41
45
|
const git = await gitFacts(process.cwd(), null);
|
|
42
46
|
filled.repo ??= git.repository ?? undefined;
|
|
43
|
-
|
|
44
|
-
filled.title ??= derivedTitle(facts?.firstPrompt ?? null, git) ?? undefined;
|
|
47
|
+
filled.title ??= derivedTitle(facts?.firstPrompt ?? null, git) ?? undefined;
|
|
45
48
|
}
|
|
46
49
|
return filled;
|
|
47
50
|
}
|
|
51
|
+
// Inside a tool: the same fields, minus a derived title, for a session that exists; on a 404 (the first turn, before the hook
|
|
52
|
+
// has reported), the PUT that creates it, at cost 0 while the cost is unknown and on BERLINER_PLAN when no plan is given.
|
|
53
|
+
export async function patchFirst(cfg, path, body, titled, guess) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
56
|
+
return { ...res, json: { created: false, ...res.json } };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (!(err instanceof CliError && err.status === 404))
|
|
60
|
+
throw err;
|
|
61
|
+
return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined }, guess);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
48
64
|
function number(values, flag) {
|
|
49
65
|
const raw = values[flag];
|
|
50
66
|
if (raw === undefined)
|
package/dist/commands/delete.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { CliError, request } from "../client.js";
|
|
3
3
|
export async function del(cfg, values, positionals) {
|
|
4
|
-
|
|
4
|
+
// A blank --evidence must not fall through to deleting the whole session.
|
|
5
|
+
const { evidence } = values;
|
|
6
|
+
if (positionals.length !== 1 || (evidence && (evidence.length !== 1 || !evidence[0].trim())))
|
|
5
7
|
throw new CliError("usage: berliner delete <session-id> [--evidence <id>] [--yes]", "usage");
|
|
6
8
|
const id = positionals[0];
|
|
7
|
-
const evidenceId =
|
|
8
|
-
if (evidenceId) {
|
|
9
|
+
const evidenceId = evidence?.[0];
|
|
10
|
+
if (evidenceId !== undefined) {
|
|
9
11
|
// Evidence ids are global, so the session named on the command line must actually hold this one.
|
|
10
12
|
const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(id)}`);
|
|
11
13
|
if (!json.session.evidence.some((e) => e.id === evidenceId)) {
|
|
12
14
|
throw new CliError(`evidence ${evidenceId} is not part of session ${id}`, "usage");
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
|
-
const question = evidenceId ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
|
|
17
|
+
const question = evidenceId !== undefined ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
|
|
16
18
|
if (!values.yes) {
|
|
17
19
|
if (!process.stdin.isTTY)
|
|
18
20
|
throw new CliError("refusing to delete without confirmation; pass --yes to delete without a prompt", "usage");
|
|
@@ -24,7 +26,7 @@ export async function del(cfg, values, positionals) {
|
|
|
24
26
|
return;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
|
-
if (evidenceId) {
|
|
29
|
+
if (evidenceId !== undefined) {
|
|
28
30
|
await request(cfg, "DELETE", `/v1/evidence/${encodeURIComponent(evidenceId)}`);
|
|
29
31
|
console.log(`Deleted evidence ${evidenceId}.`);
|
|
30
32
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, linkSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
import { findRecord, readClaudeCode, readCodex, readOpenCode, SAFE_ID } from "../agents/index.js";
|
|
6
6
|
import { CliError, request } from "../client.js";
|
|
7
7
|
import { readConfig, stateDir } from "../config.js";
|
|
8
|
+
import { viewPr } from "../gh.js";
|
|
8
9
|
import { gitFacts } from "../git.js";
|
|
9
10
|
import { estimateCost, loadPrices } from "../prices.js";
|
|
10
|
-
|
|
11
|
+
// Same text in the OpenCode plugin (cli/opencode/berliner.js).
|
|
12
|
+
const REMINDER = 'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
|
|
11
13
|
const TOOLS = ["claude-code", "codex", "opencode"];
|
|
12
14
|
const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
|
|
13
15
|
const OPENCODE_EVENTS = ["idle", "compacting"];
|
|
16
|
+
const SESSION_STARTS = ["startup", "resume", "compact", "clear"];
|
|
14
17
|
const MAX_PAYLOAD = 1024 * 1024;
|
|
15
18
|
const LOCK_MS = 60_000;
|
|
16
19
|
const DEADLINE_MS = 15_000;
|
|
20
|
+
const MAX_GH = 5;
|
|
17
21
|
export function inTool(env) {
|
|
18
22
|
if (env.CLAUDE_CODE_SESSION_ID)
|
|
19
23
|
return { tool: "claude-code", sessionId: env.CLAUDE_CODE_SESSION_ID };
|
|
@@ -81,8 +85,9 @@ async function start(tool, env) {
|
|
|
81
85
|
id = p.session?.id;
|
|
82
86
|
}
|
|
83
87
|
else {
|
|
84
|
-
//
|
|
85
|
-
|
|
88
|
+
// In the agent's context from the start, whether or not the skill triggers; after a compaction or /clear it may have lost
|
|
89
|
+
// the task and PRs it hasn't reported yet.
|
|
90
|
+
if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source))
|
|
86
91
|
return console.log(REMINDER);
|
|
87
92
|
if (!REPORT_EVENTS.includes(p.hook_event_name))
|
|
88
93
|
return;
|
|
@@ -147,15 +152,36 @@ async function child(tool, file, env) {
|
|
|
147
152
|
clearTimeout(timer);
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
|
-
// A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is
|
|
151
|
-
//
|
|
152
|
-
function takeLock(lock) {
|
|
155
|
+
// A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is moved aside and
|
|
156
|
+
// removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
|
|
157
|
+
export function takeLock(lock) {
|
|
153
158
|
if (createLock(lock))
|
|
154
159
|
return true;
|
|
155
|
-
|
|
160
|
+
const seen = statSync(lock, { throwIfNoEntry: false });
|
|
161
|
+
// Gone since: its child just unlocked, so it is free, not stale.
|
|
162
|
+
if (!seen)
|
|
163
|
+
return createLock(lock);
|
|
164
|
+
if (Date.now() - seen.mtimeMs < LOCK_MS)
|
|
156
165
|
return false;
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
const aside = `${lock}.${process.pid}`;
|
|
167
|
+
try {
|
|
168
|
+
renameSync(lock, aside);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (err.code === "ENOENT")
|
|
172
|
+
return false;
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
if (statSync(aside).ino !== seen.ino) {
|
|
176
|
+
// shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
|
|
177
|
+
try {
|
|
178
|
+
linkSync(aside, lock);
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
rmSync(aside, { force: true });
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
rmSync(aside, { force: true });
|
|
159
185
|
return createLock(lock);
|
|
160
186
|
}
|
|
161
187
|
function createLock(lock) {
|
|
@@ -191,11 +217,15 @@ async function send(tool, file, env) {
|
|
|
191
217
|
started_at: facts.startedAt ?? undefined,
|
|
192
218
|
ended_at: facts.endedAt ?? undefined,
|
|
193
219
|
repository: git.repository,
|
|
220
|
+
// The server only fills an empty ticket with these: the agent's own always wins. The URL ends in the ticket's ID.
|
|
221
|
+
detected_task_id: facts.taskUrl?.split("/").pop(),
|
|
222
|
+
detected_task_url: facts.taskUrl ?? undefined,
|
|
194
223
|
};
|
|
195
224
|
const cfg = readConfig(env);
|
|
196
225
|
const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
|
|
226
|
+
let saved;
|
|
197
227
|
try {
|
|
198
|
-
await request(cfg, "PATCH", path, body);
|
|
228
|
+
saved = await request(cfg, "PATCH", path, body);
|
|
199
229
|
}
|
|
200
230
|
catch (err) {
|
|
201
231
|
if (!(err instanceof CliError && err.status === 404))
|
|
@@ -205,16 +235,34 @@ async function send(tool, file, env) {
|
|
|
205
235
|
// session needs a cost: 0 until prices load.
|
|
206
236
|
const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan });
|
|
207
237
|
try {
|
|
208
|
-
await create(cfg.plan ?? undefined);
|
|
238
|
+
saved = await create(cfg.plan ?? undefined);
|
|
209
239
|
}
|
|
210
240
|
catch (err) {
|
|
211
241
|
// A BERLINER_PLAN the server rejects must not lose the session: the server picks the plan instead.
|
|
212
242
|
if (!(cfg.plan && err instanceof CliError && err.status === 422 && err.details?.fieldErrors?.plan))
|
|
213
243
|
throw err;
|
|
214
|
-
await create(undefined);
|
|
244
|
+
saved = await create(undefined);
|
|
215
245
|
log(env, `${tool} ${sessionId}: BERLINER_PLAN "${cfg.plan}" is not a plan; the server picked the plan`);
|
|
216
246
|
}
|
|
217
247
|
}
|
|
248
|
+
await refreshPrs(cfg, path, saved.json.session.prs, env, `${tool} ${sessionId}`);
|
|
249
|
+
}
|
|
250
|
+
// Untracked PRs keep their last reported state, so the machine's gh refreshes the open ones; the poller owns tracked PRs.
|
|
251
|
+
// In parallel, 5 seconds each, inside the child's deadline. Only changes are sent.
|
|
252
|
+
async function refreshPrs(cfg, path, prs, env, where) {
|
|
253
|
+
const open = prs.filter((p) => p.tracked === false && p.merge_state === "open").slice(0, MAX_GH);
|
|
254
|
+
const seen = await Promise.all(open.map((p) => viewPr(p.url).catch((err) => {
|
|
255
|
+
// No gh on this machine, or one that isn't signed in (exit code 4), is not an error: logging it would add lines every reply.
|
|
256
|
+
if (err.code !== "ENOENT" && err.code !== 4)
|
|
257
|
+
log(env, `${where}: gh pr view ${p.url}: ${err.message}`);
|
|
258
|
+
return null;
|
|
259
|
+
})));
|
|
260
|
+
const changed = open.flatMap((p, i) => {
|
|
261
|
+
const s = seen[i];
|
|
262
|
+
return s && (s.merge_state !== p.merge_state || s.ci_state !== p.ci_state) ? [{ url: p.url, ...s }] : [];
|
|
263
|
+
});
|
|
264
|
+
if (changed.length)
|
|
265
|
+
await request(cfg, "PATCH", path, { prs: changed });
|
|
218
266
|
}
|
|
219
267
|
// shortcut: hook.log is never rotated (one short line per skipped or failed report); rotate it if it grows large in practice.
|
|
220
268
|
function log(env, line) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CliError, request } from "../client.js";
|
|
2
|
+
import { gitFacts } from "../git.js";
|
|
3
|
+
import { print } from "../output.js";
|
|
4
|
+
export async function projects(cfg, values) {
|
|
5
|
+
const { json } = await request(cfg, "GET", "/v1/projects");
|
|
6
|
+
const lines = json.projects.length ? ["Projects:", ...json.projects.map((p) => ` ${p}`)] : ["No projects yet."];
|
|
7
|
+
if (json.repositories.length)
|
|
8
|
+
lines.push("Repository defaults:", ...json.repositories.map((r) => ` ${r.repository} ${r.project ?? "(none)"}`));
|
|
9
|
+
print({ data: json, text: lines.join("\n") }, values.json === true);
|
|
10
|
+
}
|
|
11
|
+
// The default project of one repository: shown without a name, saved with one, saved as "no project" with --none.
|
|
12
|
+
export async function project(cfg, values, positionals) {
|
|
13
|
+
if (positionals.length > 1 || (positionals.length && values.none)) {
|
|
14
|
+
throw new CliError("usage: berliner project [<name> | --none] [--repo owner/name] [--json]; quote names that contain spaces", "usage");
|
|
15
|
+
}
|
|
16
|
+
const repository = values.repo ?? (await gitFacts(process.cwd(), null)).repository;
|
|
17
|
+
if (!repository)
|
|
18
|
+
throw new CliError("no GitHub repository here; pass --repo owner/name", "usage");
|
|
19
|
+
const [owner, name, ...rest] = repository.split("/");
|
|
20
|
+
if (!owner || !name || rest.length)
|
|
21
|
+
throw new CliError(`--repo must be owner/name, got "${repository}"`, "usage");
|
|
22
|
+
if (!positionals.length && !values.none) {
|
|
23
|
+
const { json } = await request(cfg, "GET", "/v1/projects");
|
|
24
|
+
// Repositories are matched without case, as the server stores them.
|
|
25
|
+
const row = json.repositories.find((r) => r.repository.toLowerCase() === repository.toLowerCase());
|
|
26
|
+
const text = !row
|
|
27
|
+
? `No project saved for ${repository}.`
|
|
28
|
+
: row.project === null
|
|
29
|
+
? `${row.repository} has no project.`
|
|
30
|
+
: `${row.repository}: ${row.project}`;
|
|
31
|
+
return print({ data: { repository: row?.repository ?? repository, project: row?.project ?? null, saved: row !== undefined }, text }, values.json === true);
|
|
32
|
+
}
|
|
33
|
+
const path = `/v1/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/project`;
|
|
34
|
+
const { json } = await request(cfg, "PUT", path, { project: values.none ? null : positionals[0] });
|
|
35
|
+
const text = json.project === null ? `Saved: ${json.repository} has no project.` : `Saved project "${json.project}" for ${json.repository}.`;
|
|
36
|
+
print({ data: json, text }, values.json === true);
|
|
37
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "../client.js";
|
|
2
2
|
import { print, warn } from "../output.js";
|
|
3
3
|
import { uploadEvidence } from "../upload.js";
|
|
4
|
-
import { fromTool, saveSession, sessionBody } from "./body.js";
|
|
4
|
+
import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
|
|
5
5
|
import { inTool } from "./hook.js";
|
|
6
6
|
const required = ["session-id", "title", "repo", "cost", "model", "plan"];
|
|
7
7
|
export async function report(cfg, values, positionals, env) {
|
|
@@ -29,7 +29,7 @@ export async function report(cfg, values, positionals, env) {
|
|
|
29
29
|
: saveSession(cfg, "PUT", path, body, values.plan));
|
|
30
30
|
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
31
31
|
const { created, session } = json;
|
|
32
|
-
const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked
|
|
32
|
+
const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.${projectHint(session)}`;
|
|
33
33
|
const json_ = values.json === true;
|
|
34
34
|
// Human mode prints the session line before uploads start; --json waits so stdout is one document.
|
|
35
35
|
if (!json_)
|
|
@@ -49,19 +49,9 @@ export async function report(cfg, values, positionals, env) {
|
|
|
49
49
|
}
|
|
50
50
|
for (const s of result.skipped)
|
|
51
51
|
warn(`${s.path}: ${s.reason}`);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function patchFirst(cfg, path, body, titled, guess) {
|
|
56
|
-
try {
|
|
57
|
-
const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
|
|
58
|
-
return { ...res, json: { created: false, ...res.json } };
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
if (!(err instanceof CliError && err.status === 404))
|
|
62
|
-
throw err;
|
|
63
|
-
return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0 }, guess);
|
|
64
|
-
}
|
|
52
|
+
// The session is saved either way; --strict asks for a skipped file to count as a failure.
|
|
53
|
+
if (values.strict && result.skipped.length)
|
|
54
|
+
process.exitCode = 1;
|
|
65
55
|
}
|
|
66
56
|
// <path>[::<caption>]
|
|
67
57
|
function parseEvidence(spec) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { CliError, request } from "../client.js";
|
|
7
7
|
import { configPath, DEFAULT_URL, readConfig, readConfigFile, saveConfig } from "../config.js";
|
|
8
8
|
import { machineName, openBrowser, startConnect, waitForApproval } from "../setup/connect.js";
|
|
9
|
-
import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
|
|
10
|
-
import {
|
|
9
|
+
import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, hookPath, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
|
|
10
|
+
import { detectTools, onPath, skillTargets } from "../setup/targets.js";
|
|
11
11
|
// The same command the web guide shows.
|
|
12
12
|
const TEST_REPORT = 'berliner report --session-id try-1 --title "First report" --repo you/app --cost 0.10 --model test --plan pro';
|
|
13
13
|
export const tilde = (p) => (p.startsWith(`${homedir()}/`) ? `~${p.slice(homedir().length)}` : p);
|
|
@@ -47,11 +47,11 @@ export async function setup(env, values) {
|
|
|
47
47
|
const targets = skillTargets(tools, env);
|
|
48
48
|
// One reporting hook per tool found, where the spec's "Hooks" table puts it.
|
|
49
49
|
const hookFiles = {
|
|
50
|
-
"claude-code": {
|
|
51
|
-
codex: {
|
|
52
|
-
opencode: {
|
|
50
|
+
"claude-code": { describe: describeClaudeHooks, install: installClaudeHooks },
|
|
51
|
+
codex: { describe: describeCodexHooks, install: installCodexHooks },
|
|
52
|
+
opencode: { describe: describeOpenCodePlugin, install: installOpenCodePlugin },
|
|
53
53
|
};
|
|
54
|
-
const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id] }));
|
|
54
|
+
const hooks = tools.filter((t) => t.found).map((t) => ({ ...t, ...hookFiles[t.id], path: hookPath(t.id, env) }));
|
|
55
55
|
const version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
56
56
|
const install = installCommand(version);
|
|
57
57
|
// The plan is data, built and shown before anything changes.
|
|
@@ -114,7 +114,8 @@ export async function setup(env, values) {
|
|
|
114
114
|
}
|
|
115
115
|
let installFailed = false;
|
|
116
116
|
if (!values["no-install"]) {
|
|
117
|
-
|
|
117
|
+
// From home, so the install never picks up the cwd's .npmrc.
|
|
118
|
+
const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
|
|
118
119
|
installFailed = run.status !== 0;
|
|
119
120
|
if (!installFailed)
|
|
120
121
|
console.log(`✓ Installed berliner ${version}`);
|
|
@@ -152,7 +153,7 @@ export async function setup(env, values) {
|
|
|
152
153
|
if (result === "unreadable")
|
|
153
154
|
console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be read, so it was left unchanged`);
|
|
154
155
|
else
|
|
155
|
-
console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : ""}`);
|
|
156
|
+
console.log(`✓ ${h.name} hook: ${tilde(h.path)}${result === "present" ? " (already installed)" : result === "updated" ? " (updated)" : ""}`);
|
|
156
157
|
codexAdded ||= h.id === "codex" && result === "added";
|
|
157
158
|
}
|
|
158
159
|
const summary = [
|
package/dist/commands/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "../client.js";
|
|
2
2
|
import { print } from "../output.js";
|
|
3
|
-
import { fromTool, saveSession, sessionBody } from "./body.js";
|
|
3
|
+
import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
|
|
4
4
|
import { inTool } from "./hook.js";
|
|
5
5
|
export async function update(cfg, values, positionals, env) {
|
|
6
6
|
const tool = inTool(env);
|
|
@@ -12,12 +12,14 @@ export async function update(cfg, values, positionals, env) {
|
|
|
12
12
|
throw new CliError("update does not upload evidence; use report --evidence", "usage");
|
|
13
13
|
if (values.public)
|
|
14
14
|
throw new CliError("update does not publish evidence; use report --evidence --public, or berliner evidence public <id>", "usage");
|
|
15
|
+
const titled = values.title !== undefined;
|
|
15
16
|
if (tool)
|
|
16
17
|
values = await fromTool(tool, { ...values, "session-id": positionals[0] ?? values["session-id"] }, env, "update");
|
|
17
18
|
const id = tool ? tool.sessionId : positionals[0];
|
|
18
19
|
// A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
|
|
19
20
|
const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
|
|
20
|
-
const
|
|
21
|
+
const path = `/v1/sessions/${encodeURIComponent(id)}`;
|
|
22
|
+
const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan) : saveSession(cfg, "PATCH", path, body, values.plan));
|
|
21
23
|
const json = dropped.length ? { ...saved, dropped } : saved;
|
|
22
|
-
print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked
|
|
24
|
+
print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.${projectHint(json.session)}` }, values.json === true);
|
|
23
25
|
}
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { dirname } from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
4
6
|
import { CliError } from "../client.js";
|
|
7
|
+
import { hookPath, updatedHooks } from "../setup/hooks.js";
|
|
5
8
|
import { detectTools, skillTargets } from "../setup/targets.js";
|
|
6
9
|
import { confirm, installCommand, showPlan, tilde } from "./setup.js";
|
|
10
|
+
const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
7
11
|
async function latestVersion(env) {
|
|
8
|
-
const registry = (env
|
|
12
|
+
const registry = registryOf(env);
|
|
9
13
|
try {
|
|
10
14
|
const res = await fetch(`${registry}/@paxnax/berliner/latest`);
|
|
11
15
|
if (!res.ok)
|
|
@@ -20,15 +24,35 @@ async function latestVersion(env) {
|
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
// The skill text comes from the release being installed, so the plan's "already up to date" and what is written match it.
|
|
23
|
-
|
|
27
|
+
// It is read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc and node_modules.
|
|
28
|
+
async function skillOf(version, bundled, env) {
|
|
24
29
|
if (bundled)
|
|
25
30
|
return readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
try {
|
|
32
|
+
const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
|
|
33
|
+
if (!meta.ok)
|
|
34
|
+
throw new Error(`HTTP ${meta.status}`);
|
|
35
|
+
const { dist } = (await meta.json());
|
|
36
|
+
if (typeof dist?.tarball !== "string")
|
|
37
|
+
throw new Error("no tarball in the reply");
|
|
38
|
+
const res = await fetch(dist.tarball);
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(`HTTP ${res.status}`);
|
|
41
|
+
const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
|
|
42
|
+
// shortcut: plain ustar names only (no pax or GNU long names), enough for npm's short paths; parse pax if a path passes 100 bytes.
|
|
43
|
+
const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
|
|
44
|
+
for (let at = 0; at + 512 <= tar.length && tar[at];) {
|
|
45
|
+
const prefix = field(at + 345, at + 500);
|
|
46
|
+
const size = Number.parseInt(field(at + 124, at + 136).trim() || "0", 8);
|
|
47
|
+
if (`${prefix && `${prefix}/`}${field(at, at + 100)}` === "package/skill/SKILL.md")
|
|
48
|
+
return tar.toString("utf8", at + 512, at + 512 + size);
|
|
49
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
50
|
+
}
|
|
51
|
+
throw new Error("no skill/SKILL.md in the package");
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
55
|
+
}
|
|
32
56
|
}
|
|
33
57
|
// Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
|
|
34
58
|
const newer = (a, b) => {
|
|
@@ -44,7 +68,13 @@ const sameAs = (path, text) => {
|
|
|
44
68
|
return false;
|
|
45
69
|
}
|
|
46
70
|
};
|
|
47
|
-
//
|
|
71
|
+
// What a hook gains from this release, for the plan.
|
|
72
|
+
const HOOK_CHANGE = {
|
|
73
|
+
"claude-code": "the reminder also runs on startup and resume",
|
|
74
|
+
codex: "the reminder also runs on startup and resume",
|
|
75
|
+
opencode: "the new reminder, and totals saved in order",
|
|
76
|
+
};
|
|
77
|
+
// The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
|
|
48
78
|
export async function upgrade(env, values) {
|
|
49
79
|
const current = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
50
80
|
const latest = await latestVersion(env);
|
|
@@ -54,9 +84,15 @@ export async function upgrade(env, values) {
|
|
|
54
84
|
const install = installCommand(latest);
|
|
55
85
|
const tools = detectTools(env);
|
|
56
86
|
const targets = values["no-skill"] ? [] : skillTargets(tools, env);
|
|
57
|
-
const skill = targets.length ? skillOf(target, !installing) : "";
|
|
87
|
+
const skill = targets.length ? await skillOf(target, !installing, env) : "";
|
|
58
88
|
let stale = targets.filter((t) => !sameAs(t.path, skill));
|
|
59
|
-
|
|
89
|
+
// Computed now, so what is written is what the plan showed.
|
|
90
|
+
const hooks = tools.flatMap((tool) => {
|
|
91
|
+
const path = hookPath(tool.id, env);
|
|
92
|
+
const text = updatedHooks(tool.id, path);
|
|
93
|
+
return text === null ? [] : [{ ...tool, path, text }];
|
|
94
|
+
});
|
|
95
|
+
if (!installing && !stale.length && !hooks.length) {
|
|
60
96
|
console.log(`Already up to date. berliner ${current} is ${current === latest ? "the latest release" : `newer than the latest release (${latest})`}.`);
|
|
61
97
|
return;
|
|
62
98
|
}
|
|
@@ -79,6 +115,9 @@ export async function upgrade(env, values) {
|
|
|
79
115
|
return `${tool.name.padEnd(14)}${where}`;
|
|
80
116
|
}),
|
|
81
117
|
},
|
|
118
|
+
...(hooks.length
|
|
119
|
+
? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
|
|
120
|
+
: []),
|
|
82
121
|
]);
|
|
83
122
|
if (!values.yes) {
|
|
84
123
|
if (!process.stdin.isTTY)
|
|
@@ -91,7 +130,8 @@ export async function upgrade(env, values) {
|
|
|
91
130
|
console.log();
|
|
92
131
|
}
|
|
93
132
|
if (installing) {
|
|
94
|
-
|
|
133
|
+
// From home, so the install never picks up the cwd's .npmrc.
|
|
134
|
+
const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
|
|
95
135
|
if (run.status !== 0) {
|
|
96
136
|
console.error(`✗ Could not install the berliner command:\n${(run.error?.message ?? run.stdout + run.stderr).trimEnd()}`);
|
|
97
137
|
throw new CliError(`The skill was left as it is. Install the command yourself, then run upgrade again: ${install.join(" ")}`, "usage");
|
|
@@ -112,5 +152,16 @@ export async function upgrade(env, values) {
|
|
|
112
152
|
console.error(`✗ ${what}: ${tilde(t.path)} can't be written (${err.message.split(",")[0]})`);
|
|
113
153
|
}
|
|
114
154
|
}
|
|
155
|
+
for (const h of hooks) {
|
|
156
|
+
try {
|
|
157
|
+
// In place, so a settings file that is a symlink stays one.
|
|
158
|
+
writeFileSync(h.path, h.text);
|
|
159
|
+
console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
unwritten = true;
|
|
163
|
+
console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be written (${err.message.split(",")[0]})`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
115
166
|
console.log(unwritten ? "\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again." : "\nDone.");
|
|
116
167
|
}
|
package/dist/gh.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const MERGE = { OPEN: "open", MERGED: "merged", CLOSED: "closed" };
|
|
5
|
+
// CheckRun conclusions and StatusContext states.
|
|
6
|
+
const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
|
|
7
|
+
const PENDING = ["PENDING", "EXPECTED"];
|
|
8
|
+
// `gh pr view --json state,statusCheckRollup` as Berliner's merge and CI states. statusCheckRollup mixes CheckRuns (status,
|
|
9
|
+
// conclusion; conclusion is "" until completed) and commit StatusContexts (state).
|
|
10
|
+
export function prState(view) {
|
|
11
|
+
const merge_state = MERGE[String(view.state)];
|
|
12
|
+
if (!merge_state)
|
|
13
|
+
throw new Error(`unexpected PR state ${JSON.stringify(view.state)}`);
|
|
14
|
+
const checks = (Array.isArray(view.statusCheckRollup) ? view.statusCheckRollup : []);
|
|
15
|
+
const ci_state = checks.some((c) => FAILED.includes(c?.conclusion || c?.state))
|
|
16
|
+
? "failed"
|
|
17
|
+
: checks.some((c) => (typeof c?.status === "string" ? c.status !== "COMPLETED" : PENDING.includes(c?.state)))
|
|
18
|
+
? "running"
|
|
19
|
+
: checks.length
|
|
20
|
+
? "passed"
|
|
21
|
+
: "none";
|
|
22
|
+
return { merge_state, ci_state };
|
|
23
|
+
}
|
|
24
|
+
// The machine's gh, found on PATH; it rejects when gh is missing (code ENOENT), fails or takes over 5 seconds.
|
|
25
|
+
export async function viewPr(url) {
|
|
26
|
+
const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup"], { timeout: 5000 });
|
|
27
|
+
return prState(JSON.parse(stdout));
|
|
28
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CliError } from "./client.js";
|
|
|
5
5
|
import { del } from "./commands/delete.js";
|
|
6
6
|
import { evidence } from "./commands/evidence.js";
|
|
7
7
|
import { hook } from "./commands/hook.js";
|
|
8
|
+
import { project, projects } from "./commands/projects.js";
|
|
8
9
|
import { report } from "./commands/report.js";
|
|
9
10
|
import { session } from "./commands/session.js";
|
|
10
11
|
import { sessions } from "./commands/sessions.js";
|
|
@@ -28,6 +29,9 @@ Commands:
|
|
|
28
29
|
sessions [--repo --model --project --ci --merge --since <N>d | --from --to] [--limit <n>] [--json]
|
|
29
30
|
session <session-id> [--json]
|
|
30
31
|
summary same filters as sessions [--json]
|
|
32
|
+
projects the project names in use, then each repository's default [--json]
|
|
33
|
+
project [<name>] show or save this repository's default project; --none saves "no project"
|
|
34
|
+
[--none --repo <owner/name> --json]
|
|
31
35
|
delete <session-id> [--evidence <id>] [--yes]
|
|
32
36
|
evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
|
|
33
37
|
evidence private <id> make it viewable only by you again [--json]
|
|
@@ -39,7 +43,21 @@ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
|
|
|
39
43
|
const args = parse(process.argv.slice(2));
|
|
40
44
|
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
41
45
|
const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
|
|
42
|
-
const handled = new Set([
|
|
46
|
+
const handled = new Set([
|
|
47
|
+
"setup",
|
|
48
|
+
"report",
|
|
49
|
+
"update",
|
|
50
|
+
"sessions",
|
|
51
|
+
"session",
|
|
52
|
+
"summary",
|
|
53
|
+
"projects",
|
|
54
|
+
"project",
|
|
55
|
+
"delete",
|
|
56
|
+
"evidence",
|
|
57
|
+
"skill",
|
|
58
|
+
"hook",
|
|
59
|
+
"upgrade",
|
|
60
|
+
]);
|
|
43
61
|
const cfg = () => readConfig(process.env);
|
|
44
62
|
try {
|
|
45
63
|
if (args.values.help)
|
|
@@ -61,6 +79,10 @@ try {
|
|
|
61
79
|
await session(cfg(), args.values, args.positionals);
|
|
62
80
|
else if (args.command === "summary")
|
|
63
81
|
await summary(cfg(), args.values);
|
|
82
|
+
else if (args.command === "projects")
|
|
83
|
+
await projects(cfg(), args.values);
|
|
84
|
+
else if (args.command === "project")
|
|
85
|
+
await project(cfg(), args.values, args.positionals);
|
|
64
86
|
else if (args.command === "delete")
|
|
65
87
|
await del(cfg(), args.values, args.positionals);
|
|
66
88
|
else if (args.command === "evidence")
|
package/dist/setup/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { claudeDir, codexDir, opencodeDir } from "./targets.js";
|
|
3
4
|
const entries = (tool, asyncStop) => {
|
|
4
5
|
const hooks = (extra = {}) => [{ type: "command", command: `berliner hook ${tool}`, ...extra }];
|
|
5
6
|
return {
|
|
@@ -8,16 +9,17 @@ const entries = (tool, asyncStop) => {
|
|
|
8
9
|
PreCompact: { hooks: hooks() },
|
|
9
10
|
SessionEnd: { hooks: hooks() },
|
|
10
11
|
// Not async: what it prints is the reminder, which the tool adds to the agent's context.
|
|
11
|
-
SessionStart: { matcher: "compact|clear", hooks: hooks() },
|
|
12
|
+
SessionStart: { matcher: "startup|resume|compact|clear", hooks: hooks() },
|
|
12
13
|
};
|
|
13
14
|
};
|
|
14
15
|
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
16
|
const ours = (entry) => isObject(entry) &&
|
|
16
17
|
Array.isArray(entry.hooks) &&
|
|
17
18
|
entry.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.startsWith("berliner hook "));
|
|
18
|
-
// The file with our entries added to the events that lack one
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// The file with our entries added to the events that lack one (unless addMissing is false) and an older matcher of ours
|
|
20
|
+
// brought up to date in place, or null when nothing changes. "unreadable" when it isn't a JSON object whose hooks we can add
|
|
21
|
+
// to: then it is the user's to fix, and is never rewritten.
|
|
22
|
+
function merged(file, add, addMissing = true) {
|
|
21
23
|
let text = null;
|
|
22
24
|
let json = {};
|
|
23
25
|
try {
|
|
@@ -33,37 +35,56 @@ function merged(file, add) {
|
|
|
33
35
|
const hooks = json.hooks ?? {};
|
|
34
36
|
if (!isObject(hooks))
|
|
35
37
|
return "unreadable";
|
|
36
|
-
let
|
|
38
|
+
let added = false;
|
|
39
|
+
let updated = false;
|
|
37
40
|
for (const [event, entry] of Object.entries(add)) {
|
|
38
41
|
const list = hooks[event] ?? [];
|
|
39
42
|
if (!Array.isArray(list))
|
|
40
43
|
return "unreadable";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const i = list.findIndex(ours);
|
|
45
|
+
if (i < 0 && addMissing) {
|
|
46
|
+
hooks[event] = [...list, entry];
|
|
47
|
+
added = true;
|
|
48
|
+
}
|
|
49
|
+
else if (i >= 0 && entry.matcher !== undefined && list[i].matcher !== entry.matcher) {
|
|
50
|
+
hooks[event] = list.map((e, j) => (j === i ? { ...e, matcher: entry.matcher } : e));
|
|
51
|
+
updated = true;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
|
-
if (!
|
|
54
|
+
if (!added && !updated)
|
|
47
55
|
return null;
|
|
48
56
|
const indent = text?.match(/^[ \t]+(?=\S)/m)?.[0] ?? " ";
|
|
49
|
-
return `${JSON.stringify({ ...json, hooks }, null, indent)}\n
|
|
57
|
+
return { text: `${JSON.stringify({ ...json, hooks }, null, indent)}\n`, added };
|
|
50
58
|
}
|
|
51
59
|
function install(file, add) {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
55
|
-
if (
|
|
60
|
+
const result = merged(file, add);
|
|
61
|
+
if (result === "unreadable")
|
|
62
|
+
return result;
|
|
63
|
+
if (result === null)
|
|
56
64
|
return "present";
|
|
57
65
|
mkdirSync(dirname(file), { recursive: true });
|
|
58
66
|
// In place, so a settings file that is a symlink stays one.
|
|
59
|
-
writeFileSync(file, text);
|
|
60
|
-
return "added";
|
|
67
|
+
writeFileSync(file, result.text);
|
|
68
|
+
return result.added ? "added" : "updated";
|
|
61
69
|
}
|
|
62
|
-
const describe = (
|
|
70
|
+
const describe = (result) => result === "unreadable" ? "can't be read, left unchanged" : result === null ? "already installed" : result.added ? "adds a hook" : "updates the hook";
|
|
63
71
|
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code", true));
|
|
64
72
|
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code", true)));
|
|
65
73
|
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex", false));
|
|
66
74
|
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex", false)));
|
|
75
|
+
export const hookPath = (tool, env) => tool === "claude-code"
|
|
76
|
+
? join(claudeDir(env), "settings.json")
|
|
77
|
+
: tool === "codex"
|
|
78
|
+
? join(codexDir(env), "hooks.json")
|
|
79
|
+
: join(opencodeDir(env), "plugins", "berliner.js");
|
|
80
|
+
// For upgrade: the hook file's new text when a Berliner hook is installed but older than this release, else null. Hooks that
|
|
81
|
+
// are missing stay missing: adding them is setup's.
|
|
82
|
+
export function updatedHooks(tool, path) {
|
|
83
|
+
if (tool === "opencode")
|
|
84
|
+
return existsSync(path) && !current(path) ? plugin() : null;
|
|
85
|
+
const result = merged(path, entries(tool, tool === "claude-code"), false);
|
|
86
|
+
return result === "unreadable" ? null : (result?.text ?? null);
|
|
87
|
+
}
|
|
67
88
|
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
68
89
|
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
69
90
|
const current = (pluginPath) => {
|
package/opencode/berliner.js
CHANGED
|
@@ -16,7 +16,9 @@ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Same text as the CLI's SessionStart reminder (src/commands/hook.ts).
|
|
20
|
+
const REMINDER =
|
|
21
|
+
'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
|
|
20
22
|
// The id names a file, so only the characters OpenCode's own ids use.
|
|
21
23
|
const SAFE_ID = /^[\w-]+$/;
|
|
22
24
|
// Bounds a walk up or down the session tree, which subagents keep shallow.
|
|
@@ -75,20 +77,33 @@ async function save(client, sessionID) {
|
|
|
75
77
|
return session;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
// One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one.
|
|
81
|
+
// shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
|
|
82
|
+
const queues = new Map();
|
|
83
|
+
const serial = (id, fn) => {
|
|
84
|
+
const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
|
|
85
|
+
queues.set(
|
|
86
|
+
id,
|
|
87
|
+
p.catch(() => {}),
|
|
88
|
+
);
|
|
89
|
+
return p;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const report = (client, event, sessionID) =>
|
|
93
|
+
serial(sessionID, async () => {
|
|
94
|
+
try {
|
|
95
|
+
const session = await save(client, sessionID);
|
|
96
|
+
if (!session) return;
|
|
97
|
+
// Detached and unreferenced: OpenCode never waits on it.
|
|
98
|
+
const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
|
99
|
+
child.on("error", () => {});
|
|
100
|
+
child.stdin.on("error", () => {});
|
|
101
|
+
child.stdin.end(JSON.stringify({ event, session }));
|
|
102
|
+
child.unref();
|
|
103
|
+
} catch {
|
|
104
|
+
// A report that can't be sent is skipped; the next idle sends the latest totals.
|
|
105
|
+
}
|
|
106
|
+
});
|
|
92
107
|
|
|
93
108
|
export const BerlinerPlugin = async ({ client }) => ({
|
|
94
109
|
"shell.env": async (input, output) => {
|
|
@@ -100,7 +115,7 @@ export const BerlinerPlugin = async ({ client }) => ({
|
|
|
100
115
|
if (!top) return;
|
|
101
116
|
output.env.OPENCODE_SESSION_ID = top;
|
|
102
117
|
// So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
|
|
103
|
-
await save(client, top);
|
|
118
|
+
await serial(top, () => save(client, top));
|
|
104
119
|
} catch {
|
|
105
120
|
// Never fail the agent's shell.
|
|
106
121
|
}
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: berliner
|
|
3
|
-
description: Use when you commit, open or
|
|
3
|
+
description: Use as soon as you know what the task is, whenever a Linear or Jira ticket comes up, when you commit, open, update, merge or close a pull request, or finish a task, and whenever the user asks you to report to Berliner. Names the session and reports the ticket, project, pull requests, test coverage and screenshots or videos with the berliner CLI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Reporting to Berliner
|
|
7
7
|
|
|
8
|
-
Berliner records what an autonomous session cost and produced. You report with
|
|
8
|
+
Berliner records what an autonomous session cost and produced. You report with `berliner report`, and change single fields with `berliner update`.
|
|
9
9
|
|
|
10
10
|
## When to report
|
|
11
11
|
|
|
12
|
+
- As soon as you know the task: name the session (below).
|
|
13
|
+
- Right away when a ticket comes up.
|
|
12
14
|
- Before your final message, whenever you committed, opened or updated a pull request, or finished a task.
|
|
13
|
-
- Again whenever a PR changes (opened, merged, CI finishes) or you add evidence.
|
|
15
|
+
- Again whenever a PR changes (opened, merged, closed, CI finishes) or you add evidence.
|
|
14
16
|
- Right away when the user asks you to report to Berliner.
|
|
15
17
|
|
|
16
18
|
Reporting again is safe. The new values replace the old ones, PR URLs are added to the list, and evidence already uploaded is skipped. It never double-counts.
|
|
@@ -21,14 +23,78 @@ Hooks installed by `berliner setup` already report the session's cost, model, re
|
|
|
21
23
|
|
|
22
24
|
| Flag | What to send |
|
|
23
25
|
|---|---|
|
|
24
|
-
| `--title` | A short line naming the task. |
|
|
25
|
-
| `--task`, `--task-url` | The ticket ID and its URL, if there is one. |
|
|
26
|
+
| `--title` | A short line naming the task. See [Name the session](#name-the-session). |
|
|
27
|
+
| `--task`, `--task-url` | The ticket ID and its URL, if there is one. See [Tickets](#tickets). |
|
|
26
28
|
| `--pr` | Every pull request, one flag each. |
|
|
27
29
|
| `--coverage` | Percent, from the test run's coverage summary. |
|
|
28
30
|
| `--evidence` | Each screenshot or video. |
|
|
29
31
|
|
|
30
32
|
Do not pass `--session-id`, `--cost` or `--model`: the CLI fills them in from the tool's own records, and an explicit value is ignored with a warning. `--repo` and `--plan` are not needed either: the plan comes from the user's `BERLINER_PLAN`, or from their subscriptions in Settings. A report without `--title` keeps the session's existing title. `berliner update` ignores a session ID here.
|
|
31
33
|
|
|
34
|
+
## Name the session
|
|
35
|
+
|
|
36
|
+
As soon as you know what the task is, name the session:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
berliner update --title "Fix login redirect loop"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Keep it under about 60 characters, and name the work, not the prompt: "Fix login redirect loop", not "can you look at why login keeps redirecting". Name it again if the task changes. Write the title yourself; don't spawn a subagent for it. Until you do, the session is titled with the first line of the user's first prompt.
|
|
43
|
+
|
|
44
|
+
Outside Claude Code, Codex and OpenCode, the first `berliner report` creates the session, so pass `--title` there; `berliner update <session-id> --title "..."` renames it later.
|
|
45
|
+
|
|
46
|
+
## Tickets
|
|
47
|
+
|
|
48
|
+
When a ticket is mentioned, created, or found in the branch name, report it right away, without waiting for the end of the session:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
berliner update --task ENG-5521 --task-url https://linear.app/octo/issue/ENG-5521
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The hook fills in a ticket from a Linear or Jira URL in the user's prompts, but only while the session has none. A ticket you report always wins, so report the one the work is really for.
|
|
55
|
+
|
|
56
|
+
## Projects
|
|
57
|
+
|
|
58
|
+
A project is the body of work the user would report on: maintenance work for a client, a stream of recurring tickets that lives in a tracker project, or a product of the user's own. A session's project is, in order:
|
|
59
|
+
|
|
60
|
+
1. the session's own `--project`;
|
|
61
|
+
2. the user's saved default for the repository;
|
|
62
|
+
3. the ticket ID's prefix (`SMTNC-3268` gives `SMTNC`).
|
|
63
|
+
|
|
64
|
+
Send `--project` only for the ticket's tracker project (its Linear project or Jira epic), or a project the user named for this session. Never send a team name: the ticket prefix already covers it, and an explicit team name splits the repository's sessions away from its default.
|
|
65
|
+
|
|
66
|
+
`report` and `update` print this line when the repository has no saved default:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
Project: none saved for octo/app. Suggest one to the user, then save it with: berliner project "<name>"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
In an interactive session, when you see it:
|
|
73
|
+
|
|
74
|
+
1. Run `berliner projects` and reuse an existing name where one fits. Otherwise suggest the ticket's tracker project, else the repository or org name.
|
|
75
|
+
2. Ask the user once, in one line, without stopping your work: "This looks like part of **Acme maintenance**. Is that right?"
|
|
76
|
+
3. Save their answer with `berliner project "Acme maintenance"`, or `berliner project --none` if they say it has no project. Either way, you are not asked again for that repository.
|
|
77
|
+
|
|
78
|
+
In a non-interactive session (no user to answer), never ask and never save.
|
|
79
|
+
|
|
80
|
+
| Command | What it does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `berliner projects` | The project names in use, then each repository's default. |
|
|
83
|
+
| `berliner project` | This repository's default, or that none is saved. |
|
|
84
|
+
| `berliner project "<name>"` | Saves `<name>` as this repository's default. |
|
|
85
|
+
| `berliner project --none` | Saves that this repository has no project. |
|
|
86
|
+
|
|
87
|
+
The repository comes from `git remote get-url origin`; `--repo owner/name` names another one.
|
|
88
|
+
|
|
89
|
+
## Keep PR states current
|
|
90
|
+
|
|
91
|
+
A PR in a repository without the Berliner GitHub App keeps the state you last reported. So:
|
|
92
|
+
|
|
93
|
+
- When you merge a PR, or see one merged or closed, report it at once: `berliner update --pr "<url>,merge=merged"` (or `merge=closed`).
|
|
94
|
+
- Before your final message, check each open PR you reported with `gh pr view <url> --json state`, and report any change.
|
|
95
|
+
|
|
96
|
+
The hook also refreshes open PRs like these with `gh` after every reply, but don't rely on it: `gh` may be missing or signed out.
|
|
97
|
+
|
|
32
98
|
## Outside those tools
|
|
33
99
|
|
|
34
100
|
Without a hook, `report` needs these flags:
|
|
@@ -51,7 +117,9 @@ Report again with the same `--session-id` whenever something changes.
|
|
|
51
117
|
- `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
|
|
52
118
|
- `--evidence <path>[::caption]`: repeat per screenshot or video.
|
|
53
119
|
- `--public`: with `--evidence`, also make those files viewable without sign-in. See below.
|
|
54
|
-
- `--task <id>`, `--task-url <url
|
|
120
|
+
- `--task <id>`, `--task-url <url>`: the ticket. See [Tickets](#tickets).
|
|
121
|
+
- `--project <name>`: only the ticket's tracker project or a project the user named. See [Projects](#projects).
|
|
122
|
+
- `--started-at <ISO>`, `--ended-at <ISO>`.
|
|
55
123
|
|
|
56
124
|
Quote any value that contains spaces.
|
|
57
125
|
|
|
@@ -63,13 +131,13 @@ Evidence is private by default: only the user can see it, signed in. GitHub can
|
|
|
63
131
|
berliner report --evidence "./shots/after.png::Login page after the fix" --public
|
|
64
132
|
```
|
|
65
133
|
|
|
66
|
-
After the evidence line, it prints one line per file: `` for an image, the bare URL for a video. Paste those lines into the PR body as printed. Anyone with a link can view the file, so publish only what belongs in the PR. `berliner evidence private <id>` makes one private again; `berliner evidence public <id>` publishes one already uploaded. `berliner session <id>` lists the ids.
|
|
134
|
+
After the evidence line, it prints one line per file: `` for an image, the bare URL for a video. Paste those lines into the PR body as printed. Anyone with a link can view the file, so publish only what belongs in the PR, and never a capture that shows a password, a token, a cookie or a real customer's data. Before pasting, check that each link opens without signing in: `curl -s -o /dev/null -w '%{http_code}' <url>` prints `200`. `berliner evidence private <id>` makes one private again; `berliner evidence public <id>` publishes one already uploaded. `berliner session <id>` lists the ids.
|
|
67
135
|
|
|
68
136
|
## Environment
|
|
69
137
|
|
|
70
138
|
`berliner setup` stores the API token in a config file, so `BERLINER_TOKEN` is only needed to override it (a token from the Settings page at https://berliner.app). `BERLINER_URL` overrides the server address. `BERLINER_PLAN`, when the user sets it, replaces any `--plan`.
|
|
71
139
|
|
|
72
|
-
If the `berliner` command is not found,
|
|
140
|
+
If the `berliner` command is not found, carry on with your work and say in your final message that `npx @paxnax/berliner@latest setup` needs to run. Do not try to install it yourself.
|
|
73
141
|
|
|
74
142
|
## Timeouts
|
|
75
143
|
|
|
@@ -77,8 +145,8 @@ The session is saved first, then evidence is uploaded. Allow at least 10 minutes
|
|
|
77
145
|
|
|
78
146
|
## Reading the output
|
|
79
147
|
|
|
80
|
-
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.`
|
|
81
|
-
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work
|
|
148
|
+
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.` A `Project: none saved` line asks you for a project: see [Projects](#projects).
|
|
149
|
+
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work. With `--strict`, any failure exits 1, a skipped file included; the session is still saved. Re-run the same command; it is safe to repeat.
|
|
82
150
|
- `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given, and `published` (`path`, `id`, `url`, `markdown`) with `--public`.
|
|
83
151
|
- To change a field later without resending everything, use `berliner update --commits 5` inside the tools, or `berliner update <session-id> --commits 5` outside them.
|
|
84
152
|
|