Compare commits
315 Commits
v0.2.6
...
nux/poleca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123d0b2bed | ||
|
|
6be7fdd76c | ||
|
|
f0014bb21a | ||
| c9f844b477 | |||
| 2a639ff999 | |||
| eed8941126 | |||
| 9f9b2376ea | |||
| 7fd073810d | |||
|
|
b158ff27c2 | ||
|
|
5cc2995345 | ||
|
|
e57297cb1b | ||
|
|
dff6c3fb3c | ||
|
|
fb4c415127 | ||
|
|
b612df0463 | ||
|
|
785d9adfef | ||
|
|
3d7b109395 | ||
|
|
b14835b140 | ||
|
|
35abe21c50 | ||
|
|
405d40ee4b | ||
|
|
748fa73931 | ||
|
|
1dc31024ca | ||
|
|
94c2d71c13 | ||
|
|
02390251fc | ||
|
|
0dfb0be368 | ||
|
|
1feb48dd11 | ||
|
|
58d5226f30 | ||
|
|
c42b5db7ab | ||
|
|
2119841d57 | ||
|
|
2514507a49 | ||
|
|
e4ebd0784a | ||
|
|
1e97d1e637 | ||
|
|
7e5c3dd695 | ||
|
|
0cdcd0a20b | ||
|
|
aba0a5069c | ||
|
|
a8bedd2172 | ||
|
|
b9f5797b9e | ||
|
|
5791cd7e34 | ||
|
|
3931d10af3 | ||
|
|
d67aa0212c | ||
|
|
b333bf8146 | ||
|
|
7016b33b39 | ||
|
|
1a0f2d6b3b | ||
|
|
39b1c11bb6 | ||
|
|
f6fab3afad | ||
|
|
40cc4c9335 | ||
|
|
82079f9715 | ||
|
|
53fd6bad33 | ||
|
|
6e2169de7f | ||
|
|
d0e49a216a | ||
|
|
6616a4726c | ||
|
|
f00b0254f2 | ||
|
|
e12aa45dd6 | ||
|
|
9f06eb94c4 | ||
|
|
7a2090bb15 | ||
|
|
a5bbe24444 | ||
|
|
87f9a7cfd1 | ||
|
|
78001d2c01 | ||
|
|
d96b53e173 | ||
|
|
fa1f812ce9 | ||
|
|
dfd4199396 | ||
|
|
77126283dd | ||
|
|
afc1ff04b1 | ||
|
|
987502ebb3 | ||
|
|
3588dbc5e4 | ||
|
|
4fbe00e224 | ||
|
|
3afd1a1dcd | ||
|
|
535647cefc | ||
|
|
3c44e2202d | ||
|
|
b2b9cbc836 | ||
|
|
035b7775ea | ||
|
|
a8be623eeb | ||
|
|
63a30ce548 | ||
|
|
1b036aadf5 | ||
|
|
9de8859be0 | ||
|
|
560431d2f5 | ||
|
|
aef99753df | ||
|
|
d610d444d7 | ||
|
|
cd347dfdf9 | ||
|
|
d0a1e165e5 | ||
|
|
2b56ee2545 | ||
|
|
9b412707ab | ||
|
|
45951c0fad | ||
|
|
9caf5302d4 | ||
|
|
78ca8bd5bf | ||
|
|
44d5b4fdd2 | ||
|
|
77ac332a41 | ||
|
|
b71188d0b4 | ||
|
|
6bfe61f796 | ||
|
|
2aadb0165b | ||
|
|
05ea767149 | ||
|
|
f4072e58cc | ||
|
|
7c2f9687ec | ||
|
|
e591f2ae25 | ||
|
|
0a6b0b892f | ||
|
|
6a3780d282 | ||
|
|
8357a94cae | ||
|
|
8b393b7c39 | ||
|
|
195ecf7578 | ||
|
|
5218102f49 | ||
|
|
126ec84bb3 | ||
|
|
9a91a1b94f | ||
|
|
f82477d6a6 | ||
|
|
4dd11d4ffa | ||
|
|
7564cd5997 | ||
|
|
5a14053a6b | ||
|
|
d2f7dbd3ae | ||
|
|
65c1fad8ce | ||
|
|
0db2bda6e6 | ||
|
|
48ace2cbf3 | ||
|
|
3d5a66f850 | ||
|
|
b8a679c30c | ||
|
|
183a0d7d8d | ||
|
|
477c28c9d1 | ||
|
|
f58a516b7b | ||
|
|
fd61259336 | ||
|
|
6a22b47ef6 | ||
|
|
5c45b4438a | ||
|
|
08cee416a4 | ||
|
|
2fe23b7be5 | ||
|
|
6c5c671595 | ||
|
|
371074cc67 | ||
|
|
6966eb4c28 | ||
|
|
55a3b9858a | ||
|
|
e59955a580 | ||
|
|
08bc632a03 | ||
|
|
a610283078 | ||
|
|
544cacf36d | ||
|
|
b8eb936219 | ||
|
|
dcf7b81011 | ||
|
|
37f465bde5 | ||
|
|
b73ee91970 | ||
|
|
b41a5ef243 | ||
|
|
4eb3915ce9 | ||
|
|
b28c25b8a2 | ||
|
|
2333b38ecf | ||
|
|
6f9bfec60f | ||
|
|
7421d1554d | ||
|
|
e2bd5ef76c | ||
|
|
61e9a36dfd | ||
|
|
8c200d4a83 | ||
|
|
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.
|
||||
|
||||
@@ -15,6 +15,8 @@ Each leg examines the code from a different perspective. Findings are
|
||||
collected and synthesized into a prioritized, actionable review.
|
||||
|
||||
## Legs (parallel execution)
|
||||
|
||||
### Analysis Legs (read and analyze code)
|
||||
- **correctness**: Logic errors, bugs, edge cases
|
||||
- **performance**: Bottlenecks, efficiency issues
|
||||
- **security**: Vulnerabilities, OWASP concerns
|
||||
@@ -23,6 +25,16 @@ collected and synthesized into a prioritized, actionable review.
|
||||
- **style**: Convention compliance, consistency
|
||||
- **smells**: Anti-patterns, technical debt
|
||||
|
||||
### Verification Legs (check implementation quality)
|
||||
- **wiring**: Installed-but-not-wired gaps (deps added but not used)
|
||||
- **commit-discipline**: Commit quality and atomicity
|
||||
- **test-quality**: Test meaningfulness, not just coverage
|
||||
|
||||
## Presets
|
||||
- **gate**: Light review for automatic flow (wiring, security, smells, test-quality)
|
||||
- **full**: Comprehensive review (all 10 legs)
|
||||
- **custom**: Select specific legs via --legs flag
|
||||
|
||||
## Execution Model
|
||||
1. Each leg spawns as a separate polecat
|
||||
2. Polecats work in parallel
|
||||
@@ -293,6 +305,125 @@ Review the code for code smells and anti-patterns.
|
||||
- Is technical debt being added or paid down?
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# VERIFICATION LEGS - Check implementation quality (not just code analysis)
|
||||
# ============================================================================
|
||||
|
||||
[[legs]]
|
||||
id = "wiring"
|
||||
title = "Wiring Review"
|
||||
focus = "Installed-but-not-wired gaps"
|
||||
description = """
|
||||
Detect dependencies, configs, or libraries that were added but not actually used.
|
||||
|
||||
This catches subtle bugs where the implementer THINKS they integrated something,
|
||||
but the old implementation is still being used.
|
||||
|
||||
**Look for:**
|
||||
- New dependency in manifest but never imported
|
||||
- Go: module in go.mod but no import
|
||||
- Rust: crate in Cargo.toml but no `use`
|
||||
- Node: package in package.json but no import/require
|
||||
|
||||
- SDK added but old implementation remains
|
||||
- Added Sentry but still using console.error for errors
|
||||
- Added Zod but still using manual typeof validation
|
||||
|
||||
- Config/env var defined but never loaded
|
||||
- New .env var that isn't accessed in code
|
||||
|
||||
**Questions to answer:**
|
||||
- Is every new dependency actually used?
|
||||
- Are there old patterns that should have been replaced?
|
||||
- Is there dead config that suggests incomplete migration?
|
||||
"""
|
||||
|
||||
[[legs]]
|
||||
id = "commit-discipline"
|
||||
title = "Commit Discipline Review"
|
||||
focus = "Commit quality and atomicity"
|
||||
description = """
|
||||
Review commit history for good practices.
|
||||
|
||||
Good commits make the codebase easier to understand, bisect, and revert.
|
||||
|
||||
**Look for:**
|
||||
- Giant "WIP" or "fix" commits
|
||||
- Multiple unrelated changes in one commit
|
||||
- Commits that touch 20+ files across different features
|
||||
|
||||
- Poor commit messages
|
||||
- "stuff", "update", "asdf", "fix"
|
||||
- No context about WHY the change was made
|
||||
|
||||
- Unatomic commits
|
||||
- Feature + refactor + bugfix in same commit
|
||||
- Should be separable logical units
|
||||
|
||||
- Missing type prefixes (if project uses conventional commits)
|
||||
- feat:, fix:, refactor:, test:, docs:, chore:
|
||||
|
||||
**Questions to answer:**
|
||||
- Could this history be bisected effectively?
|
||||
- Would a reviewer understand the progression?
|
||||
- Are commits atomic (one logical change each)?
|
||||
"""
|
||||
|
||||
[[legs]]
|
||||
id = "test-quality"
|
||||
title = "Test Quality Review"
|
||||
focus = "Test meaningfulness, not just coverage"
|
||||
description = """
|
||||
Verify tests are actually testing something meaningful.
|
||||
|
||||
Coverage numbers lie. A test that can't fail provides no value.
|
||||
|
||||
**Look for:**
|
||||
- Weak assertions
|
||||
- Only checking != nil / !== null / is not None
|
||||
- Using .is_ok() without checking the value
|
||||
- assertTrue(true) or equivalent
|
||||
|
||||
- Missing negative test cases
|
||||
- Happy path only, no error cases
|
||||
- No boundary testing
|
||||
- No invalid input testing
|
||||
|
||||
- Tests that can't fail
|
||||
- Mocked so heavily the test is meaningless
|
||||
- Testing implementation details, not behavior
|
||||
|
||||
- Flaky test indicators
|
||||
- Sleep/delay in tests
|
||||
- Time-dependent assertions
|
||||
|
||||
**Questions to answer:**
|
||||
- Do these tests actually verify behavior?
|
||||
- Would a bug in the implementation cause a test failure?
|
||||
- Are edge cases and error paths tested?
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# PRESETS - Configurable leg selection
|
||||
# ============================================================================
|
||||
|
||||
[presets]
|
||||
[presets.gate]
|
||||
description = "Light review for automatic flow - fast, focused on blockers"
|
||||
legs = ["wiring", "security", "smells", "test-quality"]
|
||||
|
||||
[presets.full]
|
||||
description = "Comprehensive review - all legs, for major features"
|
||||
legs = ["correctness", "performance", "security", "elegance", "resilience", "style", "smells", "wiring", "commit-discipline", "test-quality"]
|
||||
|
||||
[presets.security-focused]
|
||||
description = "Security-heavy review for sensitive changes"
|
||||
legs = ["security", "resilience", "correctness", "wiring"]
|
||||
|
||||
[presets.refactor]
|
||||
description = "Review focused on code quality during refactoring"
|
||||
legs = ["elegance", "smells", "style", "commit-discipline"]
|
||||
|
||||
# Synthesis step - combines all leg outputs
|
||||
[synthesis]
|
||||
title = "Review Synthesis"
|
||||
@@ -310,10 +441,13 @@ A synthesized review at: {{.output.directory}}/{{.output.synthesis}}
|
||||
2. **Critical Issues** - P0 items from all legs, deduplicated
|
||||
3. **Major Issues** - P1 items, grouped by theme
|
||||
4. **Minor Issues** - P2 items, briefly listed
|
||||
5. **Positive Observations** - What's done well
|
||||
6. **Recommendations** - Actionable next steps
|
||||
5. **Wiring Gaps** - Dependencies added but not used (from wiring leg)
|
||||
6. **Commit Quality** - Notes on commit discipline
|
||||
7. **Test Quality** - Assessment of test meaningfulness
|
||||
8. **Positive Observations** - What's done well
|
||||
9. **Recommendations** - Actionable next steps
|
||||
|
||||
Deduplicate issues found by multiple legs (note which legs found them).
|
||||
Prioritize by impact and effort. Be actionable.
|
||||
"""
|
||||
depends_on = ["correctness", "performance", "security", "elegance", "resilience", "style", "smells"]
|
||||
depends_on = ["correctness", "performance", "security", "elegance", "resilience", "style", "smells", "wiring", "commit-discipline", "test-quality"]
|
||||
|
||||
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_ROOT/gastown/crew/* $GT_ROOT/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
|
||||
"""
|
||||
@@ -47,7 +47,7 @@ bd show hq-deacon 2>/dev/null
|
||||
gt feed --since 10m --plain | head -20
|
||||
|
||||
# Recent wisps (operational state)
|
||||
ls -lt ~/gt/.beads-wisp/*.wisp.json 2>/dev/null | head -5
|
||||
ls -lt $GT_ROOT/.beads-wisp/*.wisp.json 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
**Step 4: Check Deacon mail**
|
||||
@@ -221,7 +221,7 @@ Then exit. The next daemon tick will spawn a fresh Boot.
|
||||
**Update status file**
|
||||
```bash
|
||||
# The gt boot command handles this automatically
|
||||
# Status is written to ~/gt/deacon/dogs/boot/.boot-status.json
|
||||
# Status is written to $GT_ROOT/deacon/dogs/boot/.boot-status.json
|
||||
```
|
||||
|
||||
Boot is ephemeral by design. Each instance runs fresh.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -444,7 +480,7 @@ needs = ["zombie-scan"]
|
||||
description = """
|
||||
Execute registered plugins.
|
||||
|
||||
Scan ~/gt/plugins/ for plugin directories. Each plugin has a plugin.md with TOML frontmatter defining its gate (when to run) and instructions (what to do).
|
||||
Scan $GT_ROOT/plugins/ for plugin directories. Each plugin has a plugin.md with TOML frontmatter defining its gate (when to run) and instructions (what to do).
|
||||
|
||||
See docs/deacon-plugins.md for full documentation.
|
||||
|
||||
@@ -461,7 +497,7 @@ For each plugin:
|
||||
|
||||
Plugins marked parallel: true can run concurrently using Task tool subagents. Sequential plugins run one at a time in directory order.
|
||||
|
||||
Skip this step if ~/gt/plugins/ does not exist or is empty."""
|
||||
Skip this step if $GT_ROOT/plugins/ does not exist or is empty."""
|
||||
|
||||
[[steps]]
|
||||
id = "dog-pool-maintenance"
|
||||
@@ -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.
|
||||
|
||||
@@ -565,59 +665,84 @@ Skip dispatch - system is healthy.
|
||||
|
||||
[[steps]]
|
||||
id = "costs-digest"
|
||||
title = "Aggregate daily costs"
|
||||
title = "Aggregate daily costs [DISABLED]"
|
||||
needs = ["session-gc"]
|
||||
description = """
|
||||
**DAILY DIGEST** - Aggregate yesterday's session cost wisps.
|
||||
**⚠️ DISABLED** - Skip this step entirely.
|
||||
|
||||
Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid
|
||||
log-in-database pollution. This step aggregates them into a permanent daily
|
||||
"Cost Report YYYY-MM-DD" bead for audit purposes.
|
||||
Cost tracking is temporarily disabled because Claude Code does not expose
|
||||
session costs in a way that can be captured programmatically.
|
||||
|
||||
**Why disabled:**
|
||||
- The `gt costs` command uses tmux capture-pane to find costs
|
||||
- Claude Code displays costs in the TUI status bar, not in scrollback
|
||||
- All sessions show $0.00 because capture-pane can't see TUI chrome
|
||||
- The infrastructure is sound but has no data source
|
||||
|
||||
**What we need from Claude Code:**
|
||||
- Stop hook env var (e.g., `$CLAUDE_SESSION_COST`)
|
||||
- Or queryable file/API endpoint
|
||||
|
||||
**Re-enable when:** Claude Code exposes cost data via API or environment.
|
||||
|
||||
See: GH#24, gt-7awfj
|
||||
|
||||
**Exit criteria:** Skip this step - proceed to next."""
|
||||
|
||||
[[steps]]
|
||||
id = "patrol-digest"
|
||||
title = "Aggregate daily patrol digests"
|
||||
needs = ["costs-digest"]
|
||||
description = """
|
||||
**DAILY DIGEST** - Aggregate yesterday's patrol cycle digests.
|
||||
|
||||
Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests
|
||||
to avoid JSONL pollution. This step aggregates them into a single permanent
|
||||
"Patrol Report YYYY-MM-DD" bead for audit purposes.
|
||||
|
||||
**Step 1: Check if digest is needed**
|
||||
```bash
|
||||
# Preview yesterday's costs (dry run)
|
||||
gt costs digest --yesterday --dry-run
|
||||
# Preview yesterday's patrol digests (dry run)
|
||||
gt patrol digest --yesterday --dry-run
|
||||
```
|
||||
|
||||
If output shows "No session cost wisps found", skip to Step 3.
|
||||
If output shows "No patrol digests found", skip to Step 3.
|
||||
|
||||
**Step 2: Create the digest**
|
||||
```bash
|
||||
gt costs digest --yesterday
|
||||
gt patrol digest --yesterday
|
||||
```
|
||||
|
||||
This:
|
||||
- Queries all session.ended wisps from yesterday
|
||||
- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data
|
||||
- Deletes the source wisps
|
||||
- Queries all ephemeral patrol digests from yesterday
|
||||
- Creates a single "Patrol Report YYYY-MM-DD" bead with aggregated data
|
||||
- Deletes the source digests
|
||||
|
||||
**Step 3: Verify**
|
||||
The digest appears in `gt costs --week` queries.
|
||||
Daily digests preserve audit trail without per-session pollution.
|
||||
Daily patrol digests preserve audit trail without per-cycle pollution.
|
||||
|
||||
**Timing**: Run once per morning patrol cycle. The --yesterday flag ensures
|
||||
we don't try to digest today's incomplete data.
|
||||
|
||||
**Exit criteria:** Yesterday's costs digested (or no wisps to digest)."""
|
||||
**Exit criteria:** Yesterday's patrol digests aggregated (or none to aggregate)."""
|
||||
|
||||
[[steps]]
|
||||
id = "log-maintenance"
|
||||
title = "Rotate logs and prune state"
|
||||
needs = ["costs-digest"]
|
||||
needs = ["patrol-digest"]
|
||||
description = """
|
||||
Maintain daemon logs and state files.
|
||||
|
||||
**Step 1: Check daemon.log size**
|
||||
```bash
|
||||
# Get log file size
|
||||
ls -la ~/.beads/daemon*.log 2>/dev/null || ls -la ~/gt/.beads/daemon*.log 2>/dev/null
|
||||
ls -la ~/.beads/daemon*.log 2>/dev/null || ls -la $GT_ROOT/.beads/daemon*.log 2>/dev/null
|
||||
```
|
||||
|
||||
If daemon.log exceeds 10MB:
|
||||
```bash
|
||||
# Rotate with date suffix and gzip
|
||||
LOGFILE="$HOME/gt/.beads/daemon.log"
|
||||
LOGFILE="$GT_ROOT/.beads/daemon.log"
|
||||
if [ -f "$LOGFILE" ] && [ $(stat -f%z "$LOGFILE" 2>/dev/null || stat -c%s "$LOGFILE") -gt 10485760 ]; then
|
||||
DATE=$(date +%Y-%m-%dT%H-%M-%S)
|
||||
mv "$LOGFILE" "${LOGFILE%.log}-${DATE}.log"
|
||||
@@ -629,7 +754,7 @@ fi
|
||||
|
||||
Clean up daemon logs older than 7 days:
|
||||
```bash
|
||||
find ~/gt/.beads/ -name "daemon-*.log.gz" -mtime +7 -delete
|
||||
find $GT_ROOT/.beads/ -name "daemon-*.log.gz" -mtime +7 -delete
|
||||
```
|
||||
|
||||
**Step 3: Prune state.json of dead sessions**
|
||||
|
||||
@@ -8,7 +8,7 @@ goroutine (NOT a Claude session) that runs the interrogation state machine.
|
||||
|
||||
Dogs are lightweight workers in Boot's pool (see dog-pool-architecture.md):
|
||||
- Fixed pool of 5 goroutines (configurable via GT_DOG_POOL_SIZE)
|
||||
- State persisted to ~/gt/deacon/dogs/active/<id>.json
|
||||
- State persisted to $GT_ROOT/deacon/dogs/active/<id>.json
|
||||
- Recovery on Boot restart via orphan state files
|
||||
|
||||
## State Machine
|
||||
@@ -151,7 +151,7 @@ If target doesn't exist:
|
||||
- Skip to EPITAPH with outcome=already_dead
|
||||
|
||||
**3. Initialize state file:**
|
||||
Write initial state to ~/gt/deacon/dogs/active/{dog-id}.json
|
||||
Write initial state to $GT_ROOT/deacon/dogs/active/{dog-id}.json
|
||||
|
||||
**4. Set initial attempt counter:**
|
||||
attempt = 1
|
||||
@@ -477,11 +477,11 @@ bd close {warrant_id} --reason "{epitaph_summary}"
|
||||
|
||||
**3. Move state file to completed:**
|
||||
```bash
|
||||
mv ~/gt/deacon/dogs/active/{dog-id}.json ~/gt/deacon/dogs/completed/
|
||||
mv $GT_ROOT/deacon/dogs/active/{dog-id}.json $GT_ROOT/deacon/dogs/completed/
|
||||
```
|
||||
|
||||
**4. Report to Boot:**
|
||||
Write completion file: ~/gt/deacon/dogs/active/{dog-id}.done
|
||||
Write completion file: $GT_ROOT/deacon/dogs/active/{dog-id}.done
|
||||
```json
|
||||
{
|
||||
"dog_id": "{dog-id}",
|
||||
|
||||
@@ -132,7 +132,7 @@ gt daemon rotate-logs
|
||||
gt doctor --fix
|
||||
```
|
||||
|
||||
Old logs are moved to `~/gt/logs/archive/` with timestamps.
|
||||
Old logs are moved to `$GT_ROOT/logs/archive/` with timestamps.
|
||||
"""
|
||||
|
||||
[[steps]]
|
||||
|
||||
2669
.beads/issues.jsonl
2669
.beads/issues.jsonl
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,11 @@
|
||||
# polecat/* - Polecat working branches (Refinery merges these)
|
||||
|
||||
while read local_ref local_sha remote_ref remote_sha; do
|
||||
# Skip tags - they're allowed for releases
|
||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
branch="${remote_ref#refs/heads/}"
|
||||
|
||||
case "$branch" in
|
||||
@@ -15,17 +20,22 @@ while read local_ref local_sha remote_ref remote_sha; do
|
||||
# Allowed branches
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Invalid branch for Gas Town agents."
|
||||
echo ""
|
||||
echo "Blocked push to: $branch"
|
||||
echo ""
|
||||
echo "Allowed branches:"
|
||||
echo " main - Crew workers push here directly"
|
||||
echo " polecat/* - Polecat working branches"
|
||||
echo " beads-sync - Beads synchronization"
|
||||
echo ""
|
||||
echo "Do NOT create PRs. Push to main or let Refinery merge polecat work."
|
||||
exit 1
|
||||
# Allow feature branches when contributing to upstream (fork workflow).
|
||||
# If an 'upstream' remote exists, this is a contribution setup where
|
||||
# feature branches are needed for PRs. See: #848
|
||||
if ! git remote get-url upstream &>/dev/null; then
|
||||
echo "ERROR: Invalid branch for Gas Town agents."
|
||||
echo ""
|
||||
echo "Blocked push to: $branch"
|
||||
echo ""
|
||||
echo "Allowed branches:"
|
||||
echo " main - Crew workers push here directly"
|
||||
echo " polecat/* - Polecat working branches"
|
||||
echo " beads-sync - Beads synchronization"
|
||||
echo ""
|
||||
echo "Do NOT create PRs. Push to main or let Refinery merge polecat work."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
32
.github/workflows/windows-ci.yml
vendored
Normal file
32
.github/workflows/windows-ci.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Windows CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Windows Build and Unit Tests
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "CI Bot"
|
||||
git config --global user.email "ci@gastown.test"
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./cmd/gt
|
||||
|
||||
- name: Unit Tests
|
||||
run: go test -short ./...
|
||||
9
.gitignore
vendored
9
.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)
|
||||
@@ -49,3 +51,10 @@ CLAUDE.md
|
||||
|
||||
# Embedded formulas are committed so `go install @latest` works
|
||||
# Run `go generate ./...` after modifying .beads/formulas/
|
||||
|
||||
# Gas Town (added by gt)
|
||||
.beads/
|
||||
.logs/
|
||||
logs/
|
||||
settings/
|
||||
.events.jsonl
|
||||
|
||||
159
CHANGELOG.md
159
CHANGELOG.md
@@ -7,6 +7,165 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
#### Mail Improvements
|
||||
- **Numeric index support for `gt mail read`** - Read messages by inbox position (e.g., `gt mail read 1`)
|
||||
- **`gt mail hook` alias** - Shortcut for `gt hook attach` from mail context
|
||||
- **`--body` alias for `--message`** - More intuitive flag in `gt mail send` and `gt mail reply`
|
||||
- **Multiple message IDs in delete** - `gt mail delete msg1 msg2 msg3`
|
||||
- **Positional message arg in reply** - `gt mail reply <id> "message"` without --message flag
|
||||
- **`--all` flag for inbox** - Show all messages including read
|
||||
- **Parallel inbox queries** - ~6x speedup for mail inbox
|
||||
|
||||
#### Command Aliases
|
||||
- **`gt bd`** - Alias for `gt bead`
|
||||
- **`gt work`** - Alias for `gt hook`
|
||||
- **`--comment` alias for `--reason`** - In `gt close`
|
||||
- **`read` alias for `show`** - In `gt bead`
|
||||
|
||||
#### Configuration & Agents
|
||||
- **OpenCode as built-in agent preset** - Configure with `gt config set agent opencode`
|
||||
- **Config-based role definition system** - Roles defined in config, not beads
|
||||
- **Env field in RuntimeConfig** - Custom environment variables for agent presets
|
||||
- **ShellQuote helper** - Safe env var escaping for shell commands
|
||||
|
||||
#### Infrastructure
|
||||
- **Deacon status line display** - Shows deacon icon in mayor status line
|
||||
- **Configurable polecat branch naming** - Template-based branch naming
|
||||
- **Hook registry and install command** - Manage Claude Code hooks via `gt hooks`
|
||||
- **Doctor auto-fix capability** - SessionHookCheck can auto-repair
|
||||
- **`gt orphans kill` command** - Clean up orphaned Claude processes
|
||||
- **Zombie-scan command for deacon** - tmux-verified process cleanup
|
||||
- **Initial prompt for autonomous patrol startup** - Better agent priming
|
||||
|
||||
#### Refinery & Merging
|
||||
- **Squash merge for cleaner history** - Eliminates redundant merge commits
|
||||
- **Redundant observers** - Witness and Refinery both watch convoys
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Crew & Session Stability
|
||||
- **Don't kill pane processes on new sessions** - Prevents destroying fresh shells
|
||||
- **Auto-recover from stale tmux pane references** - Recreates sessions automatically
|
||||
- **Preserve GT_AGENT across session restarts** - Handoff maintains identity
|
||||
|
||||
#### Process Management
|
||||
- **KillPaneProcesses kills pane process itself** - Not just descendants
|
||||
- **Kill pane processes before all RespawnPane calls** - Prevents orphan leaks
|
||||
- **Shutdown reliability improvements** - Multiple fixes for clean shutdown
|
||||
- **Deacon spawns immediately after killing stuck session**
|
||||
|
||||
#### Convoy & Routing
|
||||
- **Pass convoy ID to convoy check command** - Correct ID propagation
|
||||
- **Multi-repo routing for custom types** - Correct beads routing across repos
|
||||
- **Normalize agent ID trailing slash** - Consistent ID handling
|
||||
|
||||
#### Miscellaneous
|
||||
- **Sling auto-apply mol-polecat-work** - Auto-attach on open polecat beads
|
||||
- **Wisp orphan lifecycle bug** - Proper cleanup of abandoned wisps
|
||||
- **Misclassified wisp detection** - Defense-in-depth filtering
|
||||
- **Cross-account session access in seance** - Talk to predecessors across accounts
|
||||
- **Many more bug fixes** - See git log for full details
|
||||
|
||||
## [0.4.0] - 2026-01-19
|
||||
|
||||
_Changelog not documented at release time. See git log v0.3.1..v0.4.0 for changes._
|
||||
|
||||
## [0.3.1] - 2026-01-18
|
||||
|
||||
_Changelog not documented at release time. See git log v0.3.0..v0.3.1 for changes._
|
||||
|
||||
## [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
|
||||
|
||||
4
Makefile
4
Makefile
@@ -22,8 +22,8 @@ ifeq ($(shell uname),Darwin)
|
||||
@echo "Signed $(BINARY) for macOS"
|
||||
endif
|
||||
|
||||
install: build
|
||||
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
|
||||
install: generate
|
||||
go install -ldflags "$(LDFLAGS)" ./cmd/gt
|
||||
|
||||
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!
|
||||
|
||||
57
cmd/gt/build_test.go
Normal file
57
cmd/gt/build_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossPlatformBuild verifies that the codebase compiles for all supported
|
||||
// platforms. This catches cases where platform-specific code (using build tags
|
||||
// like //go:build !windows) is called from platform-agnostic code without
|
||||
// providing stubs for all platforms.
|
||||
func TestCrossPlatformBuild(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping cross-platform build test in short mode")
|
||||
}
|
||||
|
||||
// Skip if not running on a platform that can cross-compile
|
||||
// (need Go toolchain, not just running tests)
|
||||
if os.Getenv("CI") == "" && runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("skipping cross-platform build test on unsupported platform")
|
||||
}
|
||||
|
||||
platforms := []struct {
|
||||
goos string
|
||||
goarch string
|
||||
cgo string
|
||||
}{
|
||||
{"linux", "amd64", "0"},
|
||||
{"linux", "arm64", "0"},
|
||||
{"darwin", "amd64", "0"},
|
||||
{"darwin", "arm64", "0"},
|
||||
{"windows", "amd64", "0"},
|
||||
{"freebsd", "amd64", "0"},
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
p := p // capture range variable
|
||||
t.Run(p.goos+"_"+p.goarch, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", os.DevNull, ".")
|
||||
cmd.Dir = "."
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS="+p.goos,
|
||||
"GOARCH="+p.goarch,
|
||||
"CGO_ENABLED="+p.cgo,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("build failed for %s/%s:\n%s", p.goos, p.goarch, string(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ sudo apt update
|
||||
sudo apt install -y git
|
||||
|
||||
# Install Go (apt version may be outdated, use official installer)
|
||||
wget https://go.dev/dl/go1.24.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.linux-amd64.tar.gz
|
||||
wget https://go.dev/dl/go1.24.12.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.12.linux-amd64.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
@@ -51,6 +51,7 @@ so you can see when it lands and what was included.
|
||||
|---------|-------------|-----|-------------|
|
||||
| **Convoy** | Yes | hq-cv-* | Tracking unit. What you create, track, get notified about. |
|
||||
| **Swarm** | No | None | Ephemeral. "The workers currently on this convoy's issues." |
|
||||
| **Stranded Convoy** | Yes | hq-cv-* | A convoy with ready work but no polecats assigned. Needs attention. |
|
||||
|
||||
When you "kick off a swarm", you're really:
|
||||
1. Creating a convoy (the tracking unit)
|
||||
|
||||
@@ -25,6 +25,7 @@ Protomolecule (frozen template) ─── Solid
|
||||
| **Molecule** | Active workflow instance with trackable steps |
|
||||
| **Wisp** | Ephemeral molecule for patrol cycles (never synced) |
|
||||
| **Digest** | Squashed summary of completed molecule |
|
||||
| **Shiny Workflow** | Canonical polecat formula: design → implement → review → test → submit |
|
||||
|
||||
## Common Mistake: Reading Formulas Directly
|
||||
|
||||
@@ -200,7 +201,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
|
||||
@@ -89,6 +89,58 @@ Debug routing: `BD_DEBUG_ROUTING=1 bd show <id>`
|
||||
|
||||
Process state, PIDs, ephemeral data.
|
||||
|
||||
### Rig-Level Configuration
|
||||
|
||||
Rigs support layered configuration through:
|
||||
1. **Wisp layer** (`.beads-wisp/config/`) - transient, local overrides
|
||||
2. **Rig identity bead labels** - persistent rig settings
|
||||
3. **Town defaults** (`~/gt/settings/config.json`)
|
||||
4. **System defaults** - compiled-in fallbacks
|
||||
|
||||
#### Polecat Branch Naming
|
||||
|
||||
Configure custom branch name templates for polecats:
|
||||
|
||||
```bash
|
||||
# Set via wisp (transient - for testing)
|
||||
echo '{"polecat_branch_template": "adam/{year}/{month}/{description}"}' > \
|
||||
~/gt/.beads-wisp/config/myrig.json
|
||||
|
||||
# Or set via rig identity bead labels (persistent)
|
||||
bd update gt-rig-myrig --labels="polecat_branch_template:adam/{year}/{month}/{description}"
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `{user}` | From `git config user.name` | `adam` |
|
||||
| `{year}` | Current year (YY format) | `26` |
|
||||
| `{month}` | Current month (MM format) | `01` |
|
||||
| `{name}` | Polecat name | `alpha` |
|
||||
| `{issue}` | Issue ID without prefix | `123` (from `gt-123`) |
|
||||
| `{description}` | Sanitized issue title | `fix-auth-bug` |
|
||||
| `{timestamp}` | Unique timestamp | `1ks7f9a` |
|
||||
|
||||
**Default Behavior (backward compatible):**
|
||||
|
||||
When `polecat_branch_template` is empty or not set:
|
||||
- With issue: `polecat/{name}/{issue}@{timestamp}`
|
||||
- Without issue: `polecat/{name}-{timestamp}`
|
||||
|
||||
**Example Configurations:**
|
||||
|
||||
```bash
|
||||
# GitHub enterprise format
|
||||
"adam/{year}/{month}/{description}"
|
||||
|
||||
# Simple feature branches
|
||||
"feature/{issue}"
|
||||
|
||||
# Include polecat name for clarity
|
||||
"work/{name}/{issue}"
|
||||
```
|
||||
|
||||
## Formula Format
|
||||
|
||||
```toml
|
||||
@@ -545,6 +597,24 @@ gt stop --all # Kill all sessions
|
||||
gt stop --rig <name> # Kill rig sessions
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
gt deacon health-check <agent> # Send health check ping, track response
|
||||
gt deacon health-state # Show health check state for all agents
|
||||
```
|
||||
|
||||
### Merge Queue (MQ)
|
||||
|
||||
```bash
|
||||
gt mq list [rig] # Show the merge queue
|
||||
gt mq next [rig] # Show highest-priority merge request
|
||||
gt mq submit # Submit current branch to merge queue
|
||||
gt mq status <id> # Show detailed merge request status
|
||||
gt mq retry <id> # Retry a failed merge request
|
||||
gt mq reject <id> # Reject a merge request
|
||||
```
|
||||
|
||||
## Beads Commands (bd)
|
||||
|
||||
```bash
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -44,8 +44,8 @@ type Issue struct {
|
||||
|
||||
// Agent bead slots (type=agent only)
|
||||
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
|
||||
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
|
||||
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
|
||||
// Note: role_bead field removed - role definitions are now config-based
|
||||
|
||||
// Counts from list output
|
||||
DependencyCount int `json:"dependency_count,omitempty"`
|
||||
@@ -113,6 +113,12 @@ 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)
|
||||
|
||||
// Lazy-cached town root for routing resolution.
|
||||
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
|
||||
townRoot string
|
||||
searchedRoot bool
|
||||
}
|
||||
|
||||
// New creates a new Beads wrapper for the given directory.
|
||||
@@ -120,19 +126,63 @@ 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")
|
||||
}
|
||||
|
||||
// getTownRoot returns the Gas Town root directory, using lazy caching.
|
||||
// The town root is found by walking up from workDir looking for mayor/town.json.
|
||||
// Returns empty string if not in a Gas Town project.
|
||||
func (b *Beads) getTownRoot() string {
|
||||
if !b.searchedRoot {
|
||||
b.townRoot = FindTownRoot(b.workDir)
|
||||
b.searchedRoot = true
|
||||
}
|
||||
return b.townRoot
|
||||
}
|
||||
|
||||
// getResolvedBeadsDir returns the beads directory this wrapper is operating on.
|
||||
// This follows any redirects and returns the actual beads directory path.
|
||||
func (b *Beads) getResolvedBeadsDir() string {
|
||||
if b.beadsDir != "" {
|
||||
return b.beadsDir
|
||||
}
|
||||
return ResolveBeadsDir(b.workDir)
|
||||
}
|
||||
|
||||
// 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 +191,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 +265,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 +488,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 +515,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 +536,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 +751,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,10 +5,32 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runSlotSet runs `bd slot set` from a specific directory.
|
||||
// This is needed when the agent bead was created via routing to a different
|
||||
// database than the Beads wrapper's default directory.
|
||||
func runSlotSet(workDir, beadID, slotName, slotValue string) error {
|
||||
cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue)
|
||||
cmd.Dir = workDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSlotClear runs `bd slot clear` from a specific directory.
|
||||
func runSlotClear(workDir, beadID, slotName string) error {
|
||||
cmd := exec.Command("bd", "slot", "clear", beadID, slotName)
|
||||
cmd.Dir = workDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentFields holds structured fields for agent beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type AgentFields struct {
|
||||
@@ -16,10 +38,11 @@ type AgentFields struct {
|
||||
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||
AgentState string // spawning, working, done, stuck
|
||||
HookBead string // Currently pinned work bead ID
|
||||
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
||||
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
||||
ActiveMR string // Currently active merge request bead ID (for traceability)
|
||||
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
||||
// Note: RoleBead field removed - role definitions are now config-based.
|
||||
// See internal/config/roles/*.toml and config-based-roles.md.
|
||||
}
|
||||
|
||||
// Notification level constants
|
||||
@@ -54,11 +77,7 @@ func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||
lines = append(lines, "hook_bead: null")
|
||||
}
|
||||
|
||||
if fields.RoleBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||
} else {
|
||||
lines = append(lines, "role_bead: null")
|
||||
}
|
||||
// Note: role_bead field no longer written - role definitions are config-based
|
||||
|
||||
if fields.CleanupStatus != "" {
|
||||
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
||||
@@ -112,7 +131,7 @@ func ParseAgentFields(description string) *AgentFields {
|
||||
case "hook_bead":
|
||||
fields.HookBead = value
|
||||
case "role_bead":
|
||||
fields.RoleBead = value
|
||||
// Ignored - role definitions are now config-based (backward compat)
|
||||
case "cleanup_status":
|
||||
fields.CleanupStatus = value
|
||||
case "active_mr":
|
||||
@@ -129,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields {
|
||||
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||
// Use AgentBeadID() helper to generate correct IDs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
//
|
||||
// This function automatically ensures custom types are configured in the target
|
||||
// database before creating the bead. This handles multi-repo routing scenarios
|
||||
// where the bead may be routed to a different database than the one this wrapper
|
||||
// is connected to.
|
||||
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||
// Resolve where this bead will actually be written (handles multi-repo routing)
|
||||
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||
|
||||
// Ensure target database has custom types configured
|
||||
// This is cached (sentinel file + in-memory) so repeated calls are fast
|
||||
if err := EnsureCustomTypes(targetDir); err != nil {
|
||||
return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err)
|
||||
}
|
||||
|
||||
description := FormatAgentDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
@@ -139,9 +172,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)
|
||||
}
|
||||
|
||||
@@ -155,19 +192,14 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified (this is the authoritative storage)
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Note: role slot no longer set - role definitions are config-based
|
||||
|
||||
// Set the hook slot if specified (this is the authoritative storage)
|
||||
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||
// agent's hook slot is empty. See mi-619.
|
||||
// Must run from targetDir since that's where the agent bead was created
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue - description text has the backup
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
@@ -178,9 +210,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 +233,10 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The bead already exists (likely a tombstone from a previous nuked polecat)
|
||||
// Resolve where this bead lives (for slot operations)
|
||||
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||
|
||||
// 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
|
||||
@@ -215,20 +255,17 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
return nil, fmt.Errorf("updating reopened agent bead: %w", err)
|
||||
}
|
||||
|
||||
// Set the role slot if specified
|
||||
if fields != nil && fields.RoleBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Note: role slot no longer set - role definitions are config-based
|
||||
|
||||
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||
// Must run from targetDir since that's where the agent bead lives
|
||||
_ = runSlotClear(targetDir, id, "hook")
|
||||
|
||||
// Set the hook slot if specified
|
||||
// Must run from targetDir since that's where the agent bead lives
|
||||
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
|
||||
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue - description text has the backup
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -400,11 +437,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// Package beads provides role bead management.
|
||||
//
|
||||
// DEPRECATED: Role beads are deprecated. Role definitions are now config-based.
|
||||
// See internal/config/roles/*.toml and config-based-roles.md for the new system.
|
||||
//
|
||||
// This file is kept for backward compatibility with existing role beads but
|
||||
// new code should use config.LoadRoleDefinition() instead of reading role beads.
|
||||
// The daemon no longer uses role beads as of Phase 2 (config-based roles).
|
||||
package beads
|
||||
|
||||
import (
|
||||
@@ -6,10 +13,12 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Role bead ID naming convention:
|
||||
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
||||
// DEPRECATED: Role bead ID naming convention is no longer used.
|
||||
// Role definitions are now config-based (internal/config/roles/*.toml).
|
||||
//
|
||||
// Canonical format: hq-<role>-role
|
||||
// Role beads were stored in town beads (~/.beads/) with hq- prefix.
|
||||
//
|
||||
// Canonical format was: hq-<role>-role
|
||||
//
|
||||
// Examples:
|
||||
// - hq-mayor-role
|
||||
@@ -19,8 +28,8 @@ import (
|
||||
// - hq-crew-role
|
||||
// - hq-polecat-role
|
||||
//
|
||||
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
||||
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
||||
// Legacy functions RoleBeadID() and RoleBeadIDTown() still work for
|
||||
// backward compatibility but should not be used in new code.
|
||||
|
||||
// RoleBeadID returns the role bead ID for a given role type.
|
||||
// Role beads define lifecycle configuration for each agent type.
|
||||
@@ -67,6 +76,9 @@ func PolecatRoleBeadID() string {
|
||||
|
||||
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||
//
|
||||
// Deprecated: Use config.LoadRoleDefinition() instead. Role definitions
|
||||
// are now config-based, not stored as beads.
|
||||
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||
issue, err := b.Show(roleBeadID)
|
||||
if err != nil {
|
||||
@@ -94,7 +106,9 @@ func HasLabel(issue *Issue, label string) bool {
|
||||
}
|
||||
|
||||
// RoleBeadDef defines a role bead's metadata.
|
||||
// Used by gt install and gt doctor to create missing role beads.
|
||||
//
|
||||
// Deprecated: Role beads are no longer created. Role definitions are
|
||||
// now config-based (internal/config/roles/*.toml).
|
||||
type RoleBeadDef struct {
|
||||
ID string // e.g., "hq-witness-role"
|
||||
Title string // e.g., "Witness Role"
|
||||
@@ -102,8 +116,9 @@ type RoleBeadDef struct {
|
||||
}
|
||||
|
||||
// AllRoleBeadDefs returns all role bead definitions.
|
||||
// This is the single source of truth for role beads used by both
|
||||
// gt install (initial creation) and gt doctor --fix (repair).
|
||||
//
|
||||
// Deprecated: Role beads are no longer created by gt install or gt doctor.
|
||||
// This function is kept for backward compatibility only.
|
||||
func AllRoleBeadDefs() []RoleBeadDef {
|
||||
return []RoleBeadDef{
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -1799,3 +1800,577 @@ 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",
|
||||
})
|
||||
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
|
||||
})
|
||||
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",
|
||||
})
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
130
internal/beads/beads_types.go
Normal file
130
internal/beads/beads_types.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package beads provides custom type management for agent beads.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
)
|
||||
|
||||
// typesSentinel is a marker file indicating custom types have been configured.
|
||||
// This persists across CLI invocations to avoid redundant bd config calls.
|
||||
const typesSentinel = ".gt-types-configured"
|
||||
|
||||
// ensuredDirs tracks which beads directories have been ensured this session.
|
||||
// This provides fast in-memory caching for multiple creates in the same CLI run.
|
||||
var (
|
||||
ensuredDirs = make(map[string]bool)
|
||||
ensuredMu sync.Mutex
|
||||
)
|
||||
|
||||
// FindTownRoot walks up from startDir to find the Gas Town root directory.
|
||||
// The town root is identified by the presence of mayor/town.json.
|
||||
// Returns empty string if not found (reached filesystem root).
|
||||
func FindTownRoot(startDir string) string {
|
||||
dir := startDir
|
||||
for {
|
||||
townFile := filepath.Join(dir, "mayor", "town.json")
|
||||
if _, err := os.Stat(townFile); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "" // Reached filesystem root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRoutingTarget determines which beads directory a bead ID will route to.
|
||||
// It extracts the prefix from the bead ID and looks up the corresponding route.
|
||||
// Returns the resolved beads directory path, following any redirects.
|
||||
//
|
||||
// If townRoot is empty or prefix is not found, falls back to the provided fallbackDir.
|
||||
func ResolveRoutingTarget(townRoot, beadID, fallbackDir string) string {
|
||||
if townRoot == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Extract prefix from bead ID (e.g., "gt-gastown-polecat-Toast" -> "gt-")
|
||||
prefix := ExtractPrefix(beadID)
|
||||
if prefix == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Look up rig path for this prefix
|
||||
rigPath := GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Resolve redirects and get final beads directory
|
||||
beadsDir := ResolveBeadsDir(rigPath)
|
||||
if beadsDir == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// EnsureCustomTypes ensures the target beads directory has custom types configured.
|
||||
// Uses a two-level caching strategy:
|
||||
// - In-memory cache for multiple creates in the same CLI invocation
|
||||
// - Sentinel file on disk for persistence across CLI invocations
|
||||
//
|
||||
// This function is thread-safe and idempotent.
|
||||
func EnsureCustomTypes(beadsDir string) error {
|
||||
if beadsDir == "" {
|
||||
return fmt.Errorf("empty beads directory")
|
||||
}
|
||||
|
||||
ensuredMu.Lock()
|
||||
defer ensuredMu.Unlock()
|
||||
|
||||
// Fast path: in-memory cache (same CLI invocation)
|
||||
if ensuredDirs[beadsDir] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fast path: sentinel file exists (previous CLI invocation)
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
ensuredDirs[beadsDir] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify beads directory exists
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("beads directory does not exist: %s", beadsDir)
|
||||
}
|
||||
|
||||
// Configure custom types via bd CLI
|
||||
typesList := strings.Join(constants.BeadsCustomTypesList(), ",")
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", typesList)
|
||||
cmd.Dir = beadsDir
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("configure custom types in %s: %s: %w",
|
||||
beadsDir, strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
|
||||
// Write sentinel file (best effort - don't fail if this fails)
|
||||
// The sentinel contains a version marker for future compatibility
|
||||
_ = os.WriteFile(sentinelPath, []byte("v1\n"), 0644)
|
||||
|
||||
ensuredDirs[beadsDir] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetEnsuredDirs clears the in-memory cache of ensured directories.
|
||||
// This is primarily useful for testing.
|
||||
func ResetEnsuredDirs() {
|
||||
ensuredMu.Lock()
|
||||
defer ensuredMu.Unlock()
|
||||
ensuredDirs = make(map[string]bool)
|
||||
}
|
||||
234
internal/beads/beads_types_test.go
Normal file
234
internal/beads/beads_types_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindTownRoot(t *testing.T) {
|
||||
// Create a temporary town structure
|
||||
tmpDir := t.TempDir()
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create nested directories
|
||||
deepDir := filepath.Join(tmpDir, "rig1", "crew", "worker1")
|
||||
if err := os.MkdirAll(deepDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startDir string
|
||||
expected string
|
||||
}{
|
||||
{"from town root", tmpDir, tmpDir},
|
||||
{"from mayor dir", mayorDir, tmpDir},
|
||||
{"from deep nested dir", deepDir, tmpDir},
|
||||
{"from non-town dir", t.TempDir(), ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := FindTownRoot(tc.startDir)
|
||||
if result != tc.expected {
|
||||
t.Errorf("FindTownRoot(%q) = %q, want %q", tc.startDir, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoutingTarget(t *testing.T) {
|
||||
// Create a temporary town with routes
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create mayor/town.json for FindTownRoot
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create routes.jsonl
|
||||
routesContent := `{"prefix": "gt-", "path": "gastown/mayor/rig"}
|
||||
{"prefix": "hq-", "path": "."}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the rig beads directory
|
||||
rigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fallback := "/fallback/.beads"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
townRoot string
|
||||
beadID string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "rig-level bead routes to rig",
|
||||
townRoot: tmpDir,
|
||||
beadID: "gt-gastown-polecat-Toast",
|
||||
expected: rigBeadsDir,
|
||||
},
|
||||
{
|
||||
name: "town-level bead routes to town",
|
||||
townRoot: tmpDir,
|
||||
beadID: "hq-mayor",
|
||||
expected: beadsDir,
|
||||
},
|
||||
{
|
||||
name: "unknown prefix falls back",
|
||||
townRoot: tmpDir,
|
||||
beadID: "xx-unknown",
|
||||
expected: fallback,
|
||||
},
|
||||
{
|
||||
name: "empty townRoot falls back",
|
||||
townRoot: "",
|
||||
beadID: "gt-gastown-polecat-Toast",
|
||||
expected: fallback,
|
||||
},
|
||||
{
|
||||
name: "no prefix falls back",
|
||||
townRoot: tmpDir,
|
||||
beadID: "noprefixid",
|
||||
expected: fallback,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ResolveRoutingTarget(tc.townRoot, tc.beadID, fallback)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ResolveRoutingTarget(%q, %q, %q) = %q, want %q",
|
||||
tc.townRoot, tc.beadID, fallback, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCustomTypes(t *testing.T) {
|
||||
// Reset the in-memory cache before testing
|
||||
ResetEnsuredDirs()
|
||||
|
||||
t.Run("empty beads dir returns error", func(t *testing.T) {
|
||||
err := EnsureCustomTypes("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty beads dir")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent beads dir returns error", func(t *testing.T) {
|
||||
err := EnsureCustomTypes("/nonexistent/path/.beads")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent beads dir")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sentinel file triggers cache hit", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create sentinel file
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reset cache to ensure we're testing sentinel detection
|
||||
ResetEnsuredDirs()
|
||||
|
||||
// This should succeed without running bd (sentinel exists)
|
||||
err := EnsureCustomTypes(beadsDir)
|
||||
if err != nil {
|
||||
t.Errorf("expected success with sentinel file, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("in-memory cache prevents repeated calls", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create sentinel to avoid bd call
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ResetEnsuredDirs()
|
||||
|
||||
// First call
|
||||
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove sentinel - second call should still succeed due to in-memory cache
|
||||
os.Remove(sentinelPath)
|
||||
|
||||
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||
t.Errorf("expected cache hit, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeads_getTownRoot(t *testing.T) {
|
||||
// Create a temporary town
|
||||
tmpDir := t.TempDir()
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create nested directory
|
||||
rigDir := filepath.Join(tmpDir, "myrig", "mayor", "rig")
|
||||
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := New(rigDir)
|
||||
|
||||
// First call should find town root
|
||||
root1 := b.getTownRoot()
|
||||
if root1 != tmpDir {
|
||||
t.Errorf("first getTownRoot() = %q, want %q", root1, tmpDir)
|
||||
}
|
||||
|
||||
// Second call should return cached value
|
||||
root2 := b.getTownRoot()
|
||||
if root2 != root1 {
|
||||
t.Errorf("second getTownRoot() = %q, want cached %q", root2, root1)
|
||||
}
|
||||
|
||||
// Verify searchedRoot flag is set
|
||||
if !b.searchedRoot {
|
||||
t.Error("expected searchedRoot to be true after getTownRoot()")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ func (b *Beads) AttachMolecule(pinnedBeadID, moleculeID string) (*Issue, error)
|
||||
return nil, fmt.Errorf("fetching pinned bead: %w", err)
|
||||
}
|
||||
|
||||
// Only allow pinned beads (permanent records like role definitions)
|
||||
if issue.Status != StatusPinned {
|
||||
return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status)
|
||||
}
|
||||
|
||||
@@ -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,14 +155,15 @@ 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 {
|
||||
// Kill any stale session first
|
||||
func (b *Boot) spawnTmux(agentOverride string) error {
|
||||
// Kill any stale session first.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
if b.IsSessionAlive() {
|
||||
_ = b.tmux.KillSession(SessionName)
|
||||
_ = b.tmux.KillSessionWithProcesses(SessionName)
|
||||
}
|
||||
|
||||
// Ensure boot directory exists (it should have CLAUDE.md with Boot context)
|
||||
@@ -170,8 +171,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 +194,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 +214,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.
|
||||
|
||||
@@ -3,13 +3,42 @@
|
||||
"beads@beads-marketplace": false
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash(gh pr create*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash(git checkout -b*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash(git switch -c*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt mail check --inject && gt nudge deacon session-started"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt mail check --inject && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +49,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,13 +3,42 @@
|
||||
"beads@beads-marketplace": false
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash(gh pr create*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash(git checkout -b*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash(git switch -c*)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/.local/bin:$PATH\" && gt tap guard pr-workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt nudge deacon session-started"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +49,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package cmd
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -54,15 +56,33 @@ func setupTestTownForAccount(t *testing.T) (townRoot string, accountsDir string)
|
||||
return townRoot, accountsDir
|
||||
}
|
||||
|
||||
func setTestHome(t *testing.T, fakeHome string) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("HOME", fakeHome)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
|
||||
drive := filepath.VolumeName(fakeHome)
|
||||
if drive == "" {
|
||||
return
|
||||
}
|
||||
|
||||
t.Setenv("HOMEDRIVE", drive)
|
||||
t.Setenv("HOMEPATH", strings.TrimPrefix(fakeHome, drive))
|
||||
}
|
||||
|
||||
func TestAccountSwitch(t *testing.T) {
|
||||
t.Run("switch between accounts", func(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
// Create fake home directory for ~/.claude
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
setTestHome(t, fakeHome)
|
||||
|
||||
// Create account config directories
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
@@ -133,9 +153,7 @@ func TestAccountSwitch(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
setTestHome(t, fakeHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
|
||||
@@ -186,9 +204,7 @@ func TestAccountSwitch(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
setTestHome(t, fakeHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
|
||||
@@ -224,9 +240,7 @@ func TestAccountSwitch(t *testing.T) {
|
||||
townRoot, accountsDir := setupTestTownForAccount(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", fakeHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
setTestHome(t, fakeHome)
|
||||
|
||||
workConfigDir := filepath.Join(accountsDir, "work")
|
||||
personalConfigDir := filepath.Join(accountsDir, "personal")
|
||||
|
||||
187
internal/cmd/bead.go
Normal file
187
internal/cmd/bead.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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",
|
||||
Aliases: []string{"bd"},
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
var beadReadCmd = &cobra.Command{
|
||||
Use: "read <bead-id> [flags]",
|
||||
Short: "Show details of a bead (alias for 'show')",
|
||||
Long: `Displays the full details of a bead by ID.
|
||||
|
||||
This is an alias for 'gt bead show'. All bd show flags are supported.
|
||||
|
||||
Examples:
|
||||
gt bead read gt-abc123 # Show a gastown issue
|
||||
gt bead read hq-xyz789 # Show a town-level bead
|
||||
gt bead read bd-def456 # Show a beads issue
|
||||
gt bead read 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)
|
||||
beadCmd.AddCommand(beadReadCmd)
|
||||
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
|
||||
@@ -299,9 +301,10 @@ func runDegradedTriage(b *boot.Boot) (action, target string, err error) {
|
||||
// Nudge the session to try to wake it up
|
||||
age := hb.Age()
|
||||
if age > 30*time.Minute {
|
||||
// Very stuck - restart the session
|
||||
// Very stuck - restart the session.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
fmt.Printf("Deacon heartbeat is %s old - restarting session\n", age.Round(time.Minute))
|
||||
if err := tm.KillSession(deaconSession); err == nil {
|
||||
if err := tm.KillSessionWithProcesses(deaconSession); err == nil {
|
||||
return "restart", "deacon-stuck", nil
|
||||
}
|
||||
} else {
|
||||
|
||||
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
|
||||
}
|
||||
54
internal/cmd/close.go
Normal file
54
internal/cmd/close.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"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 --comment "Done" # Same as --reason (alias)
|
||||
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 {
|
||||
// Convert --comment to --reason (alias support)
|
||||
convertedArgs := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
if arg == "--comment" {
|
||||
convertedArgs[i] = "--reason"
|
||||
} else if strings.HasPrefix(arg, "--comment=") {
|
||||
convertedArgs[i] = "--reason=" + strings.TrimPrefix(arg, "--comment=")
|
||||
} else {
|
||||
convertedArgs[i] = arg
|
||||
}
|
||||
}
|
||||
|
||||
// Build bd close command with all args passed through
|
||||
bdArgs := append([]string{"close"}, convertedArgs...)
|
||||
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,9 @@ var (
|
||||
convoyListTree bool
|
||||
convoyInteractive bool
|
||||
convoyStrandedJSON bool
|
||||
convoyCloseReason string
|
||||
convoyCloseNotify string
|
||||
convoyCheckDryRun bool
|
||||
)
|
||||
|
||||
var convoyCmd = &cobra.Command{
|
||||
@@ -106,6 +111,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 +124,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,
|
||||
@@ -167,14 +178,22 @@ Examples:
|
||||
}
|
||||
|
||||
var convoyCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Use: "check [convoy-id]",
|
||||
Short: "Check and auto-close completed convoys",
|
||||
Long: `Check all open convoys and auto-close any where all tracked issues are complete.
|
||||
Long: `Check convoys and auto-close any where all tracked issues are complete.
|
||||
|
||||
Without arguments, checks all open convoys. With a convoy ID, checks only that convoy.
|
||||
|
||||
This handles cross-rig convoy completion: convoys in town beads tracking issues
|
||||
in rig beads won't auto-close via bd close alone. This command bridges that gap.
|
||||
|
||||
Can be run manually or by deacon patrol to ensure convoys close promptly.`,
|
||||
Can be run manually or by deacon patrol to ensure convoys close promptly.
|
||||
|
||||
Examples:
|
||||
gt convoy check # Check all open convoys
|
||||
gt convoy check hq-cv-abc # Check specific convoy
|
||||
gt convoy check --dry-run # Preview what would close without acting`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runConvoyCheck,
|
||||
}
|
||||
|
||||
@@ -199,10 +218,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
|
||||
@@ -217,9 +257,16 @@ func init() {
|
||||
// Interactive TUI flag (on parent command)
|
||||
convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view")
|
||||
|
||||
// Check flags
|
||||
convoyCheckCmd.Flags().BoolVar(&convoyCheckDryRun, "dry-run", false, "Preview what would close without acting")
|
||||
|
||||
// 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 +274,7 @@ func init() {
|
||||
convoyCmd.AddCommand(convoyAddCmd)
|
||||
convoyCmd.AddCommand(convoyCheckCmd)
|
||||
convoyCmd.AddCommand(convoyStrandedCmd)
|
||||
convoyCmd.AddCommand(convoyCloseCmd)
|
||||
|
||||
rootCmd.AddCommand(convoyCmd)
|
||||
}
|
||||
@@ -263,6 +311,15 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Create convoy issue in town beads
|
||||
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
|
||||
|
||||
// Default owner to creator identity if not specified
|
||||
owner := convoyOwner
|
||||
if owner == "" {
|
||||
owner = detectSender()
|
||||
}
|
||||
if owner != "" {
|
||||
description += fmt.Sprintf("\nOwner: %s", owner)
|
||||
}
|
||||
if convoyNotify != "" {
|
||||
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
|
||||
}
|
||||
@@ -281,6 +338,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 +362,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 +383,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
||||
if len(trackedIssues) > 0 {
|
||||
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
|
||||
}
|
||||
if owner != "" {
|
||||
fmt.Printf(" Owner: %s\n", owner)
|
||||
}
|
||||
if convoyNotify != "" {
|
||||
fmt.Printf(" Notify: %s\n", convoyNotify)
|
||||
}
|
||||
@@ -389,9 +458,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++
|
||||
}
|
||||
@@ -415,7 +490,14 @@ func runConvoyCheck(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
closed, err := checkAndCloseCompletedConvoys(townBeads)
|
||||
// If a specific convoy ID is provided, check only that convoy
|
||||
if len(args) == 1 {
|
||||
convoyID := args[0]
|
||||
return checkSingleConvoy(townBeads, convoyID, convoyCheckDryRun)
|
||||
}
|
||||
|
||||
// Check all open convoys
|
||||
closed, err := checkAndCloseCompletedConvoys(townBeads, convoyCheckDryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -423,7 +505,11 @@ func runConvoyCheck(cmd *cobra.Command, args []string) error {
|
||||
if len(closed) == 0 {
|
||||
fmt.Println("No convoys ready to close.")
|
||||
} else {
|
||||
fmt.Printf("%s Auto-closed %d convoy(s):\n", style.Bold.Render("✓"), len(closed))
|
||||
if convoyCheckDryRun {
|
||||
fmt.Printf("%s Would auto-close %d convoy(s):\n", style.Warning.Render("⚠"), len(closed))
|
||||
} else {
|
||||
fmt.Printf("%s Auto-closed %d convoy(s):\n", style.Bold.Render("✓"), len(closed))
|
||||
}
|
||||
for _, c := range closed {
|
||||
fmt.Printf(" 🚚 %s: %s\n", c.ID, c.Title)
|
||||
}
|
||||
@@ -432,6 +518,184 @@ func runConvoyCheck(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSingleConvoy checks a specific convoy and closes it if all tracked issues are complete.
|
||||
func checkSingleConvoy(townBeads, convoyID string, dryRun bool) error {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Check if convoy is already closed
|
||||
if convoy.Status == "closed" {
|
||||
fmt.Printf("%s Convoy %s is already closed\n", style.Dim.Render("○"), convoyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get tracked issues
|
||||
tracked := getTrackedIssues(townBeads, convoyID)
|
||||
if len(tracked) == 0 {
|
||||
fmt.Printf("%s Convoy %s has no tracked issues\n", style.Dim.Render("○"), convoyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if all tracked issues are closed
|
||||
allClosed := true
|
||||
openCount := 0
|
||||
for _, t := range tracked {
|
||||
if t.Status != "closed" && t.Status != "tombstone" {
|
||||
allClosed = false
|
||||
openCount++
|
||||
}
|
||||
}
|
||||
|
||||
if !allClosed {
|
||||
fmt.Printf("%s Convoy %s has %d open issue(s) remaining\n", style.Dim.Render("○"), convoyID, openCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// All tracked issues are complete - close the convoy
|
||||
if dryRun {
|
||||
fmt.Printf("%s Would auto-close convoy 🚚 %s: %s\n", style.Warning.Render("⚠"), convoyID, convoy.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Actually close the convoy
|
||||
closeArgs := []string{"close", convoyID, "-r", "All tracked issues completed"}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
closeCmd.Dir = townBeads
|
||||
|
||||
if err := closeCmd.Run(); err != nil {
|
||||
return fmt.Errorf("closing convoy: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Auto-closed convoy 🚚 %s: %s\n", style.Bold.Render("✓"), convoyID, convoy.Title)
|
||||
|
||||
// Send completion notification
|
||||
notifyConvoyCompletion(townBeads, convoyID, convoy.Title)
|
||||
|
||||
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"`
|
||||
@@ -606,8 +870,9 @@ func isReadyIssue(t trackedIssueInfo, blockedIssues map[string]bool) bool {
|
||||
}
|
||||
|
||||
// checkAndCloseCompletedConvoys finds open convoys where all tracked issues are closed
|
||||
// and auto-closes them. Returns the list of convoys that were closed.
|
||||
func checkAndCloseCompletedConvoys(townBeads string) ([]struct{ ID, Title string }, error) {
|
||||
// and auto-closes them. Returns the list of convoys that were closed (or would be closed in dry-run mode).
|
||||
// If dryRun is true, no changes are made and the function returns what would have been closed.
|
||||
func checkAndCloseCompletedConvoys(townBeads string, dryRun bool) ([]struct{ ID, Title string }, error) {
|
||||
var closed []struct{ ID, Title string }
|
||||
|
||||
// List all open convoys
|
||||
@@ -646,6 +911,12 @@ func checkAndCloseCompletedConvoys(townBeads string) ([]struct{ ID, Title string
|
||||
}
|
||||
|
||||
if allClosed {
|
||||
if dryRun {
|
||||
// In dry-run mode, just record what would be closed
|
||||
closed = append(closed, struct{ ID, Title string }{convoy.ID, convoy.Title})
|
||||
continue
|
||||
}
|
||||
|
||||
// Close the convoy
|
||||
closeArgs := []string{"close", convoy.ID, "-r", "All tracked issues completed"}
|
||||
closeCmd := exec.Command("bd", closeArgs...)
|
||||
@@ -666,9 +937,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 +957,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.
|
||||
//
|
||||
@@ -23,6 +37,11 @@ import (
|
||||
// 2. Creates session.ended events in both town and rig beads
|
||||
// 3. Verifies querySessionEvents finds events from both locations
|
||||
func TestQuerySessionEvents_FindsEventsFromAllLocations(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 affects all tests
|
||||
// that create issues via bd create. See gt-lnn1xn for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
// Skip if gt and bd are not installed
|
||||
if _, err := exec.LookPath("gt"); err != nil {
|
||||
t.Skip("gt not installed, skipping integration test")
|
||||
@@ -31,6 +50,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 +74,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 +116,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 +140,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 +157,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 +167,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 +175,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 +216,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{
|
||||
|
||||
@@ -106,7 +106,6 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
|
||||
RoleType: "crew",
|
||||
Rig: rigName,
|
||||
AgentState: "idle",
|
||||
RoleBead: beads.RoleBeadIDTown("crew"),
|
||||
}
|
||||
desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", name, rigName)
|
||||
if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil {
|
||||
|
||||
@@ -3,9 +3,9 @@ package cmd
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// crewAtRetried tracks if we've already retried after stale session cleanup
|
||||
var crewAtRetried bool
|
||||
|
||||
func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
var name string
|
||||
|
||||
@@ -166,7 +169,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,
|
||||
})
|
||||
@@ -211,6 +213,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||
}
|
||||
// Note: Don't call KillPaneProcesses here - this is a NEW session with just
|
||||
// a fresh shell. Killing it would destroy the pane before we can respawn.
|
||||
// KillPaneProcesses is only needed when restarting in an EXISTING session
|
||||
// where Claude/Node processes might be running and ignoring SIGHUP.
|
||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||
return fmt.Errorf("starting runtime: %w", err)
|
||||
}
|
||||
@@ -254,7 +260,26 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
||||
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
|
||||
}
|
||||
// Kill all processes in the pane before respawning to prevent orphan leaks
|
||||
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore
|
||||
if err := t.KillPaneProcesses(paneID); err != nil {
|
||||
// Non-fatal but log the warning
|
||||
style.PrintWarning("could not kill pane processes: %v", err)
|
||||
}
|
||||
if err := t.RespawnPane(paneID, startupCmd); err != nil {
|
||||
// If pane is stale (session exists but pane doesn't), recreate the session
|
||||
if strings.Contains(err.Error(), "can't find pane") {
|
||||
if crewAtRetried {
|
||||
return fmt.Errorf("stale session persists after cleanup: %w", err)
|
||||
}
|
||||
fmt.Printf("Stale session detected, recreating...\n")
|
||||
if killErr := t.KillSession(sessionID); killErr != nil {
|
||||
return fmt.Errorf("failed to kill stale session: %w", killErr)
|
||||
}
|
||||
crewAtRetried = true
|
||||
defer func() { crewAtRetried = false }()
|
||||
return runCrewAt(cmd, args) // Retry with fresh session
|
||||
}
|
||||
return fmt.Errorf("restarting runtime: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +287,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 +306,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)
|
||||
|
||||
@@ -28,11 +28,12 @@ func runCrewRename(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Kill any running session for the old name
|
||||
// Kill any running session for the old name.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
t := tmux.NewTmux()
|
||||
oldSessionID := crewSessionName(r.Name, oldName)
|
||||
if hasSession, _ := t.HasSession(oldSessionID); hasSession {
|
||||
if err := t.KillSession(oldSessionID); err != nil {
|
||||
if err := t.KillSessionWithProcesses(oldSessionID); err != nil {
|
||||
return fmt.Errorf("killing old session: %w", err)
|
||||
}
|
||||
fmt.Printf("Killed session %s\n", oldSessionID)
|
||||
|
||||
@@ -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,51 @@ 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 deaconZombieScanCmd = &cobra.Command{
|
||||
Use: "zombie-scan",
|
||||
Short: "Find and clean zombie Claude processes not in active tmux sessions",
|
||||
Long: `Find and clean zombie Claude processes not in active tmux sessions.
|
||||
|
||||
Unlike cleanup-orphans (which uses TTY detection), zombie-scan uses tmux
|
||||
verification: it checks if each Claude process is in an active tmux session
|
||||
by comparing against actual pane PIDs.
|
||||
|
||||
A process is a zombie if:
|
||||
- It's a Claude/codex process
|
||||
- It's NOT the pane PID of any active tmux session
|
||||
- It's NOT a child of any pane PID
|
||||
- It's older than 60 seconds
|
||||
|
||||
This catches "ghost" processes that have a TTY (from a dead tmux session)
|
||||
but are no longer part of any active Gas Town session.
|
||||
|
||||
Examples:
|
||||
gt deacon zombie-scan # Find and kill zombies
|
||||
gt deacon zombie-scan --dry-run # Just list zombies, don't kill`,
|
||||
RunE: runDeaconZombieScan,
|
||||
}
|
||||
|
||||
var (
|
||||
triggerTimeout time.Duration
|
||||
|
||||
@@ -253,6 +306,9 @@ var (
|
||||
|
||||
// Pause flags
|
||||
pauseReason string
|
||||
|
||||
// Zombie scan flags
|
||||
zombieScanDryRun bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -269,6 +325,8 @@ func init() {
|
||||
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
||||
deaconCmd.AddCommand(deaconPauseCmd)
|
||||
deaconCmd.AddCommand(deaconResumeCmd)
|
||||
deaconCmd.AddCommand(deaconCleanupOrphansCmd)
|
||||
deaconCmd.AddCommand(deaconZombieScanCmd)
|
||||
|
||||
// Flags for trigger-pending
|
||||
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
||||
@@ -298,6 +356,10 @@ func init() {
|
||||
deaconPauseCmd.Flags().StringVar(&pauseReason, "reason", "",
|
||||
"Reason for pausing the Deacon")
|
||||
|
||||
// Flags for zombie-scan
|
||||
deaconZombieScanCmd.Flags().BoolVar(&zombieScanDryRun, "dry-run", false,
|
||||
"List zombies without killing them")
|
||||
|
||||
deaconStartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
deaconAttachCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
deaconRestartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
|
||||
@@ -348,12 +410,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 +432,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 +442,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 +452,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
|
||||
}
|
||||
@@ -430,8 +491,9 @@ func runDeaconStop(cmd *cobra.Command, args []string) error {
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
// Kill the session.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
if err := t.KillSessionWithProcesses(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
}
|
||||
|
||||
@@ -531,8 +593,9 @@ func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Restarting Deacon...")
|
||||
|
||||
if running {
|
||||
// Kill existing session
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
// Kill existing session.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
if err := t.KillSessionWithProcesses(sessionName); err != nil {
|
||||
style.PrintWarning("failed to kill session: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -703,25 +766,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()
|
||||
@@ -805,9 +878,10 @@ func runDeaconForceKill(cmd *cobra.Command, args []string) error {
|
||||
mailBody := fmt.Sprintf("Deacon detected %s as unresponsive.\nReason: %s\nAction: force-killing session", agent, reason)
|
||||
sendMail(townRoot, agent, "FORCE_KILL: unresponsive", mailBody)
|
||||
|
||||
// Step 2: Kill the tmux session
|
||||
// Step 2: Kill the tmux session.
|
||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||
fmt.Printf("%s Killing tmux session %s...\n", style.Dim.Render("2."), sessionName)
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
if err := t.KillSessionWithProcesses(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
}
|
||||
|
||||
@@ -1095,3 +1169,119 @@ 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
|
||||
}
|
||||
|
||||
// runDeaconZombieScan finds and cleans zombie Claude processes not in active tmux sessions.
|
||||
func runDeaconZombieScan(cmd *cobra.Command, args []string) error {
|
||||
// Find zombies using tmux verification
|
||||
zombies, err := util.FindZombieClaudeProcesses()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding zombie processes: %w", err)
|
||||
}
|
||||
|
||||
if len(zombies) == 0 {
|
||||
fmt.Printf("%s No zombie claude processes found\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Found %d zombie claude process(es)\n", style.Bold.Render("●"), len(zombies))
|
||||
|
||||
// In dry-run mode, just list them
|
||||
if zombieScanDryRun {
|
||||
for _, z := range zombies {
|
||||
ageStr := fmt.Sprintf("%dm", z.Age/60)
|
||||
fmt.Printf(" %s PID %d (%s) TTY=%s age=%s\n",
|
||||
style.Dim.Render("→"), z.PID, z.Cmd, z.TTY, ageStr)
|
||||
}
|
||||
fmt.Printf("%s Dry run - no processes killed\n", style.Dim.Render("○"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process them with signal escalation
|
||||
results, err := util.CleanupZombieClaudeProcesses()
|
||||
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) TTY=%s\n",
|
||||
style.Bold.Render("→"), r.Process.PID, r.Process.Cmd, r.Process.TTY)
|
||||
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 zombie(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())
|
||||
@@ -133,9 +134,12 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewPrefixMismatchCheck())
|
||||
d.Register(doctor.NewRoutesCheck())
|
||||
d.Register(doctor.NewRigRoutesJSONLCheck())
|
||||
d.Register(doctor.NewRoutingModeCheck())
|
||||
d.Register(doctor.NewOrphanSessionCheck())
|
||||
d.Register(doctor.NewZombieSessionCheck())
|
||||
d.Register(doctor.NewOrphanProcessCheck())
|
||||
d.Register(doctor.NewWispGCCheck())
|
||||
d.Register(doctor.NewCheckMisclassifiedWisps())
|
||||
d.Register(doctor.NewBranchCheck())
|
||||
d.Register(doctor.NewBeadsSyncOrphanCheck())
|
||||
d.Register(doctor.NewCloneDivergenceCheck())
|
||||
|
||||
@@ -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,49 @@ 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,
|
||||
}
|
||||
|
||||
var dogDoneCmd = &cobra.Command{
|
||||
Use: "done [name]",
|
||||
Short: "Mark a dog as idle (work complete)",
|
||||
Long: `Mark a dog as idle after completing its work.
|
||||
|
||||
Dogs call this command after finishing plugin execution to reset their state
|
||||
to idle, allowing them to receive new work dispatches.
|
||||
|
||||
If no name is provided, attempts to detect the current dog from BD_ACTOR.
|
||||
|
||||
Examples:
|
||||
gt dog done alpha # Explicit dog name
|
||||
gt dog done # Auto-detect from BD_ACTOR (e.g., "deacon/dogs/alpha")`,
|
||||
RunE: runDogDone,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List flags
|
||||
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
|
||||
@@ -151,12 +212,23 @@ 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)
|
||||
dogCmd.AddCommand(dogDoneCmd)
|
||||
|
||||
rootCmd.AddCommand(dogCmd)
|
||||
}
|
||||
@@ -445,6 +517,34 @@ func runDogStatus(cmd *cobra.Command, args []string) error {
|
||||
return showPackStatus(mgr)
|
||||
}
|
||||
|
||||
func runDogDone(cmd *cobra.Command, args []string) error {
|
||||
mgr, err := getDogManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var name string
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
} else {
|
||||
// Try to detect from BD_ACTOR (e.g., "deacon/dogs/alpha")
|
||||
actor := os.Getenv("BD_ACTOR")
|
||||
if actor != "" && strings.HasPrefix(actor, "deacon/dogs/") {
|
||||
name = strings.TrimPrefix(actor, "deacon/dogs/")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("no dog name provided and could not detect from BD_ACTOR")
|
||||
}
|
||||
}
|
||||
|
||||
if err := mgr.ClearWork(name); err != nil {
|
||||
return fmt.Errorf("marking dog %s as done: %w", name, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ %s marked as idle (ready for new work)\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func showDogStatus(mgr *dog.Manager, name string) error {
|
||||
d, err := mgr.Get(name)
|
||||
if err != nil {
|
||||
@@ -590,3 +690,243 @@ 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)
|
||||
}
|
||||
|
||||
// Spawn a session for the dog to execute the work.
|
||||
// Without a session, the dog's mail inbox is never checked.
|
||||
// See: https://github.com/steveyegge/gastown/issues/XXX (dog dispatch doesn't execute)
|
||||
t := tmux.NewTmux()
|
||||
townName, err := workspace.GetTownName(townRoot)
|
||||
if err != nil {
|
||||
townName = "gt" // fallback
|
||||
}
|
||||
dogSessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name)
|
||||
|
||||
// Kill any stale session first
|
||||
if has, _ := t.HasSession(dogSessionName); has {
|
||||
_ = t.KillSessionWithProcesses(dogSessionName)
|
||||
}
|
||||
|
||||
// Build startup command with initial prompt to check mail and execute plugin
|
||||
// Use BuildDogStartupCommand to properly set BD_ACTOR=deacon/dogs/<name> in the startup command
|
||||
initialPrompt := fmt.Sprintf("I am dog %s. Check my mail inbox with 'gt mail inbox' and execute the plugin instructions I received.", targetDog.Name)
|
||||
startCmd := config.BuildDogStartupCommand(targetDog.Name, townRoot, targetDog.Path, initialPrompt)
|
||||
|
||||
// Create session from dog's directory
|
||||
if err := t.NewSessionWithCommand(dogSessionName, targetDog.Path, startCmd); err != nil {
|
||||
if !dogDispatchJSON {
|
||||
fmt.Printf(" Warning: could not spawn dog session: %v\n", err)
|
||||
}
|
||||
// Non-fatal: mail was sent, dog is marked as working, but no session to execute
|
||||
// The deacon or human can manually start the session later
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,6 +15,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"
|
||||
)
|
||||
|
||||
@@ -79,6 +82,14 @@ func init() {
|
||||
}
|
||||
|
||||
func runDone(cmd *cobra.Command, args []string) error {
|
||||
// Guard: Only polecats should call gt done
|
||||
// Crew, deacons, witnesses etc. don't use gt done - they persist across tasks.
|
||||
// Polecats are ephemeral workers that self-destruct after completing work.
|
||||
actor := os.Getenv("BD_ACTOR")
|
||||
if actor != "" && !isPolecatActor(actor) {
|
||||
return fmt.Errorf("gt done is for polecats only (you are %s)\nPolecats are ephemeral workers that self-destruct after completing work.\nOther roles persist across tasks and don't use gt done.", actor)
|
||||
}
|
||||
|
||||
// Handle --phase-complete flag (overrides --status)
|
||||
var exitType string
|
||||
if donePhaseComplete {
|
||||
@@ -94,55 +105,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 +224,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 +246,63 @@ 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 if branch has commits ahead of origin/default
|
||||
// If not, work may have been pushed directly to main - that's fine, just skip MR
|
||||
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 {
|
||||
// Can't determine - assume work exists and continue
|
||||
style.PrintWarning("could not check commits ahead of %s: %v", defaultBranch, err)
|
||||
aheadCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
// If no commits ahead, work was likely pushed directly to main (or already merged)
|
||||
// This is valid - skip MR creation but still complete successfully
|
||||
if aheadCount == 0 {
|
||||
return fmt.Errorf("branch '%s' has 0 commits ahead of %s; nothing to merge", branch, defaultBranch)
|
||||
fmt.Printf("%s Branch has no commits ahead of %s\n", style.Bold.Render("→"), originDefault)
|
||||
fmt.Printf(" Work was likely pushed directly to main or already merged.\n")
|
||||
fmt.Printf(" Skipping MR creation - completing without merge request.\n\n")
|
||||
|
||||
// Skip straight to witness notification (no MR needed)
|
||||
goto notifyWitness
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -316,6 +420,7 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" Branch: %s\n", branch)
|
||||
}
|
||||
|
||||
notifyWitness:
|
||||
// Notify Witness about completion
|
||||
// Use town-level beads for cross-agent mail
|
||||
townRouter := mail.NewRouter(townRoot)
|
||||
@@ -373,26 +478,39 @@ 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
|
||||
if exitType == ExitCompleted {
|
||||
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil && roleInfo.Role == RolePolecat {
|
||||
selfNukeAttempted = true
|
||||
// "done means gone" - both worktree and session are terminated
|
||||
selfCleanAttempted := false
|
||||
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil && roleInfo.Role == RolePolecat {
|
||||
selfCleanAttempted = true
|
||||
|
||||
// Step 1: Nuke the worktree (only for COMPLETED - other statuses preserve work)
|
||||
if exitType == ExitCompleted {
|
||||
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
|
||||
// All exit types kill the session - "done means gone"
|
||||
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 +524,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 +570,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 +588,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
|
||||
}
|
||||
|
||||
@@ -454,14 +603,31 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
hookedBeadID := agentBead.HookBead
|
||||
// Only close if the hooked bead exists and is still in "hooked" status
|
||||
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
|
||||
// BUG FIX: Close attached molecule (wisp) BEFORE closing hooked bead.
|
||||
// When using formula-on-bead (gt sling formula --on bead), the base bead
|
||||
// has attached_molecule pointing to the wisp. Without this fix, gt done
|
||||
// only closed the hooked bead, leaving the wisp orphaned.
|
||||
// Order matters: wisp closes -> unblocks base bead -> base bead closes.
|
||||
attachment := beads.ParseAttachmentFields(hookedBead)
|
||||
if attachment != nil && attachment.AttachedMolecule != "" {
|
||||
if err := bd.Close(attachment.AttachedMolecule); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't close attached molecule %s: %v\n", attachment.AttachedMolecule, err)
|
||||
}
|
||||
}
|
||||
|
||||
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 +661,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 +739,62 @@ func selfNukePolecat(roleInfo RoleInfo, _ string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPolecatActor checks if a BD_ACTOR value represents a polecat.
|
||||
// Polecat actors have format: rigname/polecats/polecatname
|
||||
// Non-polecat actors have formats like: gastown/crew/name, rigname/witness, etc.
|
||||
func isPolecatActor(actor string) bool {
|
||||
parts := strings.Split(actor, "/")
|
||||
return len(parts) >= 2 && parts[1] == "polecats"
|
||||
}
|
||||
|
||||
// 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 KillSessionWithProcessesExcluding to ensure no orphaned processes are left behind,
|
||||
// while excluding our own PID to avoid killing ourselves before cleanup completes.
|
||||
// The tmux kill-session at the end will terminate us along with the session.
|
||||
t := tmux.NewTmux()
|
||||
myPID := strconv.Itoa(os.Getpid())
|
||||
if err := t.KillSessionWithProcessesExcluding(sessionName, []string{myPID}); 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,133 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPolecatActor verifies that isPolecatActor correctly identifies
|
||||
// polecat actors vs other roles based on the BD_ACTOR format.
|
||||
func TestIsPolecatActor(t *testing.T) {
|
||||
tests := []struct {
|
||||
actor string
|
||||
want bool
|
||||
}{
|
||||
// Polecats: rigname/polecats/polecatname
|
||||
{"testrig/polecats/furiosa", true},
|
||||
{"testrig/polecats/nux", true},
|
||||
{"myrig/polecats/witness", true}, // even if named "witness", still a polecat
|
||||
|
||||
// Non-polecats
|
||||
{"gastown/crew/george", false},
|
||||
{"gastown/crew/max", false},
|
||||
{"testrig/witness", false},
|
||||
{"testrig/deacon", false},
|
||||
{"testrig/mayor", false},
|
||||
{"gastown/refinery", false},
|
||||
|
||||
// Edge cases
|
||||
{"", false},
|
||||
{"single", false},
|
||||
{"polecats/name", false}, // needs rig prefix
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.actor, func(t *testing.T) {
|
||||
got := isPolecatActor(tt.actor)
|
||||
if got != tt.want {
|
||||
t.Errorf("isPolecatActor(%q) = %v, want %v", tt.actor, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
@@ -96,6 +96,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
|
||||
|
||||
@@ -387,8 +393,8 @@ 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)
|
||||
}
|
||||
|
||||
// acquireShutdownLock prevents concurrent shutdowns.
|
||||
@@ -449,21 +455,65 @@ func verifyShutdown(t *tmux.Tmux, townRoot string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for orphaned Claude/node processes
|
||||
// These can be left behind if tmux sessions were killed but child processes didn't terminate
|
||||
if pids := findOrphanedClaudeProcesses(townRoot); len(pids) > 0 {
|
||||
respawned = append(respawned, fmt.Sprintf("orphaned Claude processes (PIDs: %v)", pids))
|
||||
}
|
||||
|
||||
return respawned
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID exists.
|
||||
func isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false // Invalid PID
|
||||
// findOrphanedClaudeProcesses finds Claude/node processes that are running in the
|
||||
// town directory but aren't associated with any active tmux session.
|
||||
// This can happen when tmux sessions are killed but child processes don't terminate.
|
||||
func findOrphanedClaudeProcesses(townRoot string) []int {
|
||||
// Use pgrep to find all claude/node processes
|
||||
cmd := exec.Command("pgrep", "-l", "node")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil // pgrep found no processes or failed
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
|
||||
var orphaned []int
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: "PID command"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
pidStr := parts[0]
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this process is running in the town directory
|
||||
if isProcessInTown(pid, townRoot) {
|
||||
orphaned = append(orphaned, pid)
|
||||
}
|
||||
}
|
||||
// EPERM means process exists but we don't have permission to signal it
|
||||
if err == syscall.EPERM {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
return orphaned
|
||||
}
|
||||
|
||||
// isProcessInTown checks if a process is running in the given town directory.
|
||||
// Uses ps to check the process's working directory.
|
||||
func isProcessInTown(pid int, townRoot string) bool {
|
||||
// Use ps to get the process's working directory
|
||||
cmd := exec.Command("ps", "-o", "command=", "-p", fmt.Sprintf("%d", pid))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the command line includes the town path
|
||||
command := string(output)
|
||||
return strings.Contains(command, townRoot)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,6 +138,11 @@ func runGitInit(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" ✓ Git repository already exists\n")
|
||||
}
|
||||
|
||||
// Install pre-checkout hook to prevent accidental branch switches
|
||||
if err := InstallPreCheckoutHook(hqRoot); err != nil {
|
||||
fmt.Printf(" %s Could not install pre-checkout hook: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Create GitHub repo if requested
|
||||
if gitInitGitHub != "" {
|
||||
if err := createGitHubRepo(hqRoot, gitInitGitHub, !gitInitPublic); err != nil {
|
||||
@@ -223,6 +228,12 @@ func createGitHubRepo(hqRoot, repo string, private bool) error {
|
||||
}
|
||||
fmt.Printf(" → Creating %s GitHub repository %s...\n", visibility, repo)
|
||||
|
||||
// Ensure there's at least one commit before pushing.
|
||||
// gh repo create --push fails on empty repos with no commits.
|
||||
if err := ensureInitialCommit(hqRoot); err != nil {
|
||||
return fmt.Errorf("creating initial commit: %w", err)
|
||||
}
|
||||
|
||||
// Build gh repo create command
|
||||
args := []string{"repo", "create", repo, "--source", hqRoot}
|
||||
if private {
|
||||
@@ -247,6 +258,33 @@ func createGitHubRepo(hqRoot, repo string, private bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInitialCommit creates an initial commit if the repo has no commits.
|
||||
// gh repo create --push requires at least one commit to push.
|
||||
func ensureInitialCommit(hqRoot string) error {
|
||||
// Check if commits exist
|
||||
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||||
cmd.Dir = hqRoot
|
||||
if cmd.Run() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stage and commit
|
||||
addCmd := exec.Command("git", "add", ".")
|
||||
addCmd.Dir = hqRoot
|
||||
if err := addCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
|
||||
commitCmd := exec.Command("git", "commit", "-m", "Initial Gas Town HQ")
|
||||
commitCmd.Dir = hqRoot
|
||||
if output, err := commitCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git commit failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ Created initial commit\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitGitForHarness is the shared implementation for git initialization.
|
||||
// It can be called from both 'gt git-init' and 'gt install --git'.
|
||||
// Note: Function name kept for backwards compatibility.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
@@ -203,6 +204,13 @@ func runHandoff(cmd *cobra.Command, args []string) error {
|
||||
_ = os.WriteFile(markerPath, []byte(currentSession), 0644)
|
||||
}
|
||||
|
||||
// Kill all processes in the pane before respawning to prevent orphan leaks
|
||||
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore
|
||||
if err := t.KillPaneProcesses(pane); err != nil {
|
||||
// Non-fatal but log the warning
|
||||
style.PrintWarning("could not kill pane processes: %v", err)
|
||||
}
|
||||
|
||||
// Use exec to respawn the pane - this kills us and restarts
|
||||
return t.RespawnPane(pane, restartCmd)
|
||||
}
|
||||
@@ -383,7 +391,20 @@ func buildRestartCommand(sessionName string) (string, error) {
|
||||
// 3. export Claude-related env vars (not inherited by fresh shell)
|
||||
// 4. run claude with the startup beacon (triggers immediate context loading)
|
||||
// Use exec to ensure clean process replacement.
|
||||
runtimeCmd := config.GetRuntimeCommandWithPrompt("", beacon)
|
||||
//
|
||||
// Check if current session is using a non-default agent (GT_AGENT env var).
|
||||
// If so, preserve it across handoff by using the override variant.
|
||||
currentAgent := os.Getenv("GT_AGENT")
|
||||
var runtimeCmd string
|
||||
if currentAgent != "" {
|
||||
var err error
|
||||
runtimeCmd, err = config.GetRuntimeCommandWithPromptAndAgentOverride("", beacon, currentAgent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving agent config: %w", err)
|
||||
}
|
||||
} else {
|
||||
runtimeCmd = config.GetRuntimeCommandWithPrompt("", beacon)
|
||||
}
|
||||
|
||||
// Build environment exports - role vars first, then Claude vars
|
||||
var exports []string
|
||||
@@ -397,6 +418,15 @@ func buildRestartCommand(sessionName string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate GT_ROOT so subsequent handoffs can use it as fallback
|
||||
// when cwd-based detection fails (broken state recovery)
|
||||
exports = append(exports, "GT_ROOT="+townRoot)
|
||||
|
||||
// Preserve GT_AGENT across handoff so agent override persists
|
||||
if currentAgent != "" {
|
||||
exports = append(exports, "GT_AGENT="+currentAgent)
|
||||
}
|
||||
|
||||
// Add Claude-related env vars from current environment
|
||||
for _, name := range claudeEnvVars {
|
||||
if val := os.Getenv(name); val != "" {
|
||||
@@ -442,10 +472,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
|
||||
@@ -478,27 +509,32 @@ func sessionToGTRole(sessionName string) string {
|
||||
}
|
||||
|
||||
// detectTownRootFromCwd walks up from the current directory to find the town root.
|
||||
// Falls back to GT_TOWN_ROOT or GT_ROOT env vars if cwd detection fails (broken state recovery).
|
||||
func detectTownRootFromCwd() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
// Use workspace.FindFromCwd which handles both primary (mayor/town.json)
|
||||
// and secondary (mayor/ directory) markers
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err == nil && townRoot != "" {
|
||||
return townRoot
|
||||
}
|
||||
|
||||
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
|
||||
// Fallback: try environment variables for town root
|
||||
// GT_TOWN_ROOT is set by shell integration, GT_ROOT is set by session manager
|
||||
// This enables handoff to work even when cwd detection fails due to
|
||||
// detached HEAD, wrong branch, deleted worktree, etc.
|
||||
for _, envName := range []string{"GT_TOWN_ROOT", "GT_ROOT"} {
|
||||
if envRoot := os.Getenv(envName); envRoot != "" {
|
||||
// Verify it's actually a workspace
|
||||
if _, statErr := os.Stat(filepath.Join(envRoot, workspace.PrimaryMarker)); statErr == nil {
|
||||
return envRoot
|
||||
}
|
||||
// Try secondary marker too
|
||||
if info, statErr := os.Stat(filepath.Join(envRoot, workspace.SecondaryMarker)); statErr == nil && info.IsDir() {
|
||||
return envRoot
|
||||
}
|
||||
}
|
||||
|
||||
// Move up
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -531,6 +567,13 @@ func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kill all processes in the pane before respawning to prevent orphan leaks
|
||||
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore
|
||||
if err := t.KillPaneProcesses(targetPane); err != nil {
|
||||
// Non-fatal but log the warning
|
||||
style.PrintWarning("could not kill pane processes: %v", err)
|
||||
}
|
||||
|
||||
// Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0])
|
||||
if err := t.ClearHistory(targetPane); err != nil {
|
||||
// Non-fatal - continue with respawn even if clear fails
|
||||
@@ -590,6 +633,9 @@ func sendHandoffMail(subject, message string) (string, error) {
|
||||
return "", fmt.Errorf("detecting agent identity: %w", err)
|
||||
}
|
||||
|
||||
// Normalize identity to match mailbox query format
|
||||
agentID = mail.AddressToIdentity(agentID)
|
||||
|
||||
// Detect town root for beads location
|
||||
townRoot := detectTownRootFromCwd()
|
||||
if townRoot == "" {
|
||||
|
||||
124
internal/cmd/handoff_test.go
Normal file
124
internal/cmd/handoff_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
func TestDetectTownRootFromCwd_EnvFallback(t *testing.T) {
|
||||
// Save original env vars and restore after test
|
||||
origTownRoot := os.Getenv("GT_TOWN_ROOT")
|
||||
origRoot := os.Getenv("GT_ROOT")
|
||||
defer func() {
|
||||
os.Setenv("GT_TOWN_ROOT", origTownRoot)
|
||||
os.Setenv("GT_ROOT", origRoot)
|
||||
}()
|
||||
|
||||
// Create a temp directory that looks like a valid town
|
||||
tmpTown := t.TempDir()
|
||||
mayorDir := filepath.Join(tmpTown, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatalf("creating mayor dir: %v", err)
|
||||
}
|
||||
townJSON := filepath.Join(mayorDir, "town.json")
|
||||
if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0644); err != nil {
|
||||
t.Fatalf("creating town.json: %v", err)
|
||||
}
|
||||
|
||||
// Clear both env vars initially
|
||||
os.Setenv("GT_TOWN_ROOT", "")
|
||||
os.Setenv("GT_ROOT", "")
|
||||
|
||||
t.Run("uses GT_TOWN_ROOT when cwd detection fails", func(t *testing.T) {
|
||||
// Set GT_TOWN_ROOT to our temp town
|
||||
os.Setenv("GT_TOWN_ROOT", tmpTown)
|
||||
os.Setenv("GT_ROOT", "")
|
||||
|
||||
// Save cwd, cd to a non-town directory, and restore after
|
||||
origCwd, _ := os.Getwd()
|
||||
os.Chdir(os.TempDir())
|
||||
defer os.Chdir(origCwd)
|
||||
|
||||
result := detectTownRootFromCwd()
|
||||
if result != tmpTown {
|
||||
t.Errorf("detectTownRootFromCwd() = %q, want %q (should use GT_TOWN_ROOT fallback)", result, tmpTown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses GT_ROOT when GT_TOWN_ROOT not set", func(t *testing.T) {
|
||||
// Set only GT_ROOT
|
||||
os.Setenv("GT_TOWN_ROOT", "")
|
||||
os.Setenv("GT_ROOT", tmpTown)
|
||||
|
||||
// Save cwd, cd to a non-town directory, and restore after
|
||||
origCwd, _ := os.Getwd()
|
||||
os.Chdir(os.TempDir())
|
||||
defer os.Chdir(origCwd)
|
||||
|
||||
result := detectTownRootFromCwd()
|
||||
if result != tmpTown {
|
||||
t.Errorf("detectTownRootFromCwd() = %q, want %q (should use GT_ROOT fallback)", result, tmpTown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers GT_TOWN_ROOT over GT_ROOT", func(t *testing.T) {
|
||||
// Create another temp town for GT_ROOT
|
||||
anotherTown := t.TempDir()
|
||||
anotherMayor := filepath.Join(anotherTown, "mayor")
|
||||
os.MkdirAll(anotherMayor, 0755)
|
||||
os.WriteFile(filepath.Join(anotherMayor, "town.json"), []byte(`{"name": "other-town"}`), 0644)
|
||||
|
||||
// Set both env vars
|
||||
os.Setenv("GT_TOWN_ROOT", tmpTown)
|
||||
os.Setenv("GT_ROOT", anotherTown)
|
||||
|
||||
// Save cwd, cd to a non-town directory, and restore after
|
||||
origCwd, _ := os.Getwd()
|
||||
os.Chdir(os.TempDir())
|
||||
defer os.Chdir(origCwd)
|
||||
|
||||
result := detectTownRootFromCwd()
|
||||
if result != tmpTown {
|
||||
t.Errorf("detectTownRootFromCwd() = %q, want %q (should prefer GT_TOWN_ROOT)", result, tmpTown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores invalid GT_TOWN_ROOT", func(t *testing.T) {
|
||||
// Set GT_TOWN_ROOT to non-existent path, GT_ROOT to valid
|
||||
os.Setenv("GT_TOWN_ROOT", "/nonexistent/path/to/town")
|
||||
os.Setenv("GT_ROOT", tmpTown)
|
||||
|
||||
// Save cwd, cd to a non-town directory, and restore after
|
||||
origCwd, _ := os.Getwd()
|
||||
os.Chdir(os.TempDir())
|
||||
defer os.Chdir(origCwd)
|
||||
|
||||
result := detectTownRootFromCwd()
|
||||
if result != tmpTown {
|
||||
t.Errorf("detectTownRootFromCwd() = %q, want %q (should skip invalid GT_TOWN_ROOT and use GT_ROOT)", result, tmpTown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses secondary marker when primary missing", func(t *testing.T) {
|
||||
// Create a temp town with only mayor/ directory (no town.json)
|
||||
secondaryTown := t.TempDir()
|
||||
mayorOnlyDir := filepath.Join(secondaryTown, workspace.SecondaryMarker)
|
||||
os.MkdirAll(mayorOnlyDir, 0755)
|
||||
|
||||
os.Setenv("GT_TOWN_ROOT", secondaryTown)
|
||||
os.Setenv("GT_ROOT", "")
|
||||
|
||||
// Save cwd, cd to a non-town directory, and restore after
|
||||
origCwd, _ := os.Getwd()
|
||||
os.Chdir(os.TempDir())
|
||||
defer os.Chdir(origCwd)
|
||||
|
||||
result := detectTownRootFromCwd()
|
||||
if result != secondaryTown {
|
||||
t.Errorf("detectTownRootFromCwd() = %q, want %q (should accept secondary marker)", result, secondaryTown)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
var hookCmd = &cobra.Command{
|
||||
Use: "hook [bead-id]",
|
||||
Aliases: []string{"work"},
|
||||
GroupID: GroupWork,
|
||||
Short: "Show or attach work on your hook",
|
||||
Long: `Show what's on your hook, or attach new work.
|
||||
@@ -60,10 +61,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 +74,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 +90,7 @@ var (
|
||||
hookMessage string
|
||||
hookDryRun bool
|
||||
hookForce bool
|
||||
hookClear bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -94,6 +99,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 +111,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 +243,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 +280,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()
|
||||
|
||||
267
internal/cmd/hooks_install.go
Normal file
267
internal/cmd/hooks_install.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
var (
|
||||
installRole string
|
||||
installAllRigs bool
|
||||
installDryRun bool
|
||||
)
|
||||
|
||||
var hooksInstallCmd = &cobra.Command{
|
||||
Use: "install <hook-name>",
|
||||
Short: "Install a hook from the registry",
|
||||
Long: `Install a hook from the registry to worktrees.
|
||||
|
||||
By default, installs to the current worktree. Use --role to install
|
||||
to all worktrees of a specific role in the current rig.
|
||||
|
||||
Examples:
|
||||
gt hooks install pr-workflow-guard # Install to current worktree
|
||||
gt hooks install pr-workflow-guard --role crew # Install to all crew in current rig
|
||||
gt hooks install session-prime --role crew --all-rigs # Install to all crew everywhere
|
||||
gt hooks install pr-workflow-guard --dry-run # Preview what would be installed`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runHooksInstall,
|
||||
}
|
||||
|
||||
func init() {
|
||||
hooksCmd.AddCommand(hooksInstallCmd)
|
||||
hooksInstallCmd.Flags().StringVar(&installRole, "role", "", "Install to all worktrees of this role (crew, polecat, witness, refinery)")
|
||||
hooksInstallCmd.Flags().BoolVar(&installAllRigs, "all-rigs", false, "Install across all rigs (requires --role)")
|
||||
hooksInstallCmd.Flags().BoolVar(&installDryRun, "dry-run", false, "Preview changes without writing files")
|
||||
}
|
||||
|
||||
func runHooksInstall(cmd *cobra.Command, args []string) error {
|
||||
hookName := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Load registry
|
||||
registry, err := LoadRegistry(townRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the hook
|
||||
hookDef, ok := registry.Hooks[hookName]
|
||||
if !ok {
|
||||
return fmt.Errorf("hook %q not found in registry", hookName)
|
||||
}
|
||||
|
||||
if !hookDef.Enabled {
|
||||
fmt.Printf("%s Hook %q is disabled in registry. Use --force to install anyway.\n",
|
||||
style.Warning.Render("Warning:"), hookName)
|
||||
}
|
||||
|
||||
// Determine target worktrees
|
||||
targets, err := determineTargets(townRoot, installRole, installAllRigs, hookDef.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
// No role specified, install to current worktree
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets = []string{cwd}
|
||||
}
|
||||
|
||||
// Install to each target
|
||||
installed := 0
|
||||
for _, target := range targets {
|
||||
if err := installHookTo(target, hookName, hookDef, installDryRun); err != nil {
|
||||
fmt.Printf("%s Failed to install to %s: %v\n", style.Error.Render("Error:"), target, err)
|
||||
continue
|
||||
}
|
||||
installed++
|
||||
}
|
||||
|
||||
if installDryRun {
|
||||
fmt.Printf("\n%s Would install %q to %d worktree(s)\n", style.Dim.Render("Dry run:"), hookName, installed)
|
||||
} else {
|
||||
fmt.Printf("\n%s Installed %q to %d worktree(s)\n", style.Success.Render("Done:"), hookName, installed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineTargets finds all worktree paths matching the role criteria.
|
||||
func determineTargets(townRoot, role string, allRigs bool, allowedRoles []string) ([]string, error) {
|
||||
if role == "" {
|
||||
return nil, nil // Will use current directory
|
||||
}
|
||||
|
||||
// Check if role is allowed for this hook
|
||||
roleAllowed := false
|
||||
for _, r := range allowedRoles {
|
||||
if r == role {
|
||||
roleAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !roleAllowed {
|
||||
return nil, fmt.Errorf("hook is not applicable to role %q (allowed: %s)", role, strings.Join(allowedRoles, ", "))
|
||||
}
|
||||
|
||||
var targets []string
|
||||
|
||||
// Find rigs to scan
|
||||
var rigs []string
|
||||
if allRigs {
|
||||
entries, err := os.ReadDir(townRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") && e.Name() != "mayor" && e.Name() != "deacon" && e.Name() != "hooks" {
|
||||
rigs = append(rigs, e.Name())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Find current rig from cwd
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relPath, err := filepath.Rel(townRoot, cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := strings.Split(relPath, string(filepath.Separator))
|
||||
if len(parts) > 0 {
|
||||
rigs = []string{parts[0]}
|
||||
}
|
||||
}
|
||||
|
||||
// Find worktrees for the role in each rig
|
||||
for _, rig := range rigs {
|
||||
rigPath := filepath.Join(townRoot, rig)
|
||||
|
||||
switch role {
|
||||
case "crew":
|
||||
crewDir := filepath.Join(rigPath, "crew")
|
||||
if entries, err := os.ReadDir(crewDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
targets = append(targets, filepath.Join(crewDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
case "polecat":
|
||||
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||
if entries, err := os.ReadDir(polecatsDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
targets = append(targets, filepath.Join(polecatsDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
case "witness":
|
||||
witnessPath := filepath.Join(rigPath, "witness")
|
||||
if _, err := os.Stat(witnessPath); err == nil {
|
||||
targets = append(targets, witnessPath)
|
||||
}
|
||||
case "refinery":
|
||||
refineryPath := filepath.Join(rigPath, "refinery")
|
||||
if _, err := os.Stat(refineryPath); err == nil {
|
||||
targets = append(targets, refineryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// installHookTo installs a hook to a specific worktree.
|
||||
func installHookTo(worktreePath, hookName string, hookDef HookDefinition, dryRun bool) error {
|
||||
settingsPath := filepath.Join(worktreePath, ".claude", "settings.json")
|
||||
|
||||
// Load existing settings or create new
|
||||
var settings ClaudeSettings
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return fmt.Errorf("parsing existing settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize maps if needed
|
||||
if settings.Hooks == nil {
|
||||
settings.Hooks = make(map[string][]ClaudeHookMatcher)
|
||||
}
|
||||
if settings.EnabledPlugins == nil {
|
||||
settings.EnabledPlugins = make(map[string]bool)
|
||||
}
|
||||
|
||||
// Build the hook entries
|
||||
for _, matcher := range hookDef.Matchers {
|
||||
hookEntry := ClaudeHookMatcher{
|
||||
Matcher: matcher,
|
||||
Hooks: []ClaudeHook{
|
||||
{Type: "command", Command: hookDef.Command},
|
||||
},
|
||||
}
|
||||
|
||||
// Check if this exact matcher already exists
|
||||
exists := false
|
||||
for _, existing := range settings.Hooks[hookDef.Event] {
|
||||
if existing.Matcher == matcher {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
settings.Hooks[hookDef.Event] = append(settings.Hooks[hookDef.Event], hookEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure beads plugin is disabled (standard for Gas Town)
|
||||
settings.EnabledPlugins["beads@beads-marketplace"] = false
|
||||
|
||||
// Pretty print relative path
|
||||
relPath := worktreePath
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if rel, err := filepath.Rel(home, worktreePath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
relPath = "~/" + rel
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf(" %s %s\n", style.Dim.Render("Would install to:"), relPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating .claude directory: %w", err)
|
||||
}
|
||||
|
||||
// Write settings
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("writing settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", style.Success.Render("Installed to:"), relPath)
|
||||
return nil
|
||||
}
|
||||
165
internal/cmd/hooks_registry.go
Normal file
165
internal/cmd/hooks_registry.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// HookRegistry represents the hooks/registry.toml structure.
|
||||
type HookRegistry struct {
|
||||
Hooks map[string]HookDefinition `toml:"hooks"`
|
||||
}
|
||||
|
||||
// HookDefinition represents a single hook definition in the registry.
|
||||
type HookDefinition struct {
|
||||
Description string `toml:"description"`
|
||||
Event string `toml:"event"`
|
||||
Matchers []string `toml:"matchers"`
|
||||
Command string `toml:"command"`
|
||||
Roles []string `toml:"roles"`
|
||||
Scope string `toml:"scope"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
var (
|
||||
hooksListAll bool
|
||||
)
|
||||
|
||||
var hooksListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available hooks from the registry",
|
||||
Long: `List all hooks defined in the hook registry.
|
||||
|
||||
The registry is at ~/gt/hooks/registry.toml and defines hooks that can be
|
||||
installed for different roles (crew, polecat, witness, etc.).
|
||||
|
||||
Examples:
|
||||
gt hooks list # Show enabled hooks
|
||||
gt hooks list --all # Show all hooks including disabled`,
|
||||
RunE: runHooksList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
hooksCmd.AddCommand(hooksListCmd)
|
||||
hooksListCmd.Flags().BoolVarP(&hooksListAll, "all", "a", false, "Show all hooks including disabled")
|
||||
hooksListCmd.Flags().BoolVarP(&hooksVerbose, "verbose", "v", false, "Show hook commands and matchers")
|
||||
}
|
||||
|
||||
// LoadRegistry loads the hook registry from the town's hooks directory.
|
||||
func LoadRegistry(townRoot string) (*HookRegistry, error) {
|
||||
registryPath := filepath.Join(townRoot, "hooks", "registry.toml")
|
||||
|
||||
data, err := os.ReadFile(registryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("hook registry not found at %s", registryPath)
|
||||
}
|
||||
return nil, fmt.Errorf("reading registry: %w", err)
|
||||
}
|
||||
|
||||
var registry HookRegistry
|
||||
if _, err := toml.Decode(string(data), ®istry); err != nil {
|
||||
return nil, fmt.Errorf("parsing registry: %w", err)
|
||||
}
|
||||
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func runHooksList(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
registry, err := LoadRegistry(townRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(registry.Hooks) == 0 {
|
||||
fmt.Println(style.Dim.Render("No hooks defined in registry"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Hook Registry\n", style.Bold.Render("📋"))
|
||||
fmt.Printf("Source: %s\n\n", style.Dim.Render(filepath.Join(townRoot, "hooks", "registry.toml")))
|
||||
|
||||
// Group by event type
|
||||
byEvent := make(map[string][]struct {
|
||||
name string
|
||||
def HookDefinition
|
||||
})
|
||||
eventOrder := []string{"PreToolUse", "PostToolUse", "SessionStart", "PreCompact", "UserPromptSubmit", "Stop"}
|
||||
|
||||
for name, def := range registry.Hooks {
|
||||
if !hooksListAll && !def.Enabled {
|
||||
continue
|
||||
}
|
||||
byEvent[def.Event] = append(byEvent[def.Event], struct {
|
||||
name string
|
||||
def HookDefinition
|
||||
}{name, def})
|
||||
}
|
||||
|
||||
// Add any events not in the predefined order
|
||||
for event := range byEvent {
|
||||
found := false
|
||||
for _, o := range eventOrder {
|
||||
if event == o {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
eventOrder = append(eventOrder, event)
|
||||
}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, event := range eventOrder {
|
||||
hooks := byEvent[event]
|
||||
if len(hooks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", style.Bold.Render("▸"), event)
|
||||
|
||||
for _, h := range hooks {
|
||||
count++
|
||||
statusIcon := "●"
|
||||
statusColor := style.Success
|
||||
if !h.def.Enabled {
|
||||
statusIcon = "○"
|
||||
statusColor = style.Dim
|
||||
}
|
||||
|
||||
rolesStr := strings.Join(h.def.Roles, ", ")
|
||||
scopeStr := h.def.Scope
|
||||
|
||||
fmt.Printf(" %s %s\n", statusColor.Render(statusIcon), style.Bold.Render(h.name))
|
||||
fmt.Printf(" %s\n", h.def.Description)
|
||||
fmt.Printf(" %s %s %s %s\n",
|
||||
style.Dim.Render("roles:"), rolesStr,
|
||||
style.Dim.Render("scope:"), scopeStr)
|
||||
|
||||
if hooksVerbose {
|
||||
fmt.Printf(" %s %s\n", style.Dim.Render("command:"), h.def.Command)
|
||||
for _, m := range h.def.Matchers {
|
||||
fmt.Printf(" %s %s\n", style.Dim.Render("matcher:"), m)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("%s %d hooks in registry\n", style.Dim.Render("Total:"), count)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -74,6 +74,68 @@ type VersionChange struct {
|
||||
|
||||
// versionChanges contains agent-actionable changes for recent versions
|
||||
var versionChanges = []VersionChange{
|
||||
{
|
||||
Version: "0.5.0",
|
||||
Date: "2026-01-22",
|
||||
Changes: []string{
|
||||
"NEW: gt mail read <index> - Read messages by inbox position",
|
||||
"NEW: gt mail hook - Shortcut for gt hook attach from mail",
|
||||
"NEW: --body alias for --message in gt mail send/reply",
|
||||
"NEW: gt bd alias for gt bead, gt work alias for gt hook",
|
||||
"NEW: OpenCode as built-in agent preset (gt config set agent opencode)",
|
||||
"NEW: Config-based role definition system",
|
||||
"NEW: Deacon icon in mayor status line",
|
||||
"NEW: gt hooks - Hook registry and install command",
|
||||
"NEW: Squash merge in refinery for cleaner history",
|
||||
"CHANGED: Parallel mail inbox queries (~6x speedup)",
|
||||
"FIX: Crew session stability - Don't kill pane processes on new sessions",
|
||||
"FIX: Auto-recover from stale tmux pane references",
|
||||
"FIX: KillPaneProcesses now kills pane process itself, not just descendants",
|
||||
"FIX: Convoy ID propagation in refinery and convoy watcher",
|
||||
"FIX: Multi-repo routing for custom types and role slots",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.4.0",
|
||||
Date: "2026-01-19",
|
||||
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",
|
||||
|
||||
@@ -221,6 +221,30 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" ✓ Created deacon/.claude/settings.json\n")
|
||||
}
|
||||
|
||||
// Create boot directory (deacon/dogs/boot/) for Boot watchdog.
|
||||
// This avoids gt doctor warning on fresh install.
|
||||
bootDir := filepath.Join(deaconDir, "dogs", "boot")
|
||||
if err := os.MkdirAll(bootDir, 0755); err != nil {
|
||||
fmt.Printf(" %s Could not create boot directory: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Create plugins directory for town-level patrol plugins.
|
||||
// This avoids gt doctor warning on fresh install.
|
||||
pluginsDir := filepath.Join(absPath, "plugins")
|
||||
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
|
||||
fmt.Printf(" %s Could not create plugins directory: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created plugins/\n")
|
||||
}
|
||||
|
||||
// Create daemon.json patrol config.
|
||||
// This avoids gt doctor warning on fresh install.
|
||||
if err := config.EnsureDaemonPatrolConfig(absPath); err != nil {
|
||||
fmt.Printf(" %s Could not create daemon.json: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created mayor/daemon.json\n")
|
||||
}
|
||||
|
||||
// Initialize git BEFORE beads so that bd can compute repository fingerprint.
|
||||
// The fingerprint is required for the daemon to start properly.
|
||||
if installGit || installGitHub != "" {
|
||||
@@ -234,6 +258,12 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
// Town beads (hq- prefix) stores mayor mail, cross-rig coordination, and handoffs.
|
||||
// Rig beads are separate and have their own prefixes.
|
||||
if !installNoBeads {
|
||||
// Kill any orphaned bd daemons before initializing beads.
|
||||
// Stale daemons can interfere with fresh database creation.
|
||||
if killed, _, _ := beads.StopAllBdProcesses(false, true); killed > 0 {
|
||||
fmt.Printf(" ✓ Stopped %d orphaned bd daemon(s)\n", killed)
|
||||
}
|
||||
|
||||
if err := initTownBeads(absPath); err != nil {
|
||||
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
@@ -248,7 +278,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create town-level agent beads (Mayor, Deacon) and role beads.
|
||||
// Create town-level agent beads (Mayor, Deacon).
|
||||
// These use hq- prefix and are stored in town beads for cross-rig coordination.
|
||||
if err := initTownAgentBeads(absPath); err != nil {
|
||||
fmt.Printf(" %s Could not create town-level agent beads: %v\n", style.Dim.Render("⚠"), err)
|
||||
@@ -369,6 +399,19 @@ func initTownBeads(townPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify .beads directory was actually created (bd init can exit 0 without creating it)
|
||||
beadsDir := filepath.Join(townPath, ".beads")
|
||||
if _, statErr := os.Stat(beadsDir); os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("bd init succeeded but .beads directory not created (check bd daemon interference)")
|
||||
}
|
||||
|
||||
// Explicitly set issue_prefix config (bd init --prefix may not persist it in newer versions).
|
||||
prefixSetCmd := exec.Command("bd", "config", "set", "issue_prefix", "hq")
|
||||
prefixSetCmd.Dir = townPath
|
||||
if prefixOutput, prefixErr := prefixSetCmd.CombinedOutput(); prefixErr != nil {
|
||||
return fmt.Errorf("bd config set issue_prefix failed: %s", strings.TrimSpace(string(prefixOutput)))
|
||||
}
|
||||
|
||||
// Configure custom types for Gas Town (agent, role, rig, convoy, slot).
|
||||
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
||||
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
||||
@@ -378,6 +421,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 +455,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
|
||||
}
|
||||
|
||||
@@ -434,58 +491,30 @@ func ensureCustomTypes(beadsPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// initTownAgentBeads creates town-level agent and role beads using hq- prefix.
|
||||
// initTownAgentBeads creates town-level agent beads using hq- prefix.
|
||||
// This creates:
|
||||
// - hq-mayor, hq-deacon (agent beads for town-level agents)
|
||||
// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role,
|
||||
// hq-polecat-role, hq-crew-role (role definition beads)
|
||||
//
|
||||
// These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs.
|
||||
// Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads.
|
||||
//
|
||||
// ERROR HANDLING ASYMMETRY:
|
||||
// Agent beads (Mayor, Deacon) use hard fail - installation aborts if creation fails.
|
||||
// Role beads use soft fail - logs warning and continues if creation fails.
|
||||
// Note: Role definitions are now config-based (internal/config/roles/*.toml),
|
||||
// not stored as beads. See config-based-roles.md for details.
|
||||
//
|
||||
// Rationale: Agent beads are identity beads that track agent state, hooks, and
|
||||
// Agent beads use hard fail - installation aborts if creation fails.
|
||||
// Agent beads are identity beads that track agent state, hooks, and
|
||||
// form the foundation of the CV/reputation ledger. Without them, agents cannot
|
||||
// be properly tracked or coordinated. Role beads are documentation templates
|
||||
// that define role characteristics but are not required for agent operation -
|
||||
// agents can function without their role bead existing.
|
||||
// be properly tracked or coordinated.
|
||||
func initTownAgentBeads(townPath string) error {
|
||||
bd := beads.New(townPath)
|
||||
|
||||
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
||||
// agent/role beads during install and runtime. Ensure these types are enabled
|
||||
// agent beads during install and runtime. Ensure these types are enabled
|
||||
// before attempting to create any town-level system beads.
|
||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil {
|
||||
if err := ensureBeadsCustomTypes(townPath, constants.BeadsCustomTypesList()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Role beads (global templates) - use shared definitions from beads package
|
||||
for _, role := range beads.AllRoleBeadDefs() {
|
||||
// Check if already exists
|
||||
if _, err := bd.Show(role.ID); err == nil {
|
||||
continue // Already exists
|
||||
}
|
||||
|
||||
// Create role bead using the beads API
|
||||
// CreateWithID with Type: "role" automatically adds gt:role label
|
||||
_, err := bd.CreateWithID(role.ID, beads.CreateOptions{
|
||||
Title: role.Title,
|
||||
Type: "role",
|
||||
Description: role.Desc,
|
||||
Priority: -1, // No priority
|
||||
})
|
||||
if err != nil {
|
||||
// Log but continue - role beads are optional
|
||||
fmt.Printf(" %s Could not create role bead %s: %v\n",
|
||||
style.Dim.Render("⚠"), role.ID, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ✓ Created role bead: %s\n", role.ID)
|
||||
}
|
||||
|
||||
// Town-level agent beads
|
||||
agentDefs := []struct {
|
||||
id string
|
||||
@@ -527,7 +556,7 @@ func initTownAgentBeads(townPath string) error {
|
||||
Rig: "", // Town-level agents have no rig
|
||||
AgentState: "idle",
|
||||
HookBead: "",
|
||||
RoleBead: beads.RoleBeadIDTown(agent.roleType),
|
||||
// Note: RoleBead field removed - role definitions are now config-based
|
||||
}
|
||||
|
||||
if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil {
|
||||
|
||||
@@ -122,46 +122,6 @@ func TestInstallBeadsHasCorrectPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallTownRoleSlots validates that town-level agent beads
|
||||
// have their role slot set after install.
|
||||
func TestInstallTownRoleSlots(t *testing.T) {
|
||||
// Skip if bd is not available
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd not installed, skipping role slot test")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
hqPath := filepath.Join(tmpDir, "test-hq")
|
||||
|
||||
gtBinary := buildGT(t)
|
||||
|
||||
// Run gt install (includes beads init by default)
|
||||
cmd := exec.Command(gtBinary, "install", hqPath)
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Log install output for CI debugging
|
||||
t.Logf("gt install output:\n%s", output)
|
||||
|
||||
// Verify beads directory was created
|
||||
beadsDir := filepath.Join(hqPath, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
t.Fatalf("beads directory not created at %s", beadsDir)
|
||||
}
|
||||
|
||||
// List beads for debugging
|
||||
listCmd := exec.Command("bd", "--no-daemon", "list", "--type=agent")
|
||||
listCmd.Dir = hqPath
|
||||
listOutput, _ := listCmd.CombinedOutput()
|
||||
t.Logf("bd list --type=agent output:\n%s", listOutput)
|
||||
|
||||
assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role")
|
||||
assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role")
|
||||
}
|
||||
|
||||
// TestInstallIdempotent validates that running gt install twice
|
||||
// on the same directory fails without --force flag.
|
||||
func TestInstallIdempotent(t *testing.T) {
|
||||
@@ -327,54 +287,6 @@ func TestInstallNoBeadsFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildGT builds the gt binary and returns its path.
|
||||
// It caches the build across tests in the same run.
|
||||
var cachedGTBinary string
|
||||
|
||||
func buildGT(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
if cachedGTBinary != "" {
|
||||
// Verify cached binary still exists
|
||||
if _, err := os.Stat(cachedGTBinary); err == nil {
|
||||
return cachedGTBinary
|
||||
}
|
||||
// Binary was cleaned up, rebuild
|
||||
cachedGTBinary = ""
|
||||
}
|
||||
|
||||
// Find project root (where go.mod is)
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Walk up to find go.mod
|
||||
projectRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(projectRoot)
|
||||
if parent == projectRoot {
|
||||
t.Fatal("could not find project root (go.mod)")
|
||||
}
|
||||
projectRoot = parent
|
||||
}
|
||||
|
||||
// Build gt binary to a persistent temp location (not per-test)
|
||||
tmpDir := os.TempDir()
|
||||
tmpBinary := filepath.Join(tmpDir, "gt-integration-test")
|
||||
cmd := exec.Command("go", "build", "-o", tmpBinary, "./cmd/gt")
|
||||
cmd.Dir = projectRoot
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to build gt: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
cachedGTBinary = tmpBinary
|
||||
return tmpBinary
|
||||
}
|
||||
|
||||
// assertDirExists checks that the given path exists and is a directory.
|
||||
func assertDirExists(t *testing.T, path, name string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
mailInboxJSON bool
|
||||
mailReadJSON bool
|
||||
mailInboxUnread bool
|
||||
mailInboxAll bool
|
||||
mailInboxIdentity string
|
||||
mailCheckInject bool
|
||||
mailCheckJSON bool
|
||||
@@ -138,8 +139,13 @@ var mailInboxCmd = &cobra.Command{
|
||||
If no address is specified, shows the current context's inbox.
|
||||
Use --identity for polecats to explicitly specify their identity.
|
||||
|
||||
By default, shows all messages. Use --unread to filter to unread only,
|
||||
or --all to explicitly show all messages (read and unread).
|
||||
|
||||
Examples:
|
||||
gt mail inbox # Current context (auto-detected)
|
||||
gt mail inbox --all # Explicitly show all messages
|
||||
gt mail inbox --unread # Show only unread messages
|
||||
gt mail inbox mayor/ # Mayor's inbox
|
||||
gt mail inbox greenplace/Toast # Polecat's inbox
|
||||
gt mail inbox --identity greenplace/Toast # Explicit polecat identity`,
|
||||
@@ -148,14 +154,21 @@ Examples:
|
||||
}
|
||||
|
||||
var mailReadCmd = &cobra.Command{
|
||||
Use: "read <message-id>",
|
||||
Use: "read <message-id|index>",
|
||||
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'.`,
|
||||
You can specify a message by its ID or by its numeric index from the inbox.
|
||||
The index corresponds to the number shown in 'gt mail inbox' (1-based).
|
||||
|
||||
Examples:
|
||||
gt mail read hq-abc123 # Read by message ID
|
||||
gt mail read 3 # Read the 3rd message in inbox
|
||||
|
||||
Use 'gt mail mark-read' to mark messages as read.`,
|
||||
Aliases: []string{"show"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailRead,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailRead,
|
||||
}
|
||||
|
||||
var mailPeekCmd = &cobra.Command{
|
||||
@@ -169,12 +182,16 @@ Exits silently with code 1 if no unread messages.`,
|
||||
}
|
||||
|
||||
var mailDeleteCmd = &cobra.Command{
|
||||
Use: "delete <message-id>",
|
||||
Short: "Delete a message",
|
||||
Long: `Delete (acknowledge) a message.
|
||||
Use: "delete <message-id> [message-id...]",
|
||||
Short: "Delete messages",
|
||||
Long: `Delete (acknowledge) one or more messages.
|
||||
|
||||
This closes the message in beads.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
This closes the messages in beads.
|
||||
|
||||
Examples:
|
||||
gt mail delete hq-abc123
|
||||
gt mail delete hq-abc123 hq-def456 hq-ghi789`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runMailDelete,
|
||||
}
|
||||
|
||||
@@ -193,8 +210,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.
|
||||
@@ -260,7 +278,7 @@ Examples:
|
||||
}
|
||||
|
||||
var mailReplyCmd = &cobra.Command{
|
||||
Use: "reply <message-id>",
|
||||
Use: "reply <message-id> [message]",
|
||||
Short: "Reply to a message",
|
||||
Long: `Reply to a specific message.
|
||||
|
||||
@@ -269,35 +287,38 @@ This is a convenience command that automatically:
|
||||
- Prefixes the subject with "Re: " (if not already present)
|
||||
- Sends to the original sender
|
||||
|
||||
The message body can be provided as a positional argument or via -m flag.
|
||||
|
||||
Examples:
|
||||
gt mail reply msg-abc123 "Thanks, working on it now"
|
||||
gt mail reply msg-abc123 -m "Thanks, working on it now"
|
||||
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: runMailReply,
|
||||
}
|
||||
|
||||
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 +332,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:
|
||||
@@ -416,6 +437,7 @@ func init() {
|
||||
// Send flags
|
||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
|
||||
mailSendCmd.Flags().StringVar(&mailBody, "body", "", "Alias for --message")
|
||||
mailSendCmd.Flags().IntVar(&mailPriority, "priority", 2, "Message priority (0=urgent, 1=high, 2=normal, 3=low, 4=backlog)")
|
||||
mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
|
||||
mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)")
|
||||
@@ -431,6 +453,7 @@ func init() {
|
||||
// Inbox flags
|
||||
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
|
||||
mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
|
||||
mailInboxCmd.Flags().BoolVarP(&mailInboxAll, "all", "a", false, "Show all messages (read and unread)")
|
||||
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)")
|
||||
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "address", "", "Alias for --identity")
|
||||
|
||||
@@ -448,8 +471,8 @@ func init() {
|
||||
|
||||
// Reply flags
|
||||
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
|
||||
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
|
||||
_ = mailReplyCmd.MarkFlagRequired("message")
|
||||
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body")
|
||||
mailReplyCmd.Flags().StringVar(&mailReplyMessage, "body", "", "Reply message body (alias for --message)")
|
||||
|
||||
// Search flags
|
||||
mailSearchCmd.Flags().StringVar(&mailSearchFrom, "from", "", "Filter by sender address")
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
internal/cmd/mail_hook.go
Normal file
58
internal/cmd/mail_hook.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Flags for mail hook command (mirror of hook command flags)
|
||||
var (
|
||||
mailHookSubject string
|
||||
mailHookMessage string
|
||||
mailHookDryRun bool
|
||||
mailHookForce bool
|
||||
)
|
||||
|
||||
var mailHookCmd = &cobra.Command{
|
||||
Use: "hook <mail-id>",
|
||||
Short: "Attach mail to your hook (alias for 'gt hook attach')",
|
||||
Long: `Attach a mail message to your hook.
|
||||
|
||||
This is an alias for 'gt hook attach <mail-id>'. It attaches the specified
|
||||
mail message to your hook so you can work on it.
|
||||
|
||||
The hook is the "durability primitive" - work on your hook survives session
|
||||
restarts, context compaction, and handoffs.
|
||||
|
||||
Examples:
|
||||
gt mail hook msg-abc123 # Attach mail to your hook
|
||||
gt mail hook msg-abc123 -s "Fix the bug" # With subject for handoff
|
||||
gt mail hook msg-abc123 --force # Replace existing incomplete work
|
||||
|
||||
Related commands:
|
||||
gt hook <bead> # Attach any bead to your hook
|
||||
gt hook status # Show what's on your hook
|
||||
gt unsling # Remove work from hook`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailHook,
|
||||
}
|
||||
|
||||
func init() {
|
||||
mailHookCmd.Flags().StringVarP(&mailHookSubject, "subject", "s", "", "Subject for handoff mail (optional)")
|
||||
mailHookCmd.Flags().StringVarP(&mailHookMessage, "message", "m", "", "Message for handoff mail (optional)")
|
||||
mailHookCmd.Flags().BoolVarP(&mailHookDryRun, "dry-run", "n", false, "Show what would be done")
|
||||
mailHookCmd.Flags().BoolVarP(&mailHookForce, "force", "f", false, "Replace existing incomplete hooked bead")
|
||||
|
||||
mailCmd.AddCommand(mailHookCmd)
|
||||
}
|
||||
|
||||
// runMailHook attaches mail to the hook - delegates to the hook command's logic
|
||||
func runMailHook(cmd *cobra.Command, args []string) error {
|
||||
// Copy flags to hook command's globals (they share the same functionality)
|
||||
hookSubject = mailHookSubject
|
||||
hookMessage = mailHookMessage
|
||||
hookDryRun = mailHookDryRun
|
||||
hookForce = mailHookForce
|
||||
|
||||
// Delegate to the hook command's run function
|
||||
return runHook(cmd, args)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user