@gatebolt/cli 0.2.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 ADDED
@@ -0,0 +1,114 @@
1
+ # @gatebolt/cli
2
+
3
+ The GateBolt agent client. An AI agent **declares** the files it intends to touch before it
4
+ edits, then **observes** the actual diff when work lands; GateBolt reconciles the two and
5
+ scores the drift. This CLI is a thin, dependency-light wrapper (Node `fetch` + `git` via
6
+ `child_process`, zero runtime dependencies) over the `change.declare` / `change.observe`
7
+ ingestion API.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm i -g @gatebolt/cli # global `gatebolt` binary
13
+ # …or run on demand:
14
+ npx @gatebolt/cli declare …
15
+ ```
16
+
17
+ Requires Node ≥ 20 and `git` on your `PATH`.
18
+
19
+ ## Configure
20
+
21
+ Every command needs three settings — an API **key**, the GateBolt **URL**, and the **repo
22
+ slug** bound to that key. Store them once as a named **profile** in `~/.gatebolt/config.json`
23
+ (written owner-only, `0600`):
24
+
25
+ ```
26
+ gatebolt configure --profile payments-api \
27
+ --url https://your-gatebolt.app --repo payments-api
28
+ # paste the API key when prompted (or pass --key for scripting)
29
+ ```
30
+
31
+ The `--repo` value is normalized to a slug (lowercase, hyphen-separated), so
32
+ `Payments_API` is stored and reported as `payments-api`.
33
+
34
+ Add a profile per repo. Inside a repo, the CLI picks the active profile in this order:
35
+
36
+ 1. `--profile <name>` flag
37
+ 2. `GATEBOLT_PROFILE` env var
38
+ 3. a `.gatebolt` file at the repo root holding a profile name — safe to commit (no secret)
39
+ 4. the sole profile, when there is only one
40
+ 5. the `default` profile
41
+
42
+ An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url` / `--repo`)
43
+ or the `GBOLT_KEY` / `GBOLT_URL` / `GBOLT_REPO` env var wins over the profile, so a
44
+ fully-env setup needs no profile file at all. Point the config elsewhere (CI, tests) with
45
+ `GATEBOLT_CONFIG`.
46
+
47
+ > **Rate limit.** A new key allows 10 requests/day (~5 changes) today — grab a fresh key
48
+ > while iterating, and a `401` means you hit the daily limit.
49
+
50
+ ## `gatebolt declare`
51
+
52
+ Records the agent's intended files **before** editing. Snapshots the current commit
53
+ (`git rev-parse HEAD`) as the base plus the current branch, and writes `{ changeId, baseSha }`
54
+ to `<git-dir>/gatebolt/session.json`. The repository must have at least one commit.
55
+
56
+ | Flag | Required | Description |
57
+ | ----------------------- | -------- | ----------------------------------------------------------------------- |
58
+ | `--task <text>` | yes | What the agent is doing |
59
+ | `--vendor <vendor>` | yes | `claude_code` \| `cursor` \| `copilot` \| `codex` \| `devin` \| `other` |
60
+ | `--name <name>` | yes | Agent name |
61
+ | `--model <model>` | yes | Model id |
62
+ | `--file <path>` | yes\* | Intended file (repeatable, one per path) |
63
+ | `--version <v>` | no | Agent/tool version |
64
+ | `--launched-by <email>` | no | Who launched the agent |
65
+ | `--profile <name>` | no | Profile to resolve `key`/`url`/`repo` from |
66
+
67
+ \* At least one `--file` is required. Repeat it per path (`--file a --file b`)
68
+ rather than comma-joining, so paths that contain commas stay intact.
69
+
70
+ Declared paths must be **repo-root-relative** and match git's output exactly, or be a
71
+ directory glob like `src/auth/**` — a bare `src/auth` will **not** match the files inside it.
72
+
73
+ ```
74
+ gatebolt declare \
75
+ --task "Refresh session on token expiry" \
76
+ --vendor claude_code --name "Claude Code" --model opus-4 \
77
+ --file src/auth/session.ts
78
+ # → Declared change 1df4210c-… (1 file(s)).
79
+ ```
80
+
81
+ ## `gatebolt observe`
82
+
83
+ Derives the actual diff from git when work lands, posts it, and prints the drift verdict. It
84
+ takes no file arguments — it reads the session written by `declare`.
85
+
86
+ | Flag | Required | Description |
87
+ | -------------------- | -------- | -------------------------------------------------- |
88
+ | `--change-id <uuid>` | no | Change to observe (defaults to the active session) |
89
+ | `--base <sha>` | no | Base commit to diff against (defaults to session) |
90
+ | `--profile <name>` | no | Profile to resolve `key`/`url` from |
91
+
92
+ ```
93
+ gatebolt observe
94
+ # Change 1df4210c-…
95
+ # Drift critical (100)
96
+ # Files 1 matched, 1 unexpected, 0 missing
97
+ # Touched 1 undeclared secrets file: .env
98
+ ```
99
+
100
+ `observe` diffs the working tree against the recorded base (`git diff <base>`) and sweeps in
101
+ untracked files, so uncommitted edits and newly created files are both captured. It accepts a
102
+ base that is an ancestor of HEAD or one HEAD amended/squashed in place on the same branch, but
103
+ **refuses if the working tree has diverged from the recorded base** — a branch switch or reset
104
+ would fold in unrelated work, so re-run `declare`. It is **advisory** — it exits `0` regardless of the
105
+ drift level (errors exit non-zero) — and on success it clears the session. A diff touching more
106
+ than 2000 files is rejected; narrow the change.
107
+
108
+ ## Notes
109
+
110
+ - **One session per worktree.** Session state lives in `<git-dir>/gatebolt/`, which is
111
+ per-worktree. Run one agent session per working tree; for parallel agents, use separate git
112
+ worktrees (a shared tree can't attribute one diff to two sessions).
113
+ - **Claude Code.** To run the declare → observe loop automatically inside a Claude Code
114
+ session, use the hook recipe in [`recipes/claude-code/`](./recipes/claude-code/).
@@ -0,0 +1,43 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { slugifyRepo } from "../config.js";
5
+ import { readConfigForWrite, writeConfig } from "../profiles.js";
6
+ export async function runConfigure(argv) {
7
+ const { values } = parseArgs({
8
+ args: argv,
9
+ options: {
10
+ profile: { type: "string" },
11
+ url: { type: "string" },
12
+ repo: { type: "string" },
13
+ key: { type: "string" },
14
+ },
15
+ });
16
+ const repo = slugifyRepo(requireFlag(values.repo, "--repo"));
17
+ const url = values.url?.replace(/\/+$/, "");
18
+ const name = values.profile ?? repo;
19
+ const key = (values.key ?? (await promptForKey())).trim();
20
+ if (!key) {
21
+ throw new Error("No API key provided.");
22
+ }
23
+ const config = await readConfigForWrite();
24
+ config.profiles[name] = url ? { url, repo, key } : { repo, key };
25
+ config.default ??= name;
26
+ const path = await writeConfig(config);
27
+ process.stdout.write(`Saved profile "${name}" to ${path}.\n`);
28
+ }
29
+ function requireFlag(value, flag) {
30
+ if (!value) {
31
+ throw new Error(`Missing required option ${flag}.`);
32
+ }
33
+ return value;
34
+ }
35
+ async function promptForKey() {
36
+ const rl = createInterface({ input: stdin, output: stdout });
37
+ try {
38
+ return await rl.question("API key: ");
39
+ }
40
+ finally {
41
+ rl.close();
42
+ }
43
+ }
@@ -0,0 +1,165 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { relative, resolve, sep } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { resolveConnection, resolveRepoSlug } from "../config.js";
5
+ import { AGENT_VENDORS, DECLARED_FILES_MAX, isAgentVendor, TASK_MAX_LENGTH, } from "../contracts.js";
6
+ import { currentBranch, currentHead, hasCommits, repoContext } from "../git.js";
7
+ import { isRecord } from "../json.js";
8
+ import { profileLoader } from "../profiles.js";
9
+ import { readPendingDeclare, writePendingDeclare, writeSession, } from "../session.js";
10
+ import { postChange } from "../transport.js";
11
+ export async function runDeclare(argv) {
12
+ const { values } = parseArgs({
13
+ args: argv,
14
+ options: {
15
+ key: { type: "string" },
16
+ url: { type: "string" },
17
+ profile: { type: "string" },
18
+ repo: { type: "string" },
19
+ task: { type: "string" },
20
+ vendor: { type: "string" },
21
+ name: { type: "string" },
22
+ model: { type: "string" },
23
+ version: { type: "string" },
24
+ "launched-by": { type: "string" },
25
+ file: { type: "string", multiple: true },
26
+ },
27
+ });
28
+ const rawFiles = collectFiles(values.file);
29
+ if (rawFiles.length === 0) {
30
+ throw new Error("Declare at least one file with --file.");
31
+ }
32
+ const vendor = requireFlag(values.vendor, "--vendor");
33
+ if (!isAgentVendor(vendor)) {
34
+ throw new Error(`Invalid --vendor '${vendor}'. Expected one of: ${AGENT_VENDORS.join(", ")}.`);
35
+ }
36
+ const agent = {
37
+ vendor,
38
+ name: requireFlag(values.name, "--name"),
39
+ model: requireFlag(values.model, "--model"),
40
+ };
41
+ if (values.version)
42
+ agent.version = values.version;
43
+ const launchedBy = values["launched-by"];
44
+ if (launchedBy && !isValidEmail(launchedBy)) {
45
+ throw new Error(`Invalid --launched-by '${launchedBy}'. Expected an email address.`);
46
+ }
47
+ const task = requireFlag(values.task, "--task");
48
+ if (task.length > TASK_MAX_LENGTH) {
49
+ throw new Error(`--task is ${task.length} characters, over the ${TASK_MAX_LENGTH} limit.`);
50
+ }
51
+ const repo = await repoContext();
52
+ if (!repo) {
53
+ throw new Error("Not inside a git repository.");
54
+ }
55
+ const { gitDir: dir, root } = repo;
56
+ const loadProfile = profileLoader({ name: values.profile, root });
57
+ const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
58
+ const repoSlug = await resolveRepoSlug({ repo: values.repo }, loadProfile);
59
+ if (!(await hasCommits())) {
60
+ throw new Error("This repository has no commits yet — make an initial commit before declaring.");
61
+ }
62
+ const baseSha = await currentHead();
63
+ const branch = await currentBranch();
64
+ const declaredFiles = dedupeByPath(rawFiles.map((raw) => ({ path: normalizeRepoPath(root, raw) })));
65
+ if (declaredFiles.length > DECLARED_FILES_MAX) {
66
+ throw new Error(`Declaring ${declaredFiles.length} files, over the ${DECLARED_FILES_MAX} limit. Narrow the change.`);
67
+ }
68
+ const intentKey = buildIntentKey({
69
+ repoSlug,
70
+ task,
71
+ agent,
72
+ baseSha,
73
+ declaredFiles,
74
+ });
75
+ const correlationId = correlationIdFor(await readPendingDeclare(dir), intentKey);
76
+ const input = buildDeclareInput({
77
+ repoSlug,
78
+ task,
79
+ agent,
80
+ declaredFiles,
81
+ correlationId,
82
+ launchedBy,
83
+ branch,
84
+ });
85
+ await writePendingDeclare(dir, { correlationId, intentKey });
86
+ const result = await postChange(connection, "change.declare", input);
87
+ if (!isDeclareResult(result)) {
88
+ throw new Error("Unexpected response from server.");
89
+ }
90
+ await writeSession(dir, {
91
+ changeId: result.changeId,
92
+ baseSha,
93
+ branch,
94
+ });
95
+ process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
96
+ }
97
+ export function isDeclareResult(value) {
98
+ return isRecord(value) && typeof value.changeId === "string";
99
+ }
100
+ export function buildIntentKey(parts) {
101
+ return JSON.stringify({
102
+ repoSlug: parts.repoSlug,
103
+ task: parts.task,
104
+ agent: parts.agent,
105
+ baseSha: parts.baseSha,
106
+ files: parts.declaredFiles.map((file) => file.path).sort(),
107
+ });
108
+ }
109
+ function buildDeclareInput(parts) {
110
+ const input = {
111
+ repoSlug: parts.repoSlug,
112
+ task: parts.task,
113
+ agent: parts.agent,
114
+ declaredFiles: parts.declaredFiles,
115
+ correlationId: parts.correlationId,
116
+ };
117
+ if (parts.launchedBy)
118
+ input.launchedBy = parts.launchedBy;
119
+ if (parts.branch)
120
+ input.branch = parts.branch;
121
+ return input;
122
+ }
123
+ export function correlationIdFor(pending, intentKey) {
124
+ if (pending?.intentKey === intentKey) {
125
+ return pending.correlationId;
126
+ }
127
+ return randomUUID();
128
+ }
129
+ function collectFiles(file) {
130
+ return (file ?? [])
131
+ .map((value) => value.trim())
132
+ .filter((value) => value.length > 0);
133
+ }
134
+ export function dedupeByPath(files) {
135
+ const seen = new Set();
136
+ return files.filter((file) => {
137
+ if (seen.has(file.path))
138
+ return false;
139
+ seen.add(file.path);
140
+ return true;
141
+ });
142
+ }
143
+ export function normalizeRepoPath(root, rawPath) {
144
+ const repoRelative = relative(root, resolve(process.cwd(), rawPath))
145
+ .split(sep)
146
+ .join("/");
147
+ if (repoRelative === "" ||
148
+ repoRelative === ".." ||
149
+ repoRelative.startsWith("../")) {
150
+ throw new Error(`--file '${rawPath}' is outside the repository.`);
151
+ }
152
+ return repoRelative;
153
+ }
154
+ function requireFlag(value, flag) {
155
+ if (!value) {
156
+ throw new Error(`Missing required option ${flag}.`);
157
+ }
158
+ return value;
159
+ }
160
+ // Mirrors z.email() in @gatebolt/validators; keep in sync by hand (the CLI
161
+ // takes no dependency on the validators package).
162
+ const SERVER_EMAIL = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+.-]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$/;
163
+ export function isValidEmail(value) {
164
+ return SERVER_EMAIL.test(value);
165
+ }
@@ -0,0 +1,100 @@
1
+ import { parseArgs } from "node:util";
2
+ import { resolveConnection } from "../config.js";
3
+ import { OBSERVED_FILES_MAX } from "../contracts.js";
4
+ import { observedFilesFromGit } from "../diff.js";
5
+ import { commitExists, diffNameStatus, diffNumstat, isReconcilableBase, listUntracked, repoContext, } from "../git.js";
6
+ import { isRecord } from "../json.js";
7
+ import { profileLoader } from "../profiles.js";
8
+ import { clearPendingDeclare, clearSession, readSession } from "../session.js";
9
+ import { postChange } from "../transport.js";
10
+ function resolveTarget(values, session) {
11
+ const provided = values["change-id"];
12
+ if (provided !== undefined && !isUuid(provided)) {
13
+ throw new Error(`Invalid --change-id '${provided}'. Expected a UUID.`);
14
+ }
15
+ const changeId = provided ?? session?.changeId;
16
+ const observingSessionChange = !values["change-id"] || values["change-id"] === session?.changeId;
17
+ const base = values.base ?? (observingSessionChange ? session?.baseSha : undefined);
18
+ if (!changeId) {
19
+ throw new Error("No active declaration. Run `gatebolt declare` first, or pass --change-id.");
20
+ }
21
+ if (!base) {
22
+ throw new Error("No base commit for this change. Pass --base (the commit it was declared against).");
23
+ }
24
+ return { changeId, base, observingSessionChange };
25
+ }
26
+ export async function runObserve(argv) {
27
+ const { values } = parseArgs({
28
+ args: argv,
29
+ options: {
30
+ key: { type: "string" },
31
+ url: { type: "string" },
32
+ profile: { type: "string" },
33
+ "change-id": { type: "string" },
34
+ base: { type: "string" },
35
+ },
36
+ });
37
+ const repo = await repoContext();
38
+ if (!repo) {
39
+ throw new Error("Not inside a git repository.");
40
+ }
41
+ const dir = repo.gitDir;
42
+ const loadProfile = profileLoader({ name: values.profile, root: repo.root });
43
+ const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
44
+ const session = await readSession(dir);
45
+ const { changeId, base, observingSessionChange } = resolveTarget(values, session);
46
+ if (!(await commitExists(base))) {
47
+ throw new Error(`Base commit ${base} no longer exists. Re-run \`gatebolt declare\`.`);
48
+ }
49
+ const declaredBranch = observingSessionChange ? session?.branch : undefined;
50
+ if (!(await isReconcilableBase(base, declaredBranch))) {
51
+ throw new Error("The working tree has moved off the declared base (branch switch or divergent rebase?). Re-run `gatebolt declare`.");
52
+ }
53
+ const [nameStatus, numstat, untracked] = await Promise.all([
54
+ diffNameStatus(base),
55
+ diffNumstat(base),
56
+ listUntracked(),
57
+ ]);
58
+ const observedFiles = observedFilesFromGit(nameStatus, numstat, untracked);
59
+ if (observedFiles.length > OBSERVED_FILES_MAX) {
60
+ throw new Error(`Diff touches ${observedFiles.length} files, over the ${OBSERVED_FILES_MAX} limit. Narrow the change.`);
61
+ }
62
+ const input = { changeId, observedFiles };
63
+ const result = await postChange(connection, "change.observe", input);
64
+ if (!isReconciliation(result)) {
65
+ throw new Error("Unexpected response from server.");
66
+ }
67
+ printVerdict(result);
68
+ if (observingSessionChange) {
69
+ await clearSession(dir);
70
+ await clearPendingDeclare(dir);
71
+ }
72
+ }
73
+ // Mirrors z.uuid() in @gatebolt/validators; keep in sync by hand (the CLI
74
+ // takes no dependency on the validators package).
75
+ const SERVER_UUID = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
76
+ export function isUuid(value) {
77
+ return SERVER_UUID.test(value);
78
+ }
79
+ export function isReconciliation(value) {
80
+ if (!isRecord(value))
81
+ return false;
82
+ return (typeof value.changeId === "string" &&
83
+ typeof value.driftScore === "number" &&
84
+ typeof value.driftLevel === "string" &&
85
+ Array.isArray(value.matched) &&
86
+ Array.isArray(value.unexpected) &&
87
+ Array.isArray(value.missing) &&
88
+ Array.isArray(value.reasons));
89
+ }
90
+ function printVerdict(result) {
91
+ const lines = [
92
+ `Change ${result.changeId}`,
93
+ `Drift ${result.driftLevel} (${result.driftScore})`,
94
+ `Files ${result.matched.length} matched, ${result.unexpected.length} unexpected, ${result.missing.length} missing`,
95
+ ];
96
+ for (const reason of result.reasons) {
97
+ lines.push(` ${reason}`);
98
+ }
99
+ process.stdout.write(`${lines.join("\n")}\n`);
100
+ }
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ export const DEFAULT_BASE_URL = "https://gatebolt.com";
2
+ export async function resolveConnection(flags, loadProfile) {
3
+ const key = flags.key ?? process.env.GBOLT_KEY;
4
+ const url = flags.url ?? process.env.GBOLT_URL;
5
+ const profile = key && url ? null : await loadProfile();
6
+ const resolvedKey = key ?? profile?.key;
7
+ const baseUrl = (url ?? profile?.url ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8
+ if (!resolvedKey) {
9
+ throw new Error("Missing API key. Set GBOLT_KEY, pass --key, or run `gatebolt configure`.");
10
+ }
11
+ return { key: resolvedKey, baseUrl };
12
+ }
13
+ export async function resolveRepoSlug(flags, loadProfile) {
14
+ const raw = flags.repo ?? process.env.GBOLT_REPO ?? (await loadProfile())?.repo;
15
+ if (!raw) {
16
+ throw new Error("Missing repository slug. Set GBOLT_REPO, pass --repo, or run `gatebolt configure`.");
17
+ }
18
+ return slugifyRepo(raw);
19
+ }
20
+ const SLUG_MAX_LENGTH = 48;
21
+ export function slugifyRepo(value) {
22
+ const slug = value
23
+ .toLowerCase()
24
+ .trim()
25
+ .replace(/[^a-z0-9]+/g, "-")
26
+ .slice(0, SLUG_MAX_LENGTH)
27
+ .replace(/^-+|-+$/g, "");
28
+ if (!slug) {
29
+ throw new Error(`Could not derive a repository slug from "${value}".`);
30
+ }
31
+ return slug;
32
+ }
@@ -0,0 +1,14 @@
1
+ export const OBSERVED_FILES_MAX = 2000;
2
+ export const DECLARED_FILES_MAX = 500;
3
+ export const TASK_MAX_LENGTH = 2000;
4
+ export const AGENT_VENDORS = [
5
+ "claude_code",
6
+ "cursor",
7
+ "copilot",
8
+ "codex",
9
+ "devin",
10
+ "other",
11
+ ];
12
+ export function isAgentVendor(value) {
13
+ return AGENT_VENDORS.includes(value);
14
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,98 @@
1
+ export function observedFilesFromGit(nameStatusOutput, numstatOutput, untrackedOutput) {
2
+ const counts = parseNumstat(numstatOutput);
3
+ const observed = parseNameStatus(nameStatusOutput).map((entry) => {
4
+ const file = { path: entry.path, action: entry.action };
5
+ const line = counts.get(entry.path);
6
+ if (line) {
7
+ file.additions = line.additions;
8
+ file.deletions = line.deletions;
9
+ }
10
+ return file;
11
+ });
12
+ // A path can appear in both lists — `git rm --cached` leaves a staged deletion
13
+ // in the diff while the on-disk file shows as untracked; prefer the diff entry.
14
+ const seen = new Set(observed.map((file) => file.path));
15
+ for (const path of splitFields(untrackedOutput)) {
16
+ if (seen.has(path))
17
+ continue;
18
+ observed.push({ path, action: "added" });
19
+ }
20
+ return observed;
21
+ }
22
+ function parseNameStatus(output) {
23
+ const reader = createReader(splitFields(output));
24
+ const entries = [];
25
+ while (!reader.done()) {
26
+ const status = reader.take();
27
+ const code = status.charAt(0);
28
+ if (code === "R") {
29
+ const from = reader.take();
30
+ const to = reader.take();
31
+ entries.push({ action: "deleted", path: from });
32
+ entries.push({ action: "renamed", path: to });
33
+ continue;
34
+ }
35
+ entries.push({ action: statusToAction(status), path: reader.take() });
36
+ }
37
+ return entries;
38
+ }
39
+ function parseNumstat(output) {
40
+ const reader = createReader(splitFields(output));
41
+ const counts = new Map();
42
+ while (!reader.done()) {
43
+ const head = reader.take();
44
+ const firstTab = head.indexOf("\t");
45
+ const secondTab = head.indexOf("\t", firstTab + 1);
46
+ if (firstTab < 0 || secondTab < 0) {
47
+ throw new Error(`malformed numstat record: ${head}`);
48
+ }
49
+ const additions = head.slice(0, firstTab);
50
+ const deletions = head.slice(firstTab + 1, secondTab);
51
+ let path = head.slice(secondTab + 1);
52
+ if (path === "") {
53
+ reader.take();
54
+ path = reader.take();
55
+ }
56
+ if (additions === "-")
57
+ continue;
58
+ counts.set(path, {
59
+ additions: Number(additions),
60
+ deletions: Number(deletions),
61
+ });
62
+ }
63
+ return counts;
64
+ }
65
+ function statusToAction(status) {
66
+ switch (status.charAt(0)) {
67
+ case "A":
68
+ return "added";
69
+ case "M":
70
+ case "T":
71
+ return "modified";
72
+ case "D":
73
+ return "deleted";
74
+ default:
75
+ throw new Error(`unsupported git status: ${status}`);
76
+ }
77
+ }
78
+ function splitFields(output) {
79
+ const fields = output.split("\0");
80
+ while (fields.length > 0 && fields[fields.length - 1] === "") {
81
+ fields.pop();
82
+ }
83
+ return fields;
84
+ }
85
+ function createReader(fields) {
86
+ let index = 0;
87
+ return {
88
+ done: () => index >= fields.length,
89
+ take: () => {
90
+ const value = fields[index];
91
+ if (value === undefined) {
92
+ throw new Error("unexpected end of git output");
93
+ }
94
+ index += 1;
95
+ return value;
96
+ },
97
+ };
98
+ }
package/dist/git.js ADDED
@@ -0,0 +1,120 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ const MAX_GIT_OUTPUT_BYTES = 67_108_864;
5
+ async function git(args) {
6
+ const { stdout } = await run("git", args, {
7
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
8
+ });
9
+ return stdout;
10
+ }
11
+ export async function repoContext() {
12
+ let output;
13
+ try {
14
+ output = await git([
15
+ "rev-parse",
16
+ "--is-inside-work-tree",
17
+ "--show-toplevel",
18
+ "--git-dir",
19
+ ]);
20
+ }
21
+ catch (error) {
22
+ if (gitExitedNonZero(error))
23
+ return null;
24
+ throw gitFailure(error);
25
+ }
26
+ const [inside, root, dir] = output.split("\n").map((line) => line.trim());
27
+ if (inside !== "true" || !root || !dir)
28
+ return null;
29
+ return { gitDir: dir, root };
30
+ }
31
+ export async function currentHead() {
32
+ const output = await git(["rev-parse", "HEAD"]);
33
+ return output.trim();
34
+ }
35
+ export async function commitExists(rev) {
36
+ try {
37
+ await git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ if (gitExitedNonZero(error))
42
+ return false;
43
+ throw gitFailure(error);
44
+ }
45
+ }
46
+ export function hasCommits() {
47
+ return commitExists("HEAD");
48
+ }
49
+ export async function currentBranch() {
50
+ // `git branch --show-current` needs Git 2.22+; symbolic-ref is portable.
51
+ try {
52
+ const output = await git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
53
+ return output.trim();
54
+ }
55
+ catch (error) {
56
+ if (gitExitedNonZero(error))
57
+ return "";
58
+ throw gitFailure(error);
59
+ }
60
+ }
61
+ export async function isAncestor(rev) {
62
+ try {
63
+ await git(["merge-base", "--is-ancestor", rev, "HEAD"]);
64
+ return true;
65
+ }
66
+ catch (error) {
67
+ if (gitExitedNonZero(error))
68
+ return false;
69
+ throw gitFailure(error);
70
+ }
71
+ }
72
+ async function parentOf(rev) {
73
+ try {
74
+ const output = await git(["rev-parse", "--verify", "--quiet", `${rev}^`]);
75
+ return output.trim() || null;
76
+ }
77
+ catch (error) {
78
+ if (gitExitedNonZero(error))
79
+ return null;
80
+ throw gitFailure(error);
81
+ }
82
+ }
83
+ export async function isReconcilableBase(base, declaredBranch) {
84
+ if (await isAncestor(base))
85
+ return true;
86
+ // An in-place amend/squash shares HEAD's parent — but so does a sibling branch
87
+ // cut from that parent, and only the amend is safe to diff. Require the branch
88
+ // to distinguish them.
89
+ if (!declaredBranch || (await currentBranch()) !== declaredBranch) {
90
+ return false;
91
+ }
92
+ const [baseParent, headParent] = await Promise.all([
93
+ parentOf(base),
94
+ parentOf("HEAD"),
95
+ ]);
96
+ return baseParent !== null && baseParent === headParent;
97
+ }
98
+ export function diffNameStatus(base) {
99
+ return git(["diff", "--name-status", "-M", "-z", base]);
100
+ }
101
+ export function diffNumstat(base) {
102
+ return git(["diff", "--numstat", "-M", "-z", base]);
103
+ }
104
+ export function listUntracked() {
105
+ return git(["ls-files", "--others", "--exclude-standard", "-z"]);
106
+ }
107
+ function errorCode(error) {
108
+ return error instanceof Error
109
+ ? error.code
110
+ : undefined;
111
+ }
112
+ function gitExitedNonZero(error) {
113
+ return typeof errorCode(error) === "number";
114
+ }
115
+ function gitFailure(error) {
116
+ if (errorCode(error) === "ENOENT") {
117
+ return new Error("Could not run git — is it installed and on your PATH?");
118
+ }
119
+ return error instanceof Error ? error : new Error("Could not run git.");
120
+ }
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { runConfigure } from "./commands/configure.js";
3
+ import { runDeclare } from "./commands/declare.js";
4
+ import { runObserve } from "./commands/observe.js";
5
+ import { RequestError } from "./transport.js";
6
+ const STATUS_BAD_REQUEST = 400;
7
+ const STATUS_UNAUTHORIZED = 401;
8
+ const STATUS_FORBIDDEN = 403;
9
+ const STATUS_NOT_FOUND = 404;
10
+ const STATUS_CONFLICT = 409;
11
+ const STATUS_RATE_LIMITED = 429;
12
+ async function main() {
13
+ const [, , command = "", ...argv] = process.argv;
14
+ try {
15
+ switch (command) {
16
+ case "declare":
17
+ await runDeclare(argv);
18
+ break;
19
+ case "observe":
20
+ await runObserve(argv);
21
+ break;
22
+ case "configure":
23
+ await runConfigure(argv);
24
+ break;
25
+ default:
26
+ process.stderr.write("Usage: gatebolt <declare|observe|configure> [options]\n");
27
+ process.exitCode = 1;
28
+ }
29
+ }
30
+ catch (error) {
31
+ process.stderr.write(`${formatError(error)}\n`);
32
+ process.exitCode = 1;
33
+ }
34
+ }
35
+ function formatError(error) {
36
+ if (error instanceof RequestError) {
37
+ return messageForStatus(error);
38
+ }
39
+ if (error instanceof Error) {
40
+ return error.message;
41
+ }
42
+ return "Unexpected error.";
43
+ }
44
+ function messageForStatus(error) {
45
+ switch (error.status) {
46
+ case STATUS_BAD_REQUEST:
47
+ return "Invalid request — check the declared values and try again.";
48
+ case STATUS_UNAUTHORIZED:
49
+ return "Unauthorized — the API key is invalid or has hit its rate limit.";
50
+ case STATUS_FORBIDDEN:
51
+ return "Forbidden — the repository slug does not match this API key.";
52
+ case STATUS_NOT_FOUND:
53
+ return "Not found — no matching change for this key and repository.";
54
+ case STATUS_CONFLICT:
55
+ return "Conflict — this change was already observed with different files.";
56
+ case STATUS_RATE_LIMITED:
57
+ return "Rate limited — try again later.";
58
+ default:
59
+ return error.message;
60
+ }
61
+ }
62
+ void main();
package/dist/json.js ADDED
@@ -0,0 +1,3 @@
1
+ export function isRecord(value) {
2
+ return typeof value === "object" && value !== null;
3
+ }
@@ -0,0 +1,128 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { isRecord } from "./json.js";
5
+ const POINTER_FILE = ".gatebolt";
6
+ const CONFIG_FILE_MODE = 0o600;
7
+ function configPath() {
8
+ return (nonEmpty(process.env.GATEBOLT_CONFIG) ??
9
+ join(homedir(), ".gatebolt", "config.json"));
10
+ }
11
+ export function parseConfig(raw) {
12
+ if (!isRecord(raw) || !isRecord(raw.profiles)) {
13
+ return null;
14
+ }
15
+ const profiles = {};
16
+ for (const [name, value] of Object.entries(raw.profiles)) {
17
+ const profile = parseProfile(value);
18
+ if (!profile)
19
+ return null;
20
+ profiles[name] = profile;
21
+ }
22
+ const config = { profiles };
23
+ if (typeof raw.default === "string") {
24
+ config.default = raw.default;
25
+ }
26
+ return config;
27
+ }
28
+ function parseProfile(value) {
29
+ if (!isRecord(value))
30
+ return null;
31
+ const { url, repo, key } = value;
32
+ if (typeof repo !== "string" || typeof key !== "string") {
33
+ return null;
34
+ }
35
+ if (url !== undefined && typeof url !== "string") {
36
+ return null;
37
+ }
38
+ return url === undefined ? { repo, key } : { url, repo, key };
39
+ }
40
+ export function selectProfileName(config, inputs) {
41
+ const explicit = nonEmpty(inputs.name) ?? nonEmpty(inputs.env) ?? nonEmpty(inputs.pointer);
42
+ if (explicit)
43
+ return explicit;
44
+ const names = Object.keys(config.profiles);
45
+ if (names.length === 1)
46
+ return names[0] ?? null;
47
+ return config.default ?? null;
48
+ }
49
+ export async function resolveProfile(opts) {
50
+ const config = await loadConfig();
51
+ if (!config)
52
+ return null;
53
+ const explicit = nonEmpty(opts.name) ?? nonEmpty(process.env.GATEBOLT_PROFILE);
54
+ const pointer = await readPointer(opts.root);
55
+ const name = selectProfileName(config, {
56
+ name: opts.name,
57
+ env: process.env.GATEBOLT_PROFILE,
58
+ pointer,
59
+ });
60
+ if (!name)
61
+ return null;
62
+ const profile = config.profiles[name];
63
+ if (profile)
64
+ return profile;
65
+ if (!explicit && name === pointer)
66
+ return null;
67
+ throw new Error(`Profile "${name}" is not in ${configPath()}. Run \`gatebolt configure --profile ${name}\`.`);
68
+ }
69
+ export function profileLoader(opts) {
70
+ let cached;
71
+ return () => (cached ??= resolveProfile(opts));
72
+ }
73
+ export async function readConfigForWrite() {
74
+ const config = await loadConfig();
75
+ if (config)
76
+ return config;
77
+ return { profiles: {} };
78
+ }
79
+ export async function writeConfig(config) {
80
+ const path = configPath();
81
+ await mkdir(dirname(path), { recursive: true });
82
+ await writeFile(path, JSON.stringify(config, null, 2) + "\n", {
83
+ encoding: "utf8",
84
+ mode: CONFIG_FILE_MODE,
85
+ });
86
+ await chmod(path, CONFIG_FILE_MODE);
87
+ return path;
88
+ }
89
+ async function loadConfig() {
90
+ let raw;
91
+ try {
92
+ raw = await readFile(configPath(), "utf8");
93
+ }
94
+ catch (error) {
95
+ if (isNotFound(error))
96
+ return null;
97
+ throw error;
98
+ }
99
+ const config = parseConfig(safeJsonParse(raw));
100
+ if (!config) {
101
+ throw new Error(`${configPath()} is malformed. Fix or remove it, then retry.`);
102
+ }
103
+ return config;
104
+ }
105
+ async function readPointer(root) {
106
+ try {
107
+ return nonEmpty((await readFile(join(root, POINTER_FILE), "utf8")).trim());
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ function safeJsonParse(raw) {
114
+ try {
115
+ return JSON.parse(raw);
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function nonEmpty(value) {
122
+ if (!value)
123
+ return undefined;
124
+ return value;
125
+ }
126
+ function isNotFound(error) {
127
+ return isRecord(error) && error.code === "ENOENT";
128
+ }
@@ -0,0 +1,50 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isRecord } from "./json.js";
4
+ const SESSION_FILE = "session.json";
5
+ const PENDING_FILE = "pending-declare.json";
6
+ function sessionDir(gitDir) {
7
+ return join(gitDir, "gatebolt");
8
+ }
9
+ async function writeJson(gitDir, file, data) {
10
+ await mkdir(sessionDir(gitDir), { recursive: true });
11
+ await writeFile(join(sessionDir(gitDir), file), JSON.stringify(data, null, 2) + "\n", "utf8");
12
+ }
13
+ async function readJson(gitDir, file, parse) {
14
+ try {
15
+ const data = JSON.parse(await readFile(join(sessionDir(gitDir), file), "utf8"));
16
+ return isRecord(data) ? parse(data) : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function clearJson(gitDir, file) {
23
+ return rm(join(sessionDir(gitDir), file), { force: true });
24
+ }
25
+ export function writeSession(gitDir, session) {
26
+ return writeJson(gitDir, SESSION_FILE, session);
27
+ }
28
+ export function readSession(gitDir) {
29
+ return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch }) => typeof changeId === "string" && typeof baseSha === "string"
30
+ ? {
31
+ changeId,
32
+ baseSha,
33
+ branch: typeof branch === "string" ? branch : undefined,
34
+ }
35
+ : null);
36
+ }
37
+ export function clearSession(gitDir) {
38
+ return clearJson(gitDir, SESSION_FILE);
39
+ }
40
+ export function writePendingDeclare(gitDir, pending) {
41
+ return writeJson(gitDir, PENDING_FILE, pending);
42
+ }
43
+ export function readPendingDeclare(gitDir) {
44
+ return readJson(gitDir, PENDING_FILE, ({ correlationId, intentKey }) => typeof correlationId === "string" && typeof intentKey === "string"
45
+ ? { correlationId, intentKey }
46
+ : null);
47
+ }
48
+ export function clearPendingDeclare(gitDir) {
49
+ return clearJson(gitDir, PENDING_FILE);
50
+ }
@@ -0,0 +1,72 @@
1
+ import { isRecord } from "./json.js";
2
+ const REQUEST_TIMEOUT_MS = 30_000;
3
+ export class RequestError extends Error {
4
+ status;
5
+ code;
6
+ constructor(message, status, code) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.name = "RequestError";
11
+ }
12
+ }
13
+ export async function postChange(connection, procedure, input) {
14
+ let response;
15
+ try {
16
+ response = await fetch(`${connection.baseUrl}/api/trpc/${procedure}`, {
17
+ method: "POST",
18
+ headers: {
19
+ authorization: `Bearer ${connection.key}`,
20
+ "content-type": "application/json",
21
+ },
22
+ body: JSON.stringify({ json: input }),
23
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
24
+ });
25
+ }
26
+ catch (error) {
27
+ throw connectionError(connection.baseUrl, error);
28
+ }
29
+ let body;
30
+ try {
31
+ body = await response.json();
32
+ }
33
+ catch (error) {
34
+ if (!(error instanceof SyntaxError)) {
35
+ throw connectionError(connection.baseUrl, error);
36
+ }
37
+ body = null;
38
+ }
39
+ if (!response.ok) {
40
+ throw toRequestError(response.status, body);
41
+ }
42
+ return extractResult(body);
43
+ }
44
+ function connectionError(baseUrl, error) {
45
+ if (error instanceof Error && error.name === "TimeoutError") {
46
+ return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT");
47
+ }
48
+ return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK");
49
+ }
50
+ function toRequestError(status, body) {
51
+ const parsed = parseTrpcError(body);
52
+ return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN");
53
+ }
54
+ export function extractResult(body) {
55
+ const result = isRecord(body) ? body.result : undefined;
56
+ const data = isRecord(result) ? result.data : undefined;
57
+ if (isRecord(data) && data.json !== undefined) {
58
+ return data.json;
59
+ }
60
+ throw new RequestError("Unexpected response from server.", 0, "MALFORMED");
61
+ }
62
+ function parseTrpcError(body) {
63
+ const error = isRecord(body) ? body.error : undefined;
64
+ const json = isRecord(error) ? error.json : undefined;
65
+ if (!isRecord(json))
66
+ return null;
67
+ const data = json.data;
68
+ return {
69
+ message: typeof json.message === "string" ? json.message : undefined,
70
+ code: isRecord(data) && typeof data.code === "string" ? data.code : undefined,
71
+ };
72
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@gatebolt/cli",
3
+ "version": "0.2.0",
4
+ "description": "Declare-then-observe agent client for GateBolt — records the files an AI agent intends to touch, then reconciles the actual git diff.",
5
+ "keywords": [
6
+ "gatebolt",
7
+ "ai-agents",
8
+ "provenance",
9
+ "audit",
10
+ "reconciliation",
11
+ "claude-code",
12
+ "git",
13
+ "cli"
14
+ ],
15
+ "license": "UNLICENSED",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/gatebolt/gatebolt-cli.git"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "gatebolt": "./dist/index.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "recipes"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.18.12",
36
+ "typescript": "^5.9.3",
37
+ "vitest": "^4.0.16"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json",
42
+ "test": "vitest run",
43
+ "test:it": "vitest run --config vitest.it.config.ts"
44
+ }
45
+ }
@@ -0,0 +1,64 @@
1
+ # GateBolt × Claude Code — declare → observe recipe
2
+
3
+ Files you copy into the repo your agent works in so a Claude Code session runs
4
+ the GateBolt loop on its own: the agent **declares** the files it plans to touch
5
+ before editing, and each turn's diff is **observed** and reconciled when the turn
6
+ ends. Drives the [`gatebolt` CLI](../../README.md) — read that for what `declare`
7
+ and `observe` do.
8
+
9
+ ## What's in here
10
+
11
+ | File | Copies to | Role |
12
+ | --------------------------- | ------------------------------ | --------------------------------------------------- |
13
+ | `settings.json` | `<repo>/.claude/settings.json` | Wires the `PreToolUse` guard + `Stop` observe hook |
14
+ | `hooks/gatebolt-guard.sh` | `<repo>/.claude/hooks/` | Strict mode: deny `Edit`/`Write` until declared |
15
+ | `hooks/gatebolt-observe.sh` | `<repo>/.claude/hooks/` | `Stop`: run `gatebolt observe` when a turn ends |
16
+ | `hooks/pre-push` | `<repo>/.git/hooks/pre-push` | Backstop: refuse to push an un-observed declaration |
17
+
18
+ Merge the `hooks` block if the repo already has a `.claude/settings.json`.
19
+
20
+ ## Setup
21
+
22
+ ```sh
23
+ # 1. Copy the recipe in.
24
+ cp hooks/gatebolt-*.sh <repo>/.claude/hooks/
25
+ cp settings.json <repo>/.claude/settings.json
26
+ cp hooks/pre-push <repo>/.git/hooks/pre-push
27
+ chmod +x <repo>/.claude/hooks/gatebolt-*.sh <repo>/.git/hooks/pre-push
28
+
29
+ # 2. Save a GateBolt profile for this repo once — paste the key when prompted.
30
+ # The hooks call `gatebolt` with no flags; the profile is resolved automatically.
31
+ npx @gatebolt/cli configure --profile <repo-slug> \
32
+ --url https://your-gatebolt.app --repo <repo-slug>
33
+
34
+ # 3. Launch Claude Code.
35
+ export GATEBOLT_ENFORCE="strict" # or omit for lenient
36
+ export GATEBOLT_CLI="npx @gatebolt/cli" # optional — omit if `gatebolt` is on PATH
37
+ cd <repo> && claude
38
+ ```
39
+
40
+ **Prereqs:** `git`, `jq`, and the [`@gatebolt/cli`](../../README.md) client
41
+ (`npm i -g @gatebolt/cli`). `GATEBOLT_CLI` is a command _prefix_ the hooks append
42
+ to, defaulting to `gatebolt`; set it to `npx @gatebolt/cli` if you didn't install
43
+ globally.
44
+
45
+ ## The two modes (`GATEBOLT_ENFORCE`)
46
+
47
+ - **`strict`** — the guard **denies** every `Edit`/`Write` until a declaration
48
+ exists (holds even under `--dangerously-skip-permissions`) and tells the agent
49
+ to run `gatebolt declare` first. That `Bash` call isn't blocked, so the agent
50
+ declares, then edits go through.
51
+ - **`lenient`** (default) — no block; declaring is an instructed step (e.g. a
52
+ line in the repo's `CLAUDE.md`). The pre-push backstop guarantees nothing lands
53
+ undeclared.
54
+
55
+ ## Notes and limits
56
+
57
+ - **One turn ≈ one change.** `Stop` fires every turn, so `observe` reconciles and
58
+ clears the session each turn; strict mode re-declares next turn.
59
+ - **Rate limit.** Two API calls per change, and the default key limit is low
60
+ (10/day today, surfacing as `401` when hit). Use a fresh key when iterating.
61
+ - **Backstop is v1.** Catches only an open, un-observed declaration (the
62
+ Esc-interrupt case), not work that was never declared — a follow-up.
63
+ - **Watch over-broad declarations.** Declaring `src/**` to dodge the block kills
64
+ the drift signal, like an empty declaration. The deny asks for specific paths.
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # GateBolt strict-mode PreToolUse guard — blocks Edit/Write until a declaration
3
+ # exists for this change. Opt-in via GATEBOLT_ENFORCE=strict.
4
+ set -u
5
+
6
+ # Drain stdin so the hook's pipe doesn't block; we don't need the payload.
7
+ cat >/dev/null
8
+
9
+ [ "${GATEBOLT_ENFORCE:-lenient}" = "strict" ] || exit 0
10
+
11
+ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
12
+ git_dir=$(git -C "$project_dir" rev-parse --git-dir 2>/dev/null) || exit 0
13
+ case "$git_dir" in
14
+ /*) ;;
15
+ *) git_dir="$project_dir/$git_dir" ;;
16
+ esac
17
+
18
+ # session.json exists = already declared → allow the edit.
19
+ [ -f "$git_dir/gatebolt/session.json" ] && exit 0
20
+
21
+ cli="${GATEBOLT_CLI:-gatebolt}"
22
+ reason="GateBolt strict mode: declare the files you plan to touch before editing.
23
+ Run this in Bash first, listing EVERY file you plan to create or modify for this
24
+ task — specific repo-relative paths, not broad globs like src/** :
25
+
26
+ $cli declare \\
27
+ --task \"<one line describing the task>\" \\
28
+ --vendor claude_code --name \"Claude Code\" --model <your-model-id> \\
29
+ --file <path> [--file <path> ...]
30
+
31
+ GBOLT_KEY, GBOLT_URL and GBOLT_REPO are already set in your environment. Once it
32
+ prints \"Declared change …\", retry the edit."
33
+
34
+ # JSON deny holds even under --dangerously-skip-permissions; without jq we fall
35
+ # back to exit-2, which re-prompts but isn't guaranteed under bypass.
36
+ if command -v jq >/dev/null 2>&1; then
37
+ jq -n --arg r "$reason" '{
38
+ hookSpecificOutput: {
39
+ hookEventName: "PreToolUse",
40
+ permissionDecision: "deny",
41
+ permissionDecisionReason: $r
42
+ }
43
+ }'
44
+ exit 0
45
+ fi
46
+
47
+ printf '%s\n' "$reason" >&2
48
+ exit 2
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # GateBolt Stop hook — runs `gatebolt observe` to reconcile the diff when a turn
3
+ # ends. Advisory; never blocks the turn.
4
+ set -u
5
+
6
+ # Drain stdin; observe reads its target from the session file.
7
+ cat >/dev/null
8
+
9
+ cd "${CLAUDE_PROJECT_DIR:-$PWD}" || exit 0
10
+
11
+ git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
12
+ case "$git_dir" in
13
+ /*) ;;
14
+ *) git_dir="$PWD/$git_dir" ;;
15
+ esac
16
+
17
+ # Stop fires every turn; only reconcile when a declaration is actually open.
18
+ [ -f "$git_dir/gatebolt/session.json" ] || exit 0
19
+
20
+ cli="${GATEBOLT_CLI:-gatebolt}"
21
+ # GATEBOLT_CLI is a command prefix; word-splitting is intentional.
22
+ # shellcheck disable=SC2086
23
+ $cli observe 2>&1 || true
24
+ exit 0
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # GateBolt pre-push backstop — refuses to push while a declaration is still open
3
+ # but un-observed (the Esc-interrupt case Stop misses). Needs gatebolt's env
4
+ # (GBOLT_KEY / GBOLT_URL / GATEBOLT_CLI) exported in the shell you push from.
5
+ set -u
6
+
7
+ git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
8
+ case "$git_dir" in
9
+ /*) ;;
10
+ *) git_dir="$PWD/$git_dir" ;;
11
+ esac
12
+
13
+ [ -f "$git_dir/gatebolt/session.json" ] || exit 0
14
+
15
+ cli="${GATEBOLT_CLI:-gatebolt}"
16
+ echo "GateBolt: an un-observed declaration is still open; reconciling before push…" >&2
17
+ # shellcheck disable=SC2086
18
+ if ! $cli observe >&2; then
19
+ echo "GateBolt: observe failed — refusing to push unreconciled work." >&2
20
+ echo "Resolve the error above (or run '$cli observe' yourself), then push again." >&2
21
+ exit 1
22
+ fi
23
+ exit 0
@@ -0,0 +1,25 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gatebolt-guard.sh"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "Stop": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gatebolt-observe.sh"
20
+ }
21
+ ]
22
+ }
23
+ ]
24
+ }
25
+ }