diff --git a/AGENTS.md b/AGENTS.md index c2672302..392590b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -752,3 +752,29 @@ history/ For more details, see README.md and docs/QUICKSTART.md. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/cmd/bd/templates/hooks/post-checkout b/cmd/bd/templates/hooks/post-checkout index a7fd63f2..00d7af7a 100755 --- a/cmd/bd/templates/hooks/post-checkout +++ b/cmd/bd/templates/hooks/post-checkout @@ -1,5 +1,5 @@ #!/bin/sh -# bd-hooks-version: 0.30.0 +# bd-hooks-version: 0.30.1 # # bd (beads) post-checkout hook # diff --git a/cmd/bd/templates/hooks/post-merge b/cmd/bd/templates/hooks/post-merge index 5cb127b9..fd7e7a71 100755 --- a/cmd/bd/templates/hooks/post-merge +++ b/cmd/bd/templates/hooks/post-merge @@ -1,5 +1,5 @@ #!/bin/sh -# bd-hooks-version: 0.30.0 +# bd-hooks-version: 0.30.1 # # bd (beads) post-merge hook # diff --git a/cmd/bd/templates/hooks/pre-commit b/cmd/bd/templates/hooks/pre-commit index 6900bcb4..d636905a 100755 --- a/cmd/bd/templates/hooks/pre-commit +++ b/cmd/bd/templates/hooks/pre-commit @@ -1,5 +1,5 @@ #!/bin/sh -# bd-hooks-version: 0.30.0 +# bd-hooks-version: 0.30.1 # # bd (beads) pre-commit hook # diff --git a/cmd/bd/templates/hooks/pre-push b/cmd/bd/templates/hooks/pre-push index a1f6df3f..ed3731d6 100755 --- a/cmd/bd/templates/hooks/pre-push +++ b/cmd/bd/templates/hooks/pre-push @@ -1,5 +1,5 @@ #!/bin/sh -# bd-hooks-version: 0.30.0 +# bd-hooks-version: 0.30.1 # # bd (beads) pre-push hook # diff --git a/internal/storage/sqlite/sqlite_test.go b/internal/storage/sqlite/sqlite_test.go index ca4e702e..111b5493 100644 --- a/internal/storage/sqlite/sqlite_test.go +++ b/internal/storage/sqlite/sqlite_test.go @@ -404,12 +404,18 @@ func TestCreateIssues(t *testing.T) { checkFunc: func(t *testing.T, h *createIssuesTestHelper, issues []*types.Issue) {}, }, { - name: "closed_at invariant - closed status without closed_at", + name: "closed_at invariant - closed status without closed_at auto-sets it (GH#523)", issues: []*types.Issue{ h.newIssue("", "Missing closed_at", types.StatusClosed, 1, types.TypeTask, nil), }, - wantErr: true, - checkFunc: func(t *testing.T, h *createIssuesTestHelper, issues []*types.Issue) {}, + wantErr: false, // Defensive fix auto-sets closed_at instead of rejecting + checkFunc: func(t *testing.T, h *createIssuesTestHelper, issues []*types.Issue) { + h.assertCount(issues, 1) + h.assertEqual(types.StatusClosed, issues[0].Status, "status") + if issues[0].ClosedAt == nil { + t.Error("ClosedAt should be auto-set for closed issues (GH#523 defensive fix)") + } + }, }, { name: "nil item in batch", @@ -807,17 +813,20 @@ func TestClosedAtInvariant(t *testing.T) { } }) - t.Run("CreateIssue rejects closed issue without closed_at", func(t *testing.T) { + t.Run("CreateIssue auto-sets closed_at for closed issue (GH#523)", func(t *testing.T) { issue := &types.Issue{ Title: "Test", Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, - ClosedAt: nil, // Invalid: closed without closed_at + ClosedAt: nil, // Defensive fix should auto-set this } err := store.CreateIssue(ctx, issue, "test-user") - if err == nil { - t.Error("CreateIssue should reject closed issue without closed_at") + if err != nil { + t.Errorf("CreateIssue should auto-set closed_at (GH#523 defensive fix), got error: %v", err) + } + if issue.ClosedAt == nil { + t.Error("ClosedAt should be auto-set for closed issues (GH#523 defensive fix)") } }) diff --git a/internal/utils/id_parser_test.go b/internal/utils/id_parser_test.go index da4dae29..999a4f0e 100644 --- a/internal/utils/id_parser_test.go +++ b/internal/utils/id_parser_test.go @@ -358,9 +358,9 @@ func TestExtractIssuePrefix(t *testing.T) { expected: "alpha-beta", // Last hyphen before numeric suffix }, { - name: "multi-part non-numeric suffix", + name: "multi-part non-numeric suffix (word-like)", issueID: "vc-baseline-test", - expected: "vc-baseline", // Alphanumeric suffix uses last hyphen (GH#405 fix) + expected: "vc", // Word-like suffix (4+ chars, no digit) uses first hyphen (bd-fasa fix) }, { name: "beads-vscode style prefix", diff --git a/internal/utils/issue_446_test.go b/internal/utils/issue_446_test.go index 93f8078a..34e2dc33 100644 --- a/internal/utils/issue_446_test.go +++ b/internal/utils/issue_446_test.go @@ -43,18 +43,20 @@ func TestExtractIssuePrefixAllLetterHash(t *testing.T) { } } -// TestExtractIssuePrefixWordSuffix tests alphanumeric suffixes (GH#405 fix) -// With the GH#405 fix, all alphanumeric suffixes use last-hyphen extraction, -// even if they look like English words. This fixes multi-hyphen prefix parsing. +// TestExtractIssuePrefixWordSuffix tests word-like suffixes (bd-fasa regression) +// Word-like suffixes (4+ chars, no digits) use first-hyphen extraction because +// they're likely multi-part IDs where everything after first hyphen is the ID. +// This fixes bd-fasa regression where word-like suffixes were wrongly treated as hashes. func TestExtractIssuePrefixWordSuffix(t *testing.T) { - // These should use last-hyphen extraction (alphanumeric = valid issue ID suffix) + // Word-like suffixes (4+ chars, no digits) should use first-hyphen extraction + // The entire part after first hyphen is the ID, not just the last segment wordSuffixes := []struct { issueID string expected string }{ - {"vc-baseline-test", "vc-baseline"}, // GH#405: alphanumeric suffix uses last hyphen - {"vc-baseline-hello", "vc-baseline"}, // GH#405: alphanumeric suffix uses last hyphen - {"vc-some-feature", "vc-some"}, // GH#405: alphanumeric suffix uses last hyphen + {"vc-baseline-test", "vc"}, // bd-fasa: "baseline-test" is the ID, not "test" + {"vc-baseline-hello", "vc"}, // bd-fasa: "baseline-hello" is the ID + {"vc-some-feature", "vc"}, // bd-fasa: "some-feature" is the ID } for _, tc := range wordSuffixes { diff --git a/internal/utils/issue_id.go b/internal/utils/issue_id.go index d51c734e..f8b038c0 100644 --- a/internal/utils/issue_id.go +++ b/internal/utils/issue_id.go @@ -6,14 +6,18 @@ import ( ) // ExtractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd" -// Uses the last hyphen before an alphanumeric suffix: +// Uses the last hyphen before a numeric or hash-like suffix: // - "beads-vscode-1" -> "beads-vscode" (numeric suffix) -// - "web-app-a3f8e9" -> "web-app" (hash suffix) +// - "web-app-a3f8e9" -> "web-app" (hash suffix with digits) // - "my-cool-app-123" -> "my-cool-app" (numeric suffix) -// - "hacker-news-test" -> "hacker-news" (alphanumeric suffix, GH#405) +// - "bd-a3f" -> "bd" (3-char hash) // -// Only uses first hyphen when suffix contains non-alphanumeric characters, -// which indicates it's not an issue ID but something like a project name. +// Falls back to first hyphen when suffix looks like an English word (4+ chars, no digits): +// - "vc-baseline-test" -> "vc" (word-like suffix: "test" is not a hash) +// - "bd-multi-part-id" -> "bd" (word-like suffix: "id" is too short but "part-id" path) +// +// This distinguishes hash IDs (which may contain letters but have digits or are 3 chars) +// from multi-part IDs where the suffix after the first hyphen is the entire ID. func ExtractIssuePrefix(issueID string) string { // Try last hyphen first (handles multi-part prefixes like "beads-vscode-1") lastIdx := strings.LastIndex(issueID, "-") @@ -33,24 +37,15 @@ func ExtractIssuePrefix(issueID string) string { basePart = suffix[:dotIdx] } - // Check if basePart is alphanumeric (valid issue ID suffix) - // Issue IDs are always alphanumeric: numeric (1, 23) or hash (a3f, xyz, test) - isAlphanumeric := len(basePart) > 0 - for _, c := range basePart { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { - isAlphanumeric = false - break - } - } - - // If suffix is alphanumeric, this is an issue ID - use last hyphen - // This handles all issue ID formats including word-like hashes (GH#405) - if isAlphanumeric { + // Check if this looks like a valid issue ID suffix (numeric or hash-like) + // Use isLikelyHash which requires digits for 4+ char suffixes to avoid + // treating English words like "test", "gate", "part" as hash IDs + if isNumeric(basePart) || isLikelyHash(basePart) { return issueID[:lastIdx] } - // Suffix contains special characters - not a standard issue ID - // Fall back to first hyphen for cases like project names with descriptions + // Suffix looks like an English word (4+ chars, no digits) or contains special chars + // Fall back to first hyphen - the entire part after first hyphen is the ID firstIdx := strings.Index(issueID, "-") if firstIdx <= 0 { return "" @@ -58,6 +53,19 @@ func ExtractIssuePrefix(issueID string) string { return issueID[:firstIdx] } +// isNumeric checks if a string contains only digits +func isNumeric(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + // isLikelyHash checks if a string looks like a hash ID suffix. // Returns true for base36 strings of 3-8 characters (0-9, a-z). //