@gatebolt/cli 0.3.1 → 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
@@ -16,36 +16,82 @@ npx @gatebolt/cli declare …
16
16
 
17
17
  Requires Node ≥ 20 and `git` on your `PATH`.
18
18
 
19
+ `gatebolt --help` lists the commands and `gatebolt <command> --help` details one;
20
+ `gatebolt --version` prints the version of the CLI itself.
21
+
19
22
  ## Configure
20
23
 
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
+ 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`):
24
26
 
25
27
  ```
26
- gatebolt configure --profile payments-api \
27
- --url https://your-gatebolt.app --repo payments-api
28
+ gatebolt configure --url https://your-gatebolt.app
28
29
  # paste the API key when prompted (or pass --key for scripting)
29
30
  ```
30
31
 
31
- The `--repo` value is normalized to a slug (lowercase, hyphen-separated), so
32
- `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.)
33
35
 
34
- 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:
35
38
 
36
39
  1. `--profile <name>` flag
37
40
  2. `GATEBOLT_PROFILE` env var
38
- 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`)
39
42
  4. the sole profile, when there is only one
40
43
  5. the `default` profile
41
44
 
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`.
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`.
48
+
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
+ ```
46
89
 
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.
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.
49
95
 
50
96
  ## `gatebolt declare`
51
97
 
@@ -60,7 +106,7 @@ to `<git-dir>/gatebolt/session.json`. The repository must have at least one comm
60
106
  | `--name <name>` | yes | Agent name |
61
107
  | `--model <model>` | yes | Model id |
62
108
  | `--file <path>` | yes\* | Intended file (repeatable, one per path) |
63
- | `--version <v>` | no | Agent/tool version |
109
+ | `--agent-version <v>` | no | Agent/tool version (`--version` prints the CLI's own version) |
64
110
  | `--launched-by <email>` | no | Who launched the agent |
65
111
  | `--profile <name>` | no | Profile to resolve `key`/`url`/`repo` from |
66
112
 
@@ -101,7 +147,10 @@ gatebolt observe
101
147
  untracked files, so uncommitted edits and newly created files are both captured. It accepts a
102
148
  base that is an ancestor of HEAD or one HEAD amended/squashed in place on the same branch, but
103
149
  **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
150
+ would fold in unrelated work, so re-run `declare`. It never blocks on ambiguity, though: git
151
+ records nothing that separates the agent's commits from a colleague's, so when commits have
152
+ landed since `declare` — or a merge is in progress — `observe` **says so in a `Note` and
153
+ reconciles anyway**, leaving you to judge whose work the diff contains. It is **advisory** — it exits `0` regardless of the
105
154
  drift level (errors exit non-zero) — and on success it clears the session. A diff touching more
106
155
  than 2000 files is rejected; narrow the change.
107
156
 
@@ -110,5 +159,5 @@ than 2000 files is rejected; narrow the change.
110
159
  - **One session per worktree.** Session state lives in `<git-dir>/gatebolt/`, which is
111
160
  per-worktree. Run one agent session per working tree; for parallel agents, use separate git
112
161
  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/).
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
+ };
@@ -1,43 +1,60 @@
1
1
  import { stdin, stdout } from "node:process";
2
2
  import { createInterface } from "node:readline/promises";
3
+ import { Writable } from "node:stream";
3
4
  import { parseArgs } from "node:util";
4
- import { slugifyRepo } from "../config.js";
5
5
  import { readConfigForWrite, writeConfig } from "../profiles.js";
6
+ const DEFAULT_PROFILE = "default";
6
7
  export async function runConfigure(argv) {
7
8
  const { values } = parseArgs({
8
9
  args: argv,
9
10
  options: {
10
11
  profile: { type: "string" },
11
12
  url: { type: "string" },
12
- repo: { type: "string" },
13
13
  key: { type: "string" },
14
14
  },
15
15
  });
16
- const repo = slugifyRepo(requireFlag(values.repo, "--repo"));
17
16
  const url = values.url?.replace(/\/+$/, "");
18
- const name = values.profile ?? repo;
17
+ const name = values.profile ?? DEFAULT_PROFILE;
19
18
  const key = (values.key ?? (await promptForKey())).trim();
20
19
  if (!key) {
21
20
  throw new Error("No API key provided.");
22
21
  }
23
22
  const config = await readConfigForWrite();
24
- 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 };
25
29
  config.default ??= name;
26
30
  const path = await writeConfig(config);
27
31
  process.stdout.write(`Saved profile "${name}" to ${path}.\n`);
28
32
  }
29
- function requireFlag(value, flag) {
30
- if (!value) {
31
- throw new Error(`Missing required option ${flag}.`);
32
- }
33
- return value;
34
- }
35
33
  async function promptForKey() {
36
- const rl = createInterface({ input: stdin, output: stdout });
34
+ let muted = false;
35
+ const output = new Writable({
36
+ write(chunk, encoding, done) {
37
+ if (!muted)
38
+ stdout.write(chunk, encoding);
39
+ done();
40
+ },
41
+ });
42
+ // `terminal` moves echoing from the tty to readline, which is what makes it
43
+ // suppressible; muting the stream alone leaves the tty echoing in canonical
44
+ // mode. Off a tty nothing echoes anyway, and leaving it off keeps readline's
45
+ // cursor escapes out of piped output.
46
+ const rl = createInterface({
47
+ input: stdin,
48
+ output,
49
+ terminal: stdin.isTTY === true,
50
+ });
37
51
  try {
38
- return await rl.question("API key: ");
52
+ const answer = rl.question("API key: ");
53
+ muted = true;
54
+ return await answer;
39
55
  }
40
56
  finally {
41
57
  rl.close();
58
+ stdout.write("\n");
42
59
  }
43
60
  }
@@ -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,16 +28,19 @@ 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" },
22
34
  model: { type: "string" },
23
- version: { type: "string" },
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.");
@@ -38,8 +54,9 @@ export async function runDeclare(argv) {
38
54
  name: requireFlag(values.name, "--name"),
39
55
  model: requireFlag(values.model, "--model"),
40
56
  };
41
- if (values.version)
42
- agent.version = values.version;
57
+ const agentVersion = values["agent-version"];
58
+ if (agentVersion)
59
+ agent.version = agentVersion;
43
60
  const launchedBy = values["launched-by"];
44
61
  if (launchedBy && !isValidEmail(launchedBy)) {
45
62
  throw new Error(`Invalid --launched-by '${launchedBy}'. Expected an email address.`);
@@ -55,10 +72,10 @@ export async function runDeclare(argv) {
55
72
  const { gitDir: dir, root } = repo;
56
73
  const loadProfile = profileLoader({ name: values.profile, root });
57
74
  const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
58
- const repoSlug = await resolveRepoSlug({ repo: values.repo }, loadProfile);
59
75
  if (!(await hasCommits())) {
60
76
  throw new Error("This repository has no commits yet — make an initial commit before declaring.");
61
77
  }
78
+ const repositoryId = await resolveRepositoryId(root, connection);
62
79
  const baseSha = await currentHead();
63
80
  const branch = await currentBranch();
64
81
  const declaredFiles = dedupeByPath(rawFiles.map((raw) => ({ path: normalizeRepoPath(root, raw) })));
@@ -66,7 +83,7 @@ export async function runDeclare(argv) {
66
83
  throw new Error(`Declaring ${declaredFiles.length} files, over the ${DECLARED_FILES_MAX} limit. Narrow the change.`);
67
84
  }
68
85
  const intentKey = buildIntentKey({
69
- repoSlug,
86
+ repositoryId,
70
87
  task,
71
88
  agent,
72
89
  baseSha,
@@ -74,7 +91,7 @@ export async function runDeclare(argv) {
74
91
  });
75
92
  const correlationId = correlationIdFor(await readPendingDeclare(dir), intentKey);
76
93
  const input = buildDeclareInput({
77
- repoSlug,
94
+ repositoryId,
78
95
  task,
79
96
  agent,
80
97
  declaredFiles,
@@ -91,15 +108,40 @@ export async function runDeclare(argv) {
91
108
  changeId: result.changeId,
92
109
  baseSha,
93
110
  branch,
111
+ profile: connection.profileName,
94
112
  });
95
113
  process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
96
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
+ }
97
139
  export function isDeclareResult(value) {
98
140
  return isRecord(value) && typeof value.changeId === "string";
99
141
  }
100
142
  export function buildIntentKey(parts) {
101
143
  return JSON.stringify({
102
- repoSlug: parts.repoSlug,
144
+ repositoryId: parts.repositoryId,
103
145
  task: parts.task,
104
146
  agent: parts.agent,
105
147
  baseSha: parts.baseSha,
@@ -108,7 +150,7 @@ export function buildIntentKey(parts) {
108
150
  }
109
151
  function buildDeclareInput(parts) {
110
152
  const input = {
111
- repoSlug: parts.repoSlug,
153
+ repositoryId: parts.repositoryId,
112
154
  task: parts.task,
113
155
  agent: parts.agent,
114
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
+ }