@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 +60 -17
- package/dist/agents/claude-code.js +183 -0
- package/dist/commands/configure.js +8 -11
- package/dist/commands/declare.js +50 -9
- package/dist/commands/hook.js +128 -0
- package/dist/commands/init.js +221 -0
- package/dist/commands/link.js +61 -0
- package/dist/commands/login.js +317 -0
- package/dist/commands/observe.js +16 -10
- package/dist/config.js +48 -12
- package/dist/contracts.js +6 -0
- package/dist/diff.js +2 -1
- package/dist/errors.js +10 -8
- package/dist/git.js +17 -0
- package/dist/help.js +81 -14
- package/dist/index.js +12 -0
- package/dist/profiles.js +48 -13
- package/dist/rest.js +49 -0
- package/dist/session.js +2 -1
- package/dist/transport.js +32 -12
- package/package.json +2 -3
- package/recipes/claude-code/README.md +0 -64
- package/recipes/claude-code/hooks/gatebolt-guard.sh +0 -48
- package/recipes/claude-code/hooks/gatebolt-observe.sh +0 -24
- package/recipes/claude-code/hooks/pre-push +0 -23
- package/recipes/claude-code/settings.json +0 -25
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
|
|
25
|
-
|
|
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 --
|
|
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
|
-
|
|
35
|
-
`
|
|
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
|
-
|
|
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.
|
|
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`
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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.**
|
|
120
|
-
|
|
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 ??
|
|
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]
|
|
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({
|
package/dist/commands/declare.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|