@paxnax/berliner 0.5.3 → 0.7.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 +11 -8
- package/dist/agents/codex.js +14 -17
- package/dist/agents/opencode.js +6 -2
- package/dist/agents/read.js +50 -8
- package/dist/args.js +5 -0
- package/dist/client.js +4 -2
- package/dist/commands/body.js +11 -2
- package/dist/commands/hook.js +65 -53
- package/dist/commands/nudge.js +64 -0
- package/dist/commands/report.js +21 -6
- package/dist/commands/session.js +5 -2
- package/dist/commands/sessions.js +4 -3
- package/dist/commands/setup.js +4 -1
- package/dist/commands/summary.js +6 -5
- package/dist/commands/team.js +21 -0
- package/dist/commands/update.js +6 -2
- package/dist/commands/upgrade.js +4 -2
- package/dist/config.js +17 -2
- package/dist/gh.js +6 -4
- package/dist/index.js +14 -1
- package/dist/ledger.js +202 -0
- package/dist/lock.js +45 -0
- package/dist/output.js +5 -0
- package/dist/setup/hooks.js +28 -12
- 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/dist/upload.js +1 -0
- package/opencode/berliner.js +45 -3
- package/package.json +10 -1
- package/skill/SKILL.md +33 -1
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,10 +84,10 @@ 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"
|
|
@@ -84,7 +100,7 @@ export function updatedHooks(tool, path, next) {
|
|
|
84
100
|
const text = next ?? plugin();
|
|
85
101
|
return existsSync(path) && !current(path, text) ? text : null;
|
|
86
102
|
}
|
|
87
|
-
const result = merged(path, entries(tool
|
|
103
|
+
const result = merged(path, entries(tool), false);
|
|
88
104
|
return result === "unreadable" ? null : (result?.text ?? null);
|
|
89
105
|
}
|
|
90
106
|
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { jsonl } from "../agents/read.js";
|
|
6
|
+
import { nudgeEnabled } from "../commands/nudge.js";
|
|
7
|
+
import { stateDir } from "../config.js";
|
|
8
|
+
import { checkTests } from "./check.js";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const BUDGET_MS = 500;
|
|
11
|
+
// The session's start without reading the whole record: both tools date their first entries.
|
|
12
|
+
async function firstTimestamp(file, deadline) {
|
|
13
|
+
for await (const e of jsonl(file)) {
|
|
14
|
+
if (Date.now() > deadline)
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof e.timestamp === "string")
|
|
17
|
+
return e.timestamp;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
async function repoRoot(cwd, deadline) {
|
|
22
|
+
try {
|
|
23
|
+
return (await run("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: Math.max(1, deadline - Date.now()) })).stdout.trim() || null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// The Stop hook's line asking the agent to run the tests, or null. Asked once per set of changes: the state is saved first.
|
|
30
|
+
export async function nudge(tool, p, env) {
|
|
31
|
+
const deadline = Date.now() + BUDGET_MS;
|
|
32
|
+
if (tool === "opencode" || p.stop_hook_active === true)
|
|
33
|
+
return null;
|
|
34
|
+
if (typeof p.transcript_path !== "string" || !p.transcript_path || typeof p.cwd !== "string")
|
|
35
|
+
return null;
|
|
36
|
+
// A reply ending in a question waits on the user; it doesn't hand the work back. Markdown may close after the ?: `?**`, `?)`.
|
|
37
|
+
const last = typeof p.last_assistant_message === "string" ? p.last_assistant_message.trimEnd().split("\n").at(-1) : "";
|
|
38
|
+
if (/\?[*_)"`]*$/.test(last ?? ""))
|
|
39
|
+
return null;
|
|
40
|
+
const root = await repoRoot(p.cwd, deadline);
|
|
41
|
+
if (!root || !nudgeEnabled(env, root))
|
|
42
|
+
return null;
|
|
43
|
+
const startedAt = await firstTimestamp(p.transcript_path, deadline);
|
|
44
|
+
const check = await checkTests({ tool, transcriptPath: p.transcript_path, cwd: p.cwd, root, startedAt, deadline, quick: true });
|
|
45
|
+
if (!check || check.verdict === "verified")
|
|
46
|
+
return null;
|
|
47
|
+
const dir = join(stateDir(env), "nudge");
|
|
48
|
+
const file = join(dir, `${p.session_id}.json`);
|
|
49
|
+
const state = JSON.stringify({ changed: check.changed, changedAt: check.changedAt });
|
|
50
|
+
if (existsSync(file) && readFileSync(file, "utf8") === state)
|
|
51
|
+
return null;
|
|
52
|
+
// The changed paths name the user's files: only the user may read them.
|
|
53
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
// shortcut: nudge state files are never removed, one small file per session, like the hook's payload files; prune old ones if they pile up.
|
|
55
|
+
writeFileSync(file, state, { mode: 0o600 });
|
|
56
|
+
const files = check.changed
|
|
57
|
+
.slice(0, 3)
|
|
58
|
+
.map((f) => `\`${f}\``)
|
|
59
|
+
.join(", ");
|
|
60
|
+
// No file changed since the last pass means the run after it failed.
|
|
61
|
+
const note = check.verdict === "zero_tests"
|
|
62
|
+
? `\`${check.command}\` ran no tests. Please check the test filter and run the full suite before handing this back.`
|
|
63
|
+
: !files
|
|
64
|
+
? `\`${check.command}\` failed on its last run. Please get it passing, or say why, before handing this back.`
|
|
65
|
+
: `This repo has tests and ${files} changed since they last passed. Please run \`${check.command}\` before handing this back.`;
|
|
66
|
+
// Codex expects JSON, not plain text, on Stop's stdout; block is how it continues the turn.
|
|
67
|
+
return JSON.stringify(tool === "codex" ? { decision: "block", reason: note } : { hookSpecificOutput: { hookEventName: "Stop", additionalContext: note } });
|
|
68
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { subagentFiles } from "../agents/claude.js";
|
|
5
|
+
import { jsonl, jsonlBackward } from "../agents/read.js";
|
|
6
|
+
// Words that change how a run reports, not which tests it runs. Kept short on purpose: anything else makes the run partial.
|
|
7
|
+
const FLAGS = new Set(["--", "--silent", "--color", "--no-color", "-v", "--verbose", "--coverage", "2>&1"]);
|
|
8
|
+
const ZERO = [
|
|
9
|
+
/No test files found/,
|
|
10
|
+
/No tests found/,
|
|
11
|
+
/\b0 tests\b/,
|
|
12
|
+
/\b0 passing\b/,
|
|
13
|
+
/no tests ran/,
|
|
14
|
+
/collected 0 items/,
|
|
15
|
+
/no tests to run/,
|
|
16
|
+
/Tests:\s+0 total/,
|
|
17
|
+
];
|
|
18
|
+
// Non-zero counts only: cargo prints "0 failed" when it passes and "0 passed" when nothing ran.
|
|
19
|
+
const FAIL_COUNT = /\b[1-9]\d* (?:failed|failing)\b/;
|
|
20
|
+
const PASS_COUNT = /\b[1-9]\d* (?:passed|passing)\b/;
|
|
21
|
+
// node:test prints `# pass N` and `# fail N` (TAP) or `ℹ pass N` (spec); TAP's per-test `ok N - name` is no summary, so Go's
|
|
22
|
+
// marker takes its package and time.
|
|
23
|
+
const FAILED = [FAIL_COUNT, /^FAIL\b/m, /test result: FAILED/, /^\s*not ok\b/m, /^[#ℹ] fail [1-9]/m];
|
|
24
|
+
const PASSED = [PASS_COUNT, /^ok\s+\S+\s+(?:[\d.]+s|\(cached\))\s*$/m, /^[#ℹ] pass [1-9]/m];
|
|
25
|
+
const real = (dir) => {
|
|
26
|
+
try {
|
|
27
|
+
return realpathSync(dir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const sameDir = (a, b) => a === b || (real(a) ?? a) === real(b);
|
|
34
|
+
// A full run's shape, or null. Only a run in the repo root is full: in a monorepo, a workspace's own test script is part of
|
|
35
|
+
// the suite. The directory is the call's, moved by each `cd <dir>` before it; one it can't work out (none, `-`, `~`, a
|
|
36
|
+
// variable, quotes, pushd) makes the run partial. Piped: something after the test command owns the exit status, so only
|
|
37
|
+
// the output can tell.
|
|
38
|
+
function parse(command, main, cwd, root) {
|
|
39
|
+
const segments = command.split("&&");
|
|
40
|
+
const mainWords = main.split(" ");
|
|
41
|
+
let dir = cwd;
|
|
42
|
+
for (const [i, segment] of segments.entries()) {
|
|
43
|
+
const end = segment.search(/[|;\n]/);
|
|
44
|
+
const head = end < 0 ? segment : segment.slice(0, end);
|
|
45
|
+
const words = head.trim().split(/\s+/);
|
|
46
|
+
while (/^\w+=\S*$/.test(words[0] ?? ""))
|
|
47
|
+
words.shift();
|
|
48
|
+
if (words[0] === "cd" || words[0] === "pushd") {
|
|
49
|
+
const plain = words[0] === "cd" && words.length === 2 && /^[^-~$"'`\\][^$"'`\\]*$/.test(words[1]);
|
|
50
|
+
dir = dir && plain ? path.resolve(dir, words[1]) : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (mainWords.every((w, j) => words[j] === w) && onlyFlags(words.slice(mainWords.length)) && dir && sameDir(dir, root)) {
|
|
53
|
+
// Splitting removed the &&s, so an & left over backgrounds the chain or redirects.
|
|
54
|
+
const tail = [segment.slice(head.length), ...segments.slice(i + 1)].join(" ").replaceAll("2>&1", "");
|
|
55
|
+
return { piped: /[|;&\n]/.test(tail) };
|
|
56
|
+
}
|
|
57
|
+
// A cd later in the step, after ; & || or a newline, moves the steps after it somewhere this doesn't follow.
|
|
58
|
+
if (/[;|&\n{]\s*(cd|pushd|popd)\b/.test(segment))
|
|
59
|
+
dir = undefined;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function onlyFlags(words) {
|
|
64
|
+
for (let k = 0; k < words.length; k++) {
|
|
65
|
+
if (words[k] === "--reporter")
|
|
66
|
+
k++;
|
|
67
|
+
else if (!FLAGS.has(words[k]) && !words[k].startsWith("--reporter="))
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
export const isFullRun = (command, main, cwd, root) => parse(command, main, cwd, root) !== null;
|
|
73
|
+
function outcome(output, isError, piped) {
|
|
74
|
+
// A "nothing ran" line only means zero when no part of the run counted a test: cargo's doc-tests print "running 0 tests" after the unit tests.
|
|
75
|
+
if (ZERO.some((r) => r.test(output)) && !PASS_COUNT.test(output) && !FAIL_COUNT.test(output))
|
|
76
|
+
return "zero";
|
|
77
|
+
if (!piped)
|
|
78
|
+
return isError === false ? "passed" : isError === true ? "failed" : "unknown";
|
|
79
|
+
if (FAILED.some((r) => r.test(output)))
|
|
80
|
+
return "failed";
|
|
81
|
+
return PASSED.some((r) => r.test(output)) ? "passed" : "unknown";
|
|
82
|
+
}
|
|
83
|
+
const text = (c) => typeof c === "string" ? c : Array.isArray(c) ? c.map((p) => (p?.type === "text" && typeof p.text === "string" ? p.text : "")).join("\n") : "";
|
|
84
|
+
const parts = (e) => (Array.isArray(e.message?.content) ? e.message.content.filter((p) => p && typeof p === "object") : []);
|
|
85
|
+
const time = (ts) => (typeof ts === "string" ? Date.parse(ts) : Number.NaN);
|
|
86
|
+
function bashCall(e, p, main, root) {
|
|
87
|
+
if (e.type !== "assistant" || p.type !== "tool_use" || p.name !== "Bash" || typeof p.input?.command !== "string")
|
|
88
|
+
return null;
|
|
89
|
+
const shape = parse(p.input.command, main, typeof e.cwd === "string" ? e.cwd : undefined, root);
|
|
90
|
+
return shape && { command: p.input.command, ...shape, background: p.input.run_in_background === true };
|
|
91
|
+
}
|
|
92
|
+
function toolResult(e, p) {
|
|
93
|
+
const at = time(e.timestamp);
|
|
94
|
+
return e.type === "user" && p.type === "tool_result" && !Number.isNaN(at) ? { at, content: p.content, isError: p.is_error } : null;
|
|
95
|
+
}
|
|
96
|
+
// A background call's result only says it started.
|
|
97
|
+
const run = (call, r) => ({
|
|
98
|
+
command: call.command,
|
|
99
|
+
at: r.at,
|
|
100
|
+
outcome: call.background ? "unknown" : outcome(text(r.content), r.isError, call.piped),
|
|
101
|
+
});
|
|
102
|
+
async function forward(file, main, root, deadline) {
|
|
103
|
+
const calls = new Map();
|
|
104
|
+
const runs = [];
|
|
105
|
+
for await (const e of jsonl(file)) {
|
|
106
|
+
if (Date.now() > deadline)
|
|
107
|
+
return null;
|
|
108
|
+
for (const p of parts(e)) {
|
|
109
|
+
const call = bashCall(e, p, main, root);
|
|
110
|
+
if (call)
|
|
111
|
+
calls.set(p.id, call);
|
|
112
|
+
const r = toolResult(e, p);
|
|
113
|
+
const made = r && calls.get(p.tool_use_id);
|
|
114
|
+
if (r && made)
|
|
115
|
+
runs.push(run(made, r));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return runs;
|
|
119
|
+
}
|
|
120
|
+
// Last entry first, so a result is seen before its call. Stops at the first entry older than since.
|
|
121
|
+
async function backward(file, main, root, deadline, since) {
|
|
122
|
+
const results = new Map();
|
|
123
|
+
const runs = [];
|
|
124
|
+
for await (const e of jsonlBackward(file)) {
|
|
125
|
+
if (Date.now() > deadline)
|
|
126
|
+
return null;
|
|
127
|
+
if (time(e.timestamp) < since)
|
|
128
|
+
break;
|
|
129
|
+
for (const p of parts(e)) {
|
|
130
|
+
const r = toolResult(e, p);
|
|
131
|
+
if (r)
|
|
132
|
+
results.set(p.tool_use_id, r);
|
|
133
|
+
const call = bashCall(e, p, main, root);
|
|
134
|
+
const got = call && results.get(p.id);
|
|
135
|
+
if (call && got)
|
|
136
|
+
runs.push(run(call, got));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return runs;
|
|
140
|
+
}
|
|
141
|
+
// The full runs of main in the repo root in the session and its subagents, in time order; null once Date.now() passes the
|
|
142
|
+
// deadline. With since (ms), only the runs from then on, read from the end of each file.
|
|
143
|
+
export async function claudeRuns(transcriptPath, main, root, deadline, since) {
|
|
144
|
+
const runs = [];
|
|
145
|
+
for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
|
|
146
|
+
const found = since === undefined ? await forward(file, main, root, deadline) : await backward(file, main, root, deadline, since);
|
|
147
|
+
if (!found)
|
|
148
|
+
return null;
|
|
149
|
+
runs.push(...found);
|
|
150
|
+
}
|
|
151
|
+
if (Date.now() > deadline)
|
|
152
|
+
return null;
|
|
153
|
+
return runs.sort((a, b) => a.at - b.at);
|
|
154
|
+
}
|
|
155
|
+
// Codex records the directory as a file URL.
|
|
156
|
+
function codexDir(cwd) {
|
|
157
|
+
if (typeof cwd !== "string")
|
|
158
|
+
return undefined;
|
|
159
|
+
try {
|
|
160
|
+
return cwd.startsWith("file:") ? fileURLToPath(cwd) : cwd;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// A finished command as a run of main, or null. Codex records the argv; the script is what the shell was given.
|
|
167
|
+
function codexRun(e, main, root) {
|
|
168
|
+
const item = e.payload?.item;
|
|
169
|
+
const at = time(e.timestamp);
|
|
170
|
+
if (e.type !== "event_msg" || e.payload?.type !== "item_completed" || item?.type !== "CommandExecution" || Number.isNaN(at))
|
|
171
|
+
return null;
|
|
172
|
+
const argv = item.command;
|
|
173
|
+
const command = Array.isArray(argv) && /^-\w*c$/.test(String(argv[1])) ? argv[2] : null;
|
|
174
|
+
const shape = typeof command === "string" ? parse(command, main, codexDir(item.cwd), root) : null;
|
|
175
|
+
if (!shape || typeof command !== "string")
|
|
176
|
+
return null;
|
|
177
|
+
// No exit status recorded: nothing says it passed or failed.
|
|
178
|
+
const code = item.exit_code;
|
|
179
|
+
const output = typeof item.aggregated_output === "string" ? item.aggregated_output : "";
|
|
180
|
+
return { command, at, outcome: outcome(output, typeof code === "number" ? code !== 0 : undefined, shape.piped) };
|
|
181
|
+
}
|
|
182
|
+
// The same for a Codex rollout, which has no subagent files.
|
|
183
|
+
export async function codexRuns(rolloutPath, main, root, deadline, since) {
|
|
184
|
+
const runs = [];
|
|
185
|
+
for await (const e of since === undefined ? jsonl(rolloutPath) : jsonlBackward(rolloutPath)) {
|
|
186
|
+
if (Date.now() > deadline)
|
|
187
|
+
return null;
|
|
188
|
+
if (since !== undefined && time(e.timestamp) < since)
|
|
189
|
+
break;
|
|
190
|
+
const r = codexRun(e, main, root);
|
|
191
|
+
if (r)
|
|
192
|
+
runs.push(r);
|
|
193
|
+
}
|
|
194
|
+
return runs.sort((a, b) => a.at - b.at);
|
|
195
|
+
}
|
package/dist/upload.js
CHANGED