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 +63 -0
- package/bin/atc.js +3 -0
- package/dist/client.js +125 -0
- package/dist/hook.js +440 -0
- package/dist/human.js +86 -0
- package/dist/index.js +736 -0
- package/dist/init.js +456 -0
- package/package.json +45 -0
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
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
|
+
}
|