@profoundry-us/highball 0.4.1 → 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/lib/stamp.js ADDED
@@ -0,0 +1,73 @@
1
+ // Working-tree fingerprint behind `run --fast --if-changed`.
2
+ //
3
+ // Agents in auto mode edit through Bash — sed, heredocs, scripts — so a
4
+ // fast hook matched only on the edit tools never fires for them. Matching
5
+ // Bash too means firing after every command, and most commands are reads.
6
+ // The fingerprint makes those free: HEAD plus every dirty path with its
7
+ // size and mtime, hashed. A further edit to an already-dirty file moves
8
+ // its mtime, so the stamp tracks edits rather than just the set of dirty
9
+ // paths. Stamps live outside the repo, like the journal.
10
+ import { createHash } from "node:crypto";
11
+ import { execSync } from "node:child_process";
12
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ export function stampDir() {
17
+ return join(homedir(), ".highball", "stamps");
18
+ }
19
+
20
+ // One stamp per CHECKOUT, not per project: git worktrees and parallel
21
+ // clones share a project name but not a working tree, and a run in one
22
+ // must never let the other skip a run it needs.
23
+ export function stampPath(project, cwd, dir = stampDir()) {
24
+ const checkout = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
25
+ return join(dir, `${project}-${checkout}`);
26
+ }
27
+
28
+ // null outside a git repo: with no way to tell whether anything changed,
29
+ // --if-changed must never skip.
30
+ export function treeFingerprint(cwd = process.cwd()) {
31
+ const sh = (command) => {
32
+ try {
33
+ return execSync(command, { cwd, encoding: "utf8", stdio: [ "ignore", "pipe", "ignore" ] }).trim();
34
+ } catch {
35
+ return "";
36
+ }
37
+ };
38
+ const head = sh("git rev-parse HEAD");
39
+ if (!head) return null;
40
+
41
+ const hash = createHash("sha256").update(head).update("\0");
42
+ const status = sh("git status --porcelain=v1 -z --untracked-files=all");
43
+ for (const entry of status.split("\0").filter(Boolean)) {
44
+ hash.update(entry).update("\0");
45
+ try {
46
+ const stat = statSync(join(cwd, entry.slice(3)));
47
+ hash.update(`${stat.size}:${stat.mtimeMs}`);
48
+ } catch {
49
+ hash.update("gone");
50
+ }
51
+ hash.update("\0");
52
+ }
53
+ return hash.digest("hex");
54
+ }
55
+
56
+ export function readStamp(project, cwd, dir = stampDir()) {
57
+ try {
58
+ return readFileSync(stampPath(project, cwd, dir), "utf8").trim();
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Best-effort, like the journal: a stamp that fails to write costs one
65
+ // extra fast run, never a failed one.
66
+ export function writeStamp(project, cwd, fingerprint, dir = stampDir()) {
67
+ try {
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ writeFileSync(stampPath(project, cwd, dir), `${fingerprint}\n`);
70
+ } catch {
71
+ // ignore
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@profoundry-us/highball",
3
- "version": "0.4.1",
4
- "description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and reports runs to a Highball dashboard.",
3
+ "version": "0.6.0",
4
+ "description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and optionally reports runs to PostHog.",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "claude-code",
@@ -12,11 +12,11 @@
12
12
  ],
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://github.com/profoundry-us/highball-runner.git"
15
+ "url": "git+https://github.com/profoundry-us/highball.git"
16
16
  },
17
- "homepage": "https://github.com/profoundry-us/highball-runner#readme",
17
+ "homepage": "https://github.com/profoundry-us/highball#readme",
18
18
  "bugs": {
19
- "url": "https://github.com/profoundry-us/highball-runner/issues"
19
+ "url": "https://github.com/profoundry-us/highball/issues"
20
20
  },
21
21
  "type": "module",
22
22
  "bin": {
package/lib/login.js DELETED
@@ -1,68 +0,0 @@
1
- // `highball login` — stores a project token in the machine-local
2
- // credentials file (~/.highball/credentials.json, host → project →
3
- // token). The token is read from stdin with --token-stdin (recommended:
4
- // no shell history, no process listing) or prompted interactively.
5
- // Tokens never touch the repo tree and are never echoed back.
6
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
7
- import { dirname } from "node:path";
8
- import { createInterface } from "node:readline/promises";
9
- import { CREDENTIALS_PATH, loadConfig, readCredentials } from "./config.js";
10
-
11
- export async function login(args) {
12
- const options = parse(args);
13
-
14
- // The local checks.yml, when present, already knows the url + project —
15
- // don't make the user repeat what the repo declares.
16
- let config = null;
17
- try {
18
- config = loadConfig();
19
- } catch {
20
- // Not in a configured repo; url/project must come from flags/prompts.
21
- }
22
-
23
- const rl = createInterface({ input: process.stdin, output: process.stdout });
24
- try {
25
- const url =
26
- options.url ||
27
- config?.reporting?.url ||
28
- (await rl.question("Highball URL (e.g. https://highball.example.com): "));
29
- const project =
30
- options.project || config?.project || (await rl.question("Project slug: "));
31
- const token = options.tokenStdin
32
- ? (await readAllStdin()).trim()
33
- : (await rl.question("Project token (input is visible — prefer --token-stdin): ")).trim();
34
-
35
- if (!url || !project || !token) {
36
- console.error("highball: url, project, and token are all required.");
37
- return 1;
38
- }
39
-
40
- const credentials = readCredentials();
41
- (credentials[url] ??= {})[project] = token;
42
-
43
- mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true, mode: 0o700 });
44
- writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2) + "\n");
45
- chmodSync(CREDENTIALS_PATH, 0o600);
46
-
47
- console.log(`stored token for ${project} @ ${url} in ${CREDENTIALS_PATH}`);
48
- return 0;
49
- } finally {
50
- rl.close();
51
- }
52
- }
53
-
54
- function parse(args) {
55
- const options = { tokenStdin: false };
56
- for (let i = 0; i < args.length; i++) {
57
- if (args[i] === "--url") options.url = args[++i];
58
- else if (args[i] === "--project") options.project = args[++i];
59
- else if (args[i] === "--token-stdin") options.tokenStdin = true;
60
- }
61
- return options;
62
- }
63
-
64
- async function readAllStdin() {
65
- let text = "";
66
- for await (const chunk of process.stdin) text += chunk;
67
- return text;
68
- }
package/lib/report.js DELETED
@@ -1,75 +0,0 @@
1
- // The witness half: best-effort reporting to the Highball app — open
2
- // run, per-check results, finalize. Payloads mirror the Ruby
3
- // proto-runner exactly so the ingestion API sees one dialect. Failures
4
- // here warn and return; they NEVER fail the checks.
5
- import { execSync } from "node:child_process";
6
- import { hostname } from "node:os";
7
-
8
- // Returns the server's run id on success, null when reporting was
9
- // skipped or failed — the caller journals it either way.
10
- export async function report({ url, token, rules, results, hook, fastOnly, startedAt, branch, commitSha }) {
11
- try {
12
- const base = new URL(url);
13
- const request = async (method, path, body) => {
14
- const response = await fetch(new URL(path, base), {
15
- method,
16
- headers: {
17
- Authorization: `Bearer ${token}`,
18
- "Content-Type": "application/json"
19
- },
20
- body: JSON.stringify(body),
21
- signal: AbortSignal.timeout(15_000)
22
- });
23
- if (response.status >= 300) {
24
- throw new Error(`${path} -> ${response.status}: ${await response.text()}`);
25
- }
26
- return response.json();
27
- };
28
-
29
- const opened = await request("POST", "/api/v1/runs", {
30
- agent: hook.session_id ? "claude-code" : "manual",
31
- session_key:
32
- hook.session_id || process.env.HIGHBALL_SESSION_KEY || `manual-${hostname()}`,
33
- trigger: fastOnly ? "edit" : "stop",
34
- branch,
35
- commit_sha: commitSha,
36
- rules_snapshot: rules,
37
- started_at: startedAt.toISOString()
38
- });
39
- const runId = opened.run_id;
40
-
41
- for (const result of results) {
42
- await request("POST", `/api/v1/runs/${runId}/results`, {
43
- check_key: result.rule.id,
44
- name: result.rule.name,
45
- status: result.todo ? "todo" : result.passed ? "passed" : "failed",
46
- duration_ms: result.durationMs,
47
- // Full logs stay local; the tail is enough to read a failure on
48
- // the dashboard without shipping megabytes per keystroke.
49
- log_tail: result.passed ? null : result.output.slice(-4000),
50
- summary: result.todo
51
- ? "planned — not implemented yet"
52
- : result.passed
53
- ? null
54
- : result.output.split("\n")[0]?.trim().slice(0, 120)
55
- });
56
- }
57
-
58
- await request("PATCH", `/api/v1/runs/${runId}`, {
59
- status: results.every((result) => result.passed) ? "passed" : "failed"
60
- });
61
- console.log(`reported to ${base.host} (run ${runId})`);
62
- return runId;
63
- } catch (error) {
64
- console.error(`highball reporting skipped: ${error.message}`);
65
- return null;
66
- }
67
- }
68
-
69
- export function git(command) {
70
- try {
71
- return execSync(`${command} 2>/dev/null`, { encoding: "utf8" }).trim();
72
- } catch {
73
- return "";
74
- }
75
- }