138 Commits

Author SHA1 Message Date
mayor
e3d99c3aed fix: correct ZFC acronym (Zero Framework Cognition)
Some checks failed
Release / goreleaser (push) Failing after 5m2s
Release / publish-npm (push) Has been skipped
Release / update-homebrew (push) Has been skipped
2026-01-10 01:25:27 -08:00
mayor
819c9dd179 chore: bump version to 0.2.4
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:24:36 -08:00
mayor
9bb63900d4 docs: update CHANGELOG for v0.2.4 release
Add 0.2.4 changelog entry covering:
- Priming subsystem (PRIME.md, post-handoff detection, doctor checks)
- gt prime --dry-run, --state, --explain flags
- ZFC improvements (query tmux directly, remove PID detection)
- Cross-level hook visibility fixes
- Rig-level default formulas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:21:50 -08:00
mayor
fc1a1dea88 test: add t.Parallel() to enable parallel test execution
Add t.Parallel() calls across config and rig test files to enable
concurrent test execution and faster test runs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:20:32 -08:00
dementus
dd9cd61075 feat(prime): add --state, --dry-run, --explain flags with mutual exclusivity validation
Add three new flags to gt prime command:
- --state: Output role state as JSON and exit early (for scripting)
- --dry-run: Skip side effects (persistence, locks, events)
- --explain: Show verbose role detection reasoning

The --state flag is mutually exclusive with all other flags and errors
if combined. The other flags (--dry-run, --explain, --hook) can be
combined freely.

Also fixes missing filepath import in beads.go.

Closes: bd-t8ven

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:14:27 -08:00
rictus
272f83f1fc fix(doctor): add AGENTS.md size check to rig-level priming
The AGENTS.md file at rig level (e.g., gastown/AGENTS.md) should be a thin
bootstrap pointer (<20 lines), not full context. This adds a check in
checkRigPriming() to flag large AGENTS.md files, similar to how CLAUDE.md
is checked in checkAgentPriming().

Also fixes missing filepath import in beads.go that was breaking the build.

Closes: bd-mfrs6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:08:29 -08:00
furiosa
6e6e5ce08c refactor(prime): extract findHookedBead to eliminate duplication
detectSessionState() and checkSlungWork() both contained identical
logic for finding hooked/in_progress beads assigned to an agent.
Extracted this into findHookedBead() helper function.

Also includes priming subsystem improvements from mayor:
- Add --dry-run flag for testing without side effects
- Add --state flag to output detected state only
- Add --explain flag to show why sections are included
- Add missing filepath import to beads.go

Fixes: bd-hvwnb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:07:01 -08:00
mayor
db353c247b feat: implement priming subsystem improvements
Phase 1 of dynamic priming subsystem:

1. PRIME.md provisioning for all workers (hq-5z76w, hq-ukjrr Part A)
   - Added ProvisionPrimeMD to beads package with Gas Town context template
   - Provision at rig level in AddRig() so all workers inherit it
   - Added fallback provisioning in crew and polecat managers
   - Created PRIME.md for existing rigs

2. Post-handoff detection to prevent handoff loop bug (hq-ukjrr Part B)
   - Added FileHandoffMarker constant (.runtime/handoff_to_successor)
   - gt handoff writes marker before respawn
   - gt prime detects marker and outputs "HANDOFF COMPLETE" warning
   - Marker cleared after detection to prevent duplicate warnings

3. Priming health checks for gt doctor (hq-5scnt)
   - New priming_check.go validates priming subsystem configuration
   - Checks: SessionStart hook, gt prime command, PRIME.md presence
   - Warns if CLAUDE.md is too large (should be bootstrap pointer)
   - Fixable: provisions missing PRIME.md files

This ensures crew workers get Gas Town context (GUPP, hooks, propulsion)
even if the gt prime hook fails, via bd prime fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:07:01 -08:00
max
7533fed55e refactor(cmd): extract polecat helpers to reduce duplication
Split polecat.go (1635 lines) into:
- polecat.go (1359 lines): cobra commands and handlers
- polecat_helpers.go (260 lines): shared helper functions

Extracted:
- resolvePolecatTargets(): shared list-building logic from remove/nuke
- checkPolecatSafety(): safety check logic for destructive operations
- displaySafetyCheckBlocked(): blocked polecat display
- displayDryRunSafetyCheck(): dry-run safety status display

Reduces duplication between runPolecatRemove and runPolecatNuke.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:25:59 -08:00
gastown/crew/gus
afb944f616 fix(formula): set rigPath when falling back to gastown default
When `gt formula run` fell back to the default "gastown" rig (because no
rig could be detected), it didn't set rigPath, which meant the default
formula lookup would fail. Now rigPath is properly constructed when we
have townRoot but can't detect a current rig.

Also adds tests for GetDefaultFormula helper.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:24:28 -08:00
Brett VanderVeen
6016f15da9 feat(formula): add default formula configuration at rig level (#297)
Allow `gt formula run` to be called without a formula name by configuring
a default in the rig's settings/config.json under workflow.default_formula.

Co-authored-by: Brett VanderVeen <brett.vanderveen@gfs.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:22:45 -08:00
Steve Yegge
f90b58bc6d Merge pull request #311 from rsnodgrass/feat/ux-system-import
feat(ui): import comprehensive UX system from beads
2026-01-09 23:13:58 -08:00
gastown/crew/dennis
b60f016955 refactor(beads,mail): split large files into focused modules
Break down monolithic beads.go and mail.go into smaller, single-purpose files:

beads package:
- beads_agent.go: Agent-related bead operations
- beads_delegation.go: Delegation bead handling
- beads_dog.go: Dog pool operations
- beads_merge_slot.go: Merge slot management
- beads_mr.go: Merge request operations
- beads_redirect.go: Redirect bead handling
- beads_rig.go: Rig bead operations
- beads_role.go: Role bead management

cmd package:
- mail_announce.go: Announcement subcommand
- mail_check.go: Mail check subcommand
- mail_identity.go: Identity management
- mail_inbox.go: Inbox operations
- mail_queue.go: Queue subcommand
- mail_search.go: Search functionality
- mail_send.go: Send subcommand
- mail_thread.go: Thread operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:01:55 -08:00
gastown/crew/jack
609a4af087 feat(handoff): add explicit instructions to handoff nudge message
When a session starts via handoff, the nudge message now includes
clear instructions to check hook and mail. This prevents agent
confusion when SessionStart hooks haven't loaded CLAUDE.md yet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:58:32 -08:00
Ryan Snodgrass
e1f2bb8b4b feat(ui): import comprehensive UX system from beads
Import beads' UX design system into gastown:

- Add internal/ui/ package with Ayu theme colors and semantic styling
  - styles.go: AdaptiveColor definitions for light/dark mode
  - terminal.go: TTY detection, NO_COLOR/CLICOLOR support
  - markdown.go: Glamour rendering with agent mode bypass
  - pager.go: Smart paging with GT_PAGER support

- Add colorized help output (internal/cmd/help.go)
  - Group headers in accent color
  - Command names styled for scannability
  - Flag types and defaults muted

- Add gt thanks command (internal/cmd/thanks.go)
  - Contributor display with same logic as bd thanks
  - Styled with Ayu theme colors

- Update gt doctor to match bd doctor UX
  - Category grouping (Core, Infrastructure, Rig, Patrol, etc.)
  - Semantic icons (✓ ⚠ ✖) with Ayu colors
  - Tree connectors for detail lines
  - Summary line with pass/warn/fail counts
  - Warnings section at end with numbered issues

- Migrate existing styles to use ui package
  - internal/style/style.go uses ui.ColorPass etc.
  - internal/tui/feed/styles.go uses ui package colors

Co-Authored-By: SageOx <ox@sageox.ai>
2026-01-09 22:46:06 -08:00
gastown/crew/max
f7d497ba07 fix(zfc): remove strings.Contains conflict detection from Go code
hq-hcil1: Remove deprecated HasConflict/HasAuthFailure/IsNotARepo/HasRebaseConflict
methods that violated ZFC by having Go code decide error types based on stderr parsing.

Changes:
- Remove deprecated helper methods from GitError and SwarmGitError
- Export GetConflictingFiles() which uses git porcelain output (diff --diff-filter=U)
- Update CheckConflicts(), engineer.go, and integration.go to use GetConflictingFiles()
- Update tests to verify raw stderr is available for agent observation

ZFC principle: Go code transports raw output to agents; agents observe and decide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:31:55 -08:00
gastown/crew/max
131dac91c8 refactor(zfc): derive state from files instead of in-memory cache
Apply ZFC (Zero Forge Cache) principle across git error handling and
feed curation. Agents now observe raw git output and make their own
decisions rather than relying on pre-interpreted error types.

- Add GitError type with raw stdout/stderr for observation
- Add SwarmGitError following the same pattern
- Remove in-memory deduplication maps from Curator
- Curator now reads state from feed/events files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:23:44 -08:00
gastown/crew/george
b92e46474a fix(polecat): remove pending.json tracking anti-pattern (ZFC)
Removed the pending.json file that shadowed observable state. Now
discovers pending spawns directly from POLECAT_STARTED messages in
the Deacon's inbox.

Changes:
- CheckInboxForSpawns: Discovers from mail, no more LoadPending/SavePending
- TriggerPendingSpawns: Archives mail after successful trigger
- PruneStalePending: Archives old messages instead of pruning from JSON

The mail system is now the source of truth for pending spawns.

Closes: hq-i31f7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:11:14 -08:00
gastown/crew/joe
fc8718e680 fix(zfc): remove Go-side computation and stderr parsing violations
hq-u0ach: done.go - Add --cleanup-status flag so agents can pass cleanup
status directly. Removes computeCleanupStatus() which violated ZFC by
having Go compute cleanup status from git state.

hq-z0zqw: beads.go - Remove strings.Contains parsing for ErrNotARepo and
ErrSyncConflict. Per ZFC, Go should transport errors to agents, not parse
them to make decisions. IsBeadsRepo() now uses file existence check.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:11:02 -08:00
dementus
2f50a59e74 fix(hook): warn when hooked bead is already closed
When a bead is closed externally via bd close, it could remain on
an agent's hook, causing confusion when running gt hook. Now
gt hook detects closed beads and shows a warning message with
instructions to clear the hook using gt unsling.

Closes: gt-8w0r6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:10:09 -08:00
slit
aeb4c0d26f fix(hook): make cross-level hooks visible to mayor/deacon
The gt hook command wasn't finding hooked beads for town-level roles
(mayor, deacon) because of an identity format mismatch:

- When hooking a bead, resolveSelfTarget() sets assignee with trailing
  slash (e.g., "mayor/")
- When querying, buildAgentIdentity() returned without slash ("mayor")

This caused the assignee filter to miss the hooked bead since bd does
exact matching on the assignee field.

Fix:
- Update buildAgentIdentity() to return "mayor/" and "deacon/" with
  trailing slash, matching the format used when setting assignee
- Update isTownLevelRole() to accept both formats for compatibility

Fixes: gt-g6ng2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:09:32 -08:00
nux
c4fcdd88c8 fix(daemon,beads): use correct agent bead ID format and bd create flags
Two fixes in this commit:

1. daemon/lifecycle.go: Fix agent bead ID pattern for GUPP/orphaned work checks
   - Wrong: gt-polecat-<rig>-<name> (e.g., gt-polecat-gastown-nux)
   - Correct: <prefix>-<rig>-polecat-<name> (e.g., gt-gastown-polecat-nux)
   - Use config.GetRigPrefix() instead of hardcoding gt prefix
   - Use beads.ParseAgentBeadID() in extractRigFromAgentID

2. beads/beads.go: Fix invalid --add-label flag in bd create calls
   - bd create uses --labels, not --add-label
   - bd update uses --add-label (unchanged, was correct)
   - Fixed Create, CreateWithID, CreateAgentBead, CreateRigBead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:08:55 -08:00
gastown/crew/george
c94d59dca7 fix: ZFC improvements - query tmux directly instead of marker TTL
Two ZFC fixes:

1. Boot marker file (hq-zee5n): Changed IsRunning() to query
   tmux.HasSession() directly instead of checking marker file
   freshness with TTL. Removed stale marker check from doctor.

2. Branch pattern matching (hq-zwuh6): Replaced hardcoded "polecat/"
   strings with constants.BranchPolecatPrefix for consistency.

Also removed 60-second WaitForCommand blocking from crew Start()
which was causing gt crew start to hang.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:08:12 -08:00
gastown/crew/gus
e0858096f6 fix(zfc): move stuck detection thresholds to agent-controlled config
Per ZFC principle: 'Let agents decide thresholds. Stuck is a judgment call.'

Changes:
- Add health check threshold fields to RoleConfig (ping_timeout,
  consecutive_failures, kill_cooldown, stuck_threshold)
- Add LoadStuckConfig() to read thresholds from hq-deacon-role bead
- Update patrol_check.go to use configurable stuck threshold
- Defaults remain as fallbacks when no role bead config exists

Agents can now configure their stuck detection by adding fields to their
role bead, e.g.:
  ping_timeout: 45s
  consecutive_failures: 5
  kill_cooldown: 10m
  stuck_threshold: 2h

Fixes: hq-2355b
2026-01-09 22:07:35 -08:00
dennis
0f633be4b1 fix(zfc): remove PID-based agent liveness detection
Replace ProcessExists() checks in witness and refinery managers with
tmux session detection. Agent liveness should be derived from tmux
session state, not PID probing (per ZFC tracking principles).

- Remove util.ProcessExists() from witness/manager.go and refinery/manager.go
- Delete internal/util/process.go and process_test.go (now unused)
- Foreground mode and Stop() now rely solely on tmux HasSession/KillSession

Closes: hq-yxkdr (recentDeaths already removed)
Closes: hq-1sd4o (ProcessExists removed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:02:34 -08:00
gastown/crew/gus
593185873d feat(witness): honor role start_command + add --agent/--env overrides (#293)
Adds comprehensive override support for witness start/restart:

- Honor hq-witness-role start_command and env_vars from role bead
- Add --agent flag to override the agent/model
- Add --env flag for arbitrary env var overrides (KEY=VALUE, repeatable)

Precedence (highest to lowest):
1. CLI --env overrides
2. Role bead env_vars
3. config.AgentEnv() defaults

Examples:
  gt witness start greenplace --agent codex
  gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku

Co-authored-by: joshuavial <git@codewithjv.com>
2026-01-09 22:00:52 -08:00
gastown/crew/gus
86751e1ea5 feat(witness): add --env flag for environment variable overrides
Extends the --agent flag with a more general --env flag that allows
setting arbitrary environment variables when starting a witness.

Precedence (highest to lowest):
1. CLI --env overrides
2. Role bead env_vars
3. config.AgentEnv() defaults

Examples:
  gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku
  gt witness restart greenplace --env DEBUG=1 --env VERBOSE=true

Co-authored-by: joshuavial <git@codewithjv.com>
2026-01-09 22:00:43 -08:00
joshuavial
f9473c7b9e fix: satisfy lint and regenerate formulas 2026-01-09 21:57:11 -08:00
joshuavial
dcb085e64e chore: drop local beads changes 2026-01-09 21:56:53 -08:00
joshuavial
369cf82b77 feat: add witness start agent override 2026-01-09 21:56:53 -08:00
joshuavial
bfd3096b49 fix: prefer witness agent override 2026-01-09 21:56:53 -08:00
joshuavial
271bd7ea0a fix: honor witness role start_command 2026-01-09 21:56:53 -08:00
joshuavial
0d3f6c9654 feat: allow witness restart agent override 2026-01-09 21:56:53 -08:00
gastown/crew/gus
24136ebaa1 refactor: consolidate agent environment variables (#294)
Introduces config.AgentEnv() as the single source of truth for all agent
environment variables. Previously, different roles received different subsets
of variables depending on their startup path.

Changes:
- All agents now receive GT_ROOT and BEADS_DIR (previously only polecat/refinery)
- Add gt doctor env-vars check to validate tmux session variables
- Fix gt role home witness returning incorrect path
- Fix BEADS_DIR not following redirects for repos with tracked beads

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github.com>
2026-01-09 21:55:28 -08:00
gastown/crew/gus
7a1ed80068 fix: remove unused identity parameter from setSessionEnvironment 2026-01-09 21:54:54 -08:00
gastown/crew/gus
e6bdc639ab chore: sync embedded formulas with source 2026-01-09 21:53:05 -08:00
julianknutsen
65334320c7 docs: update environment variable documentation
Update docs to reflect the centralized config.AgentEnv() function and
complete environment variable coverage:

reference.md:
- Restructured env vars section with tables by category
- Added GT_ROOT, GT_CREW, BEADS_AGENT_NAME documentation
- Added "Environment by Role" quick reference table
- Added doctor check documentation for env-vars validation

identity.md:
- Updated Environment Setup section with complete examples
- Added crew environment example showing BEADS_NO_DAEMON
- Mentioned centralized config.AgentEnv() function
- Cross-referenced to reference.md for full details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
ce231a31af fix(doctor): use full AgentEnv for env-vars check
The env-vars check was using AgentEnvSimple which doesn't know the
actual TownRoot and BeadsDir paths. This could cause false positive
mismatches when comparing expected (empty paths) vs actual (real paths).

- Use config.AgentEnv with proper TownRoot and BeadsDir from CheckContext
- Rig-level roles resolve beads dir from rig path
- Update tests to use expectedEnv helper that generates full env vars

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
f1a2c56900 test: add unit tests for deacon and boot roles in config/env.go
Complete test coverage for all roles in the centralized AgentEnv
function:
- TestAgentEnv_Deacon: verifies deacon env vars (GT_ROLE, BD_ACTOR,
  GIT_AUTHOR_NAME)
- TestAgentEnv_Boot: verifies boot env vars including BD_ACTOR=deacon-boot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
cc87fdd03d fix(boot): use centralized AgentEnv in degraded mode
spawnDegraded was manually constructing env vars, missing GT_ROOT,
BEADS_DIR, and GIT_AUTHOR_NAME that spawnTmux sets via config.AgentEnv().

Now both paths use the same centralized env var generation for
consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
9b4c7ac28b fix(doctor): check deacon env vars after AgentEnv refactor
The env-vars doctor check was skipping deacon with a stale comment
"it doesn't use standard env vars". After the AgentEnv refactor,
deacon/manager.go now uses config.AgentEnv() like all other roles.

- Remove the skip condition for deacon in env_check.go
- Update test from TestEnvVarsCheck_DeaconSkipped to test deacon is
  actually checked (TestEnvVarsCheck_DeaconCorrect/DeaconMissing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
64e1448981 test: add unit tests for config/env.go
Tests for AgentEnv(), ExportPrefix(), BuildStartupCommandWithEnv(),
and helper functions (MergeEnv, FilterEnv, WithoutEnv, EnvToSlice).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
e9a013c0d2 fix: add BEADS_NO_DAEMON to crew for isolated clone context
Crew workspaces use clones with redirected beads directories, like
polecat and refinery. They should bypass the bd daemon for fresh
data and isolation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
e999ceb1c1 refactor: consolidate agent env vars into config.AgentEnv
Create centralized AgentEnv function as single source of truth for all
agent environment variables. All agents now consistently receive:
- GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME (role identity)
- GT_ROOT, BEADS_DIR (workspace paths)
- GT_RIG, GT_POLECAT/GT_CREW (rig-specific identity)
- BEADS_AGENT_NAME, BEADS_NO_DAEMON (beads config)
- CLAUDE_CONFIG_DIR (optional account selection)

Remove RoleEnvVars in favor of AgentEnvSimple wrapper.
Remove IncludeBeadsEnv flag - beads env vars always included.
Update all manager and cmd call sites to use AgentEnv.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
52b9a95f98 feat(doctor): add env-vars check, remove redundant gtroot check
Adds a new `gt doctor` check that verifies tmux session environment
variables match expected values from `config.RoleEnvVars()`.

- Checks all Gas Town sessions (gt-*, hq-*)
- Compares actual tmux env vars against expected for each role
- Reports mismatches with guidance to restart sessions
- Treats no sessions as success (valid when Gas Town is down)
- Skips deacon (doesn't use standard env vars)

Also:
- Adds `tmux.GetAllEnvironment()` to retrieve all session env vars
- Removes redundant gtroot_check (env-vars check covers GT_ROOT)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00
julianknutsen
1d88a73eaa fix: use ResolveBeadsDir for polecat BEADS_DIR
Previously, polecat startup used hardcoded paths for BEADS_DIR that
didn't follow redirects for repos with tracked beads. This meant
polecats working in worktrees (where .beads/redirect points to the
actual beads location) would use the wrong beads directory.

Fixed locations:
- daemon.go: polecat startup now uses ResolveBeadsDir
- polecat/session_manager.go: session startup now uses ResolveBeadsDir

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:09 -08:00
julianknutsen
7150ce2624 refactor: update managers to use RoleEnvVars
Consolidates all role startup code to use the shared RoleEnvVars()
function, ensuring consistent env vars across tmux SetEnvironment
and Claude startup command exports.

Updated:
- Mayor manager
- Deacon startup (daemon.go)
- Witness manager
- Refinery manager
- Polecat startup (daemon.go)
- BuildPolecatStartupCommand, BuildCrewStartupCommand helpers

This ensures all agents receive the same identity env vars regardless
of startup path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:09 -08:00
julianknutsen
2343e6b0ef feat: add RoleEnvVars and improve gt role CLI
Introduces config.RoleEnvVars() as the single source of truth for role
identity environment variables (GT_ROLE, GT_RIG, BD_ACTOR, etc.).

CLI improvements:
- Fix getRoleHome paths (witness has no /rig suffix, polecat/crew do)
- Make gt role env read-only (displays current role from env/cwd)
- Add EnvIncomplete handling: fill missing env vars from cwd with warning
- Add cwd mismatch warnings when not in role home directory
- gt role home now validates --polecat requires --rig

Includes comprehensive e2e tests for all role detection scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:09 -08:00
Steve Yegge
491b635cbc Merge pull request #300 from apfm-cabe-waldrop/fix/shutdown-stop-daemon
Fix gt shutdown to stop daemon
2026-01-09 21:38:07 -08:00
gastown/crew/max
cb2b130ca2 fix(crew): parallelize crew start to prevent hanging
Start crew members concurrently instead of sequentially. Previously,
`gt crew start --all` could hang for minutes because each crew member
was started one at a time, with each waiting up to 60 seconds for
Claude to initialize.

With parallel startup, all crew members start simultaneously and
the total wait time is bounded by the slowest individual startup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:24:23 -08:00
slit
be35b3eaab feat(version): add stale binary detection with startup warning
Add detection for when the installed gt binary is out of date with the
source repository. This helps catch issues where commands fail mysteriously
because the installed binary doesn't have recent fixes.

Changes:
- Add internal/version package with stale binary detection logic
- Add startup warning in PersistentPreRunE when binary is stale
- Add gt doctor check for stale-binary
- Use prefix matching for commit comparison (handles short vs full hash)

The warning is non-blocking and only shows once per shell session via
the GT_STALE_WARNED environment variable.

Resolves: gt-ud912

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:14:17 -08:00
rictus
b8075a5e06 fix(sling): accept bead IDs directly even when routing fails
When routing-based verification (verifyBeadExists) fails due to
routes.jsonl configuration issues, gt sling now falls back to pattern
matching via looksLikeBeadID to accept valid bead ID formats.

The fix ensures:
1. verifyBeadExists is tried first (routing-based lookup)
2. verifyFormulaExists is tried second (formula check)
3. looksLikeBeadID pattern match is used as final fallback

Also improved looksLikeBeadID to accept any 1-5 letter lowercase
prefix followed by hyphen and alphanumeric chars.

Fixes: gt sling bd-xxx failing with "not a valid bead or formula"
when the bead exists but routing cannot find it.

Closes: gt-9e8s5

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:13:11 -08:00
nux
9697007182 feat(beads): migrate from types to labels for bead classification
Updates the beads package to use label-based filtering (gt:agent, gt:role,
gt:merge-request, etc.) instead of the deprecated --type= flag.

Key changes:
- ListAgentBeads(): --type=agent -> --label=gt:agent
- CreateAgentBead/CreateDogAgentBead: add gt:agent label on creation
- ReadyWithType(): --type -> --label=gt:<type>
- GetRoleConfig()/GetAgentBead(): type check -> label check via HasLabel()
- FindMRForBranch(): Type filter -> Label filter
- Create()/CreateWithID(): convert Type to gt:<type> label
- CreateRigBead(): --type=rig -> --add-label=gt:rig
- ClearMail(): Type filter -> Label filter
- Add Label field to ListOptions (Type field deprecated)

Closes: gt-x0i2m

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:12:36 -08:00
gastown/crew/jack
ad8189b010 feat(deacon): use await-signal with exponential backoff in patrol loop
The Deacon patrol formula now uses `gt mol step await-signal` with
exponential backoff instead of vague "sleep 60s" instructions.

How it works:
- Subscribes to `bd activity --follow` (beads activity feed)
- Returns IMMEDIATELY when any gt/bd command triggers activity
- On timeout, waits exponentially longer: 60s → 120s → 240s → max 10m
- Tracks idle:N label on hq-deacon bead across invocations

This connects the designed-but-unintegrated backoff mechanism to the
actual patrol loop. Idle towns let Deacon sleep; active work wakes it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:07:07 -08:00
furiosa
7367aa7572 docs: add gt nudge guidance to mayor template (hq-6h9w4o)
- Add Coordination section with gt nudge command
- Clarify line 180: routine nudging is Witness job, Mayor can nudge stuck refinery/witness
- Add warning to NEVER use tmux send-keys (drops Enter key)
- Includes liftoff test timestamp in manager.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:34:44 -08:00
slit
ba76bf1232 feat(doctor): add safeguards for town root branch protection
Add three layers of protection to prevent accidental branch switches in
the town root (~/gt), which should always stay on main:

1. Doctor check `town-root-branch`: Verifies town root is on main/master.
   Fixable via `gt doctor --fix` to switch back to main.

2. Doctor check `pre-checkout-hook`: Verifies git pre-checkout hook is
   installed. The hook blocks checkout from main to any other branch.
   Fixable via `gt doctor --fix` or `gt git-init`.

3. Runtime warning in all gt commands: Non-blocking warning if town root
   is on wrong branch, with fix instructions.

The root cause of this issue was git commands running in the wrong
directory, switching the town root to a polecat branch. This broke gt
commands because rigs.json and other configs were on main, not the
polecat branch.

Closes: hq-1kwuj

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:11:53 -08:00
nux
692d6819f2 feat(crash): improve crash logging and mass death detection
Add comprehensive crash logging improvements to help diagnose mass session death events:

- Add TypeSessionDeath and TypeMassDeath event types for feed visibility
- Log pre-death events before killing sessions (who killed, why)
- Add mass death detection in daemon (3+ deaths in 30s triggers alert)
- Add macOS crash report check in gt doctor
- Support session death events in townlog and feed curator

Closes hq-kt1o6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:11:09 -08:00
rictus
97b70517cc fix(beads): make TestIntegration work with redirect architecture
- Use ResolveBeadsDir() to find beads.db in multi-worktree setups
  where .beads/redirect points to the canonical beads location
- Add --allow-stale flag to bd sync command to handle cases where
  the daemon is actively writing and staleness check would fail

Fixes hq-0cgd3

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:10:14 -08:00
gastown/crew/george
73a8889c3e fix: remove settings.json that was incorrectly force-added
This file was added with -f despite .claude/ being in .gitignore.
When the repo is used as a crew workspace, this file shadows the
proper crew-level settings at crew/.claude/settings.json.

Removing it allows Claude Code to find the correct settings by
walking up the directory tree.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:30:11 -08:00
gastown/crew/george
61b561a540 feat(doctor): add custom types check and centralize BeadsCustomTypes
- Add BeadsCustomTypes constant ("agent,role,rig,convoy,slot") to avoid
  hardcoded strings scattered across the codebase
- Add CustomTypesCheck to gt doctor that verifies Gas Town custom types
  are registered with beads, with --fix support
- Register custom types during gt init (best-effort, skips if no beads)
- Update install.go, rig_check.go, and rig/manager.go to use the constant

This ensures consistent type registration across all code paths and
catches misconfigured beads databases via gt doctor.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:30:11 -08:00
gastown/crew/george
86739556c2 fix(prime): use startup beacon instead of bare "gt prime" prompt
Agents were confused when receiving "gt prime" as their first prompt,
interpreting it as a command to investigate rather than understanding
they were starting a Gas Town session.

Changed crew_at.go, start.go, and handoff.go to use FormatStartupNudge()
which produces a proper beacon like:
  [GAS TOWN] george/crew/george <- human • 2026-01-09T10:30 • start

The SessionStart hook (gt prime --hook) still injects context - the
prompt just needs to be something agents recognize as a greeting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:30:11 -08:00
mayor
ff3d1b2e23 fix(refinery): use mayor/rig fallback for correct git remote
Refinery was using rig.Path which found the town's .git with rig-named
remotes (e.g., 'gastown') instead of 'origin'. This caused refinery to
miss polecat branches when fetching.

Now falls back to mayor/rig (which has 'origin' pointing to the project
repo) when refinery/rig doesn't exist.

Fixes: hq-uvrzt
2026-01-09 12:42:14 -08:00
mayor
69299e9a43 Fix gt shutdown to stop daemon (fixes #299)
gt shutdown was not stopping the daemon, which caused it to restart
agents (witnesses, refineries) after shutdown completed. The daemon
heartbeats every 3 minutes and calls ensureWitnessesRunning() and
ensureRefineriesRunning(), which would notice the sessions were dead
and restart them.

This adds daemon stop logic to both runGracefulShutdown (as Phase 6)
and runImmediateShutdown (after polecat cleanup), matching the behavior
that gt down already has.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:36:11 -07:00
mayor
1701474b3d docs: add caution note to v0.2.3 changelog
Some checks failed
Release / goreleaser (push) Failing after 4m16s
Release / publish-npm (push) Has been skipped
Release / update-homebrew (push) Has been skipped
2026-01-09 01:00:27 -08:00
gastown/crew/jack
a7e9fbf699 feat(deacon): add github-gate-check step to patrol formula
Adds the missing github-gate-check step that runs `bd gate discover` and
`bd gate check --type=gh` to evaluate GitHub CI gates. Updates
dispatch-gated-molecules to depend on both gate-evaluation and
github-gate-check.

Fixes: gt-sfxpr

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:47:36 -08:00
gastown/crew/joe
358fcaf935 feat(mq): add configurable integration branch naming (#104)
Enterprise teams can now customize integration branch names to match
their conventions (e.g., username/TICKET-123/feature-name).

- Add integration_branch_template to MergeQueueConfig
- Add --branch CLI override for gt mq integration create
- Support {epic}, {prefix}, {user} template variables
- Validate branch names for git-safe characters
- Store actual branch name in epic metadata at create time
- Read stored branch name in land/status (fallback for old epics)

Also fixes unrelated build error in polecat/manager.go (polecatPath
variable was undefined).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:41:35 -08:00
furiosa
f19ddc5400 feat(costs): add verbose logging for silent failures
Add --verbose/-v flag to gt costs command that outputs debug information
when silent failures occur during cost tracking operations:

- wisp list failures in querySessionCostWisps and deleteSessionCostWisps
- bd show failures when querying wisp details
- JSON unmarshal failures when parsing wisp/event data
- payload unmarshal failures when parsing session payloads

This makes debugging cost tracking issues much easier as these error
paths previously continued silently without any indication of failure.

Closes: bd-qv8f9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:35:15 -08:00
onyx
64b58b31ab fix(costs): improve cost tracking performance and determinism
- Sort map keys before iteration in createCostDigestBead for deterministic
  output ordering in By Role and By Rig sections (bd-66z6a)
- Batch wisp IDs into single bd show call to fix N+1 query pattern in
  querySessionCostWisps (bd-3hqvs)
- Batch wisp deletion into single subprocess call in deleteSessionCostWisps
  (bd-i8zab)

Part of: bd-1wmwp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:25:53 -08:00
jack
afff85cdff fix(tmux): use NewSessionWithCommand to avoid send-keys race condition
Agent sessions would fail on startup because send-keys arrived before the
shell was ready, causing 'bad pattern' and 'command not found' errors.

Fix: Create sessions with the command directly using tmux new-session's
command argument. This runs the agent as the pane's initial process,
avoiding shell readiness timing issues entirely.

Updated all agent managers: mayor, deacon, witness, refinery, polecat, crew.

Also fixes pre-existing build error in polecat/manager.go (polecatPath →
clonePath/newClonePath).

Closes #280

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:35:31 -08:00
jack
a91e6cd643 fix(git): configure refspec on bare clones for worktree compatibility
Bare clones don't have remote.origin.fetch set by default, which breaks
worktrees that need to fetch and see origin/* refs. This caused refinery
to fail because origin/main never appeared after fetch.

- Add configureRefspec() to set standard refspec on bare repos
- Call from CloneBare() and CloneBareWithReference()
- Add BareRepoRefspecCheck to doctor for existing rigs

Closes #286

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:28:11 -08:00
gastown/crew/max
9b2f4a7652 feat(polecat): add repo path to worktrees for LLM ergonomics (GH#283)
Changes polecat worktree structure from:
  polecats/<name>/
to:
  polecats/<name>/<rigname>/

This gives Claude Code agents a recognizable directory name (e.g., tidepool/)
in their cwd instead of just the polecat name, preventing confusion about
which repo they are working in.

Key changes:
- Add clonePath() method to manager.go and session_manager.go for the actual
  git worktree path, keeping polecatDir() for existence checks
- Update Add(), RepairWorktree(), Remove() to use new structure
- Update daemon lifecycle and restart code for new paths
- Update witness handlers to detect both structures
- Update doctor checks (rig_check, branch_check, config_check,
  claude_settings_check) for backward compatibility
- All code includes fallback to old structure for existing polecats

Fixes #283

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:16:10 -08:00
george
c8c97fdf64 docs: add glossary contributed by Clay Shirky (#80)
Adds comprehensive glossary covering:
- Core principles (MEOW, GUPP, NDI)
- Environments (Town, Rig)
- Roles (Mayor, Deacon, Polecat, Witness, Crew, etc.)
- Work units (Bead, Formula, Molecule, Wisp, Hook)
- Workflow commands (Convoy, Slinging, Nudging, Handoff, Seance, Patrol)

Also adds link to glossary from README Core Concepts section.

Closes #80

Co-Authored-By: Clay Shirky <cshirky@users.noreply.github.com>
2026-01-08 23:03:52 -08:00
jack
43272f6fbb feat(tmux): enable mouse mode by default for Gas Town sessions
Adds EnableMouseMode() and calls it from ConfigureGasTownSession so all
new GT sessions get mouse support. Users can now click panes, scroll with
mouse wheel, and resize by dragging. Hold Shift for terminal text selection.

Closes #33

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:01:47 -08:00
george
65c3e90374 feat: Add Codex and OpenCode runtime backend support (#281)
Adds support for alternative AI runtime backends (Codex, OpenCode) alongside
the default Claude backend through a runtime abstraction layer.

- internal/runtime/runtime.go - Runtime-agnostic helper functions
- Extended RuntimeConfig with provider-specific settings
- internal/opencode/ for OpenCode plugin support
- Updated session managers to use runtime abstraction
- Removed unused ensureXxxSession functions
- Fixed daemon.go indentation, updated terminology to runtime

Backward compatible: Claude remains default runtime.

Co-Authored-By: Ben Kraus <ben@cinematicsoftware.com>
Co-Authored-By: Cameron Palmer <cameronmpalmer@users.noreply.github.com>
2026-01-08 22:56:37 -08:00
Steve Yegge
0eacdd367b feat(costs): redesign session cost tracking with wisps and daily digests (#292)
* feat(costs): redesign session cost tracking with wisps and daily digests

Implement the wisp-based cost tracking architecture per gt-cm900:

- gt costs record now creates ephemeral wisps (not exported to JSONL)
  to avoid log-in-database pollution with O(sessions/day) events

- gt costs digest aggregates yesterday's session wisps into a single
  permanent "Cost Report YYYY-MM-DD" bead for audit purposes

- gt costs query updated: --today queries wisps, --week queries
  digest beads + today's wisps

- gt costs migrate closes legacy open session.ended beads

- Deacon patrol formula updated with costs-digest step

The new architecture:
  Session ends -> Wisp (fast, N/day) -> Patrol digest -> Bead (1/day)

This preserves audit trail while keeping issues.jsonl clean.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: sync canonical formula with embedded copy

Update .beads/formulas/ with the costs-digest step added to
mol-deacon-patrol.formula.toml. The go:generate copies from
.beads/formulas/ to internal/formula/formulas/.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:52:47 -08:00
Cameron Palmer
9fe9323b9c fix: clean up dead code and fix indentation in runtime PR
- Remove unused ensureRefinerySession function from start.go
- Remove unused ensureSession and ensureWitness functions from up.go
- Remove unused ensureWitnessSession function from witness.go
- Remove orphaned imports (runtime, session, constants, config, rig, filepath, time)
- Fix indentation error in daemon.go triggerPendingSpawns comment

These functions were added as part of the Codex/OpenCode runtime support
but were never wired up. The existing managers (refinery.Manager.Start,
witness.Manager.Start) already handle session creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:51:51 -08:00
joe
bfafb9c179 docs: add explicit no-PR rule for maintainer repos
Polecats were creating GitHub PRs instead of using gt done to submit
to the Refinery. Added clear conditional language:

- If repo is steveyegge/beads or steveyegge/gastown: NEVER create PRs
- Polecats use gt done → Refinery merges
- Crew workers push directly to main
- PRs are for external contributors only

This fixes a prompting gap that led to PR #292 being created incorrectly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:47:39 -08:00
julianknutsen
677a6ed84f feat(formula): add untracked status for formulas without .installed.json
When upgrading gt on an existing installation without .installed.json,
formulas that exist but don't match embedded were incorrectly marked as
"modified" (implying user customization). Now they're marked "untracked"
and are safe to update since there's no record of user modification.

This improves the upgrade experience:
- "modified" = tracked file user changed (skip update)
- "untracked" = file exists but not tracked (safe to update)

Adds 3 new tests for untracked scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:43:44 -08:00
julianknutsen
da2d71c3fe feat(formula): add checksum-based auto-update for embedded formulas
Adds infrastructure to automatically update embedded formulas when
the binary is upgraded, while preserving user customizations.

Changes:
- Add CheckFormulaHealth() to detect outdated/modified/missing formulas
- Add UpdateFormulas() to safely update formulas via gt doctor --fix
- Track installed formula checksums in .beads/formulas/.installed.json
- Add FormulaCheck to gt doctor with auto-fix capability
- Compute checksums at runtime from embedded files (no build-time manifest)

Update scenarios:
- Outdated (embedded changed, user unchanged): Update automatically
- Modified (user customized): Skip with warning
- Missing (user deleted): Reinstall with message
- New (never installed): Install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:43:44 -08:00
dennis
e124402b7b fix: crew start rig inference + refactor overlay to shared utility
Two improvements:

1. gt crew start now infers rig from cwd when first arg is not a valid
   rig name (gt-czltv). Previously, running `gt crew start bob` from
   within a rig directory would fail because "bob" was treated as the
   rig name. Now it checks if the arg is a valid rig first.

2. Refactored copyOverlay to shared rig.CopyOverlay utility:
   - Eliminates code duplication between crew and polecat managers
   - Preserves source file permissions instead of hardcoding 0644
   - Follows PR #278 improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:40:23 -08:00
Steve Yegge
705a7c2137 Merge pull request #278 from EscapeVelocityOperations/main
feat: add .runtime/overlay/ support for polecat/crew worktrees
2026-01-08 22:37:29 -08:00
gastown/crew/max
c2c6ddeaf9 docs(polecat): add pre-submission checklist with correct workflow
Adds a visible "CRITICAL" warning and pre-submission checklist to the
polecat template. Explicitly notes that polecats should NOT manually
close the root issue - the Refinery handles that after merge.

This addresses the intent of PR #287 while avoiding the conflicting
`bd close` instruction that would break the Refinery workflow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:37:17 -08:00
beads/crew/giles
b509107100 feat(deacon): add dispatch-gated-molecules patrol step (GH#bd-1ep6e)
Add new step to mol-deacon-patrol.formula.toml that discovers molecules
blocked on gates that have now closed, and dispatches them to the
appropriate rig's polecat pool.

This completes the async resume cycle without explicit waiter tracking.
The molecule state IS the waiter - patrol discovers reality each cycle.

- Uses bd mol ready --gated to find gate-ready molecules
- Dispatches via gt sling <mol-id> <rig>/polecats
- Runs after gate-evaluation, before health-scan
- Bumps formula version to 6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:34:25 -08:00
gastown/crew/max
34cb28e0b9 fix(beads): restore graceful degradation for mayor/rig fallback
When rig/.beads doesn't exist, fall back to mayor/rig/.beads (tracked
beads architecture) with a warning suggesting 'bd doctor' to fix.

This restores behavior that was inadvertently removed in #290, which
simplified SetupRedirect but removed the fallback path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:02:15 -08:00
Joshua Vial
1da3e18e60 fix: gt sling failing to recognize beads after JSONL updates (#290)
* fix(sling): route bd mol commands to target rig directory

Executed-By: gastown/crew/jv
Rig: gastown
Role: crew

* Fix CI: enable beads custom types during install

Executed-By: gastown/crew/jv
Rig: gastown
Role: crew

* Fix gt sling failing to recognize beads after JSONL updates

Executed-By: gastown/crew/jv
Rig: gastown
Role: crew

---------

Co-authored-by: joshuavial <git@codewithjv.com>
2026-01-08 21:00:25 -08:00
max
5adb096d9d refactor(doctor): use strings.Contains instead of custom contains()
Replace the hand-rolled contains() function with the standard library
strings.Contains(). Also removes the redundant len(data) > 0 check
since strings.Contains handles empty strings correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:26:23 -08:00
Sohail Mohammad
81bfe48ed3 feat: Set and Forget - Seamless Gas Town Integration (#255)
Adds shell integration for automatic Gas Town context detection.

Features:
- `gt enable` / `gt disable` - Global on/off switch
- `gt shell install|remove|status` - Shell integration management
- `gt rig quick-add [path]` - One-command project setup
- `gt uninstall` - Clean removal with options
- Shell hook auto-sets GT_TOWN_ROOT/GT_RIG on cd

Implementation:
- XDG-compliant state storage (~/.local/state/gastown/)
- Safe RC file manipulation with block markers
- Environment overrides (GASTOWN_DISABLED/ENABLED)
- Doctor check for global state validation

Co-authored-by: Sohail Mohammad <sohailm25@gmail.com>
2026-01-08 20:25:01 -08:00
max
41a758d6d8 fix(test): repair unterminated string literal in integration test
The t.Skipf call had a raw newline inside a double-quoted string,
which is invalid Go syntax. Use \n escape sequence instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:18:41 -08:00
mayor
5250e9e12a chore: bump version to v0.2.3
Worker safety release - prevents accidental termination of active agents.

Key changes:
- Kill authority removed from Deacon patrol (death warrants only)
- Bulletproof pause mechanism for Deacon
- Doctor warns instead of killing sessions
- New gt account switch command
- Hidden directory scanning fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:06:57 -08:00
Subhrajit Makur
b3407759d2 fix(doctor): recognize gt prime --hook as valid session hook config (#14)
The SessionHookCheck was incorrectly flagging 'gt prime --hook' as invalid,
only accepting 'session-start.sh' wrapper. The --hook flag properly handles
session_id passthrough via stdin JSON, making it a valid alternative.

Changes:
- Update usesSessionStartScript to accept --hook flag
- Add containsFlag helper to prevent false positives (e.g., --hookup)
- Update error messages and fix hints to suggest both options
- Add comprehensive tests including edge cases

Tests cover:
- Bare gt prime (fails)
- gt prime --hook (passes)
- gt prime --hookup (fails - not a valid flag)
- gt prime --verbose --hook (passes - flag order doesn't matter)
- session-start.sh (passes)
- Mixed valid/invalid hooks in same file
- Town-level and rig-level settings
2026-01-08 17:58:16 -08:00
Subhrajit Makur
c8c765a239 fix: improve integration test reliability (#13)
- Add custom types config after bd init in daemon tests
- Replace fixed sleeps with poll-based waiting in tmux tests
- Skip beads integration test for JSONL-only repos

Fixes flaky test failures in parallel execution.
2026-01-08 17:58:16 -08:00
Steve Yegge
775af2973d Merge pull request #268 from julianknutsen/fix/gt-root-env
fix: agents cannot find town-level formulas
2026-01-08 17:52:41 -08:00
Steve Yegge
da906847dd Merge pull request #279 from joshuavial/fix/polecat-dotdir-scan
fix: extend polecat dot-dir filtering beyond #258
2026-01-08 17:23:26 -08:00
Steve Yegge
0a649e6faa Merge pull request #276 from joshuavial/feat/crew-list-all
feat: add --all to gt crew list
2026-01-08 17:23:23 -08:00
Steve Yegge
fb40fa1405 Merge pull request #272 from julianknutsen/fix/100-doctor-process-check-informational
fix(doctor): gt doctor --fix kills user's personal Claude sessions
2026-01-08 17:22:02 -08:00
Steve Yegge
7bfc2fcb76 Merge pull request #271 from julianknutsen/fix/daemon-restore-deacon-check
fix(daemon): dead deacon sessions never restarted (Boot observes but doesn't restart)
2026-01-08 17:21:58 -08:00
Steve Yegge
376305e9d9 Merge pull request #270 from julianknutsen/fix/formula-session-names
fix(formula): boot triage checks wrong session name, causing deacon restarts
2026-01-08 17:21:54 -08:00
Steve Yegge
73f5b4025b Merge pull request #273 from julianknutsen/fix/174-isclauderunning-detection
fix: IsClaudeRunning detects 'claude' and version patterns
2026-01-08 17:19:24 -08:00
Steve Yegge
c756f12d00 Merge pull request #269 from julianknutsen/cleanup/remove-unused-formula-json
chore: remove unused JSON formula file
2026-01-08 17:19:20 -08:00
max
8d5611f14e feat(doctor): add rig identity beads check
Adds RigBeadsCheck to gt doctor to verify rig identity beads exist.
These beads track rig metadata (git URL, prefix, state) and are created
by gt rig add. The check scans routes.jsonl and verifies each rig
has an identity bead, with --fix to create missing ones.

Recovered from furiosa's uncommitted work after worker interruption.

Co-Authored-By: furiosa <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:59:49 -08:00
Ben Kraus
98e154b18e opencode 2026-01-08 12:48:03 -05:00
Ben Kraus
38adfa4d8b codex 2026-01-08 12:36:54 -05:00
cstar
03b0f7ff52 feat: add .runtime/overlay/ support for polecat/crew worktrees
Add overlay directory support to automatically copy gitignored files
(like .env, config files) from <rig>/.runtime/overlay/ to polecat
and crew worktree roots when they are spawned.

This allows services started by polecats/crew to have their required
configuration files at the root without committing them to git.

Changes:
- Add copyOverlay() function to polecat and crew managers
- Call copyOverlay() after setupSharedBeads() in AddWithOptions/RepairWorktreeWithOptions
- Non-fatal: overlay copy failures only log warnings, don't block spawn

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:25:02 +01:00
joshuavial
3b628150c2 test: cover gt crew list --all 2026-01-09 02:22:36 +13:00
joshuavial
1afe3fb823 feat: add --all to gt crew list 2026-01-09 02:22:20 +13:00
julianknutsen
caa88d96c5 fix: IsClaudeRunning detects 'claude' and version patterns
Claude Code can report its pane command as "node", "claude", or a version
number like "2.0.76". Previously only "node" was detected, causing healthy
sessions to be incorrectly identified as zombies and killed during daemon
heartbeat recovery.

This fix detects all three patterns to prevent witness sessions from being
killed every 3 minutes.

Based on michaellady's work in PR #174.

Co-Authored-By: michaellady <michaellady@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 02:25:15 -08:00
julianknutsen
4c9e8b8b99 fix(doctor): make orphan process check informational only
The orphan-processes check previously killed any Claude process without
a tmux ancestor, which incorrectly targeted user's personal Claude
sessions running in regular terminals.

Now the check is informational only:
- Changed from FixableCheck to BaseCheck (no auto-fix)
- Returns StatusOK with details listing processes outside tmux
- Message advises user to verify processes are expected
- Removed Fix method and related helpers

The orphan-sessions check remains fixable since it only targets gt-*
sessions that don't match valid Gas Town patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:57:27 -08:00
joshuavial
c699e3e2ed Stabilize bd role config tests 2026-01-08 22:43:31 +13:00
julianknutsen
65ecb6cafd fix(daemon): restore ensureDeaconRunning to heartbeat and use Manager
The heartbeat now explicitly calls ensureDeaconRunning() for basic
"is Deacon alive" checks, while Boot handles intelligent triage
(stuck/nudge/interrupt decisions).

Changed ensureDeaconRunning to use deacon.Manager.Start() instead of
duplicating startup logic. This gives daemon the same benefits:
- WaitForShellReady (fixes race condition)
- Claude settings setup
- Theming
- StartupNudge and PropulsionNudge (GUPP)

Heartbeat order:
1. ensureDeaconRunning - restart if dead (via Manager)
2. ensureBootRunning - intelligent triage for stuck states
3. checkDeaconHeartbeat - belt-and-suspenders fallback
4-11. Other checks (witnesses, refineries, polecats, etc.)

This was inadvertently removed when Boot was introduced, which
delegated all Deacon checks to Boot. But Boot's mol doesn't actually
restart Deacon - it just reports. Now responsibilities are clear:
daemon ensures alive, Boot ensures responsive.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:23:51 -08:00
julianknutsen
540e33dbe9 fix(formula): correct deacon session name references in formulas
The deacon tmux session is named hq-deacon, not gt-deacon. Fix the
incorrect references in mol-boot-triage and mol-gastown-boot formulas.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:16:25 -08:00
joshuavial
85dd150d75 Skip dot dirs when scanning polecats 2026-01-08 22:10:40 +13:00
joshuavial
45634059dd Ignore .claude dirs when listing polecats 2026-01-08 22:10:39 +13:00
julianknutsen
d4da2b325d chore: remove unused JSON formula file
The formula parser only supports TOML (uses toml.Decode). The JSON
version of mol-gastown-boot was never used - it was likely created
by mistake or for an abandoned experiment.

Changes:
- Remove .beads/formulas/mol-gastown-boot.formula.json
- Remove internal/formula/formulas/mol-gastown-boot.formula.json
- Simplify go:generate to only copy .formula.toml files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:09:14 -08:00
julianknutsen
4985bdfbcc feat(config): set GT_ROOT env var for all agent sessions
Previously GT_ROOT was documented as a formula search path but never
actually set, making $GT_ROOT/.beads/formulas/ unreachable for agents.

Now BuildStartupCommand automatically sets GT_ROOT to the town root,
enabling all agents (witness, refinery, polecat, crew, etc.) to find
town-level formulas without relying on cwd-relative paths.

Also adds a doctor check (gt-root-env) that warns when existing sessions
are missing GT_ROOT, with instructions to restart sessions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:56:26 -08:00
max
f4cbcb4ce9 fix: SetupRedirect now works with tracked beads architecture
The SetupRedirect function was failing for rigs that use the tracked
beads architecture where the canonical beads location is mayor/rig/.beads
and there is no rig-level .beads directory.

This fix now checks for both locations:
1. rig/.beads (with optional redirect to mayor/rig/.beads)
2. mayor/rig/.beads directly (if no rig/.beads exists)

This ensures crew and polecat workspaces get the correct redirect file
pointing to the shared beads database in all configurations.

Closes: gt-jy77g

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:10:09 -08:00
Steve Yegge
c4d956ebe7 feat(deacon): implement bulletproof pause mechanism (gt-bpo2c) (#265)
Add multi-layer pause mechanism to prevent Deacon from causing damage:

Layer 1: File-based pause state
- Location: ~/.runtime/deacon/paused.json
- Stores: paused flag, reason, timestamp, paused_by

Layer 2: Commands
- `gt deacon pause [--reason="..."]` - pause with optional reason
- `gt deacon resume` - remove pause file
- `gt deacon status` - shows pause state prominently

Layer 3: Guards
- `gt prime` for deacon: shows PAUSED message, skips patrol context
- `gt deacon heartbeat`: fails when paused

Helper package:
- internal/deacon/pause.go with IsPaused/Pause/Resume functions

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:56:46 -08:00
jack
7f6fe53c6f feat(account): add gt account switch command
Adds the ability to switch between Claude Code accounts with a single command:
  gt account switch <handle>

The command:
1. Detects current account by checking ~/.claude symlink target
2. If ~/.claude is a real directory, moves it to the current account config_dir
3. Removes existing ~/.claude symlink (if any)
4. Creates symlink from ~/.claude to target account config_dir
5. Updates default account in accounts.json
6. Prints confirmation with restart reminder

Handles edge cases:
- Already on target account (no-op with message)
- Target account does not exist (error with list of valid accounts)
- ~/.claude is real directory (first-time setup scenario)

Closes gt-jd8m1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:41:51 -08:00
jack
19f4fa3ddb fix(install): update placeholder comment to reference gt-4ke5e
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:38:35 -08:00
joe
e648edce8c docs: add Context Management section to Witness template (gt-jjama)
Add explicit handoff/cycling heuristics for the Witness role:
- Hand off after 15 patrol loops (vs Deacon's 20)
- Immediate handoff after extraordinary actions
- Define extraordinary actions specific to Witness role
- Add Handoff (Wisp-Based) section explaining idempotent patrols

This brings Witness documentation in line with Deacon's level of
detail for context cycling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:37:21 -08:00
slit
8a8b56e9e6 fix: derive gt- prefix for gastown compound words (gt-m46bb) 2026-01-07 21:12:18 -08:00
julianknutsen
c91ab85457 fix: restore agent override support lost in manager refactors
The manager refactors (ea8bef2, 72544cc0) conflicted with the agent
override feature, causing regressions:

Deacon (ea8bef2):
- Lost agentOverride parameter
- Re-added respawn loop (removed in 5f2e16f)
- Lost GUPP (startup + propulsion nudges)

Crew (72544cc0):
- Lost agentOverride wiring to StartOptions
- --agent flag had no effect on crew refresh/restart

This fix restores agent override support and GUPP while keeping
improvements from the manager refactors (zombie detection, etc).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:08:51 -08:00
Subhrajit Makur
00a59dec44 feat: Add rig-level custom agent support (#12)
* feat: Add rig-level custom agent support

Implement rig-level custom agent configuration support to enable per-rig
agent definitions in <rig>/settings/config.json, following the same pattern as
town-level agents in settings/config.json.

Changes:
- Added RigSettings.Agents field to internal/config/types.go
- Added DefaultRigAgentRegistryPath() and LoadRigAgentRegistry() functions to internal/config/agents.go
- Updated ResolveAgentConfigWithOverride() to accept and pass rigSettings parameter
- Updated GetRuntimeCommandWithAgentOverride() to use rigSettings when available
- Updated GetRuntimeCommandWithPromptAndAgentOverride() to use rigSettings
- Updated all Build*WithOverride functions to pass rigSettings

This fixes the issue where rig-level agent settings were loaded but
ignored by lookupAgentConfig, enabling per-rig custom agents for
polecats and crew members.

* test: Add rig-level custom agent tests

Added comprehensive unit tests for rig agent registry functions:
- TestDefaultRigAgentRegistryPath: verifies path construction
- TestLoadRigAgentRegistry: verifies file loading and JSON parsing
- TestLookupAgentConfigWithRigSettings: verifies agent lookup priority (rig > town > builtin)

Added placeholder integration test for future CI/CD setup.

* initial commit

* fix: resolve compilation errors in rig-level custom agent support

- Add missing RigAgentRegistryPath function (alias for DefaultRigAgentRegistryPath)
- Restore ResolveAgentConfigWithOverride function that was incorrectly removed
- Fix ResolveAgentConfig to return single value (not triple)
- Add initRegistryLocked() call to LoadRigAgentRegistry to prevent nil panic
- Fix DefaultRigAgentRegistryPath to use rigPath directly (not parent dir)
- Fix test file syntax errors (remove EOF artifacts)
- Fix test parameter order for lookupAgentConfig calls
- Fix test expectations to match correct custom agent override behavior

* test: implement rig-level custom agent integration test

- Add stub agent script that simulates AI agent with Q&A capability
- Test ResolveAgentConfig correctly picks up rig-level agents
- Test BuildPolecatStartupCommand includes custom agent command
- Test ResolveAgentConfigWithOverride respects rig agents
- Test rig agents override town agents with same name
- Add tmux integration test that spawns session and verifies output
- Stub agent echoes 'STUB_AGENT_STARTED' and handles ping/pong Q&A
- All tests pass including real tmux session verification

* docs: add OpenCode custom agent example to reference

- Show settings/agents.json format for advanced configs
- Include OpenCode example with session resume flags
- Document OPENCODE_PERMISSION env var for autonomous mode

* fix: improve rig-level agent support with docs and test fixes

- Add rig-level agent documentation to reference.md
- Document agent resolution order (rig → town → built-in)
- Deduplicate LoadAgentRegistry/LoadRigAgentRegistry into shared helper
- Fix test isolation in TestLoadRigAgentRegistry
- Fix nil pointer dereference in test assertions (use t.Fatal not t.Error)
2026-01-07 21:06:46 -08:00
Julian Knutsen
2de2d6b7e4 refactor: replace ensureRefinerySession with refinery.Manager.Start()
Replaces inline ensureRefinerySession function with refinery.NewManager(r).Start(false) in gt start --all. Gains zombie detection, proper state tracking, and WaitForShellReady fix.

CI failures (lint in beads.go, integration tests) are pre-existing issues unrelated to this PR's changes.

Co-Authored-By: julianknutsen <julianknutsen@users.noreply.github.com>
2026-01-07 21:03:52 -08:00
joshuavial
f30178265c Fix CI: enable beads custom types during install 2026-01-07 21:03:03 -08:00
keeper
5141facb21 docs: design Dog pool architecture for concurrent shutdown dances (gt-fsld8)
Key decisions:
- Fixed pool of 5 goroutines (not Claude sessions)
- State file persistence for crash recovery
- Warrant queuing when pool exhausted
- Dogs are lightweight state machine executors
- New internal/shutdown/ package (separate from existing dog package)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:01:47 -08:00
dag
15caf62b9f fix(formula): remove kill authority from Deacon patrol (gt-vhaej)
The Deacon patrol formula's zombie-scan step now:
- Only detects zombies via --dry-run, never kills directly
- Files death warrants for Boot to handle interrogation/execution
- Includes psychological weight language about termination gravity

This prevents accidental destruction of worker context, mid-task
progress, and unsaved state. Kill authority belongs to Boot.

Bumped version to 5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:01:38 -08:00
Julian Knutsen
28a9de64d5 fix(tmux): wait for shell ready before sending keys (#264)
Add WaitForShellReady call before SendKeys in all agent managers
(deacon, mayor, witness, refinery). This prevents intermittent
"can't find pane" errors that occur when the tmux session is
created but the shell isn't ready to receive input yet.

The issue manifests under load (e.g., during `gt up` when multiple
agents start in sequence) where the 200ms delay in SendKeysDelayed
isn't sufficient for the pane to be fully initialized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:49:25 -08:00
Joshua Vial
a9ed342be6 fix: ignore hidden directories when enumerating polecats (#258)
* fix(sling): route bd mol commands to target rig directory

* Fix daemon polecat enumeration to ignore hidden dirs

* Ignore hidden dirs when discovering rig polecats

* Fix CI: enable beads custom types during install

---------

Co-authored-by: joshuavial <git@codewithjv.com>
2026-01-07 20:48:09 -08:00
Mike Lady
f9e788ccfb feat(ci): Add code coverage reporting to GitHub Actions (#246)
* bd sync: 2026-01-05 06:22:43

* bd sync: 2026-01-05 07:08:42

* bd sync: 2026-01-05 07:24:58

* feat: Add code coverage PR comment to GitHub Actions

Adds a step to the CI workflow that:
- Collects code coverage during test runs
- Parses per-package coverage percentages
- Posts a markdown table comment on PRs with:
  - Overall coverage percentage
  - Per-package breakdown table
- Updates existing comment on subsequent pushes

Closes: ga-tl5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): handle fork PR permissions for coverage comment

Fork PRs cannot write comments via GITHUB_TOKEN due to security
restrictions. Add condition to skip comment step for external PRs
and upload coverage report as artifact instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(ci): separate coverage into dedicated job

- Test job now uploads coverage.out and test-output.txt as artifacts
- New Coverage Report job runs after tests complete
- Downloads coverage data, generates report, uploads as artifact
- Always uploads coverage-report artifact (for both fork and internal PRs)
- Comments on PR only for internal PRs (fork PRs get notice message)
- Cleaner separation of concerns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): coverage job waits for both test and integration

Coverage Report job now depends on [test, integration] to ensure
it only runs after all test stages complete successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): restore Coverage Report job after Test and Integration

Coverage Report job now properly:
- Depends on [test, integration] - waits for both to complete
- Downloads coverage data from Test job
- Generates and uploads coverage-report artifact
- Comments on internal PRs only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add debugging output to TestInstallTownRoleSlots

Add logging for gt install output and bd list to help diagnose
CI failures where agent beads may not be created.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): update beads to @main and fix lint errors

- Change CI to install beads from @main instead of @latest
  (latest release doesn't support role/agent issue types)
- Remove error return from cleanBeadsRuntimeFiles since all
  errors are intentionally ignored (best-effort cleanup)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): pin beads to v0.44.0 for agent/role types

Beads main recently extracted Gas Town-specific types (agent, role, etc.)
from core. Pin CI to v0.44.0 which still has these types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): unpin beads version back to @latest

Beads v0.46.0 now supports agent/role types again.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove stale gastown/.beads files from PR

These beads files are local runtime state that shouldn't be committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:45:58 -08:00
organic
c220678162 Merge remote-tracking branch 'origin/main' into polecat/organic-mk4yjuw0 2026-01-07 20:45:01 -08:00
organic
b649635f48 feat(formula): add shutdown-dance molecule for death warrant execution
Defines the state machine that Dogs execute for death warrants:
- 3-attempt interrogation with escalating timeouts (60s, 120s, 240s)
- PARDON path when session responds with ALIVE
- EXECUTE path after all attempts exhausted
- EPITAPH step for audit logging

Key design decisions documented:
- Dogs are goroutines, not Claude sessions
- Timeout gates close on timer OR early response detection
- State persisted to ~/gt/deacon/dogs/active/ for crash recovery

Implements specification for gt-cd404.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:44:30 -08:00
Julian Knutsen
117b91b87f fix(doctor): warn instead of killing sessions for stale town-root settings (#243)
When gt doctor --fix detects stale Claude settings at town root, it was
automatically killing ALL Gas Town sessions (gt-* and hq-*). This is too
disruptive because:

1. Deacon runs gt doctor automatically, creating a restart loop
2. Active crew/polecat work could be lost mid-task
3. Settings are only read at startup, so running agents already have
   the config loaded in memory

Instead, warn the user and tell them to restart agents manually:
"Town-root settings were moved. Restart agents to pick up new config:
    gt up --restart"

Addresses PR #239 feedback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:41:57 -08:00
Julian Knutsen
ffa8dd56cb test(polecat): skip beads-unavailable test when bd is installed (#244)
TestGetReturnsWorkingWithoutBeads assumes bd is not available and
expects state to default to StateWorking. When bd is installed, it
actually queries beads and returns the real state, causing the test
to fail.

Skip the test when bd is detected to avoid environment-dependent
failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:41:34 -08:00
Mike Lady
92042d679c feat: Add Cursor, Auggie, and Sourcegraph AMP agent presets (#247)
* feat: add Cursor Agent as compatible agent for Gas Town

Add AgentCursor preset with ProcessNames field for multi-agent detection:
- AgentCursor preset: cursor-agent -p -f (headless + force mode)
- ProcessNames field on AgentPresetInfo for agent detection
- IsAgentRunning(session, processNames) in tmux package
- GetProcessNames(agentName) helper function

Closes: ga-vwr

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: centralize agent preset list in config.go

Replace hardcoded ["claude", "gemini", "codex"] arrays with calls to
config.ListAgentPresets() to dynamically include all registered agents.

This fixes cursor agent not appearing in `gt config agent list` and
ensures new agent presets are automatically included everywhere.

Also updated doc comments to include "cursor" in example lists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add comprehensive agent client tests

Add tests for agent detection and command generation:

- TestIsAgentRunning: validates process name detection for all agents
  (claude/node, gemini, codex, cursor-agent)
- TestIsAgentRunning_NonexistentSession: edge case handling
- TestIsClaudeRunning: backwards compatibility wrapper
- TestListAgentPresetsMatchesConstants: ensures ListAgentPresets()
  returns all AgentPreset constants
- TestAgentCommandGeneration: validates full command line generation
  for all supported agents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add Auggie agent, fix Cursor interactive mode

Add Auggie CLI as supported agent:
- Command: auggie
- Args: --allow-indexing
- Supports session resume via --resume flag

Fix Cursor agent configuration:
- Remove -p flag (requires prompt, breaks interactive mode)
- Clear SessionIDEnv (cursor uses --resume with chatId directly)
- Keep -f flag for force/YOLO mode

Updated all test cases for both agents.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(agents): add Sourcegraph AMP as agent preset

Add AgentAmp constant and builtinPresets entry for Sourcegraph AMP CLI.

Configuration:
- Command: amp
- Args: --dangerously-allow-all --no-ide
- ResumeStyle: subcommand (amp threads continue <threadId>)
- ProcessNames: amp

Closes: ga-guq

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: lint error in cleanBeadsRuntimeFiles

Change function to not return error (was always nil).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: beads v0.46.0 compatibility and test fixes

- Add custom types config (agent,role,rig,convoy,event) after bd init calls
- Fix tmux_test.go to use variadic IsAgentRunning signature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update agent documentation for new presets

- README.md: Update agent examples to show cursor/auggie, add built-in presets list
- docs/reference.md: Add cursor, auggie, amp to built-in agents list
- CHANGELOG.md: Add entry for new agent presets under [Unreleased]

Addresses PR #247 review feedback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:35:06 -08:00
tajquitgenius
585c204648 fix: Register custom beads types during install (#250)
During `gt install`, the beads database is initialized but Gas Town's
custom issue types (agent, role, rig, convoy, slot) were not being
registered. This caused subsequent agent bead creation to fail with
"invalid issue type: agent" errors.

The fix adds `bd config set types.custom "agent,role,rig,convoy,slot"`
after `bd init` completes. This is idempotent and safe to run multiple
times.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:17:39 -08:00
Martin Emde
6209a49d54 fix(costs): derive session name for mayor/deacon without GT_TOWN
Error: Ran 1 stop hook
  ⎿  Stop hook error: Failed with non-blocking status code: Error: --session flag required (or set GT_SESSION env var, or GT_RIG/GT_ROLE)
  Usage:
    gt costs record [flags]

deriveSessionName() now falls back to gt-{role} when GT_ROLE is mayor
or deacon but GT_TOWN is not set. Previously this case returned empty
string, causing the Stop hook to fail.
2026-01-07 19:17:28 -08:00
Steve Yegge
ffeff97d9f fix(crew): improve error message when not in crew workspace
- Show clearer error explaining user needs to specify crew name or cd into crew dir
- When --rig is specified, list available crew members in that rig

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:24:38 -08:00
Steve Yegge
9b5c889795 fix(costs): detect tmux session without TMUX env var
The TMUX environment variable is not inherited when Claude Code runs
bash commands, even though we are inside a tmux session. This caused
the Stop hook's 'gt costs record' to fail with:
  Error: --session flag required

Fix: Remove the early return that checked TMUX env var. The
tmux display-message command will naturally fail if we're not
in tmux, so the check was unnecessary and harmful.

Fixes: hq-to0lr

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:20:30 -08:00
219 changed files with 32125 additions and 5046 deletions

43
.beads/PRIME.md Normal file
View 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.**

View File

@@ -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

View File

@@ -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

View File

@@ -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."
}
]
}

View File

@@ -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

View 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"

View File

@@ -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"
}
]
}
]
}
}

View File

@@ -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

View File

@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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.

View File

@@ -77,6 +77,8 @@ Work tracking units. Bundle multiple issues/tasks that get assigned to agents.
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
@@ -85,7 +87,8 @@ Git-backed issue tracking system that stores work state as structured data.
- **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** - [claude.ai/code](https://claude.ai/code)
- **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
@@ -180,6 +183,18 @@ gt convoy create "Auth System" issue-101 issue-102 --notify
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)
# or: codex # Start Codex in the workspace
gt convoy list # Check progress
```
### Beads Formula Workflow
**Best for:** Predefined, repeatable processes
@@ -258,6 +273,30 @@ gt sling bug-101 myproject/my-agent
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
### Workspace Management
@@ -274,12 +313,14 @@ gt crew add <name> --rig <rig> # Create crew workspace
```bash
gt agents # List active agents
gt sling <issue> <rig> # Assign work to agent
gt sling <issue> <rig> --agent codex # Override runtime for this sling/spawn
gt sling <issue> <rig> --agent cursor # Override runtime for this sling/spawn
gt mayor attach # Start Mayor session
gt mayor start --agent gemini # Run Mayor with a specific agent alias
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
@@ -312,6 +353,10 @@ 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
Gas Town includes a web dashboard for monitoring:

View File

@@ -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
@@ -159,16 +161,17 @@ 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

94
docs/glossary.md Normal file
View 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).*

View File

@@ -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:

View File

@@ -206,17 +206,60 @@ 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
@@ -359,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

495
dog-pool-architecture.md Normal file
View 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

13
go.mod
View File

@@ -6,7 +6,7 @@ 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
@@ -16,22 +16,30 @@ 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
@@ -41,5 +49,8 @@ require (
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
)

28
go.sum
View File

@@ -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=
@@ -29,6 +39,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
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=
@@ -37,6 +49,8 @@ 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=
@@ -45,16 +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=
@@ -80,9 +101,16 @@ 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=

View File

@@ -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

View File

@@ -0,0 +1,384 @@
// 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,
"--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
}
// 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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)
}

View 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
}

View File

@@ -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)
@@ -1741,7 +1748,7 @@ func TestSetupRedirect(t *testing.T) {
rigRoot := filepath.Join(townRoot, "testrig")
crewPath := filepath.Join(rigRoot, "crew", "max")
// No rig/.beads created
// No rig/.beads or mayor/rig/.beads created
if err := os.MkdirAll(crewPath, 0755); err != nil {
t.Fatalf("mkdir crew: %v", err)
}
@@ -1751,4 +1758,44 @@ func TestSetupRedirect(t *testing.T) {
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)
}
})
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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())
}

View File

@@ -38,17 +38,24 @@ func RoleTypeFor(role string) RoleType {
// 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 {
claudeDir := filepath.Join(workDir, ".claude")
settingsPath := filepath.Join(claudeDir, "settings.json")
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 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)
}

View File

@@ -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)
}

View 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)
}
})
}

View File

@@ -84,14 +84,12 @@ func (v beadsVersion) compare(other beadsVersion) int {
return 0
}
// getBeadsVersion executes `bd --version` and parses the output.
// Returns the version string (e.g., "0.44.0") or error.
func getBeadsVersion() (string, error) {
cmd := exec.Command("bd", "--version")
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("bd version failed: %s", string(exitErr.Stderr))
}
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
}

View File

@@ -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

View File

@@ -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.
@@ -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:
@@ -574,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",
@@ -593,30 +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 events immediately after creation.
// These are informational audit events that don't need to stay open.
// The event data is preserved in the closed bead and remains queryable.
closeCmd := exec.Command("bd", "close", eventID, "--reason=auto-closed session event")
// 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: event was created, just couldn't auto-close
// The witness patrol can clean these up if needed
fmt.Fprintf(os.Stderr, "warning: could not auto-close session event %s: %v\n", eventID, closeErr)
// 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)
}
@@ -649,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}
@@ -664,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 {
@@ -722,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
}

View File

@@ -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{},

View File

@@ -19,6 +19,7 @@ var (
crewAccount string
crewAgentOverride string
crewAll bool
crewListAll bool
crewDryRun bool
)
@@ -77,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,
}
@@ -323,6 +325,7 @@ 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")

View File

@@ -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

View File

@@ -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 an agent running in this directory
// Found an existing session with runtime running in this directory
existingSession := existingSessions[0]
fmt.Printf("%s Found existing agent 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,31 +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
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
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 claude: %w", err)
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
// 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)...) {
// Claude has exited, restart it using respawn-pane
fmt.Printf("Claude exited, restarting...\n")
// Runtime has exited, restart it using respawn-pane
fmt.Printf("Runtime exited, restarting...\n")
// Get pane ID for respawn
paneID, err := t.GetPaneID(sessionID)
@@ -178,15 +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
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
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 claude: %w", err)
return fmt.Errorf("restarting runtime: %w", err)
}
}
}
@@ -194,13 +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
// 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

View File

@@ -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

View File

@@ -1,18 +1,22 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"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/mail"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
@@ -163,7 +167,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
} else {
// Default: CLOSE the agent bead (preserves CV history)
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
@@ -236,9 +240,10 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
// 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
KillExisting: true, // Kill old session if running
Topic: "refresh", // Startup nudge topic
Interactive: true, // No --dangerously-skip-permissions
AgentOverride: crewAgentOverride,
})
if err != nil {
return fmt.Errorf("starting crew session: %w", err)
@@ -252,8 +257,9 @@ 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 - defaults to all if not specified)
// 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
@@ -262,8 +268,16 @@ func runCrewStart(cmd *cobra.Command, args []string) error {
// 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)
@@ -289,28 +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
startCrewAgentOverride = crewAgentOverride
// 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
@@ -346,8 +405,9 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
// 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
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
AgentOverride: crewAgentOverride,
})
if err != nil {
fmt.Printf("Error restarting %s: %v\n", arg, err)
@@ -425,8 +485,9 @@ func runCrewRestartAll() error {
// 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
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
AgentOverride: crewAgentOverride,
})
if err != nil {
failed++

View File

@@ -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 {

View 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)
}
}

View File

@@ -17,6 +17,7 @@ import (
"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"
@@ -112,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:
@@ -205,6 +206,35 @@ Examples:
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
@@ -220,6 +250,9 @@ var (
// Stale hooks flags
staleHooksMaxAge time.Duration
staleHooksDryRun bool
// Pause flags
pauseReason string
)
func init() {
@@ -234,6 +267,8 @@ func init() {
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,
@@ -259,6 +294,10 @@ func init() {
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)")
@@ -319,8 +358,15 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride 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
@@ -345,6 +391,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride 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",
@@ -418,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)
@@ -487,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, " ")
@@ -951,3 +1030,68 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error {
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
View 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()
}

View File

@@ -32,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)
@@ -108,12 +114,19 @@ 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())
@@ -127,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())
@@ -135,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
@@ -145,6 +161,9 @@ func runDoctor(cmd *cobra.Command, args []string) error {
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())

View File

@@ -58,6 +58,7 @@ var (
doneExit bool
donePhaseComplete bool
doneGate string
doneCleanupStatus string
)
// Valid exit types for gt done
@@ -75,6 +76,7 @@ func init() {
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)
}
@@ -428,13 +430,14 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
}
// ZFC #10: Self-report cleanup status
// Compute git state and report so Witness can decide removal safety
cleanupStatus := computeCleanupStatus(cwd)
if cleanupStatus != polecat.CleanupUnknown {
if err := bd.UpdateAgentCleanupStatus(agentBeadID, string(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
}
}
}
}
@@ -460,25 +463,19 @@ 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) polecat.CleanupStatus {
g := git.NewGit(cwd)
status, err := g.CheckUncommittedWork()
if err != nil {
// If we can't check, report unknown - Witness should be cautious
// 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
}
// Check in priority order (most critical first)
if status.UnpushedCommits > 0 {
return polecat.CleanupUnpushed
}
if status.StashCount > 0 {
return polecat.CleanupStash
}
if status.HasUncommittedChanges {
return polecat.CleanupUncommitted
}
return polecat.CleanupClean
}

54
internal/cmd/enable.go Normal file
View 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
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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"
@@ -192,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)
}
@@ -340,26 +351,40 @@ 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. export Claude-related env vars (not inherited by fresh shell)
// 4. run claude with "gt prime" as initial prompt (triggers GUPP)
// 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 != "" {
exports = append(exports, fmt.Sprintf("GT_ROLE=%s", gtRole))
exports = append(exports, fmt.Sprintf("BD_ACTOR=%s", gtRole))
exports = append(exports, fmt.Sprintf("GIT_AUTHOR_NAME=%s", gtRole))
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
@@ -606,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
View 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)
}

View File

@@ -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...)

View File

@@ -139,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

View File

@@ -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

View File

@@ -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
}

View File

@@ -11,14 +11,18 @@ 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 +34,8 @@ var (
installGit bool
installGitHub string
installPublic bool
installShell bool
installWrappers bool
)
var installCmd = &cobra.Command{
@@ -55,7 +61,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 +76,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)
}
@@ -260,6 +269,29 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Printf(" ✓ Created .claude/commands/ (slash commands for all agents)\n")
}
if installShell {
fmt.Println()
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())
}
}
fmt.Printf("\n%s HQ created successfully!\n", style.Bold.Render("✓"))
fmt.Println()
fmt.Println("Next steps:")
@@ -313,6 +345,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.
@@ -321,6 +362,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
}
@@ -337,6 +385,20 @@ 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)
@@ -358,6 +420,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
@@ -476,3 +545,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
}

View File

@@ -143,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")
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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)
}

View 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"
}

352
internal/cmd/mail_inbox.go Normal file
View File

@@ -0,0 +1,352 @@
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 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
View 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
}

View 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
View 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
View 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
}

View File

@@ -451,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:
@@ -602,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)
@@ -876,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.

View File

@@ -47,6 +47,9 @@ var (
// Integration status flags
mqIntegrationStatusJSON bool
// Integration create flags
mqIntegrationCreateBranch string
)
var mqCmd = &cobra.Command{
@@ -190,18 +193,31 @@ var mqIntegrationCreateCmd = &cobra.Command{
Short: "Create an integration branch for an epic",
Long: `Create an integration branch for batch work on an epic.
Creates a branch named integration/<epic-id> from main and pushes it
to origin. Future MRs for this epic's children can target this branch.
Creates a branch from main and pushes it to origin. Future MRs for this
epic's children can target this branch.
Branch naming:
Default: integration/<epic-id>
Config: Set merge_queue.integration_branch_template in rig settings
Override: Use --branch flag for one-off customization
Template variables:
{epic} - Full epic ID (e.g., "RA-123")
{prefix} - Epic prefix before first hyphen (e.g., "RA")
{user} - Git user.name (e.g., "klauern")
Actions:
1. Verify epic exists
2. Create branch integration/<epic-id> from main
2. Create branch from main (using template or --branch)
3. Push to origin
4. Store integration branch info in epic metadata
4. Store actual branch name in epic metadata
Example:
Examples:
gt mq integration create gt-auth-epic
# Creates integration/gt-auth-epic from main`,
# Creates integration/gt-auth-epic (default)
gt mq integration create RA-123 --branch "klauern/PROJ-1234/{epic}"
# Creates klauern/PROJ-1234/RA-123`,
Args: cobra.ExactArgs(1),
RunE: runMqIntegrationCreate,
}
@@ -287,6 +303,7 @@ func init() {
mqCmd.AddCommand(mqStatusCmd)
// Integration branch subcommands
mqIntegrationCreateCmd.Flags().StringVar(&mqIntegrationCreateBranch, "branch", "", "Override branch name template (supports {epic}, {prefix}, {user})")
mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd)
// Integration land flags

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/cobra"
@@ -16,6 +17,141 @@ import (
"github.com/steveyegge/gastown/internal/workspace"
)
// Integration branch template constants
const defaultIntegrationBranchTemplate = "integration/{epic}"
// invalidBranchCharsRegex matches characters that are invalid in git branch names.
// Git branch names cannot contain: ~ ^ : \ space, .., @{, or end with .lock
var invalidBranchCharsRegex = regexp.MustCompile(`[~^:\s\\]|\.\.|\.\.|@\{`)
// buildIntegrationBranchName expands an integration branch template with variables.
// Variables supported:
// - {epic}: Full epic ID (e.g., "RA-123")
// - {prefix}: Epic prefix before first hyphen (e.g., "RA")
// - {user}: Git user.name (e.g., "klauern")
//
// If template is empty, uses defaultIntegrationBranchTemplate.
func buildIntegrationBranchName(template, epicID string) string {
if template == "" {
template = defaultIntegrationBranchTemplate
}
result := template
result = strings.ReplaceAll(result, "{epic}", epicID)
result = strings.ReplaceAll(result, "{prefix}", extractEpicPrefix(epicID))
// Git user (optional - leaves placeholder if not available)
if user := getGitUserName(); user != "" {
result = strings.ReplaceAll(result, "{user}", user)
}
return result
}
// extractEpicPrefix extracts the prefix from an epic ID (before the first hyphen).
// Examples: "RA-123" -> "RA", "PROJ-456" -> "PROJ", "abc" -> "abc"
func extractEpicPrefix(epicID string) string {
if idx := strings.Index(epicID, "-"); idx > 0 {
return epicID[:idx]
}
return epicID
}
// getGitUserName returns the git user.name config value, or empty if not set.
func getGitUserName() string {
cmd := exec.Command("git", "config", "user.name")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// validateBranchName checks if a branch name is valid for git.
// Returns an error if the branch name contains invalid characters.
func validateBranchName(branchName string) error {
if branchName == "" {
return fmt.Errorf("branch name cannot be empty")
}
// Check for invalid characters
if invalidBranchCharsRegex.MatchString(branchName) {
return fmt.Errorf("branch name %q contains invalid characters (~ ^ : \\ space, .., or @{)", branchName)
}
// Check for .lock suffix
if strings.HasSuffix(branchName, ".lock") {
return fmt.Errorf("branch name %q cannot end with .lock", branchName)
}
// Check for leading/trailing slashes or dots
if strings.HasPrefix(branchName, "/") || strings.HasSuffix(branchName, "/") {
return fmt.Errorf("branch name %q cannot start or end with /", branchName)
}
if strings.HasPrefix(branchName, ".") || strings.HasSuffix(branchName, ".") {
return fmt.Errorf("branch name %q cannot start or end with .", branchName)
}
// Check for consecutive slashes
if strings.Contains(branchName, "//") {
return fmt.Errorf("branch name %q cannot contain consecutive slashes", branchName)
}
return nil
}
// getIntegrationBranchField extracts the integration_branch field from an epic's description.
// Returns empty string if the field is not found.
func getIntegrationBranchField(description string) string {
if description == "" {
return ""
}
lines := strings.Split(description, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") {
value := strings.TrimPrefix(trimmed, "integration_branch:")
value = strings.TrimPrefix(value, "Integration_branch:")
value = strings.TrimPrefix(value, "INTEGRATION_BRANCH:")
// Handle case variations
for _, prefix := range []string{"integration_branch:", "Integration_branch:", "INTEGRATION_BRANCH:"} {
if strings.HasPrefix(trimmed, prefix) {
value = strings.TrimPrefix(trimmed, prefix)
break
}
}
// Re-parse properly - the prefix removal above is messy
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
// getIntegrationBranchTemplate returns the integration branch template to use.
// Priority: CLI flag > rig config > default
func getIntegrationBranchTemplate(rigPath, cliOverride string) string {
if cliOverride != "" {
return cliOverride
}
// Try to load rig settings
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return defaultIntegrationBranchTemplate
}
if settings.MergeQueue != nil && settings.MergeQueue.IntegrationBranchTemplate != "" {
return settings.MergeQueue.IntegrationBranchTemplate
}
return defaultIntegrationBranchTemplate
}
// IntegrationStatusOutput is the JSON output structure for integration status.
type IntegrationStatusOutput struct {
Epic string `json:"epic"`
@@ -66,8 +202,14 @@ func runMqIntegrationCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type)
}
// Build integration branch name
branchName := "integration/" + epicID
// Build integration branch name from template
template := getIntegrationBranchTemplate(r.Path, mqIntegrationCreateBranch)
branchName := buildIntegrationBranchName(template, epicID)
// Validate the branch name
if err := validateBranchName(branchName); err != nil {
return fmt.Errorf("invalid branch name: %w", err)
}
// Initialize git for the rig
g := git.NewGit(r.Path)
@@ -185,9 +327,6 @@ func runMqIntegrationLand(cmd *cobra.Command, args []string) error {
bd := beads.New(r.Path)
g := git.NewGit(r.Path)
// Build integration branch name
branchName := "integration/" + epicID
// Show what we're about to do
if mqIntegrationLandDryRun {
fmt.Printf("%s Dry run - no changes will be made\n\n", style.Bold.Render("🔍"))
@@ -206,6 +345,13 @@ func runMqIntegrationLand(cmd *cobra.Command, args []string) error {
return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type)
}
// Get integration branch name from epic metadata (stored at create time)
// Fall back to default template for backward compatibility with old epics
branchName := getIntegrationBranchField(epic.Description)
if branchName == "" {
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, epicID)
}
fmt.Printf("Landing integration branch for epic: %s\n", epicID)
fmt.Printf(" Title: %s\n\n", epic.Title)
@@ -455,8 +601,21 @@ func runMqIntegrationStatus(cmd *cobra.Command, args []string) error {
// Initialize beads for the rig
bd := beads.New(r.Path)
// Build integration branch name
branchName := "integration/" + epicID
// Fetch epic to get stored branch name
epic, err := bd.Show(epicID)
if err != nil {
if err == beads.ErrNotFound {
return fmt.Errorf("epic '%s' not found", epicID)
}
return fmt.Errorf("fetching epic: %w", err)
}
// Get integration branch name from epic metadata (stored at create time)
// Fall back to default template for backward compatibility with old epics
branchName := getIntegrationBranchField(epic.Description)
if branchName == "" {
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, epicID)
}
// Initialize git for the rig
g := git.NewGit(r.Path)
@@ -492,8 +651,8 @@ func runMqIntegrationStatus(cmd *cobra.Command, args []string) error {
aheadCount = 0 // Non-fatal
}
// Query for MRs targeting this integration branch
targetBranch := "integration/" + epicID
// Query for MRs targeting this integration branch (use resolved name)
targetBranch := branchName
// Get all merge-request issues
allMRs, err := bd.List(beads.ListOptions{

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
@@ -32,7 +33,7 @@ func parseBranchName(branch string) branchInfo {
info := branchInfo{Branch: branch}
// Try polecat/<worker>/<issue> format
if strings.HasPrefix(branch, "polecat/") {
if strings.HasPrefix(branch, constants.BranchPolecatPrefix) {
parts := strings.SplitN(branch, "/", 3)
if len(parts) == 3 {
info.Worker = parts[1]

View File

@@ -434,3 +434,247 @@ func TestFilterMRsByTarget_NoMRFields(t *testing.T) {
t.Errorf("filterMRsByTarget() should filter out issues without MR fields, got %d", len(got))
}
}
// Tests for configurable integration branch naming (Issue #104)
func TestBuildIntegrationBranchName(t *testing.T) {
tests := []struct {
name string
template string
epicID string
want string
}{
{
name: "default template",
template: "",
epicID: "RA-123",
want: "integration/RA-123",
},
{
name: "explicit default template",
template: "integration/{epic}",
epicID: "PROJ-456",
want: "integration/PROJ-456",
},
{
name: "custom template with prefix",
template: "{prefix}/{epic}",
epicID: "RA-123",
want: "RA/RA-123",
},
{
name: "complex template",
template: "feature/{prefix}/work/{epic}",
epicID: "PROJ-789",
want: "feature/PROJ/work/PROJ-789",
},
{
name: "epic without hyphen",
template: "{prefix}/{epic}",
epicID: "epicname",
want: "epicname/epicname",
},
{
name: "user variable left as-is without git config",
template: "{user}/{epic}",
epicID: "RA-123",
// Note: {user} is replaced with git user.name if available,
// otherwise left as placeholder. In tests, it depends on git config.
want: "", // We'll check pattern instead
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildIntegrationBranchName(tt.template, tt.epicID)
if tt.want == "" {
// For user variable test, just check {epic} was replaced
if stringContains(got, "{epic}") {
t.Errorf("buildIntegrationBranchName() = %q, should have replaced {epic}", got)
}
} else if got != tt.want {
t.Errorf("buildIntegrationBranchName() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractEpicPrefix(t *testing.T) {
tests := []struct {
epicID string
want string
}{
{"RA-123", "RA"},
{"PROJ-456", "PROJ"},
{"gt-auth-epic", "gt"},
{"epicname", "epicname"},
{"X-1", "X"},
{"-123", "-123"}, // No prefix before hyphen, return full string
{"", ""},
}
for _, tt := range tests {
t.Run(tt.epicID, func(t *testing.T) {
got := extractEpicPrefix(tt.epicID)
if got != tt.want {
t.Errorf("extractEpicPrefix(%q) = %q, want %q", tt.epicID, got, tt.want)
}
})
}
}
func TestValidateBranchName(t *testing.T) {
tests := []struct {
name string
branchName string
wantErr bool
}{
{
name: "valid simple branch",
branchName: "integration/gt-epic",
wantErr: false,
},
{
name: "valid nested branch",
branchName: "user/project/feature",
wantErr: false,
},
{
name: "valid with hyphens and underscores",
branchName: "user-name/feature_branch",
wantErr: false,
},
{
name: "empty branch name",
branchName: "",
wantErr: true,
},
{
name: "contains tilde",
branchName: "branch~1",
wantErr: true,
},
{
name: "contains caret",
branchName: "branch^2",
wantErr: true,
},
{
name: "contains colon",
branchName: "branch:ref",
wantErr: true,
},
{
name: "contains space",
branchName: "branch name",
wantErr: true,
},
{
name: "contains backslash",
branchName: "branch\\name",
wantErr: true,
},
{
name: "contains double dot",
branchName: "branch..name",
wantErr: true,
},
{
name: "contains at-brace",
branchName: "branch@{name}",
wantErr: true,
},
{
name: "ends with .lock",
branchName: "branch.lock",
wantErr: true,
},
{
name: "starts with slash",
branchName: "/branch",
wantErr: true,
},
{
name: "ends with slash",
branchName: "branch/",
wantErr: true,
},
{
name: "starts with dot",
branchName: ".branch",
wantErr: true,
},
{
name: "ends with dot",
branchName: "branch.",
wantErr: true,
},
{
name: "consecutive slashes",
branchName: "branch//name",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBranchName(tt.branchName)
if (err != nil) != tt.wantErr {
t.Errorf("validateBranchName(%q) error = %v, wantErr %v", tt.branchName, err, tt.wantErr)
}
})
}
}
func TestGetIntegrationBranchField(t *testing.T) {
tests := []struct {
name string
description string
want string
}{
{
name: "empty description",
description: "",
want: "",
},
{
name: "field at beginning",
description: "integration_branch: klauern/PROJ-123/RA-epic\nSome description",
want: "klauern/PROJ-123/RA-epic",
},
{
name: "field in middle",
description: "Some text\nintegration_branch: custom/branch\nMore text",
want: "custom/branch",
},
{
name: "field with extra whitespace",
description: " integration_branch: spaced/branch \nOther content",
want: "spaced/branch",
},
{
name: "no integration_branch field",
description: "Just a plain description\nWith multiple lines",
want: "",
},
{
name: "mixed case field name",
description: "Integration_branch: CamelCase/branch",
want: "CamelCase/branch",
},
{
name: "default format",
description: "integration_branch: integration/gt-epic\nEpic for auth work",
want: "integration/gt-epic",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getIntegrationBranchField(tt.description)
if got != tt.want {
t.Errorf("getIntegrationBranchField() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -89,7 +90,6 @@ Examples:
RunE: runPolecatRemove,
}
var polecatSyncCmd = &cobra.Command{
Use: "sync <rig>/<polecat>",
Short: "Sync beads for a polecat",
@@ -129,15 +129,15 @@ Examples:
}
var (
polecatSyncAll bool
polecatSyncFromMain bool
polecatStatusJSON bool
polecatGitStateJSON bool
polecatGCDryRun bool
polecatNukeAll bool
polecatNukeDryRun bool
polecatNukeForce bool
polecatCheckRecoveryJSON bool
polecatSyncAll bool
polecatSyncFromMain bool
polecatStatusJSON bool
polecatGitStateJSON bool
polecatGCDryRun bool
polecatNukeAll bool
polecatNukeDryRun bool
polecatNukeForce bool
polecatCheckRecoveryJSON bool
)
var polecatGCCmd = &cobra.Command{
@@ -449,71 +449,14 @@ func runPolecatAdd(cmd *cobra.Command, args []string) error {
}
func runPolecatRemove(cmd *cobra.Command, args []string) error {
// Build list of polecats to remove
type polecatToRemove struct {
rigName string
polecatName string
mgr *polecat.Manager
r *rig.Rig
targets, err := resolvePolecatTargets(args, polecatRemoveAll)
if err != nil {
return err
}
var toRemove []polecatToRemove
if polecatRemoveAll {
// --all flag: first arg is just the rig name
rigName := args[0]
// Check if it looks like rig/polecat format
if _, _, err := parseAddress(rigName); err == nil {
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat remove greenplace --all')")
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
polecats, err := mgr.List()
if err != nil {
return fmt.Errorf("listing polecats: %w", err)
}
if len(polecats) == 0 {
fmt.Println("No polecats to remove.")
return nil
}
for _, p := range polecats {
toRemove = append(toRemove, polecatToRemove{
rigName: rigName,
polecatName: p.Name,
mgr: mgr,
r: r,
})
}
} else {
// Multiple rig/polecat arguments - require explicit rig/polecat format
for _, arg := range args {
// Validate format: must contain "/" to avoid misinterpreting rig names as polecat names
if !strings.Contains(arg, "/") {
return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg)
}
rigName, polecatName, err := parseAddress(arg)
if err != nil {
return fmt.Errorf("invalid address '%s': %w", arg, err)
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
toRemove = append(toRemove, polecatToRemove{
rigName: rigName,
polecatName: polecatName,
mgr: mgr,
r: r,
})
}
if len(targets) == 0 {
fmt.Println("No polecats to remove.")
return nil
}
// Remove each polecat
@@ -521,7 +464,7 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
var removeErrors []string
removed := 0
for _, p := range toRemove {
for _, p := range targets {
// Check if session is running
if !polecatForce {
polecatMgr := polecat.NewSessionManager(t, p.r)
@@ -579,7 +522,7 @@ func runPolecatSync(cmd *cobra.Command, args []string) error {
polecatName = ""
}
mgr, r, err := getPolecatManager(rigName)
mgr, _, err := getPolecatManager(rigName)
if err != nil {
return err
}
@@ -606,10 +549,15 @@ func runPolecatSync(cmd *cobra.Command, args []string) error {
// Sync each polecat
var syncErrors []string
for _, name := range polecatsToSync {
polecatDir := filepath.Join(r.Path, "polecats", name)
// Get polecat to get correct clone path (handles old vs new structure)
p, err := mgr.Get(name)
if err != nil {
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
continue
}
// Check directory exists
if _, err := os.Stat(polecatDir); os.IsNotExist(err) {
if _, err := os.Stat(p.ClonePath); os.IsNotExist(err) {
syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name))
continue
}
@@ -623,7 +571,7 @@ func runPolecatSync(cmd *cobra.Command, args []string) error {
fmt.Printf("Syncing %s/%s...\n", rigName, name)
syncCmd := exec.Command("bd", syncArgs...)
syncCmd.Dir = polecatDir
syncCmd.Dir = p.ClonePath
output, err := syncCmd.CombinedOutput()
if err != nil {
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
@@ -975,7 +923,7 @@ type RecoveryStatus struct {
NeedsRecovery bool `json:"needs_recovery"`
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
Branch string `json:"branch,omitempty"`
Issue string `json:"issue,omitempty"`
Issue string `json:"issue,omitempty"`
}
func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
@@ -1163,187 +1111,28 @@ func splitLines(s string) []string {
}
func runPolecatNuke(cmd *cobra.Command, args []string) error {
// Build list of polecats to nuke
type polecatToNuke struct {
rigName string
polecatName string
mgr *polecat.Manager
r *rig.Rig
targets, err := resolvePolecatTargets(args, polecatNukeAll)
if err != nil {
return err
}
var toNuke []polecatToNuke
if polecatNukeAll {
// --all flag: first arg is just the rig name
rigName := args[0]
// Check if it looks like rig/polecat format
if _, _, err := parseAddress(rigName); err == nil {
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat nuke greenplace --all')")
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
polecats, err := mgr.List()
if err != nil {
return fmt.Errorf("listing polecats: %w", err)
}
if len(polecats) == 0 {
fmt.Println("No polecats to nuke.")
return nil
}
for _, p := range polecats {
toNuke = append(toNuke, polecatToNuke{
rigName: rigName,
polecatName: p.Name,
mgr: mgr,
r: r,
})
}
} else {
// Multiple rig/polecat arguments - require explicit rig/polecat format
for _, arg := range args {
// Validate format: must contain "/" to avoid misinterpreting rig names as polecat names
if !strings.Contains(arg, "/") {
return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg)
}
rigName, polecatName, err := parseAddress(arg)
if err != nil {
return fmt.Errorf("invalid address '%s': %w", arg, err)
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
toNuke = append(toNuke, polecatToNuke{
rigName: rigName,
polecatName: polecatName,
mgr: mgr,
r: r,
})
}
if len(targets) == 0 {
fmt.Println("No polecats to nuke.")
return nil
}
// Safety checks: refuse to nuke polecats with active work unless --force is set
// Checks:
// 1. Unpushed commits - worktree has uncommitted/unpushed changes
// 2. Open MR beads - polecat has open merge requests pending
// 3. Work on hook - polecat has work assigned to its hook
if !polecatNukeForce && !polecatNukeDryRun {
type blockReason struct {
polecat string
reasons []string
}
var blocked []blockReason
for _, p := range toNuke {
var reasons []string
// Get polecat info for branch name
polecatInfo, infoErr := p.mgr.Get(p.polecatName)
// Check 1: Unpushed commits via cleanup_status or git state
bd := beads.New(p.r.Path)
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
if err != nil || fields == nil {
// No agent bead - fall back to git check
if infoErr == nil && polecatInfo != nil {
gitState, gitErr := getGitState(polecatInfo.ClonePath)
if gitErr != nil {
reasons = append(reasons, "cannot check git state")
} else if !gitState.Clean {
if gitState.UnpushedCommits > 0 {
reasons = append(reasons, fmt.Sprintf("has %d unpushed commit(s)", gitState.UnpushedCommits))
} else if len(gitState.UncommittedFiles) > 0 {
reasons = append(reasons, fmt.Sprintf("has %d uncommitted file(s)", len(gitState.UncommittedFiles)))
} else if gitState.StashCount > 0 {
reasons = append(reasons, fmt.Sprintf("has %d stash(es)", gitState.StashCount))
}
}
}
} else {
// Check cleanup_status from agent bead
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
switch cleanupStatus {
case polecat.CleanupClean:
// OK
case polecat.CleanupUnpushed:
reasons = append(reasons, "has unpushed commits")
case polecat.CleanupUncommitted:
reasons = append(reasons, "has uncommitted changes")
case polecat.CleanupStash:
reasons = append(reasons, "has stashed changes")
case polecat.CleanupUnknown, "":
reasons = append(reasons, "cleanup status unknown")
default:
reasons = append(reasons, fmt.Sprintf("cleanup status: %s", cleanupStatus))
}
// Check 3: Work on hook (check both Issue.HookBead from slot and fields.HookBead)
// Only flag as blocking if the hooked bead is still in an active status.
// If the hooked bead was closed externally (gt-jc7bq), don't block nuke.
hookBead := agentIssue.HookBead
if hookBead == "" {
hookBead = fields.HookBead
}
if hookBead != "" {
// Check if hooked bead is still active (not closed)
hookedIssue, err := bd.Show(hookBead)
if err == nil && hookedIssue != nil {
// Only block if bead is still active (not closed)
if hookedIssue.Status != "closed" {
reasons = append(reasons, fmt.Sprintf("has work on hook (%s)", hookBead))
}
// If closed, the hook is stale - don't block nuke
} else {
// Can't verify hooked bead - be conservative
reasons = append(reasons, fmt.Sprintf("has work on hook (%s, unverified)", hookBead))
}
}
}
// Check 2: Open MR beads for this branch
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
if mrErr == nil && mr != nil {
reasons = append(reasons, fmt.Sprintf("has open MR (%s)", mr.ID))
}
}
if len(reasons) > 0 {
blocked = append(blocked, blockReason{
polecat: fmt.Sprintf("%s/%s", p.rigName, p.polecatName),
reasons: reasons,
})
var blocked []*SafetyCheckResult
for _, p := range targets {
result := checkPolecatSafety(p)
if result.Blocked {
blocked = append(blocked, result)
}
}
if len(blocked) > 0 {
fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:"))
var polecatList []string
for _, b := range blocked {
fmt.Printf(" %s:\n", style.Bold.Render(b.polecat))
for _, r := range b.reasons {
fmt.Printf(" - %s\n", r)
}
polecatList = append(polecatList, b.polecat)
}
fmt.Println()
fmt.Println("Safety checks failed. Resolve issues before nuking, or use --force.")
fmt.Println("Options:")
fmt.Printf(" 1. Complete work: gt done (from polecat session)\n")
fmt.Printf(" 2. Push changes: git push (from polecat worktree)\n")
fmt.Printf(" 3. Escalate: gt mail send mayor/ -s \"RECOVERY_NEEDED\" -m \"...\"\n")
fmt.Printf(" 4. Force nuke (LOSES WORK): gt polecat nuke --force %s\n", strings.Join(polecatList, " "))
fmt.Println()
displaySafetyCheckBlocked(blocked)
return fmt.Errorf("blocked: %d polecat(s) have active work", len(blocked))
}
}
@@ -1353,7 +1142,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
var nukeErrors []string
nuked := 0
for _, p := range toNuke {
for _, p := range targets {
if polecatNukeDryRun {
fmt.Printf("Would nuke %s/%s:\n", p.rigName, p.polecatName)
fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName)
@@ -1361,67 +1150,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
fmt.Printf(" - Delete branch (if exists)\n")
fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName))
// Show safety check status in dry-run
fmt.Printf("\n Safety checks:\n")
polecatInfo, infoErr := p.mgr.Get(p.polecatName)
bd := beads.New(p.r.Path)
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
// Check 1: Git state
if err != nil || fields == nil {
if infoErr == nil && polecatInfo != nil {
gitState, gitErr := getGitState(polecatInfo.ClonePath)
if gitErr != nil {
fmt.Printf(" - Git state: %s\n", style.Warning.Render("cannot check"))
} else if gitState.Clean {
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
} else {
fmt.Printf(" - Git state: %s\n", style.Error.Render("dirty"))
}
} else {
fmt.Printf(" - Git state: %s\n", style.Dim.Render("unknown (no polecat info)"))
}
fmt.Printf(" - Hook: %s\n", style.Dim.Render("unknown (no agent bead)"))
} else {
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
if cleanupStatus.IsSafe() {
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
} else if cleanupStatus.RequiresRecovery() {
fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), cleanupStatus)
} else {
fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown"))
}
hookBead := agentIssue.HookBead
if hookBead == "" {
hookBead = fields.HookBead
}
if hookBead != "" {
// Check if hooked bead is still active
hookedIssue, err := bd.Show(hookBead)
if err == nil && hookedIssue != nil && hookedIssue.Status == "closed" {
fmt.Printf(" - Hook: %s (%s, closed - stale)\n", style.Warning.Render("stale"), hookBead)
} else {
fmt.Printf(" - Hook: %s (%s)\n", style.Error.Render("has work"), hookBead)
}
} else {
fmt.Printf(" - Hook: %s\n", style.Success.Render("empty"))
}
}
// Check 2: Open MR
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
if mrErr == nil && mr != nil {
fmt.Printf(" - Open MR: %s (%s)\n", style.Error.Render("yes"), mr.ID)
} else {
fmt.Printf(" - Open MR: %s\n", style.Success.Render("none"))
}
} else {
fmt.Printf(" - Open MR: %s\n", style.Dim.Render("unknown (no branch info)"))
}
displayDryRunSafetyCheck(p)
fmt.Println()
continue
}
@@ -1477,7 +1206,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
// Step 5: Close agent bead (if exists)
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
@@ -1494,7 +1223,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
// Report results
if polecatNukeDryRun {
fmt.Printf("\n%s Would nuke %d polecat(s).\n", style.Info.Render(""), len(toNuke))
fmt.Printf("\n%s Would nuke %d polecat(s).\n", style.Info.Render(""), len(targets))
return nil
}

View File

@@ -0,0 +1,201 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
)
func TestDiscoverHooksSkipsPolecatDotDirs(t *testing.T) {
townRoot := setupTestTownForDotDir(t)
rigPath := filepath.Join(townRoot, "gastown")
settingsPath := filepath.Join(rigPath, "polecats", ".claude", ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil {
t.Fatalf("mkdir settings dir: %v", err)
}
settings := `{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"Stop","command":"echo hi"}]}]}}`
if err := os.WriteFile(settingsPath, []byte(settings), 0644); err != nil {
t.Fatalf("write settings: %v", err)
}
hooks, err := discoverHooks(townRoot)
if err != nil {
t.Fatalf("discoverHooks: %v", err)
}
if len(hooks) != 0 {
t.Fatalf("expected no hooks, got %d", len(hooks))
}
}
func TestStartPolecatsWithWorkSkipsDotDirs(t *testing.T) {
townRoot := setupTestTownForDotDir(t)
rigName := "gastown"
rigPath := filepath.Join(townRoot, rigName)
addRigEntry(t, townRoot, rigName)
if err := os.MkdirAll(filepath.Join(rigPath, "polecats", ".claude"), 0755); err != nil {
t.Fatalf("mkdir .claude polecat: %v", err)
}
if err := os.MkdirAll(filepath.Join(rigPath, "polecats", "toast"), 0755); err != nil {
t.Fatalf("mkdir polecat: %v", err)
}
binDir := t.TempDir()
bdScript := `#!/bin/sh
if [ "$1" = "--no-daemon" ]; then
shift
fi
cmd="$1"
case "$cmd" in
list)
if [ "$(basename "$PWD")" = ".claude" ]; then
echo '[{"id":"gt-1"}]'
else
echo '[]'
fi
exit 0
;;
*)
exit 0
;;
esac
`
writeScript(t, binDir, "bd", bdScript)
tmuxScript := `#!/bin/sh
if [ "$1" = "has-session" ]; then
echo "tmux error" 1>&2
exit 1
fi
exit 0
`
writeScript(t, binDir, "tmux", tmuxScript)
t.Setenv("PATH", fmt.Sprintf("%s:%s", binDir, os.Getenv("PATH")))
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir town root: %v", err)
}
started, errs := startPolecatsWithWork(townRoot, rigName)
if len(started) != 0 {
t.Fatalf("expected no polecats started, got %v", started)
}
if len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestRunSessionCheckSkipsDotDirs(t *testing.T) {
townRoot := setupTestTownForDotDir(t)
rigName := "gastown"
rigPath := filepath.Join(townRoot, rigName)
addRigEntry(t, townRoot, rigName)
if err := os.MkdirAll(filepath.Join(rigPath, "polecats", ".claude"), 0755); err != nil {
t.Fatalf("mkdir .claude polecat: %v", err)
}
binDir := t.TempDir()
tmuxScript := `#!/bin/sh
if [ "$1" = "has-session" ]; then
echo "can't find session" 1>&2
exit 1
fi
exit 0
`
writeScript(t, binDir, "tmux", tmuxScript)
t.Setenv("PATH", fmt.Sprintf("%s:%s", binDir, os.Getenv("PATH")))
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir town root: %v", err)
}
output := captureStdout(t, func() {
if err := runSessionCheck(&cobra.Command{}, []string{rigName}); err != nil {
t.Fatalf("runSessionCheck: %v", err)
}
})
if strings.Contains(output, ".claude") {
t.Fatalf("expected .claude to be ignored, output:\n%s", output)
}
}
func addRigEntry(t *testing.T, townRoot, rigName string) {
t.Helper()
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
if rigsConfig.Rigs == nil {
rigsConfig.Rigs = make(map[string]config.RigEntry)
}
rigsConfig.Rigs[rigName] = config.RigEntry{
GitURL: "file:///dev/null",
AddedAt: time.Now(),
}
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
}
func setupTestTownForDotDir(t *testing.T) 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)
}
rigsPath := filepath.Join(mayorDir, "rigs.json")
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: make(map[string]config.RigEntry),
}
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
beadsDir := filepath.Join(townRoot, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
return townRoot
}
func writeScript(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0755); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}

View File

@@ -0,0 +1,260 @@
package cmd
import (
"fmt"
"strings"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
)
// polecatTarget represents a polecat to operate on.
type polecatTarget struct {
rigName string
polecatName string
mgr *polecat.Manager
r *rig.Rig
}
// resolvePolecatTargets builds a list of polecats from command args.
// If useAll is true, the first arg is treated as a rig name and all polecats in it are returned.
// Otherwise, args are parsed as rig/polecat addresses.
func resolvePolecatTargets(args []string, useAll bool) ([]polecatTarget, error) {
var targets []polecatTarget
if useAll {
// --all flag: first arg is just the rig name
rigName := args[0]
// Check if it looks like rig/polecat format
if _, _, err := parseAddress(rigName); err == nil {
return nil, fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat <cmd> %s --all')", strings.Split(rigName, "/")[0])
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return nil, err
}
polecats, err := mgr.List()
if err != nil {
return nil, fmt.Errorf("listing polecats: %w", err)
}
for _, p := range polecats {
targets = append(targets, polecatTarget{
rigName: rigName,
polecatName: p.Name,
mgr: mgr,
r: r,
})
}
} else {
// Multiple rig/polecat arguments - require explicit rig/polecat format
for _, arg := range args {
// Validate format: must contain "/" to avoid misinterpreting rig names as polecat names
if !strings.Contains(arg, "/") {
return nil, fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg)
}
rigName, polecatName, err := parseAddress(arg)
if err != nil {
return nil, fmt.Errorf("invalid address '%s': %w", arg, err)
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return nil, err
}
targets = append(targets, polecatTarget{
rigName: rigName,
polecatName: polecatName,
mgr: mgr,
r: r,
})
}
}
return targets, nil
}
// SafetyCheckResult holds the result of safety checks for a polecat.
type SafetyCheckResult struct {
Polecat string
Blocked bool
Reasons []string
CleanupStatus polecat.CleanupStatus
HookBead string
HookStale bool // true if hooked bead is closed
OpenMR string
GitState *GitState
}
// checkPolecatSafety performs safety checks before destructive operations.
// Returns nil if the polecat is safe to operate on, or a SafetyCheckResult with reasons if blocked.
func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
result := &SafetyCheckResult{
Polecat: fmt.Sprintf("%s/%s", target.rigName, target.polecatName),
}
// Get polecat info for branch name
polecatInfo, infoErr := target.mgr.Get(target.polecatName)
// Check 1: Unpushed commits via cleanup_status or git state
bd := beads.New(target.r.Path)
agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
if err != nil || fields == nil {
// No agent bead - fall back to git check
if infoErr == nil && polecatInfo != nil {
gitState, gitErr := getGitState(polecatInfo.ClonePath)
result.GitState = gitState
if gitErr != nil {
result.Reasons = append(result.Reasons, "cannot check git state")
} else if !gitState.Clean {
if gitState.UnpushedCommits > 0 {
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d unpushed commit(s)", gitState.UnpushedCommits))
} else if len(gitState.UncommittedFiles) > 0 {
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d uncommitted file(s)", len(gitState.UncommittedFiles)))
} else if gitState.StashCount > 0 {
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d stash(es)", gitState.StashCount))
}
}
}
} else {
// Check cleanup_status from agent bead
result.CleanupStatus = polecat.CleanupStatus(fields.CleanupStatus)
switch result.CleanupStatus {
case polecat.CleanupClean:
// OK
case polecat.CleanupUnpushed:
result.Reasons = append(result.Reasons, "has unpushed commits")
case polecat.CleanupUncommitted:
result.Reasons = append(result.Reasons, "has uncommitted changes")
case polecat.CleanupStash:
result.Reasons = append(result.Reasons, "has stashed changes")
case polecat.CleanupUnknown, "":
result.Reasons = append(result.Reasons, "cleanup status unknown")
default:
result.Reasons = append(result.Reasons, fmt.Sprintf("cleanup status: %s", result.CleanupStatus))
}
// Check 3: Work on hook
hookBead := agentIssue.HookBead
if hookBead == "" {
hookBead = fields.HookBead
}
if hookBead != "" {
result.HookBead = hookBead
// Check if hooked bead is still active (not closed)
hookedIssue, err := bd.Show(hookBead)
if err == nil && hookedIssue != nil {
if hookedIssue.Status != "closed" {
result.Reasons = append(result.Reasons, fmt.Sprintf("has work on hook (%s)", hookBead))
} else {
result.HookStale = true
}
} else {
result.Reasons = append(result.Reasons, fmt.Sprintf("has work on hook (%s, unverified)", hookBead))
}
}
}
// Check 2: Open MR beads for this branch
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
if mrErr == nil && mr != nil {
result.OpenMR = mr.ID
result.Reasons = append(result.Reasons, fmt.Sprintf("has open MR (%s)", mr.ID))
}
}
result.Blocked = len(result.Reasons) > 0
return result
}
// displaySafetyCheckBlocked prints blocked polecats and guidance.
func displaySafetyCheckBlocked(blocked []*SafetyCheckResult) {
fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:"))
var polecatList []string
for _, b := range blocked {
fmt.Printf(" %s:\n", style.Bold.Render(b.Polecat))
for _, r := range b.Reasons {
fmt.Printf(" - %s\n", r)
}
polecatList = append(polecatList, b.Polecat)
}
fmt.Println()
fmt.Println("Safety checks failed. Resolve issues before nuking, or use --force.")
fmt.Println("Options:")
fmt.Printf(" 1. Complete work: gt done (from polecat session)\n")
fmt.Printf(" 2. Push changes: git push (from polecat worktree)\n")
fmt.Printf(" 3. Escalate: gt mail send mayor/ -s \"RECOVERY_NEEDED\" -m \"...\"\n")
fmt.Printf(" 4. Force nuke (LOSES WORK): gt polecat nuke --force %s\n", strings.Join(polecatList, " "))
fmt.Println()
}
// displayDryRunSafetyCheck shows safety check status for dry-run mode.
func displayDryRunSafetyCheck(target polecatTarget) {
fmt.Printf("\n Safety checks:\n")
polecatInfo, infoErr := target.mgr.Get(target.polecatName)
bd := beads.New(target.r.Path)
agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
// Check 1: Git state
if err != nil || fields == nil {
if infoErr == nil && polecatInfo != nil {
gitState, gitErr := getGitState(polecatInfo.ClonePath)
if gitErr != nil {
fmt.Printf(" - Git state: %s\n", style.Warning.Render("cannot check"))
} else if gitState.Clean {
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
} else {
fmt.Printf(" - Git state: %s\n", style.Error.Render("dirty"))
}
} else {
fmt.Printf(" - Git state: %s\n", style.Dim.Render("unknown (no polecat info)"))
}
fmt.Printf(" - Hook: %s\n", style.Dim.Render("unknown (no agent bead)"))
} else {
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
if cleanupStatus.IsSafe() {
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
} else if cleanupStatus.RequiresRecovery() {
fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), cleanupStatus)
} else {
fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown"))
}
hookBead := agentIssue.HookBead
if hookBead == "" {
hookBead = fields.HookBead
}
if hookBead != "" {
hookedIssue, err := bd.Show(hookBead)
if err == nil && hookedIssue != nil && hookedIssue.Status == "closed" {
fmt.Printf(" - Hook: %s (%s, closed - stale)\n", style.Warning.Render("stale"), hookBead)
} else {
fmt.Printf(" - Hook: %s (%s)\n", style.Error.Render("has work"), hookBead)
}
} else {
fmt.Printf(" - Hook: %s\n", style.Success.Render("empty"))
}
}
// Check 2: Open MR
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
if mrErr == nil && mr != nil {
fmt.Printf(" - Open MR: %s (%s)\n", style.Error.Render("yes"), mr.ID)
} else {
fmt.Printf(" - Open MR: %s\n", style.Success.Render("none"))
}
} else {
fmt.Printf(" - Open MR: %s\n", style.Dim.Render("unknown (no branch info)"))
}
}

View File

@@ -139,7 +139,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
}, nil
}
// Resolve account for Claude config
// Resolve account for runtime config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
if err != nil {
@@ -158,7 +158,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
if !running {
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := polecat.SessionStartOptions{
ClaudeConfigDir: claudeConfigDir,
RuntimeConfigDir: claudeConfigDir,
}
if opts.Agent != "" {
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, r.Path, "", opts.Agent)

View File

@@ -17,16 +17,22 @@ import (
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/checkpoint"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/lock"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
)
var primeHookMode bool
var primeDryRun bool
var primeState bool
var primeExplain bool
// Role represents a detected agent role.
type Role string
@@ -72,6 +78,12 @@ HOOK MODE (--hook):
func init() {
primeCmd.Flags().BoolVar(&primeHookMode, "hook", false,
"Hook mode: read session ID from stdin JSON (for LLM runtime hooks)")
primeCmd.Flags().BoolVar(&primeDryRun, "dry-run", false,
"Show what would be injected without side effects (no marker removal, no bd prime, no mail)")
primeCmd.Flags().BoolVar(&primeState, "state", false,
"Show detected session state only (normal/post-handoff/crash/autonomous)")
primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
"Show why each section was included")
rootCmd.AddCommand(primeCmd)
}
@@ -85,32 +97,52 @@ func runPrime(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
// "Discover, Don't Track" principle:
// - If we're in a workspace, proceed - the workspace's existence IS the enable signal
// - If we're NOT in a workspace, check the global enabled state
// This ensures a missing/stale state file doesn't break workspace users
if townRoot == "" {
// Not in a workspace - check global enabled state
// (This matters for hooks that might run from random directories)
if !state.IsEnabled() {
return nil // Silent exit - not in workspace and not enabled
}
return fmt.Errorf("not in a Gas Town workspace")
}
// Handle hook mode: read session ID from stdin and persist it
if primeHookMode {
sessionID, source := readHookSessionID()
persistSessionID(townRoot, sessionID)
if cwd != townRoot {
persistSessionID(cwd, sessionID)
if !primeDryRun {
persistSessionID(townRoot, sessionID)
if cwd != townRoot {
persistSessionID(cwd, sessionID)
}
}
// Set environment for this process (affects event emission below)
_ = os.Setenv("GT_SESSION_ID", sessionID)
_ = os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
// Output session beacon
explain(true, "Session beacon: hook mode enabled, session ID from stdin")
fmt.Printf("[session:%s]\n", sessionID)
if source != "" {
fmt.Printf("[source:%s]\n", source)
}
}
// Check for handoff marker (prevents handoff loop bug)
// In dry-run mode, use the non-mutating version
if primeDryRun {
checkHandoffMarkerDryRun(cwd)
} else {
checkHandoffMarker(cwd)
}
// Get role using env-aware detection
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
@@ -141,14 +173,22 @@ func runPrime(cmd *cobra.Command, args []string) error {
WorkDir: cwd,
}
// --state mode: output state only and exit
if primeState {
outputState(ctx)
return nil
}
// Check and acquire identity lock for worker roles
if err := acquireIdentityLock(ctx); err != nil {
return err
if !primeDryRun {
if err := acquireIdentityLock(ctx); err != nil {
return err
}
}
// Ensure beads redirect exists for worktree-based roles
// Skip if there's a role/location mismatch to avoid creating bad redirects
if !roleInfo.Mismatch {
if !roleInfo.Mismatch && !primeDryRun {
ensureBeadsRedirect(ctx)
}
@@ -157,12 +197,16 @@ func runPrime(cmd *cobra.Command, args []string) error {
// "Discover, don't track" principle: reality is truth, state is derived.
// Emit session_start event for seance discovery
emitSessionEvent(ctx)
if !primeDryRun {
emitSessionEvent(ctx)
}
// Output session metadata for seance discovery
explain(true, "Session metadata: always included for seance discovery")
outputSessionMetadata(ctx)
// Output context
explain(true, fmt.Sprintf("Role context: detected role is %s", ctx.Role))
if err := outputPrimeContext(ctx); err != nil {
return err
}
@@ -176,6 +220,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
// Check for slung work on hook (from gt sling)
// If found, we're in autonomous mode - skip normal startup directive
hasSlungWork := checkSlungWork(ctx)
explain(hasSlungWork, "Autonomous mode: hooked/in-progress work detected")
// Output molecule context if working on a molecule step
outputMoleculeContext(ctx)
@@ -184,10 +229,18 @@ func runPrime(cmd *cobra.Command, args []string) error {
outputCheckpointContext(ctx)
// Run bd prime to output beads workflow context
runBdPrime(cwd)
if !primeDryRun {
runBdPrime(cwd)
} else {
explain(true, "bd prime: skipped in dry-run mode")
}
// Run gt mail check --inject to inject any pending mail
runMailCheckInject(cwd)
if !primeDryRun {
runMailCheckInject(cwd)
} else {
explain(true, "gt mail check --inject: skipped in dry-run mode")
}
// For Mayor, check for pending escalations
if ctx.Role == RoleMayor {
@@ -197,6 +250,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
// Output startup directive for roles that should announce themselves
// Skip if in autonomous mode (slung work provides its own directive)
if !hasSlungWork {
explain(true, "Startup directive: normal mode (no hooked work)")
outputStartupDirective(ctx)
}
@@ -601,6 +655,11 @@ func outputStartupDirective(ctx RoleContext) {
fmt.Println(" - If attachment found → **RUN IT** (no human input needed)")
fmt.Println(" - If no attachment → await user instruction")
case RoleDeacon:
// Skip startup protocol if paused - the pause message was already shown
paused, _, _ := deacon.IsPaused(ctx.TownRoot)
if paused {
return
}
fmt.Println()
fmt.Println("---")
fmt.Println()
@@ -911,6 +970,13 @@ func showMoleculeProgress(b *beads.Beads, rootID string) {
// Deacon uses wisps (Wisp:true issues in main .beads/) for patrol cycles.
// Deacon is a town-level role, so it uses town root beads (not rig beads).
func outputDeaconPatrolContext(ctx RoleContext) {
// Check if Deacon is paused - if so, output PAUSED message and skip patrol context
paused, state, err := deacon.IsPaused(ctx.TownRoot)
if err == nil && paused {
outputDeaconPausedMessage(state)
return
}
cfg := PatrolConfig{
RoleName: "deacon",
PatrolMolName: "mol-deacon-patrol",
@@ -930,6 +996,32 @@ func outputDeaconPatrolContext(ctx RoleContext) {
outputPatrolContext(cfg)
}
// outputDeaconPausedMessage outputs a prominent PAUSED message for the Deacon.
// When paused, the Deacon must not perform any patrol actions.
func outputDeaconPausedMessage(state *deacon.PauseState) {
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## ⏸️ DEACON PAUSED"))
fmt.Println("You are paused and must NOT perform any patrol actions.")
fmt.Println()
if state.Reason != "" {
fmt.Printf("Reason: %s\n", state.Reason)
}
fmt.Printf("Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
if state.PausedBy != "" {
fmt.Printf("Paused by: %s\n", state.PausedBy)
}
fmt.Println()
fmt.Println("Wait for human to run `gt deacon resume` before working.")
fmt.Println()
fmt.Println("**DO NOT:**")
fmt.Println("- Create patrol molecules")
fmt.Println("- Run heartbeats")
fmt.Println("- Check agent health")
fmt.Println("- Take any autonomous actions")
fmt.Println()
fmt.Println("You may respond to direct human questions.")
}
// outputWitnessPatrolContext shows patrol molecule status for the Witness.
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
func outputWitnessPatrolContext(ctx RoleContext) {
@@ -1460,22 +1552,17 @@ func outputSessionMetadata(ctx RoleContext) {
// resolveSessionIDForPrime finds the session ID from available sources.
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
func resolveSessionIDForPrime(actor string) string {
// 1. GT_SESSION_ID (new canonical)
if id := os.Getenv("GT_SESSION_ID"); id != "" {
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
if id := runtime.SessionIDFromEnv(); id != "" {
return id
}
// 2. CLAUDE_SESSION_ID (legacy/Claude Code)
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
return id
}
// 3. Persisted session file (from gt prime --hook)
// 2. Persisted session file (from gt prime --hook)
if id := ReadPersistedSessionID(); id != "" {
return id
}
// 4. Fallback to generated identifier
// 3. Fallback to generated identifier
return fmt.Sprintf("%s-%d", actor, os.Getpid())
}
@@ -1593,3 +1680,154 @@ func readSessionFile(dir string) string {
}
return ""
}
// checkHandoffMarker checks for a handoff marker file and outputs a warning if found.
// This prevents the "handoff loop" bug where a new session sees /handoff in context
// and incorrectly runs it again. The marker tells the new session: "handoff is DONE,
// the /handoff you see in context was from YOUR PREDECESSOR, not a request for you."
func checkHandoffMarker(workDir string) {
markerPath := filepath.Join(workDir, constants.DirRuntime, constants.FileHandoffMarker)
data, err := os.ReadFile(markerPath)
if err != nil {
// No marker = not post-handoff, normal startup
return
}
// Marker found - this is a post-handoff session
prevSession := strings.TrimSpace(string(data))
// Remove the marker FIRST so we don't warn twice
_ = os.Remove(markerPath)
// Output prominent warning
outputHandoffWarning(prevSession)
}
// checkHandoffMarkerDryRun checks for handoff marker without removing it (for --dry-run).
func checkHandoffMarkerDryRun(workDir string) {
markerPath := filepath.Join(workDir, constants.DirRuntime, constants.FileHandoffMarker)
data, err := os.ReadFile(markerPath)
if err != nil {
// No marker = not post-handoff, normal startup
explain(true, "Post-handoff: no handoff marker found")
return
}
// Marker found - this is a post-handoff session
prevSession := strings.TrimSpace(string(data))
explain(true, fmt.Sprintf("Post-handoff: marker found (predecessor: %s), marker NOT removed in dry-run", prevSession))
// Output the warning but don't remove marker
outputHandoffWarning(prevSession)
}
// outputHandoffWarning outputs the post-handoff warning message.
func outputHandoffWarning(prevSession string) {
fmt.Println()
fmt.Println(style.Bold.Render("╔══════════════════════════════════════════════════════════════════╗"))
fmt.Println(style.Bold.Render("║ ✅ HANDOFF COMPLETE - You are the NEW session ║"))
fmt.Println(style.Bold.Render("╚══════════════════════════════════════════════════════════════════╝"))
fmt.Println()
if prevSession != "" {
fmt.Printf("Your predecessor (%s) handed off to you.\n", prevSession)
}
fmt.Println()
fmt.Println(style.Bold.Render("⚠️ DO NOT run /handoff - that was your predecessor's action."))
fmt.Println(" The /handoff you see in context is NOT a request for you.")
fmt.Println()
fmt.Println("Instead: Check your hook (`gt mol status`) and mail (`gt mail inbox`).")
fmt.Println()
}
// SessionState represents the detected session state for observability.
type SessionState struct {
State string `json:"state"` // normal, post-handoff, crash-recovery, autonomous
Role Role `json:"role"` // detected role
PrevSession string `json:"prev_session,omitempty"` // for post-handoff
CheckpointAge string `json:"checkpoint_age,omitempty"` // for crash-recovery
HookedBead string `json:"hooked_bead,omitempty"` // for autonomous
}
// detectSessionState returns the current session state without side effects.
func detectSessionState(ctx RoleContext) SessionState {
state := SessionState{
State: "normal",
Role: ctx.Role,
}
// Check for handoff marker (post-handoff state)
markerPath := filepath.Join(ctx.WorkDir, constants.DirRuntime, constants.FileHandoffMarker)
if data, err := os.ReadFile(markerPath); err == nil {
state.State = "post-handoff"
state.PrevSession = strings.TrimSpace(string(data))
return state
}
// Check for checkpoint (crash-recovery state) - only for polecat/crew
if ctx.Role == RolePolecat || ctx.Role == RoleCrew {
if cp, err := checkpoint.Read(ctx.WorkDir); err == nil && cp != nil && !cp.IsStale(24*time.Hour) {
state.State = "crash-recovery"
state.CheckpointAge = cp.Age().Round(time.Minute).String()
return state
}
}
// Check for hooked work (autonomous state)
agentID := getAgentIdentity(ctx)
if agentID != "" {
b := beads.New(ctx.WorkDir)
hookedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: agentID,
Priority: -1,
})
if err == nil && len(hookedBeads) > 0 {
state.State = "autonomous"
state.HookedBead = hookedBeads[0].ID
return state
}
// Also check in_progress beads
inProgressBeads, err := b.List(beads.ListOptions{
Status: "in_progress",
Assignee: agentID,
Priority: -1,
})
if err == nil && len(inProgressBeads) > 0 {
state.State = "autonomous"
state.HookedBead = inProgressBeads[0].ID
return state
}
}
return state
}
// outputState outputs only the session state (for --state flag).
func outputState(ctx RoleContext) {
state := detectSessionState(ctx)
fmt.Printf("state: %s\n", state.State)
fmt.Printf("role: %s\n", state.Role)
switch state.State {
case "post-handoff":
if state.PrevSession != "" {
fmt.Printf("prev_session: %s\n", state.PrevSession)
}
case "crash-recovery":
if state.CheckpointAge != "" {
fmt.Printf("checkpoint_age: %s\n", state.CheckpointAge)
}
case "autonomous":
if state.HookedBead != "" {
fmt.Printf("hooked_bead: %s\n", state.HookedBead)
}
}
}
// explain outputs an explanatory message if --explain mode is enabled.
func explain(condition bool, reason string) {
if primeExplain && condition {
fmt.Printf("\n[EXPLAIN] %s\n", reason)
}
}

View File

@@ -2,7 +2,9 @@ package cmd
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/beads"
@@ -95,3 +97,73 @@ func TestGetAgentBeadID_UsesRigPrefix(t *testing.T) {
})
}
}
func TestPrimeFlagCombinations(t *testing.T) {
// Find the gt binary - we need to test CLI flag validation
gtBin, err := exec.LookPath("gt")
if err != nil {
t.Skip("gt binary not found in PATH")
}
cases := []struct {
name string
args []string
wantError bool
errorMsg string
}{
{
name: "state_alone_is_valid",
args: []string{"prime", "--state"},
wantError: false, // May fail for other reasons (not in workspace), but not flag validation
},
{
name: "state_with_hook_errors",
args: []string{"prime", "--state", "--hook"},
wantError: true,
errorMsg: "--state cannot be combined with other flags",
},
{
name: "state_with_dry_run_errors",
args: []string{"prime", "--state", "--dry-run"},
wantError: true,
errorMsg: "--state cannot be combined with other flags",
},
{
name: "state_with_explain_errors",
args: []string{"prime", "--state", "--explain"},
wantError: true,
errorMsg: "--state cannot be combined with other flags",
},
{
name: "dry_run_and_explain_valid",
args: []string{"prime", "--dry-run", "--explain"},
wantError: false, // May fail for other reasons, but not flag validation
},
{
name: "hook_and_dry_run_valid",
args: []string{"prime", "--hook", "--dry-run"},
wantError: false, // May fail for other reasons, but not flag validation
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(gtBin, tc.args...)
output, err := cmd.CombinedOutput()
if tc.wantError {
if err == nil {
t.Fatalf("expected error, got success with output: %s", output)
}
if tc.errorMsg != "" && !strings.Contains(string(output), tc.errorMsg) {
t.Fatalf("expected error containing %q, got: %s", tc.errorMsg, output)
}
}
// For non-error cases, we don't fail on other errors (like "not in workspace")
// because we're only testing flag validation
if !tc.wantError && tc.errorMsg != "" && strings.Contains(string(output), tc.errorMsg) {
t.Fatalf("unexpected error message %q in output: %s", tc.errorMsg, output)
}
})
}
}

View File

@@ -759,7 +759,7 @@ func runRigBoot(cmd *cobra.Command, args []string) error {
} else {
fmt.Printf(" Starting witness...\n")
witMgr := witness.NewManager(r)
if err := witMgr.Start(false); err != nil {
if err := witMgr.Start(false, "", nil); err != nil {
if err == witness.ErrAlreadyRunning {
skipped = append(skipped, "witness (already running)")
} else {
@@ -839,7 +839,7 @@ func runRigStart(cmd *cobra.Command, args []string) error {
} else {
fmt.Printf(" Starting witness...\n")
witMgr := witness.NewManager(r)
if err := witMgr.Start(false); err != nil {
if err := witMgr.Start(false, "", nil); err != nil {
if err == witness.ErrAlreadyRunning {
skipped = append(skipped, "witness")
} else {
@@ -1418,7 +1418,7 @@ func runRigRestart(cmd *cobra.Command, args []string) error {
skipped = append(skipped, "witness")
} else {
fmt.Printf(" Starting witness...\n")
if err := witMgr.Start(false); err != nil {
if err := witMgr.Start(false, "", nil); err != nil {
if err == witness.ErrAlreadyRunning {
skipped = append(skipped, "witness")
} else {

147
internal/cmd/rig_detect.go Normal file
View File

@@ -0,0 +1,147 @@
// ABOUTME: Hidden command for shell hook to detect rigs and update cache.
// ABOUTME: Called by shell integration to set GT_TOWN_ROOT and GT_RIG env vars.
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/workspace"
)
var rigDetectCache string
var rigDetectCmd = &cobra.Command{
Use: "detect [path]",
Short: "Detect rig from repository path (internal use)",
Hidden: true,
Long: `Detect rig from a repository path and optionally cache the result.
This is an internal command used by shell integration. It checks if the given
path is inside a Gas Town rig and outputs shell variable assignments.
When --cache is specified, the result is written to ~/.cache/gastown/rigs.cache
for fast lookups by the shell hook.
Output format (to stdout):
export GT_TOWN_ROOT=/path/to/town
export GT_RIG=rigname
Or if not in a rig:
unset GT_TOWN_ROOT GT_RIG`,
Args: cobra.MaximumNArgs(1),
RunE: runRigDetect,
}
func init() {
rigCmd.AddCommand(rigDetectCmd)
rigDetectCmd.Flags().StringVar(&rigDetectCache, "cache", "", "Repository path to cache detection result for")
}
func runRigDetect(cmd *cobra.Command, args []string) error {
checkPath := "."
if len(args) > 0 {
checkPath = args[0]
}
absPath, err := filepath.Abs(checkPath)
if err != nil {
return outputNotInRig()
}
townRoot, err := workspace.Find(absPath)
if err != nil || townRoot == "" {
return outputNotInRig()
}
rigName := detectRigFromPath(townRoot, absPath)
if rigName != "" {
fmt.Printf("export GT_TOWN_ROOT=%q\n", townRoot)
fmt.Printf("export GT_RIG=%q\n", rigName)
} else {
fmt.Printf("export GT_TOWN_ROOT=%q\n", townRoot)
fmt.Println("unset GT_RIG")
}
if rigDetectCache != "" {
if err := updateRigCache(rigDetectCache, townRoot, rigName); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update cache: %v\n", err)
}
}
return nil
}
func detectRigFromPath(townRoot, absPath string) string {
rel, err := filepath.Rel(townRoot, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return ""
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 0 || parts[0] == "." {
return ""
}
candidateRig := parts[0]
switch candidateRig {
case "mayor", "deacon", ".beads", ".claude", ".git", "plugins":
return ""
}
rigConfigPath := filepath.Join(townRoot, candidateRig, "config.json")
if _, err := os.Stat(rigConfigPath); err == nil {
return candidateRig
}
return ""
}
func outputNotInRig() error {
fmt.Println("unset GT_TOWN_ROOT GT_RIG")
return nil
}
func updateRigCache(repoRoot, townRoot, rigName string) error {
cacheDir := state.CacheDir()
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
cachePath := filepath.Join(cacheDir, "rigs.cache")
existing := make(map[string]string)
if data, err := os.ReadFile(cachePath); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if idx := strings.Index(line, ":"); idx > 0 {
existing[line[:idx]] = line[idx+1:]
}
}
}
var value string
if rigName != "" {
value = fmt.Sprintf("export GT_TOWN_ROOT=%q; export GT_RIG=%q", townRoot, rigName)
} else if townRoot != "" {
value = fmt.Sprintf("export GT_TOWN_ROOT=%q; unset GT_RIG", townRoot)
} else {
value = "unset GT_TOWN_ROOT GT_RIG"
}
existing[repoRoot] = value
var lines []string
for k, v := range existing {
lines = append(lines, k+":"+v)
}
return os.WriteFile(cachePath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
}

View File

@@ -0,0 +1,186 @@
// ABOUTME: Quick-add command for adding a repo to Gas Town with minimal friction.
// ABOUTME: Used by shell hook for automatic "add to Gas Town?" prompts.
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
quickAddUser string
quickAddYes bool
quickAddQuiet bool
)
var rigQuickAddCmd = &cobra.Command{
Use: "quick-add [path]",
Short: "Quickly add current repo to Gas Town",
Hidden: true,
Long: `Quickly add a git repository to Gas Town with minimal interaction.
This command is designed for the shell hook's "Add to Gas Town?" prompt.
It infers the rig name from the directory and git URL from the remote.
Examples:
gt rig quick-add # Add current directory
gt rig quick-add ~/Repos/myproject # Add specific path
gt rig quick-add --yes # Non-interactive`,
Args: cobra.MaximumNArgs(1),
RunE: runRigQuickAdd,
}
func init() {
rigCmd.AddCommand(rigQuickAddCmd)
rigQuickAddCmd.Flags().StringVar(&quickAddUser, "user", "", "Crew workspace name (default: $USER)")
rigQuickAddCmd.Flags().BoolVar(&quickAddYes, "yes", false, "Non-interactive, assume yes")
rigQuickAddCmd.Flags().BoolVar(&quickAddQuiet, "quiet", false, "Minimal output")
}
func runRigQuickAdd(cmd *cobra.Command, args []string) error {
targetPath := "."
if len(args) > 0 {
targetPath = args[0]
}
absPath, err := filepath.Abs(targetPath)
if err != nil {
return fmt.Errorf("resolving path: %w", err)
}
if townRoot, err := workspace.Find(absPath); err == nil && townRoot != "" {
return fmt.Errorf("already part of a Gas Town workspace: %s", townRoot)
}
gitRoot, err := findGitRoot(absPath)
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
gitURL, err := findGitRemoteURL(gitRoot)
if err != nil {
return fmt.Errorf("no git remote found: %w", err)
}
rigName := sanitizeRigName(filepath.Base(gitRoot))
townRoot, err := findOrCreateTown()
if err != nil {
return fmt.Errorf("finding Gas Town: %w", err)
}
rigPath := filepath.Join(townRoot, rigName)
if _, err := os.Stat(rigPath); err == nil {
return fmt.Errorf("rig %q already exists in %s", rigName, townRoot)
}
originalName := filepath.Base(gitRoot)
if rigName != originalName && !quickAddQuiet {
fmt.Printf("Note: Using %q as rig name (sanitized from %q)\n", rigName, originalName)
}
if !quickAddQuiet {
fmt.Printf("Adding %s to Gas Town...\n", style.Bold.Render(rigName))
fmt.Printf(" Repository: %s\n", gitURL)
fmt.Printf(" Town: %s\n", townRoot)
}
addArgs := []string{"rig", "add", rigName, gitURL}
addCmd := exec.Command("gt", addArgs...)
addCmd.Dir = townRoot
addCmd.Stdout = os.Stdout
addCmd.Stderr = os.Stderr
if err := addCmd.Run(); err != nil {
fmt.Printf("\n%s Failed to add rig. You can try manually:\n", style.Warning.Render("⚠"))
fmt.Printf(" cd %s && gt rig add %s %s\n", townRoot, rigName, gitURL)
return fmt.Errorf("gt rig add failed: %w", err)
}
user := quickAddUser
if user == "" {
user = os.Getenv("USER")
}
if user == "" {
user = "default"
}
if !quickAddQuiet {
fmt.Printf("\nCreating crew workspace for %s...\n", user)
}
crewArgs := []string{"crew", "add", user, "--rig", rigName}
crewCmd := exec.Command("gt", crewArgs...)
crewCmd.Dir = filepath.Join(townRoot, rigName)
crewCmd.Stdout = os.Stdout
crewCmd.Stderr = os.Stderr
if err := crewCmd.Run(); err != nil {
fmt.Printf(" %s Could not create crew workspace: %v\n", style.Dim.Render("⚠"), err)
fmt.Printf(" Run manually: cd %s && gt crew add %s --rig %s\n", filepath.Join(townRoot, rigName), user, rigName)
}
crewPath := filepath.Join(townRoot, rigName, "crew", user)
if !quickAddQuiet {
fmt.Printf("\n%s Added to Gas Town!\n", style.Success.Render("✓"))
fmt.Printf("\nYour workspace: %s\n", style.Bold.Render(crewPath))
}
fmt.Printf("GT_CREW_PATH=%s\n", crewPath)
return nil
}
func findGitRoot(path string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = path
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func findGitRemoteURL(gitRoot string) (string, error) {
cmd := exec.Command("git", "remote", "get-url", "origin")
cmd.Dir = gitRoot
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func sanitizeRigName(name string) string {
name = strings.ReplaceAll(name, "-", "_")
name = strings.ReplaceAll(name, ".", "_")
name = strings.ReplaceAll(name, " ", "_")
return name
}
func findOrCreateTown() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
candidates := []string{
filepath.Join(home, "gt"),
filepath.Join(home, "gastown"),
}
for _, path := range candidates {
mayorDir := filepath.Join(path, "mayor")
if _, err := os.Stat(mayorDir); err == nil {
return path, nil
}
}
return "", fmt.Errorf("no Gas Town found - run 'gt install ~/gt' first")
}

View File

@@ -4,9 +4,11 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -21,16 +23,17 @@ const (
// This is the canonical struct for role detection - used by both GetRole()
// and detectRole() functions.
type RoleInfo struct {
Role Role `json:"role"`
Source string `json:"source"` // "env", "cwd", or "explicit"
Home string `json:"home"`
Rig string `json:"rig,omitempty"`
Polecat string `json:"polecat,omitempty"`
EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set
CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd
Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection
TownRoot string `json:"town_root,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory
Role Role `json:"role"`
Source string `json:"source"` // "env", "cwd", or "explicit"
Home string `json:"home"`
Rig string `json:"rig,omitempty"`
Polecat string `json:"polecat,omitempty"`
EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set
CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd
Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection
EnvIncomplete bool `json:"env_incomplete,omitempty"` // True if env was set but missing rig/polecat, filled from cwd
TownRoot string `json:"town_root,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory
}
var roleCmd = &cobra.Command{
@@ -86,14 +89,18 @@ var roleListCmd = &cobra.Command{
var roleEnvCmd = &cobra.Command{
Use: "env",
Short: "Print export statements for current role",
Long: `Print shell export statements to set GT_ROLE and GT_ROLE_HOME.
Long: `Print shell export statements for the current role.
Usage:
eval $(gt role env) # Set role env vars in current shell`,
Role is determined from GT_ROLE environment variable or current working directory.
This is a read-only command that displays the current role's env vars.
Examples:
eval $(gt role env) # Export current role's env vars
gt role env # View what would be exported`,
RunE: runRoleEnv,
}
// Flags
// Flags for role home command
var (
roleRig string
rolePolecat string
@@ -107,7 +114,7 @@ func init() {
roleCmd.AddCommand(roleListCmd)
roleCmd.AddCommand(roleEnvCmd)
// Add --rig flag to home command for witness/refinery/polecat
// Add --rig and --polecat flags to home command for overrides
roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)")
roleHomeCmd.Flags().StringVar(&rolePolecat, "polecat", "", "Polecat/crew member name")
}
@@ -170,6 +177,20 @@ func GetRoleWithContext(cwd, townRoot string) (RoleInfo, error) {
}
}
// If env is incomplete (missing rig/polecat for roles that need them),
// fill gaps from cwd detection and mark as incomplete
needsRig := parsedRole == RoleWitness || parsedRole == RoleRefinery || parsedRole == RolePolecat || parsedRole == RoleCrew
needsPolecat := parsedRole == RolePolecat || parsedRole == RoleCrew
if needsRig && info.Rig == "" && cwdCtx.Rig != "" {
info.Rig = cwdCtx.Rig
info.EnvIncomplete = true
}
if needsPolecat && info.Polecat == "" && cwdCtx.Polecat != "" {
info.Polecat = cwdCtx.Polecat
info.EnvIncomplete = true
}
// Check for mismatch with cwd detection
if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole {
info.Mismatch = true
@@ -277,7 +298,7 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" {
return ""
}
return filepath.Join(townRoot, rig, "witness", "rig")
return filepath.Join(townRoot, rig, "witness")
case RoleRefinery:
if rig == "" {
return ""
@@ -287,12 +308,12 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" || polecat == "" {
return ""
}
return filepath.Join(townRoot, rig, "polecats", polecat)
return filepath.Join(townRoot, rig, "polecats", polecat, "rig")
case RoleCrew:
if rig == "" || polecat == "" {
return ""
}
return filepath.Join(townRoot, rig, "crew", polecat)
return filepath.Join(townRoot, rig, "crew", polecat, "rig")
default:
return ""
}
@@ -335,6 +356,11 @@ func runRoleShow(cmd *cobra.Command, args []string) error {
}
func runRoleHome(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
@@ -343,29 +369,29 @@ func runRoleHome(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace")
}
var role Role
var rig, polecat string
// Validate flag combinations: --polecat requires --rig to prevent strange merges
if rolePolecat != "" && roleRig == "" {
return fmt.Errorf("--polecat requires --rig to be specified")
}
// Start with current role detection (from env vars or cwd)
info, err := GetRole()
if err != nil {
return err
}
role := info.Role
rig := info.Rig
polecat := info.Polecat
// Apply overrides from arguments/flags
if len(args) > 0 {
// Explicit role provided
role, rig, polecat = parseRoleString(args[0])
// Override with flags if provided
if roleRig != "" {
rig = roleRig
}
if rolePolecat != "" {
polecat = rolePolecat
}
} else {
// Use current role
info, err := GetRole()
if err != nil {
return err
}
role = info.Role
rig = info.Rig
polecat = info.Polecat
role, _, _ = parseRoleString(args[0])
}
if roleRig != "" {
rig = roleRig
}
if rolePolecat != "" {
polecat = rolePolecat
}
home := getRoleHome(role, rig, polecat, townRoot)
@@ -373,6 +399,11 @@ func runRoleHome(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat)
}
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
fmt.Println(home)
return nil
}
@@ -440,33 +471,52 @@ func runRoleList(cmd *cobra.Command, args []string) error {
}
func runRoleEnv(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Get current role (read-only - from env vars or cwd)
info, err := GetRole()
if err != nil {
return err
}
// Build the role string for GT_ROLE
var roleStr string
switch info.Role {
case RoleMayor:
roleStr = "mayor"
case RoleDeacon:
roleStr = "deacon"
case RoleWitness:
roleStr = fmt.Sprintf("%s/witness", info.Rig)
case RoleRefinery:
roleStr = fmt.Sprintf("%s/refinery", info.Rig)
case RolePolecat:
roleStr = fmt.Sprintf("%s/polecats/%s", info.Rig, info.Polecat)
case RoleCrew:
roleStr = fmt.Sprintf("%s/crew/%s", info.Rig, info.Polecat)
default:
roleStr = string(info.Role)
home := getRoleHome(info.Role, info.Rig, info.Polecat, townRoot)
if home == "" {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", info.Role, info.Rig, info.Polecat)
}
fmt.Printf("export %s=%s\n", EnvGTRole, roleStr)
if info.Home != "" {
fmt.Printf("export %s=%s\n", EnvGTRoleHome, info.Home)
// Warn if env was incomplete and we filled from cwd
if info.EnvIncomplete {
fmt.Fprintf(os.Stderr, "⚠️ Warning: env vars incomplete, filled from cwd\n")
}
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
// Get canonical env vars from shared source of truth
envVars := config.AgentEnvSimple(string(info.Role), info.Rig, info.Polecat)
envVars[EnvGTRoleHome] = home
// Output in sorted order for consistent output
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("export %s=%s\n", k, envVars[k])
}
return nil

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,14 @@ package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/version"
"github.com/steveyegge/gastown/internal/workspace"
)
var rootCmd = &cobra.Command{
@@ -16,7 +21,7 @@ var rootCmd = &cobra.Command{
It coordinates agent spawning, work distribution, and communication
across distributed teams of AI agents working on shared codebases.`,
PersistentPreRunE: checkBeadsDependency,
PersistentPreRunE: persistentPreRun,
}
// Commands that don't require beads to be installed/checked.
@@ -27,8 +32,74 @@ var beadsExemptCommands = map[string]bool{
"completion": true,
}
// Commands exempt from the town root branch warning.
// These are commands that help fix the problem or are diagnostic.
var branchCheckExemptCommands = map[string]bool{
"version": true,
"help": true,
"completion": true,
"doctor": true, // Used to fix the problem
"install": true, // Initial setup
"git-init": true, // Git setup
}
// persistentPreRun runs before every command.
func persistentPreRun(cmd *cobra.Command, args []string) error {
// Get the root command name being run
cmdName := cmd.Name()
// Check town root branch (warning only, non-blocking)
if !branchCheckExemptCommands[cmdName] {
warnIfTownRootOffMain()
}
// Skip beads check for exempt commands
if beadsExemptCommands[cmdName] {
return nil
}
// Check beads version
return CheckBeadsVersion()
}
// warnIfTownRootOffMain prints a warning if the town root is not on main branch.
// This is a non-blocking warning to help catch accidental branch switches.
func warnIfTownRootOffMain() {
// Find town root (silently - don't error if not in workspace)
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
return
}
// Check if it's a git repo
gitDir := townRoot + "/.git"
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return
}
// Get current branch
gitCmd := exec.Command("git", "branch", "--show-current")
gitCmd.Dir = townRoot
out, err := gitCmd.Output()
if err != nil {
return
}
branch := strings.TrimSpace(string(out))
if branch == "" || branch == "main" || branch == "master" {
return
}
// Town root is on wrong branch - warn the user
fmt.Fprintf(os.Stderr, "\n%s Town root is on branch '%s' (should be 'main')\n",
style.Bold.Render("⚠️ WARNING:"), branch)
fmt.Fprintf(os.Stderr, " This can cause gt commands to fail. Run: %s\n\n",
style.Dim.Render("gt doctor --fix"))
}
// checkBeadsDependency verifies beads meets minimum version requirements.
// Skips check for exempt commands (version, help, completion).
// Deprecated: Use persistentPreRun instead, which calls CheckBeadsVersion.
func checkBeadsDependency(cmd *cobra.Command, args []string) error {
// Get the root command name being run
cmdName := cmd.Name()
@@ -38,10 +109,52 @@ func checkBeadsDependency(cmd *cobra.Command, args []string) error {
return nil
}
// Check for stale binary (warning only, doesn't block)
checkStaleBinaryWarning()
// Check beads version
return CheckBeadsVersion()
}
// staleBinaryWarned tracks if we've already warned about stale binary in this session.
// We use an environment variable since the binary restarts on each command.
var staleBinaryWarned = os.Getenv("GT_STALE_WARNED") == "1"
// checkStaleBinaryWarning checks if the installed binary is stale and prints a warning.
// This is a non-blocking check - errors are silently ignored.
func checkStaleBinaryWarning() {
// Only warn once per shell session
if staleBinaryWarned {
return
}
repoRoot, err := version.GetRepoRoot()
if err != nil {
// Can't find repo - silently skip (might be running from non-dev environment)
return
}
info := version.CheckStaleBinary(repoRoot)
if info.Error != nil {
// Check failed - silently skip
return
}
if info.IsStale {
staleBinaryWarned = true
os.Setenv("GT_STALE_WARNED", "1")
msg := fmt.Sprintf("gt binary is stale (built from %s, repo at %s)",
version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
if info.CommitsBehind > 0 {
msg = fmt.Sprintf("gt binary is %d commits behind (built from %s, repo at %s)",
info.CommitsBehind, version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
}
fmt.Fprintf(os.Stderr, "%s %s\n", style.WarningPrefix, msg)
fmt.Fprintf(os.Stderr, " %s Run 'gt install' to update\n", style.ArrowPrefix)
}
}
// Execute runs the root command and returns an exit code.
// The caller (main) should call os.Exit with this code.
func Execute() int {

View File

@@ -649,6 +649,9 @@ func runSessionCheck(cmd *cobra.Command, args []string) error {
if !entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") {
continue
}
polecatName := entry.Name()
sessionName := fmt.Sprintf("gt-%s-%s", r.Name, polecatName)
totalChecked++

99
internal/cmd/shell.go Normal file
View File

@@ -0,0 +1,99 @@
// ABOUTME: Shell integration management commands.
// ABOUTME: Install/remove shell hooks without full HQ setup.
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 shellCmd = &cobra.Command{
Use: "shell",
GroupID: GroupConfig,
Short: "Manage shell integration",
RunE: requireSubcommand,
}
var shellInstallCmd = &cobra.Command{
Use: "install",
Short: "Install or update shell integration",
Long: `Install or update the Gas Town shell integration.
This adds a hook to your shell RC file that:
- Sets GT_TOWN_ROOT and GT_RIG when you cd into a Gas Town rig
- Offers to add new git repos to Gas Town on first visit
Run this after upgrading gt to get the latest shell hook features.`,
RunE: runShellInstall,
}
var shellRemoveCmd = &cobra.Command{
Use: "remove",
Short: "Remove shell integration",
RunE: runShellRemove,
}
var shellStatusCmd = &cobra.Command{
Use: "status",
Short: "Show shell integration status",
RunE: runShellStatus,
}
func init() {
shellCmd.AddCommand(shellInstallCmd)
shellCmd.AddCommand(shellRemoveCmd)
shellCmd.AddCommand(shellStatusCmd)
rootCmd.AddCommand(shellCmd)
}
func runShellInstall(cmd *cobra.Command, args []string) error {
if err := shell.Install(); err != nil {
return err
}
if err := state.Enable(Version); err != nil {
fmt.Printf("%s Could not enable Gas Town: %v\n", style.Dim.Render("⚠"), err)
}
fmt.Printf("%s Shell integration installed (%s)\n", style.Success.Render("✓"), shell.RCFilePath(shell.DetectShell()))
fmt.Println()
fmt.Println("Run 'source ~/.zshrc' or open a new terminal to activate.")
return nil
}
func runShellRemove(cmd *cobra.Command, args []string) error {
if err := shell.Remove(); err != nil {
return err
}
fmt.Printf("%s Shell integration removed\n", style.Success.Render("✓"))
return nil
}
func runShellStatus(cmd *cobra.Command, args []string) error {
s, err := state.Load()
if err != nil {
fmt.Println("Gas Town: not configured")
fmt.Println("Shell integration: not installed")
return nil
}
if s.Enabled {
fmt.Println("Gas Town: enabled")
} else {
fmt.Println("Gas Town: disabled")
}
if s.ShellIntegration != "" {
fmt.Printf("Shell integration: %s (%s)\n", s.ShellIntegration, shell.RCFilePath(s.ShellIntegration))
} else {
fmt.Println("Shell integration: not installed")
}
return nil
}

View File

@@ -210,16 +210,23 @@ func runSling(cmd *cobra.Command, args []string) error {
// Try as bead first
if err := verifyBeadExists(firstArg); err == nil {
// It's a bead
// It's a verified bead
beadID = firstArg
} else {
// Not a bead - try as standalone formula
// Not a verified bead - try as standalone formula
if err := verifyFormulaExists(firstArg); err == nil {
// Standalone formula mode: gt sling <formula> [target]
return runSlingFormula(args)
}
// Neither bead nor formula
return fmt.Errorf("'%s' is not a valid bead or formula", firstArg)
// Not a formula either - check if it looks like a bead ID (routing issue workaround).
// Accept it and let the actual bd update fail later if the bead doesn't exist.
// This fixes: gt sling bd-ka761 beads/crew/dave failing with 'not a valid bead or formula'
if looksLikeBeadID(firstArg) {
beadID = firstArg
} else {
// Neither bead nor formula
return fmt.Errorf("'%s' is not a valid bead or formula", firstArg)
}
}
}
@@ -387,8 +394,14 @@ func runSling(cmd *cobra.Command, args []string) error {
if formulaName != "" {
fmt.Printf(" Instantiating formula %s...\n", formulaName)
// Route bd mutations (cook/wisp/bond) to the correct beads context for the target bead.
// Some bd mol commands don't support prefix routing, so we must run them from the
// rig directory that owns the bead's database.
formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
// Step 1: Cook the formula (ensures proto exists)
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
cookCmd.Dir = formulaWorkDir
cookCmd.Stderr = os.Stderr
if err := cookCmd.Run(); err != nil {
return fmt.Errorf("cooking formula %s: %w", formulaName, err)
@@ -398,6 +411,7 @@ func runSling(cmd *cobra.Command, args []string) error {
featureVar := fmt.Sprintf("feature=%s", info.Title)
wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--json"}
wispCmd := exec.Command("bd", wispArgs...)
wispCmd.Dir = formulaWorkDir
wispCmd.Stderr = os.Stderr
wispOut, err := wispCmd.Output()
if err != nil {
@@ -415,6 +429,7 @@ func runSling(cmd *cobra.Command, args []string) error {
// Use --no-daemon for mol bond (requires direct database access)
bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"}
bondCmd := exec.Command("bd", bondArgs...)
bondCmd.Dir = formulaWorkDir
bondCmd.Stderr = os.Stderr
bondOut, err := bondCmd.Output()
if err != nil {
@@ -503,7 +518,7 @@ func runSling(cmd *cobra.Command, args []string) error {
// This enables no-tmux mode where agents discover args via gt prime / bd show.
func storeArgsInBead(beadID, args string) error {
// Get the bead to preserve existing description content
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
out, err := showCmd.Output()
if err != nil {
return fmt.Errorf("fetching bead: %w", err)
@@ -712,8 +727,12 @@ func sessionToAgentID(sessionName string) string {
// verifyBeadExists checks that the bead exists using bd show.
// Uses bd's native prefix-based routing via routes.jsonl - do NOT set BEADS_DIR
// as that overrides routing and breaks resolution of rig-level beads.
//
// Uses --no-daemon with --allow-stale to avoid daemon socket timing issues
// while still finding beads when database is out of sync with JSONL.
// For existence checks, stale data is acceptable - we just need to know it exists.
func verifyBeadExists(beadID string) error {
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
// Run from town root so bd can find routes.jsonl for prefix-based routing.
// Do NOT set BEADS_DIR - that overrides routing and breaks rig bead resolution.
if townRoot, err := workspace.FindFromCwd(); err == nil {
@@ -734,8 +753,9 @@ type beadInfo struct {
// getBeadInfo returns status and assignee for a bead.
// Uses bd's native prefix-based routing via routes.jsonl.
// Uses --no-daemon with --allow-stale for consistency with verifyBeadExists.
func getBeadInfo(beadID string) (*beadInfo, error) {
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
// Run from town root so bd can find routes.jsonl for prefix-based routing.
if townRoot, err := workspace.FindFromCwd(); err == nil {
cmd.Dir = townRoot
@@ -806,15 +826,16 @@ func resolveSelfTarget() (agentID string, pane string, hookRoot string, err erro
// verifyFormulaExists checks that the formula exists using bd formula show.
// Formulas are TOML files (.formula.toml).
// Uses --no-daemon with --allow-stale for consistency with verifyBeadExists.
func verifyFormulaExists(formulaName string) error {
// Try bd formula show (handles all formula file formats)
cmd := exec.Command("bd", "--no-daemon", "formula", "show", formulaName)
cmd := exec.Command("bd", "--no-daemon", "formula", "show", formulaName, "--allow-stale")
if err := cmd.Run(); err == nil {
return nil
}
// Try with mol- prefix
cmd = exec.Command("bd", "--no-daemon", "formula", "show", "mol-"+formulaName)
cmd = exec.Command("bd", "--no-daemon", "formula", "show", "mol-"+formulaName, "--allow-stale")
if err := cmd.Run(); err == nil {
return nil
}

View File

@@ -1,6 +1,11 @@
package cmd
import "testing"
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseWispIDFromJSON(t *testing.T) {
tests := []struct {
@@ -183,3 +188,370 @@ func TestFormatTrackBeadIDConsumerCompatibility(t *testing.T) {
})
}
}
func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) {
townRoot := t.TempDir()
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatalf("mkdir rigDir: %v", err)
}
routes := strings.Join([]string{
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
`{"prefix":"hq-","path":"."}`,
"",
}, "\n")
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
t.Fatalf("write routes.jsonl: %v", err)
}
// Stub bd so we can observe the working directory for cook/wisp/bond.
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
set -e
echo "$(pwd)|$*" >> "${BD_LOG}"
if [ "$1" = "--no-daemon" ]; then
shift
fi
cmd="$1"
shift || true
case "$cmd" in
show)
echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]'
;;
formula)
# formula show <name>
exit 0
;;
cook)
exit 0
;;
mol)
sub="$1"
shift || true
case "$sub" in
wisp)
echo '{"new_epic_id":"gt-wisp-xyz"}'
;;
bond)
echo '{"root_id":"gt-wisp-xyz"}'
;;
esac
;;
esac
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("BD_LOG", logPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "")
t.Setenv("GT_CREW", "")
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
t.Fatalf("chdir: %v", err)
}
// Ensure we don't leak global flag state across tests.
prevOn := slingOnTarget
prevVars := slingVars
prevDryRun := slingDryRun
prevNoConvoy := slingNoConvoy
t.Cleanup(func() {
slingOnTarget = prevOn
slingVars = prevVars
slingDryRun = prevDryRun
slingNoConvoy = prevNoConvoy
})
slingDryRun = false
slingNoConvoy = true
slingVars = nil
slingOnTarget = "gt-abc123"
if err := runSling(nil, []string{"mol-review"}); err != nil {
t.Fatalf("runSling: %v", err)
}
logBytes, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read bd log: %v", err)
}
logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
wantDir := rigDir
if resolved, err := filepath.EvalSymlinks(wantDir); err == nil {
wantDir = resolved
}
gotCook := false
gotWisp := false
gotBond := false
for _, line := range logLines {
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
continue
}
dir := parts[0]
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
args := parts[1]
switch {
case strings.Contains(args, " cook "):
gotCook = true
if dir != wantDir {
t.Fatalf("bd cook ran in %q, want %q (args: %q)", dir, wantDir, args)
}
case strings.Contains(args, " mol wisp "):
gotWisp = true
if dir != wantDir {
t.Fatalf("bd mol wisp ran in %q, want %q (args: %q)", dir, wantDir, args)
}
case strings.Contains(args, " mol bond "):
gotBond = true
if dir != wantDir {
t.Fatalf("bd mol bond ran in %q, want %q (args: %q)", dir, wantDir, args)
}
}
}
if !gotCook || !gotWisp || !gotBond {
t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes))
}
}
// TestVerifyBeadExistsAllowStale reproduces the bug in gtl-ncq where beads
// visible via regular bd show fail with --no-daemon due to database sync issues.
// The fix uses --allow-stale to skip the sync check for existence verification.
func TestVerifyBeadExistsAllowStale(t *testing.T) {
townRoot := t.TempDir()
// Create minimal workspace structure
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create a stub bd that simulates the sync issue:
// - --no-daemon without --allow-stale fails (database out of sync)
// - --no-daemon with --allow-stale succeeds (skips sync check)
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
# Check for --allow-stale flag
allow_stale=false
for arg in "$@"; do
if [ "$arg" = "--allow-stale" ]; then
allow_stale=true
fi
done
if [ "$1" = "--no-daemon" ]; then
if [ "$allow_stale" = "true" ]; then
# --allow-stale skips sync check, succeeds
echo '[{"title":"Test bead","status":"open","assignee":""}]'
exit 0
else
# Without --allow-stale, fails with sync error
echo '{"error":"Database out of sync with JSONL."}'
exit 1
fi
fi
# Daemon mode works
echo '[{"title":"Test bead","status":"open","assignee":""}]'
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// EXPECTED: verifyBeadExists should use --no-daemon --allow-stale and succeed
beadID := "jv-v599"
err = verifyBeadExists(beadID)
if err != nil {
t.Errorf("verifyBeadExists(%q) failed: %v\nExpected --allow-stale to skip sync check", beadID, err)
}
}
// TestSlingWithAllowStale tests the full gt sling flow with --allow-stale fix.
// This is an integration test for the gtl-ncq bug.
func TestSlingWithAllowStale(t *testing.T) {
townRoot := t.TempDir()
// Create minimal workspace structure
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create stub bd that respects --allow-stale
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
# Check for --allow-stale flag
allow_stale=false
for arg in "$@"; do
if [ "$arg" = "--allow-stale" ]; then
allow_stale=true
fi
done
if [ "$1" = "--no-daemon" ]; then
shift
cmd="$1"
if [ "$cmd" = "show" ]; then
if [ "$allow_stale" = "true" ]; then
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
exit 0
fi
echo '{"error":"Database out of sync"}'
exit 1
fi
exit 0
fi
cmd="$1"
shift || true
case "$cmd" in
show)
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
;;
update)
exit 0
;;
esac
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "crew")
t.Setenv("GT_CREW", "jv")
t.Setenv("GT_POLECAT", "")
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Save and restore global flags
prevDryRun := slingDryRun
prevNoConvoy := slingNoConvoy
t.Cleanup(func() {
slingDryRun = prevDryRun
slingNoConvoy = prevNoConvoy
})
slingDryRun = true
slingNoConvoy = true
// EXPECTED: gt sling should use daemon mode and succeed
// ACTUAL: verifyBeadExists uses --no-daemon and fails with sync error
beadID := "jv-v599"
err = runSling(nil, []string{beadID})
if err != nil {
// Check if it's the specific error we're testing for
if strings.Contains(err.Error(), "is not a valid bead or formula") {
t.Errorf("gt sling failed to recognize bead %q: %v\nExpected to use daemon mode, but used --no-daemon which fails when DB out of sync", beadID, err)
} else {
// Some other error - might be expected in dry-run mode
t.Logf("gt sling returned error (may be expected in test): %v", err)
}
}
}
// TestLooksLikeBeadID tests the bead ID pattern recognition function.
// This ensures gt sling accepts bead IDs even when routing-based verification fails.
// Fixes: gt sling bd-ka761 failing with 'not a valid bead or formula'
//
// Note: looksLikeBeadID is a fallback check in sling. The actual sling flow is:
// 1. Try verifyBeadExists (routing-based lookup)
// 2. Try verifyFormulaExists (formula check)
// 3. Fall back to looksLikeBeadID pattern match
// So "mol-release" matches the pattern but won't be treated as bead in practice
// because it would be caught by formula verification first.
func TestLooksLikeBeadID(t *testing.T) {
tests := []struct {
input string
want bool
}{
// Valid bead IDs - should return true
{"gt-abc123", true},
{"bd-ka761", true},
{"hq-cv-abc", true},
{"ap-qtsup.16", true},
{"beads-xyz", true},
{"jv-v599", true},
{"gt-9e8s5", true},
{"hq-00gyg", true},
// Short prefixes that match pattern (but may be formulas in practice)
{"mol-release", true}, // 3-char prefix matches pattern (formula check runs first in sling)
{"mol-abc123", true}, // 3-char prefix matches pattern
// Non-bead strings - should return false
{"formula-name", false}, // "formula" is 7 chars (> 5)
{"mayor", false}, // no hyphen
{"gastown", false}, // no hyphen
{"deacon/dogs", false}, // contains slash
{"", false}, // empty
{"-abc", false}, // starts with hyphen
{"GT-abc", false}, // uppercase prefix
{"123-abc", false}, // numeric prefix
{"a-", false}, // nothing after hyphen
{"aaaaaa-b", false}, // prefix too long (6 chars)
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := looksLikeBeadID(tt.input)
if got != tt.want {
t.Errorf("looksLikeBeadID(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

View File

@@ -10,14 +10,15 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
@@ -204,7 +205,7 @@ func startCoreAgents(townRoot string, agentOverride string) error {
// Start Deacon (health monitor)
deaconMgr := deacon.NewManager(townRoot)
if err := deaconMgr.Start(); err != nil {
if err := deaconMgr.Start(agentOverride); err != nil {
if err == deacon.ErrAlreadyRunning {
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
} else {
@@ -234,7 +235,7 @@ func startRigAgents(t *tmux.Tmux, townRoot string) {
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
} else {
witMgr := witness.NewManager(r)
if err := witMgr.Start(false); err != nil {
if err := witMgr.Start(false, "", nil); err != nil {
if err == witness.ErrAlreadyRunning {
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
} else {
@@ -246,17 +247,15 @@ func startRigAgents(t *tmux.Tmux, townRoot string) {
}
// Start Refinery
refinerySession := fmt.Sprintf("gt-%s-refinery", r.Name)
refineryRunning, _ := t.HasSession(refinerySession)
if refineryRunning {
fmt.Printf(" %s %s refinery already running\n", style.Dim.Render("○"), r.Name)
} else {
created, err := ensureRefinerySession(r.Name, r)
if err != nil {
refineryMgr := refinery.NewManager(r)
if err := refineryMgr.Start(false); err != nil {
if errors.Is(err, refinery.ErrAlreadyRunning) {
fmt.Printf(" %s %s refinery already running\n", style.Dim.Render("○"), r.Name)
} else {
fmt.Printf(" %s %s refinery failed: %v\n", style.Dim.Render("○"), r.Name, err)
} else if created {
fmt.Printf(" %s %s refinery started\n", style.Bold.Render("✓"), r.Name)
}
} else {
fmt.Printf(" %s %s refinery started\n", style.Bold.Render("✓"), r.Name)
}
}
}
@@ -280,7 +279,14 @@ func startConfiguredCrew(t *tmux.Tmux, townRoot string) {
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
// Claude has exited, restart it
fmt.Printf(" %s %s/%s session exists, restarting Claude...\n", style.Dim.Render("○"), r.Name, crewName)
claudeCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, "gt prime")
// Build startup beacon for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "restart",
})
claudeCmd := config.BuildCrewStartupCommand(r.Name, crewName, r.Path, beacon)
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
fmt.Printf(" %s %s/%s restart failed: %v\n", style.Dim.Render("○"), r.Name, crewName, err)
} else {
@@ -320,86 +326,6 @@ func discoverAllRigs(townRoot string) ([]*rig.Rig, error) {
return rigMgr.DiscoverRigs()
}
// ensureRefinerySession creates a refinery tmux session if it doesn't exist.
// Returns true if a new session was created, false if it already existed.
func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
t := tmux.NewTmux()
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
// Check if session already exists
running, err := t.HasSession(sessionName)
if err != nil {
return false, fmt.Errorf("checking session: %w", err)
}
if running {
return false, nil
}
// Working directory is the refinery's rig clone
refineryRigDir := filepath.Join(r.Path, "refinery", "rig")
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist
refineryRigDir = r.Path
}
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
refineryParentDir := filepath.Join(r.Path, "refinery")
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
return false, fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionName, refineryRigDir); err != nil {
return false, fmt.Errorf("creating session: %w", err)
}
// Set environment
bdActor := fmt.Sprintf("%s/refinery", rigName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "refinery")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Set beads environment
beadsDir := filepath.Join(r.Path, "mayor", "rig", ".beads")
_ = t.SetEnvironment(sessionName, "BEADS_DIR", beadsDir)
_ = t.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1")
_ = t.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", rigName))
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery")
// Launch Claude directly (no respawn loop - daemon handles restart)
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("refinery", bdActor, r.Path, "")); err != nil {
return false, 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
address := fmt.Sprintf("%s/refinery", rigName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "deacon",
Topic: "patrol",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous patrol execution.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal
return true, nil
}
func runShutdown(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
@@ -548,6 +474,12 @@ func runGracefulShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) err
cleanupPolecats(townRoot)
}
// Phase 6: Stop the daemon
fmt.Printf("\nPhase 6: Stopping daemon...\n")
if townRoot != "" {
stopDaemonIfRunning(townRoot)
}
fmt.Println()
fmt.Printf("%s Graceful shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
return nil
@@ -567,6 +499,13 @@ func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) er
cleanupPolecats(townRoot)
}
// Stop the daemon
if townRoot != "" {
fmt.Println()
fmt.Println("Stopping daemon...")
stopDaemonIfRunning(townRoot)
}
fmt.Println()
fmt.Printf("%s Gas Town shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
@@ -716,6 +655,21 @@ func cleanupPolecats(townRoot string) {
}
}
// stopDaemonIfRunning stops the daemon if it is running.
// This prevents the daemon from restarting agents after shutdown.
func stopDaemonIfRunning(townRoot string) {
running, _, _ := daemon.IsRunning(townRoot)
if running {
if err := daemon.StopDaemon(townRoot); err != nil {
fmt.Printf(" %s Daemon: %s\n", style.Dim.Render("○"), err.Error())
} else {
fmt.Printf(" %s Daemon stopped\n", style.Bold.Render("✓"))
}
} else {
fmt.Printf(" %s Daemon not running\n", style.Dim.Render("○"))
}
}
// runStartCrew starts a crew workspace, creating it if it doesn't exist.
// This combines the functionality of 'gt crew add' and 'gt crew at --detached'.
func runStartCrew(cmd *cobra.Command, args []string) error {
@@ -777,6 +731,7 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
err = crewMgr.Start(name, crew.StartOptions{
Account: startCrewAccount,
ClaudeConfigDir: claudeConfigDir,
AgentOverride: startCrewAgentOverride,
})
if err != nil {
if errors.Is(err, crew.ErrSessionRunning) {

View File

@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/swarm"
"github.com/steveyegge/gastown/internal/tmux"
@@ -808,7 +809,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
// Close the swarm epic in beads
closeArgs := []string{"close", swarmID, "--reason", "Swarm landed to main"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
@@ -867,7 +868,7 @@ func runSwarmCancel(cmd *cobra.Command, args []string) error {
// Close the swarm epic in beads with canceled reason
closeArgs := []string{"close", swarmID, "--reason", "Swarm canceled"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/formula"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -322,7 +323,7 @@ func runSynthesisClose(cmd *cobra.Command, args []string) error {
// Close the convoy
closeArgs := []string{"close", convoyID, "--reason=synthesis complete"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)

239
internal/cmd/thanks.go Normal file
View File

@@ -0,0 +1,239 @@
package cmd
import (
"cmp"
"fmt"
"slices"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/ui"
)
// Style definitions for thanks output using ui package colors
var (
thanksTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ui.ColorWarn)
thanksSubtitleStyle = lipgloss.NewStyle().
Foreground(ui.ColorMuted)
thanksSectionStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
thanksNameStyle = lipgloss.NewStyle().
Foreground(ui.ColorPass)
thanksDimStyle = lipgloss.NewStyle().
Foreground(ui.ColorMuted)
)
// thanksBoxStyle returns a bordered box style for the thanks header
func thanksBoxStyle(width int) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(ui.ColorMuted).
Padding(1, 4).
Width(width - 4).
Align(lipgloss.Center)
}
// gastownContributors maps HUMAN contributor names to their commit counts.
// Agent names (gastown/*, beads/*, lowercase single-word names) are excluded.
// Generated from: git shortlog -sn --all (then filtered for humans only)
var gastownContributors = map[string]int{
"Steve Yegge": 2056,
"Mike Lady": 19,
"Olivier Debeuf De Rijcker": 13,
"Danno Mayer": 11,
"Dan Shapiro": 7,
"Subhrajit Makur": 7,
"Julian Knutsen": 5,
"Darko Luketic": 4,
"Martin Emde": 4,
"Greg Hughes": 3,
"Avyukth": 2,
"Ben Kraus": 2,
"Joshua Vial": 2,
"Austin Wallace": 1,
"Cameron Palmer": 1,
"Chris Sloane": 1,
"Cong": 1,
"Dave Laird": 1,
"Dave Williams": 1,
"Jacob": 1,
"Johann Taberlet": 1,
"Joshua Samuel": 1,
"Madison Bullard": 1,
"PepijnSenders": 1,
"Raymond Weitekamp": 1,
"Sohail Mohammad": 1,
"Zachary Rosen": 1,
}
var thanksCmd = &cobra.Command{
Use: "thanks",
Short: "Thank the human contributors to Gas Town",
GroupID: GroupDiag,
Long: `Display acknowledgments to all the humans who have contributed
to the Gas Town project. This command celebrates the collaborative
effort behind the multi-agent workspace manager.`,
Run: func(cmd *cobra.Command, args []string) {
printThanksPage()
},
}
// getContributorsSorted returns contributor names sorted by commit count descending
func getContributorsSorted() []string {
names := make([]string, 0, len(gastownContributors))
for name := range gastownContributors {
names = append(names, name)
}
slices.SortFunc(names, func(a, b string) int {
// sort by commit count descending, then by name ascending for ties
countCmp := cmp.Compare(gastownContributors[b], gastownContributors[a])
if countCmp != 0 {
return countCmp
}
return cmp.Compare(a, b)
})
return names
}
// printThanksPage renders the complete thanks page
func printThanksPage() {
fmt.Println()
// get sorted contributors, split into featured (top 20) and rest
sorted := getContributorsSorted()
featuredCount := 20
if len(sorted) < featuredCount {
featuredCount = len(sorted)
}
featured := sorted[:featuredCount]
additional := sorted[featuredCount:]
// calculate content width based on 4 columns
cols := 4
contentWidth := calculateColumnsWidth(featured, cols)
if contentWidth < 60 {
contentWidth = 60
}
// build header content
title := thanksTitleStyle.Render("THANK YOU!")
subtitle := thanksSubtitleStyle.Render("To all the humans who contributed to Gas Town")
headerContent := title + "\n\n" + subtitle
// render header in bordered box
header := thanksBoxStyle(contentWidth).Render(headerContent)
fmt.Println(header)
fmt.Println()
// print featured contributors section
fmt.Println(thanksSectionStyle.Render(" Featured Contributors"))
fmt.Println()
printThanksColumns(featured, cols)
// print additional contributors if any
if len(additional) > 0 {
fmt.Println()
fmt.Println(thanksSectionStyle.Render(" Additional Contributors"))
fmt.Println()
printThanksWrappedList("", additional, contentWidth)
}
fmt.Println()
}
// calculateColumnsWidth determines the width needed for n columns of names
func calculateColumnsWidth(names []string, cols int) int {
maxWidth := 0
for _, name := range names {
if len(name) > maxWidth {
maxWidth = len(name)
}
}
// cap at 20 characters per column
if maxWidth > 20 {
maxWidth = 20
}
// add padding between columns
colWidth := maxWidth + 2
return colWidth * cols
}
// printThanksColumns prints names in n columns, reading left-to-right
func printThanksColumns(names []string, cols int) {
if len(names) == 0 {
return
}
// find max width for alignment
maxWidth := 0
for _, name := range names {
if len(name) > maxWidth {
maxWidth = len(name)
}
}
if maxWidth > 20 {
maxWidth = 20
}
colWidth := maxWidth + 2
// print in rows, reading left to right (matches bd thanks)
for i := 0; i < len(names); i += cols {
fmt.Print(" ")
for j := 0; j < cols && i+j < len(names); j++ {
name := names[i+j]
if len(name) > 20 {
name = name[:17] + "..."
}
// pad BEFORE styling to avoid ANSI code width issues
padded := fmt.Sprintf("%-*s", colWidth, name)
fmt.Print(thanksNameStyle.Render(padded))
}
fmt.Println()
}
}
// printThanksWrappedList prints a comma-separated list with word wrapping
func printThanksWrappedList(label string, names []string, maxWidth int) {
indent := " "
fmt.Print(indent)
lineLen := len(indent)
if label != "" {
fmt.Print(thanksSectionStyle.Render(label) + " ")
lineLen += len(label) + 1
}
for i, name := range names {
suffix := ", "
if i == len(names)-1 {
suffix = ""
}
entry := name + suffix
if lineLen+len(entry) > maxWidth && lineLen > len(indent) {
fmt.Println()
fmt.Print(indent)
lineLen = len(indent)
}
fmt.Print(thanksDimStyle.Render(entry))
lineLen += len(entry)
}
fmt.Println()
}
func init() {
rootCmd.AddCommand(thanksCmd)
}

171
internal/cmd/uninstall.go Normal file
View File

@@ -0,0 +1,171 @@
// ABOUTME: Command to completely uninstall Gas Town from the system.
// ABOUTME: Removes shell integration, wrappers, state, and optionally workspace.
package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/wrappers"
)
var (
uninstallWorkspace bool
uninstallForce bool
)
var uninstallCmd = &cobra.Command{
Use: "uninstall",
GroupID: GroupConfig,
Short: "Remove Gas Town from the system",
Long: `Completely remove Gas Town from the system.
By default, removes:
- Shell integration (~/.zshrc or ~/.bashrc)
- Wrapper scripts (~/bin/gt-codex, ~/bin/gt-opencode)
- State directory (~/.local/state/gastown/)
- Config directory (~/.config/gastown/)
- Cache directory (~/.cache/gastown/)
The workspace (e.g., ~/gt) is NOT removed unless --workspace is specified.
Use --force to skip confirmation prompts.
Examples:
gt uninstall # Remove Gas Town, keep workspace
gt uninstall --workspace # Also remove workspace directory
gt uninstall --force # Skip confirmation`,
RunE: runUninstall,
}
func init() {
uninstallCmd.Flags().BoolVar(&uninstallWorkspace, "workspace", false,
"Also remove the workspace directory (DESTRUCTIVE)")
uninstallCmd.Flags().BoolVarP(&uninstallForce, "force", "f", false,
"Skip confirmation prompts")
rootCmd.AddCommand(uninstallCmd)
}
func runUninstall(cmd *cobra.Command, args []string) error {
if !uninstallForce {
fmt.Println("This will remove Gas Town from your system.")
fmt.Println()
fmt.Println("The following will be removed:")
fmt.Printf(" • Shell integration (%s)\n", shell.RCFilePath(shell.DetectShell()))
fmt.Printf(" • Wrapper scripts (%s)\n", wrappers.BinDir())
fmt.Printf(" • State directory (%s)\n", state.StateDir())
fmt.Printf(" • Config directory (%s)\n", state.ConfigDir())
fmt.Printf(" • Cache directory (%s)\n", state.CacheDir())
if uninstallWorkspace {
fmt.Println()
fmt.Printf(" %s WORKSPACE WILL BE DELETED\n", style.Warning.Render("⚠"))
fmt.Println(" This cannot be undone!")
}
fmt.Println()
fmt.Print("Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted.")
return nil
}
}
var errors []string
fmt.Println()
fmt.Println("Removing Gas Town...")
if err := shell.Remove(); err != nil {
errors = append(errors, fmt.Sprintf("shell integration: %v", err))
} else {
fmt.Printf(" %s Removed shell integration\n", style.Success.Render("✓"))
}
if err := wrappers.Remove(); err != nil {
errors = append(errors, fmt.Sprintf("wrapper scripts: %v", err))
} else {
fmt.Printf(" %s Removed wrapper scripts\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.StateDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("state directory: %v", err))
} else {
fmt.Printf(" %s Removed state directory\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.ConfigDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("config directory: %v", err))
} else {
fmt.Printf(" %s Removed config directory\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.CacheDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("cache directory: %v", err))
} else {
fmt.Printf(" %s Removed cache directory\n", style.Success.Render("✓"))
}
if uninstallWorkspace {
workspaceDir := findWorkspaceForUninstall()
if workspaceDir != "" {
if err := os.RemoveAll(workspaceDir); err != nil {
errors = append(errors, fmt.Sprintf("workspace: %v", err))
} else {
fmt.Printf(" %s Removed workspace: %s\n", style.Success.Render("✓"), workspaceDir)
}
}
}
if len(errors) > 0 {
fmt.Println()
fmt.Printf("%s Some components could not be removed:\n", style.Warning.Render("⚠"))
for _, e := range errors {
fmt.Printf(" • %s\n", e)
}
return fmt.Errorf("uninstall incomplete")
}
fmt.Println()
fmt.Printf("%s Gas Town has been uninstalled\n", style.Success.Render("✓"))
fmt.Println()
fmt.Println("To reinstall, run:")
fmt.Printf(" %s\n", style.Dim.Render("go install github.com/steveyegge/gastown/cmd/gt@latest"))
fmt.Printf(" %s\n", style.Dim.Render("gt install ~/gt --shell"))
return nil
}
func findWorkspaceForUninstall() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := []string{
filepath.Join(home, "gt"),
filepath.Join(home, "gastown"),
}
for _, path := range candidates {
mayorDir := filepath.Join(path, "mayor")
if _, err := os.Stat(mayorDir); err == nil {
return path
}
}
return ""
}

View File

@@ -83,7 +83,7 @@ func runUp(cmd *cobra.Command, args []string) error {
// 2. Deacon (Claude agent)
deaconMgr := deacon.NewManager(townRoot)
if err := deaconMgr.Start(); err != nil {
if err := deaconMgr.Start(""); err != nil {
if err == deacon.ErrAlreadyRunning {
printStatus("Deacon", true, deaconMgr.SessionName())
} else {
@@ -118,7 +118,7 @@ func runUp(cmd *cobra.Command, args []string) error {
}
mgr := witness.NewManager(r)
if err := mgr.Start(false); err != nil {
if err := mgr.Start(false, "", nil); err != nil {
if err == witness.ErrAlreadyRunning {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
} else {
@@ -451,6 +451,9 @@ func startPolecatsWithWork(townRoot, rigName string) ([]string, map[string]error
if !entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") {
continue
}
polecatName := entry.Name()
polecatPath := filepath.Join(polecatsDir, polecatName)

View File

@@ -7,11 +7,12 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/version"
)
// Version information - set at build time via ldflags
var (
Version = "0.2.2"
Version = "0.2.4"
// Build can be set via ldflags at compile time
Build = "dev"
// Commit and Branch - the git revision the binary was built from (optional ldflag)
@@ -28,9 +29,9 @@ var versionCmd = &cobra.Command{
branch := resolveBranch()
if commit != "" && branch != "" {
fmt.Printf("gt version %s (%s: %s@%s)\n", Version, Build, branch, shortCommit(commit))
fmt.Printf("gt version %s (%s: %s@%s)\n", Version, Build, branch, version.ShortCommit(commit))
} else if commit != "" {
fmt.Printf("gt version %s (%s: %s)\n", Version, Build, shortCommit(commit))
fmt.Printf("gt version %s (%s: %s)\n", Version, Build, version.ShortCommit(commit))
} else {
fmt.Printf("gt version %s (%s)\n", Version, Build)
}
@@ -39,6 +40,11 @@ var versionCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(versionCmd)
// Pass the build-time commit to the version package for stale binary checks
if Commit != "" {
version.SetCommit(Commit)
}
}
func resolveCommitHash() string {
@@ -57,13 +63,6 @@ func resolveCommitHash() string {
return ""
}
func shortCommit(hash string) string {
if len(hash) > 12 {
return hash[:12]
}
return hash
}
func resolveBranch() string {
if Branch != "" {
return Branch

View File

@@ -15,8 +15,10 @@ import (
// Witness command flags
var (
witnessForeground bool
witnessStatusJSON bool
witnessForeground bool
witnessStatusJSON bool
witnessAgentOverride string
witnessEnvOverrides []string
)
var witnessCmd = &cobra.Command{
@@ -41,6 +43,8 @@ states and takes action to keep work flowing.
Examples:
gt witness start greenplace
gt witness start greenplace --agent codex
gt witness start greenplace --env ANTHROPIC_MODEL=claude-3-haiku
gt witness start greenplace --foreground`,
Args: cobra.ExactArgs(1),
RunE: runWitnessStart,
@@ -93,7 +97,9 @@ var witnessRestartCmd = &cobra.Command{
Stops the current session (if running) and starts a fresh one.
Examples:
gt witness restart greenplace`,
gt witness restart greenplace
gt witness restart greenplace --agent codex
gt witness restart greenplace --env ANTHROPIC_MODEL=claude-3-haiku`,
Args: cobra.ExactArgs(1),
RunE: runWitnessRestart,
}
@@ -101,10 +107,16 @@ Examples:
func init() {
// Start flags
witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)")
witnessStartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)")
witnessStartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)")
// Status flags
witnessStatusCmd.Flags().BoolVar(&witnessStatusJSON, "json", false, "Output as JSON")
// Restart flags
witnessRestartCmd.Flags().StringVar(&witnessAgentOverride, "agent", "", "Agent alias to run the Witness with (overrides town default)")
witnessRestartCmd.Flags().StringArrayVar(&witnessEnvOverrides, "env", nil, "Environment variable override (KEY=VALUE, can be repeated)")
// Add subcommands
witnessCmd.AddCommand(witnessStartCmd)
witnessCmd.AddCommand(witnessStopCmd)
@@ -136,7 +148,7 @@ func runWitnessStart(cmd *cobra.Command, args []string) error {
fmt.Printf("Starting witness for %s...\n", rigName)
if err := mgr.Start(witnessForeground); err != nil {
if err := mgr.Start(witnessForeground, witnessAgentOverride, witnessEnvOverrides); err != nil {
if err == witness.ErrAlreadyRunning {
fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠"))
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect"))
@@ -289,7 +301,7 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
sessionName := witnessSessionName(rigName)
// Ensure session exists (creates if needed)
if err := mgr.Start(false); err != nil && err != witness.ErrAlreadyRunning {
if err := mgr.Start(false, "", nil); err != nil && err != witness.ErrAlreadyRunning {
return err
} else if err == nil {
fmt.Printf("Started witness session for %s\n", rigName)
@@ -322,7 +334,7 @@ func runWitnessRestart(cmd *cobra.Command, args []string) error {
_ = mgr.Stop()
// Start fresh
if err := mgr.Start(false); err != nil {
if err := mgr.Start(false, witnessAgentOverride, witnessEnvOverrides); err != nil {
return fmt.Errorf("starting witness: %w", err)
}

View File

@@ -0,0 +1,32 @@
package cmd
import (
"strings"
"testing"
)
func TestWitnessRestartAgentFlag(t *testing.T) {
flag := witnessRestartCmd.Flags().Lookup("agent")
if flag == nil {
t.Fatal("expected witness restart to define --agent flag")
}
if flag.DefValue != "" {
t.Errorf("expected default agent override to be empty, got %q", flag.DefValue)
}
if !strings.Contains(flag.Usage, "overrides town default") {
t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage)
}
}
func TestWitnessStartAgentFlag(t *testing.T) {
flag := witnessStartCmd.Flags().Lookup("agent")
if flag == nil {
t.Fatal("expected witness start to define --agent flag")
}
if flag.DefValue != "" {
t.Errorf("expected default agent override to be empty, got %q", flag.DefValue)
}
if !strings.Contains(flag.Usage, "overrides town default") {
t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage)
}
}

Some files were not shown because too many files have changed in this diff Show More