@profoundry-us/highball 0.5.0 → 0.6.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/ONBOARDING.md CHANGED
@@ -151,11 +151,18 @@ Decision rules:
151
151
  Highball enforces with no account and no network. Reporting is a separate,
152
152
  optional decision, and it is your human's to make — ask, do not assume.
153
153
 
154
- If they want it, they add a `reporting.posthog` block to `checks.yml` with
155
- their PostHog host and project key. That key is write-only by design (it is
156
- the same one that ships in client-side web bundles), so it is committed
157
- config, not a secret there is no login step and no credentials file. If
158
- they would rather not send anything anywhere, skip the block: the local
154
+ If they want it, there are two ways, and the choice is theirs:
155
+
156
+ - **Committed:** a `reporting.posthog` block in `checks.yml` with their
157
+ PostHog host and project key. That key is write-only by design (it is the
158
+ same one that ships in client-side web bundles), so it is committed
159
+ config, not a secret — there is no login step and no credentials file.
160
+ - **Per machine:** `HIGHBALL_POSTHOG_KEY` in the environment, with no block
161
+ in `checks.yml` at all. The `env` block of `~/.claude/settings.json`
162
+ reaches every repo's hooks. Your human sets this themselves; never ask
163
+ for the key value or write it anywhere.
164
+
165
+ If they would rather not send anything anywhere, skip both: the local
159
166
  journal (`highball runs`) already records every run in more detail than
160
167
  PostHog receives.
161
168
 
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @profoundry-us/highball
2
2
 
3
+ **Website:** [highball.profoundry.us](https://highball.profoundry.us)
4
+
3
5
  The Highball runner: executes a repo's `.highball/checks.yml` rules and blocks
4
6
  AI coding agents on failure (exit 2, the Claude Code hook contract) — "local
5
7
  CI for AI agents". Enforcement is entirely local and needs no account, no
@@ -103,6 +105,72 @@ The runner computes the branch's changed-file list once (it owns git) and
103
105
  hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
104
106
  analyzers and need no git in their execution context.
105
107
 
108
+ ## Turning it off
109
+
110
+ Three switches, differing in who they affect and — the part that usually
111
+ decides it — when they take effect:
112
+
113
+ ```bash
114
+ touch .highball/disabled # this checkout, gitignored, next run
115
+ ```
116
+
117
+ ```yaml
118
+ enabled: false # in checks.yml — committed, everyone, next run
119
+ ```
120
+
121
+ ```bash
122
+ export HIGHBALL_DISABLED=1 # your whole machine, next session restart
123
+ ```
124
+
125
+ Any of them makes `highball run` exit 0 immediately, with no rule executed, no
126
+ journal entry, and nothing reported. Hooks and rules stay exactly where they
127
+ are, so switching back on is a one-line revert.
128
+
129
+ | | takes effect | scope | committed |
130
+ | --- | --- | --- | --- |
131
+ | `.highball/disabled` | next run | this checkout | no — `init` gitignores it |
132
+ | `enabled: false` | next run | one repo, everyone who clones it | yes |
133
+ | `HIGHBALL_DISABLED` | next Claude Code restart, for hooks | every repo, your machine | no |
134
+
135
+ **Reach for the marker file first.** `touch .highball/disabled` while an agent
136
+ session is running and the very next hook run skips; delete it and the run
137
+ after that is armed again. Both directions are live because the file is
138
+ checked on every run and every hook invocation is a new process — there is no
139
+ state anywhere and nothing to restart. `init` writes a `.highball/.gitignore`
140
+ alongside it, so a switch-off in your checkout can't ride along in a commit.
141
+
142
+ Anything you write into the marker comes back as the reason on every run,
143
+ which is what the person who finds the checks off next week actually needs:
144
+
145
+ ```console
146
+ $ echo "bisecting a flaky spec" > .highball/disabled
147
+ $ npx @profoundry-us/highball run --fast
148
+ highball: disabled by .highball/disabled (bisecting a flaky spec) — no checks run
149
+ ```
150
+
151
+ `enabled: false` is the committed counterpart, for a repo genuinely stepping
152
+ away from its checks. It lands in a diff, which is what you want when the
153
+ decision belongs to the team rather than to your afternoon.
154
+
155
+ `HIGHBALL_DISABLED` is an ordinary environment variable, so a hook sees
156
+ whatever value the Claude Code process had when it started — Claude Code
157
+ writes settings `env` entries into its environment at launch, and a shell
158
+ `export` after that never reaches an already-running session. That makes it
159
+ the right switch for a machine-wide default you rarely change, and the wrong
160
+ one for a mid-session toggle. Like the marker, it is read *before*
161
+ `checks.yml`, so both still work when the config is itself what's broken.
162
+
163
+ `HIGHBALL_DISABLED=0`, `false`, `no`, `off` and empty all mean **not**
164
+ disabled. Anything else disables. Plain truthiness would make `=0` stop every
165
+ check in the repo, which is exactly the "is the guardrail live right now?"
166
+ doubt this switch exists to remove.
167
+
168
+ None of the three is silent. Each prints a line on every run, because a guardrail
169
+ that has quietly stopped guarding is worse than no guardrail — the next
170
+ person reads green and believes it. For the same reason `enabled:` accepts
171
+ only a real boolean: `enabled: "false"` and `enabled: no` are strings, and
172
+ rather than leaving checks quietly on, they fail loudly.
173
+
106
174
  ## AI-judged rules
107
175
 
108
176
  A rule with `rubric:` instead of `run:` is judged by headless Claude rather
@@ -140,9 +208,24 @@ Rubrics live with the opinions they express: a framework pack such as
140
208
  `reporting.posthog` sends runs to PostHog — the runner's only telemetry path.
141
209
  A team that already runs PostHog needs no server for this, and a team that
142
210
  doesn't can skip the block entirely and use the local journal. The project key
143
- is write-only by design, so it is committed config: no login step, no
144
- credentials file. `HIGHBALL_POSTHOG_KEY` / `HIGHBALL_POSTHOG_HOST` (or
145
- `POSTHOG_API_KEY` / `POSTHOG_HOST`) override it for CI.
211
+ is write-only by design it is the same key PostHog has you ship in browser
212
+ bundles, and all it can do is capture events — so committing it is safe:
213
+ no login step, no credentials file.
214
+
215
+ If you would still rather keep it out of the repo, set the environment
216
+ instead. `HIGHBALL_POSTHOG_KEY` (or `POSTHOG_API_KEY`) on its own turns
217
+ reporting on with no `reporting:` block at all, and `HIGHBALL_POSTHOG_HOST`
218
+ (or `POSTHOG_HOST`) points it at an EU or self-hosted instance; either wins
219
+ over committed config, which is also how CI redirects a run. One place that
220
+ reaches every repo's hooks is the `env` block of your user-level Claude Code
221
+ settings, `~/.claude/settings.json`:
222
+
223
+ ```json
224
+ { "env": { "HIGHBALL_POSTHOG_KEY": "phc_your_key" } }
225
+ ```
226
+
227
+ The trade-off is that reporting is then per machine rather than per repo: a
228
+ teammate cloning the repo reports nothing until they set the key too.
146
229
 
147
230
  The whole run leaves in ONE request to `/batch/`. PostHog events are
148
231
  immutable, which suits a runner that already defers reporting to after the
package/bin/highball.js CHANGED
@@ -20,7 +20,11 @@ Usage:
20
20
  --if-changed skips when the working tree is
21
21
  unchanged since the last run (for hooks that
22
22
  also match Bash).
23
- highball init Scaffold .highball/checks.yml and Claude Code
23
+ Switched off by .highball/disabled (this
24
+ checkout, gitignored), \`enabled: false\` in
25
+ checks.yml (committed, whole team), or
26
+ HIGHBALL_DISABLED=1 (your machine).
27
+ highball init Scaffold .highball/checks.yml and Claude Code
24
28
  hooks in the current repo.
25
29
  highball onboard Print the setup guide written for this repo's
26
30
  AI agent — tell your agent to run this and
package/lib/config.js CHANGED
@@ -16,6 +16,17 @@ export function loadConfig(root = process.cwd()) {
16
16
  }
17
17
  const config = YAML.parse(readFileSync(path, "utf8"));
18
18
  if (!config?.project) throw new Error(`${CONFIG_PATH} is missing \`project:\`.`);
19
+ // A kill switch that silently fails to kill is the worst of both worlds,
20
+ // so only a real boolean counts and anything else is an error rather than
21
+ // a shrug. `enabled: no` parses as the string "no" under YAML 1.2 and
22
+ // `enabled: "false"` as a string; both are truthy, so a typo would leave
23
+ // the repo running checks its author believed were off.
24
+ if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
25
+ throw new Error(
26
+ `${CONFIG_PATH}: \`enabled:\` must be true or false, not ` +
27
+ `\`${config.enabled}\` (${typeof config.enabled}).`
28
+ );
29
+ }
19
30
  if (!Array.isArray(config.checks)) {
20
31
  throw new Error(`${CONFIG_PATH} is missing its \`checks:\` list.`);
21
32
  }
@@ -41,6 +52,48 @@ export function loadConfig(root = process.cwd()) {
41
52
  return config;
42
53
  }
43
54
 
55
+ // The per-machine off switch, read fresh on every `highball run` — there is
56
+ // no state anywhere, so unsetting the variable re-arms the checks with
57
+ // nothing to clean up.
58
+ //
59
+ // Falsey spellings mean "don't disable" rather than "disable". Plain
60
+ // truthiness would make `HIGHBALL_DISABLED=0` stop every check in the repo,
61
+ // which is precisely the doubt about whether the guardrail is live that this
62
+ // switch exists to remove. Anything else — 1, true, yes, on, or a bare
63
+ // value — disables.
64
+ const NOT_DISABLED = new Set([ "", "0", "false", "no", "off" ]);
65
+
66
+ export function disabledByEnv(env = process.env) {
67
+ const value = env.HIGHBALL_DISABLED;
68
+ if (value === undefined) return false;
69
+ return !NOT_DISABLED.has(String(value).trim().toLowerCase());
70
+ }
71
+
72
+ // The per-checkout off switch: an untracked marker file beside checks.yml.
73
+ //
74
+ // It exists because the other two each miss the common case. `enabled: false`
75
+ // is committed, so switching your own checkout off can ship to everyone;
76
+ // HIGHBALL_DISABLED is an environment variable, so a hook only sees a change
77
+ // after the agent session restarts. This one is per repo, per checkout, and
78
+ // re-read on every run like checks.yml — `touch` and `rm`, effective on the
79
+ // next run, and `.highball/.gitignore` keeps it out of commits.
80
+ export const DISABLED_MARKER = ".highball/disabled";
81
+
82
+ // Presence is the whole signal; any text inside is the reason, echoed back on
83
+ // every run so "why are the checks off in here?" has an answer that outlives
84
+ // whoever switched them off.
85
+ export function disabledByMarker(root = process.cwd()) {
86
+ const path = join(root, DISABLED_MARKER);
87
+ if (!existsSync(path)) return null;
88
+ try {
89
+ return { reason: readFileSync(path, "utf8").split("\n")[0].trim() };
90
+ } catch {
91
+ // An unreadable marker still counts: it was put there on purpose, and
92
+ // guessing "probably fine, run the checks" is the wrong way to be wrong.
93
+ return { reason: "" };
94
+ }
95
+ }
96
+
44
97
  // PostHog is the runner's telemetry sink. There is no secret to resolve: a
45
98
  // PostHog project key is write-only by design, so it lives in committed
46
99
  // config right next to the host — no login step, no credentials file. Env
package/lib/init.js CHANGED
@@ -8,13 +8,21 @@ import { basename, join } from "node:path";
8
8
  const CHECKS_TEMPLATE = (project) => `# ${project}'s Highball rules — the file \`highball run\` reads and
9
9
  # reports from. \`fast: true\` marks rules cheap enough to run on every
10
10
  # agent edit; the rest join at turn end. \`todo: true\` declares a rule
11
- # you're committed to but haven't built — tracked on the dashboard,
11
+ # you're committed to but haven't built — tracked in run history,
12
12
  # never a failure.
13
13
  version: 1
14
14
  project: ${project}
15
15
 
16
+ # Set \`enabled: false\` to switch this repo's checks off for everyone,
17
+ # without deleting rules or hooks. To switch them off only in YOUR
18
+ # checkout, leave this alone and \`touch .highball/disabled\` instead —
19
+ # it is gitignored, and takes effect on the next run.
20
+
16
21
  # Optional telemetry. The PostHog project key is write-only by design, so
17
22
  # it is committed config — there is no login step and no credentials file.
23
+ # To keep it out of the repo instead, set HIGHBALL_POSTHOG_KEY in the
24
+ # environment (e.g. the env block of ~/.claude/settings.json) and leave
25
+ # this block out entirely.
18
26
  # reporting:
19
27
  # posthog:
20
28
  # host: https://us.i.posthog.com
@@ -77,6 +85,18 @@ export async function init() {
77
85
  console.log(`created .highball/checks.yml (project: ${project}) — add your rules`);
78
86
  }
79
87
 
88
+ // Ignore the `disabled` marker from inside .highball/ rather than by
89
+ // editing the repo's root .gitignore, which init doesn't own. It is
90
+ // committed, so every clone inherits the protection and nobody has to
91
+ // remember the setup step that keeps a local switch-off local.
92
+ const ignorePath = join(root, ".highball", ".gitignore");
93
+ if (existsSync(ignorePath)) {
94
+ console.log("kept existing .highball/.gitignore");
95
+ } else {
96
+ writeFileSync(ignorePath, "# A local, uncommitted `highball run` off switch.\ndisabled\n");
97
+ console.log("created .highball/.gitignore (keeps the `disabled` marker out of commits)");
98
+ }
99
+
80
100
  const settingsPath = join(root, ".claude", "settings.json");
81
101
  if (existsSync(settingsPath)) {
82
102
  console.log(
package/lib/journal.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // The local run journal: every run appends one JSONL line to
2
2
  // ~/.highball/runs/<project>.jsonl, whether or not remote reporting is
3
- // configured. This is what makes the runner useful with no dashboard at
4
- // all — `highball runs` reads it — and it lives outside the repo tree
5
- // (like credentials) so there's no gitignore to manage and no state to
6
- // leak into commits.
3
+ // configured. This is what makes the runner useful with no telemetry at
4
+ // all — `highball runs` and the MCP widget read it — and it lives outside
5
+ // the repo tree so there's no gitignore to manage and no state to leak
6
+ // into commits.
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
package/lib/mcp.js CHANGED
@@ -266,7 +266,7 @@ export async function mcp() {
266
266
  description:
267
267
  "Execute a repo's Highball checks (fast rules or the full suite). " +
268
268
  "Blocks until done; the run lands in the journal and, when reporting " +
269
- "is configured, on the dashboard.",
269
+ "is configured, in PostHog.",
270
270
  inputSchema: {
271
271
  dir: z.string().optional()
272
272
  .describe("Repo root containing .highball/checks.yml; defaults to cwd"),
package/lib/run.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // `highball run [--fast]` — the enforcement half. Runs each rule, prints
2
2
  // progress, exits 2 with failures on stderr (the Claude Code hook
3
3
  // contract: a Stop hook reading exit 2 blocks the agent and feeds the
4
- // output back). Reporting is the witness half and is best-effort: a dead
5
- // dashboard must never block the agent.
4
+ // output back). Reporting is the witness half and is best-effort: an
5
+ // unreachable PostHog must never block the agent.
6
6
  import { execSync, spawnSync } from "node:child_process";
7
7
  import { readFileSync } from "node:fs";
8
- import { loadConfig, resolvePosthog, commandFor } from "./config.js";
8
+ import {
9
+ CONFIG_PATH, DISABLED_MARKER, disabledByEnv, disabledByMarker,
10
+ loadConfig, resolvePosthog, commandFor
11
+ } from "./config.js";
9
12
  import { appendRun } from "./journal.js";
10
13
  import { readStamp, treeFingerprint, writeStamp } from "./stamp.js";
11
14
  import { git } from "./git.js";
@@ -26,6 +29,25 @@ export async function run(args) {
26
29
  // loop.
27
30
  if (process.env.HIGHBALL_JUDGE) return 0;
28
31
 
32
+ // Off switch #1, deliberately ahead of the config load: HIGHBALL_DISABLED
33
+ // is the one to reach for mid-task, so it has to work even when
34
+ // checks.yml is itself the thing in the way. Per machine, nothing to
35
+ // commit, nothing to accidentally push at a teammate.
36
+ if (disabledByEnv()) {
37
+ console.log("highball: disabled by HIGHBALL_DISABLED — no checks run");
38
+ return 0;
39
+ }
40
+
41
+ // Off switch #2, also ahead of the config load and for the same reason:
42
+ // the marker is a bare file next to checks.yml, so it still works when
43
+ // checks.yml is itself what's in the way.
44
+ const marker = disabledByMarker();
45
+ if (marker) {
46
+ const why = marker.reason ? ` (${marker.reason})` : "";
47
+ console.log(`highball: disabled by ${DISABLED_MARKER}${why} — no checks run`);
48
+ return 0;
49
+ }
50
+
29
51
  const fastOnly = args.includes("--fast");
30
52
  let config;
31
53
  try {
@@ -35,6 +57,17 @@ export async function run(args) {
35
57
  return 1;
36
58
  }
37
59
 
60
+ // Off switch #2, the committed counterpart: this repo isn't using
61
+ // Highball right now, for everyone who clones it. Neither switch is ever
62
+ // silent — a guardrail that has stopped guarding should say so on every
63
+ // single run, or the next person reads green and believes it.
64
+ if (config.enabled === false) {
65
+ console.log(
66
+ `highball: disabled by \`enabled: false\` in ${CONFIG_PATH} — no checks run`
67
+ );
68
+ return 0;
69
+ }
70
+
38
71
  // --if-changed: a fast hook that also matches Bash fires after every
39
72
  // command, and most commands are reads. Skip outright when the working
40
73
  // tree is exactly where the last run left it. The fingerprint is taken
@@ -42,7 +75,7 @@ export async function run(args) {
42
75
  // mid-run makes the next call run again rather than trust a stale pass.
43
76
  const fingerprint = treeFingerprint();
44
77
  if (args.includes("--if-changed") && fingerprint &&
45
- fingerprint === readStamp(config.project)) {
78
+ fingerprint === readStamp(config.project, process.cwd())) {
46
79
  console.log("highball: no changes since the last run — skipped");
47
80
  return 0;
48
81
  }
@@ -67,7 +100,7 @@ export async function run(args) {
67
100
  process.stdout.write(`→ ${rule.name} ... `);
68
101
 
69
102
  // Placeholder rules are tracked, not run: they report as "todo" so
70
- // the dashboard shows the full intended ruleset, and they can never
103
+ // the widget and PostHog show the full intended ruleset, and they can never
71
104
  // fail a run — an aspiration shouldn't block anyone.
72
105
  if (rule.todo) {
73
106
  console.log("todo (not implemented yet)");
@@ -109,7 +142,7 @@ export async function run(args) {
109
142
 
110
143
  // Stamped pass or fail: after a failure the agent's next reads must not
111
144
  // re-run and re-block; its next edit moves the tree and runs again.
112
- if (fingerprint) writeStamp(config.project, fingerprint);
145
+ if (fingerprint) writeStamp(config.project, process.cwd(), fingerprint);
113
146
 
114
147
  const failures = results.filter((result) => !result.passed);
115
148
  const durationMs = Date.now() - startedAt.getTime();
@@ -125,7 +158,7 @@ export async function run(args) {
125
158
  }
126
159
 
127
160
  // The local journal is unconditional — `highball runs` works with no
128
- // dashboard configured at all. Journal failures never fail the checks,
161
+ // reporting configured at all. Journal failures never fail the checks,
129
162
  // same policy as reporting.
130
163
  try {
131
164
  appendRun(config.project, {
@@ -157,7 +190,7 @@ export async function run(args) {
157
190
  command:
158
191
  result.rule.run ??
159
192
  (result.rule.rubric ? `judge ${result.rule.rubric}` : null),
160
- // Unlike the dashboard (failure tails only), the journal keeps
193
+ // Unlike PostHog (one-line summaries only), the journal keeps
161
194
  // every rule's output GitHub-Actions-style — it's the user's own
162
195
  // disk, and `highball runs <n> --logs` is the payoff.
163
196
  output_tail: result.output ? result.output.slice(-10_000) : null
@@ -180,7 +213,7 @@ export async function run(args) {
180
213
 
181
214
  // Claude Code hooks pass a JSON payload on stdin (session_id and
182
215
  // friends); that id groups this run with the rest of the agent's session
183
- // on the dashboard. A TTY means a human at a terminal — don't block on
216
+ // in the journal and in PostHog. A TTY means a human at a terminal — don't block on
184
217
  // read.
185
218
  //
186
219
  // The deadline matters: a non-TTY stdin that nobody writes to and nobody
package/lib/runs.js CHANGED
@@ -1,4 +1,4 @@
1
- // `highball runs [n] [--logs]` — the no-dashboard view of run history,
1
+ // `highball runs [n] [--logs]` — the terminal view of run history,
2
2
  // read from the local journal. Bare: a table of recent runs, newest
3
3
  // first. With a number: that run's detail — failure output by default,
4
4
  // every rule's captured output with --logs.
package/lib/stamp.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // The fingerprint makes those free: HEAD plus every dirty path with its
7
7
  // size and mtime, hashed. A further edit to an already-dirty file moves
8
8
  // its mtime, so the stamp tracks edits rather than just the set of dirty
9
- // paths. Stamps live per project outside the repo, like the journal.
9
+ // paths. Stamps live outside the repo, like the journal.
10
10
  import { createHash } from "node:crypto";
11
11
  import { execSync } from "node:child_process";
12
12
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -17,6 +17,14 @@ export function stampDir() {
17
17
  return join(homedir(), ".highball", "stamps");
18
18
  }
19
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
+
20
28
  // null outside a git repo: with no way to tell whether anything changed,
21
29
  // --if-changed must never skip.
22
30
  export function treeFingerprint(cwd = process.cwd()) {
@@ -45,9 +53,9 @@ export function treeFingerprint(cwd = process.cwd()) {
45
53
  return hash.digest("hex");
46
54
  }
47
55
 
48
- export function readStamp(project, dir = stampDir()) {
56
+ export function readStamp(project, cwd, dir = stampDir()) {
49
57
  try {
50
- return readFileSync(join(dir, project), "utf8").trim();
58
+ return readFileSync(stampPath(project, cwd, dir), "utf8").trim();
51
59
  } catch {
52
60
  return null;
53
61
  }
@@ -55,10 +63,10 @@ export function readStamp(project, dir = stampDir()) {
55
63
 
56
64
  // Best-effort, like the journal: a stamp that fails to write costs one
57
65
  // extra fast run, never a failed one.
58
- export function writeStamp(project, fingerprint, dir = stampDir()) {
66
+ export function writeStamp(project, cwd, fingerprint, dir = stampDir()) {
59
67
  try {
60
68
  mkdirSync(dir, { recursive: true, mode: 0o700 });
61
- writeFileSync(join(dir, project), `${fingerprint}\n`);
69
+ writeFileSync(stampPath(project, cwd, dir), `${fingerprint}\n`);
62
70
  } catch {
63
71
  // ignore
64
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profoundry-us/highball",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
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",
@@ -14,7 +14,7 @@
14
14
  "type": "git",
15
15
  "url": "git+https://github.com/profoundry-us/highball.git"
16
16
  },
17
- "homepage": "https://github.com/profoundry-us/highball#readme",
17
+ "homepage": "https://highball.profoundry.us",
18
18
  "bugs": {
19
19
  "url": "https://github.com/profoundry-us/highball/issues"
20
20
  },