Skip to content

Commit 7c2127e

Browse files
cailmdaleyclaude
andcommitted
shuttle: retire the shuttle-ctl CLI alias — every caller speaks felt shuttle <verb> (Stage A1)
The shuttle-ctl Go binary was already a thin shim that exec'd `felt shuttle`, so this migration is textual: every `shuttle-ctl <verb>` invocation becomes `felt shuttle <verb>`, and the shim source is deleted. The shuttle repo is now pure Elixir + TS — no Go. Load-bearing surfaces migrated: - Dispatch prompt (dispatcher.ex compose_prompt): the worker exit contract now instructs `felt shuttle handoff <fiber-id>`. This is the string that tells every dispatched worker how to close cleanly. - The two real shell-outs — transition.ex and lifecycle_controller.ex — now call System.cmd("felt", ["shuttle" | args], ...); the dispatcher's force-reopen runner.cmd does the same. Removed: cmd/shuttle/main.go (+ empty cmd/), the now-orphaned go.mod/go.sum, and the Makefile `cli`/`CLI_DEST` target. README/AGENTS.md describe a single repo artifact (the daemon); the agent-facing CLI is felt-owned. Tests: the two stubs that fake the lifecycle shell-out are renamed `felt` and now delegate non-`shuttle` calls (store resolution: `felt show`/`felt ls`) to the real felt, capturing only the `shuttle` write — restoring the separation the `shuttle-ctl`-named stubs got for free. mix test: 469 tests, 1 failure (the pre-existing GET /api/v1/agents, unrelated — fails identically on main). The ~/go/bin/shuttle-ctl binary is left in place; a later deploy-gated step removes it. Remaining `shuttle-ctl` mentions are provenance prose noting the shim's retirement, plus the `constitution-shuttle-ctl-ux-fixes` test fixture id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JLnW7bsT7cmzPWGBN8rs6
1 parent f8a83cb commit 7c2127e

19 files changed

Lines changed: 175 additions & 218 deletions

AGENTS.md

Lines changed: 41 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,9 @@ make build # mix escript.build → bin/shuttle (MIX_ENV=dev)
8282
make start # nohup detached; logs → ~/Library/Logs/shuttle.log (macOS)
8383
make stop # SIGTERM with 5s grace
8484
make restart # build + stop + start (the load-bearing daemon target)
85-
make cli # go build → ~/go/bin/shuttle-ctl (load-bearing CLI target)
86-
make all # restart + cli (everything)
85+
make all # restart (daemon)
8786
make logs # tail -f the log
88-
make status # shuttle-ctl ps + snapshot summary
87+
make status # felt shuttle ps + snapshot summary
8988
make clean # rm _build and stray Elixir.*.beam at project root
9089
9190
make install-agent # durable launchd keep-alive (crash + login restart)
@@ -284,10 +283,10 @@ node via a `bash -lc` login-shell fallback, so it works even though the respawn
284283
loop sources asdf but not nvm. A host without node/MySTRA fails `/astra` cleanly;
285284
the board + fibers are unaffected.
286285

287-
**Two artifacts, two languages, two release cadences.** The Elixir daemon
288-
(`bin/shuttle`) and the Go CLI (`~/go/bin/shuttle-ctl`) are independent —
289-
rebuilding one never implies rebuilding the other. Editing `cmd/shuttle/*.go`
290-
needs `make cli`; editing `lib/shuttle/*.ex` needs `make restart`.
286+
**One artifact in this repo: the Elixir daemon.** `bin/shuttle` is the only
287+
buildable thing here — editing `lib/shuttle/*.ex` needs `make restart`. The
288+
agent-facing CLI is `felt shuttle <verb>`, owned and shipped by felt (the old
289+
standalone shuttle-ctl Go shim is retired); nothing in this repo builds it.
291290

292291
**`bin/shuttle` is an escript** — it bundles BEAM bytecode at build time and
293292
loads it at boot. A restart without `make build` is a no-op for picking up
@@ -313,24 +312,24 @@ should never be committed.
313312
bin/shuttle snapshot # JSON snapshot of daemon state
314313
bin/shuttle dispatch <fiber-id> # one-shot dispatch
315314

316-
# shuttle-ctl — agent-facing CLI; offline; schema-validating
317-
shuttle-ctl status # all fibers with shuttle: blocks
318-
shuttle-ctl status --all # local + every configured remote
319-
shuttle-ctl status --remote <name> # single remote
320-
shuttle-ctl ps # live tmux workers only
321-
shuttle-ctl install <fiber> --project-dir "$PWD" [-m <agent-id>] [--disabled]
322-
shuttle-ctl repeat <fiber> --schedule "0 9 * * 1-5" --tz Europe/Paris --project-dir "$PWD"
323-
shuttle-ctl pause <fiber> # disable + kill live worker; --no-kill preserves it
324-
shuttle-ctl resume / accept <fiber>
325-
shuttle-ctl set-model <fiber> <agent-id>
326-
shuttle-ctl dispatch <fiber>
327-
shuttle-ctl handoff <fiber> # worker's clean-exit ritual: stamp
315+
# felt shuttle — agent-facing CLI; offline; schema-validating
316+
felt shuttle status # all fibers with shuttle: blocks
317+
felt shuttle status --all # local + every configured remote
318+
felt shuttle status --remote <name> # single remote
319+
felt shuttle ps # live tmux workers only
320+
felt shuttle install <fiber> --project-dir "$PWD" [-m <agent-id>] [--disabled]
321+
felt shuttle repeat <fiber> --schedule "0 9 * * 1-5" --tz Europe/Paris --project-dir "$PWD"
322+
felt shuttle pause <fiber> # disable + kill live worker; --no-kill preserves it
323+
felt shuttle resume / accept <fiber>
324+
felt shuttle set-model <fiber> <agent-id>
325+
felt shuttle dispatch <fiber>
326+
felt shuttle handoff <fiber> # worker's clean-exit ritual: stamp
328327
# shuttle.handed_off_at (→ next dispatch
329328
# is fresh) + end own tmux session. The
330329
# single final action; folds in kill $PPID.
331-
shuttle-ctl snapshot
332-
shuttle-ctl abort / attach <fiber>
333-
shuttle-ctl validate-identity # UID migration/cross-city validation
330+
felt shuttle snapshot
331+
felt shuttle abort / attach <fiber>
332+
felt shuttle validate-identity # UID migration/cross-city validation
334333
```
335334

336335
## Critical invariants
@@ -355,27 +354,27 @@ shuttle-ctl validate-identity # UID migration/cross-city validati
355354
mirror", because the read was attempted locally instead of being owner-routed.
356355
New endpoints that surface a fiber's host-local content MUST route through
357356
`OriginRouter`, not assume the bytes are reachable on this host.
358-
- **Agent records live in one source of truth: `share/agents.json`.** Both
359-
runtimes (Elixir daemon, Go CLI) embed it at compile time — Elixir via
360-
`@external_resource` + `File.read!` in `lib/shuttle/agents.ex`, Go via
361-
`//go:embed` (generated `pkg/schema/agents_embedded.go`). Edit the JSON,
362-
then `make restart`. There is no `config/agents.exs`.
357+
- **Agent records live in one source of truth: felt's registry.** Felt owns
358+
the registry for the merge; the daemon reads the already-resolved record off
359+
felt's `shuttle.resolved.agent` JSON and shells `felt shuttle agents
360+
[resolve]` for the registry / no-fiber cases. There is no daemon-embedded
361+
`share/agents.json` and no `config/agents.exs`.
363362
- **`shuttle.agent` field drives agent selection.** The `shuttle:` block's
364363
`agent:` field resolves against the registry. Default agent is
365364
`claude-sonnet`.
366365
- **`shuttle.host` field drives daemon affinity — strictly.** A daemon
367366
dispatches a block iff `block.host == own_host_id` (its `SHUTTLE_HOST` or
368367
`:inet.gethostname()`). There is no `"local"` default and no `nil`
369368
wildcard: an absent or empty `host:` is unowned and ineligible on *every*
370-
daemon. `shuttle-ctl install`/`repeat` stamp `host` by default so blocks
369+
daemon. `felt shuttle install`/`repeat` stamp `host` by default so blocks
371370
are born owned. The same predicate gates the orphan-resurrection path, so
372371
a remote restart can't re-grab another host's fiber.
373-
- **`shuttle.project_dir` is required for enabled installs.** `shuttle-ctl
372+
- **`shuttle.project_dir` is required for enabled installs.** `felt shuttle
374373
install` and `repeat` require `--project-dir`; workers start there instead
375374
of falling back to the felt store.
376-
- **shuttle-ctl is the agent-facing CLI.** Local write verbs validate before
375+
- **felt shuttle is the agent-facing CLI.** Local write verbs validate before
377376
write and work offline. Cross-host writes belong to Portolan's kanban/API
378-
surface, not to `shuttle-ctl`. `bin/shuttle` handles daemon lifecycle and
377+
surface, not to `felt shuttle`. `bin/shuttle` handles daemon lifecycle and
379378
dispatch.
380379
- **No tag predicate for dispatch.** The `shuttle:` block's `enabled: true`
381380
field is the dispatch signal. Tags are free-form qualitative noticings;
@@ -398,7 +397,7 @@ shuttle-ctl validate-identity # UID migration/cross-city validati
398397
Scheduled runs dispatch only when `next_due_at` is due AND `review.state`
399398
is `scheduled` or `accepted`. Manual dispatch is ad-hoc (`adhoc-...`
400399
run id) and preserves `next_due_at`; worker exit flips state to
401-
`awaiting`, and `shuttle-ctl accept` advances `next_due_at` only for
400+
`awaiting`, and `felt shuttle accept` advances `next_due_at` only for
402401
scheduled runs.
403402

404403
## Dispatch prompt structure
@@ -429,23 +428,23 @@ the git log of the constitution.)
429428
## Inspecting state
430429

431430
```bash
432-
shuttle-ctl status # Go walker view (independent of daemon)
431+
felt shuttle status # offline walker view (independent of daemon)
433432
bin/shuttle snapshot # raw JSON snapshot
434433
make status # daemon-side view (ps + snapshot)
435434
~/Library/Logs/shuttle.log # daemon stdout/stderr (macOS)
436435
tmux ls | grep '^shuttle-' # live workers
437436
curl -s http://127.0.0.1:4000/api/v1/agents | jq
438437
curl -s http://127.0.0.1:4000/api/v1/state | jq
439438
curl -s http://127.0.0.1:4000/api/v1/state/composite | jq
440-
shuttle-ctl validate-identity # checks :4000/:4001/:4002 by default
439+
felt shuttle validate-identity # checks :4000/:4001/:4002 by default
441440
```
442441

443442
Dispatch sanity ladder:
444443

445-
1. `shuttle-ctl status` shows `enabled: true, idle, oneshot`? → fiber is
446-
well-formed and the Go walker sees it.
444+
1. `felt shuttle status` shows `enabled: true, idle, oneshot`? → fiber is
445+
well-formed and the offline walker sees it.
447446
2. `bin/shuttle snapshot` lists it under `eligible[]`? → daemon dispatched.
448-
3. shuttle-ctl sees it but daemon doesn't → daemon binary is stale.
447+
3. `felt shuttle` sees it but daemon doesn't → daemon binary is stale.
449448
`make restart`.
450449
4. Daemon sees it but agent never appears → check `share/agents.json` for
451450
the resolved agent's `cli` and that the wrapper is on `PATH`.
@@ -480,26 +479,21 @@ shuttle/
480479
├── lib/ Elixir source
481480
│ ├── shuttle/poller.ex discover + eligibility + retry queue
482481
│ ├── shuttle/dispatcher.ex agent resolution, tmux launch
483-
│ ├── shuttle/agents.ex agent registry — reads share/agents.json at compile time
484482
│ └── shuttle_web/ agent-API HTTP endpoints (/api/v1/...)
485-
├── cmd/shuttle/ Go CLI (shuttle-ctl)
486-
├── pkg/schema/ Go schema package (types, validation, YAML I/O)
487-
│ ├── agents.go registry loader (//go:embed agents_embedded.go)
488-
│ └── agents_embedded.go generated — embeds share/agents.json bytes
489-
├── share/ shared data (canonical for both runtimes)
490-
│ ├── agents.json THE agent registry — single source of truth
491-
│ └── schema.json shuttle: block frontmatter schema
483+
├── share/ shared data (plist template, launchd assets)
492484
├── config/ Elixir env config (dev/test/prod endpoint settings)
493485
├── test/ Mix test suite
494486
└── deps/, _build/ Mix-managed; gitignored
495487
```
496488

489+
The agent-facing CLI (`felt shuttle <verb>`) and the agent registry are owned by
490+
felt, not built here — this repo is pure Elixir + TS.
491+
497492
## Tests
498493

499494
```bash
500-
mix test # full Elixir suite (110 tests, ~7s)
495+
mix test # full Elixir suite
501496
mix test --only focus # tagged subset
502-
go test ./pkg/schema/... # Go schema tests
503497

504498
# Opt-in real harness smoke. Opens real Claude/Codex/Pi CLIs in tmux,
505499
# sends no prompt, captures the idle pane, then kills the smoke sessions.

Makefile

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,28 @@
1-
# Shuttle daemon + CLI — build + lifecycle
1+
# Shuttle daemon — build + lifecycle
22
#
3-
# Two artifacts share this repo:
3+
# One artifact lives in this repo:
44
# - bin/shuttle (Elixir escript) — the daemon. Loads BEAMs at boot;
55
# `make restart` rebuilds + bounces.
6-
# - shuttle-ctl (Go binary) — the agent-facing CLI. Built into
7-
# $(CLI_DEST) which sits on PATH; `make cli` rebuilds + installs.
86
#
9-
# `make restart` is the load-bearing daemon target. `make cli` is the
10-
# load-bearing CLI target — when a new shuttle-ctl verb lands, any
11-
# tool that shells out to it will silently break with a stale binary.
12-
# The daemon and CLI are independent (Elixir vs Go), so building one
13-
# never implies rebuilding the other; `make all` does both.
7+
# The agent-facing CLI is now `felt shuttle <verb>` — owned by felt, not built
8+
# here (the old standalone shuttle-ctl Go shim is retired). This repo is pure
9+
# Elixir + TS; there is nothing to `go build`.
10+
#
11+
# `make restart` is the load-bearing daemon target.
1412

1513
LOG := $(HOME)/Library/Logs/shuttle.log
1614
# Match both the local `bin/shuttle ... -extra bin/shuttle start` shape and
1715
# remote respawn-loop `./bin/shuttle ... -extra ./bin/shuttle start` shape.
1816
# `[b]in` prevents pgrep from matching its own shell command.
1917
PIDPATTERN := [b]in/shuttle -B .* -extra \.?/?bin/shuttle start
20-
CLI_DEST := $(HOME)/go/bin/shuttle-ctl
2118
AGENT_LABEL := io.shuttle.daemon
2219
AGENT_PLIST := $(HOME)/Library/LaunchAgents/$(AGENT_LABEL).plist
2320
# Felt stores the launchd daemon polls. Defaults to the loom aggregate (~/loom,
2421
# outside ~/Documents) so the agent touches no TCC-protected path and needs no
2522
# Full Disk Access. Override to add stores: make install-agent AGENT_LOOM_HOMES=~/loom,/some/other
2623
AGENT_LOOM_HOMES ?= $(HOME)/loom
2724
# The daemon's PATH, captured from a login shell at install time so it carries
28-
# Homebrew (escript/erl), ~/.local/bin (felt), ~/go/bin (shuttle-ctl), etc. —
25+
# Homebrew (escript/erl) and ~/.local/bin (felt), etc. —
2926
# launchd's own env is too bare, and sourcing the profile at runtime under
3027
# launchd doesn't reconstruct it. This is the user's real PATH, frozen.
3128
AGENT_PATH ?= $(shell /bin/bash -lc 'echo $$PATH')
@@ -36,11 +33,11 @@ AGENT_PATH ?= $(shell /bin/bash -lc 'echo $$PATH')
3633
# ~/.ssh/agent.sock is the stable login-agent path; override if yours differs.
3734
AGENT_SSH_AUTH_SOCK ?= $(HOME)/.ssh/agent.sock
3835

39-
.PHONY: all build cli start stop restart logs status clean help install-agent uninstall-agent
36+
.PHONY: all build start stop restart logs status clean help install-agent uninstall-agent
4037

4138
# Felt owns the agent registry — the single source of truth for the merge. The
42-
# Go CLI is a thin `exec felt shuttle` shim (cmd/shuttle/main.go); the Elixir
43-
# daemon no longer embeds a registry either (Stage 4a): it reads the
39+
# agent-facing CLI is `felt shuttle <verb>` (felt absorbed every verb); the
40+
# Elixir daemon no longer embeds a registry either (Stage 4a): it reads the
4441
# already-resolved record off felt's `shuttle.resolved.agent` JSON and shells
4542
# `felt shuttle agents [resolve]` for the registry / no-fiber cases. The old
4643
# share/agents.json + pkg/schema's generated agents_embedded.go are gone.
@@ -54,28 +51,18 @@ help:
5451
@echo " make install-agent — durable launchd keep-alive (crash + login restart)"
5552
@echo " make uninstall-agent — remove the launchd agent"
5653
@echo " make logs — tail -f the daemon log"
57-
@echo " make status — shuttle-ctl ps + snapshot summary"
54+
@echo " make status — felt shuttle ps + snapshot summary"
5855
@echo " make clean — remove _build and stray .beam files"
5956
@echo ""
60-
@echo "shuttle-ctl CLI (Go):"
61-
@echo " make cli — go build → $(CLI_DEST) (must be on PATH)"
62-
@echo ""
6357
@echo "everything:"
64-
@echo " make all — restart (daemon) + cli"
58+
@echo " make all — restart (daemon)"
6559

66-
all: restart cli
60+
all: restart
6761

6862
build:
6963
mix shuttle.gen_version
7064
mix escript.build
7165

72-
# Build the Go CLI and install to $(CLI_DEST). `go install ./cmd/shuttle`
73-
# would output as `shuttle` (matches cobra Use:), so we use `go build -o`
74-
# to land it under the historical `shuttle-ctl` name.
75-
cli:
76-
@go build -o $(CLI_DEST) ./cmd/shuttle
77-
@echo "shuttle-ctl → $(CLI_DEST) (shim: exec felt shuttle)"
78-
7966
start:
8067
@if pgrep -f '$(PIDPATTERN)' >/dev/null; then \
8168
echo "shuttle already running (pid $$(pgrep -f '$(PIDPATTERN)'))"; exit 1; \
@@ -138,7 +125,7 @@ logs:
138125
@tail -f $(LOG)
139126

140127
status:
141-
@shuttle-ctl ps 2>/dev/null || echo "(shuttle-ctl ps unavailable)"
128+
@felt shuttle ps 2>/dev/null || echo "(felt shuttle ps unavailable)"
142129
@echo
143130
@bin/shuttle snapshot 2>/dev/null | python3 -c "import json,sys; o=json.load(sys.stdin); \
144131
print('felt_hosts:', o.get('felt_hosts','MISSING (binary pre-297a24d)')); \

0 commit comments

Comments
 (0)