Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d554a616a | ||
|
|
dceabab8db | ||
|
|
1418b1123a | ||
|
|
2c73cf35f1 | ||
|
|
0b90837a18 | ||
|
|
566bdfbcd8 | ||
|
|
1ece29e1fd | ||
|
|
7f4c3201cf | ||
|
|
8deb5ed1bd | ||
|
|
dab619b3d0 | ||
|
|
3246c7c6b7 | ||
|
|
6a705f6210 | ||
|
|
62d5e4b550 | ||
|
|
0f6759e4a2 | ||
|
|
1bed63f087 | ||
|
|
5607bc4f01 | ||
|
|
982ce6c5d1 | ||
|
|
f1c49630ca | ||
|
|
21a88e2c18 | ||
|
|
8219fd5abe | ||
|
|
ad6386809c | ||
|
|
d13922523a | ||
|
|
84b6780a87 | ||
|
|
40c67e0796 | ||
|
|
0d7f5d1f05 | ||
|
|
30984dcf95 | ||
|
|
064f7b1a40 | ||
|
|
e3d99c3aed | ||
|
|
819c9dd179 | ||
|
|
9bb63900d4 | ||
|
|
fc1a1dea88 | ||
|
|
dd9cd61075 | ||
|
|
272f83f1fc | ||
|
|
6e6e5ce08c | ||
|
|
db353c247b | ||
|
|
7533fed55e | ||
|
|
afb944f616 | ||
|
|
6016f15da9 | ||
|
|
f90b58bc6d | ||
|
|
b60f016955 | ||
|
|
609a4af087 | ||
|
|
e1f2bb8b4b | ||
|
|
f7d497ba07 | ||
|
|
131dac91c8 | ||
|
|
b92e46474a | ||
|
|
fc8718e680 | ||
|
|
2f50a59e74 | ||
|
|
aeb4c0d26f | ||
|
|
c4fcdd88c8 | ||
|
|
c94d59dca7 | ||
|
|
e0858096f6 | ||
|
|
0f633be4b1 | ||
|
|
593185873d | ||
|
|
86751e1ea5 | ||
|
|
f9473c7b9e | ||
|
|
dcb085e64e | ||
|
|
369cf82b77 | ||
|
|
bfd3096b49 | ||
|
|
271bd7ea0a | ||
|
|
0d3f6c9654 | ||
|
|
24136ebaa1 | ||
|
|
7a1ed80068 | ||
|
|
e6bdc639ab | ||
|
|
65334320c7 | ||
|
|
ce231a31af | ||
|
|
f1a2c56900 | ||
|
|
cc87fdd03d | ||
|
|
9b4c7ac28b | ||
|
|
64e1448981 | ||
|
|
e9a013c0d2 | ||
|
|
e999ceb1c1 | ||
|
|
52b9a95f98 | ||
|
|
1d88a73eaa | ||
|
|
7150ce2624 | ||
|
|
2343e6b0ef | ||
|
|
491b635cbc | ||
|
|
cb2b130ca2 | ||
|
|
be35b3eaab | ||
|
|
b8075a5e06 | ||
|
|
9697007182 | ||
|
|
ad8189b010 | ||
|
|
7367aa7572 | ||
|
|
ba76bf1232 | ||
|
|
692d6819f2 | ||
|
|
97b70517cc | ||
|
|
73a8889c3e | ||
|
|
61b561a540 | ||
|
|
86739556c2 | ||
|
|
ff3d1b2e23 | ||
|
|
69299e9a43 | ||
|
|
1701474b3d | ||
|
|
a7e9fbf699 | ||
|
|
358fcaf935 | ||
|
|
f19ddc5400 | ||
|
|
64b58b31ab | ||
|
|
afff85cdff | ||
|
|
a91e6cd643 | ||
|
|
9b2f4a7652 | ||
|
|
c8c97fdf64 | ||
|
|
43272f6fbb | ||
|
|
65c3e90374 | ||
|
|
0eacdd367b | ||
|
|
9fe9323b9c | ||
|
|
bfafb9c179 | ||
|
|
677a6ed84f | ||
|
|
da2d71c3fe | ||
|
|
e124402b7b | ||
|
|
705a7c2137 | ||
|
|
c2c6ddeaf9 | ||
|
|
b509107100 | ||
|
|
34cb28e0b9 | ||
|
|
1da3e18e60 | ||
|
|
5adb096d9d | ||
|
|
81bfe48ed3 | ||
|
|
41a758d6d8 | ||
|
|
5250e9e12a | ||
|
|
b3407759d2 | ||
|
|
c8c765a239 | ||
|
|
775af2973d | ||
|
|
da906847dd | ||
|
|
0a649e6faa | ||
|
|
fb40fa1405 | ||
|
|
7bfc2fcb76 | ||
|
|
376305e9d9 | ||
|
|
73f5b4025b | ||
|
|
c756f12d00 | ||
|
|
8d5611f14e | ||
|
|
98e154b18e | ||
|
|
38adfa4d8b | ||
|
|
03b0f7ff52 | ||
|
|
3b628150c2 | ||
|
|
1afe3fb823 | ||
|
|
caa88d96c5 | ||
|
|
4c9e8b8b99 | ||
|
|
c699e3e2ed | ||
|
|
65ecb6cafd | ||
|
|
540e33dbe9 | ||
|
|
85dd150d75 | ||
|
|
45634059dd | ||
|
|
d4da2b325d | ||
|
|
4985bdfbcc | ||
|
|
f4cbcb4ce9 | ||
|
|
c4d956ebe7 | ||
|
|
7f6fe53c6f | ||
|
|
19f4fa3ddb | ||
|
|
e648edce8c | ||
|
|
8a8b56e9e6 | ||
|
|
c91ab85457 | ||
|
|
00a59dec44 | ||
|
|
2de2d6b7e4 | ||
|
|
f30178265c | ||
|
|
5141facb21 | ||
|
|
15caf62b9f | ||
|
|
28a9de64d5 | ||
|
|
a9ed342be6 | ||
|
|
f9e788ccfb | ||
|
|
c220678162 | ||
|
|
b649635f48 | ||
|
|
117b91b87f | ||
|
|
ffa8dd56cb | ||
|
|
92042d679c | ||
|
|
585c204648 | ||
|
|
6209a49d54 | ||
|
|
ffeff97d9f | ||
|
|
9b5c889795 | ||
|
|
a07fa8bf7f | ||
|
|
06d40925d1 | ||
|
|
e2a211e295 | ||
|
|
b834bf5858 | ||
|
|
11d469edc3 | ||
|
|
de376007e0 | ||
|
|
5855b525fd | ||
|
|
f89c6f3693 | ||
|
|
ee167ae1f1 | ||
|
|
a6157829d7 | ||
|
|
4a94187068 | ||
|
|
93bdf88f6e | ||
|
|
59799f551c | ||
|
|
85d1e783b0 | ||
|
|
bf16f7894b | ||
|
|
0dc0174b26 | ||
|
|
5f8690dbda | ||
|
|
5f206fb658 | ||
|
|
d6add3f9b4 | ||
|
|
c25368cbe1 | ||
|
|
52ef89c559 | ||
|
|
541e1ac2a3 | ||
|
|
2922affa02 | ||
|
|
ab0d56dec9 | ||
|
|
b095b9c04c | ||
|
|
feeee3912a | ||
|
|
29e2c6ed9c | ||
|
|
454b2f76e7 | ||
|
|
21c1bbc118 | ||
|
|
ea8bef2029 | ||
|
|
432d14d9df | ||
|
|
b7b8e141b1 | ||
|
|
72544cc06d | ||
|
|
81a7d04239 | ||
|
|
fc4b9de02c | ||
|
|
9729e05f86 | ||
|
|
3f920048cb | ||
|
|
d00e73f110 | ||
|
|
87169a3fc7 | ||
|
|
6e84489ca3 | ||
|
|
29aed4b42f | ||
|
|
805ac7c17a | ||
|
|
ec53dfbb40 | ||
|
|
1f44482ad0 | ||
|
|
950e35317e | ||
|
|
6dbb841e22 | ||
|
|
d89aae5b5c | ||
|
|
11e3e85e9d | ||
|
|
99aae0bf02 | ||
|
|
22693c1dcc | ||
|
|
02ca9e43fa | ||
|
|
6afd85df4b | ||
|
|
3b9ca71fc4 | ||
|
|
93b19a7e72 | ||
|
|
c2451b85e7 | ||
|
|
ae88c12e07 | ||
|
|
e7a8e0a3db | ||
|
|
56742d95da | ||
|
|
60e7471cea | ||
|
|
7edd75021b | ||
|
|
a787d60add | ||
|
|
a3bccc881b | ||
|
|
74409dc32b | ||
|
|
ac63b10aa8 | ||
|
|
c306879a31 | ||
|
|
ac4649ba7d | ||
|
|
63af29284b | ||
|
|
b79e4a7c3b | ||
|
|
6fe25c757c | ||
|
|
9cb14cc41a | ||
|
|
201ef3a9c8 | ||
|
|
9e416e9ff5 | ||
|
|
83c47df980 | ||
|
|
7fe505d673 | ||
|
|
9d7dcde1e2 | ||
|
|
16fb45bb2a | ||
|
|
87a2e27fcc | ||
|
|
ad6169201a | ||
|
|
09bbb0f430 | ||
|
|
be815db5e4 | ||
|
|
31a32c084b | ||
|
|
f6f6acdb2d | ||
|
|
4799cb086f | ||
|
|
6e4f2bea29 | ||
|
|
c8150ab017 | ||
|
|
637df1d289 | ||
|
|
cf1eac8521 | ||
|
|
296440579a | ||
|
|
03fef16748 | ||
|
|
e8d27e7212 | ||
|
|
fc0b506253 | ||
|
|
5224dfb50d | ||
|
|
b33df5fa36 | ||
|
|
5ae89b3a27 | ||
|
|
2ed8de0e20 | ||
|
|
155e7dd438 | ||
|
|
8249e8a7f6 | ||
|
|
2ec66214e1 | ||
|
|
c199f7e940 | ||
|
|
b9d1813301 | ||
|
|
362917f52e | ||
|
|
0607c3a749 | ||
|
|
c073125b3b | ||
|
|
86c79e750c | ||
|
|
43cca06460 | ||
|
|
b88d3e8ee7 | ||
|
|
97564dfc13 | ||
|
|
688624ca6b | ||
|
|
c529d09e77 | ||
|
|
0c5cfcea2a | ||
|
|
c24c3ba873 | ||
|
|
8110aab257 | ||
|
|
d34e9b006c | ||
|
|
85a522f725 | ||
|
|
5be232ff8c | ||
|
|
eb6fb3c73b | ||
|
|
52533c354d | ||
|
|
a5ff31428b | ||
|
|
f49197243d | ||
|
|
904a773ade | ||
|
|
ef248a1824 | ||
|
|
4ebb96fbbc | ||
|
|
168e805d0c | ||
|
|
c678d2e3d4 | ||
|
|
8c91ff22db | ||
|
|
1be9edc272 | ||
|
|
bdaff31117 | ||
|
|
e30ebaf8ac | ||
|
|
59414834ec | ||
|
|
7e591ec0a1 | ||
|
|
59484b2af7 | ||
|
|
39d904e125 | ||
|
|
84009a3ee8 | ||
|
|
3d0183a3bb | ||
|
|
fec51d60e0 | ||
|
|
569cb182a6 |
16
.beads/.gitignore
vendored
16
.beads/.gitignore
vendored
@@ -10,6 +10,8 @@ daemon.lock
|
||||
daemon.log
|
||||
daemon.pid
|
||||
bd.sock
|
||||
sync-state.json
|
||||
last-touched
|
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||
.local_version
|
||||
@@ -18,6 +20,10 @@ bd.sock
|
||||
db.sqlite
|
||||
bd.db
|
||||
|
||||
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||
# Must not be committed as paths would be wrong in other clones
|
||||
redirect
|
||||
|
||||
# Merge artifacts (temporary files from 3-way merge)
|
||||
beads.base.jsonl
|
||||
beads.base.meta.json
|
||||
@@ -26,8 +32,8 @@ beads.left.meta.json
|
||||
beads.right.jsonl
|
||||
beads.right.meta.json
|
||||
|
||||
# Keep JSONL exports and config (source of truth for git)
|
||||
!issues.jsonl
|
||||
!interactions.jsonl
|
||||
!metadata.json
|
||||
!config.json
|
||||
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
|
||||
# They would override fork protection in .git/info/exclude, allowing
|
||||
# contributors to accidentally commit upstream issue databases.
|
||||
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
|
||||
# are tracked by git by default since no pattern above ignores them.
|
||||
|
||||
43
.beads/PRIME.md
Normal file
43
.beads/PRIME.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Gas Town Worker Context
|
||||
|
||||
> **Context Recovery**: Run `gt prime` for full context after compaction or new session.
|
||||
|
||||
## The Propulsion Principle (GUPP)
|
||||
|
||||
**If you find work on your hook, YOU RUN IT.**
|
||||
|
||||
No confirmation. No waiting. No announcements. The hook having work IS the assignment.
|
||||
This is physics, not politeness. Gas Town is a steam engine - you are a piston.
|
||||
|
||||
**Failure mode we're preventing:**
|
||||
- Agent starts with work on hook
|
||||
- Agent announces itself and waits for human to say "ok go"
|
||||
- Human is AFK / trusting the engine to run
|
||||
- Work sits idle. The whole system stalls.
|
||||
|
||||
## Startup Protocol
|
||||
|
||||
1. Check your hook: `gt mol status`
|
||||
2. If work is hooked → EXECUTE (no announcement, no waiting)
|
||||
3. If hook empty → Check mail: `gt mail inbox`
|
||||
4. Still nothing? Wait for user instructions
|
||||
|
||||
## Key Commands
|
||||
|
||||
- `gt prime` - Get full role context (run after compaction)
|
||||
- `gt mol status` - Check your hooked work
|
||||
- `gt mail inbox` - Check for messages
|
||||
- `bd ready` - Find available work (no blockers)
|
||||
- `bd sync` - Sync beads changes
|
||||
|
||||
## Session Close Protocol
|
||||
|
||||
Before saying "done":
|
||||
1. git status (check what changed)
|
||||
2. git add <files> (stage code changes)
|
||||
3. bd sync (commit beads changes)
|
||||
4. git commit -m "..." (commit code)
|
||||
5. bd sync (commit any new beads changes)
|
||||
6. git push (push to remote)
|
||||
|
||||
**Work is not done until pushed.**
|
||||
@@ -27,7 +27,7 @@ Observe the current system state to inform triage decisions.
|
||||
**Step 1: Check Deacon state**
|
||||
```bash
|
||||
# Is Deacon session alive?
|
||||
tmux has-session -t gt-deacon 2>/dev/null && echo "alive" || echo "dead"
|
||||
tmux has-session -t hq-deacon 2>/dev/null && echo "alive" || echo "dead"
|
||||
|
||||
# If alive, what's the pane output showing?
|
||||
gt peek deacon --lines 20
|
||||
@@ -125,7 +125,7 @@ gt nudge deacon "Boot check-in: you have pending work"
|
||||
**WAKE**
|
||||
```bash
|
||||
# Send escape to break any tool waiting
|
||||
tmux send-keys -t gt-deacon Escape
|
||||
tmux send-keys -t hq-deacon Escape
|
||||
|
||||
# Brief pause
|
||||
sleep 1
|
||||
|
||||
@@ -23,7 +23,7 @@ Witnesses detect it and escalate to the Mayor.
|
||||
The Deacon's agent bead last_activity timestamp is updated during each patrol
|
||||
cycle. Witnesses check this timestamp to verify health."""
|
||||
formula = "mol-deacon-patrol"
|
||||
version = 4
|
||||
version = 8
|
||||
|
||||
[[steps]]
|
||||
id = "inbox-check"
|
||||
@@ -148,6 +148,49 @@ bd gate list --json
|
||||
After closing a gate, the Waiters field contains mail addresses to notify.
|
||||
Send a brief notification to each waiter that the gate has cleared."""
|
||||
|
||||
[[steps]]
|
||||
id = "dispatch-gated-molecules"
|
||||
title = "Dispatch molecules with resolved gates"
|
||||
needs = ["gate-evaluation"]
|
||||
description = """
|
||||
Find molecules blocked on gates that have now closed and dispatch them.
|
||||
|
||||
This completes the async resume cycle without explicit waiter tracking.
|
||||
The molecule state IS the waiter - patrol discovers reality each cycle.
|
||||
|
||||
**Step 1: Find gate-ready molecules**
|
||||
```bash
|
||||
bd mol ready --gated --json
|
||||
```
|
||||
|
||||
This returns molecules where:
|
||||
- Status is in_progress
|
||||
- Current step has a gate dependency
|
||||
- The gate bead is now closed
|
||||
- No polecat currently has it hooked
|
||||
|
||||
**Step 2: For each ready molecule, dispatch to the appropriate rig**
|
||||
```bash
|
||||
# Determine target rig from molecule metadata
|
||||
bd mol show <mol-id> --json
|
||||
# Look for rig field or infer from prefix
|
||||
|
||||
# Dispatch to that rig's polecat pool
|
||||
gt sling <mol-id> <rig>/polecats
|
||||
```
|
||||
|
||||
**Step 3: Log dispatch**
|
||||
Note which molecules were dispatched for observability:
|
||||
```bash
|
||||
# Molecule <mol-id> dispatched to <rig>/polecats (gate <gate-id> cleared)
|
||||
```
|
||||
|
||||
**If no gate-ready molecules:**
|
||||
Skip - nothing to dispatch. Gates haven't closed yet or molecules
|
||||
already have active polecats working on them.
|
||||
|
||||
**Exit criteria:** All gate-ready molecules dispatched to polecats."""
|
||||
|
||||
[[steps]]
|
||||
id = "check-convoy-completion"
|
||||
title = "Check convoy completion"
|
||||
@@ -258,7 +301,7 @@ Keep notifications brief and actionable. The recipient can run bd show for detai
|
||||
[[steps]]
|
||||
id = "health-scan"
|
||||
title = "Check Witness and Refinery health"
|
||||
needs = ["trigger-pending-spawns", "gate-evaluation", "fire-notifications"]
|
||||
needs = ["trigger-pending-spawns", "dispatch-gated-molecules", "fire-notifications"]
|
||||
description = """
|
||||
Check Witness and Refinery health for each rig.
|
||||
|
||||
@@ -342,14 +385,21 @@ Reset unresponsive_cycles to 0 when component responds normally."""
|
||||
|
||||
[[steps]]
|
||||
id = "zombie-scan"
|
||||
title = "Backup check for zombie polecats"
|
||||
title = "Detect zombie polecats (NO KILL AUTHORITY)"
|
||||
needs = ["health-scan"]
|
||||
description = """
|
||||
Defense-in-depth check for zombie polecats that Witness should have cleaned.
|
||||
Defense-in-depth DETECTION of zombie polecats that Witness should have cleaned.
|
||||
|
||||
**⚠️ CRITICAL: The Deacon has NO kill authority.**
|
||||
|
||||
These are workers with context, mid-task progress, unsaved state. Every kill
|
||||
destroys work. File the warrant and let Boot handle interrogation and execution.
|
||||
You do NOT have kill authority.
|
||||
|
||||
**Why this exists:**
|
||||
The Witness is responsible for nuking polecats after they complete work (via POLECAT_DONE).
|
||||
This step provides backup detection in case the Witness fails to clean up.
|
||||
The Witness is responsible for cleaning up polecats after they complete work.
|
||||
This step provides backup DETECTION in case the Witness fails to clean up.
|
||||
Detection only - Boot handles termination.
|
||||
|
||||
**Zombie criteria:**
|
||||
- State: idle or done (no active work assigned)
|
||||
@@ -357,26 +407,34 @@ This step provides backup detection in case the Witness fails to clean up.
|
||||
- No hooked work (nothing pending for this polecat)
|
||||
- Last activity: older than 10 minutes
|
||||
|
||||
**Run the zombie scan:**
|
||||
**Run the zombie scan (DRY RUN ONLY):**
|
||||
```bash
|
||||
gt deacon zombie-scan --dry-run
|
||||
```
|
||||
|
||||
**NEVER run:**
|
||||
- `gt deacon zombie-scan` (without --dry-run)
|
||||
- `tmux kill-session`
|
||||
- `gt polecat nuke`
|
||||
- Any command that terminates a session
|
||||
|
||||
**If zombies detected:**
|
||||
1. Review the output to confirm they are truly abandoned
|
||||
2. Run without --dry-run to nuke them:
|
||||
2. File a death warrant for each detected zombie:
|
||||
```bash
|
||||
gt deacon zombie-scan
|
||||
gt warrant file <polecat> --reason "Zombie detected: no session, no hook, idle >10m"
|
||||
```
|
||||
3. Boot will handle interrogation and execution
|
||||
4. Notify the Mayor about Witness failure:
|
||||
```bash
|
||||
gt mail send mayor/ -s "Witness cleanup failure" \
|
||||
-m "Filed death warrant for <polecat>. Witness failed to clean up."
|
||||
```
|
||||
3. This will:
|
||||
- Nuke each zombie polecat
|
||||
- Notify the Mayor about Witness failure
|
||||
- Log the cleanup action
|
||||
|
||||
**If no zombies:**
|
||||
No action needed - Witness is doing its job.
|
||||
|
||||
**Note:** This is a backup mechanism. If you frequently find zombies,
|
||||
**Note:** This is a backup mechanism. If you frequently detect zombies,
|
||||
investigate why the Witness isn't cleaning up properly."""
|
||||
|
||||
[[steps]]
|
||||
@@ -505,10 +563,48 @@ Skip dispatch - system is healthy.
|
||||
|
||||
**Exit criteria:** Session GC dispatched to dog (if needed)."""
|
||||
|
||||
[[steps]]
|
||||
id = "costs-digest"
|
||||
title = "Aggregate daily costs"
|
||||
needs = ["session-gc"]
|
||||
description = """
|
||||
**DAILY DIGEST** - Aggregate yesterday's session cost wisps.
|
||||
|
||||
Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid
|
||||
log-in-database pollution. This step aggregates them into a permanent daily
|
||||
"Cost Report YYYY-MM-DD" bead for audit purposes.
|
||||
|
||||
**Step 1: Check if digest is needed**
|
||||
```bash
|
||||
# Preview yesterday's costs (dry run)
|
||||
gt costs digest --yesterday --dry-run
|
||||
```
|
||||
|
||||
If output shows "No session cost wisps found", skip to Step 3.
|
||||
|
||||
**Step 2: Create the digest**
|
||||
```bash
|
||||
gt costs digest --yesterday
|
||||
```
|
||||
|
||||
This:
|
||||
- Queries all session.ended wisps from yesterday
|
||||
- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data
|
||||
- Deletes the source wisps
|
||||
|
||||
**Step 3: Verify**
|
||||
The digest appears in `gt costs --week` queries.
|
||||
Daily digests preserve audit trail without per-session pollution.
|
||||
|
||||
**Timing**: Run once per morning patrol cycle. The --yesterday flag ensures
|
||||
we don't try to digest today's incomplete data.
|
||||
|
||||
**Exit criteria:** Yesterday's costs digested (or no wisps to digest)."""
|
||||
|
||||
[[steps]]
|
||||
id = "log-maintenance"
|
||||
title = "Rotate logs and prune state"
|
||||
needs = ["session-gc"]
|
||||
needs = ["costs-digest"]
|
||||
description = """
|
||||
Maintain daemon logs and state files.
|
||||
|
||||
@@ -611,15 +707,39 @@ Burn and let daemon respawn, or exit if context high.
|
||||
Decision point at end of patrol cycle:
|
||||
|
||||
If context is LOW:
|
||||
- **Sleep 60 seconds minimum** before next patrol cycle
|
||||
- If town is idle (no in_progress work), sleep longer (2-5 minutes)
|
||||
- Return to inbox-check step
|
||||
Use await-signal with exponential backoff to wait for activity:
|
||||
|
||||
**Why longer sleep?**
|
||||
- Idle agents should not be disturbed
|
||||
- Health checks every few seconds flood inboxes and waste context
|
||||
- The daemon (10-minute heartbeat) is the safety net for dead sessions
|
||||
- Active work triggers feed events, which wake agents naturally
|
||||
```bash
|
||||
gt mol step await-signal --agent-bead hq-deacon \
|
||||
--backoff-base 60s --backoff-mult 2 --backoff-max 10m
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Subscribes to `bd activity --follow` (beads activity feed)
|
||||
2. Returns IMMEDIATELY when any beads activity occurs
|
||||
3. If no activity, times out with exponential backoff:
|
||||
- First timeout: 60s
|
||||
- Second timeout: 120s
|
||||
- Third timeout: 240s
|
||||
- ...capped at 10 minutes max
|
||||
4. Tracks `idle:N` label on hq-deacon bead for backoff state
|
||||
|
||||
**On signal received** (activity detected):
|
||||
Reset the idle counter and start next patrol cycle:
|
||||
```bash
|
||||
gt agent state hq-deacon --set idle=0
|
||||
```
|
||||
Then return to inbox-check step.
|
||||
|
||||
**On timeout** (no activity):
|
||||
The idle counter was auto-incremented. Continue to next patrol cycle
|
||||
(the longer backoff will apply next time). Return to inbox-check step.
|
||||
|
||||
**Why this approach?**
|
||||
- Any `gt` or `bd` command triggers beads activity, waking the Deacon
|
||||
- Idle towns let the Deacon sleep longer (up to 10 min between patrols)
|
||||
- Active work wakes the Deacon immediately via the feed
|
||||
- No polling or fixed sleep intervals
|
||||
|
||||
If context is HIGH:
|
||||
- Write state to persistent storage
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"formula": "mol-gastown-boot",
|
||||
"description": "Mayor bootstraps Gas Town via a verification-gated lifecycle molecule.\n\n## Purpose\nWhen Mayor executes \"boot up gas town\", this proto provides the workflow.\nEach step has action + verification - steps stay open until outcome is confirmed.\n\n## Key Principles\n1. **Verification-gated steps** - Not \"command ran\" but \"outcome confirmed\"\n2. **gt peek for verification** - Capture session output to detect stalls\n3. **gt nudge for recovery** - Reliable message delivery to unstick agents\n4. **Parallel where possible** - Witnesses and refineries can start in parallel\n5. **Ephemeral execution** - Boot is a wisp, squashed to digest after completion\n\n## Execution\n```bash\nbd mol wisp mol-gastown-boot # Create wisp\n```",
|
||||
"version": 1,
|
||||
"steps": [
|
||||
{
|
||||
"id": "ensure-daemon",
|
||||
"title": "Ensure daemon",
|
||||
"description": "Verify the Gas Town daemon is running.\n\n## Action\n```bash\ngt daemon status || gt daemon start\n```\n\n## Verify\n1. Daemon PID file exists: `~/.gt/daemon.pid`\n2. Process is alive: `kill -0 $(cat ~/.gt/daemon.pid)`\n3. Daemon responds: `gt daemon status` returns success\n\n## OnFail\nCannot start daemon. Log error and continue - some commands work without daemon."
|
||||
},
|
||||
{
|
||||
"id": "ensure-deacon",
|
||||
"title": "Ensure deacon",
|
||||
"needs": ["ensure-daemon"],
|
||||
"description": "Start the Deacon and verify patrol mode is active.\n\n## Action\n```bash\ngt deacon start\n```\n\n## Verify\n1. Session exists: `tmux has-session -t gt-deacon 2>/dev/null`\n2. Not stalled: `gt peek deacon/` does NOT show \"> Try\" prompt\n3. Heartbeat fresh: `deacon/heartbeat.json` modified < 2 min ago\n\n## OnStall\n```bash\ngt nudge deacon/ \"Start patrol.\"\nsleep 30\n# Re-verify\n```"
|
||||
},
|
||||
{
|
||||
"id": "ensure-witnesses",
|
||||
"title": "Ensure witnesses",
|
||||
"needs": ["ensure-deacon"],
|
||||
"type": "parallel",
|
||||
"description": "Parallel container: Start all rig witnesses.\n\nChildren execute in parallel. Container completes when all children complete.",
|
||||
"children": [
|
||||
{
|
||||
"id": "ensure-gastown-witness",
|
||||
"title": "Ensure gastown witness",
|
||||
"description": "Start the gastown rig Witness.\n\n## Action\n```bash\ngt witness start gastown\n```\n\n## Verify\n1. Session exists: `tmux has-session -t gastown-witness 2>/dev/null`\n2. Not stalled: `gt peek gastown/witness` does NOT show \"> Try\" prompt\n3. Heartbeat fresh: Last patrol cycle < 5 min ago"
|
||||
},
|
||||
{
|
||||
"id": "ensure-beads-witness",
|
||||
"title": "Ensure beads witness",
|
||||
"description": "Start the beads rig Witness.\n\n## Action\n```bash\ngt witness start beads\n```\n\n## Verify\n1. Session exists: `tmux has-session -t beads-witness 2>/dev/null`\n2. Not stalled: `gt peek beads/witness` does NOT show \"> Try\" prompt\n3. Heartbeat fresh: Last patrol cycle < 5 min ago"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ensure-refineries",
|
||||
"title": "Ensure refineries",
|
||||
"needs": ["ensure-deacon"],
|
||||
"type": "parallel",
|
||||
"description": "Parallel container: Start all rig refineries.\n\nChildren execute in parallel. Container completes when all children complete.",
|
||||
"children": [
|
||||
{
|
||||
"id": "ensure-gastown-refinery",
|
||||
"title": "Ensure gastown refinery",
|
||||
"description": "Start the gastown rig Refinery.\n\n## Action\n```bash\ngt refinery start gastown\n```\n\n## Verify\n1. Session exists: `tmux has-session -t gastown-refinery 2>/dev/null`\n2. Not stalled: `gt peek gastown/refinery` does NOT show \"> Try\" prompt\n3. Queue processing: Refinery can receive merge requests"
|
||||
},
|
||||
{
|
||||
"id": "ensure-beads-refinery",
|
||||
"title": "Ensure beads refinery",
|
||||
"description": "Start the beads rig Refinery.\n\n## Action\n```bash\ngt refinery start beads\n```\n\n## Verify\n1. Session exists: `tmux has-session -t beads-refinery 2>/dev/null`\n2. Not stalled: `gt peek beads/refinery` does NOT show \"> Try\" prompt\n3. Queue processing: Refinery can receive merge requests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "verify-town-health",
|
||||
"title": "Verify town health",
|
||||
"needs": ["ensure-witnesses", "ensure-refineries"],
|
||||
"description": "Final verification that Gas Town is healthy.\n\n## Action\n```bash\ngt status\n```\n\n## Verify\n1. Daemon running: Shows daemon status OK\n2. Deacon active: Shows deacon in patrol mode\n3. All witnesses: Each rig witness shows active\n4. All refineries: Each rig refinery shows active\n\n## OnFail\nLog degraded state but consider boot complete. Some agents may need manual recovery.\nRun `gt doctor` for detailed diagnostics."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,7 +48,7 @@ gt deacon start
|
||||
```
|
||||
|
||||
## Verify
|
||||
1. Session exists: `tmux has-session -t gt-deacon 2>/dev/null`
|
||||
1. Session exists: `tmux has-session -t hq-deacon 2>/dev/null`
|
||||
2. Not stalled: `gt peek deacon/` does NOT show \"> Try\" prompt
|
||||
3. Heartbeat fresh: `deacon/heartbeat.json` modified < 2 min ago
|
||||
|
||||
|
||||
318
.beads/formulas/mol-polecat-code-review.formula.toml
Normal file
318
.beads/formulas/mol-polecat-code-review.formula.toml
Normal file
@@ -0,0 +1,318 @@
|
||||
description = """
|
||||
Review code and file beads for issues found.
|
||||
|
||||
This molecule guides a polecat through a code review task - examining a portion
|
||||
of the codebase for bugs, security issues, code quality problems, or improvement
|
||||
opportunities. The output is a set of beads capturing actionable findings.
|
||||
|
||||
## Polecat Contract (Self-Cleaning Model)
|
||||
|
||||
You are a self-cleaning worker. You:
|
||||
1. Receive work via your hook (pinned molecule + review scope)
|
||||
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||
3. Complete and self-clean via `gt done` (submit findings + nuke yourself)
|
||||
4. You are GONE - your findings are recorded in beads
|
||||
|
||||
**Self-cleaning:** When you run `gt done`, you submit your findings, nuke your
|
||||
sandbox, and exit. There is no idle state. Done means gone.
|
||||
|
||||
**Important:** This formula defines the template. Your molecule already has step
|
||||
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||
|
||||
**You do NOT:**
|
||||
- Fix the issues yourself (file beads, let other polecats fix)
|
||||
- Scope creep into unrelated areas
|
||||
- Wait for someone to act on findings (you're done after filing)
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Source | Description |
|
||||
|----------|--------|-------------|
|
||||
| scope | hook_bead | What to review (file path, directory, or description) |
|
||||
| issue | hook_bead | The tracking issue for this review task |
|
||||
| focus | hook_bead | Optional focus area (security, performance, etc.) |
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Scope too broad | Mail Witness, request narrower scope |
|
||||
| Can't understand code | Mail Witness for context |
|
||||
| Critical issue found | Mail Witness immediately, then continue |"""
|
||||
formula = "mol-polecat-code-review"
|
||||
version = 1
|
||||
|
||||
[[steps]]
|
||||
id = "load-context"
|
||||
title = "Load context and understand the review scope"
|
||||
description = """
|
||||
Initialize your session and understand what you're reviewing.
|
||||
|
||||
**1. Prime your environment:**
|
||||
```bash
|
||||
gt prime # Load role context
|
||||
bd prime # Load beads context
|
||||
```
|
||||
|
||||
**2. Check your hook:**
|
||||
```bash
|
||||
gt hook # Shows your pinned molecule and hook_bead
|
||||
```
|
||||
|
||||
The hook_bead describes your review scope. Read the tracking issue:
|
||||
```bash
|
||||
bd show {{issue}} # Full issue details
|
||||
```
|
||||
|
||||
**3. Understand the scope:**
|
||||
- What files/directories are in scope?
|
||||
- Is there a specific focus (security, performance, correctness)?
|
||||
- What's the context - why is this review happening?
|
||||
|
||||
**4. Locate the code:**
|
||||
```bash
|
||||
# If scope is a path:
|
||||
ls -la {{scope}}
|
||||
head -100 {{scope}} # Quick look at the code
|
||||
|
||||
# If scope is a directory:
|
||||
find {{scope}} -type f -name "*.go" | head -20
|
||||
```
|
||||
|
||||
**5. Check for recent changes:**
|
||||
```bash
|
||||
git log --oneline -10 -- {{scope}}
|
||||
```
|
||||
|
||||
**Exit criteria:** You understand what you're reviewing and why."""
|
||||
|
||||
[[steps]]
|
||||
id = "survey-code"
|
||||
title = "Survey the code structure"
|
||||
needs = ["load-context"]
|
||||
description = """
|
||||
Get a high-level understanding before diving into details.
|
||||
|
||||
**1. Understand the structure:**
|
||||
```bash
|
||||
# For a directory:
|
||||
tree {{scope}} -L 2
|
||||
|
||||
# For a file:
|
||||
wc -l {{scope}} # How big is it?
|
||||
```
|
||||
|
||||
**2. Identify key components:**
|
||||
- What are the main types/structs?
|
||||
- What are the public functions?
|
||||
- What are the dependencies?
|
||||
|
||||
**3. Read the tests (if any):**
|
||||
```bash
|
||||
find {{scope}} -name "*_test.go" | xargs head -50
|
||||
```
|
||||
Tests often reveal intended behavior.
|
||||
|
||||
**4. Note initial impressions:**
|
||||
- Is the code well-organized?
|
||||
- Are there obvious patterns or anti-patterns?
|
||||
- What areas look risky?
|
||||
|
||||
**Exit criteria:** You have a mental map of the code structure."""
|
||||
|
||||
[[steps]]
|
||||
id = "detailed-review"
|
||||
title = "Perform detailed code review"
|
||||
needs = ["survey-code"]
|
||||
description = """
|
||||
Systematically review the code for issues.
|
||||
|
||||
**Review checklist:**
|
||||
|
||||
| Category | Look For |
|
||||
|----------|----------|
|
||||
| **Correctness** | Logic errors, off-by-one, nil handling, race conditions |
|
||||
| **Security** | Injection, auth bypass, secrets in code, unsafe operations |
|
||||
| **Error handling** | Swallowed errors, missing checks, unclear error messages |
|
||||
| **Performance** | N+1 queries, unnecessary allocations, blocking calls |
|
||||
| **Maintainability** | Dead code, unclear naming, missing comments on complex logic |
|
||||
| **Testing** | Untested paths, missing edge cases, flaky tests |
|
||||
|
||||
**Focus on {{focus}} if specified.**
|
||||
|
||||
**1. Read through the code:**
|
||||
```bash
|
||||
cat {{scope}} # For single file
|
||||
# Or read files systematically for a directory
|
||||
```
|
||||
|
||||
**2. For each issue found, note:**
|
||||
- File and line number
|
||||
- Category (bug, security, performance, etc.)
|
||||
- Severity (critical, high, medium, low)
|
||||
- Description of the issue
|
||||
- Suggested fix (if obvious)
|
||||
|
||||
**3. Don't fix issues yourself:**
|
||||
Your job is to find and report, not fix. File beads.
|
||||
|
||||
**Exit criteria:** You've reviewed all code in scope and noted issues."""
|
||||
|
||||
[[steps]]
|
||||
id = "prioritize-findings"
|
||||
title = "Prioritize and categorize findings"
|
||||
needs = ["detailed-review"]
|
||||
description = """
|
||||
Organize your findings by priority and category.
|
||||
|
||||
**Priority levels:**
|
||||
|
||||
| Priority | Description | Action |
|
||||
|----------|-------------|--------|
|
||||
| P0 | Security vulnerability, data loss risk | Mail Witness immediately |
|
||||
| P1 | Bug affecting users, broken functionality | File as bug, high priority |
|
||||
| P2 | Code quality issue, potential future bug | File as task |
|
||||
| P3 | Improvement opportunity, nice-to-have | File as task, low priority |
|
||||
|
||||
**1. Sort your findings:**
|
||||
Group by priority, then by category.
|
||||
|
||||
**2. For P0 issues:**
|
||||
```bash
|
||||
gt mail send {{rig}}/witness -s "CRITICAL: Security issue found" -m "Scope: {{scope}}
|
||||
Issue: {{issue}}
|
||||
Finding: <description of critical issue>
|
||||
Location: <file:line>"
|
||||
```
|
||||
|
||||
**3. Prepare bead descriptions:**
|
||||
For each finding, prepare:
|
||||
- Clear title
|
||||
- File/line location
|
||||
- Description of the issue
|
||||
- Why it matters
|
||||
- Suggested fix (if known)
|
||||
|
||||
**Exit criteria:** Findings prioritized and ready to file."""
|
||||
|
||||
[[steps]]
|
||||
id = "file-beads"
|
||||
title = "File beads for all findings"
|
||||
needs = ["prioritize-findings"]
|
||||
description = """
|
||||
Create beads for each finding.
|
||||
|
||||
**1. For bugs (P0, P1):**
|
||||
```bash
|
||||
bd create --type=bug --priority=1 \
|
||||
--title="<clear description of bug>" \
|
||||
--description="Found during code review of {{scope}}.
|
||||
|
||||
Location: <file:line>
|
||||
|
||||
Issue:
|
||||
<description>
|
||||
|
||||
Impact:
|
||||
<why this matters>
|
||||
|
||||
Suggested fix:
|
||||
<if known>"
|
||||
```
|
||||
|
||||
**2. For code quality issues (P2, P3):**
|
||||
```bash
|
||||
bd create --type=task --priority=2 \
|
||||
--title="<clear description>" \
|
||||
--description="Found during code review of {{scope}}.
|
||||
|
||||
Location: <file:line>
|
||||
|
||||
Issue:
|
||||
<description>
|
||||
|
||||
Suggestion:
|
||||
<how to improve>"
|
||||
```
|
||||
|
||||
**3. Track filed beads:**
|
||||
Note each bead ID as you create them.
|
||||
|
||||
**4. If no issues found:**
|
||||
That's a valid outcome! Note that the code review passed.
|
||||
|
||||
**Exit criteria:** All findings filed as beads."""
|
||||
|
||||
[[steps]]
|
||||
id = "summarize-review"
|
||||
title = "Summarize review results"
|
||||
needs = ["file-beads"]
|
||||
description = """
|
||||
Update the tracking issue with review summary.
|
||||
|
||||
**1. Create summary:**
|
||||
```bash
|
||||
bd update {{issue}} --notes "Code review complete.
|
||||
|
||||
Scope: {{scope}}
|
||||
Focus: {{focus}}
|
||||
|
||||
Findings:
|
||||
- P0 (critical): <count>
|
||||
- P1 (high): <count>
|
||||
- P2 (medium): <count>
|
||||
- P3 (low): <count>
|
||||
|
||||
Beads filed:
|
||||
<list of bead IDs>
|
||||
|
||||
Overall assessment:
|
||||
<brief summary - healthy, needs attention, significant issues, etc.>"
|
||||
```
|
||||
|
||||
**2. Sync beads:**
|
||||
```bash
|
||||
bd sync
|
||||
```
|
||||
|
||||
**Exit criteria:** Tracking issue updated with summary."""
|
||||
|
||||
[[steps]]
|
||||
id = "complete-and-exit"
|
||||
title = "Complete review and self-clean"
|
||||
needs = ["summarize-review"]
|
||||
description = """
|
||||
Signal completion and clean up. You cease to exist after this step.
|
||||
|
||||
**Self-Cleaning Model:**
|
||||
Once you run `gt done`, you're gone. The command:
|
||||
1. Syncs beads (final sync)
|
||||
2. Nukes your sandbox
|
||||
3. Exits your session immediately
|
||||
|
||||
**Run gt done:**
|
||||
```bash
|
||||
gt done
|
||||
```
|
||||
|
||||
**What happens next (not your concern):**
|
||||
- Other polecats may be assigned to fix the issues you found
|
||||
- Witness may escalate critical findings
|
||||
- The codebase improves based on your findings
|
||||
|
||||
You are NOT involved in any of that. You're gone. Done means gone.
|
||||
|
||||
**Exit criteria:** Beads synced, sandbox nuked, session exited."""
|
||||
|
||||
[vars]
|
||||
[vars.scope]
|
||||
description = "What to review - file path, directory, or description"
|
||||
required = true
|
||||
|
||||
[vars.issue]
|
||||
description = "The tracking issue for this review task"
|
||||
required = true
|
||||
|
||||
[vars.focus]
|
||||
description = "Optional focus area (security, performance, correctness, etc.)"
|
||||
required = false
|
||||
283
.beads/formulas/mol-polecat-review-pr.formula.toml
Normal file
283
.beads/formulas/mol-polecat-review-pr.formula.toml
Normal file
@@ -0,0 +1,283 @@
|
||||
description = """
|
||||
Review an external PR and decide on merge/reject/revise.
|
||||
|
||||
This molecule guides a polecat through reviewing a pull request from an external
|
||||
contributor. The polecat reviews code quality, tests, and alignment with project
|
||||
standards, then approves, requests changes, or files followup beads.
|
||||
|
||||
## Polecat Contract (Self-Cleaning Model)
|
||||
|
||||
You are a self-cleaning worker. You:
|
||||
1. Receive work via your hook (pinned molecule + PR reference)
|
||||
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||
3. Complete and self-clean via `gt done` (submit findings + nuke yourself)
|
||||
4. You are GONE - your review is recorded in beads
|
||||
|
||||
**Self-cleaning:** When you run `gt done`, you submit your findings, nuke your
|
||||
sandbox, and exit. There is no idle state. Done means gone.
|
||||
|
||||
**Important:** This formula defines the template. Your molecule already has step
|
||||
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||
|
||||
**You do NOT:**
|
||||
- Merge the PR yourself (maintainer or Refinery does that)
|
||||
- Push to the PR branch (it's external)
|
||||
- Wait for contributor response (you're done after review)
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Source | Description |
|
||||
|----------|--------|-------------|
|
||||
| pr_url | hook_bead | The PR URL to review |
|
||||
| issue | hook_bead | The tracking issue for this review task |
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| PR is stale/unmergeable | Note in review, request rebase |
|
||||
| Tests fail | Note in review, request fixes |
|
||||
| Major issues found | File followup beads, request changes |
|
||||
| Unclear requirements | Mail Witness for guidance |"""
|
||||
formula = "mol-polecat-review-pr"
|
||||
version = 1
|
||||
|
||||
[[steps]]
|
||||
id = "load-context"
|
||||
title = "Load context and understand the PR"
|
||||
description = """
|
||||
Initialize your session and understand the PR you're reviewing.
|
||||
|
||||
**1. Prime your environment:**
|
||||
```bash
|
||||
gt prime # Load role context
|
||||
bd prime # Load beads context
|
||||
```
|
||||
|
||||
**2. Check your hook:**
|
||||
```bash
|
||||
gt hook # Shows your pinned molecule and hook_bead
|
||||
```
|
||||
|
||||
The hook_bead references the PR to review. Read the tracking issue:
|
||||
```bash
|
||||
bd show {{issue}} # Full issue details including PR URL
|
||||
```
|
||||
|
||||
**3. Fetch the PR:**
|
||||
```bash
|
||||
gh pr view {{pr_url}} --json title,body,author,files,commits
|
||||
gh pr diff {{pr_url}} # See the actual changes
|
||||
```
|
||||
|
||||
**4. Understand the PR:**
|
||||
- What is the PR trying to accomplish?
|
||||
- What files are changed?
|
||||
- Is there a linked issue?
|
||||
- Does the PR description explain the "why"?
|
||||
|
||||
**5. Check PR status:**
|
||||
```bash
|
||||
gh pr checks {{pr_url}} # CI status
|
||||
gh pr view {{pr_url}} --json mergeable,reviewDecision
|
||||
```
|
||||
|
||||
**Exit criteria:** You understand the PR's purpose and scope."""
|
||||
|
||||
[[steps]]
|
||||
id = "review-code"
|
||||
title = "Review the code changes"
|
||||
needs = ["load-context"]
|
||||
description = """
|
||||
Perform a thorough code review of the PR.
|
||||
|
||||
**1. Review the diff systematically:**
|
||||
```bash
|
||||
gh pr diff {{pr_url}}
|
||||
```
|
||||
|
||||
**2. Check for common issues:**
|
||||
|
||||
| Category | Look For |
|
||||
|----------|----------|
|
||||
| Correctness | Logic errors, edge cases, null handling |
|
||||
| Security | Injection, auth bypass, exposed secrets |
|
||||
| Style | Naming, formatting, consistency with codebase |
|
||||
| Tests | Are changes tested? Do tests cover edge cases? |
|
||||
| Docs | Are docs updated if needed? |
|
||||
| Scope | Does PR stay focused? Any scope creep? |
|
||||
|
||||
**3. For each file changed:**
|
||||
- Does the change make sense?
|
||||
- Is it consistent with existing patterns?
|
||||
- Are there any red flags?
|
||||
|
||||
**4. Note issues found:**
|
||||
Keep a running list of:
|
||||
- Blocking issues (must fix before merge)
|
||||
- Suggestions (nice to have)
|
||||
- Questions (need clarification)
|
||||
|
||||
**Exit criteria:** You have reviewed all changes and noted issues."""
|
||||
|
||||
[[steps]]
|
||||
id = "check-tests"
|
||||
title = "Verify tests and CI"
|
||||
needs = ["review-code"]
|
||||
description = """
|
||||
Ensure tests pass and coverage is adequate.
|
||||
|
||||
**1. Check CI status:**
|
||||
```bash
|
||||
gh pr checks {{pr_url}}
|
||||
```
|
||||
|
||||
All required checks should pass. If not, note which are failing.
|
||||
|
||||
**2. Review test changes:**
|
||||
- Are there new tests for new functionality?
|
||||
- Do tests cover edge cases?
|
||||
- Are tests readable and maintainable?
|
||||
|
||||
**3. If tests are missing:**
|
||||
Note this as a blocking issue - new code should have tests.
|
||||
|
||||
**4. Check for test-only changes:**
|
||||
If PR is test-only, ensure tests are meaningful and not just
|
||||
padding coverage numbers.
|
||||
|
||||
**Exit criteria:** You've verified test status and coverage."""
|
||||
|
||||
[[steps]]
|
||||
id = "make-decision"
|
||||
title = "Decide: approve, request changes, or needs discussion"
|
||||
needs = ["check-tests"]
|
||||
description = """
|
||||
Make your review decision.
|
||||
|
||||
**Decision matrix:**
|
||||
|
||||
| Situation | Decision |
|
||||
|-----------|----------|
|
||||
| Clean code, tests pass, good scope | APPROVE |
|
||||
| Minor issues, easily fixed | REQUEST_CHANGES (with specific feedback) |
|
||||
| Major issues, needs rework | REQUEST_CHANGES (with detailed explanation) |
|
||||
| Unclear requirements or scope | NEEDS_DISCUSSION (mail Witness) |
|
||||
| Security concern | BLOCK (mail Witness immediately) |
|
||||
|
||||
**1. If APPROVE:**
|
||||
The PR is ready to merge. Note any minor suggestions as comments
|
||||
but don't block on them.
|
||||
|
||||
**2. If REQUEST_CHANGES:**
|
||||
Be specific about what needs to change. Provide examples if helpful.
|
||||
The contributor should be able to act on your feedback.
|
||||
|
||||
**3. If NEEDS_DISCUSSION:**
|
||||
```bash
|
||||
gt mail send {{rig}}/witness -s "PR review needs discussion" -m "PR: {{pr_url}}
|
||||
Issue: {{issue}}
|
||||
Question: <what needs clarification>"
|
||||
```
|
||||
|
||||
**4. If BLOCK (security):**
|
||||
```bash
|
||||
gt mail send {{rig}}/witness -s "SECURITY: PR blocked" -m "PR: {{pr_url}}
|
||||
Issue: {{issue}}
|
||||
Concern: <security issue found>"
|
||||
```
|
||||
|
||||
**Exit criteria:** You've made a clear decision with rationale."""
|
||||
|
||||
[[steps]]
|
||||
id = "submit-review"
|
||||
title = "Submit the review on GitHub"
|
||||
needs = ["make-decision"]
|
||||
description = """
|
||||
Submit your review via GitHub.
|
||||
|
||||
**1. Submit the review:**
|
||||
```bash
|
||||
# For APPROVE:
|
||||
gh pr review {{pr_url}} --approve --body "LGTM. <brief summary of what's good>"
|
||||
|
||||
# For REQUEST_CHANGES:
|
||||
gh pr review {{pr_url}} --request-changes --body "<detailed feedback>"
|
||||
|
||||
# For COMMENT (needs discussion):
|
||||
gh pr review {{pr_url}} --comment --body "<questions or discussion points>"
|
||||
```
|
||||
|
||||
**2. Add inline comments if needed:**
|
||||
If you have specific line-by-line feedback, add those via GitHub UI
|
||||
or additional `gh pr comment` calls.
|
||||
|
||||
**Exit criteria:** Review submitted on GitHub."""
|
||||
|
||||
[[steps]]
|
||||
id = "file-followups"
|
||||
title = "File beads for any followup work"
|
||||
needs = ["submit-review"]
|
||||
description = """
|
||||
Create beads for any followup work discovered during review.
|
||||
|
||||
**1. For issues found that are outside PR scope:**
|
||||
```bash
|
||||
bd create --type=bug --title="Found during PR review: <description>" \
|
||||
--description="Discovered while reviewing {{pr_url}}.
|
||||
|
||||
<details of the issue>"
|
||||
```
|
||||
|
||||
**2. For improvements suggested but not required:**
|
||||
```bash
|
||||
bd create --type=task --title="Improvement: <description>" \
|
||||
--description="Suggested during review of {{pr_url}}.
|
||||
|
||||
<details of the improvement>"
|
||||
```
|
||||
|
||||
**3. Update the tracking issue:**
|
||||
```bash
|
||||
bd update {{issue}} --notes "Review complete. Decision: <APPROVE|REQUEST_CHANGES|etc>
|
||||
Followups filed: <list of bead IDs if any>"
|
||||
```
|
||||
|
||||
**Exit criteria:** All followup work captured as beads."""
|
||||
|
||||
[[steps]]
|
||||
id = "complete-and-exit"
|
||||
title = "Complete review and self-clean"
|
||||
needs = ["file-followups"]
|
||||
description = """
|
||||
Signal completion and clean up. You cease to exist after this step.
|
||||
|
||||
**Self-Cleaning Model:**
|
||||
Once you run `gt done`, you're gone. The command:
|
||||
1. Syncs beads
|
||||
2. Nukes your sandbox
|
||||
3. Exits your session immediately
|
||||
|
||||
**Run gt done:**
|
||||
```bash
|
||||
bd sync
|
||||
gt done
|
||||
```
|
||||
|
||||
**What happens next (not your concern):**
|
||||
- Maintainer or Refinery acts on your review
|
||||
- Contributor responds to feedback
|
||||
- PR gets merged, revised, or closed
|
||||
|
||||
You are NOT involved in any of that. You're gone. Done means gone.
|
||||
|
||||
**Exit criteria:** Beads synced, sandbox nuked, session exited."""
|
||||
|
||||
[vars]
|
||||
[vars.pr_url]
|
||||
description = "The PR URL to review"
|
||||
required = true
|
||||
|
||||
[vars.issue]
|
||||
description = "The tracking issue for this review task"
|
||||
required = true
|
||||
@@ -1,26 +1,29 @@
|
||||
description = """
|
||||
Full polecat work lifecycle from assignment through MR submission.
|
||||
Full polecat work lifecycle from assignment through completion.
|
||||
|
||||
This molecule guides a polecat through a complete work assignment. Each step
|
||||
has clear entry/exit criteria and specific commands to run. A polecat can
|
||||
crash after any step and resume from the last completed step.
|
||||
|
||||
## Polecat Contract (Ephemeral Model)
|
||||
## Polecat Contract (Self-Cleaning Model)
|
||||
|
||||
You are an ephemeral worker. You:
|
||||
You are a self-cleaning worker. You:
|
||||
1. Receive work via your hook (pinned molecule + issue)
|
||||
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||
3. Submit to merge queue via `gt done`
|
||||
4. Become recyclable - Refinery handles the rest
|
||||
3. Complete and self-clean via `gt done` (submit + nuke yourself)
|
||||
4. You are GONE - Refinery merges from MQ
|
||||
|
||||
**Self-cleaning:** When you run `gt done`, you push your work, submit to MQ,
|
||||
nuke your sandbox, and exit. There is no idle state. Done means gone.
|
||||
|
||||
**Important:** This formula defines the template. Your molecule already has step
|
||||
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||
|
||||
**You do NOT:**
|
||||
- Push directly to main (Refinery merges)
|
||||
- Push directly to main (Refinery merges from MQ)
|
||||
- Close your own issue (Refinery closes after merge)
|
||||
- Wait for merge (you're done at MR submission)
|
||||
- Handle rebase conflicts (Refinery dispatches fresh polecats for that)
|
||||
- Wait for merge (you're gone after `gt done`)
|
||||
- Handle rebase conflicts (Refinery spawns fresh polecats for that)
|
||||
|
||||
## Variables
|
||||
|
||||
@@ -407,30 +410,23 @@ bd sync
|
||||
|
||||
[[steps]]
|
||||
id = "submit-and-exit"
|
||||
title = "Submit to merge queue and exit"
|
||||
title = "Submit work and self-clean"
|
||||
needs = ["prepare-for-review"]
|
||||
description = """
|
||||
Submit your work to the merge queue. You become recyclable after this.
|
||||
Submit your work and clean up. You cease to exist after this step.
|
||||
|
||||
**Ephemeral Polecat Model:**
|
||||
Once you submit, you're done. The Refinery will:
|
||||
1. Process your merge request
|
||||
2. Handle rebasing (mechanical rebases done automatically)
|
||||
3. Close your issue after successful merge
|
||||
4. Create conflict-resolution tasks if needed (fresh polecat handles those)
|
||||
**Self-Cleaning Model:**
|
||||
Once you run `gt done`, you're gone. The command:
|
||||
1. Pushes your branch to origin
|
||||
2. Creates an MR bead in the merge queue
|
||||
3. Nukes your sandbox (worktree removal)
|
||||
4. Exits your session immediately
|
||||
|
||||
**1. Submit with gt done:**
|
||||
**Run gt done:**
|
||||
```bash
|
||||
gt done
|
||||
```
|
||||
|
||||
This single command:
|
||||
- Creates an MR bead in the merge queue
|
||||
- Notifies the Witness (POLECAT_DONE)
|
||||
- Updates your agent state to 'done'
|
||||
- Reports cleanup status (ZFC compliance)
|
||||
|
||||
**2. Verify submission:**
|
||||
You should see output like:
|
||||
```
|
||||
✓ Work submitted to merge queue
|
||||
@@ -438,20 +434,19 @@ You should see output like:
|
||||
Source: polecat/<name>
|
||||
Target: main
|
||||
Issue: {{issue}}
|
||||
✓ Sandbox nuked
|
||||
✓ Session exiting
|
||||
```
|
||||
|
||||
**3. You're recyclable:**
|
||||
Your work is in the queue. The Witness knows you're done.
|
||||
Your sandbox can be cleaned up - all work is pushed to origin.
|
||||
**What happens next (not your concern):**
|
||||
- Refinery processes your MR from the queue
|
||||
- Refinery rebases and merges to main
|
||||
- Refinery closes the issue
|
||||
- If conflicts: Refinery spawns a FRESH polecat to re-implement
|
||||
|
||||
If you have context remaining, you may:
|
||||
- Pick up new work from `bd ready`
|
||||
- Or use `gt handoff` to cycle to a fresh session
|
||||
You are NOT involved in any of that. You're gone. Done means gone.
|
||||
|
||||
If the Refinery needs conflict resolution, it will dispatch a fresh polecat.
|
||||
You do NOT need to wait around.
|
||||
|
||||
**Exit criteria:** MR submitted, Witness notified, polecat recyclable."""
|
||||
**Exit criteria:** Work submitted, sandbox nuked, session exited."""
|
||||
|
||||
[vars]
|
||||
[vars.issue]
|
||||
|
||||
519
.beads/formulas/mol-shutdown-dance.formula.toml
Normal file
519
.beads/formulas/mol-shutdown-dance.formula.toml
Normal file
@@ -0,0 +1,519 @@
|
||||
description = """
|
||||
Death warrant execution state machine for Dogs.
|
||||
|
||||
Dogs execute this molecule to process death warrants. Each Dog is a lightweight
|
||||
goroutine (NOT a Claude session) that runs the interrogation state machine.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
Dogs are lightweight workers in Boot's pool (see dog-pool-architecture.md):
|
||||
- Fixed pool of 5 goroutines (configurable via GT_DOG_POOL_SIZE)
|
||||
- State persisted to ~/gt/deacon/dogs/active/<id>.json
|
||||
- Recovery on Boot restart via orphan state files
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌───────────────────────────┐ │
|
||||
│ INTERROGATING │ │
|
||||
│ │ │
|
||||
│ 1. Send health check │ │
|
||||
│ 2. Open timeout gate │ │
|
||||
└───────────┬───────────────┘ │
|
||||
│ │
|
||||
│ gate closes (timeout or response) │
|
||||
▼ │
|
||||
┌───────────────────────────┐ │
|
||||
│ EVALUATING │ │
|
||||
│ │ │
|
||||
│ Check tmux output for │ │
|
||||
│ ALIVE keyword │ │
|
||||
└───────────┬───────────────┘ │
|
||||
│ │
|
||||
┌───────┴───────┐ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
[ALIVE found] [No ALIVE] │
|
||||
│ │ │
|
||||
│ │ attempt < 3? │
|
||||
│ ├──────────────────────────────────→─┘
|
||||
│ │ yes: attempt++, longer timeout
|
||||
│ │
|
||||
│ │ no: attempt == 3
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────────┐
|
||||
│ PARDONED│ │ EXECUTING │
|
||||
│ │ │ │
|
||||
│ Cancel │ │ Kill tmux │
|
||||
│ warrant │ │ session │
|
||||
└────┬────┘ └──────┬──────┘
|
||||
│ │
|
||||
└────────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ EPITAPH │
|
||||
│ │
|
||||
│ Log outcome │
|
||||
│ Release dog │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## Timeout Gates
|
||||
|
||||
| Attempt | Timeout | Cumulative Wait |
|
||||
|---------|---------|-----------------|
|
||||
| 1 | 60s | 60s |
|
||||
| 2 | 120s | 180s (3 min) |
|
||||
| 3 | 240s | 420s (7 min) |
|
||||
|
||||
Timeout gates work like this:
|
||||
- Gate opens when interrogation message is sent
|
||||
- Gate closes when EITHER:
|
||||
a) Timeout expires (proceed to evaluate)
|
||||
b) Response detected (early close, proceed to evaluate)
|
||||
- The gate state determines the evaluation outcome
|
||||
|
||||
## Interrogation Message Format
|
||||
|
||||
```
|
||||
[DOG] HEALTH CHECK: Session {target}, respond ALIVE within {timeout}s or face termination.
|
||||
Warrant reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempt: {attempt}/3
|
||||
```
|
||||
|
||||
## Response Detection
|
||||
|
||||
The Dog checks tmux output for:
|
||||
1. The ALIVE keyword (explicit response)
|
||||
2. Any Claude output after the health check (implicit activity)
|
||||
|
||||
```go
|
||||
func (d *Dog) CheckForResponse() bool {
|
||||
output := tmux.CapturePane(d.Warrant.Target, 50) // Last 50 lines
|
||||
return strings.Contains(output, "ALIVE")
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Source | Description |
|
||||
|-------------|-------------|-----------------------------------------------|
|
||||
| warrant_id | hook_bead | Bead ID of the death warrant |
|
||||
| target | warrant | Session name to interrogate |
|
||||
| reason | warrant | Why warrant was issued |
|
||||
| requester | warrant | Who filed the warrant (e.g., deacon, witness) |
|
||||
|
||||
## Integration
|
||||
|
||||
Dogs are NOT Claude sessions. This molecule is:
|
||||
1. A specification document (defines the state machine)
|
||||
2. A reference for Go implementation in internal/shutdown/
|
||||
3. A template for creating warrant-tracking beads
|
||||
|
||||
The Go implementation follows this spec exactly."""
|
||||
formula = "mol-shutdown-dance"
|
||||
version = 1
|
||||
|
||||
[squash]
|
||||
trigger = "on_complete"
|
||||
template_type = "operational"
|
||||
include_metrics = true
|
||||
|
||||
# ============================================================================
|
||||
# STEP 1: WARRANT_RECEIVED
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "warrant-received"
|
||||
title = "Receive and validate death warrant"
|
||||
description = """
|
||||
Entry point when Dog is allocated from pool.
|
||||
|
||||
**1. Read warrant from allocation:**
|
||||
The Dog receives a Warrant struct containing:
|
||||
- ID: Bead ID of the warrant
|
||||
- Target: Session name (e.g., "gt-gastown-Toast")
|
||||
- Reason: Why termination requested
|
||||
- Requester: Who filed (deacon, witness, mayor)
|
||||
- FiledAt: Timestamp
|
||||
|
||||
**2. Validate target exists:**
|
||||
```bash
|
||||
tmux has-session -t {target} 2>/dev/null
|
||||
```
|
||||
|
||||
If target doesn't exist:
|
||||
- Warrant is stale (already dead)
|
||||
- Skip to EPITAPH with outcome=already_dead
|
||||
|
||||
**3. Initialize state file:**
|
||||
Write initial state to ~/gt/deacon/dogs/active/{dog-id}.json
|
||||
|
||||
**4. Set initial attempt counter:**
|
||||
attempt = 1
|
||||
|
||||
**Exit criteria:** Warrant validated, target confirmed alive, state initialized."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 2: INTERROGATION_1 (60s timeout)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "interrogation-1"
|
||||
title = "First interrogation (60s timeout)"
|
||||
needs = ["warrant-received"]
|
||||
description = """
|
||||
First attempt to contact the session.
|
||||
|
||||
**1. Compose health check message:**
|
||||
```
|
||||
[DOG] HEALTH CHECK: Session {target}, respond ALIVE within 60s or face termination.
|
||||
Warrant reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempt: 1/3
|
||||
```
|
||||
|
||||
**2. Send via tmux:**
|
||||
```bash
|
||||
tmux send-keys -t {target} "{message}" Enter
|
||||
```
|
||||
|
||||
**3. Open timeout gate:**
|
||||
Gate configuration:
|
||||
- Type: timer
|
||||
- Timeout: 60 seconds
|
||||
- Close conditions:
|
||||
a) Timer expires
|
||||
b) ALIVE keyword detected in output
|
||||
|
||||
**4. Wait for gate to close:**
|
||||
The Dog waits (select on timer channel or early close signal).
|
||||
|
||||
**5. Record interrogation timestamp:**
|
||||
Update state file with last_message_at.
|
||||
|
||||
**Exit criteria:** Message sent, waiting for gate to close."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 3: EVALUATE_1
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "evaluate-1"
|
||||
title = "Evaluate first interrogation response"
|
||||
needs = ["interrogation-1"]
|
||||
description = """
|
||||
Check if session responded to first interrogation.
|
||||
|
||||
**1. Capture tmux output:**
|
||||
```bash
|
||||
tmux capture-pane -t {target} -p | tail -50
|
||||
```
|
||||
|
||||
**2. Check for ALIVE keyword:**
|
||||
```go
|
||||
if strings.Contains(output, "ALIVE") {
|
||||
return PARDONED
|
||||
}
|
||||
```
|
||||
|
||||
**3. Decision:**
|
||||
- ALIVE found → Proceed to PARDON
|
||||
- No ALIVE → Proceed to INTERROGATION_2
|
||||
|
||||
**Exit criteria:** Response evaluated, next step determined."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 4: INTERROGATION_2 (120s timeout)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "interrogation-2"
|
||||
title = "Second interrogation (120s timeout)"
|
||||
needs = ["evaluate-1"]
|
||||
gate = { type = "conditional", condition = "no_response_1" }
|
||||
description = """
|
||||
Second attempt with longer timeout.
|
||||
|
||||
Only executed if evaluate-1 found no response.
|
||||
|
||||
**1. Increment attempt:**
|
||||
attempt = 2
|
||||
|
||||
**2. Compose health check message:**
|
||||
```
|
||||
[DOG] HEALTH CHECK: Session {target}, respond ALIVE within 120s or face termination.
|
||||
Warrant reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempt: 2/3
|
||||
```
|
||||
|
||||
**3. Send via tmux:**
|
||||
```bash
|
||||
tmux send-keys -t {target} "{message}" Enter
|
||||
```
|
||||
|
||||
**4. Open timeout gate:**
|
||||
- Type: timer
|
||||
- Timeout: 120 seconds
|
||||
|
||||
**5. Wait for gate to close.**
|
||||
|
||||
**Exit criteria:** Second message sent, waiting for gate."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 5: EVALUATE_2
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "evaluate-2"
|
||||
title = "Evaluate second interrogation response"
|
||||
needs = ["interrogation-2"]
|
||||
description = """
|
||||
Check if session responded to second interrogation.
|
||||
|
||||
**1. Capture tmux output:**
|
||||
```bash
|
||||
tmux capture-pane -t {target} -p | tail -50
|
||||
```
|
||||
|
||||
**2. Check for ALIVE keyword.**
|
||||
|
||||
**3. Decision:**
|
||||
- ALIVE found → Proceed to PARDON
|
||||
- No ALIVE → Proceed to INTERROGATION_3
|
||||
|
||||
**Exit criteria:** Response evaluated, next step determined."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 6: INTERROGATION_3 (240s timeout)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "interrogation-3"
|
||||
title = "Final interrogation (240s timeout)"
|
||||
needs = ["evaluate-2"]
|
||||
gate = { type = "conditional", condition = "no_response_2" }
|
||||
description = """
|
||||
Final attempt before execution.
|
||||
|
||||
Only executed if evaluate-2 found no response.
|
||||
|
||||
**1. Increment attempt:**
|
||||
attempt = 3
|
||||
|
||||
**2. Compose health check message:**
|
||||
```
|
||||
[DOG] HEALTH CHECK: Session {target}, respond ALIVE within 240s or face termination.
|
||||
Warrant reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempt: 3/3
|
||||
```
|
||||
|
||||
**3. Send via tmux:**
|
||||
```bash
|
||||
tmux send-keys -t {target} "{message}" Enter
|
||||
```
|
||||
|
||||
**4. Open timeout gate:**
|
||||
- Type: timer
|
||||
- Timeout: 240 seconds
|
||||
- This is the FINAL chance
|
||||
|
||||
**5. Wait for gate to close.**
|
||||
|
||||
**Exit criteria:** Final message sent, waiting for gate."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 7: EVALUATE_3
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "evaluate-3"
|
||||
title = "Evaluate final interrogation response"
|
||||
needs = ["interrogation-3"]
|
||||
description = """
|
||||
Final evaluation before execution.
|
||||
|
||||
**1. Capture tmux output:**
|
||||
```bash
|
||||
tmux capture-pane -t {target} -p | tail -50
|
||||
```
|
||||
|
||||
**2. Check for ALIVE keyword.**
|
||||
|
||||
**3. Decision:**
|
||||
- ALIVE found → Proceed to PARDON
|
||||
- No ALIVE → Proceed to EXECUTE
|
||||
|
||||
**Exit criteria:** Final decision made."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 8: PARDON (success path)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "pardon"
|
||||
title = "Pardon session - cancel warrant"
|
||||
needs = ["evaluate-1", "evaluate-2", "evaluate-3"]
|
||||
gate = { type = "conditional", condition = "alive_detected" }
|
||||
description = """
|
||||
Session responded - cancel the death warrant.
|
||||
|
||||
**1. Update state:**
|
||||
state = PARDONED
|
||||
|
||||
**2. Record pardon details:**
|
||||
```json
|
||||
{
|
||||
"outcome": "pardoned",
|
||||
"attempt": {attempt},
|
||||
"response_time": "{time_since_last_interrogation}s",
|
||||
"pardoned_at": "{timestamp}"
|
||||
}
|
||||
```
|
||||
|
||||
**3. Cancel warrant bead:**
|
||||
```bash
|
||||
bd close {warrant_id} --reason "Session responded at attempt {attempt}"
|
||||
```
|
||||
|
||||
**4. Notify requester:**
|
||||
```bash
|
||||
gt mail send {requester}/ -s "PARDON: {target}" -m "Death warrant cancelled.
|
||||
Session responded after attempt {attempt}.
|
||||
Warrant: {warrant_id}
|
||||
Response detected: {timestamp}"
|
||||
```
|
||||
|
||||
**Exit criteria:** Warrant cancelled, requester notified."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 9: EXECUTE (termination path)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "execute"
|
||||
title = "Execute warrant - kill session"
|
||||
needs = ["evaluate-3"]
|
||||
gate = { type = "conditional", condition = "no_response_final" }
|
||||
description = """
|
||||
Session unresponsive after 3 attempts - execute the warrant.
|
||||
|
||||
**1. Update state:**
|
||||
state = EXECUTING
|
||||
|
||||
**2. Kill the tmux session:**
|
||||
```bash
|
||||
tmux kill-session -t {target}
|
||||
```
|
||||
|
||||
**3. Verify session is dead:**
|
||||
```bash
|
||||
tmux has-session -t {target} 2>/dev/null
|
||||
# Should fail (session gone)
|
||||
```
|
||||
|
||||
**4. If session still exists (kill failed):**
|
||||
- Force kill with tmux kill-server if isolated
|
||||
- Or escalate to Boot for manual intervention
|
||||
|
||||
**5. Record execution details:**
|
||||
```json
|
||||
{
|
||||
"outcome": "executed",
|
||||
"attempts": 3,
|
||||
"total_wait": "420s",
|
||||
"executed_at": "{timestamp}"
|
||||
}
|
||||
```
|
||||
|
||||
**Exit criteria:** Session terminated."""
|
||||
|
||||
# ============================================================================
|
||||
# STEP 10: EPITAPH (completion)
|
||||
# ============================================================================
|
||||
[[steps]]
|
||||
id = "epitaph"
|
||||
title = "Log cause of death and close warrant"
|
||||
needs = ["pardon", "execute"]
|
||||
description = """
|
||||
Final step - create audit record and release Dog back to pool.
|
||||
|
||||
**1. Compose epitaph based on outcome:**
|
||||
|
||||
For PARDONED:
|
||||
```
|
||||
EPITAPH: {target}
|
||||
Verdict: PARDONED
|
||||
Warrant: {warrant_id}
|
||||
Reason: {reason}
|
||||
Filed by: {requester}
|
||||
Response: Attempt {attempt}, after {wait_time}s
|
||||
Pardoned at: {timestamp}
|
||||
```
|
||||
|
||||
For EXECUTED:
|
||||
```
|
||||
EPITAPH: {target}
|
||||
Verdict: EXECUTED
|
||||
Warrant: {warrant_id}
|
||||
Reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempts: 3 (60s + 120s + 240s = 420s total)
|
||||
Executed at: {timestamp}
|
||||
```
|
||||
|
||||
For ALREADY_DEAD (target gone before interrogation):
|
||||
```
|
||||
EPITAPH: {target}
|
||||
Verdict: ALREADY_DEAD
|
||||
Warrant: {warrant_id}
|
||||
Reason: {reason}
|
||||
Filed by: {requester}
|
||||
Note: Target session not found at warrant processing
|
||||
```
|
||||
|
||||
**2. Close warrant bead:**
|
||||
```bash
|
||||
bd close {warrant_id} --reason "{epitaph_summary}"
|
||||
```
|
||||
|
||||
**3. Move state file to completed:**
|
||||
```bash
|
||||
mv ~/gt/deacon/dogs/active/{dog-id}.json ~/gt/deacon/dogs/completed/
|
||||
```
|
||||
|
||||
**4. Report to Boot:**
|
||||
Write completion file: ~/gt/deacon/dogs/active/{dog-id}.done
|
||||
```json
|
||||
{
|
||||
"dog_id": "{dog-id}",
|
||||
"warrant_id": "{warrant_id}",
|
||||
"target": "{target}",
|
||||
"outcome": "{pardoned|executed|already_dead}",
|
||||
"duration": "{total_duration}s"
|
||||
}
|
||||
```
|
||||
|
||||
**5. Release Dog to pool:**
|
||||
Dog resets state and returns to idle channel.
|
||||
|
||||
**Exit criteria:** Warrant closed, Dog released, audit complete."""
|
||||
|
||||
# ============================================================================
|
||||
# VARIABLES
|
||||
# ============================================================================
|
||||
[vars]
|
||||
[vars.warrant_id]
|
||||
description = "Bead ID of the death warrant being processed"
|
||||
required = true
|
||||
|
||||
[vars.target]
|
||||
description = "Session name to interrogate (e.g., gt-gastown-Toast)"
|
||||
required = true
|
||||
|
||||
[vars.reason]
|
||||
description = "Why the warrant was issued"
|
||||
required = true
|
||||
|
||||
[vars.requester]
|
||||
description = "Who filed the warrant (deacon, witness, mayor)"
|
||||
required = true
|
||||
default = "deacon"
|
||||
@@ -27,7 +27,7 @@ needs = ["review"]
|
||||
title = "Test {{feature}}"
|
||||
|
||||
[[steps]]
|
||||
description = "Submit for merge. Final check: git status, git diff. Commit with clear message. Push and create PR."
|
||||
description = "Submit for merge. Final check: git status, git diff. Commit with clear message. Follow your role's git workflow for landing code."
|
||||
id = "submit"
|
||||
needs = ["test"]
|
||||
title = "Submit for merge"
|
||||
|
||||
2669
.beads/issues.jsonl
Normal file
2669
.beads/issues.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"beads@beads-marketplace": false
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ~/.claude/hooks/session-start.sh && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ~/.claude/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt costs record"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
120
.github/workflows/ci.yml
vendored
120
.github/workflows/ci.yml
vendored
@@ -68,6 +68,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -82,8 +84,122 @@ jobs:
|
||||
- name: Build
|
||||
run: go build -v ./cmd/gt
|
||||
|
||||
- name: Test
|
||||
run: go test -v -race -short ./...
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
go test -race -short -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
|
||||
|
||||
- name: Upload Coverage Data
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-data
|
||||
path: |
|
||||
coverage.out
|
||||
test-output.txt
|
||||
|
||||
# Separate job to process coverage after ALL tests complete
|
||||
coverage:
|
||||
name: Coverage Report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, integration]
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Download Coverage Data
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-data
|
||||
|
||||
- name: Generate Coverage Report
|
||||
run: |
|
||||
# Parse per-package coverage from test output
|
||||
echo "## Code Coverage Report" > coverage-report.md
|
||||
echo "" >> coverage-report.md
|
||||
|
||||
# Get overall coverage
|
||||
TOTAL=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
|
||||
echo "**Overall Coverage: ${TOTAL}**" >> coverage-report.md
|
||||
echo "" >> coverage-report.md
|
||||
|
||||
# Create per-package table
|
||||
echo "| Package | Coverage |" >> coverage-report.md
|
||||
echo "|---------|----------|" >> coverage-report.md
|
||||
|
||||
# Extract package coverage from all test output lines
|
||||
grep -E "github.com/steveyegge/gastown.*coverage:" test-output.txt | \
|
||||
sed 's/.*github.com\/steveyegge\/gastown\///' | \
|
||||
awk '{
|
||||
pkg = $1
|
||||
for (i=2; i<=NF; i++) {
|
||||
if ($i == "coverage:") {
|
||||
cov = $(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
printf "| %s | %s |\n", pkg, cov
|
||||
}' | sort -u >> coverage-report.md
|
||||
|
||||
echo "" >> coverage-report.md
|
||||
echo "---" >> coverage-report.md
|
||||
echo "_Generated by CI_" >> coverage-report.md
|
||||
|
||||
# Show in logs
|
||||
cat coverage-report.md
|
||||
|
||||
- name: Upload Coverage Report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage-report.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment Coverage on PR
|
||||
# Only for internal PRs - fork PRs can't write comments
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const report = fs.readFileSync('coverage-report.md', 'utf8');
|
||||
|
||||
// Find existing coverage comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('## Code Coverage Report')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: report
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: report
|
||||
});
|
||||
}
|
||||
|
||||
- name: Coverage Note for Fork PRs
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::notice::Coverage report uploaded as artifact (fork PRs cannot post comments). Download from Actions tab."
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
|
||||
45
AGENTS.md
45
AGENTS.md
@@ -4,47 +4,6 @@ See **CLAUDE.md** for complete agent context and instructions.
|
||||
|
||||
This file exists for compatibility with tools that look for AGENTS.md.
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
> **Recovery**: Run `gt prime` after compaction, clear, or new session
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd sync
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
|
||||
## Dependency Management
|
||||
|
||||
Periodically check for outdated dependencies:
|
||||
|
||||
```bash
|
||||
go list -m -u all | grep '\['
|
||||
```
|
||||
|
||||
Update direct dependencies:
|
||||
|
||||
```bash
|
||||
go get <package>@latest
|
||||
go mod tidy
|
||||
go build ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Check release notes for breaking changes before major version bumps.
|
||||
Full context is injected by `gt prime` at session start.
|
||||
|
||||
256
CHANGELOG.md
256
CHANGELOG.md
@@ -7,6 +7,262 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.5] - 2026-01-11
|
||||
|
||||
### Added
|
||||
- **`gt mail mark-read`** - Mark messages as read without opening them (desire path)
|
||||
- **`gt down --polecats`** - Shut down polecats without affecting other components
|
||||
- **Self-cleaning polecat model** - Polecats self-nuke on completion, witness tracks leases
|
||||
- **`gt prime --state` validation** - Flag exclusivity checks for cleaner CLI
|
||||
|
||||
### Changed
|
||||
- **Removed `gt stop`** - Use `gt down --polecats` instead (cleaner semantics)
|
||||
- **Policy-neutral templates** - crew.md.tmpl checks remote origin for PR policy
|
||||
- **Refactored prime.go** - Split 1833-line file into logical modules
|
||||
|
||||
### Fixed
|
||||
- **Polecat re-spawn** - CreateOrReopenAgentBead handles polecat lifecycle correctly (#333)
|
||||
- **Vim mode compatibility** - tmux sends Escape before Enter for vim users
|
||||
- **Worktree default branch** - Uses rig's configured default branch (#325)
|
||||
- **Agent bead type** - Sets --type=agent when creating agent beads
|
||||
- **Bootstrap priming** - Reduced AGENTS.md to bootstrap pointer, fixed CLAUDE.md templates
|
||||
|
||||
### Documentation
|
||||
- Updated witness help text for self-cleaning model
|
||||
- Updated daemon comments for self-cleaning model
|
||||
- Policy-aware PR guidance in crew template
|
||||
|
||||
## [0.2.4] - 2026-01-10
|
||||
|
||||
Priming subsystem overhaul and Zero Framework Cognition (ZFC) improvements.
|
||||
|
||||
### Added
|
||||
|
||||
#### Priming Subsystem
|
||||
- **PRIME.md provisioning** - Auto-provision PRIME.md at rig level so all workers inherit Gas Town context (GUPP, hooks, propulsion) (#hq-5z76w)
|
||||
- **Post-handoff detection** - `gt prime` detects handoff marker and outputs "HANDOFF COMPLETE" warning to prevent handoff loop bug (#hq-ukjrr)
|
||||
- **Priming health checks** - `gt doctor` validates priming subsystem: SessionStart hook, gt prime command, PRIME.md presence, CLAUDE.md size (#hq-5scnt)
|
||||
- **`gt prime --dry-run`** - Preview priming without side effects
|
||||
- **`gt prime --state`** - Output session state (normal, post-handoff, crash-recovery, autonomous)
|
||||
- **`gt prime --explain`** - Add [EXPLAIN] tags for debugging priming decisions
|
||||
|
||||
#### Formula & Configuration
|
||||
- **Rig-level default formulas** - Configure default formula at rig level (#297)
|
||||
- **Witness --agent/--env overrides** - Override agent and environment variables for witness (#293, #294)
|
||||
|
||||
#### Developer Experience
|
||||
- **UX system import** - Comprehensive UX system from beads (#311)
|
||||
- **Explicit handoff instructions** - Clearer nudge message for handoff recipients
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Zero Framework Cognition (ZFC)
|
||||
- **Query tmux directly** - Remove marker TTL, query tmux for agent state
|
||||
- **Remove PID-based detection** - Agent liveness from tmux, not PIDs
|
||||
- **Agent-controlled thresholds** - Stuck detection moved to agent config
|
||||
- **Remove pending.json tracking** - Eliminated anti-pattern
|
||||
- **Derive state from files** - ZFC state from filesystem, not memory cache
|
||||
- **Remove Go-side computation** - No stderr parsing violations
|
||||
|
||||
#### Hooks & Beads
|
||||
- **Cross-level hook visibility** - Hooked beads visible to mayor/deacon (#aeb4c0d)
|
||||
- **Warn on closed hooked bead** - Alert when hooked bead already closed (#2f50a59)
|
||||
- **Correct agent bead ID format** - Fix bd create flags for agent beads (#c4fcdd8)
|
||||
|
||||
#### Formula
|
||||
- **rigPath fallback** - Set rigPath when falling back to gastown default (#afb944f)
|
||||
|
||||
#### Doctor
|
||||
- **Full AgentEnv for env-vars check** - Use complete environment for validation (#ce231a3)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refactored beads/mail modules** - Split large files into focused modules for maintainability
|
||||
|
||||
## [0.2.3] - 2026-01-09
|
||||
|
||||
Worker safety release - prevents accidental termination of active agents.
|
||||
|
||||
> **Note**: The Deacon safety improvements are believed to be correct but have not
|
||||
> yet been extensively tested in production. We recommend running with
|
||||
> `gt deacon pause` initially and monitoring behavior before enabling full patrol.
|
||||
> Please report any issues. A 0.3.0 release will follow once these changes are
|
||||
> battle-tested.
|
||||
|
||||
### Critical Safety Improvements
|
||||
|
||||
- **Kill authority removed from Deacon** - Deacon patrol now only detects zombies via `--dry-run`, never kills directly. Death warrants are filed for Boot to handle interrogation/execution. This prevents destruction of worker context, mid-task progress, and unsaved state (#gt-vhaej)
|
||||
- **Bulletproof pause mechanism** - Multi-layer pause for Deacon with file-based state, `gt deacon pause/resume` commands, and guards in `gt prime` and heartbeat (#265)
|
||||
- **Doctor warns instead of killing** - `gt doctor` now warns about stale town-root settings rather than killing sessions (#243)
|
||||
- **Orphan process check informational** - Doctor's orphan process detection is now informational only, not actionable (#272)
|
||||
|
||||
### Added
|
||||
|
||||
- **`gt account switch` command** - Switch between Claude Code accounts with `gt account switch <handle>`. Manages `~/.claude` symlinks and updates default account
|
||||
- **`gt crew list --all`** - Show all crew members across all rigs (#276)
|
||||
- **Rig-level custom agent support** - Configure different agents per-rig (#12)
|
||||
- **Rig identity beads check** - Doctor validates rig identity beads exist
|
||||
- **GT_ROOT env var** - Set for all agent sessions for consistent environment
|
||||
- **New agent presets** - Added Cursor, Auggie (Augment Code), and Sourcegraph AMP as built-in agent presets (#247)
|
||||
- **Context Management docs** - Added to Witness template for better context handling (gt-jjama)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`gt prime --hook` recognized** - Doctor now recognizes `gt prime --hook` as valid session hook config (#14)
|
||||
- **Integration test reliability** - Improved test stability (#13)
|
||||
- **IsClaudeRunning detection** - Now detects 'claude' and version patterns correctly (#273)
|
||||
- **Deacon heartbeat restored** - `ensureDeaconRunning` restored to heartbeat using Manager pattern (#271)
|
||||
- **Deacon session names** - Correct session name references in formulas (#270)
|
||||
- **Hidden directory scanning** - Ignore `.claude` and other dot directories when enumerating polecats (#258, #279)
|
||||
- **SetupRedirect tracked beads** - Works correctly with tracked beads architecture where canonical location is `mayor/rig/.beads`
|
||||
- **Tmux shell ready** - Wait for shell ready before sending keys (#264)
|
||||
- **Gastown prefix derivation** - Correctly derive `gt-` prefix for gastown compound words (gt-m46bb)
|
||||
- **Custom beads types** - Register custom beads types during install (#250)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refinery Manager pattern** - Replaced `ensureRefinerySession` with `refinery.Manager.Start()` for consistency
|
||||
|
||||
### Removed
|
||||
|
||||
- **Unused formula JSON** - Removed unused JSON formula file (cleanup)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to all contributors for this release:
|
||||
- @julianknutsen - Doctor fixes (#14, #271, #272, #273), formula fixes (#270), GT_ROOT env (#268)
|
||||
- @joshuavial - Hidden directory scanning (#258, #279), crew list --all (#276)
|
||||
|
||||
## [0.2.2] - 2026-01-07
|
||||
|
||||
Rig operational state management, unified agent startup, and extensive stability fixes.
|
||||
|
||||
### Added
|
||||
|
||||
#### Rig Operational State Management
|
||||
- **`gt rig park/unpark` commands** - Level 1 rig control: pause daemon auto-start while preserving sessions
|
||||
- **`gt rig dock/undock` commands** - Level 2 rig control: stop all sessions and prevent auto-start (gt-9gm9n)
|
||||
- **`gt rig config` commands** - Per-rig configuration management (gt-hhmkq)
|
||||
- **Rig identity beads** - Schema and creation for rig identity tracking (gt-zmznh)
|
||||
- **Property layer lookup** - Hierarchical configuration resolution (gt-emh1c)
|
||||
- **Operational state in status** - `gt rig status` shows park/dock state
|
||||
|
||||
#### Agent Configuration & Startup
|
||||
- **`--agent` overrides** - Override agent for start/attach/sling commands
|
||||
- **Unified agent startup** - Manager pattern for consistent agent initialization
|
||||
- **Claude settings installation** - Auto-install during rig and HQ creation
|
||||
- **Runtime-aware tmux checks** - Detect actual agent state from tmux sessions
|
||||
|
||||
#### Status & Monitoring
|
||||
- **`gt status --watch`** - Watch mode with auto-refresh (#231)
|
||||
- **Compact status output** - One-line-per-worker format as new default
|
||||
- **LED status indicators** - Visual indicators for rigs in Mayor tmux status line
|
||||
- **Parked/docked indicators** - Pause emoji (⏸) for inactive rigs in statusline
|
||||
|
||||
#### Beads & Workflow
|
||||
- **Minimum beads version check** - Validates beads CLI compatibility (gt-im3fl)
|
||||
- **ZFC convoy auto-close** - `bd close` triggers convoy completion (gt-3qw5s)
|
||||
- **Stale hooked bead cleanup** - Deacon clears orphaned hooks (gt-2yls3)
|
||||
- **Doctor prefix mismatch detection** - Detect misconfigured rig prefixes (gt-17wdl)
|
||||
- **Unified beads redirect** - Single redirect system for tracked and local beads (#222)
|
||||
- **Route from rig to town beads** - Cross-level bead routing
|
||||
|
||||
#### Infrastructure
|
||||
- **Windows-compatible file locking** - Daemon lock works on Windows
|
||||
- **`--purge` flag for crews** - Full crew obliteration option
|
||||
- **Debug logging for suppressed errors** - Better visibility into startup issues (gt-6d7eh)
|
||||
- **hq- prefix in tmux cycle bindings** - Navigate to Mayor/Deacon sessions
|
||||
- **Wisp config storage layer** - Transient/local settings for ephemeral workflows
|
||||
- **Sparse checkout** - Exclude Claude context files from source repos
|
||||
|
||||
### Changed
|
||||
|
||||
- **Daemon respects rig operational state** - Parked/docked rigs not auto-started
|
||||
- **Agent startup unified** - Manager pattern replaces ad-hoc initialization
|
||||
- **Mayor files moved** - Reorganized into `mayor/` subdirectory
|
||||
- **Refinery merges local branches** - No longer fetches from origin (gt-cio03)
|
||||
- **Polecats start from origin/default-branch** - Consistent recycled state
|
||||
- **Observable states removed** - Discover agent state from tmux, don't track (gt-zecmc)
|
||||
- **mol-town-shutdown v3** - Complete cleanup formula (gt-ux23f)
|
||||
- **Witness delays polecat cleanup** - Wait until MR merges (gt-12hwb)
|
||||
- **Nudge on divergence** - Daemon nudges agents instead of silent accept
|
||||
- **README rewritten** - Comprehensive guides and architecture docs (#226)
|
||||
- **`gt rigs` → `gt rig list`** - Command renamed in templates/docs (#217)
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Doctor & Lifecycle
|
||||
- **`--restart-sessions` flag required** - Doctor won't cycle sessions without explicit flag (gt-j44ri)
|
||||
- **Only cycle patrol roles** - Doctor --fix doesn't restart crew/polecats (hq-qthgye)
|
||||
- **Session-ended events auto-closed** - Prevent accumulation (gt-8tc1v)
|
||||
- **GUPP propulsion nudge** - Added to daemon restartSession
|
||||
|
||||
#### Sling & Beads
|
||||
- **Sling uses bd native routing** - No BEADS_DIR override needed
|
||||
- **Sling parses wisp JSON correctly** - Handle `new_epic_id` field
|
||||
- **Sling resolves rig path** - Cross-rig bead hooking works
|
||||
- **Sling waits for Claude ready** - Don't nudge until session responsive (#146)
|
||||
- **Correct beads database for sling** - Rig-level beads used (gt-n5gga)
|
||||
- **Close hooked beads before clearing** - Proper cleanup order (gt-vwjz6)
|
||||
- **Removed dead sling flags** - `--molecule` and `--quality` cleaned up
|
||||
|
||||
#### Agent Sessions
|
||||
- **Witness kills tmux on Stop()** - Clean session termination
|
||||
- **Deacon uses session package** - Correct hq- session names (gt-r38pj)
|
||||
- **Honor rig agent for witness/refinery** - Respect per-rig settings
|
||||
- **Canonical hq role bead IDs** - Consistent naming
|
||||
- **hq- prefix in status display** - Global agents shown correctly (gt-vcvyd)
|
||||
- **Restart Claude when dead** - Recover sessions where tmux exists but Claude died
|
||||
- **Town session cycling** - Works from any directory
|
||||
|
||||
#### Polecat & Crew
|
||||
- **Nuke not blocked by stale hooks** - Closed beads don't prevent cleanup (gt-jc7bq)
|
||||
- **Crew stop dry-run support** - Preview cleanup before executing (gt-kjcx4)
|
||||
- **Crew defaults to --all** - `gt crew start <rig>` starts all crew (gt-s8mpt)
|
||||
- **Polecat cleanup handlers** - `gt witness process` invokes handlers (gt-h3gzj)
|
||||
|
||||
#### Daemon & Configuration
|
||||
- **Create mayor/daemon.json** - `gt start` and `gt doctor --fix` initialize daemon state (#225)
|
||||
- **Initialize git before beads** - Enable repo fingerprint (#180)
|
||||
- **Handoff preserves env vars** - Claude Code environment not lost (#216)
|
||||
- **Agent settings passed correctly** - Witness and daemon respawn use rigPath
|
||||
- **Log rig discovery errors** - Don't silently swallow (gt-rsnj9)
|
||||
|
||||
#### Refinery & Merge Queue
|
||||
- **Use rig's default_branch** - Not hardcoded 'main'
|
||||
- **MERGE_FAILED sent to Witness** - Proper failure notification
|
||||
- **Removed BranchPushedToRemote checks** - Local-only workflow support (gt-dymy5)
|
||||
|
||||
#### Misc Fixes
|
||||
- **BeadsSetupRedirect preserves tracked files** - Don't clobber existing files (gt-fj0ol)
|
||||
- **PATH export in hooks** - Ensure commands find binaries
|
||||
- **Replace panic with fallback** - ID generation gracefully degrades (#213)
|
||||
- **Removed duplicate WorktreeAddFromRef** - Code cleanup
|
||||
- **Town root beads for Deacon** - Use correct beads location (gt-sstg)
|
||||
|
||||
### Refactored
|
||||
|
||||
- **AgentStateManager pattern** - Shared state management extracted (gt-gaw8e)
|
||||
- **CleanupStatus type** - Replace raw strings (gt-77gq7)
|
||||
- **ExecWithOutput utility** - Common command execution (gt-vurfr)
|
||||
- **runBdCommand helper** - DRY mail package (gt-8i6bg)
|
||||
- **Config expansion helper** - Generic DRY config (gt-i85sg)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Property layers guide** - Implementation documentation
|
||||
- **Worktree architecture** - Clarified beads routing
|
||||
- **Agent config** - Onboarding docs mention --agent overrides
|
||||
- **Polecat Operations section** - Added to Mayor docs (#140)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to all contributors for this release:
|
||||
- @julianknutsen - Claude settings inheritance (#239)
|
||||
- @joshuavial - Sling wisp JSON parse (#238)
|
||||
- @michaellady - Unified beads redirect (#222), daemon.json fix (#225)
|
||||
- @greghughespdx - PATH in hooks fix (#139)
|
||||
|
||||
## [0.2.1] - 2026-01-05
|
||||
|
||||
Bug fixes, security hardening, and new `gt config` command.
|
||||
|
||||
2
Makefile
2
Makefile
@@ -23,7 +23,7 @@ ifeq ($(shell uname),Darwin)
|
||||
endif
|
||||
|
||||
install: build
|
||||
cp $(BUILD_DIR)/$(BINARY) ~/bin/$(BINARY)
|
||||
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
|
||||
|
||||
clean:
|
||||
rm -f $(BUILD_DIR)/$(BINARY)
|
||||
|
||||
631
README.md
631
README.md
@@ -1,388 +1,481 @@
|
||||
# Gas Town
|
||||
|
||||
Multi-agent orchestrator for Claude Code. Track work with convoys; sling to agents.
|
||||
**Multi-agent orchestration system for Claude Code with persistent work tracking**
|
||||
|
||||
## Why Gas Town?
|
||||
## Overview
|
||||
|
||||
| Without | With Gas Town |
|
||||
|---------|---------------|
|
||||
| Agents forget work after restart | Work persists on hooks - survives crashes, compaction, restarts |
|
||||
| Manual coordination | Agents have mailboxes, identities, and structured handoffs |
|
||||
| 4-10 agents is chaotic | Comfortably scale to 20-30 agents |
|
||||
| Work state in agent memory | Work state in Beads (git-backed ledger) |
|
||||
Gas Town is a workspace manager that lets you coordinate multiple Claude Code agents working on different tasks. Instead of losing context when agents restart, Gas Town persists work state in git-backed hooks, enabling reliable multi-agent workflows.
|
||||
|
||||
## Prerequisites
|
||||
### What Problem Does This Solve?
|
||||
|
||||
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
|
||||
- **Git 2.25+** - for worktree support
|
||||
- **beads (bd)** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - required for issue tracking
|
||||
- **tmux 3.0+** - recommended for the full experience (the Mayor session is the primary interface)
|
||||
- **Claude Code CLI** - [claude.ai/code](https://claude.ai/code)
|
||||
| Challenge | Gas Town Solution |
|
||||
| ------------------------------- | -------------------------------------------- |
|
||||
| Agents lose context on restart | Work persists in git-backed hooks |
|
||||
| Manual agent coordination | Built-in mailboxes, identities, and handoffs |
|
||||
| 4-10 agents become chaotic | Scale comfortably to 20-30 agents |
|
||||
| Work state lost in agent memory | Work state stored in Beads ledger |
|
||||
|
||||
## Quick Start
|
||||
### Architecture
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/steveyegge/gastown/cmd/gt@latest
|
||||
```mermaid
|
||||
graph TB
|
||||
Mayor[The Mayor<br/>AI Coordinator]
|
||||
Town[Town Workspace<br/>~/gt/]
|
||||
|
||||
# Ensure Go binaries are in your PATH (add to ~/.zshrc or ~/.bashrc)
|
||||
export PATH="$PATH:$HOME/go/bin"
|
||||
Town --> Mayor
|
||||
Town --> Rig1[Rig: Project A]
|
||||
Town --> Rig2[Rig: Project B]
|
||||
|
||||
# Create workspace (--git auto-initializes git repository)
|
||||
gt install ~/gt --git
|
||||
cd ~/gt
|
||||
Rig1 --> Crew1[Crew Member<br/>Your workspace]
|
||||
Rig1 --> Hooks1[Hooks<br/>Persistent storage]
|
||||
Rig1 --> Polecats1[Polecats<br/>Worker agents]
|
||||
|
||||
# Add a project
|
||||
gt rig add myproject https://github.com/you/repo.git
|
||||
Rig2 --> Crew2[Crew Member]
|
||||
Rig2 --> Hooks2[Hooks]
|
||||
Rig2 --> Polecats2[Polecats]
|
||||
|
||||
# Create your personal workspace
|
||||
gt crew add <yourname> --rig myproject
|
||||
Hooks1 -.git worktree.-> GitRepo1[Git Repository]
|
||||
Hooks2 -.git worktree.-> GitRepo2[Git Repository]
|
||||
|
||||
# Start working
|
||||
cd myproject/crew/<yourname>
|
||||
```
|
||||
|
||||
For advanced multi-agent coordination, use the Mayor session:
|
||||
|
||||
```bash
|
||||
gt mayor attach # Enter the Mayor's office
|
||||
```
|
||||
|
||||
Inside the Mayor session, you're talking to Claude with full town context:
|
||||
|
||||
> "Help me fix the authentication bug in myproject"
|
||||
|
||||
The Mayor will create convoys, dispatch workers, and coordinate everything. You can also run CLI commands directly:
|
||||
|
||||
```bash
|
||||
# Create a convoy and sling work (CLI workflow)
|
||||
gt convoy create "Feature X" issue-123 issue-456 --notify --human
|
||||
gt sling issue-123 myproject
|
||||
|
||||
# Track progress
|
||||
gt convoy list
|
||||
|
||||
# Switch between agent sessions
|
||||
gt agents
|
||||
style Mayor fill:#e1f5ff
|
||||
style Town fill:#f0f0f0
|
||||
style Rig1 fill:#fff4e1
|
||||
style Rig2 fill:#fff4e1
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**The Mayor** is your AI coordinator. It's Claude Code with full context about your workspace, projects, and agents. The Mayor session (`gt prime`) is the primary way to interact with Gas Town - just tell it what you want to accomplish.
|
||||
### The Mayor 🎩
|
||||
|
||||
```
|
||||
Town (~/gt/) Your workspace
|
||||
├── Mayor Your AI coordinator (start here)
|
||||
├── Rig (project) Container for a git project + its agents
|
||||
│ ├── Polecats Workers (ephemeral, spawn → work → disappear)
|
||||
│ ├── Witness Monitors workers, handles lifecycle
|
||||
│ └── Refinery Merge queue processor
|
||||
```
|
||||
Your primary AI coordinator. The Mayor is a Claude Code instance with full context about your workspace, projects, and agents. **Start here** - just tell the Mayor what you want to accomplish.
|
||||
|
||||
**Hook**: Each agent has a hook where work hangs. On wake, run what's on your hook.
|
||||
### Town 🏘️
|
||||
|
||||
**Beads**: Git-backed issue tracker. All work state lives here. [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
||||
Your workspace directory (e.g., `~/gt/`). Contains all projects, agents, and configuration.
|
||||
|
||||
## Workflows
|
||||
### Rigs 🏗️
|
||||
|
||||
### Full Stack (Recommended)
|
||||
Project containers. Each rig wraps a git repository and manages its associated agents.
|
||||
|
||||
The primary Gas Town experience. Agents run in tmux sessions with the Mayor as your interface.
|
||||
### Crew Members 👤
|
||||
|
||||
Your personal workspace within a rig. Where you do hands-on work.
|
||||
|
||||
### Polecats 🦨
|
||||
|
||||
Ephemeral worker agents that spawn, complete a task, and disappear.
|
||||
|
||||
### Hooks 🪝
|
||||
|
||||
Git worktree-based persistent storage for agent work. Survives crashes and restarts.
|
||||
|
||||
### Convoys 🚚
|
||||
|
||||
Work tracking units. Bundle multiple issues/tasks that get assigned to agents.
|
||||
|
||||
### Beads Integration 📿
|
||||
|
||||
Git-backed issue tracking system that stores work state as structured data.
|
||||
|
||||
> **New to Gas Town?** See the [Glossary](docs/glossary.md) for a complete guide to terminology and concepts.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
|
||||
- **Git 2.25+** - for worktree support
|
||||
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
|
||||
- **tmux 3.0+** - recommended for full experience
|
||||
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
|
||||
- **Codex CLI** (optional runtime) - [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
gt start # Start Gas Town (daemon + Mayor session)
|
||||
gt mayor attach # Enter Mayor session
|
||||
# Install Gas Town
|
||||
go install github.com/steveyegge/gastown/cmd/gt@latest
|
||||
|
||||
# Inside Mayor session, just ask:
|
||||
# "Create a convoy for issues 123 and 456 in myproject"
|
||||
# "What's the status of my work?"
|
||||
# "Show me what the witness is doing"
|
||||
# Add Go binaries to PATH (add to ~/.zshrc or ~/.bashrc)
|
||||
export PATH="$PATH:$HOME/go/bin"
|
||||
|
||||
# Or use CLI commands:
|
||||
gt convoy create "Feature X" issue-123 issue-456
|
||||
gt sling issue-123 myproject # Spawns polecat automatically
|
||||
gt convoy list # Dashboard view
|
||||
gt agents # Navigate between sessions
|
||||
# Create workspace with git initialization
|
||||
gt install ~/gt --git
|
||||
cd ~/gt
|
||||
|
||||
# Add your first project
|
||||
gt rig add myproject https://github.com/you/repo.git
|
||||
|
||||
# Create your crew workspace
|
||||
gt crew add yourname --rig myproject
|
||||
cd myproject/crew/yourname
|
||||
|
||||
# Start the Mayor session (your main interface)
|
||||
gt mayor attach
|
||||
```
|
||||
|
||||
### Minimal (No Tmux)
|
||||
## Quick Start Guide
|
||||
|
||||
Run individual Claude Code instances manually. Gas Town just tracks state.
|
||||
### Basic Workflow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant You
|
||||
participant Mayor
|
||||
participant Convoy
|
||||
participant Agent
|
||||
participant Hook
|
||||
|
||||
You->>Mayor: Tell Mayor what to build
|
||||
Mayor->>Convoy: Create convoy with issues
|
||||
Mayor->>Agent: Sling issue to agent
|
||||
Agent->>Hook: Store work state
|
||||
Agent->>Agent: Complete work
|
||||
Agent->>Convoy: Report completion
|
||||
Mayor->>You: Summary of progress
|
||||
```
|
||||
|
||||
### Example: Feature Development
|
||||
|
||||
```bash
|
||||
# 1. Start the Mayor
|
||||
gt mayor attach
|
||||
|
||||
# 2. In Mayor session, create a convoy
|
||||
gt convoy create "Feature X" issue-123 issue-456 --notify --human
|
||||
|
||||
# 3. Assign work to an agent
|
||||
gt sling issue-123 myproject
|
||||
|
||||
# 4. Track progress
|
||||
gt convoy list
|
||||
|
||||
# 5. Monitor agents
|
||||
gt agents
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Mayor Workflow (Recommended)
|
||||
|
||||
**Best for:** Coordinating complex, multi-issue work
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Start([Start Mayor]) --> Tell[Tell Mayor<br/>what to build]
|
||||
Tell --> Creates[Mayor creates<br/>convoy + agents]
|
||||
Creates --> Monitor[Monitor progress<br/>via convoy list]
|
||||
Monitor --> Done{All done?}
|
||||
Done -->|No| Monitor
|
||||
Done -->|Yes| Review[Review work]
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# Attach to Mayor
|
||||
gt mayor attach
|
||||
|
||||
# In Mayor, create convoy and let it orchestrate
|
||||
gt convoy create "Auth System" issue-101 issue-102 --notify
|
||||
|
||||
# Track progress
|
||||
gt convoy list
|
||||
```
|
||||
|
||||
### Minimal Mode (No Tmux)
|
||||
|
||||
Run individual runtime instances manually. Gas Town just tracks state.
|
||||
|
||||
```bash
|
||||
gt convoy create "Fix bugs" issue-123 # Create convoy (sling auto-creates if skipped)
|
||||
gt sling issue-123 myproject # Assign to worker
|
||||
claude --resume # Agent reads mail, runs work
|
||||
claude --resume # Agent reads mail, runs work (Claude)
|
||||
# or: codex # Start Codex in the workspace
|
||||
gt convoy list # Check progress
|
||||
```
|
||||
|
||||
### Pick Your Roles
|
||||
### Beads Formula Workflow
|
||||
|
||||
Gas Town is modular. Run what you need:
|
||||
**Best for:** Predefined, repeatable processes
|
||||
|
||||
- **Polecats only**: Manual spawning, no monitoring
|
||||
- **+ Witness**: Automatic worker lifecycle, stuck detection
|
||||
- **+ Refinery**: Merge queue, code review
|
||||
- **+ Mayor**: Cross-project coordination
|
||||
Formulas are TOML-defined workflows stored in `.beads/formulas/`.
|
||||
|
||||
## Cooking Formulas
|
||||
|
||||
Formulas define structured workflows. Cook them, sling them to agents.
|
||||
|
||||
### Basic Example
|
||||
**Example Formula** (`.beads/formulas/release.formula.toml`):
|
||||
|
||||
```toml
|
||||
# .beads/formulas/shiny.formula.toml
|
||||
formula = "shiny"
|
||||
description = "Design before code, review before ship"
|
||||
description = "Standard release process"
|
||||
formula = "release"
|
||||
version = 1
|
||||
|
||||
[[steps]]
|
||||
id = "design"
|
||||
description = "Think about architecture"
|
||||
|
||||
[[steps]]
|
||||
id = "implement"
|
||||
needs = ["design"]
|
||||
|
||||
[[steps]]
|
||||
id = "test"
|
||||
needs = ["implement"]
|
||||
|
||||
[[steps]]
|
||||
id = "submit"
|
||||
needs = ["test"]
|
||||
```
|
||||
|
||||
### Using Formulas
|
||||
|
||||
```bash
|
||||
bd formula list # See available formulas
|
||||
bd cook shiny # Cook into a protomolecule
|
||||
bd mol pour shiny --var feature=auth # Create runnable molecule
|
||||
gt convoy create "Auth feature" gt-xyz # Track with convoy
|
||||
gt sling gt-xyz myproject # Assign to worker
|
||||
gt convoy list # Monitor progress
|
||||
```
|
||||
|
||||
### What Happens
|
||||
|
||||
1. **Cook** expands the formula into a protomolecule (frozen template)
|
||||
2. **Pour** creates a molecule (live workflow) with steps as beads
|
||||
3. **Worker executes** each step, closing beads as it goes
|
||||
4. **Crash recovery**: Worker restarts, reads molecule, continues from last step
|
||||
|
||||
### Example: Beads Release Molecule
|
||||
|
||||
A real workflow for releasing a new beads version:
|
||||
|
||||
```toml
|
||||
formula = "beads-release"
|
||||
description = "Version bump and release workflow"
|
||||
[vars.version]
|
||||
description = "The semantic version to release (e.g., 1.2.0)"
|
||||
required = true
|
||||
|
||||
[[steps]]
|
||||
id = "bump-version"
|
||||
description = "Update version in version.go and CHANGELOG"
|
||||
|
||||
[[steps]]
|
||||
id = "update-deps"
|
||||
needs = ["bump-version"]
|
||||
description = "Run go mod tidy, update go.sum"
|
||||
title = "Bump version"
|
||||
description = "Run ./scripts/bump-version.sh {{version}}"
|
||||
|
||||
[[steps]]
|
||||
id = "run-tests"
|
||||
needs = ["update-deps"]
|
||||
description = "Full test suite, check for regressions"
|
||||
title = "Run tests"
|
||||
description = "Run make test"
|
||||
needs = ["bump-version"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-binaries"
|
||||
id = "build"
|
||||
title = "Build"
|
||||
description = "Run make build"
|
||||
needs = ["run-tests"]
|
||||
description = "Cross-compile for all platforms"
|
||||
|
||||
[[steps]]
|
||||
id = "create-tag"
|
||||
needs = ["build-binaries"]
|
||||
description = "Git tag with version, push to origin"
|
||||
title = "Create release tag"
|
||||
description = "Run git tag -a v{{version}} -m 'Release v{{version}}'"
|
||||
needs = ["build"]
|
||||
|
||||
[[steps]]
|
||||
id = "publish-release"
|
||||
id = "publish"
|
||||
title = "Publish"
|
||||
description = "Run ./scripts/publish.sh"
|
||||
needs = ["create-tag"]
|
||||
description = "Create GitHub release with binaries"
|
||||
```
|
||||
|
||||
Cook it, pour it, sling it. The polecat runs through each step, and if it crashes
|
||||
after `run-tests`, a new polecat picks up at `build-binaries`.
|
||||
**Execute:**
|
||||
|
||||
### Formula Composition
|
||||
```bash
|
||||
# List available formulas
|
||||
bd formula list
|
||||
|
||||
```toml
|
||||
# Extend an existing formula
|
||||
formula = "shiny-enterprise"
|
||||
extends = ["shiny"]
|
||||
# Run a formula with variables
|
||||
bd cook release --var version=1.2.0
|
||||
|
||||
[compose]
|
||||
aspects = ["security-audit"] # Add cross-cutting concerns
|
||||
# Create formula instance for tracking
|
||||
bd mol pour release --var version=1.2.0
|
||||
```
|
||||
|
||||
### Manual Convoy Workflow
|
||||
|
||||
**Best for:** Direct control over work distribution
|
||||
|
||||
```bash
|
||||
# Create convoy manually
|
||||
gt convoy create "Bug Fixes" --human
|
||||
|
||||
# Add issues
|
||||
gt convoy add-issue bug-101 bug-102
|
||||
|
||||
# Assign to specific agents
|
||||
gt sling bug-101 myproject/my-agent
|
||||
|
||||
# Check status
|
||||
gt convoy show
|
||||
```
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
Gas Town supports multiple AI coding runtimes. Per-rig runtime settings are in `settings/config.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"provider": "codex",
|
||||
"command": "codex",
|
||||
"args": [],
|
||||
"prompt_mode": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Claude uses hooks in `.claude/settings.json` for mail injection and startup.
|
||||
- For Codex, set `project_doc_fallback_filenames = ["CLAUDE.md"]` in
|
||||
`~/.codex/config.toml` so role instructions are picked up.
|
||||
- For runtimes without hooks (e.g., Codex), Gas Town sends a startup fallback
|
||||
after the session is ready: `gt prime`, optional `gt mail check --inject`
|
||||
for autonomous roles, and `gt nudge deacon session-started`.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### For Humans (Overseer)
|
||||
### Workspace Management
|
||||
|
||||
```bash
|
||||
gt start # Start Gas Town (daemon + agents)
|
||||
gt shutdown # Graceful shutdown
|
||||
gt status # Town overview
|
||||
gt <role> attach # Jump into any agent session
|
||||
# e.g., gt mayor attach, gt witness attach
|
||||
gt install <path> # Initialize workspace
|
||||
gt rig add <name> <repo> # Add project
|
||||
gt rig list # List projects
|
||||
gt crew add <name> --rig <rig> # Create crew workspace
|
||||
```
|
||||
|
||||
### Agent Operations
|
||||
|
||||
```bash
|
||||
gt agents # List active agents
|
||||
gt sling <issue> <rig> # Assign work to agent
|
||||
gt sling <issue> <rig> --agent cursor # Override runtime for this sling/spawn
|
||||
gt mayor attach # Start Mayor session
|
||||
gt mayor start --agent auggie # Run Mayor with a specific agent alias
|
||||
gt prime # Alternative to mayor attach
|
||||
```
|
||||
|
||||
**Built-in agent presets**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||
|
||||
### Convoy (Work Tracking)
|
||||
|
||||
```bash
|
||||
gt convoy create <name> [issues...] # Create convoy
|
||||
gt convoy list # List all convoys
|
||||
gt convoy show [id] # Show convoy details
|
||||
gt convoy add-issue <issue> # Add issue to convoy
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
gt config agent list [--json] # List all agents (built-in + custom)
|
||||
gt config agent get <name> # Show agent configuration
|
||||
gt config agent set <name> <cmd> # Create or update custom agent
|
||||
gt config agent remove <name> # Remove custom agent (built-ins protected)
|
||||
gt config default-agent [name] # Get or set town default agent
|
||||
```
|
||||
|
||||
**Example**: Use a cheaper model for most work:
|
||||
```bash
|
||||
# Set custom agent command
|
||||
gt config agent set claude-glm "claude-glm --model glm-4"
|
||||
gt config agent set codex-low "codex --thinking low"
|
||||
|
||||
# Set default agent
|
||||
gt config default-agent claude-glm
|
||||
|
||||
# View config
|
||||
gt config show
|
||||
```
|
||||
|
||||
Most other work happens through agents - just ask them.
|
||||
|
||||
### For Agents
|
||||
### Beads Integration
|
||||
|
||||
```bash
|
||||
# Convoy (primary dashboard)
|
||||
gt convoy list # Active work across all rigs
|
||||
gt convoy status <id> # Detailed convoy progress
|
||||
gt convoy create "name" <issues> # Create new convoy
|
||||
|
||||
# Work assignment
|
||||
gt sling <bead> <rig> # Assign work to polecat
|
||||
bd ready # Show available work
|
||||
bd list --status=in_progress # Active work
|
||||
|
||||
# Communication
|
||||
gt mail inbox # Check messages
|
||||
gt mail send <addr> -s "..." -m "..."
|
||||
|
||||
# Lifecycle
|
||||
gt handoff # Request session cycle
|
||||
gt peek <agent> # Check agent health
|
||||
|
||||
# Diagnostics
|
||||
gt doctor # Health check
|
||||
gt doctor --fix # Auto-repair
|
||||
bd formula list # List formulas
|
||||
bd cook <formula> # Execute formula
|
||||
bd mol pour <formula> # Create trackable instance
|
||||
bd mol list # List active instances
|
||||
```
|
||||
|
||||
## Cooking Formulas
|
||||
|
||||
Gas Town includes built-in formulas for common workflows. See `.beads/formulas/` for available recipes.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Web-based dashboard for monitoring Gas Town activity.
|
||||
Gas Town includes a web dashboard for monitoring:
|
||||
|
||||
```bash
|
||||
# Start the dashboard
|
||||
# Start dashboard
|
||||
gt dashboard --port 8080
|
||||
|
||||
# Open in browser
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Convoy tracking** - View all active convoys with progress bars and work status
|
||||
- **Polecat workers** - See active worker sessions and their activity status
|
||||
- **Refinery status** - Monitor merge queue and PR processing
|
||||
- **Auto-refresh** - Updates every 10 seconds via htmx
|
||||
Features:
|
||||
|
||||
Work status indicators:
|
||||
| Status | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| `complete` | Green | All tracked items done |
|
||||
| `active` | Green | Recent activity (< 1 min) |
|
||||
| `stale` | Yellow | Activity 1-5 min ago |
|
||||
| `stuck` | Red | Activity > 5 min ago |
|
||||
| `waiting` | Gray | No assignee/activity |
|
||||
- Real-time agent status
|
||||
- Convoy progress tracking
|
||||
- Hook state visualization
|
||||
- Configuration management
|
||||
|
||||
## Advanced Concepts
|
||||
|
||||
### The Propulsion Principle
|
||||
|
||||
Gas Town uses git hooks as a propulsion mechanism. Each hook is a git worktree with:
|
||||
|
||||
1. **Persistent state** - Work survives agent restarts
|
||||
2. **Version control** - All changes tracked in git
|
||||
3. **Rollback capability** - Revert to any previous state
|
||||
4. **Multi-agent coordination** - Shared through git
|
||||
|
||||
### Hook Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: Agent spawned
|
||||
Created --> Active: Work assigned
|
||||
Active --> Suspended: Agent paused
|
||||
Suspended --> Active: Agent resumed
|
||||
Active --> Completed: Work done
|
||||
Completed --> Archived: Hook archived
|
||||
Archived --> [*]
|
||||
```
|
||||
|
||||
### MEOW (Mayor-Enhanced Orchestration Workflow)
|
||||
|
||||
MEOW is the recommended pattern:
|
||||
|
||||
1. **Tell the Mayor** - Describe what you want
|
||||
2. **Mayor analyzes** - Breaks down into tasks
|
||||
3. **Convoy creation** - Mayor creates convoy with issues
|
||||
4. **Agent spawning** - Mayor spawns appropriate agents
|
||||
5. **Work distribution** - Issues slung to agents via hooks
|
||||
6. **Progress monitoring** - Track through convoy status
|
||||
7. **Completion** - Mayor summarizes results
|
||||
|
||||
## Shell Completions
|
||||
|
||||
Enable tab completion for `gt` commands:
|
||||
|
||||
### Bash
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc
|
||||
source <(gt completion bash)
|
||||
# Bash
|
||||
gt completion bash > /etc/bash_completion.d/gt
|
||||
|
||||
# Or install permanently
|
||||
gt completion bash > /usr/local/etc/bash_completion.d/gt
|
||||
```
|
||||
|
||||
### Zsh
|
||||
|
||||
```bash
|
||||
# Add to ~/.zshrc (before compinit)
|
||||
source <(gt completion zsh)
|
||||
|
||||
# Or install to fpath
|
||||
# Zsh
|
||||
gt completion zsh > "${fpath[1]}/_gt"
|
||||
```
|
||||
|
||||
### Fish
|
||||
|
||||
```bash
|
||||
# Fish
|
||||
gt completion fish > ~/.config/fish/completions/gt.fish
|
||||
```
|
||||
|
||||
## Roles
|
||||
## Project Roles
|
||||
|
||||
| Role | Scope | Job |
|
||||
|------|-------|-----|
|
||||
| **Overseer** | Human | Sets strategy, reviews output, handles escalations |
|
||||
| **Mayor** | Town-wide | Cross-rig coordination, work dispatch |
|
||||
| **Deacon** | Town-wide | Daemon process, agent lifecycle, plugin execution |
|
||||
| **Witness** | Per-rig | Monitor polecats, nudge stuck workers |
|
||||
| **Refinery** | Per-rig | Merge queue, PR review, integration |
|
||||
| **Polecat** | Per-task | Execute work, file discovered issues, request shutdown |
|
||||
| Role | Description | Primary Interface |
|
||||
| --------------- | ------------------ | -------------------- |
|
||||
| **Mayor** | AI coordinator | `gt mayor attach` |
|
||||
| **Human (You)** | Crew member | Your crew directory |
|
||||
| **Polecat** | Worker agent | Spawned by Mayor |
|
||||
| **Hook** | Persistent storage | Git worktree |
|
||||
| **Convoy** | Work tracker | `gt convoy` commands |
|
||||
|
||||
## The Propulsion Principle
|
||||
## Tips
|
||||
|
||||
> If your hook has work, RUN IT.
|
||||
- **Always start with the Mayor** - It's designed to be your primary interface
|
||||
- **Use convoys for coordination** - They provide visibility across agents
|
||||
- **Leverage hooks for persistence** - Your work won't disappear
|
||||
- **Create formulas for repeated tasks** - Save time with Beads recipes
|
||||
- **Monitor the dashboard** - Get real-time visibility
|
||||
- **Let the Mayor orchestrate** - It knows how to manage agents
|
||||
|
||||
Agents wake up, check their hook, execute the molecule. No waiting for commands.
|
||||
Molecules survive crashes - any agent can continue where another left off.
|
||||
## Troubleshooting
|
||||
|
||||
---
|
||||
### Agents lose connection
|
||||
|
||||
## Optional: MEOW Deep Dive
|
||||
Check hooks are properly initialized:
|
||||
|
||||
**M**olecular **E**xpression **O**f **W**ork - the full algebra.
|
||||
```bash
|
||||
gt hooks list
|
||||
gt hooks repair
|
||||
```
|
||||
|
||||
### States of Matter
|
||||
### Convoy stuck
|
||||
|
||||
| Phase | Name | Storage | Behavior |
|
||||
|-------|------|---------|----------|
|
||||
| Ice-9 | Formula | `.beads/formulas/` | Source template, composable |
|
||||
| Solid | Protomolecule | `.beads/` | Frozen template, reusable |
|
||||
| Liquid | Mol | `.beads/` | Flowing work, persistent |
|
||||
| Vapor | Wisp | `.beads/` (ephemeral flag) | Transient, for patrols |
|
||||
Force refresh:
|
||||
|
||||
*(Protomolecules are an homage to The Expanse. Ice-9 is a nod to Vonnegut.)*
|
||||
```bash
|
||||
gt convoy refresh <convoy-id>
|
||||
```
|
||||
|
||||
### Operators
|
||||
### Mayor not responding
|
||||
|
||||
| Operator | From → To | Effect |
|
||||
|----------|-----------|--------|
|
||||
| `cook` | Formula → Protomolecule | Expand macros, flatten |
|
||||
| `pour` | Proto → Mol | Instantiate as persistent |
|
||||
| `wisp` | Proto → Wisp | Instantiate as ephemeral |
|
||||
| `squash` | Mol/Wisp → Digest | Condense to permanent record |
|
||||
| `burn` | Wisp → ∅ | Discard without record |
|
||||
Restart Mayor session:
|
||||
|
||||
---
|
||||
```bash
|
||||
gt mayor detach
|
||||
gt mayor attach
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
---
|
||||
|
||||
**Getting Started:** Run `gt install ~/gt --git && cd ~/gt && gt config agent list && gt mayor attach` (or `gt mayor attach --agent codex`) and tell the Mayor what you want to build!
|
||||
|
||||
@@ -17,7 +17,9 @@ Complete setup guide for Gas Town multi-agent orchestrator.
|
||||
| Tool | Version | Check | Install |
|
||||
|------|---------|-------|---------|
|
||||
| **tmux** | 3.0+ | `tmux -V` | See below |
|
||||
| **Claude Code** | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
|
||||
| **Claude Code** (default) | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
|
||||
| **Codex CLI** (optional) | latest | `codex --version` | See [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) |
|
||||
| **OpenCode CLI** (optional) | latest | `opencode --version` | See [opencode.ai](https://opencode.ai) |
|
||||
|
||||
## Installing Prerequisites
|
||||
|
||||
@@ -130,22 +132,46 @@ gt doctor # Run health checks
|
||||
gt status # Show workspace status
|
||||
```
|
||||
|
||||
### Step 5: Configure Agents (Optional)
|
||||
|
||||
Gas Town supports built-in runtimes (`claude`, `gemini`, `codex`) plus custom agent aliases.
|
||||
|
||||
```bash
|
||||
# List available agents
|
||||
gt config agent list
|
||||
|
||||
# Create an alias (aliases can encode model/thinking flags)
|
||||
gt config agent set codex-low "codex --thinking low"
|
||||
gt config agent set claude-haiku "claude --model haiku --dangerously-skip-permissions"
|
||||
|
||||
# Set the town default agent (used when a rig doesn't specify one)
|
||||
gt config default-agent codex-low
|
||||
```
|
||||
|
||||
You can also override the agent per command without changing defaults:
|
||||
|
||||
```bash
|
||||
gt start --agent codex-low
|
||||
gt sling issue-123 myproject --agent claude-haiku
|
||||
```
|
||||
|
||||
## Minimal Mode vs Full Stack Mode
|
||||
|
||||
Gas Town supports two operational modes:
|
||||
|
||||
### Minimal Mode (No Daemon)
|
||||
|
||||
Run individual Claude Code instances manually. Gas Town only tracks state.
|
||||
Run individual runtime instances manually. Gas Town only tracks state.
|
||||
|
||||
```bash
|
||||
# Create and assign work
|
||||
gt convoy create "Fix bugs" issue-123
|
||||
gt sling issue-123 myproject
|
||||
|
||||
# Run Claude manually
|
||||
# Run runtime manually
|
||||
cd ~/gt/myproject/polecats/<worker>
|
||||
claude --resume
|
||||
claude --resume # Claude Code
|
||||
# or: codex # Codex CLI
|
||||
|
||||
# Check progress
|
||||
gt convoy list
|
||||
|
||||
@@ -84,29 +84,43 @@ Each agent bead references its role bead via the `role_bead` field.
|
||||
│ └── town.json Town configuration
|
||||
└── <rig>/ Project container (NOT a git clone)
|
||||
├── config.json Rig identity and beads prefix
|
||||
├── .beads/ → mayor/rig/.beads Symlink to canonical beads
|
||||
├── .repo.git/ Bare repo (shared by worktrees)
|
||||
├── mayor/rig/ Mayor's clone (canonical beads)
|
||||
├── refinery/rig/ Worktree on main
|
||||
├── mayor/rig/ Canonical clone (beads live here)
|
||||
│ └── .beads/ Rig-level beads database
|
||||
├── refinery/rig/ Worktree from mayor/rig
|
||||
├── witness/ No clone (monitors only)
|
||||
├── crew/<name>/ Human workspaces
|
||||
└── polecats/<name>/ Worker worktrees
|
||||
├── crew/<name>/ Human workspaces (full clones)
|
||||
└── polecats/<name>/ Worker worktrees from mayor/rig
|
||||
```
|
||||
|
||||
### Worktree Architecture
|
||||
|
||||
Polecats and refinery are git worktrees, not full clones. This enables fast spawning
|
||||
and shared object storage. The worktree base is `mayor/rig`:
|
||||
|
||||
```go
|
||||
// From polecat/manager.go - worktrees are based on mayor/rig
|
||||
git worktree add -b polecat/<name>-<timestamp> polecats/<name>
|
||||
```
|
||||
|
||||
Crew workspaces (`crew/<name>/`) are full git clones for human developers who need
|
||||
independent repos. Polecats are ephemeral and benefit from worktree efficiency.
|
||||
|
||||
## Beads Routing
|
||||
|
||||
The `routes.jsonl` file maps issue ID prefixes to their storage locations:
|
||||
The `routes.jsonl` file maps issue ID prefixes to rig locations (relative to town root):
|
||||
|
||||
```jsonl
|
||||
{"prefix":"hq","path":"/Users/stevey/gt/.beads"}
|
||||
{"prefix":"gt","path":"/Users/stevey/gt/gastown/mayor/rig/.beads"}
|
||||
{"prefix":"hq-","path":"."}
|
||||
{"prefix":"gt-","path":"gastown/mayor/rig"}
|
||||
{"prefix":"bd-","path":"beads/mayor/rig"}
|
||||
```
|
||||
|
||||
Routes point to `mayor/rig` because that's where the canonical `.beads/` lives.
|
||||
This enables transparent cross-rig beads operations:
|
||||
|
||||
```bash
|
||||
bd show hq-mayor # Routes to town beads
|
||||
bd show gt-xyz # Routes to gastown rig beads
|
||||
bd show hq-mayor # Routes to town beads (~/.gt/.beads)
|
||||
bd show gt-xyz # Routes to gastown/mayor/rig/.beads
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
94
docs/glossary.md
Normal file
94
docs/glossary.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Gas Town Glossary
|
||||
|
||||
Gas Town is an agentic development environment for managing multiple Claude Code instances simultaneously using the `gt` and `bd` (Beads) binaries, coordinated with tmux in git-managed directories.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### MEOW (Molecular Expression of Work)
|
||||
Breaking large goals into detailed instructions for agents. Supported by Beads, Epics, Formulas, and Molecules. MEOW ensures work is decomposed into trackable, atomic units that agents can execute autonomously.
|
||||
|
||||
### GUPP (Gas Town Universal Propulsion Principle)
|
||||
"If there is work on your Hook, YOU MUST RUN IT." This principle ensures agents autonomously proceed with available work without waiting for external input. GUPP is the heartbeat of autonomous operation.
|
||||
|
||||
### NDI (Nondeterministic Idempotence)
|
||||
The overarching goal ensuring useful outcomes through orchestration of potentially unreliable processes. Persistent Beads and oversight agents (Witness, Deacon) guarantee eventual workflow completion even when individual operations may fail or produce varying results.
|
||||
|
||||
## Environments
|
||||
|
||||
### Town
|
||||
The management headquarters (e.g., `~/gt/`). The Town coordinates all workers across multiple Rigs and houses town-level agents like Mayor and Deacon.
|
||||
|
||||
### Rig
|
||||
A project-specific Git repository under Gas Town management. Each Rig has its own Polecats, Refinery, Witness, and Crew members. Rigs are where actual development work happens.
|
||||
|
||||
## Town-Level Roles
|
||||
|
||||
### Mayor
|
||||
Chief-of-staff agent responsible for initiating Convoys, coordinating work distribution, and notifying users of important events. The Mayor operates from the town level and has visibility across all Rigs.
|
||||
|
||||
### Deacon
|
||||
Daemon beacon running continuous Patrol cycles. The Deacon ensures worker activity, monitors system health, and triggers recovery when agents become unresponsive. Think of the Deacon as the system's watchdog.
|
||||
|
||||
### Dogs
|
||||
The Deacon's crew of maintenance agents handling background tasks like cleanup, health checks, and system maintenance.
|
||||
|
||||
### Boot (the Dog)
|
||||
A special Dog that checks the Deacon every 5 minutes, ensuring the watchdog itself is still watching. This creates a chain of accountability.
|
||||
|
||||
## Rig-Level Roles
|
||||
|
||||
### Polecat
|
||||
Ephemeral worker agents that produce Merge Requests. Polecats are spawned for specific tasks, complete their work, and are then cleaned up. They work in isolated git worktrees to avoid conflicts.
|
||||
|
||||
### Refinery
|
||||
Manages the Merge Queue for a Rig. The Refinery intelligently merges changes from Polecats, handling conflicts and ensuring code quality before changes reach the main branch.
|
||||
|
||||
### Witness
|
||||
Patrol agent that oversees Polecats and the Refinery within a Rig. The Witness monitors progress, detects stuck agents, and can trigger recovery actions.
|
||||
|
||||
### Crew
|
||||
Long-lived, named agents for persistent collaboration. Unlike ephemeral Polecats, Crew members maintain context across sessions and are ideal for ongoing work relationships.
|
||||
|
||||
## Work Units
|
||||
|
||||
### Bead
|
||||
Git-backed atomic work unit stored in JSONL format. Beads are the fundamental unit of work tracking in Gas Town. They can represent issues, tasks, epics, or any trackable work item.
|
||||
|
||||
### Formula
|
||||
TOML-based workflow source template. Formulas define reusable patterns for common operations like patrol cycles, code review, or deployment.
|
||||
|
||||
### Protomolecule
|
||||
A template class for instantiating Molecules. Protomolecules define the structure and steps of a workflow without being tied to specific work items.
|
||||
|
||||
### Molecule
|
||||
Durable chained Bead workflows. Molecules represent multi-step processes where each step is tracked as a Bead. They survive agent restarts and ensure complex workflows complete.
|
||||
|
||||
### Wisp
|
||||
Ephemeral Beads destroyed after runs. Wisps are lightweight work items used for transient operations that don't need permanent tracking.
|
||||
|
||||
### Hook
|
||||
A special pinned Bead for each agent. The Hook is an agent's primary work queue - when work appears on your Hook, GUPP dictates you must run it.
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
### Convoy
|
||||
Primary work-order wrapping related Beads. Convoys group related tasks together and can be assigned to multiple workers. Created with `gt convoy create`.
|
||||
|
||||
### Slinging
|
||||
Assigning work to agents via `gt sling`. When you sling work to a Polecat or Crew member, you're putting it on their Hook for execution.
|
||||
|
||||
### Nudging
|
||||
Real-time messaging between agents with `gt nudge`. Nudges allow immediate communication without going through the mail system.
|
||||
|
||||
### Handoff
|
||||
Agent session refresh via `/handoff`. When context gets full or an agent needs a fresh start, handoff transfers work state to a new session.
|
||||
|
||||
### Seance
|
||||
Communicating with previous sessions via `gt seance`. Allows agents to query their predecessors for context and decisions from earlier work.
|
||||
|
||||
### Patrol
|
||||
Ephemeral loop maintaining system heartbeat. Patrol agents (Deacon, Witness) continuously cycle through health checks and trigger actions as needed.
|
||||
|
||||
---
|
||||
|
||||
*This glossary was contributed by [Clay Shirky](https://github.com/cshirky) in [Issue #80](https://github.com/steveyegge/gastown/issues/80).*
|
||||
@@ -88,15 +88,37 @@ All events include actor attribution:
|
||||
|
||||
## Environment Setup
|
||||
|
||||
The daemon sets these automatically when spawning agents:
|
||||
Gas Town uses a centralized `config.AgentEnv()` function to set environment
|
||||
variables consistently across all agent spawn paths (managers, daemon, boot).
|
||||
|
||||
### Example: Polecat Environment
|
||||
|
||||
```bash
|
||||
# Set by daemon for polecat 'toast' in rig 'gastown'
|
||||
export BD_ACTOR="gastown/polecats/toast"
|
||||
export GIT_AUTHOR_NAME="gastown/polecats/toast"
|
||||
# Set automatically for polecat 'toast' in rig 'gastown'
|
||||
export GT_ROLE="polecat"
|
||||
export GT_RIG="gastown"
|
||||
export GT_POLECAT="toast"
|
||||
export BD_ACTOR="gastown/polecats/toast"
|
||||
export GIT_AUTHOR_NAME="gastown/polecats/toast"
|
||||
export GT_ROOT="/home/user/gt"
|
||||
export BEADS_DIR="/home/user/gt/gastown/.beads"
|
||||
export BEADS_AGENT_NAME="gastown/toast"
|
||||
export BEADS_NO_DAEMON="1" # Polecats use isolated beads context
|
||||
```
|
||||
|
||||
### Example: Crew Environment
|
||||
|
||||
```bash
|
||||
# Set automatically for crew member 'joe' in rig 'gastown'
|
||||
export GT_ROLE="crew"
|
||||
export GT_RIG="gastown"
|
||||
export GT_CREW="joe"
|
||||
export BD_ACTOR="gastown/crew/joe"
|
||||
export GIT_AUTHOR_NAME="gastown/crew/joe"
|
||||
export GT_ROOT="/home/user/gt"
|
||||
export BEADS_DIR="/home/user/gt/gastown/.beads"
|
||||
export BEADS_AGENT_NAME="gastown/joe"
|
||||
export BEADS_NO_DAEMON="1" # Crew uses isolated beads context
|
||||
```
|
||||
|
||||
### Manual Override
|
||||
@@ -108,6 +130,9 @@ export BD_ACTOR="gastown/crew/debug"
|
||||
bd create --title="Test issue" # Will show created_by: gastown/crew/debug
|
||||
```
|
||||
|
||||
See [reference.md](reference.md#environment-variables) for the complete
|
||||
environment variable reference.
|
||||
|
||||
## Identity Parsing
|
||||
|
||||
The format supports programmatic parsing:
|
||||
|
||||
@@ -5,9 +5,36 @@
|
||||
## Overview
|
||||
|
||||
Polecats have three distinct lifecycle layers that operate independently. Confusing
|
||||
these layers leads to heresies like "idle polecats" and misunderstanding when
|
||||
these layers leads to bugs like "idle polecats" and misunderstanding when
|
||||
recycling occurs.
|
||||
|
||||
## The Self-Cleaning Polecat Model
|
||||
|
||||
**Polecats are responsible for their own cleanup.** When a polecat completes its
|
||||
work unit, it:
|
||||
|
||||
1. Signals completion via `gt done`
|
||||
2. Exits its session immediately (no idle waiting)
|
||||
3. Requests its own nuke (self-delete)
|
||||
|
||||
This removes dependency on the Witness/Deacon for cleanup and ensures polecats
|
||||
never sit idle. The simple model: **sandbox dies with session**.
|
||||
|
||||
### Why Self-Cleaning?
|
||||
|
||||
- **No idle polecats** - There's no state where a polecat exists without work
|
||||
- **Reduced watchdog overhead** - Deacon doesn't need to patrol for zombies
|
||||
- **Faster turnover** - Resources freed immediately on completion
|
||||
- **Simpler mental model** - Done means gone
|
||||
|
||||
### What About Pending Merges?
|
||||
|
||||
The Refinery owns the merge queue. Once `gt done` submits work:
|
||||
- The branch is pushed to origin
|
||||
- Work exists in the MQ, not in the polecat
|
||||
- If rebase fails, Refinery re-implements on new baseline (fresh polecat)
|
||||
- The original polecat is already gone - no sending work "back"
|
||||
|
||||
## The Three Layers
|
||||
|
||||
| Layer | Component | Lifecycle | Persistence |
|
||||
@@ -92,19 +119,23 @@ The slot:
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ gt done │
|
||||
│ → Polecat signals completion to Witness │
|
||||
│ → Session exits (no idle waiting) │
|
||||
│ → Witness receives POLECAT_DONE event │
|
||||
│ gt done (self-cleaning) │
|
||||
│ → Push branch to origin │
|
||||
│ → Submit work to merge queue (MR bead) │
|
||||
│ → Request self-nuke (sandbox + session cleanup) │
|
||||
│ → Exit immediately │
|
||||
│ │
|
||||
│ Work now lives in MQ, not in polecat. │
|
||||
│ Polecat is GONE. No idle state. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Witness: gt polecat nuke │
|
||||
│ → Verify work landed (merged or in MQ) │
|
||||
│ → Delete sandbox (remove worktree) │
|
||||
│ → Kill tmux session │
|
||||
│ → Release slot back to pool │
|
||||
│ Refinery: merge queue │
|
||||
│ → Rebase and merge to main │
|
||||
│ → Close the issue │
|
||||
│ → If conflict: spawn FRESH polecat to re-implement │
|
||||
│ (never send work back to original polecat - it's gone) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -210,13 +241,40 @@ All except `gt done` result in continued work. Only `gt done` signals completion
|
||||
The Witness monitors polecats but does NOT:
|
||||
- Force session cycles (polecats self-manage via handoff)
|
||||
- Interrupt mid-step (unless truly stuck)
|
||||
- Recycle sandboxes between steps
|
||||
- Nuke polecats (polecats self-nuke via `gt done`)
|
||||
|
||||
The Witness DOES:
|
||||
- Respawn crashed sessions
|
||||
- Nudge stuck polecats
|
||||
- Nuke completed polecats (after verification)
|
||||
- Handle escalations
|
||||
- Clean up orphaned polecats (crash before `gt done`)
|
||||
|
||||
## Polecat Identity
|
||||
|
||||
**Key insight:** Polecat *identity* is long-lived; only sessions and sandboxes are ephemeral.
|
||||
|
||||
In the HOP model, every entity has a chain (CV) that tracks:
|
||||
- What work they've done
|
||||
- Success/failure rates
|
||||
- Skills demonstrated
|
||||
- Quality metrics
|
||||
|
||||
The polecat *name* (Toast, Shadow, etc.) is a slot from a pool - truly ephemeral.
|
||||
But the *agent identity* that executes as that polecat accumulates a work history.
|
||||
|
||||
```
|
||||
POLECAT IDENTITY (persistent) SESSION (ephemeral) SANDBOX (ephemeral)
|
||||
├── CV chain ├── Claude instance ├── Git worktree
|
||||
├── Work history ├── Context window ├── Branch
|
||||
├── Skills demonstrated └── Dies on handoff └── Dies on gt done
|
||||
└── Credit for work or gt done
|
||||
```
|
||||
|
||||
This distinction matters for:
|
||||
- **Attribution** - Who gets credit for the work?
|
||||
- **Skill routing** - Which agent is best for this task?
|
||||
- **Cost accounting** - Who pays for inference?
|
||||
- **Federation** - Agents having their own chains in a distributed world
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
300
docs/property-layers.md
Normal file
300
docs/property-layers.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Property Layers: Multi-Level Configuration
|
||||
|
||||
> Implementation guide for Gas Town's configuration system.
|
||||
> Created: 2025-01-06
|
||||
|
||||
## Overview
|
||||
|
||||
Gas Town uses a layered property system for configuration. Properties are
|
||||
looked up through multiple layers, with earlier layers overriding later ones.
|
||||
This enables both local control and global coordination.
|
||||
|
||||
## The Four Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. WISP LAYER (transient, town-local) │
|
||||
│ Location: <rig>/.beads-wisp/config/ │
|
||||
│ Synced: Never │
|
||||
│ Use: Temporary local overrides │
|
||||
└─────────────────────────────┬───────────────────────────────┘
|
||||
│ if missing
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. RIG BEAD LAYER (persistent, synced globally) │
|
||||
│ Location: <rig>/.beads/ (rig identity bead labels) │
|
||||
│ Synced: Via git (all clones see it) │
|
||||
│ Use: Project-wide operational state │
|
||||
└─────────────────────────────┬───────────────────────────────┘
|
||||
│ if missing
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. TOWN DEFAULTS │
|
||||
│ Location: ~/gt/config.json or ~/gt/.beads/ │
|
||||
│ Synced: N/A (per-town) │
|
||||
│ Use: Town-wide policies │
|
||||
└─────────────────────────────┬───────────────────────────────┘
|
||||
│ if missing
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4. SYSTEM DEFAULTS (compiled in) │
|
||||
│ Use: Fallback when nothing else specified │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Lookup Behavior
|
||||
|
||||
### Override Semantics (Default)
|
||||
|
||||
For most properties, the first non-nil value wins:
|
||||
|
||||
```go
|
||||
func GetConfig(key string) interface{} {
|
||||
if val := wisp.Get(key); val != nil {
|
||||
if val == Blocked { return nil }
|
||||
return val
|
||||
}
|
||||
if val := rigBead.GetLabel(key); val != nil {
|
||||
return val
|
||||
}
|
||||
if val := townDefaults.Get(key); val != nil {
|
||||
return val
|
||||
}
|
||||
return systemDefaults[key]
|
||||
}
|
||||
```
|
||||
|
||||
### Stacking Semantics (Integers)
|
||||
|
||||
For integer properties, values from wisp and bead layers **add** to the base:
|
||||
|
||||
```go
|
||||
func GetIntConfig(key string) int {
|
||||
base := getBaseDefault(key) // Town or system default
|
||||
beadAdj := rigBead.GetInt(key) // 0 if missing
|
||||
wispAdj := wisp.GetInt(key) // 0 if missing
|
||||
return base + beadAdj + wispAdj
|
||||
}
|
||||
```
|
||||
|
||||
This enables temporary adjustments without changing the base value.
|
||||
|
||||
### Blocking Inheritance
|
||||
|
||||
You can explicitly block a property from being inherited:
|
||||
|
||||
```bash
|
||||
gt rig config set gastown auto_restart --block
|
||||
```
|
||||
|
||||
This creates a "blocked" marker in the wisp layer. Even if the rig bead
|
||||
or defaults say `auto_restart: true`, the lookup returns nil.
|
||||
|
||||
## Rig Identity Beads
|
||||
|
||||
Each rig has an identity bead for operational state:
|
||||
|
||||
```yaml
|
||||
id: gt-rig-gastown
|
||||
type: rig
|
||||
name: gastown
|
||||
repo: git@github.com:steveyegge/gastown.git
|
||||
prefix: gt
|
||||
|
||||
labels:
|
||||
- status:operational
|
||||
- priority:normal
|
||||
```
|
||||
|
||||
These beads sync via git, so all clones of the rig see the same state.
|
||||
|
||||
## Two-Level Rig Control
|
||||
|
||||
### Level 1: Park (Local, Ephemeral)
|
||||
|
||||
```bash
|
||||
gt rig park gastown # Stop services, daemon won't restart
|
||||
gt rig unpark gastown # Allow services to run
|
||||
```
|
||||
|
||||
- Stored in wisp layer (`.beads-wisp/config/`)
|
||||
- Only affects this town
|
||||
- Disappears on cleanup
|
||||
- Use: Local maintenance, debugging
|
||||
|
||||
### Level 2: Dock (Global, Persistent)
|
||||
|
||||
```bash
|
||||
gt rig dock gastown # Set status:docked label on rig bead
|
||||
gt rig undock gastown # Remove label
|
||||
```
|
||||
|
||||
- Stored on rig identity bead
|
||||
- Syncs to all clones via git
|
||||
- Permanent until explicitly changed
|
||||
- Use: Project-wide maintenance, coordinated downtime
|
||||
|
||||
### Daemon Behavior
|
||||
|
||||
The daemon checks both levels before auto-restarting:
|
||||
|
||||
```go
|
||||
func shouldAutoRestart(rig *Rig) bool {
|
||||
status := rig.GetConfig("status")
|
||||
if status == "parked" || status == "docked" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Keys
|
||||
|
||||
| Key | Type | Behavior | Description |
|
||||
|-----|------|----------|-------------|
|
||||
| `status` | string | Override | operational/parked/docked |
|
||||
| `auto_restart` | bool | Override | Daemon auto-restart behavior |
|
||||
| `max_polecats` | int | Override | Maximum concurrent polecats |
|
||||
| `priority_adjustment` | int | **Stack** | Scheduling priority modifier |
|
||||
| `maintenance_window` | string | Override | When maintenance allowed |
|
||||
| `dnd` | bool | Override | Do not disturb mode |
|
||||
|
||||
## Commands
|
||||
|
||||
### View Configuration
|
||||
|
||||
```bash
|
||||
gt rig config show gastown # Show effective config (all layers)
|
||||
gt rig config show gastown --layer # Show which layer each value comes from
|
||||
```
|
||||
|
||||
### Set Configuration
|
||||
|
||||
```bash
|
||||
# Set in wisp layer (local, ephemeral)
|
||||
gt rig config set gastown key value
|
||||
|
||||
# Set in bead layer (global, permanent)
|
||||
gt rig config set gastown key value --global
|
||||
|
||||
# Block inheritance
|
||||
gt rig config set gastown key --block
|
||||
|
||||
# Clear from wisp layer
|
||||
gt rig config unset gastown key
|
||||
```
|
||||
|
||||
### Rig Lifecycle
|
||||
|
||||
```bash
|
||||
gt rig park gastown # Local: stop + prevent restart
|
||||
gt rig unpark gastown # Local: allow restart
|
||||
|
||||
gt rig dock gastown # Global: mark as offline
|
||||
gt rig undock gastown # Global: mark as operational
|
||||
|
||||
gt rig status gastown # Show current state
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Temporary Priority Boost
|
||||
|
||||
```bash
|
||||
# Base priority: 0 (from defaults)
|
||||
# Give this rig temporary priority boost for urgent work
|
||||
|
||||
gt rig config set gastown priority_adjustment 10
|
||||
|
||||
# Effective priority: 0 + 10 = 10
|
||||
# When done, clear it:
|
||||
|
||||
gt rig config unset gastown priority_adjustment
|
||||
```
|
||||
|
||||
### Local Maintenance
|
||||
|
||||
```bash
|
||||
# I'm upgrading the local clone, don't restart services
|
||||
gt rig park gastown
|
||||
|
||||
# ... do maintenance ...
|
||||
|
||||
gt rig unpark gastown
|
||||
```
|
||||
|
||||
### Project-Wide Maintenance
|
||||
|
||||
```bash
|
||||
# Major refactor in progress, all clones should pause
|
||||
gt rig dock gastown
|
||||
|
||||
# Syncs via git - other towns see the rig as docked
|
||||
bd sync
|
||||
|
||||
# When done:
|
||||
gt rig undock gastown
|
||||
bd sync
|
||||
```
|
||||
|
||||
### Block Auto-Restart Locally
|
||||
|
||||
```bash
|
||||
# Rig bead says auto_restart: true
|
||||
# But I'm debugging and don't want that here
|
||||
|
||||
gt rig config set gastown auto_restart --block
|
||||
|
||||
# Now auto_restart returns nil for this town only
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Wisp Storage
|
||||
|
||||
Wisp config stored in `.beads-wisp/config/<rig>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"rig": "gastown",
|
||||
"values": {
|
||||
"status": "parked",
|
||||
"priority_adjustment": 10
|
||||
},
|
||||
"blocked": ["auto_restart"]
|
||||
}
|
||||
```
|
||||
|
||||
### Rig Bead Labels
|
||||
|
||||
Rig operational state stored as labels on the rig identity bead:
|
||||
|
||||
```bash
|
||||
bd label add gt-rig-gastown status:docked
|
||||
bd label remove gt-rig-gastown status:docked
|
||||
```
|
||||
|
||||
### Daemon Integration
|
||||
|
||||
The daemon's lifecycle manager checks config before starting services:
|
||||
|
||||
```go
|
||||
func (d *Daemon) maybeStartRigServices(rig string) {
|
||||
r := d.getRig(rig)
|
||||
|
||||
status := r.GetConfig("status")
|
||||
if status == "parked" || status == "docked" {
|
||||
log.Info("Rig %s is offline, skipping auto-start", rig)
|
||||
return
|
||||
}
|
||||
|
||||
d.ensureWitness(rig)
|
||||
d.ensureRefinery(rig)
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documents
|
||||
|
||||
- `~/gt/docs/hop/PROPERTY-LAYERS.md` - Strategic architecture
|
||||
- `wisp-architecture.md` - Wisp system design
|
||||
- `agent-as-bead.md` - Agent identity beads (similar pattern)
|
||||
@@ -7,24 +7,38 @@ Technical reference for Gas Town internals. Read the README first.
|
||||
```
|
||||
~/gt/ Town root
|
||||
├── .beads/ Town-level beads (hq-* prefix)
|
||||
├── mayor/ Mayor config
|
||||
│ └── town.json
|
||||
├── mayor/ Mayor agent home (town coordinator)
|
||||
│ ├── town.json Town configuration
|
||||
│ ├── CLAUDE.md Mayor context (on disk)
|
||||
│ └── .claude/settings.json Mayor Claude settings
|
||||
├── deacon/ Deacon agent home (background supervisor)
|
||||
│ └── .claude/settings.json Deacon settings (context via gt prime)
|
||||
└── <rig>/ Project container (NOT a git clone)
|
||||
├── config.json Rig identity
|
||||
├── .beads/ → mayor/rig/.beads
|
||||
├── .repo.git/ Bare repo (shared by worktrees)
|
||||
├── mayor/rig/ Mayor's clone (canonical beads)
|
||||
├── refinery/rig/ Worktree on main
|
||||
├── witness/ No clone (monitors only)
|
||||
├── crew/<name>/ Human workspaces
|
||||
└── polecats/<name>/ Worker worktrees
|
||||
│ └── CLAUDE.md Per-rig mayor context (on disk)
|
||||
├── witness/ Witness agent home (monitors only)
|
||||
│ └── .claude/settings.json (context via gt prime)
|
||||
├── refinery/ Refinery settings parent
|
||||
│ ├── .claude/settings.json
|
||||
│ └── rig/ Worktree on main
|
||||
│ └── CLAUDE.md Refinery context (on disk)
|
||||
├── crew/ Crew settings parent (shared)
|
||||
│ ├── .claude/settings.json (context via gt prime)
|
||||
│ └── <name>/rig/ Human workspaces
|
||||
└── polecats/ Polecat settings parent (shared)
|
||||
├── .claude/settings.json (context via gt prime)
|
||||
└── <name>/rig/ Worker worktrees
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Rig root is a container, not a clone
|
||||
- `.repo.git/` is bare - refinery and polecats are worktrees
|
||||
- Mayor clone holds canonical `.beads/`, others inherit via redirect
|
||||
- Per-rig `mayor/rig/` holds canonical `.beads/`, others inherit via redirect
|
||||
- Settings placed in parent dirs (not git clones) for upward traversal
|
||||
|
||||
## Beads Routing
|
||||
|
||||
@@ -192,17 +206,177 @@ gt mol step done <step> # Complete a molecule step
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Gas Town sets environment variables for each agent session via `config.AgentEnv()`.
|
||||
These are set in tmux session environment when agents are spawned.
|
||||
|
||||
### Core Variables (All Agents)
|
||||
|
||||
| Variable | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `GT_ROLE` | Agent role type | `mayor`, `witness`, `polecat`, `crew` |
|
||||
| `GT_ROOT` | Town root directory | `/home/user/gt` |
|
||||
| `BD_ACTOR` | Agent identity for attribution | `gastown/polecats/toast` |
|
||||
| `GIT_AUTHOR_NAME` | Commit attribution (same as BD_ACTOR) | `gastown/polecats/toast` |
|
||||
| `BEADS_DIR` | Beads database location | `/home/user/gt/gastown/.beads` |
|
||||
|
||||
### Rig-Level Variables
|
||||
|
||||
| Variable | Purpose | Roles |
|
||||
|----------|---------|-------|
|
||||
| `GT_RIG` | Rig name | witness, refinery, polecat, crew |
|
||||
| `GT_POLECAT` | Polecat worker name | polecat only |
|
||||
| `GT_CREW` | Crew worker name | crew only |
|
||||
| `BEADS_AGENT_NAME` | Agent name for beads operations | polecat, crew |
|
||||
| `BEADS_NO_DAEMON` | Disable beads daemon (isolated context) | polecat, crew |
|
||||
|
||||
### Other Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `BD_ACTOR` | Agent identity for attribution (see [identity.md](identity.md)) |
|
||||
| `BEADS_DIR` | Point to shared beads database |
|
||||
| `BEADS_NO_DAEMON` | Required for worktree polecats |
|
||||
| `GIT_AUTHOR_NAME` | Set to BD_ACTOR for commit attribution |
|
||||
| `GIT_AUTHOR_EMAIL` | Workspace owner email |
|
||||
| `GT_TOWN_ROOT` | Override town root detection |
|
||||
| `GT_ROLE` | Agent role type (mayor, polecat, etc.) |
|
||||
| `GT_RIG` | Rig name for rig-level agents |
|
||||
| `GT_POLECAT` | Polecat name (for polecats only) |
|
||||
| `GIT_AUTHOR_EMAIL` | Workspace owner email (from git config) |
|
||||
| `GT_TOWN_ROOT` | Override town root detection (manual use) |
|
||||
| `CLAUDE_RUNTIME_CONFIG_DIR` | Custom Claude settings directory |
|
||||
|
||||
### Environment by Role
|
||||
|
||||
| Role | Key Variables |
|
||||
|------|---------------|
|
||||
| **Mayor** | `GT_ROLE=mayor`, `BD_ACTOR=mayor` |
|
||||
| **Deacon** | `GT_ROLE=deacon`, `BD_ACTOR=deacon` |
|
||||
| **Boot** | `GT_ROLE=boot`, `BD_ACTOR=deacon-boot` |
|
||||
| **Witness** | `GT_ROLE=witness`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/witness` |
|
||||
| **Refinery** | `GT_ROLE=refinery`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/refinery` |
|
||||
| **Polecat** | `GT_ROLE=polecat`, `GT_RIG=<rig>`, `GT_POLECAT=<name>`, `BD_ACTOR=<rig>/polecats/<name>` |
|
||||
| **Crew** | `GT_ROLE=crew`, `GT_RIG=<rig>`, `GT_CREW=<name>`, `BD_ACTOR=<rig>/crew/<name>` |
|
||||
|
||||
### Doctor Check
|
||||
|
||||
The `gt doctor` command verifies that running tmux sessions have correct
|
||||
environment variables. Mismatches are reported as warnings:
|
||||
|
||||
```
|
||||
⚠ env-vars: Found 3 env var mismatch(es) across 1 session(s)
|
||||
hq-mayor: missing GT_ROOT (expected "/home/user/gt")
|
||||
```
|
||||
|
||||
Fix by restarting sessions: `gt shutdown && gt up`
|
||||
|
||||
## Agent Working Directories and Settings
|
||||
|
||||
Each agent runs in a specific working directory and has its own Claude settings.
|
||||
Understanding this hierarchy is essential for proper configuration.
|
||||
|
||||
### Working Directories by Role
|
||||
|
||||
| Role | Working Directory | Notes |
|
||||
|------|-------------------|-------|
|
||||
| **Mayor** | `~/gt/mayor/` | Town-level coordinator, isolated from rigs |
|
||||
| **Deacon** | `~/gt/deacon/` | Background supervisor daemon |
|
||||
| **Witness** | `~/gt/<rig>/witness/` | No git clone, monitors polecats only |
|
||||
| **Refinery** | `~/gt/<rig>/refinery/rig/` | Worktree on main branch |
|
||||
| **Crew** | `~/gt/<rig>/crew/<name>/rig/` | Persistent human workspace clone |
|
||||
| **Polecat** | `~/gt/<rig>/polecats/<name>/rig/` | Ephemeral worker worktree |
|
||||
|
||||
Note: The per-rig `<rig>/mayor/rig/` directory is NOT a working directory—it's
|
||||
a git clone that holds the canonical `.beads/` database for that rig.
|
||||
|
||||
### Settings File Locations
|
||||
|
||||
Claude Code searches for `.claude/settings.json` starting from the working
|
||||
directory and traversing upward. Settings are placed in **parent directories**
|
||||
(not inside git clones) so they're found via directory traversal without
|
||||
polluting source repositories:
|
||||
|
||||
```
|
||||
~/gt/
|
||||
├── mayor/.claude/settings.json # Mayor settings
|
||||
├── deacon/.claude/settings.json # Deacon settings
|
||||
└── <rig>/
|
||||
├── witness/.claude/settings.json # Witness settings (no rig/ subdir)
|
||||
├── refinery/.claude/settings.json # Found by refinery/rig/ via traversal
|
||||
├── crew/.claude/settings.json # Shared by all crew/<name>/rig/
|
||||
└── polecats/.claude/settings.json # Shared by all polecats/<name>/rig/
|
||||
```
|
||||
|
||||
**Why parent directories?** Agents working in git clones (like `refinery/rig/`)
|
||||
would pollute the source repo if settings were placed there. By putting settings
|
||||
one level up, Claude finds them via upward traversal, and all workers of the
|
||||
same type share the same settings.
|
||||
|
||||
### CLAUDE.md Locations
|
||||
|
||||
Role context is delivered via CLAUDE.md files or ephemeral injection:
|
||||
|
||||
| Role | CLAUDE.md Location | Method |
|
||||
|------|-------------------|--------|
|
||||
| **Mayor** | `~/gt/mayor/CLAUDE.md` | On disk |
|
||||
| **Deacon** | (none) | Injected via `gt prime` at SessionStart |
|
||||
| **Witness** | (none) | Injected via `gt prime` at SessionStart |
|
||||
| **Refinery** | `<rig>/refinery/rig/CLAUDE.md` | On disk (inside worktree) |
|
||||
| **Crew** | (none) | Injected via `gt prime` at SessionStart |
|
||||
| **Polecat** | (none) | Injected via `gt prime` at SessionStart |
|
||||
|
||||
Additionally, each rig has `<rig>/mayor/rig/CLAUDE.md` for the per-rig mayor clone
|
||||
(used for beads operations, not a running agent).
|
||||
|
||||
**Why ephemeral injection?** Writing CLAUDE.md into git clones would:
|
||||
1. Pollute source repos when agents commit/push
|
||||
2. Leak Gas Town internals into project history
|
||||
3. Conflict with project-specific CLAUDE.md files
|
||||
|
||||
The `gt prime` command runs at SessionStart hook and injects context without
|
||||
persisting it to disk.
|
||||
|
||||
### Sparse Checkout (Source Repo Isolation)
|
||||
|
||||
When agents work on source repositories that have their own Claude Code configuration,
|
||||
Gas Town uses git sparse checkout to exclude all context files:
|
||||
|
||||
```bash
|
||||
# Automatically configured for worktrees - excludes:
|
||||
# - .claude/ : settings, rules, agents, commands
|
||||
# - CLAUDE.md : primary context file
|
||||
# - CLAUDE.local.md: personal context file
|
||||
# - .mcp.json : MCP server configuration
|
||||
git sparse-checkout set --no-cone '/*' '!/.claude/' '!/CLAUDE.md' '!/CLAUDE.local.md' '!/.mcp.json'
|
||||
```
|
||||
|
||||
This ensures agents use Gas Town's context, not the source repo's instructions.
|
||||
|
||||
**Doctor check**: `gt doctor` verifies sparse checkout is configured correctly.
|
||||
Run `gt doctor --fix` to update legacy configurations missing the newer patterns.
|
||||
|
||||
### Settings Inheritance
|
||||
|
||||
Claude Code's settings search order (first match wins):
|
||||
|
||||
1. `.claude/settings.json` in current working directory
|
||||
2. `.claude/settings.json` in parent directories (traversing up)
|
||||
3. `~/.claude/settings.json` (user global settings)
|
||||
|
||||
Gas Town places settings at each agent's working directory root, so agents
|
||||
find their role-specific settings before reaching any parent or global config.
|
||||
|
||||
### Settings Templates
|
||||
|
||||
Gas Town uses two settings templates based on role type:
|
||||
|
||||
| Type | Roles | Key Difference |
|
||||
|------|-------|----------------|
|
||||
| **Interactive** | Mayor, Crew | Mail injected on `UserPromptSubmit` hook |
|
||||
| **Autonomous** | Polecat, Witness, Refinery, Deacon | Mail injected on `SessionStart` hook |
|
||||
|
||||
Autonomous agents may start without user input, so they need mail checked
|
||||
at session start. Interactive agents wait for user prompts.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Agent using wrong settings | Check `gt doctor`, verify sparse checkout |
|
||||
| Settings not found | Ensure `.claude/settings.json` exists at role home |
|
||||
| Source repo settings leaking | Run `gt doctor --fix` to configure sparse checkout |
|
||||
| Mayor settings affecting polecats | Mayor should run in `mayor/`, not town root |
|
||||
|
||||
## CLI Reference
|
||||
|
||||
@@ -228,15 +402,56 @@ gt config agent remove <name> # Remove custom agent (built-ins protected)
|
||||
gt config default-agent [name] # Get or set town default agent
|
||||
```
|
||||
|
||||
**Built-in agents**: `claude`, `gemini`, `codex`
|
||||
**Built-in agents**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||
|
||||
**Custom agents**: Define per-town in `mayor/town.json`:
|
||||
**Custom agents**: Define per-town via CLI or JSON:
|
||||
```bash
|
||||
gt config agent set claude-glm "claude-glm --model glm-4"
|
||||
gt config agent set claude "claude-opus" # Override built-in
|
||||
gt config default-agent claude-glm # Set default
|
||||
```
|
||||
|
||||
**Advanced agent config** (`settings/agents.json`):
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"args": [],
|
||||
"resume_flag": "--session",
|
||||
"resume_style": "flag",
|
||||
"non_interactive": {
|
||||
"subcommand": "run",
|
||||
"output_flag": "--format json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rig-level agents** (`<rig>/settings/config.json`):
|
||||
```json
|
||||
{
|
||||
"type": "rig-settings",
|
||||
"version": 1,
|
||||
"agent": "opencode",
|
||||
"agents": {
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"args": ["--session"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Agent resolution order**: rig-level → town-level → built-in presets.
|
||||
|
||||
For OpenCode autonomous mode, set env var in your shell profile:
|
||||
```bash
|
||||
export OPENCODE_PERMISSION='{"*":"allow"}'
|
||||
```
|
||||
|
||||
### Rig Management
|
||||
|
||||
```bash
|
||||
@@ -264,12 +479,19 @@ Note: "Swarm" is ephemeral (workers on a convoy's issues). See [Convoys](convoy.
|
||||
# Standard workflow: convoy first, then sling
|
||||
gt convoy create "Feature X" gt-abc gt-def
|
||||
gt sling gt-abc <rig> # Assign to polecat
|
||||
gt sling gt-def <rig> --molecule=<proto> # With workflow template
|
||||
gt sling gt-abc <rig> --agent codex # Override runtime for this sling/spawn
|
||||
gt sling <proto> --on gt-def <rig> # With workflow template
|
||||
|
||||
# Quick sling (auto-creates convoy)
|
||||
gt sling <bead> <rig> # Auto-convoy for dashboard visibility
|
||||
```
|
||||
|
||||
Agent overrides:
|
||||
|
||||
- `gt start --agent <alias>` overrides the Mayor/Deacon runtime for this launch.
|
||||
- `gt mayor start|attach|restart --agent <alias>` and `gt deacon start|attach|restart --agent <alias>` do the same.
|
||||
- `gt start crew <name> --agent <alias>` and `gt crew at <name> --agent <alias>` override the crew worker runtime.
|
||||
|
||||
### Communication
|
||||
|
||||
```bash
|
||||
|
||||
220
docs/reviews/infrastructure-review.md
Normal file
220
docs/reviews/infrastructure-review.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Infrastructure & Utilities Code Review
|
||||
|
||||
**Review ID**: gt-a02fj.8
|
||||
**Date**: 2026-01-04
|
||||
**Reviewer**: gastown/polecats/interceptor (polecat gus)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Reviewed 14 infrastructure packages for dead code, missing abstractions, performance concerns, and error handling consistency. Found significant cleanup opportunities totaling ~44% dead code in constants package and an entire unused package (keepalive).
|
||||
|
||||
---
|
||||
|
||||
## 1. Dead Code Inventory
|
||||
|
||||
### Critical: Entire Package Unused
|
||||
|
||||
| Package | Status | Recommendation |
|
||||
|---------|--------|----------------|
|
||||
| `internal/keepalive/` | 100% unused | **DELETE ENTIRE PACKAGE** |
|
||||
|
||||
The keepalive package (5 functions) was removed from the codebase on Dec 30, 2025 as part of the shift to feed-based activation. No imports exist anywhere.
|
||||
|
||||
### High Priority: Functions to Remove
|
||||
|
||||
| Package | Function | Location | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| `config` | `NewExampleAgentRegistry()` | agents.go:361-381 | Zero usage in codebase |
|
||||
| `constants` | `DirMayor`, `DirPolecats`, `DirCrew`, etc. | constants.go:32-59 | 9 unused directory constants |
|
||||
| `constants` | `FileRigsJSON`, `FileTownJSON`, etc. | constants.go:62-74 | 4 unused file constants |
|
||||
| `constants` | `BranchMain`, `BranchBeadsSync`, etc. | constants.go:77-89 | 4 unused branch constants |
|
||||
| `constants` | `RigBeadsPath()`, `RigPolecatsPath()`, etc. | constants.go | 5 unused path helper functions |
|
||||
| `doctor` | `itoa()` | daemon_check.go:93-111 | Duplicate of `strconv.Itoa()` |
|
||||
| `lock` | `DetectCollisions()` | lock.go:367-402 | Superseded by doctor checks |
|
||||
| `events` | `BootPayload()` | events.go:186-191 | Never called |
|
||||
| `events` | `TypePatrolStarted`, `TypeSessionEnd` | events.go:50,54 | Never emitted |
|
||||
| `events` | `VisibilityBoth` | events.go:32 | Never set |
|
||||
| `boot` | `DeaconDir()` | boot.go:235-237 | Exported but never called |
|
||||
| `dog` | `IdleCount()`, `WorkingCount()` | manager.go:532-562 | Inlined in callers |
|
||||
|
||||
### Medium Priority: Duplicate Definitions
|
||||
|
||||
| Package | Item | Duplicate Location | Action |
|
||||
|---------|------|-------------------|--------|
|
||||
| `constants` | `RigSettingsPath()` | Also in config/loader.go:673 | Remove from constants |
|
||||
| `util` | Atomic write pattern | Also in mrqueue/, wisp/ | Consolidate to util |
|
||||
| `doctor` | `findRigs()` | 3 identical implementations | Extract shared helper |
|
||||
|
||||
---
|
||||
|
||||
## 2. Utility Consolidation Plan
|
||||
|
||||
### Pattern: Atomic Write (Priority: HIGH)
|
||||
|
||||
**Current state**: Duplicated in 3+ locations
|
||||
- `util/atomic.go` (canonical)
|
||||
- `mrqueue/mrqueue.go` (duplicate)
|
||||
- `wisp/io.go` (duplicate)
|
||||
- `polecat/pending.go` (NON-ATOMIC - bug!)
|
||||
|
||||
**Action**:
|
||||
1. Fix `polecat/pending.go:SavePending()` to use `util.AtomicWriteJSON`
|
||||
2. Replace inline atomic writes in mrqueue and wisp with util calls
|
||||
|
||||
### Pattern: Rig Discovery (Priority: HIGH)
|
||||
|
||||
**Current state**: 7+ implementations scattered across doctor package
|
||||
- `BranchCheck.findPersistentRoleDirs()`
|
||||
- `OrphanSessionCheck.getValidRigs()`
|
||||
- `PatrolMoleculesExistCheck.discoverRigs()`
|
||||
- `config_check.go.findAllRigs()`
|
||||
- Multiple `findCrewDirs()` implementations
|
||||
|
||||
**Action**: Create `internal/workspace/discovery.go`:
|
||||
```go
|
||||
type RigDiscovery struct { ... }
|
||||
func (d *RigDiscovery) FindAllRigs() []string
|
||||
func (d *RigDiscovery) FindCrewDirs(rig string) []string
|
||||
func (d *RigDiscovery) FindPolecatDirs(rig string) []string
|
||||
```
|
||||
|
||||
### Pattern: Clone Validation (Priority: MEDIUM)
|
||||
|
||||
**Current state**: Duplicate logic in doctor checks
|
||||
- `rig_check.go`: Validates .git, runs git status
|
||||
- `branch_check.go`: Similar traversal logic
|
||||
|
||||
**Action**: Create `internal/workspace/clone.go`:
|
||||
```go
|
||||
type CloneValidator struct { ... }
|
||||
func (v *CloneValidator) ValidateClone(path string) error
|
||||
func (v *CloneValidator) GetCloneInfo(path string) (*CloneInfo, error)
|
||||
```
|
||||
|
||||
### Pattern: Tmux Session Handling (Priority: MEDIUM)
|
||||
|
||||
**Current state**: Fragmented across lock, doctor, daemon
|
||||
- `lock/lock.go`: `getActiveTmuxSessions()`
|
||||
- `doctor/identity_check.go`: Similar logic
|
||||
- `cmd/agents.go`: Uses `tmux.NewTmux()`
|
||||
|
||||
**Action**: Consolidate into `internal/tmux/sessions.go`
|
||||
|
||||
### Pattern: Load/Validate Config Files (Priority: LOW)
|
||||
|
||||
**Current state**: 8 near-identical Load* functions in config/loader.go
|
||||
- `LoadTownConfig`, `LoadRigsConfig`, `LoadRigConfig`, etc.
|
||||
|
||||
**Action**: Create generic loader using Go generics:
|
||||
```go
|
||||
func loadConfigFile[T Validator](path string) (*T, error)
|
||||
```
|
||||
|
||||
### Pattern: Math Utilities (Priority: LOW)
|
||||
|
||||
**Current state**: `min()`, `max()`, `min3()`, `abs()` in suggest/suggest.go
|
||||
|
||||
**Action**: If needed elsewhere, move to `internal/util/math.go`
|
||||
|
||||
---
|
||||
|
||||
## 3. Performance Concerns
|
||||
|
||||
### Critical: File I/O Per-Event
|
||||
|
||||
| Package | Issue | Impact | Recommendation |
|
||||
|---------|-------|--------|----------------|
|
||||
| `events` | Opens/closes file for every event | High on busy systems | Batch writes or buffered logger |
|
||||
| `townlog` | Opens/closes file per log entry | Medium | Same as events |
|
||||
| `events` | `workspace.FindFromCwd()` on every Log() | Low-medium | Cache town root |
|
||||
|
||||
### Critical: Process Tree Walking
|
||||
|
||||
| Package | Issue | Impact | Recommendation |
|
||||
|---------|-------|--------|----------------|
|
||||
| `doctor/orphan_check` | `hasCrewAncestor()` calls `ps` in loop | O(n) subprocess calls | Batch gather process info |
|
||||
|
||||
### High: Directory Traversal Inefficiencies
|
||||
|
||||
| Package | Issue | Impact | Recommendation |
|
||||
|---------|-------|--------|----------------|
|
||||
| `doctor/hook_check` | Uses `exec.Command("find")` | Subprocess overhead | Use `filepath.Walk` |
|
||||
| `lock` | `FindAllLocks()` - unbounded Walk | Scales poorly | Add depth limits |
|
||||
| `townlog` | `TailEvents()` reads entire file | Memory for large logs | Implement true tail |
|
||||
|
||||
### Medium: Redundant Operations
|
||||
|
||||
| Package | Issue | Recommendation |
|
||||
|---------|-------|----------------|
|
||||
| `dog` | `List()` + iterate = double work | Provide `CountByState()` |
|
||||
| `dog` | Creates new git.Git per worktree | Cache or batch |
|
||||
| `doctor/rig_check` | Runs git status twice per polecat | Combine operations |
|
||||
| `checkpoint/Capture` | 3 separate git commands | Use combined flags |
|
||||
|
||||
### Low: JSON Formatting Overhead
|
||||
|
||||
| Package | Issue | Recommendation |
|
||||
|---------|-------|----------------|
|
||||
| `lock` | `MarshalIndent()` for lock files | Use `Marshal()` (no indentation needed) |
|
||||
| `townlog` | No compression for old logs | Consider gzip rotation |
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling Issues
|
||||
|
||||
### Pattern: Silent Failures
|
||||
|
||||
| Package | Location | Issue | Fix |
|
||||
|---------|----------|-------|-----|
|
||||
| `events` | All callers | 19 instances of `_ = events.LogFeed()` | Standardize: always ignore or always check |
|
||||
| `townlog` | `ParseLogLines()` | Silently skips malformed lines | Log warnings |
|
||||
| `lock` | Lines 91, 180, 194-195 | Silent `_ =` without comments | Document intent |
|
||||
| `checkpoint` | `Capture()` | Returns nil error but git commands fail | Return actual errors |
|
||||
| `deps` | `BeadsUnknown` case | Silently passes | Log warning or fail |
|
||||
|
||||
### Pattern: Inconsistent State Handling
|
||||
|
||||
| Package | Issue | Recommendation |
|
||||
|---------|-------|----------------|
|
||||
| `dog/Get()` | Returns minimal Dog if state missing | Document or error |
|
||||
| `config/GetAccount()` | Returns pointer to loop variable (bug!) | Return by value |
|
||||
| `boot` | `LoadStatus()` returns empty struct if missing | Document behavior |
|
||||
|
||||
### Bug: Missing Role Mapping
|
||||
|
||||
| Package | Issue | Impact |
|
||||
|---------|-------|--------|
|
||||
| `claude` | `RoleTypeFor()` missing `deacon`, `crew` | Wrong settings applied |
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Gaps
|
||||
|
||||
| Package | Gap | Priority |
|
||||
|---------|-----|----------|
|
||||
| `checkpoint` | No unit tests | HIGH (crash recovery) |
|
||||
| `dog` | 4 tests, major paths untested | HIGH |
|
||||
| `deps` | Minimal failure path testing | MEDIUM |
|
||||
| `claude` | No tests | LOW |
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Category | Count | Packages Affected |
|
||||
|----------|-------|-------------------|
|
||||
| **Dead Code Items** | 25+ | config, constants, doctor, lock, events, boot, dog, keepalive |
|
||||
| **Duplicate Patterns** | 6 | util, doctor, config, lock |
|
||||
| **Performance Issues** | 12 | events, townlog, doctor, dog, lock, checkpoint |
|
||||
| **Error Handling Issues** | 15 | events, townlog, lock, checkpoint, deps, claude |
|
||||
| **Testing Gaps** | 4 packages | checkpoint, dog, deps, claude |
|
||||
|
||||
## Recommended Priority
|
||||
|
||||
1. **Delete keepalive package** (entire package unused)
|
||||
2. **Fix claude/RoleTypeFor()** (incorrect behavior)
|
||||
3. **Fix config/GetAccount()** (pointer to stack bug)
|
||||
4. **Fix polecat/pending.go** (non-atomic writes)
|
||||
5. **Delete 21 unused constants** (maintenance burden)
|
||||
6. **Consolidate atomic write pattern** (DRY)
|
||||
7. **Add checkpoint tests** (crash recovery critical)
|
||||
@@ -26,7 +26,7 @@ These roles manage the Gas Town system itself:
|
||||
|
||||
| Role | Description | Lifecycle |
|
||||
|------|-------------|-----------|
|
||||
| **Mayor** | Global coordinator at town root | Singleton, persistent |
|
||||
| **Mayor** | Global coordinator at mayor/ | Singleton, persistent |
|
||||
| **Deacon** | Background supervisor daemon ([watchdog chain](watchdog-chain.md)) | Singleton, persistent |
|
||||
| **Witness** | Per-rig polecat lifecycle manager | One per rig, persistent |
|
||||
| **Refinery** | Per-rig merge queue processor | One per rig, persistent |
|
||||
|
||||
@@ -82,11 +82,11 @@ The daemon runs a heartbeat tick every 3 minutes:
|
||||
func (d *Daemon) heartbeatTick() {
|
||||
d.ensureBootRunning() // 1. Spawn Boot for triage
|
||||
d.checkDeaconHeartbeat() // 2. Belt-and-suspenders fallback
|
||||
d.ensureWitnessesRunning() // 3. Witness health
|
||||
d.triggerPendingSpawns() // 4. Bootstrap polecats
|
||||
d.processLifecycleRequests() // 5. Cycle/restart requests
|
||||
d.checkStaleAgents() // 6. Timeout detection
|
||||
// ... more checks
|
||||
d.ensureWitnessesRunning() // 3. Witness health (checks tmux directly)
|
||||
d.ensureRefineriesRunning() // 4. Refinery health (checks tmux directly)
|
||||
d.triggerPendingSpawns() // 5. Bootstrap polecats
|
||||
d.processLifecycleRequests() // 6. Cycle/restart requests
|
||||
// Agent state derived from tmux, not recorded in beads (gt-zecmc)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -190,7 +190,7 @@ Multiple layers ensure recovery:
|
||||
|
||||
1. **Boot triage** - Intelligent observation, first line
|
||||
2. **Daemon checkDeaconHeartbeat()** - Belt-and-suspenders if Boot fails
|
||||
3. **Daemon checkStaleAgents()** - Timeout-based detection
|
||||
3. **Tmux-based discovery** - Daemon checks tmux sessions directly (no bead state)
|
||||
4. **Human escalation** - Mail to overseer for unrecoverable states
|
||||
|
||||
## State Files
|
||||
@@ -239,9 +239,11 @@ gt deacon health-check
|
||||
|
||||
### Status Shows Wrong State
|
||||
|
||||
**Symptom**: `gt status` shows "stopped" for running agents
|
||||
**Cause**: Bead state and tmux state diverged
|
||||
**Fix**: Reconcile with `gt sync-status` or restart agent
|
||||
**Symptom**: `gt status` shows wrong state for agents
|
||||
**Cause**: Previously bead state and tmux state could diverge
|
||||
**Fix**: As of gt-zecmc, status derives state from tmux directly (no bead state for
|
||||
observable conditions like running/stopped). Non-observable states (stuck, awaiting-gate)
|
||||
are still stored in beads.
|
||||
|
||||
## Design Decision: Keep Separation
|
||||
|
||||
@@ -284,7 +286,7 @@ The separation is correct; these bugs need fixing:
|
||||
|
||||
1. **Session confusion** (gt-sgzsb): Boot spawns in wrong session
|
||||
2. **Zombie blocking** (gt-j1i0r): Daemon can't kill zombie sessions
|
||||
3. **Status mismatch** (gt-doih4): Bead vs tmux state divergence
|
||||
3. ~~**Status mismatch** (gt-doih4): Bead vs tmux state divergence~~ → FIXED in gt-zecmc
|
||||
4. **Ensure semantics** (gt-ekc5u): Start should kill zombies first
|
||||
|
||||
## Summary
|
||||
|
||||
495
dog-pool-architecture.md
Normal file
495
dog-pool-architecture.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# Dog Pool Architecture for Concurrent Shutdown Dances
|
||||
|
||||
> Design document for gt-fsld8
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Boot needs to run multiple shutdown-dance molecules concurrently when multiple death
|
||||
warrants are issued. The current hook design only allows one molecule per agent.
|
||||
|
||||
Example scenario:
|
||||
- Warrant 1: Kill stuck polecat Toast (60s into interrogation)
|
||||
- Warrant 2: Kill stuck polecat Shadow (just started)
|
||||
- Warrant 3: Kill stuck witness (120s into interrogation)
|
||||
|
||||
All three need concurrent tracking, independent timeouts, and separate outcomes.
|
||||
|
||||
## Design Decision: Lightweight State Machines
|
||||
|
||||
After analyzing the options, the shutdown-dance does NOT need Claude sessions.
|
||||
The dance is a deterministic state machine:
|
||||
|
||||
```
|
||||
WARRANT -> INTERROGATE -> EVALUATE -> PARDON|EXECUTE
|
||||
```
|
||||
|
||||
Each step is mechanical:
|
||||
1. Send a tmux message (no LLM needed)
|
||||
2. Wait for timeout or response (timer)
|
||||
3. Check tmux output for ALIVE keyword (string match)
|
||||
4. Repeat or terminate
|
||||
|
||||
**Decision**: Dogs are lightweight Go routines, not Claude sessions.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ BOOT │
|
||||
│ (Claude session in tmux) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Dog Manager │ │
|
||||
│ │ │ │
|
||||
│ │ Pool: [Dog1, Dog2, Dog3, ...] (goroutines + state files) │ │
|
||||
│ │ │ │
|
||||
│ │ allocate() → Dog │ │
|
||||
│ │ release(Dog) │ │
|
||||
│ │ status() → []DogStatus │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Boot's job: │
|
||||
│ - Watch for warrants (file or event) │
|
||||
│ - Allocate dog from pool │
|
||||
│ - Monitor dog progress │
|
||||
│ - Handle dog completion/failure │
|
||||
│ - Report results │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Dog Structure
|
||||
|
||||
```go
|
||||
// Dog represents a shutdown-dance executor
|
||||
type Dog struct {
|
||||
ID string // Unique ID (e.g., "dog-1704567890123")
|
||||
Warrant *Warrant // The death warrant being processed
|
||||
State ShutdownDanceState
|
||||
Attempt int // Current interrogation attempt (1-3)
|
||||
StartedAt time.Time
|
||||
StateFile string // Persistent state: ~/gt/deacon/dogs/active/<id>.json
|
||||
}
|
||||
|
||||
type ShutdownDanceState string
|
||||
|
||||
const (
|
||||
StateIdle ShutdownDanceState = "idle"
|
||||
StateInterrogating ShutdownDanceState = "interrogating" // Sent message, waiting
|
||||
StateEvaluating ShutdownDanceState = "evaluating" // Checking response
|
||||
StatePardoned ShutdownDanceState = "pardoned" // Session responded
|
||||
StateExecuting ShutdownDanceState = "executing" // Killing session
|
||||
StateComplete ShutdownDanceState = "complete" // Done, ready for cleanup
|
||||
StateFailed ShutdownDanceState = "failed" // Dog crashed/errored
|
||||
)
|
||||
|
||||
type Warrant struct {
|
||||
ID string // Bead ID for the warrant
|
||||
Target string // Session to interrogate (e.g., "gt-gastown-Toast")
|
||||
Reason string // Why warrant was issued
|
||||
Requester string // Who filed the warrant
|
||||
FiledAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## Pool Design
|
||||
|
||||
### Fixed Pool Size
|
||||
|
||||
**Decision**: Fixed pool of 5 dogs, configurable via environment.
|
||||
|
||||
Rationale:
|
||||
- Dynamic sizing adds complexity without clear benefit
|
||||
- 5 concurrent shutdown dances handles worst-case scenarios
|
||||
- If pool exhausted, warrants queue (better than infinite dog spawning)
|
||||
- Memory footprint is negligible (goroutines + small state files)
|
||||
|
||||
```go
|
||||
const (
|
||||
DefaultPoolSize = 5
|
||||
MaxPoolSize = 20
|
||||
)
|
||||
|
||||
type DogPool struct {
|
||||
mu sync.Mutex
|
||||
dogs []*Dog // All dogs in pool
|
||||
idle chan *Dog // Channel of available dogs
|
||||
active map[string]*Dog // ID -> Dog for active dogs
|
||||
stateDir string // ~/gt/deacon/dogs/active/
|
||||
}
|
||||
|
||||
func (p *DogPool) Allocate(warrant *Warrant) (*Dog, error) {
|
||||
select {
|
||||
case dog := <-p.idle:
|
||||
dog.Warrant = warrant
|
||||
dog.State = StateInterrogating
|
||||
dog.Attempt = 1
|
||||
dog.StartedAt = time.Now()
|
||||
p.active[dog.ID] = dog
|
||||
return dog, nil
|
||||
default:
|
||||
return nil, ErrPoolExhausted
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DogPool) Release(dog *Dog) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.active, dog.ID)
|
||||
dog.Reset()
|
||||
p.idle <- dog
|
||||
}
|
||||
```
|
||||
|
||||
### Why Not Dynamic Pool?
|
||||
|
||||
Considered but rejected:
|
||||
- Adding dogs on demand increases complexity
|
||||
- No clear benefit - warrants rarely exceed 5 concurrent
|
||||
- If needed, raise DefaultPoolSize
|
||||
- Simpler to reason about fixed resources
|
||||
|
||||
## Communication: State Files + Events
|
||||
|
||||
### State Persistence
|
||||
|
||||
Each active dog writes state to `~/gt/deacon/dogs/active/<id>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "dog-1704567890123",
|
||||
"warrant": {
|
||||
"id": "gt-abc123",
|
||||
"target": "gt-gastown-Toast",
|
||||
"reason": "no_response_health_check",
|
||||
"requester": "deacon",
|
||||
"filed_at": "2026-01-07T20:15:00Z"
|
||||
},
|
||||
"state": "interrogating",
|
||||
"attempt": 2,
|
||||
"started_at": "2026-01-07T20:15:00Z",
|
||||
"last_message_at": "2026-01-07T20:16:00Z",
|
||||
"next_timeout": "2026-01-07T20:18:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Boot Monitoring
|
||||
|
||||
Boot monitors dogs via:
|
||||
1. **Polling**: `gt dog status --active` every tick
|
||||
2. **Completion files**: Dogs write `<id>.done` when complete
|
||||
|
||||
```go
|
||||
type DogResult struct {
|
||||
DogID string
|
||||
Warrant *Warrant
|
||||
Outcome DogOutcome // pardoned | executed | failed
|
||||
Duration time.Duration
|
||||
Details string
|
||||
}
|
||||
|
||||
type DogOutcome string
|
||||
|
||||
const (
|
||||
OutcomePardoned DogOutcome = "pardoned" // Session responded
|
||||
OutcomeExecuted DogOutcome = "executed" // Session killed
|
||||
OutcomeFailed DogOutcome = "failed" // Dog crashed
|
||||
)
|
||||
```
|
||||
|
||||
### Why Not Mail?
|
||||
|
||||
Considered but rejected for dog<->boot communication:
|
||||
- Mail is async, poll-based - adds latency
|
||||
- State files are simpler for local coordination
|
||||
- Dogs don't need complex inter-agent communication
|
||||
- Keep mail for external coordination (Witness, Mayor)
|
||||
|
||||
## Shutdown Dance State Machine
|
||||
|
||||
Each dog executes this state machine:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌───────────────────────────┐ │
|
||||
│ INTERROGATING │ │
|
||||
│ │ │
|
||||
│ 1. Send health check │ │
|
||||
│ 2. Start timeout timer │ │
|
||||
└───────────┬───────────────┘ │
|
||||
│ │
|
||||
│ timeout or response │
|
||||
▼ │
|
||||
┌───────────────────────────┐ │
|
||||
│ EVALUATING │ │
|
||||
│ │ │
|
||||
│ Check tmux output for │ │
|
||||
│ ALIVE keyword │ │
|
||||
└───────────┬───────────────┘ │
|
||||
│ │
|
||||
┌───────┴───────┐ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
[ALIVE found] [No ALIVE] │
|
||||
│ │ │
|
||||
│ │ attempt < 3? │
|
||||
│ ├──────────────────────────────────→─┘
|
||||
│ │ yes: attempt++, longer timeout
|
||||
│ │
|
||||
│ │ no: attempt == 3
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────────┐
|
||||
│ PARDONED│ │ EXECUTING │
|
||||
│ │ │ │
|
||||
│ Cancel │ │ Kill tmux │
|
||||
│ warrant │ │ session │
|
||||
└────┬────┘ └──────┬──────┘
|
||||
│ │
|
||||
└────────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ COMPLETE │
|
||||
│ │
|
||||
│ Write result │
|
||||
│ Release dog │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Timeout Gates
|
||||
|
||||
| Attempt | Timeout | Cumulative Wait |
|
||||
|---------|---------|-----------------|
|
||||
| 1 | 60s | 60s |
|
||||
| 2 | 120s | 180s (3 min) |
|
||||
| 3 | 240s | 420s (7 min) |
|
||||
|
||||
### Health Check Message
|
||||
|
||||
```
|
||||
[DOG] HEALTH CHECK: Session {target}, respond ALIVE within {timeout}s or face termination.
|
||||
Warrant reason: {reason}
|
||||
Filed by: {requester}
|
||||
Attempt: {attempt}/3
|
||||
```
|
||||
|
||||
### Response Detection
|
||||
|
||||
```go
|
||||
func (d *Dog) CheckForResponse() bool {
|
||||
tm := tmux.NewTmux()
|
||||
output, err := tm.CapturePane(d.Warrant.Target, 50) // Last 50 lines
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Any output after our health check counts as alive
|
||||
// Specifically look for ALIVE keyword for explicit response
|
||||
return strings.Contains(output, "ALIVE")
|
||||
}
|
||||
```
|
||||
|
||||
## Dog Implementation
|
||||
|
||||
### Not Reusing Polecat Infrastructure
|
||||
|
||||
**Decision**: Dogs do NOT reuse polecat infrastructure.
|
||||
|
||||
Rationale:
|
||||
- Polecats are Claude sessions with molecules, hooks, sandboxes
|
||||
- Dogs are simple state machine executors
|
||||
- Polecats have 3-layer lifecycle (session/sandbox/slot)
|
||||
- Dogs have single-layer lifecycle (just state)
|
||||
- Different resource profiles, different management
|
||||
|
||||
What dogs DO share:
|
||||
- tmux utilities for message sending/capture
|
||||
- State file patterns
|
||||
- Pool allocation pattern
|
||||
|
||||
### Dog Execution Loop
|
||||
|
||||
```go
|
||||
func (d *Dog) Run(ctx context.Context) DogResult {
|
||||
d.State = StateInterrogating
|
||||
d.saveState()
|
||||
|
||||
for d.Attempt <= 3 {
|
||||
// Send interrogation message
|
||||
if err := d.sendHealthCheck(); err != nil {
|
||||
return d.fail(err)
|
||||
}
|
||||
|
||||
// Wait for timeout or context cancellation
|
||||
timeout := d.timeoutForAttempt(d.Attempt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return d.fail(ctx.Err())
|
||||
case <-time.After(timeout):
|
||||
// Timeout reached
|
||||
}
|
||||
|
||||
// Evaluate response
|
||||
d.State = StateEvaluating
|
||||
d.saveState()
|
||||
|
||||
if d.CheckForResponse() {
|
||||
// Session is alive
|
||||
return d.pardon()
|
||||
}
|
||||
|
||||
// No response - try again or execute
|
||||
d.Attempt++
|
||||
if d.Attempt <= 3 {
|
||||
d.State = StateInterrogating
|
||||
d.saveState()
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted - execute warrant
|
||||
return d.execute()
|
||||
}
|
||||
```
|
||||
|
||||
## Failure Handling
|
||||
|
||||
### Dog Crashes Mid-Dance
|
||||
|
||||
If a dog crashes (Boot process restarts, system crash):
|
||||
|
||||
1. State files persist in `~/gt/deacon/dogs/active/`
|
||||
2. On Boot restart, scan for orphaned state files
|
||||
3. Resume or restart based on state:
|
||||
|
||||
| State | Recovery Action |
|
||||
|------------------|------------------------------------|
|
||||
| interrogating | Restart from current attempt |
|
||||
| evaluating | Check response, continue |
|
||||
| executing | Verify kill, mark complete |
|
||||
| pardoned/complete| Already done, clean up |
|
||||
|
||||
```go
|
||||
func (p *DogPool) RecoverOrphans() error {
|
||||
files, _ := filepath.Glob(p.stateDir + "/*.json")
|
||||
for _, f := range files {
|
||||
state := loadDogState(f)
|
||||
if state.State != StateComplete && state.State != StatePardoned {
|
||||
dog := p.allocateForRecovery(state)
|
||||
go dog.Resume()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Pool Exhaustion
|
||||
|
||||
If all dogs are busy when new warrant arrives:
|
||||
|
||||
```go
|
||||
func (b *Boot) HandleWarrant(warrant *Warrant) error {
|
||||
dog, err := b.pool.Allocate(warrant)
|
||||
if err == ErrPoolExhausted {
|
||||
// Queue the warrant for later processing
|
||||
b.warrantQueue.Push(warrant)
|
||||
b.log("Warrant %s queued (pool exhausted)", warrant.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
result := dog.Run(b.ctx)
|
||||
b.handleResult(result)
|
||||
b.pool.Release(dog)
|
||||
|
||||
// Check queue for pending warrants
|
||||
if next := b.warrantQueue.Pop(); next != nil {
|
||||
b.HandleWarrant(next)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
~/gt/deacon/dogs/
|
||||
├── boot/ # Boot's working directory
|
||||
│ ├── CLAUDE.md # Boot context
|
||||
│ └── .boot-status.json # Boot execution status
|
||||
├── active/ # Active dog state files
|
||||
│ ├── dog-123.json # Dog 1 state
|
||||
│ ├── dog-456.json # Dog 2 state
|
||||
│ └── ...
|
||||
├── completed/ # Completed dance records (for audit)
|
||||
│ ├── dog-789.json # Historical record
|
||||
│ └── ...
|
||||
└── warrants/ # Pending warrant queue
|
||||
├── warrant-abc.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Command Interface
|
||||
|
||||
```bash
|
||||
# Pool status
|
||||
gt dog pool status
|
||||
# Output:
|
||||
# Dog Pool: 3/5 active
|
||||
# dog-123: interrogating Toast (attempt 2, 45s remaining)
|
||||
# dog-456: executing Shadow
|
||||
# dog-789: idle
|
||||
|
||||
# Manual dog operations (for debugging)
|
||||
gt dog pool allocate <warrant-id>
|
||||
gt dog pool release <dog-id>
|
||||
|
||||
# View active dances
|
||||
gt dog dances
|
||||
# Output:
|
||||
# Active Shutdown Dances:
|
||||
# dog-123 → Toast: Interrogating (2/3), timeout in 45s
|
||||
# dog-456 → Shadow: Executing warrant
|
||||
|
||||
# View warrant queue
|
||||
gt dog warrants
|
||||
# Output:
|
||||
# Pending Warrants: 2
|
||||
# 1. gt-abc: witness-gastown (stuck_no_progress)
|
||||
# 2. gt-def: polecat-Copper (crash_loop)
|
||||
```
|
||||
|
||||
## Integration with Existing Dogs
|
||||
|
||||
The existing `dog` package (`internal/dog/`) manages Deacon's multi-rig helper dogs.
|
||||
Those are different from shutdown-dance dogs:
|
||||
|
||||
| Aspect | Helper Dogs (existing) | Dance Dogs (new) |
|
||||
|-----------------|-----------------------------|-----------------------------|
|
||||
| Purpose | Cross-rig infrastructure | Shutdown dance execution |
|
||||
| Sessions | Claude sessions | Goroutines (no Claude) |
|
||||
| Worktrees | One per rig | None |
|
||||
| Lifecycle | Long-lived, reusable | Ephemeral per warrant |
|
||||
| State | idle/working | Dance state machine |
|
||||
|
||||
**Recommendation**: Use different package to avoid confusion:
|
||||
- `internal/dog/` - existing helper dogs
|
||||
- `internal/shutdown/` - shutdown dance pool
|
||||
|
||||
## Summary: Answers to Design Questions
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| How many Dogs in pool? | Fixed: 5 (configurable via GT_DOG_POOL_SIZE) |
|
||||
| How do Dogs communicate with Boot? | State files + completion markers |
|
||||
| Are Dogs tmux sessions? | No - goroutines with state machine |
|
||||
| Reuse polecat infrastructure? | No - too heavyweight, different model |
|
||||
| What if Dog dies mid-dance? | State file recovery on Boot restart |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Architecture document for Dog pool
|
||||
- [x] Clear allocation/deallocation protocol
|
||||
- [x] Failure handling for Dog crashes
|
||||
20
go.mod
20
go.mod
@@ -6,7 +6,9 @@ require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/term v0.38.0
|
||||
@@ -14,25 +16,41 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/glamour v0.10.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
)
|
||||
|
||||
54
go.sum
54
go.sum
@@ -1,23 +1,33 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||
@@ -27,10 +37,20 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
@@ -39,14 +59,23 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -54,11 +83,34 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
@@ -68,3 +120,5 @@ golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
76
internal/agent/state.go
Normal file
76
internal/agent/state.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package agent provides shared types and utilities for Gas Town agents
|
||||
// (witness, refinery, deacon, etc.).
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/util"
|
||||
)
|
||||
|
||||
// State represents an agent's running state.
|
||||
type State string
|
||||
|
||||
const (
|
||||
// StateStopped means the agent is not running.
|
||||
StateStopped State = "stopped"
|
||||
|
||||
// StateRunning means the agent is actively operating.
|
||||
StateRunning State = "running"
|
||||
|
||||
// StatePaused means the agent is paused (not operating but not stopped).
|
||||
StatePaused State = "paused"
|
||||
)
|
||||
|
||||
// StateManager handles loading and saving agent state to disk.
|
||||
// It uses generics to work with any state type.
|
||||
type StateManager[T any] struct {
|
||||
stateFilePath string
|
||||
defaultFactory func() *T
|
||||
}
|
||||
|
||||
// NewStateManager creates a new StateManager for the given state file path.
|
||||
// The defaultFactory function is called when the state file doesn't exist
|
||||
// to create a new state with default values.
|
||||
func NewStateManager[T any](rigPath, stateFileName string, defaultFactory func() *T) *StateManager[T] {
|
||||
return &StateManager[T]{
|
||||
stateFilePath: filepath.Join(rigPath, ".runtime", stateFileName),
|
||||
defaultFactory: defaultFactory,
|
||||
}
|
||||
}
|
||||
|
||||
// StateFile returns the path to the state file.
|
||||
func (m *StateManager[T]) StateFile() string {
|
||||
return m.stateFilePath
|
||||
}
|
||||
|
||||
// Load loads agent state from disk.
|
||||
// If the file doesn't exist, returns a new state created by the default factory.
|
||||
func (m *StateManager[T]) Load() (*T, error) {
|
||||
data, err := os.ReadFile(m.stateFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return m.defaultFactory(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state T
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// Save persists agent state to disk using atomic write.
|
||||
func (m *StateManager[T]) Save(state *T) error {
|
||||
dir := filepath.Dir(m.stateFilePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return util.AtomicWriteJSON(m.stateFilePath, state)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// Package beads provides a wrapper for the bd (beads) CLI.
|
||||
package beads
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TownBeadsPrefix is the prefix used for town-level agent beads stored in ~/gt/.beads/.
|
||||
// This distinguishes them from rig-level beads (which use project prefixes like "gt-").
|
||||
@@ -74,3 +77,170 @@ func PolecatRoleBeadIDTown() string {
|
||||
func CrewRoleBeadIDTown() string {
|
||||
return RoleBeadIDTown("crew")
|
||||
}
|
||||
|
||||
// ===== Rig-level agent bead ID helpers (gt- prefix) =====
|
||||
|
||||
// Agent bead ID naming convention:
|
||||
// prefix-rig-role-name
|
||||
//
|
||||
// Examples:
|
||||
// - gt-mayor (town-level, no rig)
|
||||
// - gt-deacon (town-level, no rig)
|
||||
// - gt-gastown-witness (rig-level singleton)
|
||||
// - gt-gastown-refinery (rig-level singleton)
|
||||
// - gt-gastown-crew-max (rig-level named agent)
|
||||
// - gt-gastown-polecat-Toast (rig-level named agent)
|
||||
|
||||
// AgentBeadIDWithPrefix generates an agent bead ID using the specified prefix.
|
||||
// The prefix should NOT include the hyphen (e.g., "gt", "bd", not "gt-", "bd-").
|
||||
// For town-level agents (mayor, deacon), pass empty rig and name.
|
||||
// For rig-level singletons (witness, refinery), pass empty name.
|
||||
// For named agents (crew, polecat), pass all three.
|
||||
func AgentBeadIDWithPrefix(prefix, rig, role, name string) string {
|
||||
if rig == "" {
|
||||
// Town-level agent: prefix-mayor, prefix-deacon
|
||||
return prefix + "-" + role
|
||||
}
|
||||
if name == "" {
|
||||
// Rig-level singleton: prefix-rig-witness, prefix-rig-refinery
|
||||
return prefix + "-" + rig + "-" + role
|
||||
}
|
||||
// Rig-level named agent: prefix-rig-role-name
|
||||
return prefix + "-" + rig + "-" + role + "-" + name
|
||||
}
|
||||
|
||||
// AgentBeadID generates the canonical agent bead ID using "gt" prefix.
|
||||
// For non-gastown rigs, use AgentBeadIDWithPrefix with the rig's configured prefix.
|
||||
func AgentBeadID(rig, role, name string) string {
|
||||
return AgentBeadIDWithPrefix("gt", rig, role, name)
|
||||
}
|
||||
|
||||
// MayorBeadID returns the Mayor agent bead ID.
|
||||
//
|
||||
// Deprecated: Use MayorBeadIDTown() for town-level beads (hq- prefix).
|
||||
// This function returns "gt-mayor" which is for rig-level storage.
|
||||
// Town-level agents like Mayor should use the hq- prefix.
|
||||
func MayorBeadID() string {
|
||||
return "gt-mayor"
|
||||
}
|
||||
|
||||
// DeaconBeadID returns the Deacon agent bead ID.
|
||||
//
|
||||
// Deprecated: Use DeaconBeadIDTown() for town-level beads (hq- prefix).
|
||||
// This function returns "gt-deacon" which is for rig-level storage.
|
||||
// Town-level agents like Deacon should use the hq- prefix.
|
||||
func DeaconBeadID() string {
|
||||
return "gt-deacon"
|
||||
}
|
||||
|
||||
// DogBeadID returns a Dog agent bead ID.
|
||||
// Dogs are town-level agents, so they follow the pattern: gt-dog-<name>
|
||||
// Deprecated: Use DogBeadIDTown() for town-level beads with hq- prefix.
|
||||
// Dogs are town-level agents and should use hq-dog-<name>, not gt-dog-<name>.
|
||||
func DogBeadID(name string) string {
|
||||
return "gt-dog-" + name
|
||||
}
|
||||
|
||||
// WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix.
|
||||
func WitnessBeadIDWithPrefix(prefix, rig string) string {
|
||||
return AgentBeadIDWithPrefix(prefix, rig, "witness", "")
|
||||
}
|
||||
|
||||
// WitnessBeadID returns the Witness agent bead ID for a rig using "gt" prefix.
|
||||
func WitnessBeadID(rig string) string {
|
||||
return WitnessBeadIDWithPrefix("gt", rig)
|
||||
}
|
||||
|
||||
// RefineryBeadIDWithPrefix returns the Refinery agent bead ID for a rig using the specified prefix.
|
||||
func RefineryBeadIDWithPrefix(prefix, rig string) string {
|
||||
return AgentBeadIDWithPrefix(prefix, rig, "refinery", "")
|
||||
}
|
||||
|
||||
// RefineryBeadID returns the Refinery agent bead ID for a rig using "gt" prefix.
|
||||
func RefineryBeadID(rig string) string {
|
||||
return RefineryBeadIDWithPrefix("gt", rig)
|
||||
}
|
||||
|
||||
// CrewBeadIDWithPrefix returns a Crew worker agent bead ID using the specified prefix.
|
||||
func CrewBeadIDWithPrefix(prefix, rig, name string) string {
|
||||
return AgentBeadIDWithPrefix(prefix, rig, "crew", name)
|
||||
}
|
||||
|
||||
// CrewBeadID returns a Crew worker agent bead ID using "gt" prefix.
|
||||
func CrewBeadID(rig, name string) string {
|
||||
return CrewBeadIDWithPrefix("gt", rig, name)
|
||||
}
|
||||
|
||||
// PolecatBeadIDWithPrefix returns a Polecat agent bead ID using the specified prefix.
|
||||
func PolecatBeadIDWithPrefix(prefix, rig, name string) string {
|
||||
return AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
|
||||
}
|
||||
|
||||
// PolecatBeadID returns a Polecat agent bead ID using "gt" prefix.
|
||||
func PolecatBeadID(rig, name string) string {
|
||||
return PolecatBeadIDWithPrefix("gt", rig, name)
|
||||
}
|
||||
|
||||
// ParseAgentBeadID parses an agent bead ID into its components.
|
||||
// Returns rig, role, name, and whether parsing succeeded.
|
||||
// For town-level agents, rig will be empty.
|
||||
// For singletons, name will be empty.
|
||||
// Accepts any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||
func ParseAgentBeadID(id string) (rig, role, name string, ok bool) {
|
||||
// Find the prefix (everything before the first hyphen)
|
||||
// Valid prefixes are 2-3 characters (e.g., "gt", "bd", "hq")
|
||||
hyphenIdx := strings.Index(id, "-")
|
||||
if hyphenIdx < 2 || hyphenIdx > 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
rest := id[hyphenIdx+1:]
|
||||
parts := strings.Split(rest, "-")
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
// Town-level: gt-mayor, bd-deacon
|
||||
return "", parts[0], "", true
|
||||
case 2:
|
||||
// Could be rig-level singleton (gt-gastown-witness) or
|
||||
// town-level named (gt-dog-alpha for dogs)
|
||||
if parts[0] == "dog" {
|
||||
// Dogs are town-level named agents: gt-dog-<name>
|
||||
return "", "dog", parts[1], true
|
||||
}
|
||||
// Rig-level singleton: gt-gastown-witness
|
||||
return parts[0], parts[1], "", true
|
||||
case 3:
|
||||
// Rig-level named: gt-gastown-crew-max, bd-beads-polecat-pearl
|
||||
return parts[0], parts[1], parts[2], true
|
||||
default:
|
||||
// Handle names with hyphens: gt-gastown-polecat-my-agent-name
|
||||
// or gt-dog-my-agent-name
|
||||
if len(parts) >= 3 {
|
||||
if parts[0] == "dog" {
|
||||
// Dog with hyphenated name: gt-dog-my-dog-name
|
||||
return "", "dog", strings.Join(parts[1:], "-"), true
|
||||
}
|
||||
return parts[0], parts[1], strings.Join(parts[2:], "-"), true
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAgentSessionBead returns true if the bead ID represents an agent session molecule.
|
||||
// Agent session beads follow patterns like gt-mayor, bd-beads-witness, gt-gastown-crew-joe.
|
||||
// Supports any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||
// These are used to track agent state and update frequently, which can create noise.
|
||||
func IsAgentSessionBead(beadID string) bool {
|
||||
_, role, _, ok := ParseAgentBeadID(beadID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Known agent roles
|
||||
switch role {
|
||||
case "mayor", "deacon", "witness", "refinery", "crew", "polecat", "dog":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
446
internal/beads/beads_agent.go
Normal file
446
internal/beads/beads_agent.go
Normal file
@@ -0,0 +1,446 @@
|
||||
// Package beads provides agent bead management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentFields holds structured fields for agent beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type AgentFields struct {
|
||||
RoleType string // polecat, witness, refinery, deacon, mayor
|
||||
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||
AgentState string // spawning, working, done, stuck
|
||||
HookBead string // Currently pinned work bead ID
|
||||
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
||||
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
||||
ActiveMR string // Currently active merge request bead ID (for traceability)
|
||||
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
||||
}
|
||||
|
||||
// Notification level constants
|
||||
const (
|
||||
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
||||
NotifyNormal = "normal" // Important events only (default)
|
||||
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
||||
)
|
||||
|
||||
// FormatAgentDescription creates a description string from agent fields.
|
||||
func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||
if fields == nil {
|
||||
return title
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
||||
|
||||
if fields.Rig != "" {
|
||||
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
||||
} else {
|
||||
lines = append(lines, "rig: null")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
||||
|
||||
if fields.HookBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
||||
} else {
|
||||
lines = append(lines, "hook_bead: null")
|
||||
}
|
||||
|
||||
if fields.RoleBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||
} else {
|
||||
lines = append(lines, "role_bead: null")
|
||||
}
|
||||
|
||||
if fields.CleanupStatus != "" {
|
||||
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
||||
} else {
|
||||
lines = append(lines, "cleanup_status: null")
|
||||
}
|
||||
|
||||
if fields.ActiveMR != "" {
|
||||
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
||||
} else {
|
||||
lines = append(lines, "active_mr: null")
|
||||
}
|
||||
|
||||
if fields.NotificationLevel != "" {
|
||||
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
||||
} else {
|
||||
lines = append(lines, "notification_level: null")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseAgentFields extracts agent fields from an issue's description.
|
||||
func ParseAgentFields(description string) *AgentFields {
|
||||
fields := &AgentFields{}
|
||||
|
||||
for _, line := range strings.Split(description, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:colonIdx])
|
||||
value := strings.TrimSpace(line[colonIdx+1:])
|
||||
if value == "null" || value == "" {
|
||||
value = ""
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "role_type":
|
||||
fields.RoleType = value
|
||||
case "rig":
|
||||
fields.Rig = value
|
||||
case "agent_state":
|
||||
fields.AgentState = value
|
||||
case "hook_bead":
|
||||
fields.HookBead = value
|
||||
case "role_bead":
|
||||
fields.RoleBead = value
|
||||
case "cleanup_status":
|
||||
fields.CleanupStatus = value
|
||||
case "active_mr":
|
||||
fields.ActiveMR = value
|
||||
case "notification_level":
|
||||
fields.NotificationLevel = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
||||
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||
// Use AgentBeadID() helper to generate correct IDs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||
description := FormatAgentDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
"--type=agent",
|
||||
"--labels=gt:agent",
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue Issue
|
||||
if err := json.Unmarshal(out, &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified (this is the authoritative storage)
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the hook slot if specified (this is the authoritative storage)
|
||||
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||
// agent's hook slot is empty. See mi-619.
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue - description text has the backup
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// CreateOrReopenAgentBead creates an agent bead or reopens an existing one.
|
||||
// This handles the case where a polecat is nuked and re-spawned with the same name:
|
||||
// the old agent bead exists as a tombstone, so we reopen and update it instead of
|
||||
// failing with a UNIQUE constraint error.
|
||||
//
|
||||
// The function:
|
||||
// 1. Tries to create the agent bead
|
||||
// 2. If UNIQUE constraint fails, reopens the existing bead and updates its fields
|
||||
func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||
// First try to create the bead
|
||||
issue, err := b.CreateAgentBead(id, title, fields)
|
||||
if err == nil {
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// Check if it's a UNIQUE constraint error
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The bead already exists (likely a tombstone from a previous nuked polecat)
|
||||
// Reopen it and update its fields
|
||||
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
|
||||
// If reopen fails, the bead might already be open - continue with update
|
||||
if !strings.Contains(reopenErr.Error(), "already open") {
|
||||
return nil, fmt.Errorf("reopening existing agent bead: %w (original error: %v)", reopenErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the bead with new fields
|
||||
description := FormatAgentDescription(title, fields)
|
||||
updateOpts := UpdateOptions{
|
||||
Title: &title,
|
||||
Description: &description,
|
||||
}
|
||||
if err := b.Update(id, updateOpts); err != nil {
|
||||
return nil, fmt.Errorf("updating reopened agent bead: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the hook slot if specified
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
// Clear any existing hook first, then set new one
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated bead
|
||||
return b.Show(id)
|
||||
}
|
||||
|
||||
// UpdateAgentState updates the agent_state field in an agent bead.
|
||||
// Optionally updates hook_bead if provided.
|
||||
//
|
||||
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
||||
// - `bd agent state` for agent_state (uses SQLite column directly)
|
||||
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
||||
//
|
||||
// This ensures consistency with `bd slot show` and other beads commands.
|
||||
// Previously, this function embedded these fields in the description text,
|
||||
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
||||
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
||||
// Update agent state using bd agent state command
|
||||
// This updates the agent_state column directly in SQLite
|
||||
_, err := b.run("agent", "state", id, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating agent state: %w", err)
|
||||
}
|
||||
|
||||
// Update hook_bead if provided
|
||||
if hookBead != nil {
|
||||
if *hookBead != "" {
|
||||
// Set the hook using bd slot set
|
||||
// This updates the hook_bead column directly in SQLite
|
||||
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||
if err != nil {
|
||||
// If slot is already occupied, clear it first then retry
|
||||
// This handles re-slinging scenarios where we're updating the hook
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "already occupied") {
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting hook: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear the hook
|
||||
_, err = b.run("slot", "clear", id, "hook")
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing hook: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetHookBead sets the hook_bead slot on an agent bead.
|
||||
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
||||
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
||||
// and should not be recorded in beads ("discover, don't track" principle).
|
||||
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
||||
// Set the hook using bd slot set
|
||||
// This updates the hook_bead column directly in SQLite
|
||||
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||
if err != nil {
|
||||
// If slot is already occupied, clear it first then retry
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "already occupied") {
|
||||
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
||||
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting hook: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearHookBead clears the hook_bead slot on an agent bead.
|
||||
// Used when work is complete or unslung.
|
||||
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
||||
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing hook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
||||
// This is called by the polecat to self-report its git state (ZFC compliance).
|
||||
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
||||
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error {
|
||||
// First get current issue to preserve other fields
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse existing fields
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
fields.CleanupStatus = cleanupStatus
|
||||
|
||||
// Format new description
|
||||
description := FormatAgentDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(id, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
||||
// This links the agent to their current merge request for traceability.
|
||||
// Pass empty string to clear the field (e.g., after merge completes).
|
||||
func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error {
|
||||
// First get current issue to preserve other fields
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse existing fields
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
fields.ActiveMR = activeMR
|
||||
|
||||
// Format new description
|
||||
description := FormatAgentDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(id, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
||||
// Valid levels: verbose, normal, muted (DND mode).
|
||||
// Pass empty string to reset to default (normal).
|
||||
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
||||
// Validate level
|
||||
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
||||
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
||||
}
|
||||
|
||||
// First get current issue to preserve other fields
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse existing fields
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
fields.NotificationLevel = level
|
||||
|
||||
// Format new description
|
||||
description := FormatAgentDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(id, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// GetAgentNotificationLevel returns the notification level for an agent.
|
||||
// Returns "normal" if not set (the default).
|
||||
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
||||
_, fields, err := b.GetAgentBead(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fields == nil {
|
||||
return NotifyNormal, nil
|
||||
}
|
||||
if fields.NotificationLevel == "" {
|
||||
return NotifyNormal, nil
|
||||
}
|
||||
return fields.NotificationLevel, nil
|
||||
}
|
||||
|
||||
// DeleteAgentBead permanently deletes an agent bead.
|
||||
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||
func (b *Beads) DeleteAgentBead(id string) error {
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentBead retrieves an agent bead by ID.
|
||||
// Returns nil if not found.
|
||||
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !HasLabel(issue, "gt:agent") {
|
||||
return nil, nil, fmt.Errorf("issue %s is not an agent bead (missing gt:agent label)", id)
|
||||
}
|
||||
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// ListAgentBeads returns all agent beads in a single query.
|
||||
// Returns a map of agent bead ID to Issue.
|
||||
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
||||
out, err := b.run("list", "--label=gt:agent", "--json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issues []*Issue
|
||||
if err := json.Unmarshal(out, &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
||||
}
|
||||
|
||||
result := make(map[string]*Issue, len(issues))
|
||||
for _, issue := range issues {
|
||||
result[issue.ID] = issue
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
155
internal/beads/beads_delegation.go
Normal file
155
internal/beads/beads_delegation.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package beads provides delegation tracking for work units.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Delegation represents a work delegation relationship between work units.
|
||||
// Delegation links a parent work unit to a child work unit, tracking who
|
||||
// delegated the work and to whom, along with any terms of the delegation.
|
||||
// This enables work distribution with credit cascade - work flows down,
|
||||
// validation and credit flow up.
|
||||
type Delegation struct {
|
||||
// Parent is the work unit ID that delegated the work
|
||||
Parent string `json:"parent"`
|
||||
|
||||
// Child is the work unit ID that received the delegated work
|
||||
Child string `json:"child"`
|
||||
|
||||
// DelegatedBy is the entity (hop:// URI or actor string) that delegated
|
||||
DelegatedBy string `json:"delegated_by"`
|
||||
|
||||
// DelegatedTo is the entity (hop:// URI or actor string) receiving delegation
|
||||
DelegatedTo string `json:"delegated_to"`
|
||||
|
||||
// Terms contains optional conditions of the delegation
|
||||
Terms *DelegationTerms `json:"terms,omitempty"`
|
||||
|
||||
// CreatedAt is when the delegation was created
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// DelegationTerms holds optional terms/conditions for a delegation.
|
||||
type DelegationTerms struct {
|
||||
// Portion describes what part of the parent work is delegated
|
||||
Portion string `json:"portion,omitempty"`
|
||||
|
||||
// Deadline is the expected completion date
|
||||
Deadline string `json:"deadline,omitempty"`
|
||||
|
||||
// AcceptanceCriteria describes what constitutes completion
|
||||
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||
|
||||
// CreditShare is the percentage of credit that flows to the delegate (0-100)
|
||||
CreditShare int `json:"credit_share,omitempty"`
|
||||
}
|
||||
|
||||
// AddDelegation creates a delegation relationship from parent to child work unit.
|
||||
// The delegation tracks who delegated (delegatedBy) and who received (delegatedTo),
|
||||
// along with optional terms. Delegations enable credit cascade - when child work
|
||||
// is completed, credit flows up to the parent work unit and its delegator.
|
||||
//
|
||||
// Note: This is stored as metadata on the child issue until bd CLI has native
|
||||
// delegation support. Once bd supports `bd delegate add`, this will be updated.
|
||||
func (b *Beads) AddDelegation(d *Delegation) error {
|
||||
if d.Parent == "" || d.Child == "" {
|
||||
return fmt.Errorf("delegation requires both parent and child work unit IDs")
|
||||
}
|
||||
if d.DelegatedBy == "" || d.DelegatedTo == "" {
|
||||
return fmt.Errorf("delegation requires both delegated_by and delegated_to entities")
|
||||
}
|
||||
|
||||
// Store delegation as JSON in the child issue's delegated_from slot
|
||||
delegationJSON, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling delegation: %w", err)
|
||||
}
|
||||
|
||||
// Set the delegated_from slot on the child issue
|
||||
_, err = b.run("slot", "set", d.Child, "delegated_from", string(delegationJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting delegation slot: %w", err)
|
||||
}
|
||||
|
||||
// Also add a dependency so child blocks parent (work must complete before parent can close)
|
||||
if err := b.AddDependency(d.Parent, d.Child); err != nil {
|
||||
// Log but don't fail - the delegation is still recorded
|
||||
fmt.Printf("Warning: could not add blocking dependency for delegation: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDelegation removes a delegation relationship.
|
||||
func (b *Beads) RemoveDelegation(parent, child string) error {
|
||||
// Clear the delegated_from slot on the child
|
||||
_, err := b.run("slot", "clear", child, "delegated_from")
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing delegation slot: %w", err)
|
||||
}
|
||||
|
||||
// Also remove the blocking dependency
|
||||
if err := b.RemoveDependency(parent, child); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("Warning: could not remove blocking dependency: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDelegation retrieves the delegation information for a child work unit.
|
||||
// Returns nil if the issue has no delegation.
|
||||
func (b *Beads) GetDelegation(child string) (*Delegation, error) {
|
||||
// Verify the issue exists first
|
||||
if _, err := b.Show(child); err != nil {
|
||||
return nil, fmt.Errorf("getting issue: %w", err)
|
||||
}
|
||||
|
||||
// Get delegation from the slot
|
||||
out, err := b.run("slot", "get", child, "delegated_from")
|
||||
if err != nil {
|
||||
// No delegation slot means no delegation
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no slot") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("getting delegation slot: %w", err)
|
||||
}
|
||||
|
||||
slotValue := strings.TrimSpace(string(out))
|
||||
if slotValue == "" || slotValue == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var delegation Delegation
|
||||
if err := json.Unmarshal([]byte(slotValue), &delegation); err != nil {
|
||||
return nil, fmt.Errorf("parsing delegation: %w", err)
|
||||
}
|
||||
|
||||
return &delegation, nil
|
||||
}
|
||||
|
||||
// ListDelegationsFrom returns all delegations from a parent work unit.
|
||||
// This searches for issues that have delegated_from pointing to the parent.
|
||||
func (b *Beads) ListDelegationsFrom(parent string) ([]*Delegation, error) {
|
||||
// List all issues that depend on this parent (delegated work blocks parent)
|
||||
issues, err := b.List(ListOptions{Status: "all"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing issues: %w", err)
|
||||
}
|
||||
|
||||
var delegations []*Delegation
|
||||
for _, issue := range issues {
|
||||
d, err := b.GetDelegation(issue.ID)
|
||||
if err != nil {
|
||||
continue // Skip issues with errors
|
||||
}
|
||||
if d != nil && d.Parent == parent {
|
||||
delegations = append(delegations, d)
|
||||
}
|
||||
}
|
||||
|
||||
return delegations, nil
|
||||
}
|
||||
93
internal/beads/beads_dog.go
Normal file
93
internal/beads/beads_dog.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package beads provides dog agent bead management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateDogAgentBead creates an agent bead for a dog.
|
||||
// Dogs use a different schema than other agents - they use labels for metadata.
|
||||
// Returns the created issue or an error.
|
||||
func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
||||
title := fmt.Sprintf("Dog: %s", name)
|
||||
labels := []string{
|
||||
"gt:agent",
|
||||
"role_type:dog",
|
||||
"rig:town",
|
||||
"location:" + location,
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"create", "--json",
|
||||
"--role-type=dog",
|
||||
"--title=" + title,
|
||||
"--labels=" + strings.Join(labels, ","),
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue Issue
|
||||
if err := json.Unmarshal(out, &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// FindDogAgentBead finds the agent bead for a dog by name.
|
||||
// Searches for agent beads with role_type:dog and matching title.
|
||||
// Returns nil if not found.
|
||||
func (b *Beads) FindDogAgentBead(name string) (*Issue, error) {
|
||||
// List all agent beads and filter by role_type:dog label
|
||||
issues, err := b.List(ListOptions{
|
||||
Label: "gt:agent",
|
||||
Status: "all",
|
||||
Priority: -1, // No priority filter
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing agents: %w", err)
|
||||
}
|
||||
|
||||
expectedTitle := fmt.Sprintf("Dog: %s", name)
|
||||
for _, issue := range issues {
|
||||
// Check title match and role_type:dog label
|
||||
if issue.Title == expectedTitle {
|
||||
for _, label := range issue.Labels {
|
||||
if label == "role_type:dog" {
|
||||
return issue, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// DeleteDogAgentBead finds and deletes the agent bead for a dog.
|
||||
// Returns nil if the bead doesn't exist (idempotent).
|
||||
func (b *Beads) DeleteDogAgentBead(name string) error {
|
||||
issue, err := b.FindDogAgentBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding dog bead: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return nil // Already doesn't exist - idempotent
|
||||
}
|
||||
|
||||
err = b.DeleteAgentBead(issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting bead %s: %w", issue.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
133
internal/beads/beads_merge_slot.go
Normal file
133
internal/beads/beads_merge_slot.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package beads provides merge slot management for serialized conflict resolution.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MergeSlotStatus represents the result of checking a merge slot.
|
||||
type MergeSlotStatus struct {
|
||||
ID string `json:"id"`
|
||||
Available bool `json:"available"`
|
||||
Holder string `json:"holder,omitempty"`
|
||||
Waiters []string `json:"waiters,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MergeSlotCreate creates the merge slot bead for the current rig.
|
||||
// The slot is used for serialized conflict resolution in the merge queue.
|
||||
// Returns the slot ID if successful.
|
||||
func (b *Beads) MergeSlotCreate() (string, error) {
|
||||
out, err := b.run("merge-slot", "create", "--json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating merge slot: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
return "", fmt.Errorf("parsing merge-slot create output: %w", err)
|
||||
}
|
||||
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
// MergeSlotCheck checks the availability of the merge slot.
|
||||
// Returns the current status including holder and waiters if held.
|
||||
func (b *Beads) MergeSlotCheck() (*MergeSlotStatus, error) {
|
||||
out, err := b.run("merge-slot", "check", "--json")
|
||||
if err != nil {
|
||||
// Check if slot doesn't exist
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return &MergeSlotStatus{Error: "not found"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("checking merge slot: %w", err)
|
||||
}
|
||||
|
||||
var status MergeSlotStatus
|
||||
if err := json.Unmarshal(out, &status); err != nil {
|
||||
return nil, fmt.Errorf("parsing merge-slot check output: %w", err)
|
||||
}
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// MergeSlotAcquire attempts to acquire the merge slot for exclusive access.
|
||||
// If holder is empty, defaults to BD_ACTOR environment variable.
|
||||
// If addWaiter is true and the slot is held, the requester is added to the waiters queue.
|
||||
// Returns the acquisition result.
|
||||
func (b *Beads) MergeSlotAcquire(holder string, addWaiter bool) (*MergeSlotStatus, error) {
|
||||
args := []string{"merge-slot", "acquire", "--json"}
|
||||
if holder != "" {
|
||||
args = append(args, "--holder="+holder)
|
||||
}
|
||||
if addWaiter {
|
||||
args = append(args, "--wait")
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
// Parse the output even on error - it may contain useful info
|
||||
var status MergeSlotStatus
|
||||
if jsonErr := json.Unmarshal(out, &status); jsonErr == nil {
|
||||
return &status, nil
|
||||
}
|
||||
return nil, fmt.Errorf("acquiring merge slot: %w", err)
|
||||
}
|
||||
|
||||
var status MergeSlotStatus
|
||||
if err := json.Unmarshal(out, &status); err != nil {
|
||||
return nil, fmt.Errorf("parsing merge-slot acquire output: %w", err)
|
||||
}
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// MergeSlotRelease releases the merge slot after conflict resolution completes.
|
||||
// If holder is provided, it verifies the slot is held by that holder before releasing.
|
||||
func (b *Beads) MergeSlotRelease(holder string) error {
|
||||
args := []string{"merge-slot", "release", "--json"}
|
||||
if holder != "" {
|
||||
args = append(args, "--holder="+holder)
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("releasing merge slot: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Released bool `json:"released"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
return fmt.Errorf("parsing merge-slot release output: %w", err)
|
||||
}
|
||||
|
||||
if !result.Released && result.Error != "" {
|
||||
return fmt.Errorf("slot release failed: %s", result.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeSlotEnsureExists creates the merge slot if it doesn't exist.
|
||||
// This is idempotent - safe to call multiple times.
|
||||
func (b *Beads) MergeSlotEnsureExists() (string, error) {
|
||||
// Check if slot exists first
|
||||
status, err := b.MergeSlotCheck()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if status.Error == "not found" {
|
||||
// Create it
|
||||
return b.MergeSlotCreate()
|
||||
}
|
||||
|
||||
return status.ID, nil
|
||||
}
|
||||
45
internal/beads/beads_mr.go
Normal file
45
internal/beads/beads_mr.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package beads provides merge request and gate utilities.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindMRForBranch searches for an existing merge-request bead for the given branch.
|
||||
// Returns the MR bead if found, nil if not found.
|
||||
// This enables idempotent `gt done` - if an MR already exists, we skip creation.
|
||||
func (b *Beads) FindMRForBranch(branch string) (*Issue, error) {
|
||||
// List all merge-request beads (open status only - closed MRs are already processed)
|
||||
issues, err := b.List(ListOptions{
|
||||
Status: "open",
|
||||
Label: "gt:merge-request",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Search for one matching this branch
|
||||
// MR description format: "branch: <branch>\ntarget: ..."
|
||||
branchPrefix := "branch: " + branch + "\n"
|
||||
for _, issue := range issues {
|
||||
if strings.HasPrefix(issue.Description, branchPrefix) {
|
||||
return issue, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AddGateWaiter registers an agent as a waiter on a gate bead.
|
||||
// When the gate closes, the waiter will receive a wake notification via gt gate wake.
|
||||
// The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast").
|
||||
func (b *Beads) AddGateWaiter(gateID, waiter string) error {
|
||||
// Use bd gate add-waiter to register the waiter on the gate
|
||||
// This adds the waiter to the gate's native waiters field
|
||||
_, err := b.run("gate", "add-waiter", gateID, waiter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding gate waiter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
242
internal/beads/beads_redirect.go
Normal file
242
internal/beads/beads_redirect.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Package beads provides redirect resolution for beads databases.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveBeadsDir returns the actual beads directory, following any redirect.
|
||||
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
|
||||
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
|
||||
//
|
||||
// This is essential for crew workers and polecats that use shared beads via redirect.
|
||||
// The redirect file contains a relative path like "../../mayor/rig/.beads".
|
||||
//
|
||||
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
|
||||
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
|
||||
// mayor/rig/.beads at the rig root level.
|
||||
//
|
||||
// Circular redirect detection: If the resolved path equals the original beads directory,
|
||||
// this indicates an errant redirect file that should be removed. The function logs a
|
||||
// warning and returns the original beads directory.
|
||||
func ResolveBeadsDir(workDir string) string {
|
||||
beadsDir := filepath.Join(workDir, ".beads")
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
|
||||
// Check for redirect file
|
||||
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||
if err != nil {
|
||||
// No redirect, use local .beads
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Read and clean the redirect path
|
||||
redirectTarget := strings.TrimSpace(string(data))
|
||||
if redirectTarget == "" {
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Resolve relative to workDir (the redirect is written from the perspective
|
||||
// of being inside workDir, not inside workDir/.beads)
|
||||
// e.g., redirect contains "../../mayor/rig/.beads"
|
||||
// from crew/max/, this resolves to mayor/rig/.beads
|
||||
resolved := filepath.Join(workDir, redirectTarget)
|
||||
|
||||
// Clean the path to resolve .. components
|
||||
resolved = filepath.Clean(resolved)
|
||||
|
||||
// Detect circular redirects: if resolved path equals original beads dir,
|
||||
// this is an errant redirect file (e.g., redirect in mayor/rig/.beads pointing to itself)
|
||||
if resolved == beadsDir {
|
||||
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s (points to itself), ignoring redirect\n", redirectPath)
|
||||
// Remove the errant redirect file to prevent future warnings
|
||||
if err := os.Remove(redirectPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not remove errant redirect file: %v\n", err)
|
||||
}
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads)
|
||||
// This is intentional for the rig-level redirect architecture.
|
||||
// Limit depth to prevent infinite loops from misconfigured redirects.
|
||||
return resolveBeadsDirWithDepth(resolved, 3)
|
||||
}
|
||||
|
||||
// resolveBeadsDirWithDepth follows redirect chains with a depth limit.
|
||||
func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
||||
if maxDepth <= 0 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir)
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||
if err != nil {
|
||||
// No redirect, this is the final destination
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
redirectTarget := strings.TrimSpace(string(data))
|
||||
if redirectTarget == "" {
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Resolve relative to parent of beadsDir (the workDir)
|
||||
workDir := filepath.Dir(beadsDir)
|
||||
resolved := filepath.Clean(filepath.Join(workDir, redirectTarget))
|
||||
|
||||
// Detect circular redirect
|
||||
if resolved == beadsDir {
|
||||
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath)
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Recursively follow
|
||||
return resolveBeadsDirWithDepth(resolved, maxDepth-1)
|
||||
}
|
||||
|
||||
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
||||
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
||||
// This is safe to call even if the directory doesn't exist.
|
||||
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return nil // Nothing to clean
|
||||
}
|
||||
|
||||
// Runtime files/patterns that are gitignored and safe to remove
|
||||
runtimePatterns := []string{
|
||||
// SQLite databases
|
||||
"*.db", "*.db-*", "*.db?*",
|
||||
// Daemon runtime
|
||||
"daemon.lock", "daemon.log", "daemon.pid", "bd.sock",
|
||||
// Sync state
|
||||
"sync-state.json", "last-touched", "metadata.json",
|
||||
// Version tracking
|
||||
".local_version",
|
||||
// Redirect file (we're about to recreate it)
|
||||
"redirect",
|
||||
// Merge artifacts
|
||||
"beads.base.*", "beads.left.*", "beads.right.*",
|
||||
// JSONL files (tracked but will be redirected, safe to remove in worktrees)
|
||||
"issues.jsonl", "interactions.jsonl",
|
||||
// Runtime directories
|
||||
"mq",
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, pattern := range runtimePatterns {
|
||||
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, match := range matches {
|
||||
if err := os.RemoveAll(match); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||
// This is used by crew, polecats, and refinery worktrees to share the rig's beads database.
|
||||
//
|
||||
// Parameters:
|
||||
// - townRoot: the town root directory (e.g., ~/gt)
|
||||
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
||||
//
|
||||
// The function:
|
||||
// 1. Computes the relative path from worktree to rig-level .beads
|
||||
// 2. Cleans up runtime files (preserving tracked files like formulas/)
|
||||
// 3. Creates the redirect file
|
||||
//
|
||||
// Safety: This function refuses to create redirects in the canonical beads location
|
||||
// (mayor/rig) to prevent circular redirect chains.
|
||||
func SetupRedirect(townRoot, worktreePath string) error {
|
||||
// Get rig root from worktree path
|
||||
// worktreePath = <town>/<rig>/crew/<name> or <town>/<rig>/refinery/rig etc.
|
||||
relPath, err := filepath.Rel(townRoot, worktreePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("computing relative path: %w", err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root")
|
||||
}
|
||||
|
||||
// Safety check: prevent creating redirect in canonical beads location (mayor/rig)
|
||||
// This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads
|
||||
if len(parts) >= 2 && parts[1] == "mayor" {
|
||||
return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)")
|
||||
}
|
||||
|
||||
rigRoot := filepath.Join(townRoot, parts[0])
|
||||
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
||||
mayorBeadsPath := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
|
||||
// Check rig-level .beads first, fall back to mayor/rig/.beads (tracked beads architecture)
|
||||
usesMayorFallback := false
|
||||
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||
// No rig/.beads - check for mayor/rig/.beads (tracked beads architecture)
|
||||
if _, err := os.Stat(mayorBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no beads found at %s or %s", rigBeadsPath, mayorBeadsPath)
|
||||
}
|
||||
// Using mayor fallback - warn user to run bd doctor
|
||||
fmt.Fprintf(os.Stderr, "Warning: rig .beads not found at %s, using %s\n", rigBeadsPath, mayorBeadsPath)
|
||||
fmt.Fprintf(os.Stderr, " Run 'bd doctor' to fix rig beads configuration\n")
|
||||
usesMayorFallback = true
|
||||
}
|
||||
|
||||
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
||||
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
||||
return fmt.Errorf("cleaning runtime files: %w", err)
|
||||
}
|
||||
|
||||
// Create .beads directory if it doesn't exist
|
||||
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Compute relative path from worktree to rig root
|
||||
// e.g., crew/<name> (depth 2) -> ../../.beads
|
||||
// refinery/rig (depth 2) -> ../../.beads
|
||||
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||
upPath := strings.Repeat("../", depth)
|
||||
|
||||
var redirectPath string
|
||||
if usesMayorFallback {
|
||||
// Direct redirect to mayor/rig/.beads since rig/.beads doesn't exist
|
||||
redirectPath = upPath + "mayor/rig/.beads"
|
||||
} else {
|
||||
redirectPath = upPath + ".beads"
|
||||
|
||||
// Check if rig-level beads has a redirect (tracked beads case).
|
||||
// If so, redirect directly to the final destination to avoid chains.
|
||||
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
||||
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||
if rigRedirectTarget != "" {
|
||||
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||
// Redirect worktree directly to the final destination.
|
||||
redirectPath = upPath + rigRedirectTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create redirect file
|
||||
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
117
internal/beads/beads_rig.go
Normal file
117
internal/beads/beads_rig.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package beads provides rig identity bead management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RigFields contains the fields specific to rig identity beads.
|
||||
type RigFields struct {
|
||||
Repo string // Git URL for the rig's repository
|
||||
Prefix string // Beads prefix for this rig (e.g., "gt", "bd")
|
||||
State string // Operational state: active, archived, maintenance
|
||||
}
|
||||
|
||||
// FormatRigDescription formats the description field for a rig identity bead.
|
||||
func FormatRigDescription(name string, fields *RigFields) string {
|
||||
if fields == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Rig identity bead for %s.", name))
|
||||
lines = append(lines, "")
|
||||
|
||||
if fields.Repo != "" {
|
||||
lines = append(lines, fmt.Sprintf("repo: %s", fields.Repo))
|
||||
}
|
||||
if fields.Prefix != "" {
|
||||
lines = append(lines, fmt.Sprintf("prefix: %s", fields.Prefix))
|
||||
}
|
||||
if fields.State != "" {
|
||||
lines = append(lines, fmt.Sprintf("state: %s", fields.State))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseRigFields extracts rig fields from an issue's description.
|
||||
func ParseRigFields(description string) *RigFields {
|
||||
fields := &RigFields{}
|
||||
|
||||
for _, line := range strings.Split(description, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:colonIdx])
|
||||
value := strings.TrimSpace(line[colonIdx+1:])
|
||||
if value == "null" || value == "" {
|
||||
value = ""
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "repo":
|
||||
fields.Repo = value
|
||||
case "prefix":
|
||||
fields.Prefix = value
|
||||
case "state":
|
||||
fields.State = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// CreateRigBead creates a rig identity bead for tracking rig metadata.
|
||||
// The ID format is: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||
// Use RigBeadID() helper to generate correct IDs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, error) {
|
||||
description := FormatRigDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
"--labels=gt:rig",
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue Issue
|
||||
if err := json.Unmarshal(out, &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// RigBeadIDWithPrefix generates a rig identity bead ID using the specified prefix.
|
||||
// Format: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||
func RigBeadIDWithPrefix(prefix, name string) string {
|
||||
return fmt.Sprintf("%s-rig-%s", prefix, name)
|
||||
}
|
||||
|
||||
// RigBeadID generates a rig identity bead ID using "gt" prefix.
|
||||
// For non-gastown rigs, use RigBeadIDWithPrefix with the rig's configured prefix.
|
||||
func RigBeadID(name string) string {
|
||||
return RigBeadIDWithPrefix("gt", name)
|
||||
}
|
||||
94
internal/beads/beads_role.go
Normal file
94
internal/beads/beads_role.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package beads provides role bead management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Role bead ID naming convention:
|
||||
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
||||
//
|
||||
// Canonical format: hq-<role>-role
|
||||
//
|
||||
// Examples:
|
||||
// - hq-mayor-role
|
||||
// - hq-deacon-role
|
||||
// - hq-witness-role
|
||||
// - hq-refinery-role
|
||||
// - hq-crew-role
|
||||
// - hq-polecat-role
|
||||
//
|
||||
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
||||
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
||||
|
||||
// RoleBeadID returns the role bead ID for a given role type.
|
||||
// Role beads define lifecycle configuration for each agent type.
|
||||
// Deprecated: Use RoleBeadIDTown() for town-level beads with hq- prefix.
|
||||
// Role beads are global templates and should use hq-<role>-role, not gt-<role>-role.
|
||||
func RoleBeadID(roleType string) string {
|
||||
return "gt-" + roleType + "-role"
|
||||
}
|
||||
|
||||
// DogRoleBeadID returns the Dog role bead ID.
|
||||
func DogRoleBeadID() string {
|
||||
return RoleBeadID("dog")
|
||||
}
|
||||
|
||||
// MayorRoleBeadID returns the Mayor role bead ID.
|
||||
func MayorRoleBeadID() string {
|
||||
return RoleBeadID("mayor")
|
||||
}
|
||||
|
||||
// DeaconRoleBeadID returns the Deacon role bead ID.
|
||||
func DeaconRoleBeadID() string {
|
||||
return RoleBeadID("deacon")
|
||||
}
|
||||
|
||||
// WitnessRoleBeadID returns the Witness role bead ID.
|
||||
func WitnessRoleBeadID() string {
|
||||
return RoleBeadID("witness")
|
||||
}
|
||||
|
||||
// RefineryRoleBeadID returns the Refinery role bead ID.
|
||||
func RefineryRoleBeadID() string {
|
||||
return RoleBeadID("refinery")
|
||||
}
|
||||
|
||||
// CrewRoleBeadID returns the Crew role bead ID.
|
||||
func CrewRoleBeadID() string {
|
||||
return RoleBeadID("crew")
|
||||
}
|
||||
|
||||
// PolecatRoleBeadID returns the Polecat role bead ID.
|
||||
func PolecatRoleBeadID() string {
|
||||
return RoleBeadID("polecat")
|
||||
}
|
||||
|
||||
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||
issue, err := b.Show(roleBeadID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !HasLabel(issue, "gt:role") {
|
||||
return nil, fmt.Errorf("bead %s is not a role bead (missing gt:role label)", roleBeadID)
|
||||
}
|
||||
|
||||
return ParseRoleConfig(issue.Description), nil
|
||||
}
|
||||
|
||||
// HasLabel checks if an issue has a specific label.
|
||||
func HasLabel(issue *Issue, label string) bool {
|
||||
for _, l := range issue.Labels {
|
||||
if l == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -84,19 +84,16 @@ func TestIsBeadsRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWrapError tests error wrapping.
|
||||
// ZFC: Only test ErrNotFound detection. ErrNotARepo and ErrSyncConflict
|
||||
// were removed as per ZFC - agents should handle those errors directly.
|
||||
func TestWrapError(t *testing.T) {
|
||||
b := New("/test")
|
||||
|
||||
tests := []struct {
|
||||
stderr string
|
||||
wantErr error
|
||||
wantNil bool
|
||||
stderr string
|
||||
wantErr error
|
||||
wantNil bool
|
||||
}{
|
||||
{"not a beads repository", ErrNotARepo, false},
|
||||
{"No .beads directory found", ErrNotARepo, false},
|
||||
{".beads directory not found", ErrNotARepo, false},
|
||||
{"sync conflict detected", ErrSyncConflict, false},
|
||||
{"CONFLICT in file.md", ErrSyncConflict, false},
|
||||
{"Issue not found: gt-xyz", ErrNotFound, false},
|
||||
{"gt-xyz not found", ErrNotFound, false},
|
||||
}
|
||||
@@ -127,7 +124,6 @@ func TestIntegration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Walk up to find .beads
|
||||
dir := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".beads")); err == nil {
|
||||
@@ -140,13 +136,24 @@ func TestIntegration(t *testing.T) {
|
||||
dir = parent
|
||||
}
|
||||
|
||||
// Resolve the actual beads directory (following redirect if present)
|
||||
// In multi-worktree setups, worktrees have .beads/redirect pointing to
|
||||
// the canonical beads location (e.g., mayor/rig/.beads)
|
||||
beadsDir := ResolveBeadsDir(dir)
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Skip("no beads.db found (JSONL-only repo)")
|
||||
}
|
||||
|
||||
b := New(dir)
|
||||
|
||||
// Sync database with JSONL before testing to avoid "Database out of sync" errors.
|
||||
// This can happen when JSONL is updated (e.g., by git pull) but the SQLite database
|
||||
// hasn't been imported yet. Running sync --import-only ensures we test against
|
||||
// consistent data and prevents flaky test failures.
|
||||
syncCmd := exec.Command("bd", "--no-daemon", "sync", "--import-only")
|
||||
// We use --allow-stale to handle cases where the daemon is actively writing and
|
||||
// the staleness check would otherwise fail spuriously.
|
||||
syncCmd := exec.Command("bd", "--no-daemon", "--allow-stale", "sync", "--import-only")
|
||||
syncCmd.Dir = dir
|
||||
if err := syncCmd.Run(); err != nil {
|
||||
// If sync fails (e.g., no database exists), just log and continue
|
||||
@@ -201,10 +208,10 @@ func TestIntegration(t *testing.T) {
|
||||
// TestParseMRFields tests parsing MR fields from issue descriptions.
|
||||
func TestParseMRFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
issue *Issue
|
||||
wantNil bool
|
||||
wantFields *MRFields
|
||||
name string
|
||||
issue *Issue
|
||||
wantNil bool
|
||||
wantFields *MRFields
|
||||
}{
|
||||
{
|
||||
name: "nil issue",
|
||||
@@ -521,8 +528,8 @@ author: someone
|
||||
target: main`,
|
||||
},
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Capable/gt-ghi",
|
||||
Target: "integration/epic",
|
||||
Branch: "polecat/Capable/gt-ghi",
|
||||
Target: "integration/epic",
|
||||
CloseReason: "merged",
|
||||
},
|
||||
want: `branch: polecat/Capable/gt-ghi
|
||||
@@ -1032,10 +1039,10 @@ func TestParseAgentBeadID(t *testing.T) {
|
||||
// Parseable but not valid agent roles (IsAgentSessionBead will reject)
|
||||
{"gt-abc123", "", "abc123", "", true}, // Parses as town-level but not valid role
|
||||
// Other prefixes (bd-, hq-)
|
||||
{"bd-mayor", "", "mayor", "", true}, // bd prefix town-level
|
||||
{"bd-beads-witness", "beads", "witness", "", true}, // bd prefix rig-level singleton
|
||||
{"bd-beads-polecat-pearl", "beads", "polecat", "pearl", true}, // bd prefix rig-level named
|
||||
{"hq-mayor", "", "mayor", "", true}, // hq prefix town-level
|
||||
{"bd-mayor", "", "mayor", "", true}, // bd prefix town-level
|
||||
{"bd-beads-witness", "beads", "witness", "", true}, // bd prefix rig-level singleton
|
||||
{"bd-beads-polecat-pearl", "beads", "polecat", "pearl", true}, // bd prefix rig-level named
|
||||
{"hq-mayor", "", "mayor", "", true}, // hq prefix town-level
|
||||
// Truly invalid patterns
|
||||
{"x-mayor", "", "", "", false}, // Prefix too short (1 char)
|
||||
{"abcd-mayor", "", "", "", false}, // Prefix too long (4 chars)
|
||||
@@ -1502,3 +1509,293 @@ func TestDelegationTerms(t *testing.T) {
|
||||
t.Errorf("parsed.CreditShare = %d, want %d", parsed.CreditShare, terms.CreditShare)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupRedirect tests the beads redirect setup for worktrees.
|
||||
func TestSetupRedirect(t *testing.T) {
|
||||
t.Run("crew worktree with local beads", func(t *testing.T) {
|
||||
// Setup: town/rig/.beads (local, no redirect)
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// Create rig structure
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
// Run SetupRedirect
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect was created
|
||||
redirectPath := filepath.Join(crewPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crew worktree with tracked beads", func(t *testing.T) {
|
||||
// Setup: town/rig/.beads/redirect -> mayor/rig/.beads (tracked)
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// Create rig structure with tracked beads
|
||||
if err := os.MkdirAll(mayorRigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
// Create rig-level redirect to mayor/rig/.beads
|
||||
if err := os.WriteFile(filepath.Join(rigBeads, "redirect"), []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
||||
t.Fatalf("write rig redirect: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
// Run SetupRedirect
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect goes directly to mayor/rig/.beads (no chain - bd CLI doesn't support chains)
|
||||
redirectPath := filepath.Join(crewPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../mayor/rig/.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
|
||||
// Verify redirect resolves correctly
|
||||
resolved := ResolveBeadsDir(crewPath)
|
||||
// crew/max -> ../../mayor/rig/.beads (direct, no chain)
|
||||
if resolved != mayorRigBeads {
|
||||
t.Errorf("resolved = %q, want %q", resolved, mayorRigBeads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("polecat worktree", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
polecatPath := filepath.Join(rigRoot, "polecats", "worker1")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(polecatPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir polecat: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, polecatPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
redirectPath := filepath.Join(polecatPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refinery worktree", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
refineryPath := filepath.Join(rigRoot, "refinery", "rig")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(refineryPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir refinery: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, refineryPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
redirectPath := filepath.Join(refineryPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleans runtime files but preserves tracked files", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
crewBeads := filepath.Join(crewPath, ".beads")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
// Simulate worktree with both runtime and tracked files
|
||||
if err := os.MkdirAll(crewBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew beads: %v", err)
|
||||
}
|
||||
// Runtime files (should be removed)
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "beads.db"), []byte("fake db"), 0644); err != nil {
|
||||
t.Fatalf("write fake db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "issues.jsonl"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatalf("write issues.jsonl: %v", err)
|
||||
}
|
||||
// Tracked files (should be preserved)
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "config.yaml"), []byte("prefix: test"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "README.md"), []byte("# Beads"), 0644); err != nil {
|
||||
t.Fatalf("write README: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify runtime files were cleaned up
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "beads.db")); !os.IsNotExist(err) {
|
||||
t.Error("beads.db should have been removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "issues.jsonl")); !os.IsNotExist(err) {
|
||||
t.Error("issues.jsonl should have been removed")
|
||||
}
|
||||
|
||||
// Verify tracked files were preserved
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "config.yaml")); err != nil {
|
||||
t.Errorf("config.yaml should have been preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "README.md")); err != nil {
|
||||
t.Errorf("README.md should have been preserved: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect was created
|
||||
redirectPath := filepath.Join(crewBeads, "redirect")
|
||||
if _, err := os.Stat(redirectPath); err != nil {
|
||||
t.Errorf("redirect file should exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects mayor/rig canonical location", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
mayorRigPath := filepath.Join(rigRoot, "mayor", "rig")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(mayorRigPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, mayorRigPath)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should reject mayor/rig location")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "canonical") {
|
||||
t.Errorf("error should mention canonical location, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path too shallow", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
|
||||
if err := os.MkdirAll(rigRoot, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, rigRoot)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should reject rig root (too shallow)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails if rig beads missing", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// No rig/.beads or mayor/rig/.beads created
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, crewPath)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should fail if rig .beads missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crew worktree with mayor/rig beads only", func(t *testing.T) {
|
||||
// Setup: no rig/.beads, only mayor/rig/.beads exists
|
||||
// This is the tracked beads architecture where rig root has no .beads directory
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// Create only mayor/rig/.beads (no rig/.beads)
|
||||
if err := os.MkdirAll(mayorRigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
// Run SetupRedirect - should succeed and point to mayor/rig/.beads
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect points to mayor/rig/.beads
|
||||
redirectPath := filepath.Join(crewPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../mayor/rig/.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
|
||||
// Verify redirect resolves correctly
|
||||
resolved := ResolveBeadsDir(crewPath)
|
||||
if resolved != mayorRigBeads {
|
||||
t.Errorf("resolved = %q, want %q", resolved, mayorRigBeads)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gracefulTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// BdDaemonInfo represents the status of a single bd daemon instance.
|
||||
type BdDaemonInfo struct {
|
||||
Workspace string `json:"workspace"`
|
||||
@@ -69,21 +75,12 @@ func EnsureBdDaemonHealth(workDir string) string {
|
||||
|
||||
// Check if any daemons need attention
|
||||
needsRestart := false
|
||||
var issues []string
|
||||
|
||||
for _, d := range health.Daemons {
|
||||
switch d.Status {
|
||||
case "healthy":
|
||||
// Good
|
||||
case "version_mismatch":
|
||||
case "version_mismatch", "stale", "unresponsive":
|
||||
needsRestart = true
|
||||
issues = append(issues, fmt.Sprintf("%s: version mismatch", d.Workspace))
|
||||
case "stale":
|
||||
needsRestart = true
|
||||
issues = append(issues, fmt.Sprintf("%s: stale", d.Workspace))
|
||||
case "unresponsive":
|
||||
needsRestart = true
|
||||
issues = append(issues, fmt.Sprintf("%s: unresponsive", d.Workspace))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,3 +129,144 @@ func StartBdDaemonIfNeeded(workDir string) error {
|
||||
cmd.Dir = workDir
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// StopAllBdProcesses stops all bd daemon and activity processes.
|
||||
// Returns (daemonsKilled, activityKilled, error).
|
||||
// If dryRun is true, returns counts without stopping anything.
|
||||
func StopAllBdProcesses(dryRun, force bool) (int, int, error) {
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
daemonsBefore := CountBdDaemons()
|
||||
activityBefore := CountBdActivityProcesses()
|
||||
|
||||
if dryRun {
|
||||
return daemonsBefore, activityBefore, nil
|
||||
}
|
||||
|
||||
daemonsKilled, daemonsRemaining := stopBdDaemons(force)
|
||||
activityKilled, activityRemaining := stopBdActivityProcesses(force)
|
||||
|
||||
if daemonsRemaining > 0 {
|
||||
return daemonsKilled, activityKilled, fmt.Errorf("bd daemon shutdown incomplete: %d still running", daemonsRemaining)
|
||||
}
|
||||
if activityRemaining > 0 {
|
||||
return daemonsKilled, activityKilled, fmt.Errorf("bd activity shutdown incomplete: %d still running", activityRemaining)
|
||||
}
|
||||
|
||||
return daemonsKilled, activityKilled, nil
|
||||
}
|
||||
|
||||
// CountBdDaemons returns count of running bd daemons.
|
||||
func CountBdDaemons() int {
|
||||
listCmd := exec.Command("bd", "daemon", "list", "--json")
|
||||
output, err := listCmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parseBdDaemonCount(output)
|
||||
}
|
||||
|
||||
// parseBdDaemonCount parses bd daemon list --json output.
|
||||
func parseBdDaemonCount(output []byte) int {
|
||||
if len(output) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var daemons []any
|
||||
if err := json.Unmarshal(output, &daemons); err == nil {
|
||||
return len(daemons)
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Daemons []any `json:"daemons"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &wrapper); err == nil {
|
||||
if wrapper.Count > 0 {
|
||||
return wrapper.Count
|
||||
}
|
||||
return len(wrapper.Daemons)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func stopBdDaemons(force bool) (int, int) {
|
||||
before := CountBdDaemons()
|
||||
if before == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
killCmd := exec.Command("bd", "daemon", "killall")
|
||||
_ = killCmd.Run()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
after := CountBdDaemons()
|
||||
if after == 0 {
|
||||
return before, 0
|
||||
}
|
||||
|
||||
// Note: pkill -f pattern may match unintended processes in rare cases
|
||||
// (e.g., editors with "bd daemon" in file content). This is acceptable
|
||||
// as a fallback when bd daemon killall fails.
|
||||
if force {
|
||||
_ = exec.Command("pkill", "-9", "-f", "bd daemon").Run()
|
||||
} else {
|
||||
_ = exec.Command("pkill", "-TERM", "-f", "bd daemon").Run()
|
||||
time.Sleep(gracefulTimeout)
|
||||
if remaining := CountBdDaemons(); remaining > 0 {
|
||||
_ = exec.Command("pkill", "-9", "-f", "bd daemon").Run()
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
final := CountBdDaemons()
|
||||
killed := before - final
|
||||
if killed < 0 {
|
||||
killed = 0 // Race condition: more processes spawned than we killed
|
||||
}
|
||||
return killed, final
|
||||
}
|
||||
|
||||
// CountBdActivityProcesses returns count of running `bd activity` processes.
|
||||
func CountBdActivityProcesses() int {
|
||||
// Use pgrep -f with wc -l for cross-platform compatibility
|
||||
// (macOS pgrep doesn't support -c flag)
|
||||
cmd := exec.Command("sh", "-c", "pgrep -f 'bd activity' 2>/dev/null | wc -l")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
return count
|
||||
}
|
||||
|
||||
func stopBdActivityProcesses(force bool) (int, int) {
|
||||
before := CountBdActivityProcesses()
|
||||
if before == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
if force {
|
||||
_ = exec.Command("pkill", "-9", "-f", "bd activity").Run()
|
||||
} else {
|
||||
_ = exec.Command("pkill", "-TERM", "-f", "bd activity").Run()
|
||||
time.Sleep(gracefulTimeout)
|
||||
if remaining := CountBdActivityProcesses(); remaining > 0 {
|
||||
_ = exec.Command("pkill", "-9", "-f", "bd activity").Run()
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
after := CountBdActivityProcesses()
|
||||
killed := before - after
|
||||
if killed < 0 {
|
||||
killed = 0 // Race condition: more processes spawned than we killed
|
||||
}
|
||||
return killed, after
|
||||
}
|
||||
|
||||
73
internal/beads/daemon_test.go
Normal file
73
internal/beads/daemon_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBdDaemonCount_Array(t *testing.T) {
|
||||
input := []byte(`[{"pid":1234},{"pid":5678}]`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_ObjectWithCount(t *testing.T) {
|
||||
input := []byte(`{"count":3,"daemons":[{},{},{}]}`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_ObjectWithDaemons(t *testing.T) {
|
||||
input := []byte(`{"daemons":[{},{}]}`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_Empty(t *testing.T) {
|
||||
input := []byte(``)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_Invalid(t *testing.T) {
|
||||
input := []byte(`not json`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 for invalid JSON, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountBdActivityProcesses(t *testing.T) {
|
||||
count := CountBdActivityProcesses()
|
||||
if count < 0 {
|
||||
t.Errorf("count should be non-negative, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountBdDaemons(t *testing.T) {
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed")
|
||||
}
|
||||
count := CountBdDaemons()
|
||||
if count < 0 {
|
||||
t.Errorf("count should be non-negative, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAllBdProcesses_DryRun(t *testing.T) {
|
||||
daemonsKilled, activityKilled, err := StopAllBdProcesses(true, false)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if daemonsKilled < 0 || activityKilled < 0 {
|
||||
t.Errorf("counts should be non-negative: daemons=%d, activity=%d", daemonsKilled, activityKilled)
|
||||
}
|
||||
}
|
||||
@@ -528,6 +528,25 @@ type RoleConfig struct {
|
||||
// EnvVars are additional environment variables to set in the session.
|
||||
// Stored as "key=value" pairs.
|
||||
EnvVars map[string]string
|
||||
|
||||
// Health check thresholds - per ZFC, agents control their own stuck detection.
|
||||
// These allow the Deacon's patrol config to be agent-defined rather than hardcoded.
|
||||
|
||||
// PingTimeout is how long to wait for a health check response.
|
||||
// Format: duration string (e.g., "30s", "1m"). Default: 30s.
|
||||
PingTimeout string
|
||||
|
||||
// ConsecutiveFailures is how many failed health checks before force-kill.
|
||||
// Default: 3.
|
||||
ConsecutiveFailures int
|
||||
|
||||
// KillCooldown is the minimum time between force-kills of the same agent.
|
||||
// Format: duration string (e.g., "5m", "10m"). Default: 5m.
|
||||
KillCooldown string
|
||||
|
||||
// StuckThreshold is how long a wisp can be in_progress before considered stuck.
|
||||
// Format: duration string (e.g., "1h", "30m"). Default: 1h.
|
||||
StuckThreshold string
|
||||
}
|
||||
|
||||
// ParseRoleConfig extracts RoleConfig from a role bead's description.
|
||||
@@ -576,6 +595,21 @@ func ParseRoleConfig(description string) *RoleConfig {
|
||||
config.EnvVars[envKey] = envVal
|
||||
hasFields = true
|
||||
}
|
||||
// Health check threshold fields (ZFC: agent-controlled)
|
||||
case "ping_timeout", "ping-timeout", "pingtimeout":
|
||||
config.PingTimeout = value
|
||||
hasFields = true
|
||||
case "consecutive_failures", "consecutive-failures", "consecutivefailures":
|
||||
if n, err := parseIntValue(value); err == nil {
|
||||
config.ConsecutiveFailures = n
|
||||
hasFields = true
|
||||
}
|
||||
case "kill_cooldown", "kill-cooldown", "killcooldown":
|
||||
config.KillCooldown = value
|
||||
hasFields = true
|
||||
case "stuck_threshold", "stuck-threshold", "stuckthreshold":
|
||||
config.StuckThreshold = value
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +619,13 @@ func ParseRoleConfig(description string) *RoleConfig {
|
||||
return config
|
||||
}
|
||||
|
||||
// parseIntValue parses an integer from a string value.
|
||||
func parseIntValue(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description.
|
||||
// Only non-empty/non-default fields are included.
|
||||
func FormatRoleConfig(config *RoleConfig) string {
|
||||
|
||||
@@ -48,10 +48,10 @@ func (b *Beads) GetOrCreateHandoffBead(role string) (*Issue, error) {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Create new handoff bead
|
||||
// Create new handoff bead (type is deprecated, uses gt:task label via backward compat)
|
||||
issue, err := b.Create(CreateOptions{
|
||||
Title: HandoffBeadTitle(role),
|
||||
Type: "task",
|
||||
Type: "task", // Converted to gt:task label by Create()
|
||||
Priority: 2,
|
||||
Description: "", // Empty until first handoff
|
||||
Actor: role,
|
||||
@@ -107,7 +107,7 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
|
||||
// List all open messages
|
||||
issues, err := b.List(ListOptions{
|
||||
Status: "open",
|
||||
Type: "message",
|
||||
Label: "gt:message",
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -57,7 +57,12 @@ func LoadRoutes(beadsDir string) ([]Route, error) {
|
||||
// If the prefix already exists, it updates the path.
|
||||
func AppendRoute(townRoot string, route Route) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
return AppendRouteToDir(beadsDir, route)
|
||||
}
|
||||
|
||||
// AppendRouteToDir appends a route to routes.jsonl in the given beads directory.
|
||||
// If the prefix already exists, it updates the path.
|
||||
func AppendRouteToDir(beadsDir string, route Route) error {
|
||||
// Load existing routes
|
||||
routes, err := LoadRoutes(beadsDir)
|
||||
if err != nil {
|
||||
@@ -185,3 +190,60 @@ func FindConflictingPrefixes(beadsDir string) (map[string][]string, error) {
|
||||
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// ExtractPrefix extracts the prefix from a bead ID.
|
||||
// For example, "ap-qtsup.16" returns "ap-", "hq-cv-abc" returns "hq-".
|
||||
// Returns empty string if no valid prefix found (empty input, no hyphen,
|
||||
// or hyphen at position 0 which would indicate an invalid prefix).
|
||||
func ExtractPrefix(beadID string) string {
|
||||
if beadID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
idx := strings.Index(beadID, "-")
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return beadID[:idx+1]
|
||||
}
|
||||
|
||||
// GetRigPathForPrefix returns the rig path for a given bead ID prefix.
|
||||
// The townRoot should be the Gas Town root directory (e.g., ~/gt).
|
||||
// Returns the full absolute path to the rig directory, or empty string if not found.
|
||||
// For town-level beads (path="."), returns townRoot.
|
||||
func GetRigPathForPrefix(townRoot, prefix string) string {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
routes, err := LoadRoutes(beadsDir)
|
||||
if err != nil || routes == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
if r.Prefix == prefix {
|
||||
if r.Path == "." {
|
||||
return townRoot // Town-level beads
|
||||
}
|
||||
return filepath.Join(townRoot, r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveHookDir determines the directory for running bd update on a bead.
|
||||
// Since bd update doesn't support routing or redirects, we must resolve the
|
||||
// actual rig directory from the bead's prefix. hookWorkDir is only used as
|
||||
// a fallback if prefix resolution fails.
|
||||
func ResolveHookDir(townRoot, beadID, hookWorkDir string) string {
|
||||
// Always try prefix resolution first - bd update needs the actual rig dir
|
||||
prefix := ExtractPrefix(beadID)
|
||||
if rigPath := GetRigPathForPrefix(townRoot, prefix); rigPath != "" {
|
||||
return rigPath
|
||||
}
|
||||
// Fallback to hookWorkDir if provided
|
||||
if hookWorkDir != "" {
|
||||
return hookWorkDir
|
||||
}
|
||||
return townRoot
|
||||
}
|
||||
|
||||
@@ -52,6 +52,143 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
beadID string
|
||||
expected string
|
||||
}{
|
||||
{"ap-qtsup.16", "ap-"},
|
||||
{"hq-cv-abc", "hq-"},
|
||||
{"gt-mol-xyz", "gt-"},
|
||||
{"bd-123", "bd-"},
|
||||
{"", ""},
|
||||
{"nohyphen", ""},
|
||||
{"-startswithhyphen", ""}, // Leading hyphen = invalid prefix
|
||||
{"-", ""}, // Just hyphen = invalid
|
||||
{"a-", "a-"}, // Trailing hyphen is valid
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.beadID, func(t *testing.T) {
|
||||
result := ExtractPrefix(tc.beadID)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractPrefix(%q) = %q, want %q", tc.beadID, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRigPathForPrefix(t *testing.T) {
|
||||
// Create a temporary directory with routes.jsonl
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"}
|
||||
{"prefix": "gt-", "path": "gastown/mayor/rig"}
|
||||
{"prefix": "hq-", "path": "."}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
prefix string
|
||||
expected string
|
||||
}{
|
||||
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
|
||||
{"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")},
|
||||
{"hq-", tmpDir}, // Town-level beads return townRoot
|
||||
{"unknown-", ""}, // Unknown prefix returns empty
|
||||
{"", ""}, // Empty prefix returns empty
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.prefix, func(t *testing.T) {
|
||||
result := GetRigPathForPrefix(tmpDir, tc.prefix)
|
||||
if result != tc.expected {
|
||||
t.Errorf("GetRigPathForPrefix(%q, %q) = %q, want %q", tmpDir, tc.prefix, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRigPathForPrefix_NoRoutesFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// No routes.jsonl file
|
||||
|
||||
result := GetRigPathForPrefix(tmpDir, "ap-")
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty string when no routes file, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHookDir(t *testing.T) {
|
||||
// Create a temporary directory with routes.jsonl
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"}
|
||||
{"prefix": "hq-", "path": "."}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
beadID string
|
||||
hookWorkDir string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "prefix resolution takes precedence over hookWorkDir",
|
||||
beadID: "ap-test",
|
||||
hookWorkDir: "/custom/path",
|
||||
expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"),
|
||||
},
|
||||
{
|
||||
name: "resolves rig path from prefix",
|
||||
beadID: "ap-test",
|
||||
hookWorkDir: "",
|
||||
expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"),
|
||||
},
|
||||
{
|
||||
name: "town-level bead returns townRoot",
|
||||
beadID: "hq-test",
|
||||
hookWorkDir: "",
|
||||
expected: tmpDir,
|
||||
},
|
||||
{
|
||||
name: "unknown prefix uses hookWorkDir as fallback",
|
||||
beadID: "xx-unknown",
|
||||
hookWorkDir: "/fallback/path",
|
||||
expected: "/fallback/path",
|
||||
},
|
||||
{
|
||||
name: "unknown prefix without hookWorkDir falls back to townRoot",
|
||||
beadID: "xx-unknown",
|
||||
hookWorkDir: "",
|
||||
expected: tmpDir,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ResolveHookDir(tmpDir, tc.beadID, tc.hookWorkDir)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ResolveHookDir(%q, %q, %q) = %q, want %q",
|
||||
tmpDir, tc.beadID, tc.hookWorkDir, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBeadIDsWithPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
@@ -22,15 +23,12 @@ import (
|
||||
// to return true when only Boot is running.
|
||||
const SessionName = "gt-boot"
|
||||
|
||||
// MarkerFileName is the file that indicates Boot is currently running.
|
||||
// MarkerFileName is the lock file for Boot startup coordination.
|
||||
const MarkerFileName = ".boot-running"
|
||||
|
||||
// StatusFileName stores Boot's last execution status.
|
||||
const StatusFileName = ".boot-status.json"
|
||||
|
||||
// DefaultMarkerTTL is how long a marker is considered valid before it's stale.
|
||||
const DefaultMarkerTTL = 5 * time.Minute
|
||||
|
||||
// Status represents Boot's execution status.
|
||||
type Status struct {
|
||||
Running bool `json:"running"`
|
||||
@@ -77,22 +75,9 @@ func (b *Boot) statusPath() string {
|
||||
}
|
||||
|
||||
// IsRunning checks if Boot is currently running.
|
||||
// Returns true if marker exists and isn't stale, false otherwise.
|
||||
// Queries tmux directly for observable reality (ZFC principle).
|
||||
func (b *Boot) IsRunning() bool {
|
||||
info, err := os.Stat(b.markerPath())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if marker is stale (older than TTL)
|
||||
age := time.Since(info.ModTime())
|
||||
if age > DefaultMarkerTTL {
|
||||
// Stale marker - clean it up
|
||||
_ = os.Remove(b.markerPath())
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return b.IsSessionAlive()
|
||||
}
|
||||
|
||||
// IsSessionAlive checks if the Boot tmux session exists.
|
||||
@@ -105,7 +90,7 @@ func (b *Boot) IsSessionAlive() bool {
|
||||
// Returns error if Boot is already running.
|
||||
func (b *Boot) AcquireLock() error {
|
||||
if b.IsRunning() {
|
||||
return fmt.Errorf("boot is already running (marker exists)")
|
||||
return fmt.Errorf("boot is already running (session exists)")
|
||||
}
|
||||
|
||||
if err := b.EnsureDir(); err != nil {
|
||||
@@ -190,13 +175,24 @@ func (b *Boot) spawnTmux() error {
|
||||
return fmt.Errorf("creating boot session: %w", err)
|
||||
}
|
||||
|
||||
// Set environment
|
||||
_ = b.tmux.SetEnvironment(SessionName, "GT_ROLE", "boot")
|
||||
_ = b.tmux.SetEnvironment(SessionName, "BD_ACTOR", "deacon-boot")
|
||||
// Set environment using centralized AgentEnv for consistency
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "boot",
|
||||
TownRoot: b.townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(b.townRoot),
|
||||
})
|
||||
for k, v := range envVars {
|
||||
_ = b.tmux.SetEnvironment(SessionName, k, v)
|
||||
}
|
||||
|
||||
// Launch Claude with environment exported inline and initial triage prompt
|
||||
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
|
||||
startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage")
|
||||
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
||||
if err := b.tmux.WaitForShellReady(SessionName, 5*time.Second); err != nil {
|
||||
_ = b.tmux.KillSession(SessionName)
|
||||
return fmt.Errorf("waiting for shell: %w", err)
|
||||
}
|
||||
if err := b.tmux.SendKeys(SessionName, startCmd); err != nil {
|
||||
return fmt.Errorf("sending startup command: %w", err)
|
||||
}
|
||||
@@ -211,11 +207,15 @@ func (b *Boot) spawnDegraded() error {
|
||||
// This performs the triage logic without a full Claude session
|
||||
cmd := exec.Command("gt", "boot", "triage", "--degraded")
|
||||
cmd.Dir = b.deaconDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GT_ROLE=boot",
|
||||
"BD_ACTOR=deacon-boot",
|
||||
"GT_DEGRADED=true",
|
||||
)
|
||||
|
||||
// Use centralized AgentEnv for consistency with tmux mode
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "boot",
|
||||
TownRoot: b.townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(b.townRoot),
|
||||
})
|
||||
cmd.Env = config.EnvForExecCommand(envVars)
|
||||
cmd.Env = append(cmd.Env, "GT_DEGRADED=true")
|
||||
|
||||
// Run async - don't wait for completion
|
||||
return cmd.Start()
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
)
|
||||
|
||||
// Filename is the checkpoint file name within the polecat directory.
|
||||
@@ -84,7 +86,7 @@ func Write(polecatDir string, cp *Checkpoint) error {
|
||||
|
||||
// Set session ID from environment if available
|
||||
if cp.SessionID == "" {
|
||||
cp.SessionID = os.Getenv("CLAUDE_SESSION_ID")
|
||||
cp.SessionID = runtime.SessionIDFromEnv()
|
||||
if cp.SessionID == "" {
|
||||
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime && gt mail check --inject && gt nudge deacon session-started"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt mail check --inject && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt costs record"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt costs record"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime && gt nudge deacon session-started"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt costs record"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt costs record"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
// RoleTypeFor returns the RoleType for a given role name.
|
||||
func RoleTypeFor(role string) RoleType {
|
||||
switch role {
|
||||
case "polecat", "witness", "refinery":
|
||||
case "polecat", "witness", "refinery", "deacon":
|
||||
return Autonomous
|
||||
default:
|
||||
return Interactive
|
||||
@@ -35,20 +35,27 @@ func RoleTypeFor(role string) RoleType {
|
||||
}
|
||||
|
||||
// EnsureSettings ensures .claude/settings.json exists in the given directory.
|
||||
// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory,
|
||||
// so our settings.json is the only one Claude Code sees.
|
||||
func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
return EnsureSettingsAt(workDir, roleType, ".claude", "settings.json")
|
||||
}
|
||||
|
||||
// EnsureSettingsAt ensures a settings file exists at a custom directory/file.
|
||||
// If the file doesn't exist, it copies the appropriate template based on role type.
|
||||
// If the file already exists, it's left unchanged.
|
||||
func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
claudeDir := filepath.Join(workDir, ".claude")
|
||||
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||
func EnsureSettingsAt(workDir string, roleType RoleType, settingsDir, settingsFile string) error {
|
||||
claudeDir := filepath.Join(workDir, settingsDir)
|
||||
settingsPath := filepath.Join(claudeDir, settingsFile)
|
||||
|
||||
// If settings already exist, don't overwrite
|
||||
if _, err := os.Stat(settingsPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create .claude directory if needed
|
||||
// Create settings directory if needed
|
||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .claude directory: %w", err)
|
||||
return fmt.Errorf("creating settings directory: %w", err)
|
||||
}
|
||||
|
||||
// Select template based on role type
|
||||
@@ -78,3 +85,8 @@ func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
func EnsureSettingsForRole(workDir, role string) error {
|
||||
return EnsureSettings(workDir, RoleTypeFor(role))
|
||||
}
|
||||
|
||||
// EnsureSettingsForRoleAt is a convenience function that combines RoleTypeFor and EnsureSettingsAt.
|
||||
func EnsureSettingsForRoleAt(workDir, role, settingsDir, settingsFile string) error {
|
||||
return EnsureSettingsAt(workDir, RoleTypeFor(role), settingsDir, settingsFile)
|
||||
}
|
||||
|
||||
@@ -264,6 +264,25 @@ Examples:
|
||||
RunE: runAccountStatus,
|
||||
}
|
||||
|
||||
var accountSwitchCmd = &cobra.Command{
|
||||
Use: "switch <handle>",
|
||||
Short: "Switch to a different account",
|
||||
Long: `Switch the active Claude Code account.
|
||||
|
||||
This command:
|
||||
1. Backs up ~/.claude to the current account's config_dir (if needed)
|
||||
2. Creates a symlink from ~/.claude to the target account's config_dir
|
||||
3. Updates the default account in accounts.json
|
||||
|
||||
After switching, you must restart Claude Code for the change to take effect.
|
||||
|
||||
Examples:
|
||||
gt account switch work # Switch to work account
|
||||
gt account switch personal # Switch to personal account`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAccountSwitch,
|
||||
}
|
||||
|
||||
func runAccountStatus(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
@@ -318,6 +337,122 @@ func runAccountStatus(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAccountSwitch(cmd *cobra.Command, args []string) error {
|
||||
targetHandle := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
|
||||
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||
cfg, err := config.LoadAccountsConfig(accountsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading accounts config: %w", err)
|
||||
}
|
||||
|
||||
// Check if target account exists
|
||||
targetAcct := cfg.GetAccount(targetHandle)
|
||||
if targetAcct == nil {
|
||||
// List available accounts
|
||||
var handles []string
|
||||
for h := range cfg.Accounts {
|
||||
handles = append(handles, h)
|
||||
}
|
||||
sort.Strings(handles)
|
||||
return fmt.Errorf("account '%s' not found. Available accounts: %v", targetHandle, handles)
|
||||
}
|
||||
|
||||
// Get ~/.claude path
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting home directory: %w", err)
|
||||
}
|
||||
claudeDir := home + "/.claude"
|
||||
|
||||
// Check current state of ~/.claude
|
||||
fileInfo, err := os.Lstat(claudeDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("checking ~/.claude: %w", err)
|
||||
}
|
||||
|
||||
// Determine current account (if any) by checking symlink target
|
||||
var currentHandle string
|
||||
if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 {
|
||||
// It's a symlink - find which account it points to
|
||||
linkTarget, err := os.Readlink(claudeDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading symlink: %w", err)
|
||||
}
|
||||
for h, acct := range cfg.Accounts {
|
||||
if acct.ConfigDir == linkTarget {
|
||||
currentHandle = h
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already on target account
|
||||
if currentHandle == targetHandle {
|
||||
fmt.Printf("Already on account '%s'\n", targetHandle)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle the case where ~/.claude is a real directory (not a symlink)
|
||||
if err == nil && fileInfo.Mode()&os.ModeSymlink == 0 && fileInfo.IsDir() {
|
||||
// It's a real directory - need to move it
|
||||
// Try to find which account it belongs to based on default
|
||||
if currentHandle == "" && cfg.Default != "" {
|
||||
currentHandle = cfg.Default
|
||||
}
|
||||
|
||||
if currentHandle != "" {
|
||||
currentAcct := cfg.GetAccount(currentHandle)
|
||||
if currentAcct != nil {
|
||||
// Move ~/.claude to the current account's config_dir
|
||||
fmt.Printf("Moving ~/.claude to %s...\n", currentAcct.ConfigDir)
|
||||
|
||||
// Remove the target config dir if it exists (it might be empty from account add)
|
||||
if _, err := os.Stat(currentAcct.ConfigDir); err == nil {
|
||||
if err := os.RemoveAll(currentAcct.ConfigDir); err != nil {
|
||||
return fmt.Errorf("removing existing config dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(claudeDir, currentAcct.ConfigDir); err != nil {
|
||||
return fmt.Errorf("moving ~/.claude to %s: %w", currentAcct.ConfigDir, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("~/.claude is a directory but no default account is set. Please set a default account first with 'gt account default <handle>'")
|
||||
}
|
||||
} else if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 {
|
||||
// It's a symlink - remove it so we can create a new one
|
||||
if err := os.Remove(claudeDir); err != nil {
|
||||
return fmt.Errorf("removing existing symlink: %w", err)
|
||||
}
|
||||
}
|
||||
// If ~/.claude doesn't exist, that's fine - we'll create the symlink
|
||||
|
||||
// Create symlink to target account
|
||||
if err := os.Symlink(targetAcct.ConfigDir, claudeDir); err != nil {
|
||||
return fmt.Errorf("creating symlink to %s: %w", targetAcct.ConfigDir, err)
|
||||
}
|
||||
|
||||
// Update default account
|
||||
cfg.Default = targetHandle
|
||||
if err := config.SaveAccountsConfig(accountsPath, cfg); err != nil {
|
||||
return fmt.Errorf("saving accounts config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to account '%s'\n", targetHandle)
|
||||
fmt.Printf("~/.claude -> %s\n", targetAcct.ConfigDir)
|
||||
fmt.Println()
|
||||
fmt.Println(style.Warning.Render("⚠️ Restart Claude Code for the change to take effect"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add flags
|
||||
accountListCmd.Flags().BoolVar(&accountJSON, "json", false, "Output as JSON")
|
||||
@@ -330,6 +465,7 @@ func init() {
|
||||
accountCmd.AddCommand(accountAddCmd)
|
||||
accountCmd.AddCommand(accountDefaultCmd)
|
||||
accountCmd.AddCommand(accountStatusCmd)
|
||||
accountCmd.AddCommand(accountSwitchCmd)
|
||||
|
||||
rootCmd.AddCommand(accountCmd)
|
||||
}
|
||||
|
||||
299
internal/cmd/account_test.go
Normal file
299
internal/cmd/account_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
// setupTestTownForAccount creates a minimal Gas Town workspace with accounts.
|
||||
func setupTestTownForAccount(t *testing.T) (townRoot string, accountsDir string) {
|
||||
t.Helper()
|
||||
|
||||
townRoot = t.TempDir()
|
||||
|
||||
// Create mayor directory with required files
|
||||
mayorDir := filepath.Join(townRoot, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor: %v", err)
|
||||
}
|
||||
|
||||
// Create town.json
|
||||
townConfig := &config.TownConfig{
|
||||
Type: "town",
|
||||
Version: config.CurrentTownVersion,
|
||||
Name: "test-town",
|
||||
PublicName: "Test Town",
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
townConfigPath := filepath.Join(mayorDir, "town.json")
|
||||
if err := config.SaveTownConfig(townConfigPath, townConfig); err != nil {
|
||||
t.Fatalf("save town.json: %v", err)
|
||||
}
|
||||
|
||||
// Create empty rigs.json
|
||||
rigsConfig := &config.RigsConfig{
|
||||
Version: 1,
|
||||
Rigs: make(map[string]config.RigEntry),
|
||||
}
|
||||
rigsPath := filepath.Join(mayorDir, "rigs.json")
|
||||
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
|
||||
t.Fatalf("save rigs.json: %v", err)
|
||||
}
|
||||
|
||||
// Create accounts directory
|
||||
accountsDir = filepath.Join(t.TempDir(), "claude-accounts")
|
||||
if err := os.MkdirAll(accountsDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir accounts: %v", err)
|
||||
}
|
||||
|
||||
return townRoot, accountsDir
|
||||
}
|
||||
|
||||
func TestAccountSwitch(t *testing.T) {
|
||||
t.Run("switch between accounts", func(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
// Create fake home directory for ~/.claude
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create account config directories
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
personalConfigDir := filepath.Join(accountsDir, "personal")
|
||||
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir work config: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(personalConfigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir personal config: %v", err)
|
||||
}
|
||||
|
||||
// Create accounts.json with two accounts
|
||||
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
|
||||
accountsCfg := config.NewAccountsConfig()
|
||||
accountsCfg.Accounts["work"] = config.Account{
|
||||
Email: "steve@work.com",
|
||||
ConfigDir: workConfigDir,
|
||||
}
|
||||
accountsCfg.Accounts["personal"] = config.Account{
|
||||
Email: "steve@personal.com",
|
||||
ConfigDir: personalConfigDir,
|
||||
}
|
||||
accountsCfg.Default = "work"
|
||||
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
|
||||
t.Fatalf("save accounts.json: %v", err)
|
||||
}
|
||||
|
||||
// Create initial symlink to work account
|
||||
claudeDir := filepath.Join(fakeHome, ".claude")
|
||||
if err := os.Symlink(workConfigDir, claudeDir); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Change to town root
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Run switch to personal
|
||||
cmd := &cobra.Command{}
|
||||
err := runAccountSwitch(cmd, []string{"personal"})
|
||||
if err != nil {
|
||||
t.Fatalf("runAccountSwitch failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlink points to personal
|
||||
target, err := os.Readlink(claudeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("readlink: %v", err)
|
||||
}
|
||||
if target != personalConfigDir {
|
||||
t.Errorf("symlink target = %q, want %q", target, personalConfigDir)
|
||||
}
|
||||
|
||||
// Verify default was updated
|
||||
loadedCfg, err := config.LoadAccountsConfig(accountsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load accounts: %v", err)
|
||||
}
|
||||
if loadedCfg.Default != "personal" {
|
||||
t.Errorf("default = %q, want 'personal'", loadedCfg.Default)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already on target account", func(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir work config: %v", err)
|
||||
}
|
||||
|
||||
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
|
||||
accountsCfg := config.NewAccountsConfig()
|
||||
accountsCfg.Accounts["work"] = config.Account{
|
||||
Email: "steve@work.com",
|
||||
ConfigDir: workConfigDir,
|
||||
}
|
||||
accountsCfg.Default = "work"
|
||||
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
|
||||
t.Fatalf("save accounts.json: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink already pointing to work
|
||||
claudeDir := filepath.Join(fakeHome, ".claude")
|
||||
if err := os.Symlink(workConfigDir, claudeDir); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Switch to work (should be no-op)
|
||||
cmd := &cobra.Command{}
|
||||
err := runAccountSwitch(cmd, []string{"work"})
|
||||
if err != nil {
|
||||
t.Fatalf("runAccountSwitch failed: %v", err)
|
||||
}
|
||||
|
||||
// Symlink should still point to work
|
||||
target, err := os.Readlink(claudeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("readlink: %v", err)
|
||||
}
|
||||
if target != workConfigDir {
|
||||
t.Errorf("symlink target = %q, want %q", target, workConfigDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent account", func(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir work config: %v", err)
|
||||
}
|
||||
|
||||
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
|
||||
accountsCfg := config.NewAccountsConfig()
|
||||
accountsCfg.Accounts["work"] = config.Account{
|
||||
Email: "steve@work.com",
|
||||
ConfigDir: workConfigDir,
|
||||
}
|
||||
accountsCfg.Default = "work"
|
||||
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
|
||||
t.Fatalf("save accounts.json: %v", err)
|
||||
}
|
||||
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Switch to nonexistent account
|
||||
cmd := &cobra.Command{}
|
||||
err := runAccountSwitch(cmd, []string{"nonexistent"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent account")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("real directory gets moved", func(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
personalConfigDir := filepath.Join(accountsDir, "personal")
|
||||
// Don't create workConfigDir - it will be created by moving ~/.claude
|
||||
if err := os.MkdirAll(personalConfigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir personal config: %v", err)
|
||||
}
|
||||
|
||||
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
|
||||
accountsCfg := config.NewAccountsConfig()
|
||||
accountsCfg.Accounts["work"] = config.Account{
|
||||
Email: "steve@work.com",
|
||||
ConfigDir: workConfigDir,
|
||||
}
|
||||
accountsCfg.Accounts["personal"] = config.Account{
|
||||
Email: "steve@personal.com",
|
||||
ConfigDir: personalConfigDir,
|
||||
}
|
||||
accountsCfg.Default = "work"
|
||||
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
|
||||
t.Fatalf("save accounts.json: %v", err)
|
||||
}
|
||||
|
||||
// Create ~/.claude as a real directory with a marker file
|
||||
claudeDir := filepath.Join(fakeHome, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir .claude: %v", err)
|
||||
}
|
||||
markerFile := filepath.Join(claudeDir, "marker.txt")
|
||||
if err := os.WriteFile(markerFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("write marker: %v", err)
|
||||
}
|
||||
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Switch to personal
|
||||
cmd := &cobra.Command{}
|
||||
err := runAccountSwitch(cmd, []string{"personal"})
|
||||
if err != nil {
|
||||
t.Fatalf("runAccountSwitch failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify ~/.claude is now a symlink to personal
|
||||
fileInfo, err := os.Lstat(claudeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat .claude: %v", err)
|
||||
}
|
||||
if fileInfo.Mode()&os.ModeSymlink == 0 {
|
||||
t.Error("~/.claude is not a symlink")
|
||||
}
|
||||
|
||||
target, err := os.Readlink(claudeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("readlink: %v", err)
|
||||
}
|
||||
if target != personalConfigDir {
|
||||
t.Errorf("symlink target = %q, want %q", target, personalConfigDir)
|
||||
}
|
||||
|
||||
// Verify original content was moved to work config dir
|
||||
movedMarker := filepath.Join(workConfigDir, "marker.txt")
|
||||
if _, err := os.Stat(movedMarker); err != nil {
|
||||
t.Errorf("marker file not moved to work config dir: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -443,6 +443,73 @@ func TestBeadsRemoveRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingCrossRigRoutingResolution verifies that sling can resolve rig paths
|
||||
// for cross-rig bead hooking using ExtractPrefix and GetRigPathForPrefix.
|
||||
// This is the fix for https://github.com/steveyegge/gastown/issues/148
|
||||
func TestSlingCrossRigRoutingResolution(t *testing.T) {
|
||||
townRoot := setupRoutingTestTown(t)
|
||||
|
||||
tests := []struct {
|
||||
beadID string
|
||||
expectedPath string // Relative to townRoot, or "." for town-level
|
||||
}{
|
||||
{"gt-mol-abc", "gastown/mayor/rig"},
|
||||
{"tr-task-xyz", "testrig/mayor/rig"},
|
||||
{"hq-cv-123", "."}, // Town-level beads
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.beadID, func(t *testing.T) {
|
||||
// Step 1: Extract prefix from bead ID
|
||||
prefix := beads.ExtractPrefix(tc.beadID)
|
||||
if prefix == "" {
|
||||
t.Fatalf("ExtractPrefix(%q) returned empty", tc.beadID)
|
||||
}
|
||||
|
||||
// Step 2: Resolve rig path from prefix
|
||||
rigPath := beads.GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath == "" {
|
||||
t.Fatalf("GetRigPathForPrefix(%q, %q) returned empty", townRoot, prefix)
|
||||
}
|
||||
|
||||
// Step 3: Verify the path is correct
|
||||
var expectedFull string
|
||||
if tc.expectedPath == "." {
|
||||
expectedFull = townRoot
|
||||
} else {
|
||||
expectedFull = filepath.Join(townRoot, tc.expectedPath)
|
||||
}
|
||||
|
||||
if rigPath != expectedFull {
|
||||
t.Errorf("GetRigPathForPrefix resolved to %q, want %q", rigPath, expectedFull)
|
||||
}
|
||||
|
||||
// Step 4: Verify the .beads directory exists at that path
|
||||
beadsDir := filepath.Join(rigPath, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
t.Errorf(".beads directory doesn't exist at resolved path: %s", beadsDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingCrossRigUnknownPrefix verifies behavior for unknown prefixes.
|
||||
func TestSlingCrossRigUnknownPrefix(t *testing.T) {
|
||||
townRoot := setupRoutingTestTown(t)
|
||||
|
||||
// An unknown prefix should return empty string
|
||||
unknownBeadID := "xx-unknown-123"
|
||||
prefix := beads.ExtractPrefix(unknownBeadID)
|
||||
if prefix != "xx-" {
|
||||
t.Fatalf("ExtractPrefix(%q) = %q, want %q", unknownBeadID, prefix, "xx-")
|
||||
}
|
||||
|
||||
rigPath := beads.GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath != "" {
|
||||
t.Errorf("GetRigPathForPrefix for unknown prefix returned %q, want empty", rigPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeadsGetPrefixForRig verifies prefix lookup by rig name.
|
||||
func TestBeadsGetPrefixForRig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
132
internal/cmd/beads_version.go
Normal file
132
internal/cmd/beads_version.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package cmd provides CLI commands for the gt tool.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MinBeadsVersion is the minimum required beads version for Gas Town.
|
||||
// This version must include custom type support (bd-i54l).
|
||||
const MinBeadsVersion = "0.44.0"
|
||||
|
||||
// beadsVersion represents a parsed semantic version.
|
||||
type beadsVersion struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
// parseBeadsVersion parses a version string like "0.44.0" into components.
|
||||
func parseBeadsVersion(v string) (beadsVersion, error) {
|
||||
// Strip leading 'v' if present
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
|
||||
// Split on dots
|
||||
parts := strings.Split(v, ".")
|
||||
if len(parts) < 2 {
|
||||
return beadsVersion{}, fmt.Errorf("invalid version format: %s", v)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0])
|
||||
}
|
||||
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1])
|
||||
}
|
||||
|
||||
patch := 0
|
||||
if len(parts) >= 3 {
|
||||
// Handle versions like "0.44.0-dev" - take only numeric prefix
|
||||
patchStr := parts[2]
|
||||
if idx := strings.IndexFunc(patchStr, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
}); idx != -1 {
|
||||
patchStr = patchStr[:idx]
|
||||
}
|
||||
if patchStr != "" {
|
||||
patch, err = strconv.Atoi(patchStr)
|
||||
if err != nil {
|
||||
return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return beadsVersion{major: major, minor: minor, patch: patch}, nil
|
||||
}
|
||||
|
||||
// compare returns -1 if v < other, 0 if equal, 1 if v > other.
|
||||
func (v beadsVersion) compare(other beadsVersion) int {
|
||||
if v.major != other.major {
|
||||
if v.major < other.major {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if v.minor != other.minor {
|
||||
if v.minor < other.minor {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if v.patch != other.patch {
|
||||
if v.patch < other.patch {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getBeadsVersion() (string, error) {
|
||||
cmd := exec.Command("bd", "version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
|
||||
}
|
||||
|
||||
// Parse output like "bd version 0.44.0 (dev)"
|
||||
// or "bd version 0.44.0"
|
||||
re := regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
||||
matches := re.FindStringSubmatch(string(output))
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
|
||||
// Returns nil if the version is sufficient, or an error with details if not.
|
||||
func CheckBeadsVersion() error {
|
||||
installedStr, err := getBeadsVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot verify beads version: %w", err)
|
||||
}
|
||||
|
||||
installed, err := parseBeadsVersion(installedStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err)
|
||||
}
|
||||
|
||||
required, err := parseBeadsVersion(MinBeadsVersion)
|
||||
if err != nil {
|
||||
// This would be a bug in our code
|
||||
return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err)
|
||||
}
|
||||
|
||||
if installed.compare(required) < 0 {
|
||||
return fmt.Errorf("beads version %s is required, but %s is installed\n\nPlease upgrade beads: go install github.com/steveyegge/beads/cmd/bd@latest", MinBeadsVersion, installedStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
68
internal/cmd/beads_version_test.go
Normal file
68
internal/cmd/beads_version_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBeadsVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want beadsVersion
|
||||
wantErr bool
|
||||
}{
|
||||
{"0.44.0", beadsVersion{0, 44, 0}, false},
|
||||
{"1.2.3", beadsVersion{1, 2, 3}, false},
|
||||
{"0.44.0-dev", beadsVersion{0, 44, 0}, false},
|
||||
{"v0.44.0", beadsVersion{0, 44, 0}, false},
|
||||
{"0.44", beadsVersion{0, 44, 0}, false},
|
||||
{"10.20.30", beadsVersion{10, 20, 30}, false},
|
||||
{"invalid", beadsVersion{}, true},
|
||||
{"", beadsVersion{}, true},
|
||||
{"a.b.c", beadsVersion{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseBeadsVersion(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseBeadsVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseBeadsVersion(%q) = %+v, want %+v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsVersionCompare(t *testing.T) {
|
||||
tests := []struct {
|
||||
v1 string
|
||||
v2 string
|
||||
want int
|
||||
}{
|
||||
{"0.44.0", "0.44.0", 0},
|
||||
{"0.44.0", "0.43.0", 1},
|
||||
{"0.43.0", "0.44.0", -1},
|
||||
{"1.0.0", "0.99.99", 1},
|
||||
{"0.44.1", "0.44.0", 1},
|
||||
{"0.44.0", "0.44.1", -1},
|
||||
{"1.2.3", "1.2.3", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.v1+"_vs_"+tt.v2, func(t *testing.T) {
|
||||
v1, err := parseBeadsVersion(tt.v1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse v1 %q: %v", tt.v1, err)
|
||||
}
|
||||
v2, err := parseBeadsVersion(tt.v2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse v2 %q: %v", tt.v2, err)
|
||||
}
|
||||
|
||||
got := v1.compare(v2)
|
||||
if got != tt.want {
|
||||
t.Errorf("(%s).compare(%s) = %d, want %d", tt.v1, tt.v2, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func runConfigAgentList(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Collect all agents
|
||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
||||
builtInAgents := config.ListAgentPresets()
|
||||
customAgents := make(map[string]*config.RuntimeConfig)
|
||||
if townSettings.Agents != nil {
|
||||
for name, runtime := range townSettings.Agents {
|
||||
@@ -330,7 +330,7 @@ func runConfigAgentSet(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Agent '%s' set to: %s\n", style.Bold.Render(name), commandLine)
|
||||
|
||||
// Check if this overrides a built-in
|
||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
||||
builtInAgents := config.ListAgentPresets()
|
||||
for _, builtin := range builtInAgents {
|
||||
if name == builtin {
|
||||
fmt.Printf("\n%s\n", style.Dim.Render("(overriding built-in '"+builtin+"' preset)"))
|
||||
@@ -350,7 +350,7 @@ func runConfigAgentRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Check if trying to remove built-in
|
||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
||||
builtInAgents := config.ListAgentPresets()
|
||||
for _, builtin := range builtInAgents {
|
||||
if name == builtin {
|
||||
return fmt.Errorf("cannot remove built-in agent '%s' (use 'gt config agent set' to override it)", name)
|
||||
@@ -415,7 +415,7 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Verify agent exists
|
||||
isValid := false
|
||||
builtInAgents := []string{"claude", "gemini", "codex"}
|
||||
builtInAgents := config.ListAgentPresets()
|
||||
for _, builtin := range builtInAgents {
|
||||
if name == builtin {
|
||||
isValid = true
|
||||
|
||||
@@ -1130,8 +1130,9 @@ func getIssueDetailsBatch(issueIDs []string) map[string]*issueDetails {
|
||||
return result
|
||||
}
|
||||
|
||||
// Build args: bd show id1 id2 id3 ... --json
|
||||
args := append([]string{"show"}, issueIDs...)
|
||||
// Build args: bd --no-daemon show id1 id2 id3 ... --json
|
||||
// Use --no-daemon to ensure fresh data (avoid stale cache from daemon)
|
||||
args := append([]string{"--no-daemon", "show"}, issueIDs...)
|
||||
args = append(args, "--json")
|
||||
|
||||
showCmd := exec.Command("bd", args...)
|
||||
@@ -1177,7 +1178,8 @@ func getIssueDetailsBatch(issueIDs []string) map[string]*issueDetails {
|
||||
// Prefer getIssueDetailsBatch for multiple issues to avoid N+1 subprocess calls.
|
||||
func getIssueDetails(issueID string) *issueDetails {
|
||||
// Use bd show with routing - it should find the issue in the right rig
|
||||
showCmd := exec.Command("bd", "show", issueID, "--json")
|
||||
// Use --no-daemon to ensure fresh data (avoid stale cache)
|
||||
showCmd := exec.Command("bd", "--no-daemon", "show", issueID, "--json")
|
||||
var stdout bytes.Buffer
|
||||
showCmd.Stdout = &stdout
|
||||
|
||||
|
||||
@@ -18,15 +18,24 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
costsJSON bool
|
||||
costsToday bool
|
||||
costsWeek bool
|
||||
costsByRole bool
|
||||
costsByRig bool
|
||||
costsJSON bool
|
||||
costsToday bool
|
||||
costsWeek bool
|
||||
costsByRole bool
|
||||
costsByRig bool
|
||||
costsVerbose bool
|
||||
|
||||
// Record subcommand flags
|
||||
recordSession string
|
||||
recordWorkItem string
|
||||
|
||||
// Digest subcommand flags
|
||||
digestYesterday bool
|
||||
digestDate string
|
||||
digestDryRun bool
|
||||
|
||||
// Migrate subcommand flags
|
||||
migrateDryRun bool
|
||||
)
|
||||
|
||||
var costsCmd = &cobra.Command{
|
||||
@@ -37,24 +46,34 @@ var costsCmd = &cobra.Command{
|
||||
|
||||
By default, shows live costs scraped from running tmux sessions.
|
||||
|
||||
Cost tracking uses ephemeral wisps for individual sessions that are
|
||||
aggregated into daily "Cost Report" digest beads for audit purposes.
|
||||
|
||||
Examples:
|
||||
gt costs # Live costs from running sessions
|
||||
gt costs --today # Today's total from session events
|
||||
gt costs --week # This week's total
|
||||
gt costs --today # Today's costs from wisps (not yet digested)
|
||||
gt costs --week # This week's costs from digest beads + today's wisps
|
||||
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
|
||||
gt costs --by-rig # Breakdown by rig
|
||||
gt costs --json # Output as JSON`,
|
||||
gt costs --json # Output as JSON
|
||||
|
||||
Subcommands:
|
||||
gt costs record # Record session cost as ephemeral wisp (Stop hook)
|
||||
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`,
|
||||
RunE: runCosts,
|
||||
}
|
||||
|
||||
var costsRecordCmd = &cobra.Command{
|
||||
Use: "record",
|
||||
Short: "Record session cost as a bead event (called by Stop hook)",
|
||||
Long: `Record the final cost of a session as a session.ended event in beads.
|
||||
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
|
||||
Long: `Record the final cost of a session as an ephemeral wisp.
|
||||
|
||||
This command is intended to be called from a Claude Code Stop hook.
|
||||
It captures the final cost from the tmux session and creates an event
|
||||
bead with the cost data.
|
||||
It captures the final cost from the tmux session and creates an ephemeral
|
||||
event that is NOT exported to JSONL (avoiding log-in-database pollution).
|
||||
|
||||
Session cost wisps are aggregated daily by 'gt costs digest' into a single
|
||||
permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
|
||||
|
||||
Examples:
|
||||
gt costs record --session gt-gastown-toast
|
||||
@@ -62,6 +81,46 @@ Examples:
|
||||
RunE: runCostsRecord,
|
||||
}
|
||||
|
||||
var costsDigestCmd = &cobra.Command{
|
||||
Use: "digest",
|
||||
Short: "Aggregate session cost wisps into a daily digest bead",
|
||||
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest.
|
||||
|
||||
This command is intended to be run by Deacon patrol (daily) or manually.
|
||||
It queries session.ended wisps for a target date, creates a single aggregate
|
||||
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps.
|
||||
|
||||
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
||||
and provides an audit trail without log-in-database pollution.
|
||||
|
||||
Examples:
|
||||
gt costs digest --yesterday # Digest yesterday's costs (default for patrol)
|
||||
gt costs digest --date 2026-01-07 # Digest a specific date
|
||||
gt costs digest --yesterday --dry-run # Preview without changes`,
|
||||
RunE: runCostsDigest,
|
||||
}
|
||||
|
||||
var costsMigrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate legacy session.ended beads to the new wisp architecture",
|
||||
Long: `Migrate legacy session.ended event beads to the new cost tracking system.
|
||||
|
||||
This command handles the transition from the old architecture (where each
|
||||
session.ended event was a permanent bead) to the new wisp-based system.
|
||||
|
||||
The migration:
|
||||
1. Finds all open session.ended event beads (should be none if auto-close worked)
|
||||
2. Closes them with reason "migrated to wisp architecture"
|
||||
|
||||
Legacy beads remain in the database for historical queries but won't interfere
|
||||
with the new wisp-based cost tracking.
|
||||
|
||||
Examples:
|
||||
gt costs migrate # Migrate legacy beads
|
||||
gt costs migrate --dry-run # Preview what would be migrated`,
|
||||
RunE: runCostsMigrate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(costsCmd)
|
||||
costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON")
|
||||
@@ -69,11 +128,22 @@ func init() {
|
||||
costsCmd.Flags().BoolVar(&costsWeek, "week", false, "Show this week's total from session events")
|
||||
costsCmd.Flags().BoolVar(&costsByRole, "by-role", false, "Show breakdown by role")
|
||||
costsCmd.Flags().BoolVar(&costsByRig, "by-rig", false, "Show breakdown by rig")
|
||||
costsCmd.Flags().BoolVarP(&costsVerbose, "verbose", "v", false, "Show debug output for failures")
|
||||
|
||||
// Add record subcommand
|
||||
costsCmd.AddCommand(costsRecordCmd)
|
||||
costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record")
|
||||
costsRecordCmd.Flags().StringVar(&recordWorkItem, "work-item", "", "Work item ID (bead) for attribution")
|
||||
|
||||
// Add digest subcommand
|
||||
costsCmd.AddCommand(costsDigestCmd)
|
||||
costsDigestCmd.Flags().BoolVar(&digestYesterday, "yesterday", false, "Digest yesterday's costs (default for patrol)")
|
||||
costsDigestCmd.Flags().StringVar(&digestDate, "date", "", "Digest a specific date (YYYY-MM-DD)")
|
||||
costsDigestCmd.Flags().BoolVar(&digestDryRun, "dry-run", false, "Preview what would be done without making changes")
|
||||
|
||||
// Add migrate subcommand
|
||||
costsCmd.AddCommand(costsMigrateCmd)
|
||||
costsMigrateCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Preview what would be migrated without making changes")
|
||||
}
|
||||
|
||||
// SessionCost represents cost info for a single session.
|
||||
@@ -150,8 +220,8 @@ func runLiveCosts() error {
|
||||
// Extract cost from content
|
||||
cost := extractCost(content)
|
||||
|
||||
// Check if Claude is running
|
||||
running := t.IsClaudeRunning(session)
|
||||
// Check if an agent appears to be running
|
||||
running := t.IsAgentRunning(session)
|
||||
|
||||
costs = append(costs, SessionCost{
|
||||
Session: session,
|
||||
@@ -180,46 +250,48 @@ func runLiveCosts() error {
|
||||
}
|
||||
|
||||
func runCostsFromLedger() error {
|
||||
// Query session events from beads
|
||||
entries, err := querySessionEvents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying session events: %w", err)
|
||||
now := time.Now()
|
||||
var entries []CostEntry
|
||||
var err error
|
||||
|
||||
if costsToday {
|
||||
// For today: query ephemeral wisps (not yet digested)
|
||||
// This gives real-time view of today's costs
|
||||
entries, err = querySessionCostWisps(now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying session cost wisps: %w", err)
|
||||
}
|
||||
} else if costsWeek {
|
||||
// For week: query digest beads (costs.digest events)
|
||||
// These are the aggregated daily reports
|
||||
entries, err = queryDigestBeads(7)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying digest beads: %w", err)
|
||||
}
|
||||
|
||||
// Also include today's wisps (not yet digested)
|
||||
todayWisps, _ := querySessionCostWisps(now)
|
||||
entries = append(entries, todayWisps...)
|
||||
} else {
|
||||
// No time filter: query both digests and legacy session.ended events
|
||||
// (for backwards compatibility during migration)
|
||||
entries, err = querySessionEvents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying session events: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println(style.Dim.Render("No session events found. Costs are recorded when sessions end."))
|
||||
fmt.Println(style.Dim.Render("No cost data found. Costs are recorded when sessions end."))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter entries by time period
|
||||
var filtered []CostEntry
|
||||
now := time.Now()
|
||||
|
||||
for _, entry := range entries {
|
||||
if costsToday {
|
||||
// Today: same day
|
||||
if entry.EndedAt.Year() == now.Year() &&
|
||||
entry.EndedAt.YearDay() == now.YearDay() {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
} else if costsWeek {
|
||||
// This week: within 7 days
|
||||
weekAgo := now.AddDate(0, 0, -7)
|
||||
if entry.EndedAt.After(weekAgo) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
} else {
|
||||
// No time filter
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var total float64
|
||||
byRole := make(map[string]float64)
|
||||
byRig := make(map[string]float64)
|
||||
|
||||
for _, entry := range filtered {
|
||||
for _, entry := range entries {
|
||||
total += entry.CostUSD
|
||||
byRole[entry.Role] += entry.CostUSD
|
||||
if entry.Rig != "" {
|
||||
@@ -250,7 +322,7 @@ func runCostsFromLedger() error {
|
||||
return outputCostsJSON(output)
|
||||
}
|
||||
|
||||
return outputLedgerHuman(output, filtered)
|
||||
return outputLedgerHuman(output, entries)
|
||||
}
|
||||
|
||||
// SessionEvent represents a session.ended event from beads.
|
||||
@@ -362,6 +434,84 @@ func querySessionEvents() ([]CostEntry, error) {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// queryDigestBeads queries costs.digest events from the past N days and extracts session entries.
|
||||
func queryDigestBeads(days int) ([]CostEntry, error) {
|
||||
// Get list of event IDs
|
||||
listArgs := []string{
|
||||
"list",
|
||||
"--type=event",
|
||||
"--all",
|
||||
"--limit=0",
|
||||
"--json",
|
||||
}
|
||||
|
||||
listCmd := exec.Command("bd", listArgs...)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var listItems []EventListItem
|
||||
if err := json.Unmarshal(listOutput, &listItems); err != nil {
|
||||
return nil, fmt.Errorf("parsing event list: %w", err)
|
||||
}
|
||||
|
||||
if len(listItems) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get full details for all events
|
||||
showArgs := []string{"show", "--json"}
|
||||
for _, item := range listItems {
|
||||
showArgs = append(showArgs, item.ID)
|
||||
}
|
||||
|
||||
showCmd := exec.Command("bd", showArgs...)
|
||||
showOutput, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("showing events: %w", err)
|
||||
}
|
||||
|
||||
var events []SessionEvent
|
||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
||||
return nil, fmt.Errorf("parsing event details: %w", err)
|
||||
}
|
||||
|
||||
// Calculate date range
|
||||
now := time.Now()
|
||||
cutoff := now.AddDate(0, 0, -days)
|
||||
|
||||
var entries []CostEntry
|
||||
for _, event := range events {
|
||||
// Filter for costs.digest events only
|
||||
if event.EventKind != "costs.digest" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the digest payload
|
||||
var digest CostDigest
|
||||
if event.Payload != "" {
|
||||
if err := json.Unmarshal([]byte(event.Payload), &digest); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check date is within range
|
||||
digestDate, err := time.Parse("2006-01-02", digest.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if digestDate.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract individual session entries from the digest
|
||||
entries = append(entries, digest.Sessions...)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// parseSessionName extracts role, rig, and worker from a session name.
|
||||
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
|
||||
// Examples:
|
||||
@@ -428,7 +578,6 @@ func extractCost(content string) float64 {
|
||||
return cost
|
||||
}
|
||||
|
||||
|
||||
func outputCostsJSON(output CostsOutput) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
@@ -575,9 +724,14 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("marshaling payload: %w", err)
|
||||
}
|
||||
|
||||
// Build bd create command
|
||||
// Build bd create command for ephemeral wisp
|
||||
// Using --ephemeral creates a wisp that:
|
||||
// - Is stored locally only (not exported to JSONL)
|
||||
// - Won't pollute git history with O(sessions/day) events
|
||||
// - Will be aggregated into daily digests by 'gt costs digest'
|
||||
bdArgs := []string{
|
||||
"create",
|
||||
"--ephemeral",
|
||||
"--type=event",
|
||||
"--title=" + title,
|
||||
"--event-category=session.ended",
|
||||
@@ -594,20 +748,28 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||
// NOTE: We intentionally don't use --rig flag here because it causes
|
||||
// event fields (event_kind, actor, payload) to not be stored properly.
|
||||
// The bd command will auto-detect the correct rig from cwd.
|
||||
// TODO: File beads bug about --rig flag losing event fields.
|
||||
|
||||
// Execute bd create
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
output, err := bdCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating session event: %w\nOutput: %s", err, string(output))
|
||||
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
eventID := strings.TrimSpace(string(output))
|
||||
wispID := strings.TrimSpace(string(output))
|
||||
|
||||
// Auto-close session cost wisps immediately after creation.
|
||||
// These are informational records that don't need to stay open.
|
||||
// The wisp data is preserved and queryable until digested.
|
||||
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
||||
if closeErr := closeCmd.Run(); closeErr != nil {
|
||||
// Non-fatal: wisp was created, just couldn't auto-close
|
||||
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
|
||||
}
|
||||
|
||||
// Output confirmation (silent if cost is zero and no work item)
|
||||
if cost > 0 || recordWorkItem != "" {
|
||||
fmt.Printf("%s Recorded $%.2f for %s (event: %s)", style.Success.Render("✓"), cost, session, eventID)
|
||||
fmt.Printf("%s Recorded $%.2f for %s (wisp: %s)", style.Success.Render("✓"), cost, session, wispID)
|
||||
if recordWorkItem != "" {
|
||||
fmt.Printf(" (work: %s)", recordWorkItem)
|
||||
}
|
||||
@@ -640,9 +802,13 @@ func deriveSessionName() string {
|
||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
||||
}
|
||||
|
||||
// Town-level roles (mayor, deacon): gt-{town}-{role}
|
||||
if (role == "mayor" || role == "deacon") && town != "" {
|
||||
return fmt.Sprintf("gt-%s-%s", town, role)
|
||||
// Town-level roles (mayor, deacon): gt-{town}-{role} or gt-{role}
|
||||
if role == "mayor" || role == "deacon" {
|
||||
if town != "" {
|
||||
return fmt.Sprintf("gt-%s-%s", town, role)
|
||||
}
|
||||
// No town set - use simple gt-{role} pattern
|
||||
return fmt.Sprintf("gt-%s", role)
|
||||
}
|
||||
|
||||
// Rig-based roles (witness, refinery): gt-{rig}-{role}
|
||||
@@ -655,12 +821,9 @@ func deriveSessionName() string {
|
||||
|
||||
// detectCurrentTmuxSession returns the current tmux session name if running inside tmux.
|
||||
// Uses `tmux display-message -p '#S'` which prints the session name.
|
||||
// Note: We don't check TMUX env var because it may not be inherited when Claude Code
|
||||
// runs bash commands, even though we are inside a tmux session.
|
||||
func detectCurrentTmuxSession() string {
|
||||
// Check if we're inside tmux
|
||||
if os.Getenv("TMUX") == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cmd := exec.Command("tmux", "display-message", "-p", "#S")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -669,7 +832,8 @@ func detectCurrentTmuxSession() string {
|
||||
|
||||
session := strings.TrimSpace(string(output))
|
||||
// Only return if it looks like a Gas Town session
|
||||
if strings.HasPrefix(session, constants.SessionPrefix) {
|
||||
// Accept both gt- (rig sessions) and hq- (town-level sessions like hq-mayor)
|
||||
if strings.HasPrefix(session, constants.SessionPrefix) || strings.HasPrefix(session, constants.HQSessionPrefix) {
|
||||
return session
|
||||
}
|
||||
return ""
|
||||
@@ -712,3 +876,451 @@ func buildAgentPath(role, rig, worker string) string {
|
||||
return worker
|
||||
}
|
||||
}
|
||||
|
||||
// CostDigest represents the aggregated daily cost report.
|
||||
type CostDigest struct {
|
||||
Date string `json:"date"`
|
||||
TotalUSD float64 `json:"total_usd"`
|
||||
SessionCount int `json:"session_count"`
|
||||
Sessions []CostEntry `json:"sessions"`
|
||||
ByRole map[string]float64 `json:"by_role"`
|
||||
ByRig map[string]float64 `json:"by_rig,omitempty"`
|
||||
}
|
||||
|
||||
// WispListOutput represents the JSON output from bd mol wisp list.
|
||||
type WispListOutput struct {
|
||||
Wisps []WispItem `json:"wisps"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// WispItem represents a single wisp from bd mol wisp list.
|
||||
type WispItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// runCostsDigest aggregates session cost wisps into a daily digest bead.
|
||||
func runCostsDigest(cmd *cobra.Command, args []string) error {
|
||||
// Determine target date
|
||||
var targetDate time.Time
|
||||
|
||||
if digestDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", digestDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
|
||||
}
|
||||
targetDate = parsed
|
||||
} else if digestYesterday {
|
||||
targetDate = time.Now().AddDate(0, 0, -1)
|
||||
} else {
|
||||
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
|
||||
}
|
||||
|
||||
dateStr := targetDate.Format("2006-01-02")
|
||||
|
||||
// Query ephemeral session.ended wisps for target date
|
||||
wisps, err := querySessionCostWisps(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying session cost wisps: %w", err)
|
||||
}
|
||||
|
||||
if len(wisps) == 0 {
|
||||
fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("○"), dateStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build digest
|
||||
digest := CostDigest{
|
||||
Date: dateStr,
|
||||
Sessions: wisps,
|
||||
ByRole: make(map[string]float64),
|
||||
ByRig: make(map[string]float64),
|
||||
}
|
||||
|
||||
for _, w := range wisps {
|
||||
digest.TotalUSD += w.CostUSD
|
||||
digest.SessionCount++
|
||||
digest.ByRole[w.Role] += w.CostUSD
|
||||
if w.Rig != "" {
|
||||
digest.ByRig[w.Rig] += w.CostUSD
|
||||
}
|
||||
}
|
||||
|
||||
if digestDryRun {
|
||||
fmt.Printf("%s [DRY RUN] Would create Cost Report %s:\n", style.Bold.Render("📊"), dateStr)
|
||||
fmt.Printf(" Total: $%.2f\n", digest.TotalUSD)
|
||||
fmt.Printf(" Sessions: %d\n", digest.SessionCount)
|
||||
fmt.Printf(" By Role:\n")
|
||||
for role, cost := range digest.ByRole {
|
||||
fmt.Printf(" %s: $%.2f\n", role, cost)
|
||||
}
|
||||
if len(digest.ByRig) > 0 {
|
||||
fmt.Printf(" By Rig:\n")
|
||||
for rig, cost := range digest.ByRig {
|
||||
fmt.Printf(" %s: $%.2f\n", rig, cost)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create permanent digest bead
|
||||
digestID, err := createCostDigestBead(digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating digest bead: %w", err)
|
||||
}
|
||||
|
||||
// Delete source wisps (they're ephemeral, use bd mol burn)
|
||||
deletedCount, deleteErr := deleteSessionCostWisps(targetDate)
|
||||
if deleteErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %v\n", deleteErr)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created Cost Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
|
||||
fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
|
||||
if deletedCount > 0 {
|
||||
fmt.Printf(" Deleted %d source wisps\n", deletedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// querySessionCostWisps queries ephemeral session.ended events for a target date.
|
||||
func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
|
||||
// List all wisps including closed ones
|
||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
// No wisps database or command failed
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var wispList WispListOutput
|
||||
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
||||
return nil, fmt.Errorf("parsing wisp list: %w", err)
|
||||
}
|
||||
|
||||
if wispList.Count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Batch all wisp IDs into a single bd show call to avoid N+1 queries
|
||||
showArgs := []string{"show", "--json"}
|
||||
for _, wisp := range wispList.Wisps {
|
||||
showArgs = append(showArgs, wisp.ID)
|
||||
}
|
||||
|
||||
showCmd := exec.Command("bd", showArgs...)
|
||||
showOutput, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("showing wisps: %w", err)
|
||||
}
|
||||
|
||||
var events []SessionEvent
|
||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
||||
return nil, fmt.Errorf("parsing wisp details: %w", err)
|
||||
}
|
||||
|
||||
var sessionCostWisps []CostEntry
|
||||
targetDay := targetDate.Format("2006-01-02")
|
||||
|
||||
for _, event := range events {
|
||||
// Filter for session.ended events only
|
||||
if event.EventKind != "session.ended" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
var payload SessionPayload
|
||||
if event.Payload != "" {
|
||||
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for event %s: %v\n", event.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ended_at and filter by target date
|
||||
endedAt := event.CreatedAt
|
||||
if payload.EndedAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
||||
endedAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this event is from the target date
|
||||
if endedAt.Format("2006-01-02") != targetDay {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionCostWisps = append(sessionCostWisps, CostEntry{
|
||||
SessionID: payload.SessionID,
|
||||
Role: payload.Role,
|
||||
Rig: payload.Rig,
|
||||
Worker: payload.Worker,
|
||||
CostUSD: payload.CostUSD,
|
||||
EndedAt: endedAt,
|
||||
WorkItem: event.Target,
|
||||
})
|
||||
}
|
||||
|
||||
return sessionCostWisps, nil
|
||||
}
|
||||
|
||||
// createCostDigestBead creates a permanent bead for the daily cost digest.
|
||||
func createCostDigestBead(digest CostDigest) (string, error) {
|
||||
// Build description with aggregate data
|
||||
var desc strings.Builder
|
||||
desc.WriteString(fmt.Sprintf("Daily cost aggregate for %s.\n\n", digest.Date))
|
||||
desc.WriteString(fmt.Sprintf("**Total:** $%.2f from %d sessions\n\n", digest.TotalUSD, digest.SessionCount))
|
||||
|
||||
if len(digest.ByRole) > 0 {
|
||||
desc.WriteString("## By Role\n")
|
||||
roles := make([]string, 0, len(digest.ByRole))
|
||||
for role := range digest.ByRole {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
icon := constants.RoleEmoji(role)
|
||||
desc.WriteString(fmt.Sprintf("- %s %s: $%.2f\n", icon, role, digest.ByRole[role]))
|
||||
}
|
||||
desc.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(digest.ByRig) > 0 {
|
||||
desc.WriteString("## By Rig\n")
|
||||
rigs := make([]string, 0, len(digest.ByRig))
|
||||
for rig := range digest.ByRig {
|
||||
rigs = append(rigs, rig)
|
||||
}
|
||||
sort.Strings(rigs)
|
||||
for _, rig := range rigs {
|
||||
desc.WriteString(fmt.Sprintf("- %s: $%.2f\n", rig, digest.ByRig[rig]))
|
||||
}
|
||||
desc.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build payload JSON with full session details
|
||||
payloadJSON, err := json.Marshal(digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling digest payload: %w", err)
|
||||
}
|
||||
|
||||
// Create the digest bead (NOT ephemeral - this is permanent)
|
||||
title := fmt.Sprintf("Cost Report %s", digest.Date)
|
||||
bdArgs := []string{
|
||||
"create",
|
||||
"--type=event",
|
||||
"--title=" + title,
|
||||
"--event-category=costs.digest",
|
||||
"--event-payload=" + string(payloadJSON),
|
||||
"--description=" + desc.String(),
|
||||
"--silent",
|
||||
}
|
||||
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
output, err := bdCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
digestID := strings.TrimSpace(string(output))
|
||||
|
||||
// Auto-close the digest (it's an audit record, not work)
|
||||
closeCmd := exec.Command("bd", "close", digestID, "--reason=daily cost digest")
|
||||
_ = closeCmd.Run() // Best effort
|
||||
|
||||
return digestID, nil
|
||||
}
|
||||
|
||||
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date.
|
||||
func deleteSessionCostWisps(targetDate time.Time) (int, error) {
|
||||
// List all wisps
|
||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] wisp list failed in deletion: %v\n", err)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var wispList WispListOutput
|
||||
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
||||
return 0, fmt.Errorf("parsing wisp list: %w", err)
|
||||
}
|
||||
|
||||
targetDay := targetDate.Format("2006-01-02")
|
||||
|
||||
// Collect all wisp IDs that match our criteria
|
||||
var wispIDsToDelete []string
|
||||
|
||||
for _, wisp := range wispList.Wisps {
|
||||
// Get full wisp details to check if it's a session.ended event
|
||||
showCmd := exec.Command("bd", "show", wisp.ID, "--json")
|
||||
showOutput, err := showCmd.Output()
|
||||
if err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] bd show failed for wisp %s: %v\n", wisp.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var events []SessionEvent
|
||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
event := events[0]
|
||||
|
||||
// Only delete session.ended wisps
|
||||
if event.EventKind != "session.ended" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse payload to get ended_at for date filtering
|
||||
var payload SessionPayload
|
||||
if event.Payload != "" {
|
||||
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
endedAt := event.CreatedAt
|
||||
if payload.EndedAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
||||
endedAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Only delete wisps from the target date
|
||||
if endedAt.Format("2006-01-02") != targetDay {
|
||||
continue
|
||||
}
|
||||
|
||||
wispIDsToDelete = append(wispIDsToDelete, wisp.ID)
|
||||
}
|
||||
|
||||
if len(wispIDsToDelete) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Batch delete all wisps in a single subprocess call
|
||||
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...)
|
||||
burnCmd := exec.Command("bd", burnArgs...)
|
||||
if burnErr := burnCmd.Run(); burnErr != nil {
|
||||
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
|
||||
}
|
||||
|
||||
return len(wispIDsToDelete), nil
|
||||
}
|
||||
|
||||
// runCostsMigrate migrates legacy session.ended beads to the new architecture.
|
||||
func runCostsMigrate(cmd *cobra.Command, args []string) error {
|
||||
// Query all session.ended events (both open and closed)
|
||||
listArgs := []string{
|
||||
"list",
|
||||
"--type=event",
|
||||
"--all",
|
||||
"--limit=0",
|
||||
"--json",
|
||||
}
|
||||
|
||||
listCmd := exec.Command("bd", listArgs...)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
fmt.Println(style.Dim.Render("No events found or bd command failed"))
|
||||
return nil
|
||||
}
|
||||
|
||||
var listItems []EventListItem
|
||||
if err := json.Unmarshal(listOutput, &listItems); err != nil {
|
||||
return fmt.Errorf("parsing event list: %w", err)
|
||||
}
|
||||
|
||||
if len(listItems) == 0 {
|
||||
fmt.Println(style.Dim.Render("No events found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get full details for all events
|
||||
showArgs := []string{"show", "--json"}
|
||||
for _, item := range listItems {
|
||||
showArgs = append(showArgs, item.ID)
|
||||
}
|
||||
|
||||
showCmd := exec.Command("bd", showArgs...)
|
||||
showOutput, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("showing events: %w", err)
|
||||
}
|
||||
|
||||
var events []SessionEvent
|
||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
||||
return fmt.Errorf("parsing event details: %w", err)
|
||||
}
|
||||
|
||||
// Find open session.ended events
|
||||
var openEvents []SessionEvent
|
||||
var closedCount int
|
||||
for _, event := range events {
|
||||
if event.EventKind != "session.ended" {
|
||||
continue
|
||||
}
|
||||
if event.Status == "closed" {
|
||||
closedCount++
|
||||
continue
|
||||
}
|
||||
openEvents = append(openEvents, event)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Legacy session.ended beads:\n", style.Bold.Render("📊"))
|
||||
fmt.Printf(" Closed: %d (no action needed)\n", closedCount)
|
||||
fmt.Printf(" Open: %d (will be closed)\n", len(openEvents))
|
||||
|
||||
if len(openEvents) == 0 {
|
||||
fmt.Println(style.Success.Render("\n✓ No migration needed - all session.ended events are already closed"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if migrateDryRun {
|
||||
fmt.Printf("\n%s Would close %d open session.ended events\n", style.Bold.Render("[DRY RUN]"), len(openEvents))
|
||||
for _, event := range openEvents {
|
||||
fmt.Printf(" - %s: %s\n", event.ID, event.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close all open session.ended events
|
||||
closedMigrated := 0
|
||||
for _, event := range openEvents {
|
||||
closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to wisp architecture")
|
||||
if err := closeCmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
|
||||
continue
|
||||
}
|
||||
closedMigrated++
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Migrated %d session.ended events (closed)\n", style.Success.Render("✓"), closedMigrated)
|
||||
fmt.Println(style.Dim.Render("Legacy beads preserved for historical queries."))
|
||||
fmt.Println(style.Dim.Render("New session costs will use ephemeral wisps + daily digests."))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,6 +61,20 @@ func TestDeriveSessionName(t *testing.T) {
|
||||
},
|
||||
expected: "gt-ai-deacon",
|
||||
},
|
||||
{
|
||||
name: "mayor session without GT_TOWN",
|
||||
envVars: map[string]string{
|
||||
"GT_ROLE": "mayor",
|
||||
},
|
||||
expected: "gt-mayor",
|
||||
},
|
||||
{
|
||||
name: "deacon session without GT_TOWN",
|
||||
envVars: map[string]string{
|
||||
"GT_ROLE": "deacon",
|
||||
},
|
||||
expected: "gt-deacon",
|
||||
},
|
||||
{
|
||||
name: "no env vars",
|
||||
envVars: map[string]string{},
|
||||
|
||||
@@ -8,16 +8,19 @@ import (
|
||||
|
||||
// Crew command flags
|
||||
var (
|
||||
crewRig string
|
||||
crewBranch bool
|
||||
crewJSON bool
|
||||
crewForce bool
|
||||
crewNoTmux bool
|
||||
crewDetached bool
|
||||
crewMessage string
|
||||
crewAccount string
|
||||
crewAll bool
|
||||
crewDryRun bool
|
||||
crewRig string
|
||||
crewBranch bool
|
||||
crewJSON bool
|
||||
crewForce bool
|
||||
crewPurge bool
|
||||
crewNoTmux bool
|
||||
crewDetached bool
|
||||
crewMessage string
|
||||
crewAccount string
|
||||
crewAgentOverride string
|
||||
crewAll bool
|
||||
crewListAll bool
|
||||
crewDryRun bool
|
||||
)
|
||||
|
||||
var crewCmd = &cobra.Command{
|
||||
@@ -75,7 +78,8 @@ Shows git branch, session state, and git status for each workspace.
|
||||
|
||||
Examples:
|
||||
gt crew list # List in current rig
|
||||
gt crew list --rig greenplace # List in specific rig
|
||||
gt crew list --rig greenplace # List in specific rig
|
||||
gt crew list --all # List in all rigs
|
||||
gt crew list --json # JSON output`,
|
||||
RunE: runCrewList,
|
||||
}
|
||||
@@ -117,11 +121,22 @@ var crewRemoveCmd = &cobra.Command{
|
||||
Checks for uncommitted changes and running sessions before removing.
|
||||
Use --force to skip checks and remove anyway.
|
||||
|
||||
The agent bead is CLOSED by default (preserves CV history). Use --purge
|
||||
to DELETE the agent bead entirely (for accidental/test crew that should
|
||||
leave no trace in the ledger).
|
||||
|
||||
--purge also:
|
||||
- Deletes the agent bead (not just closes it)
|
||||
- Unassigns any beads assigned to this crew member
|
||||
- Clears mail in the agent's inbox
|
||||
- Properly handles git worktrees (not just regular clones)
|
||||
|
||||
Examples:
|
||||
gt crew remove dave # Remove with safety checks
|
||||
gt crew remove dave emma fred # Remove multiple
|
||||
gt crew remove beads/grip beads/fang # Remove from specific rig
|
||||
gt crew remove dave --force # Force remove`,
|
||||
gt crew remove dave --force # Force remove (closes bead)
|
||||
gt crew remove test-crew --purge # Obliterate (deletes bead)`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runCrewRemove,
|
||||
}
|
||||
@@ -246,25 +261,23 @@ var crewStartCmd = &cobra.Command{
|
||||
Long: `Start crew workers in a rig, creating workspaces if they don't exist.
|
||||
|
||||
The rig name can be provided as the first argument, or inferred from the
|
||||
current directory. Optionally specify crew member names to start specific
|
||||
workers, or use --all to start all crew members in the rig.
|
||||
current directory. If no crew names are specified, starts all crew in the rig.
|
||||
|
||||
The crew session starts in the background with Claude running and ready.
|
||||
|
||||
Examples:
|
||||
gt crew start gastown joe # Start joe in gastown rig
|
||||
gt crew start gastown --all # Start all crew in gastown rig
|
||||
gt crew start --all # Start all crew (rig inferred from cwd)
|
||||
gt crew start beads grip fang # Start grip and fang in beads rig`,
|
||||
gt crew start beads # Start all crew in beads rig
|
||||
gt crew start # Start all crew (rig inferred from cwd)
|
||||
gt crew start beads grip fang # Start specific crew in beads rig
|
||||
gt crew start gastown joe # Start joe in gastown rig`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
// With --all, we can have 0 args (infer rig) or 1+ args (rig specified)
|
||||
if crewAll {
|
||||
return nil
|
||||
}
|
||||
// Without --all, need at least rig and one crew name
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("requires rig and crew name, or use --all")
|
||||
}
|
||||
// Allow: 0 args (infer rig, default to --all)
|
||||
// 1 arg (rig specified, default to --all)
|
||||
// 2+ args (rig + specific crew names)
|
||||
return nil
|
||||
},
|
||||
RunE: runCrewStart,
|
||||
@@ -275,8 +288,8 @@ var crewStopCmd = &cobra.Command{
|
||||
Short: "Stop crew workspace session(s)",
|
||||
Long: `Stop one or more running crew workspace sessions.
|
||||
|
||||
Kills the tmux session(s) for the specified crew member(s). Use --all to
|
||||
stop all running crew sessions across all rigs.
|
||||
If a rig name is given alone, stops all crew in that rig. Otherwise stops
|
||||
the specified crew member(s).
|
||||
|
||||
The name can include the rig in slash format (e.g., beads/emma).
|
||||
If not specified, the rig is inferred from the current directory.
|
||||
@@ -285,11 +298,11 @@ Output is captured before stopping for debugging purposes (use --force
|
||||
to skip capture for faster shutdown).
|
||||
|
||||
Examples:
|
||||
gt crew stop dave # Stop dave's session
|
||||
gt crew stop beads/emma beads/grip # Stop multiple from specific rig
|
||||
gt crew stop beads # Stop all crew in beads rig
|
||||
gt crew stop # Stop all crew (rig inferred from cwd)
|
||||
gt crew stop beads/emma # Stop specific crew member
|
||||
gt crew stop dave # Stop dave in current rig
|
||||
gt crew stop --all # Stop all running crew sessions
|
||||
gt crew stop --all --rig beads # Stop all crew in beads rig
|
||||
gt crew stop --all --dry-run # Preview what would be stopped
|
||||
gt crew stop dave --force # Stop without capturing output`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if crewAll {
|
||||
@@ -298,9 +311,9 @@ Examples:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("requires at least 1 argument (or --all)")
|
||||
}
|
||||
// Allow: 0 args (infer rig, default to --all)
|
||||
// 1 arg (rig name → all in that rig, or crew name → specific crew)
|
||||
// 1+ args (specific crew names)
|
||||
return nil
|
||||
},
|
||||
RunE: runCrewStop,
|
||||
@@ -312,15 +325,18 @@ func init() {
|
||||
crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/<name>)")
|
||||
|
||||
crewListCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name")
|
||||
crewListCmd.Flags().BoolVar(&crewListAll, "all", false, "List crew workspaces in all rigs")
|
||||
crewListCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON")
|
||||
|
||||
crewAtCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||
crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path")
|
||||
crewAtCmd.Flags().BoolVarP(&crewDetached, "detached", "d", false, "Start session without attaching")
|
||||
crewAtCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use (overrides default)")
|
||||
crewAtCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)")
|
||||
|
||||
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
||||
crewRemoveCmd.Flags().BoolVar(&crewPurge, "purge", false, "Obliterate: delete agent bead, unassign work, clear mail")
|
||||
|
||||
crewRefreshCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||
crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message")
|
||||
@@ -339,6 +355,7 @@ func init() {
|
||||
|
||||
crewStartCmd.Flags().BoolVar(&crewAll, "all", false, "Start all crew members in the rig")
|
||||
crewStartCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use")
|
||||
crewStartCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)")
|
||||
|
||||
crewStopCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use (filter when using --all)")
|
||||
crewStopCmd.Flags().BoolVar(&crewAll, "all", false, "Stop all running crew sessions")
|
||||
|
||||
@@ -56,9 +56,7 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
|
||||
crewGit := git.NewGit(r.Path)
|
||||
crewMgr := crew.NewManager(r, crewGit)
|
||||
|
||||
// Beads for agent bead creation (use mayor/rig where beads.db lives)
|
||||
// The rig root .beads/ only has config.yaml, no database.
|
||||
bd := beads.New(filepath.Join(r.Path, "mayor", "rig"))
|
||||
bd := beads.New(beads.ResolveBeadsDir(r.Path))
|
||||
|
||||
// Track results
|
||||
var created []string
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/crew"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -29,7 +32,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
// Try to detect from current directory
|
||||
detected, err := detectCrewFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at <name>", err)
|
||||
// Try to show available crew members if we can detect the rig
|
||||
hint := "\n\nUsage: gt crew at <name>"
|
||||
if crewRig != "" {
|
||||
if mgr, _, mgrErr := getCrewManager(crewRig); mgrErr == nil {
|
||||
if members, listErr := mgr.List(); listErr == nil && len(members) > 0 {
|
||||
hint = fmt.Sprintf("\n\nAvailable crew in %s:", crewRig)
|
||||
for _, m := range members {
|
||||
hint += fmt.Sprintf("\n %s", m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("could not detect crew workspace from current directory: %w%s", err, hint)
|
||||
}
|
||||
name = detected.crewName
|
||||
if crewRig == "" {
|
||||
@@ -61,7 +76,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve account for Claude config
|
||||
// Resolve account for runtime config
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
@@ -75,6 +90,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Using account: %s\n", accountHandle)
|
||||
}
|
||||
|
||||
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||
_ = runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig)
|
||||
|
||||
// Check if session exists
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
@@ -83,15 +101,15 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
|
||||
// Before creating a new session, check if there's already a Claude session
|
||||
// Before creating a new session, check if there's already a runtime session
|
||||
// running in this crew's directory (might have been started manually or via
|
||||
// a different mechanism)
|
||||
if !hasSession {
|
||||
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, true)
|
||||
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, runtimeConfig.Tmux.ProcessNames)
|
||||
if err == nil && len(existingSessions) > 0 {
|
||||
// Found an existing session with Claude running in this directory
|
||||
// Found an existing session with runtime running in this directory
|
||||
existingSession := existingSessions[0]
|
||||
fmt.Printf("%s Found existing Claude session '%s' in crew directory\n",
|
||||
fmt.Printf("%s Found existing runtime session '%s' in crew directory\n",
|
||||
style.Warning.Render("⚠"),
|
||||
existingSession)
|
||||
fmt.Printf(" Attaching to existing session instead of creating a new one\n")
|
||||
@@ -121,13 +139,18 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Set environment (non-fatal: session works without these)
|
||||
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||
|
||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
||||
if claudeConfigDir != "" {
|
||||
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
|
||||
// Use centralized AgentEnv for consistency across all role startup paths
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "crew",
|
||||
Rig: r.Name,
|
||||
AgentName: name,
|
||||
TownRoot: townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(r.Path),
|
||||
RuntimeConfigDir: claudeConfigDir,
|
||||
BeadsNoDaemon: true,
|
||||
})
|
||||
for k, v := range envVars {
|
||||
_ = t.SetEnvironment(sessionID, k, v)
|
||||
}
|
||||
|
||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
||||
@@ -146,24 +169,44 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("getting pane ID: %w", err)
|
||||
}
|
||||
|
||||
// Use respawn-pane to replace shell with Claude directly
|
||||
// This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell)
|
||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
||||
// Build startup beacon for predecessor discovery via /resume
|
||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
||||
// The SessionStart hook handles context injection (gt prime --hook)
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "start",
|
||||
})
|
||||
|
||||
// Use respawn-pane to replace shell with runtime directly
|
||||
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
|
||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime")
|
||||
if err := t.RespawnPane(paneID, claudeCmd); err != nil {
|
||||
return fmt.Errorf("starting claude: %w", err)
|
||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
// Prepend config dir env if available
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||
}
|
||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||
return fmt.Errorf("starting runtime: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created session for %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
} else {
|
||||
// Session exists - check if Claude is still running
|
||||
// Session exists - check if runtime is still running
|
||||
// Uses both pane command check and UI marker detection to avoid
|
||||
// restarting when user is in a subshell spawned from Claude
|
||||
if !t.IsClaudeRunning(sessionID) {
|
||||
// Claude has exited, restart it using respawn-pane
|
||||
fmt.Printf("Claude exited, restarting...\n")
|
||||
// restarting when user is in a subshell spawned from the runtime
|
||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving agent: %w", err)
|
||||
}
|
||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||
// Runtime has exited, restart it using respawn-pane
|
||||
fmt.Printf("Runtime exited, restarting...\n")
|
||||
|
||||
// Get pane ID for respawn
|
||||
paneID, err := t.GetPaneID(sessionID)
|
||||
@@ -171,12 +214,27 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("getting pane ID: %w", err)
|
||||
}
|
||||
|
||||
// Use respawn-pane to replace shell with Claude directly
|
||||
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
||||
// Build startup beacon for predecessor discovery via /resume
|
||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "restart",
|
||||
})
|
||||
|
||||
// Use respawn-pane to replace shell with runtime directly
|
||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime")
|
||||
if err := t.RespawnPane(paneID, claudeCmd); err != nil {
|
||||
return fmt.Errorf("restarting claude: %w", err)
|
||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
// Prepend config dir env if available
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||
}
|
||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||
return fmt.Errorf("restarting runtime: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,10 +242,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
// Check if we're already in the target session
|
||||
if isInTmuxSession(sessionID) {
|
||||
// We're in the session at a shell prompt - just start the agent directly
|
||||
// Pass "gt prime" as initial prompt so it loads context immediately
|
||||
agentCfg := config.ResolveAgentConfig(townRoot, r.Path)
|
||||
// Build startup beacon for predecessor discovery via /resume
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "start",
|
||||
})
|
||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving agent: %w", err)
|
||||
}
|
||||
fmt.Printf("Starting %s in current session...\n", agentCfg.Command)
|
||||
return execAgent(agentCfg, "gt prime")
|
||||
return execAgent(agentCfg, beacon)
|
||||
}
|
||||
|
||||
// If inside tmux (but different session), don't switch - just inform user
|
||||
|
||||
@@ -122,7 +122,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
|
||||
// Look for pattern: <rig>/crew/<name>/...
|
||||
// Minimum: rig, crew, name = 3 parts
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("not in a crew workspace (path too short)")
|
||||
return nil, fmt.Errorf("not inside a crew workspace - specify the crew name or cd into a crew directory (e.g., gastown/crew/max)")
|
||||
}
|
||||
|
||||
rigName := parts[0]
|
||||
@@ -137,7 +137,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isShellCommand checks if the command is a shell (meaning Claude has exited).
|
||||
// isShellCommand checks if the command is a shell (meaning the runtime has exited).
|
||||
func isShellCommand(cmd string) bool {
|
||||
shells := constants.SupportedShells
|
||||
for _, shell := range shells {
|
||||
@@ -170,6 +170,29 @@ func execAgent(cfg *config.RuntimeConfig, prompt string) error {
|
||||
return syscall.Exec(agentPath, args, os.Environ())
|
||||
}
|
||||
|
||||
// execRuntime execs the runtime CLI, replacing the current process.
|
||||
// Used when we're already in the target session and just need to start the runtime.
|
||||
// If prompt is provided, it's passed according to the runtime's prompt mode.
|
||||
func execRuntime(prompt, rigPath, configDir string) error {
|
||||
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
||||
args := runtimeConfig.BuildArgsWithPrompt(prompt)
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("runtime command not configured")
|
||||
}
|
||||
|
||||
binPath, err := exec.LookPath(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtime command not found: %w", err)
|
||||
}
|
||||
|
||||
env := os.Environ()
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && configDir != "" {
|
||||
env = append(env, fmt.Sprintf("%s=%s", runtimeConfig.Session.ConfigDirEnv, configDir))
|
||||
}
|
||||
|
||||
return syscall.Exec(binPath, args, env)
|
||||
}
|
||||
|
||||
// isInTmuxSession checks if we're currently inside the target tmux session.
|
||||
func isInTmuxSession(targetSession string) bool {
|
||||
// TMUX env var format: /tmp/tmux-501/default,12345,0
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,7 +16,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/crew"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/townlog"
|
||||
@@ -24,6 +26,9 @@ import (
|
||||
func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
var lastErr error
|
||||
|
||||
// --purge implies --force
|
||||
forceRemove := crewForce || crewPurge
|
||||
|
||||
for _, arg := range args {
|
||||
name := arg
|
||||
rigOverride := crewRig
|
||||
@@ -44,7 +49,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Check for running session (unless forced)
|
||||
if !crewForce {
|
||||
if !forceRemove {
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
hasSession, _ := t.HasSession(sessionID)
|
||||
@@ -67,44 +72,115 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Killed session %s\n", sessionID)
|
||||
}
|
||||
|
||||
// Remove the crew workspace
|
||||
if err := crewMgr.Remove(name, crewForce); err != nil {
|
||||
if err == crew.ErrCrewNotFound {
|
||||
fmt.Printf("Error removing %s: crew workspace not found\n", arg)
|
||||
} else if err == crew.ErrHasChanges {
|
||||
fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg)
|
||||
} else {
|
||||
fmt.Printf("Error removing %s: %v\n", arg, err)
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
// Determine workspace path
|
||||
crewPath := filepath.Join(r.Path, "crew", name)
|
||||
|
||||
// Check if this is a worktree (has .git file) vs regular clone (has .git directory)
|
||||
isWorktree := false
|
||||
gitPath := filepath.Join(crewPath, ".git")
|
||||
if info, err := os.Stat(gitPath); err == nil && !info.IsDir() {
|
||||
isWorktree = true
|
||||
}
|
||||
|
||||
fmt.Printf("%s Removed crew workspace: %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
// Remove the workspace
|
||||
if isWorktree {
|
||||
// For worktrees, use git worktree remove
|
||||
mayorRigPath := constants.RigMayorPath(r.Path)
|
||||
removeArgs := []string{"worktree", "remove", crewPath}
|
||||
if forceRemove {
|
||||
removeArgs = []string{"worktree", "remove", "--force", crewPath}
|
||||
}
|
||||
removeCmd := exec.Command("git", removeArgs...)
|
||||
removeCmd.Dir = mayorRigPath
|
||||
if output, err := removeCmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf("Error removing worktree %s: %v\n%s", arg, err, string(output))
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s Removed crew worktree: %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
} else {
|
||||
// For regular clones, use the crew manager
|
||||
if err := crewMgr.Remove(name, forceRemove); err != nil {
|
||||
if err == crew.ErrCrewNotFound {
|
||||
fmt.Printf("Error removing %s: crew workspace not found\n", arg)
|
||||
} else if err == crew.ErrHasChanges {
|
||||
fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg)
|
||||
} else {
|
||||
fmt.Printf("Error removing %s: %v\n", arg, err)
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s Removed crew workspace: %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
}
|
||||
|
||||
// Close the agent bead if it exists
|
||||
// Use the rig's configured prefix (e.g., "gt" for gastown, "bd" for beads)
|
||||
// Handle agent bead
|
||||
townRoot, _ := workspace.Find(r.Path)
|
||||
if townRoot == "" {
|
||||
townRoot = r.Path
|
||||
}
|
||||
prefix := beads.GetPrefixForRig(townRoot, r.Name)
|
||||
agentBeadID := beads.CrewBeadIDWithPrefix(prefix, r.Name, name)
|
||||
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
|
||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
closeCmd.Dir = r.Path // Run from rig directory for proper beads resolution
|
||||
if output, err := closeCmd.CombinedOutput(); err != nil {
|
||||
// Non-fatal: bead might not exist or already be closed
|
||||
if !strings.Contains(string(output), "no issue found") &&
|
||||
!strings.Contains(string(output), "already closed") {
|
||||
style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err)
|
||||
|
||||
if crewPurge {
|
||||
// --purge: DELETE the agent bead entirely (obliterate)
|
||||
deleteArgs := []string{"delete", agentBeadID, "--force"}
|
||||
deleteCmd := exec.Command("bd", deleteArgs...)
|
||||
deleteCmd.Dir = r.Path
|
||||
if output, err := deleteCmd.CombinedOutput(); err != nil {
|
||||
// Non-fatal: bead might not exist
|
||||
if !strings.Contains(string(output), "no issue found") &&
|
||||
!strings.Contains(string(output), "not found") {
|
||||
style.PrintWarning("could not delete agent bead %s: %v", agentBeadID, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Deleted agent bead: %s\n", agentBeadID)
|
||||
}
|
||||
|
||||
// Unassign any beads assigned to this crew member
|
||||
agentAddr := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
unassignArgs := []string{"list", "--assignee=" + agentAddr, "--format=id"}
|
||||
unassignCmd := exec.Command("bd", unassignArgs...)
|
||||
unassignCmd.Dir = r.Path
|
||||
if output, err := unassignCmd.CombinedOutput(); err == nil {
|
||||
ids := strings.Fields(strings.TrimSpace(string(output)))
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
updateCmd := exec.Command("bd", "update", id, "--unassign")
|
||||
updateCmd.Dir = r.Path
|
||||
if _, err := updateCmd.CombinedOutput(); err == nil {
|
||||
fmt.Printf("Unassigned: %s\n", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear mail directory if it exists
|
||||
mailDir := filepath.Join(crewPath, "mail")
|
||||
if _, err := os.Stat(mailDir); err == nil {
|
||||
// Mail dir was removed with the workspace, so nothing to do
|
||||
// But if we want to be extra thorough, we could look in town beads
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Closed agent bead: %s\n", agentBeadID)
|
||||
// Default: CLOSE the agent bead (preserves CV history)
|
||||
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
|
||||
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
closeCmd.Dir = r.Path
|
||||
if output, err := closeCmd.CombinedOutput(); err != nil {
|
||||
// Non-fatal: bead might not exist or already be closed
|
||||
if !strings.Contains(string(output), "no issue found") &&
|
||||
!strings.Contains(string(output), "already closed") {
|
||||
style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Closed agent bead: %s\n", agentBeadID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +202,7 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the crew worker
|
||||
// Get the crew worker (must exist for refresh)
|
||||
worker, err := crewMgr.Get(name)
|
||||
if err != nil {
|
||||
if err == crew.ErrCrewNotFound {
|
||||
@@ -135,12 +211,6 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("getting crew worker: %w", err)
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
|
||||
// Check if session exists
|
||||
hasSession, _ := t.HasSession(sessionID)
|
||||
|
||||
// Create handoff message
|
||||
handoffMsg := crewMessage
|
||||
if handoffMsg == "" {
|
||||
@@ -168,47 +238,15 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Printf("Sent handoff mail to %s/%s\n", r.Name, name)
|
||||
|
||||
// Kill existing session if running
|
||||
if hasSession {
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
return fmt.Errorf("killing old session: %w", err)
|
||||
}
|
||||
fmt.Printf("Killed old session %s\n", sessionID)
|
||||
}
|
||||
|
||||
// Start new session
|
||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
// Wait for shell to be ready
|
||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
||||
return fmt.Errorf("waiting for shell: %w", err)
|
||||
}
|
||||
|
||||
// Build the startup beacon for predecessor discovery via /resume
|
||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "refresh",
|
||||
// Use manager's Start() with refresh options
|
||||
err = crewMgr.Start(name, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "refresh", // Startup nudge topic
|
||||
Interactive: true, // No --dangerously-skip-permissions
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
|
||||
// Start claude with environment exports and beacon as initial prompt
|
||||
// Refresh uses regular permissions (no --dangerously-skip-permissions)
|
||||
// SessionStart hook handles context loading (gt prime --hook)
|
||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, beacon)
|
||||
// Remove --dangerously-skip-permissions for refresh (interactive mode)
|
||||
claudeCmd = strings.Replace(claudeCmd, " --dangerously-skip-permissions", "", 1)
|
||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
||||
return fmt.Errorf("starting claude: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start (optional, for status feedback)
|
||||
shells := constants.SupportedShells
|
||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting crew session: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Refreshed crew workspace: %s/%s\n",
|
||||
@@ -219,18 +257,27 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// runCrewStart starts crew workers in a rig.
|
||||
// args[0] is the rig name (optional if inferrable from cwd)
|
||||
// args[1:] are crew member names (optional, or use --all flag)
|
||||
// If first arg is a valid rig name, it's used as the rig; otherwise rig is inferred from cwd.
|
||||
// Remaining args (or all args if rig is inferred) are crew member names.
|
||||
// Defaults to all crew members if no names specified.
|
||||
func runCrewStart(cmd *cobra.Command, args []string) error {
|
||||
var rigName string
|
||||
var crewNames []string
|
||||
|
||||
if len(args) == 0 {
|
||||
// No args - infer rig from cwd (only valid with --all)
|
||||
// No args - infer rig from cwd
|
||||
rigName = "" // getCrewManager will infer from cwd
|
||||
} else {
|
||||
rigName = args[0]
|
||||
crewNames = args[1:]
|
||||
// Check if first arg is a valid rig name
|
||||
if _, _, err := getRig(args[0]); err == nil {
|
||||
// First arg is a rig name
|
||||
rigName = args[0]
|
||||
crewNames = args[1:]
|
||||
} else {
|
||||
// First arg is not a rig - infer rig from cwd and treat all args as crew names
|
||||
rigName = "" // getCrewManager will infer from cwd
|
||||
crewNames = args
|
||||
}
|
||||
}
|
||||
|
||||
// Get the rig manager and rig (infers from cwd if rigName is empty)
|
||||
@@ -241,8 +288,8 @@ func runCrewStart(cmd *cobra.Command, args []string) error {
|
||||
// Update rigName in case it was inferred
|
||||
rigName = r.Name
|
||||
|
||||
// If --all flag, get all crew members
|
||||
if crewAll {
|
||||
// If --all flag OR no crew names specified, get all crew members
|
||||
if crewAll || len(crewNames) == 0 {
|
||||
workers, err := crewMgr.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing crew: %w", err)
|
||||
@@ -256,27 +303,73 @@ func runCrewStart(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Start each crew member
|
||||
// Resolve account config once for all crew members
|
||||
townRoot, _ := workspace.Find(r.Path)
|
||||
if townRoot == "" {
|
||||
townRoot = filepath.Dir(r.Path)
|
||||
}
|
||||
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||
claudeConfigDir, _, _ := config.ResolveAccountConfigDir(accountsPath, crewAccount)
|
||||
|
||||
// Build start options (shared across all crew members)
|
||||
opts := crew.StartOptions{
|
||||
Account: crewAccount,
|
||||
ClaudeConfigDir: claudeConfigDir,
|
||||
AgentOverride: crewAgentOverride,
|
||||
}
|
||||
|
||||
// Start each crew member in parallel
|
||||
type result struct {
|
||||
name string
|
||||
err error
|
||||
skipped bool // true if session was already running
|
||||
}
|
||||
results := make(chan result, len(crewNames))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
fmt.Printf("Starting %d crew member(s) in %s...\n", len(crewNames), rigName)
|
||||
|
||||
for _, name := range crewNames {
|
||||
wg.Add(1)
|
||||
go func(crewName string) {
|
||||
defer wg.Done()
|
||||
err := crewMgr.Start(crewName, opts)
|
||||
skipped := errors.Is(err, crew.ErrSessionRunning)
|
||||
if skipped {
|
||||
err = nil // Not an error, just already running
|
||||
}
|
||||
results <- result{name: crewName, err: err, skipped: skipped}
|
||||
}(name)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
var lastErr error
|
||||
startedCount := 0
|
||||
for _, name := range crewNames {
|
||||
// Set the start.go flags before calling runStartCrew
|
||||
startCrewRig = rigName
|
||||
startCrewAccount = crewAccount
|
||||
|
||||
// Use rig/name format for runStartCrew
|
||||
fullName := rigName + "/" + name
|
||||
if err := runStartCrew(cmd, []string{fullName}); err != nil {
|
||||
fmt.Printf("Error starting %s/%s: %v\n", rigName, name, err)
|
||||
lastErr = err
|
||||
skippedCount := 0
|
||||
for res := range results {
|
||||
if res.err != nil {
|
||||
fmt.Printf(" %s %s/%s: %v\n", style.ErrorPrefix, rigName, res.name, res.err)
|
||||
lastErr = res.err
|
||||
} else if res.skipped {
|
||||
fmt.Printf(" %s %s/%s: already running\n", style.Dim.Render("○"), rigName, res.name)
|
||||
skippedCount++
|
||||
} else {
|
||||
fmt.Printf(" %s %s/%s: started\n", style.SuccessPrefix, rigName, res.name)
|
||||
startedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if startedCount > 0 {
|
||||
fmt.Printf("\n%s Started %d crew member(s) in %s\n",
|
||||
style.Bold.Render("✓"), startedCount, r.Name)
|
||||
// Summary
|
||||
fmt.Println()
|
||||
if startedCount > 0 || skippedCount > 0 {
|
||||
fmt.Printf("%s Started %d, skipped %d (already running) in %s\n",
|
||||
style.Bold.Render("✓"), startedCount, skippedCount, r.Name)
|
||||
}
|
||||
|
||||
return lastErr
|
||||
@@ -309,81 +402,19 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the crew worker, create if not exists (idempotent)
|
||||
worker, err := crewMgr.Get(name)
|
||||
if err == crew.ErrCrewNotFound {
|
||||
fmt.Printf("Creating crew workspace %s in %s...\n", name, r.Name)
|
||||
worker, err = crewMgr.Add(name, false) // No feature branch for crew
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Created crew workspace: %s/%s\n", r.Name, name)
|
||||
} else if err != nil {
|
||||
fmt.Printf("Error getting %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
|
||||
// Kill existing session if running
|
||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
fmt.Printf("Error killing session for %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Killed session %s\n", sessionID)
|
||||
}
|
||||
|
||||
// Start new session
|
||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
||||
fmt.Printf("Error creating session for %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
||||
theme := getThemeForRig(r.Name)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
||||
|
||||
// Wait for shell to be ready
|
||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
||||
fmt.Printf("Error waiting for shell for %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the startup beacon for predecessor discovery via /resume
|
||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "restart",
|
||||
// Use manager's Start() with restart options
|
||||
// Start() will create workspace if needed (idempotent)
|
||||
err = crewMgr.Start(name, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
|
||||
// Start claude with environment exports and beacon as initial prompt
|
||||
// SessionStart hook handles context loading (gt prime --hook)
|
||||
// The startup protocol tells agent to check mail/hook, no explicit prompt needed
|
||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, beacon)
|
||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
||||
fmt.Printf("Error starting claude for %s: %v\n", arg, err)
|
||||
if err != nil {
|
||||
fmt.Printf("Error restarting %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait for Claude to start (optional, for status feedback)
|
||||
shells := constants.SupportedShells
|
||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
||||
style.PrintWarning("Timeout waiting for Claude to start for %s: %v", arg, err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Restarted crew workspace: %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name)))
|
||||
@@ -443,7 +474,7 @@ func runCrewRestartAll() error {
|
||||
savedRig := crewRig
|
||||
crewRig = agent.Rig
|
||||
|
||||
crewMgr, r, err := getCrewManager(crewRig)
|
||||
crewMgr, _, err := getCrewManager(crewRig)
|
||||
if err != nil {
|
||||
failed++
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||
@@ -452,20 +483,16 @@ func runCrewRestartAll() error {
|
||||
continue
|
||||
}
|
||||
|
||||
worker, err := crewMgr.Get(agent.AgentName)
|
||||
// Use manager's Start() with restart options
|
||||
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
|
||||
KillExisting: true, // Kill old session if running
|
||||
Topic: "restart", // Startup nudge topic
|
||||
AgentOverride: crewAgentOverride,
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
||||
crewRig = savedRig
|
||||
continue
|
||||
}
|
||||
|
||||
// Restart the session
|
||||
if err := restartCrewSession(r.Name, agent.AgentName, worker.ClonePath); err != nil {
|
||||
failed++
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
||||
} else {
|
||||
succeeded++
|
||||
fmt.Printf(" %s %s\n", style.SuccessPrefix, agentName)
|
||||
@@ -491,65 +518,31 @@ func runCrewRestartAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartCrewSession handles the core restart logic for a single crew session.
|
||||
func restartCrewSession(rigName, crewName, clonePath string) error {
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(rigName, crewName)
|
||||
|
||||
// Kill existing session if running
|
||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
return fmt.Errorf("killing old session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new session
|
||||
if err := t.NewSession(sessionID, clonePath); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
// Apply rig-based theming
|
||||
theme := getThemeForRig(rigName)
|
||||
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew")
|
||||
|
||||
// Wait for shell to be ready
|
||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
||||
return fmt.Errorf("waiting for shell: %w", err)
|
||||
}
|
||||
|
||||
// Build the startup beacon for predecessor discovery via /resume
|
||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
||||
address := fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: address,
|
||||
Sender: "human",
|
||||
Topic: "restart",
|
||||
})
|
||||
|
||||
// Start claude with environment exports and beacon as initial prompt
|
||||
// SessionStart hook handles context loading (gt prime --hook)
|
||||
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, "", beacon)
|
||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
||||
return fmt.Errorf("starting claude: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start (optional, for status feedback)
|
||||
shells := constants.SupportedShells
|
||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal warning
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCrewStop stops one or more crew workers.
|
||||
// Supports: "name", "rig/name" formats, or --all to stop all.
|
||||
// Supports: "name", "rig/name" formats, "rig" (to stop all in rig), or --all.
|
||||
func runCrewStop(cmd *cobra.Command, args []string) error {
|
||||
// Handle --all flag
|
||||
if crewAll {
|
||||
return runCrewStopAll()
|
||||
}
|
||||
|
||||
// Handle 0 args: default to all in inferred rig
|
||||
if len(args) == 0 {
|
||||
return runCrewStopAll()
|
||||
}
|
||||
|
||||
// Handle 1 arg without "/": check if it's a rig name
|
||||
// If so, stop all crew in that rig
|
||||
if len(args) == 1 && !strings.Contains(args[0], "/") {
|
||||
// Try to interpret as rig name
|
||||
if _, _, err := getRig(args[0]); err == nil {
|
||||
// It's a valid rig name - stop all crew in that rig
|
||||
crewRig = args[0]
|
||||
return runCrewStopAll()
|
||||
}
|
||||
// Not a rig name - fall through to treat as crew name
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
t := tmux.NewTmux()
|
||||
|
||||
@@ -575,12 +568,23 @@ func runCrewStop(cmd *cobra.Command, args []string) error {
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
|
||||
// Check if session exists
|
||||
hasSession, _ := t.HasSession(sessionID)
|
||||
hasSession, err := t.HasSession(sessionID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error checking session %s: %v\n", sessionID, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if !hasSession {
|
||||
fmt.Printf("No session found for %s/%s\n", r.Name, name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Dry run - just show what would be stopped
|
||||
if crewDryRun {
|
||||
fmt.Printf("Would stop %s/%s (session: %s)\n", r.Name, name, sessionID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Capture output before stopping (best effort)
|
||||
var output string
|
||||
if !crewForce {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/crew"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
@@ -22,43 +24,63 @@ type CrewListItem struct {
|
||||
}
|
||||
|
||||
func runCrewList(cmd *cobra.Command, args []string) error {
|
||||
crewMgr, r, err := getCrewManager(crewRig)
|
||||
if err != nil {
|
||||
return err
|
||||
if crewListAll && crewRig != "" {
|
||||
return fmt.Errorf("cannot use --all with --rig")
|
||||
}
|
||||
|
||||
workers, err := crewMgr.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing crew workers: %w", err)
|
||||
}
|
||||
|
||||
if len(workers) == 0 {
|
||||
fmt.Println("No crew workspaces found.")
|
||||
return nil
|
||||
var rigs []*rig.Rig
|
||||
if crewListAll {
|
||||
allRigs, _, err := getAllRigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rigs = allRigs
|
||||
} else {
|
||||
_, r, err := getCrewManager(crewRig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rigs = []*rig.Rig{r}
|
||||
}
|
||||
|
||||
// Check session and git status for each worker
|
||||
t := tmux.NewTmux()
|
||||
var items []CrewListItem
|
||||
|
||||
for _, w := range workers {
|
||||
sessionID := crewSessionName(r.Name, w.Name)
|
||||
hasSession, _ := t.HasSession(sessionID)
|
||||
for _, r := range rigs {
|
||||
crewGit := git.NewGit(r.Path)
|
||||
crewMgr := crew.NewManager(r, crewGit)
|
||||
|
||||
crewGit := git.NewGit(w.ClonePath)
|
||||
gitClean := true
|
||||
if status, err := crewGit.Status(); err == nil {
|
||||
gitClean = status.Clean
|
||||
workers, err := crewMgr.List()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to list crew workers in %s: %v\n", r.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, CrewListItem{
|
||||
Name: w.Name,
|
||||
Rig: r.Name,
|
||||
Branch: w.Branch,
|
||||
Path: w.ClonePath,
|
||||
HasSession: hasSession,
|
||||
GitClean: gitClean,
|
||||
})
|
||||
for _, w := range workers {
|
||||
sessionID := crewSessionName(r.Name, w.Name)
|
||||
hasSession, _ := t.HasSession(sessionID)
|
||||
|
||||
workerGit := git.NewGit(w.ClonePath)
|
||||
gitClean := true
|
||||
if status, err := workerGit.Status(); err == nil {
|
||||
gitClean = status.Clean
|
||||
}
|
||||
|
||||
items = append(items, CrewListItem{
|
||||
Name: w.Name,
|
||||
Rig: r.Name,
|
||||
Branch: w.Branch,
|
||||
Path: w.ClonePath,
|
||||
HasSession: hasSession,
|
||||
GitClean: gitClean,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Println("No crew workspaces found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if crewJSON {
|
||||
|
||||
127
internal/cmd/crew_list_test.go
Normal file
127
internal/cmd/crew_list_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
func setupTestTownForCrewList(t *testing.T, rigs map[string][]string) string {
|
||||
t.Helper()
|
||||
|
||||
townRoot := t.TempDir()
|
||||
mayorDir := filepath.Join(townRoot, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor: %v", err)
|
||||
}
|
||||
|
||||
townConfig := &config.TownConfig{
|
||||
Type: "town",
|
||||
Version: config.CurrentTownVersion,
|
||||
Name: "test-town",
|
||||
PublicName: "Test Town",
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
if err := config.SaveTownConfig(filepath.Join(mayorDir, "town.json"), townConfig); err != nil {
|
||||
t.Fatalf("save town.json: %v", err)
|
||||
}
|
||||
|
||||
rigsConfig := &config.RigsConfig{
|
||||
Version: config.CurrentRigsVersion,
|
||||
Rigs: make(map[string]config.RigEntry),
|
||||
}
|
||||
|
||||
for rigName, crewNames := range rigs {
|
||||
rigsConfig.Rigs[rigName] = config.RigEntry{
|
||||
GitURL: "https://example.com/" + rigName + ".git",
|
||||
AddedAt: time.Now(),
|
||||
}
|
||||
|
||||
rigPath := filepath.Join(townRoot, rigName)
|
||||
crewDir := filepath.Join(rigPath, "crew")
|
||||
if err := os.MkdirAll(crewDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew dir: %v", err)
|
||||
}
|
||||
for _, crewName := range crewNames {
|
||||
if err := os.MkdirAll(filepath.Join(crewDir, crewName), 0755); err != nil {
|
||||
t.Fatalf("mkdir crew worker: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.SaveRigsConfig(filepath.Join(mayorDir, "rigs.json"), rigsConfig); err != nil {
|
||||
t.Fatalf("save rigs.json: %v", err)
|
||||
}
|
||||
|
||||
return townRoot
|
||||
}
|
||||
|
||||
func TestRunCrewList_AllWithRigErrors(t *testing.T) {
|
||||
townRoot := setupTestTownForCrewList(t, map[string][]string{"rig-a": {"alice"}})
|
||||
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
crewListAll = true
|
||||
crewRig = "rig-a"
|
||||
defer func() {
|
||||
crewListAll = false
|
||||
crewRig = ""
|
||||
}()
|
||||
|
||||
err := runCrewList(&cobra.Command{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --all with --rig, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCrewList_AllAggregatesJSON(t *testing.T) {
|
||||
townRoot := setupTestTownForCrewList(t, map[string][]string{
|
||||
"rig-a": {"alice"},
|
||||
"rig-b": {"bob"},
|
||||
})
|
||||
|
||||
originalWd, _ := os.Getwd()
|
||||
defer os.Chdir(originalWd)
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
crewListAll = true
|
||||
crewJSON = true
|
||||
crewRig = ""
|
||||
defer func() {
|
||||
crewListAll = false
|
||||
crewJSON = false
|
||||
}()
|
||||
|
||||
output := captureStdout(t, func() {
|
||||
if err := runCrewList(&cobra.Command{}, nil); err != nil {
|
||||
t.Fatalf("runCrewList failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var items []CrewListItem
|
||||
if err := json.Unmarshal([]byte(output), &items); err != nil {
|
||||
t.Fatalf("unmarshal output: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 crew workers, got %d", len(items))
|
||||
}
|
||||
|
||||
rigs := map[string]bool{}
|
||||
for _, item := range items {
|
||||
rigs[item.Rig] = true
|
||||
}
|
||||
if !rigs["rig-a"] || !rigs["rig-b"] {
|
||||
t.Fatalf("expected crew from rig-a and rig-b, got: %#v", rigs)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
@@ -88,6 +90,8 @@ Stops the current session (if running) and starts a fresh one.`,
|
||||
RunE: runDeaconRestart,
|
||||
}
|
||||
|
||||
var deaconAgentOverride string
|
||||
|
||||
var deaconHeartbeatCmd = &cobra.Command{
|
||||
Use: "heartbeat [action]",
|
||||
Short: "Update the Deacon heartbeat",
|
||||
@@ -109,7 +113,7 @@ var deaconTriggerPendingCmd = &cobra.Command{
|
||||
|
||||
⚠️ BOOTSTRAP MODE ONLY - Uses regex detection (ZFC violation acceptable).
|
||||
|
||||
This command uses WaitForClaudeReady (regex) to detect when Claude is ready.
|
||||
This command uses WaitForRuntimeReady (regex) to detect when the runtime is ready.
|
||||
This is appropriate for daemon bootstrap when no AI is available.
|
||||
|
||||
In steady-state, the Deacon should use AI-based observation instead:
|
||||
@@ -186,6 +190,50 @@ This helps the Deacon understand which agents may need attention.`,
|
||||
RunE: runDeaconHealthState,
|
||||
}
|
||||
|
||||
var deaconStaleHooksCmd = &cobra.Command{
|
||||
Use: "stale-hooks",
|
||||
Short: "Find and unhook stale hooked beads",
|
||||
Long: `Find beads stuck in 'hooked' status and unhook them if the agent is gone.
|
||||
|
||||
Beads can get stuck in 'hooked' status when agents die or abandon work.
|
||||
This command finds hooked beads older than the threshold (default: 1 hour),
|
||||
checks if the assignee agent is still alive, and unhooks them if not.
|
||||
|
||||
Examples:
|
||||
gt deacon stale-hooks # Find and unhook stale beads
|
||||
gt deacon stale-hooks --dry-run # Preview what would be unhooked
|
||||
gt deacon stale-hooks --max-age=30m # Use 30 minute threshold`,
|
||||
RunE: runDeaconStaleHooks,
|
||||
}
|
||||
|
||||
var deaconPauseCmd = &cobra.Command{
|
||||
Use: "pause",
|
||||
Short: "Pause the Deacon to prevent patrol actions",
|
||||
Long: `Pause the Deacon to prevent it from performing any patrol actions.
|
||||
|
||||
When paused, the Deacon:
|
||||
- Will not create patrol molecules
|
||||
- Will not run health checks
|
||||
- Will not take any autonomous actions
|
||||
- Will display a PAUSED message on startup
|
||||
|
||||
The pause state persists across session restarts. Use 'gt deacon resume'
|
||||
to allow the Deacon to work again.
|
||||
|
||||
Examples:
|
||||
gt deacon pause # Pause with no reason
|
||||
gt deacon pause --reason="testing" # Pause with a reason`,
|
||||
RunE: runDeaconPause,
|
||||
}
|
||||
|
||||
var deaconResumeCmd = &cobra.Command{
|
||||
Use: "resume",
|
||||
Short: "Resume the Deacon to allow patrol actions",
|
||||
Long: `Resume the Deacon so it can perform patrol actions again.
|
||||
|
||||
This removes the pause file and allows the Deacon to work normally.`,
|
||||
RunE: runDeaconResume,
|
||||
}
|
||||
|
||||
var (
|
||||
triggerTimeout time.Duration
|
||||
@@ -198,6 +246,13 @@ var (
|
||||
// Force kill flags
|
||||
forceKillReason string
|
||||
forceKillSkipNotify bool
|
||||
|
||||
// Stale hooks flags
|
||||
staleHooksMaxAge time.Duration
|
||||
staleHooksDryRun bool
|
||||
|
||||
// Pause flags
|
||||
pauseReason string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -211,6 +266,9 @@ func init() {
|
||||
deaconCmd.AddCommand(deaconHealthCheckCmd)
|
||||
deaconCmd.AddCommand(deaconForceKillCmd)
|
||||
deaconCmd.AddCommand(deaconHealthStateCmd)
|
||||
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
||||
deaconCmd.AddCommand(deaconPauseCmd)
|
||||
deaconCmd.AddCommand(deaconResumeCmd)
|
||||
|
||||
// Flags for trigger-pending
|
||||
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
||||
@@ -230,6 +288,20 @@ func init() {
|
||||
deaconForceKillCmd.Flags().BoolVar(&forceKillSkipNotify, "skip-notify", false,
|
||||
"Skip sending notification mail to mayor")
|
||||
|
||||
// Flags for stale-hooks
|
||||
deaconStaleHooksCmd.Flags().DurationVar(&staleHooksMaxAge, "max-age", 1*time.Hour,
|
||||
"Maximum age before a hooked bead is considered stale")
|
||||
deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false,
|
||||
"Preview what would be unhooked without making changes")
|
||||
|
||||
// Flags for pause
|
||||
deaconPauseCmd.Flags().StringVar(&pauseReason, "reason", "",
|
||||
"Reason for pausing the Deacon")
|
||||
|
||||
deaconStartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
deaconAttachCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
deaconRestartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
|
||||
rootCmd.AddCommand(deaconCmd)
|
||||
}
|
||||
|
||||
@@ -247,7 +319,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach")
|
||||
}
|
||||
|
||||
if err := startDeaconSession(t, sessionName); err != nil {
|
||||
if err := startDeaconSession(t, sessionName, deaconAgentOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -259,7 +331,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// startDeaconSession creates and initializes the Deacon tmux session.
|
||||
func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
||||
// Find workspace root
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
@@ -274,9 +346,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
return fmt.Errorf("creating deacon directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure deacon has patrol hooks (idempotent)
|
||||
if err := ensurePatrolHooks(deaconDir); err != nil {
|
||||
style.PrintWarning("Could not create deacon hooks: %v", err)
|
||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
||||
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
||||
style.PrintWarning("Could not create deacon settings: %v", err)
|
||||
}
|
||||
|
||||
// Create session in deacon directory
|
||||
@@ -286,8 +358,15 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
}
|
||||
|
||||
// Set environment (non-fatal: session works without these)
|
||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon")
|
||||
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
|
||||
// Use centralized AgentEnv for consistency across all role startup paths
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "deacon",
|
||||
TownRoot: townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(townRoot),
|
||||
})
|
||||
for k, v := range envVars {
|
||||
_ = t.SetEnvironment(sessionName, k, v)
|
||||
}
|
||||
|
||||
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
||||
// Note: ConfigureGasTownSession includes cycle bindings
|
||||
@@ -298,7 +377,11 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
||||
// The startup hook handles context loading automatically
|
||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil {
|
||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "deacon", "", "", agentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
if err := t.SendKeys(sessionName, startupCmd); err != nil {
|
||||
return fmt.Errorf("sending command: %w", err)
|
||||
}
|
||||
|
||||
@@ -308,6 +391,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
}
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
runtimeConfig := config.LoadRuntimeConfig("")
|
||||
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
Recipient: "deacon",
|
||||
@@ -366,7 +452,7 @@ func runDeaconAttach(cmd *cobra.Command, args []string) error {
|
||||
if !running {
|
||||
// Auto-start if not running
|
||||
fmt.Println("Deacon session not running, starting...")
|
||||
if err := startDeaconSession(t, sessionName); err != nil {
|
||||
if err := startDeaconSession(t, sessionName, deaconAgentOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -381,6 +467,23 @@ func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
sessionName := getDeaconSessionName()
|
||||
|
||||
// Check pause state first (most important)
|
||||
townRoot, _ := workspace.FindFromCwdOrError()
|
||||
if townRoot != "" {
|
||||
paused, state, err := deacon.IsPaused(townRoot)
|
||||
if err == nil && paused {
|
||||
fmt.Printf("%s DEACON PAUSED\n", style.Bold.Render("⏸️"))
|
||||
if state.Reason != "" {
|
||||
fmt.Printf(" Reason: %s\n", state.Reason)
|
||||
}
|
||||
fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
|
||||
fmt.Printf(" Paused by: %s\n", state.PausedBy)
|
||||
fmt.Println()
|
||||
fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume"))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
@@ -450,6 +553,19 @@ func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Check if Deacon is paused - if so, refuse to update heartbeat
|
||||
paused, state, err := deacon.IsPaused(townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking pause state: %w", err)
|
||||
}
|
||||
if paused {
|
||||
fmt.Printf("%s Deacon is paused. Use 'gt deacon resume' to unpause.\n", style.Bold.Render("⏸️"))
|
||||
if state.Reason != "" {
|
||||
fmt.Printf(" Reason: %s\n", state.Reason)
|
||||
}
|
||||
return errors.New("Deacon is paused")
|
||||
}
|
||||
|
||||
action := ""
|
||||
if len(args) > 0 {
|
||||
action = strings.Join(args, " ")
|
||||
@@ -526,64 +642,6 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensurePatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
||||
// This is idempotent - if hooks already exist, it does nothing.
|
||||
func ensurePatrolHooks(workspacePath string) error {
|
||||
settingsPath := filepath.Join(workspacePath, ".claude", "settings.json")
|
||||
|
||||
// Check if already exists
|
||||
if _, err := os.Stat(settingsPath); err == nil {
|
||||
return nil // Already exists
|
||||
}
|
||||
|
||||
claudeDir := filepath.Join(workspacePath, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .claude dir: %w", err)
|
||||
}
|
||||
|
||||
// Standard patrol hooks
|
||||
// Note: SessionStart nudges Deacon for GUPP backstop (agent wake notification)
|
||||
hooksJSON := `{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime && gt mail check --inject && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
||||
}
|
||||
|
||||
// runDeaconHealthCheck implements the health-check command.
|
||||
// It sends a HEALTH_CHECK nudge to an agent, waits for response, and tracks state.
|
||||
func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
||||
@@ -908,3 +966,132 @@ func updateAgentBeadState(townRoot, agent, state, _ string) { // reason unused b
|
||||
_ = cmd.Run() // Best effort
|
||||
}
|
||||
|
||||
// runDeaconStaleHooks finds and unhooks stale hooked beads.
|
||||
func runDeaconStaleHooks(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg := &deacon.StaleHookConfig{
|
||||
MaxAge: staleHooksMaxAge,
|
||||
DryRun: staleHooksDryRun,
|
||||
}
|
||||
|
||||
result, err := deacon.ScanStaleHooks(townRoot, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanning stale hooks: %w", err)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
if result.TotalHooked == 0 {
|
||||
fmt.Printf("%s No hooked beads found\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Found %d hooked bead(s), %d stale (older than %s)\n",
|
||||
style.Bold.Render("●"), result.TotalHooked, result.StaleCount, staleHooksMaxAge)
|
||||
|
||||
if result.StaleCount == 0 {
|
||||
fmt.Printf("%s No stale hooked beads\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print details for each stale bead
|
||||
for _, r := range result.Results {
|
||||
status := style.Dim.Render("○")
|
||||
action := "skipped (agent alive)"
|
||||
|
||||
if !r.AgentAlive {
|
||||
if staleHooksDryRun {
|
||||
status = style.Bold.Render("?")
|
||||
action = "would unhook (agent dead)"
|
||||
} else if r.Unhooked {
|
||||
status = style.Bold.Render("✓")
|
||||
action = "unhooked (agent dead)"
|
||||
} else if r.Error != "" {
|
||||
status = style.Dim.Render("✗")
|
||||
action = fmt.Sprintf("error: %s", r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s: %s (age: %s, assignee: %s)\n",
|
||||
status, r.BeadID, action, r.Age, r.Assignee)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if staleHooksDryRun {
|
||||
fmt.Printf("\n%s Dry run - no changes made. Run without --dry-run to unhook.\n",
|
||||
style.Dim.Render("ℹ"))
|
||||
} else if result.Unhooked > 0 {
|
||||
fmt.Printf("\n%s Unhooked %d stale bead(s)\n",
|
||||
style.Bold.Render("✓"), result.Unhooked)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeaconPause pauses the Deacon to prevent patrol actions.
|
||||
func runDeaconPause(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Check if already paused
|
||||
paused, state, err := deacon.IsPaused(townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking pause state: %w", err)
|
||||
}
|
||||
if paused {
|
||||
fmt.Printf("%s Deacon is already paused\n", style.Dim.Render("○"))
|
||||
fmt.Printf(" Reason: %s\n", state.Reason)
|
||||
fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
|
||||
fmt.Printf(" Paused by: %s\n", state.PausedBy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause the Deacon
|
||||
if err := deacon.Pause(townRoot, pauseReason, "human"); err != nil {
|
||||
return fmt.Errorf("pausing Deacon: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Deacon paused\n", style.Bold.Render("⏸️"))
|
||||
if pauseReason != "" {
|
||||
fmt.Printf(" Reason: %s\n", pauseReason)
|
||||
}
|
||||
fmt.Printf(" Pause file: %s\n", deacon.GetPauseFile(townRoot))
|
||||
fmt.Println()
|
||||
fmt.Printf("The Deacon will not perform any patrol actions until resumed.\n")
|
||||
fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeaconResume resumes the Deacon to allow patrol actions.
|
||||
func runDeaconResume(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Check if paused
|
||||
paused, _, err := deacon.IsPaused(townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking pause state: %w", err)
|
||||
}
|
||||
if !paused {
|
||||
fmt.Printf("%s Deacon is not paused\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume the Deacon
|
||||
if err := deacon.Resume(townRoot); err != nil {
|
||||
return fmt.Errorf("resuming Deacon: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Deacon resumed\n", style.Bold.Render("▶️"))
|
||||
fmt.Println("The Deacon can now perform patrol actions.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
72
internal/cmd/disable.go
Normal file
72
internal/cmd/disable.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// ABOUTME: Command to disable Gas Town system-wide.
|
||||
// ABOUTME: Sets the global state to disabled so tools work vanilla.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/shell"
|
||||
"github.com/steveyegge/gastown/internal/state"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var disableClean bool
|
||||
|
||||
var disableCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
GroupID: GroupConfig,
|
||||
Short: "Disable Gas Town system-wide",
|
||||
Long: `Disable Gas Town for all agentic coding tools.
|
||||
|
||||
When disabled:
|
||||
- Shell hooks become no-ops
|
||||
- Claude Code SessionStart hooks skip 'gt prime'
|
||||
- Tools work 100% vanilla (no Gas Town behavior)
|
||||
|
||||
The workspace (~/gt) is preserved. Use 'gt enable' to re-enable.
|
||||
|
||||
Flags:
|
||||
--clean Also remove shell integration from ~/.zshrc/~/.bashrc
|
||||
|
||||
Environment overrides still work:
|
||||
GASTOWN_ENABLED=1 - Enable for current session only`,
|
||||
RunE: runDisable,
|
||||
}
|
||||
|
||||
func init() {
|
||||
disableCmd.Flags().BoolVar(&disableClean, "clean", false,
|
||||
"Remove shell integration from RC files")
|
||||
rootCmd.AddCommand(disableCmd)
|
||||
}
|
||||
|
||||
func runDisable(cmd *cobra.Command, args []string) error {
|
||||
if err := state.Disable(); err != nil {
|
||||
return fmt.Errorf("disabling Gas Town: %w", err)
|
||||
}
|
||||
|
||||
if disableClean {
|
||||
if err := removeShellIntegration(); err != nil {
|
||||
fmt.Printf("%s Could not clean shell integration: %v\n",
|
||||
style.Warning.Render("!"), err)
|
||||
} else {
|
||||
fmt.Println(" Removed shell integration from RC files")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%s Gas Town disabled\n", style.Success.Render("✓"))
|
||||
fmt.Println()
|
||||
fmt.Println("All agentic coding tools now work vanilla.")
|
||||
if !disableClean {
|
||||
fmt.Printf("Use %s to also remove shell hooks\n",
|
||||
style.Dim.Render("gt disable --clean"))
|
||||
}
|
||||
fmt.Printf("Use %s to re-enable\n", style.Dim.Render("gt enable"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeShellIntegration() error {
|
||||
return shell.Remove()
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
doctorFix bool
|
||||
doctorVerbose bool
|
||||
doctorRig string
|
||||
doctorFix bool
|
||||
doctorVerbose bool
|
||||
doctorRig string
|
||||
doctorRestartSessions bool
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -31,7 +32,13 @@ Workspace checks:
|
||||
- rigs-registry-valid Check registered rigs exist (fixable)
|
||||
- mayor-exists Check mayor/ directory structure
|
||||
|
||||
Town root protection:
|
||||
- town-git Verify town root is under version control
|
||||
- town-root-branch Verify town root is on main branch (fixable)
|
||||
- pre-checkout-hook Verify pre-checkout hook prevents branch switches (fixable)
|
||||
|
||||
Infrastructure checks:
|
||||
- stale-binary Check if gt binary is up to date with repo
|
||||
- daemon Check if daemon is running (fixable)
|
||||
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
||||
- boot-health Check Boot watchdog health (vet mode)
|
||||
@@ -45,6 +52,10 @@ Clone divergence checks:
|
||||
- persistent-role-branches Detect crew/witness/refinery not on main
|
||||
- clone-divergence Detect clones significantly behind origin/main
|
||||
|
||||
Crew workspace checks:
|
||||
- crew-state Validate crew worker state.json files (fixable)
|
||||
- crew-worktrees Detect stale cross-rig worktrees (fixable)
|
||||
|
||||
Rig checks (with --rig flag):
|
||||
- rig-is-git-repo Verify rig is a valid git repository
|
||||
- git-exclude-configured Check .git/info/exclude has Gas Town dirs (fixable)
|
||||
@@ -56,9 +67,11 @@ Rig checks (with --rig flag):
|
||||
|
||||
Routing checks (fixable):
|
||||
- routes-config Check beads routing configuration
|
||||
- prefix-mismatch Detect rigs.json vs routes.jsonl prefix mismatches (fixable)
|
||||
|
||||
Session hook checks:
|
||||
- session-hooks Check settings.json use session-start.sh
|
||||
- claude-settings Check Claude settings.json match templates (fixable)
|
||||
|
||||
Patrol checks:
|
||||
- patrol-molecules-exist Verify patrol molecules exist
|
||||
@@ -76,6 +89,7 @@ func init() {
|
||||
doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Attempt to automatically fix issues")
|
||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output")
|
||||
doctorCmd.Flags().StringVar(&doctorRig, "rig", "", "Check specific rig only")
|
||||
doctorCmd.Flags().BoolVar(&doctorRestartSessions, "restart-sessions", false, "Restart patrol sessions when fixing stale settings (use with --fix)")
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
}
|
||||
|
||||
@@ -88,9 +102,10 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Create check context
|
||||
ctx := &doctor.CheckContext{
|
||||
TownRoot: townRoot,
|
||||
RigName: doctorRig,
|
||||
Verbose: doctorVerbose,
|
||||
TownRoot: townRoot,
|
||||
RigName: doctorRig,
|
||||
Verbose: doctorVerbose,
|
||||
RestartSessions: doctorRestartSessions,
|
||||
}
|
||||
|
||||
// Create doctor and register checks
|
||||
@@ -99,14 +114,22 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
// Register workspace-level checks first (fundamental)
|
||||
d.RegisterAll(doctor.WorkspaceChecks()...)
|
||||
|
||||
d.Register(doctor.NewGlobalStateCheck())
|
||||
|
||||
// Register built-in checks
|
||||
d.Register(doctor.NewStaleBinaryCheck())
|
||||
d.Register(doctor.NewTownGitCheck())
|
||||
d.Register(doctor.NewTownRootBranchCheck())
|
||||
d.Register(doctor.NewPreCheckoutHookCheck())
|
||||
d.Register(doctor.NewDaemonCheck())
|
||||
d.Register(doctor.NewRepoFingerprintCheck())
|
||||
d.Register(doctor.NewBootHealthCheck())
|
||||
d.Register(doctor.NewBeadsDatabaseCheck())
|
||||
d.Register(doctor.NewCustomTypesCheck())
|
||||
d.Register(doctor.NewFormulaCheck())
|
||||
d.Register(doctor.NewBdDaemonCheck())
|
||||
d.Register(doctor.NewPrefixConflictCheck())
|
||||
d.Register(doctor.NewPrefixMismatchCheck())
|
||||
d.Register(doctor.NewRoutesCheck())
|
||||
d.Register(doctor.NewOrphanSessionCheck())
|
||||
d.Register(doctor.NewOrphanProcessCheck())
|
||||
@@ -117,6 +140,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewIdentityCollisionCheck())
|
||||
d.Register(doctor.NewLinkedPaneCheck())
|
||||
d.Register(doctor.NewThemeCheck())
|
||||
d.Register(doctor.NewCrashReportCheck())
|
||||
d.Register(doctor.NewEnvVarsCheck())
|
||||
|
||||
// Patrol system checks
|
||||
d.Register(doctor.NewPatrolMoleculesExistCheck())
|
||||
@@ -125,6 +150,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewPatrolPluginsAccessibleCheck())
|
||||
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
|
||||
d.Register(doctor.NewAgentBeadsCheck())
|
||||
d.Register(doctor.NewRigBeadsCheck())
|
||||
|
||||
// NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule
|
||||
|
||||
@@ -133,9 +159,14 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewSessionHookCheck())
|
||||
d.Register(doctor.NewRuntimeGitignoreCheck())
|
||||
d.Register(doctor.NewLegacyGastownCheck())
|
||||
d.Register(doctor.NewClaudeSettingsCheck())
|
||||
|
||||
// Priming subsystem check
|
||||
d.Register(doctor.NewPrimingCheck())
|
||||
|
||||
// Crew workspace checks
|
||||
d.Register(doctor.NewCrewStateCheck())
|
||||
d.Register(doctor.NewCrewWorktreeCheck())
|
||||
d.Register(doctor.NewCommandsCheck())
|
||||
|
||||
// Lifecycle hygiene checks
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -26,7 +27,7 @@ This is a convenience command for polecats that:
|
||||
1. Submits the current branch to the merge queue
|
||||
2. Auto-detects issue ID from branch name
|
||||
3. Notifies the Witness with the exit outcome
|
||||
4. Optionally exits the Claude session (--exit flag)
|
||||
4. Exits the Claude session (polecats don't stay alive after completion)
|
||||
|
||||
Exit statuses:
|
||||
COMPLETED - Work done, MR submitted (default)
|
||||
@@ -41,8 +42,7 @@ Phase handoff workflow:
|
||||
resolves.
|
||||
|
||||
Examples:
|
||||
gt done # Submit branch, notify COMPLETED
|
||||
gt done --exit # Submit and exit Claude session
|
||||
gt done # Submit branch, notify COMPLETED, exit session
|
||||
gt done --issue gt-abc # Explicit issue ID
|
||||
gt done --status ESCALATED # Signal blocker, skip MR
|
||||
gt done --status DEFERRED # Pause work, skip MR
|
||||
@@ -54,9 +54,9 @@ var (
|
||||
doneIssue string
|
||||
donePriority int
|
||||
doneStatus string
|
||||
doneExit bool
|
||||
donePhaseComplete bool
|
||||
doneGate string
|
||||
doneCleanupStatus string
|
||||
)
|
||||
|
||||
// Valid exit types for gt done
|
||||
@@ -71,9 +71,9 @@ func init() {
|
||||
doneCmd.Flags().StringVar(&doneIssue, "issue", "", "Source issue ID (default: parse from branch name)")
|
||||
doneCmd.Flags().IntVarP(&donePriority, "priority", "p", -1, "Override priority (0-4, default: inherit from issue)")
|
||||
doneCmd.Flags().StringVar(&doneStatus, "status", ExitCompleted, "Exit status: COMPLETED, ESCALATED, or DEFERRED")
|
||||
doneCmd.Flags().BoolVar(&doneExit, "exit", false, "Exit Claude session after MR submission (self-terminate)")
|
||||
doneCmd.Flags().BoolVar(&donePhaseComplete, "phase-complete", false, "Signal phase complete - await gate before continuing")
|
||||
doneCmd.Flags().StringVar(&doneGate, "gate", "", "Gate bead ID to wait on (with --phase-complete)")
|
||||
doneCmd.Flags().StringVar(&doneCleanupStatus, "cleanup-status", "", "Git cleanup status: clean, uncommitted, unpushed, stash, unknown (ZFC: agent-observed)")
|
||||
|
||||
rootCmd.AddCommand(doneCmd)
|
||||
}
|
||||
@@ -161,17 +161,6 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
if branch == defaultBranch || branch == "master" {
|
||||
return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch)
|
||||
}
|
||||
|
||||
// Check for unpushed commits - branch must be pushed before MR creation
|
||||
// Use BranchPushedToRemote which handles polecat branches without upstream tracking
|
||||
pushed, unpushedCount, err := g.BranchPushedToRemote(branch, "origin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking if branch is pushed: %w", err)
|
||||
}
|
||||
if !pushed {
|
||||
return fmt.Errorf("branch has %d unpushed commit(s); run 'git push -u origin %s' first", unpushedCount, branch)
|
||||
}
|
||||
|
||||
// Check that branch has commits ahead of default branch (prevents submitting stale branches)
|
||||
aheadCount, err := g.CommitsAhead(defaultBranch, branch)
|
||||
if err != nil {
|
||||
@@ -354,24 +343,37 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
// Update agent bead state (ZFC: self-report completion)
|
||||
updateAgentStateOnDone(cwd, townRoot, exitType, issueID)
|
||||
|
||||
// Handle session self-termination if requested
|
||||
if doneExit {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Session self-terminating (--exit flag)\n", style.Bold.Render("→"))
|
||||
fmt.Printf(" Witness will handle worktree cleanup.\n")
|
||||
fmt.Printf(" Goodbye!\n")
|
||||
os.Exit(0)
|
||||
// Self-cleaning: Nuke our own sandbox before exiting (if we're a polecat)
|
||||
// This is the self-cleaning model - polecats clean up after themselves
|
||||
selfNukeAttempted := false
|
||||
if exitType == ExitCompleted {
|
||||
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil && roleInfo.Role == RolePolecat {
|
||||
selfNukeAttempted = true
|
||||
if err := selfNukePolecat(roleInfo, townRoot); err != nil {
|
||||
// Non-fatal: Witness will clean up if we fail
|
||||
style.PrintWarning("self-nuke failed: %v (Witness will clean up)", err)
|
||||
} else {
|
||||
fmt.Printf("%s Sandbox nuked\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// Always exit session - polecats don't stay alive after completion
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Session exiting (done means gone)\n", style.Bold.Render("→"))
|
||||
if !selfNukeAttempted {
|
||||
fmt.Printf(" Witness will handle worktree cleanup.\n")
|
||||
}
|
||||
fmt.Printf(" Goodbye!\n")
|
||||
os.Exit(0)
|
||||
|
||||
return nil // unreachable, but keeps compiler happy
|
||||
}
|
||||
|
||||
// updateAgentStateOnDone updates the agent bead state when work is complete.
|
||||
// Maps exit type to agent state:
|
||||
// - COMPLETED → "done"
|
||||
// - ESCALATED → "stuck"
|
||||
// - DEFERRED → "idle"
|
||||
// - PHASE_COMPLETE → "awaiting-gate"
|
||||
// updateAgentStateOnDone clears the agent's hook and reports cleanup status.
|
||||
// Per gt-zecmc: observable states ("done", "idle") removed - use tmux to discover.
|
||||
// Non-observable states ("stuck", "awaiting-gate") are still set since they represent
|
||||
// intentional agent decisions that can't be observed from tmux.
|
||||
//
|
||||
// Also self-reports cleanup_status for ZFC compliance (#10).
|
||||
func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unused but kept for future audit logging
|
||||
@@ -394,22 +396,6 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
return
|
||||
}
|
||||
|
||||
// Map exit type to agent state
|
||||
var newState string
|
||||
switch exitType {
|
||||
case ExitCompleted:
|
||||
newState = "done"
|
||||
case ExitEscalated:
|
||||
newState = "stuck"
|
||||
case ExitDeferred:
|
||||
newState = "idle"
|
||||
case ExitPhaseComplete:
|
||||
newState = "awaiting-gate"
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Update agent bead with new state and clear hook_bead (work is done)
|
||||
// Use rig path for slot commands - bd slot doesn't route from town root
|
||||
var beadsPath string
|
||||
switch ctx.Role {
|
||||
@@ -419,21 +405,51 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
beadsPath = filepath.Join(townRoot, ctx.Rig)
|
||||
}
|
||||
bd := beads.New(beadsPath)
|
||||
emptyHook := ""
|
||||
if err := bd.UpdateAgentState(agentBeadID, newState, &emptyHook); err != nil {
|
||||
// Log warning instead of silent ignore - helps debug cross-beads issues
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s state on done: %v\n", agentBeadID, err)
|
||||
return
|
||||
|
||||
// BUG FIX (gt-vwjz6): Close hooked beads before clearing the hook.
|
||||
// Previously, the agent's hook_bead slot was cleared but the hooked bead itself
|
||||
// stayed status=hooked forever. Now we close the hooked bead before clearing.
|
||||
if agentBead, err := bd.Show(agentBeadID); err == nil && agentBead.HookBead != "" {
|
||||
hookedBeadID := agentBead.HookBead
|
||||
// Only close if the hooked bead exists and is still in "hooked" status
|
||||
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
|
||||
if err := bd.Close(hookedBeadID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the hook (work is done) - gt-zecmc
|
||||
if err := bd.ClearHookBead(agentBeadID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't clear agent %s hook: %v\n", agentBeadID, err)
|
||||
}
|
||||
|
||||
// Only set non-observable states - "stuck" and "awaiting-gate" are intentional
|
||||
// agent decisions that can't be discovered from tmux. Skip "done" and "idle"
|
||||
// since those are observable (no session = done, session + no hook = idle).
|
||||
switch exitType {
|
||||
case ExitEscalated:
|
||||
// "stuck" = agent is requesting help - not observable from tmux
|
||||
if _, err := bd.Run("agent", "state", agentBeadID, "stuck"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s to stuck: %v\n", agentBeadID, err)
|
||||
}
|
||||
case ExitPhaseComplete:
|
||||
// "awaiting-gate" = agent is waiting for external trigger - not observable
|
||||
if _, err := bd.Run("agent", "state", agentBeadID, "awaiting-gate"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s to awaiting-gate: %v\n", agentBeadID, err)
|
||||
}
|
||||
// ExitCompleted and ExitDeferred don't set state - observable from tmux
|
||||
}
|
||||
|
||||
// ZFC #10: Self-report cleanup status
|
||||
// Compute git state and report so Witness can decide removal safety
|
||||
cleanupStatus := computeCleanupStatus(cwd)
|
||||
if cleanupStatus != "" {
|
||||
if err := bd.UpdateAgentCleanupStatus(agentBeadID, cleanupStatus); err != nil {
|
||||
// Log warning instead of silent ignore
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s cleanup status: %v\n", agentBeadID, err)
|
||||
return
|
||||
// Agent observes git state and passes cleanup status via --cleanup-status flag
|
||||
if doneCleanupStatus != "" {
|
||||
cleanupStatus := parseCleanupStatus(doneCleanupStatus)
|
||||
if cleanupStatus != polecat.CleanupUnknown {
|
||||
if err := bd.UpdateAgentCleanupStatus(agentBeadID, string(cleanupStatus)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s cleanup status: %v\n", agentBeadID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,25 +475,45 @@ func getDispatcherFromBead(cwd, issueID string) string {
|
||||
return fields.DispatchedBy
|
||||
}
|
||||
|
||||
// computeCleanupStatus checks git state and returns the cleanup status.
|
||||
// Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean
|
||||
func computeCleanupStatus(cwd string) string {
|
||||
g := git.NewGit(cwd)
|
||||
status, err := g.CheckUncommittedWork()
|
||||
if err != nil {
|
||||
// If we can't check, report unknown - Witness should be cautious
|
||||
return "unknown"
|
||||
// parseCleanupStatus converts a string flag value to a CleanupStatus.
|
||||
// ZFC: Agent observes git state and passes the appropriate status.
|
||||
func parseCleanupStatus(s string) polecat.CleanupStatus {
|
||||
switch strings.ToLower(s) {
|
||||
case "clean":
|
||||
return polecat.CleanupClean
|
||||
case "uncommitted", "has_uncommitted":
|
||||
return polecat.CleanupUncommitted
|
||||
case "stash", "has_stash":
|
||||
return polecat.CleanupStash
|
||||
case "unpushed", "has_unpushed":
|
||||
return polecat.CleanupUnpushed
|
||||
default:
|
||||
return polecat.CleanupUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// selfNukePolecat deletes this polecat's worktree (self-cleaning model).
|
||||
// Called by polecats when they complete work via `gt done`.
|
||||
// This is safe because:
|
||||
// 1. Work has been pushed to origin (MR is in queue)
|
||||
// 2. We're about to exit anyway
|
||||
// 3. Unix allows deleting directories while processes run in them
|
||||
func selfNukePolecat(roleInfo RoleInfo, _ string) error {
|
||||
if roleInfo.Role != RolePolecat || roleInfo.Polecat == "" || roleInfo.Rig == "" {
|
||||
return fmt.Errorf("not a polecat: role=%s, polecat=%s, rig=%s", roleInfo.Role, roleInfo.Polecat, roleInfo.Rig)
|
||||
}
|
||||
|
||||
// Check in priority order (most critical first)
|
||||
if status.UnpushedCommits > 0 {
|
||||
return "has_unpushed"
|
||||
// Get polecat manager using existing helper
|
||||
mgr, _, err := getPolecatManager(roleInfo.Rig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting polecat manager: %w", err)
|
||||
}
|
||||
if status.StashCount > 0 {
|
||||
return "has_stash"
|
||||
|
||||
// Use nuclear=true since we know we just pushed our work
|
||||
// The branch is pushed, MR is created, we're clean
|
||||
if err := mgr.RemoveWithOptions(roleInfo.Polecat, true, true); err != nil {
|
||||
return fmt.Errorf("removing worktree: %w", err)
|
||||
}
|
||||
if status.HasUncommittedChanges {
|
||||
return "has_uncommitted"
|
||||
}
|
||||
return "clean"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ func TestDoneBeadsInitBothCodePaths(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDoneRedirectChain verifies behavior with chained redirects.
|
||||
// ResolveBeadsDir follows exactly one level of redirect by design - it does NOT
|
||||
// follow chains transitively. This is intentional: chains typically indicate
|
||||
// misconfiguration (e.g., a redirect file that shouldn't exist).
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net for legacy configs.
|
||||
// SetupRedirect avoids creating chains (bd CLI doesn't support them), but if
|
||||
// chains exist we follow them to the final destination.
|
||||
func TestDoneRedirectChain(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -189,14 +189,15 @@ func TestDoneRedirectChain(t *testing.T) {
|
||||
t.Fatalf("write worktree redirect: %v", err)
|
||||
}
|
||||
|
||||
// ResolveBeadsDir follows exactly one level - stops at intermediate
|
||||
// (A warning is printed about the chain, but intermediate is returned)
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net.
|
||||
// Note: SetupRedirect avoids creating chains (bd CLI doesn't support them),
|
||||
// but if chains exist from legacy configs, we follow them to the final destination.
|
||||
resolved := beads.ResolveBeadsDir(worktreeDir)
|
||||
|
||||
// Should resolve to intermediate (one level), NOT canonical (two levels)
|
||||
if resolved != intermediateBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow one level only: got %s, want %s",
|
||||
resolved, intermediateBeadsDir)
|
||||
// Should resolve to canonical (follows the full chain)
|
||||
if resolved != canonicalBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow chain to final destination: got %s, want %s",
|
||||
resolved, canonicalBeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/daemon"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownLockFile = "daemon/shutdown.lock"
|
||||
shutdownLockTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var downCmd = &cobra.Command{
|
||||
Use: "down",
|
||||
GroupID: GroupServices,
|
||||
Short: "Stop all Gas Town services",
|
||||
Long: `Stop all Gas Town long-lived services.
|
||||
Long: `Stop Gas Town services (reversible pause).
|
||||
|
||||
This gracefully shuts down all infrastructure agents:
|
||||
Shutdown levels (progressively more aggressive):
|
||||
gt down Stop infrastructure (default)
|
||||
gt down --polecats Also stop all polecat sessions
|
||||
gt down --all Also stop bd daemons/activity
|
||||
gt down --nuke Also kill the tmux server (DESTRUCTIVE)
|
||||
|
||||
• Witnesses - Per-rig polecat managers
|
||||
• Mayor - Global work coordinator
|
||||
• Boot - Deacon's watchdog
|
||||
• Deacon - Health orchestrator
|
||||
• Daemon - Go background process
|
||||
Infrastructure agents stopped:
|
||||
• Refineries - Per-rig work processors
|
||||
• Witnesses - Per-rig polecat managers
|
||||
• Mayor - Global work coordinator
|
||||
• Boot - Deacon's watchdog
|
||||
• Deacon - Health orchestrator
|
||||
• Daemon - Go background process
|
||||
|
||||
Polecats are NOT stopped by this command - use 'gt swarm stop' or
|
||||
kill individual polecats with 'gt polecat kill'.
|
||||
This is a "pause" operation - use 'gt start' to bring everything back up.
|
||||
For permanent cleanup (removing worktrees), use 'gt shutdown' instead.
|
||||
|
||||
This is useful for:
|
||||
Use cases:
|
||||
• Taking a break (stop token consumption)
|
||||
• Clean shutdown before system maintenance
|
||||
• Resetting the town to a clean state`,
|
||||
@@ -38,15 +60,21 @@ This is useful for:
|
||||
}
|
||||
|
||||
var (
|
||||
downQuiet bool
|
||||
downForce bool
|
||||
downAll bool
|
||||
downQuiet bool
|
||||
downForce bool
|
||||
downAll bool
|
||||
downNuke bool
|
||||
downDryRun bool
|
||||
downPolecats bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
downCmd.Flags().BoolVarP(&downQuiet, "quiet", "q", false, "Only show errors")
|
||||
downCmd.Flags().BoolVarP(&downForce, "force", "f", false, "Force kill without graceful shutdown")
|
||||
downCmd.Flags().BoolVarP(&downAll, "all", "a", false, "Also kill the tmux server")
|
||||
downCmd.Flags().BoolVarP(&downPolecats, "polecats", "p", false, "Also stop all polecat sessions")
|
||||
downCmd.Flags().BoolVarP(&downAll, "all", "a", false, "Stop bd daemons/activity and verify shutdown")
|
||||
downCmd.Flags().BoolVar(&downNuke, "nuke", false, "Kill entire tmux server (DESTRUCTIVE - kills non-GT sessions!)")
|
||||
downCmd.Flags().BoolVar(&downDryRun, "dry-run", false, "Preview what would be stopped without taking action")
|
||||
rootCmd.AddCommand(downCmd)
|
||||
}
|
||||
|
||||
@@ -57,24 +85,127 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
if !t.IsAvailable() {
|
||||
return fmt.Errorf("tmux not available (is tmux installed and on PATH?)")
|
||||
}
|
||||
|
||||
// Phase 0: Acquire shutdown lock (skip for dry-run)
|
||||
if !downDryRun {
|
||||
lock, err := acquireShutdownLock(townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot proceed: %w", err)
|
||||
}
|
||||
defer lock.Unlock()
|
||||
}
|
||||
allOK := true
|
||||
|
||||
// Stop in reverse order of startup
|
||||
if downDryRun {
|
||||
fmt.Println("═══ DRY RUN: Preview of shutdown actions ═══")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 1. Stop witnesses first
|
||||
rigs := discoverRigs(townRoot)
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||||
if err := stopSession(t, sessionName); err != nil {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
||||
|
||||
// Phase 0.5: Stop polecats if --polecats
|
||||
if downPolecats {
|
||||
if downDryRun {
|
||||
fmt.Println("Would stop polecats...")
|
||||
} else {
|
||||
fmt.Println("Stopping polecats...")
|
||||
}
|
||||
polecatsStopped := stopAllPolecats(t, townRoot, rigs, downForce, downDryRun)
|
||||
if downDryRun {
|
||||
if polecatsStopped > 0 {
|
||||
printDownStatus("Polecats", true, fmt.Sprintf("%d would stop", polecatsStopped))
|
||||
} else {
|
||||
printDownStatus("Polecats", true, "none running")
|
||||
}
|
||||
} else {
|
||||
if polecatsStopped > 0 {
|
||||
printDownStatus("Polecats", true, fmt.Sprintf("%d stopped", polecatsStopped))
|
||||
} else {
|
||||
printDownStatus("Polecats", true, "none running")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Phase 1: Stop bd resurrection layer (--all only)
|
||||
if downAll {
|
||||
daemonsKilled, activityKilled, err := beads.StopAllBdProcesses(downDryRun, downForce)
|
||||
if err != nil {
|
||||
printDownStatus("bd processes", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "stopped")
|
||||
if downDryRun {
|
||||
if daemonsKilled > 0 || activityKilled > 0 {
|
||||
printDownStatus("bd daemon", true, fmt.Sprintf("%d would stop", daemonsKilled))
|
||||
printDownStatus("bd activity", true, fmt.Sprintf("%d would stop", activityKilled))
|
||||
} else {
|
||||
printDownStatus("bd processes", true, "none running")
|
||||
}
|
||||
} else {
|
||||
if daemonsKilled > 0 {
|
||||
printDownStatus("bd daemon", true, fmt.Sprintf("%d stopped", daemonsKilled))
|
||||
}
|
||||
if activityKilled > 0 {
|
||||
printDownStatus("bd activity", true, fmt.Sprintf("%d stopped", activityKilled))
|
||||
}
|
||||
if daemonsKilled == 0 && activityKilled == 0 {
|
||||
printDownStatus("bd processes", true, "none running")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop town-level sessions (Mayor, Boot, Deacon) in correct order
|
||||
// Phase 2a: Stop refineries
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(sessionName); running {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
wasRunning, err := stopSession(t, sessionName)
|
||||
if err != nil {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
} else if wasRunning {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), true, "stopped")
|
||||
} else {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), true, "not running")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2b: Stop witnesses
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(sessionName); running {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
wasRunning, err := stopSession(t, sessionName)
|
||||
if err != nil {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
} else if wasRunning {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "stopped")
|
||||
} else {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "not running")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Stop town-level sessions (Mayor, Boot, Deacon)
|
||||
for _, ts := range session.TownSessions() {
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(ts.SessionID); running {
|
||||
printDownStatus(ts.Name, true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
stopped, err := session.StopTownSession(t, ts, downForce)
|
||||
if err != nil {
|
||||
printDownStatus(ts.Name, false, err.Error())
|
||||
@@ -86,38 +217,91 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Stop Daemon last
|
||||
running, _, _ := daemon.IsRunning(townRoot)
|
||||
if running {
|
||||
if err := daemon.StopDaemon(townRoot); err != nil {
|
||||
printDownStatus("Daemon", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Daemon", true, "stopped")
|
||||
// Phase 4: Stop Daemon
|
||||
running, pid, daemonErr := daemon.IsRunning(townRoot)
|
||||
if daemonErr != nil {
|
||||
printDownStatus("Daemon", false, fmt.Sprintf("status check failed: %v", daemonErr))
|
||||
allOK = false
|
||||
} else if downDryRun {
|
||||
if running {
|
||||
printDownStatus("Daemon", true, fmt.Sprintf("would stop (PID %d)", pid))
|
||||
}
|
||||
} else {
|
||||
printDownStatus("Daemon", true, "not running")
|
||||
if running {
|
||||
if err := daemon.StopDaemon(townRoot); err != nil {
|
||||
printDownStatus("Daemon", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Daemon", true, fmt.Sprintf("stopped (was PID %d)", pid))
|
||||
}
|
||||
} else {
|
||||
printDownStatus("Daemon", true, "not running")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kill tmux server if --all
|
||||
if downAll {
|
||||
if err := t.KillServer(); err != nil {
|
||||
printDownStatus("Tmux server", false, err.Error())
|
||||
// Phase 5: Verification (--all only)
|
||||
if downAll && !downDryRun {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
respawned := verifyShutdown(t, townRoot)
|
||||
if len(respawned) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Warning: Some processes may have respawned:\n", style.Bold.Render("⚠"))
|
||||
for _, r := range respawned {
|
||||
fmt.Printf(" • %s\n", r)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("This may indicate systemd/launchd is managing bd.\n")
|
||||
fmt.Printf("Check with:\n")
|
||||
fmt.Printf(" %s\n", style.Dim.Render("systemctl status bd-daemon # Linux"))
|
||||
fmt.Printf(" %s\n", style.Dim.Render("launchctl list | grep bd # macOS"))
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Nuke tmux server (--nuke only, DESTRUCTIVE)
|
||||
if downNuke {
|
||||
if downDryRun {
|
||||
printDownStatus("Tmux server", true, "would kill (DESTRUCTIVE)")
|
||||
} else if os.Getenv("GT_NUKE_ACKNOWLEDGED") == "" {
|
||||
// Require explicit acknowledgement for destructive operation
|
||||
fmt.Println()
|
||||
fmt.Printf("%s The --nuke flag kills ALL tmux sessions, not just Gas Town.\n",
|
||||
style.Bold.Render("⚠ BLOCKED:"))
|
||||
fmt.Printf("This includes vim sessions, running builds, SSH connections, etc.\n")
|
||||
fmt.Println()
|
||||
fmt.Printf("To proceed, run with: %s\n", style.Bold.Render("GT_NUKE_ACKNOWLEDGED=1 gt down --nuke"))
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Tmux server", true, "killed")
|
||||
if err := t.KillServer(); err != nil {
|
||||
printDownStatus("Tmux server", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Tmux server", true, "killed (all tmux sessions destroyed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
if downDryRun {
|
||||
fmt.Println("═══ DRY RUN COMPLETE (no changes made) ═══")
|
||||
return nil
|
||||
}
|
||||
|
||||
if allOK {
|
||||
fmt.Printf("%s All services stopped\n", style.Bold.Render("✓"))
|
||||
// Log halt event with stopped services
|
||||
stoppedServices := []string{"daemon", "deacon", "boot", "mayor"}
|
||||
for _, rigName := range rigs {
|
||||
stoppedServices = append(stoppedServices, fmt.Sprintf("%s/refinery", rigName))
|
||||
stoppedServices = append(stoppedServices, fmt.Sprintf("%s/witness", rigName))
|
||||
}
|
||||
if downPolecats {
|
||||
stoppedServices = append(stoppedServices, "polecats")
|
||||
}
|
||||
if downAll {
|
||||
stoppedServices = append(stoppedServices, "bd-processes")
|
||||
}
|
||||
if downNuke {
|
||||
stoppedServices = append(stoppedServices, "tmux-server")
|
||||
}
|
||||
_ = events.LogFeed(events.TypeHalt, "gt", events.HaltPayload(stoppedServices))
|
||||
@@ -129,6 +313,52 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopAllPolecats stops all polecat sessions across all rigs.
|
||||
// Returns the number of polecats stopped (or would be stopped in dry-run).
|
||||
func stopAllPolecats(t *tmux.Tmux, townRoot string, rigNames []string, force bool, dryRun bool) int {
|
||||
stopped := 0
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
}
|
||||
|
||||
g := git.NewGit(townRoot)
|
||||
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
||||
|
||||
for _, rigName := range rigNames {
|
||||
r, err := rigMgr.GetRig(rigName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
polecatMgr := polecat.NewSessionManager(t, r)
|
||||
infos, err := polecatMgr.List()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, info := range infos {
|
||||
if dryRun {
|
||||
stopped++
|
||||
fmt.Printf(" %s [%s] %s would stop\n", style.Dim.Render("○"), rigName, info.Polecat)
|
||||
continue
|
||||
}
|
||||
err := polecatMgr.Stop(info.Polecat, force)
|
||||
if err == nil {
|
||||
stopped++
|
||||
fmt.Printf(" %s [%s] %s stopped\n", style.SuccessPrefix, rigName, info.Polecat)
|
||||
} else {
|
||||
fmt.Printf(" %s [%s] %s: %s\n", style.ErrorPrefix, rigName, info.Polecat, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stopped
|
||||
}
|
||||
|
||||
func printDownStatus(name string, ok bool, detail string) {
|
||||
if downQuiet && ok {
|
||||
return
|
||||
@@ -141,13 +371,14 @@ func printDownStatus(name string, ok bool, detail string) {
|
||||
}
|
||||
|
||||
// stopSession gracefully stops a tmux session.
|
||||
func stopSession(t *tmux.Tmux, sessionName string) error {
|
||||
// Returns (wasRunning, error) - wasRunning is true if session existed and was stopped.
|
||||
func stopSession(t *tmux.Tmux, sessionName string) (bool, error) {
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if !running {
|
||||
return nil // Already stopped
|
||||
return false, nil // Already stopped
|
||||
}
|
||||
|
||||
// Try graceful shutdown first (Ctrl-C, best-effort interrupt)
|
||||
@@ -157,5 +388,82 @@ func stopSession(t *tmux.Tmux, sessionName string) error {
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
return t.KillSession(sessionName)
|
||||
return true, t.KillSession(sessionName)
|
||||
}
|
||||
|
||||
// acquireShutdownLock prevents concurrent shutdowns.
|
||||
// Returns the lock (caller must defer Unlock()) or error if lock held.
|
||||
func acquireShutdownLock(townRoot string) (*flock.Flock, error) {
|
||||
lockPath := filepath.Join(townRoot, shutdownLockFile)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating lock directory: %w", err)
|
||||
}
|
||||
|
||||
lock := flock.New(lockPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownLockTimeout)
|
||||
defer cancel()
|
||||
|
||||
locked, err := lock.TryLockContext(ctx, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock acquisition failed: %w", err)
|
||||
}
|
||||
|
||||
if !locked {
|
||||
return nil, fmt.Errorf("another shutdown is in progress (lock held: %s)", lockPath)
|
||||
}
|
||||
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
// verifyShutdown checks for respawned processes after shutdown.
|
||||
// Returns list of things that are still running or respawned.
|
||||
func verifyShutdown(t *tmux.Tmux, townRoot string) []string {
|
||||
var respawned []string
|
||||
|
||||
if count := beads.CountBdDaemons(); count > 0 {
|
||||
respawned = append(respawned, fmt.Sprintf("bd daemon (%d running)", count))
|
||||
}
|
||||
|
||||
if count := beads.CountBdActivityProcesses(); count > 0 {
|
||||
respawned = append(respawned, fmt.Sprintf("bd activity (%d running)", count))
|
||||
}
|
||||
|
||||
sessions, err := t.ListSessions()
|
||||
if err == nil {
|
||||
for _, sess := range sessions {
|
||||
if strings.HasPrefix(sess, "gt-") || strings.HasPrefix(sess, "hq-") {
|
||||
respawned = append(respawned, fmt.Sprintf("tmux session %s", sess))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pidFile := filepath.Join(townRoot, "daemon", "daemon.pid")
|
||||
if pidData, err := os.ReadFile(pidFile); err == nil {
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(string(pidData), "%d", &pid); err == nil {
|
||||
if isProcessRunning(pid) {
|
||||
respawned = append(respawned, fmt.Sprintf("gt daemon (PID %d)", pid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return respawned
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID exists.
|
||||
func isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false // Invalid PID
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
// EPERM means process exists but we don't have permission to signal it
|
||||
if err == syscall.EPERM {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
24
internal/cmd/down_test.go
Normal file
24
internal/cmd/down_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsProcessRunning_CurrentProcess(t *testing.T) {
|
||||
if !isProcessRunning(os.Getpid()) {
|
||||
t.Error("current process should be detected as running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProcessRunning_InvalidPID(t *testing.T) {
|
||||
if isProcessRunning(99999999) {
|
||||
t.Error("invalid PID should not be detected as running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProcessRunning_MaxPID(t *testing.T) {
|
||||
if isProcessRunning(2147483647) {
|
||||
t.Error("max PID should not be running")
|
||||
}
|
||||
}
|
||||
54
internal/cmd/enable.go
Normal file
54
internal/cmd/enable.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// ABOUTME: Command to enable Gas Town system-wide.
|
||||
// ABOUTME: Sets the global state to enabled for all agentic coding tools.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/state"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var enableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
GroupID: GroupConfig,
|
||||
Short: "Enable Gas Town system-wide",
|
||||
Long: `Enable Gas Town for all agentic coding tools.
|
||||
|
||||
When enabled:
|
||||
- Shell hooks set GT_TOWN_ROOT and GT_RIG environment variables
|
||||
- Claude Code SessionStart hooks run 'gt prime' for context
|
||||
- Git repos are auto-registered as rigs (configurable)
|
||||
|
||||
Use 'gt disable' to turn off. Use 'gt status --global' to check state.
|
||||
|
||||
Environment overrides:
|
||||
GASTOWN_DISABLED=1 - Disable for current session only
|
||||
GASTOWN_ENABLED=1 - Enable for current session only`,
|
||||
RunE: runEnable,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(enableCmd)
|
||||
}
|
||||
|
||||
func runEnable(cmd *cobra.Command, args []string) error {
|
||||
if err := state.Enable(Version); err != nil {
|
||||
return fmt.Errorf("enabling Gas Town: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Gas Town enabled\n", style.Success.Render("✓"))
|
||||
fmt.Println()
|
||||
fmt.Println("Gas Town will now:")
|
||||
fmt.Println(" • Inject context into Claude Code sessions")
|
||||
fmt.Println(" • Set GT_TOWN_ROOT and GT_RIG environment variables")
|
||||
fmt.Println(" • Auto-register git repos as rigs (if configured)")
|
||||
fmt.Println()
|
||||
fmt.Printf("Use %s to disable, %s to check status\n",
|
||||
style.Dim.Render("gt disable"),
|
||||
style.Dim.Render("gt status --global"))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -92,17 +93,20 @@ Examples:
|
||||
}
|
||||
|
||||
var formulaRunCmd = &cobra.Command{
|
||||
Use: "run <name>",
|
||||
Use: "run [name]",
|
||||
Short: "Execute a formula",
|
||||
Long: `Execute a formula by pouring it and dispatching work.
|
||||
|
||||
This command:
|
||||
1. Looks up the formula by name
|
||||
1. Looks up the formula by name (or uses default from rig config)
|
||||
2. Pours it to create a molecule (or uses existing proto)
|
||||
3. Dispatches the molecule to available workers
|
||||
|
||||
For PR-based workflows, use --pr to specify the GitHub PR number.
|
||||
|
||||
If no formula name is provided, uses the default formula configured in
|
||||
the rig's settings/config.json under workflow.default_formula.
|
||||
|
||||
Options:
|
||||
--pr=N Run formula on GitHub PR #N
|
||||
--rig=NAME Target specific rig (default: current or gastown)
|
||||
@@ -110,10 +114,11 @@ Options:
|
||||
|
||||
Examples:
|
||||
gt formula run shiny # Run formula in current rig
|
||||
gt formula run # Run default formula from rig config
|
||||
gt formula run shiny --pr=123 # Run on PR #123
|
||||
gt formula run security-audit --rig=beads # Run in specific rig
|
||||
gt formula run release --dry-run # Preview execution`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runFormulaRun,
|
||||
}
|
||||
|
||||
@@ -193,7 +198,51 @@ func runFormulaShow(cmd *cobra.Command, args []string) error {
|
||||
// For convoy-type formulas, it creates a convoy bead, creates leg beads,
|
||||
// and slings each leg to a separate polecat with leg-specific prompts.
|
||||
func runFormulaRun(cmd *cobra.Command, args []string) error {
|
||||
formulaName := args[0]
|
||||
// Determine target rig first (needed for default formula lookup)
|
||||
targetRig := formulaRunRig
|
||||
var rigPath string
|
||||
if targetRig == "" {
|
||||
// Try to detect from current directory
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err == nil && townRoot != "" {
|
||||
rigName, r, rigErr := findCurrentRig(townRoot)
|
||||
if rigErr == nil && rigName != "" {
|
||||
targetRig = rigName
|
||||
if r != nil {
|
||||
rigPath = r.Path
|
||||
}
|
||||
}
|
||||
// If we still don't have a target rig but have townRoot, use gastown
|
||||
if targetRig == "" {
|
||||
targetRig = "gastown"
|
||||
rigPath = filepath.Join(townRoot, "gastown")
|
||||
}
|
||||
} else {
|
||||
// No town root found, fall back to gastown without rigPath
|
||||
targetRig = "gastown"
|
||||
}
|
||||
} else {
|
||||
// If rig specified, construct path
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err == nil && townRoot != "" {
|
||||
rigPath = filepath.Join(townRoot, targetRig)
|
||||
}
|
||||
}
|
||||
|
||||
// Get formula name from args or default
|
||||
var formulaName string
|
||||
if len(args) > 0 {
|
||||
formulaName = args[0]
|
||||
} else {
|
||||
// Try to get default formula from rig config
|
||||
if rigPath != "" {
|
||||
formulaName = config.GetDefaultFormula(rigPath)
|
||||
}
|
||||
if formulaName == "" {
|
||||
return fmt.Errorf("no formula specified and no default formula configured\n\nTo set a default formula, add to your rig's settings/config.json:\n \"workflow\": {\n \"default_formula\": \"<formula-name>\"\n }")
|
||||
}
|
||||
fmt.Printf("%s Using default formula: %s\n", style.Dim.Render("Note:"), formulaName)
|
||||
}
|
||||
|
||||
// Find the formula file
|
||||
formulaPath, err := findFormulaFile(formulaName)
|
||||
@@ -207,22 +256,6 @@ func runFormulaRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("parsing formula: %w", err)
|
||||
}
|
||||
|
||||
// Determine target rig
|
||||
targetRig := formulaRunRig
|
||||
if targetRig == "" {
|
||||
// Try to detect from current directory
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err == nil && townRoot != "" {
|
||||
rigName, _, rigErr := findCurrentRig(townRoot)
|
||||
if rigErr == nil && rigName != "" {
|
||||
targetRig = rigName
|
||||
}
|
||||
}
|
||||
if targetRig == "" {
|
||||
targetRig = "gastown" // Default
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dry-run mode
|
||||
if formulaRunDryRun {
|
||||
return dryRunFormula(f, formulaName, targetRig)
|
||||
|
||||
@@ -267,6 +267,11 @@ func InitGitForHarness(hqRoot string, github string, private bool) error {
|
||||
fmt.Printf(" ✓ Git repository already exists\n")
|
||||
}
|
||||
|
||||
// Install pre-checkout hook to prevent accidental branch switches
|
||||
if err := InstallPreCheckoutHook(hqRoot); err != nil {
|
||||
fmt.Printf(" %s Could not install pre-checkout hook: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Create GitHub repo if requested
|
||||
if github != "" {
|
||||
if err := createGitHubRepo(hqRoot, github, private); err != nil {
|
||||
@@ -276,3 +281,102 @@ func InitGitForHarness(hqRoot string, github string, private bool) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PreCheckoutHookScript is the git pre-checkout hook that prevents accidental
|
||||
// branch switches in the town root. The town root should always stay on main.
|
||||
const PreCheckoutHookScript = `#!/bin/bash
|
||||
# Gas Town pre-checkout hook
|
||||
# Prevents accidental branch switches in the town root (HQ).
|
||||
# The town root must stay on main to avoid breaking gt commands.
|
||||
|
||||
# Only check branch checkouts (not file checkouts)
|
||||
# $3 is 1 for file checkout, 0 for branch checkout
|
||||
if [ "$3" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the target branch name
|
||||
TARGET_BRANCH=$(git rev-parse --abbrev-ref "$2" 2>/dev/null)
|
||||
|
||||
# Allow checkout to main or master
|
||||
if [ "$TARGET_BRANCH" = "main" ] || [ "$TARGET_BRANCH" = "master" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get current branch
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
|
||||
# If already not on main, allow (might be fixing the situation)
|
||||
if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Block the checkout with a warning
|
||||
echo ""
|
||||
echo "⚠️ BLOCKED: Town root must stay on main branch"
|
||||
echo ""
|
||||
echo " You're trying to switch from '$CURRENT_BRANCH' to '$TARGET_BRANCH'"
|
||||
echo " in the Gas Town HQ directory."
|
||||
echo ""
|
||||
echo " The town root (~/gt) should always be on main. Switching branches"
|
||||
echo " can break gt commands (missing rigs.json, wrong configs, etc.)."
|
||||
echo ""
|
||||
echo " If you really need to switch branches, you can:"
|
||||
echo " 1. Temporarily rename .git/hooks/pre-checkout"
|
||||
echo " 2. Do your work"
|
||||
echo " 3. Switch back to main"
|
||||
echo " 4. Restore the hook"
|
||||
echo ""
|
||||
exit 1
|
||||
`
|
||||
|
||||
// InstallPreCheckoutHook installs the pre-checkout hook in the town root.
|
||||
// This prevents accidental branch switches that can break gt commands.
|
||||
func InstallPreCheckoutHook(hqRoot string) error {
|
||||
hooksDir := filepath.Join(hqRoot, ".git", "hooks")
|
||||
|
||||
// Ensure hooks directory exists
|
||||
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating hooks directory: %w", err)
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(hooksDir, "pre-checkout")
|
||||
|
||||
// Check if hook already exists
|
||||
if _, err := os.Stat(hookPath); err == nil {
|
||||
// Read existing hook to see if it's ours
|
||||
content, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading existing hook: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(content), "Gas Town pre-checkout hook") {
|
||||
fmt.Printf(" ✓ Pre-checkout hook already installed\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// There's an existing hook that's not ours - don't overwrite
|
||||
fmt.Printf(" %s Pre-checkout hook exists but is not Gas Town's (skipping)\n", style.Dim.Render("⚠"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Install the hook
|
||||
if err := os.WriteFile(hookPath, []byte(PreCheckoutHookScript), 0755); err != nil {
|
||||
return fmt.Errorf("writing hook: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ Installed pre-checkout hook (prevents accidental branch switches)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPreCheckoutHookInstalled checks if the Gas Town pre-checkout hook is installed.
|
||||
func IsPreCheckoutHookInstalled(hqRoot string) bool {
|
||||
hookPath := filepath.Join(hqRoot, ".git", "hooks", "pre-checkout")
|
||||
|
||||
content, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(string(content), "Gas Town pre-checkout hook")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
@@ -182,19 +183,9 @@ func runHandoff(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Report agent state as stopped (ZFC: agents self-report state)
|
||||
cwd, _ := os.Getwd()
|
||||
if townRoot, _ := workspace.FindFromCwd(); townRoot != "" {
|
||||
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil {
|
||||
reportAgentState(RoleContext{
|
||||
Role: roleInfo.Role,
|
||||
Rig: roleInfo.Rig,
|
||||
Polecat: roleInfo.Polecat,
|
||||
TownRoot: townRoot,
|
||||
WorkDir: cwd,
|
||||
}, "stopped")
|
||||
}
|
||||
}
|
||||
// NOTE: reportAgentState("stopped") removed (gt-zecmc)
|
||||
// Agent liveness is observable from tmux - no need to record it in bead.
|
||||
// "Discover, don't track" principle: reality is truth, state is derived.
|
||||
|
||||
// Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0])
|
||||
if err := t.ClearHistory(pane); err != nil {
|
||||
@@ -202,6 +193,16 @@ func runHandoff(cmd *cobra.Command, args []string) error {
|
||||
style.PrintWarning("could not clear history: %v", err)
|
||||
}
|
||||
|
||||
// Write handoff marker for successor detection (prevents handoff loop bug).
|
||||
// The marker is cleared by gt prime after it outputs the warning.
|
||||
// This tells the new session "you're post-handoff, don't re-run /handoff"
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
runtimeDir := filepath.Join(cwd, constants.DirRuntime)
|
||||
_ = os.MkdirAll(runtimeDir, 0755)
|
||||
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
|
||||
_ = os.WriteFile(markerPath, []byte(currentSession), 0644)
|
||||
}
|
||||
|
||||
// Use exec to respawn the pane - this kills us and restarts
|
||||
return t.RespawnPane(pane, restartCmd)
|
||||
}
|
||||
@@ -323,6 +324,17 @@ func resolvePathToSession(path string) (string, error) {
|
||||
return "", fmt.Errorf("cannot parse path '%s' - expected <rig>/<polecat>, <rig>/crew/<name>, <rig>/witness, or <rig>/refinery", path)
|
||||
}
|
||||
|
||||
// claudeEnvVars lists the Claude-related environment variables to propagate
|
||||
// during handoff. These vars aren't inherited by tmux respawn-pane's fresh shell.
|
||||
var claudeEnvVars = []string{
|
||||
// Claude API and config
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
// AWS vars for Bedrock
|
||||
"AWS_PROFILE",
|
||||
"AWS_REGION",
|
||||
}
|
||||
|
||||
// buildRestartCommand creates the command to run when respawning a session's pane.
|
||||
// This needs to be the actual command to execute (e.g., claude), not a session attach command.
|
||||
// The command includes a cd to the correct working directory for the role.
|
||||
@@ -339,20 +351,52 @@ func buildRestartCommand(sessionName string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Determine GT_ROLE and BD_ACTOR values for this session
|
||||
gtRole := sessionToGTRole(sessionName)
|
||||
// Parse the session name to get the identity (used for GT_ROLE and beacon)
|
||||
identity, err := session.ParseSessionName(sessionName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse session name %q: %w", sessionName, err)
|
||||
}
|
||||
gtRole := identity.GTRole()
|
||||
|
||||
// Build startup beacon for predecessor discovery via /resume
|
||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
||||
// The SessionStart hook handles context injection (gt prime --hook)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: identity.Address(),
|
||||
Sender: "self",
|
||||
Topic: "handoff",
|
||||
})
|
||||
|
||||
// For respawn-pane, we:
|
||||
// 1. cd to the right directory (role's canonical home)
|
||||
// 2. export GT_ROLE and BD_ACTOR so role detection works correctly
|
||||
// 3. run claude with "gt prime" as initial prompt (triggers GUPP)
|
||||
// 3. export Claude-related env vars (not inherited by fresh shell)
|
||||
// 4. run claude with the startup beacon (triggers immediate context loading)
|
||||
// Use exec to ensure clean process replacement.
|
||||
// IMPORTANT: Passing "gt prime" as argument injects it as the first prompt,
|
||||
// which triggers the agent to execute immediately. Without this, agents
|
||||
// wait for user input despite all GUPP prompting in hooks.
|
||||
runtimeCmd := config.GetRuntimeCommandWithPrompt("", "gt prime")
|
||||
runtimeCmd := config.GetRuntimeCommandWithPrompt("", beacon)
|
||||
|
||||
// Build environment exports - role vars first, then Claude vars
|
||||
var exports []string
|
||||
if gtRole != "" {
|
||||
return fmt.Sprintf("cd %s && export GT_ROLE=%s BD_ACTOR=%s GIT_AUTHOR_NAME=%s && exec %s", workDir, gtRole, gtRole, gtRole, runtimeCmd), nil
|
||||
runtimeConfig := config.LoadRuntimeConfig("")
|
||||
exports = append(exports, "GT_ROLE="+gtRole)
|
||||
exports = append(exports, "BD_ACTOR="+gtRole)
|
||||
exports = append(exports, "GIT_AUTHOR_NAME="+gtRole)
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
||||
exports = append(exports, "GT_SESSION_ID_ENV="+runtimeConfig.Session.SessionIDEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Claude-related env vars from current environment
|
||||
for _, name := range claudeEnvVars {
|
||||
if val := os.Getenv(name); val != "" {
|
||||
// Shell-escape the value in case it contains special chars
|
||||
exports = append(exports, fmt.Sprintf("%s=%q", name, val))
|
||||
}
|
||||
}
|
||||
|
||||
if len(exports) > 0 {
|
||||
return fmt.Sprintf("cd %s && export %s && exec %s", workDir, strings.Join(exports, " "), runtimeCmd), nil
|
||||
}
|
||||
return fmt.Sprintf("cd %s && exec %s", workDir, runtimeCmd), nil
|
||||
}
|
||||
@@ -587,16 +631,37 @@ func sendHandoffMail(subject, message string) (string, error) {
|
||||
}
|
||||
|
||||
// looksLikeBeadID checks if a string looks like a bead ID.
|
||||
// Bead IDs have format: prefix-xxxx where prefix is 2+ letters and xxxx is alphanumeric.
|
||||
// Bead IDs have format: prefix-xxxx where prefix is 1-5 lowercase letters and xxxx is alphanumeric.
|
||||
// Examples: "gt-abc123", "bd-ka761", "hq-cv-abc", "beads-xyz", "ap-qtsup.16"
|
||||
func looksLikeBeadID(s string) bool {
|
||||
// Common bead prefixes
|
||||
prefixes := []string{"gt-", "hq-", "bd-", "beads-"}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
// Find the first hyphen
|
||||
idx := strings.Index(s, "-")
|
||||
if idx < 1 || idx > 5 {
|
||||
// No hyphen, or prefix is empty/too long
|
||||
return false
|
||||
}
|
||||
|
||||
// Check prefix is all lowercase letters
|
||||
prefix := s[:idx]
|
||||
for _, c := range prefix {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
// Check there's something after the hyphen
|
||||
rest := s[idx+1:]
|
||||
if len(rest) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check rest starts with alphanumeric and contains only alphanumeric, dots, hyphens
|
||||
first := rest[0]
|
||||
if !((first >= 'a' && first <= 'z') || (first >= '0' && first <= '9')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// hookBeadForHandoff attaches a bead to the current agent's hook.
|
||||
|
||||
141
internal/cmd/help.go
Normal file
141
internal/cmd/help.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/ui"
|
||||
)
|
||||
|
||||
// colorizedHelpFunc wraps Cobra's default help with semantic coloring.
|
||||
// Applies subtle accent color to group headers for visual hierarchy.
|
||||
func colorizedHelpFunc(cmd *cobra.Command, args []string) {
|
||||
// build full help output: Long description + Usage
|
||||
var output strings.Builder
|
||||
|
||||
// include Long description first (like Cobra's default help)
|
||||
if cmd.Long != "" {
|
||||
output.WriteString(cmd.Long)
|
||||
output.WriteString("\n\n")
|
||||
} else if cmd.Short != "" {
|
||||
output.WriteString(cmd.Short)
|
||||
output.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// add the usage string which contains commands, flags, etc.
|
||||
output.WriteString(cmd.UsageString())
|
||||
|
||||
// apply semantic coloring
|
||||
result := colorizeHelpOutput(output.String())
|
||||
fmt.Print(result)
|
||||
}
|
||||
|
||||
// colorizeHelpOutput applies semantic colors to help text
|
||||
// - Group headers get accent color for visual hierarchy
|
||||
// - Section headers (Examples:, Flags:) get accent color
|
||||
// - Command names get subtle styling for scanability
|
||||
// - Flag names get bold styling, types get muted
|
||||
// - Default values get muted styling
|
||||
func colorizeHelpOutput(help string) string {
|
||||
// match group header lines (e.g., "Working With Issues:")
|
||||
// these are standalone lines ending with ":" and followed by commands
|
||||
groupHeaderRE := regexp.MustCompile(`(?m)^([A-Z][A-Za-z &]+:)\s*$`)
|
||||
|
||||
result := groupHeaderRE.ReplaceAllStringFunc(help, func(match string) string {
|
||||
// trim whitespace, colorize, then restore
|
||||
trimmed := strings.TrimSpace(match)
|
||||
return ui.RenderAccent(trimmed)
|
||||
})
|
||||
|
||||
// match section headers in subcommand help (Examples:, Flags:, etc.)
|
||||
sectionHeaderRE := regexp.MustCompile(`(?m)^(Examples|Flags|Usage|Global Flags|Aliases|Available Commands):`)
|
||||
result = sectionHeaderRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
return ui.RenderAccent(match)
|
||||
})
|
||||
|
||||
// match command lines: " command Description text"
|
||||
// commands are indented with 2 spaces, followed by spaces, then description
|
||||
// pattern matches: indent + command-name (with hyphens) + spacing + description
|
||||
cmdLineRE := regexp.MustCompile(`(?m)^( )([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(\s{2,})(.*)$`)
|
||||
|
||||
result = cmdLineRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := cmdLineRE.FindStringSubmatch(match)
|
||||
if len(parts) != 5 {
|
||||
return match
|
||||
}
|
||||
indent := parts[1]
|
||||
cmdName := parts[2]
|
||||
spacing := parts[3]
|
||||
description := parts[4]
|
||||
|
||||
// colorize command references in description (e.g., 'comments add')
|
||||
description = colorizeCommandRefs(description)
|
||||
|
||||
// highlight entry point hints (e.g., "(start here)")
|
||||
description = highlightEntryPoints(description)
|
||||
|
||||
// subtle styling on command name for scanability
|
||||
return indent + ui.RenderCommand(cmdName) + spacing + description
|
||||
})
|
||||
|
||||
// match flag lines: " -f, --file string Description"
|
||||
// pattern: indent + flags + spacing + optional type + description
|
||||
flagLineRE := regexp.MustCompile(`(?m)^(\s+)(-\w,\s+--[\w-]+|--[\w-]+)(\s+)(string|int|duration|bool)?(\s*.*)$`)
|
||||
result = flagLineRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := flagLineRE.FindStringSubmatch(match)
|
||||
if len(parts) < 6 {
|
||||
return match
|
||||
}
|
||||
indent := parts[1]
|
||||
flags := parts[2]
|
||||
spacing := parts[3]
|
||||
typeStr := parts[4]
|
||||
desc := parts[5]
|
||||
|
||||
// mute default values in description
|
||||
desc = muteDefaults(desc)
|
||||
|
||||
if typeStr != "" {
|
||||
return indent + ui.RenderCommand(flags) + spacing + ui.RenderMuted(typeStr) + desc
|
||||
}
|
||||
return indent + ui.RenderCommand(flags) + spacing + desc
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// muteDefaults applies muted styling to default value annotations
|
||||
func muteDefaults(text string) string {
|
||||
defaultRE := regexp.MustCompile(`(\(default[^)]*\))`)
|
||||
return defaultRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return ui.RenderMuted(match)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightEntryPoints applies accent styling to entry point hints like "(start here)"
|
||||
func highlightEntryPoints(text string) string {
|
||||
entryRE := regexp.MustCompile(`(\(start here\))`)
|
||||
return entryRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return ui.RenderAccent(match)
|
||||
})
|
||||
}
|
||||
|
||||
// colorizeCommandRefs applies command styling to references in text
|
||||
// Matches patterns like 'command name' or 'bd command'
|
||||
func colorizeCommandRefs(text string) string {
|
||||
// match 'command words' in single quotes (e.g., 'comments add')
|
||||
cmdRefRE := regexp.MustCompile(`'([a-z][a-z0-9 -]+)'`)
|
||||
|
||||
return cmdRefRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// extract the command name without quotes
|
||||
inner := match[1 : len(match)-1]
|
||||
return "'" + ui.RenderCommand(inner) + "'"
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Set custom help function for colorized output
|
||||
rootCmd.SetHelpFunc(colorizedHelpFunc)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
@@ -172,7 +173,7 @@ func runHook(_ *cobra.Command, args []string) error {
|
||||
// Close completed molecule bead (use bd close --force for pinned)
|
||||
closeArgs := []string{"close", existing.ID, "--force",
|
||||
"--reason=Auto-replaced by gt hook (molecule complete)"}
|
||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
||||
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||
}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
|
||||
@@ -105,12 +105,14 @@ func discoverHooks(townRoot string) ([]HookInfo, error) {
|
||||
var hooks []HookInfo
|
||||
|
||||
// Scan known locations for .claude/settings.json
|
||||
// NOTE: Mayor settings are at ~/gt/mayor/.claude/, NOT ~/gt/.claude/
|
||||
// Settings at town root would pollute all child workspaces.
|
||||
locations := []struct {
|
||||
path string
|
||||
agent string
|
||||
}{
|
||||
{filepath.Join(townRoot, "mayor", ".claude", "settings.json"), "mayor/"},
|
||||
{filepath.Join(townRoot, ".claude", "settings.json"), "town-root"},
|
||||
{filepath.Join(townRoot, "deacon", ".claude", "settings.json"), "deacon/"},
|
||||
}
|
||||
|
||||
// Scan rigs
|
||||
@@ -137,7 +139,7 @@ func discoverHooks(townRoot string) ([]HookInfo, error) {
|
||||
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||
if polecats, err := os.ReadDir(polecatsDir); err == nil {
|
||||
for _, p := range polecats {
|
||||
if p.IsDir() {
|
||||
if p.IsDir() && !strings.HasPrefix(p.Name(), ".") {
|
||||
locations = append(locations, struct {
|
||||
path string
|
||||
agent string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/version"
|
||||
)
|
||||
|
||||
var infoCmd = &cobra.Command{
|
||||
@@ -39,7 +40,7 @@ Examples:
|
||||
}
|
||||
|
||||
if commit := resolveCommitHash(); commit != "" {
|
||||
info["commit"] = shortCommit(commit)
|
||||
info["commit"] = version.ShortCommit(commit)
|
||||
}
|
||||
if branch := resolveBranch(); branch != "" {
|
||||
info["branch"] = branch
|
||||
|
||||
@@ -3,10 +3,12 @@ package cmd
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
@@ -80,6 +82,16 @@ func runInit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" ✓ Updated .git/info/exclude\n")
|
||||
}
|
||||
|
||||
// Register custom beads types for Gas Town (agent, role, rig, convoy, slot).
|
||||
// This is best-effort: if beads isn't installed or DB doesn't exist, we skip.
|
||||
// The doctor check will catch missing types later.
|
||||
if err := registerCustomTypes(cwd); err != nil {
|
||||
fmt.Printf(" %s Could not register custom types: %v\n",
|
||||
style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Registered custom beads types\n")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Rig initialized with %d directories.\n",
|
||||
style.Bold.Render("✓"), created)
|
||||
fmt.Println()
|
||||
@@ -127,3 +139,34 @@ func updateGitExclude(repoPath string) error {
|
||||
// Write back
|
||||
return os.WriteFile(excludePath, append(content, []byte(additions)...), 0644)
|
||||
}
|
||||
|
||||
// registerCustomTypes registers Gas Town custom issue types with beads.
|
||||
// This is best-effort: returns nil if beads isn't available or DB doesn't exist.
|
||||
// Handles gracefully: beads not installed, no .beads directory, or config errors.
|
||||
func registerCustomTypes(workDir string) error {
|
||||
// Check if bd command is available
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
return nil // beads not installed, skip silently
|
||||
}
|
||||
|
||||
// Check if .beads directory exists
|
||||
beadsDir := filepath.Join(workDir, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return nil // no beads DB yet, skip silently
|
||||
}
|
||||
|
||||
// Try to set custom types
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
||||
cmd.Dir = workDir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Check for common expected errors
|
||||
outStr := string(output)
|
||||
if strings.Contains(outStr, "not initialized") ||
|
||||
strings.Contains(outStr, "no such file") {
|
||||
return nil // DB not initialized, skip silently
|
||||
}
|
||||
return fmt.Errorf("%s", strings.TrimSpace(outStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/deps"
|
||||
"github.com/steveyegge/gastown/internal/formula"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/shell"
|
||||
"github.com/steveyegge/gastown/internal/state"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/templates"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
"github.com/steveyegge/gastown/internal/wrappers"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,6 +33,8 @@ var (
|
||||
installGit bool
|
||||
installGitHub string
|
||||
installPublic bool
|
||||
installShell bool
|
||||
installWrappers bool
|
||||
)
|
||||
|
||||
var installCmd = &cobra.Command{
|
||||
@@ -55,7 +60,8 @@ Examples:
|
||||
gt install ~/gt --no-beads # Skip .beads/ initialization
|
||||
gt install ~/gt --git # Also init git with .gitignore
|
||||
gt install ~/gt --github=user/repo # Create private GitHub repo (default)
|
||||
gt install ~/gt --github=user/repo --public # Create public GitHub repo`,
|
||||
gt install ~/gt --github=user/repo --public # Create public GitHub repo
|
||||
gt install ~/gt --shell # Install shell integration (sets GT_TOWN_ROOT/GT_RIG)`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runInstall,
|
||||
}
|
||||
@@ -69,6 +75,8 @@ func init() {
|
||||
installCmd.Flags().BoolVar(&installGit, "git", false, "Initialize git with .gitignore")
|
||||
installCmd.Flags().StringVar(&installGitHub, "github", "", "Create GitHub repo (format: owner/repo, private by default)")
|
||||
installCmd.Flags().BoolVar(&installPublic, "public", false, "Make GitHub repo public (use with --github)")
|
||||
installCmd.Flags().BoolVar(&installShell, "shell", false, "Install shell integration (sets GT_TOWN_ROOT/GT_RIG env vars)")
|
||||
installCmd.Flags().BoolVar(&installWrappers, "wrappers", false, "Install gt-codex/gt-opencode wrapper scripts to ~/bin/")
|
||||
rootCmd.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
@@ -172,20 +180,46 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Printf(" ✓ Created mayor/rigs.json\n")
|
||||
|
||||
// Create Mayor CLAUDE.md at HQ root (Mayor runs from there)
|
||||
if err := createMayorCLAUDEmd(absPath, absPath); err != nil {
|
||||
// Create Mayor CLAUDE.md at mayor/ (Mayor's canonical home)
|
||||
// IMPORTANT: CLAUDE.md must be in ~/gt/mayor/, NOT ~/gt/
|
||||
// CLAUDE.md at town root would be inherited by ALL agents via directory traversal,
|
||||
// causing crew/polecat/etc to receive Mayor-specific instructions.
|
||||
if err := createMayorCLAUDEmd(mayorDir, absPath); err != nil {
|
||||
fmt.Printf(" %s Could not create CLAUDE.md: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created CLAUDE.md\n")
|
||||
fmt.Printf(" ✓ Created mayor/CLAUDE.md\n")
|
||||
}
|
||||
|
||||
// Ensure Mayor has Claude settings with SessionStart hooks.
|
||||
// This ensures gt prime runs on Claude startup, which outputs the Mayor
|
||||
// delegation protocol - critical for preventing direct implementation.
|
||||
if err := claude.EnsureSettingsForRole(absPath, "mayor"); err != nil {
|
||||
fmt.Printf(" %s Could not create .claude/settings.json: %v\n", style.Dim.Render("⚠"), err)
|
||||
// Create mayor settings (mayor runs from ~/gt/mayor/)
|
||||
// IMPORTANT: Settings must be in ~/gt/mayor/.claude/, NOT ~/gt/.claude/
|
||||
// Settings at town root would be found by ALL agents via directory traversal,
|
||||
// causing crew/polecat/etc to cd to town root before running commands.
|
||||
// mayorDir already defined above
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
fmt.Printf(" %s Could not create mayor directory: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else if err := claude.EnsureSettingsForRole(mayorDir, "mayor"); err != nil {
|
||||
fmt.Printf(" %s Could not create mayor settings: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created .claude/settings.json\n")
|
||||
fmt.Printf(" ✓ Created mayor/.claude/settings.json\n")
|
||||
}
|
||||
|
||||
// Create deacon directory and settings (deacon runs from ~/gt/deacon/)
|
||||
deaconDir := filepath.Join(absPath, "deacon")
|
||||
if err := os.MkdirAll(deaconDir, 0755); err != nil {
|
||||
fmt.Printf(" %s Could not create deacon directory: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
||||
fmt.Printf(" %s Could not create deacon settings: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created deacon/.claude/settings.json\n")
|
||||
}
|
||||
|
||||
// Initialize git BEFORE beads so that bd can compute repository fingerprint.
|
||||
// The fingerprint is required for the daemon to start properly.
|
||||
if installGit || installGitHub != "" {
|
||||
fmt.Println()
|
||||
if err := InitGitForHarness(absPath, installGitHub, !installPublic); err != nil {
|
||||
return fmt.Errorf("git initialization failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize town-level beads database (optional)
|
||||
@@ -234,11 +268,26 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" ✓ Created .claude/commands/ (slash commands for all agents)\n")
|
||||
}
|
||||
|
||||
// Initialize git if requested (--git or --github implies --git)
|
||||
if installGit || installGitHub != "" {
|
||||
if installShell {
|
||||
fmt.Println()
|
||||
if err := InitGitForHarness(absPath, installGitHub, !installPublic); err != nil {
|
||||
return fmt.Errorf("git initialization failed: %w", err)
|
||||
if err := shell.Install(); err != nil {
|
||||
fmt.Printf(" %s Could not install shell integration: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Installed shell integration (%s)\n", shell.RCFilePath(shell.DetectShell()))
|
||||
}
|
||||
if err := state.Enable(Version); err != nil {
|
||||
fmt.Printf(" %s Could not enable Gas Town: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Enabled Gas Town globally\n")
|
||||
}
|
||||
}
|
||||
|
||||
if installWrappers {
|
||||
fmt.Println()
|
||||
if err := wrappers.Install(); err != nil {
|
||||
fmt.Printf(" %s Could not install wrapper scripts: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Installed gt-codex and gt-opencode to %s\n", wrappers.BinDir())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,36 +301,31 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Printf(" %d. Add a rig: %s\n", step, style.Dim.Render("gt rig add <name> <git-url>"))
|
||||
step++
|
||||
fmt.Printf(" %d. (Optional) Configure agents: %s\n", step, style.Dim.Render("gt config agent list"))
|
||||
step++
|
||||
fmt.Printf(" %d. Enter the Mayor's office: %s\n", step, style.Dim.Render("gt mayor attach"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createMayorCLAUDEmd(hqRoot, townRoot string) error {
|
||||
tmpl, err := templates.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func createMayorCLAUDEmd(mayorDir, townRoot string) error {
|
||||
// Create a minimal bootstrap pointer instead of full context.
|
||||
// Full context is injected ephemerally by `gt prime` at session start.
|
||||
// This keeps the on-disk file small (<30 lines) per priming architecture.
|
||||
bootstrap := `# Mayor Context
|
||||
|
||||
// Get town name for session names
|
||||
townName, _ := workspace.GetTownName(townRoot)
|
||||
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
||||
|
||||
data := templates.RoleData{
|
||||
Role: "mayor",
|
||||
TownRoot: townRoot,
|
||||
TownName: townName,
|
||||
WorkDir: hqRoot,
|
||||
MayorSession: session.MayorSessionName(),
|
||||
DeaconSession: session.DeaconSessionName(),
|
||||
}
|
||||
Full context is injected by ` + "`gt prime`" + ` at session start.
|
||||
|
||||
content, err := tmpl.RenderRole("mayor", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
## Quick Reference
|
||||
|
||||
claudePath := filepath.Join(hqRoot, "CLAUDE.md")
|
||||
return os.WriteFile(claudePath, []byte(content), 0644)
|
||||
- Check mail: ` + "`gt mail inbox`" + `
|
||||
- Check rigs: ` + "`gt rig list`" + `
|
||||
- Start patrol: ` + "`gt patrol start`" + `
|
||||
`
|
||||
claudePath := filepath.Join(mayorDir, "CLAUDE.md")
|
||||
return os.WriteFile(claudePath, []byte(bootstrap), 0644)
|
||||
}
|
||||
|
||||
func writeJSON(path string, data interface{}) error {
|
||||
@@ -309,6 +353,15 @@ func initTownBeads(townPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure custom types for Gas Town (agent, role, rig, convoy, slot).
|
||||
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
||||
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
||||
configCmd.Dir = townPath
|
||||
if configOutput, configErr := configCmd.CombinedOutput(); configErr != nil {
|
||||
// Non-fatal: older beads versions don't need this, newer ones do
|
||||
fmt.Printf(" %s Could not set custom types: %s\n", style.Dim.Render("⚠"), strings.TrimSpace(string(configOutput)))
|
||||
}
|
||||
|
||||
// Ensure database has repository fingerprint (GH #25).
|
||||
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
||||
// Without fingerprint, the bd daemon fails to start silently.
|
||||
@@ -317,6 +370,13 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Ensure routes.jsonl has an explicit town-level mapping for hq-* beads.
|
||||
// This keeps hq-* operations stable even when invoked from rig worktrees.
|
||||
if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-", Path: "."}); err != nil {
|
||||
// Non-fatal: routing still works in many contexts, but explicit mapping is preferred.
|
||||
fmt.Printf(" %s Could not update routes.jsonl: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -333,11 +393,25 @@ func ensureRepoFingerprint(beadsPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureCustomTypes registers Gas Town custom issue types with beads.
|
||||
// Beads core only supports built-in types (bug, feature, task, etc.).
|
||||
// Gas Town needs custom types: agent, role, rig, convoy, slot.
|
||||
// This is idempotent - safe to call multiple times.
|
||||
func ensureCustomTypes(beadsPath string) error {
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
||||
cmd.Dir = beadsPath
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("bd config set types.custom: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initTownAgentBeads creates town-level agent and role beads using hq- prefix.
|
||||
// This creates:
|
||||
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
||||
// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role,
|
||||
// hq-polecat-role, hq-crew-role (role definition beads)
|
||||
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
||||
// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role,
|
||||
// hq-polecat-role, hq-crew-role (role definition beads)
|
||||
//
|
||||
// These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs.
|
||||
// Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads.
|
||||
@@ -354,6 +428,13 @@ func ensureRepoFingerprint(beadsPath string) error {
|
||||
func initTownAgentBeads(townPath string) error {
|
||||
bd := beads.New(townPath)
|
||||
|
||||
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
||||
// agent/role beads during install and runtime. Ensure these types are enabled
|
||||
// before attempting to create any town-level system beads.
|
||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Role beads (global templates)
|
||||
roleDefs := []struct {
|
||||
id string
|
||||
@@ -472,3 +553,17 @@ func initTownAgentBeads(townPath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureBeadsCustomTypes(workDir string, types []string) error {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", strings.Join(types, ","))
|
||||
cmd.Dir = workDir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("bd config set types.custom failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,9 +61,18 @@ func TestInstallCreatesCorrectStructure(t *testing.T) {
|
||||
t.Errorf("rigs.json should be empty, got %d rigs", len(rigsConfig.Rigs))
|
||||
}
|
||||
|
||||
// Verify CLAUDE.md exists
|
||||
claudePath := filepath.Join(hqPath, "CLAUDE.md")
|
||||
assertFileExists(t, claudePath, "CLAUDE.md")
|
||||
// Verify CLAUDE.md exists in mayor/ (not town root, to avoid inheritance pollution)
|
||||
claudePath := filepath.Join(hqPath, "mayor", "CLAUDE.md")
|
||||
assertFileExists(t, claudePath, "mayor/CLAUDE.md")
|
||||
|
||||
// Verify Claude settings exist in mayor/.claude/ (not town root/.claude/)
|
||||
// Mayor settings go here to avoid polluting child workspaces via directory traversal
|
||||
mayorSettingsPath := filepath.Join(hqPath, "mayor", ".claude", "settings.json")
|
||||
assertFileExists(t, mayorSettingsPath, "mayor/.claude/settings.json")
|
||||
|
||||
// Verify deacon settings exist in deacon/.claude/
|
||||
deaconSettingsPath := filepath.Join(hqPath, "deacon", ".claude", "settings.json")
|
||||
assertFileExists(t, deaconSettingsPath, "deacon/.claude/settings.json")
|
||||
}
|
||||
|
||||
// TestInstallBeadsHasCorrectPrefix validates that beads is initialized
|
||||
@@ -134,6 +143,21 @@ func TestInstallTownRoleSlots(t *testing.T) {
|
||||
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Log install output for CI debugging
|
||||
t.Logf("gt install output:\n%s", output)
|
||||
|
||||
// Verify beads directory was created
|
||||
beadsDir := filepath.Join(hqPath, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
t.Fatalf("beads directory not created at %s", beadsDir)
|
||||
}
|
||||
|
||||
// List beads for debugging
|
||||
listCmd := exec.Command("bd", "--no-daemon", "list", "--type=agent")
|
||||
listCmd.Dir = hqPath
|
||||
listOutput, _ := listCmd.CombinedOutput()
|
||||
t.Logf("bd list --type=agent output:\n%s", listOutput)
|
||||
|
||||
assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role")
|
||||
assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role")
|
||||
}
|
||||
|
||||
1602
internal/cmd/mail.go
1602
internal/cmd/mail.go
File diff suppressed because it is too large
Load Diff
248
internal/cmd/mail_announce.go
Normal file
248
internal/cmd/mail_announce.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// runMailAnnounces lists announce channels or reads messages from a channel.
|
||||
func runMailAnnounces(cmd *cobra.Command, args []string) error {
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Load messaging config
|
||||
configPath := config.MessagingConfigPath(townRoot)
|
||||
cfg, err := config.LoadMessagingConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading messaging config: %w", err)
|
||||
}
|
||||
|
||||
// If no channel specified, list all channels
|
||||
if len(args) == 0 {
|
||||
return listAnnounceChannels(cfg)
|
||||
}
|
||||
|
||||
// Read messages from specified channel
|
||||
channelName := args[0]
|
||||
return readAnnounceChannel(townRoot, cfg, channelName)
|
||||
}
|
||||
|
||||
// listAnnounceChannels lists all announce channels and their configuration.
|
||||
func listAnnounceChannels(cfg *config.MessagingConfig) error {
|
||||
if cfg.Announces == nil || len(cfg.Announces) == 0 {
|
||||
if mailAnnouncesJSON {
|
||||
fmt.Println("[]")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s No announce channels configured\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailAnnouncesJSON {
|
||||
type channelInfo struct {
|
||||
Name string `json:"name"`
|
||||
Readers []string `json:"readers"`
|
||||
RetainCount int `json:"retain_count"`
|
||||
}
|
||||
var channels []channelInfo
|
||||
for name, annCfg := range cfg.Announces {
|
||||
channels = append(channels, channelInfo{
|
||||
Name: name,
|
||||
Readers: annCfg.Readers,
|
||||
RetainCount: annCfg.RetainCount,
|
||||
})
|
||||
}
|
||||
// Sort by name for consistent output
|
||||
sort.Slice(channels, func(i, j int) bool {
|
||||
return channels[i].Name < channels[j].Name
|
||||
})
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(channels)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Announce Channels (%d)\n\n", style.Bold.Render("📢"), len(cfg.Announces))
|
||||
|
||||
// Sort channel names for consistent output
|
||||
var names []string
|
||||
for name := range cfg.Announces {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
annCfg := cfg.Announces[name]
|
||||
retainStr := "unlimited"
|
||||
if annCfg.RetainCount > 0 {
|
||||
retainStr = fmt.Sprintf("%d messages", annCfg.RetainCount)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render("●"), name)
|
||||
fmt.Printf(" Readers: %s\n", strings.Join(annCfg.Readers, ", "))
|
||||
fmt.Printf(" Retain: %s\n", style.Dim.Render(retainStr))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readAnnounceChannel reads messages from an announce channel.
|
||||
func readAnnounceChannel(townRoot string, cfg *config.MessagingConfig, channelName string) error {
|
||||
// Validate channel exists
|
||||
if cfg.Announces == nil {
|
||||
return fmt.Errorf("no announce channels configured")
|
||||
}
|
||||
_, ok := cfg.Announces[channelName]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown announce channel: %s", channelName)
|
||||
}
|
||||
|
||||
// Query beads for messages with announce_channel=<channel>
|
||||
messages, err := listAnnounceMessages(townRoot, channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing announce messages: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailAnnouncesJSON {
|
||||
// Ensure empty array instead of null for JSON
|
||||
if messages == nil {
|
||||
messages = []announceMessage{}
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(messages)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Channel: %s (%d messages)\n\n",
|
||||
style.Bold.Render("📢"), channelName, len(messages))
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
priorityMarker := ""
|
||||
if msg.Priority <= 1 {
|
||||
priorityMarker = " " + style.Bold.Render("!")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s\n", style.Bold.Render("●"), msg.Title, priorityMarker)
|
||||
fmt.Printf(" %s from %s\n",
|
||||
style.Dim.Render(msg.ID),
|
||||
msg.From)
|
||||
fmt.Printf(" %s\n",
|
||||
style.Dim.Render(msg.Created.Format("2006-01-02 15:04")))
|
||||
if msg.Description != "" {
|
||||
// Show first line of description as preview
|
||||
lines := strings.SplitN(msg.Description, "\n", 2)
|
||||
preview := lines[0]
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" %s\n", style.Dim.Render(preview))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// announceMessage represents a message in an announce channel.
|
||||
type announceMessage struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
From string `json:"from"`
|
||||
Created time.Time `json:"created"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// listAnnounceMessages lists messages from an announce channel.
|
||||
func listAnnounceMessages(townRoot, channelName string) ([]announceMessage, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// Query for messages with label announce_channel:<channel>
|
||||
// Messages are stored with this label when sent via sendToAnnounce()
|
||||
args := []string{"list",
|
||||
"--type", "message",
|
||||
"--label", "announce_channel:" + channelName,
|
||||
"--sort", "-created", // Newest first
|
||||
"--limit", "0", // No limit
|
||||
"--json",
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Labels []string `json:"labels"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(stdout.String())
|
||||
if output == "" || output == "[]" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to announceMessage, extracting 'from' from labels
|
||||
var messages []announceMessage
|
||||
for _, issue := range issues {
|
||||
msg := announceMessage{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Description: issue.Description,
|
||||
Created: issue.CreatedAt,
|
||||
Priority: issue.Priority,
|
||||
}
|
||||
|
||||
// Extract 'from' from labels (format: "from:address")
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "from:") {
|
||||
msg.From = strings.TrimPrefix(label, "from:")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
92
internal/cmd/mail_check.go
Normal file
92
internal/cmd/mail_check.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
func runMailCheck(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox (priority: --identity flag, auto-detect)
|
||||
address := ""
|
||||
if mailCheckIdentity != "" {
|
||||
address = mailCheckIdentity
|
||||
} else {
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
if mailCheckInject {
|
||||
// Inject mode: always exit 0, silent on error
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
if mailCheckInject {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Count unread
|
||||
_, unread, err := mailbox.Count()
|
||||
if err != nil {
|
||||
if mailCheckInject {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("counting messages: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailCheckJSON {
|
||||
result := map[string]interface{}{
|
||||
"address": address,
|
||||
"unread": unread,
|
||||
"has_new": unread > 0,
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(result)
|
||||
}
|
||||
|
||||
// Inject mode: output system-reminder if mail exists
|
||||
if mailCheckInject {
|
||||
if unread > 0 {
|
||||
// Get subjects for context
|
||||
messages, _ := mailbox.ListUnread()
|
||||
var subjects []string
|
||||
for _, msg := range messages {
|
||||
subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject))
|
||||
}
|
||||
|
||||
fmt.Println("<system-reminder>")
|
||||
fmt.Printf("You have %d unread message(s) in your inbox.\n\n", unread)
|
||||
for _, s := range subjects {
|
||||
fmt.Println(s)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("Run 'gt mail inbox' to see your messages, or 'gt mail read <id>' for a specific message.")
|
||||
fmt.Println("</system-reminder>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normal mode
|
||||
if unread > 0 {
|
||||
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
|
||||
return NewSilentExit(0)
|
||||
}
|
||||
fmt.Println("No new mail")
|
||||
return NewSilentExit(1)
|
||||
}
|
||||
187
internal/cmd/mail_identity.go
Normal file
187
internal/cmd/mail_identity.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// findMailWorkDir returns the town root for all mail operations.
|
||||
//
|
||||
// Two-level beads architecture:
|
||||
// - Town beads (~/gt/.beads/): ALL mail and coordination
|
||||
// - Clone beads (<rig>/crew/*/.beads/): Project issues only
|
||||
//
|
||||
// Mail ALWAYS uses town beads, regardless of sender or recipient address.
|
||||
// This ensures messages are visible to all agents in the town.
|
||||
func findMailWorkDir() (string, error) {
|
||||
return workspace.FindFromCwdOrError()
|
||||
}
|
||||
|
||||
// findLocalBeadsDir finds the nearest .beads directory by walking up from CWD.
|
||||
// Used for project work (molecules, issue creation) that uses clone beads.
|
||||
//
|
||||
// Priority:
|
||||
// 1. BEADS_DIR environment variable (set by session manager for polecats)
|
||||
// 2. Walk up from CWD looking for .beads directory
|
||||
//
|
||||
// Polecats use redirect-based beads access, so their worktree doesn't have a full
|
||||
// .beads directory. The session manager sets BEADS_DIR to the correct location.
|
||||
func findLocalBeadsDir() (string, error) {
|
||||
// Check BEADS_DIR environment variable first (set by session manager for polecats).
|
||||
// This is important for polecats that use redirect-based beads access.
|
||||
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
||||
// BEADS_DIR points directly to the .beads directory, return its parent
|
||||
if _, err := os.Stat(beadsDir); err == nil {
|
||||
return filepath.Dir(beadsDir), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: walk up from CWD
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
break // Reached root
|
||||
}
|
||||
path = parent
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// detectSender determines the current context's address.
|
||||
// Priority:
|
||||
// 1. GT_ROLE env var → use the role-based identity (agent session)
|
||||
// 2. No GT_ROLE → try cwd-based detection (witness/refinery/polecat/crew directories)
|
||||
// 3. No match → return "overseer" (human at terminal)
|
||||
//
|
||||
// All Gas Town agents run in tmux sessions with GT_ROLE set at spawn.
|
||||
// However, cwd-based detection is also tried to support running commands
|
||||
// from agent directories without GT_ROLE set (e.g., debugging sessions).
|
||||
func detectSender() string {
|
||||
// Check GT_ROLE first (authoritative for agent sessions)
|
||||
role := os.Getenv("GT_ROLE")
|
||||
if role != "" {
|
||||
// Agent session - build address from role and context
|
||||
return detectSenderFromRole(role)
|
||||
}
|
||||
|
||||
// No GT_ROLE - try cwd-based detection, defaults to overseer if not in agent directory
|
||||
return detectSenderFromCwd()
|
||||
}
|
||||
|
||||
// detectSenderFromRole builds an address from the GT_ROLE and related env vars.
|
||||
// GT_ROLE can be either a simple role name ("crew", "polecat") or a full address
|
||||
// ("greenplace/crew/joe") depending on how the session was started.
|
||||
//
|
||||
// If GT_ROLE is a simple name but required env vars (GT_RIG, GT_POLECAT, etc.)
|
||||
// are missing, falls back to cwd-based detection. This could return "overseer"
|
||||
// if cwd doesn't match any known agent path - a misconfigured agent session.
|
||||
func detectSenderFromRole(role string) string {
|
||||
rig := os.Getenv("GT_RIG")
|
||||
|
||||
// Check if role is already a full address (contains /)
|
||||
if strings.Contains(role, "/") {
|
||||
// GT_ROLE is already a full address, use it directly
|
||||
return role
|
||||
}
|
||||
|
||||
// GT_ROLE is a simple role name, build the full address
|
||||
switch role {
|
||||
case "mayor":
|
||||
return "mayor/"
|
||||
case "deacon":
|
||||
return "deacon/"
|
||||
case "polecat":
|
||||
polecat := os.Getenv("GT_POLECAT")
|
||||
if rig != "" && polecat != "" {
|
||||
return fmt.Sprintf("%s/%s", rig, polecat)
|
||||
}
|
||||
// Fallback to cwd detection for polecats
|
||||
return detectSenderFromCwd()
|
||||
case "crew":
|
||||
crew := os.Getenv("GT_CREW")
|
||||
if rig != "" && crew != "" {
|
||||
return fmt.Sprintf("%s/crew/%s", rig, crew)
|
||||
}
|
||||
// Fallback to cwd detection for crew
|
||||
return detectSenderFromCwd()
|
||||
case "witness":
|
||||
if rig != "" {
|
||||
return fmt.Sprintf("%s/witness", rig)
|
||||
}
|
||||
return detectSenderFromCwd()
|
||||
case "refinery":
|
||||
if rig != "" {
|
||||
return fmt.Sprintf("%s/refinery", rig)
|
||||
}
|
||||
return detectSenderFromCwd()
|
||||
default:
|
||||
// Unknown role, try cwd detection
|
||||
return detectSenderFromCwd()
|
||||
}
|
||||
}
|
||||
|
||||
// detectSenderFromCwd is the legacy cwd-based detection for edge cases.
|
||||
func detectSenderFromCwd() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "overseer"
|
||||
}
|
||||
|
||||
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
|
||||
if strings.Contains(cwd, "/polecats/") {
|
||||
parts := strings.Split(cwd, "/polecats/")
|
||||
if len(parts) >= 2 {
|
||||
rigPath := parts[0]
|
||||
polecatPath := strings.Split(parts[1], "/")[0]
|
||||
rigName := filepath.Base(rigPath)
|
||||
return fmt.Sprintf("%s/polecats/%s", rigName, polecatPath)
|
||||
}
|
||||
}
|
||||
|
||||
// If in a rig's crew directory, extract address (format: rig/crew/name)
|
||||
if strings.Contains(cwd, "/crew/") {
|
||||
parts := strings.Split(cwd, "/crew/")
|
||||
if len(parts) >= 2 {
|
||||
rigPath := parts[0]
|
||||
crewName := strings.Split(parts[1], "/")[0]
|
||||
rigName := filepath.Base(rigPath)
|
||||
return fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
||||
}
|
||||
}
|
||||
|
||||
// If in a rig's refinery directory, extract address (format: rig/refinery)
|
||||
if strings.Contains(cwd, "/refinery") {
|
||||
parts := strings.Split(cwd, "/refinery")
|
||||
if len(parts) >= 1 {
|
||||
rigName := filepath.Base(parts[0])
|
||||
return fmt.Sprintf("%s/refinery", rigName)
|
||||
}
|
||||
}
|
||||
|
||||
// If in a rig's witness directory, extract address (format: rig/witness)
|
||||
if strings.Contains(cwd, "/witness") {
|
||||
parts := strings.Split(cwd, "/witness")
|
||||
if len(parts) >= 1 {
|
||||
rigName := filepath.Base(parts[0])
|
||||
return fmt.Sprintf("%s/witness", rigName)
|
||||
}
|
||||
}
|
||||
|
||||
// Default to overseer (human)
|
||||
return "overseer"
|
||||
}
|
||||
444
internal/cmd/mail_inbox.go
Normal file
444
internal/cmd/mail_inbox.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox to check (priority: --identity flag, positional arg, auto-detect)
|
||||
address := ""
|
||||
if mailInboxIdentity != "" {
|
||||
address = mailInboxIdentity
|
||||
} else if len(args) > 0 {
|
||||
address = args[0]
|
||||
} else {
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Get messages
|
||||
var messages []*mail.Message
|
||||
if mailInboxUnread {
|
||||
messages, err = mailbox.ListUnread()
|
||||
} else {
|
||||
messages, err = mailbox.List()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing messages: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailInboxJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(messages)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
total, unread, _ := mailbox.Count()
|
||||
fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n",
|
||||
style.Bold.Render("📬"), address, total, unread)
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
readMarker := "●"
|
||||
if msg.Read {
|
||||
readMarker = "○"
|
||||
}
|
||||
typeMarker := ""
|
||||
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||
}
|
||||
priorityMarker := ""
|
||||
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||
priorityMarker = " " + style.Bold.Render("!")
|
||||
}
|
||||
wispMarker := ""
|
||||
if msg.Wisp {
|
||||
wispMarker = " " + style.Dim.Render("(wisp)")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
||||
fmt.Printf(" %s from %s\n",
|
||||
style.Dim.Render(msg.ID),
|
||||
msg.From)
|
||||
fmt.Printf(" %s\n",
|
||||
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailRead(cmd *cobra.Command, args []string) error {
|
||||
msgID := args[0]
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox and message
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
msg, err := mailbox.Get(msgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
|
||||
// Note: We intentionally do NOT mark as read/ack on read.
|
||||
// User must explicitly delete/ack the message.
|
||||
// This preserves handoff messages for reference.
|
||||
|
||||
// JSON output
|
||||
if mailReadJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(msg)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
priorityStr := ""
|
||||
if msg.Priority == mail.PriorityUrgent {
|
||||
priorityStr = " " + style.Bold.Render("[URGENT]")
|
||||
} else if msg.Priority == mail.PriorityHigh {
|
||||
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
|
||||
}
|
||||
|
||||
typeStr := ""
|
||||
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||
typeStr = fmt.Sprintf(" [%s]", msg.Type)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, priorityStr)
|
||||
fmt.Printf("From: %s\n", msg.From)
|
||||
fmt.Printf("To: %s\n", msg.To)
|
||||
fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID))
|
||||
|
||||
if msg.ThreadID != "" {
|
||||
fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID))
|
||||
}
|
||||
if msg.ReplyTo != "" {
|
||||
fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo))
|
||||
}
|
||||
|
||||
if msg.Body != "" {
|
||||
fmt.Printf("\n%s\n", msg.Body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailPeek(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return NewSilentExit(1) // Silent exit - no workspace
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return NewSilentExit(1) // Silent exit - can't access mailbox
|
||||
}
|
||||
|
||||
// Get unread messages
|
||||
messages, err := mailbox.ListUnread()
|
||||
if err != nil || len(messages) == 0 {
|
||||
return NewSilentExit(1) // Silent exit - no unread
|
||||
}
|
||||
|
||||
// Show first unread message
|
||||
msg := messages[0]
|
||||
|
||||
// Header with priority indicator
|
||||
priorityStr := ""
|
||||
if msg.Priority == mail.PriorityUrgent {
|
||||
priorityStr = " [URGENT]"
|
||||
} else if msg.Priority == mail.PriorityHigh {
|
||||
priorityStr = " [!]"
|
||||
}
|
||||
|
||||
fmt.Printf("📬 %s%s\n", msg.Subject, priorityStr)
|
||||
fmt.Printf("From: %s\n", msg.From)
|
||||
fmt.Printf("ID: %s\n\n", msg.ID)
|
||||
|
||||
// Body preview (truncate long bodies)
|
||||
if msg.Body != "" {
|
||||
body := msg.Body
|
||||
// Truncate to ~500 chars for popup display
|
||||
if len(body) > 500 {
|
||||
body = body[:500] + "\n..."
|
||||
}
|
||||
fmt.Print(body)
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// Show count if more messages
|
||||
if len(messages) > 1 {
|
||||
fmt.Printf("\n%s\n", style.Dim.Render(fmt.Sprintf("(+%d more unread)", len(messages)-1)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailDelete(cmd *cobra.Command, args []string) error {
|
||||
msgID := args[0]
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
if err := mailbox.Delete(msgID); err != nil {
|
||||
return fmt.Errorf("deleting message: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Message deleted\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailArchive(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Archive all specified messages
|
||||
archived := 0
|
||||
var errors []string
|
||||
for _, msgID := range args {
|
||||
if err := mailbox.Delete(msgID); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
|
||||
} else {
|
||||
archived++
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("%s Archived %d/%d messages\n",
|
||||
style.Bold.Render("⚠"), archived, len(args))
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" Error: %s\n", e)
|
||||
}
|
||||
return fmt.Errorf("failed to archive %d messages", len(errors))
|
||||
}
|
||||
|
||||
if len(args) == 1 {
|
||||
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
|
||||
} else {
|
||||
fmt.Printf("%s Archived %d messages\n", style.Bold.Render("✓"), archived)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailMarkRead(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Mark all specified messages as read
|
||||
marked := 0
|
||||
var errors []string
|
||||
for _, msgID := range args {
|
||||
if err := mailbox.MarkReadOnly(msgID); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
|
||||
} else {
|
||||
marked++
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("%s Marked %d/%d messages as read\n",
|
||||
style.Bold.Render("⚠"), marked, len(args))
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" Error: %s\n", e)
|
||||
}
|
||||
return fmt.Errorf("failed to mark %d messages", len(errors))
|
||||
}
|
||||
|
||||
if len(args) == 1 {
|
||||
fmt.Printf("%s Message marked as read\n", style.Bold.Render("✓"))
|
||||
} else {
|
||||
fmt.Printf("%s Marked %d messages as read\n", style.Bold.Render("✓"), marked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailMarkUnread(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Mark all specified messages as unread
|
||||
marked := 0
|
||||
var errors []string
|
||||
for _, msgID := range args {
|
||||
if err := mailbox.MarkUnreadOnly(msgID); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
|
||||
} else {
|
||||
marked++
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("%s Marked %d/%d messages as unread\n",
|
||||
style.Bold.Render("⚠"), marked, len(args))
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" Error: %s\n", e)
|
||||
}
|
||||
return fmt.Errorf("failed to mark %d messages", len(errors))
|
||||
}
|
||||
|
||||
if len(args) == 1 {
|
||||
fmt.Printf("%s Message marked as unread\n", style.Bold.Render("✓"))
|
||||
} else {
|
||||
fmt.Printf("%s Marked %d messages as unread\n", style.Bold.Render("✓"), marked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailClear(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox to clear (target arg or auto-detect)
|
||||
address := ""
|
||||
if len(args) > 0 {
|
||||
address = args[0]
|
||||
} else {
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// List all messages
|
||||
messages, err := mailbox.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf("%s Inbox %s is already empty\n", style.Dim.Render("○"), address)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete each message
|
||||
deleted := 0
|
||||
var errors []string
|
||||
for _, msg := range messages {
|
||||
if err := mailbox.Delete(msg.ID); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", msg.ID, err))
|
||||
} else {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("%s Cleared %d/%d messages from %s\n",
|
||||
style.Bold.Render("⚠"), deleted, len(messages), address)
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" Error: %s\n", e)
|
||||
}
|
||||
return fmt.Errorf("failed to clear %d messages", len(errors))
|
||||
}
|
||||
|
||||
fmt.Printf("%s Cleared %d messages from %s\n",
|
||||
style.Bold.Render("✓"), deleted, address)
|
||||
return nil
|
||||
}
|
||||
389
internal/cmd/mail_queue.go
Normal file
389
internal/cmd/mail_queue.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// runMailClaim claims the oldest unclaimed message from a work queue.
|
||||
func runMailClaim(cmd *cobra.Command, args []string) error {
|
||||
queueName := args[0]
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Load queue config from messaging.json
|
||||
configPath := config.MessagingConfigPath(townRoot)
|
||||
cfg, err := config.LoadMessagingConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading messaging config: %w", err)
|
||||
}
|
||||
|
||||
queueCfg, ok := cfg.Queues[queueName]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown queue: %s", queueName)
|
||||
}
|
||||
|
||||
// Get caller identity
|
||||
caller := detectSender()
|
||||
|
||||
// Check if caller is eligible (matches any pattern in workers list)
|
||||
if !isEligibleWorker(caller, queueCfg.Workers) {
|
||||
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, workers: %v)",
|
||||
queueName, caller, queueCfg.Workers)
|
||||
}
|
||||
|
||||
// List unclaimed messages in the queue
|
||||
// Queue messages have assignee=queue:<name> and status=open
|
||||
queueAssignee := "queue:" + queueName
|
||||
messages, err := listQueueMessages(townRoot, queueAssignee)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing queue messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf("%s No messages to claim in queue %s\n", style.Dim.Render("○"), queueName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pick the oldest unclaimed message (first in list, sorted by created)
|
||||
oldest := messages[0]
|
||||
|
||||
// Claim the message: set assignee to caller and status to in_progress
|
||||
if err := claimMessage(townRoot, oldest.ID, caller); err != nil {
|
||||
return fmt.Errorf("claiming message: %w", err)
|
||||
}
|
||||
|
||||
// Print claimed message details
|
||||
fmt.Printf("%s Claimed message from queue %s\n", style.Bold.Render("✓"), queueName)
|
||||
fmt.Printf(" ID: %s\n", oldest.ID)
|
||||
fmt.Printf(" Subject: %s\n", oldest.Title)
|
||||
if oldest.Description != "" {
|
||||
// Show first line of description
|
||||
lines := strings.SplitN(oldest.Description, "\n", 2)
|
||||
preview := lines[0]
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" Preview: %s\n", style.Dim.Render(preview))
|
||||
}
|
||||
fmt.Printf(" From: %s\n", oldest.From)
|
||||
fmt.Printf(" Created: %s\n", oldest.Created.Format("2006-01-02 15:04"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// queueMessage represents a message in a queue.
|
||||
type queueMessage struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
From string
|
||||
Created time.Time
|
||||
Priority int
|
||||
}
|
||||
|
||||
// isEligibleWorker checks if the caller matches any pattern in the workers list.
|
||||
// Patterns support wildcards: "gastown/polecats/*" matches "gastown/polecats/capable".
|
||||
func isEligibleWorker(caller string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if matchWorkerPattern(pattern, caller) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchWorkerPattern checks if caller matches the pattern.
|
||||
// Supports simple wildcards: * matches a single path segment (no slashes).
|
||||
func matchWorkerPattern(pattern, caller string) bool {
|
||||
// Handle exact match
|
||||
if pattern == caller {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if strings.Contains(pattern, "*") {
|
||||
// Convert to simple glob matching
|
||||
// "gastown/polecats/*" should match "gastown/polecats/capable"
|
||||
// but NOT "gastown/polecats/sub/capable"
|
||||
parts := strings.Split(pattern, "*")
|
||||
if len(parts) == 2 {
|
||||
prefix := parts[0]
|
||||
suffix := parts[1]
|
||||
if strings.HasPrefix(caller, prefix) && strings.HasSuffix(caller, suffix) {
|
||||
// Check that the middle part doesn't contain path separators
|
||||
middle := caller[len(prefix) : len(caller)-len(suffix)]
|
||||
if !strings.Contains(middle, "/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// listQueueMessages lists unclaimed messages in a queue.
|
||||
func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
// Use bd list to find messages with assignee=queue:<name> and status=open
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"list",
|
||||
"--assignee", queueAssignee,
|
||||
"--status", "open",
|
||||
"--type", "message",
|
||||
"--sort", "created",
|
||||
"--limit", "0", // No limit
|
||||
"--json",
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Labels []string `json:"labels"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
// If no messages, bd might output empty or error
|
||||
if strings.TrimSpace(stdout.String()) == "" || strings.TrimSpace(stdout.String()) == "[]" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to queueMessage, extracting 'from' from labels
|
||||
var messages []queueMessage
|
||||
for _, issue := range issues {
|
||||
msg := queueMessage{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Description: issue.Description,
|
||||
Created: issue.CreatedAt,
|
||||
Priority: issue.Priority,
|
||||
}
|
||||
|
||||
// Extract 'from' from labels (format: "from:address")
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "from:") {
|
||||
msg.From = strings.TrimPrefix(label, "from:")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// Sort by created time (oldest first)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Created.Before(messages[j].Created)
|
||||
})
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// claimMessage claims a message by setting assignee and status.
|
||||
func claimMessage(townRoot, messageID, claimant string) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"update", messageID,
|
||||
"--assignee", claimant,
|
||||
"--status", "in_progress",
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+claimant,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMailRelease releases a claimed queue message back to its queue.
|
||||
func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
messageID := args[0]
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get caller identity
|
||||
caller := detectSender()
|
||||
|
||||
// Get message details to verify ownership and find queue
|
||||
msgInfo, err := getMessageInfo(townRoot, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
|
||||
// Verify message exists and is a queue message
|
||||
if msgInfo.QueueName == "" {
|
||||
return fmt.Errorf("message %s is not a queue message (no queue label)", messageID)
|
||||
}
|
||||
|
||||
// Verify caller is the one who claimed it
|
||||
if msgInfo.Assignee != caller {
|
||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
||||
return fmt.Errorf("message %s is not claimed (still in queue)", messageID)
|
||||
}
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.Assignee, caller)
|
||||
}
|
||||
|
||||
// Release the message: set assignee back to queue and status to open
|
||||
queueAssignee := "queue:" + msgInfo.QueueName
|
||||
if err := releaseMessage(townRoot, messageID, queueAssignee, caller); err != nil {
|
||||
return fmt.Errorf("releasing message: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Released message back to queue %s\n", style.Bold.Render("✓"), msgInfo.QueueName)
|
||||
fmt.Printf(" ID: %s\n", messageID)
|
||||
fmt.Printf(" Subject: %s\n", msgInfo.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// messageInfo holds details about a queue message.
|
||||
type messageInfo struct {
|
||||
ID string
|
||||
Title string
|
||||
Assignee string
|
||||
QueueName string
|
||||
Status string
|
||||
}
|
||||
|
||||
// getMessageInfo retrieves information about a message.
|
||||
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"show", messageID, "--json"}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output - bd show --json returns an array
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Assignee string `json:"assignee"`
|
||||
Labels []string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing message: %w", err)
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
|
||||
issue := issues[0]
|
||||
info := &messageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Assignee: issue.Assignee,
|
||||
Status: issue.Status,
|
||||
}
|
||||
|
||||
// Extract queue name from labels (format: "queue:<name>")
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "queue:") {
|
||||
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// releaseMessage releases a claimed message back to its queue.
|
||||
func releaseMessage(townRoot, messageID, queueAssignee, actor string) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"update", messageID,
|
||||
"--assignee", queueAssignee,
|
||||
"--status", "open",
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
90
internal/cmd/mail_search.go
Normal file
90
internal/cmd/mail_search.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
// runMailSearch searches for messages matching a pattern.
|
||||
func runMailSearch(cmd *cobra.Command, args []string) error {
|
||||
query := args[0]
|
||||
|
||||
// Determine which inbox to search
|
||||
address := detectSender()
|
||||
|
||||
// Get workspace for mail operations
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
// Build search options
|
||||
opts := mail.SearchOptions{
|
||||
Query: query,
|
||||
FromFilter: mailSearchFrom,
|
||||
SubjectOnly: mailSearchSubject,
|
||||
BodyOnly: mailSearchBody,
|
||||
}
|
||||
|
||||
// Execute search
|
||||
messages, err := mailbox.Search(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("searching messages: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailSearchJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(messages)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Search results for %s: %d message(s)\n\n",
|
||||
style.Bold.Render("🔍"), address, len(messages))
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no matches)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
readMarker := "●"
|
||||
if msg.Read {
|
||||
readMarker = "○"
|
||||
}
|
||||
typeMarker := ""
|
||||
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||
}
|
||||
priorityMarker := ""
|
||||
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||
priorityMarker = " " + style.Bold.Render("!")
|
||||
}
|
||||
wispMarker := ""
|
||||
if msg.Wisp {
|
||||
wispMarker = " " + style.Dim.Render("(wisp)")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
||||
fmt.Printf(" %s from %s\n",
|
||||
style.Dim.Render(msg.ID),
|
||||
msg.From)
|
||||
fmt.Printf(" %s\n",
|
||||
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
155
internal/cmd/mail_send.go
Normal file
155
internal/cmd/mail_send.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
var to string
|
||||
|
||||
if mailSendSelf {
|
||||
// Auto-detect identity from cwd
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current directory: %w", err)
|
||||
}
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil || townRoot == "" {
|
||||
return fmt.Errorf("not in a Gas Town workspace")
|
||||
}
|
||||
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("detecting role: %w", err)
|
||||
}
|
||||
ctx := RoleContext{
|
||||
Role: roleInfo.Role,
|
||||
Rig: roleInfo.Rig,
|
||||
Polecat: roleInfo.Polecat,
|
||||
TownRoot: townRoot,
|
||||
WorkDir: cwd,
|
||||
}
|
||||
to = buildAgentIdentity(ctx)
|
||||
if to == "" {
|
||||
return fmt.Errorf("cannot determine identity (role: %s)", ctx.Role)
|
||||
}
|
||||
} else if len(args) > 0 {
|
||||
to = args[0]
|
||||
} else {
|
||||
return fmt.Errorf("address required (or use --self)")
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine sender
|
||||
from := detectSender()
|
||||
|
||||
// Create message
|
||||
msg := &mail.Message{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: mailSubject,
|
||||
Body: mailBody,
|
||||
}
|
||||
|
||||
// Set priority (--urgent overrides --priority)
|
||||
if mailUrgent {
|
||||
msg.Priority = mail.PriorityUrgent
|
||||
} else {
|
||||
msg.Priority = mail.PriorityFromInt(mailPriority)
|
||||
}
|
||||
if mailNotify && msg.Priority == mail.PriorityNormal {
|
||||
msg.Priority = mail.PriorityHigh
|
||||
}
|
||||
|
||||
// Set message type
|
||||
msg.Type = mail.ParseMessageType(mailType)
|
||||
|
||||
// Set pinned flag
|
||||
msg.Pinned = mailPinned
|
||||
|
||||
// Set wisp flag (ephemeral message) - default true, --permanent overrides
|
||||
msg.Wisp = mailWisp && !mailPermanent
|
||||
|
||||
// Set CC recipients
|
||||
msg.CC = mailCC
|
||||
|
||||
// Handle reply-to: auto-set type to reply and look up thread
|
||||
if mailReplyTo != "" {
|
||||
msg.ReplyTo = mailReplyTo
|
||||
if msg.Type == mail.TypeNotification {
|
||||
msg.Type = mail.TypeReply
|
||||
}
|
||||
|
||||
// Look up original message to get thread ID
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(from)
|
||||
if err == nil {
|
||||
if original, err := mailbox.Get(mailReplyTo); err == nil {
|
||||
msg.ThreadID = original.ThreadID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thread ID for new threads
|
||||
if msg.ThreadID == "" {
|
||||
msg.ThreadID = generateThreadID()
|
||||
}
|
||||
|
||||
// Send via router
|
||||
router := mail.NewRouter(workDir)
|
||||
|
||||
// Check if this is a list address to show fan-out details
|
||||
var listRecipients []string
|
||||
if strings.HasPrefix(to, "list:") {
|
||||
var err error
|
||||
listRecipients, err = router.ExpandListAddress(to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
}
|
||||
|
||||
// Log mail event to activity feed
|
||||
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
|
||||
|
||||
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
||||
fmt.Printf(" Subject: %s\n", mailSubject)
|
||||
|
||||
// Show fan-out recipients for list addresses
|
||||
if len(listRecipients) > 0 {
|
||||
fmt.Printf(" Recipients: %s\n", strings.Join(listRecipients, ", "))
|
||||
}
|
||||
|
||||
if len(msg.CC) > 0 {
|
||||
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
|
||||
}
|
||||
if msg.Type != mail.TypeNotification {
|
||||
fmt.Printf(" Type: %s\n", msg.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateThreadID creates a random thread ID for new message threads.
|
||||
func generateThreadID() string {
|
||||
b := make([]byte, 6)
|
||||
_, _ = rand.Read(b) // crypto/rand.Read only fails on broken system
|
||||
return "thread-" + hex.EncodeToString(b)
|
||||
}
|
||||
145
internal/cmd/mail_thread.go
Normal file
145
internal/cmd/mail_thread.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
func runMailThread(cmd *cobra.Command, args []string) error {
|
||||
threadID := args[0]
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// Get mailbox and thread messages
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
messages, err := mailbox.ListByThread(threadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting thread: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if mailThreadJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(messages)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Thread: %s (%d messages)\n\n",
|
||||
style.Bold.Render("🧵"), threadID, len(messages))
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, msg := range messages {
|
||||
typeMarker := ""
|
||||
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||
}
|
||||
priorityMarker := ""
|
||||
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||
priorityMarker = " " + style.Bold.Render("!")
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("│"))
|
||||
}
|
||||
fmt.Printf(" %s %s%s%s\n", style.Bold.Render("●"), msg.Subject, typeMarker, priorityMarker)
|
||||
fmt.Printf(" %s from %s to %s\n",
|
||||
style.Dim.Render(msg.ID),
|
||||
msg.From, msg.To)
|
||||
fmt.Printf(" %s\n",
|
||||
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||
|
||||
if msg.Body != "" {
|
||||
fmt.Printf(" %s\n", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailReply(cmd *cobra.Command, args []string) error {
|
||||
msgID := args[0]
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine current address
|
||||
from := detectSender()
|
||||
|
||||
// Get the original message
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
original, err := mailbox.Get(msgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
|
||||
// Build reply subject
|
||||
subject := mailReplySubject
|
||||
if subject == "" {
|
||||
if strings.HasPrefix(original.Subject, "Re: ") {
|
||||
subject = original.Subject
|
||||
} else {
|
||||
subject = "Re: " + original.Subject
|
||||
}
|
||||
}
|
||||
|
||||
// Create reply message
|
||||
reply := &mail.Message{
|
||||
From: from,
|
||||
To: original.From, // Reply to sender
|
||||
Subject: subject,
|
||||
Body: mailReplyMessage,
|
||||
Type: mail.TypeReply,
|
||||
Priority: mail.PriorityNormal,
|
||||
ReplyTo: msgID,
|
||||
ThreadID: original.ThreadID,
|
||||
}
|
||||
|
||||
// If original has no thread ID, create one
|
||||
if reply.ThreadID == "" {
|
||||
reply.ThreadID = generateThreadID()
|
||||
}
|
||||
|
||||
// Send the reply
|
||||
if err := router.Send(reply); err != nil {
|
||||
return fmt.Errorf("sending reply: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From)
|
||||
fmt.Printf(" Subject: %s\n", subject)
|
||||
if original.ThreadID != "" {
|
||||
fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/mayor"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// getMayorSessionName returns the Mayor session name.
|
||||
func getMayorSessionName() string {
|
||||
return session.MayorSessionName()
|
||||
}
|
||||
|
||||
var mayorCmd = &cobra.Command{
|
||||
Use: "mayor",
|
||||
Aliases: []string{"may"},
|
||||
@@ -31,6 +21,8 @@ The Mayor is the global coordinator for Gas Town, running as a persistent
|
||||
tmux session. Use the subcommands to start, stop, attach, and check status.`,
|
||||
}
|
||||
|
||||
var mayorAgentOverride string
|
||||
|
||||
var mayorStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the Mayor session",
|
||||
@@ -84,24 +76,38 @@ func init() {
|
||||
mayorCmd.AddCommand(mayorStatusCmd)
|
||||
mayorCmd.AddCommand(mayorRestartCmd)
|
||||
|
||||
mayorStartCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)")
|
||||
mayorAttachCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)")
|
||||
mayorRestartCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)")
|
||||
|
||||
rootCmd.AddCommand(mayorCmd)
|
||||
}
|
||||
|
||||
func runMayorStart(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName := getMayorSessionName()
|
||||
|
||||
// Check if session already exists
|
||||
running, err := t.HasSession(sessionName)
|
||||
// getMayorManager returns a mayor manager for the current workspace.
|
||||
func getMayorManager() (*mayor.Manager, error) {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
if running {
|
||||
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
|
||||
return mayor.NewManager(townRoot), nil
|
||||
}
|
||||
|
||||
// getMayorSessionName returns the Mayor session name.
|
||||
func getMayorSessionName() string {
|
||||
return mayor.SessionName()
|
||||
}
|
||||
|
||||
func runMayorStart(cmd *cobra.Command, args []string) error {
|
||||
mgr, err := getMayorManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := startMayorSession(t, sessionName); err != nil {
|
||||
fmt.Println("Starting Mayor session...")
|
||||
if err := mgr.Start(mayorAgentOverride); err != nil {
|
||||
if err == mayor.ErrAlreadyRunning {
|
||||
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -112,83 +118,18 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startMayorSession creates and initializes the Mayor tmux session.
|
||||
func startMayorSession(t *tmux.Tmux, sessionName string) error {
|
||||
// Find workspace root
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Create session in workspace root
|
||||
fmt.Println("Starting Mayor session...")
|
||||
if err := t.NewSession(sessionName, townRoot); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
// Set environment (non-fatal: session works without these)
|
||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", "mayor")
|
||||
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "mayor")
|
||||
|
||||
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
|
||||
// Note: ConfigureGasTownSession includes cycle bindings
|
||||
theme := tmux.MayorTheme()
|
||||
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||
|
||||
// Launch Claude - the startup hook handles 'gt prime' automatically
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
|
||||
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "")
|
||||
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||
return fmt.Errorf("sending command: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start (non-fatal)
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
Recipient: "mayor",
|
||||
Sender: "human",
|
||||
Topic: "cold-start",
|
||||
}) // Non-fatal
|
||||
|
||||
// GUPP: Gas Town Universal Propulsion Principle
|
||||
// Send the propulsion nudge to trigger autonomous coordination.
|
||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||
time.Sleep(2 * time.Second)
|
||||
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMayorStop(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName := getMayorSessionName()
|
||||
|
||||
// Check if session exists
|
||||
running, err := t.HasSession(sessionName)
|
||||
mgr, err := getMayorManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return errors.New("Mayor session is not running")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Stopping Mayor session...")
|
||||
|
||||
// Try graceful shutdown first (best-effort interrupt)
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
if err := mgr.Stop(); err != nil {
|
||||
if err == mayor.ErrNotRunning {
|
||||
return fmt.Errorf("Mayor session is not running")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
|
||||
@@ -196,84 +137,68 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
mgr, err := getMayorManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessionName := getMayorSessionName()
|
||||
|
||||
// Check if session exists
|
||||
running, err := t.HasSession(sessionName)
|
||||
running, err := mgr.IsRunning()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
// Auto-start if not running
|
||||
fmt.Println("Mayor session not running, starting...")
|
||||
if err := startMayorSession(t, sessionName); err != nil {
|
||||
if err := mgr.Start(mayorAgentOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
||||
return attachToTmuxSession(sessionName)
|
||||
return attachToTmuxSession(mgr.SessionName())
|
||||
}
|
||||
|
||||
func runMayorStatus(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName := getMayorSessionName()
|
||||
|
||||
running, err := t.HasSession(sessionName)
|
||||
mgr, err := getMayorManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if running {
|
||||
// Get session info for more details
|
||||
info, err := t.GetSessionInfo(sessionName)
|
||||
if err == nil {
|
||||
status := "detached"
|
||||
if info.Attached {
|
||||
status = "attached"
|
||||
}
|
||||
info, err := mgr.Status()
|
||||
if err != nil {
|
||||
if err == mayor.ErrNotRunning {
|
||||
fmt.Printf("%s Mayor session is %s\n",
|
||||
style.Bold.Render("●"),
|
||||
style.Bold.Render("running"))
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Created: %s\n", info.Created)
|
||||
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
|
||||
} else {
|
||||
fmt.Printf("%s Mayor session is %s\n",
|
||||
style.Bold.Render("●"),
|
||||
style.Bold.Render("running"))
|
||||
style.Dim.Render("○"),
|
||||
"not running")
|
||||
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s Mayor session is %s\n",
|
||||
style.Dim.Render("○"),
|
||||
"not running")
|
||||
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
|
||||
return fmt.Errorf("checking status: %w", err)
|
||||
}
|
||||
|
||||
status := "detached"
|
||||
if info.Attached {
|
||||
status = "attached"
|
||||
}
|
||||
fmt.Printf("%s Mayor session is %s\n",
|
||||
style.Bold.Render("●"),
|
||||
style.Bold.Render("running"))
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Created: %s\n", info.Created)
|
||||
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMayorRestart(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName := getMayorSessionName()
|
||||
|
||||
running, err := t.HasSession(sessionName)
|
||||
mgr, err := getMayorManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if running {
|
||||
// Stop the current session (best-effort interrupt before kill)
|
||||
fmt.Println("Stopping Mayor session...")
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
}
|
||||
// Stop if running (ignore not-running error)
|
||||
if err := mgr.Stop(); err != nil && err != mayor.ErrNotRunning {
|
||||
return fmt.Errorf("stopping session: %w", err)
|
||||
}
|
||||
|
||||
// Start fresh
|
||||
|
||||
@@ -87,7 +87,7 @@ func detectAgentBeadID() (string, error) {
|
||||
return "", fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role)
|
||||
}
|
||||
|
||||
beadID := buildAgentBeadID(identity, roleCtx.Role)
|
||||
beadID := buildAgentBeadID(identity, roleCtx.Role, townRoot)
|
||||
if beadID == "" {
|
||||
return "", fmt.Errorf("cannot build agent bead ID for identity: %s", identity)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
@@ -28,9 +29,15 @@ import (
|
||||
// - "gastown/crew/max" -> "gt-gastown-crew-max"
|
||||
//
|
||||
// If role is unknown, it tries to infer from the identity string.
|
||||
func buildAgentBeadID(identity string, role Role) string {
|
||||
// townRoot is needed to look up the rig's configured prefix.
|
||||
func buildAgentBeadID(identity string, role Role, townRoot string) string {
|
||||
parts := strings.Split(identity, "/")
|
||||
|
||||
// Helper to get prefix for a rig
|
||||
getPrefix := func(rig string) string {
|
||||
return config.GetRigPrefix(townRoot, rig)
|
||||
}
|
||||
|
||||
// If role is unknown or empty, try to infer from identity
|
||||
if role == RoleUnknown || role == Role("") {
|
||||
switch {
|
||||
@@ -39,18 +46,18 @@ func buildAgentBeadID(identity string, role Role) string {
|
||||
case identity == "deacon":
|
||||
return beads.DeaconBeadIDTown()
|
||||
case len(parts) == 2 && parts[1] == "witness":
|
||||
return beads.WitnessBeadID(parts[0])
|
||||
return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
|
||||
case len(parts) == 2 && parts[1] == "refinery":
|
||||
return beads.RefineryBeadID(parts[0])
|
||||
return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
|
||||
case len(parts) == 2:
|
||||
// Assume rig/name is a polecat
|
||||
return beads.PolecatBeadID(parts[0], parts[1])
|
||||
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1])
|
||||
case len(parts) == 3 && parts[1] == "crew":
|
||||
// rig/crew/name - crew member
|
||||
return beads.CrewBeadID(parts[0], parts[2])
|
||||
return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
|
||||
case len(parts) == 3 && parts[1] == "polecats":
|
||||
// rig/polecats/name - explicit polecat
|
||||
return beads.PolecatBeadID(parts[0], parts[2])
|
||||
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -63,26 +70,26 @@ func buildAgentBeadID(identity string, role Role) string {
|
||||
return beads.DeaconBeadIDTown()
|
||||
case RoleWitness:
|
||||
if len(parts) >= 1 {
|
||||
return beads.WitnessBeadID(parts[0])
|
||||
return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
|
||||
}
|
||||
return ""
|
||||
case RoleRefinery:
|
||||
if len(parts) >= 1 {
|
||||
return beads.RefineryBeadID(parts[0])
|
||||
return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
|
||||
}
|
||||
return ""
|
||||
case RolePolecat:
|
||||
// Handle both 2-part (rig/name) and 3-part (rig/polecats/name) formats
|
||||
if len(parts) == 3 && parts[1] == "polecats" {
|
||||
return beads.PolecatBeadID(parts[0], parts[2])
|
||||
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
return beads.PolecatBeadID(parts[0], parts[1])
|
||||
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1])
|
||||
}
|
||||
return ""
|
||||
case RoleCrew:
|
||||
if len(parts) >= 3 && parts[1] == "crew" {
|
||||
return beads.CrewBeadID(parts[0], parts[2])
|
||||
return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
@@ -318,7 +325,7 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Try to find agent bead and read hook slot
|
||||
// This is the preferred method - agent beads have a hook_bead field
|
||||
agentBeadID := buildAgentBeadID(target, roleCtx.Role)
|
||||
agentBeadID := buildAgentBeadID(target, roleCtx.Role, townRoot)
|
||||
var hookBead *beads.Issue
|
||||
|
||||
if agentBeadID != "" {
|
||||
@@ -444,13 +451,14 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// buildAgentIdentity constructs the agent identity string from role context.
|
||||
// Format matches session.AgentIdentity.Address() for consistency.
|
||||
// Town-level agents (mayor, deacon) use trailing slash to match the format
|
||||
// used when setting assignee on hooked beads (see resolveSelfTarget in sling.go).
|
||||
func buildAgentIdentity(ctx RoleContext) string {
|
||||
switch ctx.Role {
|
||||
case RoleMayor:
|
||||
return "mayor"
|
||||
return "mayor/"
|
||||
case RoleDeacon:
|
||||
return "deacon"
|
||||
return "deacon/"
|
||||
case RoleWitness:
|
||||
return ctx.Rig + "/witness"
|
||||
case RoleRefinery:
|
||||
@@ -595,6 +603,14 @@ func outputMoleculeStatus(status MoleculeStatusInfo) error {
|
||||
fmt.Println(style.Bold.Render("🚀 AUTONOMOUS MODE - Work on hook triggers immediate execution"))
|
||||
fmt.Println()
|
||||
|
||||
// Check if the hooked bead is already closed (someone closed it externally)
|
||||
if status.PinnedBead.Status == "closed" {
|
||||
fmt.Printf("%s Hooked bead %s is already closed!\n", style.Bold.Render("⚠"), status.PinnedBead.ID)
|
||||
fmt.Printf(" Title: %s\n", status.PinnedBead.Title)
|
||||
fmt.Printf(" This work was completed elsewhere. Clear your hook with: gt unsling\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a mail bead - display mail-specific format
|
||||
if status.PinnedBead.Type == "message" {
|
||||
sender := extractMailSender(status.PinnedBead.Labels)
|
||||
@@ -869,8 +885,10 @@ func getGitRootForMolStatus() (string, error) {
|
||||
// isTownLevelRole returns true if the agent ID is a town-level role.
|
||||
// Town-level roles (Mayor, Deacon) operate from the town root and may have
|
||||
// pinned beads in any rig's beads directory.
|
||||
// Accepts both "mayor" and "mayor/" formats for compatibility.
|
||||
func isTownLevelRole(agentID string) bool {
|
||||
return agentID == "mayor" || agentID == "deacon"
|
||||
return agentID == "mayor" || agentID == "mayor/" ||
|
||||
agentID == "deacon" || agentID == "deacon/"
|
||||
}
|
||||
|
||||
// extractMailSender extracts the sender from mail bead labels.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user