Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6b8480c483 | ||
|
|
cd2de6ec46 | ||
|
|
025586e16b | ||
|
|
b990094010 | ||
|
|
716bab396f | ||
|
|
605eeec84e | ||
|
|
3caf32f9f7 | ||
|
|
3cdc98651e | ||
|
|
9779ae3190 | ||
|
|
b9ecb7b82e | ||
|
|
98b11eda3c | ||
|
|
3247b57926 | ||
|
|
f6fd76172e | ||
|
|
77e1199196 | ||
|
|
36ffa379b8 | ||
|
|
9835e13fee | ||
|
|
eae08ee509 | ||
|
|
7ee708ffef | ||
|
|
7182599b42 | ||
|
|
39a51c0d14 | ||
|
|
a9080ed04f | ||
|
|
043a6abc59 | ||
|
|
a1008f6f58 | ||
|
|
995476a9c0 | ||
|
|
7b35398ebc | ||
|
|
0d0d2763a8 | ||
|
|
ea5d72a07b | ||
|
|
cdea53e221 | ||
|
|
b0f377f973 | ||
|
|
28c55bd451 | ||
|
|
2a0a8c760b | ||
|
|
1f272ffc53 | ||
|
|
4bbf97ab82 | ||
|
|
add77eea84 | ||
|
|
a144c99f46 | ||
|
|
956f8cc5f0 | ||
|
|
30a6f27404 | ||
|
|
f5832188a6 | ||
|
|
a106796a0e | ||
|
|
88f784a9aa | ||
|
|
8ed31e9634 | ||
|
|
833724a7ed | ||
|
|
c7e1b207df | ||
|
|
d22b5b6ab5 | ||
|
|
91641b01a0 | ||
|
|
7ef4ddab6c | ||
|
|
5aa218fc96 | ||
|
|
e16d5840c6 | ||
|
|
947111f6d8 | ||
|
|
66f6e37844 | ||
|
|
96632fe4ba | ||
|
|
54be24ab5b | ||
|
|
ce9cd72c37 | ||
|
|
d126c967a0 | ||
|
|
b9025379b7 | ||
|
|
598a39e708 | ||
|
|
ea84079f8b | ||
|
|
b9e8be4352 | ||
|
|
89aec8e19e | ||
|
|
5d554a616a | ||
|
|
dceabab8db | ||
|
|
1418b1123a | ||
|
|
2c73cf35f1 | ||
|
|
0b90837a18 | ||
|
|
566bdfbcd8 | ||
|
|
1ece29e1fd | ||
|
|
7f4c3201cf | ||
|
|
8deb5ed1bd | ||
|
|
dab619b3d0 | ||
|
|
3246c7c6b7 | ||
|
|
6a705f6210 | ||
|
|
62d5e4b550 | ||
|
|
0f6759e4a2 | ||
|
|
1bed63f087 | ||
|
|
5607bc4f01 | ||
|
|
e7d7a1bd6b | ||
|
|
982ce6c5d1 | ||
|
|
f1c49630ca | ||
|
|
21a88e2c18 | ||
|
|
8219fd5abe | ||
|
|
ad6386809c | ||
|
|
d13922523a | ||
|
|
84b6780a87 | ||
|
|
40c67e0796 | ||
|
|
0d7f5d1f05 | ||
|
|
30984dcf95 | ||
|
|
064f7b1a40 | ||
|
|
e3d99c3aed | ||
|
|
819c9dd179 | ||
|
|
9bb63900d4 | ||
|
|
fc1a1dea88 | ||
|
|
dd9cd61075 | ||
|
|
272f83f1fc | ||
|
|
6e6e5ce08c | ||
|
|
db353c247b | ||
|
|
7533fed55e | ||
|
|
afb944f616 | ||
|
|
6016f15da9 | ||
|
|
f90b58bc6d | ||
|
|
b60f016955 | ||
|
|
609a4af087 | ||
|
|
e1f2bb8b4b | ||
|
|
f7d497ba07 | ||
|
|
131dac91c8 | ||
|
|
b92e46474a | ||
|
|
fc8718e680 | ||
|
|
2f50a59e74 | ||
|
|
aeb4c0d26f | ||
|
|
c4fcdd88c8 | ||
|
|
c94d59dca7 | ||
|
|
e0858096f6 | ||
|
|
0f633be4b1 | ||
|
|
593185873d | ||
|
|
86751e1ea5 | ||
|
|
f9473c7b9e | ||
|
|
dcb085e64e | ||
|
|
369cf82b77 | ||
|
|
bfd3096b49 | ||
|
|
271bd7ea0a | ||
|
|
0d3f6c9654 | ||
|
|
24136ebaa1 | ||
|
|
7a1ed80068 | ||
|
|
e6bdc639ab | ||
|
|
65334320c7 | ||
|
|
ce231a31af | ||
|
|
f1a2c56900 | ||
|
|
cc87fdd03d | ||
|
|
9b4c7ac28b | ||
|
|
64e1448981 | ||
|
|
e9a013c0d2 | ||
|
|
e999ceb1c1 | ||
|
|
52b9a95f98 | ||
|
|
1d88a73eaa | ||
|
|
7150ce2624 | ||
|
|
2343e6b0ef | ||
|
|
491b635cbc | ||
|
|
cb2b130ca2 | ||
|
|
be35b3eaab | ||
|
|
b8075a5e06 | ||
|
|
9697007182 | ||
|
|
ad8189b010 | ||
|
|
7367aa7572 | ||
|
|
ba76bf1232 | ||
|
|
692d6819f2 | ||
|
|
97b70517cc | ||
|
|
73a8889c3e | ||
|
|
61b561a540 | ||
|
|
86739556c2 | ||
|
|
ff3d1b2e23 | ||
|
|
69299e9a43 |
43
.beads/PRIME.md
Normal file
43
.beads/PRIME.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Gas Town Worker Context
|
||||||
|
|
||||||
|
> **Context Recovery**: Run `gt prime` for full context after compaction or new session.
|
||||||
|
|
||||||
|
## The Propulsion Principle (GUPP)
|
||||||
|
|
||||||
|
**If you find work on your hook, YOU RUN IT.**
|
||||||
|
|
||||||
|
No confirmation. No waiting. No announcements. The hook having work IS the assignment.
|
||||||
|
This is physics, not politeness. Gas Town is a steam engine - you are a piston.
|
||||||
|
|
||||||
|
**Failure mode we're preventing:**
|
||||||
|
- Agent starts with work on hook
|
||||||
|
- Agent announces itself and waits for human to say "ok go"
|
||||||
|
- Human is AFK / trusting the engine to run
|
||||||
|
- Work sits idle. The whole system stalls.
|
||||||
|
|
||||||
|
## Startup Protocol
|
||||||
|
|
||||||
|
1. Check your hook: `gt mol status`
|
||||||
|
2. If work is hooked → EXECUTE (no announcement, no waiting)
|
||||||
|
3. If hook empty → Check mail: `gt mail inbox`
|
||||||
|
4. Still nothing? Wait for user instructions
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
- `gt prime` - Get full role context (run after compaction)
|
||||||
|
- `gt mol status` - Check your hooked work
|
||||||
|
- `gt mail inbox` - Check for messages
|
||||||
|
- `bd ready` - Find available work (no blockers)
|
||||||
|
- `bd sync` - Sync beads changes
|
||||||
|
|
||||||
|
## Session Close Protocol
|
||||||
|
|
||||||
|
Before saying "done":
|
||||||
|
1. git status (check what changed)
|
||||||
|
2. git add <files> (stage code changes)
|
||||||
|
3. bd sync (commit beads changes)
|
||||||
|
4. git commit -m "..." (commit code)
|
||||||
|
5. bd sync (commit any new beads changes)
|
||||||
|
6. git push (push to remote)
|
||||||
|
|
||||||
|
**Work is not done until pushed.**
|
||||||
383
.beads/formulas/gastown-release.formula.toml
Normal file
383
.beads/formulas/gastown-release.formula.toml
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
description = """
|
||||||
|
Gas Town release workflow - from version bump to verified release.
|
||||||
|
|
||||||
|
This formula orchestrates a release cycle for Gas Town:
|
||||||
|
1. Preflight checks (workspace cleanliness, clean git, up to date)
|
||||||
|
2. Documentation updates (CHANGELOG.md, info.go)
|
||||||
|
3. Version bump (all components)
|
||||||
|
4. Git operations (commit, tag, push)
|
||||||
|
5. Local installation update
|
||||||
|
6. Daemon restart
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt mol wisp create gastown-release --var version=0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Or assign to a crew member:
|
||||||
|
```bash
|
||||||
|
gt sling gastown/crew/max --formula gastown-release --var version=0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- **Crew members (with user present)**: Attempt to resolve issues (merge branches,
|
||||||
|
commit/stash work). Ask the user if blocked.
|
||||||
|
- **Polecats (autonomous)**: Escalate via `gt escalate` if preflight fails or
|
||||||
|
unrecoverable errors occur. Do not proceed with a release if workspaces have
|
||||||
|
uncommitted work.
|
||||||
|
"""
|
||||||
|
formula = "gastown-release"
|
||||||
|
type = "workflow"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[vars.version]
|
||||||
|
description = "The semantic version to release (e.g., 0.3.0)"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "preflight-workspaces"
|
||||||
|
title = "Preflight: Check all workspaces for uncommitted work"
|
||||||
|
description = """
|
||||||
|
Before releasing, ensure no gastown workspaces have uncommitted work that would
|
||||||
|
be excluded from the release.
|
||||||
|
|
||||||
|
Check all crew workspaces and the mayor rig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check each workspace
|
||||||
|
for dir in ~/gt/gastown/crew/* ~/gt/gastown/mayor; do
|
||||||
|
if [ -d "$dir/.git" ] || [ -d "$dir" ]; then
|
||||||
|
echo "=== Checking $dir ==="
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
# Check for uncommitted changes
|
||||||
|
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
echo " ⚠ UNCOMMITTED CHANGES"
|
||||||
|
git status --short
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for stashes
|
||||||
|
stash_count=$(git stash list 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
if [ "$stash_count" -gt 0 ]; then
|
||||||
|
echo " ⚠ HAS $stash_count STASH(ES)"
|
||||||
|
git stash list
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for non-main branches with unpushed commits
|
||||||
|
current_branch=$(git branch --show-current 2>/dev/null)
|
||||||
|
if [ -n "$current_branch" ] && [ "$current_branch" != "main" ]; then
|
||||||
|
echo " ⚠ ON BRANCH: $current_branch (not main)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## If issues found:
|
||||||
|
|
||||||
|
**For crew members (interactive)**:
|
||||||
|
1. Try to resolve: merge branches, commit work, apply/drop stashes
|
||||||
|
2. If work is in-progress and not ready, ask the user whether to:
|
||||||
|
- Wait for completion
|
||||||
|
- Stash and proceed
|
||||||
|
- Exclude from this release
|
||||||
|
3. Only proceed when all workspaces are clean on main
|
||||||
|
|
||||||
|
**For polecats (autonomous)**:
|
||||||
|
1. If any workspace has uncommitted work: STOP and escalate
|
||||||
|
2. Use: `gt escalate --severity medium "Release blocked: workspace X has uncommitted work"`
|
||||||
|
3. Do NOT proceed with release - uncommitted work would be excluded
|
||||||
|
|
||||||
|
This step is critical. A release with uncommitted work means losing changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "preflight-git"
|
||||||
|
title = "Preflight: Check git status"
|
||||||
|
needs = ["preflight-workspaces"]
|
||||||
|
description = """
|
||||||
|
Ensure YOUR working tree is clean before starting release.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are uncommitted changes:
|
||||||
|
- Commit them first (if they should be in the release)
|
||||||
|
- Stash them: `git stash` (if they should NOT be in the release)
|
||||||
|
|
||||||
|
## On failure:
|
||||||
|
- **Crew**: Commit or stash your changes, then continue
|
||||||
|
- **Polecat**: Escalate if you have uncommitted changes you didn't create
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "preflight-pull"
|
||||||
|
title = "Preflight: Pull latest"
|
||||||
|
needs = ["preflight-git"]
|
||||||
|
description = """
|
||||||
|
Ensure we're up to date with origin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --rebase
|
||||||
|
```
|
||||||
|
|
||||||
|
## On merge conflicts:
|
||||||
|
- **Crew**: Resolve conflicts manually. Ask user if unsure about resolution.
|
||||||
|
- **Polecat**: Escalate immediately. Do not attempt to resolve release-blocking
|
||||||
|
merge conflicts autonomously.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "review-changes"
|
||||||
|
title = "Review changes since last release"
|
||||||
|
needs = ["preflight-pull"]
|
||||||
|
description = """
|
||||||
|
Understand what's being released.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log $(git describe --tags --abbrev=0)..HEAD --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
Categorize changes:
|
||||||
|
- Features (feat:)
|
||||||
|
- Fixes (fix:)
|
||||||
|
- Breaking changes
|
||||||
|
- Documentation
|
||||||
|
|
||||||
|
If there are no changes since last release, ask whether to proceed with an
|
||||||
|
empty release (version bump only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "update-changelog"
|
||||||
|
title = "Update CHANGELOG.md"
|
||||||
|
needs = ["review-changes"]
|
||||||
|
description = """
|
||||||
|
Write the [Unreleased] section with all changes for {{version}}.
|
||||||
|
|
||||||
|
Edit CHANGELOG.md and add entries under [Unreleased].
|
||||||
|
|
||||||
|
Format: Keep a Changelog (https://keepachangelog.com)
|
||||||
|
|
||||||
|
Sections to use:
|
||||||
|
- ### Added - for new features
|
||||||
|
- ### Changed - for changes in existing functionality
|
||||||
|
- ### Fixed - for bug fixes
|
||||||
|
- ### Deprecated - for soon-to-be removed features
|
||||||
|
- ### Removed - for now removed features
|
||||||
|
|
||||||
|
Base entries on the git log from the previous step. Group related commits.
|
||||||
|
|
||||||
|
The bump script will automatically create the version header with today's date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "update-info-go"
|
||||||
|
title = "Update info.go versionChanges"
|
||||||
|
needs = ["update-changelog"]
|
||||||
|
description = """
|
||||||
|
Add entry to versionChanges in internal/cmd/info.go.
|
||||||
|
|
||||||
|
This powers `gt info --whats-new` for agents.
|
||||||
|
|
||||||
|
Add a new entry at the TOP of the versionChanges slice:
|
||||||
|
|
||||||
|
```go
|
||||||
|
{
|
||||||
|
Version: "{{version}}",
|
||||||
|
Date: "YYYY-MM-DD", // Today's date
|
||||||
|
Changes: []string{
|
||||||
|
"NEW: Key feature 1",
|
||||||
|
"NEW: Key feature 2",
|
||||||
|
"CHANGED: Modified behavior",
|
||||||
|
"FIX: Bug that was fixed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Focus on agent-relevant and workflow-impacting changes.
|
||||||
|
Prefix with NEW:, CHANGED:, FIX:, or DEPRECATED: for clarity.
|
||||||
|
|
||||||
|
This is similar to CHANGELOG.md but focused on what agents need to know -
|
||||||
|
new commands, changed behaviors, workflow impacts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "run-bump-script"
|
||||||
|
title = "Run bump-version.sh"
|
||||||
|
needs = ["update-info-go"]
|
||||||
|
description = """
|
||||||
|
Update all component versions atomically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/bump-version.sh {{version}}
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates:
|
||||||
|
- internal/cmd/version.go - CLI version constant
|
||||||
|
- npm-package/package.json - npm package version
|
||||||
|
- CHANGELOG.md - Creates [{{version}}] header with date
|
||||||
|
|
||||||
|
Review the changes shown by the script.
|
||||||
|
|
||||||
|
## On failure:
|
||||||
|
If the script fails (e.g., version already exists, format error):
|
||||||
|
- **Crew**: Debug and fix, or ask user
|
||||||
|
- **Polecat**: Escalate with error details
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "verify-versions"
|
||||||
|
title = "Verify version consistency"
|
||||||
|
needs = ["run-bump-script"]
|
||||||
|
description = """
|
||||||
|
Confirm all versions match {{version}}.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep 'Version = ' internal/cmd/version.go
|
||||||
|
grep '"version"' npm-package/package.json | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both should show {{version}}.
|
||||||
|
|
||||||
|
## On mismatch:
|
||||||
|
Do NOT proceed. Either the bump script failed or there's a bug.
|
||||||
|
- **Crew**: Investigate and fix manually
|
||||||
|
- **Polecat**: Escalate immediately - version mismatch is a release blocker
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "commit-release"
|
||||||
|
title = "Commit release"
|
||||||
|
needs = ["verify-versions"]
|
||||||
|
description = """
|
||||||
|
Stage and commit all version changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: Bump version to {{version}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the commit to ensure all expected files are included:
|
||||||
|
- internal/cmd/version.go
|
||||||
|
- internal/cmd/info.go
|
||||||
|
- npm-package/package.json
|
||||||
|
- CHANGELOG.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "create-tag"
|
||||||
|
title = "Create release tag"
|
||||||
|
needs = ["commit-release"]
|
||||||
|
description = """
|
||||||
|
Create annotated git tag.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v{{version}} -m "Release v{{version}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `git tag -l | tail -5`
|
||||||
|
|
||||||
|
## If tag already exists:
|
||||||
|
The version may have been previously (partially) released.
|
||||||
|
- **Crew**: Ask user how to proceed (delete tag and retry? use different version?)
|
||||||
|
- **Polecat**: Escalate - do not delete existing tags autonomously
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "push-release"
|
||||||
|
title = "Push commit and tag"
|
||||||
|
needs = ["create-tag"]
|
||||||
|
description = """
|
||||||
|
Push the release commit and tag to origin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
git push origin v{{version}}
|
||||||
|
```
|
||||||
|
|
||||||
|
This triggers GitHub Actions to build release artifacts.
|
||||||
|
|
||||||
|
Monitor: https://github.com/steveyegge/gastown/actions
|
||||||
|
|
||||||
|
## On push rejection:
|
||||||
|
Someone pushed while we were releasing.
|
||||||
|
- **Crew**: Pull, rebase, re-tag, try again. Ask user if conflicts.
|
||||||
|
- **Polecat**: Escalate - release coordination conflict requires human decision
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "local-install"
|
||||||
|
title = "Update local installation"
|
||||||
|
needs = ["push-release"]
|
||||||
|
description = """
|
||||||
|
Rebuild and install gt locally with the new version.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o $(go env GOPATH)/bin/gt ./cmd/gt
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, codesign the binary:
|
||||||
|
```bash
|
||||||
|
codesign -f -s - $(go env GOPATH)/bin/gt
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
gt version
|
||||||
|
```
|
||||||
|
|
||||||
|
Should show {{version}}.
|
||||||
|
|
||||||
|
## On build failure:
|
||||||
|
- **Crew**: Debug build error, fix, retry
|
||||||
|
- **Polecat**: Escalate - release is pushed but local install failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "restart-daemons"
|
||||||
|
title = "Restart daemons"
|
||||||
|
needs = ["local-install"]
|
||||||
|
description = """
|
||||||
|
Restart gt daemons to pick up the new version.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt daemons killall
|
||||||
|
```
|
||||||
|
|
||||||
|
Daemons will auto-restart with the new version on next gt command.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
gt daemons list
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that daemon versions match {{version}}.
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
@@ -23,7 +23,7 @@ Witnesses detect it and escalate to the Mayor.
|
|||||||
The Deacon's agent bead last_activity timestamp is updated during each patrol
|
The Deacon's agent bead last_activity timestamp is updated during each patrol
|
||||||
cycle. Witnesses check this timestamp to verify health."""
|
cycle. Witnesses check this timestamp to verify health."""
|
||||||
formula = "mol-deacon-patrol"
|
formula = "mol-deacon-patrol"
|
||||||
version = 6
|
version = 8
|
||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
id = "inbox-check"
|
id = "inbox-check"
|
||||||
@@ -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.
|
**Hygiene principle**: Archive messages after they're fully processed.
|
||||||
Keep inbox near-empty - only unprocessed items should remain."""
|
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]]
|
[[steps]]
|
||||||
id = "trigger-pending-spawns"
|
id = "trigger-pending-spawns"
|
||||||
title = "Nudge newly spawned polecats"
|
title = "Nudge newly spawned polecats"
|
||||||
needs = ["inbox-check"]
|
needs = ["orphan-process-cleanup"]
|
||||||
description = """
|
description = """
|
||||||
Nudge newly spawned polecats that are ready for input.
|
Nudge newly spawned polecats that are ready for input.
|
||||||
|
|
||||||
@@ -499,10 +535,74 @@ gt dog status <name>
|
|||||||
|
|
||||||
**Exit criteria:** Pool has at least 1 idle dog."""
|
**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]]
|
[[steps]]
|
||||||
id = "orphan-check"
|
id = "orphan-check"
|
||||||
title = "Detect abandoned work"
|
title = "Detect abandoned work"
|
||||||
needs = ["dog-pool-maintenance"]
|
needs = ["dog-health-check"]
|
||||||
description = """
|
description = """
|
||||||
**DETECT ONLY** - Check for orphaned state and dispatch to dog if found.
|
**DETECT ONLY** - Check for orphaned state and dispatch to dog if found.
|
||||||
|
|
||||||
@@ -707,15 +807,39 @@ Burn and let daemon respawn, or exit if context high.
|
|||||||
Decision point at end of patrol cycle:
|
Decision point at end of patrol cycle:
|
||||||
|
|
||||||
If context is LOW:
|
If context is LOW:
|
||||||
- **Sleep 60 seconds minimum** before next patrol cycle
|
Use await-signal with exponential backoff to wait for activity:
|
||||||
- If town is idle (no in_progress work), sleep longer (2-5 minutes)
|
|
||||||
- Return to inbox-check step
|
|
||||||
|
|
||||||
**Why longer sleep?**
|
```bash
|
||||||
- Idle agents should not be disturbed
|
gt mol step await-signal --agent-bead hq-deacon \
|
||||||
- Health checks every few seconds flood inboxes and waste context
|
--backoff-base 60s --backoff-mult 2 --backoff-max 10m
|
||||||
- The daemon (10-minute heartbeat) is the safety net for dead sessions
|
```
|
||||||
- Active work triggers feed events, which wake agents naturally
|
|
||||||
|
This command:
|
||||||
|
1. Subscribes to `bd activity --follow` (beads activity feed)
|
||||||
|
2. Returns IMMEDIATELY when any beads activity occurs
|
||||||
|
3. If no activity, times out with exponential backoff:
|
||||||
|
- First timeout: 60s
|
||||||
|
- Second timeout: 120s
|
||||||
|
- Third timeout: 240s
|
||||||
|
- ...capped at 10 minutes max
|
||||||
|
4. Tracks `idle:N` label on hq-deacon bead for backoff state
|
||||||
|
|
||||||
|
**On signal received** (activity detected):
|
||||||
|
Reset the idle counter and start next patrol cycle:
|
||||||
|
```bash
|
||||||
|
gt agent state hq-deacon --set idle=0
|
||||||
|
```
|
||||||
|
Then return to inbox-check step.
|
||||||
|
|
||||||
|
**On timeout** (no activity):
|
||||||
|
The idle counter was auto-incremented. Continue to next patrol cycle
|
||||||
|
(the longer backoff will apply next time). Return to inbox-check step.
|
||||||
|
|
||||||
|
**Why this approach?**
|
||||||
|
- Any `gt` or `bd` command triggers beads activity, waking the Deacon
|
||||||
|
- Idle towns let the Deacon sleep longer (up to 10 min between patrols)
|
||||||
|
- Active work wakes the Deacon immediately via the feed
|
||||||
|
- No polling or fixed sleep intervals
|
||||||
|
|
||||||
If context is HIGH:
|
If context is HIGH:
|
||||||
- Write state to persistent storage
|
- Write state to persistent storage
|
||||||
|
|||||||
318
.beads/formulas/mol-polecat-code-review.formula.toml
Normal file
318
.beads/formulas/mol-polecat-code-review.formula.toml
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
description = """
|
||||||
|
Review code and file beads for issues found.
|
||||||
|
|
||||||
|
This molecule guides a polecat through a code review task - examining a portion
|
||||||
|
of the codebase for bugs, security issues, code quality problems, or improvement
|
||||||
|
opportunities. The output is a set of beads capturing actionable findings.
|
||||||
|
|
||||||
|
## Polecat Contract (Self-Cleaning Model)
|
||||||
|
|
||||||
|
You are a self-cleaning worker. You:
|
||||||
|
1. Receive work via your hook (pinned molecule + review scope)
|
||||||
|
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||||
|
3. Complete and self-clean via `gt done` (submit findings + nuke yourself)
|
||||||
|
4. You are GONE - your findings are recorded in beads
|
||||||
|
|
||||||
|
**Self-cleaning:** When you run `gt done`, you submit your findings, nuke your
|
||||||
|
sandbox, and exit. There is no idle state. Done means gone.
|
||||||
|
|
||||||
|
**Important:** This formula defines the template. Your molecule already has step
|
||||||
|
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||||
|
|
||||||
|
**You do NOT:**
|
||||||
|
- Fix the issues yourself (file beads, let other polecats fix)
|
||||||
|
- Scope creep into unrelated areas
|
||||||
|
- Wait for someone to act on findings (you're done after filing)
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
| Variable | Source | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| scope | hook_bead | What to review (file path, directory, or description) |
|
||||||
|
| issue | hook_bead | The tracking issue for this review task |
|
||||||
|
| focus | hook_bead | Optional focus area (security, performance, etc.) |
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
| Situation | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| Scope too broad | Mail Witness, request narrower scope |
|
||||||
|
| Can't understand code | Mail Witness for context |
|
||||||
|
| Critical issue found | Mail Witness immediately, then continue |"""
|
||||||
|
formula = "mol-polecat-code-review"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "load-context"
|
||||||
|
title = "Load context and understand the review scope"
|
||||||
|
description = """
|
||||||
|
Initialize your session and understand what you're reviewing.
|
||||||
|
|
||||||
|
**1. Prime your environment:**
|
||||||
|
```bash
|
||||||
|
gt prime # Load role context
|
||||||
|
bd prime # Load beads context
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Check your hook:**
|
||||||
|
```bash
|
||||||
|
gt hook # Shows your pinned molecule and hook_bead
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook_bead describes your review scope. Read the tracking issue:
|
||||||
|
```bash
|
||||||
|
bd show {{issue}} # Full issue details
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Understand the scope:**
|
||||||
|
- What files/directories are in scope?
|
||||||
|
- Is there a specific focus (security, performance, correctness)?
|
||||||
|
- What's the context - why is this review happening?
|
||||||
|
|
||||||
|
**4. Locate the code:**
|
||||||
|
```bash
|
||||||
|
# If scope is a path:
|
||||||
|
ls -la {{scope}}
|
||||||
|
head -100 {{scope}} # Quick look at the code
|
||||||
|
|
||||||
|
# If scope is a directory:
|
||||||
|
find {{scope}} -type f -name "*.go" | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Check for recent changes:**
|
||||||
|
```bash
|
||||||
|
git log --oneline -10 -- {{scope}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** You understand what you're reviewing and why."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "survey-code"
|
||||||
|
title = "Survey the code structure"
|
||||||
|
needs = ["load-context"]
|
||||||
|
description = """
|
||||||
|
Get a high-level understanding before diving into details.
|
||||||
|
|
||||||
|
**1. Understand the structure:**
|
||||||
|
```bash
|
||||||
|
# For a directory:
|
||||||
|
tree {{scope}} -L 2
|
||||||
|
|
||||||
|
# For a file:
|
||||||
|
wc -l {{scope}} # How big is it?
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Identify key components:**
|
||||||
|
- What are the main types/structs?
|
||||||
|
- What are the public functions?
|
||||||
|
- What are the dependencies?
|
||||||
|
|
||||||
|
**3. Read the tests (if any):**
|
||||||
|
```bash
|
||||||
|
find {{scope}} -name "*_test.go" | xargs head -50
|
||||||
|
```
|
||||||
|
Tests often reveal intended behavior.
|
||||||
|
|
||||||
|
**4. Note initial impressions:**
|
||||||
|
- Is the code well-organized?
|
||||||
|
- Are there obvious patterns or anti-patterns?
|
||||||
|
- What areas look risky?
|
||||||
|
|
||||||
|
**Exit criteria:** You have a mental map of the code structure."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "detailed-review"
|
||||||
|
title = "Perform detailed code review"
|
||||||
|
needs = ["survey-code"]
|
||||||
|
description = """
|
||||||
|
Systematically review the code for issues.
|
||||||
|
|
||||||
|
**Review checklist:**
|
||||||
|
|
||||||
|
| Category | Look For |
|
||||||
|
|----------|----------|
|
||||||
|
| **Correctness** | Logic errors, off-by-one, nil handling, race conditions |
|
||||||
|
| **Security** | Injection, auth bypass, secrets in code, unsafe operations |
|
||||||
|
| **Error handling** | Swallowed errors, missing checks, unclear error messages |
|
||||||
|
| **Performance** | N+1 queries, unnecessary allocations, blocking calls |
|
||||||
|
| **Maintainability** | Dead code, unclear naming, missing comments on complex logic |
|
||||||
|
| **Testing** | Untested paths, missing edge cases, flaky tests |
|
||||||
|
|
||||||
|
**Focus on {{focus}} if specified.**
|
||||||
|
|
||||||
|
**1. Read through the code:**
|
||||||
|
```bash
|
||||||
|
cat {{scope}} # For single file
|
||||||
|
# Or read files systematically for a directory
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. For each issue found, note:**
|
||||||
|
- File and line number
|
||||||
|
- Category (bug, security, performance, etc.)
|
||||||
|
- Severity (critical, high, medium, low)
|
||||||
|
- Description of the issue
|
||||||
|
- Suggested fix (if obvious)
|
||||||
|
|
||||||
|
**3. Don't fix issues yourself:**
|
||||||
|
Your job is to find and report, not fix. File beads.
|
||||||
|
|
||||||
|
**Exit criteria:** You've reviewed all code in scope and noted issues."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "prioritize-findings"
|
||||||
|
title = "Prioritize and categorize findings"
|
||||||
|
needs = ["detailed-review"]
|
||||||
|
description = """
|
||||||
|
Organize your findings by priority and category.
|
||||||
|
|
||||||
|
**Priority levels:**
|
||||||
|
|
||||||
|
| Priority | Description | Action |
|
||||||
|
|----------|-------------|--------|
|
||||||
|
| P0 | Security vulnerability, data loss risk | Mail Witness immediately |
|
||||||
|
| P1 | Bug affecting users, broken functionality | File as bug, high priority |
|
||||||
|
| P2 | Code quality issue, potential future bug | File as task |
|
||||||
|
| P3 | Improvement opportunity, nice-to-have | File as task, low priority |
|
||||||
|
|
||||||
|
**1. Sort your findings:**
|
||||||
|
Group by priority, then by category.
|
||||||
|
|
||||||
|
**2. For P0 issues:**
|
||||||
|
```bash
|
||||||
|
gt mail send {{rig}}/witness -s "CRITICAL: Security issue found" -m "Scope: {{scope}}
|
||||||
|
Issue: {{issue}}
|
||||||
|
Finding: <description of critical issue>
|
||||||
|
Location: <file:line>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Prepare bead descriptions:**
|
||||||
|
For each finding, prepare:
|
||||||
|
- Clear title
|
||||||
|
- File/line location
|
||||||
|
- Description of the issue
|
||||||
|
- Why it matters
|
||||||
|
- Suggested fix (if known)
|
||||||
|
|
||||||
|
**Exit criteria:** Findings prioritized and ready to file."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "file-beads"
|
||||||
|
title = "File beads for all findings"
|
||||||
|
needs = ["prioritize-findings"]
|
||||||
|
description = """
|
||||||
|
Create beads for each finding.
|
||||||
|
|
||||||
|
**1. For bugs (P0, P1):**
|
||||||
|
```bash
|
||||||
|
bd create --type=bug --priority=1 \
|
||||||
|
--title="<clear description of bug>" \
|
||||||
|
--description="Found during code review of {{scope}}.
|
||||||
|
|
||||||
|
Location: <file:line>
|
||||||
|
|
||||||
|
Issue:
|
||||||
|
<description>
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
<why this matters>
|
||||||
|
|
||||||
|
Suggested fix:
|
||||||
|
<if known>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. For code quality issues (P2, P3):**
|
||||||
|
```bash
|
||||||
|
bd create --type=task --priority=2 \
|
||||||
|
--title="<clear description>" \
|
||||||
|
--description="Found during code review of {{scope}}.
|
||||||
|
|
||||||
|
Location: <file:line>
|
||||||
|
|
||||||
|
Issue:
|
||||||
|
<description>
|
||||||
|
|
||||||
|
Suggestion:
|
||||||
|
<how to improve>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Track filed beads:**
|
||||||
|
Note each bead ID as you create them.
|
||||||
|
|
||||||
|
**4. If no issues found:**
|
||||||
|
That's a valid outcome! Note that the code review passed.
|
||||||
|
|
||||||
|
**Exit criteria:** All findings filed as beads."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "summarize-review"
|
||||||
|
title = "Summarize review results"
|
||||||
|
needs = ["file-beads"]
|
||||||
|
description = """
|
||||||
|
Update the tracking issue with review summary.
|
||||||
|
|
||||||
|
**1. Create summary:**
|
||||||
|
```bash
|
||||||
|
bd update {{issue}} --notes "Code review complete.
|
||||||
|
|
||||||
|
Scope: {{scope}}
|
||||||
|
Focus: {{focus}}
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
- P0 (critical): <count>
|
||||||
|
- P1 (high): <count>
|
||||||
|
- P2 (medium): <count>
|
||||||
|
- P3 (low): <count>
|
||||||
|
|
||||||
|
Beads filed:
|
||||||
|
<list of bead IDs>
|
||||||
|
|
||||||
|
Overall assessment:
|
||||||
|
<brief summary - healthy, needs attention, significant issues, etc.>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Sync beads:**
|
||||||
|
```bash
|
||||||
|
bd sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** Tracking issue updated with summary."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "complete-and-exit"
|
||||||
|
title = "Complete review and self-clean"
|
||||||
|
needs = ["summarize-review"]
|
||||||
|
description = """
|
||||||
|
Signal completion and clean up. You cease to exist after this step.
|
||||||
|
|
||||||
|
**Self-Cleaning Model:**
|
||||||
|
Once you run `gt done`, you're gone. The command:
|
||||||
|
1. Syncs beads (final sync)
|
||||||
|
2. Nukes your sandbox
|
||||||
|
3. Exits your session immediately
|
||||||
|
|
||||||
|
**Run gt done:**
|
||||||
|
```bash
|
||||||
|
gt done
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens next (not your concern):**
|
||||||
|
- Other polecats may be assigned to fix the issues you found
|
||||||
|
- Witness may escalate critical findings
|
||||||
|
- The codebase improves based on your findings
|
||||||
|
|
||||||
|
You are NOT involved in any of that. You're gone. Done means gone.
|
||||||
|
|
||||||
|
**Exit criteria:** Beads synced, sandbox nuked, session exited."""
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
[vars.scope]
|
||||||
|
description = "What to review - file path, directory, or description"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[vars.issue]
|
||||||
|
description = "The tracking issue for this review task"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[vars.focus]
|
||||||
|
description = "Optional focus area (security, performance, correctness, etc.)"
|
||||||
|
required = false
|
||||||
283
.beads/formulas/mol-polecat-review-pr.formula.toml
Normal file
283
.beads/formulas/mol-polecat-review-pr.formula.toml
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
description = """
|
||||||
|
Review an external PR and decide on merge/reject/revise.
|
||||||
|
|
||||||
|
This molecule guides a polecat through reviewing a pull request from an external
|
||||||
|
contributor. The polecat reviews code quality, tests, and alignment with project
|
||||||
|
standards, then approves, requests changes, or files followup beads.
|
||||||
|
|
||||||
|
## Polecat Contract (Self-Cleaning Model)
|
||||||
|
|
||||||
|
You are a self-cleaning worker. You:
|
||||||
|
1. Receive work via your hook (pinned molecule + PR reference)
|
||||||
|
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||||
|
3. Complete and self-clean via `gt done` (submit findings + nuke yourself)
|
||||||
|
4. You are GONE - your review is recorded in beads
|
||||||
|
|
||||||
|
**Self-cleaning:** When you run `gt done`, you submit your findings, nuke your
|
||||||
|
sandbox, and exit. There is no idle state. Done means gone.
|
||||||
|
|
||||||
|
**Important:** This formula defines the template. Your molecule already has step
|
||||||
|
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||||
|
|
||||||
|
**You do NOT:**
|
||||||
|
- Merge the PR yourself (maintainer or Refinery does that)
|
||||||
|
- Push to the PR branch (it's external)
|
||||||
|
- Wait for contributor response (you're done after review)
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
| Variable | Source | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| pr_url | hook_bead | The PR URL to review |
|
||||||
|
| issue | hook_bead | The tracking issue for this review task |
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
| Situation | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| PR is stale/unmergeable | Note in review, request rebase |
|
||||||
|
| Tests fail | Note in review, request fixes |
|
||||||
|
| Major issues found | File followup beads, request changes |
|
||||||
|
| Unclear requirements | Mail Witness for guidance |"""
|
||||||
|
formula = "mol-polecat-review-pr"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "load-context"
|
||||||
|
title = "Load context and understand the PR"
|
||||||
|
description = """
|
||||||
|
Initialize your session and understand the PR you're reviewing.
|
||||||
|
|
||||||
|
**1. Prime your environment:**
|
||||||
|
```bash
|
||||||
|
gt prime # Load role context
|
||||||
|
bd prime # Load beads context
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Check your hook:**
|
||||||
|
```bash
|
||||||
|
gt hook # Shows your pinned molecule and hook_bead
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook_bead references the PR to review. Read the tracking issue:
|
||||||
|
```bash
|
||||||
|
bd show {{issue}} # Full issue details including PR URL
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Fetch the PR:**
|
||||||
|
```bash
|
||||||
|
gh pr view {{pr_url}} --json title,body,author,files,commits
|
||||||
|
gh pr diff {{pr_url}} # See the actual changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Understand the PR:**
|
||||||
|
- What is the PR trying to accomplish?
|
||||||
|
- What files are changed?
|
||||||
|
- Is there a linked issue?
|
||||||
|
- Does the PR description explain the "why"?
|
||||||
|
|
||||||
|
**5. Check PR status:**
|
||||||
|
```bash
|
||||||
|
gh pr checks {{pr_url}} # CI status
|
||||||
|
gh pr view {{pr_url}} --json mergeable,reviewDecision
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** You understand the PR's purpose and scope."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "review-code"
|
||||||
|
title = "Review the code changes"
|
||||||
|
needs = ["load-context"]
|
||||||
|
description = """
|
||||||
|
Perform a thorough code review of the PR.
|
||||||
|
|
||||||
|
**1. Review the diff systematically:**
|
||||||
|
```bash
|
||||||
|
gh pr diff {{pr_url}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Check for common issues:**
|
||||||
|
|
||||||
|
| Category | Look For |
|
||||||
|
|----------|----------|
|
||||||
|
| Correctness | Logic errors, edge cases, null handling |
|
||||||
|
| Security | Injection, auth bypass, exposed secrets |
|
||||||
|
| Style | Naming, formatting, consistency with codebase |
|
||||||
|
| Tests | Are changes tested? Do tests cover edge cases? |
|
||||||
|
| Docs | Are docs updated if needed? |
|
||||||
|
| Scope | Does PR stay focused? Any scope creep? |
|
||||||
|
|
||||||
|
**3. For each file changed:**
|
||||||
|
- Does the change make sense?
|
||||||
|
- Is it consistent with existing patterns?
|
||||||
|
- Are there any red flags?
|
||||||
|
|
||||||
|
**4. Note issues found:**
|
||||||
|
Keep a running list of:
|
||||||
|
- Blocking issues (must fix before merge)
|
||||||
|
- Suggestions (nice to have)
|
||||||
|
- Questions (need clarification)
|
||||||
|
|
||||||
|
**Exit criteria:** You have reviewed all changes and noted issues."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "check-tests"
|
||||||
|
title = "Verify tests and CI"
|
||||||
|
needs = ["review-code"]
|
||||||
|
description = """
|
||||||
|
Ensure tests pass and coverage is adequate.
|
||||||
|
|
||||||
|
**1. Check CI status:**
|
||||||
|
```bash
|
||||||
|
gh pr checks {{pr_url}}
|
||||||
|
```
|
||||||
|
|
||||||
|
All required checks should pass. If not, note which are failing.
|
||||||
|
|
||||||
|
**2. Review test changes:**
|
||||||
|
- Are there new tests for new functionality?
|
||||||
|
- Do tests cover edge cases?
|
||||||
|
- Are tests readable and maintainable?
|
||||||
|
|
||||||
|
**3. If tests are missing:**
|
||||||
|
Note this as a blocking issue - new code should have tests.
|
||||||
|
|
||||||
|
**4. Check for test-only changes:**
|
||||||
|
If PR is test-only, ensure tests are meaningful and not just
|
||||||
|
padding coverage numbers.
|
||||||
|
|
||||||
|
**Exit criteria:** You've verified test status and coverage."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "make-decision"
|
||||||
|
title = "Decide: approve, request changes, or needs discussion"
|
||||||
|
needs = ["check-tests"]
|
||||||
|
description = """
|
||||||
|
Make your review decision.
|
||||||
|
|
||||||
|
**Decision matrix:**
|
||||||
|
|
||||||
|
| Situation | Decision |
|
||||||
|
|-----------|----------|
|
||||||
|
| Clean code, tests pass, good scope | APPROVE |
|
||||||
|
| Minor issues, easily fixed | REQUEST_CHANGES (with specific feedback) |
|
||||||
|
| Major issues, needs rework | REQUEST_CHANGES (with detailed explanation) |
|
||||||
|
| Unclear requirements or scope | NEEDS_DISCUSSION (mail Witness) |
|
||||||
|
| Security concern | BLOCK (mail Witness immediately) |
|
||||||
|
|
||||||
|
**1. If APPROVE:**
|
||||||
|
The PR is ready to merge. Note any minor suggestions as comments
|
||||||
|
but don't block on them.
|
||||||
|
|
||||||
|
**2. If REQUEST_CHANGES:**
|
||||||
|
Be specific about what needs to change. Provide examples if helpful.
|
||||||
|
The contributor should be able to act on your feedback.
|
||||||
|
|
||||||
|
**3. If NEEDS_DISCUSSION:**
|
||||||
|
```bash
|
||||||
|
gt mail send {{rig}}/witness -s "PR review needs discussion" -m "PR: {{pr_url}}
|
||||||
|
Issue: {{issue}}
|
||||||
|
Question: <what needs clarification>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. If BLOCK (security):**
|
||||||
|
```bash
|
||||||
|
gt mail send {{rig}}/witness -s "SECURITY: PR blocked" -m "PR: {{pr_url}}
|
||||||
|
Issue: {{issue}}
|
||||||
|
Concern: <security issue found>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** You've made a clear decision with rationale."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "submit-review"
|
||||||
|
title = "Submit the review on GitHub"
|
||||||
|
needs = ["make-decision"]
|
||||||
|
description = """
|
||||||
|
Submit your review via GitHub.
|
||||||
|
|
||||||
|
**1. Submit the review:**
|
||||||
|
```bash
|
||||||
|
# For APPROVE:
|
||||||
|
gh pr review {{pr_url}} --approve --body "LGTM. <brief summary of what's good>"
|
||||||
|
|
||||||
|
# For REQUEST_CHANGES:
|
||||||
|
gh pr review {{pr_url}} --request-changes --body "<detailed feedback>"
|
||||||
|
|
||||||
|
# For COMMENT (needs discussion):
|
||||||
|
gh pr review {{pr_url}} --comment --body "<questions or discussion points>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add inline comments if needed:**
|
||||||
|
If you have specific line-by-line feedback, add those via GitHub UI
|
||||||
|
or additional `gh pr comment` calls.
|
||||||
|
|
||||||
|
**Exit criteria:** Review submitted on GitHub."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "file-followups"
|
||||||
|
title = "File beads for any followup work"
|
||||||
|
needs = ["submit-review"]
|
||||||
|
description = """
|
||||||
|
Create beads for any followup work discovered during review.
|
||||||
|
|
||||||
|
**1. For issues found that are outside PR scope:**
|
||||||
|
```bash
|
||||||
|
bd create --type=bug --title="Found during PR review: <description>" \
|
||||||
|
--description="Discovered while reviewing {{pr_url}}.
|
||||||
|
|
||||||
|
<details of the issue>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. For improvements suggested but not required:**
|
||||||
|
```bash
|
||||||
|
bd create --type=task --title="Improvement: <description>" \
|
||||||
|
--description="Suggested during review of {{pr_url}}.
|
||||||
|
|
||||||
|
<details of the improvement>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Update the tracking issue:**
|
||||||
|
```bash
|
||||||
|
bd update {{issue}} --notes "Review complete. Decision: <APPROVE|REQUEST_CHANGES|etc>
|
||||||
|
Followups filed: <list of bead IDs if any>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** All followup work captured as beads."""
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "complete-and-exit"
|
||||||
|
title = "Complete review and self-clean"
|
||||||
|
needs = ["file-followups"]
|
||||||
|
description = """
|
||||||
|
Signal completion and clean up. You cease to exist after this step.
|
||||||
|
|
||||||
|
**Self-Cleaning Model:**
|
||||||
|
Once you run `gt done`, you're gone. The command:
|
||||||
|
1. Syncs beads
|
||||||
|
2. Nukes your sandbox
|
||||||
|
3. Exits your session immediately
|
||||||
|
|
||||||
|
**Run gt done:**
|
||||||
|
```bash
|
||||||
|
bd sync
|
||||||
|
gt done
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens next (not your concern):**
|
||||||
|
- Maintainer or Refinery acts on your review
|
||||||
|
- Contributor responds to feedback
|
||||||
|
- PR gets merged, revised, or closed
|
||||||
|
|
||||||
|
You are NOT involved in any of that. You're gone. Done means gone.
|
||||||
|
|
||||||
|
**Exit criteria:** Beads synced, sandbox nuked, session exited."""
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
[vars.pr_url]
|
||||||
|
description = "The PR URL to review"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[vars.issue]
|
||||||
|
description = "The tracking issue for this review task"
|
||||||
|
required = true
|
||||||
@@ -1,26 +1,29 @@
|
|||||||
description = """
|
description = """
|
||||||
Full polecat work lifecycle from assignment through MR submission.
|
Full polecat work lifecycle from assignment through completion.
|
||||||
|
|
||||||
This molecule guides a polecat through a complete work assignment. Each step
|
This molecule guides a polecat through a complete work assignment. Each step
|
||||||
has clear entry/exit criteria and specific commands to run. A polecat can
|
has clear entry/exit criteria and specific commands to run. A polecat can
|
||||||
crash after any step and resume from the last completed step.
|
crash after any step and resume from the last completed step.
|
||||||
|
|
||||||
## Polecat Contract (Ephemeral Model)
|
## Polecat Contract (Self-Cleaning Model)
|
||||||
|
|
||||||
You are an ephemeral worker. You:
|
You are a self-cleaning worker. You:
|
||||||
1. Receive work via your hook (pinned molecule + issue)
|
1. Receive work via your hook (pinned molecule + issue)
|
||||||
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
2. Work through molecule steps using `bd ready` / `bd close <step>`
|
||||||
3. Submit to merge queue via `gt done`
|
3. Complete and self-clean via `gt done` (submit + nuke yourself)
|
||||||
4. Become recyclable - Refinery handles the rest
|
4. You are GONE - Refinery merges from MQ
|
||||||
|
|
||||||
|
**Self-cleaning:** When you run `gt done`, you push your work, submit to MQ,
|
||||||
|
nuke your sandbox, and exit. There is no idle state. Done means gone.
|
||||||
|
|
||||||
**Important:** This formula defines the template. Your molecule already has step
|
**Important:** This formula defines the template. Your molecule already has step
|
||||||
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
beads created from it. Use `bd ready` to find them - do NOT read this file directly.
|
||||||
|
|
||||||
**You do NOT:**
|
**You do NOT:**
|
||||||
- Push directly to main (Refinery merges)
|
- Push directly to main (Refinery merges from MQ)
|
||||||
- Close your own issue (Refinery closes after merge)
|
- Close your own issue (Refinery closes after merge)
|
||||||
- Wait for merge (you're done at MR submission)
|
- Wait for merge (you're gone after `gt done`)
|
||||||
- Handle rebase conflicts (Refinery dispatches fresh polecats for that)
|
- Handle rebase conflicts (Refinery spawns fresh polecats for that)
|
||||||
|
|
||||||
## Variables
|
## Variables
|
||||||
|
|
||||||
@@ -407,30 +410,23 @@ bd sync
|
|||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
id = "submit-and-exit"
|
id = "submit-and-exit"
|
||||||
title = "Submit to merge queue and exit"
|
title = "Submit work and self-clean"
|
||||||
needs = ["prepare-for-review"]
|
needs = ["prepare-for-review"]
|
||||||
description = """
|
description = """
|
||||||
Submit your work to the merge queue. You become recyclable after this.
|
Submit your work and clean up. You cease to exist after this step.
|
||||||
|
|
||||||
**Ephemeral Polecat Model:**
|
**Self-Cleaning Model:**
|
||||||
Once you submit, you're done. The Refinery will:
|
Once you run `gt done`, you're gone. The command:
|
||||||
1. Process your merge request
|
1. Pushes your branch to origin
|
||||||
2. Handle rebasing (mechanical rebases done automatically)
|
2. Creates an MR bead in the merge queue
|
||||||
3. Close your issue after successful merge
|
3. Nukes your sandbox (worktree removal)
|
||||||
4. Create conflict-resolution tasks if needed (fresh polecat handles those)
|
4. Exits your session immediately
|
||||||
|
|
||||||
**1. Submit with gt done:**
|
**Run gt done:**
|
||||||
```bash
|
```bash
|
||||||
gt done
|
gt done
|
||||||
```
|
```
|
||||||
|
|
||||||
This single command:
|
|
||||||
- Creates an MR bead in the merge queue
|
|
||||||
- Notifies the Witness (POLECAT_DONE)
|
|
||||||
- Updates your agent state to 'done'
|
|
||||||
- Reports cleanup status (ZFC compliance)
|
|
||||||
|
|
||||||
**2. Verify submission:**
|
|
||||||
You should see output like:
|
You should see output like:
|
||||||
```
|
```
|
||||||
✓ Work submitted to merge queue
|
✓ Work submitted to merge queue
|
||||||
@@ -438,20 +434,19 @@ You should see output like:
|
|||||||
Source: polecat/<name>
|
Source: polecat/<name>
|
||||||
Target: main
|
Target: main
|
||||||
Issue: {{issue}}
|
Issue: {{issue}}
|
||||||
|
✓ Sandbox nuked
|
||||||
|
✓ Session exiting
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. You're recyclable:**
|
**What happens next (not your concern):**
|
||||||
Your work is in the queue. The Witness knows you're done.
|
- Refinery processes your MR from the queue
|
||||||
Your sandbox can be cleaned up - all work is pushed to origin.
|
- Refinery rebases and merges to main
|
||||||
|
- Refinery closes the issue
|
||||||
|
- If conflicts: Refinery spawns a FRESH polecat to re-implement
|
||||||
|
|
||||||
If you have context remaining, you may:
|
You are NOT involved in any of that. You're gone. Done means gone.
|
||||||
- Pick up new work from `bd ready`
|
|
||||||
- Or use `gt handoff` to cycle to a fresh session
|
|
||||||
|
|
||||||
If the Refinery needs conflict resolution, it will dispatch a fresh polecat.
|
**Exit criteria:** Work submitted, sandbox nuked, session exited."""
|
||||||
You do NOT need to wait around.
|
|
||||||
|
|
||||||
**Exit criteria:** MR submitted, Witness notified, polecat recyclable."""
|
|
||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
[vars.issue]
|
[vars.issue]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ needs = ["review"]
|
|||||||
title = "Test {{feature}}"
|
title = "Test {{feature}}"
|
||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
description = "Submit for merge. Final check: git status, git diff. Commit with clear message. Push and create PR."
|
description = "Submit for merge. Final check: git status, git diff. Commit with clear message. Follow your role's git workflow for landing code."
|
||||||
id = "submit"
|
id = "submit"
|
||||||
needs = ["test"]
|
needs = ["test"]
|
||||||
title = "Submit for merge"
|
title = "Submit for merge"
|
||||||
|
|||||||
2669
.beads/issues.jsonl
2669
.beads/issues.jsonl
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"beads@beads-marketplace": false
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"matcher": "",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "bash ~/.claude/hooks/session-start.sh && gt nudge deacon session-started"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PreCompact": [
|
|
||||||
{
|
|
||||||
"matcher": "",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "bash ~/.claude/hooks/session-start.sh"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"matcher": "",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "gt mail check --inject"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"matcher": "",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "gt costs record"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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"
|
git config --global user.email "ci@gastown.test"
|
||||||
|
|
||||||
- name: Install beads (bd)
|
- 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
|
- name: Build gt
|
||||||
run: go build -v -o gt ./cmd/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"
|
git config --global user.email "ci@gastown.test"
|
||||||
|
|
||||||
- name: Install beads (bd)
|
- 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
|
- name: Add to PATH
|
||||||
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -42,6 +42,8 @@ state.json
|
|||||||
.beads/mq/
|
.beads/mq/
|
||||||
.beads/last-touched
|
.beads/last-touched
|
||||||
.beads/daemon-*.log.gz
|
.beads/daemon-*.log.gz
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/sync_base.jsonl
|
||||||
.beads-wisp/
|
.beads-wisp/
|
||||||
|
|
||||||
# Clone-specific CLAUDE.md (regenerated locally per clone)
|
# Clone-specific CLAUDE.md (regenerated locally per clone)
|
||||||
|
|||||||
45
AGENTS.md
45
AGENTS.md
@@ -4,47 +4,6 @@ See **CLAUDE.md** for complete agent context and instructions.
|
|||||||
|
|
||||||
This file exists for compatibility with tools that look for AGENTS.md.
|
This file exists for compatibility with tools that look for AGENTS.md.
|
||||||
|
|
||||||
## Landing the Plane (Session Completion)
|
> **Recovery**: Run `gt prime` after compaction, clear, or new session
|
||||||
|
|
||||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
Full context is injected by `gt prime` at session start.
|
||||||
|
|
||||||
**MANDATORY WORKFLOW:**
|
|
||||||
|
|
||||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
|
||||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
|
||||||
3. **Update issue status** - Close finished work, update in-progress items
|
|
||||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
|
||||||
```bash
|
|
||||||
git pull --rebase
|
|
||||||
bd sync
|
|
||||||
git push
|
|
||||||
git status # MUST show "up to date with origin"
|
|
||||||
```
|
|
||||||
5. **Clean up** - Clear stashes, prune remote branches
|
|
||||||
6. **Verify** - All changes committed AND pushed
|
|
||||||
7. **Hand off** - Provide context for next session
|
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
|
||||||
- Work is NOT complete until `git push` succeeds
|
|
||||||
- NEVER stop before pushing - that leaves work stranded locally
|
|
||||||
- NEVER say "ready to push when you are" - YOU must push
|
|
||||||
- If push fails, resolve and retry until it succeeds
|
|
||||||
|
|
||||||
## Dependency Management
|
|
||||||
|
|
||||||
Periodically check for outdated dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go list -m -u all | grep '\['
|
|
||||||
```
|
|
||||||
|
|
||||||
Update direct dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go get <package>@latest
|
|
||||||
go mod tidy
|
|
||||||
go build ./...
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Check release notes for breaking changes before major version bumps.
|
|
||||||
|
|||||||
261
CHANGELOG.md
261
CHANGELOG.md
@@ -7,6 +7,267 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
#### Escalation System
|
||||||
|
- **Unified escalation system** - Complete escalation implementation with severity levels, routing, and tracking (gt-i9r20)
|
||||||
|
- **Escalation config schema alignment** - Configuration now matches design doc specifications
|
||||||
|
|
||||||
|
#### Agent Identity & Management
|
||||||
|
- **`gt polecat identity` subcommand group** - Agent bead management commands for polecat lifecycle
|
||||||
|
- **AGENTS.md fallback copy** - Polecats automatically copy AGENTS.md from mayor/rig for context bootstrapping
|
||||||
|
- **`--debug` flag for `gt crew at`** - Debug mode for crew attachment troubleshooting
|
||||||
|
- **Boot role detection in priming** - Proper context injection for boot role agents (#370)
|
||||||
|
|
||||||
|
#### Statusline Improvements
|
||||||
|
- **Per-agent-type health tracking** - Statusline now shows health status per agent type (#344)
|
||||||
|
- **Visual rig grouping** - Rigs sorted by activity with visual grouping in tmux statusline (#337)
|
||||||
|
|
||||||
|
#### Mail & Communication
|
||||||
|
- **`gt mail show` alias** - Alternative command for reading mail (#340)
|
||||||
|
|
||||||
|
#### Developer Experience
|
||||||
|
- **`gt stale` command** - Check for stale binaries and version mismatches
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Refactored statusline** - Merged session loops and removed dead code for cleaner implementation
|
||||||
|
- **Refactored sling.go** - Split 1560-line file into 7 focused modules for maintainability
|
||||||
|
- **Magic numbers extracted** - Suggest package now uses named constants (#353)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Configuration & Environment
|
||||||
|
- **Empty GT_ROOT/BEADS_DIR not exported** - AgentEnv no longer exports empty environment variables (#385)
|
||||||
|
- **Inherited BEADS_DIR prefix mismatch** - Prevent inherited BEADS_DIR from causing prefix mismatches (#321)
|
||||||
|
|
||||||
|
#### Beads & Routing
|
||||||
|
- **routes.jsonl corruption prevention** - Added protection against routes.jsonl corruption with doctor check for rig-level issues (#377)
|
||||||
|
- **Tracked beads init after clone** - Initialize beads database for tracked beads after git clone (#376)
|
||||||
|
- **Rig root from BeadsPath()** - Correctly return rig root to respect redirect system
|
||||||
|
|
||||||
|
#### Sling & Formula
|
||||||
|
- **Feature and issue vars in formula-on-bead mode** - Pass both variables correctly (#382)
|
||||||
|
- **Crew member shorthand resolution** - Resolve crew members correctly with shorthand paths
|
||||||
|
- **Removed obsolete --naked flag** - Cleanup of deprecated sling option
|
||||||
|
|
||||||
|
#### Doctor & Diagnostics
|
||||||
|
- **Role beads check with shared definitions** - Doctor now validates role beads using shared role definitions (#378)
|
||||||
|
- **Filter bd "Note:" messages** - Custom types check no longer confused by bd informational output (#381)
|
||||||
|
|
||||||
|
#### Installation & Setup
|
||||||
|
- **gt:role label on role beads** - Role beads now properly labeled during creation (#383)
|
||||||
|
- **Fetch origin after refspec config** - Bare clones now fetch after configuring refspec (#384)
|
||||||
|
- **Allow --wrappers in existing town** - No longer recreates HQ unnecessarily (#366)
|
||||||
|
|
||||||
|
#### Session & Lifecycle
|
||||||
|
- **Fallback instructions in start/restart beacons** - Session beacons now include fallback instructions
|
||||||
|
- **Handoff recognizes polecat session pattern** - Correctly handles gt-<rig>-<name> session names (#373)
|
||||||
|
- **gt done resilient to missing agent beads** - No longer fails when agent beads don't exist
|
||||||
|
- **MR beads as ephemeral wisps** - Create MR beads as ephemeral wisps for proper cleanup
|
||||||
|
- **Auto-detect cleanup status** - Prevents premature polecat nuke (#361)
|
||||||
|
- **Delete remote polecat branches after merge** - Refinery cleans up remote branches (#369)
|
||||||
|
|
||||||
|
#### Costs & Events
|
||||||
|
- **Query all beads locations for session events** - Cost tracking finds events across locations (#374)
|
||||||
|
|
||||||
|
#### Linting & Quality
|
||||||
|
- **errcheck and unparam violations resolved** - Fixed linting errors
|
||||||
|
- **NudgeSession for all agent notifications** - Mail now uses consistent notification method
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **Polecat three-state model** - Clarified working/stalled/zombie states
|
||||||
|
- **Name pool vs polecat pool** - Clarified misconception about pools
|
||||||
|
- **Plugin and escalation system designs** - Added design documentation
|
||||||
|
- **Documentation reorganization** - Concepts, design, and examples structure
|
||||||
|
- **gt prime clarification** - Clarified that gt prime is context recovery, not session start (GH #308)
|
||||||
|
- **Formula package documentation** - Comprehensive package docs
|
||||||
|
- **Various godoc additions** - GenerateMRIDWithTime, isAutonomousRole, formatInt, nil sentinel pattern
|
||||||
|
- **Beads issue ID format** - Clarified format in README (gt-uzx2c)
|
||||||
|
- **Stale polecat identity description** - Fixed outdated documentation
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **AGENTS.md worktree tests** - Test coverage for AGENTS.md in worktrees
|
||||||
|
- **Comprehensive test coverage** - Added tests for 5 packages (#351)
|
||||||
|
- **Sling test for bd empty output** - Fixed test for empty output handling
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- **`gt polecat add`** - Added migration warning for deprecated command
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors for this release:
|
||||||
|
- @JeremyKalmus - Various contributions (#364)
|
||||||
|
- @boshu2 - Formula package documentation (#343), PR documentation (#352)
|
||||||
|
- @sauerdaniel - Polecat mail notification fix (#347)
|
||||||
|
- @abhijit360 - Assign model to role (#368)
|
||||||
|
- @julianknutsen - Beads path fix (#334)
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`gt mail mark-read`** - Mark messages as read without opening them (desire path)
|
||||||
|
- **`gt down --polecats`** - Shut down polecats without affecting other components
|
||||||
|
- **Self-cleaning polecat model** - Polecats self-nuke on completion, witness tracks leases
|
||||||
|
- **`gt prime --state` validation** - Flag exclusivity checks for cleaner CLI
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Removed `gt stop`** - Use `gt down --polecats` instead (cleaner semantics)
|
||||||
|
- **Policy-neutral templates** - crew.md.tmpl checks remote origin for PR policy
|
||||||
|
- **Refactored prime.go** - Split 1833-line file into logical modules
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Polecat re-spawn** - CreateOrReopenAgentBead handles polecat lifecycle correctly (#333)
|
||||||
|
- **Vim mode compatibility** - tmux sends Escape before Enter for vim users
|
||||||
|
- **Worktree default branch** - Uses rig's configured default branch (#325)
|
||||||
|
- **Agent bead type** - Sets --type=agent when creating agent beads
|
||||||
|
- **Bootstrap priming** - Reduced AGENTS.md to bootstrap pointer, fixed CLAUDE.md templates
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Updated witness help text for self-cleaning model
|
||||||
|
- Updated daemon comments for self-cleaning model
|
||||||
|
- Policy-aware PR guidance in crew template
|
||||||
|
|
||||||
|
## [0.2.4] - 2026-01-10
|
||||||
|
|
||||||
|
Priming subsystem overhaul and Zero Framework Cognition (ZFC) improvements.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Priming Subsystem
|
||||||
|
- **PRIME.md provisioning** - Auto-provision PRIME.md at rig level so all workers inherit Gas Town context (GUPP, hooks, propulsion) (#hq-5z76w)
|
||||||
|
- **Post-handoff detection** - `gt prime` detects handoff marker and outputs "HANDOFF COMPLETE" warning to prevent handoff loop bug (#hq-ukjrr)
|
||||||
|
- **Priming health checks** - `gt doctor` validates priming subsystem: SessionStart hook, gt prime command, PRIME.md presence, CLAUDE.md size (#hq-5scnt)
|
||||||
|
- **`gt prime --dry-run`** - Preview priming without side effects
|
||||||
|
- **`gt prime --state`** - Output session state (normal, post-handoff, crash-recovery, autonomous)
|
||||||
|
- **`gt prime --explain`** - Add [EXPLAIN] tags for debugging priming decisions
|
||||||
|
|
||||||
|
#### Formula & Configuration
|
||||||
|
- **Rig-level default formulas** - Configure default formula at rig level (#297)
|
||||||
|
- **Witness --agent/--env overrides** - Override agent and environment variables for witness (#293, #294)
|
||||||
|
|
||||||
|
#### Developer Experience
|
||||||
|
- **UX system import** - Comprehensive UX system from beads (#311)
|
||||||
|
- **Explicit handoff instructions** - Clearer nudge message for handoff recipients
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Zero Framework Cognition (ZFC)
|
||||||
|
- **Query tmux directly** - Remove marker TTL, query tmux for agent state
|
||||||
|
- **Remove PID-based detection** - Agent liveness from tmux, not PIDs
|
||||||
|
- **Agent-controlled thresholds** - Stuck detection moved to agent config
|
||||||
|
- **Remove pending.json tracking** - Eliminated anti-pattern
|
||||||
|
- **Derive state from files** - ZFC state from filesystem, not memory cache
|
||||||
|
- **Remove Go-side computation** - No stderr parsing violations
|
||||||
|
|
||||||
|
#### Hooks & Beads
|
||||||
|
- **Cross-level hook visibility** - Hooked beads visible to mayor/deacon (#aeb4c0d)
|
||||||
|
- **Warn on closed hooked bead** - Alert when hooked bead already closed (#2f50a59)
|
||||||
|
- **Correct agent bead ID format** - Fix bd create flags for agent beads (#c4fcdd8)
|
||||||
|
|
||||||
|
#### Formula
|
||||||
|
- **rigPath fallback** - Set rigPath when falling back to gastown default (#afb944f)
|
||||||
|
|
||||||
|
#### Doctor
|
||||||
|
- **Full AgentEnv for env-vars check** - Use complete environment for validation (#ce231a3)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Refactored beads/mail modules** - Split large files into focused modules for maintainability
|
||||||
|
|
||||||
## [0.2.3] - 2026-01-09
|
## [0.2.3] - 2026-01-09
|
||||||
|
|
||||||
Worker safety release - prevents accidental termination of active agents.
|
Worker safety release - prevents accidental termination of active agents.
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -24,6 +24,9 @@ endif
|
|||||||
|
|
||||||
install: build
|
install: build
|
||||||
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
|
cp $(BUILD_DIR)/$(BINARY) ~/.local/bin/$(BINARY)
|
||||||
|
ifeq ($(shell uname),Darwin)
|
||||||
|
@codesign -s - -f ~/.local/bin/$(BINARY) 2>/dev/null || true
|
||||||
|
endif
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BUILD_DIR)/$(BINARY)
|
rm -f $(BUILD_DIR)/$(BINARY)
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -71,12 +71,14 @@ Git worktree-based persistent storage for agent work. Survives crashes and resta
|
|||||||
|
|
||||||
### Convoys 🚚
|
### 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 📿
|
### Beads Integration 📿
|
||||||
|
|
||||||
Git-backed issue tracking system that stores work state as structured data.
|
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.
|
> **New to Gas Town?** See the [Glossary](docs/glossary.md) for a complete guide to terminology and concepts.
|
||||||
|
|
||||||
## Installation
|
## 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/)
|
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
|
||||||
- **Git 2.25+** - for worktree support
|
- **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)
|
- **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
|
- **tmux 3.0+** - recommended for full experience
|
||||||
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
|
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
|
||||||
- **Codex CLI** (optional runtime) - [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli)
|
- **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
|
## 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
|
### Basic Workflow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -127,8 +142,8 @@ sequenceDiagram
|
|||||||
participant Hook
|
participant Hook
|
||||||
|
|
||||||
You->>Mayor: Tell Mayor what to build
|
You->>Mayor: Tell Mayor what to build
|
||||||
Mayor->>Convoy: Create convoy with issues
|
Mayor->>Convoy: Create convoy with beads
|
||||||
Mayor->>Agent: Sling issue to agent
|
Mayor->>Agent: Sling bead to agent
|
||||||
Agent->>Hook: Store work state
|
Agent->>Hook: Store work state
|
||||||
Agent->>Agent: Complete work
|
Agent->>Agent: Complete work
|
||||||
Agent->>Convoy: Report completion
|
Agent->>Convoy: Report completion
|
||||||
@@ -141,11 +156,11 @@ sequenceDiagram
|
|||||||
# 1. Start the Mayor
|
# 1. Start the Mayor
|
||||||
gt mayor attach
|
gt mayor attach
|
||||||
|
|
||||||
# 2. In Mayor session, create a convoy
|
# 2. In Mayor session, create a convoy with bead IDs
|
||||||
gt convoy create "Feature X" issue-123 issue-456 --notify --human
|
gt convoy create "Feature X" gt-abc12 gt-def34 --notify --human
|
||||||
|
|
||||||
# 3. Assign work to an agent
|
# 3. Assign work to an agent
|
||||||
gt sling issue-123 myproject
|
gt sling gt-abc12 myproject
|
||||||
|
|
||||||
# 4. Track progress
|
# 4. Track progress
|
||||||
gt convoy list
|
gt convoy list
|
||||||
@@ -177,7 +192,7 @@ flowchart LR
|
|||||||
gt mayor attach
|
gt mayor attach
|
||||||
|
|
||||||
# In Mayor, create convoy and let it orchestrate
|
# 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
|
# Track progress
|
||||||
gt convoy list
|
gt convoy list
|
||||||
@@ -188,8 +203,8 @@ gt convoy list
|
|||||||
Run individual runtime instances manually. Gas Town just tracks state.
|
Run individual runtime instances manually. Gas Town just tracks state.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gt convoy create "Fix bugs" issue-123 # Create convoy (sling auto-creates if skipped)
|
gt convoy create "Fix bugs" gt-abc12 # Create convoy (sling auto-creates if skipped)
|
||||||
gt sling issue-123 myproject # Assign to worker
|
gt sling gt-abc12 myproject # Assign to worker
|
||||||
claude --resume # Agent reads mail, runs work (Claude)
|
claude --resume # Agent reads mail, runs work (Claude)
|
||||||
# or: codex # Start Codex in the workspace
|
# or: codex # Start Codex in the workspace
|
||||||
gt convoy list # Check progress
|
gt convoy list # Check progress
|
||||||
@@ -263,11 +278,11 @@ bd mol pour release --var version=1.2.0
|
|||||||
# Create convoy manually
|
# Create convoy manually
|
||||||
gt convoy create "Bug Fixes" --human
|
gt convoy create "Bug Fixes" --human
|
||||||
|
|
||||||
# Add issues
|
# Add issues to existing convoy
|
||||||
gt convoy add-issue bug-101 bug-102
|
gt convoy add hq-cv-abc gt-m3k9p gt-w5t2x
|
||||||
|
|
||||||
# Assign to specific agents
|
# Assign to specific agents
|
||||||
gt sling bug-101 myproject/my-agent
|
gt sling gt-m3k9p myproject/my-agent
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
gt convoy show
|
gt convoy show
|
||||||
@@ -312,11 +327,11 @@ gt crew add <name> --rig <rig> # Create crew workspace
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
gt agents # List active agents
|
gt agents # List active agents
|
||||||
gt sling <issue> <rig> # Assign work to agent
|
gt sling <bead-id> <rig> # Assign work to agent
|
||||||
gt sling <issue> <rig> --agent cursor # Override runtime for this sling/spawn
|
gt sling <bead-id> <rig> --agent cursor # Override runtime for this sling/spawn
|
||||||
gt mayor attach # Start Mayor session
|
gt mayor attach # Start Mayor session
|
||||||
gt mayor start --agent auggie # Run Mayor with a specific agent alias
|
gt mayor start --agent auggie # Run Mayor with a specific agent alias
|
||||||
gt prime # Alternative to mayor attach
|
gt prime # Context recovery (run inside existing session)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Built-in agent presets**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
**Built-in agent presets**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
|
||||||
@@ -324,10 +339,10 @@ gt prime # Alternative to mayor attach
|
|||||||
### Convoy (Work Tracking)
|
### Convoy (Work Tracking)
|
||||||
|
|
||||||
```bash
|
```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 list # List all convoys
|
||||||
gt convoy show [id] # Show convoy details
|
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
|
### Configuration
|
||||||
@@ -406,9 +421,9 @@ MEOW is the recommended pattern:
|
|||||||
|
|
||||||
1. **Tell the Mayor** - Describe what you want
|
1. **Tell the Mayor** - Describe what you want
|
||||||
2. **Mayor analyzes** - Breaks down into tasks
|
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
|
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
|
6. **Progress monitoring** - Track through convoy status
|
||||||
7. **Completion** - Mayor summarizes results
|
7. **Completion** - Mayor summarizes results
|
||||||
|
|
||||||
@@ -475,7 +490,3 @@ gt mayor attach
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Getting Started:** Run `gt install ~/gt --git && cd ~/gt && gt config agent list && gt mayor attach` (or `gt mayor attach --agent codex`) and tell the Mayor what you want to build!
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ You can also override the agent per command without changing defaults:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
gt start --agent codex-low
|
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
|
## Minimal Mode vs Full Stack Mode
|
||||||
@@ -165,8 +165,8 @@ Run individual runtime instances manually. Gas Town only tracks state.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create and assign work
|
# Create and assign work
|
||||||
gt convoy create "Fix bugs" issue-123
|
gt convoy create "Fix bugs" gt-abc12
|
||||||
gt sling issue-123 myproject
|
gt sling gt-abc12 myproject
|
||||||
|
|
||||||
# Run runtime manually
|
# Run runtime manually
|
||||||
cd ~/gt/myproject/polecats/<worker>
|
cd ~/gt/myproject/polecats/<worker>
|
||||||
@@ -188,9 +188,9 @@ Agents run in tmux sessions. Daemon manages lifecycle automatically.
|
|||||||
gt daemon start
|
gt daemon start
|
||||||
|
|
||||||
# Create and assign work (workers spawn automatically)
|
# Create and assign work (workers spawn automatically)
|
||||||
gt convoy create "Feature X" issue-123 issue-456
|
gt convoy create "Feature X" gt-abc12 gt-def34
|
||||||
gt sling issue-123 myproject
|
gt sling gt-abc12 myproject
|
||||||
gt sling issue-456 myproject
|
gt sling gt-def34 myproject
|
||||||
|
|
||||||
# Monitor on dashboard
|
# Monitor on dashboard
|
||||||
gt convoy list
|
gt convoy list
|
||||||
@@ -303,6 +303,6 @@ rm -rf ~/gt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
1. **Read the README** - Core concepts and workflows
|
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
|
3. **Explore docs** - `docs/reference.md` for command reference
|
||||||
4. **Run doctor regularly** - `gt doctor` catches problems early
|
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/
|
||||||
|
```
|
||||||
@@ -223,4 +223,4 @@ Use rig status for "what's everyone in this rig working on?"
|
|||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [Propulsion Principle](propulsion-principle.md) - Worker execution model
|
- [Propulsion Principle](propulsion-principle.md) - Worker execution model
|
||||||
- [Mail Protocol](mail-protocol.md) - Notification delivery
|
- [Mail Protocol](../design/mail-protocol.md) - Notification delivery
|
||||||
@@ -88,15 +88,37 @@ All events include actor attribution:
|
|||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
The daemon sets these automatically when spawning agents:
|
Gas Town uses a centralized `config.AgentEnv()` function to set environment
|
||||||
|
variables consistently across all agent spawn paths (managers, daemon, boot).
|
||||||
|
|
||||||
|
### Example: Polecat Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set by daemon for polecat 'toast' in rig 'gastown'
|
# Set automatically for polecat 'toast' in rig 'gastown'
|
||||||
export BD_ACTOR="gastown/polecats/toast"
|
|
||||||
export GIT_AUTHOR_NAME="gastown/polecats/toast"
|
|
||||||
export GT_ROLE="polecat"
|
export GT_ROLE="polecat"
|
||||||
export GT_RIG="gastown"
|
export GT_RIG="gastown"
|
||||||
export GT_POLECAT="toast"
|
export GT_POLECAT="toast"
|
||||||
|
export BD_ACTOR="gastown/polecats/toast"
|
||||||
|
export GIT_AUTHOR_NAME="gastown/polecats/toast"
|
||||||
|
export GT_ROOT="/home/user/gt"
|
||||||
|
export BEADS_DIR="/home/user/gt/gastown/.beads"
|
||||||
|
export BEADS_AGENT_NAME="gastown/toast"
|
||||||
|
export BEADS_NO_DAEMON="1" # Polecats use isolated beads context
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Crew Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set automatically for crew member 'joe' in rig 'gastown'
|
||||||
|
export GT_ROLE="crew"
|
||||||
|
export GT_RIG="gastown"
|
||||||
|
export GT_CREW="joe"
|
||||||
|
export BD_ACTOR="gastown/crew/joe"
|
||||||
|
export GIT_AUTHOR_NAME="gastown/crew/joe"
|
||||||
|
export GT_ROOT="/home/user/gt"
|
||||||
|
export BEADS_DIR="/home/user/gt/gastown/.beads"
|
||||||
|
export BEADS_AGENT_NAME="gastown/joe"
|
||||||
|
export BEADS_NO_DAEMON="1" # Crew uses isolated beads context
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Override
|
### Manual Override
|
||||||
@@ -108,6 +130,9 @@ export BD_ACTOR="gastown/crew/debug"
|
|||||||
bd create --title="Test issue" # Will show created_by: gastown/crew/debug
|
bd create --title="Test issue" # Will show created_by: gastown/crew/debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [reference.md](reference.md#environment-variables) for the complete
|
||||||
|
environment variable reference.
|
||||||
|
|
||||||
## Identity Parsing
|
## Identity Parsing
|
||||||
|
|
||||||
The format supports programmatic parsing:
|
The format supports programmatic parsing:
|
||||||
@@ -180,13 +205,22 @@ steve@example.com ← global identity (from git author)
|
|||||||
|
|
||||||
**Agents execute. Humans own.** The polecat name in `completed-by: gastown/polecats/toast` is executor attribution. The CV credits the human owner (`steve@example.com`).
|
**Agents execute. Humans own.** The polecat name in `completed-by: gastown/polecats/toast` is executor attribution. The CV credits the human owner (`steve@example.com`).
|
||||||
|
|
||||||
### Polecats Are Ephemeral
|
### Polecats Have Persistent Identities
|
||||||
|
|
||||||
Polecats are like K8s pods - ephemeral executors with no persistent identity:
|
Polecats have **persistent identities but ephemeral sessions**. Like employees who
|
||||||
- Named pool for human convenience (furiosa, nux, slit)
|
clock in/out: each work session is fresh (new tmux, new worktree), but the identity
|
||||||
- Names are transient - reused after cleanup
|
persists across sessions.
|
||||||
- No persistent polecat CV
|
|
||||||
- Work credits the human owner
|
- **Identity (persistent)**: Agent bead, CV chain, work history
|
||||||
|
- **Session (ephemeral)**: Claude instance, context window
|
||||||
|
- **Sandbox (ephemeral)**: Git worktree, branch
|
||||||
|
|
||||||
|
Work credits the polecat identity, enabling:
|
||||||
|
- Performance tracking per polecat
|
||||||
|
- Capability-based routing (send Go work to polecats with Go track records)
|
||||||
|
- Model comparison (A/B test different models via different polecats)
|
||||||
|
|
||||||
|
See [polecat-lifecycle.md](polecat-lifecycle.md#polecat-identity) for details.
|
||||||
|
|
||||||
### Skills Are Derived
|
### Skills Are Derived
|
||||||
|
|
||||||
@@ -154,6 +154,50 @@ gt mol squash # Squash attached molecule
|
|||||||
gt mol step done <step> # Complete a molecule step
|
gt mol step done <step> # Complete a molecule step
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Polecat Workflow
|
||||||
|
|
||||||
|
Polecats receive work via their hook - a pinned molecule attached to an issue.
|
||||||
|
They execute molecule steps sequentially, closing each step as they complete it.
|
||||||
|
|
||||||
|
### Molecule Types for Polecats
|
||||||
|
|
||||||
|
| Type | Storage | Use Case |
|
||||||
|
|------|---------|----------|
|
||||||
|
| **Regular Molecule** | `.beads/` (synced) | Discrete deliverables, audit trail |
|
||||||
|
| **Wisp** | `.beads/` (ephemeral) | Patrol cycles, operational loops |
|
||||||
|
|
||||||
|
Polecats typically use **regular molecules** because each assignment has audit value.
|
||||||
|
Patrol agents (Witness, Refinery, Deacon) use **wisps** to prevent accumulation.
|
||||||
|
|
||||||
|
### Hook Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt hook # What's on MY hook?
|
||||||
|
gt mol attach-from-mail <id> # Attach work from mail message
|
||||||
|
gt done # Signal completion (syncs, submits to MQ, notifies Witness)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polecat Workflow Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Spawn with work on hook
|
||||||
|
2. gt hook # What's hooked?
|
||||||
|
3. bd mol current # Where am I?
|
||||||
|
4. Execute current step
|
||||||
|
5. bd close <step> --continue
|
||||||
|
6. If more steps: GOTO 3
|
||||||
|
7. gt done # Signal completion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wisp vs Molecule Decision
|
||||||
|
|
||||||
|
| Question | Molecule | Wisp |
|
||||||
|
|----------|----------|------|
|
||||||
|
| Does it need audit trail? | Yes | No |
|
||||||
|
| Will it repeat continuously? | No | Yes |
|
||||||
|
| Is it discrete deliverable? | Yes | No |
|
||||||
|
| Is it operational routine? | No | Yes |
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
1. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
||||||
@@ -5,8 +5,56 @@
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Polecats have three distinct lifecycle layers that operate independently. Confusing
|
Polecats have three distinct lifecycle layers that operate independently. Confusing
|
||||||
these layers leads to heresies like "idle polecats" and misunderstanding when
|
these layers leads to "heresies" like thinking there are "idle polecats" and
|
||||||
recycling occurs.
|
misunderstanding when recycling occurs.
|
||||||
|
|
||||||
|
## The Three Operating States
|
||||||
|
|
||||||
|
Polecats have exactly three operating states. There is **no idle pool**.
|
||||||
|
|
||||||
|
| State | Description | How it happens |
|
||||||
|
|-------|-------------|----------------|
|
||||||
|
| **Working** | Actively doing assigned work | Normal operation |
|
||||||
|
| **Stalled** | Session stopped mid-work | Interrupted, crashed, or timed out without being nudged |
|
||||||
|
| **Zombie** | Completed work but failed to die | `gt done` failed during cleanup |
|
||||||
|
|
||||||
|
**The key distinction:** Zombies completed their work; stalled polecats did not.
|
||||||
|
|
||||||
|
- **Stalled** = supposed to be working, but stopped. The polecat was interrupted or
|
||||||
|
crashed and was never nudged back to life. Work is incomplete.
|
||||||
|
- **Zombie** = finished work, tried to exit via `gt done`, but cleanup failed. The
|
||||||
|
session should have shut down but didn't. Work is complete, just stuck in limbo.
|
||||||
|
|
||||||
|
There is no "idle" state. Polecats don't wait around between tasks. When work is
|
||||||
|
done, `gt done` shuts down the session. If you see a non-working polecat, something
|
||||||
|
is broken.
|
||||||
|
|
||||||
|
## The Self-Cleaning Polecat Model
|
||||||
|
|
||||||
|
**Polecats are responsible for their own cleanup.** When a polecat completes its
|
||||||
|
work unit, it:
|
||||||
|
|
||||||
|
1. Signals completion via `gt done`
|
||||||
|
2. Exits its session immediately (no idle waiting)
|
||||||
|
3. Requests its own nuke (self-delete)
|
||||||
|
|
||||||
|
This removes dependency on the Witness/Deacon for cleanup and ensures polecats
|
||||||
|
never sit idle. The simple model: **sandbox dies with session**.
|
||||||
|
|
||||||
|
### Why Self-Cleaning?
|
||||||
|
|
||||||
|
- **No idle polecats** - There's no state where a polecat exists without work
|
||||||
|
- **Reduced watchdog overhead** - Deacon patrols for stalled/zombie polecats, not idle ones
|
||||||
|
- **Faster turnover** - Resources freed immediately on completion
|
||||||
|
- **Simpler mental model** - Done means gone
|
||||||
|
|
||||||
|
### What About Pending Merges?
|
||||||
|
|
||||||
|
The Refinery owns the merge queue. Once `gt done` submits work:
|
||||||
|
- The branch is pushed to origin
|
||||||
|
- Work exists in the MQ, not in the polecat
|
||||||
|
- If rebase fails, Refinery re-implements on new baseline (fresh polecat)
|
||||||
|
- The original polecat is already gone - no sending work "back"
|
||||||
|
|
||||||
## The Three Layers
|
## The Three Layers
|
||||||
|
|
||||||
@@ -92,19 +140,23 @@ The slot:
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ gt done │
|
│ gt done (self-cleaning) │
|
||||||
│ → Polecat signals completion to Witness │
|
│ → Push branch to origin │
|
||||||
│ → Session exits (no idle waiting) │
|
│ → Submit work to merge queue (MR bead) │
|
||||||
│ → Witness receives POLECAT_DONE event │
|
│ → Request self-nuke (sandbox + session cleanup) │
|
||||||
|
│ → Exit immediately │
|
||||||
|
│ │
|
||||||
|
│ Work now lives in MQ, not in polecat. │
|
||||||
|
│ Polecat is GONE. No idle state. │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Witness: gt polecat nuke │
|
│ Refinery: merge queue │
|
||||||
│ → Verify work landed (merged or in MQ) │
|
│ → Rebase and merge to main │
|
||||||
│ → Delete sandbox (remove worktree) │
|
│ → Close the issue │
|
||||||
│ → Kill tmux session │
|
│ → If conflict: spawn FRESH polecat to re-implement │
|
||||||
│ → Release slot back to pool │
|
│ (never send work back to original polecat - it's gone) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,19 +179,24 @@ during normal operation.
|
|||||||
|
|
||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
|
|
||||||
### Idle Polecats
|
### "Idle" Polecats (They Don't Exist)
|
||||||
|
|
||||||
**Myth:** Polecats wait between tasks in an idle state.
|
**Myth:** Polecats wait between tasks in an idle pool.
|
||||||
|
|
||||||
**Reality:** Polecats don't exist without work. The lifecycle is:
|
**Reality:** There is no idle state. Polecats don't exist without work:
|
||||||
1. Work assigned → polecat spawned
|
1. Work assigned → polecat spawned
|
||||||
2. Work done → polecat nuked
|
2. Work done → `gt done` → session exits → polecat nuked
|
||||||
3. There is no idle state
|
3. There is no step 3 where they wait around
|
||||||
|
|
||||||
If you see a polecat without work, something is broken. Either:
|
If you see a non-working polecat, it's in a **failure state**:
|
||||||
- The hook was lost (bug)
|
|
||||||
- The session crashed before loading context
|
| What you see | What it is | What went wrong |
|
||||||
- Manual intervention corrupted state
|
|--------------|------------|-----------------|
|
||||||
|
| Session exists but not working | **Stalled** | Interrupted/crashed, never nudged |
|
||||||
|
| Session done but didn't exit | **Zombie** | `gt done` failed during cleanup |
|
||||||
|
|
||||||
|
Don't call these "idle" - that implies they're waiting for work. They're not.
|
||||||
|
A stalled polecat is *supposed* to be working. A zombie is *supposed* to be dead.
|
||||||
|
|
||||||
### Manual State Transitions
|
### Manual State Transitions
|
||||||
|
|
||||||
@@ -161,20 +218,23 @@ gt polecat nuke Toast # (from Witness, after verification)
|
|||||||
Polecats manage their own session lifecycle. The Witness manages sandbox lifecycle.
|
Polecats manage their own session lifecycle. The Witness manages sandbox lifecycle.
|
||||||
External manipulation bypasses verification.
|
External manipulation bypasses verification.
|
||||||
|
|
||||||
### Sandboxes Without Work
|
### Sandboxes Without Work (Stalled Polecats)
|
||||||
|
|
||||||
**Anti-pattern:** A sandbox exists but no molecule is hooked.
|
**Anti-pattern:** A sandbox exists but no molecule is hooked, or the session isn't running.
|
||||||
|
|
||||||
This means:
|
This is a **stalled** polecat. It means:
|
||||||
- The polecat was spawned incorrectly
|
- The session crashed and wasn't nudged back to life
|
||||||
- The hook was lost during crash
|
- The hook was lost during a crash
|
||||||
- State corruption occurred
|
- State corruption occurred
|
||||||
|
|
||||||
|
This is NOT an "idle" polecat waiting for work. It's stalled - supposed to be
|
||||||
|
working but stopped unexpectedly.
|
||||||
|
|
||||||
**Recovery:**
|
**Recovery:**
|
||||||
```bash
|
```bash
|
||||||
# From Witness:
|
# From Witness:
|
||||||
gt polecat nuke Toast # Clean slate
|
gt polecat nuke Toast # Clean up the stalled polecat
|
||||||
gt sling gt-abc gastown # Respawn with work
|
gt sling gt-abc gastown # Respawn with fresh polecat
|
||||||
```
|
```
|
||||||
|
|
||||||
### Confusing Session with Sandbox
|
### Confusing Session with Sandbox
|
||||||
@@ -210,16 +270,43 @@ All except `gt done` result in continued work. Only `gt done` signals completion
|
|||||||
The Witness monitors polecats but does NOT:
|
The Witness monitors polecats but does NOT:
|
||||||
- Force session cycles (polecats self-manage via handoff)
|
- Force session cycles (polecats self-manage via handoff)
|
||||||
- Interrupt mid-step (unless truly stuck)
|
- Interrupt mid-step (unless truly stuck)
|
||||||
- Recycle sandboxes between steps
|
- Nuke polecats (polecats self-nuke via `gt done`)
|
||||||
|
|
||||||
The Witness DOES:
|
The Witness DOES:
|
||||||
|
- Detect and nudge stalled polecats (sessions that stopped unexpectedly)
|
||||||
|
- Clean up zombie polecats (sessions where `gt done` failed)
|
||||||
- Respawn crashed sessions
|
- Respawn crashed sessions
|
||||||
- Nudge stuck polecats
|
- Handle escalations from stuck polecats (polecats that explicitly asked for help)
|
||||||
- Nuke completed polecats (after verification)
|
|
||||||
- Handle escalations
|
## Polecat Identity
|
||||||
|
|
||||||
|
**Key insight:** Polecat *identity* is long-lived; only sessions and sandboxes are ephemeral.
|
||||||
|
|
||||||
|
In the HOP model, every entity has a chain (CV) that tracks:
|
||||||
|
- What work they've done
|
||||||
|
- Success/failure rates
|
||||||
|
- Skills demonstrated
|
||||||
|
- Quality metrics
|
||||||
|
|
||||||
|
The polecat *name* (Toast, Shadow, etc.) is a slot from a pool - truly ephemeral.
|
||||||
|
But the *agent identity* that executes as that polecat accumulates a work history.
|
||||||
|
|
||||||
|
```
|
||||||
|
POLECAT IDENTITY (persistent) SESSION (ephemeral) SANDBOX (ephemeral)
|
||||||
|
├── CV chain ├── Claude instance ├── Git worktree
|
||||||
|
├── Work history ├── Context window ├── Branch
|
||||||
|
├── Skills demonstrated └── Dies on handoff └── Dies on gt done
|
||||||
|
└── Credit for work or gt done
|
||||||
|
```
|
||||||
|
|
||||||
|
This distinction matters for:
|
||||||
|
- **Attribution** - Who gets credit for the work?
|
||||||
|
- **Skill routing** - Which agent is best for this task?
|
||||||
|
- **Cost accounting** - Who pays for inference?
|
||||||
|
- **Federation** - Agents having their own chains in a distributed world
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Understanding Gas Town](understanding-gas-town.md) - Role taxonomy and architecture
|
- [Overview](../overview.md) - Role taxonomy and architecture
|
||||||
- [Polecat Wisp Architecture](polecat-wisp-architecture.md) - Molecule execution
|
- [Molecules](molecules.md) - Molecule execution and polecat workflow
|
||||||
- [Propulsion Principle](propulsion-principle.md) - Why work triggers immediate execution
|
- [Propulsion Principle](propulsion-principle.md) - Why work triggers immediate execution
|
||||||
@@ -125,6 +125,6 @@ bd show gt-xyz # Routes to gastown/mayor/rig/.beads
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [reference.md](reference.md) - Command reference
|
- [reference.md](../reference.md) - Command reference
|
||||||
- [molecules.md](molecules.md) - Workflow molecules
|
- [molecules.md](../concepts/molecules.md) - Workflow molecules
|
||||||
- [identity.md](identity.md) - Agent identity and BD_ACTOR
|
- [identity.md](../concepts/identity.md) - Agent identity and BD_ACTOR
|
||||||
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
|
||||||
@@ -306,7 +306,7 @@ Rationale:
|
|||||||
What dogs DO share:
|
What dogs DO share:
|
||||||
- tmux utilities for message sending/capture
|
- tmux utilities for message sending/capture
|
||||||
- State file patterns
|
- State file patterns
|
||||||
- Pool allocation pattern
|
- Name slot allocation pattern (pool of names, not instances)
|
||||||
|
|
||||||
### Dog Execution Loop
|
### Dog Execution Loop
|
||||||
|
|
||||||
576
docs/design/escalation-system.md
Normal file
576
docs/design/escalation-system.md
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
# Escalation System Design
|
||||||
|
|
||||||
|
> Detailed design for the Gas Town unified escalation system.
|
||||||
|
> Written 2026-01-11, crew/george session.
|
||||||
|
> Parent epic: gt-i9r20
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Current escalation is ad-hoc "mail Mayor". Issues:
|
||||||
|
- Mayor gets backlogged easily (especially during swarms)
|
||||||
|
- No severity differentiation
|
||||||
|
- No alternative channels (email, SMS, Slack)
|
||||||
|
- No tracking of stale/unacknowledged escalations
|
||||||
|
- No visibility into escalation history
|
||||||
|
|
||||||
|
## Design Goals
|
||||||
|
|
||||||
|
1. **Unified API**: Single `gt escalate` command for all escalation needs
|
||||||
|
2. **Severity-based routing**: Different severities go to different channels
|
||||||
|
3. **Config-driven**: Town config controls routing, no code changes needed
|
||||||
|
4. **Audit trail**: All escalations tracked as beads
|
||||||
|
5. **Stale detection**: Unacknowledged escalations re-escalate automatically
|
||||||
|
6. **Extensible**: Easy to add new notification channels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ gt escalate command │
|
||||||
|
│ --severity --subject --body --source │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Escalation Manager │
|
||||||
|
│ 1. Read config (settings/escalation.json) │
|
||||||
|
│ 2. Create escalation bead │
|
||||||
|
│ 3. Execute route actions for severity │
|
||||||
|
└─────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┼───────────┬───────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────┐ ┌─────────┐ ┌───────┐ ┌───────┐
|
||||||
|
│ Bead │ │ Mail │ │ Email │ │ SMS │
|
||||||
|
│Create │ │ Action │ │Action │ │Action │
|
||||||
|
└───────┘ └─────────┘ └───────┘ └───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. Agent calls `gt escalate --severity=high --subject="..." --body="..."`
|
||||||
|
2. Command loads escalation config from `settings/escalation.json`
|
||||||
|
3. Creates escalation bead with severity, subject, body, source labels
|
||||||
|
4. Looks up route for severity level
|
||||||
|
5. Executes each action in the route (bead already created, then mail, email, etc.)
|
||||||
|
6. Returns escalation bead ID
|
||||||
|
|
||||||
|
### Stale Escalation Flow
|
||||||
|
|
||||||
|
1. Deacon patrol (or plugin) runs `gt escalate stale`
|
||||||
|
2. Queries for escalation beads older than threshold without `acknowledged:true`
|
||||||
|
3. For each stale escalation:
|
||||||
|
- Bump severity (low→medium, medium→high, high→critical)
|
||||||
|
- Re-execute route for new severity
|
||||||
|
- Add `reescalated:true` label and timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### File Location
|
||||||
|
|
||||||
|
`~/gt/settings/escalation.json`
|
||||||
|
|
||||||
|
This follows the existing pattern where `~/gt/settings/` contains town-level behavioral config.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```go
|
||||||
|
// EscalationConfig represents escalation routing configuration.
|
||||||
|
type EscalationConfig struct {
|
||||||
|
Type string `json:"type"` // "escalation"
|
||||||
|
Version int `json:"version"` // schema version
|
||||||
|
|
||||||
|
// Routes maps severity levels to action lists.
|
||||||
|
// Actions are executed in order.
|
||||||
|
Routes map[string][]string `json:"routes"`
|
||||||
|
|
||||||
|
// Contacts contains contact information for actions.
|
||||||
|
Contacts EscalationContacts `json:"contacts"`
|
||||||
|
|
||||||
|
// StaleThreshold is how long before an unacknowledged escalation
|
||||||
|
// is considered stale and gets re-escalated. Default: "4h"
|
||||||
|
StaleThreshold string `json:"stale_threshold,omitempty"`
|
||||||
|
|
||||||
|
// MaxReescalations limits how many times an escalation can be
|
||||||
|
// re-escalated. Default: 2 (low→medium→high, then stops)
|
||||||
|
MaxReescalations int `json:"max_reescalations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationContacts contains contact information.
|
||||||
|
type EscalationContacts struct {
|
||||||
|
HumanEmail string `json:"human_email,omitempty"`
|
||||||
|
HumanSMS string `json:"human_sms,omitempty"`
|
||||||
|
SlackWebhook string `json:"slack_webhook,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const CurrentEscalationVersion = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "escalation",
|
||||||
|
"version": 1,
|
||||||
|
"routes": {
|
||||||
|
"low": ["bead"],
|
||||||
|
"medium": ["bead", "mail:mayor"],
|
||||||
|
"high": ["bead", "mail:mayor", "email:human"],
|
||||||
|
"critical": ["bead", "mail:mayor", "email:human", "sms:human"]
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"human_email": "",
|
||||||
|
"human_sms": ""
|
||||||
|
},
|
||||||
|
"stale_threshold": "4h",
|
||||||
|
"max_reescalations": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Types
|
||||||
|
|
||||||
|
| Action | Format | Behavior |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| `bead` | `bead` | Create escalation bead (always first, implicit) |
|
||||||
|
| `mail:<target>` | `mail:mayor` | Send gt mail to target |
|
||||||
|
| `email:human` | `email:human` | Send email to `contacts.human_email` |
|
||||||
|
| `sms:human` | `sms:human` | Send SMS to `contacts.human_sms` |
|
||||||
|
| `slack` | `slack` | Post to `contacts.slack_webhook` |
|
||||||
|
| `log` | `log` | Write to escalation log file |
|
||||||
|
|
||||||
|
### Severity Levels
|
||||||
|
|
||||||
|
| Level | Use Case | Default Route |
|
||||||
|
|-------|----------|---------------|
|
||||||
|
| `low` | Informational, non-urgent | bead only |
|
||||||
|
| `medium` | Needs attention soon | bead + mail mayor |
|
||||||
|
| `high` | Urgent, needs human | bead + mail + email |
|
||||||
|
| `critical` | Emergency, immediate | bead + mail + email + SMS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Escalation Beads
|
||||||
|
|
||||||
|
### Bead Format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: gt-esc-abc123
|
||||||
|
type: escalation
|
||||||
|
status: open
|
||||||
|
title: "Plugin FAILED: rebuild-gt"
|
||||||
|
labels:
|
||||||
|
- severity:high
|
||||||
|
- source:plugin:rebuild-gt
|
||||||
|
- acknowledged:false
|
||||||
|
- reescalated:false
|
||||||
|
- reescalation_count:0
|
||||||
|
description: |
|
||||||
|
Build failed: make returned exit code 2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Source: plugin:rebuild-gt
|
||||||
|
- Original severity: medium
|
||||||
|
- Escalated at: 2026-01-11T19:00:00Z
|
||||||
|
created_at: 2026-01-11T15:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
### Label Schema
|
||||||
|
|
||||||
|
| Label | Values | Purpose |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `severity:<level>` | low, medium, high, critical | Current severity |
|
||||||
|
| `source:<type>:<name>` | plugin:rebuild-gt, patrol:deacon | What triggered it |
|
||||||
|
| `acknowledged:<bool>` | true, false | Has human acknowledged |
|
||||||
|
| `reescalated:<bool>` | true, false | Has been re-escalated |
|
||||||
|
| `reescalation_count:<n>` | 0, 1, 2, ... | Times re-escalated |
|
||||||
|
| `original_severity:<level>` | low, medium, high | Initial severity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### gt escalate
|
||||||
|
|
||||||
|
Create a new escalation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate \
|
||||||
|
--severity=<low|medium|high|critical> \
|
||||||
|
--subject="Short description" \
|
||||||
|
--body="Detailed explanation" \
|
||||||
|
[--source="plugin:rebuild-gt"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- `--severity` (required): Escalation severity level
|
||||||
|
- `--subject` (required): Short description (becomes bead title)
|
||||||
|
- `--body` (required): Detailed explanation (becomes bead description)
|
||||||
|
- `--source`: Source identifier for tracking (e.g., "plugin:rebuild-gt")
|
||||||
|
- `--dry-run`: Show what would happen without executing
|
||||||
|
- `--json`: Output escalation bead ID as JSON
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
- 0: Success
|
||||||
|
- 1: Config error or invalid flags
|
||||||
|
- 2: Action failed (e.g., email send failed)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
gt escalate \
|
||||||
|
--severity=high \
|
||||||
|
--subject="Plugin FAILED: rebuild-gt" \
|
||||||
|
--body="Build failed: make returned exit code 2. Working directory: ~/gt/gastown/crew/george" \
|
||||||
|
--source="plugin:rebuild-gt"
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# ✓ Created escalation gt-esc-abc123 (severity: high)
|
||||||
|
# → Created bead
|
||||||
|
# → Mailed mayor/
|
||||||
|
# → Emailed steve@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt escalate ack
|
||||||
|
|
||||||
|
Acknowledge an escalation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate ack <bead-id> [--note="Investigating"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Sets `acknowledged:true` label
|
||||||
|
- Optionally adds note to bead
|
||||||
|
- Prevents re-escalation
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
gt escalate ack gt-esc-abc123 --note="Looking into it"
|
||||||
|
# ✓ Acknowledged gt-esc-abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt escalate list
|
||||||
|
|
||||||
|
List escalations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate list [--severity=...] [--stale] [--unacked] [--all]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- `--severity`: Filter by severity level
|
||||||
|
- `--stale`: Show only stale (past threshold, unacked)
|
||||||
|
- `--unacked`: Show only unacknowledged
|
||||||
|
- `--all`: Include acknowledged/closed
|
||||||
|
- `--json`: Output as JSON
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
gt escalate list --unacked
|
||||||
|
# 📢 Unacknowledged Escalations (2)
|
||||||
|
#
|
||||||
|
# ● gt-esc-abc123 [HIGH] Plugin FAILED: rebuild-gt
|
||||||
|
# Source: plugin:rebuild-gt · Age: 2h · Stale in: 2h
|
||||||
|
# ● gt-esc-def456 [MEDIUM] Witness unresponsive
|
||||||
|
# Source: patrol:deacon · Age: 30m · Stale in: 3h30m
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt escalate stale
|
||||||
|
|
||||||
|
Check for and re-escalate stale escalations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate stale [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Queries unacked escalations older than `stale_threshold`
|
||||||
|
- For each, bumps severity and re-executes route
|
||||||
|
- Respects `max_reescalations` limit
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
gt escalate stale
|
||||||
|
# 🔄 Re-escalating stale escalations...
|
||||||
|
#
|
||||||
|
# gt-esc-abc123: medium → high (age: 5h, reescalation: 1/2)
|
||||||
|
# → Emailed steve@example.com
|
||||||
|
#
|
||||||
|
# ✓ Re-escalated 1 escalation
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt escalate close
|
||||||
|
|
||||||
|
Close an escalation (resolved).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate close <bead-id> [--reason="Fixed in commit abc123"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Sets status to closed
|
||||||
|
- Adds resolution note
|
||||||
|
- Records who closed it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File: internal/cmd/escalate.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
// escalateCmd is the parent command for escalation management.
|
||||||
|
var escalateCmd = &cobra.Command{
|
||||||
|
Use: "escalate",
|
||||||
|
Short: "Manage escalations",
|
||||||
|
Long: `Create, acknowledge, and manage escalations with severity-based routing.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateCreateCmd creates a new escalation.
|
||||||
|
var escalateCreateCmd = &cobra.Command{
|
||||||
|
Use: "escalate --severity=<level> --subject=<text> --body=<text>",
|
||||||
|
Short: "Create a new escalation",
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateAckCmd acknowledges an escalation.
|
||||||
|
var escalateAckCmd = &cobra.Command{
|
||||||
|
Use: "ack <bead-id>",
|
||||||
|
Short: "Acknowledge an escalation",
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateListCmd lists escalations.
|
||||||
|
var escalateListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List escalations",
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateStaleCmd checks for stale escalations.
|
||||||
|
var escalateStaleCmd = &cobra.Command{
|
||||||
|
Use: "stale",
|
||||||
|
Short: "Re-escalate stale escalations",
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateCloseCmd closes an escalation.
|
||||||
|
var escalateCloseCmd = &cobra.Command{
|
||||||
|
Use: "close <bead-id>",
|
||||||
|
Short: "Close an escalation",
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File: internal/escalation/manager.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
package escalation
|
||||||
|
|
||||||
|
// Manager handles escalation creation and routing.
|
||||||
|
type Manager struct {
|
||||||
|
config *config.EscalationConfig
|
||||||
|
beads *beads.Client
|
||||||
|
mailer *mail.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalate creates a new escalation and executes the route.
|
||||||
|
func (m *Manager) Escalate(ctx context.Context, opts EscalateOptions) (*Escalation, error) {
|
||||||
|
// 1. Validate options
|
||||||
|
// 2. Create escalation bead
|
||||||
|
// 3. Look up route for severity
|
||||||
|
// 4. Execute each action
|
||||||
|
// 5. Return escalation with results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge marks an escalation as acknowledged.
|
||||||
|
func (m *Manager) Acknowledge(ctx context.Context, beadID string, note string) error {
|
||||||
|
// 1. Load escalation bead
|
||||||
|
// 2. Set acknowledged:true label
|
||||||
|
// 3. Add note if provided
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReescalateStale finds and re-escalates stale escalations.
|
||||||
|
func (m *Manager) ReescalateStale(ctx context.Context) ([]Reescalation, error) {
|
||||||
|
// 1. Query unacked escalations older than threshold
|
||||||
|
// 2. For each, bump severity
|
||||||
|
// 3. Execute new route
|
||||||
|
// 4. Update labels
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File: internal/escalation/actions.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
package escalation
|
||||||
|
|
||||||
|
// Action is an escalation route action.
|
||||||
|
type Action interface {
|
||||||
|
Execute(ctx context.Context, esc *Escalation) error
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeadAction creates the escalation bead.
|
||||||
|
type BeadAction struct{}
|
||||||
|
|
||||||
|
// MailAction sends gt mail.
|
||||||
|
type MailAction struct {
|
||||||
|
Target string // e.g., "mayor"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailAction sends email.
|
||||||
|
type EmailAction struct {
|
||||||
|
Recipient string // from config.contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMSAction sends SMS.
|
||||||
|
type SMSAction struct {
|
||||||
|
Recipient string // from config.contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAction parses an action string into an Action.
|
||||||
|
func ParseAction(s string) (Action, error) {
|
||||||
|
// "bead" -> BeadAction{}
|
||||||
|
// "mail:mayor" -> MailAction{Target: "mayor"}
|
||||||
|
// "email:human" -> EmailAction{Recipient: "human"}
|
||||||
|
// etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email/SMS Implementation
|
||||||
|
|
||||||
|
For v1, use simple exec of external commands:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// EmailAction sends email using the 'mail' command or similar.
|
||||||
|
func (a *EmailAction) Execute(ctx context.Context, esc *Escalation) error {
|
||||||
|
// Option 1: Use system mail command
|
||||||
|
// Option 2: Use sendgrid/ses API (future)
|
||||||
|
// Option 3: Use configured webhook
|
||||||
|
|
||||||
|
// For now, just log a placeholder
|
||||||
|
// Real implementation can be added based on user's infrastructure
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The email/SMS actions can start as stubs that log warnings, with real implementations added based on the user's infrastructure (SendGrid, Twilio, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Plugins use escalation for failure notification:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# In plugin.md execution section:
|
||||||
|
|
||||||
|
On failure:
|
||||||
|
```bash
|
||||||
|
gt escalate \
|
||||||
|
--severity=medium \
|
||||||
|
--subject="Plugin FAILED: rebuild-gt" \
|
||||||
|
--body="$ERROR" \
|
||||||
|
--source="plugin:rebuild-gt"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deacon Patrol
|
||||||
|
|
||||||
|
Deacon uses escalation for health issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In health-scan step:
|
||||||
|
if [ $unresponsive_cycles -ge 5 ]; then
|
||||||
|
gt escalate \
|
||||||
|
--severity=high \
|
||||||
|
--subject="Witness unresponsive: gastown" \
|
||||||
|
--body="Witness has been unresponsive for $unresponsive_cycles cycles" \
|
||||||
|
--source="patrol:deacon:health-scan"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stale Escalation Check
|
||||||
|
|
||||||
|
Can be either:
|
||||||
|
1. A Deacon patrol step
|
||||||
|
2. A plugin (dogfood!)
|
||||||
|
3. Part of `gt escalate` itself (run periodically)
|
||||||
|
|
||||||
|
Recommendation: Start as patrol step, migrate to plugin later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- Config loading and validation
|
||||||
|
- Action parsing
|
||||||
|
- Severity level ordering
|
||||||
|
- Re-escalation logic
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- Create escalation → bead exists
|
||||||
|
- Acknowledge → label updated
|
||||||
|
- Stale detection → re-escalation triggers
|
||||||
|
- Route execution → all actions called
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. `gt escalate --severity=low --subject="Test" --body="Testing"`
|
||||||
|
2. `gt escalate list --unacked`
|
||||||
|
3. `gt escalate ack <id>`
|
||||||
|
4. Wait for stale threshold, run `gt escalate stale`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Internal Dependencies (task order)
|
||||||
|
|
||||||
|
```
|
||||||
|
gt-i9r20.2 (Config Schema)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
gt-i9r20.1 (gt escalate command)
|
||||||
|
│
|
||||||
|
├──▶ gt-i9r20.4 (gt escalate ack)
|
||||||
|
│
|
||||||
|
└──▶ gt-i9r20.3 (Stale patrol)
|
||||||
|
```
|
||||||
|
|
||||||
|
### External Dependencies
|
||||||
|
|
||||||
|
- `bd create` for creating escalation beads
|
||||||
|
- `bd list` for querying escalations
|
||||||
|
- `bd label` for updating labels
|
||||||
|
- `gt mail send` for mail action
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (Resolved)
|
||||||
|
|
||||||
|
1. **Where to store config?** → `settings/escalation.json` (follows existing pattern)
|
||||||
|
2. **How to implement email/SMS?** → Start with stubs, add real impl based on infrastructure
|
||||||
|
3. **Stale check: patrol step or plugin?** → Start as patrol step, can migrate to plugin
|
||||||
|
4. **Escalation bead type?** → `type: escalation` (new bead type)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Slack integration**: Post to Slack channels
|
||||||
|
2. **PagerDuty integration**: Create incidents
|
||||||
|
3. **Escalation dashboard**: Web UI for escalation management
|
||||||
|
4. **Scheduled escalations**: "Remind me in 2h if not resolved"
|
||||||
|
5. **Escalation templates**: Pre-defined escalation types
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Federation Architecture
|
# Federation Architecture
|
||||||
|
|
||||||
|
> **Status: Design spec - not yet implemented**
|
||||||
|
|
||||||
> Multi-workspace coordination for Gas Town and Beads
|
> Multi-workspace coordination for Gas Town and Beads
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -100,7 +102,7 @@ Distribute work across workspaces:
|
|||||||
|
|
||||||
## Agent Provenance
|
## Agent Provenance
|
||||||
|
|
||||||
Every agent operation is attributed. See [identity.md](identity.md) for the
|
Every agent operation is attributed. See [identity.md](../concepts/identity.md) for the
|
||||||
complete BD_ACTOR format convention.
|
complete BD_ACTOR format convention.
|
||||||
|
|
||||||
### Git Commits
|
### Git Commits
|
||||||
141
docs/design/operational-state.md
Normal file
141
docs/design/operational-state.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Operational State in Gas Town
|
||||||
|
|
||||||
|
> Managing runtime state through events and labels.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Gas Town tracks operational state changes as structured data. This document covers:
|
||||||
|
- **Events**: State transitions as beads (immutable audit trail)
|
||||||
|
- **Labels-as-state**: Fast queries via role bead labels (current state cache)
|
||||||
|
|
||||||
|
For Boot triage and degraded mode details, see [Watchdog Chain](watchdog-chain.md).
|
||||||
|
|
||||||
|
## Events: State Transitions as Data
|
||||||
|
|
||||||
|
Operational state changes are recorded as event beads. Each event captures:
|
||||||
|
- **What** changed (`event_type`)
|
||||||
|
- **Who** caused it (`actor`)
|
||||||
|
- **What** was affected (`target`)
|
||||||
|
- **Context** (`payload`)
|
||||||
|
- **When** (`created_at`)
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
| Event Type | Description | Payload |
|
||||||
|
|------------|-------------|---------|
|
||||||
|
| `patrol.muted` | Patrol cycle disabled | `{reason, until?}` |
|
||||||
|
| `patrol.unmuted` | Patrol cycle re-enabled | `{reason?}` |
|
||||||
|
| `agent.started` | Agent session began | `{session_id?}` |
|
||||||
|
| `agent.stopped` | Agent session ended | `{reason, outcome?}` |
|
||||||
|
| `mode.degraded` | System entered degraded mode | `{reason}` |
|
||||||
|
| `mode.normal` | System returned to normal | `{}` |
|
||||||
|
|
||||||
|
### Creating Events
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mute deacon patrol
|
||||||
|
bd create --type=event --event-type=patrol.muted \
|
||||||
|
--actor=human:overseer --target=agent:deacon \
|
||||||
|
--payload='{"reason":"fixing convoy deadlock","until":"gt-abc1"}'
|
||||||
|
|
||||||
|
# System entered degraded mode
|
||||||
|
bd create --type=event --event-type=mode.degraded \
|
||||||
|
--actor=system:daemon --target=rig:greenplace \
|
||||||
|
--payload='{"reason":"tmux unavailable"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying Events
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recent events for an agent
|
||||||
|
bd list --type=event --target=agent:deacon --limit=10
|
||||||
|
|
||||||
|
# All patrol state changes
|
||||||
|
bd list --type=event --event-type=patrol.muted
|
||||||
|
bd list --type=event --event-type=patrol.unmuted
|
||||||
|
|
||||||
|
# Events in the activity feed
|
||||||
|
bd activity --follow --type=event
|
||||||
|
```
|
||||||
|
|
||||||
|
## Labels-as-State Pattern
|
||||||
|
|
||||||
|
Events capture the full history. Labels cache the current state for fast queries.
|
||||||
|
|
||||||
|
### Convention
|
||||||
|
|
||||||
|
Labels use `<dimension>:<value>` format:
|
||||||
|
- `patrol:muted` / `patrol:active`
|
||||||
|
- `mode:degraded` / `mode:normal`
|
||||||
|
- `status:idle` / `status:working` (for persistent agents only - see note)
|
||||||
|
|
||||||
|
**Note on polecats:** The `status:idle` label does NOT apply to polecats. Polecats
|
||||||
|
have no idle state - they're either working, stalled (stopped unexpectedly), or
|
||||||
|
zombie (`gt done` failed). This label is for persistent agents like Deacon, Witness,
|
||||||
|
and Crew members who can legitimately be idle between tasks.
|
||||||
|
|
||||||
|
### State Change Flow
|
||||||
|
|
||||||
|
1. Create event bead (full context, immutable)
|
||||||
|
2. Update role bead labels (current state cache)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mute patrol
|
||||||
|
bd create --type=event --event-type=patrol.muted ...
|
||||||
|
bd update role-deacon --add-label=patrol:muted --remove-label=patrol:active
|
||||||
|
|
||||||
|
# Unmute patrol
|
||||||
|
bd create --type=event --event-type=patrol.unmuted ...
|
||||||
|
bd update role-deacon --add-label=patrol:active --remove-label=patrol:muted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying Current State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Is deacon patrol muted?
|
||||||
|
bd show role-deacon | grep patrol:
|
||||||
|
|
||||||
|
# All agents with muted patrol
|
||||||
|
bd list --type=role --label=patrol:muted
|
||||||
|
|
||||||
|
# All agents in degraded mode
|
||||||
|
bd list --type=role --label=mode:degraded
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration vs State
|
||||||
|
|
||||||
|
| Type | Storage | Example |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **Static config** | TOML files | Daemon tick interval |
|
||||||
|
| **Operational state** | Beads (events + labels) | Patrol muted |
|
||||||
|
| **Runtime flags** | Marker files | `.deacon-disabled` |
|
||||||
|
|
||||||
|
Static config rarely changes and doesn't need history.
|
||||||
|
Operational state changes at runtime and benefits from audit trail.
|
||||||
|
Marker files are fast checks that can trigger deeper beads queries.
|
||||||
|
|
||||||
|
## Commands Summary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create operational event
|
||||||
|
bd create --type=event --event-type=<type> \
|
||||||
|
--actor=<entity> --target=<entity> --payload='<json>'
|
||||||
|
|
||||||
|
# Update state label
|
||||||
|
bd update <role-bead> --add-label=<dim>:<val> --remove-label=<dim>:<old>
|
||||||
|
|
||||||
|
# Query current state
|
||||||
|
bd list --type=role --label=<dim>:<val>
|
||||||
|
|
||||||
|
# Query state history
|
||||||
|
bd list --type=event --target=<entity>
|
||||||
|
|
||||||
|
# Boot management
|
||||||
|
gt dog status boot
|
||||||
|
gt dog call boot
|
||||||
|
gt dog prime boot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Events are the source of truth. Labels are the cache.*
|
||||||
485
docs/design/plugin-system.md
Normal file
485
docs/design/plugin-system.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Plugin System Design
|
||||||
|
|
||||||
|
> Design document for the Gas Town plugin system.
|
||||||
|
> Written 2026-01-11, crew/george session.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Gas Town needs extensible, project-specific automation that runs during Deacon patrol cycles. The immediate use case is rebuilding stale binaries (gt, bd, wv), but the pattern generalizes to any periodic maintenance task.
|
||||||
|
|
||||||
|
Current state:
|
||||||
|
- Plugin infrastructure exists conceptually (patrol step mentions it)
|
||||||
|
- `~/gt/plugins/` directory exists with README
|
||||||
|
- No actual plugins in production use
|
||||||
|
- No formalized execution model
|
||||||
|
|
||||||
|
## Design Principles Applied
|
||||||
|
|
||||||
|
### Discover, Don't Track
|
||||||
|
> Reality is truth. State is derived.
|
||||||
|
|
||||||
|
Plugin state (last run, run count, results) lives on the ledger as wisps, not in shadow state files. Gate evaluation queries the ledger directly.
|
||||||
|
|
||||||
|
### ZFC: Zero Framework Cognition
|
||||||
|
> Agent decides. Go transports.
|
||||||
|
|
||||||
|
The Deacon (agent) evaluates gates and decides whether to dispatch. Go code provides transport (`gt dog dispatch`) but doesn't make decisions.
|
||||||
|
|
||||||
|
### MEOW Stack Integration
|
||||||
|
|
||||||
|
| Layer | Plugin Analog |
|
||||||
|
|-------|---------------|
|
||||||
|
| **M**olecule | `plugin.md` - work template with TOML frontmatter |
|
||||||
|
| **E**phemeral | Plugin-run wisps - high-volume, digestible |
|
||||||
|
| **O**bservable | Plugin runs appear in `bd activity` feed |
|
||||||
|
| **W**orkflow | Gate → Dispatch → Execute → Record → Digest |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Plugin Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
~/gt/
|
||||||
|
├── plugins/ # Town-level plugins (universal)
|
||||||
|
│ └── README.md
|
||||||
|
├── gastown/
|
||||||
|
│ └── plugins/ # Rig-level plugins
|
||||||
|
│ └── rebuild-gt/
|
||||||
|
│ └── plugin.md
|
||||||
|
├── beads/
|
||||||
|
│ └── plugins/
|
||||||
|
│ └── rebuild-bd/
|
||||||
|
│ └── plugin.md
|
||||||
|
└── wyvern/
|
||||||
|
└── plugins/
|
||||||
|
└── rebuild-wv/
|
||||||
|
└── plugin.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Town-level** (`~/gt/plugins/`): Universal plugins that apply everywhere.
|
||||||
|
**Rig-level** (`<rig>/plugins/`): Project-specific plugins.
|
||||||
|
|
||||||
|
The Deacon scans both locations during patrol.
|
||||||
|
|
||||||
|
### Execution Model: Dog Dispatch
|
||||||
|
|
||||||
|
**Key insight**: Plugin execution should not block Deacon patrol.
|
||||||
|
|
||||||
|
Dogs are reusable workers designed for infrastructure tasks. Plugin execution is dispatched to dogs:
|
||||||
|
|
||||||
|
```
|
||||||
|
Deacon Patrol Dog Worker
|
||||||
|
───────────────── ─────────────────
|
||||||
|
1. Scan plugins
|
||||||
|
2. Evaluate gates
|
||||||
|
3. For open gates:
|
||||||
|
└─ gt dog dispatch plugin ──→ 4. Execute plugin
|
||||||
|
(non-blocking) 5. Create result wisp
|
||||||
|
6. Send DOG_DONE
|
||||||
|
4. Continue patrol
|
||||||
|
...
|
||||||
|
5. Process DOG_DONE ←── (next cycle)
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Deacon stays responsive
|
||||||
|
- Multiple plugins can run concurrently (different dogs)
|
||||||
|
- Plugin failures don't stall patrol
|
||||||
|
- Consistent with Dogs' purpose (infrastructure work)
|
||||||
|
|
||||||
|
### State Tracking: Wisps on the Ledger
|
||||||
|
|
||||||
|
Each plugin run creates a wisp:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd wisp create \
|
||||||
|
--label type:plugin-run \
|
||||||
|
--label plugin:rebuild-gt \
|
||||||
|
--label rig:gastown \
|
||||||
|
--label result:success \
|
||||||
|
--body "Rebuilt gt: abc123 → def456 (5 commits)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gate evaluation** queries wisps instead of state files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cooldown check: any runs in last hour?
|
||||||
|
bd list --type=wisp --label=plugin:rebuild-gt --since=1h --limit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Derived state** (no state.json needed):
|
||||||
|
|
||||||
|
| Query | Command |
|
||||||
|
|-------|---------|
|
||||||
|
| Last run time | `bd list --label=plugin:X --limit=1 --json` |
|
||||||
|
| Run count | `bd list --label=plugin:X --json \| jq length` |
|
||||||
|
| Last result | Parse `result:` label from latest wisp |
|
||||||
|
| Failure rate | Count `result:failure` vs total |
|
||||||
|
|
||||||
|
### Digest Pattern
|
||||||
|
|
||||||
|
Like cost digests, plugin wisps accumulate and get squashed daily:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt plugin digest --yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates: `Plugin Digest 2026-01-10` bead with summary
|
||||||
|
Deletes: Individual plugin-run wisps from that day
|
||||||
|
|
||||||
|
This keeps the ledger clean while preserving audit history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Format Specification
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
rebuild-gt/
|
||||||
|
└── plugin.md # Definition with TOML frontmatter
|
||||||
|
```
|
||||||
|
|
||||||
|
### plugin.md Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
+++
|
||||||
|
name = "rebuild-gt"
|
||||||
|
description = "Rebuild stale gt binary from source"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[gate]
|
||||||
|
type = "cooldown"
|
||||||
|
duration = "1h"
|
||||||
|
|
||||||
|
[tracking]
|
||||||
|
labels = ["plugin:rebuild-gt", "rig:gastown", "category:maintenance"]
|
||||||
|
digest = true
|
||||||
|
|
||||||
|
[execution]
|
||||||
|
timeout = "5m"
|
||||||
|
notify_on_failure = true
|
||||||
|
+++
|
||||||
|
|
||||||
|
# Rebuild gt Binary
|
||||||
|
|
||||||
|
Instructions for the dog worker to execute...
|
||||||
|
```
|
||||||
|
|
||||||
|
### TOML Frontmatter Schema
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Required
|
||||||
|
name = "string" # Unique plugin identifier
|
||||||
|
description = "string" # Human-readable description
|
||||||
|
version = 1 # Schema version (for future evolution)
|
||||||
|
|
||||||
|
[gate]
|
||||||
|
type = "cooldown|cron|condition|event|manual"
|
||||||
|
# Type-specific fields:
|
||||||
|
duration = "1h" # For cooldown
|
||||||
|
schedule = "0 9 * * *" # For cron
|
||||||
|
check = "gt stale -q" # For condition (exit 0 = run)
|
||||||
|
on = "startup" # For event
|
||||||
|
|
||||||
|
[tracking]
|
||||||
|
labels = ["label:value", ...] # Labels for execution wisps
|
||||||
|
digest = true|false # Include in daily digest
|
||||||
|
|
||||||
|
[execution]
|
||||||
|
timeout = "5m" # Max execution time
|
||||||
|
notify_on_failure = true # Escalate on failure
|
||||||
|
severity = "low" # Escalation severity if failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gate Types
|
||||||
|
|
||||||
|
| Type | Config | Behavior |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `cooldown` | `duration = "1h"` | Query wisps, run if none in window |
|
||||||
|
| `cron` | `schedule = "0 9 * * *"` | Run on cron schedule |
|
||||||
|
| `condition` | `check = "cmd"` | Run check command, run if exit 0 |
|
||||||
|
| `event` | `on = "startup"` | Run on Deacon startup |
|
||||||
|
| `manual` | (no gate section) | Never auto-run, dispatch explicitly |
|
||||||
|
|
||||||
|
### Instructions Section
|
||||||
|
|
||||||
|
The markdown body after the frontmatter contains agent-executable instructions. The dog worker reads and executes these steps.
|
||||||
|
|
||||||
|
Standard sections:
|
||||||
|
- **Detection**: Check if action is needed
|
||||||
|
- **Action**: The actual work
|
||||||
|
- **Record Result**: Create the execution wisp
|
||||||
|
- **Notification**: On success/failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Escalation System
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Current escalation is ad-hoc "mail Mayor". Issues:
|
||||||
|
- Mayor gets backlogged easily
|
||||||
|
- No severity differentiation
|
||||||
|
- No alternative channels (email, SMS, etc.)
|
||||||
|
- No tracking of stale escalations
|
||||||
|
|
||||||
|
### Solution: Unified Escalation API
|
||||||
|
|
||||||
|
New command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate \
|
||||||
|
--severity=<low|medium|high|critical> \
|
||||||
|
--subject="Plugin FAILED: rebuild-gt" \
|
||||||
|
--body="Build failed: make returned exit code 2" \
|
||||||
|
--source="plugin:rebuild-gt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Escalation Routing
|
||||||
|
|
||||||
|
The command reads town config (`~/gt/config.json` or similar) for routing rules:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"escalation": {
|
||||||
|
"routes": {
|
||||||
|
"low": ["bead"],
|
||||||
|
"medium": ["bead", "mail:mayor"],
|
||||||
|
"high": ["bead", "mail:mayor", "email:human"],
|
||||||
|
"critical": ["bead", "mail:mayor", "email:human", "sms:human"]
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"human_email": "steve@example.com",
|
||||||
|
"human_sms": "+1234567890"
|
||||||
|
},
|
||||||
|
"stale_threshold": "4h"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Escalation Actions
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `bead` | Create escalation bead with severity label |
|
||||||
|
| `mail:mayor` | Send mail to mayor/ |
|
||||||
|
| `email:human` | Send email via configured service |
|
||||||
|
| `sms:human` | Send SMS via configured service |
|
||||||
|
|
||||||
|
### Escalation Beads
|
||||||
|
|
||||||
|
Every escalation creates a bead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: escalation
|
||||||
|
status: open
|
||||||
|
labels:
|
||||||
|
- severity:high
|
||||||
|
- source:plugin:rebuild-gt
|
||||||
|
- acknowledged:false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stale Escalation Patrol
|
||||||
|
|
||||||
|
A patrol step (or plugin!) checks for unacknowledged escalations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd list --type=escalation --label=acknowledged:false --older-than=4h
|
||||||
|
```
|
||||||
|
|
||||||
|
Stale escalations get re-escalated at higher severity.
|
||||||
|
|
||||||
|
### Acknowledging Escalations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate ack <bead-id>
|
||||||
|
# Sets label acknowledged:true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Commands Required
|
||||||
|
|
||||||
|
### gt stale
|
||||||
|
|
||||||
|
Expose binary staleness check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt stale # Human-readable output
|
||||||
|
gt stale --json # Machine-readable
|
||||||
|
gt stale --quiet # Exit code only (0=stale, 1=fresh)
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt dog dispatch
|
||||||
|
|
||||||
|
Formalized plugin dispatch to dogs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt dog dispatch --plugin <name> [--rig <rig>]
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
1. Finds the plugin definition
|
||||||
|
2. Slinga a standardized work unit to an idle dog
|
||||||
|
3. Returns immediately (non-blocking)
|
||||||
|
|
||||||
|
### gt escalate
|
||||||
|
|
||||||
|
Unified escalation API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt escalate \
|
||||||
|
--severity=<level> \
|
||||||
|
--subject="..." \
|
||||||
|
--body="..." \
|
||||||
|
[--source="..."]
|
||||||
|
|
||||||
|
gt escalate ack <bead-id>
|
||||||
|
gt escalate list [--severity=...] [--stale]
|
||||||
|
```
|
||||||
|
|
||||||
|
### gt plugin
|
||||||
|
|
||||||
|
Plugin management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt plugin list # List all plugins
|
||||||
|
gt plugin show <name> # Show plugin details
|
||||||
|
gt plugin run <name> [--force] # Manual trigger
|
||||||
|
gt plugin digest [--yesterday] # Squash wisps to digest
|
||||||
|
gt plugin history <name> # Show execution history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
|
||||||
|
1. **`gt stale` command** - Expose CheckStaleBinary() via CLI
|
||||||
|
2. **Plugin format spec** - Finalize TOML schema
|
||||||
|
3. **Plugin scanning** - Deacon scans town + rig plugin dirs
|
||||||
|
|
||||||
|
### Phase 2: Execution
|
||||||
|
|
||||||
|
4. **`gt dog dispatch --plugin`** - Formalized dog dispatch
|
||||||
|
5. **Plugin execution in dogs** - Dog reads plugin.md, executes
|
||||||
|
6. **Wisp creation** - Record results on ledger
|
||||||
|
|
||||||
|
### Phase 3: Gates & State
|
||||||
|
|
||||||
|
7. **Gate evaluation** - Cooldown via wisp query
|
||||||
|
8. **Other gate types** - Cron, condition, event
|
||||||
|
9. **Plugin digest** - Daily squash of plugin wisps
|
||||||
|
|
||||||
|
### Phase 4: Escalation
|
||||||
|
|
||||||
|
10. **`gt escalate` command** - Unified escalation API
|
||||||
|
11. **Escalation routing** - Config-driven multi-channel
|
||||||
|
12. **Stale escalation patrol** - Check unacknowledged
|
||||||
|
|
||||||
|
### Phase 5: First Plugin
|
||||||
|
|
||||||
|
13. **`rebuild-gt` plugin** - The actual gastown plugin
|
||||||
|
14. **Documentation** - So Beads/Wyvern can create theirs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: rebuild-gt Plugin
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
+++
|
||||||
|
name = "rebuild-gt"
|
||||||
|
description = "Rebuild stale gt binary from gastown source"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[gate]
|
||||||
|
type = "cooldown"
|
||||||
|
duration = "1h"
|
||||||
|
|
||||||
|
[tracking]
|
||||||
|
labels = ["plugin:rebuild-gt", "rig:gastown", "category:maintenance"]
|
||||||
|
digest = true
|
||||||
|
|
||||||
|
[execution]
|
||||||
|
timeout = "5m"
|
||||||
|
notify_on_failure = true
|
||||||
|
severity = "medium"
|
||||||
|
+++
|
||||||
|
|
||||||
|
# Rebuild gt Binary
|
||||||
|
|
||||||
|
Checks if the gt binary is stale (built from older commit than HEAD) and rebuilds.
|
||||||
|
|
||||||
|
## Gate Check
|
||||||
|
|
||||||
|
The Deacon evaluates this before dispatch. If gate closed, skip.
|
||||||
|
|
||||||
|
## Detection
|
||||||
|
|
||||||
|
Check binary staleness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt stale --json
|
||||||
|
```
|
||||||
|
|
||||||
|
If `"stale": false`, record success wisp and exit early.
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
Rebuild from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/gt/gastown/crew/george && make build && make install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Record Result
|
||||||
|
|
||||||
|
On success:
|
||||||
|
```bash
|
||||||
|
bd wisp create \
|
||||||
|
--label type:plugin-run \
|
||||||
|
--label plugin:rebuild-gt \
|
||||||
|
--label rig:gastown \
|
||||||
|
--label result:success \
|
||||||
|
--body "Rebuilt gt: $OLD → $NEW ($N commits)"
|
||||||
|
```
|
||||||
|
|
||||||
|
On failure:
|
||||||
|
```bash
|
||||||
|
bd wisp create \
|
||||||
|
--label type:plugin-run \
|
||||||
|
--label plugin:rebuild-gt \
|
||||||
|
--label rig:gastown \
|
||||||
|
--label result:failure \
|
||||||
|
--body "Build failed: $ERROR"
|
||||||
|
|
||||||
|
gt escalate --severity=medium \
|
||||||
|
--subject="Plugin FAILED: rebuild-gt" \
|
||||||
|
--body="$ERROR" \
|
||||||
|
--source="plugin:rebuild-gt"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Plugin discovery in multiple clones**: If gastown has crew/george, crew/max, crew/joe - which clone's plugins/ dir is canonical? Probably: scan all, dedupe by name, prefer rig-root if exists.
|
||||||
|
|
||||||
|
2. **Dog assignment**: Should specific plugins prefer specific dogs? Or any idle dog?
|
||||||
|
|
||||||
|
3. **Plugin dependencies**: Can plugins depend on other plugins? Probably not in v1.
|
||||||
|
|
||||||
|
4. **Plugin disable/enable**: How to temporarily disable a plugin without deleting it? Label on a plugin bead? `enabled = false` in frontmatter?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRIMING.md - Core design principles
|
||||||
|
- mol-deacon-patrol.formula.toml - Patrol step plugin-run
|
||||||
|
- ~/gt/plugins/README.md - Current plugin stub
|
||||||
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
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Decision 009: Session Events Architecture
|
|
||||||
|
|
||||||
**Status:** Accepted
|
|
||||||
**Date:** 2025-12-31
|
|
||||||
**Context:** Where should session events live? Beads, separate repo, or events.jsonl?
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Session events are **orchestration infrastructure**, not work items. They stay in
|
|
||||||
`events.jsonl` (outside beads). Work attribution happens by capturing `session_id`
|
|
||||||
on beads mutations (issue close, MR merge).
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The seance feature needs to discover and resume Claude Code sessions. This requires:
|
|
||||||
1. **Pointer** to session (session_id) - for `claude --resume`
|
|
||||||
2. **Attribution** (which work happened in this session) - for entity CV
|
|
||||||
|
|
||||||
Claude Code already stores full session transcripts indefinitely. Gas Town doesn't
|
|
||||||
need to duplicate them - just point at them.
|
|
||||||
|
|
||||||
## The Separation
|
|
||||||
|
|
||||||
| Layer | Storage | Content | Retention |
|
|
||||||
|-------|---------|---------|-----------|
|
|
||||||
| **Orchestration** | `~/.events.jsonl` | session_start, nudges, mail routing | Ephemeral (auto-prune) |
|
|
||||||
| **Work** | Beads (rig-level) | Issues, MRs, convoys | Permanent (ledger) |
|
|
||||||
| **Entity activity** | Beads (entity chain) | Session digests | Permanent (CV) |
|
|
||||||
| **Transcript** | Claude Code | Full session content | Claude Code's retention |
|
|
||||||
|
|
||||||
## Why Not Beads for Events?
|
|
||||||
|
|
||||||
1. **Volume**: Orchestration events are high volume, would overwhelm work signal
|
|
||||||
2. **Ephemerality**: Most orchestration events don't need CV/ledger permanence
|
|
||||||
3. **Different audiences**: Work items are cross-agent; orchestration is internal
|
|
||||||
4. **Claude Code has it**: Transcripts already live there; we just need pointers
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### Phase 1: Attribution (Now)
|
|
||||||
- `gt done` captures `CLAUDE_SESSION_ID` in issue close
|
|
||||||
- Beads supports `closed_by_session` field on issue mutations
|
|
||||||
- Events.jsonl continues to capture `session_start` for seance
|
|
||||||
|
|
||||||
### Phase 2: Session Digests (Future)
|
|
||||||
- Sessions as wisps: `session_start` creates ephemeral wisp
|
|
||||||
- Session work adds steps (issues closed, commits made)
|
|
||||||
- `session_end` squashes to digest
|
|
||||||
- Digest lives on entity chain (agent CV)
|
|
||||||
|
|
||||||
### Phase 3: Pruning (Future)
|
|
||||||
- Events.jsonl auto-prunes after N days
|
|
||||||
- Session digests provide permanent summary
|
|
||||||
- Full transcripts remain in Claude Code
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive:**
|
|
||||||
- Clean separation of concerns
|
|
||||||
- Work ledger stays focused on work
|
|
||||||
- CV attribution via session_id on beads mutations
|
|
||||||
- Seance works via events.jsonl discovery
|
|
||||||
|
|
||||||
**Negative:**
|
|
||||||
- Two systems to understand (events vs beads)
|
|
||||||
- Need to ensure session_id flows through commands
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- `gt seance` - Session discovery and resume
|
|
||||||
- `gt-3zsml` - SessionStart hook passes session_id to gt prime
|
|
||||||
- PRIMING.md - "The Feed Is the Signal" section
|
|
||||||
- CONTEXT.md - Entity chains and CV model
|
|
||||||
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
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
# Operational State in Gas Town
|
|
||||||
|
|
||||||
> Managing runtime state, degraded modes, and the Boot triage system.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Gas Town needs to track operational state: Is the Deacon's patrol muted? Is the
|
|
||||||
system in degraded mode? When did state change, and why?
|
|
||||||
|
|
||||||
This document covers:
|
|
||||||
- **Events**: State transitions as beads
|
|
||||||
- **Labels-as-state**: Fast queries via role bead labels
|
|
||||||
- **Boot**: The dog that triages the Deacon
|
|
||||||
- **Degraded mode**: Operating without tmux
|
|
||||||
|
|
||||||
## Events: State Transitions as Data
|
|
||||||
|
|
||||||
Operational state changes are recorded as event beads. Each event captures:
|
|
||||||
- **What** changed (`event_type`)
|
|
||||||
- **Who** caused it (`actor`)
|
|
||||||
- **What** was affected (`target`)
|
|
||||||
- **Context** (`payload`)
|
|
||||||
- **When** (`created_at`)
|
|
||||||
|
|
||||||
### Event Types
|
|
||||||
|
|
||||||
| Event Type | Description | Payload |
|
|
||||||
|------------|-------------|---------|
|
|
||||||
| `patrol.muted` | Patrol cycle disabled | `{reason, until?}` |
|
|
||||||
| `patrol.unmuted` | Patrol cycle re-enabled | `{reason?}` |
|
|
||||||
| `agent.started` | Agent session began | `{session_id?}` |
|
|
||||||
| `agent.stopped` | Agent session ended | `{reason, outcome?}` |
|
|
||||||
| `mode.degraded` | System entered degraded mode | `{reason}` |
|
|
||||||
| `mode.normal` | System returned to normal | `{}` |
|
|
||||||
|
|
||||||
### Creating Events
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Mute deacon patrol
|
|
||||||
bd create --type=event --event-type=patrol.muted \
|
|
||||||
--actor=human:overseer --target=agent:deacon \
|
|
||||||
--payload='{"reason":"fixing convoy deadlock","until":"gt-abc1"}'
|
|
||||||
|
|
||||||
# System entered degraded mode
|
|
||||||
bd create --type=event --event-type=mode.degraded \
|
|
||||||
--actor=system:daemon --target=rig:greenplace \
|
|
||||||
--payload='{"reason":"tmux unavailable"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Events
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recent events for an agent
|
|
||||||
bd list --type=event --target=agent:deacon --limit=10
|
|
||||||
|
|
||||||
# All patrol state changes
|
|
||||||
bd list --type=event --event-type=patrol.muted
|
|
||||||
bd list --type=event --event-type=patrol.unmuted
|
|
||||||
|
|
||||||
# Events in the activity feed
|
|
||||||
bd activity --follow --type=event
|
|
||||||
```
|
|
||||||
|
|
||||||
## Labels-as-State Pattern
|
|
||||||
|
|
||||||
Events capture the full history. Labels cache the current state for fast queries.
|
|
||||||
|
|
||||||
### Convention
|
|
||||||
|
|
||||||
Labels use `<dimension>:<value>` format:
|
|
||||||
- `patrol:muted` / `patrol:active`
|
|
||||||
- `mode:degraded` / `mode:normal`
|
|
||||||
- `status:idle` / `status:working`
|
|
||||||
|
|
||||||
### State Change Flow
|
|
||||||
|
|
||||||
1. Create event bead (full context, immutable)
|
|
||||||
2. Update role bead labels (current state cache)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Mute patrol
|
|
||||||
bd create --type=event --event-type=patrol.muted ...
|
|
||||||
bd update role-deacon --add-label=patrol:muted --remove-label=patrol:active
|
|
||||||
|
|
||||||
# Unmute patrol
|
|
||||||
bd create --type=event --event-type=patrol.unmuted ...
|
|
||||||
bd update role-deacon --add-label=patrol:active --remove-label=patrol:muted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Current State
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Is deacon patrol muted?
|
|
||||||
bd show role-deacon | grep patrol:
|
|
||||||
|
|
||||||
# All agents with muted patrol
|
|
||||||
bd list --type=role --label=patrol:muted
|
|
||||||
|
|
||||||
# All agents in degraded mode
|
|
||||||
bd list --type=role --label=mode:degraded
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boot: The Deacon's Watchdog
|
|
||||||
|
|
||||||
> See [Watchdog Chain](watchdog-chain.md) for the complete Daemon/Boot/Deacon
|
|
||||||
> architecture and design rationale.
|
|
||||||
|
|
||||||
Boot is a dog (Deacon helper) that triages the Deacon's health. The daemon pokes
|
|
||||||
Boot instead of the Deacon directly, centralizing the "when to wake" decision in
|
|
||||||
an agent that can reason about it.
|
|
||||||
|
|
||||||
### Why Boot?
|
|
||||||
|
|
||||||
The daemon is dumb transport (ZFC principle). It can't decide:
|
|
||||||
- Is the Deacon stuck or just thinking?
|
|
||||||
- Should we interrupt or let it continue?
|
|
||||||
- Is the system in a state where nudging would help?
|
|
||||||
|
|
||||||
Boot is an agent that can observe and decide.
|
|
||||||
|
|
||||||
### Boot's Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
Daemon tick
|
|
||||||
│
|
|
||||||
├── Check: Is Boot already running? (marker file)
|
|
||||||
│ └── Yes + recent: Skip this tick
|
|
||||||
│
|
|
||||||
└── Spawn Boot (fresh session each time)
|
|
||||||
│
|
|
||||||
└── Boot runs triage molecule
|
|
||||||
├── Observe (wisps, mail, git state, tmux panes)
|
|
||||||
├── Decide (start/wake/nudge/interrupt/nothing)
|
|
||||||
├── Act
|
|
||||||
├── Clean inbox (discard stale handoffs)
|
|
||||||
└── Handoff (or exit in degraded mode)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Boot is Always Fresh
|
|
||||||
|
|
||||||
Boot restarts on each daemon tick. This is intentional:
|
|
||||||
- Narrow scope makes restarts cheap
|
|
||||||
- Fresh context avoids accumulated confusion
|
|
||||||
- Handoff mail provides continuity without session persistence
|
|
||||||
- No keepalive needed
|
|
||||||
|
|
||||||
### Boot's Decision Guidance
|
|
||||||
|
|
||||||
Agents may take several minutes on legitimate work - composing artifacts, running
|
|
||||||
tools, deep analysis. Ten minutes or more in edge cases.
|
|
||||||
|
|
||||||
To assess whether an agent is stuck:
|
|
||||||
1. Check the agent's last reported activity (recent wisps, mail sent, git commits)
|
|
||||||
2. Observe the tmux pane output over a 30-second window
|
|
||||||
3. Look for signs of progress vs. signs of hanging (tool prompt, error loop, silence)
|
|
||||||
|
|
||||||
Agents work in small steps with feedback. Most tasks complete in 2-3 minutes, but
|
|
||||||
task nature matters.
|
|
||||||
|
|
||||||
**Boot's options (increasing disruption):**
|
|
||||||
- Let them continue (if progress is evident)
|
|
||||||
- `gt nudge <agent>` (gentle wake signal)
|
|
||||||
- Escape + chat (interrupt and ask what's happening)
|
|
||||||
- Request process restart (last resort, for true hangs)
|
|
||||||
|
|
||||||
**Common false positives:**
|
|
||||||
- Tool waiting for user confirmation
|
|
||||||
- Long-running test suite
|
|
||||||
- Large file read/write operations
|
|
||||||
|
|
||||||
### Boot's Location
|
|
||||||
|
|
||||||
```
|
|
||||||
~/gt/deacon/dogs/boot/
|
|
||||||
```
|
|
||||||
|
|
||||||
Session name: `gt-boot`
|
|
||||||
|
|
||||||
Created/maintained by `bd doctor`.
|
|
||||||
|
|
||||||
### Boot Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check Boot status
|
|
||||||
gt dog status boot
|
|
||||||
|
|
||||||
# Manual Boot run (debugging)
|
|
||||||
gt dog call boot
|
|
||||||
|
|
||||||
# Prime Boot with context
|
|
||||||
gt dog prime boot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Degraded Mode
|
|
||||||
|
|
||||||
Gas Town can operate without tmux, with reduced capabilities.
|
|
||||||
|
|
||||||
### Detection
|
|
||||||
|
|
||||||
The daemon detects degraded mode mechanically and passes it to agents:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GT_DEGRADED=true # Set by daemon when tmux unavailable
|
|
||||||
```
|
|
||||||
|
|
||||||
Boot and other agents check this environment variable.
|
|
||||||
|
|
||||||
### What Changes in Degraded Mode
|
|
||||||
|
|
||||||
| Capability | Normal | Degraded |
|
|
||||||
|------------|--------|----------|
|
|
||||||
| Observe tmux panes | Yes | No |
|
|
||||||
| Interactive interrupt | Yes | No |
|
|
||||||
| Session management | Full | Limited |
|
|
||||||
| Agent spawn | tmux sessions | Direct spawn |
|
|
||||||
| Boot lifecycle | Handoff | Exit |
|
|
||||||
|
|
||||||
### Agents in Degraded Mode
|
|
||||||
|
|
||||||
In degraded mode, agents:
|
|
||||||
- Cannot observe other agents' pane output
|
|
||||||
- Cannot interactively interrupt stuck agents
|
|
||||||
- Focus on beads/git state observation only
|
|
||||||
- Report anomalies but can't fix interactively
|
|
||||||
|
|
||||||
Boot specifically:
|
|
||||||
- Runs to completion and exits (no handoff)
|
|
||||||
- Limited to: start deacon, file beads, mail overseer
|
|
||||||
- Cannot: observe panes, nudge, interrupt
|
|
||||||
|
|
||||||
### Recording Degraded Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# System entered degraded mode
|
|
||||||
bd create --type=event --event-type=mode.degraded \
|
|
||||||
--actor=system:daemon --target=rig:greenplace \
|
|
||||||
--payload='{"reason":"tmux unavailable"}'
|
|
||||||
|
|
||||||
bd update role-greenplace --add-label=mode:degraded --remove-label=mode:normal
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration vs State
|
|
||||||
|
|
||||||
| Type | Storage | Example |
|
|
||||||
|------|---------|---------|
|
|
||||||
| **Static config** | TOML files | Daemon tick interval |
|
|
||||||
| **Operational state** | Beads (events + labels) | Patrol muted |
|
|
||||||
| **Runtime flags** | Marker files | `.deacon-disabled` |
|
|
||||||
|
|
||||||
Static config rarely changes and doesn't need history.
|
|
||||||
Operational state changes at runtime and benefits from audit trail.
|
|
||||||
Marker files are fast checks that can trigger deeper beads queries.
|
|
||||||
|
|
||||||
## Commands Summary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create operational event
|
|
||||||
bd create --type=event --event-type=<type> \
|
|
||||||
--actor=<entity> --target=<entity> --payload='<json>'
|
|
||||||
|
|
||||||
# Update state label
|
|
||||||
bd update <role-bead> --add-label=<dim>:<val> --remove-label=<dim>:<old>
|
|
||||||
|
|
||||||
# Query current state
|
|
||||||
bd list --type=role --label=<dim>:<val>
|
|
||||||
|
|
||||||
# Query state history
|
|
||||||
bd list --type=event --target=<entity>
|
|
||||||
|
|
||||||
# Boot management
|
|
||||||
gt dog status boot
|
|
||||||
gt dog call boot
|
|
||||||
gt dog prime boot
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Events are the source of truth. Labels are the cache.*
|
|
||||||
@@ -27,7 +27,7 @@ These roles manage the Gas Town system itself:
|
|||||||
| Role | Description | Lifecycle |
|
| Role | Description | Lifecycle |
|
||||||
|------|-------------|-----------|
|
|------|-------------|-----------|
|
||||||
| **Mayor** | Global coordinator at mayor/ | Singleton, persistent |
|
| **Mayor** | Global coordinator at mayor/ | Singleton, persistent |
|
||||||
| **Deacon** | Background supervisor daemon ([watchdog chain](watchdog-chain.md)) | Singleton, persistent |
|
| **Deacon** | Background supervisor daemon ([watchdog chain](design/watchdog-chain.md)) | Singleton, persistent |
|
||||||
| **Witness** | Per-rig polecat lifecycle manager | One per rig, persistent |
|
| **Witness** | Per-rig polecat lifecycle manager | One per rig, persistent |
|
||||||
| **Refinery** | Per-rig merge queue processor | One per rig, persistent |
|
| **Refinery** | Per-rig merge queue processor | One per rig, persistent |
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ These roles do actual project work:
|
|||||||
|
|
||||||
| Role | Description | Lifecycle |
|
| Role | Description | Lifecycle |
|
||||||
|------|-------------|-----------|
|
|------|-------------|-----------|
|
||||||
| **Polecat** | Ephemeral worker with own worktree | Transient, Witness-managed ([details](polecat-lifecycle.md)) |
|
| **Polecat** | Ephemeral worker with own worktree | Transient, Witness-managed ([details](concepts/polecat-lifecycle.md)) |
|
||||||
| **Crew** | Persistent worker with own clone | Long-lived, user-managed |
|
| **Crew** | Persistent worker with own clone | Long-lived, user-managed |
|
||||||
| **Dog** | Deacon helper for infrastructure tasks | Ephemeral, Deacon-managed |
|
| **Dog** | Deacon helper for infrastructure tasks | Ephemeral, Deacon-managed |
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ gt convoy list
|
|||||||
- Historical record of completed work (`gt convoy list --all`)
|
- Historical record of completed work (`gt convoy list --all`)
|
||||||
|
|
||||||
The "swarm" is ephemeral - just the workers currently assigned to a convoy's issues.
|
The "swarm" is ephemeral - just the workers currently assigned to a convoy's issues.
|
||||||
When issues close, the convoy lands. See [Convoys](convoy.md) for details.
|
When issues close, the convoy lands. See [Convoys](concepts/convoy.md) for details.
|
||||||
|
|
||||||
## Crew vs Polecats
|
## Crew vs Polecats
|
||||||
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# Polecat Wisp Architecture
|
|
||||||
|
|
||||||
How polecats use molecules and wisps to execute work in Gas Town.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Polecats receive work via their hook - a pinned molecule attached to an issue.
|
|
||||||
They execute molecule steps sequentially, closing each step as they complete it.
|
|
||||||
|
|
||||||
## Molecule Types for Polecats
|
|
||||||
|
|
||||||
| Type | Storage | Use Case |
|
|
||||||
|------|---------|----------|
|
|
||||||
| **Regular Molecule** | `.beads/` (synced) | Discrete deliverables, audit trail |
|
|
||||||
| **Wisp** | `.beads/` (ephemeral, type=wisp) | Patrol cycles, operational loops |
|
|
||||||
|
|
||||||
Polecats typically use **regular molecules** because each assignment has audit value.
|
|
||||||
Patrol agents (Witness, Refinery, Deacon) use **wisps** to prevent accumulation.
|
|
||||||
|
|
||||||
## Step Execution
|
|
||||||
|
|
||||||
### The Traditional Approach
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Check current status
|
|
||||||
gt hook
|
|
||||||
|
|
||||||
# 2. Find next step
|
|
||||||
bd ready --parent=gt-abc
|
|
||||||
|
|
||||||
# 3. Claim the step
|
|
||||||
bd update gt-abc.4 --status=in_progress
|
|
||||||
|
|
||||||
# 4. Do the work...
|
|
||||||
|
|
||||||
# 5. Close the step
|
|
||||||
bd close gt-abc.4
|
|
||||||
|
|
||||||
# 6. Repeat from step 2
|
|
||||||
```
|
|
||||||
|
|
||||||
### The Propulsion Approach
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Check where you are
|
|
||||||
bd mol current
|
|
||||||
|
|
||||||
# 2. Do the work on current step...
|
|
||||||
|
|
||||||
# 3. Close and advance in one command
|
|
||||||
bd close gt-abc.4 --continue
|
|
||||||
|
|
||||||
# 4. Repeat from step 1
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--continue` flag:
|
|
||||||
- Closes the current step
|
|
||||||
- Finds the next ready step in the same molecule
|
|
||||||
- Auto-marks it `in_progress`
|
|
||||||
- Outputs the transition
|
|
||||||
|
|
||||||
### Example Session
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ bd mol current
|
|
||||||
You're working on molecule gt-abc (Implement user auth)
|
|
||||||
|
|
||||||
✓ gt-abc.1: Design schema
|
|
||||||
✓ gt-abc.2: Create models
|
|
||||||
→ gt-abc.3: Add endpoints [in_progress] <- YOU ARE HERE
|
|
||||||
○ gt-abc.4: Write tests
|
|
||||||
○ gt-abc.5: Update docs
|
|
||||||
|
|
||||||
Progress: 2/5 steps complete
|
|
||||||
|
|
||||||
$ # ... implement the endpoints ...
|
|
||||||
|
|
||||||
$ bd close gt-abc.3 --continue
|
|
||||||
✓ Closed gt-abc.3: Add endpoints
|
|
||||||
|
|
||||||
Next ready in molecule:
|
|
||||||
gt-abc.4: Write tests
|
|
||||||
|
|
||||||
→ Marked in_progress (use --no-auto to skip)
|
|
||||||
|
|
||||||
$ bd mol current
|
|
||||||
You're working on molecule gt-abc (Implement user auth)
|
|
||||||
|
|
||||||
✓ gt-abc.1: Design schema
|
|
||||||
✓ gt-abc.2: Create models
|
|
||||||
✓ gt-abc.3: Add endpoints
|
|
||||||
→ gt-abc.4: Write tests [in_progress] <- YOU ARE HERE
|
|
||||||
○ gt-abc.5: Update docs
|
|
||||||
|
|
||||||
Progress: 3/5 steps complete
|
|
||||||
```
|
|
||||||
|
|
||||||
## Molecule Completion
|
|
||||||
|
|
||||||
When closing the last step:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ bd close gt-abc.5 --continue
|
|
||||||
✓ Closed gt-abc.5: Update docs
|
|
||||||
|
|
||||||
Molecule gt-abc complete! All steps closed.
|
|
||||||
Consider: bd mol squash gt-abc --summary '...'
|
|
||||||
```
|
|
||||||
|
|
||||||
After all steps are closed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Squash to digest for audit trail
|
|
||||||
bd mol squash gt-abc --summary "Implemented user authentication with JWT"
|
|
||||||
|
|
||||||
# Or if it's routine work
|
|
||||||
bd mol burn gt-abc
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hook Management
|
|
||||||
|
|
||||||
### Checking Your Hook
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gt hook
|
|
||||||
```
|
|
||||||
|
|
||||||
Shows what molecule is pinned to your current agent and the associated bead.
|
|
||||||
|
|
||||||
### Attaching Work from Mail
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gt mail inbox
|
|
||||||
gt mol attach-from-mail <mail-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Completing Work
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After all molecule steps closed
|
|
||||||
gt done
|
|
||||||
|
|
||||||
# This:
|
|
||||||
# 1. Syncs beads
|
|
||||||
# 2. Submits to merge queue
|
|
||||||
# 3. Notifies Witness
|
|
||||||
```
|
|
||||||
|
|
||||||
## Polecat Workflow Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Spawn with work on hook
|
|
||||||
2. gt hook # What's hooked?
|
|
||||||
3. bd mol current # Where am I?
|
|
||||||
4. Execute current step
|
|
||||||
5. bd close <step> --continue
|
|
||||||
6. If more steps: GOTO 3
|
|
||||||
7. gt done # Signal completion
|
|
||||||
8. Wait for Witness cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wisp vs Molecule Decision
|
|
||||||
|
|
||||||
| Question | Molecule | Wisp |
|
|
||||||
|----------|----------|------|
|
|
||||||
| Does it need audit trail? | Yes | No |
|
|
||||||
| Will it repeat continuously? | No | Yes |
|
|
||||||
| Is it discrete deliverable? | Yes | No |
|
|
||||||
| Is it operational routine? | No | Yes |
|
|
||||||
|
|
||||||
Polecats: **Use molecules** (deliverables have audit value)
|
|
||||||
Patrol agents: **Use wisps** (routine loops don't accumulate)
|
|
||||||
@@ -206,17 +206,60 @@ gt mol step done <step> # Complete a molecule step
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
Gas Town sets environment variables for each agent session via `config.AgentEnv()`.
|
||||||
|
These are set in tmux session environment when agents are spawned.
|
||||||
|
|
||||||
|
### Core Variables (All Agents)
|
||||||
|
|
||||||
|
| Variable | Purpose | Example |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `GT_ROLE` | Agent role type | `mayor`, `witness`, `polecat`, `crew` |
|
||||||
|
| `GT_ROOT` | Town root directory | `/home/user/gt` |
|
||||||
|
| `BD_ACTOR` | Agent identity for attribution | `gastown/polecats/toast` |
|
||||||
|
| `GIT_AUTHOR_NAME` | Commit attribution (same as BD_ACTOR) | `gastown/polecats/toast` |
|
||||||
|
| `BEADS_DIR` | Beads database location | `/home/user/gt/gastown/.beads` |
|
||||||
|
|
||||||
|
### Rig-Level Variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Roles |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `GT_RIG` | Rig name | witness, refinery, polecat, crew |
|
||||||
|
| `GT_POLECAT` | Polecat worker name | polecat only |
|
||||||
|
| `GT_CREW` | Crew worker name | crew only |
|
||||||
|
| `BEADS_AGENT_NAME` | Agent name for beads operations | polecat, crew |
|
||||||
|
| `BEADS_NO_DAEMON` | Disable beads daemon (isolated context) | polecat, crew |
|
||||||
|
|
||||||
|
### Other Variables
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `BD_ACTOR` | Agent identity for attribution (see [identity.md](identity.md)) |
|
| `GIT_AUTHOR_EMAIL` | Workspace owner email (from git config) |
|
||||||
| `BEADS_DIR` | Point to shared beads database |
|
| `GT_TOWN_ROOT` | Override town root detection (manual use) |
|
||||||
| `BEADS_NO_DAEMON` | Required for worktree polecats |
|
| `CLAUDE_RUNTIME_CONFIG_DIR` | Custom Claude settings directory |
|
||||||
| `GIT_AUTHOR_NAME` | Set to BD_ACTOR for commit attribution |
|
|
||||||
| `GIT_AUTHOR_EMAIL` | Workspace owner email |
|
### Environment by Role
|
||||||
| `GT_TOWN_ROOT` | Override town root detection |
|
|
||||||
| `GT_ROLE` | Agent role type (mayor, polecat, etc.) |
|
| Role | Key Variables |
|
||||||
| `GT_RIG` | Rig name for rig-level agents |
|
|------|---------------|
|
||||||
| `GT_POLECAT` | Polecat name (for polecats only) |
|
| **Mayor** | `GT_ROLE=mayor`, `BD_ACTOR=mayor` |
|
||||||
|
| **Deacon** | `GT_ROLE=deacon`, `BD_ACTOR=deacon` |
|
||||||
|
| **Boot** | `GT_ROLE=boot`, `BD_ACTOR=deacon-boot` |
|
||||||
|
| **Witness** | `GT_ROLE=witness`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/witness` |
|
||||||
|
| **Refinery** | `GT_ROLE=refinery`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/refinery` |
|
||||||
|
| **Polecat** | `GT_ROLE=polecat`, `GT_RIG=<rig>`, `GT_POLECAT=<name>`, `BD_ACTOR=<rig>/polecats/<name>` |
|
||||||
|
| **Crew** | `GT_ROLE=crew`, `GT_RIG=<rig>`, `GT_CREW=<name>`, `BD_ACTOR=<rig>/crew/<name>` |
|
||||||
|
|
||||||
|
### Doctor Check
|
||||||
|
|
||||||
|
The `gt doctor` command verifies that running tmux sessions have correct
|
||||||
|
environment variables. Mismatches are reported as warnings:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠ env-vars: Found 3 env var mismatch(es) across 1 session(s)
|
||||||
|
hq-mayor: missing GT_ROOT (expected "/home/user/gt")
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix by restarting sessions: `gt shutdown && gt up`
|
||||||
|
|
||||||
## Agent Working Directories and Settings
|
## Agent Working Directories and Settings
|
||||||
|
|
||||||
@@ -428,7 +471,7 @@ gt convoy list --all # Include landed convoys
|
|||||||
gt convoy list --status=closed # Only landed convoys
|
gt convoy list --status=closed # Only landed convoys
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: "Swarm" is ephemeral (workers on a convoy's issues). See [Convoys](convoy.md).
|
Note: "Swarm" is ephemeral (workers on a convoy's issues). See [Convoys](concepts/convoy.md).
|
||||||
|
|
||||||
### Work Assignment
|
### Work Assignment
|
||||||
|
|
||||||
@@ -467,7 +510,7 @@ gt escalate -s HIGH "msg" # Important blocker
|
|||||||
gt escalate -s MEDIUM "msg" -m "Details..."
|
gt escalate -s MEDIUM "msg" -m "Details..."
|
||||||
```
|
```
|
||||||
|
|
||||||
See [escalation.md](escalation.md) for full protocol.
|
See [escalation.md](design/escalation.md) for full protocol.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
@@ -568,4 +611,4 @@ bd mol bond mol-security-scan $PATROL_ID --var scope="$SCOPE"
|
|||||||
|
|
||||||
**Nondeterministic idempotence**: Any worker can continue any molecule. Steps are atomic checkpoints in beads.
|
**Nondeterministic idempotence**: Any worker can continue any molecule. Steps are atomic checkpoints in beads.
|
||||||
|
|
||||||
**Convoy tracking**: Convoys track batched work across rigs. A "swarm" is ephemeral - just the workers currently on a convoy's issues. See [Convoys](convoy.md) for details.
|
**Convoy tracking**: Convoys track batched work across rigs. A "swarm" is ephemeral - just the workers currently on a convoy's issues. See [Convoys](concepts/convoy.md) for details.
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
# Infrastructure & Utilities Code Review
|
|
||||||
|
|
||||||
**Review ID**: gt-a02fj.8
|
|
||||||
**Date**: 2026-01-04
|
|
||||||
**Reviewer**: gastown/polecats/interceptor (polecat gus)
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Reviewed 14 infrastructure packages for dead code, missing abstractions, performance concerns, and error handling consistency. Found significant cleanup opportunities totaling ~44% dead code in constants package and an entire unused package (keepalive).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Dead Code Inventory
|
|
||||||
|
|
||||||
### Critical: Entire Package Unused
|
|
||||||
|
|
||||||
| Package | Status | Recommendation |
|
|
||||||
|---------|--------|----------------|
|
|
||||||
| `internal/keepalive/` | 100% unused | **DELETE ENTIRE PACKAGE** |
|
|
||||||
|
|
||||||
The keepalive package (5 functions) was removed from the codebase on Dec 30, 2025 as part of the shift to feed-based activation. No imports exist anywhere.
|
|
||||||
|
|
||||||
### High Priority: Functions to Remove
|
|
||||||
|
|
||||||
| Package | Function | Location | Notes |
|
|
||||||
|---------|----------|----------|-------|
|
|
||||||
| `config` | `NewExampleAgentRegistry()` | agents.go:361-381 | Zero usage in codebase |
|
|
||||||
| `constants` | `DirMayor`, `DirPolecats`, `DirCrew`, etc. | constants.go:32-59 | 9 unused directory constants |
|
|
||||||
| `constants` | `FileRigsJSON`, `FileTownJSON`, etc. | constants.go:62-74 | 4 unused file constants |
|
|
||||||
| `constants` | `BranchMain`, `BranchBeadsSync`, etc. | constants.go:77-89 | 4 unused branch constants |
|
|
||||||
| `constants` | `RigBeadsPath()`, `RigPolecatsPath()`, etc. | constants.go | 5 unused path helper functions |
|
|
||||||
| `doctor` | `itoa()` | daemon_check.go:93-111 | Duplicate of `strconv.Itoa()` |
|
|
||||||
| `lock` | `DetectCollisions()` | lock.go:367-402 | Superseded by doctor checks |
|
|
||||||
| `events` | `BootPayload()` | events.go:186-191 | Never called |
|
|
||||||
| `events` | `TypePatrolStarted`, `TypeSessionEnd` | events.go:50,54 | Never emitted |
|
|
||||||
| `events` | `VisibilityBoth` | events.go:32 | Never set |
|
|
||||||
| `boot` | `DeaconDir()` | boot.go:235-237 | Exported but never called |
|
|
||||||
| `dog` | `IdleCount()`, `WorkingCount()` | manager.go:532-562 | Inlined in callers |
|
|
||||||
|
|
||||||
### Medium Priority: Duplicate Definitions
|
|
||||||
|
|
||||||
| Package | Item | Duplicate Location | Action |
|
|
||||||
|---------|------|-------------------|--------|
|
|
||||||
| `constants` | `RigSettingsPath()` | Also in config/loader.go:673 | Remove from constants |
|
|
||||||
| `util` | Atomic write pattern | Also in mrqueue/, wisp/ | Consolidate to util |
|
|
||||||
| `doctor` | `findRigs()` | 3 identical implementations | Extract shared helper |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Utility Consolidation Plan
|
|
||||||
|
|
||||||
### Pattern: Atomic Write (Priority: HIGH)
|
|
||||||
|
|
||||||
**Current state**: Duplicated in 3+ locations
|
|
||||||
- `util/atomic.go` (canonical)
|
|
||||||
- `mrqueue/mrqueue.go` (duplicate)
|
|
||||||
- `wisp/io.go` (duplicate)
|
|
||||||
- `polecat/pending.go` (NON-ATOMIC - bug!)
|
|
||||||
|
|
||||||
**Action**:
|
|
||||||
1. Fix `polecat/pending.go:SavePending()` to use `util.AtomicWriteJSON`
|
|
||||||
2. Replace inline atomic writes in mrqueue and wisp with util calls
|
|
||||||
|
|
||||||
### Pattern: Rig Discovery (Priority: HIGH)
|
|
||||||
|
|
||||||
**Current state**: 7+ implementations scattered across doctor package
|
|
||||||
- `BranchCheck.findPersistentRoleDirs()`
|
|
||||||
- `OrphanSessionCheck.getValidRigs()`
|
|
||||||
- `PatrolMoleculesExistCheck.discoverRigs()`
|
|
||||||
- `config_check.go.findAllRigs()`
|
|
||||||
- Multiple `findCrewDirs()` implementations
|
|
||||||
|
|
||||||
**Action**: Create `internal/workspace/discovery.go`:
|
|
||||||
```go
|
|
||||||
type RigDiscovery struct { ... }
|
|
||||||
func (d *RigDiscovery) FindAllRigs() []string
|
|
||||||
func (d *RigDiscovery) FindCrewDirs(rig string) []string
|
|
||||||
func (d *RigDiscovery) FindPolecatDirs(rig string) []string
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern: Clone Validation (Priority: MEDIUM)
|
|
||||||
|
|
||||||
**Current state**: Duplicate logic in doctor checks
|
|
||||||
- `rig_check.go`: Validates .git, runs git status
|
|
||||||
- `branch_check.go`: Similar traversal logic
|
|
||||||
|
|
||||||
**Action**: Create `internal/workspace/clone.go`:
|
|
||||||
```go
|
|
||||||
type CloneValidator struct { ... }
|
|
||||||
func (v *CloneValidator) ValidateClone(path string) error
|
|
||||||
func (v *CloneValidator) GetCloneInfo(path string) (*CloneInfo, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern: Tmux Session Handling (Priority: MEDIUM)
|
|
||||||
|
|
||||||
**Current state**: Fragmented across lock, doctor, daemon
|
|
||||||
- `lock/lock.go`: `getActiveTmuxSessions()`
|
|
||||||
- `doctor/identity_check.go`: Similar logic
|
|
||||||
- `cmd/agents.go`: Uses `tmux.NewTmux()`
|
|
||||||
|
|
||||||
**Action**: Consolidate into `internal/tmux/sessions.go`
|
|
||||||
|
|
||||||
### Pattern: Load/Validate Config Files (Priority: LOW)
|
|
||||||
|
|
||||||
**Current state**: 8 near-identical Load* functions in config/loader.go
|
|
||||||
- `LoadTownConfig`, `LoadRigsConfig`, `LoadRigConfig`, etc.
|
|
||||||
|
|
||||||
**Action**: Create generic loader using Go generics:
|
|
||||||
```go
|
|
||||||
func loadConfigFile[T Validator](path string) (*T, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern: Math Utilities (Priority: LOW)
|
|
||||||
|
|
||||||
**Current state**: `min()`, `max()`, `min3()`, `abs()` in suggest/suggest.go
|
|
||||||
|
|
||||||
**Action**: If needed elsewhere, move to `internal/util/math.go`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Performance Concerns
|
|
||||||
|
|
||||||
### Critical: File I/O Per-Event
|
|
||||||
|
|
||||||
| Package | Issue | Impact | Recommendation |
|
|
||||||
|---------|-------|--------|----------------|
|
|
||||||
| `events` | Opens/closes file for every event | High on busy systems | Batch writes or buffered logger |
|
|
||||||
| `townlog` | Opens/closes file per log entry | Medium | Same as events |
|
|
||||||
| `events` | `workspace.FindFromCwd()` on every Log() | Low-medium | Cache town root |
|
|
||||||
|
|
||||||
### Critical: Process Tree Walking
|
|
||||||
|
|
||||||
| Package | Issue | Impact | Recommendation |
|
|
||||||
|---------|-------|--------|----------------|
|
|
||||||
| `doctor/orphan_check` | `hasCrewAncestor()` calls `ps` in loop | O(n) subprocess calls | Batch gather process info |
|
|
||||||
|
|
||||||
### High: Directory Traversal Inefficiencies
|
|
||||||
|
|
||||||
| Package | Issue | Impact | Recommendation |
|
|
||||||
|---------|-------|--------|----------------|
|
|
||||||
| `doctor/hook_check` | Uses `exec.Command("find")` | Subprocess overhead | Use `filepath.Walk` |
|
|
||||||
| `lock` | `FindAllLocks()` - unbounded Walk | Scales poorly | Add depth limits |
|
|
||||||
| `townlog` | `TailEvents()` reads entire file | Memory for large logs | Implement true tail |
|
|
||||||
|
|
||||||
### Medium: Redundant Operations
|
|
||||||
|
|
||||||
| Package | Issue | Recommendation |
|
|
||||||
|---------|-------|----------------|
|
|
||||||
| `dog` | `List()` + iterate = double work | Provide `CountByState()` |
|
|
||||||
| `dog` | Creates new git.Git per worktree | Cache or batch |
|
|
||||||
| `doctor/rig_check` | Runs git status twice per polecat | Combine operations |
|
|
||||||
| `checkpoint/Capture` | 3 separate git commands | Use combined flags |
|
|
||||||
|
|
||||||
### Low: JSON Formatting Overhead
|
|
||||||
|
|
||||||
| Package | Issue | Recommendation |
|
|
||||||
|---------|-------|----------------|
|
|
||||||
| `lock` | `MarshalIndent()` for lock files | Use `Marshal()` (no indentation needed) |
|
|
||||||
| `townlog` | No compression for old logs | Consider gzip rotation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Error Handling Issues
|
|
||||||
|
|
||||||
### Pattern: Silent Failures
|
|
||||||
|
|
||||||
| Package | Location | Issue | Fix |
|
|
||||||
|---------|----------|-------|-----|
|
|
||||||
| `events` | All callers | 19 instances of `_ = events.LogFeed()` | Standardize: always ignore or always check |
|
|
||||||
| `townlog` | `ParseLogLines()` | Silently skips malformed lines | Log warnings |
|
|
||||||
| `lock` | Lines 91, 180, 194-195 | Silent `_ =` without comments | Document intent |
|
|
||||||
| `checkpoint` | `Capture()` | Returns nil error but git commands fail | Return actual errors |
|
|
||||||
| `deps` | `BeadsUnknown` case | Silently passes | Log warning or fail |
|
|
||||||
|
|
||||||
### Pattern: Inconsistent State Handling
|
|
||||||
|
|
||||||
| Package | Issue | Recommendation |
|
|
||||||
|---------|-------|----------------|
|
|
||||||
| `dog/Get()` | Returns minimal Dog if state missing | Document or error |
|
|
||||||
| `config/GetAccount()` | Returns pointer to loop variable (bug!) | Return by value |
|
|
||||||
| `boot` | `LoadStatus()` returns empty struct if missing | Document behavior |
|
|
||||||
|
|
||||||
### Bug: Missing Role Mapping
|
|
||||||
|
|
||||||
| Package | Issue | Impact |
|
|
||||||
|---------|-------|--------|
|
|
||||||
| `claude` | `RoleTypeFor()` missing `deacon`, `crew` | Wrong settings applied |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Testing Gaps
|
|
||||||
|
|
||||||
| Package | Gap | Priority |
|
|
||||||
|---------|-----|----------|
|
|
||||||
| `checkpoint` | No unit tests | HIGH (crash recovery) |
|
|
||||||
| `dog` | 4 tests, major paths untested | HIGH |
|
|
||||||
| `deps` | Minimal failure path testing | MEDIUM |
|
|
||||||
| `claude` | No tests | LOW |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary Statistics
|
|
||||||
|
|
||||||
| Category | Count | Packages Affected |
|
|
||||||
|----------|-------|-------------------|
|
|
||||||
| **Dead Code Items** | 25+ | config, constants, doctor, lock, events, boot, dog, keepalive |
|
|
||||||
| **Duplicate Patterns** | 6 | util, doctor, config, lock |
|
|
||||||
| **Performance Issues** | 12 | events, townlog, doctor, dog, lock, checkpoint |
|
|
||||||
| **Error Handling Issues** | 15 | events, townlog, lock, checkpoint, deps, claude |
|
|
||||||
| **Testing Gaps** | 4 packages | checkpoint, dog, deps, claude |
|
|
||||||
|
|
||||||
## Recommended Priority
|
|
||||||
|
|
||||||
1. **Delete keepalive package** (entire package unused)
|
|
||||||
2. **Fix claude/RoleTypeFor()** (incorrect behavior)
|
|
||||||
3. **Fix config/GetAccount()** (pointer to stack bug)
|
|
||||||
4. **Fix polecat/pending.go** (non-atomic writes)
|
|
||||||
5. **Delete 21 unused constants** (maintenance burden)
|
|
||||||
6. **Consolidate atomic write pattern** (DRY)
|
|
||||||
7. **Add checkpoint tests** (crash recovery critical)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Swarm (Ephemeral Worker View)
|
|
||||||
|
|
||||||
> **Note**: "Swarm" is an ephemeral concept, not a persistent entity.
|
|
||||||
> For tracking work, see [Convoys](convoy.md).
|
|
||||||
|
|
||||||
## What is a Swarm?
|
|
||||||
|
|
||||||
A **swarm** is simply "the workers currently assigned to a convoy's issues."
|
|
||||||
It has no separate ID and no persistent state - it's just a view of active workers.
|
|
||||||
|
|
||||||
| Concept | Persistent? | ID | Description |
|
|
||||||
|---------|-------------|-----|-------------|
|
|
||||||
| **Convoy** | Yes | hq-* | The tracking unit. What you create and track. |
|
|
||||||
| **Swarm** | No | None | The workers. Ephemeral view of who's working. |
|
|
||||||
|
|
||||||
## The Relationship
|
|
||||||
|
|
||||||
```
|
|
||||||
Convoy hq-abc ─────────tracks───────────► Issues
|
|
||||||
│
|
|
||||||
│ assigned to
|
|
||||||
▼
|
|
||||||
Polecats
|
|
||||||
│
|
|
||||||
────────┴────────
|
|
||||||
"the swarm"
|
|
||||||
(ephemeral)
|
|
||||||
```
|
|
||||||
|
|
||||||
When you say "kick off a swarm," you're really:
|
|
||||||
1. Creating a convoy (persistent tracking)
|
|
||||||
2. Assigning polecats to the convoy's issues
|
|
||||||
3. The swarm = those polecats while they work
|
|
||||||
|
|
||||||
When the work completes, the convoy lands and the swarm dissolves.
|
|
||||||
|
|
||||||
## Viewing the Swarm
|
|
||||||
|
|
||||||
The swarm appears in convoy status:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gt convoy status hq-abc
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Convoy: hq-abc (Deploy v2.0)
|
|
||||||
════════════════════════════
|
|
||||||
|
|
||||||
Progress: 2/3 complete
|
|
||||||
|
|
||||||
Issues
|
|
||||||
✓ gt-xyz: Update API closed
|
|
||||||
→ bd-ghi: Update docs in_progress @beads/amber
|
|
||||||
○ gt-jkl: Final review open
|
|
||||||
|
|
||||||
Workers (the swarm) ← this is the swarm
|
|
||||||
beads/amber bd-ghi running 12m
|
|
||||||
```
|
|
||||||
|
|
||||||
## Historical Note
|
|
||||||
|
|
||||||
Earlier Gas Town development used "swarm" as if it were a persistent entity
|
|
||||||
with its own lifecycle. The `gt swarm` commands were built on this model.
|
|
||||||
|
|
||||||
The correct model is:
|
|
||||||
- **Convoy** = the persistent tracking unit (what `gt swarm` was trying to be)
|
|
||||||
- **Swarm** = ephemeral workers (no separate tracking needed)
|
|
||||||
|
|
||||||
The `gt swarm` command is being deprecated in favor of `gt convoy`.
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Convoys](convoy.md) - The persistent tracking unit
|
|
||||||
- [Propulsion Principle](propulsion-principle.md) - Worker execution model
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Test Coverage and Quality Review
|
|
||||||
|
|
||||||
**Reviewed by**: polecat/gus
|
|
||||||
**Date**: 2026-01-04
|
|
||||||
**Issue**: gt-a02fj.9
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
- **80 test files** covering **32 out of 42 packages** (76% package coverage)
|
|
||||||
- **631 test functions** with 192 subtests (30% use table-driven pattern)
|
|
||||||
- **10 packages** with **0 test coverage** (2,452 lines)
|
|
||||||
- **1 confirmed flaky test** candidate
|
|
||||||
- Test quality is generally good with moderate mocking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coverage Gap Inventory
|
|
||||||
|
|
||||||
### Packages Without Tests (Priority Order)
|
|
||||||
|
|
||||||
| Priority | Package | Lines | Risk | Notes |
|
|
||||||
|----------|---------|-------|------|-------|
|
|
||||||
| **P0** | `internal/lock` | 402 | **CRITICAL** | Multi-agent lock management. Bugs cause worker collisions. Already has `execCommand` mockable for testing. |
|
|
||||||
| **P1** | `internal/events` | 295 | HIGH | Event bus for audit trail. Mutex-protected writes. Core observability. |
|
|
||||||
| **P1** | `internal/boot` | 242 | HIGH | Boot watchdog lifecycle. Spawns tmux sessions. |
|
|
||||||
| **P1** | `internal/checkpoint` | 216 | HIGH | Session crash recovery. Critical for polecat continuity. |
|
|
||||||
| **P2** | `internal/tui/convoy` | 601 | MEDIUM | TUI component. Harder to test but user-facing. |
|
|
||||||
| **P2** | `internal/constants` | 221 | LOW | Mostly configuration constants. Low behavioral risk. |
|
|
||||||
| **P3** | `internal/style` | 331 | LOW | Output formatting. Visual only. |
|
|
||||||
| **P3** | `internal/claude` | 80 | LOW | Claude settings parsing. |
|
|
||||||
| **P3** | `internal/wisp` | 52 | LOW | Ephemeral molecule I/O. Small surface. |
|
|
||||||
| **P4** | `cmd/gt` | 12 | TRIVIAL | Main entry point. Minimal code. |
|
|
||||||
|
|
||||||
**Total untested lines**: 2,452
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flaky Test Candidates
|
|
||||||
|
|
||||||
### Confirmed: `internal/feed/curator_test.go`
|
|
||||||
|
|
||||||
**Issue**: Uses `time.Sleep()` for synchronization (lines 59, 71, 119, 138)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Give curator time to start
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
...
|
|
||||||
// Wait for processing
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Risk**: Flaky under load, CI delays, or slow machines.
|
|
||||||
|
|
||||||
**Fix**: Replace with channel-based synchronization or polling with timeout:
|
|
||||||
```go
|
|
||||||
// Wait for condition with timeout
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if conditionMet() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Quality Analysis
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
1. **Table-driven tests**: 30% of tests use `t.Run()` (192/631)
|
|
||||||
2. **Good isolation**: Only 2 package-level test variables
|
|
||||||
3. **Dedicated integration tests**: 15 files with explicit integration/e2e naming
|
|
||||||
4. **Error handling**: 316 uses of `if err != nil` in tests
|
|
||||||
5. **No random data**: No `rand.` usage in tests (deterministic)
|
|
||||||
6. **Environment safety**: Uses `t.Setenv()` for clean env var handling
|
|
||||||
|
|
||||||
### Areas for Improvement
|
|
||||||
|
|
||||||
1. **`testing.Short()`**: Only 1 usage. Long-running tests should check this.
|
|
||||||
2. **External dependencies**: 26 tests skip when `bd` or `tmux` unavailable - consider mocking more.
|
|
||||||
3. **time.Sleep usage**: Found in `curator_test.go` - should be eliminated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Smells (Minor)
|
|
||||||
|
|
||||||
| Smell | Location | Severity | Notes |
|
|
||||||
|-------|----------|----------|-------|
|
|
||||||
| Sleep-based sync | `feed/curator_test.go` | HIGH | See flaky section |
|
|
||||||
| External dep skips | Multiple files | LOW | Reasonable for integration tests |
|
|
||||||
| Skip-heavy file | `tmux/tmux_test.go` | LOW | Acceptable - tmux not always available |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority List for New Tests
|
|
||||||
|
|
||||||
### Immediate (P0)
|
|
||||||
|
|
||||||
1. **`internal/lock`** - Critical path
|
|
||||||
- Test `Acquire()` with stale lock cleanup
|
|
||||||
- Test `Check()` with live/dead PIDs
|
|
||||||
- Test `CleanStaleLocks()` with mock tmux sessions
|
|
||||||
- Test `DetectCollisions()`
|
|
||||||
- Test concurrent lock acquisition (race detection)
|
|
||||||
|
|
||||||
### High Priority (P1)
|
|
||||||
|
|
||||||
2. **`internal/events`**
|
|
||||||
- Test `Log()` file creation and append
|
|
||||||
- Test `write()` mutex behavior
|
|
||||||
- Test payload helpers
|
|
||||||
- Test graceful handling when not in workspace
|
|
||||||
|
|
||||||
3. **`internal/boot`**
|
|
||||||
- Test `IsRunning()` with stale markers
|
|
||||||
- Test `AcquireLock()` / `ReleaseLock()` cycle
|
|
||||||
- Test `SaveStatus()` / `LoadStatus()` round-trip
|
|
||||||
- Test degraded mode path
|
|
||||||
|
|
||||||
4. **`internal/checkpoint`**
|
|
||||||
- Test `Read()` / `Write()` round-trip
|
|
||||||
- Test `Capture()` git state extraction
|
|
||||||
- Test `IsStale()` with various durations
|
|
||||||
- Test `Summary()` output
|
|
||||||
|
|
||||||
### Medium Priority (P2)
|
|
||||||
|
|
||||||
5. **`internal/tui/convoy`** - Consider golden file tests for view output
|
|
||||||
6. **`internal/constants`** - Test any validation logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Missing Test Types
|
|
||||||
|
|
||||||
| Type | Current State | Recommendation |
|
|
||||||
|------|--------------|----------------|
|
|
||||||
| Unit tests | Good coverage where present | Add for P0-P1 packages |
|
|
||||||
| Integration tests | 15 dedicated files | Adequate |
|
|
||||||
| E2E tests | `browser_e2e_test.go` | Consider more CLI E2E |
|
|
||||||
| Fuzz tests | None | Consider for parsers (`formula/parser.go`) |
|
|
||||||
| Benchmark tests | None visible | Add for hot paths (`lock`, `events`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Actionable Next Steps
|
|
||||||
|
|
||||||
1. **Fix flaky test**: Refactor `feed/curator_test.go` to use channels/polling
|
|
||||||
2. **Add lock tests**: Highest priority - bugs here break multi-agent
|
|
||||||
3. **Add events tests**: Core observability must be tested
|
|
||||||
4. **Add checkpoint tests**: Session recovery is critical path
|
|
||||||
5. **Run with race detector**: `go test -race ./...` to catch data races
|
|
||||||
6. **Consider `-short` flag**: Add `testing.Short()` checks to slow tests
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
# Wisp Squash Design: Cadences, Rules, Templates
|
|
||||||
|
|
||||||
Design specification for how wisps squash to digests in Gas Town.
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
Wisps are ephemeral molecules that need to be condensed into digests for:
|
|
||||||
- **Audit trail**: What happened, when, by whom
|
|
||||||
- **Activity feed**: Observable progress in the capability ledger
|
|
||||||
- **Space efficiency**: Ephemeral data doesn't accumulate indefinitely
|
|
||||||
|
|
||||||
Currently under-designed:
|
|
||||||
- **Cadences**: When should squash happen?
|
|
||||||
- **Templates**: What should digests contain?
|
|
||||||
- **Retention**: How long to keep, when to aggregate?
|
|
||||||
|
|
||||||
## Squash Cadences
|
|
||||||
|
|
||||||
### Patrol Wisps (Deacon, Witness, Refinery)
|
|
||||||
|
|
||||||
**Trigger**: End of each patrol cycle
|
|
||||||
|
|
||||||
```
|
|
||||||
patrol-start → steps → loop-or-exit step → squash → new wisp
|
|
||||||
```
|
|
||||||
|
|
||||||
| Decision Point | Action |
|
|
||||||
|----------------|--------|
|
|
||||||
| `loop-or-exit` with low context | Squash current wisp, create new wisp |
|
|
||||||
| `loop-or-exit` with high context | Squash current wisp, handoff |
|
|
||||||
| Extraordinary action | Squash immediately, handoff |
|
|
||||||
|
|
||||||
**Rationale**: Each patrol cycle is a logical unit. Squashing per-cycle keeps
|
|
||||||
digests meaningful and prevents context-filling sessions from losing history.
|
|
||||||
|
|
||||||
### Work Wisps (Polecats)
|
|
||||||
|
|
||||||
**Trigger**: Before `gt done` or molecule completion
|
|
||||||
|
|
||||||
```
|
|
||||||
work-assigned → steps → all-complete → squash → gt done → merge queue
|
|
||||||
```
|
|
||||||
|
|
||||||
Polecats typically use regular molecules (not wisps), but when wisps are used
|
|
||||||
for exploratory work:
|
|
||||||
|
|
||||||
| Scenario | Action |
|
|
||||||
|----------|--------|
|
|
||||||
| Molecule completes | Squash to digest |
|
|
||||||
| Molecule abandoned | Burn (no digest) |
|
|
||||||
| Molecule handed off | Squash, include handoff context |
|
|
||||||
|
|
||||||
### Time-Based Cadences (Future)
|
|
||||||
|
|
||||||
For long-running molecules that span multiple sessions:
|
|
||||||
|
|
||||||
| Duration | Action |
|
|
||||||
|----------|--------|
|
|
||||||
| Session ends | Auto-squash if molecule in progress |
|
|
||||||
| > 24 hours | Create checkpoint digest |
|
|
||||||
| > 7 days | Warning: stale molecule |
|
|
||||||
|
|
||||||
**Not implemented initially** - simplicity first.
|
|
||||||
|
|
||||||
## Summary Templates
|
|
||||||
|
|
||||||
### Template Structure
|
|
||||||
|
|
||||||
Digests have three sections:
|
|
||||||
1. **Header**: Standard metadata (who, what, when)
|
|
||||||
2. **Body**: Context-specific content (from template)
|
|
||||||
3. **Footer**: System metrics (steps, duration, commit refs)
|
|
||||||
|
|
||||||
### Patrol Digest Template
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Patrol Digest: {{.Agent}}
|
|
||||||
|
|
||||||
**Cycle**: {{.CycleNumber}} | **Duration**: {{.Duration}}
|
|
||||||
|
|
||||||
### Actions Taken
|
|
||||||
{{range .Actions}}
|
|
||||||
- {{.Icon}} {{.Description}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
### Issues Filed
|
|
||||||
{{range .IssuesFiled}}
|
|
||||||
- {{.ID}}: {{.Title}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
- Inbox: {{.InboxCount}} messages processed
|
|
||||||
- Health checks: {{.HealthChecks}}
|
|
||||||
- Alerts: {{.AlertCount}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Work Digest Template
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Work Digest: {{.IssueTitle}}
|
|
||||||
|
|
||||||
**Issue**: {{.IssueID}} | **Agent**: {{.Agent}} | **Duration**: {{.Duration}}
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
{{.Summary}}
|
|
||||||
|
|
||||||
### Steps Completed
|
|
||||||
{{range .Steps}}
|
|
||||||
- [{{.Status}}] {{.Title}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
### Artifacts
|
|
||||||
- Commits: {{range .Commits}}{{.Short}}, {{end}}
|
|
||||||
- Files changed: {{.FilesChanged}}
|
|
||||||
- Lines: +{{.LinesAdded}} -{{.LinesRemoved}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formula-Defined Templates
|
|
||||||
|
|
||||||
Formulas can define custom squash templates in `[squash]` section:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
formula = "mol-my-workflow"
|
|
||||||
version = 1
|
|
||||||
|
|
||||||
[squash]
|
|
||||||
template = """
|
|
||||||
## {{.Title}} Complete
|
|
||||||
|
|
||||||
Duration: {{.Duration}}
|
|
||||||
Key metrics:
|
|
||||||
{{range .Steps}}
|
|
||||||
- {{.ID}}: {{.CustomField}}
|
|
||||||
{{end}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Template variables from step outputs
|
|
||||||
[squash.vars]
|
|
||||||
include_metrics = true
|
|
||||||
summary_length = "short" # short | medium | detailed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution order**:
|
|
||||||
1. Formula-defined template (if present)
|
|
||||||
2. Type-specific default (patrol vs work)
|
|
||||||
3. Minimal fallback (current behavior)
|
|
||||||
|
|
||||||
## Retention Rules
|
|
||||||
|
|
||||||
### Digest Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
Wisp → Squash → Digest (active) → Digest (archived) → Rollup
|
|
||||||
```
|
|
||||||
|
|
||||||
| Phase | Duration | Storage |
|
|
||||||
|-------|----------|---------|
|
|
||||||
| Active | 30 days | `.beads/issues.jsonl` |
|
|
||||||
| Archived | 1 year | `.beads/archive/` (compressed) |
|
|
||||||
| Rollup | Permanent | Weekly/monthly summaries |
|
|
||||||
|
|
||||||
### Rollup Strategy
|
|
||||||
|
|
||||||
After retention period, digests aggregate into rollups:
|
|
||||||
|
|
||||||
**Weekly Patrol Rollup**:
|
|
||||||
```markdown
|
|
||||||
## Week of {{.WeekStart}}
|
|
||||||
|
|
||||||
| Agent | Cycles | Issues Filed | Merges | Incidents |
|
|
||||||
|-------|--------|--------------|--------|-----------|
|
|
||||||
| Deacon | 140 | 3 | - | 0 |
|
|
||||||
| Witness | 168 | 12 | - | 2 |
|
|
||||||
| Refinery | 84 | 0 | 47 | 1 |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Monthly Work Rollup**:
|
|
||||||
```markdown
|
|
||||||
## {{.Month}} Work Summary
|
|
||||||
|
|
||||||
Issues completed: {{.TotalIssues}}
|
|
||||||
Total duration: {{.TotalDuration}}
|
|
||||||
Contributors: {{range .Contributors}}{{.Name}}, {{end}}
|
|
||||||
|
|
||||||
Top categories:
|
|
||||||
{{range .Categories}}
|
|
||||||
- {{.Name}}: {{.Count}} issues
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retention Configuration
|
|
||||||
|
|
||||||
Per-rig settings in `config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"retention": {
|
|
||||||
"digest_active_days": 30,
|
|
||||||
"digest_archive_days": 365,
|
|
||||||
"rollup_weekly": true,
|
|
||||||
"rollup_monthly": true,
|
|
||||||
"auto_archive": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Template System (MVP)
|
|
||||||
|
|
||||||
1. Add `[squash]` section parsing to formula loader
|
|
||||||
2. Create default templates for patrol and work digests
|
|
||||||
3. Enhance `bd mol squash` to use templates
|
|
||||||
4. Add `--template` flag for override
|
|
||||||
|
|
||||||
### Phase 2: Cadence Automation
|
|
||||||
|
|
||||||
1. Hook squash into `gt done` flow
|
|
||||||
2. Add patrol cycle completion detection
|
|
||||||
3. Emit squash events for activity feed
|
|
||||||
|
|
||||||
### Phase 3: Retention & Archival
|
|
||||||
|
|
||||||
1. Implement digest aging (active → archived)
|
|
||||||
2. Add `bd archive` command for manual archival
|
|
||||||
3. Create rollup generator for weekly/monthly summaries
|
|
||||||
4. Background daemon task for auto-archival
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Squash with Template
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use formula-defined template
|
|
||||||
bd mol squash <id>
|
|
||||||
|
|
||||||
# Use explicit template
|
|
||||||
bd mol squash <id> --template=detailed
|
|
||||||
|
|
||||||
# Add custom summary
|
|
||||||
bd mol squash <id> --summary="Patrol complete: 3 issues filed"
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Digests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List recent digests
|
|
||||||
bd list --label=digest
|
|
||||||
|
|
||||||
# View rollups
|
|
||||||
bd rollup list
|
|
||||||
bd rollup show weekly-2025-01
|
|
||||||
```
|
|
||||||
|
|
||||||
### Archive Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Archive old digests
|
|
||||||
bd archive --older-than=30d
|
|
||||||
|
|
||||||
# Generate rollup
|
|
||||||
bd rollup generate --week=2025-01
|
|
||||||
|
|
||||||
# Restore from archive
|
|
||||||
bd archive restore <digest-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Activity Feed Integration
|
|
||||||
|
|
||||||
Digests feed into the activity feed for observability:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "digest",
|
|
||||||
"agent": "greenplace/witness",
|
|
||||||
"timestamp": "2025-12-30T10:00:00Z",
|
|
||||||
"summary": "Patrol cycle 47 complete",
|
|
||||||
"metrics": {
|
|
||||||
"issues_filed": 2,
|
|
||||||
"polecats_nudged": 1,
|
|
||||||
"duration_minutes": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The feed curator (daemon) can aggregate these for dashboards.
|
|
||||||
|
|
||||||
## Formula Example
|
|
||||||
|
|
||||||
Complete formula with squash configuration:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
formula = "mol-witness-patrol"
|
|
||||||
version = 1
|
|
||||||
type = "workflow"
|
|
||||||
description = "Witness patrol cycle"
|
|
||||||
|
|
||||||
[squash]
|
|
||||||
trigger = "on_complete"
|
|
||||||
template_type = "patrol"
|
|
||||||
include_metrics = true
|
|
||||||
|
|
||||||
[[steps]]
|
|
||||||
id = "inbox-check"
|
|
||||||
title = "Check inbox"
|
|
||||||
description = "Process messages and escalations"
|
|
||||||
|
|
||||||
[[steps]]
|
|
||||||
id = "health-scan"
|
|
||||||
title = "Scan polecat health"
|
|
||||||
description = "Check all polecats for stuck/idle"
|
|
||||||
|
|
||||||
[[steps]]
|
|
||||||
id = "nudge-stuck"
|
|
||||||
title = "Nudge stuck workers"
|
|
||||||
description = "Send nudges to idle polecats"
|
|
||||||
|
|
||||||
[[steps]]
|
|
||||||
id = "loop-or-exit"
|
|
||||||
title = "Loop or exit decision"
|
|
||||||
description = "Decide whether to continue or handoff"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
### Existing Digests
|
|
||||||
|
|
||||||
Current minimal digests remain valid. New template system is additive:
|
|
||||||
- Old digests: Title, basic description
|
|
||||||
- New digests: Structured content, metrics
|
|
||||||
|
|
||||||
### Backward Compatibility
|
|
||||||
|
|
||||||
- `bd mol squash` without template uses current behavior
|
|
||||||
- Formulas without `[squash]` section use type defaults
|
|
||||||
- No breaking changes to existing workflows
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
### Why Squash Per-Cycle?
|
|
||||||
|
|
||||||
**Alternative**: Squash on session end only
|
|
||||||
|
|
||||||
**Rejected because**:
|
|
||||||
- Sessions can crash mid-cycle (lost audit trail)
|
|
||||||
- High-context sessions may span multiple cycles
|
|
||||||
- Per-cycle gives finer granularity
|
|
||||||
|
|
||||||
### Why Formula-Defined Templates?
|
|
||||||
|
|
||||||
**Alternative**: Hard-coded templates per role
|
|
||||||
|
|
||||||
**Rejected because**:
|
|
||||||
- Different workflows have different metrics
|
|
||||||
- Extensibility for custom formulas
|
|
||||||
- Separation of concerns (workflow defines its own output)
|
|
||||||
|
|
||||||
### Why Retain Forever (as Rollups)?
|
|
||||||
|
|
||||||
**Alternative**: Delete after N days
|
|
||||||
|
|
||||||
**Rejected because**:
|
|
||||||
- Capability ledger needs long-term history
|
|
||||||
- Rollups are small (aggregate stats)
|
|
||||||
- Audit requirements vary by use case
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- **Search**: Full-text search over archived digests
|
|
||||||
- **Analytics**: Metrics aggregation dashboard
|
|
||||||
- **Export**: Export digests to external systems
|
|
||||||
- **Compliance**: Configurable retention for regulatory needs
|
|
||||||
13
go.mod
13
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/go-rod/rod v0.116.2
|
github.com/go-rod/rod v0.116.2
|
||||||
github.com/gofrs/flock v0.13.0
|
github.com/gofrs/flock v0.13.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -16,22 +16,30 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||||
|
github.com/charmbracelet/glamour v0.10.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
@@ -41,5 +49,8 @@ require (
|
|||||||
github.com/ysmood/got v0.40.0 // indirect
|
github.com/ysmood/got v0.40.0 // indirect
|
||||||
github.com/ysmood/gson v0.7.3 // indirect
|
github.com/ysmood/gson v0.7.3 // indirect
|
||||||
github.com/ysmood/leakless v0.9.0 // indirect
|
github.com/ysmood/leakless v0.9.0 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -1,23 +1,33 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||||
|
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||||
|
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||||
@@ -29,6 +39,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||||
@@ -37,6 +49,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
|||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
@@ -45,16 +59,23 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@@ -80,9 +101,16 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
|||||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ func formatDays(d time.Duration) string {
|
|||||||
return formatInt(days) + "d"
|
return formatInt(days) + "d"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatInt converts a non-negative integer to its decimal string representation.
|
||||||
|
// For single digits (0-9), it uses direct rune conversion for efficiency.
|
||||||
|
// For larger numbers, it extracts digits iteratively from least to most significant.
|
||||||
|
// This avoids importing strconv for simple integer formatting in the activity package.
|
||||||
func formatInt(n int) string {
|
func formatInt(n int) string {
|
||||||
if n < 10 {
|
if n < 10 {
|
||||||
return string(rune('0'+n))
|
return string(rune('0'+n))
|
||||||
|
|||||||
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"`
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
// Package beads provides a wrapper for the bd (beads) CLI.
|
// Package beads provides a wrapper for the bd (beads) CLI.
|
||||||
package beads
|
package beads
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// TownBeadsPrefix is the prefix used for town-level agent beads stored in ~/gt/.beads/.
|
// TownBeadsPrefix is the prefix used for town-level agent beads stored in ~/gt/.beads/.
|
||||||
// This distinguishes them from rig-level beads (which use project prefixes like "gt-").
|
// This distinguishes them from rig-level beads (which use project prefixes like "gt-").
|
||||||
@@ -74,3 +77,170 @@ func PolecatRoleBeadIDTown() string {
|
|||||||
func CrewRoleBeadIDTown() string {
|
func CrewRoleBeadIDTown() string {
|
||||||
return RoleBeadIDTown("crew")
|
return RoleBeadIDTown("crew")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Rig-level agent bead ID helpers (gt- prefix) =====
|
||||||
|
|
||||||
|
// Agent bead ID naming convention:
|
||||||
|
// prefix-rig-role-name
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - gt-mayor (town-level, no rig)
|
||||||
|
// - gt-deacon (town-level, no rig)
|
||||||
|
// - gt-gastown-witness (rig-level singleton)
|
||||||
|
// - gt-gastown-refinery (rig-level singleton)
|
||||||
|
// - gt-gastown-crew-max (rig-level named agent)
|
||||||
|
// - gt-gastown-polecat-Toast (rig-level named agent)
|
||||||
|
|
||||||
|
// AgentBeadIDWithPrefix generates an agent bead ID using the specified prefix.
|
||||||
|
// The prefix should NOT include the hyphen (e.g., "gt", "bd", not "gt-", "bd-").
|
||||||
|
// For town-level agents (mayor, deacon), pass empty rig and name.
|
||||||
|
// For rig-level singletons (witness, refinery), pass empty name.
|
||||||
|
// For named agents (crew, polecat), pass all three.
|
||||||
|
func AgentBeadIDWithPrefix(prefix, rig, role, name string) string {
|
||||||
|
if rig == "" {
|
||||||
|
// Town-level agent: prefix-mayor, prefix-deacon
|
||||||
|
return prefix + "-" + role
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
// Rig-level singleton: prefix-rig-witness, prefix-rig-refinery
|
||||||
|
return prefix + "-" + rig + "-" + role
|
||||||
|
}
|
||||||
|
// Rig-level named agent: prefix-rig-role-name
|
||||||
|
return prefix + "-" + rig + "-" + role + "-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentBeadID generates the canonical agent bead ID using "gt" prefix.
|
||||||
|
// For non-gastown rigs, use AgentBeadIDWithPrefix with the rig's configured prefix.
|
||||||
|
func AgentBeadID(rig, role, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix("gt", rig, role, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayorBeadID returns the Mayor agent bead ID.
|
||||||
|
//
|
||||||
|
// Deprecated: Use MayorBeadIDTown() for town-level beads (hq- prefix).
|
||||||
|
// This function returns "gt-mayor" which is for rig-level storage.
|
||||||
|
// Town-level agents like Mayor should use the hq- prefix.
|
||||||
|
func MayorBeadID() string {
|
||||||
|
return "gt-mayor"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeaconBeadID returns the Deacon agent bead ID.
|
||||||
|
//
|
||||||
|
// Deprecated: Use DeaconBeadIDTown() for town-level beads (hq- prefix).
|
||||||
|
// This function returns "gt-deacon" which is for rig-level storage.
|
||||||
|
// Town-level agents like Deacon should use the hq- prefix.
|
||||||
|
func DeaconBeadID() string {
|
||||||
|
return "gt-deacon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DogBeadID returns a Dog agent bead ID.
|
||||||
|
// Dogs are town-level agents, so they follow the pattern: gt-dog-<name>
|
||||||
|
// Deprecated: Use DogBeadIDTown() for town-level beads with hq- prefix.
|
||||||
|
// Dogs are town-level agents and should use hq-dog-<name>, not gt-dog-<name>.
|
||||||
|
func DogBeadID(name string) string {
|
||||||
|
return "gt-dog-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix.
|
||||||
|
func WitnessBeadIDWithPrefix(prefix, rig string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "witness", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessBeadID returns the Witness agent bead ID for a rig using "gt" prefix.
|
||||||
|
func WitnessBeadID(rig string) string {
|
||||||
|
return WitnessBeadIDWithPrefix("gt", rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryBeadIDWithPrefix returns the Refinery agent bead ID for a rig using the specified prefix.
|
||||||
|
func RefineryBeadIDWithPrefix(prefix, rig string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "refinery", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryBeadID returns the Refinery agent bead ID for a rig using "gt" prefix.
|
||||||
|
func RefineryBeadID(rig string) string {
|
||||||
|
return RefineryBeadIDWithPrefix("gt", rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewBeadIDWithPrefix returns a Crew worker agent bead ID using the specified prefix.
|
||||||
|
func CrewBeadIDWithPrefix(prefix, rig, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "crew", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewBeadID returns a Crew worker agent bead ID using "gt" prefix.
|
||||||
|
func CrewBeadID(rig, name string) string {
|
||||||
|
return CrewBeadIDWithPrefix("gt", rig, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatBeadIDWithPrefix returns a Polecat agent bead ID using the specified prefix.
|
||||||
|
func PolecatBeadIDWithPrefix(prefix, rig, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatBeadID returns a Polecat agent bead ID using "gt" prefix.
|
||||||
|
func PolecatBeadID(rig, name string) string {
|
||||||
|
return PolecatBeadIDWithPrefix("gt", rig, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAgentBeadID parses an agent bead ID into its components.
|
||||||
|
// Returns rig, role, name, and whether parsing succeeded.
|
||||||
|
// For town-level agents, rig will be empty.
|
||||||
|
// For singletons, name will be empty.
|
||||||
|
// Accepts any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||||
|
func ParseAgentBeadID(id string) (rig, role, name string, ok bool) {
|
||||||
|
// Find the prefix (everything before the first hyphen)
|
||||||
|
// Valid prefixes are 2-3 characters (e.g., "gt", "bd", "hq")
|
||||||
|
hyphenIdx := strings.Index(id, "-")
|
||||||
|
if hyphenIdx < 2 || hyphenIdx > 3 {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := id[hyphenIdx+1:]
|
||||||
|
parts := strings.Split(rest, "-")
|
||||||
|
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
// Town-level: gt-mayor, bd-deacon
|
||||||
|
return "", parts[0], "", true
|
||||||
|
case 2:
|
||||||
|
// Could be rig-level singleton (gt-gastown-witness) or
|
||||||
|
// town-level named (gt-dog-alpha for dogs)
|
||||||
|
if parts[0] == "dog" {
|
||||||
|
// Dogs are town-level named agents: gt-dog-<name>
|
||||||
|
return "", "dog", parts[1], true
|
||||||
|
}
|
||||||
|
// Rig-level singleton: gt-gastown-witness
|
||||||
|
return parts[0], parts[1], "", true
|
||||||
|
case 3:
|
||||||
|
// Rig-level named: gt-gastown-crew-max, bd-beads-polecat-pearl
|
||||||
|
return parts[0], parts[1], parts[2], true
|
||||||
|
default:
|
||||||
|
// Handle names with hyphens: gt-gastown-polecat-my-agent-name
|
||||||
|
// or gt-dog-my-agent-name
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
if parts[0] == "dog" {
|
||||||
|
// Dog with hyphenated name: gt-dog-my-dog-name
|
||||||
|
return "", "dog", strings.Join(parts[1:], "-"), true
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], strings.Join(parts[2:], "-"), true
|
||||||
|
}
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAgentSessionBead returns true if the bead ID represents an agent session molecule.
|
||||||
|
// Agent session beads follow patterns like gt-mayor, bd-beads-witness, gt-gastown-crew-joe.
|
||||||
|
// Supports any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||||
|
// These are used to track agent state and update frequently, which can create noise.
|
||||||
|
func IsAgentSessionBead(beadID string) bool {
|
||||||
|
_, role, _, ok := ParseAgentBeadID(beadID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Known agent roles
|
||||||
|
switch role {
|
||||||
|
case "mayor", "deacon", "witness", "refinery", "crew", "polecat", "dog":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
514
internal/beads/beads_agent.go
Normal file
514
internal/beads/beads_agent.go
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
// Package beads provides agent bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentFields holds structured fields for agent beads.
|
||||||
|
// These are stored as "key: value" lines in the description.
|
||||||
|
type AgentFields struct {
|
||||||
|
RoleType string // polecat, witness, refinery, deacon, mayor
|
||||||
|
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||||
|
AgentState string // spawning, working, done, stuck
|
||||||
|
HookBead string // Currently pinned work bead ID
|
||||||
|
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
||||||
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
||||||
|
ActiveMR string // Currently active merge request bead ID (for traceability)
|
||||||
|
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification level constants
|
||||||
|
const (
|
||||||
|
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
||||||
|
NotifyNormal = "normal" // Important events only (default)
|
||||||
|
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatAgentDescription creates a description string from agent fields.
|
||||||
|
func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||||
|
if fields == nil {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, title)
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
||||||
|
|
||||||
|
if fields.Rig != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "rig: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
||||||
|
|
||||||
|
if fields.HookBead != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "hook_bead: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.RoleBead != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "role_bead: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.CleanupStatus != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "cleanup_status: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.ActiveMR != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "active_mr: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.NotificationLevel != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "notification_level: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAgentFields extracts agent fields from an issue's description.
|
||||||
|
func ParseAgentFields(description string) *AgentFields {
|
||||||
|
fields := &AgentFields{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(description, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
colonIdx := strings.Index(line, ":")
|
||||||
|
if colonIdx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:colonIdx])
|
||||||
|
value := strings.TrimSpace(line[colonIdx+1:])
|
||||||
|
if value == "null" || value == "" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "role_type":
|
||||||
|
fields.RoleType = value
|
||||||
|
case "rig":
|
||||||
|
fields.Rig = value
|
||||||
|
case "agent_state":
|
||||||
|
fields.AgentState = value
|
||||||
|
case "hook_bead":
|
||||||
|
fields.HookBead = value
|
||||||
|
case "role_bead":
|
||||||
|
fields.RoleBead = value
|
||||||
|
case "cleanup_status":
|
||||||
|
fields.CleanupStatus = value
|
||||||
|
case "active_mr":
|
||||||
|
fields.ActiveMR = value
|
||||||
|
case "notification_level":
|
||||||
|
fields.NotificationLevel = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
||||||
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||||
|
// Use AgentBeadID() helper to generate correct IDs.
|
||||||
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||||
|
description := FormatAgentDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--id=" + id,
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
"--type=agent",
|
||||||
|
"--labels=gt:agent",
|
||||||
|
}
|
||||||
|
if NeedsForceForID(id) {
|
||||||
|
args = append(args, "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the role slot if specified (this is the authoritative storage)
|
||||||
|
if fields != nil && fields.RoleBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue
|
||||||
|
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the hook slot if specified (this is the authoritative storage)
|
||||||
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||||
|
// agent's hook slot is empty. See mi-619.
|
||||||
|
if fields != nil && fields.HookBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue - description text has the backup
|
||||||
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrReopenAgentBead creates an agent bead or reopens an existing one.
|
||||||
|
// This handles the case where a polecat is nuked and re-spawned with the same name:
|
||||||
|
// the old agent bead exists as a 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
|
||||||
|
func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||||
|
// First try to create the bead
|
||||||
|
issue, err := b.CreateAgentBead(id, title, fields)
|
||||||
|
if err == nil {
|
||||||
|
return issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a UNIQUE constraint error
|
||||||
|
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bead already exists (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
|
||||||
|
if !strings.Contains(reopenErr.Error(), "already open") {
|
||||||
|
return nil, fmt.Errorf("reopening existing agent bead: %w (original error: %v)", reopenErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the bead with new fields
|
||||||
|
description := FormatAgentDescription(title, fields)
|
||||||
|
updateOpts := UpdateOptions{
|
||||||
|
Title: &title,
|
||||||
|
Description: &description,
|
||||||
|
}
|
||||||
|
if err := b.Update(id, updateOpts); err != nil {
|
||||||
|
return nil, fmt.Errorf("updating reopened agent bead: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the role slot if specified
|
||||||
|
if fields != nil && fields.RoleBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue
|
||||||
|
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||||
|
_, _ = b.run("slot", "clear", id, "hook")
|
||||||
|
|
||||||
|
// Set the hook slot if specified
|
||||||
|
if fields != nil && fields.HookBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue
|
||||||
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated bead
|
||||||
|
return b.Show(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentState updates the agent_state field in an agent bead.
|
||||||
|
// Optionally updates hook_bead if provided.
|
||||||
|
//
|
||||||
|
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
||||||
|
// - `bd agent state` for agent_state (uses SQLite column directly)
|
||||||
|
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
||||||
|
//
|
||||||
|
// This ensures consistency with `bd slot show` and other beads commands.
|
||||||
|
// Previously, this function embedded these fields in the description text,
|
||||||
|
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
||||||
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
||||||
|
// Update agent state using bd agent state command
|
||||||
|
// This updates the agent_state column directly in SQLite
|
||||||
|
_, err := b.run("agent", "state", id, state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating agent state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hook_bead if provided
|
||||||
|
if hookBead != nil {
|
||||||
|
if *hookBead != "" {
|
||||||
|
// Set the hook using bd slot set
|
||||||
|
// This updates the hook_bead column directly in SQLite
|
||||||
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||||
|
if err != nil {
|
||||||
|
// If slot is already occupied, clear it first then retry
|
||||||
|
// This handles re-slinging scenarios where we're updating the hook
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "already occupied") {
|
||||||
|
_, _ = b.run("slot", "clear", id, "hook")
|
||||||
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear the hook
|
||||||
|
_, err = b.run("slot", "clear", id, "hook")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHookBead sets the hook_bead slot on an agent bead.
|
||||||
|
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
||||||
|
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
||||||
|
// and should not be recorded in beads ("discover, don't track" principle).
|
||||||
|
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
||||||
|
// Set the hook using bd slot set
|
||||||
|
// This updates the hook_bead column directly in SQLite
|
||||||
|
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||||
|
if err != nil {
|
||||||
|
// If slot is already occupied, clear it first then retry
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "already occupied") {
|
||||||
|
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
||||||
|
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHookBead clears the hook_bead slot on an agent bead.
|
||||||
|
// Used when work is complete or unslung.
|
||||||
|
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
||||||
|
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing hook: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
||||||
|
// This is called by the polecat to self-report its git state (ZFC compliance).
|
||||||
|
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
||||||
|
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.CleanupStatus = cleanupStatus
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
||||||
|
// This links the agent to their current merge request for traceability.
|
||||||
|
// Pass empty string to clear the field (e.g., after merge completes).
|
||||||
|
func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.ActiveMR = activeMR
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
||||||
|
// Valid levels: verbose, normal, muted (DND mode).
|
||||||
|
// Pass empty string to reset to default (normal).
|
||||||
|
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
||||||
|
// Validate level
|
||||||
|
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
||||||
|
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.NotificationLevel = level
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentNotificationLevel returns the notification level for an agent.
|
||||||
|
// Returns "normal" if not set (the default).
|
||||||
|
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
||||||
|
_, fields, err := b.GetAgentBead(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return NotifyNormal, nil
|
||||||
|
}
|
||||||
|
if fields.NotificationLevel == "" {
|
||||||
|
return NotifyNormal, nil
|
||||||
|
}
|
||||||
|
return fields.NotificationLevel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAgentBead permanently deletes an agent bead.
|
||||||
|
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||||
|
//
|
||||||
|
// 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) {
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !HasLabel(issue, "gt:agent") {
|
||||||
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (missing gt:agent label)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
return issue, fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAgentBeads returns all agent beads in a single query.
|
||||||
|
// Returns a map of agent bead ID to Issue.
|
||||||
|
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
||||||
|
out, err := b.run("list", "--label=gt:agent", "--json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []*Issue
|
||||||
|
if err := json.Unmarshal(out, &issues); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]*Issue, len(issues))
|
||||||
|
for _, issue := range issues {
|
||||||
|
result[issue.ID] = issue
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
485
internal/beads/beads_channel.go
Normal file
485
internal/beads/beads_channel.go
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
// 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"
|
||||||
|
"os"
|
||||||
|
"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
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
// If channel has >= retainCount messages, deletes oldest until count < retainCount.
|
||||||
|
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 limit
|
||||||
|
if fields.RetentionCount <= 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"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &messages); err != nil {
|
||||||
|
return fmt.Errorf("parsing channel messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many to delete
|
||||||
|
// We're being called after a new message is posted, so we want to end up with retainCount
|
||||||
|
toDelete := len(messages) - fields.RetentionCount
|
||||||
|
if toDelete <= 0 {
|
||||||
|
return nil // No pruning needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete oldest messages (best-effort)
|
||||||
|
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||||
|
// Use close instead of delete for audit trail
|
||||||
|
_, _ = b.run("close", messages[i].ID, "--reason=channel retention pruning")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneAllChannels enforces retention on all channels.
|
||||||
|
// Called by Deacon patrol as a backup cleanup mechanism.
|
||||||
|
// Uses a 10% buffer to avoid thrashing (only prunes if count > retainCount * 1.1).
|
||||||
|
func (b *Beads) PruneAllChannels() (int, error) {
|
||||||
|
channels, err := b.ListChannelBeads()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pruned := 0
|
||||||
|
for name, fields := range channels {
|
||||||
|
if fields.RetentionCount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count messages
|
||||||
|
out, err := b.run("list",
|
||||||
|
"--type=message",
|
||||||
|
"--label=channel:"+name,
|
||||||
|
"--json",
|
||||||
|
"--limit=0",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip on error
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &messages); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10% buffer - only prune if significantly over limit
|
||||||
|
threshold := int(float64(fields.RetentionCount) * 1.1)
|
||||||
|
if len(messages) <= threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune down to exactly retainCount
|
||||||
|
toDelete := len(messages) - fields.RetentionCount
|
||||||
|
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||||
|
if _, err := b.run("close", messages[i].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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
internal/beads/beads_delegation.go
Normal file
155
internal/beads/beads_delegation.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Package beads provides delegation tracking for work units.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delegation represents a work delegation relationship between work units.
|
||||||
|
// Delegation links a parent work unit to a child work unit, tracking who
|
||||||
|
// delegated the work and to whom, along with any terms of the delegation.
|
||||||
|
// This enables work distribution with credit cascade - work flows down,
|
||||||
|
// validation and credit flow up.
|
||||||
|
type Delegation struct {
|
||||||
|
// Parent is the work unit ID that delegated the work
|
||||||
|
Parent string `json:"parent"`
|
||||||
|
|
||||||
|
// Child is the work unit ID that received the delegated work
|
||||||
|
Child string `json:"child"`
|
||||||
|
|
||||||
|
// DelegatedBy is the entity (hop:// URI or actor string) that delegated
|
||||||
|
DelegatedBy string `json:"delegated_by"`
|
||||||
|
|
||||||
|
// DelegatedTo is the entity (hop:// URI or actor string) receiving delegation
|
||||||
|
DelegatedTo string `json:"delegated_to"`
|
||||||
|
|
||||||
|
// Terms contains optional conditions of the delegation
|
||||||
|
Terms *DelegationTerms `json:"terms,omitempty"`
|
||||||
|
|
||||||
|
// CreatedAt is when the delegation was created
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelegationTerms holds optional terms/conditions for a delegation.
|
||||||
|
type DelegationTerms struct {
|
||||||
|
// Portion describes what part of the parent work is delegated
|
||||||
|
Portion string `json:"portion,omitempty"`
|
||||||
|
|
||||||
|
// Deadline is the expected completion date
|
||||||
|
Deadline string `json:"deadline,omitempty"`
|
||||||
|
|
||||||
|
// AcceptanceCriteria describes what constitutes completion
|
||||||
|
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||||
|
|
||||||
|
// CreditShare is the percentage of credit that flows to the delegate (0-100)
|
||||||
|
CreditShare int `json:"credit_share,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDelegation creates a delegation relationship from parent to child work unit.
|
||||||
|
// The delegation tracks who delegated (delegatedBy) and who received (delegatedTo),
|
||||||
|
// along with optional terms. Delegations enable credit cascade - when child work
|
||||||
|
// is completed, credit flows up to the parent work unit and its delegator.
|
||||||
|
//
|
||||||
|
// Note: This is stored as metadata on the child issue until bd CLI has native
|
||||||
|
// delegation support. Once bd supports `bd delegate add`, this will be updated.
|
||||||
|
func (b *Beads) AddDelegation(d *Delegation) error {
|
||||||
|
if d.Parent == "" || d.Child == "" {
|
||||||
|
return fmt.Errorf("delegation requires both parent and child work unit IDs")
|
||||||
|
}
|
||||||
|
if d.DelegatedBy == "" || d.DelegatedTo == "" {
|
||||||
|
return fmt.Errorf("delegation requires both delegated_by and delegated_to entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store delegation as JSON in the child issue's delegated_from slot
|
||||||
|
delegationJSON, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling delegation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the delegated_from slot on the child issue
|
||||||
|
_, err = b.run("slot", "set", d.Child, "delegated_from", string(delegationJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add a dependency so child blocks parent (work must complete before parent can close)
|
||||||
|
if err := b.AddDependency(d.Parent, d.Child); err != nil {
|
||||||
|
// Log but don't fail - the delegation is still recorded
|
||||||
|
fmt.Printf("Warning: could not add blocking dependency for delegation: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDelegation removes a delegation relationship.
|
||||||
|
func (b *Beads) RemoveDelegation(parent, child string) error {
|
||||||
|
// Clear the delegated_from slot on the child
|
||||||
|
_, err := b.run("slot", "clear", child, "delegated_from")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove the blocking dependency
|
||||||
|
if err := b.RemoveDependency(parent, child); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
fmt.Printf("Warning: could not remove blocking dependency: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDelegation retrieves the delegation information for a child work unit.
|
||||||
|
// Returns nil if the issue has no delegation.
|
||||||
|
func (b *Beads) GetDelegation(child string) (*Delegation, error) {
|
||||||
|
// Verify the issue exists first
|
||||||
|
if _, err := b.Show(child); err != nil {
|
||||||
|
return nil, fmt.Errorf("getting issue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get delegation from the slot
|
||||||
|
out, err := b.run("slot", "get", child, "delegated_from")
|
||||||
|
if err != nil {
|
||||||
|
// No delegation slot means no delegation
|
||||||
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no slot") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slotValue := strings.TrimSpace(string(out))
|
||||||
|
if slotValue == "" || slotValue == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegation Delegation
|
||||||
|
if err := json.Unmarshal([]byte(slotValue), &delegation); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing delegation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &delegation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDelegationsFrom returns all delegations from a parent work unit.
|
||||||
|
// This searches for issues that have delegated_from pointing to the parent.
|
||||||
|
func (b *Beads) ListDelegationsFrom(parent string) ([]*Delegation, error) {
|
||||||
|
// List all issues that depend on this parent (delegated work blocks parent)
|
||||||
|
issues, err := b.List(ListOptions{Status: "all"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegations []*Delegation
|
||||||
|
for _, issue := range issues {
|
||||||
|
d, err := b.GetDelegation(issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip issues with errors
|
||||||
|
}
|
||||||
|
if d != nil && d.Parent == parent {
|
||||||
|
delegations = append(delegations, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delegations, nil
|
||||||
|
}
|
||||||
93
internal/beads/beads_dog.go
Normal file
93
internal/beads/beads_dog.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Package beads provides dog agent bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateDogAgentBead creates an agent bead for a dog.
|
||||||
|
// Dogs use a different schema than other agents - they use labels for metadata.
|
||||||
|
// Returns the created issue or an error.
|
||||||
|
func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
||||||
|
title := fmt.Sprintf("Dog: %s", name)
|
||||||
|
labels := []string{
|
||||||
|
"gt:agent",
|
||||||
|
"role_type:dog",
|
||||||
|
"rig:town",
|
||||||
|
"location:" + location,
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"create", "--json",
|
||||||
|
"--role-type=dog",
|
||||||
|
"--title=" + title,
|
||||||
|
"--labels=" + strings.Join(labels, ","),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindDogAgentBead finds the agent bead for a dog by name.
|
||||||
|
// Searches for agent beads with role_type:dog and matching title.
|
||||||
|
// Returns nil if not found.
|
||||||
|
func (b *Beads) FindDogAgentBead(name string) (*Issue, error) {
|
||||||
|
// List all agent beads and filter by role_type:dog label
|
||||||
|
issues, err := b.List(ListOptions{
|
||||||
|
Label: "gt:agent",
|
||||||
|
Status: "all",
|
||||||
|
Priority: -1, // No priority filter
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing agents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedTitle := fmt.Sprintf("Dog: %s", name)
|
||||||
|
for _, issue := range issues {
|
||||||
|
// Check title match and role_type:dog label
|
||||||
|
if issue.Title == expectedTitle {
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
if label == "role_type:dog" {
|
||||||
|
return issue, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDogAgentBead finds and deletes the agent bead for a dog.
|
||||||
|
// Returns nil if the bead doesn't exist (idempotent).
|
||||||
|
func (b *Beads) DeleteDogAgentBead(name string) error {
|
||||||
|
issue, err := b.FindDogAgentBead(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding dog bead: %w", err)
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
return nil // Already doesn't exist - idempotent
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.DeleteAgentBead(issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting bead %s: %w", issue.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
441
internal/beads/beads_escalation.go
Normal file
441
internal/beads/beads_escalation.go
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
// Package beads provides escalation bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EscalationFields holds structured fields for escalation beads.
|
||||||
|
// These are stored as "key: value" lines in the description.
|
||||||
|
type EscalationFields struct {
|
||||||
|
Severity string // critical, high, medium, low
|
||||||
|
Reason string // Why this was escalated
|
||||||
|
Source string // Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)
|
||||||
|
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
|
||||||
|
EscalatedAt string // ISO 8601 timestamp
|
||||||
|
AckedBy string // Agent that acknowledged (empty if not acked)
|
||||||
|
AckedAt string // When acknowledged (empty if not acked)
|
||||||
|
ClosedBy string // Agent that closed (empty if not closed)
|
||||||
|
ClosedReason string // Resolution reason (empty if not closed)
|
||||||
|
RelatedBead string // Optional: related bead ID (task, bug, etc.)
|
||||||
|
OriginalSeverity string // Original severity before any re-escalation
|
||||||
|
ReescalationCount int // Number of times this has been re-escalated
|
||||||
|
LastReescalatedAt string // When last re-escalated (empty if never)
|
||||||
|
LastReescalatedBy string // Who last re-escalated (empty if never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationState constants for bead status tracking.
|
||||||
|
const (
|
||||||
|
EscalationOpen = "open" // Unacknowledged
|
||||||
|
EscalationAcked = "acked" // Acknowledged but not resolved
|
||||||
|
EscalationClosed = "closed" // Resolved/closed
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatEscalationDescription creates a description string from escalation fields.
|
||||||
|
func FormatEscalationDescription(title string, fields *EscalationFields) string {
|
||||||
|
if fields == nil {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, title)
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
|
||||||
|
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
|
||||||
|
if fields.Source != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("source: %s", fields.Source))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "source: null")
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
|
||||||
|
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
|
||||||
|
|
||||||
|
if fields.AckedBy != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("acked_by: %s", fields.AckedBy))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "acked_by: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.AckedAt != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("acked_at: %s", fields.AckedAt))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "acked_at: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.ClosedBy != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("closed_by: %s", fields.ClosedBy))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "closed_by: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.ClosedReason != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("closed_reason: %s", fields.ClosedReason))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "closed_reason: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.RelatedBead != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("related_bead: %s", fields.RelatedBead))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "related_bead: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reescalation fields
|
||||||
|
if fields.OriginalSeverity != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("original_severity: %s", fields.OriginalSeverity))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "original_severity: null")
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("reescalation_count: %d", fields.ReescalationCount))
|
||||||
|
if fields.LastReescalatedAt != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("last_reescalated_at: %s", fields.LastReescalatedAt))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "last_reescalated_at: null")
|
||||||
|
}
|
||||||
|
if fields.LastReescalatedBy != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("last_reescalated_by: %s", fields.LastReescalatedBy))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "last_reescalated_by: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEscalationFields extracts escalation fields from an issue's description.
|
||||||
|
func ParseEscalationFields(description string) *EscalationFields {
|
||||||
|
fields := &EscalationFields{}
|
||||||
|
|
||||||
|
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 "severity":
|
||||||
|
fields.Severity = value
|
||||||
|
case "reason":
|
||||||
|
fields.Reason = value
|
||||||
|
case "source":
|
||||||
|
fields.Source = value
|
||||||
|
case "escalated_by":
|
||||||
|
fields.EscalatedBy = value
|
||||||
|
case "escalated_at":
|
||||||
|
fields.EscalatedAt = value
|
||||||
|
case "acked_by":
|
||||||
|
fields.AckedBy = value
|
||||||
|
case "acked_at":
|
||||||
|
fields.AckedAt = value
|
||||||
|
case "closed_by":
|
||||||
|
fields.ClosedBy = value
|
||||||
|
case "closed_reason":
|
||||||
|
fields.ClosedReason = value
|
||||||
|
case "related_bead":
|
||||||
|
fields.RelatedBead = value
|
||||||
|
case "original_severity":
|
||||||
|
fields.OriginalSeverity = value
|
||||||
|
case "reescalation_count":
|
||||||
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
|
fields.ReescalationCount = n
|
||||||
|
}
|
||||||
|
case "last_reescalated_at":
|
||||||
|
fields.LastReescalatedAt = value
|
||||||
|
case "last_reescalated_by":
|
||||||
|
fields.LastReescalatedBy = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEscalationBead creates an escalation bead for tracking escalations.
|
||||||
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*Issue, error) {
|
||||||
|
description := FormatEscalationDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
"--type=task",
|
||||||
|
"--labels=gt:escalation",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add severity as a label for easy filtering
|
||||||
|
if fields != nil && fields.Severity != "" {
|
||||||
|
args = append(args, fmt.Sprintf("--labels=severity:%s", fields.Severity))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AckEscalation acknowledges an escalation bead.
|
||||||
|
// Sets acked_by and acked_at fields, adds "acked" label.
|
||||||
|
func (b *Beads) AckEscalation(id, ackedBy string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's an escalation
|
||||||
|
if !HasLabel(issue, "gt:escalation") {
|
||||||
|
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseEscalationFields(issue.Description)
|
||||||
|
fields.AckedBy = ackedBy
|
||||||
|
fields.AckedAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatEscalationDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{
|
||||||
|
Description: &description,
|
||||||
|
AddLabels: []string{"acked"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseEscalation closes an escalation bead with a resolution reason.
|
||||||
|
// Sets closed_by and closed_reason fields, closes the issue.
|
||||||
|
func (b *Beads) CloseEscalation(id, closedBy, reason string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's an escalation
|
||||||
|
if !HasLabel(issue, "gt:escalation") {
|
||||||
|
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseEscalationFields(issue.Description)
|
||||||
|
fields.ClosedBy = closedBy
|
||||||
|
fields.ClosedReason = reason
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatEscalationDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
// Update description first
|
||||||
|
if err := b.Update(id, UpdateOptions{
|
||||||
|
Description: &description,
|
||||||
|
AddLabels: []string{"resolved"},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the issue
|
||||||
|
_, err = b.run("close", id, "--reason="+reason)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEscalationBead retrieves an escalation bead by ID.
|
||||||
|
// Returns nil if not found.
|
||||||
|
func (b *Beads) GetEscalationBead(id string) (*Issue, *EscalationFields, 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:escalation") {
|
||||||
|
return nil, nil, fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ParseEscalationFields(issue.Description)
|
||||||
|
return issue, fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEscalations returns all open escalation beads.
|
||||||
|
func (b *Beads) ListEscalations() ([]*Issue, error) {
|
||||||
|
out, err := b.run("list", "--label=gt:escalation", "--status=open", "--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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEscalationsBySeverity returns open escalation beads filtered by severity.
|
||||||
|
func (b *Beads) ListEscalationsBySeverity(severity string) ([]*Issue, error) {
|
||||||
|
out, err := b.run("list",
|
||||||
|
"--label=gt:escalation",
|
||||||
|
"--label=severity:"+severity,
|
||||||
|
"--status=open",
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStaleEscalations returns escalations older than the given threshold.
|
||||||
|
// threshold is a duration string like "1h" or "30m".
|
||||||
|
func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error) {
|
||||||
|
// Get all open escalations
|
||||||
|
escalations, err := b.ListEscalations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-threshold)
|
||||||
|
var stale []*Issue
|
||||||
|
|
||||||
|
for _, issue := range escalations {
|
||||||
|
// Skip acknowledged escalations
|
||||||
|
if HasLabel(issue, "acked") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if older than threshold
|
||||||
|
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip if can't parse
|
||||||
|
}
|
||||||
|
|
||||||
|
if createdAt.Before(cutoff) {
|
||||||
|
stale = append(stale, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReescalationResult holds the result of a reescalation operation.
|
||||||
|
type ReescalationResult struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
OldSeverity string
|
||||||
|
NewSeverity string
|
||||||
|
ReescalationNum int
|
||||||
|
Skipped bool
|
||||||
|
SkipReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReescalateEscalation bumps the severity of an escalation and updates tracking fields.
|
||||||
|
// Returns the new severity if successful, or an error.
|
||||||
|
// reescalatedBy should be the identity of the agent/process doing the reescalation.
|
||||||
|
// maxReescalations limits how many times an escalation can be bumped (0 = unlimited).
|
||||||
|
func (b *Beads) ReescalateEscalation(id, reescalatedBy string, maxReescalations int) (*ReescalationResult, error) {
|
||||||
|
// Get the escalation
|
||||||
|
issue, fields, err := b.GetEscalationBead(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
return nil, fmt.Errorf("escalation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ReescalationResult{
|
||||||
|
ID: id,
|
||||||
|
Title: issue.Title,
|
||||||
|
OldSeverity: fields.Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already at max reescalations
|
||||||
|
if maxReescalations > 0 && fields.ReescalationCount >= maxReescalations {
|
||||||
|
result.Skipped = true
|
||||||
|
result.SkipReason = fmt.Sprintf("already at max reescalations (%d)", maxReescalations)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already at critical (can't bump further)
|
||||||
|
if fields.Severity == "critical" {
|
||||||
|
result.Skipped = true
|
||||||
|
result.SkipReason = "already at critical severity"
|
||||||
|
result.NewSeverity = "critical"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save original severity on first reescalation
|
||||||
|
if fields.OriginalSeverity == "" {
|
||||||
|
fields.OriginalSeverity = fields.Severity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bump severity
|
||||||
|
newSeverity := bumpSeverity(fields.Severity)
|
||||||
|
fields.Severity = newSeverity
|
||||||
|
fields.ReescalationCount++
|
||||||
|
fields.LastReescalatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
fields.LastReescalatedBy = reescalatedBy
|
||||||
|
|
||||||
|
result.NewSeverity = newSeverity
|
||||||
|
result.ReescalationNum = fields.ReescalationCount
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatEscalationDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
// Update the bead with new description and severity label
|
||||||
|
if err := b.Update(id, UpdateOptions{
|
||||||
|
Description: &description,
|
||||||
|
AddLabels: []string{"reescalated", "severity:" + newSeverity},
|
||||||
|
RemoveLabels: []string{"severity:" + result.OldSeverity},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("updating escalation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpSeverity returns the next higher severity level.
|
||||||
|
// low -> medium -> high -> critical
|
||||||
|
func bumpSeverity(severity string) string {
|
||||||
|
switch severity {
|
||||||
|
case "low":
|
||||||
|
return "medium"
|
||||||
|
case "medium":
|
||||||
|
return "high"
|
||||||
|
case "high":
|
||||||
|
return "critical"
|
||||||
|
default:
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
"os"
|
||||||
|
"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
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
internal/beads/beads_merge_slot.go
Normal file
133
internal/beads/beads_merge_slot.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package beads provides merge slot management for serialized conflict resolution.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MergeSlotStatus represents the result of checking a merge slot.
|
||||||
|
type MergeSlotStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Holder string `json:"holder,omitempty"`
|
||||||
|
Waiters []string `json:"waiters,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotCreate creates the merge slot bead for the current rig.
|
||||||
|
// The slot is used for serialized conflict resolution in the merge queue.
|
||||||
|
// Returns the slot ID if successful.
|
||||||
|
func (b *Beads) MergeSlotCreate() (string, error) {
|
||||||
|
out, err := b.run("merge-slot", "create", "--json")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing merge-slot create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotCheck checks the availability of the merge slot.
|
||||||
|
// Returns the current status including holder and waiters if held.
|
||||||
|
func (b *Beads) MergeSlotCheck() (*MergeSlotStatus, error) {
|
||||||
|
out, err := b.run("merge-slot", "check", "--json")
|
||||||
|
if err != nil {
|
||||||
|
// Check if slot doesn't exist
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
return &MergeSlotStatus{Error: "not found"}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("checking merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if err := json.Unmarshal(out, &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing merge-slot check output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotAcquire attempts to acquire the merge slot for exclusive access.
|
||||||
|
// If holder is empty, defaults to BD_ACTOR environment variable.
|
||||||
|
// If addWaiter is true and the slot is held, the requester is added to the waiters queue.
|
||||||
|
// Returns the acquisition result.
|
||||||
|
func (b *Beads) MergeSlotAcquire(holder string, addWaiter bool) (*MergeSlotStatus, error) {
|
||||||
|
args := []string{"merge-slot", "acquire", "--json"}
|
||||||
|
if holder != "" {
|
||||||
|
args = append(args, "--holder="+holder)
|
||||||
|
}
|
||||||
|
if addWaiter {
|
||||||
|
args = append(args, "--wait")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
// Parse the output even on error - it may contain useful info
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if jsonErr := json.Unmarshal(out, &status); jsonErr == nil {
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("acquiring merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if err := json.Unmarshal(out, &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing merge-slot acquire output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotRelease releases the merge slot after conflict resolution completes.
|
||||||
|
// If holder is provided, it verifies the slot is held by that holder before releasing.
|
||||||
|
func (b *Beads) MergeSlotRelease(holder string) error {
|
||||||
|
args := []string{"merge-slot", "release", "--json"}
|
||||||
|
if holder != "" {
|
||||||
|
args = append(args, "--holder="+holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("releasing merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Released bool `json:"released"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
return fmt.Errorf("parsing merge-slot release output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Released && result.Error != "" {
|
||||||
|
return fmt.Errorf("slot release failed: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotEnsureExists creates the merge slot if it doesn't exist.
|
||||||
|
// This is idempotent - safe to call multiple times.
|
||||||
|
func (b *Beads) MergeSlotEnsureExists() (string, error) {
|
||||||
|
// Check if slot exists first
|
||||||
|
status, err := b.MergeSlotCheck()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Error == "not found" {
|
||||||
|
// Create it
|
||||||
|
return b.MergeSlotCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.ID, nil
|
||||||
|
}
|
||||||
45
internal/beads/beads_mr.go
Normal file
45
internal/beads/beads_mr.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Package beads provides merge request and gate utilities.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindMRForBranch searches for an existing merge-request bead for the given branch.
|
||||||
|
// Returns the MR bead if found, nil if not found.
|
||||||
|
// This enables idempotent `gt done` - if an MR already exists, we skip creation.
|
||||||
|
func (b *Beads) FindMRForBranch(branch string) (*Issue, error) {
|
||||||
|
// List all merge-request beads (open status only - closed MRs are already processed)
|
||||||
|
issues, err := b.List(ListOptions{
|
||||||
|
Status: "open",
|
||||||
|
Label: "gt:merge-request",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for one matching this branch
|
||||||
|
// MR description format: "branch: <branch>\ntarget: ..."
|
||||||
|
branchPrefix := "branch: " + branch + "\n"
|
||||||
|
for _, issue := range issues {
|
||||||
|
if strings.HasPrefix(issue.Description, branchPrefix) {
|
||||||
|
return issue, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddGateWaiter registers an agent as a waiter on a gate bead.
|
||||||
|
// When the gate closes, the waiter will receive a wake notification via gt gate wake.
|
||||||
|
// The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast").
|
||||||
|
func (b *Beads) AddGateWaiter(gateID, waiter string) error {
|
||||||
|
// Use bd gate add-waiter to register the waiter on the gate
|
||||||
|
// This adds the waiter to the gate's native waiters field
|
||||||
|
_, err := b.run("gate", "add-waiter", gateID, waiter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding gate waiter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
362
internal/beads/beads_queue.go
Normal file
362
internal/beads/beads_queue.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// Package beads provides queue bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"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
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
245
internal/beads/beads_redirect.go
Normal file
245
internal/beads/beads_redirect.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// Package beads provides redirect resolution for beads databases.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveBeadsDir returns the actual beads directory, following any redirect.
|
||||||
|
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
|
||||||
|
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
|
||||||
|
//
|
||||||
|
// This is essential for crew workers and polecats that use shared beads via redirect.
|
||||||
|
// The redirect file contains a relative path like "../../mayor/rig/.beads".
|
||||||
|
//
|
||||||
|
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
|
||||||
|
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
|
||||||
|
// mayor/rig/.beads at the rig root level.
|
||||||
|
//
|
||||||
|
// Circular redirect detection: If the resolved path equals the original beads directory,
|
||||||
|
// this indicates an errant redirect file that should be removed. The function logs a
|
||||||
|
// warning and returns the original beads directory.
|
||||||
|
func ResolveBeadsDir(workDir string) string {
|
||||||
|
if filepath.Base(workDir) == ".beads" {
|
||||||
|
workDir = filepath.Dir(workDir)
|
||||||
|
}
|
||||||
|
beadsDir := filepath.Join(workDir, ".beads")
|
||||||
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||||
|
|
||||||
|
// Check for redirect file
|
||||||
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||||
|
if err != nil {
|
||||||
|
// No redirect, use local .beads
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and clean the redirect path
|
||||||
|
redirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if redirectTarget == "" {
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative to workDir (the redirect is written from the perspective
|
||||||
|
// of being inside workDir, not inside workDir/.beads)
|
||||||
|
// e.g., redirect contains "../../mayor/rig/.beads"
|
||||||
|
// from crew/max/, this resolves to mayor/rig/.beads
|
||||||
|
resolved := filepath.Join(workDir, redirectTarget)
|
||||||
|
|
||||||
|
// Clean the path to resolve .. components
|
||||||
|
resolved = filepath.Clean(resolved)
|
||||||
|
|
||||||
|
// Detect circular redirects: if resolved path equals original beads dir,
|
||||||
|
// this is an errant redirect file (e.g., redirect in mayor/rig/.beads pointing to itself)
|
||||||
|
if resolved == beadsDir {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s (points to itself), ignoring redirect\n", redirectPath)
|
||||||
|
// Remove the errant redirect file to prevent future warnings
|
||||||
|
if err := os.Remove(redirectPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: could not remove errant redirect file: %v\n", err)
|
||||||
|
}
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads)
|
||||||
|
// This is intentional for the rig-level redirect architecture.
|
||||||
|
// Limit depth to prevent infinite loops from misconfigured redirects.
|
||||||
|
return resolveBeadsDirWithDepth(resolved, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBeadsDirWithDepth follows redirect chains with a depth limit.
|
||||||
|
func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
||||||
|
if maxDepth <= 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir)
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||||
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||||
|
if err != nil {
|
||||||
|
// No redirect, this is the final destination
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if redirectTarget == "" {
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative to parent of beadsDir (the workDir)
|
||||||
|
workDir := filepath.Dir(beadsDir)
|
||||||
|
resolved := filepath.Clean(filepath.Join(workDir, redirectTarget))
|
||||||
|
|
||||||
|
// Detect circular redirect
|
||||||
|
if resolved == beadsDir {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath)
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively follow
|
||||||
|
return resolveBeadsDirWithDepth(resolved, maxDepth-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
||||||
|
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
||||||
|
// This is safe to call even if the directory doesn't exist.
|
||||||
|
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
return nil // Nothing to clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime files/patterns that are gitignored and safe to remove
|
||||||
|
runtimePatterns := []string{
|
||||||
|
// SQLite databases
|
||||||
|
"*.db", "*.db-*", "*.db?*",
|
||||||
|
// Daemon runtime
|
||||||
|
"daemon.lock", "daemon.log", "daemon.pid", "bd.sock",
|
||||||
|
// Sync state
|
||||||
|
"sync-state.json", "last-touched", "metadata.json",
|
||||||
|
// Version tracking
|
||||||
|
".local_version",
|
||||||
|
// Redirect file (we're about to recreate it)
|
||||||
|
"redirect",
|
||||||
|
// Merge artifacts
|
||||||
|
"beads.base.*", "beads.left.*", "beads.right.*",
|
||||||
|
// JSONL files (tracked but will be redirected, safe to remove in worktrees)
|
||||||
|
"issues.jsonl", "interactions.jsonl",
|
||||||
|
// Runtime directories
|
||||||
|
"mq",
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, pattern := range runtimePatterns {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if err := os.RemoveAll(match); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||||
|
// This is used by crew, polecats, and refinery worktrees to share the rig's beads database.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - townRoot: the town root directory (e.g., ~/gt)
|
||||||
|
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
||||||
|
//
|
||||||
|
// The function:
|
||||||
|
// 1. Computes the relative path from worktree to rig-level .beads
|
||||||
|
// 2. Cleans up runtime files (preserving tracked files like formulas/)
|
||||||
|
// 3. Creates the redirect file
|
||||||
|
//
|
||||||
|
// Safety: This function refuses to create redirects in the canonical beads location
|
||||||
|
// (mayor/rig) to prevent circular redirect chains.
|
||||||
|
func SetupRedirect(townRoot, worktreePath string) error {
|
||||||
|
// Get rig root from worktree path
|
||||||
|
// worktreePath = <town>/<rig>/crew/<name> or <town>/<rig>/refinery/rig etc.
|
||||||
|
relPath, err := filepath.Rel(townRoot, worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("computing relative path: %w", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check: prevent creating redirect in canonical beads location (mayor/rig)
|
||||||
|
// This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads
|
||||||
|
if len(parts) >= 2 && parts[1] == "mayor" {
|
||||||
|
return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)")
|
||||||
|
}
|
||||||
|
|
||||||
|
rigRoot := filepath.Join(townRoot, parts[0])
|
||||||
|
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
||||||
|
mayorBeadsPath := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||||
|
|
||||||
|
// Check rig-level .beads first, fall back to mayor/rig/.beads (tracked beads architecture)
|
||||||
|
usesMayorFallback := false
|
||||||
|
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||||
|
// No rig/.beads - check for mayor/rig/.beads (tracked beads architecture)
|
||||||
|
if _, err := os.Stat(mayorBeadsPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("no beads found at %s or %s", rigBeadsPath, mayorBeadsPath)
|
||||||
|
}
|
||||||
|
// Using mayor fallback - warn user to run bd doctor
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: rig .beads not found at %s, using %s\n", rigBeadsPath, mayorBeadsPath)
|
||||||
|
fmt.Fprintf(os.Stderr, " Run 'bd doctor' to fix rig beads configuration\n")
|
||||||
|
usesMayorFallback = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
||||||
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||||
|
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
||||||
|
return fmt.Errorf("cleaning runtime files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .beads directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating .beads dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute relative path from worktree to rig root
|
||||||
|
// e.g., crew/<name> (depth 2) -> ../../.beads
|
||||||
|
// refinery/rig (depth 2) -> ../../.beads
|
||||||
|
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||||
|
upPath := strings.Repeat("../", depth)
|
||||||
|
|
||||||
|
var redirectPath string
|
||||||
|
if usesMayorFallback {
|
||||||
|
// Direct redirect to mayor/rig/.beads since rig/.beads doesn't exist
|
||||||
|
redirectPath = upPath + "mayor/rig/.beads"
|
||||||
|
} else {
|
||||||
|
redirectPath = upPath + ".beads"
|
||||||
|
|
||||||
|
// Check if rig-level beads has a redirect (tracked beads case).
|
||||||
|
// If so, redirect directly to the final destination to avoid chains.
|
||||||
|
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
||||||
|
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||||
|
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||||
|
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if rigRedirectTarget != "" {
|
||||||
|
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||||
|
// Redirect worktree directly to the final destination.
|
||||||
|
redirectPath = upPath + rigRedirectTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create redirect file
|
||||||
|
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
||||||
|
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
||||||
|
return fmt.Errorf("creating redirect file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
120
internal/beads/beads_rig.go
Normal file
120
internal/beads/beads_rig.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Package beads provides rig identity bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RigFields contains the fields specific to rig identity beads.
|
||||||
|
type RigFields struct {
|
||||||
|
Repo string // Git URL for the rig's repository
|
||||||
|
Prefix string // Beads prefix for this rig (e.g., "gt", "bd")
|
||||||
|
State string // Operational state: active, archived, maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRigDescription formats the description field for a rig identity bead.
|
||||||
|
func FormatRigDescription(name string, fields *RigFields) string {
|
||||||
|
if fields == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Rig identity bead for %s.", name))
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
if fields.Repo != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("repo: %s", fields.Repo))
|
||||||
|
}
|
||||||
|
if fields.Prefix != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("prefix: %s", fields.Prefix))
|
||||||
|
}
|
||||||
|
if fields.State != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("state: %s", fields.State))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRigFields extracts rig fields from an issue's description.
|
||||||
|
func ParseRigFields(description string) *RigFields {
|
||||||
|
fields := &RigFields{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(description, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
colonIdx := strings.Index(line, ":")
|
||||||
|
if colonIdx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:colonIdx])
|
||||||
|
value := strings.TrimSpace(line[colonIdx+1:])
|
||||||
|
if value == "null" || value == "" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "repo":
|
||||||
|
fields.Repo = value
|
||||||
|
case "prefix":
|
||||||
|
fields.Prefix = value
|
||||||
|
case "state":
|
||||||
|
fields.State = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRigBead creates a rig identity bead for tracking rig metadata.
|
||||||
|
// The ID format is: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||||
|
// Use RigBeadID() helper to generate correct IDs.
|
||||||
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, error) {
|
||||||
|
description := FormatRigDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--id=" + id,
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
"--labels=gt:rig",
|
||||||
|
}
|
||||||
|
if NeedsForceForID(id) {
|
||||||
|
args = append(args, "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigBeadIDWithPrefix generates a rig identity bead ID using the specified prefix.
|
||||||
|
// Format: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||||
|
func RigBeadIDWithPrefix(prefix, name string) string {
|
||||||
|
return fmt.Sprintf("%s-rig-%s", prefix, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigBeadID generates a rig identity bead ID using "gt" prefix.
|
||||||
|
// For non-gastown rigs, use RigBeadIDWithPrefix with the rig's configured prefix.
|
||||||
|
func RigBeadID(name string) string {
|
||||||
|
return RigBeadIDWithPrefix("gt", name)
|
||||||
|
}
|
||||||
145
internal/beads/beads_role.go
Normal file
145
internal/beads/beads_role.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Package beads provides role bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role bead ID naming convention:
|
||||||
|
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
||||||
|
//
|
||||||
|
// Canonical format: hq-<role>-role
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - hq-mayor-role
|
||||||
|
// - hq-deacon-role
|
||||||
|
// - hq-witness-role
|
||||||
|
// - hq-refinery-role
|
||||||
|
// - hq-crew-role
|
||||||
|
// - hq-polecat-role
|
||||||
|
//
|
||||||
|
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
||||||
|
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
||||||
|
|
||||||
|
// RoleBeadID returns the role bead ID for a given role type.
|
||||||
|
// Role beads define lifecycle configuration for each agent type.
|
||||||
|
// Deprecated: Use RoleBeadIDTown() for town-level beads with hq- prefix.
|
||||||
|
// Role beads are global templates and should use hq-<role>-role, not gt-<role>-role.
|
||||||
|
func RoleBeadID(roleType string) string {
|
||||||
|
return "gt-" + roleType + "-role"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DogRoleBeadID returns the Dog role bead ID.
|
||||||
|
func DogRoleBeadID() string {
|
||||||
|
return RoleBeadID("dog")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayorRoleBeadID returns the Mayor role bead ID.
|
||||||
|
func MayorRoleBeadID() string {
|
||||||
|
return RoleBeadID("mayor")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeaconRoleBeadID returns the Deacon role bead ID.
|
||||||
|
func DeaconRoleBeadID() string {
|
||||||
|
return RoleBeadID("deacon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessRoleBeadID returns the Witness role bead ID.
|
||||||
|
func WitnessRoleBeadID() string {
|
||||||
|
return RoleBeadID("witness")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryRoleBeadID returns the Refinery role bead ID.
|
||||||
|
func RefineryRoleBeadID() string {
|
||||||
|
return RoleBeadID("refinery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewRoleBeadID returns the Crew role bead ID.
|
||||||
|
func CrewRoleBeadID() string {
|
||||||
|
return RoleBeadID("crew")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatRoleBeadID returns the Polecat role bead ID.
|
||||||
|
func PolecatRoleBeadID() string {
|
||||||
|
return RoleBeadID("polecat")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||||
|
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||||
|
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||||
|
issue, err := b.Show(roleBeadID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !HasLabel(issue, "gt:role") {
|
||||||
|
return nil, fmt.Errorf("bead %s is not a role bead (missing gt:role label)", roleBeadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseRoleConfig(issue.Description), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLabel checks if an issue has a specific label.
|
||||||
|
func HasLabel(issue *Issue, label string) bool {
|
||||||
|
for _, l := range issue.Labels {
|
||||||
|
if l == label {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleBeadDef defines a role bead's metadata.
|
||||||
|
// Used by gt install and gt doctor to create missing role beads.
|
||||||
|
type RoleBeadDef struct {
|
||||||
|
ID string // e.g., "hq-witness-role"
|
||||||
|
Title string // e.g., "Witness Role"
|
||||||
|
Desc string // Description of the role
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
func AllRoleBeadDefs() []RoleBeadDef {
|
||||||
|
return []RoleBeadDef{
|
||||||
|
{
|
||||||
|
ID: MayorRoleBeadIDTown(),
|
||||||
|
Title: "Mayor Role",
|
||||||
|
Desc: "Role definition for Mayor agents. Global coordinator for cross-rig work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: DeaconRoleBeadIDTown(),
|
||||||
|
Title: "Deacon Role",
|
||||||
|
Desc: "Role definition for Deacon agents. Daemon beacon for heartbeats and monitoring.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: DogRoleBeadIDTown(),
|
||||||
|
Title: "Dog Role",
|
||||||
|
Desc: "Role definition for Dog agents. Town-level workers for cross-rig tasks.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: WitnessRoleBeadIDTown(),
|
||||||
|
Title: "Witness Role",
|
||||||
|
Desc: "Role definition for Witness agents. Per-rig worker monitor with progressive nudging.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: RefineryRoleBeadIDTown(),
|
||||||
|
Title: "Refinery Role",
|
||||||
|
Desc: "Role definition for Refinery agents. Merge queue processor with verification gates.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: PolecatRoleBeadIDTown(),
|
||||||
|
Title: "Polecat Role",
|
||||||
|
Desc: "Role definition for Polecat agents. Ephemeral workers for batch work dispatch.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: CrewRoleBeadIDTown(),
|
||||||
|
Title: "Crew Role",
|
||||||
|
Desc: "Role definition for Crew agents. Persistent user-managed workspaces.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package beads
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -84,6 +85,8 @@ func TestIsBeadsRepo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapError tests error wrapping.
|
// TestWrapError tests error wrapping.
|
||||||
|
// ZFC: Only test ErrNotFound detection. ErrNotARepo and ErrSyncConflict
|
||||||
|
// were removed as per ZFC - agents should handle those errors directly.
|
||||||
func TestWrapError(t *testing.T) {
|
func TestWrapError(t *testing.T) {
|
||||||
b := New("/test")
|
b := New("/test")
|
||||||
|
|
||||||
@@ -92,11 +95,6 @@ func TestWrapError(t *testing.T) {
|
|||||||
wantErr error
|
wantErr error
|
||||||
wantNil bool
|
wantNil bool
|
||||||
}{
|
}{
|
||||||
{"not a beads repository", ErrNotARepo, false},
|
|
||||||
{"No .beads directory found", ErrNotARepo, false},
|
|
||||||
{".beads directory not found", ErrNotARepo, false},
|
|
||||||
{"sync conflict detected", ErrSyncConflict, false},
|
|
||||||
{"CONFLICT in file.md", ErrSyncConflict, false},
|
|
||||||
{"Issue not found: gt-xyz", ErrNotFound, false},
|
{"Issue not found: gt-xyz", ErrNotFound, false},
|
||||||
{"gt-xyz not found", ErrNotFound, false},
|
{"gt-xyz not found", ErrNotFound, false},
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,11 @@ func TestIntegration(t *testing.T) {
|
|||||||
dir = parent
|
dir = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
// Resolve the actual beads directory (following redirect if present)
|
||||||
|
// In multi-worktree setups, worktrees have .beads/redirect pointing to
|
||||||
|
// the canonical beads location (e.g., mayor/rig/.beads)
|
||||||
|
beadsDir := ResolveBeadsDir(dir)
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
t.Skip("no beads.db found (JSONL-only repo)")
|
t.Skip("no beads.db found (JSONL-only repo)")
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,9 @@ func TestIntegration(t *testing.T) {
|
|||||||
// This can happen when JSONL is updated (e.g., by git pull) but the SQLite database
|
// This can happen when JSONL is updated (e.g., by git pull) but the SQLite database
|
||||||
// hasn't been imported yet. Running sync --import-only ensures we test against
|
// hasn't been imported yet. Running sync --import-only ensures we test against
|
||||||
// consistent data and prevents flaky test failures.
|
// consistent data and prevents flaky test failures.
|
||||||
syncCmd := exec.Command("bd", "--no-daemon", "sync", "--import-only")
|
// We use --allow-stale to handle cases where the daemon is actively writing and
|
||||||
|
// the staleness check would otherwise fail spuriously.
|
||||||
|
syncCmd := exec.Command("bd", "--no-daemon", "--allow-stale", "sync", "--import-only")
|
||||||
syncCmd.Dir = dir
|
syncCmd.Dir = dir
|
||||||
if err := syncCmd.Run(); err != nil {
|
if err := syncCmd.Run(); err != nil {
|
||||||
// If sync fails (e.g., no database exists), just log and continue
|
// If sync fails (e.g., no database exists), just log and continue
|
||||||
@@ -1796,3 +1800,606 @@ 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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
agentID := "test-testrig-polecat-lifecycle"
|
||||||
|
|
||||||
|
// Simulate polecat lifecycle: spawn → nuke → respawn
|
||||||
|
|
||||||
|
// Spawn 1: Create agent bead with first task
|
||||||
|
issue1, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
HookBead: "test-task-1",
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
if issue1.Status != "open" {
|
||||||
|
t.Errorf("Spawn 1: status = %q, want 'open'", issue1.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nuke 1: Close agent bead (workaround for tombstone bug)
|
||||||
|
err = bd.CloseAndClearAgentBead(agentID, "polecat nuked")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Nuke 1 - CloseAndClearAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn 2: CreateOrReopenAgentBead should reopen and update
|
||||||
|
issue2, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
HookBead: "test-task-2", // Different task
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
if issue2.Status != "open" {
|
||||||
|
t.Errorf("Spawn 2: status = %q, want 'open'", issue2.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the hook was updated to the new task
|
||||||
|
fields := ParseAgentFields(issue2.Description)
|
||||||
|
if fields.HookBead != "test-task-2" {
|
||||||
|
t.Errorf("Spawn 2: hook_bead = %q, want 'test-task-2'", fields.HookBead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nuke 2: Close again
|
||||||
|
err = bd.CloseAndClearAgentBead(agentID, "polecat nuked again")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Nuke 2 - CloseAndClearAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn 3: Should still work
|
||||||
|
issue3, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
HookBead: "test-task-3",
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = ParseAgentFields(issue3.Description)
|
||||||
|
if fields.HookBead != "test-task-3" {
|
||||||
|
t.Errorf("Spawn 3: hook_bead = %q, want 'test-task-3'", fields.HookBead)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("LIFECYCLE TEST PASSED: spawn → nuke → respawn works with close/reopen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseAndClearAgentBead_FieldClearing tests that CloseAndClearAgentBead clears all mutable
|
||||||
|
// fields to emulate delete --force --hard behavior. This ensures reopened agent
|
||||||
|
// beads don't have stale state from previous lifecycle.
|
||||||
|
func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
// Test cases for field clearing permutations
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields *AgentFields
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all_fields_populated",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "running",
|
||||||
|
HookBead: "test-issue-123",
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
CleanupStatus: "clean",
|
||||||
|
ActiveMR: "test-mr-456",
|
||||||
|
NotificationLevel: "normal",
|
||||||
|
},
|
||||||
|
reason: "polecat completed work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only_hook_bead",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
HookBead: "test-issue-789",
|
||||||
|
},
|
||||||
|
reason: "polecat nuked",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only_active_mr",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "running",
|
||||||
|
ActiveMR: "test-mr-abc",
|
||||||
|
},
|
||||||
|
reason: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only_cleanup_status",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "idle",
|
||||||
|
CleanupStatus: "has_uncommitted",
|
||||||
|
},
|
||||||
|
reason: "cleanup required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_mutable_fields",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
},
|
||||||
|
reason: "fresh spawn closed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "polecat_with_all_field_types",
|
||||||
|
fields: &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "processing",
|
||||||
|
HookBead: "test-task-xyz",
|
||||||
|
ActiveMR: "test-mr-processing",
|
||||||
|
CleanupStatus: "has_uncommitted",
|
||||||
|
NotificationLevel: "verbose",
|
||||||
|
},
|
||||||
|
reason: "comprehensive cleanup",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Use tc.name for suffix to avoid hash-like patterns (e.g., single digits)
|
||||||
|
// that trigger bd's isLikelyHash() prefix extraction in v0.47.1+
|
||||||
|
agentID := fmt.Sprintf("test-testrig-%s-%s", tc.fields.RoleType, tc.name)
|
||||||
|
|
||||||
|
// Step 1: Create agent bead with specified fields
|
||||||
|
_, err := bd.CreateAgentBead(agentID, "Test agent", tc.fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fields were set
|
||||||
|
issue, err := bd.Show(agentID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Show before close: %v", err)
|
||||||
|
}
|
||||||
|
beforeFields := ParseAgentFields(issue.Description)
|
||||||
|
if tc.fields.HookBead != "" && beforeFields.HookBead != tc.fields.HookBead {
|
||||||
|
t.Errorf("before close: hook_bead = %q, want %q", beforeFields.HookBead, tc.fields.HookBead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Close the agent bead
|
||||||
|
err = bd.CloseAndClearAgentBead(agentID, tc.reason)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify bead is closed
|
||||||
|
issue, err = bd.Show(agentID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Show after close: %v", err)
|
||||||
|
}
|
||||||
|
if issue.Status != "closed" {
|
||||||
|
t.Errorf("status = %q, want 'closed'", issue.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Verify mutable fields were cleared
|
||||||
|
afterFields := ParseAgentFields(issue.Description)
|
||||||
|
|
||||||
|
// hook_bead should be cleared (empty or "null")
|
||||||
|
if afterFields.HookBead != "" {
|
||||||
|
t.Errorf("after close: hook_bead = %q, want empty (was %q)", afterFields.HookBead, tc.fields.HookBead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// active_mr should be cleared
|
||||||
|
if afterFields.ActiveMR != "" {
|
||||||
|
t.Errorf("after close: active_mr = %q, want empty (was %q)", afterFields.ActiveMR, tc.fields.ActiveMR)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup_status should be cleared
|
||||||
|
if afterFields.CleanupStatus != "" {
|
||||||
|
t.Errorf("after close: cleanup_status = %q, want empty (was %q)", afterFields.CleanupStatus, tc.fields.CleanupStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agent_state should be "closed"
|
||||||
|
if afterFields.AgentState != "closed" {
|
||||||
|
t.Errorf("after close: agent_state = %q, want 'closed' (was %q)", afterFields.AgentState, tc.fields.AgentState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable fields should be preserved
|
||||||
|
if afterFields.RoleType != tc.fields.RoleType {
|
||||||
|
t.Errorf("after close: role_type = %q, want %q (should be preserved)", afterFields.RoleType, tc.fields.RoleType)
|
||||||
|
}
|
||||||
|
if afterFields.Rig != tc.fields.Rig {
|
||||||
|
t.Errorf("after close: rig = %q, want %q (should be preserved)", afterFields.Rig, tc.fields.Rig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseAndClearAgentBead_NonExistent tests behavior when closing a non-existent agent bead.
|
||||||
|
func TestCloseAndClearAgentBead_NonExistent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
agentID := "test-testrig-polecat-cleanreopen"
|
||||||
|
|
||||||
|
// Step 1: Create agent with all fields populated
|
||||||
|
_, err := bd.CreateAgentBead(agentID, "Test agent", &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "running",
|
||||||
|
HookBead: "test-old-issue",
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
CleanupStatus: "clean",
|
||||||
|
ActiveMR: "test-old-mr",
|
||||||
|
NotificationLevel: "normal",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Close - should clear mutable fields
|
||||||
|
err = bd.CloseAndClearAgentBead(agentID, "completing old work")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CloseAndClearAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Reopen with new fields
|
||||||
|
newIssue, err := bd.CreateOrReopenAgentBead(agentID, agentID, &AgentFields{
|
||||||
|
RoleType: "polecat",
|
||||||
|
Rig: "testrig",
|
||||||
|
AgentState: "spawning",
|
||||||
|
HookBead: "test-new-issue",
|
||||||
|
RoleBead: "test-polecat-role",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateOrReopenAgentBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Verify new state - should have new hook, no stale data
|
||||||
|
fields := ParseAgentFields(newIssue.Description)
|
||||||
|
|
||||||
|
if fields.HookBead != "test-new-issue" {
|
||||||
|
t.Errorf("hook_bead = %q, want 'test-new-issue'", fields.HookBead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old active_mr should NOT be present (was cleared on close)
|
||||||
|
if fields.ActiveMR == "test-old-mr" {
|
||||||
|
t.Error("active_mr still has stale value 'test-old-mr' - CloseAndClearAgentBead didn't clear it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// agent_state should be the new state
|
||||||
|
if fields.AgentState != "spawning" {
|
||||||
|
t.Errorf("agent_state = %q, want 'spawning'", fields.AgentState)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("CLEAN STATE CONFIRMED: Reopened agent bead has no stale mutable fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseAndClearAgentBead_ReasonVariations tests close with different reason values.
|
||||||
|
func TestCloseAndClearAgentBead_ReasonVariations(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
bd := New(beadsDir)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gracefulTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// BdDaemonInfo represents the status of a single bd daemon instance.
|
// BdDaemonInfo represents the status of a single bd daemon instance.
|
||||||
type BdDaemonInfo struct {
|
type BdDaemonInfo struct {
|
||||||
Workspace string `json:"workspace"`
|
Workspace string `json:"workspace"`
|
||||||
@@ -69,21 +75,12 @@ func EnsureBdDaemonHealth(workDir string) string {
|
|||||||
|
|
||||||
// Check if any daemons need attention
|
// Check if any daemons need attention
|
||||||
needsRestart := false
|
needsRestart := false
|
||||||
var issues []string
|
|
||||||
|
|
||||||
for _, d := range health.Daemons {
|
for _, d := range health.Daemons {
|
||||||
switch d.Status {
|
switch d.Status {
|
||||||
case "healthy":
|
case "healthy":
|
||||||
// Good
|
// Good
|
||||||
case "version_mismatch":
|
case "version_mismatch", "stale", "unresponsive":
|
||||||
needsRestart = true
|
needsRestart = true
|
||||||
issues = append(issues, fmt.Sprintf("%s: version mismatch", d.Workspace))
|
|
||||||
case "stale":
|
|
||||||
needsRestart = true
|
|
||||||
issues = append(issues, fmt.Sprintf("%s: stale", d.Workspace))
|
|
||||||
case "unresponsive":
|
|
||||||
needsRestart = true
|
|
||||||
issues = append(issues, fmt.Sprintf("%s: unresponsive", d.Workspace))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +109,8 @@ func EnsureBdDaemonHealth(workDir string) string {
|
|||||||
|
|
||||||
// restartBdDaemons restarts all bd daemons.
|
// restartBdDaemons restarts all bd daemons.
|
||||||
func restartBdDaemons() error { //nolint:unparam // error return kept for future use
|
func restartBdDaemons() error { //nolint:unparam // error return kept for future use
|
||||||
// Stop all daemons first
|
// Stop all daemons first using pkill to avoid auto-start side effects
|
||||||
stopCmd := exec.Command("bd", "daemon", "killall")
|
_ = exec.Command("pkill", "-TERM", "-f", "bd daemon").Run()
|
||||||
_ = stopCmd.Run() // Ignore errors - daemons might not be running
|
|
||||||
|
|
||||||
// Give time for cleanup
|
// Give time for cleanup
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
@@ -128,7 +124,121 @@ func restartBdDaemons() error { //nolint:unparam // error return kept for future
|
|||||||
// StartBdDaemonIfNeeded starts the bd daemon for a specific workspace if not running.
|
// 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.
|
// This is a best-effort operation - failures are logged but don't block execution.
|
||||||
func StartBdDaemonIfNeeded(workDir string) error {
|
func StartBdDaemonIfNeeded(workDir string) error {
|
||||||
cmd := exec.Command("bd", "daemon", "--start")
|
cmd := exec.Command("bd", "daemon", "start")
|
||||||
cmd.Dir = workDir
|
cmd.Dir = workDir
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StopAllBdProcesses stops all bd daemon and activity processes.
|
||||||
|
// Returns (daemonsKilled, activityKilled, error).
|
||||||
|
// If dryRun is true, returns counts without stopping anything.
|
||||||
|
func StopAllBdProcesses(dryRun, force bool) (int, int, error) {
|
||||||
|
if _, err := exec.LookPath("bd"); err != nil {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
daemonsBefore := CountBdDaemons()
|
||||||
|
activityBefore := CountBdActivityProcesses()
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
return daemonsBefore, activityBefore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
daemonsKilled, daemonsRemaining := stopBdDaemons(force)
|
||||||
|
activityKilled, activityRemaining := stopBdActivityProcesses(force)
|
||||||
|
|
||||||
|
if daemonsRemaining > 0 {
|
||||||
|
return daemonsKilled, activityKilled, fmt.Errorf("bd daemon shutdown incomplete: %d still running", daemonsRemaining)
|
||||||
|
}
|
||||||
|
if activityRemaining > 0 {
|
||||||
|
return daemonsKilled, activityKilled, fmt.Errorf("bd activity shutdown incomplete: %d still running", activityRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
return daemonsKilled, activityKilled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBdDaemons returns count of running bd daemons.
|
||||||
|
// Uses pgrep instead of "bd daemon list" to avoid triggering daemon auto-start
|
||||||
|
// during shutdown verification.
|
||||||
|
func CountBdDaemons() int {
|
||||||
|
// Use pgrep -f with wc -l for cross-platform compatibility
|
||||||
|
// (macOS pgrep doesn't support -c flag)
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'bd daemon' 2>/dev/null | wc -l")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count, _ := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func stopBdDaemons(force bool) (int, int) {
|
||||||
|
before := CountBdDaemons()
|
||||||
|
if before == 0 {
|
||||||
|
return 0, 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
|
||||||
|
// given the alternative of respawning daemons during shutdown.
|
||||||
|
if force {
|
||||||
|
_ = exec.Command("pkill", "-9", "-f", "bd daemon").Run()
|
||||||
|
} else {
|
||||||
|
_ = exec.Command("pkill", "-TERM", "-f", "bd daemon").Run()
|
||||||
|
time.Sleep(gracefulTimeout)
|
||||||
|
if remaining := CountBdDaemons(); remaining > 0 {
|
||||||
|
_ = exec.Command("pkill", "-9", "-f", "bd daemon").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
final := CountBdDaemons()
|
||||||
|
killed := before - final
|
||||||
|
if killed < 0 {
|
||||||
|
killed = 0 // Race condition: more processes spawned than we killed
|
||||||
|
}
|
||||||
|
return killed, final
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBdActivityProcesses returns count of running `bd activity` processes.
|
||||||
|
func CountBdActivityProcesses() int {
|
||||||
|
// Use pgrep -f with wc -l for cross-platform compatibility
|
||||||
|
// (macOS pgrep doesn't support -c flag)
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'bd activity' 2>/dev/null | wc -l")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count, _ := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopBdActivityProcesses(force bool) (int, int) {
|
||||||
|
before := CountBdActivityProcesses()
|
||||||
|
if before == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if force {
|
||||||
|
_ = exec.Command("pkill", "-9", "-f", "bd activity").Run()
|
||||||
|
} else {
|
||||||
|
_ = exec.Command("pkill", "-TERM", "-f", "bd activity").Run()
|
||||||
|
time.Sleep(gracefulTimeout)
|
||||||
|
if remaining := CountBdActivityProcesses(); remaining > 0 {
|
||||||
|
_ = exec.Command("pkill", "-9", "-f", "bd activity").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
after := CountBdActivityProcesses()
|
||||||
|
killed := before - after
|
||||||
|
if killed < 0 {
|
||||||
|
killed = 0 // Race condition: more processes spawned than we killed
|
||||||
|
}
|
||||||
|
return killed, after
|
||||||
|
}
|
||||||
|
|||||||
33
internal/beads/daemon_test.go
Normal file
33
internal/beads/daemon_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCountBdActivityProcesses(t *testing.T) {
|
||||||
|
count := CountBdActivityProcesses()
|
||||||
|
if count < 0 {
|
||||||
|
t.Errorf("count should be non-negative, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountBdDaemons(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("bd"); err != nil {
|
||||||
|
t.Skip("bd not installed")
|
||||||
|
}
|
||||||
|
count := CountBdDaemons()
|
||||||
|
if count < 0 {
|
||||||
|
t.Errorf("count should be non-negative, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopAllBdProcesses_DryRun(t *testing.T) {
|
||||||
|
daemonsKilled, activityKilled, err := StopAllBdProcesses(true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if daemonsKilled < 0 || activityKilled < 0 {
|
||||||
|
t.Errorf("counts should be non-negative: daemons=%d, activity=%d", daemonsKilled, activityKilled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -528,6 +528,25 @@ type RoleConfig struct {
|
|||||||
// EnvVars are additional environment variables to set in the session.
|
// EnvVars are additional environment variables to set in the session.
|
||||||
// Stored as "key=value" pairs.
|
// Stored as "key=value" pairs.
|
||||||
EnvVars map[string]string
|
EnvVars map[string]string
|
||||||
|
|
||||||
|
// Health check thresholds - per ZFC, agents control their own stuck detection.
|
||||||
|
// These allow the Deacon's patrol config to be agent-defined rather than hardcoded.
|
||||||
|
|
||||||
|
// PingTimeout is how long to wait for a health check response.
|
||||||
|
// Format: duration string (e.g., "30s", "1m"). Default: 30s.
|
||||||
|
PingTimeout string
|
||||||
|
|
||||||
|
// ConsecutiveFailures is how many failed health checks before force-kill.
|
||||||
|
// Default: 3.
|
||||||
|
ConsecutiveFailures int
|
||||||
|
|
||||||
|
// KillCooldown is the minimum time between force-kills of the same agent.
|
||||||
|
// Format: duration string (e.g., "5m", "10m"). Default: 5m.
|
||||||
|
KillCooldown string
|
||||||
|
|
||||||
|
// StuckThreshold is how long a wisp can be in_progress before considered stuck.
|
||||||
|
// Format: duration string (e.g., "1h", "30m"). Default: 1h.
|
||||||
|
StuckThreshold string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseRoleConfig extracts RoleConfig from a role bead's description.
|
// ParseRoleConfig extracts RoleConfig from a role bead's description.
|
||||||
@@ -576,6 +595,21 @@ func ParseRoleConfig(description string) *RoleConfig {
|
|||||||
config.EnvVars[envKey] = envVal
|
config.EnvVars[envKey] = envVal
|
||||||
hasFields = true
|
hasFields = true
|
||||||
}
|
}
|
||||||
|
// Health check threshold fields (ZFC: agent-controlled)
|
||||||
|
case "ping_timeout", "ping-timeout", "pingtimeout":
|
||||||
|
config.PingTimeout = value
|
||||||
|
hasFields = true
|
||||||
|
case "consecutive_failures", "consecutive-failures", "consecutivefailures":
|
||||||
|
if n, err := parseIntValue(value); err == nil {
|
||||||
|
config.ConsecutiveFailures = n
|
||||||
|
hasFields = true
|
||||||
|
}
|
||||||
|
case "kill_cooldown", "kill-cooldown", "killcooldown":
|
||||||
|
config.KillCooldown = value
|
||||||
|
hasFields = true
|
||||||
|
case "stuck_threshold", "stuck-threshold", "stuckthreshold":
|
||||||
|
config.StuckThreshold = value
|
||||||
|
hasFields = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +619,13 @@ func ParseRoleConfig(description string) *RoleConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseIntValue parses an integer from a string value.
|
||||||
|
func parseIntValue(s string) (int, error) {
|
||||||
|
var n int
|
||||||
|
_, err := fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description.
|
// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description.
|
||||||
// Only non-empty/non-default fields are included.
|
// Only non-empty/non-default fields are included.
|
||||||
func FormatRoleConfig(config *RoleConfig) string {
|
func FormatRoleConfig(config *RoleConfig) string {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,10 +48,10 @@ func (b *Beads) GetOrCreateHandoffBead(role string) (*Issue, error) {
|
|||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new handoff bead
|
// Create new handoff bead (type is deprecated, uses gt:task label via backward compat)
|
||||||
issue, err := b.Create(CreateOptions{
|
issue, err := b.Create(CreateOptions{
|
||||||
Title: HandoffBeadTitle(role),
|
Title: HandoffBeadTitle(role),
|
||||||
Type: "task",
|
Type: "task", // Converted to gt:task label by Create()
|
||||||
Priority: 2,
|
Priority: 2,
|
||||||
Description: "", // Empty until first handoff
|
Description: "", // Empty until first handoff
|
||||||
Actor: role,
|
Actor: role,
|
||||||
@@ -107,7 +107,7 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
|
|||||||
// List all open messages
|
// List all open messages
|
||||||
issues, err := b.List(ListOptions{
|
issues, err := b.List(ListOptions{
|
||||||
Status: "open",
|
Status: "open",
|
||||||
Type: "message",
|
Label: "gt:message",
|
||||||
Priority: -1,
|
Priority: -1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route represents a prefix-to-path routing rule.
|
// 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.
|
// WriteRoutes writes routes to routes.jsonl, overwriting existing content.
|
||||||
func WriteRoutes(beadsDir string, routes []Route) error {
|
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)
|
routesPath := filepath.Join(beadsDir, RoutesFileName)
|
||||||
|
|
||||||
file, err := os.Create(routesPath)
|
file, err := os.Create(routesPath)
|
||||||
@@ -150,7 +157,7 @@ func GetPrefixForRig(townRoot, rigName string) string {
|
|||||||
beadsDir := filepath.Join(townRoot, ".beads")
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
routes, err := LoadRoutes(beadsDir)
|
routes, err := LoadRoutes(beadsDir)
|
||||||
if err != nil || routes == nil {
|
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
|
// 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.
|
// FindConflictingPrefixes checks for duplicate prefixes in routes.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetPrefixForRig(t *testing.T) {
|
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) {
|
func TestExtractPrefix(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
beadID string
|
beadID string
|
||||||
@@ -100,7 +129,7 @@ func TestGetRigPathForPrefix(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
|
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
|
||||||
{"gt-", filepath.Join(tmpDir, "gastown/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
|
{"unknown-", ""}, // Unknown prefix returns empty
|
||||||
{"", ""}, // Empty prefix returns empty
|
{"", ""}, // Empty prefix returns empty
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,12 @@ import (
|
|||||||
// to return true when only Boot is running.
|
// to return true when only Boot is running.
|
||||||
const SessionName = "gt-boot"
|
const SessionName = "gt-boot"
|
||||||
|
|
||||||
// MarkerFileName is the file that indicates Boot is currently running.
|
// MarkerFileName is the lock file for Boot startup coordination.
|
||||||
const MarkerFileName = ".boot-running"
|
const MarkerFileName = ".boot-running"
|
||||||
|
|
||||||
// StatusFileName stores Boot's last execution status.
|
// StatusFileName stores Boot's last execution status.
|
||||||
const StatusFileName = ".boot-status.json"
|
const StatusFileName = ".boot-status.json"
|
||||||
|
|
||||||
// DefaultMarkerTTL is how long a marker is considered valid before it's stale.
|
|
||||||
const DefaultMarkerTTL = 5 * time.Minute
|
|
||||||
|
|
||||||
// Status represents Boot's execution status.
|
// Status represents Boot's execution status.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
@@ -43,11 +40,11 @@ type Status struct {
|
|||||||
|
|
||||||
// Boot manages the Boot watchdog lifecycle.
|
// Boot manages the Boot watchdog lifecycle.
|
||||||
type Boot struct {
|
type Boot struct {
|
||||||
townRoot string
|
townRoot string
|
||||||
bootDir string // ~/gt/deacon/dogs/boot/
|
bootDir string // ~/gt/deacon/dogs/boot/
|
||||||
deaconDir string // ~/gt/deacon/
|
deaconDir string // ~/gt/deacon/
|
||||||
tmux *tmux.Tmux
|
tmux *tmux.Tmux
|
||||||
degraded bool
|
degraded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Boot manager.
|
// New creates a new Boot manager.
|
||||||
@@ -77,22 +74,9 @@ func (b *Boot) statusPath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsRunning checks if Boot is currently running.
|
// IsRunning checks if Boot is currently running.
|
||||||
// Returns true if marker exists and isn't stale, false otherwise.
|
// Queries tmux directly for observable reality (ZFC principle).
|
||||||
func (b *Boot) IsRunning() bool {
|
func (b *Boot) IsRunning() bool {
|
||||||
info, err := os.Stat(b.markerPath())
|
return b.IsSessionAlive()
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if marker is stale (older than TTL)
|
|
||||||
age := time.Since(info.ModTime())
|
|
||||||
if age > DefaultMarkerTTL {
|
|
||||||
// Stale marker - clean it up
|
|
||||||
_ = os.Remove(b.markerPath())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSessionAlive checks if the Boot tmux session exists.
|
// IsSessionAlive checks if the Boot tmux session exists.
|
||||||
@@ -105,7 +89,7 @@ func (b *Boot) IsSessionAlive() bool {
|
|||||||
// Returns error if Boot is already running.
|
// Returns error if Boot is already running.
|
||||||
func (b *Boot) AcquireLock() error {
|
func (b *Boot) AcquireLock() error {
|
||||||
if b.IsRunning() {
|
if b.IsRunning() {
|
||||||
return fmt.Errorf("boot is already running (marker exists)")
|
return fmt.Errorf("boot is already running (session exists)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := b.EnsureDir(); err != nil {
|
if err := b.EnsureDir(); err != nil {
|
||||||
@@ -160,7 +144,8 @@ func (b *Boot) LoadStatus() (*Status, error) {
|
|||||||
// Spawn starts Boot in a fresh tmux session.
|
// Spawn starts Boot in a fresh tmux session.
|
||||||
// Boot runs the mol-boot-triage molecule and exits when done.
|
// Boot runs the mol-boot-triage molecule and exits when done.
|
||||||
// In degraded mode (no tmux), it runs in a subprocess.
|
// 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() {
|
if b.IsRunning() {
|
||||||
return fmt.Errorf("boot is already running")
|
return fmt.Errorf("boot is already running")
|
||||||
}
|
}
|
||||||
@@ -170,11 +155,11 @@ func (b *Boot) Spawn() error {
|
|||||||
return b.spawnDegraded()
|
return b.spawnDegraded()
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.spawnTmux()
|
return b.spawnTmux(agentOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawnTmux spawns Boot in a tmux session.
|
// spawnTmux spawns Boot in a tmux session.
|
||||||
func (b *Boot) spawnTmux() error {
|
func (b *Boot) spawnTmux(agentOverride string) error {
|
||||||
// Kill any stale session first
|
// Kill any stale session first
|
||||||
if b.IsSessionAlive() {
|
if b.IsSessionAlive() {
|
||||||
_ = b.tmux.KillSession(SessionName)
|
_ = b.tmux.KillSession(SessionName)
|
||||||
@@ -185,25 +170,32 @@ func (b *Boot) spawnTmux() error {
|
|||||||
return fmt.Errorf("ensuring boot dir: %w", err)
|
return fmt.Errorf("ensuring boot dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session in boot directory (not deacon dir) so Claude reads Boot's CLAUDE.md
|
// Build startup command with optional agent override
|
||||||
if err := b.tmux.NewSession(SessionName, b.bootDir); err != nil {
|
// 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)
|
return fmt.Errorf("creating boot session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment
|
// Set environment using centralized AgentEnv for consistency
|
||||||
_ = b.tmux.SetEnvironment(SessionName, "GT_ROLE", "boot")
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||||
_ = b.tmux.SetEnvironment(SessionName, "BD_ACTOR", "deacon-boot")
|
Role: "boot",
|
||||||
|
TownRoot: b.townRoot,
|
||||||
// Launch Claude with environment exported inline and initial triage prompt
|
})
|
||||||
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
|
for k, v := range envVars {
|
||||||
startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage")
|
_ = b.tmux.SetEnvironment(SessionName, k, v)
|
||||||
// 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
|
return nil
|
||||||
@@ -216,11 +208,14 @@ func (b *Boot) spawnDegraded() error {
|
|||||||
// This performs the triage logic without a full Claude session
|
// This performs the triage logic without a full Claude session
|
||||||
cmd := exec.Command("gt", "boot", "triage", "--degraded")
|
cmd := exec.Command("gt", "boot", "triage", "--degraded")
|
||||||
cmd.Dir = b.deaconDir
|
cmd.Dir = b.deaconDir
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GT_ROLE=boot",
|
// Use centralized AgentEnv for consistency with tmux mode
|
||||||
"BD_ACTOR=deacon-boot",
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||||
"GT_DEGRADED=true",
|
Role: "boot",
|
||||||
)
|
TownRoot: b.townRoot,
|
||||||
|
})
|
||||||
|
cmd.Env = config.EnvForExecCommand(envVars)
|
||||||
|
cmd.Env = append(cmd.Env, "GT_DEGRADED=true")
|
||||||
|
|
||||||
// Run async - don't wait for completion
|
// Run async - don't wait for completion
|
||||||
return cmd.Start()
|
return cmd.Start()
|
||||||
|
|||||||
@@ -181,9 +181,9 @@ func (cp *Checkpoint) Age() time.Duration {
|
|||||||
return time.Since(cp.Timestamp)
|
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 {
|
func (cp *Checkpoint) IsStale(threshold time.Duration) bool {
|
||||||
return cp.Age() > threshold
|
return cp.Age() >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary returns a concise summary of the checkpoint.
|
// Summary returns a concise summary of the checkpoint.
|
||||||
|
|||||||
398
internal/checkpoint/checkpoint_test.go
Normal file
398
internal/checkpoint/checkpoint_test.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPath(t *testing.T) {
|
||||||
|
dir := "/some/polecat/dir"
|
||||||
|
got := Path(dir)
|
||||||
|
want := filepath.Join(dir, Filename)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Path(%q) = %q, want %q", dir, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWrite(t *testing.T) {
|
||||||
|
// Create temp directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Test reading non-existent checkpoint returns nil, nil
|
||||||
|
cp, err := Read(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read non-existent: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cp != nil {
|
||||||
|
t.Fatal("Read non-existent: expected nil checkpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and write a checkpoint
|
||||||
|
original := &Checkpoint{
|
||||||
|
MoleculeID: "mol-123",
|
||||||
|
CurrentStep: "step-1",
|
||||||
|
StepTitle: "Build the thing",
|
||||||
|
ModifiedFiles: []string{"file1.go", "file2.go"},
|
||||||
|
LastCommit: "abc123",
|
||||||
|
Branch: "feature/test",
|
||||||
|
HookedBead: "gt-xyz",
|
||||||
|
Notes: "Some notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Write(tmpDir, original); err != nil {
|
||||||
|
t.Fatalf("Write: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
path := Path(tmpDir)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
t.Fatal("Write: checkpoint file not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
loaded, err := Read(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if loaded == nil {
|
||||||
|
t.Fatal("Read: expected non-nil checkpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fields
|
||||||
|
if loaded.MoleculeID != original.MoleculeID {
|
||||||
|
t.Errorf("MoleculeID = %q, want %q", loaded.MoleculeID, original.MoleculeID)
|
||||||
|
}
|
||||||
|
if loaded.CurrentStep != original.CurrentStep {
|
||||||
|
t.Errorf("CurrentStep = %q, want %q", loaded.CurrentStep, original.CurrentStep)
|
||||||
|
}
|
||||||
|
if loaded.StepTitle != original.StepTitle {
|
||||||
|
t.Errorf("StepTitle = %q, want %q", loaded.StepTitle, original.StepTitle)
|
||||||
|
}
|
||||||
|
if loaded.Branch != original.Branch {
|
||||||
|
t.Errorf("Branch = %q, want %q", loaded.Branch, original.Branch)
|
||||||
|
}
|
||||||
|
if loaded.HookedBead != original.HookedBead {
|
||||||
|
t.Errorf("HookedBead = %q, want %q", loaded.HookedBead, original.HookedBead)
|
||||||
|
}
|
||||||
|
if loaded.Notes != original.Notes {
|
||||||
|
t.Errorf("Notes = %q, want %q", loaded.Notes, original.Notes)
|
||||||
|
}
|
||||||
|
if len(loaded.ModifiedFiles) != len(original.ModifiedFiles) {
|
||||||
|
t.Errorf("ModifiedFiles len = %d, want %d", len(loaded.ModifiedFiles), len(original.ModifiedFiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify timestamp was set
|
||||||
|
if loaded.Timestamp.IsZero() {
|
||||||
|
t.Error("Timestamp should be set by Write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SessionID was set
|
||||||
|
if loaded.SessionID == "" {
|
||||||
|
t.Error("SessionID should be set by Write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWritePreservesTimestamp(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create checkpoint with explicit timestamp
|
||||||
|
ts := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
cp := &Checkpoint{
|
||||||
|
Timestamp: ts,
|
||||||
|
Notes: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Write(tmpDir, cp); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Read(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !loaded.Timestamp.Equal(ts) {
|
||||||
|
t.Errorf("Timestamp = %v, want %v", loaded.Timestamp, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCorruptedJSON(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := Path(tmpDir)
|
||||||
|
|
||||||
|
// Write invalid JSON
|
||||||
|
if err := os.WriteFile(path, []byte("not valid json{"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Read(tmpDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Read corrupted JSON: expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemove(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a checkpoint
|
||||||
|
cp := &Checkpoint{Notes: "to be removed"}
|
||||||
|
if err := Write(tmpDir, cp); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
path := Path(tmpDir)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
t.Fatal("checkpoint should exist before Remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove it
|
||||||
|
if err := Remove(tmpDir); err != nil {
|
||||||
|
t.Fatalf("Remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("checkpoint should not exist after Remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove again should not error
|
||||||
|
if err := Remove(tmpDir); err != nil {
|
||||||
|
t.Fatalf("Remove non-existent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture(t *testing.T) {
|
||||||
|
// Use current directory (should be a git repo)
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Getwd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find git root
|
||||||
|
gitRoot := cwd
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(gitRoot, ".git")); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(gitRoot)
|
||||||
|
if parent == gitRoot {
|
||||||
|
t.Skip("not in a git repository")
|
||||||
|
}
|
||||||
|
gitRoot = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
cp, err := Capture(gitRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Capture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have timestamp
|
||||||
|
if cp.Timestamp.IsZero() {
|
||||||
|
t.Error("Timestamp should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have branch (we're in a git repo)
|
||||||
|
if cp.Branch == "" {
|
||||||
|
t.Error("Branch should be set in git repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have last commit
|
||||||
|
if cp.LastCommit == "" {
|
||||||
|
t.Error("LastCommit should be set in git repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithMolecule(t *testing.T) {
|
||||||
|
cp := &Checkpoint{}
|
||||||
|
result := cp.WithMolecule("mol-abc", "step-1", "Do the thing")
|
||||||
|
|
||||||
|
if result != cp {
|
||||||
|
t.Error("WithMolecule should return same checkpoint")
|
||||||
|
}
|
||||||
|
if cp.MoleculeID != "mol-abc" {
|
||||||
|
t.Errorf("MoleculeID = %q, want %q", cp.MoleculeID, "mol-abc")
|
||||||
|
}
|
||||||
|
if cp.CurrentStep != "step-1" {
|
||||||
|
t.Errorf("CurrentStep = %q, want %q", cp.CurrentStep, "step-1")
|
||||||
|
}
|
||||||
|
if cp.StepTitle != "Do the thing" {
|
||||||
|
t.Errorf("StepTitle = %q, want %q", cp.StepTitle, "Do the thing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithHookedBead(t *testing.T) {
|
||||||
|
cp := &Checkpoint{}
|
||||||
|
result := cp.WithHookedBead("gt-123")
|
||||||
|
|
||||||
|
if result != cp {
|
||||||
|
t.Error("WithHookedBead should return same checkpoint")
|
||||||
|
}
|
||||||
|
if cp.HookedBead != "gt-123" {
|
||||||
|
t.Errorf("HookedBead = %q, want %q", cp.HookedBead, "gt-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithNotes(t *testing.T) {
|
||||||
|
cp := &Checkpoint{}
|
||||||
|
result := cp.WithNotes("important context")
|
||||||
|
|
||||||
|
if result != cp {
|
||||||
|
t.Error("WithNotes should return same checkpoint")
|
||||||
|
}
|
||||||
|
if cp.Notes != "important context" {
|
||||||
|
t.Errorf("Notes = %q, want %q", cp.Notes, "important context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAge(t *testing.T) {
|
||||||
|
cp := &Checkpoint{
|
||||||
|
Timestamp: time.Now().Add(-5 * time.Minute),
|
||||||
|
}
|
||||||
|
|
||||||
|
age := cp.Age()
|
||||||
|
if age < 4*time.Minute || age > 6*time.Minute {
|
||||||
|
t.Errorf("Age = %v, expected ~5 minutes", age)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStale(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
age time.Duration
|
||||||
|
threshold time.Duration
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"fresh", 5 * time.Minute, 1 * time.Hour, false},
|
||||||
|
{"stale", 2 * time.Hour, 1 * time.Hour, true},
|
||||||
|
{"exactly threshold", 1 * time.Hour, 1 * time.Hour, true}, // timing race: by the time IsStale runs, age > threshold
|
||||||
|
{"just over threshold", 1*time.Hour + time.Second, 1 * time.Hour, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cp := &Checkpoint{
|
||||||
|
Timestamp: time.Now().Add(-tt.age),
|
||||||
|
}
|
||||||
|
got := cp.IsStale(tt.threshold)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsStale(%v) = %v, want %v", tt.threshold, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cp *Checkpoint
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
cp: &Checkpoint{},
|
||||||
|
want: "no significant state",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "molecule only",
|
||||||
|
cp: &Checkpoint{MoleculeID: "mol-123"},
|
||||||
|
want: "molecule mol-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "molecule with step",
|
||||||
|
cp: &Checkpoint{MoleculeID: "mol-123", CurrentStep: "step-1"},
|
||||||
|
want: "molecule mol-123, step step-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hooked bead",
|
||||||
|
cp: &Checkpoint{HookedBead: "gt-abc"},
|
||||||
|
want: "hooked: gt-abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modified files",
|
||||||
|
cp: &Checkpoint{ModifiedFiles: []string{"a.go", "b.go"}},
|
||||||
|
want: "2 modified files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "branch",
|
||||||
|
cp: &Checkpoint{Branch: "feature/test"},
|
||||||
|
want: "branch: feature/test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full",
|
||||||
|
cp: &Checkpoint{
|
||||||
|
MoleculeID: "mol-123",
|
||||||
|
CurrentStep: "step-1",
|
||||||
|
HookedBead: "gt-abc",
|
||||||
|
ModifiedFiles: []string{"a.go"},
|
||||||
|
Branch: "main",
|
||||||
|
},
|
||||||
|
want: "molecule mol-123, step step-1, hooked: gt-abc, 1 modified files, branch: main",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.cp.Summary()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Summary() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointJSONRoundtrip(t *testing.T) {
|
||||||
|
original := &Checkpoint{
|
||||||
|
MoleculeID: "mol-test",
|
||||||
|
CurrentStep: "step-2",
|
||||||
|
StepTitle: "Testing JSON",
|
||||||
|
ModifiedFiles: []string{"x.go", "y.go", "z.go"},
|
||||||
|
LastCommit: "deadbeef",
|
||||||
|
Branch: "develop",
|
||||||
|
HookedBead: "gt-roundtrip",
|
||||||
|
Timestamp: time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC),
|
||||||
|
SessionID: "session-123",
|
||||||
|
Notes: "Testing round trip",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded Checkpoint
|
||||||
|
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.MoleculeID != original.MoleculeID {
|
||||||
|
t.Errorf("MoleculeID mismatch")
|
||||||
|
}
|
||||||
|
if loaded.CurrentStep != original.CurrentStep {
|
||||||
|
t.Errorf("CurrentStep mismatch")
|
||||||
|
}
|
||||||
|
if loaded.StepTitle != original.StepTitle {
|
||||||
|
t.Errorf("StepTitle mismatch")
|
||||||
|
}
|
||||||
|
if loaded.Branch != original.Branch {
|
||||||
|
t.Errorf("Branch mismatch")
|
||||||
|
}
|
||||||
|
if loaded.HookedBead != original.HookedBead {
|
||||||
|
t.Errorf("HookedBead mismatch")
|
||||||
|
}
|
||||||
|
if loaded.SessionID != original.SessionID {
|
||||||
|
t.Errorf("SessionID mismatch")
|
||||||
|
}
|
||||||
|
if loaded.Notes != original.Notes {
|
||||||
|
t.Errorf("Notes mismatch")
|
||||||
|
}
|
||||||
|
if !loaded.Timestamp.Equal(original.Timestamp) {
|
||||||
|
t.Errorf("Timestamp mismatch")
|
||||||
|
}
|
||||||
|
if len(loaded.ModifiedFiles) != len(original.ModifiedFiles) {
|
||||||
|
t.Errorf("ModifiedFiles length mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
167
internal/cmd/bead.go
Normal file
167
internal/cmd/bead.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
var beadCmd = &cobra.Command{
|
||||||
|
Use: "bead",
|
||||||
|
GroupID: GroupWork,
|
||||||
|
Short: "Bead management utilities",
|
||||||
|
Long: `Utilities for managing beads across repositories.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var beadMoveCmd = &cobra.Command{
|
||||||
|
Use: "move <bead-id> <target-prefix>",
|
||||||
|
Short: "Move a bead to a different repository",
|
||||||
|
Long: `Move a bead from one repository to another.
|
||||||
|
|
||||||
|
This creates a copy of the bead in the target repository (with the new prefix)
|
||||||
|
and closes the source bead with a reference to the new location.
|
||||||
|
|
||||||
|
The target prefix determines which repository receives the bead.
|
||||||
|
Common prefixes: gt- (gastown), bd- (beads), hq- (headquarters)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt bead move gt-abc123 bd- # Move gt-abc123 to beads repo as bd-*
|
||||||
|
gt bead move hq-xyz bd- # Move hq-xyz to beads repo
|
||||||
|
gt bead move bd-123 gt- # Move bd-123 to gastown repo`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runBeadMove,
|
||||||
|
}
|
||||||
|
|
||||||
|
var beadMoveDryRun bool
|
||||||
|
|
||||||
|
var beadShowCmd = &cobra.Command{
|
||||||
|
Use: "show <bead-id> [flags]",
|
||||||
|
Short: "Show details of a bead",
|
||||||
|
Long: `Displays the full details of a bead by ID.
|
||||||
|
|
||||||
|
This is an alias for 'gt show'. All bd show flags are supported.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt bead show gt-abc123 # Show a gastown issue
|
||||||
|
gt bead show hq-xyz789 # Show a town-level bead
|
||||||
|
gt bead show bd-def456 # Show a beads issue
|
||||||
|
gt bead show gt-abc123 --json # Output as JSON`,
|
||||||
|
DisableFlagParsing: true, // Pass all flags through to bd show
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runShow(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
beadMoveCmd.Flags().BoolVarP(&beadMoveDryRun, "dry-run", "n", false, "Show what would be done")
|
||||||
|
beadCmd.AddCommand(beadMoveCmd)
|
||||||
|
beadCmd.AddCommand(beadShowCmd)
|
||||||
|
rootCmd.AddCommand(beadCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveBeadInfo holds the essential fields we need to copy when moving beads
|
||||||
|
type moveBeadInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"issue_type"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
Assignee string `json:"assignee"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBeadMove(cmd *cobra.Command, args []string) error {
|
||||||
|
sourceID := args[0]
|
||||||
|
targetPrefix := args[1]
|
||||||
|
|
||||||
|
// Normalize prefix (ensure it ends with -)
|
||||||
|
if !strings.HasSuffix(targetPrefix, "-") {
|
||||||
|
targetPrefix = targetPrefix + "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source bead details
|
||||||
|
showCmd := exec.Command("bd", "show", sourceID, "--json")
|
||||||
|
output, err := showCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting bead %s: %w", sourceID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bd show --json returns an array
|
||||||
|
var sources []moveBeadInfo
|
||||||
|
if err := json.Unmarshal(output, &sources); err != nil {
|
||||||
|
return fmt.Errorf("parsing bead data: %w", err)
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return fmt.Errorf("bead %s not found", sourceID)
|
||||||
|
}
|
||||||
|
source := sources[0]
|
||||||
|
|
||||||
|
// Don't move closed beads
|
||||||
|
if source.Status == "closed" {
|
||||||
|
return fmt.Errorf("cannot move closed bead %s", sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Moving %s to %s...\n", style.Bold.Render("→"), sourceID, targetPrefix)
|
||||||
|
fmt.Printf(" Title: %s\n", source.Title)
|
||||||
|
fmt.Printf(" Type: %s\n", source.Type)
|
||||||
|
|
||||||
|
if beadMoveDryRun {
|
||||||
|
fmt.Printf("\nDry run - would:\n")
|
||||||
|
fmt.Printf(" 1. Create new bead with prefix %s\n", targetPrefix)
|
||||||
|
fmt.Printf(" 2. Close %s with reference to new bead\n", sourceID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build create command for target
|
||||||
|
createArgs := []string{
|
||||||
|
"create",
|
||||||
|
"--prefix", targetPrefix,
|
||||||
|
"--title", source.Title,
|
||||||
|
"--type", source.Type,
|
||||||
|
"--priority", fmt.Sprintf("%d", source.Priority),
|
||||||
|
"--silent", // Only output the ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.Description != "" {
|
||||||
|
createArgs = append(createArgs, "--description", source.Description)
|
||||||
|
}
|
||||||
|
if source.Assignee != "" {
|
||||||
|
createArgs = append(createArgs, "--assignee", source.Assignee)
|
||||||
|
}
|
||||||
|
for _, label := range source.Labels {
|
||||||
|
createArgs = append(createArgs, "--label", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new bead
|
||||||
|
createCmd := exec.Command("bd", createArgs...)
|
||||||
|
createCmd.Stderr = os.Stderr
|
||||||
|
newIDBytes, err := createCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating new bead: %w", err)
|
||||||
|
}
|
||||||
|
newID := strings.TrimSpace(string(newIDBytes))
|
||||||
|
|
||||||
|
fmt.Printf("%s Created %s\n", style.Bold.Render("✓"), newID)
|
||||||
|
|
||||||
|
// Close the source bead with reference
|
||||||
|
closeReason := fmt.Sprintf("Moved to %s", newID)
|
||||||
|
closeCmd := exec.Command("bd", "close", sourceID, "--reason", closeReason)
|
||||||
|
closeCmd.Stderr = os.Stderr
|
||||||
|
if err := closeCmd.Run(); err != nil {
|
||||||
|
// Try to clean up the new bead if close fails
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to close source bead: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "New bead %s was created but source %s remains open\n", newID, sourceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Closed %s (moved to %s)\n", style.Bold.Render("✓"), sourceID, newID)
|
||||||
|
fmt.Printf("\nBead moved: %s → %s\n", sourceID, newID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
419
internal/cmd/beads_db_init_test.go
Normal file
419
internal/cmd/beads_db_init_test.go
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Package cmd contains integration tests for beads db initialization after clone.
|
||||||
|
//
|
||||||
|
// Run with: go test -tags=integration ./internal/cmd -run TestBeadsDbInitAfterClone -v
|
||||||
|
//
|
||||||
|
// Bug: GitHub Issue #72
|
||||||
|
// When a repo with tracked .beads/ is added as a rig, beads.db doesn't exist
|
||||||
|
// (it's gitignored) and bd operations fail because no one runs `bd init`.
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTrackedBeadsRepoWithIssues creates a git repo with .beads/ tracked that contains existing issues.
|
||||||
|
// This simulates a clone of a repo that has tracked beads with issues exported to issues.jsonl.
|
||||||
|
// The beads.db is NOT included (gitignored), so prefix must be detected from issues.jsonl.
|
||||||
|
func createTrackedBeadsRepoWithIssues(t *testing.T, path, prefix string, numIssues int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize git repo with explicit main branch
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init", "--initial-branch=main"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test User"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial file and commit (so we have something before beads)
|
||||||
|
readmePath := filepath.Join(path, "README.md")
|
||||||
|
if err := os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("write README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commitCmds := [][]string{
|
||||||
|
{"git", "add", "."},
|
||||||
|
{"git", "commit", "-m", "Initial commit"},
|
||||||
|
}
|
||||||
|
for _, args := range commitCmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize beads
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bd init
|
||||||
|
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", prefix)
|
||||||
|
cmd.Dir = path
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("bd init failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create issues
|
||||||
|
for i := 1; i <= numIssues; i++ {
|
||||||
|
cmd = exec.Command("bd", "--no-daemon", "-q", "create",
|
||||||
|
"--type", "task", "--title", fmt.Sprintf("Test issue %d", i))
|
||||||
|
cmd.Dir = path
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("bd create issue %d failed: %v\nOutput: %s", i, err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add .beads to git (simulating tracked beads)
|
||||||
|
cmd = exec.Command("git", "add", ".beads")
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git add .beads: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "commit", "-m", "Add beads with issues")
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git commit beads: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove beads.db to simulate what a clone would look like
|
||||||
|
// (beads.db is gitignored, so cloned repos don't have it)
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
if err := os.Remove(dbPath); err != nil {
|
||||||
|
t.Fatalf("remove beads.db: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBeadsDbInitAfterClone tests that when a tracked beads repo is added as a rig,
|
||||||
|
// the beads database is properly initialized even though beads.db doesn't exist.
|
||||||
|
func TestBeadsDbInitAfterClone(t *testing.T) {
|
||||||
|
// Skip if bd is not available
|
||||||
|
if _, err := exec.LookPath("bd"); err != nil {
|
||||||
|
t.Skip("bd not installed, skipping test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
gtBinary := buildGT(t)
|
||||||
|
|
||||||
|
t.Run("TrackedRepoWithExistingPrefix", func(t *testing.T) {
|
||||||
|
// GitHub Issue #72: gt rig add should detect existing prefix from tracked beads
|
||||||
|
// https://github.com/steveyegge/gastown/issues/72
|
||||||
|
//
|
||||||
|
// This tests that when a tracked beads repo has existing issues in issues.jsonl,
|
||||||
|
// gt rig add can detect the prefix from those issues WITHOUT --prefix flag.
|
||||||
|
|
||||||
|
townRoot := filepath.Join(tmpDir, "town-prefix-test")
|
||||||
|
reposDir := filepath.Join(tmpDir, "repos")
|
||||||
|
os.MkdirAll(reposDir, 0755)
|
||||||
|
|
||||||
|
// Create a repo with existing beads prefix "existing-prefix" AND issues
|
||||||
|
// This creates issues.jsonl with issues like "existing-prefix-1", etc.
|
||||||
|
existingRepo := filepath.Join(reposDir, "existing-repo")
|
||||||
|
createTrackedBeadsRepoWithIssues(t, existingRepo, "existing-prefix", 3)
|
||||||
|
|
||||||
|
// Install town
|
||||||
|
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "prefix-test")
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rig WITHOUT specifying --prefix - should detect "existing-prefix" from issues.jsonl
|
||||||
|
cmd = exec.Command(gtBinary, "rig", "add", "myrig", existingRepo)
|
||||||
|
cmd.Dir = townRoot
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt rig add failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify routes.jsonl has the prefix
|
||||||
|
routesContent, err := os.ReadFile(filepath.Join(townRoot, ".beads", "routes.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(routesContent), `"prefix":"existing-prefix-"`) {
|
||||||
|
t.Errorf("routes.jsonl should contain existing-prefix-, got:\n%s", routesContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOW TRY TO USE bd - this is the key test for the bug
|
||||||
|
// Without the fix, beads.db doesn't exist and bd operations fail
|
||||||
|
rigPath := filepath.Join(townRoot, "myrig", "mayor", "rig")
|
||||||
|
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
|
||||||
|
"--type", "task", "--title", "test-from-rig")
|
||||||
|
cmd.Dir = rigPath
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bd create failed (bug!): %v\nOutput: %s\n\nThis is the bug: beads.db doesn't exist after clone because bd init was never run", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
t.Fatalf("parse output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(result.ID, "existing-prefix-") {
|
||||||
|
t.Errorf("expected existing-prefix- prefix, got %s", result.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TrackedRepoWithNoIssuesRequiresPrefix", func(t *testing.T) {
|
||||||
|
// Regression test: When a tracked beads repo has NO issues (fresh init),
|
||||||
|
// gt rig add must use the --prefix flag since there's nothing to detect from.
|
||||||
|
|
||||||
|
townRoot := filepath.Join(tmpDir, "town-no-issues")
|
||||||
|
reposDir := filepath.Join(tmpDir, "repos-no-issues")
|
||||||
|
os.MkdirAll(reposDir, 0755)
|
||||||
|
|
||||||
|
// Create a tracked beads repo with NO issues (just bd init)
|
||||||
|
emptyRepo := filepath.Join(reposDir, "empty-repo")
|
||||||
|
createTrackedBeadsRepoWithNoIssues(t, emptyRepo, "empty-prefix")
|
||||||
|
|
||||||
|
// Install town
|
||||||
|
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "no-issues-test")
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rig WITH --prefix since we can't detect from empty issues.jsonl
|
||||||
|
cmd = exec.Command(gtBinary, "rig", "add", "emptyrig", emptyRepo, "--prefix", "empty-prefix")
|
||||||
|
cmd.Dir = townRoot
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt rig add with --prefix failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify routes.jsonl has the prefix
|
||||||
|
routesContent, err := os.ReadFile(filepath.Join(townRoot, ".beads", "routes.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(routesContent), `"prefix":"empty-prefix-"`) {
|
||||||
|
t.Errorf("routes.jsonl should contain empty-prefix-, got:\n%s", routesContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bd operations work with the configured prefix
|
||||||
|
rigPath := filepath.Join(townRoot, "emptyrig", "mayor", "rig")
|
||||||
|
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
|
||||||
|
"--type", "task", "--title", "test-from-empty-repo")
|
||||||
|
cmd.Dir = rigPath
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bd create failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
t.Fatalf("parse output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(result.ID, "empty-prefix-") {
|
||||||
|
t.Errorf("expected empty-prefix- prefix, got %s", result.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TrackedRepoWithPrefixMismatchErrors", func(t *testing.T) {
|
||||||
|
// Test that when --prefix is explicitly provided but doesn't match
|
||||||
|
// the prefix detected from existing issues, gt rig add fails with an error.
|
||||||
|
|
||||||
|
townRoot := filepath.Join(tmpDir, "town-mismatch")
|
||||||
|
reposDir := filepath.Join(tmpDir, "repos-mismatch")
|
||||||
|
os.MkdirAll(reposDir, 0755)
|
||||||
|
|
||||||
|
// Create a repo with existing beads prefix "real-prefix" with issues
|
||||||
|
mismatchRepo := filepath.Join(reposDir, "mismatch-repo")
|
||||||
|
createTrackedBeadsRepoWithIssues(t, mismatchRepo, "real-prefix", 2)
|
||||||
|
|
||||||
|
// Install town
|
||||||
|
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "mismatch-test")
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rig with WRONG --prefix - should fail
|
||||||
|
cmd = exec.Command(gtBinary, "rig", "add", "mismatchrig", mismatchRepo, "--prefix", "wrong-prefix")
|
||||||
|
cmd.Dir = townRoot
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Should fail
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("gt rig add should have failed with prefix mismatch, but succeeded.\nOutput: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error message mentions the mismatch
|
||||||
|
outputStr := string(output)
|
||||||
|
if !strings.Contains(outputStr, "prefix mismatch") {
|
||||||
|
t.Errorf("expected 'prefix mismatch' in error, got:\n%s", outputStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outputStr, "real-prefix") {
|
||||||
|
t.Errorf("expected 'real-prefix' (detected) in error, got:\n%s", outputStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outputStr, "wrong-prefix") {
|
||||||
|
t.Errorf("expected 'wrong-prefix' (provided) in error, got:\n%s", outputStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TrackedRepoWithNoIssuesFallsBackToDerivedPrefix", func(t *testing.T) {
|
||||||
|
// Test the fallback behavior: when a tracked beads repo has NO issues
|
||||||
|
// and NO --prefix is provided, gt rig add should derive prefix from rig name.
|
||||||
|
|
||||||
|
townRoot := filepath.Join(tmpDir, "town-derived")
|
||||||
|
reposDir := filepath.Join(tmpDir, "repos-derived")
|
||||||
|
os.MkdirAll(reposDir, 0755)
|
||||||
|
|
||||||
|
// Create a tracked beads repo with NO issues
|
||||||
|
derivedRepo := filepath.Join(reposDir, "derived-repo")
|
||||||
|
createTrackedBeadsRepoWithNoIssues(t, derivedRepo, "original-prefix")
|
||||||
|
|
||||||
|
// Install town
|
||||||
|
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "derived-test")
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rig WITHOUT --prefix - should derive from rig name "testrig"
|
||||||
|
// deriveBeadsPrefix("testrig") should produce some abbreviation
|
||||||
|
cmd = exec.Command(gtBinary, "rig", "add", "testrig", derivedRepo)
|
||||||
|
cmd.Dir = townRoot
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gt rig add (no --prefix) failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The output should mention "Using prefix" since detection failed
|
||||||
|
if !strings.Contains(string(output), "Using prefix") {
|
||||||
|
t.Logf("Output: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bd operations work - the key test is that beads.db was initialized
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig", "mayor", "rig")
|
||||||
|
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
|
||||||
|
"--type", "task", "--title", "test-derived-prefix")
|
||||||
|
cmd.Dir = rigPath
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bd create failed (beads.db not initialized?): %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
t.Fatalf("parse output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ID should have SOME prefix (derived from "testrig")
|
||||||
|
// We don't care exactly what it is, just that bd works
|
||||||
|
if result.ID == "" {
|
||||||
|
t.Error("expected non-empty issue ID")
|
||||||
|
}
|
||||||
|
t.Logf("Created issue with derived prefix: %s", result.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTrackedBeadsRepoWithNoIssues creates a git repo with .beads/ tracked but NO issues.
|
||||||
|
// This simulates a fresh bd init that was committed before any issues were created.
|
||||||
|
func createTrackedBeadsRepoWithNoIssues(t *testing.T, path, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize git repo with explicit main branch
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init", "--initial-branch=main"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test User"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial file and commit
|
||||||
|
readmePath := filepath.Join(path, "README.md")
|
||||||
|
if err := os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("write README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commitCmds := [][]string{
|
||||||
|
{"git", "add", "."},
|
||||||
|
{"git", "commit", "-m", "Initial commit"},
|
||||||
|
}
|
||||||
|
for _, args := range commitCmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize beads
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bd init (creates beads.db but no issues)
|
||||||
|
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", prefix)
|
||||||
|
cmd.Dir = path
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("bd init failed: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add .beads to git (simulating tracked beads)
|
||||||
|
cmd = exec.Command("git", "add", ".beads")
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git add .beads: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "commit", "-m", "Add beads (no issues)")
|
||||||
|
cmd.Dir = path
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git commit beads: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove beads.db to simulate what a clone would look like
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
if err := os.Remove(dbPath); err != nil {
|
||||||
|
t.Fatalf("remove beads.db: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
@@ -104,6 +104,58 @@ func setupRoutingTestTown(t *testing.T) string {
|
|||||||
return townRoot
|
return townRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initBeadsDBWithPrefix(t *testing.T, dir, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", "--no-daemon", "init", "--quiet", "--prefix", prefix)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("bd init failed in %s: %v\n%s", dir, err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty issues.jsonl to prevent bd auto-export from corrupting routes.jsonl.
|
||||||
|
// Without this, bd create writes issue data to routes.jsonl (the first .jsonl file
|
||||||
|
// it finds), corrupting the routing configuration. This mirrors what gt install does.
|
||||||
|
issuesPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
||||||
|
if err := os.WriteFile(issuesPath, []byte(""), 0644); err != nil {
|
||||||
|
t.Fatalf("create issues.jsonl in %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestIssue(t *testing.T, dir, title string) *beads.Issue {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
args := []string{"--no-daemon", "create", "--json", "--title", title, "--type", "task",
|
||||||
|
"--description", "Integration test issue"}
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
combinedCmd := exec.Command("bd", args...)
|
||||||
|
combinedCmd.Dir = dir
|
||||||
|
combinedOutput, _ := combinedCmd.CombinedOutput()
|
||||||
|
t.Fatalf("create issue in %s: %v\n%s", dir, err, combinedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue beads.Issue
|
||||||
|
if err := json.Unmarshal(output, &issue); err != nil {
|
||||||
|
t.Fatalf("parse create output in %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
if issue.ID == "" {
|
||||||
|
t.Fatalf("create issue in %s returned empty ID", dir)
|
||||||
|
}
|
||||||
|
return &issue
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIssueID(issues []*beads.Issue, id string) bool {
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// TestBeadsRoutingFromTownRoot verifies that bd show routes to correct rig
|
// TestBeadsRoutingFromTownRoot verifies that bd show routes to correct rig
|
||||||
// based on issue ID prefix when run from town root.
|
// based on issue ID prefix when run from town root.
|
||||||
func TestBeadsRoutingFromTownRoot(t *testing.T) {
|
func TestBeadsRoutingFromTownRoot(t *testing.T) {
|
||||||
@@ -114,37 +166,38 @@ func TestBeadsRoutingFromTownRoot(t *testing.T) {
|
|||||||
|
|
||||||
townRoot := setupRoutingTestTown(t)
|
townRoot := setupRoutingTestTown(t)
|
||||||
|
|
||||||
|
initBeadsDBWithPrefix(t, townRoot, "hq")
|
||||||
|
|
||||||
|
gastownRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
|
testrigRigPath := filepath.Join(townRoot, "testrig", "mayor", "rig")
|
||||||
|
initBeadsDBWithPrefix(t, gastownRigPath, "gt")
|
||||||
|
initBeadsDBWithPrefix(t, testrigRigPath, "tr")
|
||||||
|
|
||||||
|
townIssue := createTestIssue(t, townRoot, "Town-level routing test")
|
||||||
|
gastownIssue := createTestIssue(t, gastownRigPath, "Gastown routing test")
|
||||||
|
testrigIssue := createTestIssue(t, testrigRigPath, "Testrig routing test")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
prefix string
|
id string
|
||||||
expectedRig string // Expected rig path fragment in error/output
|
title string
|
||||||
}{
|
}{
|
||||||
{"hq-", "."}, // Town-level beads
|
{townIssue.ID, townIssue.Title},
|
||||||
{"gt-", "gastown"},
|
{gastownIssue.ID, gastownIssue.Title},
|
||||||
{"tr-", "testrig"},
|
{testrigIssue.ID, testrigIssue.Title},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
townBeads := beads.New(townRoot)
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.prefix, func(t *testing.T) {
|
t.Run(tc.id, func(t *testing.T) {
|
||||||
// Create a fake issue ID with the prefix
|
issue, err := townBeads.Show(tc.id)
|
||||||
issueID := tc.prefix + "test123"
|
if err != nil {
|
||||||
|
t.Fatalf("bd show %s failed: %v", tc.id, err)
|
||||||
// Run bd show - it will fail since issue doesn't exist,
|
}
|
||||||
// but we're testing routing, not the issue itself
|
if issue.ID != tc.id {
|
||||||
cmd := exec.Command("bd", "--no-daemon", "show", issueID)
|
t.Errorf("issue.ID = %s, want %s", issue.ID, tc.id)
|
||||||
cmd.Dir = townRoot
|
}
|
||||||
cmd.Env = append(os.Environ(), "BD_DEBUG_ROUTING=1")
|
if issue.Title != tc.title {
|
||||||
output, _ := cmd.CombinedOutput()
|
t.Errorf("issue.Title = %q, want %q", issue.Title, tc.title)
|
||||||
|
|
||||||
// The debug routing output or error message should indicate
|
|
||||||
// which beads directory was used
|
|
||||||
outputStr := string(output)
|
|
||||||
t.Logf("Output for %s: %s", issueID, outputStr)
|
|
||||||
|
|
||||||
// We expect either the routing debug output or an error from the correct beads
|
|
||||||
// If routing works, the error will be about not finding the issue,
|
|
||||||
// not about routing failure
|
|
||||||
if strings.Contains(outputStr, "no matching route") {
|
|
||||||
t.Errorf("routing failed for prefix %s: %s", tc.prefix, outputStr)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -263,30 +316,21 @@ func TestBeadsListFromPolecatDirectory(t *testing.T) {
|
|||||||
townRoot := setupRoutingTestTown(t)
|
townRoot := setupRoutingTestTown(t)
|
||||||
polecatDir := filepath.Join(townRoot, "gastown", "polecats", "rictus")
|
polecatDir := filepath.Join(townRoot, "gastown", "polecats", "rictus")
|
||||||
|
|
||||||
// Initialize beads in mayor/rig so bd list can work
|
rigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
mayorRigBeads := filepath.Join(townRoot, "gastown", "mayor", "rig", ".beads")
|
initBeadsDBWithPrefix(t, rigPath, "gt")
|
||||||
|
|
||||||
// Create a minimal beads.db (or use bd init)
|
issue := createTestIssue(t, rigPath, "Polecat list redirect test")
|
||||||
// For now, just test that the redirect is followed
|
|
||||||
cmd := exec.Command("bd", "--no-daemon", "list")
|
|
||||||
cmd.Dir = polecatDir
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
// We expect either success (empty list) or an error about missing db,
|
|
||||||
// but NOT an error about missing .beads directory (since redirect should work)
|
|
||||||
outputStr := string(output)
|
|
||||||
t.Logf("bd list output: %s", outputStr)
|
|
||||||
|
|
||||||
|
issues, err := beads.New(polecatDir).List(beads.ListOptions{
|
||||||
|
Status: "open",
|
||||||
|
Priority: -1,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check it's not a "no .beads directory" error
|
t.Fatalf("bd list from polecat dir failed: %v", err)
|
||||||
if strings.Contains(outputStr, "no .beads directory") {
|
}
|
||||||
t.Errorf("redirect not followed: %s", outputStr)
|
|
||||||
}
|
if !hasIssueID(issues, issue.ID) {
|
||||||
// Check it's finding the right beads directory via redirect
|
t.Errorf("bd list from polecat dir missing issue %s", issue.ID)
|
||||||
if strings.Contains(outputStr, "redirect") && !strings.Contains(outputStr, mayorRigBeads) {
|
|
||||||
// This is okay - the redirect is being processed
|
|
||||||
t.Logf("redirect detected in output (expected)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,18 +344,20 @@ func TestBeadsListFromCrewDirectory(t *testing.T) {
|
|||||||
townRoot := setupRoutingTestTown(t)
|
townRoot := setupRoutingTestTown(t)
|
||||||
crewDir := filepath.Join(townRoot, "gastown", "crew", "max")
|
crewDir := filepath.Join(townRoot, "gastown", "crew", "max")
|
||||||
|
|
||||||
cmd := exec.Command("bd", "--no-daemon", "list")
|
rigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
cmd.Dir = crewDir
|
initBeadsDBWithPrefix(t, rigPath, "gt")
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
outputStr := string(output)
|
issue := createTestIssue(t, rigPath, "Crew list redirect test")
|
||||||
t.Logf("bd list output from crew: %s", outputStr)
|
|
||||||
|
|
||||||
|
issues, err := beads.New(crewDir).List(beads.ListOptions{
|
||||||
|
Status: "open",
|
||||||
|
Priority: -1,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check it's not a "no .beads directory" error
|
t.Fatalf("bd list from crew dir failed: %v", err)
|
||||||
if strings.Contains(outputStr, "no .beads directory") {
|
}
|
||||||
t.Errorf("redirect not followed for crew: %s", outputStr)
|
if !hasIssueID(issues, issue.ID) {
|
||||||
}
|
t.Errorf("bd list from crew dir missing issue %s", issue.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MinBeadsVersion is the minimum required beads version for Gas Town.
|
// MinBeadsVersion is the minimum required beads version for Gas Town.
|
||||||
@@ -84,10 +87,19 @@ func (v beadsVersion) compare(other beadsVersion) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-compiled regex for beads version parsing
|
||||||
|
var beadsVersionRe = regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
||||||
|
|
||||||
func getBeadsVersion() (string, error) {
|
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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return "", fmt.Errorf("bd version check timed out")
|
||||||
|
}
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
|
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
|
||||||
}
|
}
|
||||||
@@ -96,8 +108,7 @@ func getBeadsVersion() (string, error) {
|
|||||||
|
|
||||||
// Parse output like "bd version 0.44.0 (dev)"
|
// Parse output like "bd version 0.44.0 (dev)"
|
||||||
// or "bd version 0.44.0"
|
// or "bd version 0.44.0"
|
||||||
re := regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
matches := beadsVersionRe.FindStringSubmatch(string(output))
|
||||||
matches := re.FindStringSubmatch(string(output))
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
|
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
|
return matches[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cachedVersionCheckResult error
|
||||||
|
versionCheckOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
|
// 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.
|
// 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 {
|
func CheckBeadsVersion() error {
|
||||||
|
versionCheckOnce.Do(func() {
|
||||||
|
cachedVersionCheckResult = checkBeadsVersionInternal()
|
||||||
|
})
|
||||||
|
return cachedVersionCheckResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBeadsVersionInternal() error {
|
||||||
installedStr, err := getBeadsVersion()
|
installedStr, err := getBeadsVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot verify beads version: %w", err)
|
return fmt.Errorf("cannot verify beads version: %w", err)
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
bootStatusJSON bool
|
bootStatusJSON bool
|
||||||
bootDegraded bool
|
bootDegraded bool
|
||||||
|
bootAgentOverride string
|
||||||
)
|
)
|
||||||
|
|
||||||
var bootCmd = &cobra.Command{
|
var bootCmd = &cobra.Command{
|
||||||
@@ -84,6 +85,7 @@ Use --degraded flag when running in degraded mode.`,
|
|||||||
func init() {
|
func init() {
|
||||||
bootStatusCmd.Flags().BoolVar(&bootStatusJSON, "json", false, "Output as JSON")
|
bootStatusCmd.Flags().BoolVar(&bootStatusJSON, "json", false, "Output as JSON")
|
||||||
bootTriageCmd.Flags().BoolVar(&bootDegraded, "degraded", false, "Run in degraded mode (no tmux)")
|
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(bootStatusCmd)
|
||||||
bootCmd.AddCommand(bootSpawnCmd)
|
bootCmd.AddCommand(bootSpawnCmd)
|
||||||
@@ -206,7 +208,7 @@ func runBootSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn Boot
|
// Spawn Boot
|
||||||
if err := b.Spawn(); err != nil {
|
if err := b.Spawn(bootAgentOverride); err != nil {
|
||||||
status.Error = err.Error()
|
status.Error = err.Error()
|
||||||
status.CompletedAt = time.Now()
|
status.CompletedAt = time.Now()
|
||||||
status.Running = false
|
status.Running = false
|
||||||
|
|||||||
19
internal/cmd/boot_test.go
Normal file
19
internal/cmd/boot_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootSpawnAgentFlag(t *testing.T) {
|
||||||
|
flag := bootSpawnCmd.Flags().Lookup("agent")
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatal("expected boot spawn to define --agent flag")
|
||||||
|
}
|
||||||
|
if flag.DefValue != "" {
|
||||||
|
t.Errorf("expected default agent override to be empty, got %q", flag.DefValue)
|
||||||
|
}
|
||||||
|
if !strings.Contains(flag.Usage, "overrides town default") {
|
||||||
|
t.Errorf("expected --agent usage to mention overrides town default, got %q", flag.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
internal/cmd/cat.go
Normal file
66
internal/cmd/cat.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var catJSON bool
|
||||||
|
|
||||||
|
var catCmd = &cobra.Command{
|
||||||
|
Use: "cat <bead-id>",
|
||||||
|
GroupID: GroupWork,
|
||||||
|
Short: "Display bead content",
|
||||||
|
Long: `Display the content of a bead (issue, task, molecule, etc.).
|
||||||
|
|
||||||
|
This is a convenience wrapper around 'bd show' that integrates with gt.
|
||||||
|
Accepts any bead ID (bd-*, hq-*, mol-*).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt cat bd-abc123 # Show a bead
|
||||||
|
gt cat hq-xyz789 # Show a town-level bead
|
||||||
|
gt cat bd-abc --json # Output as JSON`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runCat,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(catCmd)
|
||||||
|
catCmd.Flags().BoolVar(&catJSON, "json", false, "Output as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCat(cmd *cobra.Command, args []string) error {
|
||||||
|
beadID := args[0]
|
||||||
|
|
||||||
|
// Validate it looks like a bead ID
|
||||||
|
if !isBeadID(beadID) {
|
||||||
|
return fmt.Errorf("invalid bead ID %q (expected bd-*, hq-*, or mol-* prefix)", beadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build bd show command
|
||||||
|
bdArgs := []string{"show", beadID}
|
||||||
|
if catJSON {
|
||||||
|
bdArgs = append(bdArgs, "--json")
|
||||||
|
}
|
||||||
|
|
||||||
|
bdCmd := exec.Command("bd", bdArgs...)
|
||||||
|
bdCmd.Stdout = os.Stdout
|
||||||
|
bdCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
return bdCmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBeadID checks if a string looks like a bead ID.
|
||||||
|
func isBeadID(s string) bool {
|
||||||
|
prefixes := []string{"bd-", "hq-", "mol-"}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(s, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
40
internal/cmd/close.go
Normal file
40
internal/cmd/close.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var closeCmd = &cobra.Command{
|
||||||
|
Use: "close [bead-id...]",
|
||||||
|
GroupID: GroupWork,
|
||||||
|
Short: "Close one or more beads",
|
||||||
|
Long: `Close one or more beads (wrapper for 'bd close').
|
||||||
|
|
||||||
|
This is a convenience command that passes through to 'bd close' with
|
||||||
|
all arguments and flags preserved.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt close gt-abc # Close bead gt-abc
|
||||||
|
gt close gt-abc gt-def # Close multiple beads
|
||||||
|
gt close --reason "Done" # Close with reason
|
||||||
|
gt close --force # Force close pinned beads`,
|
||||||
|
DisableFlagParsing: true, // Pass all flags through to bd close
|
||||||
|
RunE: runClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(closeCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClose(cmd *cobra.Command, args []string) error {
|
||||||
|
// Build bd close command with all args passed through
|
||||||
|
bdArgs := append([]string{"close"}, args...)
|
||||||
|
bdCmd := exec.Command("bd", bdArgs...)
|
||||||
|
bdCmd.Stdin = os.Stdin
|
||||||
|
bdCmd.Stdout = os.Stdout
|
||||||
|
bdCmd.Stderr = os.Stderr
|
||||||
|
return bdCmd.Run()
|
||||||
|
}
|
||||||
118
internal/cmd/commit.go
Normal file
118
internal/cmd/commit.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAgentEmailDomain is the default domain for agent git emails.
|
||||||
|
const DefaultAgentEmailDomain = "gastown.local"
|
||||||
|
|
||||||
|
var commitCmd = &cobra.Command{
|
||||||
|
Use: "commit [flags] [-- git-commit-args...]",
|
||||||
|
Short: "Git commit with automatic agent identity",
|
||||||
|
Long: `Git commit wrapper that automatically sets git author identity for agents.
|
||||||
|
|
||||||
|
When run by an agent (GT_ROLE set), this command:
|
||||||
|
1. Detects the agent identity from environment variables
|
||||||
|
2. Converts it to a git-friendly name and email
|
||||||
|
3. Runs 'git commit' with the correct identity
|
||||||
|
|
||||||
|
The email domain is configurable in town settings (agent_email_domain).
|
||||||
|
Default: gastown.local
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt commit -m "Fix bug" # Commit as current agent
|
||||||
|
gt commit -am "Quick fix" # Stage all and commit
|
||||||
|
gt commit -- --amend # Amend last commit
|
||||||
|
|
||||||
|
Identity mapping:
|
||||||
|
Agent: gastown/crew/jack → Name: gastown/crew/jack
|
||||||
|
Email: gastown.crew.jack@gastown.local
|
||||||
|
|
||||||
|
When run without GT_ROLE (human), passes through to git commit with no changes.`,
|
||||||
|
RunE: runCommit,
|
||||||
|
DisableFlagParsing: true, // We'll parse flags ourselves to pass them to git
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commitCmd.GroupID = GroupWork
|
||||||
|
rootCmd.AddCommand(commitCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommit(cmd *cobra.Command, args []string) error {
|
||||||
|
// Detect agent identity
|
||||||
|
identity := detectSender()
|
||||||
|
|
||||||
|
// If overseer (human), just pass through to git commit
|
||||||
|
if identity == "overseer" {
|
||||||
|
return runGitCommit(args, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load agent email domain from town settings
|
||||||
|
domain := DefaultAgentEmailDomain
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err == nil && townRoot != "" {
|
||||||
|
settings, err := config.LoadOrCreateTownSettings(config.TownSettingsPath(townRoot))
|
||||||
|
if err == nil && settings.AgentEmailDomain != "" {
|
||||||
|
domain = settings.AgentEmailDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert identity to git-friendly email
|
||||||
|
// "gastown/crew/jack" → "gastown.crew.jack@domain"
|
||||||
|
email := identityToEmail(identity, domain)
|
||||||
|
|
||||||
|
// Use identity as the author name (human-readable)
|
||||||
|
name := identity
|
||||||
|
|
||||||
|
return runGitCommit(args, name, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityToEmail converts a Gas Town identity to a git email address.
|
||||||
|
// "gastown/crew/jack" → "gastown.crew.jack@domain"
|
||||||
|
// "mayor/" → "mayor@domain"
|
||||||
|
func identityToEmail(identity, domain string) string {
|
||||||
|
// Remove trailing slash if present
|
||||||
|
identity = strings.TrimSuffix(identity, "/")
|
||||||
|
|
||||||
|
// Replace slashes with dots for email local part
|
||||||
|
localPart := strings.ReplaceAll(identity, "/", ".")
|
||||||
|
|
||||||
|
return localPart + "@" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGitCommit executes git commit with optional identity override.
|
||||||
|
// If name and email are empty, runs git commit with no overrides.
|
||||||
|
// Preserves git's exit code for proper wrapper behavior.
|
||||||
|
func runGitCommit(args []string, name, email string) error {
|
||||||
|
var gitArgs []string
|
||||||
|
|
||||||
|
// If we have an identity, prepend -c flags
|
||||||
|
if name != "" && email != "" {
|
||||||
|
gitArgs = append(gitArgs, "-c", "user.name="+name)
|
||||||
|
gitArgs = append(gitArgs, "-c", "user.email="+email)
|
||||||
|
}
|
||||||
|
|
||||||
|
gitArgs = append(gitArgs, "commit")
|
||||||
|
gitArgs = append(gitArgs, args...)
|
||||||
|
|
||||||
|
gitCmd := exec.Command("git", gitArgs...)
|
||||||
|
gitCmd.Stdin = os.Stdin
|
||||||
|
gitCmd.Stdout = os.Stdout
|
||||||
|
gitCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := gitCmd.Run(); err != nil {
|
||||||
|
// Preserve git's exit code for proper wrapper behavior
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
os.Exit(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
71
internal/cmd/commit_test.go
Normal file
71
internal/cmd/commit_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIdentityToEmail(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
identity string
|
||||||
|
domain string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "crew member",
|
||||||
|
identity: "gastown/crew/jack",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "gastown.crew.jack@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "polecat",
|
||||||
|
identity: "gastown/polecats/max",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "gastown.polecats.max@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "witness",
|
||||||
|
identity: "gastown/witness",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "gastown.witness@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "refinery",
|
||||||
|
identity: "gastown/refinery",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "gastown.refinery@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mayor with trailing slash",
|
||||||
|
identity: "mayor/",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "mayor@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deacon with trailing slash",
|
||||||
|
identity: "deacon/",
|
||||||
|
domain: "gastown.local",
|
||||||
|
want: "deacon@gastown.local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom domain",
|
||||||
|
identity: "myrig/crew/alice",
|
||||||
|
domain: "example.com",
|
||||||
|
want: "myrig.crew.alice@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested",
|
||||||
|
identity: "rig/polecats/nested/deep",
|
||||||
|
domain: "test.io",
|
||||||
|
want: "rig.polecats.nested.deep@test.io",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := identityToEmail(tt.identity, tt.domain)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("identityToEmail(%q, %q) = %q, want %q",
|
||||||
|
tt.identity, tt.domain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,27 @@ Examples:
|
|||||||
RunE: runConfigDefaultAgent,
|
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
|
// Flags
|
||||||
var (
|
var (
|
||||||
configAgentListJSON bool
|
configAgentListJSON bool
|
||||||
@@ -444,6 +465,54 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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() {
|
func init() {
|
||||||
// Add flags
|
// Add flags
|
||||||
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
|
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
|
||||||
@@ -462,6 +531,7 @@ func init() {
|
|||||||
// Add subcommands to config
|
// Add subcommands to config
|
||||||
configCmd.AddCommand(configAgentCmd)
|
configCmd.AddCommand(configAgentCmd)
|
||||||
configCmd.AddCommand(configDefaultAgentCmd)
|
configCmd.AddCommand(configDefaultAgentCmd)
|
||||||
|
configCmd.AddCommand(configAgentEmailDomainCmd)
|
||||||
|
|
||||||
// Register with root
|
// Register with root
|
||||||
rootCmd.AddCommand(configCmd)
|
rootCmd.AddCommand(configCmd)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tui/convoy"
|
"github.com/steveyegge/gastown/internal/tui/convoy"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -62,6 +63,7 @@ func looksLikeIssueID(s string) bool {
|
|||||||
var (
|
var (
|
||||||
convoyMolecule string
|
convoyMolecule string
|
||||||
convoyNotify string
|
convoyNotify string
|
||||||
|
convoyOwner string
|
||||||
convoyStatusJSON bool
|
convoyStatusJSON bool
|
||||||
convoyListJSON bool
|
convoyListJSON bool
|
||||||
convoyListStatus string
|
convoyListStatus string
|
||||||
@@ -69,6 +71,8 @@ var (
|
|||||||
convoyListTree bool
|
convoyListTree bool
|
||||||
convoyInteractive bool
|
convoyInteractive bool
|
||||||
convoyStrandedJSON bool
|
convoyStrandedJSON bool
|
||||||
|
convoyCloseReason string
|
||||||
|
convoyCloseNotify string
|
||||||
)
|
)
|
||||||
|
|
||||||
var convoyCmd = &cobra.Command{
|
var convoyCmd = &cobra.Command{
|
||||||
@@ -106,6 +110,7 @@ TRACKING SEMANTICS:
|
|||||||
COMMANDS:
|
COMMANDS:
|
||||||
create Create a convoy tracking specified issues
|
create Create a convoy tracking specified issues
|
||||||
add Add issues to an existing convoy (reopens if closed)
|
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
|
status Show convoy progress, tracked issues, and active workers
|
||||||
list List convoys (the dashboard view)`,
|
list List convoys (the dashboard view)`,
|
||||||
}
|
}
|
||||||
@@ -118,10 +123,15 @@ var convoyCreateCmd = &cobra.Command{
|
|||||||
The convoy is created in town-level beads (hq-* prefix) and can track
|
The convoy is created in town-level beads (hq-* prefix) and can track
|
||||||
issues across any rig.
|
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:
|
Examples:
|
||||||
gt convoy create "Deploy v2.0" gt-abc bd-xyz
|
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 # defaults to mayor/
|
||||||
gt convoy create "Release prep" gt-abc --notify ops/ # notify ops/
|
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`,
|
gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`,
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: runConvoyCreate,
|
RunE: runConvoyCreate,
|
||||||
@@ -199,10 +209,31 @@ Examples:
|
|||||||
RunE: runConvoyStranded,
|
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() {
|
func init() {
|
||||||
// Create flags
|
// Create flags
|
||||||
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
|
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/"
|
convoyCreateCmd.Flags().Lookup("notify").NoOptDefVal = "mayor/"
|
||||||
|
|
||||||
// Status flags
|
// Status flags
|
||||||
@@ -220,6 +251,10 @@ func init() {
|
|||||||
// Stranded flags
|
// Stranded flags
|
||||||
convoyStrandedCmd.Flags().BoolVar(&convoyStrandedJSON, "json", false, "Output as JSON")
|
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
|
// Add subcommands
|
||||||
convoyCmd.AddCommand(convoyCreateCmd)
|
convoyCmd.AddCommand(convoyCreateCmd)
|
||||||
convoyCmd.AddCommand(convoyStatusCmd)
|
convoyCmd.AddCommand(convoyStatusCmd)
|
||||||
@@ -227,6 +262,7 @@ func init() {
|
|||||||
convoyCmd.AddCommand(convoyAddCmd)
|
convoyCmd.AddCommand(convoyAddCmd)
|
||||||
convoyCmd.AddCommand(convoyCheckCmd)
|
convoyCmd.AddCommand(convoyCheckCmd)
|
||||||
convoyCmd.AddCommand(convoyStrandedCmd)
|
convoyCmd.AddCommand(convoyStrandedCmd)
|
||||||
|
convoyCmd.AddCommand(convoyCloseCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(convoyCmd)
|
rootCmd.AddCommand(convoyCmd)
|
||||||
}
|
}
|
||||||
@@ -263,6 +299,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Create convoy issue in town beads
|
// Create convoy issue in town beads
|
||||||
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
|
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
|
||||||
|
if convoyOwner != "" {
|
||||||
|
description += fmt.Sprintf("\nOwner: %s", convoyOwner)
|
||||||
|
}
|
||||||
if convoyNotify != "" {
|
if convoyNotify != "" {
|
||||||
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
|
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
|
||||||
}
|
}
|
||||||
@@ -281,6 +320,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
|||||||
"--description=" + description,
|
"--description=" + description,
|
||||||
"--json",
|
"--json",
|
||||||
}
|
}
|
||||||
|
if beads.NeedsForceForID(convoyID) {
|
||||||
|
createArgs = append(createArgs, "--force")
|
||||||
|
}
|
||||||
|
|
||||||
createCmd := exec.Command("bd", createArgs...)
|
createCmd := exec.Command("bd", createArgs...)
|
||||||
createCmd.Dir = townBeads
|
createCmd.Dir = townBeads
|
||||||
@@ -302,9 +344,15 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
|||||||
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
||||||
depCmd := exec.Command("bd", depArgs...)
|
depCmd := exec.Command("bd", depArgs...)
|
||||||
depCmd.Dir = townBeads
|
depCmd.Dir = townBeads
|
||||||
|
var depStderr bytes.Buffer
|
||||||
|
depCmd.Stderr = &depStderr
|
||||||
|
|
||||||
if err := depCmd.Run(); err != nil {
|
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 {
|
} else {
|
||||||
trackedCount++
|
trackedCount++
|
||||||
}
|
}
|
||||||
@@ -317,6 +365,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
|
|||||||
if len(trackedIssues) > 0 {
|
if len(trackedIssues) > 0 {
|
||||||
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
|
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
|
||||||
}
|
}
|
||||||
|
if convoyOwner != "" {
|
||||||
|
fmt.Printf(" Owner: %s\n", convoyOwner)
|
||||||
|
}
|
||||||
if convoyNotify != "" {
|
if convoyNotify != "" {
|
||||||
fmt.Printf(" Notify: %s\n", convoyNotify)
|
fmt.Printf(" Notify: %s\n", convoyNotify)
|
||||||
}
|
}
|
||||||
@@ -389,9 +440,15 @@ func runConvoyAdd(cmd *cobra.Command, args []string) error {
|
|||||||
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
|
||||||
depCmd := exec.Command("bd", depArgs...)
|
depCmd := exec.Command("bd", depArgs...)
|
||||||
depCmd.Dir = townBeads
|
depCmd.Dir = townBeads
|
||||||
|
var depStderr bytes.Buffer
|
||||||
|
depCmd.Stderr = &depStderr
|
||||||
|
|
||||||
if err := depCmd.Run(); err != nil {
|
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 {
|
} else {
|
||||||
addedCount++
|
addedCount++
|
||||||
}
|
}
|
||||||
@@ -432,6 +489,98 @@ func runConvoyCheck(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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.
|
// strandedConvoyInfo holds info about a stranded convoy.
|
||||||
type strandedConvoyInfo struct {
|
type strandedConvoyInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -666,9 +815,9 @@ func checkAndCloseCompletedConvoys(townBeads string) ([]struct{ ID, Title string
|
|||||||
return closed, nil
|
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) {
|
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"}
|
showArgs := []string{"show", convoyID, "--json"}
|
||||||
showCmd := exec.Command("bd", showArgs...)
|
showCmd := exec.Command("bd", showArgs...)
|
||||||
showCmd.Dir = townBeads
|
showCmd.Dir = townBeads
|
||||||
@@ -686,20 +835,26 @@ func notifyConvoyCompletion(townBeads, convoyID, title string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse notify address from description
|
// Parse owner and notify addresses from description
|
||||||
desc := convoys[0].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") {
|
for _, line := range strings.Split(desc, "\n") {
|
||||||
if strings.HasPrefix(line, "Notify: ") {
|
var addr string
|
||||||
addr := strings.TrimPrefix(line, "Notify: ")
|
if strings.HasPrefix(line, "Owner: ") {
|
||||||
if addr != "" {
|
addr = strings.TrimPrefix(line, "Owner: ")
|
||||||
// Send notification via gt mail
|
} else if strings.HasPrefix(line, "Notify: ") {
|
||||||
mailArgs := []string{"mail", "send", addr,
|
addr = strings.TrimPrefix(line, "Notify: ")
|
||||||
"-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...)
|
if addr != "" && !notified[addr] {
|
||||||
_ = mailCmd.Run() // Best effort, ignore errors
|
// Send notification via gt mail
|
||||||
}
|
mailArgs := []string{"mail", "send", addr,
|
||||||
break
|
"-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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1186,6 +1341,10 @@ func getIssueDetails(issueID string) *issueDetails {
|
|||||||
if err := showCmd.Run(); err != nil {
|
if err := showCmd.Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Handle bd --no-daemon exit 0 bug: empty stdout means not found
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var issues []struct {
|
var issues []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -275,10 +278,7 @@ func runCostsFromLedger() error {
|
|||||||
} else {
|
} else {
|
||||||
// No time filter: query both digests and legacy session.ended events
|
// No time filter: query both digests and legacy session.ended events
|
||||||
// (for backwards compatibility during migration)
|
// (for backwards compatibility during migration)
|
||||||
entries, err = querySessionEvents()
|
entries = querySessionEvents()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("querying session events: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
@@ -353,7 +353,62 @@ type EventListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// querySessionEvents queries beads for session.ended events and converts them to CostEntry.
|
// querySessionEvents queries beads for session.ended events and converts them to CostEntry.
|
||||||
func querySessionEvents() ([]CostEntry, error) {
|
// It queries both town-level beads and all rig-level beads to find all session events.
|
||||||
|
// Errors from individual locations are logged (if verbose) but don't fail the query.
|
||||||
|
func querySessionEvents() []CostEntry {
|
||||||
|
// Discover town root for cwd-based bd discovery
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
// Not in a Gas Town workspace - return empty list
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all beads locations to query
|
||||||
|
beadsLocations := []string{townRoot}
|
||||||
|
|
||||||
|
// Load rigs to find all rig beads locations
|
||||||
|
rigsConfigPath := filepath.Join(townRoot, constants.DirMayor, constants.FileRigsJSON)
|
||||||
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||||
|
if err == nil && rigsConfig != nil {
|
||||||
|
for rigName := range rigsConfig.Rigs {
|
||||||
|
rigPath := filepath.Join(townRoot, rigName)
|
||||||
|
// Verify rig has a beads database
|
||||||
|
rigBeadsPath := filepath.Join(rigPath, constants.DirBeads)
|
||||||
|
if _, statErr := os.Stat(rigBeadsPath); statErr == nil {
|
||||||
|
beadsLocations = append(beadsLocations, rigPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query each beads location and merge results
|
||||||
|
var allEntries []CostEntry
|
||||||
|
seenIDs := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, location := range beadsLocations {
|
||||||
|
entries, err := querySessionEventsFromLocation(location)
|
||||||
|
if err != nil {
|
||||||
|
// Log but continue with other locations
|
||||||
|
if costsVerbose {
|
||||||
|
fmt.Fprintf(os.Stderr, "[costs] query from %s failed: %v\n", location, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by event ID (use SessionID as key)
|
||||||
|
for _, entry := range entries {
|
||||||
|
key := entry.SessionID + entry.EndedAt.String()
|
||||||
|
if !seenIDs[key] {
|
||||||
|
seenIDs[key] = true
|
||||||
|
allEntries = append(allEntries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// querySessionEventsFromLocation queries a single beads location for session.ended events.
|
||||||
|
func querySessionEventsFromLocation(location string) ([]CostEntry, error) {
|
||||||
// Step 1: Get list of event IDs
|
// Step 1: Get list of event IDs
|
||||||
listArgs := []string{
|
listArgs := []string{
|
||||||
"list",
|
"list",
|
||||||
@@ -364,6 +419,7 @@ func querySessionEvents() ([]CostEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listCmd := exec.Command("bd", listArgs...)
|
listCmd := exec.Command("bd", listArgs...)
|
||||||
|
listCmd.Dir = location
|
||||||
listOutput, err := listCmd.Output()
|
listOutput, err := listCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If bd fails (e.g., no beads database), return empty list
|
// If bd fails (e.g., no beads database), return empty list
|
||||||
@@ -387,6 +443,7 @@ func querySessionEvents() ([]CostEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showCmd := exec.Command("bd", showArgs...)
|
showCmd := exec.Command("bd", showArgs...)
|
||||||
|
showCmd.Dir = location
|
||||||
showOutput, err := showCmd.Output()
|
showOutput, err := showCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("showing events: %w", err)
|
return nil, fmt.Errorf("showing events: %w", err)
|
||||||
@@ -749,8 +806,20 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
// event fields (event_kind, actor, payload) to not be stored properly.
|
// event fields (event_kind, actor, payload) to not be stored properly.
|
||||||
// The bd command will auto-detect the correct rig from cwd.
|
// 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 := exec.Command("bd", bdArgs...)
|
||||||
|
bdCmd.Dir = townRoot
|
||||||
output, err := bdCmd.CombinedOutput()
|
output, err := bdCmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
||||||
@@ -762,6 +831,7 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
// These are informational records that don't need to stay open.
|
// These are informational records that don't need to stay open.
|
||||||
// The wisp data is preserved and queryable until digested.
|
// The wisp data is preserved and queryable until digested.
|
||||||
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
||||||
|
closeCmd.Dir = townRoot
|
||||||
if closeErr := closeCmd.Run(); closeErr != nil {
|
if closeErr := closeCmd.Run(); closeErr != nil {
|
||||||
// Non-fatal: wisp was created, just couldn't auto-close
|
// 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)
|
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
|
||||||
|
|||||||
255
internal/cmd/costs_workdir_test.go
Normal file
255
internal/cmd/costs_workdir_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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.
|
||||||
|
//
|
||||||
|
// Bug: Events created by rig-level agents (polecats, witness, etc.) are stored in
|
||||||
|
// the rig's .beads database. Events created by town-level agents (mayor, deacon)
|
||||||
|
// are stored in the town's .beads database. querySessionEvents must query ALL
|
||||||
|
// beads locations to find all events.
|
||||||
|
//
|
||||||
|
// This test:
|
||||||
|
// 1. Creates a town with a rig
|
||||||
|
// 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 if gt and bd are not installed
|
||||||
|
if _, err := exec.LookPath("gt"); err != nil {
|
||||||
|
t.Skip("gt not installed, skipping integration test")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("bd"); err != nil {
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Create town directory
|
||||||
|
if err := os.MkdirAll(townRoot, 0755); err != nil {
|
||||||
|
t.Fatalf("creating town directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a git repo (required for gt install)
|
||||||
|
gitInitCmd := exec.Command("git", "init")
|
||||||
|
gitInitCmd.Dir = townRoot
|
||||||
|
if out, err := gitInitCmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git init: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a bare repo to use as the rig source
|
||||||
|
bareRepo := filepath.Join(tmpDir, "bare-repo.git")
|
||||||
|
bareInitCmd := exec.Command("git", "init", "--bare", bareRepo)
|
||||||
|
if out, err := bareInitCmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git init --bare: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary clone to add initial content (bare repos need content)
|
||||||
|
tempClone := filepath.Join(tmpDir, "temp-clone")
|
||||||
|
cloneCmd := exec.Command("git", "clone", bareRepo, tempClone)
|
||||||
|
if out, err := cloneCmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git clone bare: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add initial commit to bare repo
|
||||||
|
initFileCmd := exec.Command("bash", "-c", "echo 'test' > README.md && git add . && git commit -m 'init'")
|
||||||
|
initFileCmd.Dir = tempClone
|
||||||
|
if out, err := initFileCmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("initial commit: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
pushCmd := exec.Command("git", "push", "origin", "main")
|
||||||
|
pushCmd.Dir = tempClone
|
||||||
|
// Try main first, fall back to master
|
||||||
|
if _, err := pushCmd.CombinedOutput(); err != nil {
|
||||||
|
pushCmd2 := exec.Command("git", "push", "origin", "master")
|
||||||
|
pushCmd2.Dir = tempClone
|
||||||
|
if out, err := pushCmd2.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git push: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the rig path
|
||||||
|
rigPath := filepath.Join(townRoot, "testrig")
|
||||||
|
|
||||||
|
// Verify rig has its own .beads
|
||||||
|
rigBeadsPath := filepath.Join(rigPath, ".beads")
|
||||||
|
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||||
|
t.Fatalf("rig .beads not created at %s", rigBeadsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session.ended event in TOWN beads (simulating mayor/deacon)
|
||||||
|
townEventPayload := `{"cost_usd":1.50,"session_id":"hq-mayor","role":"mayor","ended_at":"2026-01-12T10:00:00Z"}`
|
||||||
|
townEventCmd := exec.Command("bd", "create",
|
||||||
|
"--type=event",
|
||||||
|
"--title=Town session ended",
|
||||||
|
"--event-category=session.ended",
|
||||||
|
"--event-payload="+townEventPayload,
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
t.Logf("Created town event: %s", string(townOut))
|
||||||
|
|
||||||
|
// Create a session.ended event in RIG beads (simulating polecat)
|
||||||
|
rigEventPayload := `{"cost_usd":2.50,"session_id":"gt-testrig-toast","role":"polecat","rig":"testrig","worker":"toast","ended_at":"2026-01-12T11:00:00Z"}`
|
||||||
|
rigEventCmd := exec.Command("bd", "create",
|
||||||
|
"--type=event",
|
||||||
|
"--title=Rig session ended",
|
||||||
|
"--event-category=session.ended",
|
||||||
|
"--event-payload="+rigEventPayload,
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
t.Logf("Created rig event: %s", string(rigOut))
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var townEvents, rigEvents []struct{ ID string }
|
||||||
|
json.Unmarshal(townListOut, &townEvents)
|
||||||
|
json.Unmarshal(rigListOut, &rigEvents)
|
||||||
|
|
||||||
|
t.Logf("Town beads has %d events", len(townEvents))
|
||||||
|
t.Logf("Rig beads has %d events", len(rigEvents))
|
||||||
|
|
||||||
|
// Both should have events (they're in separate DBs)
|
||||||
|
if len(townEvents) == 0 {
|
||||||
|
t.Error("Expected town beads to have events")
|
||||||
|
}
|
||||||
|
if len(rigEvents) == 0 {
|
||||||
|
t.Error("Expected rig beads to have events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current directory and change to town root for query
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getting current directory: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Chdir(origDir); err != nil {
|
||||||
|
t.Errorf("restoring directory: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := os.Chdir(townRoot); err != nil {
|
||||||
|
t.Fatalf("changing to town root: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify workspace discovery works
|
||||||
|
foundTownRoot, wsErr := workspace.FindFromCwdOrError()
|
||||||
|
if wsErr != nil {
|
||||||
|
t.Fatalf("workspace.FindFromCwdOrError failed: %v", wsErr)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call querySessionEvents - this should find events from ALL locations
|
||||||
|
entries := querySessionEvents()
|
||||||
|
|
||||||
|
t.Logf("querySessionEvents returned %d entries", len(entries))
|
||||||
|
|
||||||
|
// We created 2 session.ended events (one town, one rig)
|
||||||
|
// The fix should find BOTH
|
||||||
|
if len(entries) < 2 {
|
||||||
|
t.Errorf("querySessionEvents found %d entries, expected at least 2 (one from town, one from rig)", len(entries))
|
||||||
|
t.Log("This indicates the bug: querySessionEvents only queries town-level beads, missing rig-level events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we found both the mayor and polecat sessions
|
||||||
|
var foundMayor, foundPolecat bool
|
||||||
|
for _, e := range entries {
|
||||||
|
t.Logf(" Entry: session=%s role=%s cost=$%.2f", e.SessionID, e.Role, e.CostUSD)
|
||||||
|
if e.Role == "mayor" {
|
||||||
|
foundMayor = true
|
||||||
|
}
|
||||||
|
if e.Role == "polecat" {
|
||||||
|
foundPolecat = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundMayor {
|
||||||
|
t.Error("Missing mayor session from town beads")
|
||||||
|
}
|
||||||
|
if !foundPolecat {
|
||||||
|
t.Error("Missing polecat session from rig beads")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ var (
|
|||||||
crewAll bool
|
crewAll bool
|
||||||
crewListAll bool
|
crewListAll bool
|
||||||
crewDryRun bool
|
crewDryRun bool
|
||||||
|
crewDebug bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var crewCmd = &cobra.Command{
|
var crewCmd = &cobra.Command{
|
||||||
@@ -333,6 +334,7 @@ func init() {
|
|||||||
crewAtCmd.Flags().BoolVarP(&crewDetached, "detached", "d", false, "Start session without attaching")
|
crewAtCmd.Flags().BoolVarP(&crewDetached, "detached", "d", false, "Start session without attaching")
|
||||||
crewAtCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use (overrides default)")
|
crewAtCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use (overrides default)")
|
||||||
crewAtCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)")
|
crewAtCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)")
|
||||||
|
crewAtCmd.Flags().BoolVar(&crewDebug, "debug", false, "Show debug output for troubleshooting")
|
||||||
|
|
||||||
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||||
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/runtime"
|
"github.com/steveyegge/gastown/internal/runtime"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -16,6 +18,13 @@ import (
|
|||||||
func runCrewAt(cmd *cobra.Command, args []string) error {
|
func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||||
var name string
|
var name string
|
||||||
|
|
||||||
|
// Debug mode: --debug flag or GT_DEBUG env var
|
||||||
|
debug := crewDebug || os.Getenv("GT_DEBUG") != ""
|
||||||
|
if debug {
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
fmt.Printf("[DEBUG] runCrewAt: args=%v, crewRig=%q, cwd=%q\n", args, crewRig, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine crew name: from arg, or auto-detect from cwd
|
// Determine crew name: from arg, or auto-detect from cwd
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
name = args[0]
|
name = args[0]
|
||||||
@@ -51,6 +60,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name)
|
fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[DEBUG] after detection: name=%q, crewRig=%q\n", name, crewRig)
|
||||||
|
}
|
||||||
|
|
||||||
crewMgr, r, err := getCrewManager(crewRig)
|
crewMgr, r, err := getCrewManager(crewRig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -89,15 +102,24 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
runtimeConfig := config.LoadRuntimeConfig(r.Path)
|
||||||
_ = runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig)
|
if err := runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig); err != nil {
|
||||||
|
// Non-fatal but log warning - missing settings can cause agents to start without hooks
|
||||||
|
style.PrintWarning("could not ensure settings for %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
sessionID := crewSessionName(r.Name, name)
|
sessionID := crewSessionName(r.Name, name)
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[DEBUG] sessionID=%q (r.Name=%q, name=%q)\n", sessionID, r.Name, name)
|
||||||
|
}
|
||||||
hasSession, err := t.HasSession(sessionID)
|
hasSession, err := t.HasSession(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[DEBUG] hasSession=%v\n", hasSession)
|
||||||
|
}
|
||||||
|
|
||||||
// Before creating a new session, check if there's already a runtime session
|
// Before creating a new session, check if there's already a runtime session
|
||||||
// running in this crew's directory (might have been started manually or via
|
// running in this crew's directory (might have been started manually or via
|
||||||
@@ -137,13 +159,17 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
// Set environment (non-fatal: session works without these)
|
||||||
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
// Use centralized AgentEnv for consistency across all role startup paths
|
||||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
Role: "crew",
|
||||||
|
Rig: r.Name,
|
||||||
// Set runtime config dir for account selection (non-fatal)
|
AgentName: name,
|
||||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
|
TownRoot: townRoot,
|
||||||
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir)
|
RuntimeConfigDir: claudeConfigDir,
|
||||||
|
BeadsNoDaemon: true,
|
||||||
|
})
|
||||||
|
for k, v := range envVars {
|
||||||
|
_ = t.SetEnvironment(sessionID, k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
||||||
@@ -162,11 +188,20 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("getting pane ID: %w", err)
|
return fmt.Errorf("getting pane ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
|
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
||||||
|
// The SessionStart hook handles context injection (gt prime --hook)
|
||||||
|
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||||
|
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "human",
|
||||||
|
Topic: "start",
|
||||||
|
})
|
||||||
|
|
||||||
// Use respawn-pane to replace shell with runtime directly
|
// Use respawn-pane to replace shell with runtime directly
|
||||||
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
|
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
|
||||||
// Pass "gt prime" as initial prompt if supported
|
|
||||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
}
|
}
|
||||||
@@ -198,10 +233,18 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("getting pane ID: %w", err)
|
return fmt.Errorf("getting pane ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
|
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
||||||
|
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||||
|
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "human",
|
||||||
|
Topic: "restart",
|
||||||
|
})
|
||||||
|
|
||||||
// Use respawn-pane to replace shell with runtime directly
|
// Use respawn-pane to replace shell with runtime directly
|
||||||
// Pass "gt prime" as initial prompt if supported
|
|
||||||
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
|
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
}
|
}
|
||||||
@@ -217,19 +260,36 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Check if we're already in the target session
|
// Check if we're already in the target session
|
||||||
if isInTmuxSession(sessionID) {
|
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
|
||||||
// Pass "gt prime" as initial prompt so it loads context immediately
|
|
||||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolving agent: %w", err)
|
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{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "human",
|
||||||
|
Topic: "start",
|
||||||
|
})
|
||||||
fmt.Printf("Starting %s in current session...\n", agentCfg.Command)
|
fmt.Printf("Starting %s in current session...\n", agentCfg.Command)
|
||||||
return execAgent(agentCfg, "gt prime")
|
return execAgent(agentCfg, beacon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If inside tmux (but different session), don't switch - just inform user
|
// If inside tmux (but different session), don't switch - just inform user
|
||||||
if tmux.IsInsideTmux() {
|
insideTmux := tmux.IsInsideTmux()
|
||||||
fmt.Printf("Started %s/%s. Use C-b s to switch.\n", r.Name, name)
|
if debug {
|
||||||
|
fmt.Printf("[DEBUG] tmux.IsInsideTmux()=%v\n", insideTmux)
|
||||||
|
}
|
||||||
|
if insideTmux {
|
||||||
|
fmt.Printf("Session %s ready. Use C-b s to switch.\n", sessionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +299,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach to session
|
// Attach to session - show which session we're attaching to
|
||||||
|
fmt.Printf("Attaching to %s...\n", sessionID)
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[DEBUG] calling attachToTmuxSession(%q)\n", sessionID)
|
||||||
|
}
|
||||||
return attachToTmuxSession(sessionID)
|
return attachToTmuxSession(sessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,14 +214,22 @@ func isInTmuxSession(targetSession string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// attachToTmuxSession attaches to a tmux session.
|
// 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 {
|
func attachToTmuxSession(sessionID string) error {
|
||||||
tmuxPath, err := exec.LookPath("tmux")
|
tmuxPath, err := exec.LookPath("tmux")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tmux not found: %w", err)
|
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.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
@@ -300,28 +303,73 @@ func runCrewStart(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start each crew member
|
// Resolve account config once for all crew members
|
||||||
|
townRoot, _ := workspace.Find(r.Path)
|
||||||
|
if townRoot == "" {
|
||||||
|
townRoot = filepath.Dir(r.Path)
|
||||||
|
}
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
claudeConfigDir, _, _ := config.ResolveAccountConfigDir(accountsPath, crewAccount)
|
||||||
|
|
||||||
|
// Build start options (shared across all crew members)
|
||||||
|
opts := crew.StartOptions{
|
||||||
|
Account: crewAccount,
|
||||||
|
ClaudeConfigDir: claudeConfigDir,
|
||||||
|
AgentOverride: crewAgentOverride,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start each crew member in parallel
|
||||||
|
type result struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
skipped bool // true if session was already running
|
||||||
|
}
|
||||||
|
results := make(chan result, len(crewNames))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
fmt.Printf("Starting %d crew member(s) in %s...\n", len(crewNames), rigName)
|
||||||
|
|
||||||
|
for _, name := range crewNames {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(crewName string) {
|
||||||
|
defer wg.Done()
|
||||||
|
err := crewMgr.Start(crewName, opts)
|
||||||
|
skipped := errors.Is(err, crew.ErrSessionRunning)
|
||||||
|
if skipped {
|
||||||
|
err = nil // Not an error, just already running
|
||||||
|
}
|
||||||
|
results <- result{name: crewName, err: err, skipped: skipped}
|
||||||
|
}(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines to complete
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect results
|
||||||
var lastErr error
|
var lastErr error
|
||||||
startedCount := 0
|
startedCount := 0
|
||||||
for _, name := range crewNames {
|
skippedCount := 0
|
||||||
// Set the start.go flags before calling runStartCrew
|
for res := range results {
|
||||||
startCrewRig = rigName
|
if res.err != nil {
|
||||||
startCrewAccount = crewAccount
|
fmt.Printf(" %s %s/%s: %v\n", style.ErrorPrefix, rigName, res.name, res.err)
|
||||||
startCrewAgentOverride = crewAgentOverride
|
lastErr = res.err
|
||||||
|
} else if res.skipped {
|
||||||
// Use rig/name format for runStartCrew
|
fmt.Printf(" %s %s/%s: already running\n", style.Dim.Render("○"), rigName, res.name)
|
||||||
fullName := rigName + "/" + name
|
skippedCount++
|
||||||
if err := runStartCrew(cmd, []string{fullName}); err != nil {
|
|
||||||
fmt.Printf("Error starting %s/%s: %v\n", rigName, name, err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Printf(" %s %s/%s: started\n", style.SuccessPrefix, rigName, res.name)
|
||||||
startedCount++
|
startedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if startedCount > 0 {
|
// Summary
|
||||||
fmt.Printf("\n%s Started %d crew member(s) in %s\n",
|
fmt.Println()
|
||||||
style.Bold.Render("✓"), startedCount, r.Name)
|
if startedCount > 0 || skippedCount > 0 {
|
||||||
|
fmt.Printf("%s Started %d, skipped %d (already running) in %s\n",
|
||||||
|
style.Bold.Render("✓"), startedCount, skippedCount, r.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastErr
|
return lastErr
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ func runCrewStatus(cmd *cobra.Command, args []string) error {
|
|||||||
crewRig = rig
|
crewRig = rig
|
||||||
}
|
}
|
||||||
targetName = crewName
|
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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/session"
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/util"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +237,27 @@ This removes the pause file and allows the Deacon to work normally.`,
|
|||||||
RunE: runDeaconResume,
|
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 (
|
var (
|
||||||
triggerTimeout time.Duration
|
triggerTimeout time.Duration
|
||||||
|
|
||||||
@@ -269,6 +292,7 @@ func init() {
|
|||||||
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
||||||
deaconCmd.AddCommand(deaconPauseCmd)
|
deaconCmd.AddCommand(deaconPauseCmd)
|
||||||
deaconCmd.AddCommand(deaconResumeCmd)
|
deaconCmd.AddCommand(deaconResumeCmd)
|
||||||
|
deaconCmd.AddCommand(deaconCleanupOrphansCmd)
|
||||||
|
|
||||||
// Flags for trigger-pending
|
// Flags for trigger-pending
|
||||||
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
||||||
@@ -348,39 +372,41 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
|||||||
|
|
||||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
||||||
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
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...")
|
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)
|
return fmt.Errorf("creating session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
// Set environment (non-fatal: session works without these)
|
||||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon")
|
// Use centralized AgentEnv for consistency across all role startup paths
|
||||||
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
||||||
|
Role: "deacon",
|
||||||
|
TownRoot: townRoot,
|
||||||
|
})
|
||||||
|
for k, v := range envVars {
|
||||||
|
_ = t.SetEnvironment(sessionName, k, v)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
||||||
// Note: ConfigureGasTownSession includes cycle bindings
|
// Note: ConfigureGasTownSession includes cycle bindings
|
||||||
theme := tmux.DeaconTheme()
|
theme := tmux.DeaconTheme()
|
||||||
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
||||||
|
|
||||||
// Launch Claude directly (no shell respawn loop)
|
// Wait for Claude to start
|
||||||
// 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)
|
|
||||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
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)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
@@ -388,17 +414,21 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
|||||||
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
if err := session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
Recipient: "deacon",
|
Recipient: "deacon",
|
||||||
Sender: "daemon",
|
Sender: "daemon",
|
||||||
Topic: "patrol",
|
Topic: "patrol",
|
||||||
}) // Non-fatal
|
}); err != nil {
|
||||||
|
style.PrintWarning("failed to send startup nudge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
// GUPP: Gas Town Universal Propulsion Principle
|
||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
// Send the propulsion nudge to trigger autonomous patrol execution.
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||||
time.Sleep(2 * time.Second)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -696,25 +726,35 @@ func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("%s Sent HEALTH_CHECK to %s, waiting %s...\n",
|
fmt.Printf("%s Sent HEALTH_CHECK to %s, waiting %s...\n",
|
||||||
style.Bold.Render("→"), agent, healthCheckTimeout)
|
style.Bold.Render("→"), agent, healthCheckTimeout)
|
||||||
|
|
||||||
// Wait for response
|
// Wait for response using context and ticker for reliability
|
||||||
deadline := time.Now().Add(healthCheckTimeout)
|
// 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
|
responded := false
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for {
|
||||||
time.Sleep(2 * time.Second) // Check every 2 seconds
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
goto Done
|
||||||
|
case <-ticker.C:
|
||||||
|
newTime, err := getAgentBeadUpdateTime(townRoot, beadID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
newTime, err := getAgentBeadUpdateTime(townRoot, beadID)
|
// If bead was updated after our baseline, agent responded
|
||||||
if err != nil {
|
if newTime.After(baselineTime) {
|
||||||
continue
|
responded = true
|
||||||
}
|
goto Done
|
||||||
|
}
|
||||||
// If bead was updated after our baseline, agent responded
|
|
||||||
if newTime.After(baselineTime) {
|
|
||||||
responded = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Done:
|
||||||
// Record result
|
// Record result
|
||||||
if responded {
|
if responded {
|
||||||
agentState.RecordResponse()
|
agentState.RecordResponse()
|
||||||
@@ -1088,3 +1128,54 @@ func runDeaconResume(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ Workspace checks:
|
|||||||
- rigs-registry-valid Check registered rigs exist (fixable)
|
- rigs-registry-valid Check registered rigs exist (fixable)
|
||||||
- mayor-exists Check mayor/ directory structure
|
- mayor-exists Check mayor/ directory structure
|
||||||
|
|
||||||
|
Town root protection:
|
||||||
|
- town-git Verify town root is under version control
|
||||||
|
- town-root-branch Verify town root is on main branch (fixable)
|
||||||
|
- pre-checkout-hook Verify pre-checkout hook prevents branch switches (fixable)
|
||||||
|
|
||||||
Infrastructure checks:
|
Infrastructure checks:
|
||||||
|
- stale-binary Check if gt binary is up to date with repo
|
||||||
- daemon Check if daemon is running (fixable)
|
- daemon Check if daemon is running (fixable)
|
||||||
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
||||||
- boot-health Check Boot watchdog health (vet mode)
|
- boot-health Check Boot watchdog health (vet mode)
|
||||||
@@ -111,19 +117,26 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewGlobalStateCheck())
|
d.Register(doctor.NewGlobalStateCheck())
|
||||||
|
|
||||||
// Register built-in checks
|
// Register built-in checks
|
||||||
|
d.Register(doctor.NewStaleBinaryCheck())
|
||||||
|
d.Register(doctor.NewSqlite3Check())
|
||||||
d.Register(doctor.NewTownGitCheck())
|
d.Register(doctor.NewTownGitCheck())
|
||||||
|
d.Register(doctor.NewTownRootBranchCheck())
|
||||||
|
d.Register(doctor.NewPreCheckoutHookCheck())
|
||||||
d.Register(doctor.NewDaemonCheck())
|
d.Register(doctor.NewDaemonCheck())
|
||||||
d.Register(doctor.NewRepoFingerprintCheck())
|
d.Register(doctor.NewRepoFingerprintCheck())
|
||||||
d.Register(doctor.NewBootHealthCheck())
|
d.Register(doctor.NewBootHealthCheck())
|
||||||
d.Register(doctor.NewBeadsDatabaseCheck())
|
d.Register(doctor.NewBeadsDatabaseCheck())
|
||||||
|
d.Register(doctor.NewCustomTypesCheck())
|
||||||
|
d.Register(doctor.NewRoleLabelCheck())
|
||||||
d.Register(doctor.NewFormulaCheck())
|
d.Register(doctor.NewFormulaCheck())
|
||||||
d.Register(doctor.NewBdDaemonCheck())
|
d.Register(doctor.NewBdDaemonCheck())
|
||||||
d.Register(doctor.NewPrefixConflictCheck())
|
d.Register(doctor.NewPrefixConflictCheck())
|
||||||
d.Register(doctor.NewPrefixMismatchCheck())
|
d.Register(doctor.NewPrefixMismatchCheck())
|
||||||
d.Register(doctor.NewRoutesCheck())
|
d.Register(doctor.NewRoutesCheck())
|
||||||
|
d.Register(doctor.NewRigRoutesJSONLCheck())
|
||||||
d.Register(doctor.NewOrphanSessionCheck())
|
d.Register(doctor.NewOrphanSessionCheck())
|
||||||
|
d.Register(doctor.NewZombieSessionCheck())
|
||||||
d.Register(doctor.NewOrphanProcessCheck())
|
d.Register(doctor.NewOrphanProcessCheck())
|
||||||
d.Register(doctor.NewGTRootCheck())
|
|
||||||
d.Register(doctor.NewWispGCCheck())
|
d.Register(doctor.NewWispGCCheck())
|
||||||
d.Register(doctor.NewBranchCheck())
|
d.Register(doctor.NewBranchCheck())
|
||||||
d.Register(doctor.NewBeadsSyncOrphanCheck())
|
d.Register(doctor.NewBeadsSyncOrphanCheck())
|
||||||
@@ -131,6 +144,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewIdentityCollisionCheck())
|
d.Register(doctor.NewIdentityCollisionCheck())
|
||||||
d.Register(doctor.NewLinkedPaneCheck())
|
d.Register(doctor.NewLinkedPaneCheck())
|
||||||
d.Register(doctor.NewThemeCheck())
|
d.Register(doctor.NewThemeCheck())
|
||||||
|
d.Register(doctor.NewCrashReportCheck())
|
||||||
|
d.Register(doctor.NewEnvVarsCheck())
|
||||||
|
|
||||||
// Patrol system checks
|
// Patrol system checks
|
||||||
d.Register(doctor.NewPatrolMoleculesExistCheck())
|
d.Register(doctor.NewPatrolMoleculesExistCheck())
|
||||||
@@ -140,6 +155,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
|
d.Register(doctor.NewPatrolRolesHavePromptsCheck())
|
||||||
d.Register(doctor.NewAgentBeadsCheck())
|
d.Register(doctor.NewAgentBeadsCheck())
|
||||||
d.Register(doctor.NewRigBeadsCheck())
|
d.Register(doctor.NewRigBeadsCheck())
|
||||||
|
d.Register(doctor.NewRoleBeadsCheck())
|
||||||
|
|
||||||
// NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule
|
// NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule
|
||||||
|
|
||||||
@@ -150,6 +166,9 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewLegacyGastownCheck())
|
d.Register(doctor.NewLegacyGastownCheck())
|
||||||
d.Register(doctor.NewClaudeSettingsCheck())
|
d.Register(doctor.NewClaudeSettingsCheck())
|
||||||
|
|
||||||
|
// Priming subsystem check
|
||||||
|
d.Register(doctor.NewPrimingCheck())
|
||||||
|
|
||||||
// Crew workspace checks
|
// Crew workspace checks
|
||||||
d.Register(doctor.NewCrewStateCheck())
|
d.Register(doctor.NewCrewStateCheck())
|
||||||
d.Register(doctor.NewCrewWorktreeCheck())
|
d.Register(doctor.NewCrewWorktreeCheck())
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user