Compare commits
178 Commits
v0.2.6
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 11546d9bef | |||
| d3bf408eba | |||
| 34c77e883d | |||
|
|
9cd2696abe | ||
|
|
2b3f287f02 | ||
|
|
021b087a12 | ||
|
|
3cb3a0bbf7 | ||
|
|
7714295a43 | ||
|
|
616ff01e2c | ||
|
|
8d41f817b9 | ||
|
|
3f724336f4 | ||
|
|
576e73a924 | ||
|
|
5ecf8ccaf5 | ||
|
|
238ad8cd95 | ||
|
|
50bcf96afb | ||
|
|
2feefd1731 | ||
|
|
4a856f6e0d | ||
|
|
e853ac3539 | ||
|
|
f14dadc956 | ||
|
|
f19a0ab5d6 | ||
|
|
38d3c0c4f1 | ||
|
|
d4ad4c0726 | ||
|
|
88a74c50f7 | ||
|
|
7ff87ff012 | ||
|
|
bd655f58f9 | ||
|
|
72b03469d1 | ||
|
|
d6a4bc22fd | ||
|
|
3283ee42aa | ||
|
|
b40a6b0736 | ||
|
|
265239d4a1 | ||
|
|
cd67eae044 | ||
|
|
5badb54048 | ||
|
|
4deeba6304 | ||
|
|
93c6c70296 | ||
|
|
bda1dc97c5 | ||
|
|
5823c9fb36 | ||
|
|
885b5023d3 | ||
|
|
4ef93e1d8a | ||
|
|
6d29f34cd0 | ||
|
|
8880c61067 | ||
|
|
0cc4867ad7 | ||
|
|
d8bb9a9ba9 | ||
|
|
8dab7b662a | ||
|
|
938b068145 | ||
|
|
eed5cddc97 | ||
|
|
15d1dc8fa8 | ||
|
|
11b38294d4 | ||
|
|
d4026b79cf | ||
|
|
eb18dbf9e2 | ||
|
|
4d8236e26c | ||
|
|
6b895e56de | ||
|
|
ae2fddf4fc | ||
|
|
eea3dd564d | ||
|
|
5178fa7f0a | ||
|
|
0545d596c3 | ||
|
|
22064b0730 | ||
|
|
5a56525655 | ||
|
|
74050cd0ab | ||
|
|
fbc67e89e1 | ||
|
|
43e38f037c | ||
|
|
22a24c5648 | ||
|
|
9b34b6bfec | ||
|
|
301a42a90e | ||
|
|
7af7634022 | ||
|
|
29f8dd67e2 | ||
|
|
91433e8b1d | ||
|
|
c7e1451ce6 | ||
|
|
f89ac47ff9 | ||
|
|
e344e77921 | ||
|
|
a09c6b71d7 | ||
|
|
4fa6cfa0da | ||
|
|
c51047b654 | ||
|
|
d42a9bd6e0 | ||
|
|
08ef50047d | ||
|
|
95cb58e36f | ||
|
|
d3606c8c46 | ||
|
|
a88d2e1a9e | ||
|
|
29039ed69d | ||
|
|
b1a5241430 | ||
|
|
03213a7307 | ||
|
|
7e158cddd6 | ||
|
|
e5aea04fa1 | ||
|
|
8332a719ab | ||
|
|
139f3aeba3 | ||
|
|
add3d56c8b | ||
|
|
5c13e5f95a | ||
|
|
3ebb1118d3 | ||
|
|
618b0d9810 | ||
|
|
39185f8d00 | ||
|
|
a4776b9bee | ||
|
|
20effb0a51 | ||
|
|
4f02abb535 | ||
|
|
cbbf566f06 | ||
|
|
e30e46a87a | ||
|
|
7bbc09230e | ||
|
|
2ffc8e8712 | ||
|
|
012d50b2b2 | ||
|
|
bf8bddb004 | ||
|
|
42999d883d | ||
|
|
b3b980fd79 | ||
|
|
839fa19e90 | ||
|
|
7164e7a6d2 | ||
|
|
8eafcc8a16 | ||
|
|
a244c3d498 | ||
|
|
0bf68de517 | ||
|
|
42d9890e5c | ||
|
|
92144757ac | ||
|
|
e7ca4908dc | ||
|
|
3cf77b2e8b | ||
|
|
a1195cb104 | ||
|
|
80af0547ea | ||
|
|
08755f62cd | ||
|
|
5d96243414 | ||
|
|
60da5de104 | ||
|
|
0a6fa457f6 | ||
|
|
1043f00d06 | ||
|
|
8660641009 | ||
|
|
4ee1a4472d | ||
|
|
5882039715 | ||
|
|
7d8d96f7f9 | ||
|
|
69110309cc | ||
|
|
901b60e927 | ||
|
|
712a37b9c1 | ||
|
|
aa0bfd0c40 | ||
|
|
1453b8b592 | ||
|
|
65c5e05c43 | ||
|
|
bd2a5ab56a | ||
|
|
f32a63e6e5 | ||
|
|
c61b67eb03 | ||
|
|
fa99e615f0 | ||
|
|
ff6c02b15d | ||
|
|
66805079de | ||
|
|
bedccb1634 | ||
|
|
e0e5a00dfc | ||
|
|
275910b702 | ||
|
|
fdd4b0aeb0 | ||
|
|
f42ec42268 | ||
|
|
503e66ba8d | ||
|
|
8051c8bdd7 | ||
|
|
c0526f244e | ||
|
|
bda248fb9a | ||
|
|
45de02db43 | ||
|
|
9315248134 | ||
|
|
73a349e5ee | ||
|
|
a2607b5b72 | ||
|
|
18893e713a | ||
|
|
ea12679a5a | ||
|
|
b1fcb7d3e7 | ||
|
|
a43c89c01b | ||
|
|
e043f4a16c | ||
|
|
87fde4b4fd | ||
|
|
e083317cc3 | ||
|
|
7924921d17 | ||
|
|
278b2f2d4d | ||
|
|
791b388a93 | ||
|
|
6becab4a60 | ||
|
|
38bedc03e8 | ||
|
|
e7b0af0295 | ||
|
|
f9ca7bb87b | ||
|
|
392ff1d31b | ||
|
|
58207a00ec | ||
|
|
f0192c8b3d | ||
|
|
15cfb76c2c | ||
|
|
2d8949a3d3 | ||
|
|
f79614d764 | ||
|
|
e442212c05 | ||
|
|
6b2a7438e1 | ||
|
|
1902182f3a | ||
|
|
c99b004aeb | ||
|
|
c860112cf6 | ||
|
|
ee2ca10b0a | ||
|
|
5a373fbd57 | ||
|
|
efac19d184 | ||
|
|
ff3f3b4580 | ||
|
|
5a7c328f1f | ||
|
|
069fe0f285 | ||
|
|
1e3bf292f9 | ||
|
|
d6dc43938d |
5
.beads/.gitignore
vendored
5
.beads/.gitignore
vendored
@@ -32,6 +32,11 @@ beads.left.meta.json
|
||||
beads.right.jsonl
|
||||
beads.right.meta.json
|
||||
|
||||
# Sync state (local-only, per-machine)
|
||||
# These files are machine-specific and should not be shared across clones
|
||||
.sync.lock
|
||||
sync_base.jsonl
|
||||
|
||||
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
|
||||
# They would override fork protection in .git/info/exclude, allowing
|
||||
# contributors to accidentally commit upstream issue databases.
|
||||
|
||||
381
.beads/formulas/gastown-release.formula.toml
Normal file
381
.beads/formulas/gastown-release.formula.toml
Normal file
@@ -0,0 +1,381 @@
|
||||
description = """
|
||||
Gas Town release workflow - from version bump to verified release.
|
||||
|
||||
This formula orchestrates a release cycle for Gas Town:
|
||||
1. Preflight checks (workspace cleanliness, clean git, up to date)
|
||||
2. Documentation updates (CHANGELOG.md, info.go)
|
||||
3. Version bump (all components)
|
||||
4. Git operations (commit, tag, push)
|
||||
5. Local installation update
|
||||
6. Daemon restart
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gt mol wisp create gastown-release --var version=0.3.0
|
||||
```
|
||||
|
||||
Or assign to a crew member:
|
||||
```bash
|
||||
gt sling gastown/crew/max --formula gastown-release --var version=0.3.0
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Crew members (with user present)**: Attempt to resolve issues (merge branches,
|
||||
commit/stash work). Ask the user if blocked.
|
||||
- **Polecats (autonomous)**: Escalate via `gt escalate` if preflight fails or
|
||||
unrecoverable errors occur. Do not proceed with a release if workspaces have
|
||||
uncommitted work.
|
||||
"""
|
||||
formula = "gastown-release"
|
||||
type = "workflow"
|
||||
version = 1
|
||||
|
||||
[vars.version]
|
||||
description = "The semantic version to release (e.g., 0.3.0)"
|
||||
required = true
|
||||
|
||||
[[steps]]
|
||||
id = "preflight-workspaces"
|
||||
title = "Preflight: Check all workspaces for uncommitted work"
|
||||
description = """
|
||||
Before releasing, ensure no gastown workspaces have uncommitted work that would
|
||||
be excluded from the release.
|
||||
|
||||
Check all crew workspaces and the mayor rig:
|
||||
|
||||
```bash
|
||||
# Check each workspace
|
||||
for dir in ~/gt/gastown/crew/* ~/gt/gastown/mayor; do
|
||||
if [ -d "$dir/.git" ] || [ -d "$dir" ]; then
|
||||
echo "=== Checking $dir ==="
|
||||
cd "$dir" 2>/dev/null || continue
|
||||
|
||||
# Check for uncommitted changes
|
||||
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||
echo " ⚠ UNCOMMITTED CHANGES"
|
||||
git status --short
|
||||
fi
|
||||
|
||||
# Check for stashes
|
||||
stash_count=$(git stash list 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$stash_count" -gt 0 ]; then
|
||||
echo " ⚠ HAS $stash_count STASH(ES)"
|
||||
git stash list
|
||||
fi
|
||||
|
||||
# Check for non-main branches with unpushed commits
|
||||
current_branch=$(git branch --show-current 2>/dev/null)
|
||||
if [ -n "$current_branch" ] && [ "$current_branch" != "main" ]; then
|
||||
echo " ⚠ ON BRANCH: $current_branch (not main)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## If issues found:
|
||||
|
||||
**For crew members (interactive)**:
|
||||
1. Try to resolve: merge branches, commit work, apply/drop stashes
|
||||
2. If work is in-progress and not ready, ask the user whether to:
|
||||
- Wait for completion
|
||||
- Stash and proceed
|
||||
- Exclude from this release
|
||||
3. Only proceed when all workspaces are clean on main
|
||||
|
||||
**For polecats (autonomous)**:
|
||||
1. If any workspace has uncommitted work: STOP and escalate
|
||||
2. Use: `gt escalate --severity medium "Release blocked: workspace X has uncommitted work"`
|
||||
3. Do NOT proceed with release - uncommitted work would be excluded
|
||||
|
||||
This step is critical. A release with uncommitted work means losing changes.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "preflight-git"
|
||||
title = "Preflight: Check git status"
|
||||
needs = ["preflight-workspaces"]
|
||||
description = """
|
||||
Ensure YOUR working tree is clean before starting release.
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes:
|
||||
- Commit them first (if they should be in the release)
|
||||
- Stash them: `git stash` (if they should NOT be in the release)
|
||||
|
||||
## On failure:
|
||||
- **Crew**: Commit or stash your changes, then continue
|
||||
- **Polecat**: Escalate if you have uncommitted changes you didn't create
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "preflight-pull"
|
||||
title = "Preflight: Pull latest"
|
||||
needs = ["preflight-git"]
|
||||
description = """
|
||||
Ensure we're up to date with origin.
|
||||
|
||||
```bash
|
||||
git pull --rebase
|
||||
```
|
||||
|
||||
## On merge conflicts:
|
||||
- **Crew**: Resolve conflicts manually. Ask user if unsure about resolution.
|
||||
- **Polecat**: Escalate immediately. Do not attempt to resolve release-blocking
|
||||
merge conflicts autonomously.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "review-changes"
|
||||
title = "Review changes since last release"
|
||||
needs = ["preflight-pull"]
|
||||
description = """
|
||||
Understand what's being released.
|
||||
|
||||
```bash
|
||||
git log $(git describe --tags --abbrev=0)..HEAD --oneline
|
||||
```
|
||||
|
||||
Categorize changes:
|
||||
- Features (feat:)
|
||||
- Fixes (fix:)
|
||||
- Breaking changes
|
||||
- Documentation
|
||||
|
||||
If there are no changes since last release, ask whether to proceed with an
|
||||
empty release (version bump only).
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "update-changelog"
|
||||
title = "Update CHANGELOG.md"
|
||||
needs = ["review-changes"]
|
||||
description = """
|
||||
Write the [Unreleased] section with all changes for {{version}}.
|
||||
|
||||
Edit CHANGELOG.md and add entries under [Unreleased].
|
||||
|
||||
Format: Keep a Changelog (https://keepachangelog.com)
|
||||
|
||||
Sections to use:
|
||||
- ### Added - for new features
|
||||
- ### Changed - for changes in existing functionality
|
||||
- ### Fixed - for bug fixes
|
||||
- ### Deprecated - for soon-to-be removed features
|
||||
- ### Removed - for now removed features
|
||||
|
||||
Base entries on the git log from the previous step. Group related commits.
|
||||
|
||||
The bump script will automatically create the version header with today's date.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "update-info-go"
|
||||
title = "Update info.go versionChanges"
|
||||
needs = ["update-changelog"]
|
||||
description = """
|
||||
Add entry to versionChanges in internal/cmd/info.go.
|
||||
|
||||
This powers `gt info --whats-new` for agents.
|
||||
|
||||
Add a new entry at the TOP of the versionChanges slice:
|
||||
|
||||
```go
|
||||
{
|
||||
Version: "{{version}}",
|
||||
Date: "YYYY-MM-DD", // Today's date
|
||||
Changes: []string{
|
||||
"NEW: Key feature 1",
|
||||
"NEW: Key feature 2",
|
||||
"CHANGED: Modified behavior",
|
||||
"FIX: Bug that was fixed",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Focus on agent-relevant and workflow-impacting changes.
|
||||
Prefix with NEW:, CHANGED:, FIX:, or DEPRECATED: for clarity.
|
||||
|
||||
This is similar to CHANGELOG.md but focused on what agents need to know -
|
||||
new commands, changed behaviors, workflow impacts.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "run-bump-script"
|
||||
title = "Run bump-version.sh"
|
||||
needs = ["update-info-go"]
|
||||
description = """
|
||||
Update all component versions atomically.
|
||||
|
||||
```bash
|
||||
./scripts/bump-version.sh {{version}}
|
||||
```
|
||||
|
||||
This updates:
|
||||
- internal/cmd/version.go - CLI version constant
|
||||
- npm-package/package.json - npm package version
|
||||
- CHANGELOG.md - Creates [{{version}}] header with date
|
||||
|
||||
Review the changes shown by the script.
|
||||
|
||||
## On failure:
|
||||
If the script fails (e.g., version already exists, format error):
|
||||
- **Crew**: Debug and fix, or ask user
|
||||
- **Polecat**: Escalate with error details
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "verify-versions"
|
||||
title = "Verify version consistency"
|
||||
needs = ["run-bump-script"]
|
||||
description = """
|
||||
Confirm all versions match {{version}}.
|
||||
|
||||
```bash
|
||||
grep 'Version = ' internal/cmd/version.go
|
||||
grep '"version"' npm-package/package.json | head -1
|
||||
```
|
||||
|
||||
Both should show {{version}}.
|
||||
|
||||
## On mismatch:
|
||||
Do NOT proceed. Either the bump script failed or there's a bug.
|
||||
- **Crew**: Investigate and fix manually
|
||||
- **Polecat**: Escalate immediately - version mismatch is a release blocker
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "commit-release"
|
||||
title = "Commit release"
|
||||
needs = ["verify-versions"]
|
||||
description = """
|
||||
Stage and commit all version changes.
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Bump version to {{version}}"
|
||||
```
|
||||
|
||||
Review the commit to ensure all expected files are included:
|
||||
- internal/cmd/version.go
|
||||
- internal/cmd/info.go
|
||||
- npm-package/package.json
|
||||
- CHANGELOG.md
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "create-tag"
|
||||
title = "Create release tag"
|
||||
needs = ["commit-release"]
|
||||
description = """
|
||||
Create annotated git tag.
|
||||
|
||||
```bash
|
||||
git tag -a v{{version}} -m "Release v{{version}}"
|
||||
```
|
||||
|
||||
Verify: `git tag -l | tail -5`
|
||||
|
||||
## If tag already exists:
|
||||
The version may have been previously (partially) released.
|
||||
- **Crew**: Ask user how to proceed (delete tag and retry? use different version?)
|
||||
- **Polecat**: Escalate - do not delete existing tags autonomously
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "push-release"
|
||||
title = "Push commit and tag"
|
||||
needs = ["create-tag"]
|
||||
description = """
|
||||
Push the release commit and tag to origin.
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin v{{version}}
|
||||
```
|
||||
|
||||
This triggers GitHub Actions to build release artifacts.
|
||||
|
||||
Monitor: https://github.com/steveyegge/gastown/actions
|
||||
|
||||
## On push rejection:
|
||||
Someone pushed while we were releasing.
|
||||
- **Crew**: Pull, rebase, re-tag, try again. Ask user if conflicts.
|
||||
- **Polecat**: Escalate - release coordination conflict requires human decision
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "local-install"
|
||||
title = "Update local installation"
|
||||
needs = ["push-release"]
|
||||
description = """
|
||||
Rebuild and install gt locally with the new version.
|
||||
|
||||
```bash
|
||||
go build -o $(go env GOPATH)/bin/gt ./cmd/gt
|
||||
```
|
||||
|
||||
On macOS, codesign the binary:
|
||||
```bash
|
||||
codesign -f -s - $(go env GOPATH)/bin/gt
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
gt version
|
||||
```
|
||||
|
||||
Should show {{version}}.
|
||||
|
||||
## On build failure:
|
||||
- **Crew**: Debug build error, fix, retry
|
||||
- **Polecat**: Escalate - release is pushed but local install failed
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "restart-daemons"
|
||||
title = "Restart daemons"
|
||||
needs = ["local-install"]
|
||||
description = """
|
||||
Restart gt daemon to pick up the new version.
|
||||
|
||||
```bash
|
||||
gt daemon stop && gt daemon start
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
gt daemon status
|
||||
```
|
||||
|
||||
The daemon should show the new binary timestamp and no stale warning.
|
||||
|
||||
Note: This step is safe to retry if it fails.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
id = "release-complete"
|
||||
title = "Release complete"
|
||||
needs = ["restart-daemons"]
|
||||
description = """
|
||||
Release v{{version}} is complete!
|
||||
|
||||
Summary:
|
||||
- All workspaces verified clean before release
|
||||
- Version files updated (version.go, package.json)
|
||||
- CHANGELOG.md updated with release date
|
||||
- info.go versionChanges updated for `gt info --whats-new`
|
||||
- Git tag v{{version}} pushed
|
||||
- GitHub Actions triggered for artifact builds
|
||||
- Local gt binary rebuilt and installed
|
||||
- Daemons restarted with new version
|
||||
|
||||
Optional next steps:
|
||||
- Monitor GitHub Actions for release build completion
|
||||
- Verify release artifacts at https://github.com/steveyegge/gastown/releases
|
||||
- Announce the release
|
||||
"""
|
||||
@@ -84,10 +84,46 @@ Callbacks may spawn new polecats, update issue state, or trigger other actions.
|
||||
**Hygiene principle**: Archive messages after they're fully processed.
|
||||
Keep inbox near-empty - only unprocessed items should remain."""
|
||||
|
||||
[[steps]]
|
||||
id = "orphan-process-cleanup"
|
||||
title = "Clean up orphaned claude subagent processes"
|
||||
needs = ["inbox-check"]
|
||||
description = """
|
||||
Clean up orphaned claude subagent processes.
|
||||
|
||||
Claude Code's Task tool spawns subagent processes that sometimes don't clean up
|
||||
properly after completion. These accumulate and consume significant memory.
|
||||
|
||||
**Detection method:**
|
||||
Orphaned processes have no controlling terminal (TTY = "?"). Legitimate claude
|
||||
instances in terminals have a TTY like "pts/0".
|
||||
|
||||
**Run cleanup:**
|
||||
```bash
|
||||
gt deacon cleanup-orphans
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Lists all claude/codex processes with `ps -eo pid,tty,comm`
|
||||
2. Filters for TTY = "?" (no controlling terminal)
|
||||
3. Sends SIGTERM to each orphaned process
|
||||
4. Reports how many were killed
|
||||
|
||||
**Why this is safe:**
|
||||
- Processes in terminals (your personal sessions) have a TTY - they won't be touched
|
||||
- Only kills processes that have no controlling terminal
|
||||
- These orphans are children of the tmux server with no TTY, indicating they're
|
||||
detached subagents that failed to exit
|
||||
|
||||
**If cleanup fails:**
|
||||
Log the error but continue patrol - this is best-effort cleanup.
|
||||
|
||||
**Exit criteria:** Orphan cleanup attempted (success or logged failure)."""
|
||||
|
||||
[[steps]]
|
||||
id = "trigger-pending-spawns"
|
||||
title = "Nudge newly spawned polecats"
|
||||
needs = ["inbox-check"]
|
||||
needs = ["orphan-process-cleanup"]
|
||||
description = """
|
||||
Nudge newly spawned polecats that are ready for input.
|
||||
|
||||
@@ -499,10 +535,74 @@ gt dog status <name>
|
||||
|
||||
**Exit criteria:** Pool has at least 1 idle dog."""
|
||||
|
||||
[[steps]]
|
||||
id = "dog-health-check"
|
||||
title = "Check for stuck dogs"
|
||||
needs = ["dog-pool-maintenance"]
|
||||
description = """
|
||||
Check for dogs that have been working too long (stuck).
|
||||
|
||||
Dogs dispatched via `gt dog dispatch --plugin` are marked as "working" with
|
||||
a work description like "plugin:rebuild-gt". If a dog hangs, crashes, or
|
||||
takes too long, it needs intervention.
|
||||
|
||||
**Step 1: List working dogs**
|
||||
```bash
|
||||
gt dog list --json
|
||||
# Filter for state: "working"
|
||||
```
|
||||
|
||||
**Step 2: Check work duration**
|
||||
For each working dog:
|
||||
```bash
|
||||
gt dog status <name> --json
|
||||
# Check: work_started_at, current_work
|
||||
```
|
||||
|
||||
Compare against timeout:
|
||||
- If plugin has [execution] timeout in plugin.md, use that
|
||||
- Default timeout: 10 minutes for infrastructure tasks
|
||||
|
||||
**Duration calculation:**
|
||||
```
|
||||
stuck_threshold = plugin_timeout or 10m
|
||||
duration = now - work_started_at
|
||||
is_stuck = duration > stuck_threshold
|
||||
```
|
||||
|
||||
**Step 3: Handle stuck dogs**
|
||||
|
||||
For dogs working > timeout:
|
||||
```bash
|
||||
# Option A: File death warrant (Boot handles termination)
|
||||
gt warrant file deacon/dogs/<name> --reason "Stuck: working on <work> for <duration>"
|
||||
|
||||
# Option B: Force clear work and notify
|
||||
gt dog clear <name> --force
|
||||
gt mail send deacon/ -s "DOG_TIMEOUT <name>" -m "Dog <name> timed out on <work> after <duration>"
|
||||
```
|
||||
|
||||
**Decision matrix:**
|
||||
|
||||
| Duration over timeout | Action |
|
||||
|----------------------|--------|
|
||||
| < 2x timeout | Log warning, check next cycle |
|
||||
| 2x - 5x timeout | File death warrant |
|
||||
| > 5x timeout | Force clear + escalate to Mayor |
|
||||
|
||||
**Step 4: Track chronic failures**
|
||||
If same dog gets stuck repeatedly:
|
||||
```bash
|
||||
gt mail send mayor/ -s "Dog <name> chronic failures" \
|
||||
-m "Dog has timed out N times in last 24h. Consider removing from pool."
|
||||
```
|
||||
|
||||
**Exit criteria:** All stuck dogs handled (warrant filed or cleared)."""
|
||||
|
||||
[[steps]]
|
||||
id = "orphan-check"
|
||||
title = "Detect abandoned work"
|
||||
needs = ["dog-pool-maintenance"]
|
||||
needs = ["dog-health-check"]
|
||||
description = """
|
||||
**DETECT ONLY** - Check for orphaned state and dispatch to dog if found.
|
||||
|
||||
|
||||
2669
.beads/issues.jsonl
2669
.beads/issues.jsonl
File diff suppressed because it is too large
Load Diff
51
.github/workflows/block-internal-prs.yml
vendored
51
.github/workflows/block-internal-prs.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Block Internal PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
block-internal-prs:
|
||||
name: Block Internal PRs
|
||||
# Only run if PR is from the same repo (not a fork)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close PR and comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.issue.number;
|
||||
const branch = context.payload.pull_request.head.ref;
|
||||
|
||||
const body = [
|
||||
'**Internal PRs are not allowed.**',
|
||||
'',
|
||||
'Gas Town agents push directly to main. PRs are for external contributors only.',
|
||||
'',
|
||||
'To land your changes:',
|
||||
'```bash',
|
||||
'git checkout main',
|
||||
'git merge ' + branch,
|
||||
'git push origin main',
|
||||
'git push origin --delete ' + branch,
|
||||
'```',
|
||||
'',
|
||||
'See CLAUDE.md: "Crew workers push directly to main. No feature branches. NEVER create PRs."'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: body
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
core.setFailed('Internal PR blocked. Push directly to main instead.');
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -235,7 +235,8 @@ jobs:
|
||||
git config --global user.email "ci@gastown.test"
|
||||
|
||||
- name: Install beads (bd)
|
||||
run: go install github.com/steveyegge/beads/cmd/bd@latest
|
||||
# Pin to v0.47.1 - v0.47.2 has routing defaults that cause prefix mismatch errors
|
||||
run: go install github.com/steveyegge/beads/cmd/bd@v0.47.1
|
||||
|
||||
- name: Build gt
|
||||
run: go build -v -o gt ./cmd/gt
|
||||
|
||||
3
.github/workflows/integration.yml
vendored
3
.github/workflows/integration.yml
vendored
@@ -30,7 +30,8 @@ jobs:
|
||||
git config --global user.email "ci@gastown.test"
|
||||
|
||||
- name: Install beads (bd)
|
||||
run: go install github.com/steveyegge/beads/cmd/bd@latest
|
||||
# Pin to v0.47.1 - v0.47.2 has routing defaults that cause prefix mismatch errors
|
||||
run: go install github.com/steveyegge/beads/cmd/bd@v0.47.1
|
||||
|
||||
- name: Add to PATH
|
||||
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -42,6 +42,8 @@ state.json
|
||||
.beads/mq/
|
||||
.beads/last-touched
|
||||
.beads/daemon-*.log.gz
|
||||
.beads/.sync.lock
|
||||
.beads/sync_base.jsonl
|
||||
.beads-wisp/
|
||||
|
||||
# Clone-specific CLAUDE.md (regenerated locally per clone)
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2026-01-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?"
|
||||
|
||||
## [0.3.1] - 2026-01-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphan cleanup on macOS** - Fixed TTY comparison (`??` vs `?`) so orphan detection works on macOS
|
||||
- **Session kill leaves orphans** - `gt done` and `gt crew stop` now use `KillSessionWithProcesses` to properly terminate all child processes before killing the tmux session
|
||||
|
||||
## [0.3.0] - 2026-01-17
|
||||
|
||||
### Added
|
||||
|
||||
#### Release Automation
|
||||
- **`gastown-release` molecule formula** - Workflow for releases with preflight checks, CHANGELOG/info.go updates, local install, and daemon restart
|
||||
|
||||
#### New Commands
|
||||
- **`gt show`** - Inspect bead contents and metadata
|
||||
- **`gt cat`** - Display bead content directly
|
||||
- **`gt orphans list/kill`** - Detect and clean up orphaned Claude processes
|
||||
- **`gt convoy close`** - Manual convoy closure command
|
||||
- **`gt commit`** - Wrapper for git commit with bead awareness
|
||||
- **`gt trail`** - View commit trail for current work
|
||||
- **`gt mail ack`** - Alias for mark-read command
|
||||
|
||||
#### Plugin System
|
||||
- **Plugin discovery and management** - `gt plugin run`, `gt plugin history`
|
||||
- **`gt dispatch --plugin`** - Execute plugins via dispatch command
|
||||
|
||||
#### Messaging Infrastructure (Beads-Native)
|
||||
- **Queue beads** - New bead type for message queues
|
||||
- **Channel beads** - Pub/sub messaging with retention
|
||||
- **Group beads** - Group management for messaging
|
||||
- **Address resolution** - Resolve agent addresses for mail routing
|
||||
- **`gt mail claim`** - Claim messages from queues
|
||||
|
||||
#### Agent Identity
|
||||
- **`gt polecat identity show`** - Display CV summary for agents
|
||||
- **Worktree setup hooks** - Inject local configurations into worktrees
|
||||
|
||||
#### Performance & Reliability
|
||||
- **Parallel agent startup** - Faster boot with concurrency limit
|
||||
- **Event-driven convoy completion** - Deacon checks convoy status on events
|
||||
- **Automatic orphan cleanup** - Detect and kill orphaned Claude processes
|
||||
- **Namepool auto-theming** - Themes selected per rig based on name hash
|
||||
|
||||
### Changed
|
||||
|
||||
- **MR tracking via beads** - Removed mrqueue package, MRs now stored as beads
|
||||
- **Desire-path commands** - Added agent ergonomics shortcuts
|
||||
- **Explicit escalation in templates** - Polecat templates include escalation instructions
|
||||
- **NamePool state is transient** - InUse state no longer persisted to config
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Process Management
|
||||
- **Kill process tree on shutdown** - Prevents orphaned Claude processes
|
||||
- **Explicit pane process kill** - Prevents setsid orphans in tmux
|
||||
- **Session survival verification** - Verify session survives startup before returning
|
||||
- **Batch session queries** - Improved performance in `gt down`
|
||||
- **Prevent tmux server exit** - `gt down` no longer kills tmux server
|
||||
|
||||
#### Beads & Routing
|
||||
- **Agent bead prefix alignment** - Force multi-hyphen IDs for consistency
|
||||
- **hq- prefix for town-level beads** - Groups, channels use correct prefix
|
||||
- **CreatedAt for group/channel beads** - Proper timestamps on creation
|
||||
- **Routes.jsonl protection** - Doctor check for rig-level routing issues
|
||||
- **Clear BEADS_DIR in auto-convoys** - Prevent prefix inheritance issues
|
||||
|
||||
#### Mail & Communication
|
||||
- **Channel routing in router.Send()** - Mail correctly routes to channels
|
||||
- **Filter unread in beads mode** - Correct unread message filtering
|
||||
- **Town root detection** - Use workspace.Find for consistent detection
|
||||
|
||||
#### Session & Lifecycle
|
||||
- **Idle Polecat Heresy warnings** - Templates warn against idle waiting
|
||||
- **Direct push prohibition for polecats** - Explicit in templates
|
||||
- **Handoff working directory** - Use correct witness directory
|
||||
- **Dead polecat handling in sling** - Detect and handle dead polecats
|
||||
- **gt done self-cleaning** - Kill tmux session on completion
|
||||
|
||||
#### Doctor & Diagnostics
|
||||
- **Zombie session detection** - Detect dead Claude processes in tmux
|
||||
- **sqlite3 availability check** - Verify sqlite3 is installed
|
||||
- **Clone divergence check** - Remove blocking git fetch
|
||||
|
||||
#### Build & Platform
|
||||
- **Windows build support** - Platform-specific process/signal handling
|
||||
- **macOS codesigning** - Sign binary after install
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Idle Polecat Heresy** - Document the anti-pattern of waiting for work
|
||||
- **Bead ID vs Issue ID** - Clarify terminology in README
|
||||
- **Explicit escalation** - Add escalation guidance to polecat templates
|
||||
- **Getting Started placement** - Fix README section ordering
|
||||
|
||||
## [0.2.6] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
3
Makefile
3
Makefile
@@ -24,6 +24,9 @@ endif
|
||||
|
||||
install: build
|
||||
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
|
||||
ifeq ($(shell uname),Darwin)
|
||||
@codesign -s - -f ~/.local/bin/$(BINARY) 2>/dev/null || true
|
||||
endif
|
||||
|
||||
clean:
|
||||
rm -f $(BUILD_DIR)/$(BINARY)
|
||||
|
||||
55
README.md
55
README.md
@@ -71,12 +71,14 @@ Git worktree-based persistent storage for agent work. Survives crashes and resta
|
||||
|
||||
### Convoys 🚚
|
||||
|
||||
Work tracking units. Bundle multiple issues/tasks that get assigned to agents.
|
||||
Work tracking units. Bundle multiple beads that get assigned to agents.
|
||||
|
||||
### Beads Integration 📿
|
||||
|
||||
Git-backed issue tracking system that stores work state as structured data.
|
||||
|
||||
**Bead IDs** (also called **issue IDs**) use a prefix + 5-character alphanumeric format (e.g., `gt-abc12`, `hq-x7k2m`). The prefix indicates the item's origin or rig. Commands like `gt sling` and `gt convoy` accept these IDs to reference specific work items. The terms "bead" and "issue" are used interchangeably—beads are the underlying data format, while issues are the work items stored as beads.
|
||||
|
||||
> **New to Gas Town?** See the [Glossary](docs/glossary.md) for a complete guide to terminology and concepts.
|
||||
|
||||
## Installation
|
||||
@@ -86,6 +88,7 @@ Git-backed issue tracking system that stores work state as structured data.
|
||||
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
|
||||
- **Git 2.25+** - for worktree support
|
||||
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
|
||||
- **sqlite3** - for convoy database queries (usually pre-installed on macOS/Linux)
|
||||
- **tmux 3.0+** - recommended for full experience
|
||||
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
|
||||
- **Codex CLI** (optional runtime) - [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli)
|
||||
@@ -116,6 +119,18 @@ gt mayor attach
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
### Getting Started
|
||||
Run
|
||||
```shell
|
||||
gt install ~/gt --git &&
|
||||
cd ~/gt &&
|
||||
gt config agent list &&
|
||||
gt mayor attach
|
||||
```
|
||||
and tell the Mayor what you want to build!
|
||||
|
||||
---
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```mermaid
|
||||
@@ -127,8 +142,8 @@ sequenceDiagram
|
||||
participant Hook
|
||||
|
||||
You->>Mayor: Tell Mayor what to build
|
||||
Mayor->>Convoy: Create convoy with issues
|
||||
Mayor->>Agent: Sling issue to agent
|
||||
Mayor->>Convoy: Create convoy with beads
|
||||
Mayor->>Agent: Sling bead to agent
|
||||
Agent->>Hook: Store work state
|
||||
Agent->>Agent: Complete work
|
||||
Agent->>Convoy: Report completion
|
||||
@@ -141,11 +156,11 @@ sequenceDiagram
|
||||
# 1. Start the Mayor
|
||||
gt mayor attach
|
||||
|
||||
# 2. In Mayor session, create a convoy
|
||||
gt convoy create "Feature X" issue-123 issue-456 --notify --human
|
||||
# 2. In Mayor session, create a convoy with bead IDs
|
||||
gt convoy create "Feature X" gt-abc12 gt-def34 --notify --human
|
||||
|
||||
# 3. Assign work to an agent
|
||||
gt sling issue-123 myproject
|
||||
gt sling gt-abc12 myproject
|
||||
|
||||
# 4. Track progress
|
||||
gt convoy list
|
||||
@@ -177,7 +192,7 @@ flowchart LR
|
||||
gt mayor attach
|
||||
|
||||
# In Mayor, create convoy and let it orchestrate
|
||||
gt convoy create "Auth System" issue-101 issue-102 --notify
|
||||
gt convoy create "Auth System" gt-x7k2m gt-p9n4q --notify
|
||||
|
||||
# Track progress
|
||||
gt convoy list
|
||||
@@ -188,8 +203,8 @@ gt convoy list
|
||||
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
|
||||
gt convoy create "Fix bugs" gt-abc12 # Create convoy (sling auto-creates if skipped)
|
||||
gt sling gt-abc12 myproject # Assign to worker
|
||||
claude --resume # Agent reads mail, runs work (Claude)
|
||||
# or: codex # Start Codex in the workspace
|
||||
gt convoy list # Check progress
|
||||
@@ -263,11 +278,11 @@ bd mol pour release --var version=1.2.0
|
||||
# Create convoy manually
|
||||
gt convoy create "Bug Fixes" --human
|
||||
|
||||
# Add issues
|
||||
gt convoy add-issue bug-101 bug-102
|
||||
# Add issues to existing convoy
|
||||
gt convoy add hq-cv-abc gt-m3k9p gt-w5t2x
|
||||
|
||||
# Assign to specific agents
|
||||
gt sling bug-101 myproject/my-agent
|
||||
gt sling gt-m3k9p myproject/my-agent
|
||||
|
||||
# Check status
|
||||
gt convoy show
|
||||
@@ -312,8 +327,8 @@ 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 cursor # Override runtime for this sling/spawn
|
||||
gt sling <bead-id> <rig> # Assign work to agent
|
||||
gt sling <bead-id> <rig> --agent cursor # Override runtime for this sling/spawn
|
||||
gt mayor attach # Start Mayor session
|
||||
gt mayor start --agent auggie # Run Mayor with a specific agent alias
|
||||
gt prime # Context recovery (run inside existing session)
|
||||
@@ -324,10 +339,10 @@ gt prime # Context recovery (run inside existing session)
|
||||
### Convoy (Work Tracking)
|
||||
|
||||
```bash
|
||||
gt convoy create <name> [issues...] # Create convoy
|
||||
gt convoy create <name> [issues...] # Create convoy with issues
|
||||
gt convoy list # List all convoys
|
||||
gt convoy show [id] # Show convoy details
|
||||
gt convoy add-issue <issue> # Add issue to convoy
|
||||
gt convoy add <convoy-id> <issue-id...> # Add issues to convoy
|
||||
```
|
||||
|
||||
### Configuration
|
||||
@@ -406,9 +421,9 @@ MEOW is the recommended pattern:
|
||||
|
||||
1. **Tell the Mayor** - Describe what you want
|
||||
2. **Mayor analyzes** - Breaks down into tasks
|
||||
3. **Convoy creation** - Mayor creates convoy with issues
|
||||
3. **Convoy creation** - Mayor creates convoy with beads
|
||||
4. **Agent spawning** - Mayor spawns appropriate agents
|
||||
5. **Work distribution** - Issues slung to agents via hooks
|
||||
5. **Work distribution** - Beads slung to agents via hooks
|
||||
6. **Progress monitoring** - Track through convoy status
|
||||
7. **Completion** - Mayor summarizes results
|
||||
|
||||
@@ -475,7 +490,3 @@ gt mayor attach
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
---
|
||||
|
||||
**Getting Started:** Run `gt install ~/gt --git && cd ~/gt && gt config agent list && gt mayor attach` (or `gt mayor attach --agent codex`) and tell the Mayor what you want to build!
|
||||
|
||||
@@ -152,7 +152,7 @@ You can also override the agent per command without changing defaults:
|
||||
|
||||
```bash
|
||||
gt start --agent codex-low
|
||||
gt sling issue-123 myproject --agent claude-haiku
|
||||
gt sling gt-abc12 myproject --agent claude-haiku
|
||||
```
|
||||
|
||||
## Minimal Mode vs Full Stack Mode
|
||||
@@ -165,8 +165,8 @@ 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
|
||||
gt convoy create "Fix bugs" gt-abc12
|
||||
gt sling gt-abc12 myproject
|
||||
|
||||
# Run runtime manually
|
||||
cd ~/gt/myproject/polecats/<worker>
|
||||
@@ -188,9 +188,9 @@ Agents run in tmux sessions. Daemon manages lifecycle automatically.
|
||||
gt daemon start
|
||||
|
||||
# Create and assign work (workers spawn automatically)
|
||||
gt convoy create "Feature X" issue-123 issue-456
|
||||
gt sling issue-123 myproject
|
||||
gt sling issue-456 myproject
|
||||
gt convoy create "Feature X" gt-abc12 gt-def34
|
||||
gt sling gt-abc12 myproject
|
||||
gt sling gt-def34 myproject
|
||||
|
||||
# Monitor on dashboard
|
||||
gt convoy list
|
||||
@@ -303,6 +303,6 @@ rm -rf ~/gt
|
||||
After installation:
|
||||
|
||||
1. **Read the README** - Core concepts and workflows
|
||||
2. **Try a simple workflow** - `gt convoy create "Test" test-issue`
|
||||
2. **Try a simple workflow** - `bd create "Test task"` then `gt convoy create "Test" <bead-id>`
|
||||
3. **Explore docs** - `docs/reference.md` for command reference
|
||||
4. **Run doctor regularly** - `gt doctor` catches problems early
|
||||
|
||||
201
docs/beads-native-messaging.md
Normal file
201
docs/beads-native-messaging.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Beads-Native Messaging
|
||||
|
||||
This document describes the beads-native messaging system for Gas Town, which replaces the file-based messaging configuration with persistent beads stored in the town's `.beads` directory.
|
||||
|
||||
## Overview
|
||||
|
||||
Beads-native messaging introduces three new bead types for managing communication:
|
||||
|
||||
- **Groups** (`gt:group`) - Named collections of addresses for mail distribution
|
||||
- **Queues** (`gt:queue`) - Work queues where messages can be claimed by workers
|
||||
- **Channels** (`gt:channel`) - Pub/sub broadcast streams with message retention
|
||||
|
||||
All messaging beads use the `hq-` prefix because they are town-level entities that span rigs.
|
||||
|
||||
## Bead Types
|
||||
|
||||
### Groups (`gt:group`)
|
||||
|
||||
Groups are named collections of addresses used for mail distribution. When you send to a group, the message is delivered to all members.
|
||||
|
||||
**Bead ID format:** `hq-group-<name>` (e.g., `hq-group-ops-team`)
|
||||
|
||||
**Fields:**
|
||||
- `name` - Unique group name
|
||||
- `members` - Comma-separated list of addresses, patterns, or nested group names
|
||||
- `created_by` - Who created the group (from BD_ACTOR)
|
||||
- `created_at` - ISO 8601 timestamp
|
||||
|
||||
**Member types:**
|
||||
- Direct addresses: `gastown/crew/max`, `mayor/`, `deacon/`
|
||||
- Wildcard patterns: `*/witness`, `gastown/*`, `gastown/crew/*`
|
||||
- Special patterns: `@town`, `@crew`, `@witnesses`
|
||||
- Nested groups: Reference other group names
|
||||
|
||||
### Queues (`gt:queue`)
|
||||
|
||||
Queues are work queues where messages wait to be claimed by workers. Unlike groups, each message goes to exactly one claimant.
|
||||
|
||||
**Bead ID format:** `hq-q-<name>` (town-level) or `gt-q-<name>` (rig-level)
|
||||
|
||||
**Fields:**
|
||||
- `name` - Queue name
|
||||
- `status` - `active`, `paused`, or `closed`
|
||||
- `max_concurrency` - Maximum concurrent workers (0 = unlimited)
|
||||
- `processing_order` - `fifo` or `priority`
|
||||
- `available_count` - Items ready to process
|
||||
- `processing_count` - Items currently being processed
|
||||
- `completed_count` - Items completed
|
||||
- `failed_count` - Items that failed
|
||||
|
||||
### Channels (`gt:channel`)
|
||||
|
||||
Channels are pub/sub streams for broadcasting messages. Messages are retained according to the channel's retention policy.
|
||||
|
||||
**Bead ID format:** `hq-channel-<name>` (e.g., `hq-channel-alerts`)
|
||||
|
||||
**Fields:**
|
||||
- `name` - Unique channel name
|
||||
- `subscribers` - Comma-separated list of subscribed addresses
|
||||
- `status` - `active` or `closed`
|
||||
- `retention_count` - Number of recent messages to retain (0 = unlimited)
|
||||
- `retention_hours` - Hours to retain messages (0 = forever)
|
||||
- `created_by` - Who created the channel
|
||||
- `created_at` - ISO 8601 timestamp
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Group Management
|
||||
|
||||
```bash
|
||||
# List all groups
|
||||
gt mail group list
|
||||
|
||||
# Show group details
|
||||
gt mail group show <name>
|
||||
|
||||
# Create a new group with members
|
||||
gt mail group create <name> [members...]
|
||||
gt mail group create ops-team gastown/witness gastown/crew/max
|
||||
|
||||
# Add member to group
|
||||
gt mail group add <name> <member>
|
||||
|
||||
# Remove member from group
|
||||
gt mail group remove <name> <member>
|
||||
|
||||
# Delete a group
|
||||
gt mail group delete <name>
|
||||
```
|
||||
|
||||
### Channel Management
|
||||
|
||||
```bash
|
||||
# List all channels
|
||||
gt mail channel
|
||||
gt mail channel list
|
||||
|
||||
# View channel messages
|
||||
gt mail channel <name>
|
||||
gt mail channel show <name>
|
||||
|
||||
# Create a channel with retention policy
|
||||
gt mail channel create <name> [--retain-count=N] [--retain-hours=N]
|
||||
gt mail channel create alerts --retain-count=100
|
||||
|
||||
# Delete a channel
|
||||
gt mail channel delete <name>
|
||||
```
|
||||
|
||||
### Sending Messages
|
||||
|
||||
The `gt mail send` command now supports groups, queues, and channels:
|
||||
|
||||
```bash
|
||||
# Send to a group (expands to all members)
|
||||
gt mail send my-group -s "Subject" -m "Body"
|
||||
|
||||
# Send to a queue (single message, workers claim)
|
||||
gt mail send queue:my-queue -s "Work item" -m "Details"
|
||||
|
||||
# Send to a channel (broadcast with retention)
|
||||
gt mail send channel:my-channel -s "Announcement" -m "Content"
|
||||
|
||||
# Direct address (unchanged)
|
||||
gt mail send gastown/crew/max -s "Hello" -m "World"
|
||||
```
|
||||
|
||||
## Address Resolution
|
||||
|
||||
When sending mail, addresses are resolved in this order:
|
||||
|
||||
1. **Explicit prefix** - If address starts with `group:`, `queue:`, or `channel:`, use that type directly
|
||||
2. **Contains `/`** - Treat as agent address or pattern (direct delivery)
|
||||
3. **Starts with `@`** - Special pattern (`@town`, `@crew`, etc.) or beads-native group
|
||||
4. **Name lookup** - Search for group → queue → channel by name
|
||||
|
||||
If a name matches multiple types (e.g., both a group and a channel named "alerts"), the resolver returns an error and requires an explicit prefix.
|
||||
|
||||
## Key Implementation Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `internal/beads/beads_group.go` | Group bead CRUD operations |
|
||||
| `internal/beads/beads_queue.go` | Queue bead CRUD operations |
|
||||
| `internal/beads/beads_channel.go` | Channel bead + retention logic |
|
||||
| `internal/mail/resolve.go` | Address resolution logic |
|
||||
| `internal/cmd/mail_group.go` | Group CLI commands |
|
||||
| `internal/cmd/mail_channel.go` | Channel CLI commands |
|
||||
| `internal/cmd/mail_send.go` | Updated send with resolver |
|
||||
|
||||
## Retention Policy
|
||||
|
||||
Channels support two retention mechanisms:
|
||||
|
||||
- **Count-based** (`--retain-count=N`): Keep only the last N messages
|
||||
- **Time-based** (`--retain-hours=N`): Delete messages older than N hours
|
||||
|
||||
Retention is enforced:
|
||||
1. **On-write**: After posting a new message, old messages are pruned
|
||||
2. **On-patrol**: Deacon patrol runs `PruneAllChannels()` as a backup cleanup
|
||||
|
||||
The patrol uses a 10% buffer to avoid thrashing (only prunes if count > retainCount × 1.1).
|
||||
|
||||
## Examples
|
||||
|
||||
### Create a team distribution group
|
||||
|
||||
```bash
|
||||
# Create a group for the ops team
|
||||
gt mail group create ops-team gastown/witness gastown/crew/max deacon/
|
||||
|
||||
# Send to the group
|
||||
gt mail send ops-team -s "Team meeting" -m "Tomorrow at 10am"
|
||||
|
||||
# Add a new member
|
||||
gt mail group add ops-team gastown/crew/dennis
|
||||
```
|
||||
|
||||
### Set up an alerts channel
|
||||
|
||||
```bash
|
||||
# Create an alerts channel that keeps last 50 messages
|
||||
gt mail channel create alerts --retain-count=50
|
||||
|
||||
# Send an alert
|
||||
gt mail send channel:alerts -s "Build failed" -m "See CI for details"
|
||||
|
||||
# View recent alerts
|
||||
gt mail channel alerts
|
||||
```
|
||||
|
||||
### Create nested groups
|
||||
|
||||
```bash
|
||||
# Create role-based groups
|
||||
gt mail group create witnesses */witness
|
||||
gt mail group create leads gastown/crew/max gastown/crew/dennis
|
||||
|
||||
# Create a group that includes other groups
|
||||
gt mail group create all-hands witnesses leads mayor/
|
||||
```
|
||||
@@ -200,7 +200,8 @@ gt done # Signal completion (syncs, submits to MQ, notifi
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
||||
2. **Check progress with `bd mol current`** - Know where you are before resuming
|
||||
3. **Squash completed molecules** - Create digests for audit trail
|
||||
4. **Burn routine wisps** - Don't accumulate ephemeral patrol data
|
||||
1. **CRITICAL: Close steps in real-time** - Mark `in_progress` BEFORE starting, `closed` IMMEDIATELY after completing. Never batch-close steps at the end. Molecules ARE the ledger - each step closure is a timestamped CV entry. Batch-closing corrupts the timeline and violates HOP's core promise.
|
||||
2. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
||||
3. **Check progress with `bd mol current`** - Know where you are before resuming
|
||||
4. **Squash completed molecules** - Create digests for audit trail
|
||||
5. **Burn routine wisps** - Don't accumulate ephemeral patrol data
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
## Overview
|
||||
|
||||
Polecats have three distinct lifecycle layers that operate independently. Confusing
|
||||
these layers leads to bugs like "idle polecats" and misunderstanding when
|
||||
recycling occurs.
|
||||
these layers leads to "heresies" like thinking there are "idle polecats" and
|
||||
misunderstanding when recycling occurs.
|
||||
|
||||
## The Three Operating States
|
||||
|
||||
|
||||
197
docs/design/convoy-lifecycle.md
Normal file
197
docs/design/convoy-lifecycle.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Convoy Lifecycle Design
|
||||
|
||||
> Making convoys actively converge on completion.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Convoys are passive trackers. They group work but don't drive it. The completion
|
||||
loop has a structural gap:
|
||||
|
||||
```
|
||||
Create → Assign → Execute → Issues close → ??? → Convoy closes
|
||||
```
|
||||
|
||||
The `???` is "Deacon patrol runs `gt convoy check`" - a poll-based single point of
|
||||
failure. When Deacon is down, convoys don't close. Work completes but the loop
|
||||
never lands.
|
||||
|
||||
## Current State
|
||||
|
||||
### What Works
|
||||
- Convoy creation and issue tracking
|
||||
- `gt convoy status` shows progress
|
||||
- `gt convoy stranded` finds unassigned work
|
||||
- `gt convoy check` auto-closes completed convoys
|
||||
|
||||
### What Breaks
|
||||
1. **Poll-based completion**: Only Deacon runs `gt convoy check`
|
||||
2. **No event-driven trigger**: Issue close doesn't propagate to convoy
|
||||
3. **No manual close**: Can't force-close abandoned convoys
|
||||
4. **Single observer**: No redundant completion detection
|
||||
5. **Weak notification**: Convoy owner not always clear
|
||||
|
||||
## Design: Active Convoy Convergence
|
||||
|
||||
### Principle: Event-Driven, Redundantly Observed
|
||||
|
||||
Convoy completion should be:
|
||||
1. **Event-driven**: Triggered by issue close, not polling
|
||||
2. **Redundantly observed**: Multiple agents can detect and close
|
||||
3. **Manually overridable**: Humans can force-close
|
||||
|
||||
### Event-Driven Completion
|
||||
|
||||
When an issue closes, check if it's tracked by a convoy:
|
||||
|
||||
```
|
||||
Issue closes
|
||||
↓
|
||||
Is issue tracked by convoy? ──(no)──► done
|
||||
│
|
||||
(yes)
|
||||
↓
|
||||
Run gt convoy check <convoy-id>
|
||||
↓
|
||||
All tracked issues closed? ──(no)──► done
|
||||
│
|
||||
(yes)
|
||||
↓
|
||||
Close convoy, send notifications
|
||||
```
|
||||
|
||||
**Implementation options:**
|
||||
1. Daemon hook on `bd update --status=closed`
|
||||
2. Refinery step after successful merge
|
||||
3. Witness step after verifying polecat completion
|
||||
|
||||
Option 1 is most reliable - catches all closes regardless of source.
|
||||
|
||||
### Redundant Observers
|
||||
|
||||
Per PRIMING.md: "Redundant Monitoring Is Resilience."
|
||||
|
||||
Three places should check convoy completion:
|
||||
|
||||
| Observer | When | Scope |
|
||||
|----------|------|-------|
|
||||
| **Daemon** | On any issue close | All convoys |
|
||||
| **Witness** | After verifying polecat work | Rig's convoy work |
|
||||
| **Deacon** | Periodic patrol | All convoys (backup) |
|
||||
|
||||
Any observer noticing completion triggers close. Idempotent - closing
|
||||
an already-closed convoy is a no-op.
|
||||
|
||||
### Manual Close Command
|
||||
|
||||
**Desire path**: `gt convoy close` is expected but missing.
|
||||
|
||||
```bash
|
||||
# Close a completed convoy
|
||||
gt convoy close hq-cv-abc
|
||||
|
||||
# Force-close an abandoned convoy
|
||||
gt convoy close hq-cv-xyz --reason="work done differently"
|
||||
|
||||
# Close with explicit notification
|
||||
gt convoy close hq-cv-abc --notify mayor/
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- Abandoned convoys no longer relevant
|
||||
- Work completed outside tracked path
|
||||
- Force-closing stuck convoys
|
||||
|
||||
### Convoy Owner/Requester
|
||||
|
||||
Track who requested the convoy for targeted notifications:
|
||||
|
||||
```bash
|
||||
gt convoy create "Feature X" gt-abc --owner mayor/ --notify overseer
|
||||
```
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `owner` | Who requested (gets completion notification) |
|
||||
| `notify` | Additional subscribers |
|
||||
|
||||
If `owner` not specified, defaults to creator (from `created_by`).
|
||||
|
||||
### Convoy States
|
||||
|
||||
```
|
||||
OPEN ──(all issues close)──► CLOSED
|
||||
│ │
|
||||
│ ▼
|
||||
│ (add issues)
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
(auto-reopens)
|
||||
```
|
||||
|
||||
Adding issues to closed convoy reopens automatically.
|
||||
|
||||
**New state for abandonment:**
|
||||
|
||||
```
|
||||
OPEN ──► CLOSED (completed)
|
||||
│
|
||||
└────► ABANDONED (force-closed without completion)
|
||||
```
|
||||
|
||||
### Timeout/SLA (Future)
|
||||
|
||||
Optional `due_at` field for convoy deadline:
|
||||
|
||||
```bash
|
||||
gt convoy create "Sprint work" gt-abc --due="2026-01-15"
|
||||
```
|
||||
|
||||
Overdue convoys surface in `gt convoy stranded --overdue`.
|
||||
|
||||
## Commands
|
||||
|
||||
### New: `gt convoy close`
|
||||
|
||||
```bash
|
||||
gt convoy close <convoy-id> [--reason=<reason>] [--notify=<agent>]
|
||||
```
|
||||
|
||||
- Closes convoy regardless of tracked issue status
|
||||
- Sets `close_reason` field
|
||||
- Sends notification to owner and subscribers
|
||||
- Idempotent - closing closed convoy is no-op
|
||||
|
||||
### Enhanced: `gt convoy check`
|
||||
|
||||
```bash
|
||||
# Check all convoys (current behavior)
|
||||
gt convoy check
|
||||
|
||||
# Check specific convoy (new)
|
||||
gt convoy check <convoy-id>
|
||||
|
||||
# Dry-run mode
|
||||
gt convoy check --dry-run
|
||||
```
|
||||
|
||||
### New: `gt convoy reopen`
|
||||
|
||||
```bash
|
||||
gt convoy reopen <convoy-id>
|
||||
```
|
||||
|
||||
Explicit reopen for clarity (currently implicit via add).
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **P0: `gt convoy close`** - Desire path, escape hatch
|
||||
2. **P0: Event-driven check** - Daemon hook on issue close
|
||||
3. **P1: Redundant observers** - Witness/Refinery integration
|
||||
4. **P2: Owner field** - Targeted notifications
|
||||
5. **P3: Timeout/SLA** - Deadline tracking
|
||||
|
||||
## Related
|
||||
|
||||
- [convoy.md](../concepts/convoy.md) - Convoy concept and usage
|
||||
- [watchdog-chain.md](watchdog-chain.md) - Deacon patrol system
|
||||
- [mail-protocol.md](mail-protocol.md) - Notification delivery
|
||||
248
docs/formula-resolution.md
Normal file
248
docs/formula-resolution.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Formula Resolution Architecture
|
||||
|
||||
> Where formulas live, how they're found, and how they'll scale to Mol Mall
|
||||
|
||||
## The Problem
|
||||
|
||||
Formulas currently exist in multiple locations with no clear precedence:
|
||||
- `.beads/formulas/` (source of truth for a project)
|
||||
- `internal/formula/formulas/` (embedded copy for `go install`)
|
||||
- Crew directories have their own `.beads/formulas/` (diverging copies)
|
||||
|
||||
When an agent runs `bd cook mol-polecat-work`, which version do they get?
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Predictable resolution** - Clear precedence rules
|
||||
2. **Local customization** - Override system defaults without forking
|
||||
3. **Project-specific formulas** - Committed workflows for collaborators
|
||||
4. **Mol Mall ready** - Architecture supports remote formula installation
|
||||
5. **Federation ready** - Formulas are shareable across towns via HOP (Highway Operations Protocol)
|
||||
|
||||
## Three-Tier Resolution
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FORMULA RESOLUTION ORDER │
|
||||
│ (most specific wins) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
TIER 1: PROJECT (rig-level)
|
||||
Location: <project>/.beads/formulas/
|
||||
Source: Committed to project repo
|
||||
Use case: Project-specific workflows (deploy, test, release)
|
||||
Example: ~/gt/gastown/.beads/formulas/mol-gastown-release.formula.toml
|
||||
|
||||
TIER 2: TOWN (user-level)
|
||||
Location: ~/gt/.beads/formulas/
|
||||
Source: Mol Mall installs, user customizations
|
||||
Use case: Cross-project workflows, personal preferences
|
||||
Example: ~/gt/.beads/formulas/mol-polecat-work.formula.toml (customized)
|
||||
|
||||
TIER 3: SYSTEM (embedded)
|
||||
Location: Compiled into gt binary
|
||||
Source: gastown/mayor/rig/.beads/formulas/ at build time
|
||||
Use case: Defaults, blessed patterns, fallback
|
||||
Example: mol-polecat-work.formula.toml (factory default)
|
||||
```
|
||||
|
||||
### Resolution Algorithm
|
||||
|
||||
```go
|
||||
func ResolveFormula(name string, cwd string) (Formula, Tier, error) {
|
||||
// Tier 1: Project-level (walk up from cwd to find .beads/formulas/)
|
||||
if projectDir := findProjectRoot(cwd); projectDir != "" {
|
||||
path := filepath.Join(projectDir, ".beads", "formulas", name+".formula.toml")
|
||||
if f, err := loadFormula(path); err == nil {
|
||||
return f, TierProject, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Town-level
|
||||
townDir := getTownRoot() // ~/gt or $GT_HOME
|
||||
path := filepath.Join(townDir, ".beads", "formulas", name+".formula.toml")
|
||||
if f, err := loadFormula(path); err == nil {
|
||||
return f, TierTown, nil
|
||||
}
|
||||
|
||||
// Tier 3: Embedded (system)
|
||||
if f, err := loadEmbeddedFormula(name); err == nil {
|
||||
return f, TierSystem, nil
|
||||
}
|
||||
|
||||
return nil, 0, ErrFormulaNotFound
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Order
|
||||
|
||||
**Project wins** because:
|
||||
- Project maintainers know their workflows best
|
||||
- Collaborators get consistent behavior via git
|
||||
- CI/CD uses the same formulas as developers
|
||||
|
||||
**Town is middle** because:
|
||||
- User customizations override system defaults
|
||||
- Mol Mall installs don't require project changes
|
||||
- Cross-project consistency for the user
|
||||
|
||||
**System is fallback** because:
|
||||
- Always available (compiled in)
|
||||
- Factory reset target
|
||||
- The "blessed" versions
|
||||
|
||||
## Formula Identity
|
||||
|
||||
### Current Format
|
||||
|
||||
```toml
|
||||
formula = "mol-polecat-work"
|
||||
version = 4
|
||||
description = "..."
|
||||
```
|
||||
|
||||
### Extended Format (Mol Mall Ready)
|
||||
|
||||
```toml
|
||||
[formula]
|
||||
name = "mol-polecat-work"
|
||||
version = "4.0.0" # Semver
|
||||
author = "steve@gastown.io" # Author identity
|
||||
license = "MIT"
|
||||
repository = "https://github.com/steveyegge/gastown"
|
||||
|
||||
[formula.registry]
|
||||
uri = "hop://molmall.gastown.io/formulas/mol-polecat-work@4.0.0"
|
||||
checksum = "sha256:abc123..." # Integrity verification
|
||||
signed_by = "steve@gastown.io" # Optional signing
|
||||
|
||||
[formula.capabilities]
|
||||
# What capabilities does this formula exercise? Used for agent routing.
|
||||
primary = ["go", "testing", "code-review"]
|
||||
secondary = ["git", "ci-cd"]
|
||||
```
|
||||
|
||||
### Version Resolution
|
||||
|
||||
When multiple versions exist:
|
||||
|
||||
```bash
|
||||
bd cook mol-polecat-work # Resolves per tier order
|
||||
bd cook mol-polecat-work@4 # Specific major version
|
||||
bd cook mol-polecat-work@4.0.0 # Exact version
|
||||
bd cook mol-polecat-work@latest # Explicit latest
|
||||
```
|
||||
|
||||
## Crew Directory Problem
|
||||
|
||||
### Current State
|
||||
|
||||
Crew directories (`gastown/crew/max/`) are sparse checkouts of gastown. They have:
|
||||
- Their own `.beads/formulas/` (from the checkout)
|
||||
- These can diverge from `mayor/rig/.beads/formulas/`
|
||||
|
||||
### The Fix
|
||||
|
||||
Crew should NOT have their own formula copies. Options:
|
||||
|
||||
**Option A: Symlink/Redirect**
|
||||
```bash
|
||||
# crew/max/.beads/formulas -> ../../mayor/rig/.beads/formulas
|
||||
```
|
||||
All crew share the rig's formulas.
|
||||
|
||||
**Option B: Provision on Demand**
|
||||
Crew directories don't have `.beads/formulas/`. Resolution falls through to:
|
||||
1. Town-level (~/gt/.beads/formulas/)
|
||||
2. System (embedded)
|
||||
|
||||
**Option C: Sparse Checkout Exclusion**
|
||||
Exclude `.beads/formulas/` from crew sparse checkouts entirely.
|
||||
|
||||
**Recommendation: Option B** - Crew shouldn't need project-level formulas. They work on the project, they don't define its workflows.
|
||||
|
||||
## Commands
|
||||
|
||||
### Existing
|
||||
|
||||
```bash
|
||||
bd formula list # Available formulas (should show tier)
|
||||
bd formula show <name> # Formula details
|
||||
bd cook <formula> # Formula → Proto
|
||||
```
|
||||
|
||||
### Enhanced
|
||||
|
||||
```bash
|
||||
# List with tier information
|
||||
bd formula list
|
||||
mol-polecat-work v4 [project]
|
||||
mol-polecat-code-review v1 [town]
|
||||
mol-witness-patrol v2 [system]
|
||||
|
||||
# Show resolution path
|
||||
bd formula show mol-polecat-work --resolve
|
||||
Resolving: mol-polecat-work
|
||||
✓ Found at: ~/gt/gastown/.beads/formulas/mol-polecat-work.formula.toml
|
||||
Tier: project
|
||||
Version: 4
|
||||
|
||||
Resolution path checked:
|
||||
1. [project] ~/gt/gastown/.beads/formulas/ ← FOUND
|
||||
2. [town] ~/gt/.beads/formulas/
|
||||
3. [system] <embedded>
|
||||
|
||||
# Override tier for testing
|
||||
bd cook mol-polecat-work --tier=system # Force embedded version
|
||||
bd cook mol-polecat-work --tier=town # Force town version
|
||||
```
|
||||
|
||||
### Future (Mol Mall)
|
||||
|
||||
```bash
|
||||
# Install from Mol Mall
|
||||
gt formula install mol-code-review-strict
|
||||
gt formula install mol-code-review-strict@2.0.0
|
||||
gt formula install hop://acme.corp/formulas/mol-deploy
|
||||
|
||||
# Manage installed formulas
|
||||
gt formula list --installed # What's in town-level
|
||||
gt formula upgrade mol-polecat-work # Update to latest
|
||||
gt formula pin mol-polecat-work@4.0.0 # Lock version
|
||||
gt formula uninstall mol-code-review-strict
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Resolution Order (Now)
|
||||
|
||||
1. Implement three-tier resolution in `bd cook`
|
||||
2. Add `--resolve` flag to show resolution path
|
||||
3. Update `bd formula list` to show tiers
|
||||
4. Fix crew directories (Option B)
|
||||
|
||||
### Phase 2: Town-Level Formulas
|
||||
|
||||
1. Establish `~/gt/.beads/formulas/` as town formula location
|
||||
2. Add `gt formula` commands for managing town formulas
|
||||
3. Support manual installation (copy file, track in `.installed.json`)
|
||||
|
||||
### Phase 3: Mol Mall Integration
|
||||
|
||||
1. Define registry API (see mol-mall-design.md)
|
||||
2. Implement `gt formula install` from remote
|
||||
3. Add version pinning and upgrade flows
|
||||
4. Add integrity verification (checksums, optional signing)
|
||||
|
||||
### Phase 4: Federation (HOP)
|
||||
|
||||
1. Add capability tags to formula schema
|
||||
2. Track formula execution for agent accountability
|
||||
3. Enable federation (cross-town formula sharing via Highway Operations Protocol)
|
||||
4. Author attribution and validation records
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Mol Mall Design](mol-mall-design.md) - Registry architecture
|
||||
- [molecules.md](molecules.md) - Formula → Proto → Mol lifecycle
|
||||
- [understanding-gas-town.md](../../../docs/understanding-gas-town.md) - Gas Town architecture
|
||||
476
docs/mol-mall-design.md
Normal file
476
docs/mol-mall-design.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Mol Mall Design
|
||||
|
||||
> A marketplace for Gas Town formulas
|
||||
|
||||
## Vision
|
||||
|
||||
**Mol Mall** is a registry for sharing formulas across Gas Town installations. Think npm for molecules, or Terraform Registry for workflows.
|
||||
|
||||
```
|
||||
"Cook a formula, sling it to a polecat, the witness watches, refinery merges."
|
||||
|
||||
What if you could browse a mall of formulas, install one, and immediately
|
||||
have your polecats executing world-class workflows?
|
||||
```
|
||||
|
||||
### The Network Effect
|
||||
|
||||
A well-designed formula for "code review" or "security audit" or "deploy to K8s" can spread across thousands of Gas Town installations. Each adoption means:
|
||||
- More agents executing proven workflows
|
||||
- More structured, trackable work output
|
||||
- Better capability routing (agents with track records on a formula get similar work)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Registry Types
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MOL MALL REGISTRIES │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
PUBLIC REGISTRY (molmall.gastown.io)
|
||||
├── Community formulas (MIT licensed)
|
||||
├── Official Gas Town formulas (blessed)
|
||||
├── Verified publisher formulas
|
||||
└── Open contribution model
|
||||
|
||||
PRIVATE REGISTRY (self-hosted)
|
||||
├── Organization-specific formulas
|
||||
├── Proprietary workflows
|
||||
├── Internal deployment patterns
|
||||
└── Enterprise compliance formulas
|
||||
|
||||
FEDERATED REGISTRY (HOP future)
|
||||
├── Cross-organization discovery
|
||||
├── Skill-based search
|
||||
└── Attribution chain tracking
|
||||
└── hop:// URI resolution
|
||||
```
|
||||
|
||||
### URI Scheme
|
||||
|
||||
```
|
||||
hop://molmall.gastown.io/formulas/mol-polecat-work@4.0.0
|
||||
└──────────────────┘ └──────────────┘ └───┘
|
||||
registry host formula name version
|
||||
|
||||
# Short forms
|
||||
mol-polecat-work # Default registry, latest version
|
||||
mol-polecat-work@4 # Major version
|
||||
mol-polecat-work@4.0.0 # Exact version
|
||||
@acme/mol-deploy # Scoped to publisher
|
||||
hop://acme.corp/formulas/mol-deploy # Full HOP URI
|
||||
```
|
||||
|
||||
### Registry API
|
||||
|
||||
```yaml
|
||||
# OpenAPI-style specification
|
||||
|
||||
GET /formulas
|
||||
# List all formulas
|
||||
Query:
|
||||
- q: string # Search query
|
||||
- capabilities: string[] # Filter by capability tags
|
||||
- author: string # Filter by author
|
||||
- limit: int
|
||||
- offset: int
|
||||
Response:
|
||||
formulas:
|
||||
- name: mol-polecat-work
|
||||
version: 4.0.0
|
||||
description: "Full polecat work lifecycle..."
|
||||
author: steve@gastown.io
|
||||
downloads: 12543
|
||||
capabilities: [go, testing, code-review]
|
||||
|
||||
GET /formulas/{name}
|
||||
# Get formula metadata
|
||||
Response:
|
||||
name: mol-polecat-work
|
||||
versions: [4.0.0, 3.2.1, 3.2.0, ...]
|
||||
latest: 4.0.0
|
||||
author: steve@gastown.io
|
||||
repository: https://github.com/steveyegge/gastown
|
||||
license: MIT
|
||||
capabilities:
|
||||
primary: [go, testing]
|
||||
secondary: [git, code-review]
|
||||
stats:
|
||||
downloads: 12543
|
||||
stars: 234
|
||||
used_by: 89 # towns using this formula
|
||||
|
||||
GET /formulas/{name}/{version}
|
||||
# Get specific version
|
||||
Response:
|
||||
name: mol-polecat-work
|
||||
version: 4.0.0
|
||||
checksum: sha256:abc123...
|
||||
signature: <optional PGP signature>
|
||||
content: <base64 or URL to .formula.toml>
|
||||
changelog: "Added self-cleaning model..."
|
||||
published_at: 2026-01-10T00:00:00Z
|
||||
|
||||
POST /formulas
|
||||
# Publish formula (authenticated)
|
||||
Body:
|
||||
name: mol-my-workflow
|
||||
version: 1.0.0
|
||||
content: <formula TOML>
|
||||
changelog: "Initial release"
|
||||
Auth: Bearer token (linked to HOP identity)
|
||||
|
||||
GET /formulas/{name}/{version}/download
|
||||
# Download formula content
|
||||
Response: raw .formula.toml content
|
||||
```
|
||||
|
||||
## Formula Package Format
|
||||
|
||||
### Simple Case: Single File
|
||||
|
||||
Most formulas are single `.formula.toml` files:
|
||||
|
||||
```bash
|
||||
gt formula install mol-polecat-code-review
|
||||
# Downloads mol-polecat-code-review.formula.toml to ~/gt/.beads/formulas/
|
||||
```
|
||||
|
||||
### Complex Case: Formula Bundle
|
||||
|
||||
Some formulas need supporting files (scripts, templates, configs):
|
||||
|
||||
```
|
||||
mol-deploy-k8s.formula.bundle/
|
||||
├── formula.toml # Main formula
|
||||
├── templates/
|
||||
│ ├── deployment.yaml.tmpl
|
||||
│ └── service.yaml.tmpl
|
||||
├── scripts/
|
||||
│ └── healthcheck.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Bundle format:
|
||||
```bash
|
||||
# Bundles are tarballs
|
||||
mol-deploy-k8s-1.0.0.bundle.tar.gz
|
||||
```
|
||||
|
||||
Installation:
|
||||
```bash
|
||||
gt formula install mol-deploy-k8s
|
||||
# Extracts to ~/gt/.beads/formulas/mol-deploy-k8s/
|
||||
# formula.toml is at mol-deploy-k8s/formula.toml
|
||||
```
|
||||
|
||||
## Installation Flow
|
||||
|
||||
### Basic Install
|
||||
|
||||
```bash
|
||||
$ gt formula install mol-polecat-code-review
|
||||
|
||||
Resolving mol-polecat-code-review...
|
||||
Registry: molmall.gastown.io
|
||||
Version: 1.2.0 (latest)
|
||||
Author: steve@gastown.io
|
||||
Skills: code-review, security
|
||||
|
||||
Downloading... ████████████████████ 100%
|
||||
Verifying checksum... ✓
|
||||
|
||||
Installed to: ~/gt/.beads/formulas/mol-polecat-code-review.formula.toml
|
||||
```
|
||||
|
||||
### Version Pinning
|
||||
|
||||
```bash
|
||||
$ gt formula install mol-polecat-work@4.0.0
|
||||
|
||||
Installing mol-polecat-work@4.0.0 (pinned)...
|
||||
✓ Installed
|
||||
|
||||
$ gt formula list --installed
|
||||
mol-polecat-work 4.0.0 [pinned]
|
||||
mol-polecat-code-review 1.2.0 [latest]
|
||||
```
|
||||
|
||||
### Upgrade Flow
|
||||
|
||||
```bash
|
||||
$ gt formula upgrade mol-polecat-code-review
|
||||
|
||||
Checking for updates...
|
||||
Current: 1.2.0
|
||||
Latest: 1.3.0
|
||||
|
||||
Changelog for 1.3.0:
|
||||
- Added security focus option
|
||||
- Improved test coverage step
|
||||
|
||||
Upgrade? [y/N] y
|
||||
|
||||
Downloading... ✓
|
||||
Installed: mol-polecat-code-review@1.3.0
|
||||
```
|
||||
|
||||
### Lock File
|
||||
|
||||
```json
|
||||
// ~/gt/.beads/formulas/.lock.json
|
||||
{
|
||||
"version": 1,
|
||||
"formulas": {
|
||||
"mol-polecat-work": {
|
||||
"version": "4.0.0",
|
||||
"pinned": true,
|
||||
"checksum": "sha256:abc123...",
|
||||
"installed_at": "2026-01-10T00:00:00Z",
|
||||
"source": "hop://molmall.gastown.io/formulas/mol-polecat-work@4.0.0"
|
||||
},
|
||||
"mol-polecat-code-review": {
|
||||
"version": "1.3.0",
|
||||
"pinned": false,
|
||||
"checksum": "sha256:def456...",
|
||||
"installed_at": "2026-01-10T12:00:00Z",
|
||||
"source": "hop://molmall.gastown.io/formulas/mol-polecat-code-review@1.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Publishing Flow
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
```bash
|
||||
$ gt formula publish --init
|
||||
|
||||
Setting up Mol Mall publishing...
|
||||
|
||||
1. Create account at https://molmall.gastown.io/signup
|
||||
2. Generate API token at https://molmall.gastown.io/settings/tokens
|
||||
3. Run: gt formula login
|
||||
|
||||
$ gt formula login
|
||||
Token: ********
|
||||
Logged in as: steve@gastown.io
|
||||
```
|
||||
|
||||
### Publishing
|
||||
|
||||
```bash
|
||||
$ gt formula publish mol-polecat-work
|
||||
|
||||
Publishing mol-polecat-work...
|
||||
|
||||
Pre-flight checks:
|
||||
✓ formula.toml is valid
|
||||
✓ Version 4.0.0 not yet published
|
||||
✓ Required fields present (name, version, description)
|
||||
✓ Skills declared
|
||||
|
||||
Publish to molmall.gastown.io? [y/N] y
|
||||
|
||||
Uploading... ✓
|
||||
Published: hop://molmall.gastown.io/formulas/mol-polecat-work@4.0.0
|
||||
|
||||
View at: https://molmall.gastown.io/formulas/mol-polecat-work
|
||||
```
|
||||
|
||||
### Verification Levels
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FORMULA TRUST LEVELS │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
UNVERIFIED (default)
|
||||
Anyone can publish
|
||||
Basic validation only
|
||||
Displayed with ⚠️ warning
|
||||
|
||||
VERIFIED PUBLISHER
|
||||
Publisher identity confirmed
|
||||
Displayed with ✓ checkmark
|
||||
Higher search ranking
|
||||
|
||||
OFFICIAL
|
||||
Maintained by Gas Town team
|
||||
Displayed with 🏛️ badge
|
||||
Included in embedded defaults
|
||||
|
||||
AUDITED
|
||||
Security review completed
|
||||
Displayed with 🔒 badge
|
||||
Required for enterprise registries
|
||||
```
|
||||
|
||||
## Capability Tagging
|
||||
|
||||
### Formula Capability Declaration
|
||||
|
||||
```toml
|
||||
[formula.capabilities]
|
||||
# What capabilities does this formula exercise? Used for agent routing.
|
||||
primary = ["go", "testing", "code-review"]
|
||||
secondary = ["git", "ci-cd"]
|
||||
|
||||
# Capability weights (optional, for fine-grained routing)
|
||||
[formula.capabilities.weights]
|
||||
go = 0.3 # 30% of formula work is Go
|
||||
testing = 0.4 # 40% is testing
|
||||
code-review = 0.3 # 30% is code review
|
||||
```
|
||||
|
||||
### Capability-Based Search
|
||||
|
||||
```bash
|
||||
$ gt formula search --capabilities="security,go"
|
||||
|
||||
Formulas matching capabilities: security, go
|
||||
|
||||
mol-security-audit v2.1.0 ⭐ 4.8 📥 8,234
|
||||
Capabilities: security, go, code-review
|
||||
"Comprehensive security audit workflow"
|
||||
|
||||
mol-dependency-scan v1.0.0 ⭐ 4.2 📥 3,102
|
||||
Capabilities: security, go, supply-chain
|
||||
"Scan Go dependencies for vulnerabilities"
|
||||
```
|
||||
|
||||
### Agent Accountability
|
||||
|
||||
When a polecat completes a formula, the execution is tracked:
|
||||
|
||||
```
|
||||
Polecat: beads/amber
|
||||
Formula: mol-polecat-code-review@1.3.0
|
||||
Completed: 2026-01-10T15:30:00Z
|
||||
Capabilities exercised:
|
||||
- code-review (primary)
|
||||
- security (secondary)
|
||||
- go (secondary)
|
||||
```
|
||||
|
||||
This execution record enables:
|
||||
1. **Routing** - Agents with successful track records get similar work
|
||||
2. **Debugging** - Trace which agent did what, when
|
||||
3. **Quality metrics** - Track success rates by agent and formula
|
||||
|
||||
## Private Registries
|
||||
|
||||
### Enterprise Deployment
|
||||
|
||||
```yaml
|
||||
# ~/.gtconfig.yaml
|
||||
registries:
|
||||
- name: acme
|
||||
url: https://molmall.acme.corp
|
||||
auth: token
|
||||
priority: 1 # Check first
|
||||
|
||||
- name: public
|
||||
url: https://molmall.gastown.io
|
||||
auth: none
|
||||
priority: 2 # Fallback
|
||||
```
|
||||
|
||||
### Self-Hosted Registry
|
||||
|
||||
```bash
|
||||
# Docker deployment
|
||||
docker run -d \
|
||||
-p 8080:8080 \
|
||||
-v /data/formulas:/formulas \
|
||||
-e AUTH_PROVIDER=oidc \
|
||||
gastown/molmall-registry:latest
|
||||
|
||||
# Configuration
|
||||
MOLMALL_STORAGE=s3://bucket/formulas
|
||||
MOLMALL_AUTH=oidc
|
||||
MOLMALL_OIDC_ISSUER=https://auth.acme.corp
|
||||
```
|
||||
|
||||
## Federation
|
||||
|
||||
Federation enables formula sharing across organizations using the Highway Operations Protocol (HOP).
|
||||
|
||||
### Cross-Registry Discovery
|
||||
|
||||
```bash
|
||||
$ gt formula search "deploy kubernetes" --federated
|
||||
|
||||
Searching across federated registries...
|
||||
|
||||
molmall.gastown.io:
|
||||
mol-deploy-k8s v3.0.0 🏛️ Official
|
||||
|
||||
molmall.acme.corp:
|
||||
@acme/mol-deploy-k8s v2.1.0 ✓ Verified
|
||||
|
||||
molmall.bigco.io:
|
||||
@bigco/k8s-workflow v1.0.0 ⚠️ Unverified
|
||||
```
|
||||
|
||||
### HOP URI Resolution
|
||||
|
||||
The `hop://` URI scheme provides cross-registry entity references:
|
||||
|
||||
```bash
|
||||
# Full HOP URI
|
||||
gt formula install hop://molmall.acme.corp/formulas/@acme/mol-deploy@2.1.0
|
||||
|
||||
# Resolution via HOP (Highway Operations Protocol)
|
||||
1. Parse hop:// URI
|
||||
2. Resolve registry endpoint (DNS/HOP discovery)
|
||||
3. Authenticate (if required)
|
||||
4. Download formula
|
||||
5. Verify checksum/signature
|
||||
6. Install to town-level
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Local Commands (Now)
|
||||
|
||||
- `gt formula list` with tier display
|
||||
- `gt formula show --resolve`
|
||||
- Formula resolution order (project → town → system)
|
||||
|
||||
### Phase 2: Manual Sharing
|
||||
|
||||
- Formula export/import
|
||||
- `gt formula export mol-polecat-work > mol-polecat-work.formula.toml`
|
||||
- `gt formula import < mol-polecat-work.formula.toml`
|
||||
- Lock file format
|
||||
|
||||
### Phase 3: Public Registry
|
||||
|
||||
- molmall.gastown.io launch
|
||||
- `gt formula install` from registry
|
||||
- `gt formula publish` flow
|
||||
- Basic search and browse
|
||||
|
||||
### Phase 4: Enterprise Features
|
||||
|
||||
- Private registry support
|
||||
- Authentication integration
|
||||
- Verification levels
|
||||
- Audit logging
|
||||
|
||||
### Phase 5: Federation (HOP)
|
||||
|
||||
- Capability tags in schema
|
||||
- Federation protocol (Highway Operations Protocol)
|
||||
- Cross-registry search
|
||||
- Agent execution tracking for accountability
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Formula Resolution](formula-resolution.md) - Local resolution order
|
||||
- [molecules.md](molecules.md) - Formula lifecycle (cook, pour, squash)
|
||||
- [understanding-gas-town.md](../../../docs/understanding-gas-town.md) - Gas Town architecture
|
||||
189
internal/agent/state_test.go
Normal file
189
internal/agent/state_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStateConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state State
|
||||
value string
|
||||
}{
|
||||
{"StateStopped", StateStopped, "stopped"},
|
||||
{"StateRunning", StateRunning, "running"},
|
||||
{"StatePaused", StatePaused, "paused"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.state) != tt.value {
|
||||
t.Errorf("State constant = %q, want %q", tt.state, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_StateFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||
return &TestState{Value: "default"}
|
||||
})
|
||||
|
||||
expectedPath := filepath.Join(tmpDir, ".runtime", "test-state.json")
|
||||
if manager.StateFile() != expectedPath {
|
||||
t.Errorf("StateFile() = %q, want %q", manager.StateFile(), expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_Load_NoFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[TestState](tmpDir, "nonexistent.json", func() *TestState {
|
||||
return &TestState{Value: "default"}
|
||||
})
|
||||
|
||||
state, err := manager.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if state.Value != "default" {
|
||||
t.Errorf("Load() value = %q, want %q", state.Value, "default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_Load_Save_Load(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||
return &TestState{Value: "default"}
|
||||
})
|
||||
|
||||
// Save initial state
|
||||
state := &TestState{Value: "test-value", Count: 42}
|
||||
if err := manager.Save(state); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Load it back
|
||||
loaded, err := manager.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if loaded.Value != state.Value {
|
||||
t.Errorf("Load() value = %q, want %q", loaded.Value, state.Value)
|
||||
}
|
||||
if loaded.Count != state.Count {
|
||||
t.Errorf("Load() count = %d, want %d", loaded.Count, state.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_Load_CreatesDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||
return &TestState{Value: "default"}
|
||||
})
|
||||
|
||||
// Save should create .runtime directory
|
||||
state := &TestState{Value: "test"}
|
||||
if err := manager.Save(state); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify directory was created
|
||||
runtimeDir := filepath.Join(tmpDir, ".runtime")
|
||||
if _, err := os.Stat(runtimeDir); err != nil {
|
||||
t.Errorf("Save() should create .runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_Load_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||
return &TestState{Value: "default"}
|
||||
})
|
||||
|
||||
// Write invalid JSON
|
||||
statePath := manager.StateFile()
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil {
|
||||
t.Fatalf("Failed to create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte("invalid json"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
|
||||
_, err := manager.Load()
|
||||
if err == nil {
|
||||
t.Error("Load() with invalid JSON should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
state State
|
||||
want string
|
||||
}{
|
||||
{StateStopped, "stopped"},
|
||||
{StateRunning, "running"},
|
||||
{StatePaused, "paused"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.state) != tt.want {
|
||||
t.Errorf("State(%q) = %q, want %q", tt.state, string(tt.state), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_GenericType(t *testing.T) {
|
||||
// Test that StateManager works with different types
|
||||
|
||||
type ComplexState struct {
|
||||
Name string `json:"name"`
|
||||
Values []int `json:"values"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Nested struct {
|
||||
X int `json:"x"`
|
||||
} `json:"nested"`
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
manager := NewStateManager[ComplexState](tmpDir, "complex.json", func() *ComplexState {
|
||||
return &ComplexState{Name: "default", Values: []int{}}
|
||||
})
|
||||
|
||||
original := &ComplexState{
|
||||
Name: "test",
|
||||
Values: []int{1, 2, 3},
|
||||
Enabled: true,
|
||||
}
|
||||
original.Nested.X = 42
|
||||
|
||||
if err := manager.Save(original); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := manager.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if loaded.Name != original.Name {
|
||||
t.Errorf("Name = %q, want %q", loaded.Name, original.Name)
|
||||
}
|
||||
if len(loaded.Values) != len(original.Values) {
|
||||
t.Errorf("Values length = %d, want %d", len(loaded.Values), len(original.Values))
|
||||
}
|
||||
if loaded.Enabled != original.Enabled {
|
||||
t.Errorf("Enabled = %v, want %v", loaded.Enabled, original.Enabled)
|
||||
}
|
||||
if loaded.Nested.X != original.Nested.X {
|
||||
t.Errorf("Nested.X = %d, want %d", loaded.Nested.X, original.Nested.X)
|
||||
}
|
||||
}
|
||||
|
||||
// TestState is a simple type for testing
|
||||
type TestState struct {
|
||||
Value string `json:"value"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
@@ -113,6 +113,7 @@ type SyncStatus struct {
|
||||
type Beads struct {
|
||||
workDir string
|
||||
beadsDir string // Optional BEADS_DIR override for cross-database access
|
||||
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
||||
}
|
||||
|
||||
// New creates a new Beads wrapper for the given directory.
|
||||
@@ -120,19 +121,43 @@ func New(workDir string) *Beads {
|
||||
return &Beads{workDir: workDir}
|
||||
}
|
||||
|
||||
// NewIsolated creates a Beads wrapper for test isolation.
|
||||
// This suppresses inherited beads env vars (BD_ACTOR, BEADS_DB) to prevent
|
||||
// tests from accidentally routing to production databases.
|
||||
func NewIsolated(workDir string) *Beads {
|
||||
return &Beads{workDir: workDir, isolated: true}
|
||||
}
|
||||
|
||||
// NewWithBeadsDir creates a Beads wrapper with an explicit BEADS_DIR.
|
||||
// This is needed when running from a polecat worktree but accessing town-level beads.
|
||||
func NewWithBeadsDir(workDir, beadsDir string) *Beads {
|
||||
return &Beads{workDir: workDir, beadsDir: beadsDir}
|
||||
}
|
||||
|
||||
// getActor returns the BD_ACTOR value for this context.
|
||||
// Returns empty string when in isolated mode (tests) to prevent
|
||||
// inherited actors from routing to production databases.
|
||||
func (b *Beads) getActor() string {
|
||||
if b.isolated {
|
||||
return ""
|
||||
}
|
||||
return os.Getenv("BD_ACTOR")
|
||||
}
|
||||
|
||||
// Init initializes a new beads database in the working directory.
|
||||
// This uses the same environment isolation as other commands.
|
||||
func (b *Beads) Init(prefix string) error {
|
||||
_, err := b.run("init", "--prefix", prefix, "--quiet")
|
||||
return err
|
||||
}
|
||||
|
||||
// run executes a bd command and returns stdout.
|
||||
func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
// Use --no-daemon for faster read operations (avoids daemon IPC overhead)
|
||||
// The daemon is primarily useful for write coalescing, not reads
|
||||
fullArgs := append([]string{"--no-daemon"}, args...)
|
||||
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = b.workDir
|
||||
// The daemon is primarily useful for write coalescing, not reads.
|
||||
// Use --allow-stale to prevent failures when db is out of sync with JSONL
|
||||
// (e.g., after daemon is killed during shutdown before syncing).
|
||||
fullArgs := append([]string{"--no-daemon", "--allow-stale"}, args...)
|
||||
|
||||
// Always explicitly set BEADS_DIR to prevent inherited env vars from
|
||||
// causing prefix mismatches. Use explicit beadsDir if set, otherwise
|
||||
@@ -141,7 +166,28 @@ func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
if beadsDir == "" {
|
||||
beadsDir = ResolveBeadsDir(b.workDir)
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
// In isolated mode, use --db flag to force specific database path
|
||||
// This bypasses bd's routing logic that can redirect to .beads-planning
|
||||
// Skip --db for init command since it creates the database
|
||||
isInit := len(args) > 0 && args[0] == "init"
|
||||
if b.isolated && !isInit {
|
||||
beadsDB := filepath.Join(beadsDir, "beads.db")
|
||||
fullArgs = append([]string{"--db", beadsDB}, fullArgs...)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = b.workDir
|
||||
|
||||
// Build environment: filter beads env vars when in isolated mode (tests)
|
||||
// to prevent routing to production databases.
|
||||
var env []string
|
||||
if b.isolated {
|
||||
env = filterBeadsEnv(os.Environ())
|
||||
} else {
|
||||
env = os.Environ()
|
||||
}
|
||||
cmd.Env = append(env, "BEADS_DIR="+beadsDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -194,6 +240,27 @@ func (b *Beads) wrapError(err error, stderr string, args []string) error {
|
||||
return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
// filterBeadsEnv removes beads-related environment variables from the given
|
||||
// environment slice. This ensures test isolation by preventing inherited
|
||||
// BD_ACTOR, BEADS_DB, GT_ROOT, HOME etc. from routing commands to production databases.
|
||||
func filterBeadsEnv(environ []string) []string {
|
||||
filtered := make([]string, 0, len(environ))
|
||||
for _, env := range environ {
|
||||
// Skip beads-related env vars that could interfere with test isolation
|
||||
// BD_ACTOR, BEADS_* - direct beads config
|
||||
// GT_ROOT - causes bd to find global routes file
|
||||
// HOME - causes bd to find ~/.beads-planning routing
|
||||
if strings.HasPrefix(env, "BD_ACTOR=") ||
|
||||
strings.HasPrefix(env, "BEADS_") ||
|
||||
strings.HasPrefix(env, "GT_ROOT=") ||
|
||||
strings.HasPrefix(env, "HOME=") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, env)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// List returns issues matching the given options.
|
||||
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
||||
args := []string{"list", "--json"}
|
||||
@@ -396,9 +463,10 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
|
||||
args = append(args, "--ephemeral")
|
||||
}
|
||||
// Default Actor from BD_ACTOR env var if not specified
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
actor := opts.Actor
|
||||
if actor == "" {
|
||||
actor = os.Getenv("BD_ACTOR")
|
||||
actor = b.getActor()
|
||||
}
|
||||
if actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
@@ -422,6 +490,9 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
|
||||
// deterministic IDs rather than auto-generated ones.
|
||||
func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
|
||||
args := []string{"create", "--json", "--id=" + id}
|
||||
if NeedsForceForID(id) {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
|
||||
if opts.Title != "" {
|
||||
args = append(args, "--title="+opts.Title)
|
||||
@@ -440,9 +511,10 @@ func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
|
||||
args = append(args, "--parent="+opts.Parent)
|
||||
}
|
||||
// Default Actor from BD_ACTOR env var if not specified
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
actor := opts.Actor
|
||||
if actor == "" {
|
||||
actor = os.Getenv("BD_ACTOR")
|
||||
actor = b.getActor()
|
||||
}
|
||||
if actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
@@ -654,15 +726,16 @@ This is physics, not politeness. Gas Town is a steam engine - you are a piston.
|
||||
|
||||
## Session Close Protocol
|
||||
|
||||
Before saying "done":
|
||||
Before signaling completion:
|
||||
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)
|
||||
7. ` + "`gt done`" + ` (submit to merge queue and exit)
|
||||
|
||||
**Work is not done until pushed.**
|
||||
**Polecats MUST call ` + "`gt done`" + ` - this submits work and exits the session.**
|
||||
`
|
||||
|
||||
// ProvisionPrimeMD writes the Gas Town PRIME.md file to the specified beads directory.
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -139,9 +138,13 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
"--type=agent",
|
||||
"--labels=gt:agent",
|
||||
}
|
||||
if NeedsForceForID(id) {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
@@ -178,9 +181,14 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
|
||||
// CreateOrReopenAgentBead creates an agent bead or reopens an existing one.
|
||||
// This handles the case where a polecat is nuked and re-spawned with the same name:
|
||||
// the old agent bead exists as a tombstone, so we reopen and update it instead of
|
||||
// the old agent bead exists as a closed bead, so we reopen and update it instead of
|
||||
// failing with a UNIQUE constraint error.
|
||||
//
|
||||
// NOTE: This does NOT handle tombstones. If the old bead was hard-deleted (creating
|
||||
// a tombstone), this function will fail. Use CloseAndClearAgentBead instead of DeleteAgentBead
|
||||
// when cleaning up agent beads to ensure they can be reopened later.
|
||||
//
|
||||
//
|
||||
// The function:
|
||||
// 1. Tries to create the agent bead
|
||||
// 2. If UNIQUE constraint fails, reopens the existing bead and updates its fields
|
||||
@@ -196,7 +204,7 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The bead already exists (likely a tombstone from a previous nuked polecat)
|
||||
// The bead already exists (should be closed from previous polecat lifecycle)
|
||||
// Reopen it and update its fields
|
||||
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
|
||||
// If reopen fails, the bead might already be open - continue with update
|
||||
@@ -223,10 +231,11 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
|
||||
// Set the hook slot if specified
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
// Clear any existing hook first, then set new one
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
@@ -400,11 +409,70 @@ func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
||||
|
||||
// DeleteAgentBead permanently deletes an agent bead.
|
||||
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||
//
|
||||
// WARNING: Due to a bd bug, --hard --force still creates tombstones instead of
|
||||
// truly deleting. This breaks CreateOrReopenAgentBead because tombstones are
|
||||
// invisible to bd show/reopen but still block bd create via UNIQUE constraint.
|
||||
//
|
||||
//
|
||||
// WORKAROUND: Use CloseAndClearAgentBead instead, which allows CreateOrReopenAgentBead
|
||||
// to reopen the bead on re-spawn.
|
||||
func (b *Beads) DeleteAgentBead(id string) error {
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseAndClearAgentBead closes an agent bead (soft delete).
|
||||
// This is the recommended way to clean up agent beads because CreateOrReopenAgentBead
|
||||
// can reopen closed beads when re-spawning polecats with the same name.
|
||||
//
|
||||
// This is a workaround for the bd tombstone bug where DeleteAgentBead creates
|
||||
// tombstones that cannot be reopened.
|
||||
//
|
||||
// To emulate the clean slate of delete --force --hard, this clears all mutable
|
||||
// fields (hook_bead, active_mr, cleanup_status, agent_state) before closing.
|
||||
func (b *Beads) CloseAndClearAgentBead(id, reason string) error {
|
||||
// Clear mutable fields to emulate delete --force --hard behavior.
|
||||
// This ensures reopened agent beads don't have stale state.
|
||||
|
||||
// First get current issue to preserve immutable fields
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
// If we can't read the issue, still attempt to close
|
||||
args := []string{"close", id}
|
||||
if reason != "" {
|
||||
args = append(args, "--reason="+reason)
|
||||
}
|
||||
_, closeErr := b.run(args...)
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// Parse existing fields and clear mutable ones
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
fields.HookBead = "" // Clear hook_bead
|
||||
fields.ActiveMR = "" // Clear active_mr
|
||||
fields.CleanupStatus = "" // Clear cleanup_status
|
||||
fields.AgentState = "closed"
|
||||
|
||||
// Update description with cleared fields
|
||||
description := FormatAgentDescription(issue.Title, fields)
|
||||
if err := b.Update(id, UpdateOptions{Description: &description}); err != nil {
|
||||
// Non-fatal: continue with close even if update fails
|
||||
}
|
||||
|
||||
// Also clear the hook slot in the database
|
||||
if err := b.ClearHookBead(id); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
args := []string{"close", id}
|
||||
if reason != "" {
|
||||
args = append(args, "--reason="+reason)
|
||||
}
|
||||
_, err = b.run(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentBead retrieves an agent bead by ID.
|
||||
// Returns nil if not found.
|
||||
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
||||
|
||||
529
internal/beads/beads_channel.go
Normal file
529
internal/beads/beads_channel.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// Package beads provides channel bead management for beads-native messaging.
|
||||
// Channels are named pub/sub streams where messages are broadcast to subscribers.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChannelFields holds structured fields for channel beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type ChannelFields struct {
|
||||
Name string // Unique channel name (e.g., "alerts", "builds")
|
||||
Subscribers []string // Addresses subscribed to this channel
|
||||
Status string // active, closed
|
||||
RetentionCount int // Number of recent messages to retain (0 = unlimited)
|
||||
RetentionHours int // Hours to retain messages (0 = forever)
|
||||
CreatedBy string // Who created the channel
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// Channel status constants
|
||||
const (
|
||||
ChannelStatusActive = "active"
|
||||
ChannelStatusClosed = "closed"
|
||||
)
|
||||
|
||||
// FormatChannelDescription creates a description string from channel fields.
|
||||
func FormatChannelDescription(title string, fields *ChannelFields) string {
|
||||
if fields == nil {
|
||||
return title
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("name: %s", fields.Name))
|
||||
|
||||
// Subscribers stored as comma-separated list
|
||||
if len(fields.Subscribers) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("subscribers: %s", strings.Join(fields.Subscribers, ",")))
|
||||
} else {
|
||||
lines = append(lines, "subscribers: null")
|
||||
}
|
||||
|
||||
if fields.Status != "" {
|
||||
lines = append(lines, fmt.Sprintf("status: %s", fields.Status))
|
||||
} else {
|
||||
lines = append(lines, "status: active")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("retention_count: %d", fields.RetentionCount))
|
||||
lines = append(lines, fmt.Sprintf("retention_hours: %d", fields.RetentionHours))
|
||||
|
||||
if fields.CreatedBy != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_by: %s", fields.CreatedBy))
|
||||
} else {
|
||||
lines = append(lines, "created_by: null")
|
||||
}
|
||||
|
||||
if fields.CreatedAt != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_at: %s", fields.CreatedAt))
|
||||
} else {
|
||||
lines = append(lines, "created_at: null")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseChannelFields extracts channel fields from an issue's description.
|
||||
func ParseChannelFields(description string) *ChannelFields {
|
||||
fields := &ChannelFields{
|
||||
Status: ChannelStatusActive,
|
||||
}
|
||||
|
||||
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 "name":
|
||||
fields.Name = value
|
||||
case "subscribers":
|
||||
if value != "" {
|
||||
// Parse comma-separated subscribers
|
||||
for _, s := range strings.Split(value, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
fields.Subscribers = append(fields.Subscribers, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "status":
|
||||
fields.Status = value
|
||||
case "retention_count":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.RetentionCount = v
|
||||
}
|
||||
case "retention_hours":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.RetentionHours = v
|
||||
}
|
||||
case "created_by":
|
||||
fields.CreatedBy = value
|
||||
case "created_at":
|
||||
fields.CreatedAt = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// ChannelBeadID returns the bead ID for a channel name.
|
||||
// Format: hq-channel-<name> (town-level, channels span rigs)
|
||||
func ChannelBeadID(name string) string {
|
||||
return "hq-channel-" + name
|
||||
}
|
||||
|
||||
// CreateChannelBead creates a channel bead for pub/sub messaging.
|
||||
// The ID format is: hq-channel-<name> (e.g., hq-channel-alerts)
|
||||
// Channels are town-level entities (hq- prefix) because they span rigs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
func (b *Beads) CreateChannelBead(name string, subscribers []string, createdBy string) (*Issue, error) {
|
||||
id := ChannelBeadID(name)
|
||||
title := fmt.Sprintf("Channel: %s", name)
|
||||
|
||||
fields := &ChannelFields{
|
||||
Name: name,
|
||||
Subscribers: subscribers,
|
||||
Status: ChannelStatusActive,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
description := FormatChannelDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
"--type=task", // Channels use task type with gt:channel label
|
||||
"--labels=gt:channel",
|
||||
"--force", // Override prefix check (town beads may have mixed prefixes)
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); 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
|
||||
}
|
||||
|
||||
// GetChannelBead retrieves a channel bead by name.
|
||||
// Returns nil, nil if not found.
|
||||
func (b *Beads) GetChannelBead(name string) (*Issue, *ChannelFields, error) {
|
||||
id := ChannelBeadID(name)
|
||||
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:channel") {
|
||||
return nil, nil, fmt.Errorf("bead %s is not a channel bead (missing gt:channel label)", id)
|
||||
}
|
||||
|
||||
fields := ParseChannelFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// GetChannelByID retrieves a channel bead by its full ID.
|
||||
// Returns nil, nil if not found.
|
||||
func (b *Beads) GetChannelByID(id string) (*Issue, *ChannelFields, 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:channel") {
|
||||
return nil, nil, fmt.Errorf("bead %s is not a channel bead (missing gt:channel label)", id)
|
||||
}
|
||||
|
||||
fields := ParseChannelFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// UpdateChannelSubscribers updates the subscribers list for a channel.
|
||||
func (b *Beads) UpdateChannelSubscribers(name string, subscribers []string) error {
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("channel %q not found", name)
|
||||
}
|
||||
|
||||
fields.Subscribers = subscribers
|
||||
description := FormatChannelDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// SubscribeToChannel adds a subscriber to a channel if not already subscribed.
|
||||
func (b *Beads) SubscribeToChannel(name string, subscriber string) error {
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("channel %q not found", name)
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
for _, s := range fields.Subscribers {
|
||||
if s == subscriber {
|
||||
return nil // Already subscribed
|
||||
}
|
||||
}
|
||||
|
||||
fields.Subscribers = append(fields.Subscribers, subscriber)
|
||||
description := FormatChannelDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UnsubscribeFromChannel removes a subscriber from a channel.
|
||||
func (b *Beads) UnsubscribeFromChannel(name string, subscriber string) error {
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("channel %q not found", name)
|
||||
}
|
||||
|
||||
// Filter out the subscriber
|
||||
var newSubscribers []string
|
||||
for _, s := range fields.Subscribers {
|
||||
if s != subscriber {
|
||||
newSubscribers = append(newSubscribers, s)
|
||||
}
|
||||
}
|
||||
|
||||
fields.Subscribers = newSubscribers
|
||||
description := FormatChannelDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UpdateChannelRetention updates the retention policy for a channel.
|
||||
func (b *Beads) UpdateChannelRetention(name string, retentionCount, retentionHours int) error {
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("channel %q not found", name)
|
||||
}
|
||||
|
||||
fields.RetentionCount = retentionCount
|
||||
fields.RetentionHours = retentionHours
|
||||
description := FormatChannelDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UpdateChannelStatus updates the status of a channel bead.
|
||||
func (b *Beads) UpdateChannelStatus(name, status string) error {
|
||||
// Validate status
|
||||
if status != ChannelStatusActive && status != ChannelStatusClosed {
|
||||
return fmt.Errorf("invalid channel status %q: must be active or closed", status)
|
||||
}
|
||||
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("channel %q not found", name)
|
||||
}
|
||||
|
||||
fields.Status = status
|
||||
description := FormatChannelDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// DeleteChannelBead permanently deletes a channel bead.
|
||||
func (b *Beads) DeleteChannelBead(name string) error {
|
||||
id := ChannelBeadID(name)
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// ListChannelBeads returns all channel beads.
|
||||
func (b *Beads) ListChannelBeads() (map[string]*ChannelFields, error) {
|
||||
out, err := b.run("list", "--label=gt:channel", "--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]*ChannelFields, len(issues))
|
||||
for _, issue := range issues {
|
||||
fields := ParseChannelFields(issue.Description)
|
||||
if fields.Name != "" {
|
||||
result[fields.Name] = fields
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LookupChannelByName finds a channel by its name field (not by ID).
|
||||
// This is used for address resolution where we may not know the full bead ID.
|
||||
func (b *Beads) LookupChannelByName(name string) (*Issue, *ChannelFields, error) {
|
||||
// First try direct lookup by standard ID format
|
||||
issue, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if issue != nil {
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// If not found by ID, search all channels by name field
|
||||
channels, err := b.ListChannelBeads()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if fields, ok := channels[name]; ok {
|
||||
// Found by name, now get the full issue
|
||||
id := ChannelBeadID(name)
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
return nil, nil, nil // Not found
|
||||
}
|
||||
|
||||
// EnforceChannelRetention prunes old messages from a channel to enforce retention.
|
||||
// Called after posting a new message to the channel (on-write cleanup).
|
||||
// Enforces both count-based (RetentionCount) and time-based (RetentionHours) limits.
|
||||
func (b *Beads) EnforceChannelRetention(name string) error {
|
||||
// Get channel config
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Skip if no retention limits configured
|
||||
if fields.RetentionCount <= 0 && fields.RetentionHours <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query messages in this channel (oldest first)
|
||||
out, err := b.run("list",
|
||||
"--type=message",
|
||||
"--label=channel:"+name,
|
||||
"--json",
|
||||
"--limit=0",
|
||||
"--sort=created",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing channel messages: %w", err)
|
||||
}
|
||||
|
||||
var messages []struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &messages); err != nil {
|
||||
return fmt.Errorf("parsing channel messages: %w", err)
|
||||
}
|
||||
|
||||
// Track which messages to delete (use map to avoid duplicates)
|
||||
toDeleteIDs := make(map[string]bool)
|
||||
|
||||
// Time-based retention: delete messages older than RetentionHours
|
||||
if fields.RetentionHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(fields.RetentionHours) * time.Hour)
|
||||
for _, msg := range messages {
|
||||
createdAt, err := time.Parse(time.RFC3339, msg.CreatedAt)
|
||||
if err != nil {
|
||||
continue // Skip messages with unparseable timestamps
|
||||
}
|
||||
if createdAt.Before(cutoff) {
|
||||
toDeleteIDs[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count-based retention: delete oldest messages beyond RetentionCount
|
||||
if fields.RetentionCount > 0 {
|
||||
toDeleteByCount := len(messages) - fields.RetentionCount
|
||||
for i := 0; i < toDeleteByCount && i < len(messages); i++ {
|
||||
toDeleteIDs[messages[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete marked messages (best-effort)
|
||||
for id := range toDeleteIDs {
|
||||
// Use close instead of delete for audit trail
|
||||
_, _ = b.run("close", id, "--reason=channel retention pruning")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneAllChannels enforces retention on all channels.
|
||||
// Called by Deacon patrol as a backup cleanup mechanism.
|
||||
// Enforces both count-based (RetentionCount) and time-based (RetentionHours) limits.
|
||||
// Uses a 10% buffer for count-based pruning to avoid thrashing.
|
||||
func (b *Beads) PruneAllChannels() (int, error) {
|
||||
channels, err := b.ListChannelBeads()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
pruned := 0
|
||||
for name, fields := range channels {
|
||||
// Skip if no retention limits configured
|
||||
if fields.RetentionCount <= 0 && fields.RetentionHours <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get messages with timestamps
|
||||
out, err := b.run("list",
|
||||
"--type=message",
|
||||
"--label=channel:"+name,
|
||||
"--json",
|
||||
"--limit=0",
|
||||
"--sort=created",
|
||||
)
|
||||
if err != nil {
|
||||
continue // Skip on error
|
||||
}
|
||||
|
||||
var messages []struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &messages); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track which messages to delete (use map to avoid duplicates)
|
||||
toDeleteIDs := make(map[string]bool)
|
||||
|
||||
// Time-based retention: delete messages older than RetentionHours
|
||||
if fields.RetentionHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(fields.RetentionHours) * time.Hour)
|
||||
for _, msg := range messages {
|
||||
createdAt, err := time.Parse(time.RFC3339, msg.CreatedAt)
|
||||
if err != nil {
|
||||
continue // Skip messages with unparseable timestamps
|
||||
}
|
||||
if createdAt.Before(cutoff) {
|
||||
toDeleteIDs[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count-based retention with 10% buffer to avoid thrashing
|
||||
if fields.RetentionCount > 0 {
|
||||
threshold := int(float64(fields.RetentionCount) * 1.1)
|
||||
if len(messages) > threshold {
|
||||
toDeleteByCount := len(messages) - fields.RetentionCount
|
||||
for i := 0; i < toDeleteByCount && i < len(messages); i++ {
|
||||
toDeleteIDs[messages[i].ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete marked messages
|
||||
for id := range toDeleteIDs {
|
||||
if _, err := b.run("close", id, "--reason=patrol retention pruning"); err == nil {
|
||||
pruned++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pruned, nil
|
||||
}
|
||||
271
internal/beads/beads_channel_test.go
Normal file
271
internal/beads/beads_channel_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatChannelDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
fields *ChannelFields
|
||||
want []string // Lines that should be present
|
||||
}{
|
||||
{
|
||||
name: "basic channel",
|
||||
title: "Channel: alerts",
|
||||
fields: &ChannelFields{
|
||||
Name: "alerts",
|
||||
Subscribers: []string{"gastown/crew/max", "gastown/witness"},
|
||||
Status: ChannelStatusActive,
|
||||
CreatedBy: "human",
|
||||
CreatedAt: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
want: []string{
|
||||
"Channel: alerts",
|
||||
"name: alerts",
|
||||
"subscribers: gastown/crew/max,gastown/witness",
|
||||
"status: active",
|
||||
"created_by: human",
|
||||
"created_at: 2024-01-15T10:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty subscribers",
|
||||
title: "Channel: empty",
|
||||
fields: &ChannelFields{
|
||||
Name: "empty",
|
||||
Subscribers: nil,
|
||||
Status: ChannelStatusActive,
|
||||
CreatedBy: "admin",
|
||||
},
|
||||
want: []string{
|
||||
"name: empty",
|
||||
"subscribers: null",
|
||||
"created_by: admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with retention",
|
||||
title: "Channel: builds",
|
||||
fields: &ChannelFields{
|
||||
Name: "builds",
|
||||
Subscribers: []string{"*/witness"},
|
||||
RetentionCount: 100,
|
||||
RetentionHours: 24,
|
||||
},
|
||||
want: []string{
|
||||
"name: builds",
|
||||
"retention_count: 100",
|
||||
"retention_hours: 24",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "closed channel",
|
||||
title: "Channel: old",
|
||||
fields: &ChannelFields{
|
||||
Name: "old",
|
||||
Status: ChannelStatusClosed,
|
||||
},
|
||||
want: []string{
|
||||
"status: closed",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil fields",
|
||||
title: "Just a title",
|
||||
fields: nil,
|
||||
want: []string{"Just a title"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatChannelDescription(tt.title, tt.fields)
|
||||
for _, line := range tt.want {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("FormatChannelDescription() missing line %q\ngot:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
description string
|
||||
want *ChannelFields
|
||||
}{
|
||||
{
|
||||
name: "full channel",
|
||||
description: `Channel: alerts
|
||||
|
||||
name: alerts
|
||||
subscribers: gastown/crew/max,gastown/witness,*/refinery
|
||||
status: active
|
||||
retention_count: 50
|
||||
retention_hours: 48
|
||||
created_by: human
|
||||
created_at: 2024-01-15T10:00:00Z`,
|
||||
want: &ChannelFields{
|
||||
Name: "alerts",
|
||||
Subscribers: []string{"gastown/crew/max", "gastown/witness", "*/refinery"},
|
||||
Status: ChannelStatusActive,
|
||||
RetentionCount: 50,
|
||||
RetentionHours: 48,
|
||||
CreatedBy: "human",
|
||||
CreatedAt: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null subscribers",
|
||||
description: `Channel: empty
|
||||
|
||||
name: empty
|
||||
subscribers: null
|
||||
status: active
|
||||
created_by: admin`,
|
||||
want: &ChannelFields{
|
||||
Name: "empty",
|
||||
Subscribers: nil,
|
||||
Status: ChannelStatusActive,
|
||||
CreatedBy: "admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single subscriber",
|
||||
description: `name: solo
|
||||
subscribers: gastown/crew/max
|
||||
status: active`,
|
||||
want: &ChannelFields{
|
||||
Name: "solo",
|
||||
Subscribers: []string{"gastown/crew/max"},
|
||||
Status: ChannelStatusActive,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
description: "",
|
||||
want: &ChannelFields{
|
||||
Status: ChannelStatusActive, // Default
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscribers with spaces",
|
||||
description: `name: spaced
|
||||
subscribers: a, b , c
|
||||
status: active`,
|
||||
want: &ChannelFields{
|
||||
Name: "spaced",
|
||||
Subscribers: []string{"a", "b", "c"},
|
||||
Status: ChannelStatusActive,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "closed status",
|
||||
description: `name: archived
|
||||
status: closed`,
|
||||
want: &ChannelFields{
|
||||
Name: "archived",
|
||||
Status: ChannelStatusClosed,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseChannelFields(tt.description)
|
||||
if got.Name != tt.want.Name {
|
||||
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
|
||||
}
|
||||
if got.Status != tt.want.Status {
|
||||
t.Errorf("Status = %q, want %q", got.Status, tt.want.Status)
|
||||
}
|
||||
if got.RetentionCount != tt.want.RetentionCount {
|
||||
t.Errorf("RetentionCount = %d, want %d", got.RetentionCount, tt.want.RetentionCount)
|
||||
}
|
||||
if got.RetentionHours != tt.want.RetentionHours {
|
||||
t.Errorf("RetentionHours = %d, want %d", got.RetentionHours, tt.want.RetentionHours)
|
||||
}
|
||||
if got.CreatedBy != tt.want.CreatedBy {
|
||||
t.Errorf("CreatedBy = %q, want %q", got.CreatedBy, tt.want.CreatedBy)
|
||||
}
|
||||
if got.CreatedAt != tt.want.CreatedAt {
|
||||
t.Errorf("CreatedAt = %q, want %q", got.CreatedAt, tt.want.CreatedAt)
|
||||
}
|
||||
if len(got.Subscribers) != len(tt.want.Subscribers) {
|
||||
t.Errorf("Subscribers count = %d, want %d", len(got.Subscribers), len(tt.want.Subscribers))
|
||||
} else {
|
||||
for i, s := range got.Subscribers {
|
||||
if s != tt.want.Subscribers[i] {
|
||||
t.Errorf("Subscribers[%d] = %q, want %q", i, s, tt.want.Subscribers[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelBeadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"alerts", "hq-channel-alerts"},
|
||||
{"builds", "hq-channel-builds"},
|
||||
{"team-updates", "hq-channel-team-updates"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ChannelBeadID(tt.name); got != tt.want {
|
||||
t.Errorf("ChannelBeadID(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelRoundTrip(t *testing.T) {
|
||||
// Test that Format -> Parse preserves data
|
||||
original := &ChannelFields{
|
||||
Name: "test-channel",
|
||||
Subscribers: []string{"gastown/crew/max", "*/witness", "@town"},
|
||||
Status: ChannelStatusActive,
|
||||
RetentionCount: 100,
|
||||
RetentionHours: 72,
|
||||
CreatedBy: "tester",
|
||||
CreatedAt: "2024-01-15T12:00:00Z",
|
||||
}
|
||||
|
||||
description := FormatChannelDescription("Channel: test-channel", original)
|
||||
parsed := ParseChannelFields(description)
|
||||
|
||||
if parsed.Name != original.Name {
|
||||
t.Errorf("Name: got %q, want %q", parsed.Name, original.Name)
|
||||
}
|
||||
if parsed.Status != original.Status {
|
||||
t.Errorf("Status: got %q, want %q", parsed.Status, original.Status)
|
||||
}
|
||||
if parsed.RetentionCount != original.RetentionCount {
|
||||
t.Errorf("RetentionCount: got %d, want %d", parsed.RetentionCount, original.RetentionCount)
|
||||
}
|
||||
if parsed.RetentionHours != original.RetentionHours {
|
||||
t.Errorf("RetentionHours: got %d, want %d", parsed.RetentionHours, original.RetentionHours)
|
||||
}
|
||||
if parsed.CreatedBy != original.CreatedBy {
|
||||
t.Errorf("CreatedBy: got %q, want %q", parsed.CreatedBy, original.CreatedBy)
|
||||
}
|
||||
if parsed.CreatedAt != original.CreatedAt {
|
||||
t.Errorf("CreatedAt: got %q, want %q", parsed.CreatedAt, original.CreatedAt)
|
||||
}
|
||||
if len(parsed.Subscribers) != len(original.Subscribers) {
|
||||
t.Fatalf("Subscribers count: got %d, want %d", len(parsed.Subscribers), len(original.Subscribers))
|
||||
}
|
||||
for i, s := range original.Subscribers {
|
||||
if parsed.Subscribers[i] != s {
|
||||
t.Errorf("Subscribers[%d]: got %q, want %q", i, parsed.Subscribers[i], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package beads
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -28,7 +27,8 @@ func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -183,7 +182,8 @@ func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*I
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
311
internal/beads/beads_group.go
Normal file
311
internal/beads/beads_group.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Package beads provides group bead management for beads-native messaging.
|
||||
// Groups are named collections of addresses used for mail distribution.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GroupFields holds structured fields for group beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type GroupFields struct {
|
||||
Name string // Unique group name (e.g., "ops-team", "all-witnesses")
|
||||
Members []string // Addresses, patterns, or group names (can nest)
|
||||
CreatedBy string // Who created the group
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// FormatGroupDescription creates a description string from group fields.
|
||||
func FormatGroupDescription(title string, fields *GroupFields) string {
|
||||
if fields == nil {
|
||||
return title
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("name: %s", fields.Name))
|
||||
|
||||
// Members stored as comma-separated list
|
||||
if len(fields.Members) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("members: %s", strings.Join(fields.Members, ",")))
|
||||
} else {
|
||||
lines = append(lines, "members: null")
|
||||
}
|
||||
|
||||
if fields.CreatedBy != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_by: %s", fields.CreatedBy))
|
||||
} else {
|
||||
lines = append(lines, "created_by: null")
|
||||
}
|
||||
|
||||
if fields.CreatedAt != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_at: %s", fields.CreatedAt))
|
||||
} else {
|
||||
lines = append(lines, "created_at: null")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseGroupFields extracts group fields from an issue's description.
|
||||
func ParseGroupFields(description string) *GroupFields {
|
||||
fields := &GroupFields{}
|
||||
|
||||
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 "name":
|
||||
fields.Name = value
|
||||
case "members":
|
||||
if value != "" {
|
||||
// Parse comma-separated members
|
||||
for _, m := range strings.Split(value, ",") {
|
||||
m = strings.TrimSpace(m)
|
||||
if m != "" {
|
||||
fields.Members = append(fields.Members, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "created_by":
|
||||
fields.CreatedBy = value
|
||||
case "created_at":
|
||||
fields.CreatedAt = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// GroupBeadID returns the bead ID for a group name.
|
||||
// Format: hq-group-<name> (town-level, groups span rigs)
|
||||
func GroupBeadID(name string) string {
|
||||
return "hq-group-" + name
|
||||
}
|
||||
|
||||
// CreateGroupBead creates a group bead for mail distribution.
|
||||
// The ID format is: hq-group-<name> (e.g., hq-group-ops-team)
|
||||
// Groups are town-level entities (hq- prefix) because they span rigs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
func (b *Beads) CreateGroupBead(name string, members []string, createdBy string) (*Issue, error) {
|
||||
id := GroupBeadID(name)
|
||||
title := fmt.Sprintf("Group: %s", name)
|
||||
|
||||
fields := &GroupFields{
|
||||
Name: name,
|
||||
Members: members,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
description := FormatGroupDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
"--type=task", // Groups use task type with gt:group label
|
||||
"--labels=gt:group",
|
||||
"--force", // Override prefix check (town beads may have mixed prefixes)
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); 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
|
||||
}
|
||||
|
||||
// GetGroupBead retrieves a group bead by name.
|
||||
// Returns nil, nil if not found.
|
||||
func (b *Beads) GetGroupBead(name string) (*Issue, *GroupFields, error) {
|
||||
id := GroupBeadID(name)
|
||||
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:group") {
|
||||
return nil, nil, fmt.Errorf("bead %s is not a group bead (missing gt:group label)", id)
|
||||
}
|
||||
|
||||
fields := ParseGroupFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// GetGroupByID retrieves a group bead by its full ID.
|
||||
// Returns nil, nil if not found.
|
||||
func (b *Beads) GetGroupByID(id string) (*Issue, *GroupFields, 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:group") {
|
||||
return nil, nil, fmt.Errorf("bead %s is not a group bead (missing gt:group label)", id)
|
||||
}
|
||||
|
||||
fields := ParseGroupFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// UpdateGroupMembers updates the members list for a group.
|
||||
func (b *Beads) UpdateGroupMembers(name string, members []string) error {
|
||||
issue, fields, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("group %q not found", name)
|
||||
}
|
||||
|
||||
fields.Members = members
|
||||
description := FormatGroupDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// AddGroupMember adds a member to a group if not already present.
|
||||
func (b *Beads) AddGroupMember(name string, member string) error {
|
||||
issue, fields, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("group %q not found", name)
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
for _, m := range fields.Members {
|
||||
if m == member {
|
||||
return nil // Already a member
|
||||
}
|
||||
}
|
||||
|
||||
fields.Members = append(fields.Members, member)
|
||||
description := FormatGroupDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// RemoveGroupMember removes a member from a group.
|
||||
func (b *Beads) RemoveGroupMember(name string, member string) error {
|
||||
issue, fields, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("group %q not found", name)
|
||||
}
|
||||
|
||||
// Filter out the member
|
||||
var newMembers []string
|
||||
for _, m := range fields.Members {
|
||||
if m != member {
|
||||
newMembers = append(newMembers, m)
|
||||
}
|
||||
}
|
||||
|
||||
fields.Members = newMembers
|
||||
description := FormatGroupDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// DeleteGroupBead permanently deletes a group bead.
|
||||
func (b *Beads) DeleteGroupBead(name string) error {
|
||||
id := GroupBeadID(name)
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// ListGroupBeads returns all group beads.
|
||||
func (b *Beads) ListGroupBeads() (map[string]*GroupFields, error) {
|
||||
out, err := b.run("list", "--label=gt:group", "--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]*GroupFields, len(issues))
|
||||
for _, issue := range issues {
|
||||
fields := ParseGroupFields(issue.Description)
|
||||
if fields.Name != "" {
|
||||
result[fields.Name] = fields
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LookupGroupByName finds a group by its name field (not by ID).
|
||||
// This is used for address resolution where we may not know the full bead ID.
|
||||
func (b *Beads) LookupGroupByName(name string) (*Issue, *GroupFields, error) {
|
||||
// First try direct lookup by standard ID format
|
||||
issue, fields, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if issue != nil {
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// If not found by ID, search all groups by name field
|
||||
groups, err := b.ListGroupBeads()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if fields, ok := groups[name]; ok {
|
||||
// Found by name, now get the full issue
|
||||
id := GroupBeadID(name)
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
return nil, nil, nil // Not found
|
||||
}
|
||||
209
internal/beads/beads_group_test.go
Normal file
209
internal/beads/beads_group_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatGroupDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
fields *GroupFields
|
||||
want []string // Lines that should be present
|
||||
}{
|
||||
{
|
||||
name: "basic group",
|
||||
title: "Group: ops-team",
|
||||
fields: &GroupFields{
|
||||
Name: "ops-team",
|
||||
Members: []string{"gastown/crew/max", "gastown/witness"},
|
||||
CreatedBy: "human",
|
||||
CreatedAt: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
want: []string{
|
||||
"Group: ops-team",
|
||||
"name: ops-team",
|
||||
"members: gastown/crew/max,gastown/witness",
|
||||
"created_by: human",
|
||||
"created_at: 2024-01-15T10:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty members",
|
||||
title: "Group: empty",
|
||||
fields: &GroupFields{
|
||||
Name: "empty",
|
||||
Members: nil,
|
||||
CreatedBy: "admin",
|
||||
},
|
||||
want: []string{
|
||||
"name: empty",
|
||||
"members: null",
|
||||
"created_by: admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "patterns in members",
|
||||
title: "Group: all-witnesses",
|
||||
fields: &GroupFields{
|
||||
Name: "all-witnesses",
|
||||
Members: []string{"*/witness", "@crew"},
|
||||
},
|
||||
want: []string{
|
||||
"members: */witness,@crew",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil fields",
|
||||
title: "Just a title",
|
||||
fields: nil,
|
||||
want: []string{"Just a title"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatGroupDescription(tt.title, tt.fields)
|
||||
for _, line := range tt.want {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("FormatGroupDescription() missing line %q\ngot:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
description string
|
||||
want *GroupFields
|
||||
}{
|
||||
{
|
||||
name: "full group",
|
||||
description: `Group: ops-team
|
||||
|
||||
name: ops-team
|
||||
members: gastown/crew/max,gastown/witness,*/refinery
|
||||
created_by: human
|
||||
created_at: 2024-01-15T10:00:00Z`,
|
||||
want: &GroupFields{
|
||||
Name: "ops-team",
|
||||
Members: []string{"gastown/crew/max", "gastown/witness", "*/refinery"},
|
||||
CreatedBy: "human",
|
||||
CreatedAt: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null members",
|
||||
description: `Group: empty
|
||||
|
||||
name: empty
|
||||
members: null
|
||||
created_by: admin`,
|
||||
want: &GroupFields{
|
||||
Name: "empty",
|
||||
Members: nil,
|
||||
CreatedBy: "admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single member",
|
||||
description: `name: solo
|
||||
members: gastown/crew/max`,
|
||||
want: &GroupFields{
|
||||
Name: "solo",
|
||||
Members: []string{"gastown/crew/max"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
description: "",
|
||||
want: &GroupFields{},
|
||||
},
|
||||
{
|
||||
name: "members with spaces",
|
||||
description: `name: spaced
|
||||
members: a, b , c`,
|
||||
want: &GroupFields{
|
||||
Name: "spaced",
|
||||
Members: []string{"a", "b", "c"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseGroupFields(tt.description)
|
||||
if got.Name != tt.want.Name {
|
||||
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
|
||||
}
|
||||
if got.CreatedBy != tt.want.CreatedBy {
|
||||
t.Errorf("CreatedBy = %q, want %q", got.CreatedBy, tt.want.CreatedBy)
|
||||
}
|
||||
if got.CreatedAt != tt.want.CreatedAt {
|
||||
t.Errorf("CreatedAt = %q, want %q", got.CreatedAt, tt.want.CreatedAt)
|
||||
}
|
||||
if len(got.Members) != len(tt.want.Members) {
|
||||
t.Errorf("Members count = %d, want %d", len(got.Members), len(tt.want.Members))
|
||||
} else {
|
||||
for i, m := range got.Members {
|
||||
if m != tt.want.Members[i] {
|
||||
t.Errorf("Members[%d] = %q, want %q", i, m, tt.want.Members[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupBeadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"ops-team", "hq-group-ops-team"},
|
||||
{"all", "hq-group-all"},
|
||||
{"crew-leads", "hq-group-crew-leads"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := GroupBeadID(tt.name); got != tt.want {
|
||||
t.Errorf("GroupBeadID(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
// Test that Format -> Parse preserves data
|
||||
original := &GroupFields{
|
||||
Name: "test-group",
|
||||
Members: []string{"gastown/crew/max", "*/witness", "@town"},
|
||||
CreatedBy: "tester",
|
||||
CreatedAt: "2024-01-15T12:00:00Z",
|
||||
}
|
||||
|
||||
description := FormatGroupDescription("Group: test-group", original)
|
||||
parsed := ParseGroupFields(description)
|
||||
|
||||
if parsed.Name != original.Name {
|
||||
t.Errorf("Name: got %q, want %q", parsed.Name, original.Name)
|
||||
}
|
||||
if parsed.CreatedBy != original.CreatedBy {
|
||||
t.Errorf("CreatedBy: got %q, want %q", parsed.CreatedBy, original.CreatedBy)
|
||||
}
|
||||
if parsed.CreatedAt != original.CreatedAt {
|
||||
t.Errorf("CreatedAt: got %q, want %q", parsed.CreatedAt, original.CreatedAt)
|
||||
}
|
||||
if len(parsed.Members) != len(original.Members) {
|
||||
t.Fatalf("Members count: got %d, want %d", len(parsed.Members), len(original.Members))
|
||||
}
|
||||
for i, m := range original.Members {
|
||||
if parsed.Members[i] != m {
|
||||
t.Errorf("Members[%d]: got %q, want %q", i, parsed.Members[i], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
393
internal/beads/beads_queue.go
Normal file
393
internal/beads/beads_queue.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Package beads provides queue bead management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QueueFields holds structured fields for queue beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type QueueFields struct {
|
||||
Name string // Queue name (human-readable identifier)
|
||||
ClaimPattern string // Pattern for who can claim from queue (e.g., "gastown/polecats/*")
|
||||
Status string // active, paused, closed
|
||||
MaxConcurrency int // Maximum number of concurrent workers (0 = unlimited)
|
||||
ProcessingOrder string // fifo, priority (default: fifo)
|
||||
AvailableCount int // Number of items ready to process
|
||||
ProcessingCount int // Number of items currently being processed
|
||||
CompletedCount int // Number of items completed
|
||||
FailedCount int // Number of items that failed
|
||||
CreatedBy string // Who created this queue
|
||||
CreatedAt string // ISO 8601 timestamp of creation
|
||||
}
|
||||
|
||||
// Queue status constants
|
||||
const (
|
||||
QueueStatusActive = "active"
|
||||
QueueStatusPaused = "paused"
|
||||
QueueStatusClosed = "closed"
|
||||
)
|
||||
|
||||
// Queue processing order constants
|
||||
const (
|
||||
QueueOrderFIFO = "fifo"
|
||||
QueueOrderPriority = "priority"
|
||||
)
|
||||
|
||||
// FormatQueueDescription creates a description string from queue fields.
|
||||
func FormatQueueDescription(title string, fields *QueueFields) string {
|
||||
if fields == nil {
|
||||
return title
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, "")
|
||||
|
||||
if fields.Name != "" {
|
||||
lines = append(lines, fmt.Sprintf("name: %s", fields.Name))
|
||||
} else {
|
||||
lines = append(lines, "name: null")
|
||||
}
|
||||
|
||||
if fields.ClaimPattern != "" {
|
||||
lines = append(lines, fmt.Sprintf("claim_pattern: %s", fields.ClaimPattern))
|
||||
} else {
|
||||
lines = append(lines, "claim_pattern: *") // Default: anyone can claim
|
||||
}
|
||||
|
||||
if fields.Status != "" {
|
||||
lines = append(lines, fmt.Sprintf("status: %s", fields.Status))
|
||||
} else {
|
||||
lines = append(lines, "status: active")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("max_concurrency: %d", fields.MaxConcurrency))
|
||||
|
||||
if fields.ProcessingOrder != "" {
|
||||
lines = append(lines, fmt.Sprintf("processing_order: %s", fields.ProcessingOrder))
|
||||
} else {
|
||||
lines = append(lines, "processing_order: fifo")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("available_count: %d", fields.AvailableCount))
|
||||
lines = append(lines, fmt.Sprintf("processing_count: %d", fields.ProcessingCount))
|
||||
lines = append(lines, fmt.Sprintf("completed_count: %d", fields.CompletedCount))
|
||||
lines = append(lines, fmt.Sprintf("failed_count: %d", fields.FailedCount))
|
||||
|
||||
if fields.CreatedBy != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_by: %s", fields.CreatedBy))
|
||||
}
|
||||
if fields.CreatedAt != "" {
|
||||
lines = append(lines, fmt.Sprintf("created_at: %s", fields.CreatedAt))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseQueueFields extracts queue fields from an issue's description.
|
||||
func ParseQueueFields(description string) *QueueFields {
|
||||
fields := &QueueFields{
|
||||
Status: QueueStatusActive,
|
||||
ProcessingOrder: QueueOrderFIFO,
|
||||
ClaimPattern: "*", // Default: anyone can claim
|
||||
}
|
||||
|
||||
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 "name":
|
||||
fields.Name = value
|
||||
case "claim_pattern":
|
||||
if value != "" {
|
||||
fields.ClaimPattern = value
|
||||
}
|
||||
case "status":
|
||||
fields.Status = value
|
||||
case "max_concurrency":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.MaxConcurrency = v
|
||||
}
|
||||
case "processing_order":
|
||||
fields.ProcessingOrder = value
|
||||
case "available_count":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.AvailableCount = v
|
||||
}
|
||||
case "processing_count":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.ProcessingCount = v
|
||||
}
|
||||
case "completed_count":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.CompletedCount = v
|
||||
}
|
||||
case "failed_count":
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
fields.FailedCount = v
|
||||
}
|
||||
case "created_by":
|
||||
fields.CreatedBy = value
|
||||
case "created_at":
|
||||
fields.CreatedAt = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// QueueBeadID returns the queue bead ID for a given queue name.
|
||||
// Format: hq-q-<name> for town-level queues, gt-q-<name> for rig-level queues.
|
||||
func QueueBeadID(name string, isTownLevel bool) string {
|
||||
if isTownLevel {
|
||||
return "hq-q-" + name
|
||||
}
|
||||
return "gt-q-" + name
|
||||
}
|
||||
|
||||
// CreateQueueBead creates a queue bead for tracking work queues.
|
||||
// The ID format is: <prefix>-q-<name> (e.g., gt-q-merge, hq-q-dispatch)
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
func (b *Beads) CreateQueueBead(id, title string, fields *QueueFields) (*Issue, error) {
|
||||
description := FormatQueueDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
"--type=queue",
|
||||
"--labels=gt:queue",
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); 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
|
||||
}
|
||||
|
||||
// GetQueueBead retrieves a queue bead by ID.
|
||||
// Returns nil if not found.
|
||||
func (b *Beads) GetQueueBead(id string) (*Issue, *QueueFields, 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:queue") {
|
||||
return nil, nil, fmt.Errorf("issue %s is not a queue bead (missing gt:queue label)", id)
|
||||
}
|
||||
|
||||
fields := ParseQueueFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
// UpdateQueueFields updates the fields of a queue bead.
|
||||
func (b *Beads) UpdateQueueFields(id string, fields *QueueFields) error {
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
description := FormatQueueDescription(issue.Title, fields)
|
||||
return b.Update(id, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// UpdateQueueCounts updates the count fields of a queue bead.
|
||||
// This is a convenience method for incrementing/decrementing counts.
|
||||
func (b *Beads) UpdateQueueCounts(id string, available, processing, completed, failed int) error {
|
||||
issue, currentFields, err := b.GetQueueBead(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
currentFields.AvailableCount = available
|
||||
currentFields.ProcessingCount = processing
|
||||
currentFields.CompletedCount = completed
|
||||
currentFields.FailedCount = failed
|
||||
|
||||
return b.UpdateQueueFields(id, currentFields)
|
||||
}
|
||||
|
||||
// UpdateQueueStatus updates the status of a queue bead.
|
||||
func (b *Beads) UpdateQueueStatus(id, status string) error {
|
||||
// Validate status
|
||||
if status != QueueStatusActive && status != QueueStatusPaused && status != QueueStatusClosed {
|
||||
return fmt.Errorf("invalid queue status %q: must be active, paused, or closed", status)
|
||||
}
|
||||
|
||||
issue, currentFields, err := b.GetQueueBead(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
currentFields.Status = status
|
||||
return b.UpdateQueueFields(id, currentFields)
|
||||
}
|
||||
|
||||
// ListQueueBeads returns all queue beads.
|
||||
func (b *Beads) ListQueueBeads() (map[string]*Issue, error) {
|
||||
out, err := b.run("list", "--label=gt:queue", "--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
|
||||
}
|
||||
|
||||
// DeleteQueueBead permanently deletes a queue bead.
|
||||
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||
func (b *Beads) DeleteQueueBead(id string) error {
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// LookupQueueByName finds a queue by its name field (not by ID).
|
||||
// This is used for address resolution where we may not know the full bead ID.
|
||||
func (b *Beads) LookupQueueByName(name string) (*Issue, *QueueFields, error) {
|
||||
// First try direct lookup by standard ID formats (town and rig level)
|
||||
for _, isTownLevel := range []bool{true, false} {
|
||||
id := QueueBeadID(name, isTownLevel)
|
||||
issue, fields, err := b.GetQueueBead(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if issue != nil {
|
||||
return issue, fields, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by ID, search all queues by name field
|
||||
queues, err := b.ListQueueBeads()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, issue := range queues {
|
||||
fields := ParseQueueFields(issue.Description)
|
||||
if fields.Name == name {
|
||||
return issue, fields, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil // Not found
|
||||
}
|
||||
|
||||
// MatchClaimPattern checks if an identity matches a claim pattern.
|
||||
// Patterns support:
|
||||
// - "*" matches anyone
|
||||
// - "gastown/polecats/*" matches any polecat in gastown rig
|
||||
// - "*/witness" matches any witness role across rigs
|
||||
// - Exact match for specific identities
|
||||
func MatchClaimPattern(pattern, identity string) bool {
|
||||
// Wildcard matches anyone
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if pattern == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard pattern matching
|
||||
if strings.Contains(pattern, "*") {
|
||||
// Convert to simple glob matching
|
||||
// "gastown/polecats/*" should match "gastown/polecats/capable"
|
||||
// "*/witness" should match "gastown/witness"
|
||||
parts := strings.Split(pattern, "*")
|
||||
if len(parts) == 2 {
|
||||
prefix := parts[0]
|
||||
suffix := parts[1]
|
||||
if strings.HasPrefix(identity, prefix) && strings.HasSuffix(identity, suffix) {
|
||||
// Check that the middle part doesn't contain path separators
|
||||
// unless the pattern allows it (e.g., "*/" at start)
|
||||
middle := identity[len(prefix) : len(identity)-len(suffix)]
|
||||
// Only allow single segment match (no extra slashes)
|
||||
if !strings.Contains(middle, "/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FindEligibleQueues returns all queue beads that the given identity can claim from.
|
||||
func (b *Beads) FindEligibleQueues(identity string) ([]*Issue, []*QueueFields, error) {
|
||||
queues, err := b.ListQueueBeads()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var eligibleIssues []*Issue
|
||||
var eligibleFields []*QueueFields
|
||||
|
||||
for _, issue := range queues {
|
||||
fields := ParseQueueFields(issue.Description)
|
||||
|
||||
// Skip inactive queues
|
||||
if fields.Status != QueueStatusActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if identity matches claim pattern
|
||||
if MatchClaimPattern(fields.ClaimPattern, identity) {
|
||||
eligibleIssues = append(eligibleIssues, issue)
|
||||
eligibleFields = append(eligibleFields, fields)
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleIssues, eligibleFields, nil
|
||||
}
|
||||
301
internal/beads/beads_queue_test.go
Normal file
301
internal/beads/beads_queue_test.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchClaimPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
identity string
|
||||
want bool
|
||||
}{
|
||||
// Wildcard matches anyone
|
||||
{
|
||||
name: "wildcard matches anyone",
|
||||
pattern: "*",
|
||||
identity: "gastown/crew/max",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches town-level agent",
|
||||
pattern: "*",
|
||||
identity: "mayor/",
|
||||
want: true,
|
||||
},
|
||||
|
||||
// Exact match
|
||||
{
|
||||
name: "exact match",
|
||||
pattern: "gastown/crew/max",
|
||||
identity: "gastown/crew/max",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match fails on different identity",
|
||||
pattern: "gastown/crew/max",
|
||||
identity: "gastown/crew/nux",
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Suffix wildcard
|
||||
{
|
||||
name: "suffix wildcard matches",
|
||||
pattern: "gastown/polecats/*",
|
||||
identity: "gastown/polecats/capable",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "suffix wildcard matches different name",
|
||||
pattern: "gastown/polecats/*",
|
||||
identity: "gastown/polecats/nux",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "suffix wildcard doesn't match nested path",
|
||||
pattern: "gastown/polecats/*",
|
||||
identity: "gastown/polecats/sub/capable",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "suffix wildcard doesn't match different rig",
|
||||
pattern: "gastown/polecats/*",
|
||||
identity: "bartertown/polecats/capable",
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Prefix wildcard
|
||||
{
|
||||
name: "prefix wildcard matches",
|
||||
pattern: "*/witness",
|
||||
identity: "gastown/witness",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "prefix wildcard matches different rig",
|
||||
pattern: "*/witness",
|
||||
identity: "bartertown/witness",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "prefix wildcard doesn't match different role",
|
||||
pattern: "*/witness",
|
||||
identity: "gastown/refinery",
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Crew patterns
|
||||
{
|
||||
name: "crew wildcard",
|
||||
pattern: "gastown/crew/*",
|
||||
identity: "gastown/crew/max",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "crew wildcard matches any crew member",
|
||||
pattern: "gastown/crew/*",
|
||||
identity: "gastown/crew/jack",
|
||||
want: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty identity doesn't match",
|
||||
pattern: "*",
|
||||
identity: "",
|
||||
want: true, // * matches anything
|
||||
},
|
||||
{
|
||||
name: "empty pattern doesn't match",
|
||||
pattern: "",
|
||||
identity: "gastown/crew/max",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MatchClaimPattern(tt.pattern, tt.identity)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchClaimPattern(%q, %q) = %v, want %v",
|
||||
tt.pattern, tt.identity, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatQueueDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
fields *QueueFields
|
||||
want []string // Lines that should be present
|
||||
}{
|
||||
{
|
||||
name: "basic queue",
|
||||
title: "Queue: work-requests",
|
||||
fields: &QueueFields{
|
||||
Name: "work-requests",
|
||||
ClaimPattern: "gastown/crew/*",
|
||||
Status: QueueStatusActive,
|
||||
},
|
||||
want: []string{
|
||||
"Queue: work-requests",
|
||||
"name: work-requests",
|
||||
"claim_pattern: gastown/crew/*",
|
||||
"status: active",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "queue with default claim pattern",
|
||||
title: "Queue: public",
|
||||
fields: &QueueFields{
|
||||
Name: "public",
|
||||
Status: QueueStatusActive,
|
||||
},
|
||||
want: []string{
|
||||
"name: public",
|
||||
"claim_pattern: *", // Default
|
||||
"status: active",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "queue with counts",
|
||||
title: "Queue: processing",
|
||||
fields: &QueueFields{
|
||||
Name: "processing",
|
||||
ClaimPattern: "*/refinery",
|
||||
Status: QueueStatusActive,
|
||||
AvailableCount: 5,
|
||||
ProcessingCount: 2,
|
||||
CompletedCount: 10,
|
||||
FailedCount: 1,
|
||||
},
|
||||
want: []string{
|
||||
"name: processing",
|
||||
"claim_pattern: */refinery",
|
||||
"available_count: 5",
|
||||
"processing_count: 2",
|
||||
"completed_count: 10",
|
||||
"failed_count: 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil fields",
|
||||
title: "Just Title",
|
||||
fields: nil,
|
||||
want: []string{"Just Title"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatQueueDescription(tt.title, tt.fields)
|
||||
for _, line := range tt.want {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("FormatQueueDescription() missing line %q in:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueueFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
description string
|
||||
wantName string
|
||||
wantPattern string
|
||||
wantStatus string
|
||||
}{
|
||||
{
|
||||
name: "basic queue",
|
||||
description: `Queue: work-requests
|
||||
|
||||
name: work-requests
|
||||
claim_pattern: gastown/crew/*
|
||||
status: active`,
|
||||
wantName: "work-requests",
|
||||
wantPattern: "gastown/crew/*",
|
||||
wantStatus: QueueStatusActive,
|
||||
},
|
||||
{
|
||||
name: "queue with defaults",
|
||||
description: `Queue: minimal
|
||||
|
||||
name: minimal`,
|
||||
wantName: "minimal",
|
||||
wantPattern: "*", // Default
|
||||
wantStatus: QueueStatusActive,
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
description: "",
|
||||
wantName: "",
|
||||
wantPattern: "*", // Default
|
||||
wantStatus: QueueStatusActive,
|
||||
},
|
||||
{
|
||||
name: "queue with counts",
|
||||
description: `Queue: processing
|
||||
|
||||
name: processing
|
||||
claim_pattern: */refinery
|
||||
status: paused
|
||||
available_count: 5
|
||||
processing_count: 2`,
|
||||
wantName: "processing",
|
||||
wantPattern: "*/refinery",
|
||||
wantStatus: QueueStatusPaused,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseQueueFields(tt.description)
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("Name = %q, want %q", got.Name, tt.wantName)
|
||||
}
|
||||
if got.ClaimPattern != tt.wantPattern {
|
||||
t.Errorf("ClaimPattern = %q, want %q", got.ClaimPattern, tt.wantPattern)
|
||||
}
|
||||
if got.Status != tt.wantStatus {
|
||||
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueBeadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queueName string
|
||||
isTownLevel bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "town-level queue",
|
||||
queueName: "dispatch",
|
||||
isTownLevel: true,
|
||||
want: "hq-q-dispatch",
|
||||
},
|
||||
{
|
||||
name: "rig-level queue",
|
||||
queueName: "merge",
|
||||
isTownLevel: false,
|
||||
want: "gt-q-merge",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := QueueBeadID(tt.queueName, tt.isTownLevel)
|
||||
if got != tt.want {
|
||||
t.Errorf("QueueBeadID(%q, %v) = %q, want %q",
|
||||
tt.queueName, tt.isTownLevel, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
// 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 {
|
||||
if filepath.Base(workDir) == ".beads" {
|
||||
workDir = filepath.Dir(workDir)
|
||||
}
|
||||
beadsDir := filepath.Join(workDir, ".beads")
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ package beads
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -85,9 +84,13 @@ func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, erro
|
||||
"--description=" + description,
|
||||
"--labels=gt:rig",
|
||||
}
|
||||
if NeedsForceForID(id) {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -1799,3 +1800,583 @@ func TestSetupRedirect(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentBeadTombstoneBug demonstrates the bd bug where `bd delete --hard --force`
|
||||
// creates tombstones instead of truly deleting records.
|
||||
//
|
||||
//
|
||||
// This test documents the bug behavior:
|
||||
// 1. Create agent bead
|
||||
// 2. Delete with --hard --force (supposed to permanently delete)
|
||||
// 3. BUG: Tombstone is created instead
|
||||
// 4. BUG: bd create fails with UNIQUE constraint
|
||||
// 5. BUG: bd reopen fails with "issue not found" (tombstones are invisible)
|
||||
func TestAgentBeadTombstoneBug(t *testing.T) {
|
||||
// Skip: bd CLI 0.47.2 has a bug where database writes don't commit
|
||||
// ("sql: database is closed" during auto-flush). This blocks all tests
|
||||
// that need to create issues. See internal issue for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create isolated beads instance and initialize database
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
agentID := "test-testrig-polecat-tombstone"
|
||||
|
||||
// Step 1: Create agent bead
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Delete with --hard --force (supposed to permanently delete)
|
||||
err = bd.DeleteAgentBead(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: BUG - Tombstone exists (check via bd list --status=tombstone)
|
||||
out, err := bd.run("list", "--status=tombstone", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("list tombstones: %v", err)
|
||||
}
|
||||
|
||||
// Parse to check if our agent is in the tombstone list
|
||||
var tombstones []Issue
|
||||
if err := json.Unmarshal(out, &tombstones); err != nil {
|
||||
t.Fatalf("parse tombstones: %v", err)
|
||||
}
|
||||
|
||||
foundTombstone := false
|
||||
for _, ts := range tombstones {
|
||||
if ts.ID == agentID {
|
||||
foundTombstone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundTombstone {
|
||||
// If bd ever fixes the --hard flag, this test will fail here
|
||||
// That's a good thing - it means the bug is fixed!
|
||||
t.Skip("bd --hard appears to be fixed (no tombstone created) - update this test")
|
||||
}
|
||||
|
||||
// Step 4: BUG - bd create fails with UNIQUE constraint
|
||||
_, err = bd.CreateAgentBead(agentID, "Test agent 2", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected UNIQUE constraint error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
t.Errorf("expected UNIQUE constraint error, got: %v", err)
|
||||
}
|
||||
|
||||
// Step 5: BUG - bd reopen fails (tombstones are invisible)
|
||||
_, err = bd.run("reopen", agentID, "--reason=test")
|
||||
if err == nil {
|
||||
t.Fatal("expected reopen to fail on tombstone, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no issue found") && !strings.Contains(err.Error(), "issue not found") {
|
||||
t.Errorf("expected 'issue not found' error, got: %v", err)
|
||||
}
|
||||
|
||||
t.Log("BUG CONFIRMED: bd delete --hard creates tombstones that block recreation")
|
||||
}
|
||||
|
||||
// TestAgentBeadCloseReopenWorkaround demonstrates the workaround for the tombstone bug:
|
||||
// use Close instead of Delete, then Reopen works.
|
||||
func TestAgentBeadCloseReopenWorkaround(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
agentID := "test-testrig-polecat-closereopen"
|
||||
|
||||
// Step 1: Create agent bead
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Close (not delete) - this is the workaround
|
||||
err = bd.CloseAndClearAgentBead(agentID, "polecat removed")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Verify bead is closed (not tombstone)
|
||||
issue, err := bd.Show(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("Show after close: %v", err)
|
||||
}
|
||||
if issue.Status != "closed" {
|
||||
t.Errorf("status = %q, want 'closed'", issue.Status)
|
||||
}
|
||||
|
||||
// Step 4: Reopen works on closed beads
|
||||
_, err = bd.run("reopen", agentID, "--reason=re-spawning")
|
||||
if err != nil {
|
||||
t.Fatalf("reopen failed: %v", err)
|
||||
}
|
||||
|
||||
// Step 5: Verify bead is open again
|
||||
issue, err = bd.Show(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("Show after reopen: %v", err)
|
||||
}
|
||||
if issue.Status != "open" {
|
||||
t.Errorf("status = %q, want 'open'", issue.Status)
|
||||
}
|
||||
|
||||
t.Log("WORKAROUND CONFIRMED: Close + Reopen works for agent bead lifecycle")
|
||||
}
|
||||
|
||||
// TestCreateOrReopenAgentBead_ClosedBead tests that CreateOrReopenAgentBead
|
||||
// successfully reopens a closed agent bead and updates its fields.
|
||||
func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
agentID := "test-testrig-polecat-lifecycle"
|
||||
|
||||
// Simulate polecat lifecycle: spawn → nuke → respawn
|
||||
|
||||
// Spawn 1: Create agent bead with first task
|
||||
issue1, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-1",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err)
|
||||
}
|
||||
if issue1.Status != "open" {
|
||||
t.Errorf("Spawn 1: status = %q, want 'open'", issue1.Status)
|
||||
}
|
||||
|
||||
// Nuke 1: Close agent bead (workaround for tombstone bug)
|
||||
err = bd.CloseAndClearAgentBead(agentID, "polecat nuked")
|
||||
if err != nil {
|
||||
t.Fatalf("Nuke 1 - CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Spawn 2: CreateOrReopenAgentBead should reopen and update
|
||||
issue2, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-2", // Different task
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err)
|
||||
}
|
||||
if issue2.Status != "open" {
|
||||
t.Errorf("Spawn 2: status = %q, want 'open'", issue2.Status)
|
||||
}
|
||||
|
||||
// Verify the hook was updated to the new task
|
||||
fields := ParseAgentFields(issue2.Description)
|
||||
if fields.HookBead != "test-task-2" {
|
||||
t.Errorf("Spawn 2: hook_bead = %q, want 'test-task-2'", fields.HookBead)
|
||||
}
|
||||
|
||||
// Nuke 2: Close again
|
||||
err = bd.CloseAndClearAgentBead(agentID, "polecat nuked again")
|
||||
if err != nil {
|
||||
t.Fatalf("Nuke 2 - CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Spawn 3: Should still work
|
||||
issue3, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-task-3",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err)
|
||||
}
|
||||
|
||||
fields = ParseAgentFields(issue3.Description)
|
||||
if fields.HookBead != "test-task-3" {
|
||||
t.Errorf("Spawn 3: hook_bead = %q, want 'test-task-3'", fields.HookBead)
|
||||
}
|
||||
|
||||
t.Log("LIFECYCLE TEST PASSED: spawn → nuke → respawn works with close/reopen")
|
||||
}
|
||||
|
||||
// TestCloseAndClearAgentBead_FieldClearing tests that CloseAndClearAgentBead clears all mutable
|
||||
// fields to emulate delete --force --hard behavior. This ensures reopened agent
|
||||
// beads don't have stale state from previous lifecycle.
|
||||
func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
// Test cases for field clearing permutations
|
||||
tests := []struct {
|
||||
name string
|
||||
fields *AgentFields
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "all_fields_populated",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
HookBead: "test-issue-123",
|
||||
RoleBead: "test-polecat-role",
|
||||
CleanupStatus: "clean",
|
||||
ActiveMR: "test-mr-456",
|
||||
NotificationLevel: "normal",
|
||||
},
|
||||
reason: "polecat completed work",
|
||||
},
|
||||
{
|
||||
name: "only_hook_bead",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-issue-789",
|
||||
},
|
||||
reason: "polecat nuked",
|
||||
},
|
||||
{
|
||||
name: "only_active_mr",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
ActiveMR: "test-mr-abc",
|
||||
},
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "only_cleanup_status",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "idle",
|
||||
CleanupStatus: "has_uncommitted",
|
||||
},
|
||||
reason: "cleanup required",
|
||||
},
|
||||
{
|
||||
name: "no_mutable_fields",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
},
|
||||
reason: "fresh spawn closed",
|
||||
},
|
||||
{
|
||||
name: "polecat_with_all_field_types",
|
||||
fields: &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "processing",
|
||||
HookBead: "test-task-xyz",
|
||||
ActiveMR: "test-mr-processing",
|
||||
CleanupStatus: "has_uncommitted",
|
||||
NotificationLevel: "verbose",
|
||||
},
|
||||
reason: "comprehensive cleanup",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use tc.name for suffix to avoid hash-like patterns (e.g., single digits)
|
||||
// that trigger bd's isLikelyHash() prefix extraction in v0.47.1+
|
||||
agentID := fmt.Sprintf("test-testrig-%s-%s", tc.fields.RoleType, tc.name)
|
||||
|
||||
// Step 1: Create agent bead with specified fields
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", tc.fields)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields were set
|
||||
issue, err := bd.Show(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("Show before close: %v", err)
|
||||
}
|
||||
beforeFields := ParseAgentFields(issue.Description)
|
||||
if tc.fields.HookBead != "" && beforeFields.HookBead != tc.fields.HookBead {
|
||||
t.Errorf("before close: hook_bead = %q, want %q", beforeFields.HookBead, tc.fields.HookBead)
|
||||
}
|
||||
|
||||
// Step 2: Close the agent bead
|
||||
err = bd.CloseAndClearAgentBead(agentID, tc.reason)
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Verify bead is closed
|
||||
issue, err = bd.Show(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("Show after close: %v", err)
|
||||
}
|
||||
if issue.Status != "closed" {
|
||||
t.Errorf("status = %q, want 'closed'", issue.Status)
|
||||
}
|
||||
|
||||
// Step 4: Verify mutable fields were cleared
|
||||
afterFields := ParseAgentFields(issue.Description)
|
||||
|
||||
// hook_bead should be cleared (empty or "null")
|
||||
if afterFields.HookBead != "" {
|
||||
t.Errorf("after close: hook_bead = %q, want empty (was %q)", afterFields.HookBead, tc.fields.HookBead)
|
||||
}
|
||||
|
||||
// active_mr should be cleared
|
||||
if afterFields.ActiveMR != "" {
|
||||
t.Errorf("after close: active_mr = %q, want empty (was %q)", afterFields.ActiveMR, tc.fields.ActiveMR)
|
||||
}
|
||||
|
||||
// cleanup_status should be cleared
|
||||
if afterFields.CleanupStatus != "" {
|
||||
t.Errorf("after close: cleanup_status = %q, want empty (was %q)", afterFields.CleanupStatus, tc.fields.CleanupStatus)
|
||||
}
|
||||
|
||||
// agent_state should be "closed"
|
||||
if afterFields.AgentState != "closed" {
|
||||
t.Errorf("after close: agent_state = %q, want 'closed' (was %q)", afterFields.AgentState, tc.fields.AgentState)
|
||||
}
|
||||
|
||||
// Immutable fields should be preserved
|
||||
if afterFields.RoleType != tc.fields.RoleType {
|
||||
t.Errorf("after close: role_type = %q, want %q (should be preserved)", afterFields.RoleType, tc.fields.RoleType)
|
||||
}
|
||||
if afterFields.Rig != tc.fields.Rig {
|
||||
t.Errorf("after close: rig = %q, want %q (should be preserved)", afterFields.Rig, tc.fields.Rig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseAndClearAgentBead_NonExistent tests behavior when closing a non-existent agent bead.
|
||||
func TestCloseAndClearAgentBead_NonExistent(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to close non-existent bead
|
||||
err := bd.CloseAndClearAgentBead("test-nonexistent-polecat-xyz", "should fail")
|
||||
|
||||
// Should return an error (bd close on non-existent issue fails)
|
||||
if err == nil {
|
||||
t.Error("CloseAndClearAgentBead on non-existent bead should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseAndClearAgentBead_AlreadyClosed tests behavior when closing an already-closed agent bead.
|
||||
func TestCloseAndClearAgentBead_AlreadyClosed(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
agentID := "test-testrig-polecat-doubleclosed"
|
||||
|
||||
// Create agent bead
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
HookBead: "test-issue-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// First close - should succeed
|
||||
err = bd.CloseAndClearAgentBead(agentID, "first close")
|
||||
if err != nil {
|
||||
t.Fatalf("First CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Second close - behavior depends on bd close semantics
|
||||
// Document actual behavior: bd close on already-closed bead may error or be idempotent
|
||||
err = bd.CloseAndClearAgentBead(agentID, "second close")
|
||||
|
||||
// Verify bead is still closed regardless of error
|
||||
issue, showErr := bd.Show(agentID)
|
||||
if showErr != nil {
|
||||
t.Fatalf("Show after double close: %v", showErr)
|
||||
}
|
||||
if issue.Status != "closed" {
|
||||
t.Errorf("status after double close = %q, want 'closed'", issue.Status)
|
||||
}
|
||||
|
||||
// Log actual behavior for documentation
|
||||
if err != nil {
|
||||
t.Logf("BEHAVIOR: CloseAndClearAgentBead on already-closed bead returns error: %v", err)
|
||||
} else {
|
||||
t.Log("BEHAVIOR: CloseAndClearAgentBead on already-closed bead is idempotent (no error)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseAndClearAgentBead_ReopenHasCleanState tests that reopening a closed agent bead
|
||||
// starts with clean state (no stale hook_bead, active_mr, etc.).
|
||||
func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
agentID := "test-testrig-polecat-cleanreopen"
|
||||
|
||||
// Step 1: Create agent with all fields populated
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
HookBead: "test-old-issue",
|
||||
RoleBead: "test-polecat-role",
|
||||
CleanupStatus: "clean",
|
||||
ActiveMR: "test-old-mr",
|
||||
NotificationLevel: "normal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Close - should clear mutable fields
|
||||
err = bd.CloseAndClearAgentBead(agentID, "completing old work")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Reopen with new fields
|
||||
newIssue, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "spawning",
|
||||
HookBead: "test-new-issue",
|
||||
RoleBead: "test-polecat-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrReopenAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: Verify new state - should have new hook, no stale data
|
||||
fields := ParseAgentFields(newIssue.Description)
|
||||
|
||||
if fields.HookBead != "test-new-issue" {
|
||||
t.Errorf("hook_bead = %q, want 'test-new-issue'", fields.HookBead)
|
||||
}
|
||||
|
||||
// The old active_mr should NOT be present (was cleared on close)
|
||||
if fields.ActiveMR == "test-old-mr" {
|
||||
t.Error("active_mr still has stale value 'test-old-mr' - CloseAndClearAgentBead didn't clear it")
|
||||
}
|
||||
|
||||
// agent_state should be the new state
|
||||
if fields.AgentState != "spawning" {
|
||||
t.Errorf("agent_state = %q, want 'spawning'", fields.AgentState)
|
||||
}
|
||||
|
||||
t.Log("CLEAN STATE CONFIRMED: Reopened agent bead has no stale mutable fields")
|
||||
}
|
||||
|
||||
// TestCloseAndClearAgentBead_ReasonVariations tests close with different reason values.
|
||||
func TestCloseAndClearAgentBead_ReasonVariations(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
}{
|
||||
{"empty_reason", ""},
|
||||
{"simple_reason", "polecat nuked"},
|
||||
{"reason_with_spaces", "polecat completed work successfully"},
|
||||
{"reason_with_special_chars", "closed: issue #123 (resolved)"},
|
||||
{"long_reason", "This is a very long reason that explains in detail why the agent bead was closed including multiple sentences and detailed context about the situation."},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use tc.name for suffix to avoid hash-like patterns (e.g., "reason0")
|
||||
// that trigger bd's isLikelyHash() prefix extraction in v0.47.1+
|
||||
agentID := fmt.Sprintf("test-testrig-polecat-%s", tc.name)
|
||||
|
||||
// Create agent bead
|
||||
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: "testrig",
|
||||
AgentState: "running",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Close with specified reason
|
||||
err = bd.CloseAndClearAgentBead(agentID, tc.reason)
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||
}
|
||||
|
||||
// Verify closed
|
||||
issue, err := bd.Show(agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("Show: %v", err)
|
||||
}
|
||||
if issue.Status != "closed" {
|
||||
t.Errorf("status = %q, want 'closed'", issue.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +109,8 @@ func EnsureBdDaemonHealth(workDir string) string {
|
||||
|
||||
// restartBdDaemons restarts all bd daemons.
|
||||
func restartBdDaemons() error { //nolint:unparam // error return kept for future use
|
||||
// Stop all daemons first
|
||||
stopCmd := exec.Command("bd", "daemon", "killall")
|
||||
_ = stopCmd.Run() // Ignore errors - daemons might not be running
|
||||
// Stop all daemons first using pkill to avoid auto-start side effects
|
||||
_ = exec.Command("pkill", "-TERM", "-f", "bd daemon").Run()
|
||||
|
||||
// Give time for cleanup
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -125,7 +124,7 @@ func restartBdDaemons() error { //nolint:unparam // error return kept for future
|
||||
// StartBdDaemonIfNeeded starts the bd daemon for a specific workspace if not running.
|
||||
// This is a best-effort operation - failures are logged but don't block execution.
|
||||
func StartBdDaemonIfNeeded(workDir string) error {
|
||||
cmd := exec.Command("bd", "daemon", "--start")
|
||||
cmd := exec.Command("bd", "daemon", "start")
|
||||
cmd.Dir = workDir
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -159,39 +158,20 @@ func StopAllBdProcesses(dryRun, force bool) (int, int, error) {
|
||||
}
|
||||
|
||||
// CountBdDaemons returns count of running bd daemons.
|
||||
// Uses pgrep instead of "bd daemon list" to avoid triggering daemon auto-start
|
||||
// during shutdown verification.
|
||||
func CountBdDaemons() int {
|
||||
listCmd := exec.Command("bd", "daemon", "list", "--json")
|
||||
output, err := listCmd.Output()
|
||||
// Use pgrep -f with wc -l for cross-platform compatibility
|
||||
// (macOS pgrep doesn't support -c flag)
|
||||
cmd := exec.Command("sh", "-c", "pgrep -f 'bd daemon' 2>/dev/null | wc -l")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parseBdDaemonCount(output)
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
return count
|
||||
}
|
||||
|
||||
// parseBdDaemonCount parses bd daemon list --json output.
|
||||
func parseBdDaemonCount(output []byte) int {
|
||||
if len(output) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var daemons []any
|
||||
if err := json.Unmarshal(output, &daemons); err == nil {
|
||||
return len(daemons)
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Daemons []any `json:"daemons"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &wrapper); err == nil {
|
||||
if wrapper.Count > 0 {
|
||||
return wrapper.Count
|
||||
}
|
||||
return len(wrapper.Daemons)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func stopBdDaemons(force bool) (int, int) {
|
||||
before := CountBdDaemons()
|
||||
@@ -199,19 +179,11 @@ func stopBdDaemons(force bool) (int, int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
killCmd := exec.Command("bd", "daemon", "killall")
|
||||
_ = killCmd.Run()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
after := CountBdDaemons()
|
||||
if after == 0 {
|
||||
return before, 0
|
||||
}
|
||||
|
||||
// Use pkill directly instead of "bd daemon killall" to avoid triggering
|
||||
// daemon auto-start as a side effect of running bd commands.
|
||||
// Note: pkill -f pattern may match unintended processes in rare cases
|
||||
// (e.g., editors with "bd daemon" in file content). This is acceptable
|
||||
// as a fallback when bd daemon killall fails.
|
||||
// given the alternative of respawning daemons during shutdown.
|
||||
if force {
|
||||
_ = exec.Command("pkill", "-9", "-f", "bd daemon").Run()
|
||||
} else {
|
||||
|
||||
@@ -5,46 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBdDaemonCount_Array(t *testing.T) {
|
||||
input := []byte(`[{"pid":1234},{"pid":5678}]`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_ObjectWithCount(t *testing.T) {
|
||||
input := []byte(`{"count":3,"daemons":[{},{},{}]}`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_ObjectWithDaemons(t *testing.T) {
|
||||
input := []byte(`{"daemons":[{},{}]}`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_Empty(t *testing.T) {
|
||||
input := []byte(``)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBdDaemonCount_Invalid(t *testing.T) {
|
||||
input := []byte(`not json`)
|
||||
count := parseBdDaemonCount(input)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 for invalid JSON, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountBdActivityProcesses(t *testing.T) {
|
||||
count := CountBdActivityProcesses()
|
||||
if count < 0 {
|
||||
|
||||
11
internal/beads/force.go
Normal file
11
internal/beads/force.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package beads
|
||||
|
||||
import "strings"
|
||||
|
||||
// NeedsForceForID returns true when a bead ID uses multiple hyphens.
|
||||
// Recent bd versions infer the prefix from the last hyphen, which can cause
|
||||
// prefix-mismatch errors for valid system IDs like "st-stockdrop-polecat-nux"
|
||||
// and "hq-cv-abc". We pass --force to honor the explicit ID in those cases.
|
||||
func NeedsForceForID(id string) bool {
|
||||
return strings.Count(id, "-") > 1
|
||||
}
|
||||
23
internal/beads/force_test.go
Normal file
23
internal/beads/force_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package beads
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNeedsForceForID(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
want bool
|
||||
}{
|
||||
{id: "", want: false},
|
||||
{id: "hq-mayor", want: false},
|
||||
{id: "gt-abc123", want: false},
|
||||
{id: "hq-mayor-role", want: true},
|
||||
{id: "st-stockdrop-polecat-nux", want: true},
|
||||
{id: "hq-cv-abc", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := NeedsForceForID(tc.id); got != tc.want {
|
||||
t.Fatalf("NeedsForceForID(%q) = %v, want %v", tc.id, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
// Route represents a prefix-to-path routing rule.
|
||||
@@ -111,6 +113,11 @@ func RemoveRoute(townRoot string, prefix string) error {
|
||||
|
||||
// WriteRoutes writes routes to routes.jsonl, overwriting existing content.
|
||||
func WriteRoutes(beadsDir string, routes []Route) error {
|
||||
// Ensure beads directory exists
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating beads directory: %w", err)
|
||||
}
|
||||
|
||||
routesPath := filepath.Join(beadsDir, RoutesFileName)
|
||||
|
||||
file, err := os.Create(routesPath)
|
||||
@@ -150,7 +157,7 @@ func GetPrefixForRig(townRoot, rigName string) string {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
routes, err := LoadRoutes(beadsDir)
|
||||
if err != nil || routes == nil {
|
||||
return "gt" // Default prefix
|
||||
return config.GetRigPrefix(townRoot, rigName)
|
||||
}
|
||||
|
||||
// Look for a route where the path starts with the rig name
|
||||
@@ -163,7 +170,7 @@ func GetPrefixForRig(townRoot, rigName string) string {
|
||||
}
|
||||
}
|
||||
|
||||
return "gt" // Default prefix
|
||||
return config.GetRigPrefix(townRoot, rigName)
|
||||
}
|
||||
|
||||
// FindConflictingPrefixes checks for duplicate prefixes in routes.
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
func TestGetPrefixForRig(t *testing.T) {
|
||||
@@ -52,6 +54,33 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrefixForRig_RigsConfigFallback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Write rigs.json with a non-gt prefix
|
||||
rigsPath := filepath.Join(tmpDir, "mayor", "rigs.json")
|
||||
if err := os.MkdirAll(filepath.Dir(rigsPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &config.RigsConfig{
|
||||
Version: config.CurrentRigsVersion,
|
||||
Rigs: map[string]config.RigEntry{
|
||||
"project_ideas": {
|
||||
BeadsConfig: &config.BeadsConfig{Prefix: "pi"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := config.SaveRigsConfig(rigsPath, cfg); err != nil {
|
||||
t.Fatalf("SaveRigsConfig: %v", err)
|
||||
}
|
||||
|
||||
result := GetPrefixForRig(tmpDir, "project_ideas")
|
||||
if result != "pi" {
|
||||
t.Errorf("Expected prefix from rigs config, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
beadID string
|
||||
@@ -100,7 +129,7 @@ func TestGetRigPathForPrefix(t *testing.T) {
|
||||
}{
|
||||
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
|
||||
{"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")},
|
||||
{"hq-", tmpDir}, // Town-level beads return townRoot
|
||||
{"hq-", tmpDir}, // Town-level beads return townRoot
|
||||
{"unknown-", ""}, // Unknown prefix returns empty
|
||||
{"", ""}, // Empty prefix returns empty
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
@@ -41,11 +40,11 @@ type Status struct {
|
||||
|
||||
// Boot manages the Boot watchdog lifecycle.
|
||||
type Boot struct {
|
||||
townRoot string
|
||||
bootDir string // ~/gt/deacon/dogs/boot/
|
||||
deaconDir string // ~/gt/deacon/
|
||||
tmux *tmux.Tmux
|
||||
degraded bool
|
||||
townRoot string
|
||||
bootDir string // ~/gt/deacon/dogs/boot/
|
||||
deaconDir string // ~/gt/deacon/
|
||||
tmux *tmux.Tmux
|
||||
degraded bool
|
||||
}
|
||||
|
||||
// New creates a new Boot manager.
|
||||
@@ -145,7 +144,8 @@ func (b *Boot) LoadStatus() (*Status, error) {
|
||||
// Spawn starts Boot in a fresh tmux session.
|
||||
// Boot runs the mol-boot-triage molecule and exits when done.
|
||||
// In degraded mode (no tmux), it runs in a subprocess.
|
||||
func (b *Boot) Spawn() error {
|
||||
// The agentOverride parameter allows specifying an agent alias to use instead of the town default.
|
||||
func (b *Boot) Spawn(agentOverride string) error {
|
||||
if b.IsRunning() {
|
||||
return fmt.Errorf("boot is already running")
|
||||
}
|
||||
@@ -155,11 +155,11 @@ func (b *Boot) Spawn() error {
|
||||
return b.spawnDegraded()
|
||||
}
|
||||
|
||||
return b.spawnTmux()
|
||||
return b.spawnTmux(agentOverride)
|
||||
}
|
||||
|
||||
// spawnTmux spawns Boot in a tmux session.
|
||||
func (b *Boot) spawnTmux() error {
|
||||
func (b *Boot) spawnTmux(agentOverride string) error {
|
||||
// Kill any stale session first
|
||||
if b.IsSessionAlive() {
|
||||
_ = b.tmux.KillSession(SessionName)
|
||||
@@ -170,8 +170,22 @@ func (b *Boot) spawnTmux() error {
|
||||
return fmt.Errorf("ensuring boot dir: %w", err)
|
||||
}
|
||||
|
||||
// Create new session in boot directory (not deacon dir) so Claude reads Boot's CLAUDE.md
|
||||
if err := b.tmux.NewSession(SessionName, b.bootDir); err != nil {
|
||||
// Build startup command with optional agent override
|
||||
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
|
||||
var startCmd string
|
||||
if agentOverride != "" {
|
||||
var err error
|
||||
startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", "gt boot triage", agentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command with agent override: %w", err)
|
||||
}
|
||||
} else {
|
||||
startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", "gt boot triage")
|
||||
}
|
||||
|
||||
// Create session with command directly to avoid send-keys race condition.
|
||||
// See: https://github.com/anthropics/gastown/issues/280
|
||||
if err := b.tmux.NewSessionWithCommand(SessionName, b.bootDir, startCmd); err != nil {
|
||||
return fmt.Errorf("creating boot session: %w", err)
|
||||
}
|
||||
|
||||
@@ -179,24 +193,11 @@ func (b *Boot) spawnTmux() error {
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -212,7 +213,6 @@ func (b *Boot) spawnDegraded() error {
|
||||
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")
|
||||
|
||||
@@ -181,9 +181,9 @@ func (cp *Checkpoint) Age() time.Duration {
|
||||
return time.Since(cp.Timestamp)
|
||||
}
|
||||
|
||||
// IsStale returns true if the checkpoint is older than the threshold.
|
||||
// IsStale returns true if the checkpoint is at or older than the threshold.
|
||||
func (cp *Checkpoint) IsStale(threshold time.Duration) bool {
|
||||
return cp.Age() > threshold
|
||||
return cp.Age() >= threshold
|
||||
}
|
||||
|
||||
// Summary returns a concise summary of the checkpoint.
|
||||
|
||||
167
internal/cmd/bead.go
Normal file
167
internal/cmd/bead.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var beadCmd = &cobra.Command{
|
||||
Use: "bead",
|
||||
GroupID: GroupWork,
|
||||
Short: "Bead management utilities",
|
||||
Long: `Utilities for managing beads across repositories.`,
|
||||
}
|
||||
|
||||
var beadMoveCmd = &cobra.Command{
|
||||
Use: "move <bead-id> <target-prefix>",
|
||||
Short: "Move a bead to a different repository",
|
||||
Long: `Move a bead from one repository to another.
|
||||
|
||||
This creates a copy of the bead in the target repository (with the new prefix)
|
||||
and closes the source bead with a reference to the new location.
|
||||
|
||||
The target prefix determines which repository receives the bead.
|
||||
Common prefixes: gt- (gastown), bd- (beads), hq- (headquarters)
|
||||
|
||||
Examples:
|
||||
gt bead move gt-abc123 bd- # Move gt-abc123 to beads repo as bd-*
|
||||
gt bead move hq-xyz bd- # Move hq-xyz to beads repo
|
||||
gt bead move bd-123 gt- # Move bd-123 to gastown repo`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runBeadMove,
|
||||
}
|
||||
|
||||
var beadMoveDryRun bool
|
||||
|
||||
var beadShowCmd = &cobra.Command{
|
||||
Use: "show <bead-id> [flags]",
|
||||
Short: "Show details of a bead",
|
||||
Long: `Displays the full details of a bead by ID.
|
||||
|
||||
This is an alias for 'gt show'. All bd show flags are supported.
|
||||
|
||||
Examples:
|
||||
gt bead show gt-abc123 # Show a gastown issue
|
||||
gt bead show hq-xyz789 # Show a town-level bead
|
||||
gt bead show bd-def456 # Show a beads issue
|
||||
gt bead show gt-abc123 --json # Output as JSON`,
|
||||
DisableFlagParsing: true, // Pass all flags through to bd show
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runShow(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
beadMoveCmd.Flags().BoolVarP(&beadMoveDryRun, "dry-run", "n", false, "Show what would be done")
|
||||
beadCmd.AddCommand(beadMoveCmd)
|
||||
beadCmd.AddCommand(beadShowCmd)
|
||||
rootCmd.AddCommand(beadCmd)
|
||||
}
|
||||
|
||||
// moveBeadInfo holds the essential fields we need to copy when moving beads
|
||||
type moveBeadInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"issue_type"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
Labels []string `json:"labels"`
|
||||
Assignee string `json:"assignee"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func runBeadMove(cmd *cobra.Command, args []string) error {
|
||||
sourceID := args[0]
|
||||
targetPrefix := args[1]
|
||||
|
||||
// Normalize prefix (ensure it ends with -)
|
||||
if !strings.HasSuffix(targetPrefix, "-") {
|
||||
targetPrefix = targetPrefix + "-"
|
||||
}
|
||||
|
||||
// Get source bead details
|
||||
showCmd := exec.Command("bd", "show", sourceID, "--json")
|
||||
output, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting bead %s: %w", sourceID, err)
|
||||
}
|
||||
|
||||
// bd show --json returns an array
|
||||
var sources []moveBeadInfo
|
||||
if err := json.Unmarshal(output, &sources); err != nil {
|
||||
return fmt.Errorf("parsing bead data: %w", err)
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return fmt.Errorf("bead %s not found", sourceID)
|
||||
}
|
||||
source := sources[0]
|
||||
|
||||
// Don't move closed beads
|
||||
if source.Status == "closed" {
|
||||
return fmt.Errorf("cannot move closed bead %s", sourceID)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Moving %s to %s...\n", style.Bold.Render("→"), sourceID, targetPrefix)
|
||||
fmt.Printf(" Title: %s\n", source.Title)
|
||||
fmt.Printf(" Type: %s\n", source.Type)
|
||||
|
||||
if beadMoveDryRun {
|
||||
fmt.Printf("\nDry run - would:\n")
|
||||
fmt.Printf(" 1. Create new bead with prefix %s\n", targetPrefix)
|
||||
fmt.Printf(" 2. Close %s with reference to new bead\n", sourceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build create command for target
|
||||
createArgs := []string{
|
||||
"create",
|
||||
"--prefix", targetPrefix,
|
||||
"--title", source.Title,
|
||||
"--type", source.Type,
|
||||
"--priority", fmt.Sprintf("%d", source.Priority),
|
||||
"--silent", // Only output the ID
|
||||
}
|
||||
|
||||
if source.Description != "" {
|
||||
createArgs = append(createArgs, "--description", source.Description)
|
||||
}
|
||||
if source.Assignee != "" {
|
||||
createArgs = append(createArgs, "--assignee", source.Assignee)
|
||||
}
|
||||
for _, label := range source.Labels {
|
||||
createArgs = append(createArgs, "--label", label)
|
||||
}
|
||||
|
||||
// Create the new bead
|
||||
createCmd := exec.Command("bd", createArgs...)
|
||||
createCmd.Stderr = os.Stderr
|
||||
newIDBytes, err := createCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating new bead: %w", err)
|
||||
}
|
||||
newID := strings.TrimSpace(string(newIDBytes))
|
||||
|
||||
fmt.Printf("%s Created %s\n", style.Bold.Render("✓"), newID)
|
||||
|
||||
// Close the source bead with reference
|
||||
closeReason := fmt.Sprintf("Moved to %s", newID)
|
||||
closeCmd := exec.Command("bd", "close", sourceID, "--reason", closeReason)
|
||||
closeCmd.Stderr = os.Stderr
|
||||
if err := closeCmd.Run(); err != nil {
|
||||
// Try to clean up the new bead if close fails
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close source bead: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "New bead %s was created but source %s remains open\n", newID, sourceID)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s Closed %s (moved to %s)\n", style.Bold.Render("✓"), sourceID, newID)
|
||||
fmt.Printf("\nBead moved: %s → %s\n", sourceID, newID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinBeadsVersion is the minimum required beads version for Gas Town.
|
||||
@@ -84,10 +87,19 @@ func (v beadsVersion) compare(other beadsVersion) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Pre-compiled regex for beads version parsing
|
||||
var beadsVersionRe = regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
||||
|
||||
func getBeadsVersion() (string, error) {
|
||||
cmd := exec.Command("bd", "version")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bd", "version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("bd version check timed out")
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
|
||||
}
|
||||
@@ -96,8 +108,7 @@ func getBeadsVersion() (string, error) {
|
||||
|
||||
// Parse output like "bd version 0.44.0 (dev)"
|
||||
// or "bd version 0.44.0"
|
||||
re := regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
||||
matches := re.FindStringSubmatch(string(output))
|
||||
matches := beadsVersionRe.FindStringSubmatch(string(output))
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
@@ -105,9 +116,22 @@ func getBeadsVersion() (string, error) {
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
var (
|
||||
cachedVersionCheckResult error
|
||||
versionCheckOnce sync.Once
|
||||
)
|
||||
|
||||
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
|
||||
// Returns nil if the version is sufficient, or an error with details if not.
|
||||
// The check is performed only once per process execution.
|
||||
func CheckBeadsVersion() error {
|
||||
versionCheckOnce.Do(func() {
|
||||
cachedVersionCheckResult = checkBeadsVersionInternal()
|
||||
})
|
||||
return cachedVersionCheckResult
|
||||
}
|
||||
|
||||
func checkBeadsVersionInternal() error {
|
||||
installedStr, err := getBeadsVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot verify beads version: %w", err)
|
||||
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
bootStatusJSON bool
|
||||
bootDegraded bool
|
||||
bootStatusJSON bool
|
||||
bootDegraded bool
|
||||
bootAgentOverride string
|
||||
)
|
||||
|
||||
var bootCmd = &cobra.Command{
|
||||
@@ -84,6 +85,7 @@ Use --degraded flag when running in degraded mode.`,
|
||||
func init() {
|
||||
bootStatusCmd.Flags().BoolVar(&bootStatusJSON, "json", false, "Output as JSON")
|
||||
bootTriageCmd.Flags().BoolVar(&bootDegraded, "degraded", false, "Run in degraded mode (no tmux)")
|
||||
bootSpawnCmd.Flags().StringVar(&bootAgentOverride, "agent", "", "Agent alias to run Boot with (overrides town default)")
|
||||
|
||||
bootCmd.AddCommand(bootStatusCmd)
|
||||
bootCmd.AddCommand(bootSpawnCmd)
|
||||
@@ -206,7 +208,7 @@ func runBootSpawn(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Spawn Boot
|
||||
if err := b.Spawn(); err != nil {
|
||||
if err := b.Spawn(bootAgentOverride); err != nil {
|
||||
status.Error = err.Error()
|
||||
status.CompletedAt = time.Now()
|
||||
status.Running = false
|
||||
|
||||
19
internal/cmd/boot_test.go
Normal file
19
internal/cmd/boot_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootSpawnAgentFlag(t *testing.T) {
|
||||
flag := bootSpawnCmd.Flags().Lookup("agent")
|
||||
if flag == nil {
|
||||
t.Fatal("expected boot spawn 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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -55,6 +56,9 @@ func runBroadcast(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("listing sessions: %w", err)
|
||||
}
|
||||
|
||||
// Get sender identity to exclude self
|
||||
sender := os.Getenv("BD_ACTOR")
|
||||
|
||||
// Filter to target agents
|
||||
var targets []*AgentSession
|
||||
for _, agent := range agents {
|
||||
@@ -70,6 +74,11 @@ func runBroadcast(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip self to avoid interrupting own session
|
||||
if sender != "" && formatAgentName(agent) == sender {
|
||||
continue
|
||||
}
|
||||
|
||||
targets = append(targets, agent)
|
||||
}
|
||||
|
||||
|
||||
66
internal/cmd/cat.go
Normal file
66
internal/cmd/cat.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var catJSON bool
|
||||
|
||||
var catCmd = &cobra.Command{
|
||||
Use: "cat <bead-id>",
|
||||
GroupID: GroupWork,
|
||||
Short: "Display bead content",
|
||||
Long: `Display the content of a bead (issue, task, molecule, etc.).
|
||||
|
||||
This is a convenience wrapper around 'bd show' that integrates with gt.
|
||||
Accepts any bead ID (bd-*, hq-*, mol-*).
|
||||
|
||||
Examples:
|
||||
gt cat bd-abc123 # Show a bead
|
||||
gt cat hq-xyz789 # Show a town-level bead
|
||||
gt cat bd-abc --json # Output as JSON`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runCat,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(catCmd)
|
||||
catCmd.Flags().BoolVar(&catJSON, "json", false, "Output as JSON")
|
||||
}
|
||||
|
||||
func runCat(cmd *cobra.Command, args []string) error {
|
||||
beadID := args[0]
|
||||
|
||||
// Validate it looks like a bead ID
|
||||
if !isBeadID(beadID) {
|
||||
return fmt.Errorf("invalid bead ID %q (expected bd-*, hq-*, or mol-* prefix)", beadID)
|
||||
}
|
||||
|
||||
// Build bd show command
|
||||
bdArgs := []string{"show", beadID}
|
||||
if catJSON {
|
||||
bdArgs = append(bdArgs, "--json")
|
||||
}
|
||||
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
bdCmd.Stdout = os.Stdout
|
||||
bdCmd.Stderr = os.Stderr
|
||||
|
||||
return bdCmd.Run()
|
||||
}
|
||||
|
||||
// isBeadID checks if a string looks like a bead ID.
|
||||
func isBeadID(s string) bool {
|
||||
prefixes := []string{"bd-", "hq-", "mol-"}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
40
internal/cmd/close.go
Normal file
40
internal/cmd/close.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var closeCmd = &cobra.Command{
|
||||
Use: "close [bead-id...]",
|
||||
GroupID: GroupWork,
|
||||
Short: "Close one or more beads",
|
||||
Long: `Close one or more beads (wrapper for 'bd close').
|
||||
|
||||
This is a convenience command that passes through to 'bd close' with
|
||||
all arguments and flags preserved.
|
||||
|
||||
Examples:
|
||||
gt close gt-abc # Close bead gt-abc
|
||||
gt close gt-abc gt-def # Close multiple beads
|
||||
gt close --reason "Done" # Close with reason
|
||||
gt close --force # Force close pinned beads`,
|
||||
DisableFlagParsing: true, // Pass all flags through to bd close
|
||||
RunE: runClose,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(closeCmd)
|
||||
}
|
||||
|
||||
func runClose(cmd *cobra.Command, args []string) error {
|
||||
// Build bd close command with all args passed through
|
||||
bdArgs := append([]string{"close"}, args...)
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
bdCmd.Stdin = os.Stdin
|
||||
bdCmd.Stdout = os.Stdout
|
||||
bdCmd.Stderr = os.Stderr
|
||||
return bdCmd.Run()
|
||||
}
|
||||
118
internal/cmd/commit.go
Normal file
118
internal/cmd/commit.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// DefaultAgentEmailDomain is the default domain for agent git emails.
|
||||
const DefaultAgentEmailDomain = "gastown.local"
|
||||
|
||||
var commitCmd = &cobra.Command{
|
||||
Use: "commit [flags] [-- git-commit-args...]",
|
||||
Short: "Git commit with automatic agent identity",
|
||||
Long: `Git commit wrapper that automatically sets git author identity for agents.
|
||||
|
||||
When run by an agent (GT_ROLE set), this command:
|
||||
1. Detects the agent identity from environment variables
|
||||
2. Converts it to a git-friendly name and email
|
||||
3. Runs 'git commit' with the correct identity
|
||||
|
||||
The email domain is configurable in town settings (agent_email_domain).
|
||||
Default: gastown.local
|
||||
|
||||
Examples:
|
||||
gt commit -m "Fix bug" # Commit as current agent
|
||||
gt commit -am "Quick fix" # Stage all and commit
|
||||
gt commit -- --amend # Amend last commit
|
||||
|
||||
Identity mapping:
|
||||
Agent: gastown/crew/jack → Name: gastown/crew/jack
|
||||
Email: gastown.crew.jack@gastown.local
|
||||
|
||||
When run without GT_ROLE (human), passes through to git commit with no changes.`,
|
||||
RunE: runCommit,
|
||||
DisableFlagParsing: true, // We'll parse flags ourselves to pass them to git
|
||||
}
|
||||
|
||||
func init() {
|
||||
commitCmd.GroupID = GroupWork
|
||||
rootCmd.AddCommand(commitCmd)
|
||||
}
|
||||
|
||||
func runCommit(cmd *cobra.Command, args []string) error {
|
||||
// Detect agent identity
|
||||
identity := detectSender()
|
||||
|
||||
// If overseer (human), just pass through to git commit
|
||||
if identity == "overseer" {
|
||||
return runGitCommit(args, "", "")
|
||||
}
|
||||
|
||||
// Load agent email domain from town settings
|
||||
domain := DefaultAgentEmailDomain
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err == nil && townRoot != "" {
|
||||
settings, err := config.LoadOrCreateTownSettings(config.TownSettingsPath(townRoot))
|
||||
if err == nil && settings.AgentEmailDomain != "" {
|
||||
domain = settings.AgentEmailDomain
|
||||
}
|
||||
}
|
||||
|
||||
// Convert identity to git-friendly email
|
||||
// "gastown/crew/jack" → "gastown.crew.jack@domain"
|
||||
email := identityToEmail(identity, domain)
|
||||
|
||||
// Use identity as the author name (human-readable)
|
||||
name := identity
|
||||
|
||||
return runGitCommit(args, name, email)
|
||||
}
|
||||
|
||||
// identityToEmail converts a Gas Town identity to a git email address.
|
||||
// "gastown/crew/jack" → "gastown.crew.jack@domain"
|
||||
// "mayor/" → "mayor@domain"
|
||||
func identityToEmail(identity, domain string) string {
|
||||
// Remove trailing slash if present
|
||||
identity = strings.TrimSuffix(identity, "/")
|
||||
|
||||
// Replace slashes with dots for email local part
|
||||
localPart := strings.ReplaceAll(identity, "/", ".")
|
||||
|
||||
return localPart + "@" + domain
|
||||
}
|
||||
|
||||
// runGitCommit executes git commit with optional identity override.
|
||||
// If name and email are empty, runs git commit with no overrides.
|
||||
// Preserves git's exit code for proper wrapper behavior.
|
||||
func runGitCommit(args []string, name, email string) error {
|
||||
var gitArgs []string
|
||||
|
||||
// If we have an identity, prepend -c flags
|
||||
if name != "" && email != "" {
|
||||
gitArgs = append(gitArgs, "-c", "user.name="+name)
|
||||
gitArgs = append(gitArgs, "-c", "user.email="+email)
|
||||
}
|
||||
|
||||
gitArgs = append(gitArgs, "commit")
|
||||
gitArgs = append(gitArgs, args...)
|
||||
|
||||
gitCmd := exec.Command("git", gitArgs...)
|
||||
gitCmd.Stdin = os.Stdin
|
||||
gitCmd.Stdout = os.Stdout
|
||||
gitCmd.Stderr = os.Stderr
|
||||
|
||||
if err := gitCmd.Run(); err != nil {
|
||||
// Preserve git's exit code for proper wrapper behavior
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
71
internal/cmd/commit_test.go
Normal file
71
internal/cmd/commit_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentityToEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity string
|
||||
domain string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "crew member",
|
||||
identity: "gastown/crew/jack",
|
||||
domain: "gastown.local",
|
||||
want: "gastown.crew.jack@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "polecat",
|
||||
identity: "gastown/polecats/max",
|
||||
domain: "gastown.local",
|
||||
want: "gastown.polecats.max@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "witness",
|
||||
identity: "gastown/witness",
|
||||
domain: "gastown.local",
|
||||
want: "gastown.witness@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "refinery",
|
||||
identity: "gastown/refinery",
|
||||
domain: "gastown.local",
|
||||
want: "gastown.refinery@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "mayor with trailing slash",
|
||||
identity: "mayor/",
|
||||
domain: "gastown.local",
|
||||
want: "mayor@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "deacon with trailing slash",
|
||||
identity: "deacon/",
|
||||
domain: "gastown.local",
|
||||
want: "deacon@gastown.local",
|
||||
},
|
||||
{
|
||||
name: "custom domain",
|
||||
identity: "myrig/crew/alice",
|
||||
domain: "example.com",
|
||||
want: "myrig.crew.alice@example.com",
|
||||
},
|
||||
{
|
||||
name: "deeply nested",
|
||||
identity: "rig/polecats/nested/deep",
|
||||
domain: "test.io",
|
||||
want: "rig.polecats.nested.deep@test.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := identityToEmail(tt.identity, tt.domain)
|
||||
if got != tt.want {
|
||||
t.Errorf("identityToEmail(%q, %q) = %q, want %q",
|
||||
tt.identity, tt.domain, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,27 @@ Examples:
|
||||
RunE: runConfigDefaultAgent,
|
||||
}
|
||||
|
||||
var configAgentEmailDomainCmd = &cobra.Command{
|
||||
Use: "agent-email-domain [domain]",
|
||||
Short: "Get or set agent email domain",
|
||||
Long: `Get or set the domain used for agent git commit emails.
|
||||
|
||||
When agents commit code via 'gt commit', their identity is converted
|
||||
to a git email address. For example, "gastown/crew/jack" becomes
|
||||
"gastown.crew.jack@{domain}".
|
||||
|
||||
With no arguments, shows the current domain.
|
||||
With an argument, sets the domain.
|
||||
|
||||
Default: gastown.local
|
||||
|
||||
Examples:
|
||||
gt config agent-email-domain # Show current domain
|
||||
gt config agent-email-domain gastown.local # Set to gastown.local
|
||||
gt config agent-email-domain example.com # Set custom domain`,
|
||||
RunE: runConfigAgentEmailDomain,
|
||||
}
|
||||
|
||||
// Flags
|
||||
var (
|
||||
configAgentListJSON bool
|
||||
@@ -444,6 +465,54 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigAgentEmailDomain(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
|
||||
// Load town settings
|
||||
settingsPath := config.TownSettingsPath(townRoot)
|
||||
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading town settings: %w", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
// Show current domain
|
||||
domain := townSettings.AgentEmailDomain
|
||||
if domain == "" {
|
||||
domain = DefaultAgentEmailDomain
|
||||
}
|
||||
fmt.Printf("Agent email domain: %s\n", style.Bold.Render(domain))
|
||||
fmt.Printf("\nExample: gastown/crew/jack → gastown.crew.jack@%s\n", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set new domain
|
||||
domain := args[0]
|
||||
|
||||
// Basic validation - domain should not be empty and should not start with @
|
||||
if domain == "" {
|
||||
return fmt.Errorf("domain cannot be empty")
|
||||
}
|
||||
if strings.HasPrefix(domain, "@") {
|
||||
return fmt.Errorf("domain should not include @: use '%s' instead", strings.TrimPrefix(domain, "@"))
|
||||
}
|
||||
|
||||
// Set domain
|
||||
townSettings.AgentEmailDomain = domain
|
||||
|
||||
// Save settings
|
||||
if err := config.SaveTownSettings(settingsPath, townSettings); err != nil {
|
||||
return fmt.Errorf("saving town settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent email domain set to '%s'\n", style.Bold.Render(domain))
|
||||
fmt.Printf("\nExample: gastown/crew/jack → gastown.crew.jack@%s\n", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add flags
|
||||
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
|
||||
@@ -462,6 +531,7 @@ func init() {
|
||||
// Add subcommands to config
|
||||
configCmd.AddCommand(configAgentCmd)
|
||||
configCmd.AddCommand(configDefaultAgentCmd)
|
||||
configCmd.AddCommand(configAgentEmailDomainCmd)
|
||||
|
||||
// Register with root
|
||||
rootCmd.AddCommand(configCmd)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tui/convoy"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -62,6 +63,7 @@ func looksLikeIssueID(s string) bool {
|
||||
var (
|
||||
convoyMolecule string
|
||||
convoyNotify string
|
||||
convoyOwner string
|
||||
convoyStatusJSON bool
|
||||
convoyListJSON bool
|
||||
convoyListStatus string
|
||||
@@ -69,6 +71,8 @@ var (
|
||||
convoyListTree bool
|
||||
convoyInteractive bool
|
||||
convoyStrandedJSON bool
|
||||
convoyCloseReason string
|
||||
convoyCloseNotify string
|
||||
)
|
||||
|
||||
var convoyCmd = &cobra.Command{
|
||||
@@ -106,6 +110,7 @@ TRACKING SEMANTICS:
|
||||
COMMANDS:
|
||||
create Create a convoy tracking specified issues
|
||||
add Add issues to an existing convoy (reopens if closed)
|
||||
close Close a convoy (manually, regardless of tracked issue status)
|
||||
status Show convoy progress, tracked issues, and active workers
|
||||
list List convoys (the dashboard view)`,
|
||||
}
|
||||
@@ -118,10 +123,15 @@ var convoyCreateCmd = &cobra.Command{
|
||||
The convoy is created in town-level beads (hq-* prefix) and can track
|
||||
issues across any rig.
|
||||
|
||||
The --owner flag specifies who requested the convoy (receives completion
|
||||
notification by default). If not specified, defaults to created_by.
|
||||
The --notify flag adds additional subscribers beyond the owner.
|
||||
|
||||
Examples:
|
||||
gt convoy create "Deploy v2.0" gt-abc bd-xyz
|
||||
gt convoy create "Release prep" gt-abc --notify # defaults to mayor/
|
||||
gt convoy create "Release prep" gt-abc --notify ops/ # notify ops/
|
||||
gt convoy create "Feature rollout" gt-a gt-b --owner mayor/ --notify ops/
|
||||
gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runConvoyCreate,
|
||||
@@ -199,10 +209,31 @@ Examples:
|
||||
RunE: runConvoyStranded,
|
||||
}
|
||||
|
||||
var convoyCloseCmd = &cobra.Command{
|
||||
Use: "close <convoy-id>",
|
||||
Short: "Close a convoy",
|
||||
Long: `Close a convoy, optionally with a reason.
|
||||
|
||||
Closes the convoy regardless of tracked issue status. Use this to:
|
||||
- Force-close abandoned convoys no longer relevant
|
||||
- Close convoys where work completed outside the tracked path
|
||||
- Manually close stuck convoys
|
||||
|
||||
The close is idempotent - closing an already-closed convoy is a no-op.
|
||||
|
||||
Examples:
|
||||
gt convoy close hq-cv-abc
|
||||
gt convoy close hq-cv-abc --reason="work done differently"
|
||||
gt convoy close hq-cv-xyz --notify mayor/`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runConvoyClose,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Create flags
|
||||
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
|
||||
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Address to notify on completion (default: mayor/ if flag used without value)")
|
||||
convoyCreateCmd.Flags().StringVar(&convoyOwner, "owner", "", "Owner who requested convoy (gets completion notification)")
|
||||
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Additional address to notify on completion (default: mayor/ if flag used without value)")
|
||||
convoyCreateCmd.Flags().Lookup("notify").NoOptDefVal = "mayor/"
|
||||
|
||||
// Status flags
|
||||
@@ -220,6 +251,10 @@ func init() {
|
||||
// Stranded flags
|
||||
convoyStrandedCmd.Flags().BoolVar(&convoyStrandedJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Close flags
|
||||
convoyCloseCmd.Flags().StringVar(&convoyCloseReason, "reason", "", "Reason for closing the convoy")
|
||||
convoyCloseCmd.Flags().StringVar(&convoyCloseNotify, "notify", "", "Agent to notify on close (e.g., mayor/)")
|
||||
|
||||
// Add subcommands
|
||||
convoyCmd.AddCommand(convoyCreateCmd)
|
||||
convoyCmd.AddCommand(convoyStatusCmd)
|
||||
@@ -227,6 +262,7 @@ func init() {
|
||||
convoyCmd.AddCommand(convoyAddCmd)
|
||||
convoyCmd.AddCommand(convoyCheckCmd)
|
||||
convoyCmd.AddCommand(convoyStrandedCmd)
|
||||
convoyCmd.AddCommand(convoyCloseCmd)
|
||||
|
||||
rootCmd.AddCommand(convoyCmd)
|
||||
}
|
||||
@@ -263,6 +299,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Create convoy issue in town beads
|
||||
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
|
||||
if convoyOwner != "" {
|
||||
description += fmt.Sprintf("\nOwner: %s", convoyOwner)
|
||||
}
|
||||
if convoyNotify != "" {
|
||||
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
|
||||
}
|
||||
@@ -281,6 +320,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
"--description=" + description,
|
||||
"--json",
|
||||
}
|
||||
if beads.NeedsForceForID(convoyID) {
|
||||
createArgs = append(createArgs, "--force")
|
||||
}
|
||||
|
||||
createCmd := exec.Command("bd", createArgs...)
|
||||
createCmd.Dir = townBeads
|
||||
@@ -302,9 +344,15 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
||||
depCmd := exec.Command("bd", depArgs...)
|
||||
depCmd.Dir = townBeads
|
||||
var depStderr bytes.Buffer
|
||||
depCmd.Stderr = &depStderr
|
||||
|
||||
if err := depCmd.Run(); err != nil {
|
||||
style.PrintWarning("couldn't track %s: %v", issueID, err)
|
||||
errMsg := strings.TrimSpace(depStderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
style.PrintWarning("couldn't track %s: %s", issueID, errMsg)
|
||||
} else {
|
||||
trackedCount++
|
||||
}
|
||||
@@ -317,6 +365,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
if len(trackedIssues) > 0 {
|
||||
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
|
||||
}
|
||||
if convoyOwner != "" {
|
||||
fmt.Printf(" Owner: %s\n", convoyOwner)
|
||||
}
|
||||
if convoyNotify != "" {
|
||||
fmt.Printf(" Notify: %s\n", convoyNotify)
|
||||
}
|
||||
@@ -389,9 +440,15 @@ func runConvoyAdd(cmd *cobra.Command, args []string) error {
|
||||
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
||||
depCmd := exec.Command("bd", depArgs...)
|
||||
depCmd.Dir = townBeads
|
||||
var depStderr bytes.Buffer
|
||||
depCmd.Stderr = &depStderr
|
||||
|
||||
if err := depCmd.Run(); err != nil {
|
||||
style.PrintWarning("couldn't add %s: %v", issueID, err)
|
||||
errMsg := strings.TrimSpace(depStderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
style.PrintWarning("couldn't add %s: %s", issueID, errMsg)
|
||||
} else {
|
||||
addedCount++
|
||||
}
|
||||
@@ -432,6 +489,98 @@ func runConvoyCheck(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConvoyClose(cmd *cobra.Command, args []string) error {
|
||||
convoyID := args[0]
|
||||
|
||||
townBeads, err := getTownBeadsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get convoy details
|
||||
showArgs := []string{"show", convoyID, "--json"}
|
||||
showCmd := exec.Command("bd", showArgs...)
|
||||
showCmd.Dir = townBeads
|
||||
var stdout bytes.Buffer
|
||||
showCmd.Stdout = &stdout
|
||||
|
||||
if err := showCmd.Run(); err != nil {
|
||||
return fmt.Errorf("convoy '%s' not found", convoyID)
|
||||
}
|
||||
|
||||
var convoys []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"issue_type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
|
||||
return fmt.Errorf("parsing convoy data: %w", err)
|
||||
}
|
||||
|
||||
if len(convoys) == 0 {
|
||||
return fmt.Errorf("convoy '%s' not found", convoyID)
|
||||
}
|
||||
|
||||
convoy := convoys[0]
|
||||
|
||||
// Verify it's actually a convoy type
|
||||
if convoy.Type != "convoy" {
|
||||
return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type)
|
||||
}
|
||||
|
||||
// Idempotent: if already closed, just report it
|
||||
if convoy.Status == "closed" {
|
||||
fmt.Printf("%s Convoy %s is already closed\n", style.Dim.Render("○"), convoyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build close reason
|
||||
reason := convoyCloseReason
|
||||
if reason == "" {
|
||||
reason = "Manually closed"
|
||||
}
|
||||
|
||||
// Close the convoy
|
||||
closeArgs := []string{"close", convoyID, "-r", reason}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
closeCmd.Dir = townBeads
|
||||
|
||||
if err := closeCmd.Run(); err != nil {
|
||||
return fmt.Errorf("closing convoy: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Closed convoy 🚚 %s: %s\n", style.Bold.Render("✓"), convoyID, convoy.Title)
|
||||
if convoyCloseReason != "" {
|
||||
fmt.Printf(" Reason: %s\n", convoyCloseReason)
|
||||
}
|
||||
|
||||
// Send notification if --notify flag provided
|
||||
if convoyCloseNotify != "" {
|
||||
sendCloseNotification(convoyCloseNotify, convoyID, convoy.Title, reason)
|
||||
} else {
|
||||
// Check if convoy has a notify address in description
|
||||
notifyConvoyCompletion(townBeads, convoyID, convoy.Title)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendCloseNotification sends a notification about convoy closure.
|
||||
func sendCloseNotification(addr, convoyID, title, reason string) {
|
||||
subject := fmt.Sprintf("🚚 Convoy closed: %s", title)
|
||||
body := fmt.Sprintf("Convoy %s has been closed.\n\nReason: %s", convoyID, reason)
|
||||
|
||||
mailArgs := []string{"mail", "send", addr, "-s", subject, "-m", body}
|
||||
mailCmd := exec.Command("gt", mailArgs...)
|
||||
if err := mailCmd.Run(); err != nil {
|
||||
style.PrintWarning("couldn't send notification: %v", err)
|
||||
} else {
|
||||
fmt.Printf(" Notified: %s\n", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// strandedConvoyInfo holds info about a stranded convoy.
|
||||
type strandedConvoyInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -666,9 +815,9 @@ func checkAndCloseCompletedConvoys(townBeads string) ([]struct{ ID, Title string
|
||||
return closed, nil
|
||||
}
|
||||
|
||||
// notifyConvoyCompletion sends a notification if the convoy has a notify address.
|
||||
// notifyConvoyCompletion sends notifications to owner and any notify addresses.
|
||||
func notifyConvoyCompletion(townBeads, convoyID, title string) {
|
||||
// Get convoy description to find notify address
|
||||
// Get convoy description to find owner and notify addresses
|
||||
showArgs := []string{"show", convoyID, "--json"}
|
||||
showCmd := exec.Command("bd", showArgs...)
|
||||
showCmd.Dir = townBeads
|
||||
@@ -686,20 +835,26 @@ func notifyConvoyCompletion(townBeads, convoyID, title string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse notify address from description
|
||||
// Parse owner and notify addresses from description
|
||||
desc := convoys[0].Description
|
||||
notified := make(map[string]bool) // Track who we've notified to avoid duplicates
|
||||
|
||||
for _, line := range strings.Split(desc, "\n") {
|
||||
if strings.HasPrefix(line, "Notify: ") {
|
||||
addr := strings.TrimPrefix(line, "Notify: ")
|
||||
if addr != "" {
|
||||
// Send notification via gt mail
|
||||
mailArgs := []string{"mail", "send", addr,
|
||||
"-s", fmt.Sprintf("🚚 Convoy landed: %s", title),
|
||||
"-m", fmt.Sprintf("Convoy %s has completed.\n\nAll tracked issues are now closed.", convoyID)}
|
||||
mailCmd := exec.Command("gt", mailArgs...)
|
||||
_ = mailCmd.Run() // Best effort, ignore errors
|
||||
}
|
||||
break
|
||||
var addr string
|
||||
if strings.HasPrefix(line, "Owner: ") {
|
||||
addr = strings.TrimPrefix(line, "Owner: ")
|
||||
} else if strings.HasPrefix(line, "Notify: ") {
|
||||
addr = strings.TrimPrefix(line, "Notify: ")
|
||||
}
|
||||
|
||||
if addr != "" && !notified[addr] {
|
||||
// Send notification via gt mail
|
||||
mailArgs := []string{"mail", "send", addr,
|
||||
"-s", fmt.Sprintf("🚚 Convoy landed: %s", title),
|
||||
"-m", fmt.Sprintf("Convoy %s has completed.\n\nAll tracked issues are now closed.", convoyID)}
|
||||
mailCmd := exec.Command("gt", mailArgs...)
|
||||
_ = mailCmd.Run() // Best effort, ignore errors
|
||||
notified[addr] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,22 @@ var (
|
||||
var costsCmd = &cobra.Command{
|
||||
Use: "costs",
|
||||
GroupID: GroupDiag,
|
||||
Short: "Show costs for running Claude sessions",
|
||||
Short: "Show costs for running Claude sessions [DISABLED]",
|
||||
Long: `Display costs for Claude Code sessions in Gas Town.
|
||||
|
||||
By default, shows live costs scraped from running tmux sessions.
|
||||
⚠️ COST TRACKING IS CURRENTLY DISABLED
|
||||
|
||||
Cost tracking uses ephemeral wisps for individual sessions that are
|
||||
aggregated into daily "Cost Report" digest beads for audit purposes.
|
||||
Claude Code displays costs in the TUI status bar, which cannot be captured
|
||||
via tmux. All sessions will show $0.00 until Claude Code exposes cost data
|
||||
through an API or environment variable.
|
||||
|
||||
What we need from Claude Code:
|
||||
- Stop hook env var (e.g., $CLAUDE_SESSION_COST)
|
||||
- Or queryable file/API endpoint
|
||||
|
||||
See: GH#24, gt-7awfj
|
||||
|
||||
The infrastructure remains in place and will work once cost data is available.
|
||||
|
||||
Examples:
|
||||
gt costs # Live costs from running sessions
|
||||
@@ -194,6 +203,11 @@ func runCosts(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runLiveCosts() error {
|
||||
// Warn that cost tracking is disabled
|
||||
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
|
||||
style.Warning.Render("⚠"))
|
||||
fmt.Fprintf(os.Stderr, " All sessions will show $0.00. See: GH#24, gt-7awfj\n\n")
|
||||
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Get all tmux sessions
|
||||
@@ -253,6 +267,11 @@ func runLiveCosts() error {
|
||||
}
|
||||
|
||||
func runCostsFromLedger() error {
|
||||
// Warn that cost tracking is disabled
|
||||
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
|
||||
style.Warning.Render("⚠"))
|
||||
fmt.Fprintf(os.Stderr, " Historical data may show $0.00 for all sessions. See: GH#24, gt-7awfj\n\n")
|
||||
|
||||
now := time.Now()
|
||||
var entries []CostEntry
|
||||
var err error
|
||||
@@ -806,8 +825,20 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||
// event fields (event_kind, actor, payload) to not be stored properly.
|
||||
// The bd command will auto-detect the correct rig from cwd.
|
||||
|
||||
// Execute bd create
|
||||
// Find town root so bd can find the .beads database.
|
||||
// The stop hook may run from a role subdirectory (e.g., mayor/) that
|
||||
// doesn't have its own .beads, so we need to run bd from town root.
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
if townRoot == "" {
|
||||
return fmt.Errorf("not in a Gas Town workspace")
|
||||
}
|
||||
|
||||
// Execute bd create from town root
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
bdCmd.Dir = townRoot
|
||||
output, err := bdCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
||||
@@ -819,6 +850,7 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||
// 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")
|
||||
closeCmd.Dir = townRoot
|
||||
if closeErr := closeCmd.Run(); closeErr != nil {
|
||||
// Non-fatal: wisp was created, just couldn't auto-close
|
||||
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
|
||||
|
||||
@@ -5,11 +5,25 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// filterGTEnv removes GT_* and BD_* environment variables to isolate test subprocess.
|
||||
// This prevents tests from inheriting the parent workspace's Gas Town configuration.
|
||||
func filterGTEnv(env []string) []string {
|
||||
filtered := make([]string, 0, len(env))
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "GT_") || strings.HasPrefix(e, "BD_") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// TestQuerySessionEvents_FindsEventsFromAllLocations verifies that querySessionEvents
|
||||
// finds session.ended events from both town-level and rig-level beads databases.
|
||||
//
|
||||
@@ -31,6 +45,13 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
t.Skip("bd not installed, skipping integration test")
|
||||
}
|
||||
|
||||
// Skip when running inside a Gas Town workspace - this integration test
|
||||
// creates a separate workspace and the subprocesses can interact with
|
||||
// the parent workspace's daemon, causing hangs.
|
||||
if os.Getenv("GT_TOWN_ROOT") != "" || os.Getenv("BD_ACTOR") != "" {
|
||||
t.Skip("skipping integration test inside Gas Town workspace (use 'go test' outside workspace)")
|
||||
}
|
||||
|
||||
// Create a temporary directory structure
|
||||
tmpDir := t.TempDir()
|
||||
townRoot := filepath.Join(tmpDir, "test-town")
|
||||
@@ -48,8 +69,10 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
}
|
||||
|
||||
// Use gt install to set up the town
|
||||
// Clear GT environment variables to isolate test from parent workspace
|
||||
gtInstallCmd := exec.Command("gt", "install")
|
||||
gtInstallCmd.Dir = townRoot
|
||||
gtInstallCmd.Env = filterGTEnv(os.Environ())
|
||||
if out, err := gtInstallCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gt install: %v\n%s", err, out)
|
||||
}
|
||||
@@ -88,6 +111,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
// Add rig using gt rig add
|
||||
rigAddCmd := exec.Command("gt", "rig", "add", "testrig", bareRepo, "--prefix=tr")
|
||||
rigAddCmd.Dir = townRoot
|
||||
rigAddCmd.Env = filterGTEnv(os.Environ())
|
||||
if out, err := rigAddCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gt rig add: %v\n%s", err, out)
|
||||
}
|
||||
@@ -111,6 +135,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
"--json",
|
||||
)
|
||||
townEventCmd.Dir = townRoot
|
||||
townEventCmd.Env = filterGTEnv(os.Environ())
|
||||
townOut, err := townEventCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("creating town event: %v\n%s", err, townOut)
|
||||
@@ -127,6 +152,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
"--json",
|
||||
)
|
||||
rigEventCmd.Dir = rigPath
|
||||
rigEventCmd.Env = filterGTEnv(os.Environ())
|
||||
rigOut, err := rigEventCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("creating rig event: %v\n%s", err, rigOut)
|
||||
@@ -136,6 +162,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
// Verify events are in separate databases by querying each directly
|
||||
townListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json")
|
||||
townListCmd.Dir = townRoot
|
||||
townListCmd.Env = filterGTEnv(os.Environ())
|
||||
townListOut, err := townListCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("listing town events: %v\n%s", err, townListOut)
|
||||
@@ -143,6 +170,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
|
||||
rigListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json")
|
||||
rigListCmd.Dir = rigPath
|
||||
rigListCmd.Env = filterGTEnv(os.Environ())
|
||||
rigListOut, err := rigListCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("listing rig events: %v\n%s", err, rigListOut)
|
||||
@@ -183,7 +211,14 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
|
||||
if wsErr != nil {
|
||||
t.Fatalf("workspace.FindFromCwdOrError failed: %v", wsErr)
|
||||
}
|
||||
if foundTownRoot != townRoot {
|
||||
normalizePath := func(path string) string {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
if normalizePath(foundTownRoot) != normalizePath(townRoot) {
|
||||
t.Errorf("workspace.FindFromCwdOrError returned %s, expected %s", foundTownRoot, townRoot)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,27 +27,33 @@ var (
|
||||
var crewCmd = &cobra.Command{
|
||||
Use: "crew",
|
||||
GroupID: GroupWorkspace,
|
||||
Short: "Manage crew workspaces (user-managed persistent workspaces)",
|
||||
Short: "Manage crew workers (persistent workspaces for humans)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Crew workers are user-managed persistent workspaces within a rig.
|
||||
Long: `Manage crew workers - persistent workspaces for human developers.
|
||||
|
||||
Unlike polecats which are witness-managed and transient, crew workers are:
|
||||
- Persistent: Not auto-garbage-collected
|
||||
- User-managed: Overseer controls lifecycle
|
||||
- Long-lived identities: recognizable names like dave, emma, fred
|
||||
- Gas Town integrated: Mail, handoff mechanics work
|
||||
- Tmux optional: Can work in terminal directly
|
||||
CREW VS POLECATS:
|
||||
Polecats: Ephemeral. Witness-managed. Auto-nuked after work.
|
||||
Crew: Persistent. User-managed. Stays until you remove it.
|
||||
|
||||
Crew workers are full git clones (not worktrees) for human developers
|
||||
who want persistent context and control over their workspace lifecycle.
|
||||
Use crew workers for exploratory work, long-running tasks, or when you
|
||||
want to keep uncommitted changes around.
|
||||
|
||||
Features:
|
||||
- Gas Town integrated: Mail, nudge, handoff all work
|
||||
- Recognizable names: dave, emma, fred (not ephemeral pool names)
|
||||
- Tmux optional: Can work in terminal directly without tmux session
|
||||
|
||||
Commands:
|
||||
gt crew start <name> Start a crew workspace (creates if needed)
|
||||
gt crew stop <name> Stop crew workspace session(s)
|
||||
gt crew add <name> Create a new crew workspace
|
||||
gt crew list List crew workspaces with status
|
||||
gt crew at <name> Attach to crew workspace session
|
||||
gt crew remove <name> Remove a crew workspace
|
||||
gt crew refresh <name> Context cycling with mail-to-self handoff
|
||||
gt crew restart <name> Kill and restart session fresh (alias: rs)
|
||||
gt crew status [<name>] Show detailed workspace status`,
|
||||
gt crew start <name> Start session (creates workspace if needed)
|
||||
gt crew stop <name> Stop session(s)
|
||||
gt crew add <name> Create workspace without starting
|
||||
gt crew list List workspaces with status
|
||||
gt crew at <name> Attach to session
|
||||
gt crew remove <name> Remove workspace
|
||||
gt crew refresh <name> Context cycle with handoff mail
|
||||
gt crew restart <name> Kill and restart session fresh`,
|
||||
}
|
||||
|
||||
var crewAddCmd = &cobra.Command{
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
|
||||
"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"
|
||||
@@ -166,7 +165,6 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
Rig: r.Name,
|
||||
AgentName: name,
|
||||
TownRoot: townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(r.Path),
|
||||
RuntimeConfigDir: claudeConfigDir,
|
||||
BeadsNoDaemon: true,
|
||||
})
|
||||
@@ -262,7 +260,18 @@ 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
|
||||
// Check if agent is already running - don't restart if so
|
||||
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)...) {
|
||||
// Agent is already running, nothing to do
|
||||
fmt.Printf("Already in %s session with %s running.\n", name, agentCfg.Command)
|
||||
return nil
|
||||
}
|
||||
|
||||
// We're in the session at a shell prompt - start the agent
|
||||
// Build startup beacon for predecessor discovery via /resume
|
||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
@@ -270,10 +279,6 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
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, beacon)
|
||||
}
|
||||
|
||||
@@ -214,14 +214,22 @@ func isInTmuxSession(targetSession string) bool {
|
||||
}
|
||||
|
||||
// attachToTmuxSession attaches to a tmux session.
|
||||
// Should only be called from outside tmux.
|
||||
// If already inside tmux, uses switch-client instead of attach-session.
|
||||
func attachToTmuxSession(sessionID string) error {
|
||||
tmuxPath, err := exec.LookPath("tmux")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID)
|
||||
// Check if we're already inside a tmux session
|
||||
var cmd *exec.Cmd
|
||||
if os.Getenv("TMUX") != "" {
|
||||
// Inside tmux: switch to the target session
|
||||
cmd = exec.Command(tmuxPath, "switch-client", "-t", sessionID)
|
||||
} else {
|
||||
// Outside tmux: attach to the session
|
||||
cmd = exec.Command(tmuxPath, "attach-session", "-t", sessionID)
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -60,11 +60,11 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Kill session if it exists
|
||||
// Kill session if it exists (with proper process cleanup to avoid orphans)
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
fmt.Printf("Error killing session for %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
@@ -591,8 +591,8 @@ func runCrewStop(cmd *cobra.Command, args []string) error {
|
||||
output, _ = t.CapturePane(sessionID, 50)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
// Kill the session (with proper process cleanup to avoid orphans)
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
fmt.Printf(" %s [%s] %s: %s\n",
|
||||
style.ErrorPrefix,
|
||||
r.Name, name,
|
||||
@@ -681,8 +681,8 @@ func runCrewStopAll() error {
|
||||
output, _ = t.CapturePane(sessionID, 50)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
// Kill the session (with proper process cleanup to avoid orphans)
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
failed++
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
||||
|
||||
@@ -40,6 +40,13 @@ func runCrewStatus(cmd *cobra.Command, args []string) error {
|
||||
crewRig = rig
|
||||
}
|
||||
targetName = crewName
|
||||
} else if crewRig == "" {
|
||||
// Check if single arg (without "/") is a valid rig name
|
||||
// If so, show status for all crew in that rig
|
||||
if _, _, err := getRig(targetName); err == nil {
|
||||
crewRig = targetName
|
||||
targetName = "" // Show all crew in the rig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/util"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
@@ -33,14 +35,20 @@ var deaconCmd = &cobra.Command{
|
||||
Use: "deacon",
|
||||
Aliases: []string{"dea"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the Deacon session",
|
||||
Short: "Manage the Deacon (town-level watchdog)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Deacon tmux session.
|
||||
Long: `Manage the Deacon - the town-level watchdog for Gas Town.
|
||||
|
||||
The Deacon is the hierarchical health-check orchestrator for Gas Town.
|
||||
It monitors the Mayor and Witnesses, handles lifecycle requests, and
|
||||
keeps the town running. Use the subcommands to start, stop, attach,
|
||||
and check status.`,
|
||||
The Deacon ("daemon beacon") is the only agent that receives mechanical
|
||||
heartbeats from the daemon. It monitors system health across all rigs:
|
||||
- Watches all Witnesses (are they alive? stuck? responsive?)
|
||||
- Manages Dogs for cross-rig infrastructure work
|
||||
- Handles lifecycle requests (respawns, restarts)
|
||||
- Receives heartbeat pokes and decides what needs attention
|
||||
|
||||
The Deacon patrols the town; Witnesses patrol their rigs; Polecats work.
|
||||
|
||||
Role shortcuts: "deacon" in mail/nudge addresses resolves to this agent.`,
|
||||
}
|
||||
|
||||
var deaconStartCmd = &cobra.Command{
|
||||
@@ -235,6 +243,27 @@ This removes the pause file and allows the Deacon to work normally.`,
|
||||
RunE: runDeaconResume,
|
||||
}
|
||||
|
||||
var deaconCleanupOrphansCmd = &cobra.Command{
|
||||
Use: "cleanup-orphans",
|
||||
Short: "Clean up orphaned claude subagent processes",
|
||||
Long: `Clean up orphaned claude subagent processes.
|
||||
|
||||
Claude Code's Task tool spawns subagent processes that sometimes don't clean up
|
||||
properly after completion. These accumulate and consume significant memory.
|
||||
|
||||
Detection is based on TTY column: processes with TTY "?" have no controlling
|
||||
terminal. Legitimate claude instances in terminals have a TTY like "pts/0".
|
||||
|
||||
This is safe because:
|
||||
- Processes in terminals (your personal sessions) have a TTY - won't be touched
|
||||
- Only kills processes that have no controlling terminal
|
||||
- These orphans are children of the tmux server with no TTY
|
||||
|
||||
Example:
|
||||
gt deacon cleanup-orphans`,
|
||||
RunE: runDeaconCleanupOrphans,
|
||||
}
|
||||
|
||||
var (
|
||||
triggerTimeout time.Duration
|
||||
|
||||
@@ -269,6 +298,7 @@ func init() {
|
||||
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
||||
deaconCmd.AddCommand(deaconPauseCmd)
|
||||
deaconCmd.AddCommand(deaconResumeCmd)
|
||||
deaconCmd.AddCommand(deaconCleanupOrphansCmd)
|
||||
|
||||
// Flags for trigger-pending
|
||||
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
||||
@@ -348,12 +378,20 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
||||
|
||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
||||
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
||||
style.PrintWarning("Could not create deacon settings: %v", err)
|
||||
return fmt.Errorf("creating deacon settings: %w", err)
|
||||
}
|
||||
|
||||
// Create session in deacon directory
|
||||
// Build startup command first
|
||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", "", agentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
|
||||
// Create session with command directly to avoid send-keys race condition.
|
||||
// See: https://github.com/anthropics/gastown/issues/280
|
||||
fmt.Println("Starting Deacon session...")
|
||||
if err := t.NewSession(sessionName, deaconDir); err != nil {
|
||||
if err := t.NewSessionWithCommand(sessionName, deaconDir, startupCmd); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
@@ -362,7 +400,6 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
||||
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||
Role: "deacon",
|
||||
TownRoot: townRoot,
|
||||
BeadsDir: beads.ResolveBeadsDir(townRoot),
|
||||
})
|
||||
for k, v := range envVars {
|
||||
_ = t.SetEnvironment(sessionName, k, v)
|
||||
@@ -373,21 +410,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
||||
theme := tmux.DeaconTheme()
|
||||
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
||||
|
||||
// Launch Claude directly (no shell respawn loop)
|
||||
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
||||
// The startup hook handles context loading automatically
|
||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "deacon", "", "", agentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
if err := t.SendKeys(sessionName, startupCmd); err != nil {
|
||||
return fmt.Errorf("sending command: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start (non-fatal)
|
||||
// Wait for Claude to start
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
return fmt.Errorf("waiting for deacon to start: %w", err)
|
||||
}
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
@@ -395,17 +420,21 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
||||
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
if err := session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
Recipient: "deacon",
|
||||
Sender: "daemon",
|
||||
Topic: "patrol",
|
||||
}) // Non-fatal
|
||||
}); err != nil {
|
||||
style.PrintWarning("failed to send startup nudge: %v", err)
|
||||
}
|
||||
|
||||
// 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("deacon", deaconDir)) // Non-fatal
|
||||
if err := t.NudgeSession(sessionName, session.PropulsionNudgeForRole("deacon", deaconDir)); err != nil {
|
||||
return fmt.Errorf("sending propulsion nudge: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -703,25 +732,35 @@ func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("%s Sent HEALTH_CHECK to %s, waiting %s...\n",
|
||||
style.Bold.Render("→"), agent, healthCheckTimeout)
|
||||
|
||||
// Wait for response
|
||||
deadline := time.Now().Add(healthCheckTimeout)
|
||||
// Wait for response using context and ticker for reliability
|
||||
// This prevents loop hangs if system clock changes
|
||||
ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
responded := false
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(2 * time.Second) // Check every 2 seconds
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
goto Done
|
||||
case <-ticker.C:
|
||||
newTime, err := getAgentBeadUpdateTime(townRoot, beadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
newTime, err := getAgentBeadUpdateTime(townRoot, beadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If bead was updated after our baseline, agent responded
|
||||
if newTime.After(baselineTime) {
|
||||
responded = true
|
||||
break
|
||||
// If bead was updated after our baseline, agent responded
|
||||
if newTime.After(baselineTime) {
|
||||
responded = true
|
||||
goto Done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Done:
|
||||
// Record result
|
||||
if responded {
|
||||
agentState.RecordResponse()
|
||||
@@ -1095,3 +1134,54 @@ func runDeaconResume(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeaconCleanupOrphans cleans up orphaned claude subagent processes.
|
||||
func runDeaconCleanupOrphans(cmd *cobra.Command, args []string) error {
|
||||
// First, find orphans
|
||||
orphans, err := util.FindOrphanedClaudeProcesses()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding orphaned processes: %w", err)
|
||||
}
|
||||
|
||||
if len(orphans) == 0 {
|
||||
fmt.Printf("%s No orphaned claude processes found\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Found %d orphaned claude process(es)\n", style.Bold.Render("●"), len(orphans))
|
||||
|
||||
// Process them with signal escalation
|
||||
results, err := util.CleanupOrphanedClaudeProcesses()
|
||||
if err != nil {
|
||||
style.PrintWarning("cleanup had errors: %v", err)
|
||||
}
|
||||
|
||||
// Report results
|
||||
var terminated, escalated, unkillable int
|
||||
for _, r := range results {
|
||||
switch r.Signal {
|
||||
case "SIGTERM":
|
||||
fmt.Printf(" %s Sent SIGTERM to PID %d (%s)\n", style.Bold.Render("→"), r.Process.PID, r.Process.Cmd)
|
||||
terminated++
|
||||
case "SIGKILL":
|
||||
fmt.Printf(" %s Escalated to SIGKILL for PID %d (%s)\n", style.Bold.Render("!"), r.Process.PID, r.Process.Cmd)
|
||||
escalated++
|
||||
case "UNKILLABLE":
|
||||
fmt.Printf(" %s WARNING: PID %d (%s) survived SIGKILL\n", style.Bold.Render("⚠"), r.Process.PID, r.Process.Cmd)
|
||||
unkillable++
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
summary := fmt.Sprintf("Processed %d orphan(s)", len(results))
|
||||
if escalated > 0 {
|
||||
summary += fmt.Sprintf(" (%d escalated to SIGKILL)", escalated)
|
||||
}
|
||||
if unkillable > 0 {
|
||||
summary += fmt.Sprintf(" (%d unkillable)", unkillable)
|
||||
}
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("✓"), summary)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Register built-in checks
|
||||
d.Register(doctor.NewStaleBinaryCheck())
|
||||
d.Register(doctor.NewSqlite3Check())
|
||||
d.Register(doctor.NewTownGitCheck())
|
||||
d.Register(doctor.NewTownRootBranchCheck())
|
||||
d.Register(doctor.NewPreCheckoutHookCheck())
|
||||
@@ -134,6 +135,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewRoutesCheck())
|
||||
d.Register(doctor.NewRigRoutesJSONLCheck())
|
||||
d.Register(doctor.NewOrphanSessionCheck())
|
||||
d.Register(doctor.NewZombieSessionCheck())
|
||||
d.Register(doctor.NewOrphanProcessCheck())
|
||||
d.Register(doctor.NewWispGCCheck())
|
||||
d.Register(doctor.NewBranchCheck())
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/dog"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/plugin"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -24,20 +26,36 @@ var (
|
||||
dogForce bool
|
||||
dogRemoveAll bool
|
||||
dogCallAll bool
|
||||
|
||||
// Dispatch flags
|
||||
dogDispatchPlugin string
|
||||
dogDispatchRig string
|
||||
dogDispatchCreate bool
|
||||
dogDispatchDog string
|
||||
dogDispatchJSON bool
|
||||
dogDispatchDryRun bool
|
||||
)
|
||||
|
||||
var dogCmd = &cobra.Command{
|
||||
Use: "dog",
|
||||
Aliases: []string{"dogs"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage dogs (Deacon's helper workers)",
|
||||
Long: `Manage dogs in the kennel.
|
||||
Short: "Manage dogs (cross-rig infrastructure workers)",
|
||||
Long: `Manage dogs - reusable workers for infrastructure and cleanup.
|
||||
|
||||
Dogs are reusable helper workers managed by the Deacon for infrastructure
|
||||
and cleanup tasks. Unlike polecats (single-rig, ephemeral), dogs handle
|
||||
cross-rig infrastructure work with worktrees into each rig.
|
||||
CATS VS DOGS:
|
||||
Polecats (cats) build features. One rig. Ephemeral (one task, then nuked).
|
||||
Dogs clean up messes. Cross-rig. Reusable (multiple tasks, eventually recycled).
|
||||
|
||||
The kennel is located at ~/gt/deacon/dogs/.`,
|
||||
Dogs are managed by the Deacon for town-level work:
|
||||
- Infrastructure tasks (rebuilding, syncing, migrations)
|
||||
- Cleanup operations (orphan branches, stale files)
|
||||
- Cross-rig work that spans multiple projects
|
||||
|
||||
Each dog has worktrees into every configured rig, enabling cross-project
|
||||
operations. Dogs return to idle state after completing work (unlike cats).
|
||||
|
||||
The kennel is at ~/gt/deacon/dogs/. The Deacon dispatches work to dogs.`,
|
||||
}
|
||||
|
||||
var dogAddCmd = &cobra.Command{
|
||||
@@ -137,6 +155,33 @@ Examples:
|
||||
RunE: runDogStatus,
|
||||
}
|
||||
|
||||
var dogDispatchCmd = &cobra.Command{
|
||||
Use: "dispatch --plugin <name>",
|
||||
Short: "Dispatch plugin execution to a dog",
|
||||
Long: `Dispatch a plugin for execution by a dog worker.
|
||||
|
||||
This is the formalized command for sending plugin work to dogs. The Deacon
|
||||
uses this during patrol cycles to dispatch plugins with open gates.
|
||||
|
||||
The command:
|
||||
1. Finds the plugin definition (plugin.md)
|
||||
2. Assigns work to an idle dog (marks as working)
|
||||
3. Sends mail with plugin instructions to the dog
|
||||
4. Returns immediately (non-blocking)
|
||||
|
||||
The dog discovers the work via its mail inbox and executes the plugin
|
||||
instructions. On completion, the dog sends DOG_DONE mail to deacon/.
|
||||
|
||||
Examples:
|
||||
gt dog dispatch --plugin rebuild-gt
|
||||
gt dog dispatch --plugin rebuild-gt --rig gastown
|
||||
gt dog dispatch --plugin rebuild-gt --dog alpha
|
||||
gt dog dispatch --plugin rebuild-gt --create
|
||||
gt dog dispatch --plugin rebuild-gt --dry-run
|
||||
gt dog dispatch --plugin rebuild-gt --json`,
|
||||
RunE: runDogDispatch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List flags
|
||||
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
|
||||
@@ -151,12 +196,22 @@ func init() {
|
||||
// Status flags
|
||||
dogStatusCmd.Flags().BoolVar(&dogStatusJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Dispatch flags
|
||||
dogDispatchCmd.Flags().StringVar(&dogDispatchPlugin, "plugin", "", "Plugin name to dispatch (required)")
|
||||
dogDispatchCmd.Flags().StringVar(&dogDispatchRig, "rig", "", "Limit plugin search to specific rig")
|
||||
dogDispatchCmd.Flags().StringVar(&dogDispatchDog, "dog", "", "Dispatch to specific dog (default: any idle)")
|
||||
dogDispatchCmd.Flags().BoolVar(&dogDispatchCreate, "create", false, "Create a dog if none idle")
|
||||
dogDispatchCmd.Flags().BoolVar(&dogDispatchJSON, "json", false, "Output as JSON")
|
||||
dogDispatchCmd.Flags().BoolVarP(&dogDispatchDryRun, "dry-run", "n", false, "Show what would be done without doing it")
|
||||
_ = dogDispatchCmd.MarkFlagRequired("plugin")
|
||||
|
||||
// Add subcommands
|
||||
dogCmd.AddCommand(dogAddCmd)
|
||||
dogCmd.AddCommand(dogRemoveCmd)
|
||||
dogCmd.AddCommand(dogListCmd)
|
||||
dogCmd.AddCommand(dogCallCmd)
|
||||
dogCmd.AddCommand(dogStatusCmd)
|
||||
dogCmd.AddCommand(dogDispatchCmd)
|
||||
|
||||
rootCmd.AddCommand(dogCmd)
|
||||
}
|
||||
@@ -590,3 +645,214 @@ func dogFormatTimeAgo(t time.Time) string {
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
|
||||
// runDogDispatch dispatches plugin execution to a dog worker.
|
||||
func runDogDispatch(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
|
||||
// Get rig names for plugin scanner
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading rigs config: %w", err)
|
||||
}
|
||||
|
||||
var rigNames []string
|
||||
for rigName := range rigsConfig.Rigs {
|
||||
rigNames = append(rigNames, rigName)
|
||||
}
|
||||
|
||||
// If --rig specified, search only that rig
|
||||
if dogDispatchRig != "" {
|
||||
rigNames = []string{dogDispatchRig}
|
||||
}
|
||||
|
||||
// Find the plugin using scanner
|
||||
scanner := plugin.NewScanner(townRoot, rigNames)
|
||||
p, err := scanner.GetPlugin(dogDispatchPlugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding plugin: %w", err)
|
||||
}
|
||||
|
||||
// Get dog manager (reuse rigsConfig from above)
|
||||
mgr := dog.NewManager(townRoot, rigsConfig)
|
||||
|
||||
// Find target dog
|
||||
var targetDog *dog.Dog
|
||||
var dogCreated bool
|
||||
if dogDispatchDog != "" {
|
||||
// Specific dog requested
|
||||
targetDog, err = mgr.Get(dogDispatchDog)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting dog %s: %w", dogDispatchDog, err)
|
||||
}
|
||||
if targetDog.State == dog.StateWorking {
|
||||
return fmt.Errorf("dog %s is already working", dogDispatchDog)
|
||||
}
|
||||
} else {
|
||||
// Find idle dog from pool
|
||||
targetDog, err = mgr.GetIdleDog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding idle dog: %w", err)
|
||||
}
|
||||
|
||||
if targetDog == nil {
|
||||
if dogDispatchCreate {
|
||||
// Create a new dog (reuse generateDogName from sling_dog.go)
|
||||
newName := generateDogName(mgr)
|
||||
if dogDispatchDryRun {
|
||||
targetDog = &dog.Dog{Name: newName, State: dog.StateIdle}
|
||||
dogCreated = true
|
||||
} else {
|
||||
targetDog, err = mgr.Add(newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating dog %s: %w", newName, err)
|
||||
}
|
||||
dogCreated = true
|
||||
|
||||
// Create agent bead for the dog
|
||||
b := beads.New(townRoot)
|
||||
location := filepath.Join("deacon", "dogs", newName)
|
||||
if _, beadErr := b.CreateDogAgentBead(newName, location); beadErr != nil {
|
||||
// Non-fatal warning
|
||||
if !dogDispatchJSON {
|
||||
fmt.Printf(" Warning: could not create agent bead: %v\n", beadErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("no idle dogs available (use --create to add one)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare dispatch result for JSON output
|
||||
workDesc := fmt.Sprintf("plugin:%s", p.Name)
|
||||
result := dogDispatchResult{
|
||||
Plugin: p.Name,
|
||||
PluginPath: p.Path,
|
||||
Dog: targetDog.Name,
|
||||
DogCreated: dogCreated,
|
||||
Work: workDesc,
|
||||
DryRun: dogDispatchDryRun,
|
||||
}
|
||||
if p.RigName != "" {
|
||||
result.PluginRig = p.RigName
|
||||
}
|
||||
|
||||
// Dry-run mode: show what would happen and exit
|
||||
if dogDispatchDryRun {
|
||||
if dogDispatchJSON {
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
fmt.Printf("Dry run - would dispatch:\n")
|
||||
fmt.Printf(" Plugin: %s\n", p.Name)
|
||||
if p.RigName != "" {
|
||||
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
|
||||
} else {
|
||||
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
|
||||
}
|
||||
fmt.Printf(" Dog: %s%s\n", targetDog.Name, ifStr(dogCreated, " (would create)", ""))
|
||||
fmt.Printf(" Work: %s\n", workDesc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Assign work FIRST (before sending mail) to prevent race condition
|
||||
// If this fails, we haven't sent any mail yet
|
||||
if err := mgr.AssignWork(targetDog.Name, workDesc); err != nil {
|
||||
return fmt.Errorf("assigning work to dog: %w", err)
|
||||
}
|
||||
|
||||
// Create and send mail message with plugin instructions
|
||||
dogAddress := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
|
||||
subject := fmt.Sprintf("Plugin: %s", p.Name)
|
||||
body := formatPluginMailBody(p)
|
||||
|
||||
router := mail.NewRouterWithTownRoot(townRoot, townRoot)
|
||||
msg := &mail.Message{
|
||||
From: "deacon/",
|
||||
To: dogAddress,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if err := router.Send(msg); err != nil {
|
||||
// Rollback: clear work assignment since mail failed
|
||||
if clearErr := mgr.ClearWork(targetDog.Name); clearErr != nil {
|
||||
// Log rollback failure but return original error
|
||||
if !dogDispatchJSON {
|
||||
fmt.Printf(" Warning: rollback failed: %v\n", clearErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("sending plugin mail to dog: %w", err)
|
||||
}
|
||||
|
||||
// Success - output result
|
||||
if dogDispatchJSON {
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Found plugin: %s\n", style.Bold.Render("✓"), p.Name)
|
||||
if p.RigName != "" {
|
||||
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
|
||||
} else {
|
||||
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
|
||||
}
|
||||
if dogCreated {
|
||||
fmt.Printf("%s Created dog %s (pool was empty)\n", style.Bold.Render("✓"), targetDog.Name)
|
||||
}
|
||||
fmt.Printf("%s Dispatching to dog: %s\n", style.Bold.Render("🐕"), targetDog.Name)
|
||||
fmt.Printf("%s Plugin dispatched (non-blocking)\n", style.Bold.Render("✓"))
|
||||
fmt.Printf(" Dog: %s\n", targetDog.Name)
|
||||
fmt.Printf(" Work: %s\n", workDesc)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dogDispatchResult is the JSON output for gt dog dispatch.
|
||||
type dogDispatchResult struct {
|
||||
Plugin string `json:"plugin"`
|
||||
PluginRig string `json:"plugin_rig,omitempty"`
|
||||
PluginPath string `json:"plugin_path"`
|
||||
Dog string `json:"dog"`
|
||||
DogCreated bool `json:"dog_created,omitempty"`
|
||||
Work string `json:"work"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// ifStr returns ifTrue if cond is true, otherwise ifFalse.
|
||||
func ifStr(cond bool, ifTrue, ifFalse string) string {
|
||||
if cond {
|
||||
return ifTrue
|
||||
}
|
||||
return ifFalse
|
||||
}
|
||||
|
||||
// formatPluginMailBody formats the plugin as instructions for the dog.
|
||||
func formatPluginMailBody(p *plugin.Plugin) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("Execute the following plugin:\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**Plugin**: %s\n", p.Name))
|
||||
sb.WriteString(fmt.Sprintf("**Description**: %s\n", p.Description))
|
||||
if p.RigName != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Rig**: %s\n", p.RigName))
|
||||
}
|
||||
if p.Execution != nil && p.Execution.Timeout != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Timeout**: %s\n", p.Execution.Timeout))
|
||||
}
|
||||
sb.WriteString("\n---\n\n")
|
||||
sb.WriteString("## Instructions\n\n")
|
||||
sb.WriteString(p.Instructions)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
sb.WriteString("After completion:\n")
|
||||
sb.WriteString("1. Create a wisp to record the result (success/failure)\n")
|
||||
sb.WriteString("2. Send DOG_DONE mail to deacon/\n")
|
||||
sb.WriteString("3. Return to idle state\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/townlog"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
@@ -94,55 +96,90 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
// Find workspace with fallback for deleted worktrees (hq-3xaxy)
|
||||
// If the polecat's worktree was deleted by Witness before gt done finishes,
|
||||
// getcwd will fail. We fall back to GT_TOWN_ROOT env var in that case.
|
||||
townRoot, cwd, err := workspace.FindFromCwdWithFallback()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Track if cwd is available - affects which operations we can do
|
||||
cwdAvailable := cwd != ""
|
||||
if !cwdAvailable {
|
||||
style.PrintWarning("working directory deleted (worktree nuked?), using fallback paths")
|
||||
// Try to get cwd from GT_POLECAT_PATH env var (set by session manager)
|
||||
if polecatPath := os.Getenv("GT_POLECAT_PATH"); polecatPath != "" {
|
||||
cwd = polecatPath // May still be gone, but we have a path to use
|
||||
}
|
||||
}
|
||||
|
||||
// Find current rig
|
||||
rigName, _, err := findCurrentRig(townRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize git for the current directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current directory: %w", err)
|
||||
// Initialize git - use cwd if available, otherwise use rig's mayor clone
|
||||
var g *git.Git
|
||||
if cwdAvailable {
|
||||
g = git.NewGit(cwd)
|
||||
} else {
|
||||
// Fallback: use the rig's mayor clone for git operations
|
||||
mayorClone := filepath.Join(townRoot, rigName, "mayor", "rig")
|
||||
g = git.NewGit(mayorClone)
|
||||
}
|
||||
g := git.NewGit(cwd)
|
||||
|
||||
// Get current branch
|
||||
branch, err := g.CurrentBranch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current branch: %w", err)
|
||||
// Get current branch - try env var first if cwd is gone
|
||||
var branch string
|
||||
if !cwdAvailable {
|
||||
// Try to get branch from GT_BRANCH env var (set by session manager)
|
||||
branch = os.Getenv("GT_BRANCH")
|
||||
}
|
||||
if branch == "" {
|
||||
var err error
|
||||
branch, err = g.CurrentBranch()
|
||||
if err != nil {
|
||||
// Last resort: try to extract from polecat name (polecat/<name>-<suffix>)
|
||||
if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" {
|
||||
branch = fmt.Sprintf("polecat/%s", polecatName)
|
||||
style.PrintWarning("could not get branch from git, using fallback: %s", branch)
|
||||
} else {
|
||||
return fmt.Errorf("getting current branch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect cleanup status if not explicitly provided
|
||||
// This prevents premature polecat cleanup by ensuring witness knows git state
|
||||
if doneCleanupStatus == "" {
|
||||
workStatus, err := g.CheckUncommittedWork()
|
||||
if err != nil {
|
||||
style.PrintWarning("could not auto-detect cleanup status: %v", err)
|
||||
if !cwdAvailable {
|
||||
// Can't detect git state without working directory, default to unknown
|
||||
doneCleanupStatus = "unknown"
|
||||
style.PrintWarning("cannot detect cleanup status - working directory deleted")
|
||||
} else {
|
||||
switch {
|
||||
case workStatus.HasUncommittedChanges:
|
||||
doneCleanupStatus = "uncommitted"
|
||||
case workStatus.StashCount > 0:
|
||||
doneCleanupStatus = "stash"
|
||||
default:
|
||||
// CheckUncommittedWork.UnpushedCommits doesn't work for branches
|
||||
// without upstream tracking (common for polecats). Use the more
|
||||
// robust BranchPushedToRemote which compares against origin/main.
|
||||
pushed, unpushedCount, err := g.BranchPushedToRemote(branch, "origin")
|
||||
if err != nil {
|
||||
style.PrintWarning("could not check if branch is pushed: %v", err)
|
||||
doneCleanupStatus = "unpushed" // err on side of caution
|
||||
} else if !pushed || unpushedCount > 0 {
|
||||
doneCleanupStatus = "unpushed"
|
||||
} else {
|
||||
doneCleanupStatus = "clean"
|
||||
workStatus, err := g.CheckUncommittedWork()
|
||||
if err != nil {
|
||||
style.PrintWarning("could not auto-detect cleanup status: %v", err)
|
||||
} else {
|
||||
switch {
|
||||
case workStatus.HasUncommittedChanges:
|
||||
doneCleanupStatus = "uncommitted"
|
||||
case workStatus.StashCount > 0:
|
||||
doneCleanupStatus = "stash"
|
||||
default:
|
||||
// CheckUncommittedWork.UnpushedCommits doesn't work for branches
|
||||
// without upstream tracking (common for polecats). Use the more
|
||||
// robust BranchPushedToRemote which compares against origin/main.
|
||||
pushed, unpushedCount, err := g.BranchPushedToRemote(branch, "origin")
|
||||
if err != nil {
|
||||
style.PrintWarning("could not check if branch is pushed: %v", err)
|
||||
doneCleanupStatus = "unpushed" // err on side of caution
|
||||
} else if !pushed || unpushedCount > 0 {
|
||||
doneCleanupStatus = "unpushed"
|
||||
} else {
|
||||
doneCleanupStatus = "clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +215,16 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
agentBeadID = getAgentBeadID(ctx)
|
||||
}
|
||||
|
||||
// If issue ID not set by flag or branch name, try agent's hook_bead.
|
||||
// This handles cases where branch name doesn't contain issue ID
|
||||
// (e.g., "polecat/furiosa-mkb0vq9f" doesn't have the actual issue).
|
||||
if issueID == "" && agentBeadID != "" {
|
||||
bd := beads.New(beads.ResolveBeadsDir(cwd))
|
||||
if hookIssue := getIssueFromAgentHook(bd, agentBeadID); hookIssue != "" {
|
||||
issueID = hookIssue
|
||||
}
|
||||
}
|
||||
|
||||
// Get configured default branch for this rig
|
||||
defaultBranch := "main" // fallback
|
||||
if rigCfg, err := rig.LoadRigConfig(filepath.Join(townRoot, rigName)); err == nil && rigCfg.DefaultBranch != "" {
|
||||
@@ -190,15 +237,53 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
if branch == defaultBranch || branch == "master" {
|
||||
return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch)
|
||||
}
|
||||
// Check that branch has commits ahead of default branch (prevents submitting stale branches)
|
||||
aheadCount, err := g.CommitsAhead(defaultBranch, branch)
|
||||
|
||||
// CRITICAL: Verify work exists before completing (hq-xthqf)
|
||||
// Polecats calling gt done without commits results in lost work.
|
||||
// We MUST check for:
|
||||
// 1. Working directory availability (can't verify git state without it)
|
||||
// 2. Uncommitted changes (work that would be lost)
|
||||
// 3. Unique commits compared to origin (ensures branch was pushed with actual work)
|
||||
|
||||
// Block if working directory not available - can't verify git state
|
||||
if !cwdAvailable {
|
||||
return fmt.Errorf("cannot complete: working directory not available (worktree deleted?)\nUse --status DEFERRED to exit without completing")
|
||||
}
|
||||
|
||||
// Block if there are uncommitted changes (would be lost on completion)
|
||||
workStatus, err := g.CheckUncommittedWork()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking commits ahead of %s: %w", defaultBranch, err)
|
||||
return fmt.Errorf("checking git status: %w", err)
|
||||
}
|
||||
if workStatus.HasUncommittedChanges {
|
||||
return fmt.Errorf("cannot complete: uncommitted changes would be lost\nCommit your changes first, or use --status DEFERRED to exit without completing\nUncommitted: %s", workStatus.String())
|
||||
}
|
||||
|
||||
// Check that branch has commits ahead of origin/default (not local default)
|
||||
// This ensures we compare against the remote, not a potentially stale local copy
|
||||
originDefault := "origin/" + defaultBranch
|
||||
aheadCount, err := g.CommitsAhead(originDefault, "HEAD")
|
||||
if err != nil {
|
||||
// Fallback to local branch comparison if origin not available
|
||||
aheadCount, err = g.CommitsAhead(defaultBranch, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking commits ahead of %s: %w", defaultBranch, err)
|
||||
}
|
||||
}
|
||||
if aheadCount == 0 {
|
||||
return fmt.Errorf("branch '%s' has 0 commits ahead of %s; nothing to merge", branch, defaultBranch)
|
||||
return fmt.Errorf("branch '%s' has 0 commits ahead of %s; nothing to merge\nMake and commit changes first, or use --status DEFERRED to exit without completing", branch, originDefault)
|
||||
}
|
||||
|
||||
// CRITICAL: Push branch BEFORE creating MR bead (hq-6dk53, hq-a4ksk)
|
||||
// The MR bead triggers Refinery to process this branch. If the branch
|
||||
// isn't pushed yet, Refinery finds nothing to merge. The worktree gets
|
||||
// nuked at the end of gt done, so the commits are lost forever.
|
||||
fmt.Printf("Pushing branch to remote...\n")
|
||||
if err := g.Push("origin", branch, false); err != nil {
|
||||
return fmt.Errorf("pushing branch '%s' to origin: %w\nCommits exist locally but failed to push. Fix the issue and retry.", branch, err)
|
||||
}
|
||||
fmt.Printf("%s Branch pushed to origin\n", style.Bold.Render("✓"))
|
||||
|
||||
if issueID == "" {
|
||||
return fmt.Errorf("cannot determine source issue from branch '%s'; use --issue to specify", branch)
|
||||
}
|
||||
@@ -373,26 +458,38 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
// Update agent bead state (ZFC: self-report completion)
|
||||
updateAgentStateOnDone(cwd, townRoot, exitType, issueID)
|
||||
|
||||
// Self-cleaning: Nuke our own sandbox before exiting (if we're a polecat)
|
||||
// Self-cleaning: Nuke our own sandbox and session (if we're a polecat)
|
||||
// This is the self-cleaning model - polecats clean up after themselves
|
||||
selfNukeAttempted := false
|
||||
// "done means gone" - both worktree and session are terminated
|
||||
selfCleanAttempted := false
|
||||
if exitType == ExitCompleted {
|
||||
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil && roleInfo.Role == RolePolecat {
|
||||
selfNukeAttempted = true
|
||||
selfCleanAttempted = true
|
||||
|
||||
// Step 1: Nuke the worktree
|
||||
if err := selfNukePolecat(roleInfo, townRoot); err != nil {
|
||||
// Non-fatal: Witness will clean up if we fail
|
||||
style.PrintWarning("self-nuke failed: %v (Witness will clean up)", err)
|
||||
style.PrintWarning("worktree nuke failed: %v (Witness will clean up)", err)
|
||||
} else {
|
||||
fmt.Printf("%s Sandbox nuked\n", style.Bold.Render("✓"))
|
||||
fmt.Printf("%s Worktree nuked\n", style.Bold.Render("✓"))
|
||||
}
|
||||
|
||||
// Step 2: Kill our own session (this terminates Claude and the shell)
|
||||
// This is the last thing we do - the process will be killed when tmux session dies
|
||||
fmt.Printf("%s Terminating session (done means gone)\n", style.Bold.Render("→"))
|
||||
if err := selfKillSession(townRoot, roleInfo); err != nil {
|
||||
// If session kill fails, fall through to os.Exit
|
||||
style.PrintWarning("session kill failed: %v", err)
|
||||
}
|
||||
// If selfKillSession succeeds, we won't reach here (process killed by tmux)
|
||||
}
|
||||
}
|
||||
|
||||
// Always exit session - polecats don't stay alive after completion
|
||||
// Fallback exit for non-polecats or if self-clean failed
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Session exiting (done means gone)\n", style.Bold.Render("→"))
|
||||
if !selfNukeAttempted {
|
||||
fmt.Printf(" Witness will handle worktree cleanup.\n")
|
||||
fmt.Printf("%s Session exiting\n", style.Bold.Render("→"))
|
||||
if !selfCleanAttempted {
|
||||
fmt.Printf(" Witness will handle cleanup.\n")
|
||||
}
|
||||
fmt.Printf(" Goodbye!\n")
|
||||
os.Exit(0)
|
||||
@@ -406,11 +503,36 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
// intentional agent decisions that can't be observed from tmux.
|
||||
//
|
||||
// Also self-reports cleanup_status for ZFC compliance (#10).
|
||||
//
|
||||
// BUG FIX (hq-3xaxy): This function must be resilient to working directory deletion.
|
||||
// If the polecat's worktree is deleted before gt done finishes, we use env vars as fallback.
|
||||
// All errors are warnings, not failures - gt done must complete even if bead ops fail.
|
||||
func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unused but kept for future audit logging
|
||||
// Get role context
|
||||
// Get role context - try multiple sources for resilience
|
||||
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
||||
if err != nil {
|
||||
return
|
||||
// Fallback: try to construct role info from environment variables
|
||||
// This handles the case where cwd is deleted but env vars are set
|
||||
envRole := os.Getenv("GT_ROLE")
|
||||
envRig := os.Getenv("GT_RIG")
|
||||
envPolecat := os.Getenv("GT_POLECAT")
|
||||
|
||||
if envRole == "" || envRig == "" {
|
||||
// Can't determine role, skip agent state update
|
||||
return
|
||||
}
|
||||
|
||||
// Parse role string to get Role type
|
||||
parsedRole, _, _ := parseRoleString(envRole)
|
||||
|
||||
roleInfo = RoleInfo{
|
||||
Role: parsedRole,
|
||||
Rig: envRig,
|
||||
Polecat: envPolecat,
|
||||
TownRoot: townRoot,
|
||||
WorkDir: cwd,
|
||||
Source: "env-fallback",
|
||||
}
|
||||
}
|
||||
|
||||
ctx := RoleContext{
|
||||
@@ -427,6 +549,8 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
}
|
||||
|
||||
// Use rig path for slot commands - bd slot doesn't route from town root
|
||||
// IMPORTANT: Use the rig's directory (not polecat worktree) so bd commands
|
||||
// work even if the polecat worktree is deleted.
|
||||
var beadsPath string
|
||||
switch ctx.Role {
|
||||
case RoleMayor, RoleDeacon:
|
||||
@@ -443,10 +567,14 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
// BUG FIX (hq-i26n2): Check if agent bead exists before clearing hook.
|
||||
// Old polecats may not have identity beads, so ClearHookBead would fail.
|
||||
// gt done must be resilient - missing agent bead is not an error.
|
||||
//
|
||||
// BUG FIX (hq-3xaxy): All bead operations are non-fatal. If the agent bead
|
||||
// is deleted by another process (e.g., Witness cleanup), we just warn.
|
||||
agentBead, err := bd.Show(agentBeadID)
|
||||
if err != nil {
|
||||
// Agent bead doesn't exist - nothing to clear, that's fine
|
||||
// This happens for polecats created before identity beads existed
|
||||
// This happens for polecats created before identity beads existed,
|
||||
// or if the agent bead was deleted by another process
|
||||
return
|
||||
}
|
||||
|
||||
@@ -455,13 +583,17 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
// Only close if the hooked bead exists and is still in "hooked" status
|
||||
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
|
||||
if err := bd.Close(hookedBeadID); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the hook (work is done) - gt-zecmc
|
||||
// BUG FIX (hq-3xaxy): This is non-fatal - if hook clearing fails, warn and continue.
|
||||
// The Witness will clean up any orphaned state.
|
||||
if err := bd.ClearHookBead(agentBeadID); err != nil {
|
||||
// Non-fatal: warn but don't fail gt done
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't clear agent %s hook: %v\n", agentBeadID, err)
|
||||
}
|
||||
|
||||
@@ -495,6 +627,21 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
}
|
||||
}
|
||||
|
||||
// getIssueFromAgentHook retrieves the issue ID from an agent's hook_bead field.
|
||||
// This is the authoritative source for what work a polecat is doing, since branch
|
||||
// names may not contain the issue ID (e.g., "polecat/furiosa-mkb0vq9f").
|
||||
// Returns empty string if agent doesn't exist or has no hook.
|
||||
func getIssueFromAgentHook(bd *beads.Beads, agentBeadID string) string {
|
||||
if agentBeadID == "" {
|
||||
return ""
|
||||
}
|
||||
agentBead, err := bd.Show(agentBeadID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return agentBead.HookBead
|
||||
}
|
||||
|
||||
// getDispatcherFromBead retrieves the dispatcher agent ID from the bead's attachment fields.
|
||||
// Returns empty string if no dispatcher is recorded.
|
||||
func getDispatcherFromBead(cwd, issueID string) string {
|
||||
@@ -558,3 +705,51 @@ func selfNukePolecat(roleInfo RoleInfo, _ string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfKillSession terminates the polecat's own tmux session after logging the event.
|
||||
// This completes the self-cleaning model: "done means gone" - both worktree and session.
|
||||
//
|
||||
// The polecat determines its session from environment variables:
|
||||
// - GT_RIG: the rig name
|
||||
// - GT_POLECAT: the polecat name
|
||||
// Session name format: gt-<rig>-<polecat>
|
||||
func selfKillSession(townRoot string, roleInfo RoleInfo) error {
|
||||
// Get session info from environment (set at session startup)
|
||||
rigName := os.Getenv("GT_RIG")
|
||||
polecatName := os.Getenv("GT_POLECAT")
|
||||
|
||||
// Fall back to roleInfo if env vars not set (shouldn't happen but be safe)
|
||||
if rigName == "" {
|
||||
rigName = roleInfo.Rig
|
||||
}
|
||||
if polecatName == "" {
|
||||
polecatName = roleInfo.Polecat
|
||||
}
|
||||
|
||||
if rigName == "" || polecatName == "" {
|
||||
return fmt.Errorf("cannot determine session: rig=%q, polecat=%q", rigName, polecatName)
|
||||
}
|
||||
|
||||
sessionName := fmt.Sprintf("gt-%s-%s", rigName, polecatName)
|
||||
agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||
|
||||
// Log to townlog (human-readable audit log)
|
||||
if townRoot != "" {
|
||||
logger := townlog.NewLogger(townRoot)
|
||||
_ = logger.Log(townlog.EventKill, agentID, "self-clean: done means gone")
|
||||
}
|
||||
|
||||
// Log to events (JSON audit log with structured payload)
|
||||
_ = events.LogFeed(events.TypeSessionDeath, agentID,
|
||||
events.SessionDeathPayload(sessionName, agentID, "self-clean: done means gone", "gt done"))
|
||||
|
||||
// Kill our own tmux session with proper process cleanup
|
||||
// This will terminate Claude and all child processes, completing the self-cleaning cycle.
|
||||
// We use KillSessionWithProcesses to ensure no orphaned processes are left behind.
|
||||
t := tmux.NewTmux()
|
||||
if err := t.KillSessionWithProcesses(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session %s: %w", sessionName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -246,3 +247,97 @@ func TestDoneCircularRedirectProtection(t *testing.T) {
|
||||
t.Errorf("circular redirect should return original: got %s, want %s", resolved, beadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueFromAgentHook verifies that getIssueFromAgentHook correctly
|
||||
// retrieves the issue ID from an agent's hook_bead field.
|
||||
// This is critical because branch names like "polecat/furiosa-mkb0vq9f" don't
|
||||
// contain the actual issue ID (test-845.1), but the agent's hook does.
|
||||
func TestGetIssueFromAgentHook(t *testing.T) {
|
||||
// Skip: bd CLI 0.47.2 has a bug where database writes don't commit
|
||||
// ("sql: database is closed" during auto-flush). This blocks tests
|
||||
// that need to create issues. See internal issue for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentBeadID string
|
||||
setupBeads func(t *testing.T, bd *beads.Beads) // setup agent bead with hook
|
||||
wantIssueID string
|
||||
}{
|
||||
{
|
||||
name: "agent with hook_bead returns issue ID",
|
||||
agentBeadID: "test-testrig-polecat-furiosa",
|
||||
setupBeads: func(t *testing.T, bd *beads.Beads) {
|
||||
// Create a task that will be hooked
|
||||
_, err := bd.CreateWithID("test-456", beads.CreateOptions{
|
||||
Title: "Task to be hooked",
|
||||
Type: "task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create task bead: %v", err)
|
||||
}
|
||||
|
||||
// Create agent bead using CreateAgentBead
|
||||
// Agent ID format: <prefix>-<rig>-<role>-<name>
|
||||
_, err = bd.CreateAgentBead("test-testrig-polecat-furiosa", "Test polecat agent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create agent bead: %v", err)
|
||||
}
|
||||
|
||||
// Set hook_bead on agent
|
||||
if err := bd.SetHookBead("test-testrig-polecat-furiosa", "test-456"); err != nil {
|
||||
t.Fatalf("set hook bead: %v", err)
|
||||
}
|
||||
},
|
||||
wantIssueID: "test-456",
|
||||
},
|
||||
{
|
||||
name: "agent without hook_bead returns empty",
|
||||
agentBeadID: "test-testrig-polecat-idle",
|
||||
setupBeads: func(t *testing.T, bd *beads.Beads) {
|
||||
// Create agent bead without hook
|
||||
_, err := bd.CreateAgentBead("test-testrig-polecat-idle", "Test agent without hook", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create agent bead: %v", err)
|
||||
}
|
||||
},
|
||||
wantIssueID: "",
|
||||
},
|
||||
{
|
||||
name: "nonexistent agent returns empty",
|
||||
agentBeadID: "test-nonexistent",
|
||||
setupBeads: func(t *testing.T, bd *beads.Beads) {},
|
||||
wantIssueID: "",
|
||||
},
|
||||
{
|
||||
name: "empty agent ID returns empty",
|
||||
agentBeadID: "",
|
||||
setupBeads: func(t *testing.T, bd *beads.Beads) {},
|
||||
wantIssueID: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Initialize the beads database
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
// beads.New expects the .beads directory path
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := beads.New(beadsDir)
|
||||
|
||||
tt.setupBeads(t, bd)
|
||||
|
||||
got := getIssueFromAgentHook(bd, tt.agentBeadID)
|
||||
if got != tt.wantIssueID {
|
||||
t.Errorf("getIssueFromAgentHook(%q) = %q, want %q", tt.agentBeadID, got, tt.wantIssueID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
@@ -96,6 +95,12 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("cannot proceed: %w", err)
|
||||
}
|
||||
defer func() { _ = lock.Unlock() }()
|
||||
|
||||
// Prevent tmux server from exiting when all sessions are killed.
|
||||
// By default, tmux exits when there are no sessions (exit-empty on).
|
||||
// This ensures the server stays running for subsequent `gt up`.
|
||||
// Ignore errors - if there's no server, nothing to configure.
|
||||
_ = t.SetExitEmpty(false)
|
||||
}
|
||||
allOK := true
|
||||
|
||||
@@ -106,6 +111,9 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
|
||||
rigs := discoverRigs(townRoot)
|
||||
|
||||
// Pre-fetch all sessions once for O(1) lookups (avoids N+1 subprocess calls)
|
||||
sessionSet, _ := t.GetSessionSet() // Ignore error - empty set is safe fallback
|
||||
|
||||
// Phase 0.5: Stop polecats if --polecats
|
||||
if downPolecats {
|
||||
if downDryRun {
|
||||
@@ -162,12 +170,12 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(sessionName); running {
|
||||
if sessionSet.Has(sessionName) {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
wasRunning, err := stopSession(t, sessionName)
|
||||
wasRunning, err := stopSessionWithCache(t, sessionName, sessionSet)
|
||||
if err != nil {
|
||||
printDownStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
@@ -182,12 +190,12 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(sessionName); running {
|
||||
if sessionSet.Has(sessionName) {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
wasRunning, err := stopSession(t, sessionName)
|
||||
wasRunning, err := stopSessionWithCache(t, sessionName, sessionSet)
|
||||
if err != nil {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
@@ -201,12 +209,12 @@ func runDown(cmd *cobra.Command, args []string) error {
|
||||
// Phase 3: Stop town-level sessions (Mayor, Boot, Deacon)
|
||||
for _, ts := range session.TownSessions() {
|
||||
if downDryRun {
|
||||
if running, _ := t.HasSession(ts.SessionID); running {
|
||||
if sessionSet.Has(ts.SessionID) {
|
||||
printDownStatus(ts.Name, true, "would stop")
|
||||
}
|
||||
continue
|
||||
}
|
||||
stopped, err := session.StopTownSession(t, ts, downForce)
|
||||
stopped, err := session.StopTownSessionWithCache(t, ts, downForce, sessionSet)
|
||||
if err != nil {
|
||||
printDownStatus(ts.Name, false, err.Error())
|
||||
allOK = false
|
||||
@@ -387,8 +395,25 @@ func stopSession(t *tmux.Tmux, sessionName string) (bool, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
return true, t.KillSession(sessionName)
|
||||
// Kill the session (with explicit process termination to prevent orphans)
|
||||
return true, t.KillSessionWithProcesses(sessionName)
|
||||
}
|
||||
|
||||
// stopSessionWithCache is like stopSession but uses a pre-fetched SessionSet
|
||||
// for O(1) existence check instead of spawning a subprocess.
|
||||
func stopSessionWithCache(t *tmux.Tmux, sessionName string, cache *tmux.SessionSet) (bool, error) {
|
||||
if !cache.Has(sessionName) {
|
||||
return false, nil // Already stopped
|
||||
}
|
||||
|
||||
// Try graceful shutdown first (Ctrl-C, best-effort interrupt)
|
||||
if !downForce {
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Kill the session (with explicit process termination to prevent orphans)
|
||||
return true, t.KillSessionWithProcesses(sessionName)
|
||||
}
|
||||
|
||||
// acquireShutdownLock prevents concurrent shutdowns.
|
||||
@@ -451,19 +476,3 @@ func verifyShutdown(t *tmux.Tmux, townRoot string) []string {
|
||||
|
||||
return respawned
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID exists.
|
||||
func isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false // Invalid PID
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
// EPERM means process exists but we don't have permission to signal it
|
||||
if err == syscall.EPERM {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package cmd
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SilentExitError signals that the command should exit with a specific code
|
||||
// without printing an error message. This is used for scripting purposes
|
||||
@@ -19,12 +22,14 @@ func NewSilentExit(code int) *SilentExitError {
|
||||
}
|
||||
|
||||
// IsSilentExit checks if an error is a SilentExitError and returns its code.
|
||||
// Uses errors.As to properly handle wrapped errors.
|
||||
// Returns 0 and false if err is nil or not a SilentExitError.
|
||||
func IsSilentExit(err error) (int, bool) {
|
||||
if err == nil {
|
||||
return 0, false
|
||||
}
|
||||
if se, ok := err.(*SilentExitError); ok {
|
||||
var se *SilentExitError
|
||||
if errors.As(err, &se) {
|
||||
return se.Code, true
|
||||
}
|
||||
return 0, false
|
||||
|
||||
92
internal/cmd/errors_test.go
Normal file
92
internal/cmd/errors_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSilentExitError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{"zero code", 0, "exit 0"},
|
||||
{"success code", 1, "exit 1"},
|
||||
{"error code", 2, "exit 2"},
|
||||
{"custom code", 42, "exit 42"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e := &SilentExitError{Code: tt.code}
|
||||
got := e.Error()
|
||||
if got != tt.want {
|
||||
t.Errorf("SilentExitError.Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSilentExit(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
}{
|
||||
{0},
|
||||
{1},
|
||||
{2},
|
||||
{127},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("code_%d", tt.code), func(t *testing.T) {
|
||||
err := NewSilentExit(tt.code)
|
||||
if err == nil {
|
||||
t.Fatal("NewSilentExit should return non-nil")
|
||||
}
|
||||
if err.Code != tt.code {
|
||||
t.Errorf("NewSilentExit(%d).Code = %d, want %d", tt.code, err.Code, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSilentExit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantIsSilent bool
|
||||
}{
|
||||
{"nil error", nil, 0, false},
|
||||
{"silent exit code 0", NewSilentExit(0), 0, true},
|
||||
{"silent exit code 1", NewSilentExit(1), 1, true},
|
||||
{"silent exit code 2", NewSilentExit(2), 2, true},
|
||||
{"other error", errors.New("some error"), 0, false},
|
||||
{"wrapped silent exit", fmt.Errorf("wrapped: %w", NewSilentExit(5)), 5, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, isSilent := IsSilentExit(tt.err)
|
||||
if isSilent != tt.wantIsSilent {
|
||||
t.Errorf("IsSilentExit(%v) isSilent = %v, want %v", tt.err, isSilent, tt.wantIsSilent)
|
||||
}
|
||||
if code != tt.wantCode {
|
||||
t.Errorf("IsSilentExit(%v) code = %d, want %d", tt.err, code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSilentExitError_Is(t *testing.T) {
|
||||
err := NewSilentExit(1)
|
||||
var target *SilentExitError
|
||||
if !errors.As(err, &target) {
|
||||
t.Error("errors.As should find SilentExitError")
|
||||
}
|
||||
if target.Code != 1 {
|
||||
t.Errorf("errors.As extracted code = %d, want 1", target.Code)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -335,6 +336,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
|
||||
"--title=" + convoyTitle,
|
||||
"--description=" + description,
|
||||
}
|
||||
if beads.NeedsForceForID(convoyID) {
|
||||
createArgs = append(createArgs, "--force")
|
||||
}
|
||||
|
||||
createCmd := exec.Command("bd", createArgs...)
|
||||
createCmd.Dir = townBeads
|
||||
@@ -365,6 +369,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
|
||||
"--title=" + leg.Title,
|
||||
"--description=" + legDesc,
|
||||
}
|
||||
if beads.NeedsForceForID(legBeadID) {
|
||||
legArgs = append(legArgs, "--force")
|
||||
}
|
||||
|
||||
legCmd := exec.Command("bd", legArgs...)
|
||||
legCmd.Dir = townBeads
|
||||
@@ -405,6 +412,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error {
|
||||
"--title=" + f.Synthesis.Title,
|
||||
"--description=" + synDesc,
|
||||
}
|
||||
if beads.NeedsForceForID(synthesisBeadID) {
|
||||
synArgs = append(synArgs, "--force")
|
||||
}
|
||||
|
||||
synCmd := exec.Command("bd", synArgs...)
|
||||
synCmd.Dir = townBeads
|
||||
|
||||
@@ -442,10 +442,11 @@ func sessionWorkDir(sessionName, townRoot string) (string, error) {
|
||||
return "", fmt.Errorf("cannot parse crew session name: %s", sessionName)
|
||||
|
||||
case strings.HasSuffix(sessionName, "-witness"):
|
||||
// gt-<rig>-witness -> <townRoot>/<rig>/witness/rig
|
||||
// gt-<rig>-witness -> <townRoot>/<rig>/witness
|
||||
// Note: witness doesn't have a /rig worktree like refinery does
|
||||
rig := strings.TrimPrefix(sessionName, "gt-")
|
||||
rig = strings.TrimSuffix(rig, "-witness")
|
||||
return fmt.Sprintf("%s/%s/witness/rig", townRoot, rig), nil
|
||||
return fmt.Sprintf("%s/%s/witness", townRoot, rig), nil
|
||||
|
||||
case strings.HasSuffix(sessionName, "-refinery"):
|
||||
// gt-<rig>-refinery -> <townRoot>/<rig>/refinery/rig
|
||||
@@ -479,27 +480,13 @@ func sessionToGTRole(sessionName string) string {
|
||||
|
||||
// detectTownRootFromCwd walks up from the current directory to find the town root.
|
||||
func detectTownRootFromCwd() string {
|
||||
cwd, err := os.Getwd()
|
||||
// Use workspace.FindFromCwd which handles both primary (mayor/town.json)
|
||||
// and secondary (mayor/ directory) markers
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
dir := cwd
|
||||
for {
|
||||
// Check for primary marker (mayor/town.json)
|
||||
markerPath := filepath.Join(dir, "mayor", "town.json")
|
||||
if _, err := os.Stat(markerPath); err == nil {
|
||||
return dir
|
||||
}
|
||||
|
||||
// Move up
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
return townRoot
|
||||
}
|
||||
|
||||
// handoffRemoteSession respawns a different session and optionally switches to it.
|
||||
|
||||
@@ -60,10 +60,12 @@ Examples:
|
||||
|
||||
// hookShowCmd shows hook status in compact one-line format
|
||||
var hookShowCmd = &cobra.Command{
|
||||
Use: "show <agent>",
|
||||
Use: "show [agent]",
|
||||
Short: "Show what's on an agent's hook (compact)",
|
||||
Long: `Show what's on any agent's hook in compact one-line format.
|
||||
|
||||
With no argument, shows your own hook status (auto-detected from context).
|
||||
|
||||
Use cases:
|
||||
- Mayor checking what polecats are working on
|
||||
- Witness checking polecat status
|
||||
@@ -71,13 +73,14 @@ Use cases:
|
||||
- Quick status overview
|
||||
|
||||
Examples:
|
||||
gt hook show # What's on MY hook? (auto-detect)
|
||||
gt hook show gastown/polecats/nux # What's nux working on?
|
||||
gt hook show gastown/witness # What's the witness hooked to?
|
||||
gt hook show mayor # What's the mayor working on?
|
||||
|
||||
Output format (one line):
|
||||
gastown/polecats/nux: gt-abc123 'Fix the widget bug' [in_progress]`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runHookShow,
|
||||
}
|
||||
|
||||
@@ -86,6 +89,7 @@ var (
|
||||
hookMessage string
|
||||
hookDryRun bool
|
||||
hookForce bool
|
||||
hookClear bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -94,6 +98,7 @@ func init() {
|
||||
hookCmd.Flags().StringVarP(&hookMessage, "message", "m", "", "Message for handoff mail (optional)")
|
||||
hookCmd.Flags().BoolVarP(&hookDryRun, "dry-run", "n", false, "Show what would be done")
|
||||
hookCmd.Flags().BoolVarP(&hookForce, "force", "f", false, "Replace existing incomplete hooked bead")
|
||||
hookCmd.Flags().BoolVar(&hookClear, "clear", false, "Clear your hook (alias for 'gt unhook')")
|
||||
|
||||
// --json flag for status output (used when no args, i.e., gt hook --json)
|
||||
hookCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON (for status)")
|
||||
@@ -105,8 +110,15 @@ func init() {
|
||||
rootCmd.AddCommand(hookCmd)
|
||||
}
|
||||
|
||||
// runHookOrStatus dispatches to status or hook based on args
|
||||
// runHookOrStatus dispatches to status, clear, or hook based on args/flags
|
||||
func runHookOrStatus(cmd *cobra.Command, args []string) error {
|
||||
// --clear flag is alias for 'gt unhook'
|
||||
if hookClear {
|
||||
// Pass through dry-run and force flags
|
||||
unslingDryRun = hookDryRun
|
||||
unslingForce = hookForce
|
||||
return runUnsling(cmd, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
// No args - show status
|
||||
return runMoleculeStatus(cmd, args)
|
||||
@@ -230,8 +242,10 @@ func runHook(_ *cobra.Command, args []string) error {
|
||||
fmt.Printf(" Use 'gt handoff' to restart with this work\n")
|
||||
fmt.Printf(" Use 'gt hook' to see hook status\n")
|
||||
|
||||
// Log hook event to activity feed
|
||||
_ = events.LogFeed(events.TypeHook, agentID, events.HookPayload(beadID))
|
||||
// Log hook event to activity feed (non-fatal)
|
||||
if err := events.LogFeed(events.TypeHook, agentID, events.HookPayload(beadID)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: failed to log hook event: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -265,7 +279,17 @@ func checkPinnedBeadComplete(b *beads.Beads, issue *beads.Issue) (isComplete boo
|
||||
|
||||
// runHookShow displays another agent's hook in compact one-line format.
|
||||
func runHookShow(cmd *cobra.Command, args []string) error {
|
||||
target := args[0]
|
||||
var target string
|
||||
if len(args) > 0 {
|
||||
target = args[0]
|
||||
} else {
|
||||
// Auto-detect current agent from context
|
||||
agentID, _, _, err := resolveSelfTarget()
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-detecting agent (use explicit argument): %w", err)
|
||||
}
|
||||
target = agentID
|
||||
}
|
||||
|
||||
// Find beads directory
|
||||
workDir, err := findLocalBeadsDir()
|
||||
|
||||
@@ -74,6 +74,47 @@ type VersionChange struct {
|
||||
|
||||
// versionChanges contains agent-actionable changes for recent versions
|
||||
var versionChanges = []VersionChange{
|
||||
{
|
||||
Version: "0.4.0",
|
||||
Date: "2026-01-17",
|
||||
Changes: []string{
|
||||
"FIX: Orphan cleanup skips valid tmux sessions - Prevents false kills of witnesses/refineries/deacon during startup by checking gt-*/hq-* session membership",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.3.1",
|
||||
Date: "2026-01-17",
|
||||
Changes: []string{
|
||||
"FIX: Orphan cleanup on macOS - TTY comparison now handles macOS '??' format",
|
||||
"FIX: Session kill orphan prevention - gt done and gt crew stop use KillSessionWithProcesses",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.3.0",
|
||||
Date: "2026-01-17",
|
||||
Changes: []string{
|
||||
"NEW: gt show/cat - Inspect bead contents and metadata",
|
||||
"NEW: gt orphans list/kill - Detect and clean up orphaned Claude processes",
|
||||
"NEW: gt convoy close - Manual convoy closure command",
|
||||
"NEW: gt commit/trail - Git wrappers with bead awareness",
|
||||
"NEW: Plugin system - gt plugin run/history, gt dispatch --plugin",
|
||||
"NEW: Beads-native messaging - Queue, channel, and group beads",
|
||||
"NEW: gt mail claim - Claim messages from queues",
|
||||
"NEW: gt polecat identity show - Display CV summary",
|
||||
"NEW: gastown-release molecule formula - Automated release workflow",
|
||||
"NEW: Parallel agent startup - Faster boot with concurrency limit",
|
||||
"NEW: Automatic orphan cleanup - Detect and kill orphaned processes",
|
||||
"NEW: Worktree setup hooks - Inject local configurations",
|
||||
"CHANGED: MR tracking via beads - Removed mrqueue package",
|
||||
"CHANGED: Desire-path commands - Agent ergonomics shortcuts",
|
||||
"CHANGED: Explicit escalation in polecat templates",
|
||||
"FIX: Kill process tree on shutdown - Prevents orphaned Claude processes",
|
||||
"FIX: Agent bead prefix alignment - Multi-hyphen IDs for consistency",
|
||||
"FIX: Idle Polecat Heresy warnings in templates",
|
||||
"FIX: Zombie session detection in doctor",
|
||||
"FIX: Windows build support with platform-specific handling",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.2.0",
|
||||
Date: "2026-01-04",
|
||||
|
||||
@@ -378,6 +378,14 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not set custom types: %s\n", style.Dim.Render("⚠"), strings.TrimSpace(string(configOutput)))
|
||||
}
|
||||
|
||||
// Configure allowed_prefixes for convoy beads (hq-cv-* IDs).
|
||||
// This allows bd create --id=hq-cv-xxx to pass prefix validation.
|
||||
prefixCmd := exec.Command("bd", "config", "set", "allowed_prefixes", "hq,hq-cv")
|
||||
prefixCmd.Dir = townPath
|
||||
if prefixOutput, prefixErr := prefixCmd.CombinedOutput(); prefixErr != nil {
|
||||
fmt.Printf(" %s Could not set allowed_prefixes: %s\n", style.Dim.Render("⚠"), strings.TrimSpace(string(prefixOutput)))
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -404,6 +412,12 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not update routes.jsonl: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Register hq-cv- prefix for convoy beads (auto-created by gt sling).
|
||||
// Convoys use hq-cv-* IDs for visual distinction from other town beads.
|
||||
if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-cv-", Path: "."}); err != nil {
|
||||
fmt.Printf(" %s Could not register convoy prefix: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -150,9 +150,10 @@ Examples:
|
||||
var mailReadCmd = &cobra.Command{
|
||||
Use: "read <message-id>",
|
||||
Short: "Read a message",
|
||||
Long: `Read a specific message and mark it as read.
|
||||
Long: `Read a specific message (does not mark as read).
|
||||
|
||||
The message ID can be found from 'gt mail inbox'.`,
|
||||
The message ID can be found from 'gt mail inbox'.
|
||||
Use 'gt mail mark-read' to mark messages as read.`,
|
||||
Aliases: []string{"show"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailRead,
|
||||
@@ -193,8 +194,9 @@ Examples:
|
||||
}
|
||||
|
||||
var mailMarkReadCmd = &cobra.Command{
|
||||
Use: "mark-read <message-id> [message-id...]",
|
||||
Short: "Mark messages as read without archiving",
|
||||
Use: "mark-read <message-id> [message-id...]",
|
||||
Aliases: []string{"ack"},
|
||||
Short: "Mark messages as read without archiving",
|
||||
Long: `Mark one or more messages as read without removing them from inbox.
|
||||
|
||||
This adds a 'read' label to the message, which is reflected in the inbox display.
|
||||
@@ -277,27 +279,27 @@ Examples:
|
||||
}
|
||||
|
||||
var mailClaimCmd = &cobra.Command{
|
||||
Use: "claim <queue-name>",
|
||||
Use: "claim [queue-name]",
|
||||
Short: "Claim a message from a queue",
|
||||
Long: `Claim the oldest unclaimed message from a work queue.
|
||||
|
||||
SYNTAX:
|
||||
gt mail claim <queue-name>
|
||||
gt mail claim [queue-name]
|
||||
|
||||
BEHAVIOR:
|
||||
1. List unclaimed messages in the queue
|
||||
2. Pick the oldest unclaimed message
|
||||
3. Set assignee to caller identity
|
||||
4. Set status to in_progress
|
||||
5. Print claimed message details
|
||||
1. If queue specified, claim from that queue
|
||||
2. If no queue specified, claim from any eligible queue
|
||||
3. Add claimed-by and claimed-at labels to the message
|
||||
4. Print claimed message details
|
||||
|
||||
ELIGIBILITY:
|
||||
The caller must match a pattern in the queue's workers list
|
||||
(defined in ~/gt/config/messaging.json).
|
||||
The caller must match the queue's claim_pattern (stored in the queue bead).
|
||||
Pattern examples: "*" (anyone), "gastown/polecats/*" (specific rig crew).
|
||||
|
||||
Examples:
|
||||
gt mail claim work/gastown # Claim from gastown work queue`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
gt mail claim work-requests # Claim from specific queue
|
||||
gt mail claim # Claim from any eligible queue`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runMailClaim,
|
||||
}
|
||||
|
||||
@@ -311,14 +313,14 @@ SYNTAX:
|
||||
|
||||
BEHAVIOR:
|
||||
1. Find the message by ID
|
||||
2. Verify caller is the one who claimed it (assignee matches)
|
||||
3. Set assignee back to queue:<name> (from message labels)
|
||||
4. Set status back to open
|
||||
5. Message returns to queue for others to claim
|
||||
2. Verify caller is the one who claimed it (claimed-by label matches)
|
||||
3. Remove claimed-by and claimed-at labels
|
||||
4. Message returns to queue for others to claim
|
||||
|
||||
ERROR CASES:
|
||||
- Message not found
|
||||
- Message not claimed (still assigned to queue)
|
||||
- Message is not a queue message
|
||||
- Message not claimed
|
||||
- Caller did not claim this message
|
||||
|
||||
Examples:
|
||||
|
||||
548
internal/cmd/mail_channel.go
Normal file
548
internal/cmd/mail_channel.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Channel command flags
|
||||
var (
|
||||
channelJSON bool
|
||||
channelRetainCount int
|
||||
channelRetainHours int
|
||||
)
|
||||
|
||||
var mailChannelCmd = &cobra.Command{
|
||||
Use: "channel [name]",
|
||||
Short: "Manage and view beads-native channels",
|
||||
Long: `View and manage beads-native broadcast channels.
|
||||
|
||||
Without arguments, lists all channels.
|
||||
With a channel name, shows messages from that channel.
|
||||
|
||||
Channels are pub/sub streams where messages are broadcast to subscribers.
|
||||
Messages are retained according to the channel's retention policy.
|
||||
|
||||
Examples:
|
||||
gt mail channel # List all channels
|
||||
gt mail channel alerts # View messages from 'alerts' channel
|
||||
gt mail channel list # Alias for listing channels
|
||||
gt mail channel show alerts # Same as: gt mail channel alerts
|
||||
gt mail channel create alerts --retain-count=100
|
||||
gt mail channel delete alerts`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runMailChannel,
|
||||
}
|
||||
|
||||
var channelListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all channels",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runChannelList,
|
||||
}
|
||||
|
||||
var channelShowCmd = &cobra.Command{
|
||||
Use: "show <name>",
|
||||
Short: "Show channel messages",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelShow,
|
||||
}
|
||||
|
||||
var channelCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new channel",
|
||||
Long: `Create a new broadcast channel.
|
||||
|
||||
Retention policy:
|
||||
--retain-count=N Keep only last N messages (0 = unlimited)
|
||||
--retain-hours=N Delete messages older than N hours (0 = forever)`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelCreate,
|
||||
}
|
||||
|
||||
var channelDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: "Delete a channel",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelDelete,
|
||||
}
|
||||
|
||||
var channelSubscribeCmd = &cobra.Command{
|
||||
Use: "subscribe <name>",
|
||||
Short: "Subscribe to a channel",
|
||||
Long: `Subscribe the current identity (BD_ACTOR) to a channel.
|
||||
|
||||
Subscribers receive messages broadcast to the channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelSubscribe,
|
||||
}
|
||||
|
||||
var channelUnsubscribeCmd = &cobra.Command{
|
||||
Use: "unsubscribe <name>",
|
||||
Short: "Unsubscribe from a channel",
|
||||
Long: `Unsubscribe the current identity (BD_ACTOR) from a channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelUnsubscribe,
|
||||
}
|
||||
|
||||
var channelSubscribersCmd = &cobra.Command{
|
||||
Use: "subscribers <name>",
|
||||
Short: "List channel subscribers",
|
||||
Long: `List all subscribers to a channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelSubscribers,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List flags
|
||||
channelListCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Show flags
|
||||
channelShowCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Create flags
|
||||
channelCreateCmd.Flags().IntVar(&channelRetainCount, "retain-count", 0, "Number of messages to retain (0 = unlimited)")
|
||||
channelCreateCmd.Flags().IntVar(&channelRetainHours, "retain-hours", 0, "Hours to retain messages (0 = forever)")
|
||||
|
||||
// Subscribers flags
|
||||
channelSubscribersCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Main channel command flags
|
||||
mailChannelCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Add subcommands
|
||||
mailChannelCmd.AddCommand(channelListCmd)
|
||||
mailChannelCmd.AddCommand(channelShowCmd)
|
||||
mailChannelCmd.AddCommand(channelCreateCmd)
|
||||
mailChannelCmd.AddCommand(channelDeleteCmd)
|
||||
mailChannelCmd.AddCommand(channelSubscribeCmd)
|
||||
mailChannelCmd.AddCommand(channelUnsubscribeCmd)
|
||||
mailChannelCmd.AddCommand(channelSubscribersCmd)
|
||||
|
||||
mailCmd.AddCommand(mailChannelCmd)
|
||||
}
|
||||
|
||||
// runMailChannel handles the main channel command (list or show).
|
||||
func runMailChannel(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return runChannelList(cmd, args)
|
||||
}
|
||||
return runChannelShow(cmd, args)
|
||||
}
|
||||
|
||||
func runChannelList(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
channels, err := b.ListChannelBeads()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing channels: %w", err)
|
||||
}
|
||||
|
||||
if channelJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(channels)
|
||||
}
|
||||
|
||||
if len(channels) == 0 {
|
||||
fmt.Println("No channels defined.")
|
||||
fmt.Println("\nCreate one with: gt mail channel create <name>")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "NAME\tRETENTION\tSTATUS\tCREATED BY")
|
||||
for name, fields := range channels {
|
||||
retention := "unlimited"
|
||||
if fields.RetentionCount > 0 {
|
||||
retention = fmt.Sprintf("%d msgs", fields.RetentionCount)
|
||||
} else if fields.RetentionHours > 0 {
|
||||
retention = fmt.Sprintf("%d hours", fields.RetentionHours)
|
||||
}
|
||||
status := fields.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, retention, status, fields.CreatedBy)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runChannelShow(cmd *cobra.Command, args []string) error {
|
||||
channelName := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check if channel exists
|
||||
_, fields, err := b.GetChannelBead(channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", channelName)
|
||||
}
|
||||
|
||||
// Query messages for this channel
|
||||
messages, err := listChannelMessages(townRoot, channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing channel messages: %w", err)
|
||||
}
|
||||
|
||||
if channelJSON {
|
||||
if messages == nil {
|
||||
messages = []channelMessage{}
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(messages)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Channel: %s (%d messages)\n",
|
||||
style.Bold.Render("📡"), channelName, len(messages))
|
||||
if fields.RetentionCount > 0 {
|
||||
fmt.Printf(" Retention: %d messages\n", fields.RetentionCount)
|
||||
} else if fields.RetentionHours > 0 {
|
||||
fmt.Printf(" Retention: %d hours\n", fields.RetentionHours)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
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.Body != "" {
|
||||
// Show first line as preview
|
||||
lines := strings.SplitN(msg.Body, "\n", 2)
|
||||
preview := lines[0]
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" %s\n", style.Dim.Render(preview))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelCreate(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
if !isValidGroupName(name) { // Reuse group name validation
|
||||
return fmt.Errorf("invalid channel name %q: must be alphanumeric with dashes/underscores", name)
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
createdBy := os.Getenv("BD_ACTOR")
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check if channel already exists
|
||||
existing, _, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("channel already exists: %s", name)
|
||||
}
|
||||
|
||||
_, err = b.CreateChannelBead(name, nil, createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating channel: %w", err)
|
||||
}
|
||||
|
||||
// Update retention settings if specified
|
||||
if channelRetainCount > 0 || channelRetainHours > 0 {
|
||||
if err := b.UpdateChannelRetention(name, channelRetainCount, channelRetainHours); err != nil {
|
||||
// Non-fatal: channel created but retention not set
|
||||
fmt.Printf("Warning: could not set retention: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Created channel %q", name)
|
||||
if channelRetainCount > 0 {
|
||||
fmt.Printf(" (retain %d messages)", channelRetainCount)
|
||||
} else if channelRetainHours > 0 {
|
||||
fmt.Printf(" (retain %d hours)", channelRetainHours)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelDelete(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check if channel exists
|
||||
existing, _, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
if err := b.DeleteChannelBead(name); err != nil {
|
||||
return fmt.Errorf("deleting channel: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted channel %q\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelSubscribe(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
subscriber := os.Getenv("BD_ACTOR")
|
||||
if subscriber == "" {
|
||||
return fmt.Errorf("BD_ACTOR not set - cannot determine subscriber identity")
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check channel exists and current subscription status
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
for _, s := range fields.Subscribers {
|
||||
if s == subscriber {
|
||||
fmt.Printf("%s is already subscribed to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.SubscribeToChannel(name, subscriber); err != nil {
|
||||
return fmt.Errorf("subscribing to channel: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Subscribed %s to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelUnsubscribe(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
subscriber := os.Getenv("BD_ACTOR")
|
||||
if subscriber == "" {
|
||||
return fmt.Errorf("BD_ACTOR not set - cannot determine subscriber identity")
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check channel exists and current subscription status
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Check if actually subscribed
|
||||
found := false
|
||||
for _, s := range fields.Subscribers {
|
||||
if s == subscriber {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Printf("%s is not subscribed to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := b.UnsubscribeFromChannel(name, subscriber); err != nil {
|
||||
return fmt.Errorf("unsubscribing from channel: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Unsubscribed %s from channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelSubscribers(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
if channelJSON {
|
||||
subs := fields.Subscribers
|
||||
if subs == nil {
|
||||
subs = []string{}
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(subs)
|
||||
}
|
||||
|
||||
if len(fields.Subscribers) == 0 {
|
||||
fmt.Printf("Channel %q has no subscribers\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Subscribers to channel %q:\n", name)
|
||||
for _, sub := range fields.Subscribers {
|
||||
fmt.Printf(" %s\n", sub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// channelMessage represents a message in a channel.
|
||||
type channelMessage struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
From string `json:"from"`
|
||||
Created time.Time `json:"created"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// listChannelMessages lists messages from a beads-native channel.
|
||||
func listChannelMessages(townRoot, channelName string) ([]channelMessage, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// Query for messages with label channel:<name>
|
||||
args := []string{"list",
|
||||
"--type", "message",
|
||||
"--label", "channel:" + channelName,
|
||||
"--sort", "-created",
|
||||
"--limit", "0",
|
||||
"--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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var messages []channelMessage
|
||||
for _, issue := range issues {
|
||||
msg := channelMessage{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Body: issue.Description,
|
||||
Created: issue.CreatedAt,
|
||||
Priority: issue.Priority,
|
||||
}
|
||||
|
||||
// Extract 'from' from labels
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "from:") {
|
||||
msg.From = strings.TrimPrefix(label, "from:")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Created.After(messages[j].Created)
|
||||
})
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
354
internal/cmd/mail_group.go
Normal file
354
internal/cmd/mail_group.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Group command flags
|
||||
var (
|
||||
groupJSON bool
|
||||
groupMembers []string
|
||||
)
|
||||
|
||||
var mailGroupCmd = &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "Manage mail groups",
|
||||
Long: `Create and manage mail distribution groups.
|
||||
|
||||
Groups are named collections of addresses used for mail distribution.
|
||||
Members can be:
|
||||
- Direct addresses (gastown/crew/max)
|
||||
- Patterns (*/witness, gastown/*)
|
||||
- Other group names (nested groups)
|
||||
|
||||
Examples:
|
||||
gt mail group list # List all groups
|
||||
gt mail group show ops-team # Show group members
|
||||
gt mail group create ops-team gastown/witness gastown/crew/max
|
||||
gt mail group add ops-team deacon/
|
||||
gt mail group remove ops-team gastown/witness
|
||||
gt mail group delete ops-team`,
|
||||
RunE: requireSubcommand,
|
||||
}
|
||||
|
||||
var groupListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all groups",
|
||||
Long: "List all mail distribution groups.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runGroupList,
|
||||
}
|
||||
|
||||
var groupShowCmd = &cobra.Command{
|
||||
Use: "show <name>",
|
||||
Short: "Show group details",
|
||||
Long: "Display the members and metadata for a group.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runGroupShow,
|
||||
}
|
||||
|
||||
var groupCreateCmd = &cobra.Command{
|
||||
Use: "create <name> [members...]",
|
||||
Short: "Create a new group",
|
||||
Long: `Create a new mail distribution group.
|
||||
|
||||
Members can be specified as positional arguments or with --member flags.
|
||||
|
||||
Examples:
|
||||
gt mail group create ops-team gastown/witness gastown/crew/max
|
||||
gt mail group create ops-team --member gastown/witness --member gastown/crew/max`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runGroupCreate,
|
||||
}
|
||||
|
||||
var groupAddCmd = &cobra.Command{
|
||||
Use: "add <name> <member>",
|
||||
Short: "Add member to group",
|
||||
Long: "Add a new member to an existing group.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runGroupAdd,
|
||||
}
|
||||
|
||||
var groupRemoveCmd = &cobra.Command{
|
||||
Use: "remove <name> <member>",
|
||||
Short: "Remove member from group",
|
||||
Long: "Remove a member from an existing group.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runGroupRemove,
|
||||
}
|
||||
|
||||
var groupDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: "Delete a group",
|
||||
Long: "Permanently delete a mail distribution group.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runGroupDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List flags
|
||||
groupListCmd.Flags().BoolVar(&groupJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Show flags
|
||||
groupShowCmd.Flags().BoolVar(&groupJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Create flags
|
||||
groupCreateCmd.Flags().StringArrayVar(&groupMembers, "member", nil, "Member to add (repeatable)")
|
||||
|
||||
// Add subcommands
|
||||
mailGroupCmd.AddCommand(groupListCmd)
|
||||
mailGroupCmd.AddCommand(groupShowCmd)
|
||||
mailGroupCmd.AddCommand(groupCreateCmd)
|
||||
mailGroupCmd.AddCommand(groupAddCmd)
|
||||
mailGroupCmd.AddCommand(groupRemoveCmd)
|
||||
mailGroupCmd.AddCommand(groupDeleteCmd)
|
||||
|
||||
mailCmd.AddCommand(mailGroupCmd)
|
||||
}
|
||||
|
||||
func runGroupList(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
groups, err := b.ListGroupBeads()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing groups: %w", err)
|
||||
}
|
||||
|
||||
if groupJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(groups)
|
||||
}
|
||||
|
||||
if len(groups) == 0 {
|
||||
fmt.Println("No groups defined.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "NAME\tMEMBERS\tCREATED BY")
|
||||
for name, fields := range groups {
|
||||
memberCount := len(fields.Members)
|
||||
memberStr := fmt.Sprintf("%d member(s)", memberCount)
|
||||
if memberCount <= 3 {
|
||||
memberStr = strings.Join(fields.Members, ", ")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", name, memberStr, fields.CreatedBy)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func runGroupShow(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
issue, fields, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting group: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("group not found: %s", name)
|
||||
}
|
||||
|
||||
if groupJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(fields)
|
||||
}
|
||||
|
||||
fmt.Printf("Group: %s\n", fields.Name)
|
||||
fmt.Printf("Created by: %s\n", fields.CreatedBy)
|
||||
if fields.CreatedAt != "" {
|
||||
fmt.Printf("Created at: %s\n", fields.CreatedAt)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("Members:")
|
||||
if len(fields.Members) == 0 {
|
||||
fmt.Println(" (no members)")
|
||||
} else {
|
||||
for _, m := range fields.Members {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGroupCreate(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
members := args[1:] // Positional members
|
||||
|
||||
// Add --member flag values
|
||||
members = append(members, groupMembers...)
|
||||
|
||||
if !isValidGroupName(name) {
|
||||
return fmt.Errorf("invalid group name %q: must be alphanumeric with dashes/underscores", name)
|
||||
}
|
||||
|
||||
// Validate member patterns
|
||||
for _, m := range members {
|
||||
if !isValidMemberPattern(m) {
|
||||
return fmt.Errorf("invalid member pattern: %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Detect creator
|
||||
createdBy := os.Getenv("BD_ACTOR")
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check if group already exists
|
||||
existing, _, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("group already exists: %s", name)
|
||||
}
|
||||
|
||||
_, err = b.CreateGroupBead(name, members, createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating group: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created group %q with %d member(s)\n", name, len(members))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGroupAdd(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
member := args[1]
|
||||
|
||||
if !isValidMemberPattern(member) {
|
||||
return fmt.Errorf("invalid member pattern: %s", member)
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
if err := b.AddGroupMember(name, member); err != nil {
|
||||
return fmt.Errorf("adding member: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Added %q to group %q\n", member, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGroupRemove(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
member := args[1]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
if err := b.RemoveGroupMember(name, member); err != nil {
|
||||
return fmt.Errorf("removing member: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed %q from group %q\n", member, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGroupDelete(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check if group exists
|
||||
existing, _, err := b.GetGroupBead(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return fmt.Errorf("group not found: %s", name)
|
||||
}
|
||||
|
||||
if err := b.DeleteGroupBead(name); err != nil {
|
||||
return fmt.Errorf("deleting group: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted group %q\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidGroupName checks if a group name is valid.
|
||||
// Group names must be alphanumeric with dashes and underscores.
|
||||
func isValidGroupName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isValidMemberPattern checks if a member pattern is syntactically valid.
|
||||
// Valid patterns include:
|
||||
// - Direct addresses: gastown/crew/max, mayor/, deacon/
|
||||
// - Wildcards: */witness, gastown/*, gastown/crew/*
|
||||
// - Special patterns: @town, @crew, @witnesses
|
||||
// - Group names: ops-team
|
||||
func isValidMemberPattern(pattern string) bool {
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// @ patterns are valid
|
||||
if strings.HasPrefix(pattern, "@") {
|
||||
return len(pattern) > 1
|
||||
}
|
||||
|
||||
// Path patterns with wildcards
|
||||
if strings.Contains(pattern, "/") {
|
||||
// Must have valid path segments
|
||||
parts := strings.Split(pattern, "/")
|
||||
for _, p := range parts {
|
||||
if p == "" && pattern[len(pattern)-1] != '/' {
|
||||
return false // Empty segment (except trailing /)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Simple name (group reference) - use same validation as group names
|
||||
return isValidGroupName(pattern)
|
||||
}
|
||||
73
internal/cmd/mail_group_test.go
Normal file
73
internal/cmd/mail_group_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidGroupName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"ops-team", true},
|
||||
{"all_witnesses", true},
|
||||
{"team123", true},
|
||||
{"A", true},
|
||||
{"abc", true},
|
||||
{"my-cool-group", true},
|
||||
|
||||
// Invalid
|
||||
{"", false},
|
||||
{"with spaces", false},
|
||||
{"with.dots", false},
|
||||
{"@team", false},
|
||||
{"group/name", false},
|
||||
{"team!", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isValidGroupName(tt.name); got != tt.want {
|
||||
t.Errorf("isValidGroupName(%q) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidMemberPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
// Direct addresses
|
||||
{"gastown/crew/max", true},
|
||||
{"mayor/", true},
|
||||
{"deacon/", true},
|
||||
{"gastown/witness", true},
|
||||
|
||||
// Wildcard patterns
|
||||
{"*/witness", true},
|
||||
{"gastown/*", true},
|
||||
{"gastown/crew/*", true},
|
||||
|
||||
// Special patterns
|
||||
{"@town", true},
|
||||
{"@crew", true},
|
||||
{"@witnesses", true},
|
||||
{"@rig/gastown", true},
|
||||
|
||||
// Group names
|
||||
{"ops-team", true},
|
||||
{"all_witnesses", true},
|
||||
|
||||
// Invalid
|
||||
{"", false},
|
||||
{"@", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pattern, func(t *testing.T) {
|
||||
if got := isValidMemberPattern(tt.pattern); got != tt.want {
|
||||
t.Errorf("isValidMemberPattern(%q) = %v, want %v", tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -11,6 +12,23 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
// getMailbox returns the mailbox for the given address.
|
||||
func getMailbox(address string) (*mail.Mailbox, error) {
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
return mailbox, nil
|
||||
}
|
||||
|
||||
func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox to check (priority: --identity flag, positional arg, auto-detect)
|
||||
address := ""
|
||||
@@ -22,17 +40,9 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get messages
|
||||
@@ -93,22 +103,17 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runMailRead(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("msgID argument required")
|
||||
}
|
||||
msgID := args[0]
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := mailbox.Get(msgID)
|
||||
@@ -164,15 +169,7 @@ 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)
|
||||
mailbox, err := getMailbox(address)
|
||||
if err != nil {
|
||||
return NewSilentExit(1) // Silent exit - can't access mailbox
|
||||
}
|
||||
@@ -220,22 +217,17 @@ func runMailPeek(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runMailDelete(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("msgID argument required")
|
||||
}
|
||||
msgID := args[0]
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := mailbox.Delete(msgID); err != nil {
|
||||
@@ -250,17 +242,9 @@ func runMailArchive(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Archive all specified messages
|
||||
@@ -296,17 +280,9 @@ func runMailMarkRead(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark all specified messages as read
|
||||
@@ -342,17 +318,9 @@ func runMailMarkUnread(cmd *cobra.Command, args []string) error {
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark all specified messages as unread
|
||||
@@ -393,17 +361,9 @@ func runMailClear(cmd *cobra.Command, args []string) error {
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// All mail uses town beads (two-level architecture)
|
||||
workDir, err := findMailWorkDir()
|
||||
mailbox, err := getMailbox(address)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// List all messages
|
||||
@@ -422,6 +382,10 @@ func runMailClear(cmd *cobra.Command, args []string) error {
|
||||
var errors []string
|
||||
for _, msg := range messages {
|
||||
if err := mailbox.Delete(msg.ID); err != nil {
|
||||
// If file is already gone (race condition), ignore it and count as success
|
||||
if os.IsNotExist(err) || strings.Contains(err.Error(), "no such file") {
|
||||
continue
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", msg.ID, err))
|
||||
} else {
|
||||
deleted++
|
||||
|
||||
@@ -6,52 +6,86 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// runMailClaim claims the oldest unclaimed message from a work queue.
|
||||
// If a queue name is provided, claims from that specific queue.
|
||||
// If no queue name is provided, claims from any queue the caller is eligible for.
|
||||
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()
|
||||
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||
bd := beads.NewWithBeadsDir(townRoot, beadsDir)
|
||||
|
||||
// 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)
|
||||
var queueName string
|
||||
var queueFields *beads.QueueFields
|
||||
|
||||
if len(args) > 0 {
|
||||
// Specific queue requested
|
||||
queueName = args[0]
|
||||
|
||||
// Look up the queue bead
|
||||
queueID := beads.QueueBeadID(queueName, true) // Try town-level first
|
||||
issue, fields, err := bd.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
// Try rig-level
|
||||
queueID = beads.QueueBeadID(queueName, false)
|
||||
issue, fields, err = bd.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("unknown queue: %s", queueName)
|
||||
}
|
||||
}
|
||||
queueFields = fields
|
||||
|
||||
// Check if caller is eligible
|
||||
if !beads.MatchClaimPattern(queueFields.ClaimPattern, caller) {
|
||||
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, pattern: %s)",
|
||||
queueName, caller, queueFields.ClaimPattern)
|
||||
}
|
||||
} else {
|
||||
// No queue specified - find any queue the caller can claim from
|
||||
eligibleIssues, eligibleFields, err := bd.FindEligibleQueues(caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding eligible queues: %w", err)
|
||||
}
|
||||
if len(eligibleIssues) == 0 {
|
||||
fmt.Printf("%s No queues available for claiming (caller: %s)\n",
|
||||
style.Dim.Render("○"), caller)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the first eligible queue
|
||||
queueFields = eligibleFields[0]
|
||||
queueName = queueFields.Name
|
||||
if queueName == "" {
|
||||
// Fallback to ID-based name
|
||||
queueName = eligibleIssues[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
// List unclaimed messages in the queue
|
||||
// Queue messages have assignee=queue:<name> and status=open
|
||||
queueAssignee := "queue:" + queueName
|
||||
messages, err := listQueueMessages(townRoot, queueAssignee)
|
||||
// Queue messages have queue:<name> label and no claimed-by label
|
||||
messages, err := listUnclaimedQueueMessages(beadsDir, queueName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing queue messages: %w", err)
|
||||
}
|
||||
@@ -64,8 +98,8 @@ func runMailClaim(cmd *cobra.Command, args []string) error {
|
||||
// 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 {
|
||||
// Claim the message: add claimed-by and claimed-at labels
|
||||
if err := claimQueueMessage(beadsDir, oldest.ID, caller); err != nil {
|
||||
return fmt.Errorf("claiming message: %w", err)
|
||||
}
|
||||
|
||||
@@ -96,60 +130,18 @@ type queueMessage struct {
|
||||
From string
|
||||
Created time.Time
|
||||
Priority int
|
||||
ClaimedBy string
|
||||
ClaimedAt *time.Time
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// listUnclaimedQueueMessages lists unclaimed messages in a queue.
|
||||
// Unclaimed messages have queue:<name> label but no claimed-by label.
|
||||
func listUnclaimedQueueMessages(beadsDir, queueName string) ([]queueMessage, error) {
|
||||
// Use bd list to find messages with queue:<name> label and status=open
|
||||
args := []string{"list",
|
||||
"--assignee", queueAssignee,
|
||||
"--label", "queue:" + queueName,
|
||||
"--status", "open",
|
||||
"--type", "message",
|
||||
"--sort", "created",
|
||||
"--limit", "0", // No limit
|
||||
"--json",
|
||||
}
|
||||
|
||||
@@ -186,7 +178,7 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to queueMessage, extracting 'from' from labels
|
||||
// Convert to queueMessage, filtering out already claimed messages
|
||||
var messages []queueMessage
|
||||
for _, issue := range issues {
|
||||
msg := queueMessage{
|
||||
@@ -197,18 +189,27 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
Priority: issue.Priority,
|
||||
}
|
||||
|
||||
// Extract 'from' from labels (format: "from:address")
|
||||
// Extract labels
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "from:") {
|
||||
msg.From = strings.TrimPrefix(label, "from:")
|
||||
break
|
||||
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||
msg.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
msg.ClaimedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
// Only include unclaimed messages
|
||||
if msg.ClaimedBy == "" {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by created time (oldest first)
|
||||
// Sort by created time (oldest first) for FIFO ordering
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Created.Before(messages[j].Created)
|
||||
})
|
||||
@@ -216,13 +217,13 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// claimMessage claims a message by setting assignee and status.
|
||||
func claimMessage(townRoot, messageID, claimant string) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
// claimQueueMessage claims a message by adding claimed-by and claimed-at labels.
|
||||
func claimQueueMessage(beadsDir, messageID, claimant string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
args := []string{"update", messageID,
|
||||
"--assignee", claimant,
|
||||
"--status", "in_progress",
|
||||
args := []string{"label", "add", messageID,
|
||||
"claimed-by:" + claimant,
|
||||
"claimed-at:" + now,
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
@@ -255,11 +256,13 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||
|
||||
// Get caller identity
|
||||
caller := detectSender()
|
||||
|
||||
// Get message details to verify ownership and find queue
|
||||
msgInfo, err := getMessageInfo(townRoot, messageID)
|
||||
msgInfo, err := getQueueMessageInfo(beadsDir, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
@@ -270,16 +273,15 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if msgInfo.ClaimedBy == "" {
|
||||
return fmt.Errorf("message %s is not claimed", messageID)
|
||||
}
|
||||
if msgInfo.ClaimedBy != caller {
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.ClaimedBy, 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 {
|
||||
// Release the message: remove claimed-by and claimed-at labels
|
||||
if err := releaseQueueMessage(beadsDir, messageID, caller); err != nil {
|
||||
return fmt.Errorf("releasing message: %w", err)
|
||||
}
|
||||
|
||||
@@ -290,19 +292,18 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// messageInfo holds details about a queue message.
|
||||
type messageInfo struct {
|
||||
// queueMessageInfo holds details about a queue message.
|
||||
type queueMessageInfo struct {
|
||||
ID string
|
||||
Title string
|
||||
Assignee string
|
||||
QueueName string
|
||||
ClaimedBy string
|
||||
ClaimedAt *time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
// getMessageInfo retrieves information about a message.
|
||||
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// getQueueMessageInfo retrieves information about a queue message.
|
||||
func getQueueMessageInfo(beadsDir, messageID string) (*queueMessageInfo, error) {
|
||||
args := []string{"show", messageID, "--json"}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
@@ -327,7 +328,6 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Assignee string `json:"assignee"`
|
||||
Labels []string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -341,49 +341,380 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
}
|
||||
|
||||
issue := issues[0]
|
||||
info := &messageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Assignee: issue.Assignee,
|
||||
Status: issue.Status,
|
||||
info := &queueMessageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Status: issue.Status,
|
||||
}
|
||||
|
||||
// Extract queue name from labels (format: "queue:<name>")
|
||||
// Extract fields from labels
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "queue:") {
|
||||
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||
break
|
||||
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||
info.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
info.ClaimedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
// releaseQueueMessage releases a claimed message by removing claim labels.
|
||||
func releaseQueueMessage(beadsDir, messageID, actor string) error {
|
||||
// Get current message info to find the exact claim labels
|
||||
info, err := getQueueMessageInfo(beadsDir, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
// Remove claimed-by label
|
||||
if info.ClaimedBy != "" {
|
||||
args := []string{"label", "remove", messageID, "claimed-by:" + info.ClaimedBy}
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
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)
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" && !strings.Contains(errMsg, "does not have label") {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove claimed-at label if present
|
||||
if info.ClaimedAt != nil {
|
||||
claimedAtStr := info.ClaimedAt.Format(time.RFC3339)
|
||||
args := []string{"label", "remove", messageID, "claimed-at:" + claimedAtStr}
|
||||
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 != "" && !strings.Contains(errMsg, "does not have label") {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Queue management commands (beads-native)
|
||||
|
||||
var (
|
||||
mailQueueClaimers string
|
||||
mailQueueJSON bool
|
||||
)
|
||||
|
||||
var mailQueueCmd = &cobra.Command{
|
||||
Use: "queue",
|
||||
Short: "Manage mail queues",
|
||||
Long: `Manage beads-native mail queues.
|
||||
|
||||
Queues provide a way to distribute work to eligible workers.
|
||||
Messages sent to a queue can be claimed by workers matching the claim pattern.
|
||||
|
||||
COMMANDS:
|
||||
create Create a new queue
|
||||
show Show queue details
|
||||
list List all queues
|
||||
delete Delete a queue
|
||||
|
||||
Examples:
|
||||
gt mail queue create work --claimers 'gastown/polecats/*'
|
||||
gt mail queue show work
|
||||
gt mail queue list
|
||||
gt mail queue delete work`,
|
||||
RunE: requireSubcommand,
|
||||
}
|
||||
|
||||
var mailQueueCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new queue",
|
||||
Long: `Create a new beads-native mail queue.
|
||||
|
||||
The --claimers flag specifies a pattern for who can claim messages from this queue.
|
||||
Patterns support wildcards: 'gastown/polecats/*' matches any polecat in gastown rig.
|
||||
|
||||
Examples:
|
||||
gt mail queue create work --claimers 'gastown/polecats/*'
|
||||
gt mail queue create dispatch --claimers 'gastown/crew/*'
|
||||
gt mail queue create urgent --claimers '*'`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailQueueCreate,
|
||||
}
|
||||
|
||||
var mailQueueShowCmd = &cobra.Command{
|
||||
Use: "show <name>",
|
||||
Short: "Show queue details",
|
||||
Long: `Show details about a mail queue.
|
||||
|
||||
Displays the queue's claim pattern, status, and message counts.
|
||||
|
||||
Examples:
|
||||
gt mail queue show work
|
||||
gt mail queue show dispatch --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailQueueShow,
|
||||
}
|
||||
|
||||
var mailQueueListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all queues",
|
||||
Long: `List all beads-native mail queues.
|
||||
|
||||
Shows queue names, claim patterns, and status.
|
||||
|
||||
Examples:
|
||||
gt mail queue list
|
||||
gt mail queue list --json`,
|
||||
RunE: runMailQueueList,
|
||||
}
|
||||
|
||||
var mailQueueDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: "Delete a queue",
|
||||
Long: `Delete a mail queue.
|
||||
|
||||
This permanently removes the queue bead. Messages in the queue are not affected.
|
||||
|
||||
Examples:
|
||||
gt mail queue delete work`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailQueueDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Queue create flags
|
||||
mailQueueCreateCmd.Flags().StringVar(&mailQueueClaimers, "claimers", "", "Pattern for who can claim from this queue (required)")
|
||||
_ = mailQueueCreateCmd.MarkFlagRequired("claimers")
|
||||
|
||||
// Queue show/list flags
|
||||
mailQueueShowCmd.Flags().BoolVar(&mailQueueJSON, "json", false, "Output as JSON")
|
||||
mailQueueListCmd.Flags().BoolVar(&mailQueueJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Add queue subcommands
|
||||
mailQueueCmd.AddCommand(mailQueueCreateCmd)
|
||||
mailQueueCmd.AddCommand(mailQueueShowCmd)
|
||||
mailQueueCmd.AddCommand(mailQueueListCmd)
|
||||
mailQueueCmd.AddCommand(mailQueueDeleteCmd)
|
||||
|
||||
// Add queue command to mail
|
||||
mailCmd.AddCommand(mailQueueCmd)
|
||||
}
|
||||
|
||||
// runMailQueueCreate creates a new beads-native queue.
|
||||
func runMailQueueCreate(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)
|
||||
}
|
||||
|
||||
// Get caller identity for created_by
|
||||
caller := detectSender()
|
||||
|
||||
// Create queue bead
|
||||
b := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
|
||||
|
||||
// Generate queue bead ID (town-level: hq-q-<name>)
|
||||
queueID := beads.QueueBeadID(queueName, true)
|
||||
|
||||
// Check if queue already exists
|
||||
existing, _, err := b.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for existing queue: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("queue %q already exists", queueName)
|
||||
}
|
||||
|
||||
// Create queue fields
|
||||
fields := &beads.QueueFields{
|
||||
Name: queueName,
|
||||
ClaimPattern: mailQueueClaimers,
|
||||
Status: beads.QueueStatusActive,
|
||||
CreatedBy: caller,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Queue: %s", queueName)
|
||||
_, err = b.CreateQueueBead(queueID, title, fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating queue: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created queue %s\n", style.Bold.Render("✓"), queueName)
|
||||
fmt.Printf(" ID: %s\n", queueID)
|
||||
fmt.Printf(" Claimers: %s\n", mailQueueClaimers)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMailQueueShow shows details about a queue.
|
||||
func runMailQueueShow(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)
|
||||
}
|
||||
|
||||
// Get queue bead
|
||||
b := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
|
||||
|
||||
queueID := beads.QueueBeadID(queueName, true)
|
||||
issue, fields, err := b.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("queue %q not found", queueName)
|
||||
}
|
||||
|
||||
if mailQueueJSON {
|
||||
output := map[string]interface{}{
|
||||
"id": issue.ID,
|
||||
"name": fields.Name,
|
||||
"claim_pattern": fields.ClaimPattern,
|
||||
"status": fields.Status,
|
||||
"available_count": fields.AvailableCount,
|
||||
"processing_count": fields.ProcessingCount,
|
||||
"completed_count": fields.CompletedCount,
|
||||
"failed_count": fields.FailedCount,
|
||||
"created_by": fields.CreatedBy,
|
||||
"created_at": fields.CreatedAt,
|
||||
}
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Queue: %s\n", style.Bold.Render("📬"), queueName)
|
||||
fmt.Printf(" ID: %s\n", issue.ID)
|
||||
fmt.Printf(" Claimers: %s\n", fields.ClaimPattern)
|
||||
fmt.Printf(" Status: %s\n", fields.Status)
|
||||
fmt.Printf(" Available: %d\n", fields.AvailableCount)
|
||||
fmt.Printf(" Processing: %d\n", fields.ProcessingCount)
|
||||
fmt.Printf(" Completed: %d\n", fields.CompletedCount)
|
||||
if fields.FailedCount > 0 {
|
||||
fmt.Printf(" Failed: %d\n", fields.FailedCount)
|
||||
}
|
||||
if fields.CreatedBy != "" {
|
||||
fmt.Printf(" Created by: %s\n", fields.CreatedBy)
|
||||
}
|
||||
if fields.CreatedAt != "" {
|
||||
fmt.Printf(" Created at: %s\n", fields.CreatedAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMailQueueList lists all queues.
|
||||
func runMailQueueList(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)
|
||||
}
|
||||
|
||||
// List queue beads
|
||||
b := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
|
||||
|
||||
queues, err := b.ListQueueBeads()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing queues: %w", err)
|
||||
}
|
||||
|
||||
if len(queues) == 0 {
|
||||
fmt.Printf("%s No queues found\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if mailQueueJSON {
|
||||
var output []map[string]interface{}
|
||||
for _, issue := range queues {
|
||||
fields := beads.ParseQueueFields(issue.Description)
|
||||
output = append(output, map[string]interface{}{
|
||||
"id": issue.ID,
|
||||
"name": fields.Name,
|
||||
"claim_pattern": fields.ClaimPattern,
|
||||
"status": fields.Status,
|
||||
})
|
||||
}
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Queues (%d)\n\n", style.Bold.Render("📬"), len(queues))
|
||||
for _, issue := range queues {
|
||||
fields := beads.ParseQueueFields(issue.Description)
|
||||
fmt.Printf(" %s\n", style.Bold.Render(fields.Name))
|
||||
fmt.Printf(" Claimers: %s\n", fields.ClaimPattern)
|
||||
fmt.Printf(" Status: %s\n", fields.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMailQueueDelete deletes a queue.
|
||||
func runMailQueueDelete(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)
|
||||
}
|
||||
|
||||
// Delete queue bead
|
||||
b := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
|
||||
|
||||
queueID := beads.QueueBeadID(queueName, true)
|
||||
|
||||
// Verify queue exists
|
||||
issue, _, err := b.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("queue %q not found", queueName)
|
||||
}
|
||||
|
||||
if err := b.DeleteQueueBead(queueID); err != nil {
|
||||
return fmt.Errorf("deleting queue: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Deleted queue %s\n", style.Bold.Render("✓"), queueName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
@@ -109,21 +110,55 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
msg.ThreadID = generateThreadID()
|
||||
}
|
||||
|
||||
// Send via router
|
||||
router := mail.NewRouter(workDir)
|
||||
// Use address resolver for new address types
|
||||
townRoot, _ := workspace.FindFromCwd()
|
||||
b := beads.New(townRoot)
|
||||
resolver := mail.NewResolver(b, townRoot)
|
||||
|
||||
// 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 {
|
||||
recipients, err := resolver.Resolve(to)
|
||||
if err != nil {
|
||||
// Fall back to legacy routing if resolver fails
|
||||
router := mail.NewRouter(workDir)
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
}
|
||||
_ = 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)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
// Route based on recipient type
|
||||
router := mail.NewRouter(workDir)
|
||||
var recipientAddrs []string
|
||||
|
||||
for _, rec := range recipients {
|
||||
switch rec.Type {
|
||||
case mail.RecipientQueue:
|
||||
// Queue messages: single message, workers claim
|
||||
msg.To = rec.Address
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending to queue: %w", err)
|
||||
}
|
||||
recipientAddrs = append(recipientAddrs, rec.Address)
|
||||
|
||||
case mail.RecipientChannel:
|
||||
// Channel messages: single message, broadcast
|
||||
msg.To = rec.Address
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending to channel: %w", err)
|
||||
}
|
||||
recipientAddrs = append(recipientAddrs, rec.Address)
|
||||
|
||||
default:
|
||||
// Direct/agent messages: fan out to each recipient
|
||||
msgCopy := *msg
|
||||
msgCopy.To = rec.Address
|
||||
if err := router.Send(&msgCopy); err != nil {
|
||||
return fmt.Errorf("sending to %s: %w", rec.Address, err)
|
||||
}
|
||||
recipientAddrs = append(recipientAddrs, rec.Address)
|
||||
}
|
||||
}
|
||||
|
||||
// Log mail event to activity feed
|
||||
@@ -132,9 +167,9 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
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, ", "))
|
||||
// Show resolved recipients if fan-out occurred
|
||||
if len(recipientAddrs) > 1 || (len(recipientAddrs) == 1 && recipientAddrs[0] != to) {
|
||||
fmt.Printf(" Recipients: %s\n", strings.Join(recipientAddrs, ", "))
|
||||
}
|
||||
|
||||
if len(msg.CC) > 0 {
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
func TestMatchWorkerPattern(t *testing.T) {
|
||||
// TestClaimPatternMatching tests claim pattern matching via the beads package.
|
||||
// This verifies that the pattern matching used for queue eligibility works correctly.
|
||||
func TestClaimPatternMatching(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
@@ -55,43 +58,9 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Crew patterns
|
||||
// Universal wildcard
|
||||
{
|
||||
name: "crew wildcard matches",
|
||||
pattern: "gastown/crew/*",
|
||||
caller: "gastown/crew/max",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "crew wildcard doesn't match polecats",
|
||||
pattern: "gastown/crew/*",
|
||||
caller: "gastown/polecats/capable",
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Different rigs
|
||||
{
|
||||
name: "different rig wildcard",
|
||||
pattern: "beads/polecats/*",
|
||||
caller: "beads/polecats/capable",
|
||||
want: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty pattern",
|
||||
pattern: "",
|
||||
caller: "gastown/polecats/capable",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty caller",
|
||||
pattern: "gastown/polecats/*",
|
||||
caller: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "pattern is just wildcard",
|
||||
name: "universal wildcard matches anything",
|
||||
pattern: "*",
|
||||
caller: "anything",
|
||||
want: true,
|
||||
@@ -100,103 +69,47 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchWorkerPattern(tt.pattern, tt.caller)
|
||||
got := beads.MatchClaimPattern(tt.pattern, tt.caller)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchWorkerPattern(%q, %q) = %v, want %v",
|
||||
t.Errorf("MatchClaimPattern(%q, %q) = %v, want %v",
|
||||
tt.pattern, tt.caller, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEligibleWorker(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
caller string
|
||||
patterns []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matches first pattern",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches second pattern",
|
||||
caller: "gastown/crew/max",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches none",
|
||||
caller: "beads/polecats/capable",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty patterns list",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: []string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil patterns",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exact match in list",
|
||||
caller: "mayor/",
|
||||
patterns: []string{"mayor/", "gastown/witness"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isEligibleWorker(tt.caller, tt.patterns)
|
||||
if got != tt.want {
|
||||
t.Errorf("isEligibleWorker(%q, %v) = %v, want %v",
|
||||
tt.caller, tt.patterns, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReleaseValidation tests the validation logic for the release command.
|
||||
// TestQueueMessageReleaseValidation tests the validation logic for the release command.
|
||||
// This tests that release correctly identifies:
|
||||
// - Messages not claimed (still in queue)
|
||||
// - Messages not claimed (no claimed-by label)
|
||||
// - Messages claimed by a different worker
|
||||
// - Messages without queue labels (non-queue messages)
|
||||
func TestMailReleaseValidation(t *testing.T) {
|
||||
func TestQueueMessageReleaseValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgInfo *messageInfo
|
||||
msgInfo *queueMessageInfo
|
||||
caller string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "caller matches assignee - valid release",
|
||||
msgInfo: &messageInfo{
|
||||
name: "caller matches claimed-by - valid release",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test1",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "message still in queue - not claimed",
|
||||
msgInfo: &messageInfo{
|
||||
name: "message not claimed",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test2",
|
||||
Title: "Test Message",
|
||||
Assignee: "queue:work/gastown",
|
||||
QueueName: "work/gastown",
|
||||
ClaimedBy: "", // Not claimed
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
@@ -205,12 +118,12 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "claimed by different worker",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test3",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/other",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/other",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
@@ -218,10 +131,10 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "not a queue message",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test4",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "", // No queue label
|
||||
Status: "open",
|
||||
},
|
||||
@@ -233,7 +146,7 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRelease(tt.msgInfo, tt.caller)
|
||||
err := validateQueueRelease(tt.msgInfo, tt.caller)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
@@ -251,20 +164,22 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateRelease checks if a message can be released by the caller.
|
||||
// This is extracted for testing; the actual release command uses this logic inline.
|
||||
func validateRelease(msgInfo *messageInfo, caller string) error {
|
||||
// validateQueueRelease checks if a queue message can be released by the caller.
|
||||
// This mirrors the validation logic in runMailRelease.
|
||||
func validateQueueRelease(msgInfo *queueMessageInfo, caller string) error {
|
||||
// Verify message is a queue message
|
||||
if msgInfo.QueueName == "" {
|
||||
return fmt.Errorf("message %s is not a queue message (no queue label)", msgInfo.ID)
|
||||
}
|
||||
|
||||
// Verify message is claimed
|
||||
if msgInfo.ClaimedBy == "" {
|
||||
return fmt.Errorf("message %s is not claimed", msgInfo.ID)
|
||||
}
|
||||
|
||||
// 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)", msgInfo.ID)
|
||||
}
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.Assignee, caller)
|
||||
if msgInfo.ClaimedBy != caller {
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.ClaimedBy, caller)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/mayor"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
@@ -13,12 +16,20 @@ var mayorCmd = &cobra.Command{
|
||||
Use: "mayor",
|
||||
Aliases: []string{"may"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the Mayor session",
|
||||
Short: "Manage the Mayor (Chief of Staff for cross-rig coordination)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Mayor tmux session.
|
||||
Long: `Manage the Mayor - the Overseer's Chief of Staff.
|
||||
|
||||
The Mayor is the global coordinator for Gas Town, running as a persistent
|
||||
tmux session. Use the subcommands to start, stop, attach, and check status.`,
|
||||
The Mayor is the global coordinator for Gas Town:
|
||||
- Receives escalations from Witnesses and Deacon
|
||||
- Coordinates work across multiple rigs
|
||||
- Handles human communication when needed
|
||||
- Routes strategic decisions and cross-project issues
|
||||
|
||||
The Mayor is the primary interface between the human Overseer and the
|
||||
automated agents. When in doubt, escalate to the Mayor.
|
||||
|
||||
Role shortcuts: "mayor" in mail/nudge addresses resolves to this agent.`,
|
||||
}
|
||||
|
||||
var mayorAgentOverride string
|
||||
@@ -142,6 +153,14 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding workspace: %w", err)
|
||||
}
|
||||
|
||||
t := tmux.NewTmux()
|
||||
sessionID := mgr.SessionName()
|
||||
|
||||
running, err := mgr.IsRunning()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
@@ -152,10 +171,45 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||
if err := mgr.Start(mayorAgentOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Session exists - check if runtime is still running (hq-95xfq)
|
||||
// If runtime exited or sitting at shell, restart with proper context
|
||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, townRoot, mayorAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving agent: %w", err)
|
||||
}
|
||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
||||
// Runtime has exited, restart it with proper context
|
||||
fmt.Println("Runtime exited, restarting with context...")
|
||||
|
||||
paneID, err := t.GetPaneID(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting pane ID: %w", err)
|
||||
}
|
||||
|
||||
// Build startup beacon for context (like gt handoff does)
|
||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||
Recipient: "mayor",
|
||||
Sender: "human",
|
||||
Topic: "attach",
|
||||
})
|
||||
|
||||
// Build startup command with beacon
|
||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "", townRoot, "", beacon, mayorAgentOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building startup command: %w", err)
|
||||
}
|
||||
|
||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||
return fmt.Errorf("restarting runtime: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Mayor restarted with context\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
||||
return attachToTmuxSession(mgr.SessionName())
|
||||
return attachToTmuxSession(sessionID)
|
||||
}
|
||||
|
||||
func runMayorStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -284,17 +284,21 @@ func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string,
|
||||
return result
|
||||
}
|
||||
|
||||
func printMigrationResult(r migrationResult) {
|
||||
var icon string
|
||||
switch r.Status {
|
||||
func getMigrationStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "migrated", "would migrate":
|
||||
icon = " ✓"
|
||||
return " ✓"
|
||||
case "skipped":
|
||||
icon = " ⊘"
|
||||
return " ⊘"
|
||||
case "error":
|
||||
icon = " ✗"
|
||||
return " ✗"
|
||||
default:
|
||||
return " ?"
|
||||
}
|
||||
fmt.Printf("%s %s → %s: %s\n", icon, r.OldID, r.NewID, r.Message)
|
||||
}
|
||||
|
||||
func printMigrationResult(r migrationResult) {
|
||||
fmt.Printf("%s %s → %s: %s\n", getMigrationStatusIcon(r.Status), r.OldID, r.NewID, r.Message)
|
||||
}
|
||||
|
||||
func printMigrationSummary(results []migrationResult, dryRun bool) {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestMigrationResultStatus(t *testing.T) {
|
||||
Status: "migrated",
|
||||
Message: "successfully migrated",
|
||||
},
|
||||
wantIcon: "✓",
|
||||
wantIcon: " ✓",
|
||||
},
|
||||
{
|
||||
name: "would migrate shows checkmark",
|
||||
@@ -30,7 +30,7 @@ func TestMigrationResultStatus(t *testing.T) {
|
||||
Status: "would migrate",
|
||||
Message: "would copy state from gt-mayor",
|
||||
},
|
||||
wantIcon: "✓",
|
||||
wantIcon: " ✓",
|
||||
},
|
||||
{
|
||||
name: "skipped shows empty circle",
|
||||
@@ -40,7 +40,7 @@ func TestMigrationResultStatus(t *testing.T) {
|
||||
Status: "skipped",
|
||||
Message: "already exists",
|
||||
},
|
||||
wantIcon: "⊘",
|
||||
wantIcon: " ⊘",
|
||||
},
|
||||
{
|
||||
name: "error shows X",
|
||||
@@ -50,23 +50,15 @@ func TestMigrationResultStatus(t *testing.T) {
|
||||
Status: "error",
|
||||
Message: "failed to create",
|
||||
},
|
||||
wantIcon: "✗",
|
||||
wantIcon: " ✗",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var icon string
|
||||
switch tt.result.Status {
|
||||
case "migrated", "would migrate":
|
||||
icon = "✓"
|
||||
case "skipped":
|
||||
icon = "⊘"
|
||||
case "error":
|
||||
icon = "✗"
|
||||
}
|
||||
icon := getMigrationStatusIcon(tt.result.Status)
|
||||
if icon != tt.wantIcon {
|
||||
t.Errorf("icon for status %q = %q, want %q", tt.result.Status, icon, tt.wantIcon)
|
||||
t.Errorf("getMigrationStatusIcon(%q) = %q, want %q", tt.result.Status, icon, tt.wantIcon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,13 +215,15 @@ squashed_at: %s
|
||||
}())
|
||||
}
|
||||
|
||||
// Create the digest bead
|
||||
// Create the digest bead (ephemeral to avoid JSONL pollution)
|
||||
// Per-cycle digests are aggregated daily by 'gt patrol digest'
|
||||
digestIssue, err := b.Create(beads.CreateOptions{
|
||||
Title: digestTitle,
|
||||
Description: digestDesc,
|
||||
Type: "task",
|
||||
Priority: 4, // P4 - backlog priority for digests
|
||||
Priority: 4, // P4 - backlog priority for digests
|
||||
Actor: target,
|
||||
Ephemeral: true, // Don't export to JSONL - daily aggregation handles permanent record
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating digest: %w", err)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/mrqueue"
|
||||
"github.com/steveyegge/gastown/internal/refinery"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
@@ -71,6 +71,22 @@ func runMQList(cmd *cobra.Command, args []string) error {
|
||||
var scored []scoredIssue
|
||||
|
||||
for _, issue := range issues {
|
||||
// Manual status filtering as workaround for bd list not respecting --status filter
|
||||
if mqListReady {
|
||||
// Ready view should only show open MRs
|
||||
if issue.Status != "open" {
|
||||
continue
|
||||
}
|
||||
} else if mqListStatus != "" && !strings.EqualFold(mqListStatus, "all") {
|
||||
// Explicit status filter should match exactly
|
||||
if !strings.EqualFold(issue.Status, mqListStatus) {
|
||||
continue
|
||||
}
|
||||
} else if mqListStatus == "" && issue.Status != "open" {
|
||||
// Default case (no status specified) should only show open
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse MR fields
|
||||
fields := beads.ParseMRFields(issue)
|
||||
|
||||
@@ -260,7 +276,7 @@ func outputJSON(data interface{}) error {
|
||||
return enc.Encode(data)
|
||||
}
|
||||
|
||||
// calculateMRScore computes the priority score for an MR using the mrqueue scoring function.
|
||||
// calculateMRScore computes the priority score for an MR using the refinery scoring function.
|
||||
// Higher scores mean higher priority (process first).
|
||||
func calculateMRScore(issue *beads.Issue, fields *beads.MRFields, now time.Time) float64 {
|
||||
// Parse MR creation time
|
||||
@@ -273,7 +289,7 @@ func calculateMRScore(issue *beads.Issue, fields *beads.MRFields, now time.Time)
|
||||
}
|
||||
|
||||
// Build score input
|
||||
input := mrqueue.ScoreInput{
|
||||
input := refinery.ScoreInput{
|
||||
Priority: issue.Priority,
|
||||
MRCreatedAt: mrCreatedAt,
|
||||
Now: now,
|
||||
@@ -291,5 +307,5 @@ func calculateMRScore(issue *beads.Issue, fields *beads.MRFields, now time.Time)
|
||||
}
|
||||
}
|
||||
|
||||
return mrqueue.ScoreMRWithDefaults(input)
|
||||
return refinery.ScoreMRWithDefaults(input)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ func runMQNext(cmd *cobra.Command, args []string) error {
|
||||
// Filter to only ready MRs (no blockers)
|
||||
var ready []*beads.Issue
|
||||
for _, issue := range issues {
|
||||
// Skip closed MRs (workaround for bd list not respecting --status filter)
|
||||
if issue.Status != "open" {
|
||||
continue
|
||||
}
|
||||
if len(issue.BlockedBy) == 0 && issue.BlockedByCount == 0 {
|
||||
ready = append(ready, issue)
|
||||
}
|
||||
|
||||
@@ -25,26 +25,48 @@ type branchInfo struct {
|
||||
Worker string // Worker name (polecat name)
|
||||
}
|
||||
|
||||
// issuePattern matches issue IDs in branch names (e.g., "gt-xyz" or "gt-abc.1")
|
||||
var issuePattern = regexp.MustCompile(`([a-z]+-[a-z0-9]+(?:\.[0-9]+)?)`)
|
||||
|
||||
// parseBranchName extracts issue ID and worker from a branch name.
|
||||
// Supports formats:
|
||||
// - polecat/<worker>/<issue> → issue=<issue>, worker=<worker>
|
||||
// - polecat/<worker>-<timestamp> → issue="", worker=<worker> (modern polecat branches)
|
||||
// - <issue> → issue=<issue>, worker=""
|
||||
func parseBranchName(branch string) branchInfo {
|
||||
info := branchInfo{Branch: branch}
|
||||
|
||||
// Try polecat/<worker>/<issue> format
|
||||
// Try polecat/<worker>/<issue> or polecat/<worker>/<issue>@<timestamp> format
|
||||
if strings.HasPrefix(branch, constants.BranchPolecatPrefix) {
|
||||
parts := strings.SplitN(branch, "/", 3)
|
||||
if len(parts) == 3 {
|
||||
info.Worker = parts[1]
|
||||
info.Issue = parts[2]
|
||||
// Strip @timestamp suffix if present (e.g., "gt-abc@mk123" -> "gt-abc")
|
||||
issue := parts[2]
|
||||
if atIdx := strings.Index(issue, "@"); atIdx > 0 {
|
||||
issue = issue[:atIdx]
|
||||
}
|
||||
info.Issue = issue
|
||||
return info
|
||||
}
|
||||
// Modern polecat branch format: polecat/<worker>-<timestamp>
|
||||
// The second part is "worker-timestamp", not an issue ID.
|
||||
// Don't try to extract an issue ID - gt done will use hook_bead fallback.
|
||||
if len(parts) == 2 {
|
||||
// Extract worker name from "worker-timestamp" format
|
||||
workerPart := parts[1]
|
||||
if dashIdx := strings.LastIndex(workerPart, "-"); dashIdx > 0 {
|
||||
info.Worker = workerPart[:dashIdx]
|
||||
} else {
|
||||
info.Worker = workerPart
|
||||
}
|
||||
// Explicitly don't set info.Issue - let hook_bead fallback handle it
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find an issue ID pattern in the branch name
|
||||
// Common patterns: prefix-xxx, prefix-xxx.n (subtask)
|
||||
issuePattern := regexp.MustCompile(`([a-z]+-[a-z0-9]+(?:\.[0-9]+)?)`)
|
||||
if matches := issuePattern.FindStringSubmatch(branch); len(matches) > 1 {
|
||||
info.Issue = matches[1]
|
||||
}
|
||||
@@ -147,15 +169,27 @@ func runMqSubmit(cmd *cobra.Command, args []string) error {
|
||||
description += fmt.Sprintf("\nworker: %s", worker)
|
||||
}
|
||||
|
||||
// Create MR bead (ephemeral wisp - will be cleaned up after merge)
|
||||
mrIssue, err := bd.Create(beads.CreateOptions{
|
||||
Title: title,
|
||||
Type: "merge-request",
|
||||
Priority: priority,
|
||||
Description: description,
|
||||
})
|
||||
// Check if MR bead already exists for this branch (idempotency)
|
||||
var mrIssue *beads.Issue
|
||||
existingMR, err := bd.FindMRForBranch(branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating merge request bead: %w", err)
|
||||
style.PrintWarning("could not check for existing MR: %v", err)
|
||||
// Continue with creation attempt - Create will fail if duplicate
|
||||
} else if existingMR != nil {
|
||||
mrIssue = existingMR
|
||||
fmt.Printf("%s MR already exists (idempotent)\n", style.Bold.Render("✓"))
|
||||
} else {
|
||||
// Create MR bead (ephemeral wisp - will be cleaned up after merge)
|
||||
mrIssue, err = bd.Create(beads.CreateOptions{
|
||||
Title: title,
|
||||
Type: "merge-request",
|
||||
Priority: priority,
|
||||
Description: description,
|
||||
Ephemeral: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating merge request bead: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Success output
|
||||
@@ -180,60 +214,61 @@ func runMqSubmit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println(style.Dim.Render(" You may need to run 'gt handoff --shutdown' manually"))
|
||||
return nil
|
||||
}
|
||||
// polecatCleanup blocks forever waiting for termination, so we never reach here
|
||||
// polecatCleanup may timeout while waiting, but MR was already created
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectIntegrationBranch checks if an issue is a child of an epic that has an integration branch.
|
||||
// detectIntegrationBranch checks if an issue is a descendant of an epic that has an integration branch.
|
||||
// Traverses up the parent chain until it finds an epic or runs out of parents.
|
||||
// Returns the integration branch target (e.g., "integration/gt-epic") if found, or "" if not.
|
||||
func detectIntegrationBranch(bd *beads.Beads, g *git.Git, issueID string) (string, error) {
|
||||
// Get the source issue
|
||||
issue, err := bd.Show(issueID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up issue %s: %w", issueID, err)
|
||||
// Traverse up the parent chain looking for an epic with an integration branch
|
||||
// Limit depth to prevent infinite loops in case of circular references
|
||||
const maxDepth = 10
|
||||
currentID := issueID
|
||||
|
||||
for depth := 0; depth < maxDepth; depth++ {
|
||||
// Get the current issue
|
||||
issue, err := bd.Show(currentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up issue %s: %w", currentID, err)
|
||||
}
|
||||
|
||||
// Check if this issue is an epic
|
||||
if issue.Type == "epic" {
|
||||
// Found an epic - check if it has an integration branch
|
||||
integrationBranch := "integration/" + issue.ID
|
||||
|
||||
// Check local first (faster)
|
||||
exists, err := g.BranchExists(integrationBranch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("checking local branch: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return integrationBranch, nil
|
||||
}
|
||||
|
||||
// Check remote
|
||||
exists, err = g.RemoteBranchExists("origin", integrationBranch)
|
||||
if err != nil {
|
||||
// Remote check failure is non-fatal, continue to parent
|
||||
} else if exists {
|
||||
return integrationBranch, nil
|
||||
}
|
||||
// Epic found but no integration branch - continue checking parents
|
||||
// in case there's a higher-level epic with an integration branch
|
||||
}
|
||||
|
||||
// Move to parent
|
||||
if issue.Parent == "" {
|
||||
return "", nil // No more parents, no integration branch found
|
||||
}
|
||||
currentID = issue.Parent
|
||||
}
|
||||
|
||||
// Check if issue has a parent
|
||||
if issue.Parent == "" {
|
||||
return "", nil // No parent, no integration branch
|
||||
}
|
||||
|
||||
// Get the parent issue
|
||||
parent, err := bd.Show(issue.Parent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up parent %s: %w", issue.Parent, err)
|
||||
}
|
||||
|
||||
// Check if parent is an epic
|
||||
if parent.Type != "epic" {
|
||||
return "", nil // Parent is not an epic
|
||||
}
|
||||
|
||||
// Check if integration branch exists
|
||||
integrationBranch := "integration/" + parent.ID
|
||||
|
||||
// Check local first (faster)
|
||||
exists, err := g.BranchExists(integrationBranch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("checking local branch: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return integrationBranch, nil
|
||||
}
|
||||
|
||||
// Check remote
|
||||
exists, err = g.RemoteBranchExists("origin", integrationBranch)
|
||||
if err != nil {
|
||||
// Remote check failure is non-fatal
|
||||
return "", nil
|
||||
}
|
||||
if exists {
|
||||
return integrationBranch, nil
|
||||
}
|
||||
|
||||
return "", nil // No integration branch found
|
||||
return "", nil // Max depth reached, no integration branch found
|
||||
}
|
||||
|
||||
// polecatCleanup sends a lifecycle shutdown request to the witness and waits for termination.
|
||||
@@ -271,6 +306,10 @@ Please verify state and execute lifecycle action.
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Timeout after 5 minutes to prevent indefinite blocking
|
||||
const maxCleanupWait = 5 * time.Minute
|
||||
timeout := time.After(maxCleanupWait)
|
||||
|
||||
waitStart := time.Now()
|
||||
for {
|
||||
select {
|
||||
@@ -279,9 +318,14 @@ Please verify state and execute lifecycle action.
|
||||
fmt.Printf("%s Still waiting (%v elapsed)...\n", style.Dim.Render("◌"), elapsed)
|
||||
if elapsed >= 2*time.Minute {
|
||||
fmt.Println(style.Dim.Render(" Hint: If witness isn't responding, you may need to:"))
|
||||
fmt.Println(style.Dim.Render(" - Check if witness is running"))
|
||||
fmt.Println(style.Dim.Render(" - Check if witness is running: gt rig status"))
|
||||
fmt.Println(style.Dim.Render(" - Use Ctrl+C to abort and manually exit"))
|
||||
}
|
||||
case <-timeout:
|
||||
fmt.Printf("%s Timeout waiting for polecat retirement\n", style.WarningPrefix)
|
||||
fmt.Println(style.Dim.Render(" The polecat may have already terminated, or witness is unresponsive."))
|
||||
fmt.Println(style.Dim.Render(" You can verify with: gt polecat status"))
|
||||
return nil // Don't fail the MR submission just because cleanup timed out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
@@ -68,6 +69,24 @@ func TestParseBranchName(t *testing.T) {
|
||||
wantIssue: "gt-abc.1",
|
||||
wantWorker: "Worker",
|
||||
},
|
||||
{
|
||||
name: "polecat branch with issue and timestamp",
|
||||
branch: "polecat/furiosa/gt-jns7.1@mk123456",
|
||||
wantIssue: "gt-jns7.1",
|
||||
wantWorker: "furiosa",
|
||||
},
|
||||
{
|
||||
name: "modern polecat branch (timestamp format)",
|
||||
branch: "polecat/furiosa-mkc36bb9",
|
||||
wantIssue: "", // Should NOT extract fake issue from worker-timestamp
|
||||
wantWorker: "furiosa",
|
||||
},
|
||||
{
|
||||
name: "modern polecat branch with longer name",
|
||||
branch: "polecat/citadel-mk0vro62",
|
||||
wantIssue: "",
|
||||
wantWorker: "citadel",
|
||||
},
|
||||
{
|
||||
name: "simple issue branch",
|
||||
branch: "gt-xyz",
|
||||
@@ -678,3 +697,46 @@ func TestGetIntegrationBranchField(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssuePatternCompiledAtPackageLevel verifies that the issuePattern regex
|
||||
// is compiled once at package level (not on every parseBranchName call).
|
||||
func TestIssuePatternCompiledAtPackageLevel(t *testing.T) {
|
||||
// Verify the pattern is not nil and is a compiled regex
|
||||
if issuePattern == nil {
|
||||
t.Error("issuePattern should be compiled at package level, got nil")
|
||||
}
|
||||
// Verify it matches expected patterns
|
||||
tests := []struct {
|
||||
branch string
|
||||
wantMatch bool
|
||||
wantIssue string
|
||||
}{
|
||||
{"polecat/Nux/gt-xyz", true, "gt-xyz"},
|
||||
{"gt-abc", true, "gt-abc"},
|
||||
{"feature/proj-123-add-feature", true, "proj-123"},
|
||||
{"main", false, ""},
|
||||
{"", false, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.branch, func(t *testing.T) {
|
||||
matches := issuePattern.FindStringSubmatch(tt.branch)
|
||||
if (len(matches) > 1) != tt.wantMatch {
|
||||
t.Errorf("FindStringSubmatch(%q) match = %v, want %v", tt.branch, len(matches) > 1, tt.wantMatch)
|
||||
}
|
||||
if tt.wantMatch && len(matches) > 1 && matches[1] != tt.wantIssue {
|
||||
t.Errorf("FindStringSubmatch(%q) issue = %q, want %q", tt.branch, matches[1], tt.wantIssue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolecatCleanupTimeoutConstant verifies the timeout constant is set correctly.
|
||||
func TestPolecatCleanupTimeoutConstant(t *testing.T) {
|
||||
// This test documents the expected timeout value.
|
||||
// The actual timeout behavior is tested manually or with integration tests.
|
||||
const expectedMaxCleanupWait = 5 * time.Minute
|
||||
if expectedMaxCleanupWait != 5*time.Minute {
|
||||
t.Errorf("expectedMaxCleanupWait = %v, want 5m", expectedMaxCleanupWait)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +187,17 @@ func runNamepoolSet(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("saving pool: %w", err)
|
||||
}
|
||||
|
||||
// Also save to rig config
|
||||
if err := saveRigNamepoolConfig(rigPath, theme, nil); err != nil {
|
||||
// Load existing settings to preserve custom names when changing theme
|
||||
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
||||
var existingNames []string
|
||||
if existingSettings, err := config.LoadRigSettings(settingsPath); err == nil {
|
||||
if existingSettings.Namepool != nil {
|
||||
existingNames = existingSettings.Namepool.Names
|
||||
}
|
||||
}
|
||||
|
||||
// Also save to rig config, preserving existing custom names
|
||||
if err := saveRigNamepoolConfig(rigPath, theme, existingNames); err != nil {
|
||||
return fmt.Errorf("saving config: %w", err)
|
||||
}
|
||||
|
||||
@@ -206,18 +215,42 @@ func runNamepoolAdd(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a rig directory")
|
||||
}
|
||||
|
||||
// Load pool
|
||||
pool := polecat.NewNamePool(rigPath, rigName)
|
||||
if err := pool.Load(); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("loading pool: %w", err)
|
||||
// Load existing rig settings to get current theme and custom names
|
||||
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
||||
settings, err := config.LoadRigSettings(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) || strings.Contains(err.Error(), "not found") {
|
||||
settings = config.NewRigSettings()
|
||||
} else {
|
||||
return fmt.Errorf("loading settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pool.AddCustomName(name)
|
||||
|
||||
if err := pool.Save(); err != nil {
|
||||
return fmt.Errorf("saving pool: %w", err)
|
||||
// Initialize namepool config if needed
|
||||
if settings.Namepool == nil {
|
||||
settings.Namepool = config.DefaultNamepoolConfig()
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
for _, n := range settings.Namepool.Names {
|
||||
if n == name {
|
||||
fmt.Printf("Name '%s' already in pool\n", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Append new name to existing custom names
|
||||
settings.Namepool.Names = append(settings.Namepool.Names, name)
|
||||
|
||||
// Save to settings/config.json (the source of truth for config)
|
||||
if err := config.SaveRigSettings(settingsPath, settings); err != nil {
|
||||
return fmt.Errorf("saving settings: %w", err)
|
||||
}
|
||||
|
||||
// Note: No need to update runtime pool state - the settings file is the source
|
||||
// of truth for custom names. The pool state file only persists OverflowNext/MaxSize.
|
||||
// New managers will load custom names from settings/config.json.
|
||||
|
||||
fmt.Printf("Added '%s' to the name pool\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ func init() {
|
||||
var nudgeCmd = &cobra.Command{
|
||||
Use: "nudge <target> [message]",
|
||||
GroupID: GroupComm,
|
||||
Short: "Send a message to a polecat or deacon session reliably",
|
||||
Long: `Sends a message to a polecat's or deacon's Claude Code session.
|
||||
Short: "Send a synchronous message to any Gas Town worker",
|
||||
Long: `Universal synchronous messaging API for Gas Town worker-to-worker communication.
|
||||
|
||||
Delivers a message directly to any worker's Claude Code session: polecats, crew,
|
||||
witness, refinery, mayor, or deacon. Use this for real-time coordination when
|
||||
you need immediate attention from another worker.
|
||||
|
||||
Uses a reliable delivery pattern:
|
||||
1. Sends text in literal mode (-l flag)
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -38,12 +40,113 @@ Examples:
|
||||
var (
|
||||
orphansDays int
|
||||
orphansAll bool
|
||||
|
||||
// Kill commits command flags
|
||||
orphansKillDryRun bool
|
||||
orphansKillDays int
|
||||
orphansKillAll bool
|
||||
orphansKillForce bool
|
||||
|
||||
// Process orphan flags
|
||||
orphansProcsForce bool
|
||||
)
|
||||
|
||||
// Commit orphan kill command
|
||||
var orphansKillCmd = &cobra.Command{
|
||||
Use: "kill",
|
||||
Short: "Remove all orphans (commits and processes)",
|
||||
Long: `Remove orphaned commits and kill orphaned Claude processes.
|
||||
|
||||
This command performs a complete orphan cleanup:
|
||||
1. Finds and removes orphaned commits via 'git gc --prune=now'
|
||||
2. Finds and kills orphaned Claude processes (PPID=1)
|
||||
|
||||
WARNING: This operation is irreversible. Once commits are pruned,
|
||||
they cannot be recovered.
|
||||
|
||||
The command will:
|
||||
1. Find orphaned commits (same as 'gt orphans')
|
||||
2. Find orphaned Claude processes (same as 'gt orphans procs')
|
||||
3. Show what will be removed/killed
|
||||
4. Ask for confirmation (unless --force)
|
||||
5. Run git gc and kill processes
|
||||
|
||||
Examples:
|
||||
gt orphans kill # Kill orphans from last 7 days (default)
|
||||
gt orphans kill --days=14 # Kill orphans from last 2 weeks
|
||||
gt orphans kill --all # Kill all orphans
|
||||
gt orphans kill --dry-run # Preview without deleting
|
||||
gt orphans kill --force # Skip confirmation prompt`,
|
||||
RunE: runOrphansKill,
|
||||
}
|
||||
|
||||
// Process orphan commands
|
||||
var orphansProcsCmd = &cobra.Command{
|
||||
Use: "procs",
|
||||
Short: "Manage orphaned Claude processes",
|
||||
Long: `Find and kill Claude processes that have become orphaned (PPID=1).
|
||||
|
||||
These are processes that survived session termination and are now
|
||||
parented to init/launchd. They consume resources and should be killed.
|
||||
|
||||
Examples:
|
||||
gt orphans procs # List orphaned Claude processes
|
||||
gt orphans procs list # Same as above
|
||||
gt orphans procs kill # Kill orphaned processes`,
|
||||
RunE: runOrphansListProcesses, // Default to list
|
||||
}
|
||||
|
||||
var orphansProcsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List orphaned Claude processes",
|
||||
Long: `List Claude processes that have become orphaned (PPID=1).
|
||||
|
||||
These are processes that survived session termination and are now
|
||||
parented to init/launchd. They consume resources and should be killed.
|
||||
|
||||
Excludes:
|
||||
- tmux server processes
|
||||
- Claude.app desktop application processes
|
||||
|
||||
Examples:
|
||||
gt orphans procs list # Show all orphan Claude processes`,
|
||||
RunE: runOrphansListProcesses,
|
||||
}
|
||||
|
||||
var orphansProcsKillCmd = &cobra.Command{
|
||||
Use: "kill",
|
||||
Short: "Kill orphaned Claude processes",
|
||||
Long: `Kill Claude processes that have become orphaned (PPID=1).
|
||||
|
||||
Without flags, prompts for confirmation before killing.
|
||||
Use -f/--force to kill without confirmation.
|
||||
|
||||
Examples:
|
||||
gt orphans procs kill # Kill with confirmation
|
||||
gt orphans procs kill -f # Force kill without confirmation`,
|
||||
RunE: runOrphansKillProcesses,
|
||||
}
|
||||
|
||||
func init() {
|
||||
orphansCmd.Flags().IntVar(&orphansDays, "days", 7, "Show orphans from last N days")
|
||||
orphansCmd.Flags().BoolVar(&orphansAll, "all", false, "Show all orphans (no date filter)")
|
||||
|
||||
// Kill commits command flags
|
||||
orphansKillCmd.Flags().BoolVar(&orphansKillDryRun, "dry-run", false, "Preview without deleting")
|
||||
orphansKillCmd.Flags().IntVar(&orphansKillDays, "days", 7, "Kill orphans from last N days")
|
||||
orphansKillCmd.Flags().BoolVar(&orphansKillAll, "all", false, "Kill all orphans (no date filter)")
|
||||
orphansKillCmd.Flags().BoolVar(&orphansKillForce, "force", false, "Skip confirmation prompt")
|
||||
|
||||
// Process orphan kill command flags
|
||||
orphansProcsKillCmd.Flags().BoolVarP(&orphansProcsForce, "force", "f", false, "Kill without confirmation")
|
||||
|
||||
// Wire up subcommands
|
||||
orphansProcsCmd.AddCommand(orphansProcsListCmd)
|
||||
orphansProcsCmd.AddCommand(orphansProcsKillCmd)
|
||||
|
||||
orphansCmd.AddCommand(orphansKillCmd)
|
||||
orphansCmd.AddCommand(orphansProcsCmd)
|
||||
|
||||
rootCmd.AddCommand(orphansCmd)
|
||||
}
|
||||
|
||||
@@ -243,3 +346,331 @@ func formatAge(t time.Time) string {
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
|
||||
// runOrphansKill removes orphaned commits and kills orphaned processes
|
||||
func runOrphansKill(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigName, r, err := findCurrentRig(townRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determining rig: %w", err)
|
||||
}
|
||||
|
||||
mayorPath := r.Path + "/mayor/rig"
|
||||
|
||||
// Find orphaned commits
|
||||
fmt.Printf("Scanning for orphaned commits in %s...\n", rigName)
|
||||
commitOrphans, err := findOrphanCommits(mayorPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding orphan commits: %w", err)
|
||||
}
|
||||
|
||||
// Filter commits by date
|
||||
cutoff := time.Now().AddDate(0, 0, -orphansKillDays)
|
||||
var filteredCommits []OrphanCommit
|
||||
for _, o := range commitOrphans {
|
||||
if orphansKillAll || o.Date.After(cutoff) {
|
||||
filteredCommits = append(filteredCommits, o)
|
||||
}
|
||||
}
|
||||
|
||||
// Find orphaned processes
|
||||
fmt.Printf("Scanning for orphaned Claude processes...\n\n")
|
||||
procOrphans, err := findOrphanProcesses()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding orphan processes: %w", err)
|
||||
}
|
||||
|
||||
// Check if there's anything to do
|
||||
if len(filteredCommits) == 0 && len(procOrphans) == 0 {
|
||||
fmt.Printf("%s No orphans found\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show orphaned commits
|
||||
if len(filteredCommits) > 0 {
|
||||
fmt.Printf("%s Found %d orphaned commit(s) to remove:\n\n", style.Warning.Render("⚠"), len(filteredCommits))
|
||||
for _, o := range filteredCommits {
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render(o.SHA[:8]), o.Subject)
|
||||
fmt.Printf(" %s by %s\n\n", style.Dim.Render(formatAge(o.Date)), o.Author)
|
||||
}
|
||||
} else if len(commitOrphans) > 0 {
|
||||
fmt.Printf("%s No orphaned commits in the last %d days (use --days=N or --all)\n\n",
|
||||
style.Dim.Render("ℹ"), orphansKillDays)
|
||||
}
|
||||
|
||||
// Show orphaned processes
|
||||
if len(procOrphans) > 0 {
|
||||
fmt.Printf("%s Found %d orphaned Claude process(es) to kill:\n\n", style.Warning.Render("⚠"), len(procOrphans))
|
||||
for _, o := range procOrphans {
|
||||
displayArgs := o.Args
|
||||
if len(displayArgs) > 80 {
|
||||
displayArgs = displayArgs[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render(fmt.Sprintf("PID %d", o.PID)), displayArgs)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if orphansKillDryRun {
|
||||
fmt.Printf("%s Dry run - no changes made\n", style.Dim.Render("ℹ"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirmation
|
||||
if !orphansKillForce {
|
||||
fmt.Printf("%s\n", style.Warning.Render("WARNING: This operation is irreversible!"))
|
||||
total := len(filteredCommits) + len(procOrphans)
|
||||
fmt.Printf("Remove %d orphan(s)? [y/N] ", total)
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
if strings.ToLower(strings.TrimSpace(response)) != "y" {
|
||||
fmt.Printf("%s Canceled\n", style.Dim.Render("ℹ"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Kill orphaned commits
|
||||
if len(filteredCommits) > 0 {
|
||||
fmt.Printf("\nRunning git gc --prune=now...\n")
|
||||
gcCmd := exec.Command("git", "gc", "--prune=now")
|
||||
gcCmd.Dir = mayorPath
|
||||
gcCmd.Stdout = os.Stdout
|
||||
gcCmd.Stderr = os.Stderr
|
||||
if err := gcCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git gc failed: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Removed %d orphaned commit(s)\n", style.Bold.Render("✓"), len(filteredCommits))
|
||||
}
|
||||
|
||||
// Kill orphaned processes
|
||||
if len(procOrphans) > 0 {
|
||||
fmt.Printf("\nKilling orphaned processes...\n")
|
||||
var killed, failed int
|
||||
for _, o := range procOrphans {
|
||||
proc, err := os.FindProcess(o.PID)
|
||||
if err != nil {
|
||||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
if err == os.ErrProcessDone {
|
||||
fmt.Printf(" %s PID %d: already terminated\n", style.Dim.Render("○"), o.PID)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" %s PID %d killed\n", style.Bold.Render("✓"), o.PID)
|
||||
killed++
|
||||
}
|
||||
|
||||
fmt.Printf("%s %d process(es) killed", style.Bold.Render("✓"), killed)
|
||||
if failed > 0 {
|
||||
fmt.Printf(", %d failed", failed)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Orphan cleanup complete\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrphanProcess represents a Claude process that has become orphaned (PPID=1)
|
||||
type OrphanProcess struct {
|
||||
PID int
|
||||
Args string
|
||||
}
|
||||
|
||||
// findOrphanProcesses finds Claude processes with PPID=1 (orphaned)
|
||||
func findOrphanProcesses() ([]OrphanProcess, error) {
|
||||
// Run ps to get all processes with PID, PPID, and args
|
||||
cmd := exec.Command("ps", "-eo", "pid,ppid,args")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("running ps: %w", err)
|
||||
}
|
||||
|
||||
var orphans []OrphanProcess
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
|
||||
// Skip header line
|
||||
if scanner.Scan() {
|
||||
// First line is header, skip it
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ppid, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only interested in orphans (PPID=1)
|
||||
if ppid != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reconstruct the args (rest of the fields)
|
||||
args := strings.Join(fields[2:], " ")
|
||||
|
||||
// Check if it's a claude-related process
|
||||
if !isClaudeProcess(args) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Exclude processes we don't want to kill
|
||||
if isExcludedProcess(args) {
|
||||
continue
|
||||
}
|
||||
|
||||
orphans = append(orphans, OrphanProcess{
|
||||
PID: pid,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
return orphans, nil
|
||||
}
|
||||
|
||||
// isClaudeProcess checks if the process is claude-related
|
||||
func isClaudeProcess(args string) bool {
|
||||
argsLower := strings.ToLower(args)
|
||||
return strings.Contains(argsLower, "claude")
|
||||
}
|
||||
|
||||
// isExcludedProcess checks if the process should be excluded from orphan list
|
||||
func isExcludedProcess(args string) bool {
|
||||
// Exclude any tmux process (server, new-session, etc.)
|
||||
// These may contain "claude" in args but are tmux processes, not actual Claude processes
|
||||
if strings.HasPrefix(args, "tmux ") || strings.HasPrefix(args, "/usr/bin/tmux") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exclude Claude.app desktop application processes
|
||||
if strings.Contains(args, "Claude.app") || strings.Contains(args, "/Applications/Claude") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exclude Claude Helper processes (part of Claude.app)
|
||||
if strings.Contains(args, "Claude Helper") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// runOrphansListProcesses lists orphaned Claude processes
|
||||
func runOrphansListProcesses(cmd *cobra.Command, args []string) error {
|
||||
orphans, err := findOrphanProcesses()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding orphan processes: %w", err)
|
||||
}
|
||||
|
||||
if len(orphans) == 0 {
|
||||
fmt.Printf("%s No orphaned Claude processes found\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Found %d orphaned Claude process(es):\n\n", style.Warning.Render("⚠"), len(orphans))
|
||||
|
||||
for _, o := range orphans {
|
||||
// Truncate args for display
|
||||
displayArgs := o.Args
|
||||
if len(displayArgs) > 80 {
|
||||
displayArgs = displayArgs[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render(fmt.Sprintf("PID %d", o.PID)), displayArgs)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n", style.Dim.Render("Use 'gt orphans procs kill' to terminate these processes"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runOrphansKillProcesses kills orphaned Claude processes
|
||||
func runOrphansKillProcesses(cmd *cobra.Command, args []string) error {
|
||||
orphans, err := findOrphanProcesses()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding orphan processes: %w", err)
|
||||
}
|
||||
|
||||
if len(orphans) == 0 {
|
||||
fmt.Printf("%s No orphaned Claude processes found\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show what we're about to kill
|
||||
fmt.Printf("%s Found %d orphaned Claude process(es):\n\n", style.Warning.Render("⚠"), len(orphans))
|
||||
for _, o := range orphans {
|
||||
displayArgs := o.Args
|
||||
if len(displayArgs) > 80 {
|
||||
displayArgs = displayArgs[:77] + "..."
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render(fmt.Sprintf("PID %d", o.PID)), displayArgs)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Confirm unless --force
|
||||
if !orphansProcsForce {
|
||||
fmt.Printf("Kill these %d process(es)? [y/N] ", len(orphans))
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Aborted")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the processes
|
||||
var killed, failed int
|
||||
for _, o := range orphans {
|
||||
proc, err := os.FindProcess(o.PID)
|
||||
if err != nil {
|
||||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
// Send SIGTERM first for graceful shutdown
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
// Process may have already exited
|
||||
if err == os.ErrProcessDone {
|
||||
fmt.Printf(" %s PID %d: already terminated\n", style.Dim.Render("○"), o.PID)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" %s PID %d killed\n", style.Bold.Render("✓"), o.PID)
|
||||
killed++
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %d killed", style.Bold.Render("Summary:"), killed)
|
||||
if failed > 0 {
|
||||
fmt.Printf(", %d failed", failed)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
381
internal/cmd/patrol.go
Normal file
381
internal/cmd/patrol.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Package cmd provides CLI commands for the gt tool.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var (
|
||||
// Patrol digest flags
|
||||
patrolDigestYesterday bool
|
||||
patrolDigestDate string
|
||||
patrolDigestDryRun bool
|
||||
patrolDigestVerbose bool
|
||||
)
|
||||
|
||||
var patrolCmd = &cobra.Command{
|
||||
Use: "patrol",
|
||||
GroupID: GroupDiag,
|
||||
Short: "Patrol digest management",
|
||||
Long: `Manage patrol cycle digests.
|
||||
|
||||
Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests
|
||||
to avoid JSONL pollution. This command aggregates them into daily summaries.
|
||||
|
||||
Examples:
|
||||
gt patrol digest --yesterday # Aggregate yesterday's patrol digests
|
||||
gt patrol digest --dry-run # Preview what would be aggregated`,
|
||||
}
|
||||
|
||||
var patrolDigestCmd = &cobra.Command{
|
||||
Use: "digest",
|
||||
Short: "Aggregate patrol cycle digests into a daily summary bead",
|
||||
Long: `Aggregate ephemeral patrol cycle digests into a permanent daily summary.
|
||||
|
||||
This command is intended to be run by Deacon patrol (daily) or manually.
|
||||
It queries patrol digests for a target date, creates a single aggregate
|
||||
"Patrol Report YYYY-MM-DD" bead, then deletes the source digests.
|
||||
|
||||
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
||||
and provides an audit trail without per-cycle pollution.
|
||||
|
||||
Examples:
|
||||
gt patrol digest --yesterday # Digest yesterday's patrols (for daily patrol)
|
||||
gt patrol digest --date 2026-01-15
|
||||
gt patrol digest --yesterday --dry-run`,
|
||||
RunE: runPatrolDigest,
|
||||
}
|
||||
|
||||
func init() {
|
||||
patrolCmd.AddCommand(patrolDigestCmd)
|
||||
rootCmd.AddCommand(patrolCmd)
|
||||
|
||||
// Patrol digest flags
|
||||
patrolDigestCmd.Flags().BoolVar(&patrolDigestYesterday, "yesterday", false, "Digest yesterday's patrol cycles")
|
||||
patrolDigestCmd.Flags().StringVar(&patrolDigestDate, "date", "", "Digest patrol cycles for specific date (YYYY-MM-DD)")
|
||||
patrolDigestCmd.Flags().BoolVar(&patrolDigestDryRun, "dry-run", false, "Preview what would be created without creating")
|
||||
patrolDigestCmd.Flags().BoolVarP(&patrolDigestVerbose, "verbose", "v", false, "Verbose output")
|
||||
}
|
||||
|
||||
// PatrolDigest represents the aggregated daily patrol report.
|
||||
type PatrolDigest struct {
|
||||
Date string `json:"date"`
|
||||
TotalCycles int `json:"total_cycles"`
|
||||
ByRole map[string]int `json:"by_role"` // deacon, witness, refinery
|
||||
Cycles []PatrolCycleEntry `json:"cycles"`
|
||||
}
|
||||
|
||||
// PatrolCycleEntry represents a single patrol cycle in the digest.
|
||||
type PatrolCycleEntry struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"` // deacon, witness, refinery
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClosedAt time.Time `json:"closed_at,omitempty"`
|
||||
}
|
||||
|
||||
// runPatrolDigest aggregates patrol cycle digests into a daily digest bead.
|
||||
func runPatrolDigest(cmd *cobra.Command, args []string) error {
|
||||
// Determine target date
|
||||
var targetDate time.Time
|
||||
|
||||
if patrolDigestDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", patrolDigestDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
|
||||
}
|
||||
targetDate = parsed
|
||||
} else if patrolDigestYesterday {
|
||||
targetDate = time.Now().AddDate(0, 0, -1)
|
||||
} else {
|
||||
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
|
||||
}
|
||||
|
||||
dateStr := targetDate.Format("2006-01-02")
|
||||
|
||||
// Idempotency check: see if digest already exists for this date
|
||||
existingID, err := findExistingPatrolDigest(dateStr)
|
||||
if err != nil {
|
||||
// Non-fatal: continue with creation attempt
|
||||
if patrolDigestVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[patrol] warning: failed to check existing digest: %v\n", err)
|
||||
}
|
||||
} else if existingID != "" {
|
||||
fmt.Printf("%s Patrol digest already exists for %s (bead: %s)\n",
|
||||
style.Dim.Render("○"), dateStr, existingID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query ephemeral patrol digest beads for target date
|
||||
cycles, err := queryPatrolDigests(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying patrol digests: %w", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
fmt.Printf("%s No patrol digests found for %s\n", style.Dim.Render("○"), dateStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build digest
|
||||
digest := PatrolDigest{
|
||||
Date: dateStr,
|
||||
Cycles: cycles,
|
||||
ByRole: make(map[string]int),
|
||||
}
|
||||
|
||||
for _, c := range cycles {
|
||||
digest.TotalCycles++
|
||||
digest.ByRole[c.Role]++
|
||||
}
|
||||
|
||||
if patrolDigestDryRun {
|
||||
fmt.Printf("%s [DRY RUN] Would create Patrol Report %s:\n", style.Bold.Render("📊"), dateStr)
|
||||
fmt.Printf(" Total cycles: %d\n", digest.TotalCycles)
|
||||
fmt.Printf(" 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 {
|
||||
fmt.Printf(" %s: %d cycles\n", role, digest.ByRole[role])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create permanent digest bead
|
||||
digestID, err := createPatrolDigestBead(digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating digest bead: %w", err)
|
||||
}
|
||||
|
||||
// Delete source digests (they're ephemeral)
|
||||
deletedCount, deleteErr := deletePatrolDigests(targetDate)
|
||||
if deleteErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to delete some source digests: %v\n", deleteErr)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created Patrol Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
|
||||
fmt.Printf(" Total: %d cycles\n", digest.TotalCycles)
|
||||
for role, count := range digest.ByRole {
|
||||
fmt.Printf(" %s: %d\n", role, count)
|
||||
}
|
||||
if deletedCount > 0 {
|
||||
fmt.Printf(" Deleted %d source digests\n", deletedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryPatrolDigests queries ephemeral patrol digest beads for a target date.
|
||||
func queryPatrolDigests(targetDate time.Time) ([]PatrolCycleEntry, error) {
|
||||
// List closed issues with "digest" label that are ephemeral
|
||||
// Patrol digests have titles like "Digest: mol-deacon-patrol", "Digest: mol-witness-patrol"
|
||||
listCmd := exec.Command("bd", "list",
|
||||
"--status=closed",
|
||||
"--label=digest",
|
||||
"--json",
|
||||
"--limit=0", // Get all
|
||||
)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
if patrolDigestVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[patrol] bd list failed: %v\n", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClosedAt time.Time `json:"closed_at"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(listOutput, &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing issue list: %w", err)
|
||||
}
|
||||
|
||||
targetDay := targetDate.Format("2006-01-02")
|
||||
var patrolDigests []PatrolCycleEntry
|
||||
|
||||
for _, issue := range issues {
|
||||
// Only process ephemeral patrol digests
|
||||
if !issue.Ephemeral {
|
||||
continue
|
||||
}
|
||||
|
||||
// Must be a patrol digest (title starts with "Digest: mol-")
|
||||
if !strings.HasPrefix(issue.Title, "Digest: mol-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if created on target date
|
||||
if issue.CreatedAt.Format("2006-01-02") != targetDay {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract role from title (e.g., "Digest: mol-deacon-patrol" -> "deacon")
|
||||
role := extractPatrolRole(issue.Title)
|
||||
|
||||
patrolDigests = append(patrolDigests, PatrolCycleEntry{
|
||||
ID: issue.ID,
|
||||
Role: role,
|
||||
Title: issue.Title,
|
||||
Description: issue.Description,
|
||||
CreatedAt: issue.CreatedAt,
|
||||
ClosedAt: issue.ClosedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return patrolDigests, nil
|
||||
}
|
||||
|
||||
// extractPatrolRole extracts the role from a patrol digest title.
|
||||
// "Digest: mol-deacon-patrol" -> "deacon"
|
||||
// "Digest: mol-witness-patrol" -> "witness"
|
||||
// "Digest: gt-wisp-abc123" -> "unknown"
|
||||
func extractPatrolRole(title string) string {
|
||||
// Remove "Digest: " prefix
|
||||
title = strings.TrimPrefix(title, "Digest: ")
|
||||
|
||||
// Extract role from "mol-<role>-patrol" or "gt-wisp-<id>"
|
||||
if strings.HasPrefix(title, "mol-") && strings.HasSuffix(title, "-patrol") {
|
||||
// "mol-deacon-patrol" -> "deacon"
|
||||
role := strings.TrimPrefix(title, "mol-")
|
||||
role = strings.TrimSuffix(role, "-patrol")
|
||||
return role
|
||||
}
|
||||
|
||||
// For wisp digests, try to extract from description or return generic
|
||||
return "patrol"
|
||||
}
|
||||
|
||||
// createPatrolDigestBead creates a permanent bead for the daily patrol digest.
|
||||
func createPatrolDigestBead(digest PatrolDigest) (string, error) {
|
||||
// Build description with aggregate data
|
||||
var desc strings.Builder
|
||||
desc.WriteString(fmt.Sprintf("Daily patrol aggregate for %s.\n\n", digest.Date))
|
||||
desc.WriteString(fmt.Sprintf("**Total Cycles:** %d\n\n", digest.TotalCycles))
|
||||
|
||||
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 {
|
||||
desc.WriteString(fmt.Sprintf("- %s: %d cycles\n", role, digest.ByRole[role]))
|
||||
}
|
||||
desc.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build payload JSON with cycle 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("Patrol Report %s", digest.Date)
|
||||
bdArgs := []string{
|
||||
"create",
|
||||
"--type=event",
|
||||
"--title=" + title,
|
||||
"--event-category=patrol.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 patrol digest")
|
||||
_ = closeCmd.Run() // Best effort
|
||||
|
||||
return digestID, nil
|
||||
}
|
||||
|
||||
// findExistingPatrolDigest checks if a patrol digest already exists for the given date.
|
||||
// Returns the bead ID if found, empty string if not found.
|
||||
func findExistingPatrolDigest(dateStr string) (string, error) {
|
||||
expectedTitle := fmt.Sprintf("Patrol Report %s", dateStr)
|
||||
|
||||
// Query event beads with patrol.digest category
|
||||
listCmd := exec.Command("bd", "list",
|
||||
"--type=event",
|
||||
"--json",
|
||||
"--limit=50", // Recent events only
|
||||
)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var events []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(listOutput, &events); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, evt := range events {
|
||||
if evt.Title == expectedTitle {
|
||||
return evt.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deletePatrolDigests deletes ephemeral patrol digest beads for a target date.
|
||||
func deletePatrolDigests(targetDate time.Time) (int, error) {
|
||||
// Query patrol digests for the target date
|
||||
cycles, err := queryPatrolDigests(targetDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Collect IDs to delete
|
||||
var idsToDelete []string
|
||||
for _, cycle := range cycles {
|
||||
idsToDelete = append(idsToDelete, cycle.ID)
|
||||
}
|
||||
|
||||
// Delete in batch
|
||||
deleteArgs := append([]string{"delete", "--force"}, idsToDelete...)
|
||||
deleteCmd := exec.Command("bd", deleteArgs...)
|
||||
if err := deleteCmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("deleting patrol digests: %w", err)
|
||||
}
|
||||
|
||||
return len(idsToDelete), nil
|
||||
}
|
||||
101
internal/cmd/patrol_test.go
Normal file
101
internal/cmd/patrol_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPatrolRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "deacon patrol",
|
||||
title: "Digest: mol-deacon-patrol",
|
||||
expected: "deacon",
|
||||
},
|
||||
{
|
||||
name: "witness patrol",
|
||||
title: "Digest: mol-witness-patrol",
|
||||
expected: "witness",
|
||||
},
|
||||
{
|
||||
name: "refinery patrol",
|
||||
title: "Digest: mol-refinery-patrol",
|
||||
expected: "refinery",
|
||||
},
|
||||
{
|
||||
name: "wisp digest without patrol suffix",
|
||||
title: "Digest: gt-wisp-abc123",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "random title",
|
||||
title: "Some other digest",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "empty title",
|
||||
title: "",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "just digest prefix",
|
||||
title: "Digest: ",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "mol prefix but no patrol suffix",
|
||||
title: "Digest: mol-deacon-other",
|
||||
expected: "patrol",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractPatrolRole(tt.title)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractPatrolRole(%q) = %q, want %q", tt.title, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolDigestDateFormat(t *testing.T) {
|
||||
// Test that PatrolDigest.Date format is YYYY-MM-DD
|
||||
digest := PatrolDigest{
|
||||
Date: "2026-01-17",
|
||||
TotalCycles: 5,
|
||||
ByRole: map[string]int{"deacon": 2, "witness": 3},
|
||||
}
|
||||
|
||||
if digest.Date != "2026-01-17" {
|
||||
t.Errorf("Date format incorrect: got %q", digest.Date)
|
||||
}
|
||||
|
||||
if digest.TotalCycles != 5 {
|
||||
t.Errorf("TotalCycles: got %d, want 5", digest.TotalCycles)
|
||||
}
|
||||
|
||||
if digest.ByRole["deacon"] != 2 {
|
||||
t.Errorf("ByRole[deacon]: got %d, want 2", digest.ByRole["deacon"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolCycleEntry(t *testing.T) {
|
||||
entry := PatrolCycleEntry{
|
||||
ID: "gt-abc123",
|
||||
Role: "deacon",
|
||||
Title: "Digest: mol-deacon-patrol",
|
||||
Description: "Test description",
|
||||
}
|
||||
|
||||
if entry.ID != "gt-abc123" {
|
||||
t.Errorf("ID: got %q, want %q", entry.ID, "gt-abc123")
|
||||
}
|
||||
|
||||
if entry.Role != "deacon" {
|
||||
t.Errorf("Role: got %q, want %q", entry.Role, "deacon")
|
||||
}
|
||||
}
|
||||
507
internal/cmd/plugin.go
Normal file
507
internal/cmd/plugin.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/plugin"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Plugin command flags
|
||||
var (
|
||||
pluginListJSON bool
|
||||
pluginShowJSON bool
|
||||
pluginRunForce bool
|
||||
pluginRunDryRun bool
|
||||
pluginHistoryJSON bool
|
||||
pluginHistoryLimit int
|
||||
)
|
||||
|
||||
var pluginCmd = &cobra.Command{
|
||||
Use: "plugin",
|
||||
GroupID: GroupConfig,
|
||||
Short: "Plugin management",
|
||||
Long: `Manage plugins that run during Deacon patrol cycles.
|
||||
|
||||
Plugins are periodic automation tasks defined by plugin.md files with TOML frontmatter.
|
||||
|
||||
PLUGIN LOCATIONS:
|
||||
~/gt/plugins/ Town-level plugins (universal, apply everywhere)
|
||||
<rig>/plugins/ Rig-level plugins (project-specific)
|
||||
|
||||
GATE TYPES:
|
||||
cooldown Run if enough time has passed (e.g., 1h)
|
||||
cron Run on a schedule (e.g., "0 9 * * *")
|
||||
condition Run if a check command returns exit 0
|
||||
event Run on events (e.g., startup)
|
||||
manual Never auto-run, trigger explicitly
|
||||
|
||||
Examples:
|
||||
gt plugin list # List all discovered plugins
|
||||
gt plugin show <name> # Show plugin details
|
||||
gt plugin list --json # JSON output`,
|
||||
RunE: requireSubcommand,
|
||||
}
|
||||
|
||||
var pluginListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all discovered plugins",
|
||||
Long: `List all plugins from town and rig plugin directories.
|
||||
|
||||
Plugins are discovered from:
|
||||
- ~/gt/plugins/ (town-level)
|
||||
- <rig>/plugins/ for each registered rig
|
||||
|
||||
When a plugin exists at both levels, the rig-level version takes precedence.
|
||||
|
||||
Examples:
|
||||
gt plugin list # Human-readable output
|
||||
gt plugin list --json # JSON output for scripting`,
|
||||
RunE: runPluginList,
|
||||
}
|
||||
|
||||
var pluginShowCmd = &cobra.Command{
|
||||
Use: "show <name>",
|
||||
Short: "Show plugin details",
|
||||
Long: `Show detailed information about a plugin.
|
||||
|
||||
Displays the plugin's configuration, gate settings, and instructions.
|
||||
|
||||
Examples:
|
||||
gt plugin show rebuild-gt
|
||||
gt plugin show rebuild-gt --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPluginShow,
|
||||
}
|
||||
|
||||
var pluginRunCmd = &cobra.Command{
|
||||
Use: "run <name>",
|
||||
Short: "Manually trigger plugin execution",
|
||||
Long: `Manually trigger a plugin to run.
|
||||
|
||||
By default, checks if the gate would allow execution and informs you
|
||||
if it wouldn't. Use --force to bypass gate checks.
|
||||
|
||||
Examples:
|
||||
gt plugin run rebuild-gt # Run if gate allows
|
||||
gt plugin run rebuild-gt --force # Bypass gate check
|
||||
gt plugin run rebuild-gt --dry-run # Show what would happen`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPluginRun,
|
||||
}
|
||||
|
||||
var pluginHistoryCmd = &cobra.Command{
|
||||
Use: "history <name>",
|
||||
Short: "Show plugin execution history",
|
||||
Long: `Show recent execution history for a plugin.
|
||||
|
||||
Queries ephemeral beads (wisps) that record plugin runs.
|
||||
|
||||
Examples:
|
||||
gt plugin history rebuild-gt
|
||||
gt plugin history rebuild-gt --json
|
||||
gt plugin history rebuild-gt --limit 20`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPluginHistory,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List subcommand flags
|
||||
pluginListCmd.Flags().BoolVar(&pluginListJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Show subcommand flags
|
||||
pluginShowCmd.Flags().BoolVar(&pluginShowJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Run subcommand flags
|
||||
pluginRunCmd.Flags().BoolVar(&pluginRunForce, "force", false, "Bypass gate check")
|
||||
pluginRunCmd.Flags().BoolVar(&pluginRunDryRun, "dry-run", false, "Show what would happen without executing")
|
||||
|
||||
// History subcommand flags
|
||||
pluginHistoryCmd.Flags().BoolVar(&pluginHistoryJSON, "json", false, "Output as JSON")
|
||||
pluginHistoryCmd.Flags().IntVar(&pluginHistoryLimit, "limit", 10, "Maximum number of runs to show")
|
||||
|
||||
// Add subcommands
|
||||
pluginCmd.AddCommand(pluginListCmd)
|
||||
pluginCmd.AddCommand(pluginShowCmd)
|
||||
pluginCmd.AddCommand(pluginRunCmd)
|
||||
pluginCmd.AddCommand(pluginHistoryCmd)
|
||||
|
||||
rootCmd.AddCommand(pluginCmd)
|
||||
}
|
||||
|
||||
// getPluginScanner creates a scanner with town root and all rig names.
|
||||
func getPluginScanner() (*plugin.Scanner, string, error) {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Load rigs config to get rig names
|
||||
rigsConfigPath := constants.MayorRigsPath(townRoot)
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
}
|
||||
|
||||
// Extract rig names
|
||||
rigNames := make([]string, 0, len(rigsConfig.Rigs))
|
||||
for name := range rigsConfig.Rigs {
|
||||
rigNames = append(rigNames, name)
|
||||
}
|
||||
sort.Strings(rigNames)
|
||||
|
||||
scanner := plugin.NewScanner(townRoot, rigNames)
|
||||
return scanner, townRoot, nil
|
||||
}
|
||||
|
||||
func runPluginList(cmd *cobra.Command, args []string) error {
|
||||
scanner, townRoot, err := getPluginScanner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plugins, err := scanner.DiscoverAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("discovering plugins: %w", err)
|
||||
}
|
||||
|
||||
// Sort plugins by name
|
||||
sort.Slice(plugins, func(i, j int) bool {
|
||||
return plugins[i].Name < plugins[j].Name
|
||||
})
|
||||
|
||||
if pluginListJSON {
|
||||
return outputPluginListJSON(plugins)
|
||||
}
|
||||
|
||||
return outputPluginListText(plugins, townRoot)
|
||||
}
|
||||
|
||||
func outputPluginListJSON(plugins []*plugin.Plugin) error {
|
||||
summaries := make([]plugin.PluginSummary, len(plugins))
|
||||
for i, p := range plugins {
|
||||
summaries[i] = p.Summary()
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(summaries)
|
||||
}
|
||||
|
||||
func outputPluginListText(plugins []*plugin.Plugin, townRoot string) error {
|
||||
if len(plugins) == 0 {
|
||||
fmt.Printf("%s No plugins discovered\n", style.Dim.Render("○"))
|
||||
fmt.Printf("\n Plugin directories:\n")
|
||||
fmt.Printf(" %s/plugins/\n", townRoot)
|
||||
fmt.Printf("\n Create a plugin by adding a directory with plugin.md\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Discovered %d plugin(s)\n\n", style.Success.Render("●"), len(plugins))
|
||||
|
||||
// Group by location
|
||||
townPlugins := make([]*plugin.Plugin, 0)
|
||||
rigPlugins := make(map[string][]*plugin.Plugin)
|
||||
|
||||
for _, p := range plugins {
|
||||
if p.Location == plugin.LocationTown {
|
||||
townPlugins = append(townPlugins, p)
|
||||
} else {
|
||||
rigPlugins[p.RigName] = append(rigPlugins[p.RigName], p)
|
||||
}
|
||||
}
|
||||
|
||||
// Print town-level plugins
|
||||
if len(townPlugins) > 0 {
|
||||
fmt.Printf(" %s\n", style.Bold.Render("Town-level plugins:"))
|
||||
for _, p := range townPlugins {
|
||||
printPluginSummary(p)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print rig-level plugins by rig
|
||||
rigNames := make([]string, 0, len(rigPlugins))
|
||||
for name := range rigPlugins {
|
||||
rigNames = append(rigNames, name)
|
||||
}
|
||||
sort.Strings(rigNames)
|
||||
|
||||
for _, rigName := range rigNames {
|
||||
fmt.Printf(" %s\n", style.Bold.Render(fmt.Sprintf("Rig %s:", rigName)))
|
||||
for _, p := range rigPlugins[rigName] {
|
||||
printPluginSummary(p)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPluginSummary(p *plugin.Plugin) {
|
||||
gateType := "manual"
|
||||
if p.Gate != nil && p.Gate.Type != "" {
|
||||
gateType = string(p.Gate.Type)
|
||||
}
|
||||
|
||||
desc := p.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render(p.Name), style.Dim.Render(fmt.Sprintf("[%s]", gateType)))
|
||||
if desc != "" {
|
||||
fmt.Printf(" %s\n", style.Dim.Render(desc))
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginShow(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
scanner, _, err := getPluginScanner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := scanner.GetPlugin(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pluginShowJSON {
|
||||
return outputPluginShowJSON(p)
|
||||
}
|
||||
|
||||
return outputPluginShowText(p)
|
||||
}
|
||||
|
||||
func outputPluginShowJSON(p *plugin.Plugin) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(p)
|
||||
}
|
||||
|
||||
func outputPluginShowText(p *plugin.Plugin) error {
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Plugin:"), p.Name)
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Path:"), p.Path)
|
||||
|
||||
if p.Description != "" {
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Description:"), p.Description)
|
||||
}
|
||||
|
||||
// Location
|
||||
locStr := string(p.Location)
|
||||
if p.RigName != "" {
|
||||
locStr = fmt.Sprintf("%s (%s)", p.Location, p.RigName)
|
||||
}
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Location:"), locStr)
|
||||
|
||||
fmt.Printf("%s %d\n", style.Bold.Render("Version:"), p.Version)
|
||||
|
||||
// Gate
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Gate:"))
|
||||
if p.Gate != nil {
|
||||
fmt.Printf(" Type: %s\n", p.Gate.Type)
|
||||
if p.Gate.Duration != "" {
|
||||
fmt.Printf(" Duration: %s\n", p.Gate.Duration)
|
||||
}
|
||||
if p.Gate.Schedule != "" {
|
||||
fmt.Printf(" Schedule: %s\n", p.Gate.Schedule)
|
||||
}
|
||||
if p.Gate.Check != "" {
|
||||
fmt.Printf(" Check: %s\n", p.Gate.Check)
|
||||
}
|
||||
if p.Gate.On != "" {
|
||||
fmt.Printf(" On: %s\n", p.Gate.On)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Type: manual (no gate section)\n")
|
||||
}
|
||||
|
||||
// Tracking
|
||||
if p.Tracking != nil {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Tracking:"))
|
||||
if len(p.Tracking.Labels) > 0 {
|
||||
fmt.Printf(" Labels: %s\n", strings.Join(p.Tracking.Labels, ", "))
|
||||
}
|
||||
fmt.Printf(" Digest: %v\n", p.Tracking.Digest)
|
||||
}
|
||||
|
||||
// Execution
|
||||
if p.Execution != nil {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Execution:"))
|
||||
if p.Execution.Timeout != "" {
|
||||
fmt.Printf(" Timeout: %s\n", p.Execution.Timeout)
|
||||
}
|
||||
fmt.Printf(" Notify on failure: %v\n", p.Execution.NotifyOnFailure)
|
||||
if p.Execution.Severity != "" {
|
||||
fmt.Printf(" Severity: %s\n", p.Execution.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// Instructions preview
|
||||
if p.Instructions != "" {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Instructions:"))
|
||||
lines := strings.Split(p.Instructions, "\n")
|
||||
preview := lines
|
||||
if len(lines) > 10 {
|
||||
preview = lines[:10]
|
||||
}
|
||||
for _, line := range preview {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
if len(lines) > 10 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("... (%d more lines)", len(lines)-10)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPluginRun(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
scanner, townRoot, err := getPluginScanner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := scanner.GetPlugin(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check gate status for cooldown gates
|
||||
gateOpen := true
|
||||
gateReason := ""
|
||||
if p.Gate != nil && p.Gate.Type == plugin.GateCooldown && !pluginRunForce {
|
||||
recorder := plugin.NewRecorder(townRoot)
|
||||
duration := p.Gate.Duration
|
||||
if duration == "" {
|
||||
duration = "1h" // default
|
||||
}
|
||||
count, err := recorder.CountRunsSince(p.Name, duration)
|
||||
if err != nil {
|
||||
// Log warning but continue
|
||||
fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err)
|
||||
} else if count > 0 {
|
||||
gateOpen = false
|
||||
gateReason = fmt.Sprintf("ran %d time(s) within %s cooldown", count, duration)
|
||||
}
|
||||
}
|
||||
|
||||
if pluginRunDryRun {
|
||||
fmt.Printf("%s Dry run for plugin: %s\n", style.Bold.Render("Plugin:"), p.Name)
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Location:"), p.Path)
|
||||
if p.Gate != nil {
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("Gate type:"), p.Gate.Type)
|
||||
}
|
||||
if !gateOpen {
|
||||
fmt.Printf("%s %s (use --force to override)\n", style.Warning.Render("Gate closed:"), gateReason)
|
||||
} else {
|
||||
fmt.Printf("%s Would execute plugin instructions\n", style.Success.Render("Gate open:"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !gateOpen && !pluginRunForce {
|
||||
fmt.Printf("%s Gate closed: %s\n", style.Warning.Render("⚠"), gateReason)
|
||||
fmt.Printf(" Use --force to bypass gate check\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute the plugin
|
||||
// For manual runs, we print the instructions for the agent/user to execute
|
||||
// Automatic execution via dogs is handled by gt-n08ix.2
|
||||
fmt.Printf("%s Running plugin: %s\n", style.Success.Render("●"), p.Name)
|
||||
if pluginRunForce && !gateOpen {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(gate bypassed with --force)"))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Instructions:"))
|
||||
fmt.Println(p.Instructions)
|
||||
|
||||
// Record the run
|
||||
recorder := plugin.NewRecorder(townRoot)
|
||||
beadID, err := recorder.RecordRun(plugin.PluginRunRecord{
|
||||
PluginName: p.Name,
|
||||
RigName: p.RigName,
|
||||
Result: plugin.ResultSuccess, // Manual runs are marked success
|
||||
Body: "Manual run via gt plugin run",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("\n%s Recorded run: %s\n", style.Dim.Render("●"), beadID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPluginHistory(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
_, townRoot, err := getPluginScanner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recorder := plugin.NewRecorder(townRoot)
|
||||
runs, err := recorder.GetRunsSince(name, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying history: %w", err)
|
||||
}
|
||||
|
||||
if runs == nil {
|
||||
runs = []*plugin.PluginRunBead{}
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if pluginHistoryLimit > 0 && len(runs) > pluginHistoryLimit {
|
||||
runs = runs[:pluginHistoryLimit]
|
||||
}
|
||||
|
||||
if pluginHistoryJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
|
||||
if len(runs) == 0 {
|
||||
fmt.Printf("%s No execution history for plugin: %s\n", style.Dim.Render("○"), name)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Execution history for %s (%d runs)\n\n", style.Success.Render("●"), name, len(runs))
|
||||
|
||||
for _, run := range runs {
|
||||
resultStyle := style.Success
|
||||
resultIcon := "✓"
|
||||
if run.Result == plugin.ResultFailure {
|
||||
resultStyle = style.Error
|
||||
resultIcon = "✗"
|
||||
} else if run.Result == plugin.ResultSkipped {
|
||||
resultStyle = style.Dim
|
||||
resultIcon = "○"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
resultStyle.Render(resultIcon),
|
||||
run.CreatedAt.Format("2006-01-02 15:04"),
|
||||
style.Dim.Render(run.ID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -30,14 +30,27 @@ var (
|
||||
|
||||
var polecatCmd = &cobra.Command{
|
||||
Use: "polecat",
|
||||
Aliases: []string{"cat", "polecats"},
|
||||
Aliases: []string{"polecats"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage polecats in rigs",
|
||||
Short: "Manage polecats (ephemeral workers, one task then nuked)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage polecat lifecycle in rigs.
|
||||
|
||||
Polecats are worker agents that operate in their own git worktrees.
|
||||
Use the subcommands to add, remove, list, wake, and sleep polecats.`,
|
||||
Polecats are EPHEMERAL workers: spawned for one task, nuked when done.
|
||||
There is NO idle state. A polecat is either:
|
||||
- Working: Actively doing assigned work
|
||||
- Stalled: Session crashed mid-work (needs Witness intervention)
|
||||
- Zombie: Finished but gt done failed (needs cleanup)
|
||||
|
||||
Self-cleaning model: When work completes, the polecat runs 'gt done',
|
||||
which pushes the branch, submits to the merge queue, and exits. The
|
||||
Witness then nukes the sandbox. Polecats don't wait for more work.
|
||||
|
||||
Session vs sandbox: The Claude session cycles frequently (handoffs,
|
||||
compaction). The git worktree (sandbox) persists until nuke. Work
|
||||
survives session restarts.
|
||||
|
||||
Cats build features. Dogs clean up messes.`,
|
||||
}
|
||||
|
||||
var polecatListCmd = &cobra.Command{
|
||||
@@ -330,7 +343,8 @@ func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) {
|
||||
}
|
||||
|
||||
polecatGit := git.NewGit(r.Path)
|
||||
mgr := polecat.NewManager(r, polecatGit)
|
||||
t := tmux.NewTmux()
|
||||
mgr := polecat.NewManager(r, polecatGit, t)
|
||||
|
||||
return mgr, r, nil
|
||||
}
|
||||
@@ -363,7 +377,7 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
|
||||
|
||||
for _, r := range rigs {
|
||||
polecatGit := git.NewGit(r.Path)
|
||||
mgr := polecat.NewManager(r, polecatGit)
|
||||
mgr := polecat.NewManager(r, polecatGit, t)
|
||||
polecatMgr := polecat.NewSessionManager(t, r)
|
||||
|
||||
polecats, err := mgr.List()
|
||||
@@ -956,7 +970,7 @@ func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
|
||||
// We need to read it directly from beads since manager doesn't expose it
|
||||
rigPath := r.Path
|
||||
bd := beads.New(rigPath)
|
||||
agentBeadID := beads.PolecatBeadID(rigName, polecatName)
|
||||
agentBeadID := polecatBeadIDForRig(r, rigName, polecatName)
|
||||
_, fields, err := bd.GetAgentBead(agentBeadID)
|
||||
|
||||
status := RecoveryStatus{
|
||||
@@ -1157,7 +1171,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName)
|
||||
fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName)
|
||||
fmt.Printf(" - Delete branch (if exists)\n")
|
||||
fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName))
|
||||
fmt.Printf(" - Close agent bead: %s\n", polecatBeadIDForRig(p.r, p.rigName, p.polecatName))
|
||||
|
||||
displayDryRunSafetyCheck(p)
|
||||
fmt.Println()
|
||||
@@ -1202,8 +1216,15 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Step 4: Delete branch (if we know it)
|
||||
// Use bare repo if it exists (matches where worktree was created), otherwise mayor/rig
|
||||
if branchToDelete != "" {
|
||||
repoGit := git.NewGit(filepath.Join(p.r.Path, "mayor", "rig"))
|
||||
var repoGit *git.Git
|
||||
bareRepoPath := filepath.Join(p.r.Path, ".repo.git")
|
||||
if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() {
|
||||
repoGit = git.NewGitWithDir(bareRepoPath, "")
|
||||
} else {
|
||||
repoGit = git.NewGit(filepath.Join(p.r.Path, "mayor", "rig"))
|
||||
}
|
||||
if err := repoGit.DeleteBranch(branchToDelete, true); err != nil {
|
||||
// Non-fatal - branch might already be gone
|
||||
fmt.Printf(" %s branch delete: %v\n", style.Dim.Render("○"), err)
|
||||
@@ -1213,7 +1234,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Step 5: Close agent bead (if exists)
|
||||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||||
agentBeadID := polecatBeadIDForRig(p.r, p.rigName, p.polecatName)
|
||||
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
||||
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
@@ -104,7 +105,7 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
|
||||
|
||||
// Check 1: Unpushed commits via cleanup_status or git state
|
||||
bd := beads.New(target.r.Path)
|
||||
agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName)
|
||||
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName)
|
||||
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
|
||||
|
||||
if err != nil || fields == nil {
|
||||
@@ -176,6 +177,15 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func rigPrefix(r *rig.Rig) string {
|
||||
townRoot := filepath.Dir(r.Path)
|
||||
return beads.GetPrefixForRig(townRoot, r.Name)
|
||||
}
|
||||
|
||||
func polecatBeadIDForRig(r *rig.Rig, rigName, polecatName string) string {
|
||||
return beads.PolecatBeadIDWithPrefix(rigPrefix(r), rigName, polecatName)
|
||||
}
|
||||
|
||||
// displaySafetyCheckBlocked prints blocked polecats and guidance.
|
||||
func displaySafetyCheckBlocked(blocked []*SafetyCheckResult) {
|
||||
fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:"))
|
||||
@@ -202,7 +212,7 @@ 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)
|
||||
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName)
|
||||
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
|
||||
|
||||
// Check 1: Git state
|
||||
|
||||
@@ -4,7 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
@@ -15,8 +22,8 @@ import (
|
||||
|
||||
// Polecat identity command flags
|
||||
var (
|
||||
polecatIdentityListJSON bool
|
||||
polecatIdentityShowJSON bool
|
||||
polecatIdentityListJSON bool
|
||||
polecatIdentityShowJSON bool
|
||||
polecatIdentityRemoveForce bool
|
||||
)
|
||||
|
||||
@@ -72,15 +79,18 @@ Example:
|
||||
|
||||
var polecatIdentityShowCmd = &cobra.Command{
|
||||
Use: "show <rig> <name>",
|
||||
Short: "Show identity bead details and CV summary",
|
||||
Long: `Show detailed identity bead information for a polecat.
|
||||
Short: "Show polecat identity with CV summary",
|
||||
Long: `Show detailed identity information for a polecat including work history.
|
||||
|
||||
Displays:
|
||||
- Identity bead fields
|
||||
- CV history (past work)
|
||||
- Current hook bead details
|
||||
- Identity bead ID and creation date
|
||||
- Session count
|
||||
- Completion statistics (issues completed, failed, abandoned)
|
||||
- Language breakdown from file extensions
|
||||
- Work type breakdown (feat, fix, refactor, etc.)
|
||||
- Recent work list with relative timestamps
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
gt polecat identity show gastown Toast
|
||||
gt polecat identity show gastown Toast --json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
@@ -160,6 +170,40 @@ type IdentityInfo struct {
|
||||
SessionRunning bool `json:"session_running"`
|
||||
}
|
||||
|
||||
// IdentityDetails holds detailed identity information for show command.
|
||||
type IdentityDetails struct {
|
||||
IdentityInfo
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
CVBeads []string `json:"cv_beads,omitempty"`
|
||||
}
|
||||
|
||||
// CVSummary represents the CV/work history summary for a polecat.
|
||||
type CVSummary struct {
|
||||
Identity string `json:"identity"`
|
||||
Created string `json:"created,omitempty"`
|
||||
Sessions int `json:"sessions"`
|
||||
IssuesCompleted int `json:"issues_completed"`
|
||||
IssuesFailed int `json:"issues_failed"`
|
||||
IssuesAbandoned int `json:"issues_abandoned"`
|
||||
Languages map[string]int `json:"languages,omitempty"`
|
||||
WorkTypes map[string]int `json:"work_types,omitempty"`
|
||||
AvgCompletionMin int `json:"avg_completion_minutes,omitempty"`
|
||||
FirstPassRate float64 `json:"first_pass_rate,omitempty"`
|
||||
RecentWork []RecentWorkItem `json:"recent_work,omitempty"`
|
||||
}
|
||||
|
||||
// RecentWorkItem represents a recent work item in the CV.
|
||||
type RecentWorkItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Completed string `json:"completed"`
|
||||
Ago string `json:"ago"`
|
||||
}
|
||||
|
||||
func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
|
||||
rigName := args[0]
|
||||
var polecatName string
|
||||
@@ -177,7 +221,8 @@ func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
|
||||
// Generate name if not provided
|
||||
if polecatName == "" {
|
||||
polecatGit := git.NewGit(r.Path)
|
||||
mgr := polecat.NewManager(r, polecatGit)
|
||||
t := tmux.NewTmux()
|
||||
mgr := polecat.NewManager(r, polecatGit, t)
|
||||
polecatName, err = mgr.AllocateName()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating polecat name: %w", err)
|
||||
@@ -187,7 +232,7 @@ func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Check if identity already exists
|
||||
bd := beads.New(r.Path)
|
||||
beadID := beads.PolecatBeadID(rigName, polecatName)
|
||||
beadID := polecatBeadIDForRig(r, rigName, polecatName)
|
||||
existingIssue, _, _ := bd.GetAgentBead(beadID)
|
||||
if existingIssue != nil && existingIssue.Status != "closed" {
|
||||
return fmt.Errorf("identity bead %s already exists", beadID)
|
||||
@@ -250,7 +295,7 @@ func runPolecatIdentityList(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Check if worktree exists
|
||||
worktreeExists := false
|
||||
mgr := polecat.NewManager(r, nil)
|
||||
mgr := polecat.NewManager(r, nil, t)
|
||||
if p, err := mgr.Get(name); err == nil && p != nil {
|
||||
worktreeExists = true
|
||||
}
|
||||
@@ -328,16 +373,6 @@ func runPolecatIdentityList(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdentityDetails holds detailed identity information for show command.
|
||||
type IdentityDetails struct {
|
||||
IdentityInfo
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
CVBeads []string `json:"cv_beads,omitempty"`
|
||||
}
|
||||
|
||||
func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
||||
rigName := args[0]
|
||||
polecatName := args[1]
|
||||
@@ -350,7 +385,7 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Get identity bead
|
||||
bd := beads.New(r.Path)
|
||||
beadID := beads.PolecatBeadID(rigName, polecatName)
|
||||
beadID := polecatBeadIDForRig(r, rigName, polecatName)
|
||||
issue, fields, err := bd.GetAgentBead(beadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting identity bead: %w", err)
|
||||
@@ -362,72 +397,71 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
||||
// Check worktree and session
|
||||
t := tmux.NewTmux()
|
||||
polecatMgr := polecat.NewSessionManager(t, r)
|
||||
mgr := polecat.NewManager(r, nil)
|
||||
mgr := polecat.NewManager(r, nil, t)
|
||||
|
||||
worktreeExists := false
|
||||
var clonePath string
|
||||
if p, err := mgr.Get(polecatName); err == nil && p != nil {
|
||||
worktreeExists = true
|
||||
clonePath = p.ClonePath
|
||||
}
|
||||
sessionRunning, _ := polecatMgr.IsRunning(polecatName)
|
||||
|
||||
// Build details
|
||||
details := IdentityDetails{
|
||||
IdentityInfo: IdentityInfo{
|
||||
Rig: rigName,
|
||||
Name: polecatName,
|
||||
BeadID: beadID,
|
||||
AgentState: fields.AgentState,
|
||||
HookBead: issue.HookBead,
|
||||
CleanupStatus: fields.CleanupStatus,
|
||||
WorktreeExists: worktreeExists,
|
||||
SessionRunning: sessionRunning,
|
||||
},
|
||||
Title: issue.Title,
|
||||
CreatedAt: issue.CreatedAt,
|
||||
UpdatedAt: issue.UpdatedAt,
|
||||
}
|
||||
if details.HookBead == "" {
|
||||
details.HookBead = fields.HookBead
|
||||
}
|
||||
// Build CV summary with enhanced analytics
|
||||
cv := buildCVSummary(r.Path, rigName, polecatName, beadID, clonePath)
|
||||
|
||||
// Get CV beads (work history) - beads that were assigned to this polecat
|
||||
// Assignee format is "rig/name" (e.g., "gastown/Toast")
|
||||
assignee := fmt.Sprintf("%s/%s", rigName, polecatName)
|
||||
cvBeads, _ := bd.ListByAssignee(assignee)
|
||||
for _, cv := range cvBeads {
|
||||
if cv.ID != beadID && cv.Status == "closed" {
|
||||
details.CVBeads = append(details.CVBeads, cv.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON output
|
||||
// JSON output - include both identity details and CV
|
||||
if polecatIdentityShowJSON {
|
||||
output := struct {
|
||||
IdentityInfo
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
CV *CVSummary `json:"cv,omitempty"`
|
||||
}{
|
||||
IdentityInfo: IdentityInfo{
|
||||
Rig: rigName,
|
||||
Name: polecatName,
|
||||
BeadID: beadID,
|
||||
AgentState: fields.AgentState,
|
||||
HookBead: issue.HookBead,
|
||||
CleanupStatus: fields.CleanupStatus,
|
||||
WorktreeExists: worktreeExists,
|
||||
SessionRunning: sessionRunning,
|
||||
},
|
||||
Title: issue.Title,
|
||||
CreatedAt: issue.CreatedAt,
|
||||
UpdatedAt: issue.UpdatedAt,
|
||||
CV: cv,
|
||||
}
|
||||
if output.HookBead == "" {
|
||||
output.HookBead = fields.HookBead
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(details)
|
||||
return enc.Encode(output)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Identity: %s/%s", rigName, polecatName)))
|
||||
|
||||
fmt.Printf(" Bead ID: %s\n", details.BeadID)
|
||||
fmt.Printf(" Title: %s\n", details.Title)
|
||||
fmt.Printf("\n%s %s/%s\n", style.Bold.Render("Identity:"), rigName, polecatName)
|
||||
fmt.Printf(" Bead ID: %s\n", beadID)
|
||||
fmt.Printf(" Title: %s\n", issue.Title)
|
||||
|
||||
// Status
|
||||
sessionStr := style.Dim.Render("stopped")
|
||||
if details.SessionRunning {
|
||||
if sessionRunning {
|
||||
sessionStr = style.Success.Render("running")
|
||||
}
|
||||
fmt.Printf(" Session: %s\n", sessionStr)
|
||||
|
||||
worktreeStr := style.Dim.Render("no")
|
||||
if details.WorktreeExists {
|
||||
if worktreeExists {
|
||||
worktreeStr = style.Success.Render("yes")
|
||||
}
|
||||
fmt.Printf(" Worktree: %s\n", worktreeStr)
|
||||
|
||||
// Agent state
|
||||
stateStr := details.AgentState
|
||||
stateStr := fields.AgentState
|
||||
if stateStr == "" {
|
||||
stateStr = "unknown"
|
||||
}
|
||||
@@ -444,36 +478,71 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" Agent State: %s\n", stateStr)
|
||||
|
||||
// Hook
|
||||
if details.HookBead != "" {
|
||||
fmt.Printf(" Hook: %s\n", details.HookBead)
|
||||
hookBead := issue.HookBead
|
||||
if hookBead == "" {
|
||||
hookBead = fields.HookBead
|
||||
}
|
||||
if hookBead != "" {
|
||||
fmt.Printf(" Hook: %s\n", hookBead)
|
||||
} else {
|
||||
fmt.Printf(" Hook: %s\n", style.Dim.Render("(empty)"))
|
||||
}
|
||||
|
||||
// Cleanup status
|
||||
if details.CleanupStatus != "" {
|
||||
fmt.Printf(" Cleanup: %s\n", details.CleanupStatus)
|
||||
if fields.CleanupStatus != "" {
|
||||
fmt.Printf(" Cleanup: %s\n", fields.CleanupStatus)
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if details.CreatedAt != "" {
|
||||
fmt.Printf(" Created: %s\n", style.Dim.Render(details.CreatedAt))
|
||||
if issue.CreatedAt != "" {
|
||||
fmt.Printf(" Created: %s\n", style.Dim.Render(issue.CreatedAt))
|
||||
}
|
||||
if details.UpdatedAt != "" {
|
||||
fmt.Printf(" Updated: %s\n", style.Dim.Render(details.UpdatedAt))
|
||||
if issue.UpdatedAt != "" {
|
||||
fmt.Printf(" Updated: %s\n", style.Dim.Render(issue.UpdatedAt))
|
||||
}
|
||||
|
||||
// CV summary
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("CV (Work History)"))
|
||||
if len(details.CVBeads) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no completed work)"))
|
||||
} else {
|
||||
for _, cv := range details.CVBeads {
|
||||
fmt.Printf(" - %s\n", cv)
|
||||
// CV Summary section with enhanced analytics
|
||||
fmt.Printf("\n%s\n", style.Bold.Render("CV Summary:"))
|
||||
fmt.Printf(" Sessions: %d\n", cv.Sessions)
|
||||
fmt.Printf(" Issues completed: %s\n", style.Success.Render(fmt.Sprintf("%d", cv.IssuesCompleted)))
|
||||
fmt.Printf(" Issues failed: %s\n", formatCountStyled(cv.IssuesFailed, style.Error))
|
||||
fmt.Printf(" Issues abandoned: %s\n", formatCountStyled(cv.IssuesAbandoned, style.Warning))
|
||||
|
||||
// Language stats
|
||||
if len(cv.Languages) > 0 {
|
||||
fmt.Printf("\n %s %s\n", style.Bold.Render("Languages:"), formatLanguageStats(cv.Languages))
|
||||
}
|
||||
|
||||
// Work type stats
|
||||
if len(cv.WorkTypes) > 0 {
|
||||
fmt.Printf(" %s %s\n", style.Bold.Render("Types:"), formatWorkTypeStats(cv.WorkTypes))
|
||||
}
|
||||
|
||||
// Performance metrics
|
||||
if cv.AvgCompletionMin > 0 {
|
||||
fmt.Printf("\n Avg completion time: %d minutes\n", cv.AvgCompletionMin)
|
||||
}
|
||||
if cv.FirstPassRate > 0 {
|
||||
fmt.Printf(" First-pass success: %.0f%%\n", cv.FirstPassRate*100)
|
||||
}
|
||||
|
||||
// Recent work
|
||||
if len(cv.RecentWork) > 0 {
|
||||
fmt.Printf("\n%s\n", style.Bold.Render("Recent work:"))
|
||||
for _, work := range cv.RecentWork {
|
||||
typeStr := ""
|
||||
if work.Type != "" {
|
||||
typeStr = work.Type + ": "
|
||||
}
|
||||
title := work.Title
|
||||
if len(title) > 40 {
|
||||
title = title[:37] + "..."
|
||||
}
|
||||
fmt.Printf(" %-10s %s%s %s\n", work.ID, typeStr, title, style.Dim.Render(work.Ago))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -494,8 +563,8 @@ func runPolecatIdentityRename(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
bd := beads.New(r.Path)
|
||||
oldBeadID := beads.PolecatBeadID(rigName, oldName)
|
||||
newBeadID := beads.PolecatBeadID(rigName, newName)
|
||||
oldBeadID := polecatBeadIDForRig(r, rigName, oldName)
|
||||
newBeadID := polecatBeadIDForRig(r, rigName, newName)
|
||||
|
||||
// Check old identity exists
|
||||
oldIssue, oldFields, err := bd.GetAgentBead(oldBeadID)
|
||||
@@ -562,7 +631,7 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
bd := beads.New(r.Path)
|
||||
beadID := beads.PolecatBeadID(rigName, polecatName)
|
||||
beadID := polecatBeadIDForRig(r, rigName, polecatName)
|
||||
|
||||
// Check identity exists
|
||||
issue, fields, err := bd.GetAgentBead(beadID)
|
||||
@@ -633,3 +702,374 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("%s Removed identity bead: %s\n", style.SuccessPrefix, beadID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCVSummary constructs the CV summary for a polecat.
|
||||
// Returns a partial CV on errors rather than failing - CV data is best-effort.
|
||||
func buildCVSummary(rigPath, rigName, polecatName, identityBeadID, clonePath string) *CVSummary {
|
||||
cv := &CVSummary{
|
||||
Identity: identityBeadID,
|
||||
Languages: make(map[string]int),
|
||||
WorkTypes: make(map[string]int),
|
||||
RecentWork: []RecentWorkItem{},
|
||||
}
|
||||
|
||||
// Use clonePath for beads queries (has proper redirect setup)
|
||||
// Fall back to rigPath if clonePath is empty
|
||||
beadsQueryPath := clonePath
|
||||
if beadsQueryPath == "" {
|
||||
beadsQueryPath = rigPath
|
||||
}
|
||||
|
||||
// Get agent bead info for creation date
|
||||
bd := beads.New(beadsQueryPath)
|
||||
agentBead, _, err := bd.GetAgentBead(identityBeadID)
|
||||
if err == nil && agentBead != nil {
|
||||
if agentBead.CreatedAt != "" && len(agentBead.CreatedAt) >= 10 {
|
||||
cv.Created = agentBead.CreatedAt[:10] // Just the date part
|
||||
}
|
||||
}
|
||||
|
||||
// Count sessions from checkpoint files (session history)
|
||||
cv.Sessions = countPolecatSessions(rigPath, polecatName)
|
||||
|
||||
// Query completed issues assigned to this polecat
|
||||
assignee := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||
completedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "closed")
|
||||
if err == nil {
|
||||
cv.IssuesCompleted = len(completedIssues)
|
||||
|
||||
// Extract work types from issue titles/types
|
||||
for _, issue := range completedIssues {
|
||||
workType := extractWorkType(issue.Title, issue.Type)
|
||||
if workType != "" {
|
||||
cv.WorkTypes[workType]++
|
||||
}
|
||||
|
||||
// Add to recent work (limit to 5)
|
||||
if len(cv.RecentWork) < 5 {
|
||||
ago := formatRelativeTimeCV(issue.Updated)
|
||||
cv.RecentWork = append(cv.RecentWork, RecentWorkItem{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Type: workType,
|
||||
Completed: issue.Updated,
|
||||
Ago: ago,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query failed/escalated issues
|
||||
escalatedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "escalated")
|
||||
if err == nil {
|
||||
cv.IssuesFailed = len(escalatedIssues)
|
||||
}
|
||||
|
||||
// Query abandoned issues (deferred)
|
||||
deferredIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "deferred")
|
||||
if err == nil {
|
||||
cv.IssuesAbandoned = len(deferredIssues)
|
||||
}
|
||||
|
||||
// Get language stats from git commits
|
||||
if clonePath != "" {
|
||||
langStats := getLanguageStats(clonePath)
|
||||
if len(langStats) > 0 {
|
||||
cv.Languages = langStats
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate first-pass success rate
|
||||
total := cv.IssuesCompleted + cv.IssuesFailed + cv.IssuesAbandoned
|
||||
if total > 0 {
|
||||
cv.FirstPassRate = float64(cv.IssuesCompleted) / float64(total)
|
||||
}
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
// IssueInfo holds basic issue information for CV queries.
|
||||
type IssueInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"issue_type"`
|
||||
Status string `json:"status"`
|
||||
Updated string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// queryAssignedIssues queries beads for issues assigned to a specific agent.
|
||||
func queryAssignedIssues(rigPath, assignee, status string) ([]IssueInfo, error) {
|
||||
// Use bd list with filters
|
||||
args := []string{"list", "--assignee=" + assignee, "--json"}
|
||||
if status != "" {
|
||||
args = append(args, "--status="+status)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Dir = rigPath
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []IssueInfo{}, nil
|
||||
}
|
||||
|
||||
var issues []IssueInfo
|
||||
if err := json.Unmarshal(out, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort by updated date (most recent first)
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].Updated > issues[j].Updated
|
||||
})
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// extractWorkType extracts the work type from issue title or type.
|
||||
func extractWorkType(title, issueType string) string {
|
||||
// Check explicit issue type first
|
||||
switch issueType {
|
||||
case "bug":
|
||||
return "fix"
|
||||
case "task", "feature":
|
||||
return "feat"
|
||||
case "epic":
|
||||
return "epic"
|
||||
}
|
||||
|
||||
// Try to extract from conventional commit-style title
|
||||
title = strings.ToLower(title)
|
||||
prefixes := []string{"feat:", "fix:", "refactor:", "docs:", "test:", "chore:", "style:", "perf:"}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(title, prefix) {
|
||||
return strings.TrimSuffix(prefix, ":")
|
||||
}
|
||||
}
|
||||
|
||||
// Try to infer from keywords
|
||||
if strings.Contains(title, "fix") || strings.Contains(title, "bug") {
|
||||
return "fix"
|
||||
}
|
||||
if strings.Contains(title, "add") || strings.Contains(title, "implement") || strings.Contains(title, "create") {
|
||||
return "feat"
|
||||
}
|
||||
if strings.Contains(title, "refactor") || strings.Contains(title, "cleanup") {
|
||||
return "refactor"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getLanguageStats analyzes git history to determine language distribution.
|
||||
func getLanguageStats(clonePath string) map[string]int {
|
||||
stats := make(map[string]int)
|
||||
|
||||
// Get list of files changed in commits by this author
|
||||
// We use git log with --name-only to get file names
|
||||
cmd := exec.Command("git", "log", "--name-only", "--pretty=format:", "--diff-filter=ACMR", "-100")
|
||||
cmd.Dir = clonePath
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return stats
|
||||
}
|
||||
|
||||
// Count file extensions
|
||||
extCount := make(map[string]int)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(line)
|
||||
if ext != "" {
|
||||
extCount[ext]++
|
||||
}
|
||||
}
|
||||
|
||||
// Map extensions to languages
|
||||
extToLang := map[string]string{
|
||||
".go": "Go",
|
||||
".ts": "TypeScript",
|
||||
".tsx": "TypeScript",
|
||||
".js": "JavaScript",
|
||||
".jsx": "JavaScript",
|
||||
".py": "Python",
|
||||
".rs": "Rust",
|
||||
".java": "Java",
|
||||
".rb": "Ruby",
|
||||
".c": "C",
|
||||
".cpp": "C++",
|
||||
".h": "C",
|
||||
".hpp": "C++",
|
||||
".cs": "C#",
|
||||
".swift": "Swift",
|
||||
".kt": "Kotlin",
|
||||
".scala": "Scala",
|
||||
".php": "PHP",
|
||||
".sh": "Shell",
|
||||
".bash": "Shell",
|
||||
".zsh": "Shell",
|
||||
".md": "Markdown",
|
||||
".yaml": "YAML",
|
||||
".yml": "YAML",
|
||||
".json": "JSON",
|
||||
".toml": "TOML",
|
||||
".sql": "SQL",
|
||||
".html": "HTML",
|
||||
".css": "CSS",
|
||||
".scss": "SCSS",
|
||||
}
|
||||
|
||||
for ext, count := range extCount {
|
||||
if lang, ok := extToLang[ext]; ok {
|
||||
stats[lang] += count
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// formatRelativeTimeCV returns a human-readable relative time string for CV display.
|
||||
func formatRelativeTimeCV(timestamp string) string {
|
||||
// Try RFC3339 format with timezone (ISO 8601)
|
||||
t, err := time.Parse(time.RFC3339, timestamp)
|
||||
if err != nil {
|
||||
// Try RFC3339Nano
|
||||
t, err = time.Parse(time.RFC3339Nano, timestamp)
|
||||
if err != nil {
|
||||
// Try without timezone
|
||||
t, err = time.Parse("2006-01-02T15:04:05", timestamp)
|
||||
if err != nil {
|
||||
// Try alternative format
|
||||
t, err = time.Parse("2006-01-02 15:04:05", timestamp)
|
||||
if err != nil {
|
||||
// Try date only
|
||||
t, err = time.Parse("2006-01-02", timestamp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
mins := int(d.Minutes())
|
||||
if mins == 1 {
|
||||
return "1m ago"
|
||||
}
|
||||
return fmt.Sprintf("%dm ago", mins)
|
||||
case d < 24*time.Hour:
|
||||
hours := int(d.Hours())
|
||||
if hours == 1 {
|
||||
return "1h ago"
|
||||
}
|
||||
return fmt.Sprintf("%dh ago", hours)
|
||||
case d < 7*24*time.Hour:
|
||||
days := int(d.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1d ago"
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", days)
|
||||
default:
|
||||
weeks := int(d.Hours() / 24 / 7)
|
||||
if weeks == 1 {
|
||||
return "1w ago"
|
||||
}
|
||||
return fmt.Sprintf("%dw ago", weeks)
|
||||
}
|
||||
}
|
||||
|
||||
// formatCountStyled formats a count with appropriate styling using lipgloss.Style.
|
||||
func formatCountStyled(count int, s lipgloss.Style) string {
|
||||
if count == 0 {
|
||||
return style.Dim.Render("0")
|
||||
}
|
||||
return s.Render(strconv.Itoa(count))
|
||||
}
|
||||
|
||||
// countPolecatSessions counts the number of sessions from checkpoint files.
|
||||
func countPolecatSessions(rigPath, polecatName string) int {
|
||||
// Look for checkpoint files in the polecat's directory
|
||||
checkpointDir := filepath.Join(rigPath, "polecats", polecatName, ".checkpoints")
|
||||
entries, err := os.ReadDir(checkpointDir)
|
||||
if err != nil {
|
||||
// Also check at rig level
|
||||
checkpointDir = filepath.Join(rigPath, ".checkpoints")
|
||||
entries, err = os.ReadDir(checkpointDir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Count checkpoint files that contain this polecat's name
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.Contains(entry.Name(), polecatName) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// If no checkpoint files found, return at least 1 if polecat exists
|
||||
if count == 0 {
|
||||
return 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// formatLanguageStats formats language statistics for display.
|
||||
func formatLanguageStats(langs map[string]int) string {
|
||||
// Sort by count descending
|
||||
type langCount struct {
|
||||
lang string
|
||||
count int
|
||||
}
|
||||
var sorted []langCount
|
||||
for lang, count := range langs {
|
||||
sorted = append(sorted, langCount{lang, count})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].count > sorted[j].count
|
||||
})
|
||||
|
||||
// Format top languages
|
||||
var parts []string
|
||||
for i, lc := range sorted {
|
||||
if i >= 3 { // Show top 3
|
||||
break
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", lc.lang, lc.count))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// formatWorkTypeStats formats work type statistics for display.
|
||||
func formatWorkTypeStats(types map[string]int) string {
|
||||
// Sort by count descending
|
||||
type typeCount struct {
|
||||
typ string
|
||||
count int
|
||||
}
|
||||
var sorted []typeCount
|
||||
for typ, count := range types {
|
||||
sorted = append(sorted, typeCount{typ, count})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].count > sorted[j].count
|
||||
})
|
||||
|
||||
// Format all types
|
||||
var parts []string
|
||||
for _, tc := range sorted {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", tc.typ, tc.count))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
@@ -64,9 +64,10 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
||||
return nil, fmt.Errorf("rig '%s' not found", rigName)
|
||||
}
|
||||
|
||||
// Get polecat manager
|
||||
// Get polecat manager (with tmux for session-aware allocation)
|
||||
polecatGit := git.NewGit(r.Path)
|
||||
polecatMgr := polecat.NewManager(r, polecatGit)
|
||||
t := tmux.NewTmux()
|
||||
polecatMgr := polecat.NewManager(r, polecatGit, t)
|
||||
|
||||
// Allocate a new polecat name
|
||||
polecatName, err := polecatMgr.AllocateName()
|
||||
@@ -124,8 +125,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
|
||||
fmt.Printf("Using account: %s\n", accountHandle)
|
||||
}
|
||||
|
||||
// Start session
|
||||
t := tmux.NewTmux()
|
||||
// Start session (reuse tmux from manager)
|
||||
polecatSessMgr := polecat.NewSessionManager(t, r)
|
||||
|
||||
// Check if already running
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
var primeHookMode bool
|
||||
var primeDryRun bool
|
||||
var primeState bool
|
||||
var primeStateJSON bool
|
||||
var primeExplain bool
|
||||
|
||||
// Role represents a detected agent role.
|
||||
@@ -72,6 +73,8 @@ func init() {
|
||||
"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(&primeStateJSON, "json", false,
|
||||
"Output state as JSON (requires --state)")
|
||||
primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
|
||||
"Show why each section was included")
|
||||
rootCmd.AddCommand(primeCmd)
|
||||
@@ -82,9 +85,13 @@ func init() {
|
||||
type RoleContext = RoleInfo
|
||||
|
||||
func runPrime(cmd *cobra.Command, args []string) error {
|
||||
// Validate flag combinations: --state is exclusive
|
||||
// Validate flag combinations: --state is exclusive (except --json)
|
||||
if primeState && (primeHookMode || primeDryRun || primeExplain) {
|
||||
return fmt.Errorf("--state cannot be combined with other flags")
|
||||
return fmt.Errorf("--state cannot be combined with other flags (except --json)")
|
||||
}
|
||||
// --json requires --state
|
||||
if primeStateJSON && !primeState {
|
||||
return fmt.Errorf("--json requires --state")
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
@@ -170,7 +177,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// --state mode: output state only and exit
|
||||
if primeState {
|
||||
outputState(ctx)
|
||||
outputState(ctx, primeStateJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -402,9 +403,22 @@ func outputHandoffWarning(prevSession string) {
|
||||
}
|
||||
|
||||
// outputState outputs only the session state (for --state flag).
|
||||
func outputState(ctx RoleContext) {
|
||||
// If jsonOutput is true, outputs JSON format instead of key:value.
|
||||
func outputState(ctx RoleContext, jsonOutput bool) {
|
||||
state := detectSessionState(ctx)
|
||||
|
||||
if jsonOutput {
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
// Fall back to plain text on error
|
||||
fmt.Printf("state: %s\n", state.State)
|
||||
fmt.Printf("role: %s\n", state.Role)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("state: %s\n", state.State)
|
||||
fmt.Printf("role: %s\n", state.Role)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user