@coulb/crux-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Coulbourne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,405 @@
1
+ # crux
2
+
3
+ **Agent orchestration for one operator (with a life).**
4
+
5
+ Crux runs a **standing lead** on every project you give it — a durable agent that parks on the
6
+ project's stream, picks up each thread as it arrives, answers what it can, spawns a headless worker
7
+ for the code, and reaches your phone only when the call is actually yours.
8
+
9
+ This is the CLI. It is the interface: every verb below is one an agent (or you) drives crux with.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g @coulb/crux-cli
15
+ ```
16
+
17
+ Node 18+. Zero dependencies. The installed command is `crux`.
18
+
19
+ ## Config
20
+
21
+ Point the CLI at your Crux API and give it a token. Resolution order:
22
+
23
+ 1. env `CRUX_API_URL` / `CRUX_API_TOKEN`
24
+ 2. `~/.config/crux/config.json`:
25
+
26
+ ```json
27
+ { "url": "https://crux.coulb.com", "token": "…" }
28
+ ```
29
+
30
+ The token is your API bearer token — never commit it.
31
+
32
+ ## First run — three commands
33
+
34
+ ```bash
35
+ cd ~/src/acme
36
+ crux discover # this repo is now a crux project
37
+ crux swarm up # a standing lead is now working it
38
+ crux push "the importer keeps timing out" # give it something to do
39
+ ```
40
+
41
+ That is the whole loop: a project, a lead on it, and work going in. Everything below is detail.
42
+
43
+ ## `crux discover` — step zero
44
+
45
+ A repo becomes a crux project. Run this before anything else on this page:
46
+
47
+ ```bash
48
+ cd ~/src/acme
49
+ crux discover # Enter, Enter, Enter
50
+ crux swarm up # a standing lead is now working it
51
+ crux push "…" # give it something to do
52
+ ```
53
+
54
+ ```console
55
+ $ crux discover
56
+ Found ~/src/acme — a Laravel app.
57
+
58
+ slug [acme]
59
+ name [Acme]
60
+ color (emerald violet sky amber rose pink zinc) [emerald]
61
+ keywords [acme,laravel,php]
62
+ repo [/Users/you/src/acme]
63
+
64
+ ✓ acme is a crux project — created.
65
+ repo ~/src/acme
66
+ config ~/.config/crux/leads.json — swarm.projects.acme.repo added
67
+ lead not started
68
+
69
+ Next: crux swarm up --project acme
70
+ ```
71
+
72
+ **It does not ask what it can read.** The repo path comes from git (a subdirectory still finds the
73
+ root). The name comes from the `origin` remote, then `package.json` / `composer.json`, then the
74
+ directory. The framework comes from the manifests — a lead that knows it is standing in a Laravel app
75
+ writes better briefs, and "laravel" becomes a routing keyword. The **keywords** are what route a
76
+ captured item to this project, so they are the line worth reading before you press Enter.
77
+
78
+ It writes **both** places, and needs both: the project (`POST /api/projects` — so it exists in the
79
+ feed, the app and the web) and `swarm.projects.<slug>.repo` in the leads config, so that `crux swarm
80
+ up` knows which repo the lead runs in. A project with no repo cannot be led. If the config is not
81
+ writable it says so and creates neither.
82
+
83
+ **Idempotent.** Run it twice and you get one project and one config entry. A second run reports
84
+ `unchanged` — and means it; `created` and `updated` name what actually changed. A slug already taken
85
+ by a different repo is refused, and so is a repo that is already another project (one directory with
86
+ two leads is two agents racing one codebase). Every refusal names its fix.
87
+
88
+ ```bash
89
+ crux discover ~/src/acme --yes # every default, no questions
90
+ crux discover ~/src/acme --slug orbit # register it under a slug of your choosing
91
+ crux discover ~/src/acme --json # machine output (implies --yes)
92
+ ```
93
+
94
+ ## Usage
95
+
96
+ ```bash
97
+ crux push "The acme importer keeps timing out — bump the worker timeout?" # auto-routes
98
+ crux push --project crux --source agent "CLI smoke test from lead agent" # explicit project
99
+ crux activity --project crux --actor lead "Shipped items API + CLI"
100
+ echo '{ "source":"github", "title":"…", "format":"quick" }' | crux decide
101
+ crux feed --project crux
102
+ crux projects
103
+ ```
104
+
105
+ `push` returns the item id and the project it routed to. When `--project` is omitted the API
106
+ routes by keyword (or Haiku, if `ANTHROPIC_API_KEY` is set on the server).
107
+
108
+ ## Draining the unrouted queue
109
+
110
+ ```bash
111
+ crux next --claim --actor god-agent || exit 0 # exits 3 when the queue is empty
112
+ crux route <id> --project <slug> [--note "why"] # reassign; re-enters the routed flow
113
+ crux done <id> [--note "why"] [--drop] # close it (--drop = dropped, not done)
114
+ ```
115
+
116
+ `--claim` takes an exclusive lease first, so two overlapping pollers never both act on the
117
+ same item. `--actor` is recorded on the claim and on every activity the verbs log.
118
+
119
+ ## `--title` and `--status` — the row a thread shows up as
120
+
121
+ A thread renders as a **row**: a short **title** on the main line, and under it, smaller and grey, a
122
+ **status line** — one phrase saying what is happening to it *right now*.
123
+
124
+ **The lead sets both. Nothing generates them.** There is no model on the write path deriving a title
125
+ from the captured text, and there is not going to be one: the lead is already an LLM, it has already
126
+ read the thread, and it already knows what it is doing to it. Re-deriving that server-side would pay
127
+ twice for worse information — and a *derived* status line goes stale the instant the lead does the
128
+ next thing, which a set one cannot, because the lead sets it **as it works**.
129
+
130
+ So there is no "update the row" call. `--status` rides the request the lead was already making:
131
+
132
+ ```bash
133
+ crux accept 214 --title "auth timeout" --status "reading the failing test" --actor lead-orbit
134
+ crux activity --item 214 --project orbit --type note "found it — the retry has no ceiling" \
135
+ --status "patching the retry loop"
136
+ crux spawn 214 --project-id 7 --title "auth timeout" --status "worker on it" --brief "…"
137
+ crux need --item 214 --kind decide --status "blocked on the retry policy" --option … --option …
138
+ crux done 214 --note "shipped in #91" --status "merged, closed"
139
+ ```
140
+
141
+ | flag | on | is |
142
+ |---|---|---|
143
+ | `--title "<2-3 words>"` | `accept`, `spawn` | the thread's **name** — the row's main line. Set it once, when you pick the thread up. It is a name, not a summary; the row clips it to one line. |
144
+ | `--status "<phrase>"` | `accept`, `activity`, `need`, `spawn`, `done` | what is happening **now**. Carried on whatever you were already sending. |
145
+
146
+ Two rules worth knowing:
147
+
148
+ - **`--status` on `activity` needs `--item`.** A status line belongs to a *thread*, and an activity
149
+ without `--item` is filed against the project as a whole — so there is nothing to set it on. That is
150
+ refused (422) rather than silently dropped, because it is exactly the shape of a lead that forgot
151
+ `--item`, and a quietly discarded status line would leave it thinking the row had updated.
152
+ - **`crux done` always rewrites the status line.** A closed thread must not sit there still announcing
153
+ work that stopped hours ago — nothing will ever correct it, since a closed thread gets no more
154
+ writes. Pass a **resting** phrase (`--status "merged, closed"`); omit it and the close writes `done`
155
+ (or `dropped`) itself. Note this `--status` is the status *line*, not the disposition — `--drop` is
156
+ what makes it dropped.
157
+
158
+ **Untitled threads render exactly as they always have.** Every thread that predates this feature has
159
+ no title, and no thread has one before a lead accepts it — those rows go on showing the captured text,
160
+ and a thread with no status line shows no second line at all (never a blank grey strip). A lead that
161
+ never passes `--title` is not broken, it is just leaving its rows unnamed.
162
+
163
+ ## `crux spawn` — a worker that reports and dies
164
+
165
+ ```bash
166
+ crux spawn <id> --project-id <n> --brief "<the task>" # headless: reports, then exits
167
+ crux spawn <id> --project-id <n> --live # interactive + Remote Control
168
+ ```
169
+
170
+ Headless is the default. The worker runs under `claude -p`, so **its final message IS its report** —
171
+ `crux supervise` (started for you, detached) waits for it to exit and posts that message to the
172
+ thread. The agent does not choose to report and cannot forget to.
173
+
174
+ **A run ends in exactly one of three ways** — full contract in [the worker contract](https://crux.coulb.com/docs/worker-contract):
175
+
176
+ | Ending | Thread gets | Pushes to you? |
177
+ |---|---|---|
178
+ | **reported** | the agent's final message, verbatim | no |
179
+ | **died** | **Worker failed — …** | **yes** |
180
+ | **cancelled by lead** | **Worker cancelled by … Not a crash.** | no |
181
+
182
+ **Silence is the bug.** A worker whose process ends having printed nothing gets a loud failure note
183
+ and a phone push — otherwise the thread waits on a dead agent forever.
184
+
185
+ ## `crux cancel` — stop a worker on purpose
186
+
187
+ ```bash
188
+ crux cancel <id> --note "the approach changed" --actor lead-crux
189
+ ```
190
+
191
+ Use this — **never `solo processes stop`** — when you need to stop a worker mid-flight (the brief
192
+ turned out to be wrong, the approach changed). The supervisor cannot see intent, only a process that
193
+ ended having printed nothing, *which is exactly what a crash looks like*. Killing a worker by hand
194
+ therefore posts "Worker failed … it crashed, was killed, or never started" and pushes it to your
195
+ phone, for a run in which nothing failed.
196
+
197
+ `crux cancel` writes a **tombstone** — keyed by item id **and** Solo process id — *before* it stops
198
+ the process, so the supervisor reports a cancellation rather than a death, silently. The thread stays
199
+ **open**: re-brief and `crux spawn` again. (`crux done` is the other thing — the thread closes and
200
+ its worker dies with it.)
201
+
202
+ A worker killed with no tombstone is still reported as a crash. That is deliberate: an unclaimed
203
+ silence is the one thing the supervisor exists to catch.
204
+
205
+ ## `crux tail` — push-style events
206
+
207
+ A long-running watcher. It prints **one flushed single-line JSON record per new event**,
208
+ oldest first, and **nothing at all** otherwise — silence always means "no new input". Arm it
209
+ under Claude Code's Monitor and an agent is woken by real Crux input instead of burning
210
+ tokens on an agentic poll.
211
+
212
+ ```bash
213
+ crux tail --unrouted # triage: everything the router couldn't place
214
+ crux tail --project crux # a project lead: its own feed
215
+ crux tail --topics items,activities --project crux --interval 30
216
+ ```
217
+
218
+ ```json
219
+ {"topic":"item.created","id":8,"project":null,"summary":"the faucet is dripping again","ts":"2026-07-09T01:31:05+00:00","cursor":9,"status":"unmatched","source":"cli"}
220
+ ```
221
+
222
+ The record is a **pointer, not a payload** — fetch details with `crux feed --json` or the API.
223
+ Items carry `status` and `source`; activities carry `actor` and `type`, so a consumer can skip
224
+ the events it caused itself rather than wake on its own echo.
225
+
226
+ - **Durable cursor.** Position is saved to `~/.config/crux/tail-cursor.json`, keyed by
227
+ (server, topics, project, status), so a restart resumes exactly where it left off and two
228
+ tails never fight. Every read-modify-write of that shared file is serialised under a lock, so
229
+ concurrent tails cannot lose each other's advances. Written after the lines are flushed, so
230
+ delivery is **at-least-once** — a consumer that acts on events must be idempotent.
231
+ - **First run starts from now.** No history is replayed. Pass `--since <timestamp>` to start
232
+ from an instant instead.
233
+ - **Errors never reach stdout.** Transient API failures are retried with backoff and logged to
234
+ stderr; a 4xx (bad cursor, revoked token) exits rather than spinning.
235
+ - **`--interval` defaults to 60s.** See the hibernation note in `STATUS-cli.md`.
236
+ - **Every poll announces the listener.** `--actor <name>` (default `<user>@<host>`) and the poll
237
+ interval ride along as headers, and the dashboard turns them into a per-project status dot.
238
+ The name is **self-asserted and advisory, not a credential** — it is a health signal, nothing
239
+ is authorised on it. Set it to the lead's name in `solo.yml`: `--actor lead-mercury`.
240
+
241
+ ## `crux leads` — is the fleet actually alive?
242
+
243
+ A dead lead and an idle one both produce silence. This tells them apart: it walks every Solo
244
+ project and reports whether a live lead is tailing its crux stream.
245
+
246
+ ```bash
247
+ crux leads # report; exits 1 if anything is uncovered
248
+ crux leads --json # machine-readable, for a health check
249
+ crux leads --fix # start the solo.yml command for each uncovered project
250
+ crux leads --fix --dry-run
251
+ ```
252
+
253
+ Coverage is read off the running `crux tail --project <slug>` command line, found by walking the
254
+ process tree beneath each Solo process — **never** off a process name. Names lie: the stopped
255
+ `kind: agent` rows are named exactly like the new `kind: command` ones.
256
+
257
+ It reports three failure classes, not just the happy path:
258
+
259
+ | state | meaning |
260
+ |---|---|
261
+ | `uncovered` | a Solo project with no lead tailing it |
262
+ | `orphan` | a lead tailing a slug that has no Solo project |
263
+ | `duplicate` | two leads on one slug — the stream is double-processed |
264
+ | `unmapped` | a Solo project missing from the name→slug map, never silently skipped |
265
+ | `unmanaged` | a crux slug with no Solo project and no lead (registry drift; a warning) |
266
+ | `leadless` | configured to expect no lead, e.g. `personal`. Expected, not an error. |
267
+
268
+ `--fix` only ever starts leads for `uncovered` projects, and **refuses** to start a second tail on
269
+ a slug that is already covered.
270
+
271
+ The Solo-name→crux-slug mapping is explicit config, not string munging (`acme-search-index` is
272
+ `acme`; `status-page` is `beacon` — no transformation derives either one, which is why the map is
273
+ explicit and an unmapped project is reported rather than guessed at). It lives in
274
+ `~/.config/crux/leads.json`, or wherever `CRUX_LEADS_CONFIG` points.
275
+
276
+ ## `crux swarm` — go from "I have crux projects" to "I have leads working them"
277
+
278
+ > **The full guide is [Running a swarm](https://crux.coulb.com/docs/swarm)** — what a swarm is, getting one up, the
279
+ > backends, the config, and the traps. What follows is the CLI-level summary.
280
+
281
+ `crux leads` tells you whether a fleet is alive. `crux swarm` **creates one**, on whatever
282
+ orchestrator you already run.
283
+
284
+ ```bash
285
+ crux swarm up --dry-run # print the plan; start nothing
286
+ crux swarm up # start a lead for every project that has none
287
+ crux swarm status # what's covered, what isn't (exits 1 if anything is uncovered)
288
+ crux swarm down # stop the leads crux started — and only those
289
+ ```
290
+
291
+ A **lead** is a durable agent that parks on `crux tail`, acks each thread the moment it arrives,
292
+ answers what it can as chat, spawns workers for real work, escalates with `crux need`, and never
293
+ exits. `swarm up` writes the brief that says all of that (`~/.config/crux/leads/<slug>.md` — read
294
+ it, edit it, it's yours) and starts one lead per uncovered project.
295
+
296
+ That brief is the actual product. It used to be folklore: hand-written files in a private
297
+ directory, encoding the whole operating contract, that nobody but their author could reproduce.
298
+ Now crux generates it, from your project registry and your config — no private brief directory, no
299
+ baked-in paths, nothing of anyone else's machine in it.
300
+
301
+ ### Backends — bring your own orchestration
302
+
303
+ | backend | what it does |
304
+ |---|---|
305
+ | `solo` | **the default.** Spawns a [Solo](https://soloterm.com) agent per lead, *creating the Solo project from your repo path if it doesn't exist*. No declaration needed — this is what `crux leads --fix` cannot do. |
306
+ | `exec` | **the escape hatch.** Runs any command you give it. This is what makes "bring your own orchestration" true rather than a slogan. |
307
+
308
+ **Solo is the one we run.** [Solo](https://soloterm.com) is a terminal workspace for the processes
309
+ and agents you already keep running, and on it the entire swarm config is:
310
+
311
+ ```json
312
+ "swarm": { "backend": "solo" }
313
+ ```
314
+
315
+ No repo paths, no declarations. Solo already records a path per project, so `crux swarm up` works
316
+ with nothing configured at all — and when the Solo project doesn't exist yet, the backend creates it
317
+ from your repo path rather than refusing. Each lead lands as a named agent process (`lead-acme`) you
318
+ can open, read, and talk to.
319
+
320
+ `exec` takes a command template and fills in the blanks:
321
+
322
+ ```json
323
+ "swarm": {
324
+ "backend": "exec",
325
+ "exec": {
326
+ "command": "tmux new-session -d -s {name} '{command}'",
327
+ "stop": "tmux kill-session -t {name}"
328
+ },
329
+ "projects": { "crux": { "repo": "~/src/crux", "brief": "extra context for this lead" } }
330
+ }
331
+ ```
332
+
333
+ Placeholders: `{slug}` `{name}` `{repo}` `{brief_path}` `{command}`. An unknown one is **refused**,
334
+ never expanded to nothing — a swarm that silently half-starts is worse than one that won't start.
335
+
336
+ **Set `stop` whenever your command forks** (`tmux new-session -d`, `nohup … &`). Those exit
337
+ immediately, so the pid crux recorded is a dead handle to a live lead. With `stop`, crux addresses
338
+ the lead by name and can still kill it; without it, `crux swarm down` tells you it could not, rather
339
+ than reporting a lead it never stopped as stopped.
340
+
341
+ ### It is idempotent, and that is the whole design
342
+
343
+ Run `swarm up` twice and the second run starts nothing. Two tails on one stream race the shared
344
+ cursor file and deliver every item **twice**, so "start a lead" has to mean "make sure exactly one
345
+ lead is running" — a reconciliation, not a spawn.
346
+
347
+ Coverage is read off the **OS process table** (who is really running `crux tail --project <slug>`),
348
+ never off a backend's opinion — Solo's own status lies, and tmux has no idea what a crux slug is.
349
+ That's why `exec` gets working reconciliation for free. Plus one thing the process table can't see:
350
+ a lead crux started whose tail hasn't come up *yet*. Without that, a second `up` inside the boot
351
+ window would double-tail the stream.
352
+
353
+ An agent multiplexer *could* have answered instead — the good ones track real agent status (idle /
354
+ working / blocked) — and one still doesn't get asked, for a precise reason. A healthy lead parked on
355
+ `crux tail` is doing nothing, so it gets called `idle`; a lead frozen at Claude Code's folder-trust
356
+ prompt is *also* doing nothing, so that gets called `idle` too. The two states that matter most are
357
+ indistinguishable to it, and the process table separates them cleanly. A backend's own status is
358
+ shown as detail; it is never what crux believes.
359
+
360
+ | state | meaning |
361
+ |---|---|
362
+ | `covered` | something is tailing this slug. Nothing to do. |
363
+ | `starting` | crux started this lead; its process is alive but its tail hasn't appeared yet. |
364
+ | `tail-down` | it's alive and has tailed before — it's between tail restarts. Not a new lead. |
365
+ | `stalled` | **alive, but it has NEVER tailed.** A dead swarm that looks alive. See below. |
366
+ | `leadless` | configured to expect no lead. Expected, not an error. |
367
+ | refusals | no repo path; a duplicate tail; a repo that disagrees with the backend's. |
368
+
369
+ Nothing is ever silently skipped. Every project crux won't bring up is named, with the reason.
370
+
371
+ ### The one thing that will bite you first
372
+
373
+ Claude Code asks you to trust a directory it has never seen — and
374
+ `--dangerously-skip-permissions` **does not skip that prompt**. A lead started in an untrusted repo
375
+ sits at it forever: the agent is alive, the swarm is dead, and the only symptom is silence.
376
+
377
+ So crux watches for it. A lead that has been up for three minutes and has never started its tail is
378
+ reported `stalled`, with the cause, and `swarm up` refuses to start a second one on top of it
379
+ (it would stall in exactly the same place). To fix: run `claude` once in the repo and accept the
380
+ folder, then `crux swarm down --project <slug>` and `up` again.
381
+
382
+ ### Config
383
+
384
+ Everything lives under `swarm` in the leads config (`~/.config/crux/leads.json`, or
385
+ `CRUX_LEADS_CONFIG`, or the template that ships with the package):
386
+
387
+ | key | |
388
+ |---|---|
389
+ | `backend` | `solo` (default) or `exec`. Override per-run with `--backend`, or `CRUX_SWARM_BACKEND`. |
390
+ | `agent` | the command an `exec` lead runs. Default `claude --dangerously-skip-permissions`. |
391
+ | `solo.agentToolId` | the Solo agent tool to spawn. Default `3` (claude). |
392
+ | `exec.command` / `exec.stop` | the templates above. |
393
+ | `projects.<slug>.repo` | where that lead works. **Required** for any project your backend can't locate itself — Solo already records a path per project, so a Solo user needs none of this. |
394
+ | `projects.<slug>.brief` | extra context pasted into that lead's generated brief. |
395
+
396
+ `crux swarm down` stops **only** what `crux swarm up` started (it's tracked in
397
+ `~/.config/crux/swarm.json`). A lead you started by hand is reported and left running — crux will
398
+ not kill a process it did not start and cannot restart.
399
+
400
+ ## Tests
401
+
402
+ ```bash
403
+ npm test # node:test — the reconciliation classifiers, the cursor lock, and `crux swarm`
404
+ # end-to-end against a stub Solo and a stub API
405
+ ```