@gatebolt/cli 0.8.0 → 0.9.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.
@@ -5,10 +5,12 @@ const CLAUDE_DIR = ".claude";
5
5
  const SETTINGS_FILE = "settings.json";
6
6
  const PRE_TOOL_USE = "PreToolUse";
7
7
  const STOP = "Stop";
8
+ const USER_PROMPT_SUBMIT = "UserPromptSubmit";
8
9
  const EDIT_TOOLS_MATCHER = "Edit|Write|NotebookEdit";
9
10
  const COMMAND_TYPE = "command";
10
11
  const GUARD_SUB = "guard";
11
12
  const POST_TURN_SUB = "post-turn";
13
+ const PROMPT_SUB = "prompt";
12
14
  const ENFORCE_STRICT = "strict";
13
15
  const ENFORCE_LENIENT = "lenient";
14
16
  const DENY_DECISION = "deny";
@@ -28,8 +30,8 @@ function guardCommand(cli) {
28
30
  `{ echo "${MISSING_CLI_MESSAGE}" >&2; ` +
29
31
  `printf '%s\\n' '${missingCliDenyPayload()}'; }; exit 0; }`);
30
32
  }
31
- function postTurnCommand(cli) {
32
- return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${POST_TURN_SUB} || ` +
33
+ function advisoryCommand(cli, sub) {
34
+ return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${sub} || ` +
33
35
  `echo "${MISSING_CLI_MESSAGE}" >&2`);
34
36
  }
35
37
  export function mergeSettings(existing, opts) {
@@ -43,10 +45,15 @@ export function mergeSettings(existing, opts) {
43
45
  }
44
46
  hooks[PRE_TOOL_USE] = preToolUse;
45
47
  const stop = arrayField(hooks[STOP], `hooks.${STOP}`);
46
- if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, postTurnCommand(opts.command))) {
48
+ if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, advisoryCommand(opts.command, POST_TURN_SUB))) {
47
49
  changed = true;
48
50
  }
49
51
  hooks[STOP] = stop;
52
+ const userPrompt = arrayField(hooks[USER_PROMPT_SUBMIT], `hooks.${USER_PROMPT_SUBMIT}`);
53
+ if (ensureHookCommand(userPrompt, `hook ${PROMPT_SUB}`, advisoryCommand(opts.command, PROMPT_SUB))) {
54
+ changed = true;
55
+ }
56
+ hooks[USER_PROMPT_SUBMIT] = userPrompt;
50
57
  if (opts.enforce === ENFORCE_STRICT) {
51
58
  const env = plainObject(settings.env, "env");
52
59
  settings.env = env;
@@ -109,6 +109,7 @@ export async function runDeclare(argv) {
109
109
  baseSha,
110
110
  branch,
111
111
  profile: connection.profileName,
112
+ sessionId: process.env.CLAUDE_CODE_SESSION_ID,
112
113
  });
113
114
  process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
114
115
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { formatError } from "../errors.js";
4
- import { hasCommits, repoContext } from "../git.js";
4
+ import { hasCommits, repoContext, worktreePaths } from "../git.js";
5
5
  import { isRecord } from "../json.js";
6
6
  import { readSession } from "../session.js";
7
7
  import { RequestError } from "../transport.js";
@@ -23,17 +23,31 @@ task — specific repo-relative paths, not broad globs like src/** :
23
23
  The GateBolt CLI is already configured for this repository. Once it
24
24
  prints "Declared change …", retry the edit.`;
25
25
  }
26
+ function promptReminder(cli) {
27
+ return `This repository uses GateBolt strict mode. Before editing any file, declare
28
+ the files you plan to touch — run this in Bash first, listing EVERY file you will
29
+ create or modify for this task:
30
+
31
+ ${cli} declare \\
32
+ --task "<one line describing the task>" \\
33
+ --vendor claude_code --name "Claude Code" --model <your-model-id> \\
34
+ --file <path> [--file <path> ...]
35
+
36
+ Then make your edits. Editing before declaring will be blocked.`;
37
+ }
26
38
  export async function runHook(argv, input = {}) {
27
39
  const sub = argv[0] ?? "";
28
40
  switch (sub) {
29
41
  case "guard":
30
42
  return runGuard(input);
43
+ case "prompt":
44
+ return runPrompt(input);
31
45
  case "post-turn":
32
46
  return runPostTurn(input);
33
47
  case "pre-push":
34
48
  return runPrePush(input);
35
49
  default:
36
- throw new Error(`Unknown hook '${sub}'. Expected one of: guard, post-turn, pre-push.`);
50
+ throw new Error(`Unknown hook '${sub}'. Expected one of: guard, prompt, post-turn, pre-push.`);
37
51
  }
38
52
  }
39
53
  async function resolveRepo(input) {
@@ -63,6 +77,7 @@ export async function readHookPayload() {
63
77
  filePath: toolInput && typeof toolInput.file_path === "string"
64
78
  ? toolInput.file_path
65
79
  : undefined,
80
+ sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
66
81
  };
67
82
  }
68
83
  function safeJsonParse(raw) {
@@ -108,12 +123,36 @@ async function runGuard(input) {
108
123
  const cli = process.env.GATEBOLT_CLI ?? DEFAULT_CLI;
109
124
  process.stdout.write(denyPayload(declareInstructions(cli)));
110
125
  }
126
+ // Proactively remind the agent to declare before its first edit, so it doesn't
127
+ // discover strict mode by tripping the guard. Silent once a declaration is open.
128
+ async function runPrompt(input) {
129
+ if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
130
+ return;
131
+ const repo = await resolveRepo(input);
132
+ if (!repo || !(await hasCommits()))
133
+ return;
134
+ if (await readSession(repo.gitDir))
135
+ return;
136
+ process.stdout.write(promptReminder(process.env.GATEBOLT_CLI ?? DEFAULT_CLI));
137
+ }
111
138
  async function runPostTurn(input) {
112
139
  const repo = await resolveRepo(input);
113
140
  if (!repo)
114
141
  return;
115
- if (!(await readSession(repo.gitDir)))
116
- return;
142
+ if (await readSession(repo.gitDir))
143
+ return observeHere();
144
+ for (const { path, session } of await worktreeSessions()) {
145
+ if (session.sessionId === undefined) {
146
+ process.stderr.write(`GateBolt: ${path} has an open declaration from outside this session; leaving it open.\n`);
147
+ continue;
148
+ }
149
+ if (session.sessionId !== input.sessionId)
150
+ continue;
151
+ if (chdirTo(path))
152
+ await observeHere();
153
+ }
154
+ }
155
+ async function observeHere() {
117
156
  try {
118
157
  await runObserve([]);
119
158
  }
@@ -121,6 +160,29 @@ async function runPostTurn(input) {
121
160
  process.stderr.write(`${formatError(error)}\n`);
122
161
  }
123
162
  }
163
+ async function worktreeSessions() {
164
+ const found = [];
165
+ for (const path of await worktreePaths().catch(() => [])) {
166
+ if (!chdirTo(path))
167
+ continue;
168
+ const repo = await hookRepoContext();
169
+ const session = repo ? await readSession(repo.gitDir) : null;
170
+ if (session)
171
+ found.push({ path, session });
172
+ }
173
+ return found;
174
+ }
175
+ // `git worktree list` keeps reporting a worktree whose directory was deleted
176
+ // without a `git worktree prune`.
177
+ function chdirTo(path) {
178
+ try {
179
+ process.chdir(path);
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
124
186
  // Backstop for the interrupt case the Stop hook misses: an open, un-observed
125
187
  // declaration.
126
188
  async function runPrePush(input) {
package/dist/git.js CHANGED
@@ -34,6 +34,13 @@ export async function hooksPath() {
34
34
  const output = await git(["rev-parse", "--git-path", "hooks"]);
35
35
  return resolve(output.trim());
36
36
  }
37
+ export async function worktreePaths() {
38
+ const output = await git(["worktree", "list", "--porcelain"]);
39
+ return output
40
+ .split("\n")
41
+ .filter((line) => line.startsWith("worktree "))
42
+ .map((line) => line.replace(/^worktree /, "").trim());
43
+ }
37
44
  export async function isPathIgnored(path) {
38
45
  try {
39
46
  await git(["check-ignore", "--quiet", path]);
package/dist/session.js CHANGED
@@ -26,12 +26,13 @@ export function writeSession(gitDir, session) {
26
26
  return writeJson(gitDir, SESSION_FILE, session);
27
27
  }
28
28
  export function readSession(gitDir) {
29
- return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile }) => typeof changeId === "string" && typeof baseSha === "string"
29
+ return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile, sessionId }) => typeof changeId === "string" && typeof baseSha === "string"
30
30
  ? {
31
31
  changeId,
32
32
  baseSha,
33
33
  branch: typeof branch === "string" ? branch : undefined,
34
34
  profile: typeof profile === "string" ? profile : undefined,
35
+ sessionId: typeof sessionId === "string" ? sessionId : undefined,
35
36
  }
36
37
  : null);
37
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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
5
  "keywords": [
6
6
  "gatebolt",