Skip to content

Commit c9fc77f

Browse files
committed
chore: publish prep — scrub, license, README, CI
- Module path: github.com/cailmdaley/shuttle-cli → github.com/cailmdaley/shuttle - Schema $id: same rewrite - Remove personal paths (/Users/cd280747, ~/Documents/projects/lightcone, ~/wedding) from comments and test fixtures - Replace with generic examples or architecture-neutral descriptions - Add LICENSE (Apache 2.0) and NOTICE (Symphony attribution) - Rewrite README: principles-first, three-artifacts, install path, lineage - Rewrite CLAUDE.md: contributor-facing, no loom paths or personal context - Add .github/workflows/ci.yml: mix compile --warnings-as-errors, mix test, go test ./pkg/schema/... - Add CONTRIBUTING.md - Gitignore bin/shuttle (escript) and .felt (user-local symlink) All tests pass: 110 Elixir + Go schema suite.
1 parent ee5ebe1 commit c9fc77f

26 files changed

Lines changed: 594 additions & 175 deletions

.github/workflows/ci.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
elixir:
11+
name: Elixir (mix compile + mix test)
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Elixir
17+
uses: erlef/setup-beam@v1
18+
with:
19+
otp-version: "27"
20+
elixir-version: "1.17"
21+
22+
- name: Cache deps
23+
uses: actions/cache@v4
24+
with:
25+
path: deps
26+
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
27+
restore-keys: ${{ runner.os }}-mix-
28+
29+
- name: Cache _build
30+
uses: actions/cache@v4
31+
with:
32+
path: _build
33+
key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}
34+
restore-keys: ${{ runner.os }}-build-
35+
36+
- name: Install dependencies
37+
run: mix deps.get
38+
39+
- name: Compile (warnings as errors)
40+
run: mix compile --warnings-as-errors
41+
42+
- name: Run tests
43+
run: mix test
44+
45+
go:
46+
name: Go (build + test)
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
51+
- name: Set up Go
52+
uses: actions/setup-go@v5
53+
with:
54+
go-version: "1.22"
55+
56+
- name: Build CLI
57+
run: go build ./cmd/shuttle
58+
59+
- name: Run tests
60+
run: go test ./pkg/schema/...

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ erl_crash.dump
2222
# Ignore package tarball (built via "mix hex.build").
2323
shuttle-*.tar
2424

25+
# Shuttle daemon escript (built via `mix escript.build`)
26+
/bin/shuttle
27+
2528
# Go binaries (build with `go build` from cmd/shuttle/)
2629
/bin/shuttle-ctl
2730
/shuttle
31+
32+
# felt symlink (user-local; points to the user's felt store)
33+
.felt

CLAUDE.md

Lines changed: 76 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
# Shuttle — Agent Notes
1+
# Shuttle — Contributor Notes
22

33
Local OTP-supervised dispatcher for felt constitution workers. Polls the felt
44
tree, launches one tmux worker per eligible fiber, exposes a snapshot surface
5-
for Portolan and other consumers.
5+
for dashboards and other consumers.
66

7-
**Status: Stages 0–6 complete; Stage 7 (BEAM distribution / SSH-drop resilience) deferred.**
8-
The Elixir engine is the production dispatcher; portolan's TS engine is retired.
9-
See [[ai-futures/shuttle/constitution-shuttle-standalone]] for the canonical invariants.
10-
11-
The repo has no git remote — local-only, like portolan. Don't add one without intent.
7+
**Status: Stages 0–6 complete; Stage 7 (BEAM distribution / SSH-drop resilience) in progress.**
8+
The Elixir daemon is the production dispatcher.
129

1310
## Build + lifecycle
1411

@@ -17,7 +14,7 @@ running daemon until you restart. Use the Makefile:
1714

1815
```
1916
make build # mix escript.build → bin/shuttle (MIX_ENV=dev)
20-
make start # nohup detached; logs → ~/Library/Logs/shuttle.log
17+
make start # nohup detached; logs → ~/Library/Logs/shuttle.log (macOS)
2118
make stop # SIGTERM with 5s grace
2219
make restart # build + stop + start (the load-bearing daemon target)
2320
make cli # go build → ~/go/bin/shuttle-ctl (load-bearing CLI target)
@@ -30,17 +27,11 @@ make clean # rm _build and stray Elixir.*.beam at project root
3027
**Two artifacts, two languages, two release cadences.** The Elixir daemon
3128
(`bin/shuttle`) and the Go CLI (`~/go/bin/shuttle-ctl`) are independent —
3229
rebuilding one never implies rebuilding the other. Editing `cmd/shuttle/*.go`
33-
needs `make cli`; editing `lib/shuttle/*.ex` needs `make restart`. When the
34-
kanban or any portolan path starts shelling out to a new shuttle-ctl verb,
35-
`make cli` becomes load-bearing — a stale binary breaks transitions
36-
silently. See
37-
[[ai-futures/portolan/gotchas/gotcha-shuttle-ctl-binary-stale-after-source-update]].
38-
39-
**`bin/shuttle` is an escript** — it bundles BEAM bytecode at build time and loads it at boot. A restart without `make build` is a no-op for picking up source edits; `mix compile` without restart is a no-op for an already-running daemon. **`make restart` always.** When `shuttle-ctl status` sees a fiber but `bin/shuttle snapshot` doesn't list it as eligible, the daemon is stale.
30+
needs `make cli`; editing `lib/shuttle/*.ex` needs `make restart`.
4031

41-
Default `MIX_ENV=dev` matches the localhost:4000 endpoint config in
42-
`config/dev.exs`. The Phoenix endpoint binds 127.0.0.1:4000 only when
43-
`server: true` is set there — don't propagate that to other envs.
32+
**`bin/shuttle` is an escript** — it bundles BEAM bytecode at build time and
33+
loads it at boot. A restart without `make build` is a no-op for picking up
34+
source edits. `make restart` always.
4435

4536
If `mix escript.build` warns about "redefining module Shuttle.X" with the
4637
"current version loaded from Elixir.Shuttle.X.beam" hint, run `make clean`
@@ -55,8 +46,8 @@ bin/shuttle dispatch <fiber-id> # one-shot dispatch
5546

5647
# shuttle-ctl — agent-facing CLI; offline; schema-validating
5748
shuttle-ctl status # all fibers with shuttle: blocks
58-
shuttle-ctl status --all # local + every configured remote (via daemon /state/composite)
59-
shuttle-ctl status --remote candide # single remote, filtered from the composite snapshot
49+
shuttle-ctl status --all # local + every configured remote
50+
shuttle-ctl status --remote <name> # single remote
6051
shuttle-ctl ps # live tmux workers only
6152
shuttle-ctl install <fiber> [-m <agent-id>] [--disabled]
6253
shuttle-ctl repeat <fiber> --schedule "0 9 * * 1-5" --tz Europe/Paris
@@ -68,85 +59,92 @@ shuttle-ctl migrate --dry-run # preview eligibility migration
6859

6960
## Critical invariants
7061

71-
- **tmux owns the worker process; Shuttle owns the watcher.** Workers stay attachable via `tmux attach -t shuttle-<fiber-id>`. Supervise watchers, not workers.
72-
- **Felt is the data layer; Shuttle shells out to the felt CLI.** Don't import felt internals.
73-
- **Agent records live in one source of truth: `share/agents.json`.** Both runtimes (Elixir daemon, Go CLI) embed it at compile time — Elixir via `@external_resource` + `File.read!` in `lib/shuttle/agents.ex`, Go via `//go:embed` (generated `pkg/schema/agents_embedded.go`). Edit the JSON, then `make restart`. There is no `config/agents.exs` and no hand-edited fallback list anywhere — see `[[ai-futures/shuttle/finding-agent-registry-four-mirrors]]` for the cleanup that landed.
74-
- **`shuttle.agent` field drives agent selection.** The `shuttle:` block's `agent:` field resolves against the registry. Bare `codex`/`pi` felt tags resolve via back-compat aliases when no `shuttle.agent` is set; default agent is `claude-sonnet`.
75-
- **shuttle-ctl is the agent-facing CLI.** Write verbs validate before write; works offline. Elixir `bin/shuttle` handles daemon lifecycle and dispatch only.
76-
- **No tag predicate for dispatch.** `constitution` is a human convention only — a fiber with a `shuttle:` block dispatches with or without it. Tags are free-form qualitative noticings; only `idea` is read by Portolan's kanban (for column placement).
62+
- **tmux owns the worker process; Shuttle owns the watcher.** Workers stay
63+
attachable via `tmux attach -t shuttle-<fiber-id>`. Supervise watchers,
64+
not workers.
65+
- **Felt is the data layer; Shuttle shells out to the felt CLI.** Don't
66+
import felt internals.
67+
- **Agent records live in one source of truth: `share/agents.json`.** Both
68+
runtimes (Elixir daemon, Go CLI) embed it at compile time — Elixir via
69+
`@external_resource` + `File.read!` in `lib/shuttle/agents.ex`, Go via
70+
`//go:embed` (generated `pkg/schema/agents_embedded.go`). Edit the JSON,
71+
then `make restart`. There is no `config/agents.exs`.
72+
- **`shuttle.agent` field drives agent selection.** The `shuttle:` block's
73+
`agent:` field resolves against the registry. Default agent is
74+
`claude-sonnet`.
75+
- **shuttle-ctl is the agent-facing CLI.** Write verbs validate before
76+
write; works offline. `bin/shuttle` handles daemon lifecycle and dispatch.
77+
- **No tag predicate for dispatch.** The `shuttle:` block's `enabled: true`
78+
field is the dispatch signal. Tags are free-form qualitative noticings;
79+
only `idea` is load-bearing for Portolan's kanban column placement.
7780

7881
## How dispatch works
7982

80-
- **Poller** (`lib/shuttle/poller.ex`) is the single GenServer that owns the
81-
tick. It walks each configured `felt_host`, parses each `*.md` file's
82-
frontmatter, and considers a fiber eligible iff `shuttle.enabled: true` AND
83-
`status in ["open", "active"]` AND not already running/claimed AND deps
84-
satisfied.
85-
- **Configured hosts** come from `LOOM_HOMES` (comma-separated) →
86-
persisted `~/.shuttle/felt_hosts.json``LOOM_HOME` → default `~/loom`.
87-
`POST /api/v1/felt-hosts` rewrites the persisted file; earlier-configured
88-
hosts win on fiber-id collisions across hosts.
83+
- **Poller** (`lib/shuttle/poller.ex`) owns the tick. It walks each
84+
configured felt host, pulls candidate metadata via `felt ls --json` and
85+
per-fiber detail via `felt show -j`, and considers a fiber eligible iff
86+
`shuttle.enabled: true` AND `status in ["open", "active"]` AND not
87+
already running AND deps satisfied.
88+
- **Configured hosts** come from `LOOM_HOMES` (comma-separated env var) →
89+
persisted `~/.shuttle/felt_hosts.json``LOOM_HOME``~/loom`.
90+
`POST /api/v1/felt-hosts` rewrites the persisted file.
8991
- **Dispatcher** (`lib/shuttle/dispatcher.ex`) resolves the agent via
9092
`Shuttle.Agents.resolve_by_name/1` against the embedded registry, spawns
9193
`shuttle-<fiber-id>` tmux session.
92-
- **Standing roles** are recurring fibers — `shuttle.kind: standing` with a
93-
cron `schedule:`. Daemon dispatches only when `next_due_at` is due AND
94-
`review.state` is `scheduled` or `accepted`. Worker exit flips state to
95-
`awaiting`; `shuttle-ctl accept` advances `next_due_at`.
94+
- **Standing roles**`shuttle.kind: standing` with a cron `schedule:`.
95+
Dispatches only when `next_due_at` is due AND `review.state` is
96+
`scheduled` or `accepted`. Worker exit flips state to `awaiting`;
97+
`shuttle-ctl accept` advances `next_due_at`.
98+
99+
## Dispatch prompt structure
100+
101+
All prompt variants share this shape (`compose_prompt/3` in dispatcher.ex):
102+
103+
1. **Orientation paragraph** — what Shuttle is, what the worker is here to
104+
do, how the practice loads. Per-prompt, not boilerplate. Goes first
105+
because in causal attention every downstream token sees the prefix.
106+
2. **`Fiber: <id>`** (and `Run: <run-id>` for standing) — identity lines.
107+
3. **`From User · <relative time>`** — the most recent `--kind
108+
review-comment` event, if any. Pulled fresh at dispatch.
109+
110+
The fiber's outcome and last editorial event are not inlined — they're
111+
already in scope after `felt show <id>` and `felt history <id>`, which
112+
the shuttle skill prescribes the worker calls on arrival.
96113

97114
## Inspecting state
98115

99-
```
100-
make status # daemon-side view (ps + snapshot)
116+
```bash
101117
shuttle-ctl status # Go walker view (independent of daemon)
102-
shuttle-ctl status -j # JSON
103118
bin/shuttle snapshot # raw JSON snapshot
104-
~/Library/Logs/shuttle.log # daemon stdout/stderr
119+
make status # daemon-side view (ps + snapshot)
120+
~/Library/Logs/shuttle.log # daemon stdout/stderr (macOS)
105121
tmux ls | grep '^shuttle-' # live workers
106-
curl -s http://127.0.0.1:4000/api/v1/agents | jq # agent registry as JSON
107-
curl -s http://127.0.0.1:4000/api/v1/state | jq # full orchestrator state
108-
curl -s http://127.0.0.1:4000/api/v1/state/composite | jq # local + per-remote snapshots
122+
curl -s http://127.0.0.1:4000/api/v1/agents | jq
123+
curl -s http://127.0.0.1:4000/api/v1/state | jq
124+
curl -s http://127.0.0.1:4000/api/v1/state/composite | jq
109125
```
110126

111-
**Cross-host visibility.** `--all` and `--remote` go through the local
112-
daemon's `/api/v1/state/composite`; the daemon's `RemoteRegistry` polls
113-
each configured remote (`config :shuttle, :remotes, [...]`) over its
114-
SSH-tunnel-mapped port and merges the snapshots with freshness flags.
115-
The CLI never talks to remote daemons directly — `SHUTTLE_DAEMON_URL`
116-
overrides which local daemon to query, but remote URLs live in mix
117-
config alone. See [[ai-futures/shuttle/constitution-shuttle-remote-dispatch]].
127+
Dispatch sanity ladder:
118128

119-
## Dispatch prompt structure
120-
121-
The fresh, resume, and standing-run prompts all share the same shape (`compose_prompt/3` in `lib/shuttle/dispatcher.ex`):
122-
123-
1. **Orientation paragraph** — what Shuttle is, what the worker is here to do, how the practice loads. Per-prompt, not boilerplate. Goes first because in causal attention every downstream token sees the prefix.
124-
2. **`Fiber: <id>`** (and `Run: <run-id>` for standing) — identity lines, grep-able.
125-
3. **`From User · <relative time>`** — the most recent `--kind review-comment` event, if any. Pulled fresh at dispatch. The user's intent, inlined so it sits in attention prefix.
126-
127-
The fiber's outcome and last editorial event are *not* inlined — they're already in scope after `felt show <id>` (outcome + Recent line) and `felt history <id>` (full chain), which the shuttle skill prescribes the worker calls on arrival. Inlining either duplicates state and risks drift between the prompt's snapshot and felt's view.
128-
129-
Operational instructions (read the constitution, exit before half-full, append an editorial event, `kill $PPID`, standing-run awaiting-review handoff) live in the `shuttle` and `felt` skills, not the prompt. The prompt's job is orientation; duplicating practice means drift.
130-
131-
A useful sanity ladder when something isn't dispatching:
132-
133-
1. `shuttle-ctl status` shows `enabled: true, idle, oneshot`? → fiber is well-formed and the Go walker sees it.
134-
2. `bin/shuttle snapshot` lists it under `eligible[]` with `state: running`? → daemon dispatched it.
135-
3. If shuttle-ctl sees it but daemon doesn't → daemon binary is stale. `make restart`.
136-
4. If daemon sees it but agent never appears → check `share/agents.json` for the resolved agent's `cli` and that the wrapper is on `PATH`.
137-
5. If the snapshot has no `felt_hosts:` field → binary pre-`297a24d`. Same fix: `make restart`.
129+
1. `shuttle-ctl status` shows `enabled: true, idle, oneshot`? → fiber is
130+
well-formed and the Go walker sees it.
131+
2. `bin/shuttle snapshot` lists it under `eligible[]`? → daemon dispatched.
132+
3. shuttle-ctl sees it but daemon doesn't → daemon binary is stale.
133+
`make restart`.
134+
4. Daemon sees it but agent never appears → check `share/agents.json` for
135+
the resolved agent's `cli` and that the wrapper is on `PATH`.
138136

139137
## Codebase layout
140138

141139
```
142-
shuttle/ project root (this dir, flat — no nested shuttle/)
140+
shuttle/
143141
├── CLAUDE.md you're reading it
144142
├── Makefile build + daemon lifecycle
145143
├── mix.exs Mix project
146144
├── bin/shuttle the daemon escript (built artifact)
147145
├── lib/ Elixir source
148146
│ ├── shuttle/poller.ex discover + eligibility + retry queue
149-
│ ├── shuttle/dispatcher.ex agent resolution, tmux launch, session-UUID capture
147+
│ ├── shuttle/dispatcher.ex agent resolution, tmux launch
150148
│ ├── shuttle/agents.ex agent registry — reads share/agents.json at compile time
151149
│ └── shuttle_web/ agent-API HTTP endpoints (/api/v1/...)
152150
├── cmd/shuttle/ Go CLI (shuttle-ctl)
@@ -163,21 +161,12 @@ shuttle/ project root (this dir, flat — no nested shuttle/
163161

164162
## Tests
165163

166-
```
167-
mix test # full Elixir suite (78 tests, ~7s)
164+
```bash
165+
mix test # full Elixir suite (110 tests, ~7s)
168166
mix test --only focus # tagged subset
169167
go test ./pkg/schema/... # Go schema tests
170168
```
171169

172-
Disk-walking tests use real fixture directories under `test/support/` rather
173-
than mocks — the walker is a thin filesystem read so this is faster and
174-
catches more.
175-
176-
## Pointers
170+
## Contributing
177171

178-
- Constitution: `felt show ai-futures/shuttle/constitution-shuttle-standalone`
179-
- SPEC: `~/loom/.felt/ai-futures/shuttle/SPEC.md`
180-
- Agent registry: `share/agents.json` (single source of truth — see invariants above)
181-
- Loom shell wrappers: `~/loom/shell-functions.sh` (claude, codex, pi, kimi, glm)
182-
- Shuttle skill: `~/.claude/skills/shuttle/SKILL.md`
183-
- Fibers: `ai-futures/shuttle/` in `~/loom/.felt/` — design fibers, constitutions (cutover, multi-host, CLI, etc.), gotchas, postmortems.
172+
See `CONTRIBUTING.md`.

CONTRIBUTING.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Contributing to Shuttle
2+
3+
Thank you for your interest in Shuttle.
4+
5+
## Getting started
6+
7+
```bash
8+
git clone https://github.com/cailmdaley/shuttle
9+
cd shuttle
10+
mix deps.get && mix compile
11+
make cli
12+
```
13+
14+
Requirements: Erlang/OTP 26+, Elixir 1.16+, Go 1.21+, the `felt` CLI.
15+
16+
## Running tests
17+
18+
```bash
19+
mix test # Elixir suite
20+
go test ./pkg/schema/... # Go schema tests
21+
```
22+
23+
CI runs `mix compile --warnings-as-errors`, `mix test`, and
24+
`go test ./pkg/schema/...` on every PR.
25+
26+
## Invariants
27+
28+
Before opening a PR, verify:
29+
30+
- `mix compile --warnings-as-errors` passes
31+
- `mix test` passes
32+
- `go test ./pkg/schema/...` passes
33+
- No personal paths (`~/loom`, `/Users/...`) in tracked files
34+
- `share/agents.json` is the only agent registry — do not add a parallel
35+
registry in Elixir config or Go source
36+
37+
## Scope
38+
39+
Shuttle is deliberately personal-scale: no auth model, no team conventions,
40+
felt as the only work source. Contributions that add general-purpose
41+
infrastructure are welcome; contributions that add a specific integration
42+
layer belong in a fork or a `Shuttle.WorkSource` adapter once that
43+
abstraction lands.
44+
45+
## Opening issues
46+
47+
- **Bugs:** include steps to reproduce and the output of `bin/shuttle snapshot`.
48+
- **Features:** describe the problem, not just the solution. A concrete
49+
use-case helps.
50+
51+
## License
52+
53+
By contributing, you agree that your contributions will be licensed under
54+
the Apache License 2.0.

0 commit comments

Comments
 (0)