diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 6b722c07..7012cf17 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -53,7 +53,7 @@ {"id":"bd-157","content_hash":"ba5173c61613a29786641ba06a93427de87bed65ce39dbc3c3ddd2b6900f827e","title":"Integration test: mutation to export latency","description":"Measure time from bd create to JSONL update. Verify \u003c500ms latency. Test with multiple rapid mutations to verify batching.","notes":"Test added to daemon_test.go as TestMutationToExportLatency().\n\nCurrently skipped with note that it should be enabled once bd-146 (event-driven daemon) is fully implemented and enabled by default.\n\nThe test structure is complete:\n1. Sets up test environment with fast debounce (500ms)\n2. SingleMutationLatency: measures latency from mutation to JSONL update\n3. RapidMutationBatching: verifies multiple mutations batch into single export\n\nOnce event-driven mode is default, remove the t.Skip() line and the test will validate \u003c500ms latency.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T20:49:49.103759-07:00","updated_at":"2025-10-29T20:49:49.103759-07:00","closed_at":"2025-10-29T14:19:19.808139-07:00","dependencies":[{"issue_id":"bd-157","depends_on_id":"bd-159","type":"parent-child","created_at":"2025-10-29T20:49:49.107244-07:00","created_by":"import-remap"}]} {"id":"bd-159","content_hash":"d82bff5cbac4246b9eee872ebdf97db6b627daabb3b81a359a7d8512ebb5915e","title":"Event-driven daemon architecture","description":"Replace 5-second polling sync loop with event-driven architecture that reacts instantly to changes. Eliminates stale data issues while reducing CPU ~60%. Key components: FileWatcher (fsnotify), Debouncer (500ms), RPC mutation events, optional git hooks. Target latency: \u003c500ms (vs 5000ms). See event_driven_daemon.md for full design.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-29T20:49:49.10595-07:00","updated_at":"2025-10-29T20:49:49.10595-07:00"} {"id":"bd-16","content_hash":"685c91a6de8e1610feb5dbda18412f3eee178a37064d9ddf55511fb693dec9ba","title":"Delete skipped tests for \"old buggy behavior\"","description":"Three test functions are permanently skipped with comments indicating they test behavior that was fixed in GH#120. These tests will never run again and should be deleted.\n\nTest functions to remove:\n\n1. `cmd/bd/import_collision_test.go:228`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n2. `cmd/bd/import_collision_test.go:505`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\n3. `internal/storage/sqlite/collision_test.go:919`\n ```go\n t.Skip(\"Test expects old buggy behavior - needs rewrite for GH#120 fix\")\n ```\n\nImpact: Removes ~150 LOC of permanently skipped tests","acceptance_criteria":"- Delete the 3 test functions entirely (~150 LOC total)\n- Update test file comments to reference GH#120 fix if needed\n- All remaining tests pass: `go test ./...`\n- No reduction in meaningful test coverage (these test fixed bugs)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:30:19.961185-07:00","updated_at":"2025-10-28T14:09:21.642632-07:00","closed_at":"2025-10-28T14:09:21.642632-07:00","labels":["cleanup","dead-code","phase-1","test-cleanup"],"dependencies":[{"issue_id":"bd-16","depends_on_id":"bd-26","type":"parent-child","created_at":"2025-10-27T20:30:19.962815-07:00","created_by":"daemon"}]} -{"id":"bd-160","content_hash":"1ee07b713143f1abcc3c8189ae49a41e34669822a1843fe1ca823c5f69af4494","title":"Export deduplication breaks when JSONL and export_hashes table diverge","description":"## Problem\n\nThe export deduplication feature (timestamp-only skipping) breaks when the JSONL file and export_hashes table get out of sync, causing exports to skip issues that aren't actually in the file.\n\n## Symptoms\n\n- `bd export` reports \"Skipped 128 issue(s) with timestamp-only changes\"\n- JSONL file only has 38 lines but DB has 149 issues\n- export_hashes table has 149 entries\n- Auto-import doesn't trigger (hash matches despite missing data)\n- Two repos on same commit show different issue counts\n\n## Root Cause\n\nshouldSkipExport() in autoflush.go compares current issue hash with stored export_hashes entry. If they match, it skips export assuming the issue is already in the JSONL.\n\nThis assumption fails when:\n1. Git operations (pull, reset, checkout) change JSONL without clearing export_hashes\n2. Manual JSONL edits or corruption\n3. Import operations that modify DB but don't update export_hashes\n4. Partial exports that update export_hashes but don't complete\n\n## Impact\n\n- **Critical data loss risk**: Issues appear to be tracked but aren't persisted to git\n- Breaks multi-repo sync (root cause of today's debugging session)\n- Auto-import fails to detect staleness (hash matches despite missing data)\n- Silent data corruption (no error messages, just missing issues)\n\n## Reproduction\n\n1. Have DB with 149 issues, all in export_hashes table\n2. Truncate JSONL to 38 lines (simulate git reset or corruption)\n3. Run `bd export` - it skips 128 issues\n4. JSONL still has only 38 lines but export thinks it succeeded\n\n## Current Workaround\n\n```bash\nsqlite3 .beads/beads.db \"DELETE FROM export_hashes\"\nbd export -o .beads/beads.jsonl\n```\n\n## Proposed Solutions\n\n**Option 1: Verify JSONL integrity before skipping**\n- Count lines in JSONL, compare with export_hashes count\n- If mismatch, clear export_hashes and force full export\n- Safe but adds I/O overhead\n\n**Option 2: Hash-based JSONL validation**\n- Store hash of entire JSONL file in metadata\n- Before export, check if JSONL hash matches\n- If mismatch, clear export_hashes\n- More efficient, detects any JSONL corruption\n\n**Option 3: Disable timestamp-only deduplication**\n- Remove the feature entirely\n- Always export all issues\n- Simplest and safest, but creates larger git commits\n\n**Option 4: Clear export_hashes on git operations**\n- Add post-merge hook to clear export_hashes\n- Clear on any import operation\n- Defensive approach but may over-clear\n\n## Recommended Fix\n\nCombination of Options 2 + 4:\n1. Store JSONL file hash in metadata after export\n2. Check hash before export, clear export_hashes if mismatch \n3. Clear export_hashes on import operations\n4. Add `bd validate` check for JSONL/export_hashes sync\n\n## Files Involved\n\n- cmd/bd/autoflush.go (shouldSkipExport)\n- cmd/bd/export.go (export with deduplication)\n- internal/storage/sqlite/metadata.go (export_hashes table)","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-29T21:14:28.435954-07:00","updated_at":"2025-10-29T22:22:20.406934-07:00","closed_at":"2025-10-29T22:22:20.406934-07:00"} +{"id":"bd-160","content_hash":"4f2b5a203d6a7b5e38176dd6ef68afb4b7d0e11889718381e28bf006f4e83a16","title":"Export deduplication breaks when JSONL and export_hashes table diverge","description":"## Problem\n\nThe export deduplication feature (timestamp-only skipping) breaks when the JSONL file and export_hashes table get out of sync, causing exports to skip issues that aren't actually in the file.\n\n## Symptoms\n\n- `bd export` reports \"Skipped 128 issue(s) with timestamp-only changes\"\n- JSONL file only has 38 lines but DB has 149 issues\n- export_hashes table has 149 entries\n- Auto-import doesn't trigger (hash matches despite missing data)\n- Two repos on same commit show different issue counts\n\n## Root Cause\n\nshouldSkipExport() in autoflush.go compares current issue hash with stored export_hashes entry. If they match, it skips export assuming the issue is already in the JSONL.\n\nThis assumption fails when:\n1. Git operations (pull, reset, checkout) change JSONL without clearing export_hashes\n2. Manual JSONL edits or corruption\n3. Import operations that modify DB but don't update export_hashes\n4. Partial exports that update export_hashes but don't complete\n\n## Impact\n\n- **Critical data loss risk**: Issues appear to be tracked but aren't persisted to git\n- Breaks multi-repo sync (root cause of today's debugging session)\n- Auto-import fails to detect staleness (hash matches despite missing data)\n- Silent data corruption (no error messages, just missing issues)\n\n## Reproduction\n\n1. Have DB with 149 issues, all in export_hashes table\n2. Truncate JSONL to 38 lines (simulate git reset or corruption)\n3. Run `bd export` - it skips 128 issues\n4. JSONL still has only 38 lines but export thinks it succeeded\n\n## Current Workaround\n\n```bash\nsqlite3 .beads/beads.db \"DELETE FROM export_hashes\"\nbd export -o .beads/beads.jsonl\n```\n\n## Proposed Solutions\n\n**Option 1: Verify JSONL integrity before skipping**\n- Count lines in JSONL, compare with export_hashes count\n- If mismatch, clear export_hashes and force full export\n- Safe but adds I/O overhead\n\n**Option 2: Hash-based JSONL validation**\n- Store hash of entire JSONL file in metadata\n- Before export, check if JSONL hash matches\n- If mismatch, clear export_hashes\n- More efficient, detects any JSONL corruption\n\n**Option 3: Disable timestamp-only deduplication**\n- Remove the feature entirely\n- Always export all issues\n- Simplest and safest, but creates larger git commits\n\n**Option 4: Clear export_hashes on git operations**\n- Add post-merge hook to clear export_hashes\n- Clear on any import operation\n- Defensive approach but may over-clear\n\n## Recommended Fix\n\nCombination of Options 2 + 4:\n1. Store JSONL file hash in metadata after export\n2. Check hash before export, clear export_hashes if mismatch \n3. Clear export_hashes on import operations\n4. Add `bd validate` check for JSONL/export_hashes sync\n\n## Files Involved\n\n- cmd/bd/autoflush.go (shouldSkipExport)\n- cmd/bd/export.go (export with deduplication)\n- internal/storage/sqlite/metadata.go (export_hashes table)","notes":"## Recovery Session (2025-10-29 21:30)\n\n### What Happened\n- Created 14 new hash ID issues (bd-165 through bd-178) \n- bd sync appeared to succeed\n- Canonical repo (~/src/beads): 162 issues in DB + JSONL ✓\n- Secondary repo (fred/beads): Only 145 issues vs 162 in canonical ✗\n- Both repos on same git commit but different issue counts!\n\n### Bug Manifestation During Recovery\n\n1. **Initial state**: fred/beads had 145 issues, 145 lines in JSONL, 145 export_hashes entries\n\n2. **After git reset --hard origin/main**: \n - JSONL: 162 lines (from git)\n - DB: 150 issues (auto-import partially worked)\n - Auto-import failed with UNIQUE constraint error\n\n3. **After manual import --resolve-collisions**:\n - DB: 160 issues\n - JSONL: Still 162 lines\n - export_hashes: 159 entries\n\n4. **After bd export**: \n - **JSONL reduced to 17 lines!** ← The bug in action\n - export_hashes: 159 entries (skipped exporting 142 issues)\n - Silent data loss - no error message\n\n5. **After clearing export_hashes and re-export**:\n - JSONL: 159 lines (missing 3 issues still)\n - DB: 159 issues\n - Still diverged from canonical\n\n### The Bug Loop\nOnce export_hashes and JSONL diverge:\n- Export skips issues already in export_hashes\n- But those issues aren't actually in JSONL\n- This creates corrupt JSONL with missing issues\n- Auto-import can't detect the problem (file hash matches what was exported)\n- Data is lost with no error messages\n\n### Recovery Solution\nCouldn't break the loop with export alone. Had to:\n1. Copy .beads/beads.db from canonical repo\n2. Clear export_hashes\n3. Full re-export\n4. Finally converged to 162 issues\n\n### Key Learnings\n\n1. **The bug is worse than we thought**: It can create corrupt exports (17 lines instead of 162!)\n\n2. **Auto-import can't save you**: Once export is corrupt, auto-import just imports the corrupt data\n\n3. **Silent failure**: No warnings, no errors, just missing issues\n\n4. **Git operations trigger it**: git reset, git pull, etc. change JSONL without clearing export_hashes\n\n5. **Import operations populate export_hashes**: Even manual imports update export_hashes, setting up future export failures\n\n### Immediate Action Required\n\n**DISABLE EXPORT DEDUPLICATION NOW**\n\nThis feature is fundamentally broken and causes data loss. Should be disabled until properly fixed.\n\nQuick fix options:\n- Set environment variable to disable feature\n- Comment out shouldSkipExport check\n- Always clear export_hashes before export\n- Add validation that DB count == JSONL line count before allowing export\n\n### Long-term Fix\n\nNeed Option 2 + 4 from proposed solutions:\n1. Store JSONL file hash after every successful export\n2. Before export, verify JSONL hash matches expected\n3. If mismatch, log WARNING and clear export_hashes\n4. Clear export_hashes on every import operation\n5. Add git post-merge hook to clear export_hashes\n6. Add `bd validate` command to detect divergence\n","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-29T21:14:28.435954-07:00","updated_at":"2025-10-29T21:57:03.06641-07:00","closed_at":"2025-10-29T21:57:03.06641-07:00"} {"id":"bd-161","content_hash":"4c03fb79e67c0948d0d887b56fcbf71ed3b987e4bfd84628d7b9b2fa047a61fa","title":"Add TestNWayCollision for 5+ clones","description":"## Overview\nAdd comprehensive tests for N-way (5+) collision resolution to verify the solution scales beyond 3 clones.\n\n## Purpose\nWhile TestThreeCloneCollision validates the basic N-way case, we need to verify:\n1. Solution scales to arbitrary N\n2. Performance is acceptable with more clones\n3. Convergence time is bounded\n4. No edge cases in larger collision groups\n\n## Implementation Tasks\n\n### 1. Create TestFiveCloneCollision\nFile: beads_twoclone_test.go (or new beads_nway_test.go)\n\n```go\nfunc TestFiveCloneCollision(t *testing.T) {\n // Test with 5 clones creating same ID with different content\n // Verify all 5 clones converge after sync rounds\n \n t.Run(\"SequentialSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"A\", \"B\", \"C\", \"D\", \"E\")\n })\n \n t.Run(\"ReverseSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"E\", \"D\", \"C\", \"B\", \"A\")\n })\n \n t.Run(\"RandomSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"C\", \"A\", \"E\", \"B\", \"D\")\n })\n}\n```\n\n### 2. Implement generalized testNCloneCollision\nGeneralize the 3-clone test to handle arbitrary N:\n\n```go\nfunc testNCloneCollision(t *testing.T, numClones int, syncOrder ...string) {\n t.Helper()\n \n if len(syncOrder) != numClones {\n t.Fatalf(\"syncOrder length (%d) must match numClones (%d)\", \n len(syncOrder), numClones)\n }\n \n tmpDir := t.TempDir()\n \n // Setup remote and N clones\n remoteDir := setupBareRepo(t, tmpDir)\n cloneDirs := make(map[string]string)\n \n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name)\n }\n \n // Each clone creates issue with same ID but different content\n for name, dir := range cloneDirs {\n createIssue(t, dir, fmt.Sprintf(\"Issue from clone %s\", name))\n }\n \n // Sync in specified order\n for _, name := range syncOrder {\n syncClone(t, cloneDirs[name], name)\n }\n \n // Final pull for convergence\n for name, dir := range cloneDirs {\n finalPull(t, dir, name)\n }\n \n // Verify all clones have all N issues\n expectedTitles := make(map[string]bool)\n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n expectedTitles[fmt.Sprintf(\"Issue from clone %s\", name)] = true\n }\n \n for name, dir := range cloneDirs {\n titles := getTitles(t, dir)\n if !compareTitleSets(titles, expectedTitles) {\n t.Errorf(\"Clone %s missing issues: expected %v, got %v\", \n name, expectedTitles, titles)\n }\n }\n \n t.Log(\"✓ All\", numClones, \"clones converged successfully\")\n}\n```\n\n### 3. Add performance benchmarks\nTest convergence time and memory usage:\n\n```go\nfunc BenchmarkNWayCollision(b *testing.B) {\n for _, n := range []int{3, 5, 10, 20} {\n b.Run(fmt.Sprintf(\"N=%d\", n), func(b *testing.B) {\n for i := 0; i \u003c b.N; i++ {\n // Run N-way collision and measure time\n testNCloneCollisionBench(b, n)\n }\n })\n }\n}\n```\n\n### 4. Add convergence time tests\nVerify bounded convergence:\n\n```go\nfunc TestConvergenceTime(t *testing.T) {\n // Test that convergence happens within expected rounds\n // For N clones, should converge in at most N-1 sync rounds\n \n for n := 3; n \u003c= 10; n++ {\n t.Run(fmt.Sprintf(\"N=%d\", n), func(t *testing.T) {\n rounds := measureConvergenceRounds(t, n)\n maxExpected := n - 1\n if rounds \u003e maxExpected {\n t.Errorf(\"Convergence took %d rounds, expected ≤ %d\", \n rounds, maxExpected)\n }\n })\n }\n}\n```\n\n### 5. Add edge case tests\nTest boundary conditions:\n- All N clones have identical content (dedup works)\n- N-1 clones have same content, 1 differs\n- All N clones have unique content\n- Mix of collisions and non-collisions\n\n## Acceptance Criteria\n- TestFiveCloneCollision passes with all sync orders\n- All 5 clones converge to identical content\n- Performance is acceptable (\u003c 5 seconds for 5 clones)\n- Convergence time is bounded (≤ N-1 rounds)\n- Edge cases handled correctly\n- Benchmarks show scalability to 10+ clones\n\n## Files to Create/Modify\n- beads_twoclone_test.go or beads_nway_test.go\n- Add helper functions for N-clone setup\n\n## Testing Strategy\n\n### Test Matrix\n| N Clones | Sync Orders | Expected Result |\n|----------|-------------|-----------------|\n| 3 | A→B→C | Pass |\n| 3 | C→B→A | Pass |\n| 5 | A→B→C→D→E | Pass |\n| 5 | E→D→C→B→A | Pass |\n| 5 | Random | Pass |\n| 10 | Sequential | Pass |\n\n### Performance Targets\n- 3 clones: \u003c 2 seconds\n- 5 clones: \u003c 5 seconds\n- 10 clones: \u003c 15 seconds\n\n## Dependencies\n- Requires bd-95, bd-96, bd-97, bd-98 to be completed\n- TestThreeCloneCollision must pass first\n\n## Success Metrics\n- All tests pass for N ∈ {3, 5, 10}\n- Convergence time scales linearly (O(N))\n- Memory usage reasonable (\u003c 100MB for 10 clones)\n- No data corruption or loss in any scenario","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-29T21:19:36.202118-07:00","updated_at":"2025-10-29T21:19:36.202118-07:00"} {"id":"bd-162","content_hash":"f180247fd30176bb37125a69c1c9361815d52e3437f930b81ec164d4cb92c4dd","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-108, bd-115, bd-113, bd-153.\n\nFiles: cmd/bd/validate.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-29T21:19:36.20268-07:00","updated_at":"2025-10-29T21:19:36.20268-07:00"} {"id":"bd-163","content_hash":"6440d1ece0a91c8f49adc09aafa7a998b049bcd51f257125ad8bc0b7b03e317b","title":"Update AGENTS.md with event-driven mode","description":"Document BEADS_DAEMON_MODE env var. Explain opt-in during Phase 1. Add troubleshooting for watcher failures.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-29T21:19:36.203089-07:00","updated_at":"2025-10-29T21:19:36.203089-07:00","dependencies":[{"issue_id":"bd-163","depends_on_id":"bd-164","type":"parent-child","created_at":"2025-10-29T21:19:36.206187-07:00","created_by":"import-remap"}]} @@ -73,6 +73,7 @@ {"id":"bd-176","content_hash":"4b63fd2a65d45bfabe57dfe896015343682813978c76071c1cb1df747659fd2c","title":"Update documentation for hash IDs and aliases","description":"Update all documentation to explain hash-based IDs and aliasing system.\n\n## Files to Update\n\n### 1. README.md\nAdd section explaining hash IDs:\n```markdown\n## Issue IDs\n\nBeads uses **hash-based IDs** for collision-free distributed issue tracking:\n\n- **Hash ID**: `bd-af78e9a2` (8-char SHA256 prefix, immutable, globally unique)\n- **Alias**: `#42` (sequential number, mutable, human-friendly)\n\n### Using IDs\n```bash\nbd show bd-af78e9a2 # Use hash ID\nbd show #42 # Use alias\nbd show 42 # Use alias (shorthand)\n```\n\n### Why Hash IDs?\n- **Collision-free**: Work offline without ID conflicts\n- **Distributed**: No coordination needed between clones\n- **Git-friendly**: Different IDs = different JSONL lines, fewer merge conflicts\n\n### Aliases\nAliases are workspace-local shortcuts for hash IDs. They're:\n- Automatically assigned on issue creation\n- Reassigned deterministically on sync (if conflicts)\n- Can be manually controlled with `bd alias` commands\n```\n\n### 2. AGENTS.md\nUpdate agent workflow:\n```markdown\n## Hash-Based IDs (v2.0+)\n\nBeads v2.0 uses hash-based IDs to eliminate collision problems:\n\n**When creating issues**:\n```bash\nbd create \"Fix bug\" -p 1\n# → Creates bd-af78e9a2 with alias #1\n```\n\n**When referencing issues**:\n- In text: Use hash IDs (stable): \"See bd-af78e9a2 for details\"\n- In CLI: Use aliases (readable): `bd update #42 --status done`\n\n**After sync**:\n- Alias conflicts resolved automatically (content-hash ordering)\n- No ID collisions possible\n- No remapping needed\n\n**Migration from v1.x**:\n```bash\nbd migrate --hash-ids # One-time migration\n```\n```\n\n### 3. QUICKSTART.md (if exists)\nShow alias usage in examples:\n```bash\n# Create issue (gets hash ID + alias)\nbd create \"Fix authentication bug\" -p 1\n# → Created bd-af78e9a2 (alias: #1)\n\n# Reference by alias\nbd show #1\nbd update #1 --status in_progress\nbd close #1 --reason \"Fixed\"\n```\n\n### 4. ADVANCED.md\nAdd section on hash ID internals:\n```markdown\n## Hash ID Generation\n\nHash IDs are generated deterministically:\n\n```go\nSHA256(title || description || timestamp || workspace_id)[:8]\n```\n\n**Collision probability**:\n- 8 hex chars = 2^32 space = ~4 billion IDs\n- Birthday paradox: 50% collision probability at ~65,000 issues\n- For typical projects (\u003c10,000 issues), collision risk is negligible\n\n**Collision detection**:\nIf a hash collision occurs (extremely rare), beads:\n1. Detects on insert (UNIQUE constraint)\n2. Appends random suffix: `bd-af78e9a2-a1b2`\n3. Retries insert\n\n## Alias Conflict Resolution\n\nWhen multiple clones assign same alias to different issues:\n\n**Strategy**: Content-hash ordering (deterministic)\n- Sort conflicting issue IDs lexicographically\n- Lowest hash ID keeps the alias\n- Others reassigned to next available aliases\n\n**Example**:\n```\nClone A: Assigns #42 to bd-a1b2c3d4\nClone B: Assigns #42 to bd-e5f6a7b8\nAfter sync: bd-a1b2c3d4 keeps #42 (lower hash)\n bd-e5f6a7b8 gets #100 (next available)\n```\n```\n\n### 5. MIGRATION.md (new file)\n```markdown\n# Migrating to Hash-Based IDs (v2.0)\n\n## Overview\nBeads v2.0 introduces hash-based IDs to eliminate collision problems. This is a **breaking change** requiring migration.\n\n## Migration Steps\n\n### 1. Backup\n```bash\ncp -r .beads .beads.backup\ngit commit -am \"Pre-migration backup\"\n```\n\n### 2. Run Migration\n```bash\n# Dry run first\nbd migrate --hash-ids --dry-run\n\n# Apply migration\nbd migrate --hash-ids\n```\n\n### 3. Commit Changes\n```bash\ngit add .beads/issues.jsonl\ngit commit -m \"Migrate to hash-based IDs (v2.0)\"\ngit push origin main\n```\n\n### 4. Coordinate with Collaborators\nAll clones must migrate before syncing:\n1. Notify team: \"Migrating to v2.0 on [date]\"\n2. All collaborators pull latest\n3. All run `bd migrate --hash-ids`\n4. All push changes\n5. Resume normal work\n\n## Rollback\n```bash\n# Restore backup\nmv .beads.backup .beads\nbd export # Regenerate JSONL\ngit checkout .beads/issues.jsonl\n```\n\n## FAQ\n\n**Q: Can I mix v1.x and v2.0 clones?**\nA: No. All clones must be on same version.\n\n**Q: Will my old issue IDs work?**\nA: No, but aliases preserve the numbers: bd-1 → #1\n\n**Q: What happens to links like \"see bd-42\"?**\nA: Migration updates all text references automatically.\n```\n\n### 6. CHANGELOG.md\n```markdown\n## v2.0.0 (YYYY-MM-DD)\n\n### Breaking Changes\n- **Hash-based IDs**: Issues now use collision-free hash IDs (bd-af78e9a2)\n instead of sequential IDs (bd-1, bd-2)\n- **Aliasing system**: Human-friendly aliases (#42) for hash IDs\n- **Migration required**: Run `bd migrate --hash-ids` to convert v1.x databases\n\n### Added\n- `bd alias` command for manual alias control\n- `bd migrate --hash-ids` migration tool\n- Alias conflict resolution (deterministic, content-hash ordering)\n\n### Removed\n- ID collision detection and resolution (~2,100 LOC)\n- `bd import --resolve-collisions` flag (no longer needed)\n\n### Benefits\n- ✅ Zero ID collisions in distributed workflows\n- ✅ Simpler codebase (-1,350 net LOC)\n- ✅ Better git merge behavior\n- ✅ True offline-first operation\n```\n\n## Testing\n- Build docs locally (if using doc generator)\n- Check all links work\n- Verify examples are correct\n- Spellcheck\n\n## Files to Create/Modify\n- README.md (hash ID section)\n- AGENTS.md (workflow updates)\n- ADVANCED.md (internals)\n- MIGRATION.md (new)\n- CHANGELOG.md (v2.0 entry)\n- docs/ (any other docs)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-29T21:28:10.979971-07:00","updated_at":"2025-10-29T21:28:10.979971-07:00","dependencies":[{"issue_id":"bd-176","depends_on_id":"bd-165","type":"parent-child","created_at":"2025-10-29T21:28:10.981344-07:00","created_by":"stevey"},{"issue_id":"bd-176","depends_on_id":"bd-173","type":"blocks","created_at":"2025-10-29T21:28:10.981767-07:00","created_by":"stevey"},{"issue_id":"bd-176","depends_on_id":"bd-174","type":"blocks","created_at":"2025-10-29T21:28:10.982167-07:00","created_by":"stevey"}]} {"id":"bd-177","content_hash":"cf15dbd5b7dbc10b54ea8d0d173899983bcdc0775b799e77c9334ec3f9fcba14","title":"Update MCP server for hash IDs","description":"Update beads-mcp server to support hash IDs and aliases.\n\n## Changes Needed\n\n### 1. MCP Function Signatures (No Change)\nFunctions already use issue IDs as strings, so they work with hash IDs:\n\n```python\n# These already work!\nbeads_show(issue_id: str) # Accepts bd-af78e9a2 or #42\nbeads_update(issue_id: str, ...) # Accepts both formats\nbeads_close(issue_ids: List[str]) # Accepts both formats\n```\n\n### 2. Add Alias Resolution Helper\nFile: integrations/beads-mcp/src/beads_mcp/server.py\n\n```python\ndef resolve_issue_id(issue_id: str) -\u003e str:\n \"\"\"Resolve alias to hash ID if needed.\"\"\"\n # Hash ID: pass through\n if issue_id.startswith('bd-') and len(issue_id) == 11:\n return issue_id\n \n # Alias: #42 or 42\n alias_str = issue_id.lstrip('#')\n try:\n alias = int(alias_str)\n # Call bd to resolve\n result = subprocess.run(\n ['bd', 'alias', 'find', f'bd-{alias}'],\n capture_output=True, text=True\n )\n if result.returncode == 0:\n return result.stdout.strip()\n except ValueError:\n pass\n \n # Invalid format\n raise ValueError(f\"Invalid issue ID: {issue_id}\")\n```\n\n### 3. Update Response Formatting\nShow aliases in responses:\n\n```python\n@server.call_tool()\nasync def beads_show(issue_id: str) -\u003e List[TextContent]:\n resolved_id = resolve_issue_id(issue_id)\n \n result = subprocess.run(['bd', 'show', resolved_id], ...)\n \n # Parse response and add alias info\n # Format: \"bd-af78e9a2 (alias: #42)\"\n ...\n```\n\n### 4. Add beads_alias_* Functions\n\n```python\n@server.call_tool()\nasync def beads_alias_list() -\u003e List[TextContent]:\n \"\"\"List all alias mappings.\"\"\"\n result = subprocess.run(['bd', 'alias', 'list'], ...)\n return [TextContent(type=\"text\", text=result.stdout)]\n\n@server.call_tool()\nasync def beads_alias_set(alias: int, issue_id: str) -\u003e List[TextContent]:\n \"\"\"Manually assign alias to issue.\"\"\"\n result = subprocess.run(['bd', 'alias', 'set', str(alias), issue_id], ...)\n return [TextContent(type=\"text\", text=result.stdout)]\n\n@server.call_tool()\nasync def beads_alias_compact() -\u003e List[TextContent]:\n \"\"\"Compact aliases to fill gaps.\"\"\"\n result = subprocess.run(['bd', 'alias', 'compact'], ...)\n return [TextContent(type=\"text\", text=result.stdout)]\n```\n\n### 5. Update Documentation\nFile: integrations/beads-mcp/README.md\n\n```markdown\n## Issue IDs (v2.0+)\n\nThe MCP server accepts both hash IDs and aliases:\n\n```python\n# Using hash IDs\nawait beads_show(issue_id=\"bd-af78e9a2\")\n\n# Using aliases\nawait beads_show(issue_id=\"#42\")\nawait beads_show(issue_id=\"42\") # Shorthand\n```\n\n## Alias Management\n\nNew functions for alias control:\n\n- `beads_alias_list()` - List all alias mappings\n- `beads_alias_set(alias, issue_id)` - Manually assign alias\n- `beads_alias_compact()` - Compact aliases to fill gaps\n\n## Migration\n\nAfter migrating to hash IDs:\n1. Update beads-mcp: `pip install --upgrade beads-mcp`\n2. Restart MCP server\n3. All existing workflows continue to work\n```\n\n### 6. Version Compatibility\nDetect and handle both v1.x and v2.0 formats:\n\n```python\ndef detect_beads_version() -\u003e str:\n \"\"\"Detect if beads is using sequential or hash IDs.\"\"\"\n result = subprocess.run(['bd', 'list', '-n', '1'], ...)\n first_id = parse_first_issue_id(result.stdout)\n \n if first_id.startswith('bd-') and len(first_id) \u003e 11:\n return '2.0' # Hash ID\n else:\n return '1.x' # Sequential ID\n\n# On startup\nbeads_version = detect_beads_version()\nlogger.info(f\"Detected beads version: {beads_version}\")\n```\n\n## Testing\n\n### Unit Tests\nFile: integrations/beads-mcp/tests/test_hash_ids.py\n\n```python\ndef test_resolve_hash_id():\n \"\"\"Hash IDs pass through unchanged.\"\"\"\n assert resolve_issue_id(\"bd-af78e9a2\") == \"bd-af78e9a2\"\n\ndef test_resolve_alias():\n \"\"\"Aliases resolve to hash IDs.\"\"\"\n # Mock bd alias find command\n assert resolve_issue_id(\"#42\") == \"bd-af78e9a2\"\n assert resolve_issue_id(\"42\") == \"bd-af78e9a2\"\n\ndef test_invalid_id():\n \"\"\"Invalid IDs raise ValueError.\"\"\"\n with pytest.raises(ValueError):\n resolve_issue_id(\"invalid\")\n```\n\n### Integration Tests\n```python\nasync def test_show_with_hash_id(mcp_server):\n result = await mcp_server.beads_show(issue_id=\"bd-af78e9a2\")\n assert \"bd-af78e9a2\" in result[0].text\n\nasync def test_show_with_alias(mcp_server):\n result = await mcp_server.beads_show(issue_id=\"#42\")\n assert \"bd-af78e9a2\" in result[0].text # Resolved\n```\n\n## Backward Compatibility\nThe MCP server should work with both:\n- Beads v1.x (sequential IDs)\n- Beads v2.0+ (hash IDs)\n\nDetection happens at runtime based on issue ID format.\n\n## Files to Modify\n- integrations/beads-mcp/src/beads_mcp/server.py\n- integrations/beads-mcp/README.md\n- integrations/beads-mcp/tests/test_hash_ids.py (new)\n- integrations/beads-mcp/pyproject.toml (bump version)\n\n## Deployment\n```bash\ncd integrations/beads-mcp\n# Bump version to 2.0.0\npoetry version 2.0.0\n# Publish to PyPI\npoetry publish --build\n```","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-29T21:28:45.256074-07:00","updated_at":"2025-10-29T21:28:45.256074-07:00","dependencies":[{"issue_id":"bd-177","depends_on_id":"bd-165","type":"parent-child","created_at":"2025-10-29T21:28:45.257315-07:00","created_by":"stevey"},{"issue_id":"bd-177","depends_on_id":"bd-170","type":"blocks","created_at":"2025-10-29T21:28:45.258057-07:00","created_by":"stevey"}]} {"id":"bd-178","content_hash":"4b348a47b8819c70bed5407a8a785605238738f5e56e344a8140a13de2c5dfd8","title":"Dogfood: Migrate beads repo to hash IDs","description":"Final validation: migrate the beads project itself to hash-based IDs.\n\n## Purpose\nDogfooding the migration on beads' own issue database to:\n1. Validate migration tool works on real data\n2. Discover edge cases\n3. Verify all workflows still work\n4. Build confidence for users\n\n## Pre-Migration Checklist\n- [ ] All bd-165 child tasks completed\n- [ ] All tests pass: `go test ./...`\n- [ ] Migration tool tested on test databases\n- [ ] Documentation updated\n- [ ] MCP server updated and published\n- [ ] Clean git status\n\n## Migration Steps\n\n### 1. Create Backup\n```bash\n# Backup database\ncp -r .beads .beads.backup-1761798568\n\n# Backup JSONL\ncp .beads/beads.jsonl .beads/beads.jsonl.backup\n\n# Create git branch for migration\ngit checkout -b hash-id-migration\ngit add .beads.backup-*\ngit commit -m \"Pre-migration backup\"\n```\n\n### 2. Run Migration (Dry Run)\n```bash\nbd migrate --hash-ids --dry-run \u003e migration-plan.txt\ncat migration-plan.txt\n\n# Review:\n# - Number of issues to migrate\n# - Hash collision check (should be zero)\n# - Text reference updates\n# - Dependency updates\n```\n\n### 3. Run Migration (Real)\n```bash\nbd migrate --hash-ids 2\u003e\u00261 | tee migration-log.txt\n\n# Expected output:\n# ✓ Backup created: .beads/beads.db.backup-1234567890\n# ✓ Generated 150 hash IDs\n# ✓ No hash collisions detected\n# ✓ Updated issues table schema\n# ✓ Updated 150 issue IDs\n# ✓ Updated 87 dependencies\n# ✓ Updated 234 text references\n# ✓ Exported to .beads/beads.jsonl\n# ✓ Migration complete!\n```\n\n### 4. Validation\n\n#### Database Integrity\n```bash\n# Check all issues have hash IDs\nbd list | grep -v \"bd-[a-f0-9]\\{8\\}\" \u0026\u0026 echo \"FAIL: Non-hash IDs found\"\n\n# Check all issues have aliases\nsqlite3 .beads/beads.db \"SELECT COUNT(*) FROM issues WHERE alias IS NULL\"\n# Should be 0\n\n# Check no alias duplicates\nsqlite3 .beads/beads.db \"SELECT alias, COUNT(*) FROM issues GROUP BY alias HAVING COUNT(*) \u003e 1\"\n# Should be empty\n```\n\n#### Functionality Tests\n```bash\n# Test show by hash ID\nbd show bd-\n\n# Test show by alias\nbd show #1\n\n# Test create new issue\nbd create \"Test issue after migration\" -p 2\n# Should get hash ID + alias\n\n# Test update\nbd update #1 --priority 1\n\n# Test dependencies\nbd dep tree #1\n\n# Test export\nbd export\ngit diff .beads/beads.jsonl\n# Should show hash IDs\n```\n\n#### Text Reference Validation\n```bash\n# Check that old IDs were updated in descriptions\ngrep -r \"bd-[0-9]\\{1,3\\}[^a-f0-9]\" .beads/beads.jsonl \u0026\u0026 echo \"FAIL: Old ID format found\"\n\n# Verify hash ID references exist\ngrep -o \"bd-[a-f0-9]\\{8\\}\" .beads/beads.jsonl | sort -u | wc -l\n# Should match number of hash IDs\n```\n\n### 5. Commit Migration\n```bash\ngit add .beads/beads.jsonl .beads/beads.db\ngit commit -m \"Migrate to hash-based IDs (v2.0)\n\n- Migrated 150 issues to hash IDs\n- Preserved aliases (#1-#150)\n- Updated 87 dependencies\n- Updated 234 text references\n- Zero hash collisions\n\nMigration log: migration-log.txt\"\n\ngit push origin hash-id-migration\n```\n\n### 6. Create PR\n```bash\ngh pr create --title \"Migrate to hash-based IDs (v2.0)\" --body \"## Summary\nMigrates beads project to hash-based IDs as part of v2.0 release.\n\n## Migration Stats\n- Issues migrated: 150\n- Dependencies updated: 87\n- Text references updated: 234\n- Hash collisions: 0\n- Aliases assigned: 150\n\n## Validation\n- ✅ All tests pass\n- ✅ Database integrity verified\n- ✅ All workflows tested (show, update, create, deps)\n- ✅ Text references updated correctly\n- ✅ Export produces valid JSONL\n\n## Files Changed\n- `.beads/beads.jsonl` - Hash IDs in all entries\n- `.beads/beads.db` - Schema updated with aliases\n\n## Rollback\nIf issues arise:\n\\`\\`\\`bash\nmv .beads.backup-1234567890 .beads\nbd export\n\\`\\`\\`\n\nSee migration-log.txt for full details.\"\n```\n\n### 7. Merge and Cleanup\n```bash\n# After PR approval\ngit checkout main\ngit merge hash-id-migration\ngit push origin main\n\n# Tag release\ngit tag v2.0.0\ngit push origin v2.0.0\n\n# Cleanup\nrm migration-log.txt migration-plan.txt\ngit checkout .beads.backup-* # Keep in git history\n```\n\n## Rollback Procedure\nIf migration fails or has issues:\n\n```bash\n# Restore backup\nmv .beads .beads.failed-migration\nmv .beads.backup-1234567890 .beads\n\n# Regenerate JSONL\nbd export\n\n# Verify restoration\nbd list\ngit diff .beads/beads.jsonl\n\n# Cleanup\ngit checkout hash-id-migration\ngit reset --hard main\n```\n\n## Post-Migration Communication\n\n### GitHub Issue/Discussion\n```markdown\n## Beads v2.0 Released: Hash-Based IDs\n\nWe've migrated beads to hash-based IDs! 🎉\n\n**What changed:**\n- Issues now use hash IDs (bd-af78e9a2) instead of sequential (bd-42)\n- Human-friendly aliases (#42) for easy reference\n- Zero collision risk in distributed workflows\n\n**Action required:**\nIf you have a local clone, you need to migrate:\n\n\\`\\`\\`bash\ngit pull origin main\nbd migrate --hash-ids\ngit push origin main\n\\`\\`\\`\n\nSee MIGRATION.md for details.\n\n**Benefits:**\n- ✅ No more ID collisions\n- ✅ Work offline without coordination\n- ✅ Simpler codebase (-2,100 LOC)\n\nQuestions? Reply here or see docs/HASH_IDS.md\n```\n\n## Success Criteria\n- [ ] Migration completes without errors\n- [ ] All validation checks pass\n- [ ] PR merged to main\n- [ ] v2.0.0 tagged and released\n- [ ] Documentation updated\n- [ ] Community notified\n- [ ] No rollback needed within 1 week\n\n## Files to Create\n- migration-log.txt (transient)\n- migration-plan.txt (transient)\n\n## Timeline\nExecute after all other bd-165 tasks complete (estimated: ~8 weeks from start)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-29T21:29:28.591526-07:00","updated_at":"2025-10-29T21:29:28.591526-07:00","dependencies":[{"issue_id":"bd-178","depends_on_id":"bd-165","type":"parent-child","created_at":"2025-10-29T21:29:28.59248-07:00","created_by":"stevey"},{"issue_id":"bd-178","depends_on_id":"bd-173","type":"blocks","created_at":"2025-10-29T21:29:28.593033-07:00","created_by":"stevey"},{"issue_id":"bd-178","depends_on_id":"bd-175","type":"blocks","created_at":"2025-10-29T21:29:28.593437-07:00","created_by":"stevey"},{"issue_id":"bd-178","depends_on_id":"bd-176","type":"blocks","created_at":"2025-10-29T21:29:28.593876-07:00","created_by":"stevey"},{"issue_id":"bd-178","depends_on_id":"bd-177","type":"blocks","created_at":"2025-10-29T21:29:28.594521-07:00","created_by":"stevey"}]} +{"id":"bd-179","content_hash":"b4ee73e439a133a77e5df27e3e457674dbc9968fdbee0dc630175726960bb8cf","title":"Improve integration test coverage for stateful features","description":"","design":"## Context\n\nbd-160 revealed a critical gap: the export deduplication feature had unit tests but no integration tests simulating real-world git operations. This led to silent data loss in production.\n\n## Root Cause\n- Unit tests only tested functions in isolation\n- No integration tests for git operations (pull, reset, checkout) modifying JSONL\n- No tests validating export_hashes and JSONL stay in sync\n- Missing tests for stateful distributed system interactions (DB + JSONL + git)\n\n## Completed (bd-160)\n✓ TestJSONLIntegrityValidation - unit tests for validation logic\n✓ TestImportClearsExportHashes - tests import clears hashes\n✓ TestExportIntegrityAfterJSONLTruncation - simulates git reset (would have caught bd-160)\n✓ TestExportIntegrityAfterJSONLDeletion - tests recovery from file deletion\n✓ TestMultipleExportsStayConsistent - tests repeated exports\n\n## Still Needed (High Priority)\n1. Multi-repo sync test - two clones staying in sync after push/pull\n2. Auto-flush integration test - JSONL integrity preserved during auto-flush\n3. Daemon auto-sync integration test - complex state management\n4. Import after corruption test - recovery from partial data loss\n\n## Medium Priority\n- Partial export failure handling (disk full, network interruption)\n- Concurrent export/import race conditions\n- Large dataset performance tests (1000+ issues)\n- Export hash migration tests (version upgrades)\n\n## Testing Principles\n1. Test real-world scenarios: git ops, user errors, system failures, concurrent ops\n2. Integration tests for stateful systems (DB + files + git)\n3. Regression test for every bug fix\n4. Test invariants: JSONL count == DB count, hash consistency, etc.\n\n## Key Lesson\nStateful distributed systems need integration tests, not just unit tests.","acceptance_criteria":"- [ ] Multi-repo sync test implemented\n- [ ] Auto-flush integration test implemented \n- [ ] Daemon auto-sync integration test implemented\n- [ ] Testing guidelines added to CONTRIBUTING.md\n- [ ] CI runs integration tests\n- [ ] All critical workflows have integration test coverage","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-29T21:53:15.397137-07:00","updated_at":"2025-10-29T21:53:15.397137-07:00"} {"id":"bd-18","content_hash":"8a8df680150f73fef6ac9cede6a1b2b0033406b35553a8a3795b13a542cd62f1","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.963392-07:00","updated_at":"2025-10-28T14:14:55.724226-07:00","closed_at":"2025-10-28T14:14:55.724226-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-18","depends_on_id":"bd-26","type":"parent-child","created_at":"2025-10-27T20:30:19.968126-07:00","created_by":"daemon"}]} {"id":"bd-19","content_hash":"af7f41ff73c3aaba006d9cfbf8e35332e25d5b42f9e620b5e94d41c05550ea81","title":"Extract SQLite migrations into separate files","description":"The file `internal/storage/sqlite/sqlite.go` is 2,136 lines and contains 11 sequential migrations alongside core storage logic. Extract migrations into a versioned system.\n\nCurrent issues:\n- 11 migration functions mixed with core logic\n- Hard to see migration history\n- Sequential migrations slow database open\n- No clear migration versioning\n\nMigration functions to extract:\n- `migrateDirtyIssuesTable()`\n- `migrateIssueCountersTable()`\n- `migrateExternalRefColumn()`\n- `migrateCompositeIndexes()`\n- `migrateClosedAtConstraint()`\n- `migrateCompactionColumns()`\n- `migrateSnapshotsTable()`\n- `migrateCompactionConfig()`\n- `migrateCompactedAtCommitColumn()`\n- `migrateExportHashesTable()`\n- Plus 1 more (11 total)\n\nTarget structure:\n```\ninternal/storage/sqlite/\n├── sqlite.go # Core storage (~800 lines)\n├── schema.go # Table definitions (~200 lines)\n├── migrations.go # Migration orchestration (~200 lines)\n└── migrations/ # Individual migrations\n ├── 001_initial_schema.go\n ├── 002_dirty_issues.go\n ├── 003_issue_counters.go\n [... through 011_export_hashes.go]\n```\n\nBenefits:\n- Clear migration history\n- Each migration self-contained\n- Easier to review migration changes in PRs\n- Future migrations easier to add","acceptance_criteria":"- All 11 migrations extracted to separate files\n- Migration version tracking in database\n- Migrations run in order on fresh database\n- Existing databases upgrade correctly\n- All tests pass: `go test ./internal/storage/sqlite/...`\n- Database initialization time unchanged or improved\n- Add migration rollback capability (optional, nice-to-have)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:47.870671-07:00","updated_at":"2025-10-27T22:22:23.81842-07:00","labels":["database","phase-2","refactor"],"dependencies":[{"issue_id":"bd-19","depends_on_id":"bd-26","type":"parent-child","created_at":"2025-10-27T20:30:47.875564-07:00","created_by":"daemon"}]} {"id":"bd-2","content_hash":"4ad564b5b844f5673cd8ec6355ad921cbf71e4fbd6d0a6aa5f4e9c4e3222408e","title":"Clean up linter errors (914 total issues)","description":"The codebase has 914 linter issues reported by golangci-lint. While many are documented as baseline in LINTING.md, we should clean these up systematically to improve code quality and maintainability.","design":"Break down by linter category, prioritizing high-impact issues:\n1. dupl (7) - Code duplication\n2. goconst (12) - Repeated strings\n3. gocyclo (11) - High complexity functions\n4. revive (78) - Style issues\n5. gosec (102) - Security warnings\n6. errcheck (683) - Unchecked errors (many in tests)","acceptance_criteria":"All linter categories reduced to acceptable levels, with remaining baseline documented in LINTING.md","notes":"Reduced from 56 to 41 issues locally, then to 0 issues.\n\n**Fixed in commits:**\n- c2c7eda: Fixed 15 actual errors (dupl, gosec, revive, staticcheck, unparam)\n- 963181d: Configured exclusions to get to 0 issues locally\n\n**Current status:**\n- ✅ Local: golangci-lint reports 0 issues\n- ❌ CI: Still failing (see [deleted:bd-50])\n\n**Problem:**\nConfig v2 format or golangci-lint-action@v8 compatibility issue causing CI to fail despite local success.\n\n**Next:** Debug [deleted:bd-50] to fix CI/local discrepancy","status":"in_progress","priority":2,"issue_type":"epic","created_at":"2025-10-24T01:01:12.997982-07:00","updated_at":"2025-10-28T16:20:02.454709-07:00"} @@ -131,7 +132,7 @@ {"id":"bd-69","content_hash":"a4e81b23d88d41c8fd3fe31fb7ef387f99cb54ea42a6baa210ede436ecce3288","title":"Replace getStorageForRequest with Direct Access","description":"Replace all getStorageForRequest(req) calls with s.storage","acceptance_criteria":"- No references to getStorageForRequest() in codebase (except in deleted file)\n- All handlers use s.storage directly\n- Code compiles without errors\n\nFiles to update:\n- internal/rpc/server_issues_epics.go (~8 calls)\n- internal/rpc/server_labels_deps_comments.go (~4 calls)\n- internal/rpc/server_compact.go (~2 calls)\n- internal/rpc/server_export_import_auto.go (~2 calls)\n- internal/rpc/server_routing_validation_diagnostics.go (~1 call)\n\nPattern: store, err := s.getStorageForRequest(req) → store := s.storage","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.430127-07:00","updated_at":"2025-10-28T19:20:58.312809-07:00","closed_at":"2025-10-28T19:20:58.312809-07:00"} {"id":"bd-7","content_hash":"e88e5d98a2a5bebc38b3ac505b00687bfe78bd72654bd0c756bceee4a01e15f5","title":"Enforce daemon singleton per workspace with file locking","description":"Agent in ~/src/wyvern discovered 4 simultaneous daemon processes running, causing infinite directory recursion (.beads/.beads/.beads/...). Each daemon used relative paths and created nested .beads/ directories.\n\nRoot cause: No singleton enforcement. Multiple `bd daemon` processes can start in same workspace.\n\nExpected: One daemon per workspace (each workspace = separate .beads/ dir with bd.sock)\nActual: Multiple daemons can run simultaneously in same workspace\n\nNote: Separate git clones = separate workspaces = separate daemons (correct). Git worktrees share .beads/ and have known limitations (documented, use --no-daemon).","design":"Use flock (file locking) on daemon socket or database file to enforce singleton:\n\n1. On daemon start, attempt exclusive lock on .beads/bd.sock or .beads/daemon.lock\n2. If lock held by another process, refuse to start (exit with clear error)\n3. Hold lock for lifetime of daemon process\n4. Release lock on daemon shutdown\n\nAlternative: Use PID file with stale detection (check if PID is still running)\n\nImplementation location: Daemon startup code in cmd/bd/ or internal/daemon/","acceptance_criteria":"1. Starting second daemon process in same workspace fails with clear error\n2. Test: Start daemon, attempt second start, verify failure\n3. Killing daemon releases lock, allowing new daemon to start\n4. No infinite .beads/ directory recursion possible\n5. Works correctly with auto-start mechanism","status":"in_progress","priority":0,"issue_type":"bug","created_at":"2025-10-25T23:13:12.269549-07:00","updated_at":"2025-10-27T22:22:23.814937-07:00"} {"id":"bd-70","content_hash":"c0b1677fe3f4aa3f395ae4d79bff5362632d5db26477bf571c09f9177b8741ef","title":"Event-driven daemon architecture","description":"Replace 5-second polling sync loop with event-driven architecture that reacts instantly to changes. Eliminates stale data issues while reducing CPU ~60%. Key components: FileWatcher (fsnotify), Debouncer (500ms), RPC mutation events, optional git hooks. Target latency: \u003c500ms (vs 5000ms). See event_driven_daemon.md for full design.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-10-28T16:20:02.430479-07:00","updated_at":"2025-10-28T16:30:26.631191-07:00","closed_at":"2025-10-28T16:30:26.631191-07:00"} -{"id":"bd-71","content_hash":"6b2a1aedbdbcb30b98d4a8196801953a1eb22204d63e31954ef9ab6020a7a26b","title":"Create cmd/bd/daemon_watcher.go (~150 LOC)","description":"Implement FileWatcher using fsnotify to watch JSONL file and git refs. Handle platform differences (inotify/FSEvents/ReadDirectoryChangesW). Include edge case handling for file rename, event storm, watcher failure.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.430809-07:00","updated_at":"2025-10-28T16:20:02.430809-07:00"} +{"id":"bd-71","content_hash":"6b2a1aedbdbcb30b98d4a8196801953a1eb22204d63e31954ef9ab6020a7a26b","title":"Create cmd/bd/daemon_watcher.go (~150 LOC)","description":"Implement FileWatcher using fsnotify to watch JSONL file and git refs. Handle platform differences (inotify/FSEvents/ReadDirectoryChangesW). Include edge case handling for file rename, event storm, watcher failure.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.430809-07:00","updated_at":"2025-10-29T22:49:21.971735-07:00","closed_at":"2025-10-29T22:49:21.971735-07:00"} {"id":"bd-72","content_hash":"a596aa8d6114d4938471e181ebc30da5d0315f74fd711a92dbbb83f5d0e7af88","title":"Create cmd/bd/daemon_debouncer.go (~60 LOC)","description":"Implement Debouncer to batch rapid events into single action. Default 500ms, configurable via BEADS_DEBOUNCE_MS. Thread-safe with mutex.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.431118-07:00","updated_at":"2025-10-28T16:20:02.431118-07:00","closed_at":"2025-10-28T12:03:35.614191-07:00"} {"id":"bd-73","content_hash":"27cecaa2dc6cdabb2ae77fd65fbf8dca8f4c536bdf140a13b25cdd16376c9845","title":"Add docs/architecture/event_driven.md","description":"Copy event_driven_daemon.md into docs/ folder. Add to documentation index.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T16:20:02.431399-07:00","updated_at":"2025-10-28T16:20:02.431399-07:00"} {"id":"bd-74","content_hash":"8407a18ee38e96f92e7c7afde2f39b3df6fad409ccd5080243925d8a05fc85c1","title":"Run final validation and cleanup checks","description":"Final validation pass to ensure all cleanup objectives met and no regressions introduced.\n\nValidation checklist:\n1. Dead code verification: `go run golang.org/x/tools/cmd/deadcode@latest -test ./...`\n2. Test coverage: `go test -cover ./...`\n3. Build verification: `go build ./cmd/bd/`\n4. Linting: `golangci-lint run`\n5. Integration tests\n6. Metrics verification\n7. Git clean check\n\nFinal metrics to report:\n- LOC removed: ~____\n- Files deleted: ____\n- Files created: ____\n- Test coverage: ____%\n- Build time: ____ (before/after)\n- Test run time: ____ (before/after)\n\nImpact: Confirms all cleanup objectives achieved successfully","acceptance_criteria":"- Zero unreachable functions per deadcode analyzer\n- All tests pass: `go test ./...`\n- Test coverage maintained or improved\n- Builds cleanly: `go build ./...`\n- Linting shows improvements\n- Integration tests all pass\n- LOC reduction target achieved (~2,500 LOC)\n- No unintended behavior changes\n- Git commit messages document all changes","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-29T20:49:49.131575-07:00","updated_at":"2025-10-29T20:49:49.131575-07:00"} @@ -155,7 +156,7 @@ {"id":"bd-91","content_hash":"0744c30a5397c6c44b949c038af110eaf6453ec3800bff55cb027eecc47ab5b5","title":"Fix TestTwoCloneCollision to compare content not timestamps","description":"The test at beads_twoclone_test.go:204-207 currently compares full JSON output including timestamps, causing false negative failures.\n\nCurrent behavior:\n- Both clones converge to identical semantic content\n- Clone A: test-2=\"Issue from clone A\", test-1=\"Issue from clone B\"\n- Clone B: test-1=\"Issue from clone B\", test-2=\"Issue from clone A\"\n- Titles match IDs correctly, no data corruption\n- Only timestamps differ (expected and acceptable)\n\nFix needed:\n- Replace exact JSON comparison with content-aware comparison\n- Normalize or ignore timestamp fields when asserting convergence\n- Test should PASS after this fix\n\nThis blocks completion of bd-86.","acceptance_criteria":"- Test compares issue content (title, description, status, priority) not timestamps\n- TestTwoCloneCollision passes\n- Both clones shown to have identical semantic content\n- Timestamps explicitly documented as acceptable difference","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T17:58:52.057194-07:00","updated_at":"2025-10-28T18:01:38.751895-07:00","closed_at":"2025-10-28T18:01:38.751895-07:00","dependencies":[{"issue_id":"bd-91","depends_on_id":"bd-90","type":"parent-child","created_at":"2025-10-28T17:58:52.058202-07:00","created_by":"stevey"},{"issue_id":"bd-91","depends_on_id":"bd-86","type":"blocks","created_at":"2025-10-28T17:58:52.05873-07:00","created_by":"stevey"}]} {"id":"bd-92","content_hash":"e006b991353a26f949bc3ae4476849ef785f399f6aca866586eb6fa03d243b35","title":"Add TestThreeCloneCollision for regression protection","description":"Add a 3-clone collision test to document behavior and provide regression protection.\n\nPurpose:\n- Verify content convergence regardless of sync order\n- Document the ID non-determinism behavior (IDs may be assigned differently based on sync order)\n- Provide regression protection for multi-way collisions\n\nTest design:\n- 3 clones create same ID with different content\n- Test two different sync orders (A→B→C vs C→A→B)\n- Assert content sets match (ignore specific ID assignments)\n- Add comment explaining ID non-determinism is expected behavior\n\nKnown limitation:\n- Content always converges correctly (all issues present with correct titles)\n- Numeric ID assignments (test-2 vs test-3) depend on sync order\n- This is acceptable if content convergence is the primary goal","acceptance_criteria":"- TestThreeCloneCollision added to beads_twoclone_test.go (or new file)\n- Tests 3 clones with same ID collision\n- Tests two different sync orders\n- Asserts content convergence (all issues present, correct titles)\n- Documents ID non-determinism in test comments\n- Test passes consistently","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-28T17:59:05.941735-07:00","updated_at":"2025-10-28T18:09:12.717604-07:00","closed_at":"2025-10-28T18:09:12.717604-07:00","dependencies":[{"issue_id":"bd-92","depends_on_id":"bd-90","type":"parent-child","created_at":"2025-10-28T17:59:05.942783-07:00","created_by":"stevey"}]} {"id":"bd-93","content_hash":"b86d4c406dd6783a00683a31c8729ea08e846e0ddbc54211e1e3d6dedb96def4","title":"Document 3-clone ID non-determinism in collision resolution","description":"Document the known behavior of 3+ way collision resolution where ID assignments may vary based on sync order, even though content always converges correctly.\n\nUpdates needed:\n- Update bd-86 notes to mark 2-clone case as solved\n- Document 3-clone ID non-determinism as known limitation\n- Add explanation to ADVANCED.md or collision resolution docs\n- Explain why this happens (pairwise hash comparison is deterministic, but multi-way ID allocation uses sync-order dependent counters)\n- Clarify trade-offs: content convergence ✅ vs ID stability ❌\n\nKey points to document:\n- Hash-based resolution is pairwise deterministic\n- Content always converges correctly (all issues present with correct data)\n- Numeric ID assignments in 3+ way collisions depend on sync order\n- This is acceptable for most use cases (content convergence is primary goal)\n- Full determinism would require complex multi-way comparison","acceptance_criteria":"- bd-86 updated with notes about 2-clone solution being complete\n- 3-clone ID non-determinism documented in ADVANCED.md or similar\n- Explanation includes why it happens and trade-offs\n- Links to TestThreeCloneCollision as demonstration\n- Users understand this is expected behavior, not a bug","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T17:59:21.93014-07:00","updated_at":"2025-10-28T17:59:21.93014-07:00","dependencies":[{"issue_id":"bd-93","depends_on_id":"bd-90","type":"parent-child","created_at":"2025-10-28T17:59:21.938709-07:00","created_by":"stevey"}]} -{"id":"bd-94","content_hash":"d7c5637527778c5c835f5e4b6e15fbd51a3476d6749ab3155b8aeac08a8ef339","title":"Fix N-way collision convergence","description":"Epic to fix the N-way collision convergence problem documented in n-way-collision-convergence.md.\n\n## Problem Summary\nThe current collision resolution implementation works correctly for 2-way collisions but does not converge for 3-way (and by extension N-way) collisions. TestThreeCloneCollision demonstrates this with reproducible failures.\n\n## Root Causes Identified\n1. Pairwise resolution doesn't scale - each clone makes local decisions without global context\n2. DetectCollisions modifies state during detection (line 83-86 in collision.go)\n3. No remapping history - can't track transitive remap chains (test-1 → test-2 → test-3)\n4. Import-time resolution is too late - happens after git merge\n\n## Solution Architecture\nReplace pairwise resolution with deterministic global N-way resolution using:\n- Content-addressable identity (content hashing)\n- Global collision resolution (sort all versions by hash)\n- Read-only detection phase (separate from modification)\n- Idempotent imports (content-first matching)\n\n## Success Criteria\n- TestThreeCloneCollision passes without skipping\n- All clones converge to identical content after final pull\n- No data loss (all issues present in all clones)\n- Works for N workers (test with 5+ clones)\n- Idempotent imports (importing same JSONL multiple times is safe)\n\n## Implementation Phases\nSee child issues for detailed breakdown of each phase.","status":"in_progress","priority":0,"issue_type":"epic","created_at":"2025-10-28T18:36:28.234425-07:00","updated_at":"2025-10-28T19:49:52.776357-07:00"} +{"id":"bd-94","content_hash":"d7c5637527778c5c835f5e4b6e15fbd51a3476d6749ab3155b8aeac08a8ef339","title":"Fix N-way collision convergence","description":"Epic to fix the N-way collision convergence problem documented in n-way-collision-convergence.md.\n\n## Problem Summary\nThe current collision resolution implementation works correctly for 2-way collisions but does not converge for 3-way (and by extension N-way) collisions. TestThreeCloneCollision demonstrates this with reproducible failures.\n\n## Root Causes Identified\n1. Pairwise resolution doesn't scale - each clone makes local decisions without global context\n2. DetectCollisions modifies state during detection (line 83-86 in collision.go)\n3. No remapping history - can't track transitive remap chains (test-1 → test-2 → test-3)\n4. Import-time resolution is too late - happens after git merge\n\n## Solution Architecture\nReplace pairwise resolution with deterministic global N-way resolution using:\n- Content-addressable identity (content hashing)\n- Global collision resolution (sort all versions by hash)\n- Read-only detection phase (separate from modification)\n- Idempotent imports (content-first matching)\n\n## Success Criteria\n- TestThreeCloneCollision passes without skipping\n- All clones converge to identical content after final pull\n- No data loss (all issues present in all clones)\n- Works for N workers (test with 5+ clones)\n- Idempotent imports (importing same JSONL multiple times is safe)\n\n## Implementation Phases\nSee child issues for detailed breakdown of each phase.","status":"closed","priority":0,"issue_type":"epic","created_at":"2025-10-28T18:36:28.234425-07:00","updated_at":"2025-10-29T22:22:33.818711-07:00","closed_at":"2025-10-29T22:22:33.818711-07:00"} {"id":"bd-95","content_hash":"12cd30dee3c08ba58d03e4468e6fe261a47d58c3b75397d9f14f38ee644fab6e","title":"Add content-addressable identity to Issue type","description":"## Overview\nPhase 1: Add content hashing to enable global identification of issues regardless of their assigned IDs.\n\n## Current Problem\nThe system identifies issues only by ID (e.g., test-1, test-2). When multiple clones create the same ID with different content, there's no way to identify that these are semantically different issues without comparing all fields.\n\n## Solution\nAdd a ContentHash field to the Issue type that represents the canonical content fingerprint.\n\n## Implementation Tasks\n\n### 1. Add ContentHash field to Issue type\nFile: internal/types/types.go\n```go\ntype Issue struct {\n ID string\n ContentHash string // SHA256 of canonical content\n // ... existing fields\n}\n```\n\n### 2. Add content hash computation method\nUse existing hashIssueContent from collision.go:186 as foundation:\n```go\nfunc (i *Issue) ComputeContentHash() string {\n return hashIssueContent(i)\n}\n```\n\n### 3. Compute hash at creation time\n- Modify CreateIssue to compute and store ContentHash\n- Modify CreateIssues (batch) to compute hashes\n\n### 4. Compute hash at import time \n- Modify ImportIssues to compute ContentHash for all incoming issues\n- Store hash in database\n\n### 5. Add database column\n- Add migration to add content_hash column to issues table\n- Update SELECT/INSERT statements to include content_hash\n- Index on content_hash for fast lookups\n\n### 6. Populate existing issues\n- Add migration step to compute ContentHash for all existing issues\n- Use hashIssueContent function\n\n## Acceptance Criteria\n- Issue type has ContentHash field\n- Hash is computed automatically at creation time\n- Hash is computed for imported issues\n- Database stores content_hash column\n- All existing issues have non-empty ContentHash\n- Hash is deterministic (same content → same hash)\n- Hash excludes ID, timestamps (only semantic content)\n\n## Files to Modify\n- internal/types/types.go\n- internal/storage/sqlite/sqlite.go (schema, CreateIssue, CreateIssues)\n- internal/storage/sqlite/migrations.go (new migration)\n- internal/importer/importer.go (compute hash during import)\n- cmd/bd/create.go (compute hash at creation)\n\n## Testing\n- Unit test: same content produces same hash\n- Unit test: different content produces different hash \n- Unit test: hash excludes ID and timestamps\n- Integration test: hash persists in database\n- Migration test: existing issues get hashes populated","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:36:44.914967-07:00","updated_at":"2025-10-28T18:57:10.985198-07:00","closed_at":"2025-10-28T18:57:10.985198-07:00","dependencies":[{"issue_id":"bd-95","depends_on_id":"bd-94","type":"parent-child","created_at":"2025-10-28T18:39:20.547325-07:00","created_by":"daemon"}]} {"id":"bd-96","content_hash":"49aad5fa2497f7f88fb74d54553825b93c1021ed7db04cfb2e58682699d8dca9","title":"Make DetectCollisions read-only (separate detection from modification)","description":"## Overview\nPhase 2: Separate collision detection from state modification to enable safe, composable collision resolution.\n\n## Current Problem\nDetectCollisions (collision.go:38-111) modifies database state during detection:\n- Line 83-86: Deletes issues when content matches but ID differs\n- This violates separation of concerns\n- Causes race conditions when processing multiple issues\n- Makes contentToDBIssue map stale after first deletion\n- Partial failures leave DB in inconsistent state\n\n## Solution\nMake DetectCollisions purely read-only. Move all modifications to a separate ApplyCollisionResolution function.\n\n## Implementation Tasks\n\n### 1. Add RenameDetail to CollisionResult\nFile: internal/storage/sqlite/collision.go\n```go\ntype CollisionResult struct {\n ExactMatches []string\n Collisions []*CollisionDetail\n NewIssues []string\n Renames []*RenameDetail // NEW\n}\n\ntype RenameDetail struct {\n OldID string // ID in database\n NewID string // ID in incoming\n Issue *types.Issue // The issue with new ID\n}\n```\n\n### 2. Remove deletion from DetectCollisions\nReplace lines 83-86:\n```go\n// OLD (DELETE THIS):\nif err := s.DeleteIssue(ctx, dbMatch.ID); err != nil {\n return nil, fmt.Errorf(\"failed to delete renamed issue...\")\n}\n\n// NEW (ADD THIS):\nresult.Renames = append(result.Renames, \u0026RenameDetail{\n OldID: dbMatch.ID,\n NewID: incoming.ID,\n Issue: incoming,\n})\ncontinue // Don't mark as NewIssue yet\n```\n\n### 3. Create ApplyCollisionResolution function\nNew function to apply all modifications atomically:\n```go\nfunc ApplyCollisionResolution(ctx context.Context, s *SQLiteStorage,\n result *CollisionResult, mapping map[string]string) error {\n \n // Phase 1: Handle renames (delete old IDs)\n for _, rename := range result.Renames {\n if err := s.DeleteIssue(ctx, rename.OldID); err != nil {\n return fmt.Errorf(\"failed to delete renamed issue %s: %w\", \n rename.OldID, err)\n }\n }\n \n // Phase 2: Create new IDs (from mapping)\n // Phase 3: Update references\n return nil\n}\n```\n\n### 4. Update callers to use two-phase approach\nFile: internal/importer/importer.go (handleCollisions)\n```go\n// Phase 1: Detect (read-only)\ncollisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)\n\n// Phase 2: Resolve (compute mapping)\nmapping, err := sqlite.ResolveNWayCollisions(ctx, sqliteStore, collisionResult)\n\n// Phase 3: Apply (modify DB)\nerr = sqlite.ApplyCollisionResolution(ctx, sqliteStore, collisionResult, mapping)\n```\n\n### 5. Update tests\n- Verify DetectCollisions doesn't modify DB\n- Test ApplyCollisionResolution separately\n- Add test for rename detection without modification\n\n## Acceptance Criteria\n- DetectCollisions performs zero writes to database\n- DetectCollisions returns RenameDetail entries for content matches\n- ApplyCollisionResolution handles all modifications\n- All existing tests still pass\n- New test verifies read-only detection\n- contentToDBIssue map stays consistent throughout detection\n\n## Files to Modify\n- internal/storage/sqlite/collision.go (DetectCollisions, new function)\n- internal/importer/importer.go (handleCollisions caller)\n- internal/storage/sqlite/collision_test.go (add tests)\n\n## Testing\n- Unit test: DetectCollisions with content match doesn't delete DB issue\n- Unit test: RenameDetail correctly populated\n- Unit test: ApplyCollisionResolution applies renames\n- Integration test: Full flow still works end-to-end\n\n## Risk Mitigation\nThis is a significant refactor of core collision logic. Recommend:\n1. Add comprehensive tests before modifying\n2. Use feature flag to enable/disable new behavior\n3. Test thoroughly with TestTwoCloneCollision first","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:37:09.652326-07:00","updated_at":"2025-10-28T19:08:17.715416-07:00","closed_at":"2025-10-28T19:08:17.715416-07:00","dependencies":[{"issue_id":"bd-96","depends_on_id":"bd-94","type":"parent-child","created_at":"2025-10-28T18:39:20.570276-07:00","created_by":"daemon"},{"issue_id":"bd-96","depends_on_id":"bd-95","type":"blocks","created_at":"2025-10-28T18:39:28.285653-07:00","created_by":"daemon"}]} {"id":"bd-97","content_hash":"ecebc4a18d5355bafc88e778ee87365717f894d3590d325a97ecf8b3f763d54d","title":"Implement global N-way collision resolution algorithm","description":"## Overview\nPhase 3: Replace pairwise collision resolution with global N-way resolution that produces deterministic results regardless of sync order.\n\n## Current Problem\nScoreCollisions (collision.go:228) compares issues pairwise:\n```go\ncollision.RemapIncoming = existingHash \u003c incomingHash\n```\n\nThis works for 2-way but fails for 3+ way because:\n- Each clone makes local decisions without global context\n- No guarantee intermediate states are consistent\n- Remapping decisions depend on sync order\n- Can't detect transitive remap chains (test-1 → test-2 → test-3)\n\n## Solution\nImplement global resolution that:\n1. Collects ALL versions of same logical issue\n2. Sorts by content hash (deterministic)\n3. Assigns sequential IDs based on sorted order\n4. All clones converge to same assignments\n\n## Implementation Tasks\n\n### 1. Create ResolveNWayCollisions function\nFile: internal/storage/sqlite/collision.go\n\nReplace ScoreCollisions with:\n```go\n// ResolveNWayCollisions handles N-way collisions deterministically.\n// Groups all versions with same base ID, sorts by content hash,\n// assigns sequential IDs. Returns mapping of old ID → new ID.\nfunc ResolveNWayCollisions(ctx context.Context, s *SQLiteStorage,\n collisions []*CollisionDetail, incoming []*types.Issue) (map[string]string, error) {\n \n if len(collisions) == 0 {\n return make(map[string]string), nil\n }\n \n // Group by base ID pattern (e.g., test-1, test-2 → base \"test-1\")\n groups := groupCollisionsByBaseID(collisions)\n \n idMapping := make(map[string]string)\n \n for baseID, versions := range groups {\n // 1. Collect all unique versions by content hash\n uniqueVersions := deduplicateVersionsByContentHash(versions)\n \n // 2. Sort by content hash (deterministic!)\n sort.Slice(uniqueVersions, func(i, j int) bool {\n return uniqueVersions[i].ContentHash \u003c uniqueVersions[j].ContentHash\n })\n \n // 3. Assign sequential IDs based on sorted order\n prefix := extractPrefix(baseID)\n baseNum := extractNumber(baseID)\n \n for i, version := range uniqueVersions {\n targetID := fmt.Sprintf(\"%s-%d\", prefix, baseNum+i)\n \n // Map this version to its deterministic ID\n if version.ID != targetID {\n idMapping[version.ID] = targetID\n }\n }\n }\n \n return idMapping, nil\n}\n```\n\n### 2. Implement helper functions\n\n```go\n// groupCollisionsByBaseID groups collisions by their logical base ID\nfunc groupCollisionsByBaseID(collisions []*CollisionDetail) map[string][]*types.Issue {\n groups := make(map[string][]*types.Issue)\n for _, c := range collisions {\n baseID := c.ID // All share same ID (that's why they collide)\n groups[baseID] = append(groups[baseID], c.ExistingIssue, c.IncomingIssue)\n }\n return groups\n}\n\n// deduplicateVersionsByContentHash keeps one issue per unique content hash\nfunc deduplicateVersionsByContentHash(issues []*types.Issue) []*types.Issue {\n seen := make(map[string]*types.Issue)\n for _, issue := range issues {\n if _, found := seen[issue.ContentHash]; !found {\n seen[issue.ContentHash] = issue\n }\n }\n result := make([]*types.Issue, 0, len(seen))\n for _, issue := range seen {\n result = append(result, issue)\n }\n return result\n}\n```\n\n### 3. Update handleCollisions in importer\nFile: internal/importer/importer.go\n\nReplace ScoreCollisions call with:\n```go\n// OLD:\nif err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {\n return nil, fmt.Errorf(\"failed to score collisions: %w\", err)\n}\n\n// NEW:\nidMapping, err := sqlite.ResolveNWayCollisions(ctx, sqliteStore, \n collisionResult.Collisions, issues)\nif err != nil {\n return nil, fmt.Errorf(\"failed to resolve collisions: %w\", err)\n}\n```\n\n### 4. Update RemapCollisions\nRemapCollisions currently uses collision.RemapIncoming field. Update to use idMapping directly:\n- Remove RemapIncoming logic\n- Use idMapping to determine what to remap\n- Simplify to just apply the computed mapping\n\n### 5. Add comprehensive tests\n\nTest cases:\n1. 3-way collision with different content → 3 sequential IDs\n2. 3-way collision with 2 identical content → 2 IDs (dedupe works)\n3. Sync order independence (A→B→C vs C→A→B produce same result)\n4. Content hash ordering is respected\n5. Works with 5+ clones\n\n## Acceptance Criteria\n- ResolveNWayCollisions implemented and replaces ScoreCollisions\n- Groups all versions of same ID together\n- Deduplicates by content hash\n- Sorts by content hash deterministically\n- Assigns sequential IDs starting from base ID\n- Returns complete mapping (old ID → new ID)\n- All clones converge to same ID assignments\n- Works for arbitrary N-way collisions\n- TestThreeCloneCollision passes (or gets much closer)\n\n## Files to Modify\n- internal/storage/sqlite/collision.go (new function, helpers)\n- internal/importer/importer.go (call new function)\n- internal/storage/sqlite/collision_test.go (comprehensive tests)\n\n## Testing Strategy\n\n### Unit Tests\n- groupCollisionsByBaseID correctly groups\n- deduplicateVersionsByContentHash removes duplicates\n- Sorting by hash is stable and deterministic\n- Sequential ID assignment is correct\n\n### Integration Tests\n- 3-way collision resolves to 3 issues\n- Sync order doesn't affect final IDs\n- Content hash ordering determines winner\n\n### Property Tests\n- For any N clones with same content, all converge to same IDs\n- Idempotent: running resolution twice produces same result\n\n## Dependencies\n- Requires bd-95 (ContentHash field) to be completed first\n- Requires bd-96 (read-only detection) for clean integration\n\n## Notes\nThis is the core algorithm that enables convergence. The key insight:\n**Sort by content hash globally, not pairwise comparison.**","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:37:42.85616-07:00","updated_at":"2025-10-28T20:03:26.675257-07:00","closed_at":"2025-10-28T20:03:26.675257-07:00","dependencies":[{"issue_id":"bd-97","depends_on_id":"bd-94","type":"parent-child","created_at":"2025-10-28T18:39:20.593102-07:00","created_by":"daemon"},{"issue_id":"bd-97","depends_on_id":"bd-95","type":"blocks","created_at":"2025-10-28T18:39:28.30886-07:00","created_by":"daemon"},{"issue_id":"bd-97","depends_on_id":"bd-96","type":"blocks","created_at":"2025-10-28T18:39:28.336312-07:00","created_by":"daemon"}]} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 240b273c..5cb091ab 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "beads", "source": "./", "description": "AI-supervised issue tracker for coding workflows", - "version": "0.18.0" + "version": "0.19.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b45f0266..2eba21b8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "beads", "description": "AI-supervised issue tracker for coding workflows. Manage tasks, discover work, and maintain context with simple CLI commands.", - "version": "0.18.0", + "version": "0.19.0", "author": { "name": "Steve Yegge", "url": "https://github.com/steveyegge" diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index 2c707236..66a92972 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -160,6 +160,12 @@ func autoImportIfNewer() { return } + // Clear export_hashes before import to prevent staleness (bd-160) + // Import operations may add/update issues, so export_hashes entries become invalid + if err := store.ClearAllExportHashes(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear export_hashes before import: %v\n", err) + } + // Use shared import logic (bd-157) opts := ImportOptions{ ResolveCollisions: true, // Auto-import always resolves collisions @@ -433,6 +439,54 @@ func shouldSkipExport(ctx context.Context, issue *types.Issue) (bool, error) { return currentHash == storedHash, nil } +// validateJSONLIntegrity checks if JSONL file hash matches stored hash. +// If mismatch detected, clears export_hashes and logs warning (bd-160). +func validateJSONLIntegrity(ctx context.Context, jsonlPath string) error { + // Get stored JSONL file hash + storedHash, err := store.GetJSONLFileHash(ctx) + if err != nil { + return fmt.Errorf("failed to get stored JSONL hash: %w", err) + } + + // If no hash stored, this is first export - skip validation + if storedHash == "" { + return nil + } + + // Read current JSONL file + jsonlData, err := os.ReadFile(jsonlPath) + if err != nil { + if os.IsNotExist(err) { + // JSONL doesn't exist but we have a stored hash - clear export_hashes + fmt.Fprintf(os.Stderr, "⚠️ WARNING: JSONL file missing but export_hashes exist. Clearing export_hashes.\n") + if err := store.ClearAllExportHashes(ctx); err != nil { + return fmt.Errorf("failed to clear export_hashes: %w", err) + } + return nil + } + return fmt.Errorf("failed to read JSONL file: %w", err) + } + + // Compute current JSONL hash + hasher := sha256.New() + hasher.Write(jsonlData) + currentHash := hex.EncodeToString(hasher.Sum(nil)) + + // Compare hashes + if currentHash != storedHash { + fmt.Fprintf(os.Stderr, "⚠️ WARNING: JSONL file hash mismatch detected (bd-160)\n") + fmt.Fprintf(os.Stderr, " This indicates JSONL and export_hashes are out of sync.\n") + fmt.Fprintf(os.Stderr, " Clearing export_hashes to force full re-export.\n") + + // Clear export_hashes to force full re-export + if err := store.ClearAllExportHashes(ctx); err != nil { + return fmt.Errorf("failed to clear export_hashes: %w", err) + } + } + + return nil +} + func writeJSONLAtomic(jsonlPath string, issues []*types.Issue) ([]string, error) { // Sort issues by ID for consistent output sort.Slice(issues, func(i, j int) bool { @@ -600,6 +654,13 @@ func flushToJSONL() { } ctx := context.Background() + + // Validate JSONL integrity before export (bd-160) + // This detects if JSONL and export_hashes are out of sync (e.g., after git operations) + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + recordFailure(fmt.Errorf("JSONL integrity check failed: %w", err)) + return + } // Determine which issues to export var dirtyIDs []string @@ -711,6 +772,11 @@ func flushToJSONL() { if err := store.SetMetadata(ctx, "last_import_hash", exportedHash); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to update last_import_hash after export: %v\n", err) } + + // Store JSONL file hash for integrity validation (bd-160) + if err := store.SetJSONLFileHash(ctx, exportedHash); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update jsonl_file_hash after export: %v\n", err) + } } // Success! diff --git a/cmd/bd/daemon_debouncer.go b/cmd/bd/daemon_debouncer.go index 3641325f..9ac0808f 100644 --- a/cmd/bd/daemon_debouncer.go +++ b/cmd/bd/daemon_debouncer.go @@ -12,6 +12,7 @@ type Debouncer struct { timer *time.Timer duration time.Duration action func() + seq uint64 // Sequence number to prevent stale timer fires } // NewDebouncer creates a new debouncer with the given duration and action. @@ -34,11 +35,21 @@ func (d *Debouncer) Trigger() { d.timer.Stop() } + // Increment sequence number to invalidate any pending timers + d.seq++ + currentSeq := d.seq + d.timer = time.AfterFunc(d.duration, func() { - d.action() d.mu.Lock() - d.timer = nil - d.mu.Unlock() + defer d.mu.Unlock() + + // Only fire if this is still the latest trigger + if d.seq == currentSeq { + d.timer = nil + d.mu.Unlock() // Unlock before calling action to avoid holding lock during callback + d.action() + d.mu.Lock() // Re-lock for defer + } }) } diff --git a/cmd/bd/daemon_watcher.go b/cmd/bd/daemon_watcher.go index 8faa8ba1..ab9be605 100644 --- a/cmd/bd/daemon_watcher.go +++ b/cmd/bd/daemon_watcher.go @@ -13,16 +13,20 @@ import ( // FileWatcher monitors JSONL and git ref changes using filesystem events or polling. type FileWatcher struct { - watcher *fsnotify.Watcher - debouncer *Debouncer - jsonlPath string - pollingMode bool - lastModTime time.Time - lastExists bool - lastSize int64 - pollInterval time.Duration - gitRefsPath string - cancel context.CancelFunc + watcher *fsnotify.Watcher + debouncer *Debouncer + jsonlPath string + parentDir string + pollingMode bool + lastModTime time.Time + lastExists bool + lastSize int64 + pollInterval time.Duration + gitRefsPath string + gitHeadPath string + lastHeadModTime time.Time + lastHeadExists bool + cancel context.CancelFunc } // NewFileWatcher creates a file watcher for the given JSONL path. @@ -31,6 +35,7 @@ type FileWatcher struct { func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) { fw := &FileWatcher{ jsonlPath: jsonlPath, + parentDir: filepath.Dir(jsonlPath), debouncer: NewDebouncer(500*time.Millisecond, onChanged), pollInterval: 5 * time.Second, } @@ -46,8 +51,16 @@ func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) { fallbackEnv := os.Getenv("BEADS_WATCHER_FALLBACK") fallbackDisabled := fallbackEnv == "false" || fallbackEnv == "0" - // Store git refs path for filtering - fw.gitRefsPath = filepath.Join(filepath.Dir(jsonlPath), "..", ".git", "refs", "heads") + // Store git paths for filtering + gitDir := filepath.Join(fw.parentDir, "..", ".git") + fw.gitRefsPath = filepath.Join(gitDir, "refs", "heads") + fw.gitHeadPath = filepath.Join(gitDir, "HEAD") + + // Get initial git HEAD state for polling + if stat, err := os.Stat(fw.gitHeadPath); err == nil { + fw.lastHeadModTime = stat.ModTime() + fw.lastHeadExists = true + } watcher, err := fsnotify.NewWatcher() if err != nil { @@ -63,22 +76,33 @@ func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) { fw.watcher = watcher - // Watch the JSONL file - if err := watcher.Add(jsonlPath); err != nil { - watcher.Close() - if fallbackDisabled { - return nil, fmt.Errorf("failed to watch JSONL and BEADS_WATCHER_FALLBACK is disabled: %w", err) - } - // Fall back to polling mode - fmt.Fprintf(os.Stderr, "Warning: failed to watch JSONL (%v), falling back to polling mode (%v interval)\n", err, fw.pollInterval) - fmt.Fprintf(os.Stderr, "Set BEADS_WATCHER_FALLBACK=false to disable this fallback and require fsnotify\n") - fw.pollingMode = true - fw.watcher = nil - return fw, nil + // Watch the parent directory (catches creates/renames) + if err := watcher.Add(fw.parentDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to watch parent directory %s: %v\n", fw.parentDir, err) } - // Also watch .git/refs/heads for branch changes (best effort) + // Watch the JSONL file (may not exist yet) + if err := watcher.Add(jsonlPath); err != nil { + if os.IsNotExist(err) { + // File doesn't exist yet - rely on parent dir watch + fmt.Fprintf(os.Stderr, "Info: JSONL file %s doesn't exist yet, watching parent directory\n", jsonlPath) + } else { + watcher.Close() + if fallbackDisabled { + return nil, fmt.Errorf("failed to watch JSONL and BEADS_WATCHER_FALLBACK is disabled: %w", err) + } + // Fall back to polling mode + fmt.Fprintf(os.Stderr, "Warning: failed to watch JSONL (%v), falling back to polling mode (%v interval)\n", err, fw.pollInterval) + fmt.Fprintf(os.Stderr, "Set BEADS_WATCHER_FALLBACK=false to disable this fallback and require fsnotify\n") + fw.pollingMode = true + fw.watcher = nil + return fw, nil + } + } + + // Also watch .git/refs/heads and .git/HEAD for branch changes (best effort) _ = watcher.Add(fw.gitRefsPath) // Ignore error - not all setups have this + _ = watcher.Add(fw.gitHeadPath) // Ignore error - not all setups have this return fw, nil } @@ -97,6 +121,8 @@ func (fw *FileWatcher) Start(ctx context.Context, log daemonLogger) { } go func() { + jsonlBase := filepath.Base(fw.jsonlPath) + for { select { case event, ok := <-fw.watcher.Events: @@ -104,30 +130,43 @@ func (fw *FileWatcher) Start(ctx context.Context, log daemonLogger) { return } - // Handle JSONL write events - if event.Name == fw.jsonlPath && event.Op&fsnotify.Write != 0 { - log.log("File change detected: %s", event.Name) + // Handle parent directory events (file create/replace) + if event.Name == filepath.Join(fw.parentDir, jsonlBase) && event.Op&fsnotify.Create != 0 { + log.log("JSONL file created: %s", event.Name) + // Ensure we're watching the file directly + _ = fw.watcher.Add(fw.jsonlPath) fw.debouncer.Trigger() + continue + } + + // Handle JSONL write/chmod events + if event.Name == fw.jsonlPath && event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Chmod) != 0 { + log.log("File change detected: %s (op: %v)", event.Name, event.Op) + fw.debouncer.Trigger() + continue } // Handle JSONL removal/rename (e.g., git checkout) if event.Name == fw.jsonlPath && (event.Op&fsnotify.Remove != 0 || event.Op&fsnotify.Rename != 0) { log.log("JSONL removed/renamed, re-establishing watch") fw.watcher.Remove(fw.jsonlPath) - // Brief wait for file to be recreated - time.Sleep(100 * time.Millisecond) - if err := fw.watcher.Add(fw.jsonlPath); err != nil { - log.log("Failed to re-watch JSONL: %v", err) - } else { - // File was recreated, trigger to reload - fw.debouncer.Trigger() - } + // Retry with exponential backoff + fw.reEstablishWatch(ctx, log) + continue + } + + // Handle .git/HEAD changes (branch switches) + if event.Name == fw.gitHeadPath && event.Op&(fsnotify.Write|fsnotify.Create) != 0 { + log.log("Git HEAD change detected: %s", event.Name) + fw.debouncer.Trigger() + continue } // Handle git ref changes (only events under gitRefsPath) if event.Op&fsnotify.Write != 0 && strings.HasPrefix(event.Name, fw.gitRefsPath) { log.log("Git ref change detected: %s", event.Name) fw.debouncer.Trigger() + continue } case err, ok := <-fw.watcher.Errors: @@ -143,6 +182,32 @@ func (fw *FileWatcher) Start(ctx context.Context, log daemonLogger) { }() } +// reEstablishWatch attempts to re-add the JSONL watch with exponential backoff. +func (fw *FileWatcher) reEstablishWatch(ctx context.Context, log daemonLogger) { + delays := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond} + + for _, delay := range delays { + select { + case <-ctx.Done(): + return + case <-time.After(delay): + if err := fw.watcher.Add(fw.jsonlPath); err != nil { + if os.IsNotExist(err) { + log.log("JSONL still missing after %v, retrying...", delay) + continue + } + log.log("Failed to re-watch JSONL after %v: %v", delay, err) + return + } + // Success! + log.log("Successfully re-established JSONL watch after %v", delay) + fw.debouncer.Trigger() + return + } + } + log.log("Failed to re-establish JSONL watch after all retries") +} + // startPolling begins polling for file changes using a ticker. func (fw *FileWatcher) startPolling(ctx context.Context, log daemonLogger) { log.log("Starting polling mode with %v interval", fw.pollInterval) @@ -152,6 +217,9 @@ func (fw *FileWatcher) startPolling(ctx context.Context, log daemonLogger) { for { select { case <-ticker.C: + changed := false + + // Check JSONL file stat, err := os.Stat(fw.jsonlPath) if err != nil { if os.IsNotExist(err) { @@ -161,32 +229,61 @@ func (fw *FileWatcher) startPolling(ctx context.Context, log daemonLogger) { fw.lastModTime = time.Time{} fw.lastSize = 0 log.log("File missing (polling): %s", fw.jsonlPath) - fw.debouncer.Trigger() + changed = true } - continue + } else { + log.log("Polling error: %v", err) + } + } else { + // File exists + if !fw.lastExists { + // File appeared + fw.lastExists = true + fw.lastModTime = stat.ModTime() + fw.lastSize = stat.Size() + log.log("File appeared (polling): %s", fw.jsonlPath) + changed = true + } else if !stat.ModTime().Equal(fw.lastModTime) || stat.Size() != fw.lastSize { + // File exists and existed before - check for changes + fw.lastModTime = stat.ModTime() + fw.lastSize = stat.Size() + log.log("File change detected (polling): %s", fw.jsonlPath) + changed = true } - log.log("Polling error: %v", err) - continue } - // File exists - if !fw.lastExists { - // File appeared - fw.lastExists = true - fw.lastModTime = stat.ModTime() - fw.lastSize = stat.Size() - log.log("File appeared (polling): %s", fw.jsonlPath) - fw.debouncer.Trigger() - continue + // Check .git/HEAD for branch changes + headStat, err := os.Stat(fw.gitHeadPath) + if err != nil { + if os.IsNotExist(err) { + if fw.lastHeadExists { + fw.lastHeadExists = false + fw.lastHeadModTime = time.Time{} + log.log("Git HEAD missing (polling): %s", fw.gitHeadPath) + changed = true + } + } + // Ignore other errors for HEAD - it's optional + } else { + // HEAD exists + if !fw.lastHeadExists { + // HEAD appeared + fw.lastHeadExists = true + fw.lastHeadModTime = headStat.ModTime() + log.log("Git HEAD appeared (polling): %s", fw.gitHeadPath) + changed = true + } else if !headStat.ModTime().Equal(fw.lastHeadModTime) { + // HEAD changed (branch switch) + fw.lastHeadModTime = headStat.ModTime() + log.log("Git HEAD change detected (polling): %s", fw.gitHeadPath) + changed = true + } } - // File exists and existed before - check for changes - if !stat.ModTime().Equal(fw.lastModTime) || stat.Size() != fw.lastSize { - fw.lastModTime = stat.ModTime() - fw.lastSize = stat.Size() - log.log("File change detected (polling): %s", fw.jsonlPath) + if changed { fw.debouncer.Trigger() } + case <-ctx.Done(): return } diff --git a/cmd/bd/export.go b/cmd/bd/export.go index bf8f3db4..0e0c5a41 100644 --- a/cmd/bd/export.go +++ b/cmd/bd/export.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" @@ -273,6 +275,17 @@ Output to stdout by default, or use -o flag for file output.`, // Clear auto-flush state since we just manually exported // This cancels any pending auto-flush timer and marks DB as clean clearAutoFlushState() + + // Store JSONL file hash for integrity validation (bd-160) + jsonlData, err := os.ReadFile(finalPath) + if err == nil { + hasher := sha256.New() + hasher.Write(jsonlData) + fileHash := hex.EncodeToString(hasher.Sum(nil)) + if err := store.SetJSONLFileHash(ctx, fileHash); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update jsonl_file_hash: %v\n", err) + } + } } // If writing to file, atomically replace the target file diff --git a/cmd/bd/export_integrity_integration_test.go b/cmd/bd/export_integrity_integration_test.go new file mode 100644 index 00000000..13acd507 --- /dev/null +++ b/cmd/bd/export_integrity_integration_test.go @@ -0,0 +1,294 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// TestExportIntegrityAfterJSONLTruncation simulates the bd-160 bug scenario. +// This integration test would have caught the export deduplication bug. +func TestExportIntegrityAfterJSONLTruncation(t *testing.T) { + // Setup: Create a database with multiple issues + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl") + + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + // Initialize database + if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue prefix: %v", err) + } + + // Create 10 issues + const numIssues = 10 + var allIssues []*types.Issue + for i := 1; i <= numIssues; i++ { + issue := &types.Issue{ + ID: "bd-" + string(rune('0'+i)), + Title: "Test issue " + string(rune('0'+i)), + Description: "Description " + string(rune('0'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + allIssues = append(allIssues, issue) + + if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue %s: %v", issue.ID, err) + } + } + + // Step 1: Export all issues + exportedIDs, err := writeJSONLAtomic(jsonlPath, allIssues) + if err != nil { + t.Fatalf("initial export failed: %v", err) + } + + if len(exportedIDs) != numIssues { + t.Fatalf("expected %d exported issues, got %d", numIssues, len(exportedIDs)) + } + + // Store JSONL file hash (simulating what the system should do) + jsonlData, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("failed to read JSONL: %v", err) + } + + initialSize := len(jsonlData) + + // Step 2: Simulate git operation that truncates JSONL (the bd-160 scenario) + // This simulates: git reset --hard , git checkout , etc. + truncatedData := jsonlData[:len(jsonlData)/2] // Keep only first half + if err := os.WriteFile(jsonlPath, truncatedData, 0644); err != nil { + t.Fatalf("failed to truncate JSONL: %v", err) + } + + // Verify JSONL is indeed truncated + truncatedSize := len(truncatedData) + if truncatedSize >= initialSize { + t.Fatalf("JSONL should be truncated, but size is %d (was %d)", truncatedSize, initialSize) + } + + // Step 3: Run export again with integrity validation enabled + // Set global store for validateJSONLIntegrity + oldStore := store + store = testStore + defer func() { store = oldStore }() + + // This should detect the mismatch and clear export_hashes + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + t.Fatalf("integrity validation failed: %v", err) + } + + // Step 4: Export all issues again + exportedIDs2, err := writeJSONLAtomic(jsonlPath, allIssues) + if err != nil { + t.Fatalf("second export failed: %v", err) + } + + // Step 5: Verify all issues were exported (not skipped) + if len(exportedIDs2) != numIssues { + t.Errorf("INTEGRITY VIOLATION: expected %d exported issues after truncation, got %d", + numIssues, len(exportedIDs2)) + t.Errorf("This indicates the bug bd-160 would have occurred!") + + // Read JSONL to count actual lines + finalData, _ := os.ReadFile(jsonlPath) + lines := 0 + for _, b := range finalData { + if b == '\n' { + lines++ + } + } + t.Errorf("JSONL has %d lines, DB has %d issues", lines, numIssues) + } + + // Step 6: Verify JSONL has all issues + finalData, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("failed to read final JSONL: %v", err) + } + + // Count newlines to verify all issues present + lineCount := 0 + for _, b := range finalData { + if b == '\n' { + lineCount++ + } + } + + if lineCount != numIssues { + t.Errorf("JSONL should have %d lines (issues), got %d", numIssues, lineCount) + t.Errorf("Data loss detected - this is the bd-160 bug!") + } +} + +// TestExportIntegrityAfterJSONLDeletion tests recovery when JSONL is deleted +func TestExportIntegrityAfterJSONLDeletion(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl") + + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue prefix: %v", err) + } + + // Create issues and export + issue := &types.Issue{ + ID: "bd-1", + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + _, err = writeJSONLAtomic(jsonlPath, []*types.Issue{issue}) + if err != nil { + t.Fatalf("export failed: %v", err) + } + + // Store JSONL hash (would happen in real export) + _ , _ = os.ReadFile(jsonlPath) + + // Set global store + oldStore := store + store = testStore + defer func() { store = oldStore }() + + // Delete JSONL (simulating user error or git clean) + if err := os.Remove(jsonlPath); err != nil { + t.Fatalf("failed to remove JSONL: %v", err) + } + + // Integrity validation should detect missing file + // (In real system, this happens before next export) + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + // Error is OK if file doesn't exist + if !os.IsNotExist(err) { + t.Fatalf("unexpected error: %v", err) + } + } + + // Export again should recreate JSONL + _, err = writeJSONLAtomic(jsonlPath, []*types.Issue{issue}) + if err != nil { + t.Fatalf("export after deletion failed: %v", err) + } + + // Verify JSONL was recreated + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + t.Fatal("JSONL should have been recreated") + } + + // Verify content + newData, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("failed to read recreated JSONL: %v", err) + } + + if len(newData) == 0 { + t.Fatal("Recreated JSONL is empty - data loss!") + } +} + +// TestMultipleExportsStayConsistent tests that repeated exports maintain integrity +func TestMultipleExportsStayConsistent(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl") + + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue prefix: %v", err) + } + + // Create 5 issues + var issues []*types.Issue + for i := 1; i <= 5; i++ { + issue := &types.Issue{ + ID: "bd-" + string(rune('0'+i)), + Title: "Issue " + string(rune('0'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issues = append(issues, issue) + if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + } + + // Export multiple times and verify consistency + for iteration := 0; iteration < 3; iteration++ { + exportedIDs, err := writeJSONLAtomic(jsonlPath, issues) + if err != nil { + t.Fatalf("export iteration %d failed: %v", iteration, err) + } + + if len(exportedIDs) != len(issues) { + t.Errorf("iteration %d: expected %d exports, got %d", + iteration, len(issues), len(exportedIDs)) + } + + // Count lines in JSONL + data, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("failed to read JSONL: %v", err) + } + + lines := 0 + for _, b := range data { + if b == '\n' { + lines++ + } + } + + if lines != len(issues) { + t.Errorf("iteration %d: JSONL has %d lines, expected %d", + iteration, lines, len(issues)) + } + } +} diff --git a/cmd/bd/jsonl_integrity_test.go b/cmd/bd/jsonl_integrity_test.go new file mode 100644 index 00000000..dd38beb4 --- /dev/null +++ b/cmd/bd/jsonl_integrity_test.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +const testActor = "test" + +// TestJSONLIntegrityValidation tests the JSONL integrity validation (bd-160) +func TestJSONLIntegrityValidation(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl") + + // Ensure .beads directory exists + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + // Create database + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer testStore.Close() + + // Set global store for validateJSONLIntegrity + oldStore := store + store = testStore + defer func() { store = oldStore }() + + ctx := context.Background() + + // Initialize database with prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue prefix: %v", err) + } + + // Create a test issue + issue := &types.Issue{ + ID: "bd-1", + Title: "Test issue", + Description: "Test description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := testStore.CreateIssue(ctx, issue, testActor); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Export to JSONL + issues := []*types.Issue{issue} + exportedIDs, err := writeJSONLAtomic(jsonlPath, issues) + if err != nil { + t.Fatalf("failed to write JSONL: %v", err) + } + + if len(exportedIDs) != 1 { + t.Fatalf("expected 1 exported ID, got %d", len(exportedIDs)) + } + + // Compute and store JSONL file hash + jsonlData, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("failed to read JSONL: %v", err) + } + hasher := sha256.New() + hasher.Write(jsonlData) + fileHash := hex.EncodeToString(hasher.Sum(nil)) + + if err := testStore.SetJSONLFileHash(ctx, fileHash); err != nil { + t.Fatalf("failed to set JSONL file hash: %v", err) + } + + // Test 1: Validate with matching hash (should succeed) + t.Run("MatchingHash", func(t *testing.T) { + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + t.Fatalf("validation failed with matching hash: %v", err) + } + }) + + // Test 2: Modify JSONL file (simulating git pull) and validate + t.Run("MismatchedHash", func(t *testing.T) { + // Modify the JSONL file + if err := os.WriteFile(jsonlPath, []byte(`{"id":"bd-1","title":"Modified"}`+"\n"), 0644); err != nil { + t.Fatalf("failed to modify JSONL: %v", err) + } + + // Add an export hash to verify it gets cleared + if err := testStore.SetExportHash(ctx, "bd-1", "dummy-hash"); err != nil { + t.Fatalf("failed to set export hash: %v", err) + } + + // Validate should detect mismatch and clear export_hashes + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + t.Fatalf("validation failed: %v", err) + } + + // Verify export_hashes were cleared + hash, err := testStore.GetExportHash(ctx, "bd-1") + if err != nil { + t.Fatalf("failed to get export hash: %v", err) + } + if hash != "" { + t.Fatalf("expected export hash to be cleared, got %q", hash) + } + }) + + // Test 3: Missing JSONL file + t.Run("MissingJSONL", func(t *testing.T) { + // Store a hash to simulate previous export + if err := testStore.SetJSONLFileHash(ctx, "some-hash"); err != nil { + t.Fatalf("failed to set JSONL file hash: %v", err) + } + + // Add an export hash + if err := testStore.SetExportHash(ctx, "bd-1", "dummy-hash"); err != nil { + t.Fatalf("failed to set export hash: %v", err) + } + + // Remove JSONL file + if err := os.Remove(jsonlPath); err != nil { + t.Fatalf("failed to remove JSONL: %v", err) + } + + // Validate should detect missing file and clear export_hashes + if err := validateJSONLIntegrity(ctx, jsonlPath); err != nil { + t.Fatalf("validation failed: %v", err) + } + + // Verify export_hashes were cleared + hash, err := testStore.GetExportHash(ctx, "bd-1") + if err != nil { + t.Fatalf("failed to get export hash: %v", err) + } + if hash != "" { + t.Fatalf("expected export hash to be cleared, got %q", hash) + } + }) +} + +// TestImportClearsExportHashes tests that imports clear export_hashes (bd-160) +func TestImportClearsExportHashes(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + + // Ensure .beads directory exists + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + // Create database + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + // Initialize database with prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue prefix: %v", err) + } + + // Create a test issue + issue := &types.Issue{ + ID: "bd-1", + Title: "Test issue", + Description: "Test description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := testStore.CreateIssue(ctx, issue, testActor); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Set an export hash + if err := testStore.SetExportHash(ctx, "bd-1", "dummy-hash"); err != nil { + t.Fatalf("failed to set export hash: %v", err) + } + + // Verify hash is set + hash, err := testStore.GetExportHash(ctx, "bd-1") + if err != nil { + t.Fatalf("failed to get export hash: %v", err) + } + if hash != "dummy-hash" { + t.Fatalf("expected hash 'dummy-hash', got %q", hash) + } + + // Import another issue (should clear export_hashes) + issue2 := &types.Issue{ + ID: "bd-2", + Title: "Another issue", + Description: "Another description", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + + opts := ImportOptions{ + ResolveCollisions: false, + DryRun: false, + SkipUpdate: false, + Strict: false, + SkipPrefixValidation: true, + } + + _, err = importIssuesCore(ctx, dbPath, testStore, []*types.Issue{issue2}, opts) + if err != nil { + t.Fatalf("import failed: %v", err) + } + + // Verify export_hashes were cleared + hash, err = testStore.GetExportHash(ctx, "bd-1") + if err != nil { + t.Fatalf("failed to get export hash after import: %v", err) + } + if hash != "" { + t.Fatalf("expected export hash to be cleared after import, got %q", hash) + } +} diff --git a/cmd/bd/version.go b/cmd/bd/version.go index 09316b15..650288b4 100644 --- a/cmd/bd/version.go +++ b/cmd/bd/version.go @@ -11,7 +11,7 @@ import ( var ( // Version is the current version of bd (overridden by ldflags at build time) - Version = "0.18.0" + Version = "0.19.0" // Build can be set via ldflags at compile time Build = "dev" ) diff --git a/integrations/beads-mcp/pyproject.toml b/integrations/beads-mcp/pyproject.toml index 4198cc3e..b739a7b8 100644 --- a/integrations/beads-mcp/pyproject.toml +++ b/integrations/beads-mcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beads-mcp" -version = "0.18.0" +version = "0.19.0" description = "MCP server for beads issue tracker." readme = "README.md" requires-python = ">=3.10" diff --git a/integrations/beads-mcp/src/beads_mcp/__init__.py b/integrations/beads-mcp/src/beads_mcp/__init__.py index 9e920d87..8e8e2081 100644 --- a/integrations/beads-mcp/src/beads_mcp/__init__.py +++ b/integrations/beads-mcp/src/beads_mcp/__init__.py @@ -4,4 +4,4 @@ This package provides an MCP (Model Context Protocol) server that exposes beads (bd) issue tracker functionality to MCP Clients. """ -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 773b505c..715e11b7 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -76,6 +76,14 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss if needCloseStore { defer func() { _ = sqliteStore.Close() }() } + + // Clear export_hashes before import to prevent staleness (bd-160) + // Import operations may add/update issues, so export_hashes entries become invalid + if !opts.DryRun { + if err := sqliteStore.ClearAllExportHashes(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear export_hashes before import: %v\n", err) + } + } // Check and handle prefix mismatches if err := handlePrefixMismatch(ctx, sqliteStore, issues, opts, result); err != nil { diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index a6cd58ec..b5766019 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -595,6 +595,24 @@ func (m *MemoryStorage) SetExportHash(ctx context.Context, issueID, hash string) return nil } +// ClearAllExportHashes clears all export hashes +func (m *MemoryStorage) ClearAllExportHashes(ctx context.Context) error { + // Memory storage doesn't track export hashes, no-op + return nil +} + +// GetJSONLFileHash gets the JSONL file hash +func (m *MemoryStorage) GetJSONLFileHash(ctx context.Context) (string, error) { + // Memory storage doesn't track JSONL file hashes, return empty string + return "", nil +} + +// SetJSONLFileHash sets the JSONL file hash +func (m *MemoryStorage) SetJSONLFileHash(ctx context.Context, fileHash string) error { + // Memory storage doesn't track JSONL file hashes, no-op + return nil +} + // GetDependencyTree gets the dependency tree for an issue func (m *MemoryStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) { // Simplified implementation - just return direct dependencies diff --git a/internal/storage/sqlite/hash.go b/internal/storage/sqlite/hash.go index 55b1362f..5d5b2626 100644 --- a/internal/storage/sqlite/hash.go +++ b/internal/storage/sqlite/hash.go @@ -50,3 +50,36 @@ func (s *SQLiteStorage) ClearAllExportHashes(ctx context.Context) error { } return nil } + +// GetJSONLFileHash retrieves the stored hash of the JSONL file. +// Returns empty string if no hash is stored (bd-160). +func (s *SQLiteStorage) GetJSONLFileHash(ctx context.Context) (string, error) { + var hash string + err := s.db.QueryRowContext(ctx, ` + SELECT value FROM metadata WHERE key = 'jsonl_file_hash' + `).Scan(&hash) + + if err == sql.ErrNoRows { + return "", nil // No hash stored yet + } + if err != nil { + return "", fmt.Errorf("failed to get jsonl_file_hash: %w", err) + } + + return hash, nil +} + +// SetJSONLFileHash stores the hash of the JSONL file after export (bd-160). +func (s *SQLiteStorage) SetJSONLFileHash(ctx context.Context, fileHash string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO metadata (key, value) + VALUES ('jsonl_file_hash', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `, fileHash) + + if err != nil { + return fmt.Errorf("failed to set jsonl_file_hash: %w", err) + } + + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index de49dd85..2b24626e 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -59,6 +59,11 @@ type Storage interface { // Export hash tracking (for timestamp-only dedup, bd-164) GetExportHash(ctx context.Context, issueID string) (string, error) SetExportHash(ctx context.Context, issueID, contentHash string) error + ClearAllExportHashes(ctx context.Context) error + + // JSONL file integrity (bd-160) + GetJSONLFileHash(ctx context.Context) (string, error) + SetJSONLFileHash(ctx context.Context, fileHash string) error // Config SetConfig(ctx context.Context, key, value string) error