thalixtower-cli 0.6.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,63 @@
1
+ # thalixtower-cli — `atc`
2
+
3
+ The Thalix Tower CLI. Coordinate multiple coding agents working one repo: claim
4
+ scope before editing, get inline conflict verdicts, share a decision log, and
5
+ receive your **standing orders** at every checkin. Also the terminal for humans —
6
+ `atc login` manages projects, tokens, and standing orders without the dashboard.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm i -g thalixtower-cli
12
+ ```
13
+
14
+ ## Wire a repo (fast path)
15
+
16
+ ```bash
17
+ atc init # scaffolds .mcp.json + the AGENTS.md compliance block + .gitignore
18
+ ```
19
+
20
+ ## Agent plane (frequency token)
21
+
22
+ Get a **frequency token** (dashboard https://tower.thalixinc.ai → project →
23
+ Mint token, or `atc tokens mint` after login), then:
24
+
25
+ ```bash
26
+ export ATC_TOKEN=atcf_… # required
27
+ # export ATC_API=… # optional; defaults to the hosted prod API
28
+ ```
29
+
30
+ ```bash
31
+ atc checkin --task "refactor auth" # contact the Tower; the brief arrives with
32
+ # your standing orders first — follow them
33
+ atc standing # your assigned standing orders, in full
34
+ atc brief # roster + claims + conflicts + NOTAMs
35
+ atc claim "src/auth/**" # request scope; exit code 3 if it conflicts
36
+ atc squawk "rewriting User model" # status + heartbeat
37
+ atc note "auth uses JWT, 15min TTL" # decision log (--pin to pin; note show <id> for full)
38
+ atc standing propose handoff --body "…" # propose a DRAFT standing order (a human assigns)
39
+ atc clear ["src/auth/**"] # release scope (all, or one glob)
40
+ atc checkout # leave; releases your claims
41
+ ```
42
+
43
+ ## Human plane (`atc login`)
44
+
45
+ ```bash
46
+ atc login # opens the browser; approve; 30-day session
47
+ atc whoami / atc logout
48
+
49
+ atc projects [create "<name>"]
50
+ atc tokens --project <p> # list (shows each token's alignment)
51
+ atc tokens mint --project <p> --label colby --orders relay-agent
52
+ atc tokens align <tokenId> --orders a,b | --default
53
+ atc standing ls|show|create|edit|rm|assign --project <p>
54
+ ```
55
+
56
+ **Standing orders** are a per-project library of named instructions. Which orders
57
+ an agent receives is decided by its token's **alignment** (`mint --orders`,
58
+ realign anytime — it lands at the agent's next brief); unaligned tokens get the
59
+ project default set (`atc standing assign`).
60
+
61
+ Agent session state lives in `.atc/` in the worktree (gitignore it); your login
62
+ session lives in `~/.config/atc/`. Add `--json` to any command for
63
+ machine-readable output. Full protocol: https://tower.thalixinc.ai/docs
package/bin/atc.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher → compiled CLI. Build with `npm run build` (tsc → dist/).
3
+ require('../dist/index.js');
package/dist/client.js ADDED
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadSession = exports.saveSession = exports.DEFAULT_API = void 0;
37
+ exports.loadConfig = loadConfig;
38
+ exports.workspaceId = workspaceId;
39
+ exports.clearSession = clearSession;
40
+ exports.api = api;
41
+ exports.currentBranch = currentBranch;
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const node_crypto_1 = require("node:crypto");
45
+ /**
46
+ * Thin client for the Thalix Tower /v1 API + local session state.
47
+ * Config is env-only (ATC_API / ATC_TOKEN). `.atc/` holds:
48
+ * workspace.json { workspaceId } — persistent takeover identity (ADR-0003)
49
+ * session.json { callsign, sessionToken, api } — lease-bound; cleared on checkout
50
+ * Both live in the worktree and must be gitignored.
51
+ */
52
+ const ATC_DIR = path.join(process.cwd(), '.atc');
53
+ const WORKSPACE_FILE = path.join(ATC_DIR, 'workspace.json');
54
+ const SESSION_FILE = path.join(ATC_DIR, 'session.json');
55
+ /** Prod Tower API. Override with ATC_API (e.g. the dev stack). */
56
+ exports.DEFAULT_API = 'https://api.tower.thalixinc.ai';
57
+ function loadConfig() {
58
+ return {
59
+ api: (process.env.ATC_API || exports.DEFAULT_API).replace(/\/$/, ''),
60
+ token: process.env.ATC_TOKEN ?? '',
61
+ };
62
+ }
63
+ function readJson(file) {
64
+ try {
65
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function writeJson(file, data) {
72
+ fs.mkdirSync(ATC_DIR, { recursive: true });
73
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
74
+ }
75
+ /** Stable per-checkout workspace id (created once, survives checkout). */
76
+ function workspaceId() {
77
+ const existing = readJson(WORKSPACE_FILE);
78
+ if (existing?.workspaceId)
79
+ return existing.workspaceId;
80
+ const id = `ws_${(0, node_crypto_1.randomUUID)()}`;
81
+ writeJson(WORKSPACE_FILE, { workspaceId: id });
82
+ return id;
83
+ }
84
+ const saveSession = (s) => writeJson(SESSION_FILE, s);
85
+ exports.saveSession = saveSession;
86
+ const loadSession = () => readJson(SESSION_FILE);
87
+ exports.loadSession = loadSession;
88
+ function clearSession() {
89
+ try {
90
+ fs.rmSync(SESSION_FILE);
91
+ }
92
+ catch {
93
+ /* already gone */
94
+ }
95
+ }
96
+ async function api(cfg, bearer, method, apiPath, body) {
97
+ const res = await fetch(`${cfg.api}${apiPath}`, {
98
+ method,
99
+ headers: {
100
+ authorization: `Bearer ${bearer}`,
101
+ 'content-type': 'application/json',
102
+ },
103
+ body: body ? JSON.stringify(body) : undefined,
104
+ });
105
+ const text = await res.text();
106
+ let parsed;
107
+ try {
108
+ parsed = text ? JSON.parse(text) : {};
109
+ }
110
+ catch {
111
+ parsed = { error: text };
112
+ }
113
+ return { status: res.status, ok: res.ok, body: parsed };
114
+ }
115
+ /** Best-effort current git branch for display. */
116
+ function currentBranch() {
117
+ try {
118
+ const head = fs.readFileSync(path.join(process.cwd(), '.git', 'HEAD'), 'utf8').trim();
119
+ const m = /ref:\s+refs\/heads\/(.+)/.exec(head);
120
+ return m ? m[1] : undefined;
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
package/dist/hook.js ADDED
@@ -0,0 +1,440 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.editPaths = editPaths;
37
+ exports.runHook = runHook;
38
+ exports.runAudit = runAudit;
39
+ exports.installHooks = installHooks;
40
+ /**
41
+ * atc hook — the Tier-3 compliance client (Rev-001 §A / Rev-002 Phase 3).
42
+ *
43
+ * Wired into Claude Code's hook system (`atc hook install`), it makes the
44
+ * protocol automatic: session-start injects the brief (standing orders first),
45
+ * prompt-submit heartbeats and surfaces traffic/conflicts, pre-edit auto-claims
46
+ * the file being touched and surfaces the inline conflict verdict.
47
+ *
48
+ * Non-negotiables (ADR-0004): FAIL-OPEN always (the Tower must never brick a
49
+ * session — any failure exits 0 with one stderr warning per session); NEVER
50
+ * blocks an edit (no permissionDecision is ever emitted); at most one network
51
+ * call per path per session (local cache in .atc/hook-state.json).
52
+ */
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ const client_1 = require("./client");
56
+ const PRE_EDIT_TIMEOUT_MS = Number(process.env.ATC_HOOK_TIMEOUT_MS || 300);
57
+ const SESSION_START_TIMEOUT_MS = 2500; // checkin+brief may ride a cold start
58
+ const HEARTBEAT_TIMEOUT_MS = 800;
59
+ const HEARTBEAT_MIN_MS = 60_000; // prompt-submit fires every turn; squawk at most this often
60
+ const COALESCE_THRESHOLD = 20; // >N file claims under one top dir → one dir/** claim
61
+ const ATTEMPTED_RETRIES_PER_CALL = 2;
62
+ const STATE_FILE = path.join(process.cwd(), '.atc', 'hook-state.json');
63
+ function loadState() {
64
+ try {
65
+ const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
66
+ return { claimed: {}, edited: [], coalesced: [], ...s };
67
+ }
68
+ catch {
69
+ return { claimed: {}, edited: [], coalesced: [] };
70
+ }
71
+ }
72
+ function saveState(state) {
73
+ try {
74
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
75
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
76
+ }
77
+ catch {
78
+ /* fail-open */
79
+ }
80
+ }
81
+ function warnOnce(state, msg) {
82
+ if (state.towerWarned)
83
+ return;
84
+ state.towerWarned = true;
85
+ process.stderr.write(`atc hook: ${msg} — coordination disabled for this session (edits unaffected)\n`);
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Fail-open HTTP with a hard client budget
89
+ // ---------------------------------------------------------------------------
90
+ async function timedApi(cfg, bearer, method, apiPath, body, timeoutMs) {
91
+ try {
92
+ const res = await fetch(`${cfg.api}${apiPath}`, {
93
+ method,
94
+ headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' },
95
+ body: body ? JSON.stringify(body) : undefined,
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ });
98
+ const text = await res.text();
99
+ let parsed = {};
100
+ try {
101
+ parsed = text ? JSON.parse(text) : {};
102
+ }
103
+ catch {
104
+ parsed = {};
105
+ }
106
+ return { ok: res.ok, status: res.status, body: parsed };
107
+ }
108
+ catch {
109
+ return null; // timeout / network — caller fails open
110
+ }
111
+ }
112
+ /**
113
+ * One session per workspace: reuse .atc/session.json; checkin only when it is
114
+ * missing or rejected (then write back for the CLI/MCP to pick up).
115
+ */
116
+ async function ensureSession(cfg, timeoutMs) {
117
+ const existing = (0, client_1.loadSession)();
118
+ if (existing)
119
+ return existing;
120
+ if (!cfg.token)
121
+ return null;
122
+ const r = await timedApi(cfg, cfg.token, 'POST', '/v1/sessions', {
123
+ workspaceId: (0, client_1.workspaceId)(),
124
+ cli: process.env.ATC_CLI || 'claude-code',
125
+ worktree: process.cwd(),
126
+ branch: (0, client_1.currentBranch)(),
127
+ task: 'hooked session',
128
+ }, timeoutMs);
129
+ if (!r?.ok)
130
+ return null;
131
+ const session = { callsign: r.body.callsign, sessionToken: r.body.sessionToken, api: cfg.api };
132
+ (0, client_1.saveSession)(session);
133
+ return session;
134
+ }
135
+ /** Session-token call with one re-checkin retry on 401 (token may have rotated). */
136
+ async function sessionApi(cfg, method, apiPath, body, timeoutMs) {
137
+ const session = await ensureSession(cfg, timeoutMs);
138
+ if (!session)
139
+ return null;
140
+ let r = await timedApi(cfg, session.sessionToken, method, apiPath, body, timeoutMs);
141
+ if (r && r.status === 401 && cfg.token) {
142
+ // Stale session.json (another client took over and checked out) → re-checkin once.
143
+ try {
144
+ fs.rmSync(path.join(process.cwd(), '.atc', 'session.json'));
145
+ }
146
+ catch { /* ignore */ }
147
+ const fresh = await ensureSession(cfg, timeoutMs);
148
+ if (!fresh)
149
+ return r;
150
+ r = await timedApi(cfg, fresh.sessionToken, method, apiPath, body, timeoutMs);
151
+ }
152
+ return r;
153
+ }
154
+ function readPayload() {
155
+ try {
156
+ if (process.stdin.isTTY)
157
+ return {};
158
+ const raw = fs.readFileSync(0, 'utf8');
159
+ return raw ? JSON.parse(raw) : {};
160
+ }
161
+ catch {
162
+ return {};
163
+ }
164
+ }
165
+ function emitContext(hookEventName, text) {
166
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: text } }) + '\n');
167
+ }
168
+ /** Files an edit tool is about to touch, relative to the workspace. */
169
+ function editPaths(toolName, toolInput, cwd) {
170
+ if (!toolInput)
171
+ return [];
172
+ const raw = [];
173
+ if (typeof toolInput.file_path === 'string')
174
+ raw.push(toolInput.file_path);
175
+ if (typeof toolInput.notebook_path === 'string')
176
+ raw.push(toolInput.notebook_path);
177
+ if (Array.isArray(toolInput.edits)) {
178
+ for (const e of toolInput.edits) {
179
+ if (e && typeof e.file_path === 'string')
180
+ raw.push(e.file_path);
181
+ }
182
+ }
183
+ void toolName;
184
+ const out = [];
185
+ for (const p of raw) {
186
+ const rel = path.isAbsolute(p) ? path.relative(cwd, p) : p;
187
+ // Outside the workspace (or weird) → not repo scope, don't claim it.
188
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
189
+ continue;
190
+ const norm = rel.split(path.sep).join('/');
191
+ if (!out.includes(norm))
192
+ out.push(norm);
193
+ }
194
+ return out;
195
+ }
196
+ function renderVerdict(relPath, conflicts) {
197
+ const lines = conflicts
198
+ .map((c) => `${c.by} holds ${c.theirGlob} (${c.status}, seen ${c.lastSeen})`)
199
+ .join('; ');
200
+ return (`⚠ Thalix Tower conflict on ${relPath}: ${lines}. ` +
201
+ `The claim is advisory — you may proceed, but coordinate: squawk blocked and surface this to the human if the overlap is real.`);
202
+ }
203
+ function renderBrief(b) {
204
+ const parts = ['[Thalix Tower brief]'];
205
+ if (b.standing?.body) {
206
+ parts.push(`STANDING ORDERS (${(b.standing.orders ?? []).map((o) => o.name).join(', ')}) — follow these:\n${b.standing.body}`);
207
+ }
208
+ const others = (b.roster ?? []).filter((r) => r.callsign !== b.you?.callsign);
209
+ if (others.length) {
210
+ parts.push('On frequency: ' +
211
+ others.map((r) => `${r.callsign} (${r.code ?? r.statusText ?? 'working'}, ${r.claimCount} claims)`).join(', '));
212
+ }
213
+ if ((b.conflictsWithMine ?? []).length) {
214
+ parts.push('CONFLICTS touching you: ' + b.conflictsWithMine.map((c) => `${c.by}:${c.theirGlob} vs ${c.glob}`).join('; '));
215
+ }
216
+ if (b.traffic?.unacked > 0) {
217
+ parts.push(`✉ ${b.traffic.unacked} unacked message(s): ` +
218
+ (b.traffic.preview ?? []).map((m) => `${m.from}: "${m.body}"`).join(' · ') +
219
+ ' — read with atc_traffic and answer with atc_call.');
220
+ }
221
+ if ((b.notams ?? []).length) {
222
+ parts.push('Recent NOTAMs: ' + b.notams.slice(0, 5).map((n) => `[${n.id}] ${n.body.slice(0, 80)}`).join(' · '));
223
+ }
224
+ parts.push(`(You are ${b.you?.callsign}. Claims are auto-derived from your edits by the Tower hook.)`);
225
+ return parts.join('\n\n');
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // Events
229
+ // ---------------------------------------------------------------------------
230
+ async function runSessionStart(payload) {
231
+ const cfg = (0, client_1.loadConfig)();
232
+ if (!cfg.token && !(0, client_1.loadSession)())
233
+ return; // not a Tower-wired repo
234
+ const state = loadState();
235
+ // New host session → fresh per-session caches (claims on the board persist).
236
+ if (payload.session_id && payload.session_id !== state.sessionId) {
237
+ state.sessionId = payload.session_id;
238
+ state.claimed = {};
239
+ state.edited = [];
240
+ state.coalesced = [];
241
+ state.towerWarned = false;
242
+ }
243
+ const r = await sessionApi(cfg, 'GET', '/v1/brief', undefined, SESSION_START_TIMEOUT_MS);
244
+ if (!r?.ok) {
245
+ warnOnce(state, 'Tower unreachable at session start');
246
+ saveState(state);
247
+ return;
248
+ }
249
+ state.lastHeartbeat = Date.now();
250
+ saveState(state);
251
+ emitContext('SessionStart', renderBrief(r.body));
252
+ }
253
+ async function runPrompt() {
254
+ const cfg = (0, client_1.loadConfig)();
255
+ if (!cfg.token && !(0, client_1.loadSession)())
256
+ return;
257
+ const state = loadState();
258
+ if (state.lastHeartbeat && Date.now() - state.lastHeartbeat < HEARTBEAT_MIN_MS)
259
+ return;
260
+ state.lastHeartbeat = Date.now(); // even on failure — don't hammer a down Tower
261
+ const r = await sessionApi(cfg, 'POST', '/v1/squawk', {}, HEARTBEAT_TIMEOUT_MS);
262
+ saveState(state);
263
+ if (!r?.ok)
264
+ return; // heartbeat is best-effort; no warning needed here
265
+ const parts = [];
266
+ if ((r.body.conflictsWithMine ?? []).length) {
267
+ parts.push('⚠ Thalix Tower: conflicts on your claims — ' +
268
+ r.body.conflictsWithMine.map((c) => `${c.by}:${c.theirGlob} vs ${c.glob}`).join('; '));
269
+ }
270
+ if (r.body.unackedTraffic > 0) {
271
+ parts.push(`✉ Thalix Tower: ${r.body.unackedTraffic} unacked message(s) — ` +
272
+ (r.body.trafficPreview ?? []).map((m) => `${m.from}: "${m.body}"`).join(' · ') +
273
+ ' — read with atc_traffic and answer with atc_call.');
274
+ }
275
+ if (parts.length)
276
+ emitContext('UserPromptSubmit', parts.join('\n'));
277
+ }
278
+ /** Top-level dir → claimed-file count, for coalescing. */
279
+ function coalesceCandidate(state) {
280
+ const counts = new Map();
281
+ for (const p of Object.keys(state.claimed)) {
282
+ if (state.claimed[p] !== 'claimed' || !p.includes('/'))
283
+ continue;
284
+ const top = p.split('/')[0];
285
+ counts.set(top, (counts.get(top) ?? 0) + 1);
286
+ }
287
+ for (const [dir, n] of counts) {
288
+ if (n > COALESCE_THRESHOLD && !state.coalesced.includes(dir))
289
+ return dir;
290
+ }
291
+ return null;
292
+ }
293
+ async function runPreEdit(payload) {
294
+ const cfg = (0, client_1.loadConfig)();
295
+ if (!cfg.token && !(0, client_1.loadSession)())
296
+ return;
297
+ const cwd = payload.cwd || process.cwd();
298
+ const paths = editPaths(payload.tool_name, payload.tool_input, cwd);
299
+ if (paths.length === 0)
300
+ return;
301
+ const state = loadState();
302
+ for (const p of paths)
303
+ if (!state.edited.includes(p))
304
+ state.edited.push(p);
305
+ // Already-claimed (or coalesced-covered) paths cost nothing.
306
+ const covered = (p) => state.claimed[p] === 'claimed' || state.coalesced.some((d) => p.startsWith(`${d}/`));
307
+ const toClaim = paths.filter((p) => !covered(p));
308
+ if (toClaim.length === 0) {
309
+ saveState(state);
310
+ return;
311
+ }
312
+ // Piggyback: retry previously timed-out paths ONCE; a second failure drops
313
+ // them for the session (ADR-0004 — bounded retries, no hammering).
314
+ const attempted = Object.keys(state.claimed)
315
+ .filter((p) => state.claimed[p] === 'attempted')
316
+ .slice(0, ATTEMPTED_RETRIES_PER_CALL);
317
+ const verdicts = [];
318
+ await Promise.all([...toClaim, ...attempted].map(async (p) => {
319
+ const isRetry = attempted.includes(p);
320
+ const r = await sessionApi(cfg, 'POST', '/v1/claims', { glob: p, kind: 'file' }, PRE_EDIT_TIMEOUT_MS);
321
+ if (!r) {
322
+ state.claimed[p] = isRetry ? 'dropped' : 'attempted';
323
+ warnOnce(state, 'claim timed out');
324
+ return;
325
+ }
326
+ if (!r.ok) {
327
+ warnOnce(state, `claim rejected (${r.status})`);
328
+ return;
329
+ }
330
+ state.claimed[p] = 'claimed';
331
+ if ((r.body.conflicts ?? []).length && toClaim.includes(p)) {
332
+ verdicts.push(renderVerdict(p, r.body.conflicts));
333
+ }
334
+ }));
335
+ // Coalesce when one directory accumulates too many file claims.
336
+ const dir = coalesceCandidate(state);
337
+ if (dir) {
338
+ const r = await sessionApi(cfg, 'POST', '/v1/claims', { glob: `${dir}/**`, kind: 'glob' }, PRE_EDIT_TIMEOUT_MS);
339
+ if (r?.ok)
340
+ state.coalesced.push(dir);
341
+ }
342
+ saveState(state);
343
+ if (verdicts.length)
344
+ emitContext('PreToolUse', verdicts.join('\n'));
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Entry points
348
+ // ---------------------------------------------------------------------------
349
+ /** `atc hook run <event>` — ALWAYS exits 0; the Tower never breaks a session. */
350
+ async function runHook(event) {
351
+ try {
352
+ const payload = readPayload();
353
+ if (event === 'session-start')
354
+ await runSessionStart(payload);
355
+ else if (event === 'prompt')
356
+ await runPrompt();
357
+ else if (event === 'pre-edit')
358
+ await runPreEdit(payload);
359
+ // unknown events: silently no-op (forward compat)
360
+ }
361
+ catch {
362
+ /* fail-open — never disturb the session */
363
+ }
364
+ process.exit(0);
365
+ }
366
+ /** `atc audit` — compliance rate from the hook's own per-session record. */
367
+ function runAudit(json) {
368
+ const state = loadState();
369
+ const edited = state.edited;
370
+ const covered = edited.filter((p) => state.claimed[p] === 'claimed' || state.coalesced.some((d) => p.startsWith(`${d}/`)));
371
+ const rate = edited.length === 0 ? 1 : covered.length / edited.length;
372
+ const data = {
373
+ edited: edited.length,
374
+ claimed: covered.length,
375
+ attempted: Object.values(state.claimed).filter((v) => v === 'attempted' || v === 'dropped').length,
376
+ complianceRate: Math.round(rate * 100) / 100,
377
+ };
378
+ if (json) {
379
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
380
+ }
381
+ else {
382
+ process.stdout.write(edited.length === 0
383
+ ? 'no edits recorded by the hook this session\n'
384
+ : `compliance: ${covered.length}/${edited.length} edited paths claimed before edit (${Math.round(rate * 100)}%)` +
385
+ (data.attempted ? ` · ${data.attempted} attempted (Tower slow/down)` : '') +
386
+ '\n');
387
+ }
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // `atc hook install` — wire .claude/settings.json (idempotent, plan + confirm)
391
+ // ---------------------------------------------------------------------------
392
+ const HOOK_ENTRIES = {
393
+ SessionStart: { command: 'atc hook run session-start', timeout: 10 },
394
+ UserPromptSubmit: { command: 'atc hook run prompt', timeout: 5 },
395
+ PreToolUse: { matcher: 'Edit|Write|MultiEdit|NotebookEdit', command: 'atc hook run pre-edit', timeout: 5 },
396
+ };
397
+ function installHooks(opts) {
398
+ const file = path.join(process.cwd(), '.claude', 'settings.json');
399
+ let settings = {};
400
+ try {
401
+ settings = JSON.parse(fs.readFileSync(file, 'utf8'));
402
+ }
403
+ catch {
404
+ /* fresh file */
405
+ }
406
+ const hooks = settings.hooks ?? {};
407
+ const additions = [];
408
+ for (const [event, spec] of Object.entries(HOOK_ENTRIES)) {
409
+ const groups = hooks[event] ?? [];
410
+ const present = groups.some((g) => (g.hooks ?? []).some((h) => typeof h.command === 'string' && h.command.includes('atc hook run')));
411
+ if (present)
412
+ continue;
413
+ const entry = { hooks: [{ type: 'command', command: spec.command, timeout: spec.timeout }] };
414
+ if (spec.matcher)
415
+ entry.matcher = spec.matcher;
416
+ groups.push(entry);
417
+ hooks[event] = groups;
418
+ additions.push(`${event}${spec.matcher ? ` (${spec.matcher})` : ''} → ${spec.command}`);
419
+ }
420
+ if (additions.length === 0) {
421
+ if (!opts.json)
422
+ process.stdout.write('hooks already installed — nothing to do\n');
423
+ return { changed: false };
424
+ }
425
+ if (!opts.json) {
426
+ process.stdout.write('atc hook install — will add to .claude/settings.json:\n');
427
+ for (const a of additions)
428
+ process.stdout.write(` + ${a}\n`);
429
+ process.stdout.write('\nThe hook fails open (Tower down → edits unaffected) and never blocks a tool call.\n' +
430
+ 'Claude Code will ask you to approve these project hooks on first run.\n');
431
+ }
432
+ settings.hooks = hooks;
433
+ fs.mkdirSync(path.dirname(file), { recursive: true });
434
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
435
+ if (!opts.json)
436
+ process.stdout.write(`\nwrote .claude/settings.json\n`);
437
+ else
438
+ process.stdout.write(JSON.stringify({ added: additions }, null, 2) + '\n');
439
+ return { changed: true };
440
+ }