@gatebolt/cli 0.4.0 → 0.5.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 CHANGED
@@ -21,34 +21,77 @@ Requires Node ≥ 20 and `git` on your `PATH`.
21
21
 
22
22
  ## Configure
23
23
 
24
- Every command needs three settings — an API **key**, the GateBolt **URL**, and the **repo
25
- slug** to record the work against. Store them once as a named **profile** in
26
- `~/.gatebolt/config.json` (written owner-only, `0600`):
24
+ Every command needs two connection settings — an API **key** and the GateBolt **URL**. Store
25
+ them once as a **profile** in `~/.gatebolt/config.json` (written owner-only, `0600`):
27
26
 
28
27
  ```
29
- gatebolt configure --profile payments-api \
30
- --url https://your-gatebolt.app --repo payments-api
28
+ gatebolt configure --url https://your-gatebolt.app
31
29
  # paste the API key when prompted (or pass --key for scripting)
32
30
  ```
33
31
 
34
- The `--repo` value is normalized to a slug (lowercase, hyphen-separated), so
35
- `Payments_API` is stored and reported as `payments-api`.
32
+ One profile per GateBolt workspace. `--profile <name>` saves another alongside it; without the
33
+ flag it saves `default`, and a second `configure` refuses rather than replace the first
34
+ workspace's key. (`gatebolt login` writes a profile for you.)
36
35
 
37
- Add a profile per repo. Inside a repo, the CLI picks the active profile in this order:
36
+ Which repository each change belongs to is recorded separately, by `gatebolt link` (below).
37
+ Inside a repo, the CLI picks the active profile in this order:
38
38
 
39
39
  1. `--profile <name>` flag
40
40
  2. `GATEBOLT_PROFILE` env var
41
- 3. a `.gatebolt` file at the repo root holding a profile name safe to commit (no secret)
41
+ 3. the `profile` field of a committable `.gatebolt` file (see `gatebolt link`)
42
42
  4. the sole profile, when there is only one
43
43
  5. the `default` profile
44
44
 
45
- An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url` / `--repo`)
46
- or the `GBOLT_KEY` / `GBOLT_URL` / `GBOLT_REPO` env var wins over the profile, so a
47
- fully-env setup needs no profile file at all. Point the config elsewhere (CI, tests) with
48
- `GATEBOLT_CONFIG`.
45
+ An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url`) or the
46
+ `GATEBOLT_KEY` / `GATEBOLT_URL` env var wins over the profile, so a fully-env setup needs no profile
47
+ file at all. Point the config elsewhere (CI, tests) with `GATEBOLT_CONFIG`.
49
48
 
50
- > **Rate limit.** A new key allows 10 requests/day (~5 changes) today — grab a fresh key
51
- > while iterating, and a `401` means you hit the daily limit.
49
+ ## `gatebolt init`
50
+
51
+ Sets up GateBolt in a repository in one command. Run it once from the repo root, after saving a
52
+ key (see [Configure](#configure)):
53
+
54
+ ```
55
+ gatebolt init
56
+ ```
57
+
58
+ It **links** the repository (writing a committable `.gatebolt`, exactly like `gatebolt link`),
59
+ **wires** your agent's declare → observe hooks — for Claude Code, appended to
60
+ `.claude/settings.local.json` — and installs a **pre-push backstop** at `.git/hooks/pre-push`
61
+ that reconciles an open declaration before a push. It reuses a saved profile (it never signs you
62
+ in), and is safe to re-run — it never duplicates hooks.
63
+
64
+ | Flag | Description |
65
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------- |
66
+ | `--enforce <mode>` | `strict` blocks edits until you declare; `lenient` keeps it advisory; omit to leave a repo's current mode unchanged |
67
+ | `--no-hooks` | Only link the repository; don't wire the agent hooks |
68
+ | `--no-pre-push` | Don't install the pre-push backstop |
69
+ | `--force` | Replace an existing, non-GateBolt pre-push hook |
70
+ | `--name <name>` | Name the repository (when there is no `origin` remote to derive from) |
71
+
72
+ `--profile` selects a saved profile. Unlike the other commands, `init` takes no `--key` / `--url`:
73
+ the hooks it wires authenticate later, so they need a durable saved profile, not one-off flags
74
+ (`GATEBOLT_KEY` / `GATEBOLT_URL` env still apply).
75
+
76
+ The hooks call `gatebolt hook <guard|post-turn|pre-push>` — internal subcommands you don't run by
77
+ hand: `guard` blocks an edit until a declaration exists (strict mode), `post-turn` reconciles the
78
+ diff when a turn ends, and `pre-push` reconciles before a push.
79
+
80
+ ## `gatebolt link`
81
+
82
+ Records **which repository** this checkout is, so every clone, worktree and CI job records
83
+ against the same one. Run it once per repo, from the repo root, and commit the file it writes:
84
+
85
+ ```
86
+ gatebolt link
87
+ git add .gatebolt && git commit -m "record the GateBolt repository"
88
+ ```
89
+
90
+ It derives the repository's name from the `origin` remote (or pass `--name` when there is no
91
+ remote), asks the server to find-or-create it, and writes the repository id to a committable
92
+ `.gatebolt` file. `declare` and `observe` read that id; when `.gatebolt` is absent, `declare`
93
+ links automatically from the remote. The file is authoritative — a malformed one is a hard
94
+ error telling you to re-run `gatebolt link`, never a silent guess.
52
95
 
53
96
  ## `gatebolt declare`
54
97
 
@@ -116,5 +159,5 @@ than 2000 files is rejected; narrow the change.
116
159
  - **One session per worktree.** Session state lives in `<git-dir>/gatebolt/`, which is
117
160
  per-worktree. Run one agent session per working tree; for parallel agents, use separate git
118
161
  worktrees (a shared tree can't attribute one diff to two sessions).
119
- - **Claude Code.** To run the declare → observe loop automatically inside a Claude Code
120
- session, use the hook recipe in [`recipes/claude-code/`](./recipes/claude-code/).
162
+ - **Claude Code.** `gatebolt init` wires the declare → observe loop to run automatically inside a
163
+ Claude Code session see [`gatebolt init`](#gatebolt-init).
@@ -0,0 +1,183 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isRecord } from "../json.js";
4
+ const CLAUDE_DIR = ".claude";
5
+ const SETTINGS_FILE = "settings.local.json";
6
+ const GITIGNORE_FILE = ".gitignore";
7
+ const GITIGNORE_ENTRY = `${CLAUDE_DIR}/${SETTINGS_FILE}`;
8
+ const PRE_TOOL_USE = "PreToolUse";
9
+ const STOP = "Stop";
10
+ const EDIT_TOOLS_MATCHER = "Edit|Write|NotebookEdit";
11
+ const COMMAND_TYPE = "command";
12
+ const GUARD_SUB = "guard";
13
+ const POST_TURN_SUB = "post-turn";
14
+ const ENFORCE_STRICT = "strict";
15
+ const ENFORCE_LENIENT = "lenient";
16
+ export function mergeSettings(existing, opts) {
17
+ const settings = structuredClone(existing);
18
+ const hooks = plainObject(settings.hooks, "hooks");
19
+ settings.hooks = hooks;
20
+ let changed = false;
21
+ const preToolUse = arrayField(hooks[PRE_TOOL_USE], `hooks.${PRE_TOOL_USE}`);
22
+ if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, opts.command, EDIT_TOOLS_MATCHER)) {
23
+ changed = true;
24
+ }
25
+ hooks[PRE_TOOL_USE] = preToolUse;
26
+ const stop = arrayField(hooks[STOP], `hooks.${STOP}`);
27
+ if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, opts.command)) {
28
+ changed = true;
29
+ }
30
+ hooks[STOP] = stop;
31
+ if (opts.enforce === ENFORCE_STRICT) {
32
+ const env = plainObject(settings.env, "env");
33
+ settings.env = env;
34
+ if (env.GATEBOLT_ENFORCE !== ENFORCE_STRICT) {
35
+ env.GATEBOLT_ENFORCE = ENFORCE_STRICT;
36
+ changed = true;
37
+ }
38
+ if (env.GATEBOLT_CLI !== opts.command) {
39
+ env.GATEBOLT_CLI = opts.command;
40
+ changed = true;
41
+ }
42
+ }
43
+ else if (opts.enforce === ENFORCE_LENIENT && settings.env !== undefined) {
44
+ const env = plainObject(settings.env, "env");
45
+ if ("GATEBOLT_ENFORCE" in env) {
46
+ delete env.GATEBOLT_ENFORCE;
47
+ changed = true;
48
+ }
49
+ if ("GATEBOLT_CLI" in env) {
50
+ delete env.GATEBOLT_CLI;
51
+ changed = true;
52
+ }
53
+ if (Object.keys(env).length === 0) {
54
+ delete settings.env;
55
+ }
56
+ else {
57
+ settings.env = env;
58
+ }
59
+ }
60
+ return { settings, changed };
61
+ }
62
+ export function withGitignoreEntry(existing, entry) {
63
+ const present = existing.split("\n").some((line) => line.trim() === entry);
64
+ if (present)
65
+ return null;
66
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
67
+ return `${existing}${prefix}${entry}\n`;
68
+ }
69
+ function plainObject(value, label) {
70
+ if (value === undefined)
71
+ return {};
72
+ if (isRecord(value) && !Array.isArray(value))
73
+ return value;
74
+ throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON object.`);
75
+ }
76
+ function arrayField(value, label) {
77
+ if (value === undefined)
78
+ return [];
79
+ if (Array.isArray(value))
80
+ return value;
81
+ throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON array.`);
82
+ }
83
+ // Update our command in place when it drifts, not just add-when-absent: a
84
+ // re-run of `init` that left a stale command would silently disable the guard.
85
+ function ensureHookCommand(entries, suffix, command, matcher) {
86
+ const desired = `${command} ${suffix}`;
87
+ const existing = findGateboltHook(entries, suffix);
88
+ if (existing) {
89
+ if (existing.command === desired)
90
+ return false;
91
+ existing.command = desired;
92
+ return true;
93
+ }
94
+ const hook = { type: COMMAND_TYPE, command: desired };
95
+ entries.push(matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] });
96
+ return true;
97
+ }
98
+ function findGateboltHook(entries, suffix) {
99
+ for (const entry of entries) {
100
+ if (!isRecord(entry) || !Array.isArray(entry.hooks))
101
+ continue;
102
+ for (const hook of entry.hooks) {
103
+ if (isRecord(hook) &&
104
+ typeof hook.command === "string" &&
105
+ hook.command.trim().endsWith(suffix)) {
106
+ return hook;
107
+ }
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ async function wireClaudeCode(root, opts) {
113
+ const settingsPath = join(root, CLAUDE_DIR, SETTINGS_FILE);
114
+ const { settings, changed } = mergeSettings(await readSettings(settingsPath), opts);
115
+ if (changed) {
116
+ await mkdir(join(root, CLAUDE_DIR), { recursive: true });
117
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
118
+ }
119
+ const gitignoreChanged = await ensureGitignore(root);
120
+ return {
121
+ settingsPath,
122
+ settingsChanged: changed,
123
+ gitignoreChanged,
124
+ enforce: effectiveEnforce(settings),
125
+ };
126
+ }
127
+ function effectiveEnforce(settings) {
128
+ const env = settings.env;
129
+ if (isRecord(env) && env.GATEBOLT_ENFORCE === ENFORCE_STRICT) {
130
+ return ENFORCE_STRICT;
131
+ }
132
+ return ENFORCE_LENIENT;
133
+ }
134
+ async function readSettings(path) {
135
+ let raw;
136
+ try {
137
+ raw = await readFile(path, "utf8");
138
+ }
139
+ catch (error) {
140
+ if (isNotFound(error))
141
+ return {};
142
+ throw error;
143
+ }
144
+ const data = safeParse(raw);
145
+ if (!isRecord(data) || Array.isArray(data)) {
146
+ throw new Error(`${path} is not a JSON object. Fix or remove it, then re-run \`gatebolt init\`.`);
147
+ }
148
+ return data;
149
+ }
150
+ async function ensureGitignore(root) {
151
+ const path = join(root, GITIGNORE_FILE);
152
+ const updated = withGitignoreEntry(await readTextOrEmpty(path), GITIGNORE_ENTRY);
153
+ if (updated === null)
154
+ return false;
155
+ await writeFile(path, updated, "utf8");
156
+ return true;
157
+ }
158
+ async function readTextOrEmpty(path) {
159
+ try {
160
+ return await readFile(path, "utf8");
161
+ }
162
+ catch (error) {
163
+ if (isNotFound(error))
164
+ return "";
165
+ throw error;
166
+ }
167
+ }
168
+ function safeParse(raw) {
169
+ try {
170
+ return JSON.parse(raw);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ function isNotFound(error) {
177
+ return isRecord(error) && error.code === "ENOENT";
178
+ }
179
+ export const claudeCodeAdapter = {
180
+ id: "claude-code",
181
+ displayName: "Claude Code",
182
+ wire: wireClaudeCode,
183
+ };
@@ -2,37 +2,34 @@ import { stdin, stdout } from "node:process";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { Writable } from "node:stream";
4
4
  import { parseArgs } from "node:util";
5
- import { slugifyRepo } from "../config.js";
6
5
  import { readConfigForWrite, writeConfig } from "../profiles.js";
6
+ const DEFAULT_PROFILE = "default";
7
7
  export async function runConfigure(argv) {
8
8
  const { values } = parseArgs({
9
9
  args: argv,
10
10
  options: {
11
11
  profile: { type: "string" },
12
12
  url: { type: "string" },
13
- repo: { type: "string" },
14
13
  key: { type: "string" },
15
14
  },
16
15
  });
17
- const repo = slugifyRepo(requireFlag(values.repo, "--repo"));
18
16
  const url = values.url?.replace(/\/+$/, "");
19
- const name = values.profile ?? repo;
17
+ const name = values.profile ?? DEFAULT_PROFILE;
20
18
  const key = (values.key ?? (await promptForKey())).trim();
21
19
  if (!key) {
22
20
  throw new Error("No API key provided.");
23
21
  }
24
22
  const config = await readConfigForWrite();
25
- config.profiles[name] = url ? { url, repo, key } : { repo, key };
23
+ const existing = config.profiles[name];
24
+ if (!values.profile && existing && existing.key !== key) {
25
+ throw new Error(`Profile "${name}" already holds a different key. Pass --profile <name> to save ` +
26
+ `this one alongside it, or --profile ${name} to replace it.`);
27
+ }
28
+ config.profiles[name] = url ? { url, key } : { key };
26
29
  config.default ??= name;
27
30
  const path = await writeConfig(config);
28
31
  process.stdout.write(`Saved profile "${name}" to ${path}.\n`);
29
32
  }
30
- function requireFlag(value, flag) {
31
- if (!value) {
32
- throw new Error(`Missing required option ${flag}.`);
33
- }
34
- return value;
35
- }
36
33
  async function promptForKey() {
37
34
  let muted = false;
38
35
  const output = new Writable({
@@ -1,13 +1,26 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { relative, resolve, sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { resolveConnection, resolveRepoSlug } from "../config.js";
4
+ import { resolveConnection } from "../config.js";
5
5
  import { AGENT_VENDORS, DECLARED_FILES_MAX, isAgentVendor, TASK_MAX_LENGTH, } from "../contracts.js";
6
6
  import { currentBranch, currentHead, hasCommits, repoContext } from "../git.js";
7
7
  import { isRecord } from "../json.js";
8
- import { profileLoader } from "../profiles.js";
9
- import { readPendingDeclare, writePendingDeclare, writeSession, } from "../session.js";
8
+ import { profileLoader, readRepoLink } from "../profiles.js";
9
+ import { clearPendingDeclare, clearSession, readPendingDeclare, readSession, writePendingDeclare, writeSession, } from "../session.js";
10
10
  import { postChange } from "../transport.js";
11
+ import { deriveIdentity, linkRepository } from "./link.js";
12
+ const NO_REMOTE = `GateBolt cannot derive this repository's identity from its \`origin\` remote.
13
+
14
+ Ask a human to run this once, from the repo root:
15
+
16
+ gatebolt link --name <a-name-for-this-repo>
17
+
18
+ It writes a .gatebolt file holding the repository id. Commit that file and every
19
+ clone, worktree and CI job will resolve the same repository.
20
+
21
+ Do not guess a name. A wrong name creates a second repository and splits this
22
+ repo's audit trail in two.`;
23
+ const ID_PREFIX_LEN = 8;
11
24
  export async function runDeclare(argv) {
12
25
  const { values } = parseArgs({
13
26
  args: argv,
@@ -15,7 +28,6 @@ export async function runDeclare(argv) {
15
28
  key: { type: "string" },
16
29
  url: { type: "string" },
17
30
  profile: { type: "string" },
18
- repo: { type: "string" },
19
31
  task: { type: "string" },
20
32
  vendor: { type: "string" },
21
33
  name: { type: "string" },
@@ -23,8 +35,12 @@ export async function runDeclare(argv) {
23
35
  "agent-version": { type: "string" },
24
36
  "launched-by": { type: "string" },
25
37
  file: { type: "string", multiple: true },
38
+ abort: { type: "boolean" },
26
39
  },
27
40
  });
41
+ if (values.abort) {
42
+ return abortDeclaration();
43
+ }
28
44
  const rawFiles = collectFiles(values.file);
29
45
  if (rawFiles.length === 0) {
30
46
  throw new Error("Declare at least one file with --file.");
@@ -56,10 +72,10 @@ export async function runDeclare(argv) {
56
72
  const { gitDir: dir, root } = repo;
57
73
  const loadProfile = profileLoader({ name: values.profile, root });
58
74
  const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
59
- const repoSlug = await resolveRepoSlug({ repo: values.repo }, loadProfile);
60
75
  if (!(await hasCommits())) {
61
76
  throw new Error("This repository has no commits yet — make an initial commit before declaring.");
62
77
  }
78
+ const repositoryId = await resolveRepositoryId(root, connection);
63
79
  const baseSha = await currentHead();
64
80
  const branch = await currentBranch();
65
81
  const declaredFiles = dedupeByPath(rawFiles.map((raw) => ({ path: normalizeRepoPath(root, raw) })));
@@ -67,7 +83,7 @@ export async function runDeclare(argv) {
67
83
  throw new Error(`Declaring ${declaredFiles.length} files, over the ${DECLARED_FILES_MAX} limit. Narrow the change.`);
68
84
  }
69
85
  const intentKey = buildIntentKey({
70
- repoSlug,
86
+ repositoryId,
71
87
  task,
72
88
  agent,
73
89
  baseSha,
@@ -75,7 +91,7 @@ export async function runDeclare(argv) {
75
91
  });
76
92
  const correlationId = correlationIdFor(await readPendingDeclare(dir), intentKey);
77
93
  const input = buildDeclareInput({
78
- repoSlug,
94
+ repositoryId,
79
95
  task,
80
96
  agent,
81
97
  declaredFiles,
@@ -92,15 +108,40 @@ export async function runDeclare(argv) {
92
108
  changeId: result.changeId,
93
109
  baseSha,
94
110
  branch,
111
+ profile: connection.profileName,
95
112
  });
96
113
  process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
97
114
  }
115
+ async function abortDeclaration() {
116
+ const repo = await repoContext();
117
+ if (!repo) {
118
+ throw new Error("Not inside a git repository.");
119
+ }
120
+ const session = await readSession(repo.gitDir);
121
+ if (!session) {
122
+ process.stdout.write("No open declaration to clear.\n");
123
+ return;
124
+ }
125
+ await clearSession(repo.gitDir);
126
+ await clearPendingDeclare(repo.gitDir);
127
+ process.stdout.write(`Cleared the open declaration (change ${session.changeId.slice(0, ID_PREFIX_LEN)}…). Declare again to track new work.\n`);
128
+ }
129
+ async function resolveRepositoryId(root, connection) {
130
+ const link = await readRepoLink(root);
131
+ if (link)
132
+ return link.repositoryId;
133
+ const identity = await deriveIdentity();
134
+ if (!identity) {
135
+ throw new Error(NO_REMOTE);
136
+ }
137
+ return (await linkRepository(connection, root, identity)).repositoryId;
138
+ }
98
139
  export function isDeclareResult(value) {
99
140
  return isRecord(value) && typeof value.changeId === "string";
100
141
  }
101
142
  export function buildIntentKey(parts) {
102
143
  return JSON.stringify({
103
- repoSlug: parts.repoSlug,
144
+ repositoryId: parts.repositoryId,
104
145
  task: parts.task,
105
146
  agent: parts.agent,
106
147
  baseSha: parts.baseSha,
@@ -109,7 +150,7 @@ export function buildIntentKey(parts) {
109
150
  }
110
151
  function buildDeclareInput(parts) {
111
152
  const input = {
112
- repoSlug: parts.repoSlug,
153
+ repositoryId: parts.repositoryId,
113
154
  task: parts.task,
114
155
  agent: parts.agent,
115
156
  declaredFiles: parts.declaredFiles,
@@ -0,0 +1,128 @@
1
+ import { existsSync } from "node:fs";
2
+ import { formatError } from "../errors.js";
3
+ import { hasCommits, repoContext } from "../git.js";
4
+ import { readSession } from "../session.js";
5
+ import { RequestError } from "../transport.js";
6
+ import { runObserve } from "./observe.js";
7
+ const ENFORCE_STRICT = "strict";
8
+ const PRE_TOOL_USE = "PreToolUse";
9
+ const DENY_DECISION = "deny";
10
+ const DEFAULT_CLI = "gatebolt";
11
+ function declareInstructions(cli) {
12
+ return `GateBolt strict mode: declare the files you plan to touch before editing.
13
+ Run this in Bash first, listing EVERY file you plan to create or modify for this
14
+ task — specific repo-relative paths, not broad globs like src/** :
15
+
16
+ ${cli} declare \\
17
+ --task "<one line describing the task>" \\
18
+ --vendor claude_code --name "Claude Code" --model <your-model-id> \\
19
+ --file <path> [--file <path> ...]
20
+
21
+ The GateBolt CLI is already configured for this repository. Once it
22
+ prints "Declared change …", retry the edit.`;
23
+ }
24
+ export async function runHook(argv) {
25
+ anchorToProjectDir();
26
+ drainStdin();
27
+ const sub = argv[0] ?? "";
28
+ switch (sub) {
29
+ case "guard":
30
+ return runGuard();
31
+ case "post-turn":
32
+ return runPostTurn();
33
+ case "pre-push":
34
+ return runPrePush();
35
+ default:
36
+ throw new Error(`Unknown hook '${sub}'. Expected one of: guard, post-turn, pre-push.`);
37
+ }
38
+ }
39
+ let stdinDrained = false;
40
+ function drainStdin() {
41
+ if (stdinDrained || process.stdin.isTTY)
42
+ return;
43
+ stdinDrained = true;
44
+ process.stdin.on("error", () => undefined);
45
+ process.stdin.resume();
46
+ }
47
+ function anchorToProjectDir() {
48
+ const dir = process.env.CLAUDE_PROJECT_DIR;
49
+ if (dir && existsSync(dir))
50
+ process.chdir(dir);
51
+ }
52
+ async function hookRepoContext() {
53
+ try {
54
+ return await repoContext();
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ async function runGuard() {
61
+ if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
62
+ return;
63
+ const repo = await hookRepoContext();
64
+ if (!repo)
65
+ return;
66
+ if (await readSession(repo.gitDir))
67
+ return;
68
+ const hasBaseCommit = await hasCommits();
69
+ if (!hasBaseCommit)
70
+ return;
71
+ const cli = process.env.GATEBOLT_CLI ?? DEFAULT_CLI;
72
+ process.stdout.write(denyPayload(declareInstructions(cli)));
73
+ }
74
+ async function runPostTurn() {
75
+ const repo = await hookRepoContext();
76
+ if (!repo)
77
+ return;
78
+ if (!(await readSession(repo.gitDir)))
79
+ return;
80
+ try {
81
+ await runObserve([]);
82
+ }
83
+ catch (error) {
84
+ process.stderr.write(`${formatError(error)}\n`);
85
+ }
86
+ }
87
+ // Backstop for the interrupt case the Stop hook misses: an open, un-observed
88
+ // declaration.
89
+ async function runPrePush() {
90
+ const repo = await hookRepoContext();
91
+ if (!repo)
92
+ return;
93
+ if (!(await readSession(repo.gitDir)))
94
+ return;
95
+ process.stderr.write("GateBolt: an un-observed declaration is still open; reconciling before push…\n");
96
+ try {
97
+ await runObserve([]);
98
+ }
99
+ catch (error) {
100
+ if (isUnreachable(error)) {
101
+ process.stderr.write("GateBolt: couldn't reach the server to reconcile; allowing the push.\n" +
102
+ `${formatError(error)}\n` +
103
+ "The declaration stays open and will reconcile on your next `gatebolt observe`.\n");
104
+ return;
105
+ }
106
+ process.stderr.write("GateBolt: observe failed — refusing to push unreconciled work.\n" +
107
+ `${formatError(error)}\n` +
108
+ "Resolve the error above (or run `gatebolt observe` yourself), then push again.\n" +
109
+ "If this declaration can no longer be reconciled, run `gatebolt declare --abort` to clear it.\n");
110
+ process.exitCode = 1;
111
+ }
112
+ }
113
+ function isUnreachable(error) {
114
+ return (error instanceof RequestError &&
115
+ (error.code === "NETWORK" || error.code === "TIMEOUT"));
116
+ }
117
+ // Claude Code's PreToolUse deny contract. Emitting this JSON on stdout with a
118
+ // zero exit holds the block even under --dangerously-skip-permissions, where an
119
+ // exit-2 fallback would not.
120
+ export function denyPayload(reason) {
121
+ return (JSON.stringify({
122
+ hookSpecificOutput: {
123
+ hookEventName: PRE_TOOL_USE,
124
+ permissionDecision: DENY_DECISION,
125
+ permissionDecisionReason: reason,
126
+ },
127
+ }) + "\n");
128
+ }