@paxnax/berliner 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +25 -9
- package/dist/agents/read.js +44 -8
- package/dist/args.js +3 -0
- package/dist/client.js +4 -2
- package/dist/commands/body.js +22 -6
- package/dist/commands/hook.js +48 -5
- package/dist/commands/nudge.js +64 -0
- package/dist/commands/report.js +7 -3
- package/dist/commands/session.js +3 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup.js +4 -1
- package/dist/commands/team.js +21 -0
- package/dist/commands/update.js +6 -2
- package/dist/commands/upgrade.js +25 -13
- package/dist/config.js +17 -2
- package/dist/gh.js +6 -4
- package/dist/index.js +16 -2
- package/dist/setup/hooks.js +36 -18
- package/dist/team.js +99 -0
- package/dist/tests/check.js +108 -0
- package/dist/tests/command.js +72 -0
- package/dist/tests/nudge.js +68 -0
- package/dist/tests/runs.js +195 -0
- package/opencode/berliner.js +10 -2
- package/package.json +10 -1
- package/skill/SKILL.md +32 -0
package/dist/commands/upgrade.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gunzipSync } from "node:zlib";
|
|
|
6
6
|
import { CliError } from "../client.js";
|
|
7
7
|
import { hookPath, updatedHooks } from "../setup/hooks.js";
|
|
8
8
|
import { detectTools, skillTargets } from "../setup/targets.js";
|
|
9
|
+
import { askNudge } from "./nudge.js";
|
|
9
10
|
import { confirm, installCommand, showPlan, tilde } from "./setup.js";
|
|
10
11
|
const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
11
12
|
async function latestVersion(env) {
|
|
@@ -23,11 +24,13 @@ async function latestVersion(env) {
|
|
|
23
24
|
throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
// The skill
|
|
27
|
-
//
|
|
28
|
-
|
|
27
|
+
// The skill and the OpenCode plugin come from the release being installed, so the plan's "already up to date" and what is
|
|
28
|
+
// written match it. They are read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc
|
|
29
|
+
// and node_modules.
|
|
30
|
+
async function releaseFiles(version, bundled, env) {
|
|
31
|
+
const local = (file) => readFileSync(new URL(`../../${file}`, import.meta.url), "utf8");
|
|
29
32
|
if (bundled)
|
|
30
|
-
return
|
|
33
|
+
return { skill: local("skill/SKILL.md"), plugin: local("opencode/berliner.js") };
|
|
31
34
|
try {
|
|
32
35
|
const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
|
|
33
36
|
if (!meta.ok)
|
|
@@ -41,17 +44,23 @@ async function skillOf(version, bundled, env) {
|
|
|
41
44
|
const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
|
|
42
45
|
// 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
46
|
const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
|
|
47
|
+
const files = new Map();
|
|
44
48
|
for (let at = 0; at + 512 <= tar.length && tar[at];) {
|
|
45
49
|
const prefix = field(at + 345, at + 500);
|
|
46
50
|
const size = Number.parseInt(field(at + 124, at + 136).trim() || "0", 8);
|
|
47
|
-
|
|
48
|
-
return tar.toString("utf8", at + 512, at + 512 + size);
|
|
51
|
+
files.set(`${prefix && `${prefix}/`}${field(at, at + 100)}`, tar.toString("utf8", at + 512, at + 512 + size));
|
|
49
52
|
at += 512 + Math.ceil(size / 512) * 512;
|
|
50
53
|
}
|
|
51
|
-
|
|
54
|
+
const read = (file) => {
|
|
55
|
+
const text = files.get(`package/${file}`);
|
|
56
|
+
if (text === undefined)
|
|
57
|
+
throw new Error(`no ${file} in the package`);
|
|
58
|
+
return text;
|
|
59
|
+
};
|
|
60
|
+
return { skill: read("skill/SKILL.md"), plugin: read("opencode/berliner.js") };
|
|
52
61
|
}
|
|
53
62
|
catch (err) {
|
|
54
|
-
throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
63
|
+
throw new CliError(`Could not read the skill and OpenCode plugin of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
// Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
|
|
@@ -70,7 +79,7 @@ const sameAs = (path, text) => {
|
|
|
70
79
|
};
|
|
71
80
|
// What a hook gains from this release, for the plan.
|
|
72
81
|
const HOOK_CHANGE = {
|
|
73
|
-
"claude-code": "
|
|
82
|
+
"claude-code": "Stop hook no longer async, so Berliner can ask the agent to run the tests",
|
|
74
83
|
codex: "the reminder also runs on startup and resume",
|
|
75
84
|
opencode: "the new reminder, and totals saved in order",
|
|
76
85
|
};
|
|
@@ -84,12 +93,14 @@ export async function upgrade(env, values) {
|
|
|
84
93
|
const install = installCommand(latest);
|
|
85
94
|
const tools = detectTools(env);
|
|
86
95
|
const targets = values["no-skill"] ? [] : skillTargets(tools, env);
|
|
87
|
-
|
|
96
|
+
// The plugin too, so --no-skill still brings it up to date.
|
|
97
|
+
const release = targets.length || tools.some((t) => t.id === "opencode" && t.found) ? await releaseFiles(target, !installing, env) : null;
|
|
98
|
+
const skill = release?.skill ?? "";
|
|
88
99
|
let stale = targets.filter((t) => !sameAs(t.path, skill));
|
|
89
100
|
// Computed now, for the plan.
|
|
90
101
|
const hooks = tools.flatMap((tool) => {
|
|
91
102
|
const path = hookPath(tool.id, env);
|
|
92
|
-
const text = updatedHooks(tool.id, path);
|
|
103
|
+
const text = updatedHooks(tool.id, path, release?.plugin);
|
|
93
104
|
return text === null ? [] : [{ ...tool, path, text }];
|
|
94
105
|
});
|
|
95
106
|
if (!installing && !stale.length && !hooks.length) {
|
|
@@ -127,6 +138,7 @@ export async function upgrade(env, values) {
|
|
|
127
138
|
// Without an install, the skill is all Continue covered.
|
|
128
139
|
if (installing && stale.length && !(await confirm(process.stdin, process.stdout, "Upgrade the skill too? [Y/n] ")))
|
|
129
140
|
stale = [];
|
|
141
|
+
await askNudge(env);
|
|
130
142
|
console.log();
|
|
131
143
|
}
|
|
132
144
|
if (installing) {
|
|
@@ -153,8 +165,8 @@ export async function upgrade(env, values) {
|
|
|
153
165
|
}
|
|
154
166
|
}
|
|
155
167
|
for (const h of hooks) {
|
|
156
|
-
// Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the
|
|
157
|
-
// the plan showed
|
|
168
|
+
// Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the
|
|
169
|
+
// release's, as the plan showed.
|
|
158
170
|
// shortcut: an edit between this read and write is still lost; lock if that shows up.
|
|
159
171
|
const text = h.id === "opencode" ? h.text : updatedHooks(h.id, h.path);
|
|
160
172
|
if (text === null)
|
package/dist/config.js
CHANGED
|
@@ -7,14 +7,29 @@ export function configPath(env) {
|
|
|
7
7
|
}
|
|
8
8
|
export const stateDir = (env) => join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner");
|
|
9
9
|
export const cacheDir = (env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "berliner");
|
|
10
|
+
function readNudge(raw) {
|
|
11
|
+
if (typeof raw !== "object" || raw === null)
|
|
12
|
+
return undefined;
|
|
13
|
+
const { off_all, off, asked } = raw;
|
|
14
|
+
return {
|
|
15
|
+
...(asked === true && { asked: true }),
|
|
16
|
+
...(off_all === true && { off_all: true }),
|
|
17
|
+
...(Array.isArray(off) && { off: off.filter((r) => typeof r === "string") }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
10
20
|
// A missing or unreadable file is the same as no file: the environment may still hold the token.
|
|
11
21
|
export function readConfigFile(env) {
|
|
12
22
|
try {
|
|
13
23
|
const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
|
|
14
24
|
if (typeof parsed !== "object" || parsed === null)
|
|
15
25
|
return null;
|
|
16
|
-
const { token, url } = parsed;
|
|
17
|
-
return {
|
|
26
|
+
const { token, url, team, nudge } = parsed;
|
|
27
|
+
return {
|
|
28
|
+
token: typeof token === "string" ? token : undefined,
|
|
29
|
+
url: typeof url === "string" ? url : undefined,
|
|
30
|
+
team: typeof team === "string" ? team : undefined,
|
|
31
|
+
nudge: readNudge(nudge),
|
|
32
|
+
};
|
|
18
33
|
}
|
|
19
34
|
catch {
|
|
20
35
|
return null;
|
package/dist/gh.js
CHANGED
|
@@ -5,8 +5,8 @@ const MERGE = { OPEN: "open", MERGED: "merged", CLOSED: "closed" };
|
|
|
5
5
|
// CheckRun conclusions and StatusContext states.
|
|
6
6
|
const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
|
|
7
7
|
const PENDING = ["PENDING", "EXPECTED"];
|
|
8
|
-
// `gh pr view --json state,statusCheckRollup` as Berliner's merge and CI states
|
|
9
|
-
// conclusion; conclusion is "" until completed) and commit StatusContexts (state).
|
|
8
|
+
// `gh pr view --json state,statusCheckRollup,headRefOid` as Berliner's merge and CI states and the head commit they belong to.
|
|
9
|
+
// statusCheckRollup mixes CheckRuns (status, conclusion; conclusion is "" until completed) and commit StatusContexts (state).
|
|
10
10
|
export function prState(view) {
|
|
11
11
|
const merge_state = MERGE[String(view.state)];
|
|
12
12
|
if (!merge_state)
|
|
@@ -19,10 +19,12 @@ export function prState(view) {
|
|
|
19
19
|
: checks.length
|
|
20
20
|
? "passed"
|
|
21
21
|
: "none";
|
|
22
|
-
|
|
22
|
+
// Anything else the server would refuse, losing the states with it.
|
|
23
|
+
const head_sha = typeof view.headRefOid === "string" && /^[0-9a-f]{40}$/.test(view.headRefOid) ? view.headRefOid : null;
|
|
24
|
+
return { merge_state, ci_state, head_sha };
|
|
23
25
|
}
|
|
24
26
|
// The machine's gh, found on PATH; it rejects when gh is missing (code ENOENT), fails or takes over 5 seconds.
|
|
25
27
|
export async function viewPr(url) {
|
|
26
|
-
const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup"], { timeout: 5000 });
|
|
28
|
+
const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup,headRefOid"], { timeout: 5000 });
|
|
27
29
|
return prState(JSON.parse(stdout));
|
|
28
30
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
//usr/bin/true;
|
|
2
|
+
//usr/bin/true; command -v node >/dev/null && exec node "$0" "$@"; exec bun --config=/dev/null --env-file=/dev/null "$0" "$@"
|
|
3
|
+
// Bun reads the cwd's .env and bunfig.toml (whose preload runs code), so a checkout could redirect the token or run its code.
|
|
3
4
|
import { parse } from "./args.js";
|
|
4
5
|
import { CliError } from "./client.js";
|
|
5
6
|
import { del } from "./commands/delete.js";
|
|
6
7
|
import { evidence } from "./commands/evidence.js";
|
|
7
8
|
import { hook } from "./commands/hook.js";
|
|
9
|
+
import { nudge } from "./commands/nudge.js";
|
|
8
10
|
import { project, projects } from "./commands/projects.js";
|
|
9
11
|
import { report } from "./commands/report.js";
|
|
10
12
|
import { session } from "./commands/session.js";
|
|
11
13
|
import { sessions } from "./commands/sessions.js";
|
|
12
14
|
import { setup } from "./commands/setup.js";
|
|
13
15
|
import { skill } from "./commands/skill.js";
|
|
16
|
+
import { team } from "./commands/team.js";
|
|
14
17
|
import { summary } from "./commands/summary.js";
|
|
15
18
|
import { update } from "./commands/update.js";
|
|
16
19
|
import { upgrade } from "./commands/upgrade.js";
|
|
@@ -32,6 +35,11 @@ Commands:
|
|
|
32
35
|
projects the project names in use, then each repository's default [--json]
|
|
33
36
|
project [<name>] show or save this repository's default project; --none saves "no project"
|
|
34
37
|
[--none --repo <owner/name> --json]
|
|
38
|
+
team [<uuid>] show or save the team new sessions are reported to; --none clears it
|
|
39
|
+
a team can also come from --team <uuid> / --no-team, .berliner.json {"team": "<uuid>"}, BERLINER_TEAM
|
|
40
|
+
[--none --json]
|
|
41
|
+
nudge [on|off] show or set whether Berliner asks agents to run the tests before they hand work back, for this repository
|
|
42
|
+
[--all for every repository]
|
|
35
43
|
delete <session-id> [--evidence <id>] [--yes]
|
|
36
44
|
evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
|
|
37
45
|
evidence private <id> make it viewable only by you again [--json]
|
|
@@ -39,7 +47,7 @@ Commands:
|
|
|
39
47
|
skill print the bundled SKILL.md
|
|
40
48
|
hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
|
|
41
49
|
|
|
42
|
-
Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
|
|
50
|
+
Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan), BERLINER_TEAM`;
|
|
43
51
|
const args = parse(process.argv.slice(2));
|
|
44
52
|
// Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
|
|
45
53
|
const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
|
|
@@ -52,6 +60,8 @@ const handled = new Set([
|
|
|
52
60
|
"summary",
|
|
53
61
|
"projects",
|
|
54
62
|
"project",
|
|
63
|
+
"team",
|
|
64
|
+
"nudge",
|
|
55
65
|
"delete",
|
|
56
66
|
"evidence",
|
|
57
67
|
"skill",
|
|
@@ -83,6 +93,10 @@ try {
|
|
|
83
93
|
await projects(cfg(), args.values);
|
|
84
94
|
else if (args.command === "project")
|
|
85
95
|
await project(cfg(), args.values, args.positionals);
|
|
96
|
+
else if (args.command === "team")
|
|
97
|
+
await team(process.env, args.values, args.positionals);
|
|
98
|
+
else if (args.command === "nudge")
|
|
99
|
+
await nudge(process.env, args.values, args.positionals);
|
|
86
100
|
else if (args.command === "delete")
|
|
87
101
|
await del(cfg(), args.values, args.positionals);
|
|
88
102
|
else if (args.command === "evidence")
|
package/dist/setup/hooks.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { claudeDir, codexDir, opencodeDir } from "./targets.js";
|
|
4
|
-
const entries = (tool
|
|
4
|
+
const entries = (tool) => {
|
|
5
5
|
const hooks = (extra = {}) => [{ type: "command", command: `berliner hook ${tool}`, ...extra }];
|
|
6
6
|
return {
|
|
7
|
-
// Claude Code
|
|
8
|
-
Stop: { hooks: hooks(
|
|
7
|
+
// Not async: Claude Code only reads what a foreground Stop hook prints to the agent. The hook still returns at once.
|
|
8
|
+
Stop: { hooks: hooks() },
|
|
9
9
|
PreCompact: { hooks: hooks() },
|
|
10
10
|
SessionEnd: { hooks: hooks() },
|
|
11
11
|
// Not async: what it prints is the reminder, which the tool adds to the agent's context.
|
|
@@ -16,7 +16,7 @@ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
|
16
16
|
const ours = (entry) => isObject(entry) &&
|
|
17
17
|
Array.isArray(entry.hooks) &&
|
|
18
18
|
entry.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.startsWith("berliner hook "));
|
|
19
|
-
// The file with our entries added to the events that lack one (unless addMissing is false) and an older matcher of ours
|
|
19
|
+
// The file with our entries added to the events that lack one (unless addMissing is false) and an older matcher or async flag of ours
|
|
20
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
21
|
// to: then it is the user's to fix, and is never rewritten.
|
|
22
22
|
function merged(file, add, addMissing = true) {
|
|
@@ -46,9 +46,25 @@ function merged(file, add, addMissing = true) {
|
|
|
46
46
|
hooks[event] = [...list, entry];
|
|
47
47
|
added = true;
|
|
48
48
|
}
|
|
49
|
-
else if (i >= 0
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
else if (i >= 0) {
|
|
50
|
+
const matcher = entry.matcher !== undefined && list[i].matcher !== entry.matcher;
|
|
51
|
+
const wantAsync = entry.hooks[0].async;
|
|
52
|
+
const stale = (h) => isObject(h) && ours({ hooks: [h] }) && h.async !== wantAsync;
|
|
53
|
+
const flag = list[i].hooks.some(stale);
|
|
54
|
+
if (matcher || flag) {
|
|
55
|
+
hooks[event] = list.map((e, j) => {
|
|
56
|
+
if (j !== i)
|
|
57
|
+
return e;
|
|
58
|
+
const hs = e.hooks.map((h) => {
|
|
59
|
+
if (!stale(h))
|
|
60
|
+
return h;
|
|
61
|
+
const { async: _, ...rest } = h;
|
|
62
|
+
return wantAsync === undefined ? rest : { ...rest, async: wantAsync };
|
|
63
|
+
});
|
|
64
|
+
return { ...e, ...(matcher ? { matcher: entry.matcher } : {}), hooks: hs };
|
|
65
|
+
});
|
|
66
|
+
updated = true;
|
|
67
|
+
}
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
70
|
if (!added && !updated)
|
|
@@ -68,28 +84,30 @@ function install(file, add) {
|
|
|
68
84
|
return result.added ? "added" : "updated";
|
|
69
85
|
}
|
|
70
86
|
const describe = (result) => result === "unreadable" ? "can't be read, left unchanged" : result === null ? "already installed" : result.added ? "adds a hook" : "updates the hook";
|
|
71
|
-
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code"
|
|
72
|
-
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code"
|
|
73
|
-
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex"
|
|
74
|
-
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex"
|
|
87
|
+
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code"));
|
|
88
|
+
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code")));
|
|
89
|
+
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex"));
|
|
90
|
+
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex")));
|
|
75
91
|
export const hookPath = (tool, env) => tool === "claude-code"
|
|
76
92
|
? join(claudeDir(env), "settings.json")
|
|
77
93
|
: tool === "codex"
|
|
78
94
|
? join(codexDir(env), "hooks.json")
|
|
79
95
|
: join(opencodeDir(env), "plugins", "berliner.js");
|
|
80
96
|
// 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
|
-
|
|
85
|
-
|
|
97
|
+
// are missing stay missing: adding them is setup's. `next` is the plugin of the release being installed, else this one's.
|
|
98
|
+
export function updatedHooks(tool, path, next) {
|
|
99
|
+
if (tool === "opencode") {
|
|
100
|
+
const text = next ?? plugin();
|
|
101
|
+
return existsSync(path) && !current(path, text) ? text : null;
|
|
102
|
+
}
|
|
103
|
+
const result = merged(path, entries(tool), false);
|
|
86
104
|
return result === "unreadable" ? null : (result?.text ?? null);
|
|
87
105
|
}
|
|
88
106
|
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
89
107
|
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
90
|
-
const current = (pluginPath) => {
|
|
108
|
+
const current = (pluginPath, text = plugin()) => {
|
|
91
109
|
try {
|
|
92
|
-
return readFileSync(pluginPath, "utf8") ===
|
|
110
|
+
return readFileSync(pluginPath, "utf8") === text;
|
|
93
111
|
}
|
|
94
112
|
catch {
|
|
95
113
|
return false;
|
package/dist/team.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { CliError } from "./client.js";
|
|
6
|
+
import { readConfigFile, stateDir } from "./config.js";
|
|
7
|
+
import { gitFacts } from "./git.js";
|
|
8
|
+
import { warn } from "./output.js";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
// The `team` of .berliner.json at the repository root, or undefined outside a repository or without the file.
|
|
12
|
+
async function repoTeam(cwd) {
|
|
13
|
+
const root = await run("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 2000 }).then((r) => r.stdout.trim(), () => null);
|
|
14
|
+
if (!root)
|
|
15
|
+
return undefined;
|
|
16
|
+
const file = join(root, ".berliner.json");
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT")
|
|
23
|
+
return undefined;
|
|
24
|
+
throw new CliError(`${file} is not readable JSON: ${err.message}`, "usage");
|
|
25
|
+
}
|
|
26
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
27
|
+
throw new CliError(`${file} must hold a JSON object like {"team": "<uuid>"}`, "usage");
|
|
28
|
+
const { team } = parsed;
|
|
29
|
+
if (team !== undefined && typeof team !== "string")
|
|
30
|
+
throw new CliError(`"team" in ${file} must be a string (the team's id)`, "usage");
|
|
31
|
+
return team || undefined;
|
|
32
|
+
}
|
|
33
|
+
// The founder's order: --no-team, --team, .berliner.json, BERLINER_TEAM, the global config, else the developer's own plan.
|
|
34
|
+
export async function resolveTeamSource(values, cwd, env) {
|
|
35
|
+
if (values["no-team"] && values.team !== undefined)
|
|
36
|
+
throw new CliError("--team and --no-team can't be used together", "usage");
|
|
37
|
+
if (values["no-team"])
|
|
38
|
+
return { team: null, source: "flag" };
|
|
39
|
+
if (values.team)
|
|
40
|
+
return { team: values.team, source: "flag" };
|
|
41
|
+
const repo = await repoTeam(cwd);
|
|
42
|
+
if (repo)
|
|
43
|
+
return { team: repo, source: "repo" };
|
|
44
|
+
if (env.BERLINER_TEAM)
|
|
45
|
+
return { team: env.BERLINER_TEAM, source: "env" };
|
|
46
|
+
const saved = readConfigFile(env)?.team;
|
|
47
|
+
return saved ? { team: saved, source: "config" } : { team: null, source: "none" };
|
|
48
|
+
}
|
|
49
|
+
export const resolveTeam = async (values, cwd, env) => (await resolveTeamSource(values, cwd, env)).team;
|
|
50
|
+
// A session keeps the team it was created with, so a different setting now only deserves a word.
|
|
51
|
+
export function teamWarning(saved, resolved) {
|
|
52
|
+
return (saved?.id ?? null) === resolved ? null : `this session is already on ${saved?.name ?? "your own plan"}; the team setting applies to new sessions`;
|
|
53
|
+
}
|
|
54
|
+
const refusals = (env) => join(stateDir(env), "refusals.json");
|
|
55
|
+
function readNotes(env) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(refusals(env), "utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Renamed into place, so a concurrent reader never sees half a file.
|
|
64
|
+
// shortcut: two writers at once can lose one note (read, then write); the next refused report leaves it again.
|
|
65
|
+
function writeNotes(env, notes) {
|
|
66
|
+
const file = refusals(env);
|
|
67
|
+
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
68
|
+
if (!Object.keys(notes).length)
|
|
69
|
+
rmSync(file, { force: true });
|
|
70
|
+
else {
|
|
71
|
+
writeFileSync(`${file}.${process.pid}`, JSON.stringify(notes));
|
|
72
|
+
renameSync(`${file}.${process.pid}`, file);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// The hook can't print: it leaves the server's refusal here for the next command in that repository.
|
|
76
|
+
export function noteRefusal(env, repository, message) {
|
|
77
|
+
writeNotes(env, { ...readNotes(env), [repository]: { message, at: new Date().toISOString() } });
|
|
78
|
+
}
|
|
79
|
+
export function takeRefusal(env, repository) {
|
|
80
|
+
const notes = readNotes(env);
|
|
81
|
+
const note = notes[repository];
|
|
82
|
+
if (!note)
|
|
83
|
+
return null;
|
|
84
|
+
delete notes[repository];
|
|
85
|
+
writeNotes(env, notes);
|
|
86
|
+
return note.message;
|
|
87
|
+
}
|
|
88
|
+
// Foreground commands print a refusal the hook left for this directory's repository, once. Outside a repository: nothing.
|
|
89
|
+
export async function announceRefusal(env) {
|
|
90
|
+
try {
|
|
91
|
+
const repository = (await gitFacts(process.cwd(), null)).repository;
|
|
92
|
+
const message = repository && takeRefusal(env, repository);
|
|
93
|
+
if (message)
|
|
94
|
+
warn(message);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A note that can't be read or removed must not stop the command.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { testCommand } from "./command.js";
|
|
6
|
+
import { claudeRuns, codexRuns } from "./runs.js";
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
const DOCS = /^docs\/|\.(?:md|mdx|txt)$/i;
|
|
9
|
+
// Best effort like git.ts, within what is left of the budget: a git error or no time left gives null.
|
|
10
|
+
async function git(cwd, args, deadline) {
|
|
11
|
+
const timeout = deadline - Date.now();
|
|
12
|
+
if (timeout <= 0)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return (await run("git", ["-c", "core.quotePath=false", ...args], { cwd, timeout })).stdout;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const mtime = (file) => lstatSync(file, { throwIfNoEntry: false })?.mtimeMs;
|
|
22
|
+
// A deleted file is dated by the change to its nearest remaining folder: that removal is the folder's last change unless a
|
|
23
|
+
// later one came after it, which only makes the date later.
|
|
24
|
+
function removedAt(root, file) {
|
|
25
|
+
for (let dir = path.dirname(file);; dir = path.dirname(dir)) {
|
|
26
|
+
const at = mtime(path.join(root, dir));
|
|
27
|
+
if (at !== undefined || dir === ".")
|
|
28
|
+
return at ?? Date.now();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// The code files the session changed, uncommitted or committed since it started, newest first, and whether any code file is
|
|
32
|
+
// uncommitted whatever its date; null when git fails.
|
|
33
|
+
async function changedFiles(root, startedAt, start, deadline) {
|
|
34
|
+
const status = await git(root, ["status", "--porcelain", "-z", "--untracked-files=all"], deadline);
|
|
35
|
+
if (status === null)
|
|
36
|
+
return null;
|
|
37
|
+
// A repository with no commit has no log.
|
|
38
|
+
const log = (await git(root, ["log", `--since=${startedAt}`, "--name-only", "--format=%x01%ct"], deadline)) ?? "";
|
|
39
|
+
const uncommitted = new Set();
|
|
40
|
+
const entries = status.split("\0");
|
|
41
|
+
for (let i = 0; i < entries.length; i++) {
|
|
42
|
+
if (!entries[i])
|
|
43
|
+
continue;
|
|
44
|
+
uncommitted.add(entries[i].slice(3));
|
|
45
|
+
// A rename or copy is followed by its source path.
|
|
46
|
+
if (/^[RC]/.test(entries[i]))
|
|
47
|
+
uncommitted.add(entries[++i]);
|
|
48
|
+
}
|
|
49
|
+
const committed = new Map();
|
|
50
|
+
let commitAt = 0;
|
|
51
|
+
for (const line of log.split("\n")) {
|
|
52
|
+
if (line.startsWith("\x01"))
|
|
53
|
+
commitAt = Number(line.slice(1)) * 1000;
|
|
54
|
+
else if (line && !committed.has(line))
|
|
55
|
+
committed.set(line, commitAt); // newest commit first
|
|
56
|
+
}
|
|
57
|
+
const files = [];
|
|
58
|
+
for (const file of new Set([...uncommitted, ...committed.keys()])) {
|
|
59
|
+
if (DOCS.test(file))
|
|
60
|
+
continue;
|
|
61
|
+
const at = mtime(path.join(root, file)) ?? (uncommitted.has(file) ? removedAt(root, file) : committed.get(file));
|
|
62
|
+
if (at !== undefined && at >= start)
|
|
63
|
+
files.push({ path: file, at });
|
|
64
|
+
}
|
|
65
|
+
return { files: files.sort((a, b) => b.at - a.at), dirty: [...uncommitted].some((f) => !DOCS.test(f)) };
|
|
66
|
+
}
|
|
67
|
+
// The session's test runs against its changes, or null when there is nothing to say. Quick mode reads only the runs since
|
|
68
|
+
// the newest change, so a pass before it gives unchecked, not stale.
|
|
69
|
+
export async function checkTests(o) {
|
|
70
|
+
// OpenCode records no commands.
|
|
71
|
+
if ((o.tool !== "claude-code" && o.tool !== "codex") || !o.startedAt)
|
|
72
|
+
return null;
|
|
73
|
+
const start = Date.parse(o.startedAt);
|
|
74
|
+
if (Number.isNaN(start))
|
|
75
|
+
return null;
|
|
76
|
+
const root = o.root ?? (await git(o.cwd, ["rev-parse", "--show-toplevel"], o.deadline))?.trim();
|
|
77
|
+
if (!root)
|
|
78
|
+
return null;
|
|
79
|
+
const main = testCommand(root);
|
|
80
|
+
if (!main)
|
|
81
|
+
return null;
|
|
82
|
+
const { command } = main;
|
|
83
|
+
const found = await changedFiles(root, o.startedAt, start, o.deadline);
|
|
84
|
+
if (!found?.files.length)
|
|
85
|
+
return null;
|
|
86
|
+
const { files, dirty } = found;
|
|
87
|
+
const [headSha, headAt] = (await git(root, ["log", "-1", "--format=%H %ct"], o.deadline))?.trim().split(" ") ?? [];
|
|
88
|
+
const read = o.tool === "codex" ? codexRuns : claudeRuns;
|
|
89
|
+
const runs = await read(o.transcriptPath, command, root, o.deadline, o.quick ? files[0].at : start);
|
|
90
|
+
if (!runs || Date.now() > o.deadline)
|
|
91
|
+
return null;
|
|
92
|
+
const counted = runs.filter((r) => r.outcome !== "unknown");
|
|
93
|
+
const last = counted.at(-1);
|
|
94
|
+
const pass = counted.findLast((r) => r.outcome === "passed");
|
|
95
|
+
const changed = pass ? files.filter((f) => f.at > pass.at) : files;
|
|
96
|
+
// The latest counted run decides: a failure after a pass is not verified.
|
|
97
|
+
const verdict = last?.outcome === "zero" ? "zero_tests" : last?.outcome !== "passed" ? "unchecked" : changed.length ? "stale" : "verified";
|
|
98
|
+
return {
|
|
99
|
+
verdict,
|
|
100
|
+
command,
|
|
101
|
+
changed: changed.map((f) => f.path),
|
|
102
|
+
changedAt: files[0].at,
|
|
103
|
+
// CI vouches for a commit only when CI runs the tests, and the server takes SHA-1 ids only, as gh.ts reads them.
|
|
104
|
+
headSha: main.source === "workflow" && /^[0-9a-f]{40}$/.test(headSha ?? "") ? headSha : null,
|
|
105
|
+
// Any uncommitted code change, however old, means HEAD is not the tree that was tested.
|
|
106
|
+
treeClean: headAt !== undefined && !dirty,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// In the order they are tried.
|
|
4
|
+
const KNOWN = [
|
|
5
|
+
"npm test",
|
|
6
|
+
"npm run test",
|
|
7
|
+
"pnpm test",
|
|
8
|
+
"yarn test",
|
|
9
|
+
"bun test",
|
|
10
|
+
"pytest",
|
|
11
|
+
"go test ./...",
|
|
12
|
+
"cargo test",
|
|
13
|
+
"vendor/bin/phpunit",
|
|
14
|
+
"composer test",
|
|
15
|
+
"bundle exec rspec",
|
|
16
|
+
"mix test",
|
|
17
|
+
];
|
|
18
|
+
const LOCKS = [
|
|
19
|
+
["pnpm-lock.yaml", "pnpm test"],
|
|
20
|
+
["yarn.lock", "yarn test"],
|
|
21
|
+
["bun.lock", "bun test"],
|
|
22
|
+
["bun.lockb", "bun test"],
|
|
23
|
+
];
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
// The repo's main test command: the first known one a CI workflow runs, else package.json's test script. Synchronous reads only.
|
|
26
|
+
export function testCommand(root) {
|
|
27
|
+
const dir = path.join(root, ".github", "workflows");
|
|
28
|
+
const workflows = existsSync(dir)
|
|
29
|
+
? readdirSync(dir)
|
|
30
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
31
|
+
.sort()
|
|
32
|
+
.map((f) => path.join(dir, f))
|
|
33
|
+
: [];
|
|
34
|
+
const files = [...workflows, path.join(root, "package.json"), ...LOCKS.map(([f]) => path.join(root, f))];
|
|
35
|
+
const key = files.map((f) => `${f}:${existsSync(f) ? statSync(f).mtimeMs : ""}`).join("|");
|
|
36
|
+
const hit = cache.get(root);
|
|
37
|
+
if (hit?.key === key)
|
|
38
|
+
return hit.found;
|
|
39
|
+
const ci = fromWorkflows(workflows);
|
|
40
|
+
const found = ci ? { command: ci, source: "workflow" } : fromPackage(root);
|
|
41
|
+
cache.set(root, { key, found });
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
// shortcut: line matching, not YAML; upgrade if workflows with anchors or matrices that build the command go wrong.
|
|
45
|
+
function fromWorkflows(files) {
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
// A `run: cmd` line, a `- run: cmd` step or a line of a `run: |` block.
|
|
48
|
+
const lines = readFileSync(file, "utf8")
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((l) => l
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/^-\s+/, "")
|
|
53
|
+
.replace(/^run:\s*/, ""));
|
|
54
|
+
const found = KNOWN.find((c) => lines.some((l) => l === c || l.startsWith(`${c} `) || l.startsWith(`${c}\t`)));
|
|
55
|
+
if (found)
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function fromPackage(root) {
|
|
61
|
+
let scripts;
|
|
62
|
+
try {
|
|
63
|
+
scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// npm init's placeholder always fails: it is no tests.
|
|
69
|
+
if (typeof scripts?.test !== "string" || /no test specified/.test(scripts.test))
|
|
70
|
+
return null;
|
|
71
|
+
return { command: LOCKS.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? "npm test", source: "package" };
|
|
72
|
+
}
|