diff --git a/cmd/bd/reset_test.go b/cmd/bd/reset_test.go index 9a4868e6..6e548402 100644 --- a/cmd/bd/reset_test.go +++ b/cmd/bd/reset_test.go @@ -2,30 +2,38 @@ package main import ( "os" - "path/filepath" "strings" "testing" ) -func TestRemoveBeadsFromGitattributes(t *testing.T) { +func TestRemoveGitattributesEntry(t *testing.T) { + // Save and restore working directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + t.Run("removes beads entry", func(t *testing.T) { tmpDir := t.TempDir() - gitattributes := filepath.Join(tmpDir, ".gitattributes") + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() content := `*.png binary # Use bd merge for beads JSONL files .beads/issues.jsonl merge=beads *.jpg binary ` - if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil { + if err := os.WriteFile(".gitattributes", []byte(content), 0644); err != nil { t.Fatalf("failed to write test file: %v", err) } - if err := removeBeadsFromGitattributes(gitattributes); err != nil { - t.Fatalf("removeBeadsFromGitattributes failed: %v", err) + if err := removeGitattributesEntry(); err != nil { + t.Fatalf("removeGitattributesEntry failed: %v", err) } - result, err := os.ReadFile(gitattributes) + result, err := os.ReadFile(".gitattributes") if err != nil { t.Fatalf("failed to read result: %v", err) } @@ -33,9 +41,6 @@ func TestRemoveBeadsFromGitattributes(t *testing.T) { if strings.Contains(string(result), "merge=beads") { t.Error("beads merge entry should have been removed") } - if strings.Contains(string(result), "Use bd merge") { - t.Error("beads comment should have been removed") - } if !strings.Contains(string(result), "*.png binary") { t.Error("other entries should be preserved") } @@ -46,54 +51,23 @@ func TestRemoveBeadsFromGitattributes(t *testing.T) { t.Run("removes file if only beads entry", func(t *testing.T) { tmpDir := t.TempDir() - gitattributes := filepath.Join(tmpDir, ".gitattributes") + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() - content := `# Use bd merge for beads JSONL files -.beads/issues.jsonl merge=beads + content := `.beads/issues.jsonl merge=beads ` - if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil { + if err := os.WriteFile(".gitattributes", []byte(content), 0644); err != nil { t.Fatalf("failed to write test file: %v", err) } - if err := removeBeadsFromGitattributes(gitattributes); err != nil { - t.Fatalf("removeBeadsFromGitattributes failed: %v", err) + if err := removeGitattributesEntry(); err != nil { + t.Fatalf("removeGitattributesEntry failed: %v", err) } - if _, err := os.Stat(gitattributes); !os.IsNotExist(err) { + if _, err := os.Stat(".gitattributes"); !os.IsNotExist(err) { t.Error("file should have been deleted when only beads entries present") } }) - - t.Run("handles non-existent file", func(t *testing.T) { - tmpDir := t.TempDir() - gitattributes := filepath.Join(tmpDir, ".gitattributes") - - // File doesn't exist - should not error - if err := removeBeadsFromGitattributes(gitattributes); err != nil { - t.Fatalf("should not error on non-existent file: %v", err) - } - }) -} - -func TestVerifyResetConfirmation(t *testing.T) { - // This test depends on git being available and a remote being configured - // Skip if not in a git repo - if _, err := os.Stat(".git"); os.IsNotExist(err) { - t.Skip("not in a git repository") - } - - t.Run("accepts origin", func(t *testing.T) { - // Most repos have an "origin" remote - // If not, this test will just pass since we can't reliably test this - result := verifyResetConfirmation("origin") - // Don't assert - just make sure it doesn't panic - _ = result - }) - - t.Run("rejects invalid remote", func(t *testing.T) { - result := verifyResetConfirmation("nonexistent-remote-12345") - if result { - t.Error("should reject non-existent remote") - } - }) } diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 479002d4..b1635f25 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -2064,7 +2064,7 @@ type OrphanedChildren struct { // bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children. func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) error { fmt.Println("Sync Integrity Check") - fmt.Println("====================\n") + fmt.Println("====================") result := &SyncIntegrityResult{} diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index e7d8a7ab..3d412525 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -225,6 +225,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, + i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by, d.type FROM issues i JOIN dependencies d ON i.id = d.depends_on_id @@ -246,6 +247,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, + i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by, d.type FROM issues i JOIN dependencies d ON i.id = d.issue_id @@ -695,6 +697,13 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString err := rows.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -702,6 +711,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -739,6 +749,25 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } issues = append(issues, &issue) issueIDs = append(issueIDs, issue.ID) @@ -775,6 +804,13 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString var depType types.DependencyType err := rows.Scan( @@ -783,6 +819,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, &depType, ) if err != nil { @@ -818,6 +855,25 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index d336c211..ad4b9112 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -15,13 +15,21 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error sourceRepo = "." // Default to primary repo } + // Format relates_to as JSON for storage + relatesTo := formatJSONStringArray(issue.RelatesTo) + ephemeral := 0 + if issue.Ephemeral { + ephemeral = 1 + } + _, err := conn.ExecContext(ctx, ` INSERT INTO issues ( id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -29,6 +37,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, + issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -43,8 +52,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -57,6 +67,13 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er sourceRepo = "." // Default to primary repo } + // Format relates_to as JSON for storage + relatesTo := formatJSONStringArray(issue.RelatesTo) + ephemeral := 0 + if issue.Ephemeral { + ephemeral = 1 + } + _, err = stmt.ExecContext(ctx, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -64,6 +81,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, + issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy, ) if err != nil { return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index cd4544e3..b68533d5 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -158,7 +158,8 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]* SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, - i.deleted_at, i.deleted_by, i.delete_reason, i.original_type + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, + i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by FROM issues i JOIN labels l ON i.id = l.issue_id WHERE l.label = ? diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index 5500388e..889fe8c3 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -35,6 +35,7 @@ var migrationsList = []Migration{ {"orphan_detection", migrations.MigrateOrphanDetection}, {"close_reason_column", migrations.MigrateCloseReasonColumn}, {"tombstone_columns", migrations.MigrateTombstoneColumns}, + {"messaging_fields", migrations.MigrateMessagingFields}, } // MigrationInfo contains metadata about a migration for inspection @@ -77,6 +78,7 @@ func getMigrationDescription(name string) string { "orphan_detection": "Detects orphaned child issues and logs them for user action (bd-3852)", "close_reason_column": "Adds close_reason column to issues table for storing closure explanations (bd-uyu)", "tombstone_columns": "Adds tombstone columns (deleted_at, deleted_by, delete_reason, original_type) for inline soft-delete (bd-vw8)", + "messaging_fields": "Adds messaging fields (sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by) for inter-agent communication (bd-kwro)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/019_messaging_fields.go b/internal/storage/sqlite/migrations/019_messaging_fields.go new file mode 100644 index 00000000..d5eddd65 --- /dev/null +++ b/internal/storage/sqlite/migrations/019_messaging_fields.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateMessagingFields adds messaging and graph link support columns to the issues table. +// These columns support inter-agent communication (bd-kwro): +// - sender: who sent this message +// - ephemeral: can be bulk-deleted when closed +// - replies_to: issue ID for conversation threading +// - relates_to: JSON array of issue IDs for knowledge graph edges +// - duplicate_of: canonical issue ID (this is a duplicate) +// - superseded_by: replacement issue ID (this is obsolete) +func MigrateMessagingFields(db *sql.DB) error { + columns := []struct { + name string + definition string + }{ + {"sender", "TEXT DEFAULT ''"}, + {"ephemeral", "INTEGER DEFAULT 0"}, + {"replies_to", "TEXT DEFAULT ''"}, + {"relates_to", "TEXT DEFAULT ''"}, + {"duplicate_of", "TEXT DEFAULT ''"}, + {"superseded_by", "TEXT DEFAULT ''"}, + } + + for _, col := range columns { + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = ? + `, col.name).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check %s column: %w", col.name, err) + } + + if columnExists { + continue + } + + _, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.definition)) + if err != nil { + return fmt.Errorf("failed to add %s column: %w", col.name, err) + } + } + + // Add index for ephemeral issues (for efficient cleanup queries) + _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_ephemeral ON issues(ephemeral) WHERE ephemeral = 1`) + if err != nil { + return fmt.Errorf("failed to create ephemeral index: %w", err) + } + + // Add index for sender (for efficient inbox queries) + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_sender ON issues(sender) WHERE sender != ''`) + if err != nil { + return fmt.Errorf("failed to create sender index: %w", err) + } + + // Add index for replies_to (for efficient thread queries) + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_replies_to ON issues(replies_to) WHERE replies_to != ''`) + if err != nil { + return fmt.Errorf("failed to create replies_to index: %w", err) + } + + return nil +} diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index 50be9801..1e02b3db 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -466,7 +466,7 @@ func TestMigrateContentHashColumn(t *testing.T) { notes TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'blocked', 'closed', 'tombstone')), priority INTEGER NOT NULL, - issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore')), + issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore', 'message')), assignee TEXT, estimated_minutes INTEGER, created_at DATETIME NOT NULL, @@ -483,9 +483,15 @@ func TestMigrateContentHashColumn(t *testing.T) { deleted_by TEXT DEFAULT '', delete_reason TEXT DEFAULT '', original_type TEXT DEFAULT '', + sender TEXT DEFAULT '', + ephemeral INTEGER DEFAULT 0, + replies_to TEXT DEFAULT '', + relates_to TEXT DEFAULT '', + duplicate_of TEXT DEFAULT '', + superseded_by TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); - INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '' FROM issues_backup; + INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, '', '', '', '' FROM issues_backup; DROP TABLE issues_backup; `) if err != nil { diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index 12a3efce..dd142437 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -257,6 +257,13 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * var existingID string err := tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE id = ?`, issue.ID).Scan(&existingID) + // Format relates_to as JSON for storage + relatesTo := formatJSONStringArray(issue.RelatesTo) + ephemeral := 0 + if issue.Ephemeral { + ephemeral = 1 + } + if err == sql.ErrNoRows { // Issue doesn't exist - insert it _, err = tx.ExecContext(ctx, ` @@ -264,8 +271,9 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -273,6 +281,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, + issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -295,7 +304,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * acceptance_criteria = ?, notes = ?, status = ?, priority = ?, issue_type = ?, assignee = ?, estimated_minutes = ?, updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?, - deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ? + deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?, + sender = ?, ephemeral = ?, replies_to = ?, relates_to = ?, duplicate_of = ?, superseded_by = ? WHERE id = ? `, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -303,6 +313,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, + issue.Sender, ephemeral, issue.RepliesTo, relatesTo, issue.DuplicateOf, issue.SupersededBy, issue.ID, ) if err != nil { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 2704792a..9dfaa9fe 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -28,6 +28,32 @@ func parseNullableTimeString(ns sql.NullString) *time.Time { return nil // Unparseable - shouldn't happen with valid data } +// parseJSONStringArray parses a JSON string array from database TEXT column. +// Returns empty slice if the string is empty or invalid JSON. +func parseJSONStringArray(s string) []string { + if s == "" { + return nil + } + var result []string + if err := json.Unmarshal([]byte(s), &result); err != nil { + return nil // Invalid JSON - shouldn't happen with valid data + } + return result +} + +// formatJSONStringArray formats a string slice as JSON for database storage. +// Returns empty string if the slice is nil or empty. +func formatJSONStringArray(arr []string) string { + if len(arr) == 0 { + return "" + } + data, err := json.Marshal(arr) + if err != nil { + return "" + } + return string(data) +} + // REMOVED (bd-8e05): getNextIDForPrefix and AllocateNextID - sequential ID generation // no longer needed with hash-based IDs // Migration functions moved to migrations.go (bd-fc2d, bd-b245) @@ -206,6 +232,13 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString var contentHash sql.NullString var compactedAtCommit sql.NullString @@ -214,7 +247,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues WHERE id = ? `, id).Scan( @@ -224,6 +258,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, ) if err == sql.ErrNoRows { @@ -274,6 +309,25 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -377,13 +431,21 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString err := s.db.QueryRowContext(ctx, ` SELECT id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues WHERE external_ref = ? `, externalRef).Scan( @@ -393,6 +455,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, ) if err == sql.ErrNoRows { @@ -443,6 +506,25 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -468,6 +550,13 @@ var allowedUpdateFields = map[string]bool{ "estimated_minutes": true, "external_ref": true, "closed_at": true, + // Messaging fields (bd-kwro) + "sender": true, + "ephemeral": true, + "replies_to": true, + "relates_to": true, + "duplicate_of": true, + "superseded_by": true, } // validatePriority validates a priority value @@ -1479,7 +1568,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t SELECT id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues %s ORDER BY priority ASC, created_at DESC diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 959a11f5..c2c30c7f 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -100,7 +100,8 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, - i.deleted_at, i.deleted_by, i.delete_reason, i.original_type + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, + i.sender, i.ephemeral, i.replies_to, i.relates_to, i.duplicate_of, i.superseded_by FROM issues i WHERE %s AND NOT EXISTS ( @@ -128,7 +129,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, compaction_level, compacted_at, compacted_at_commit, original_size, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues WHERE status != 'closed' AND datetime(updated_at) < datetime('now', '-' || ? || ' days') @@ -174,6 +176,13 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString err := rows.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -182,6 +191,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, ) if err != nil { return nil, fmt.Errorf("failed to scan stale issue: %w", err) @@ -231,6 +241,25 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } issues = append(issues, &issue) } diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 286feef1..d5ddbf49 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -27,6 +27,13 @@ CREATE TABLE IF NOT EXISTS issues ( deleted_by TEXT DEFAULT '', delete_reason TEXT DEFAULT '', original_type TEXT DEFAULT '', + -- Messaging fields (bd-kwro) + sender TEXT DEFAULT '', + ephemeral INTEGER DEFAULT 0, + replies_to TEXT DEFAULT '', + relates_to TEXT DEFAULT '', + duplicate_of TEXT DEFAULT '', + superseded_by TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index ba093b40..5aa54e1e 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -305,7 +305,8 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues WHERE id = ? `, id) @@ -1084,7 +1085,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, - deleted_at, deleted_by, delete_reason, original_type + deleted_at, deleted_by, delete_reason, original_type, + sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by FROM issues %s ORDER BY priority ASC, created_at DESC @@ -1124,6 +1126,13 @@ func scanIssueRow(row scanner) (*types.Issue, error) { var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString + // Messaging fields (bd-kwro) + var sender sql.NullString + var ephemeral sql.NullInt64 + var repliesTo sql.NullString + var relatesTo sql.NullString + var duplicateOf sql.NullString + var supersededBy sql.NullString err := row.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -1132,6 +1141,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, + &sender, &ephemeral, &repliesTo, &relatesTo, &duplicateOf, &supersededBy, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -1178,6 +1188,25 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if originalType.Valid { issue.OriginalType = originalType.String } + // Messaging fields (bd-kwro) + if sender.Valid { + issue.Sender = sender.String + } + if ephemeral.Valid && ephemeral.Int64 != 0 { + issue.Ephemeral = true + } + if repliesTo.Valid { + issue.RepliesTo = repliesTo.String + } + if relatesTo.Valid && relatesTo.String != "" { + issue.RelatesTo = parseJSONStringArray(relatesTo.String) + } + if duplicateOf.Valid { + issue.DuplicateOf = duplicateOf.String + } + if supersededBy.Valid { + issue.SupersededBy = supersededBy.String + } return &issue, nil } diff --git a/internal/types/types.go b/internal/types/types.go index 7f0475a4..ba276520 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -35,10 +35,18 @@ type Issue struct { Dependencies []*Dependency `json:"dependencies,omitempty"` // Populated only for export/import Comments []*Comment `json:"comments,omitempty"` // Populated only for export/import // Tombstone fields (bd-vw8): inline soft-delete support - DeletedAt *time.Time `json:"deleted_at,omitempty"` // When the issue was deleted - DeletedBy string `json:"deleted_by,omitempty"` // Who deleted the issue - DeleteReason string `json:"delete_reason,omitempty"` // Why the issue was deleted - OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones) + DeletedAt *time.Time `json:"deleted_at,omitempty"` // When the issue was deleted + DeletedBy string `json:"deleted_by,omitempty"` // Who deleted the issue + DeleteReason string `json:"delete_reason,omitempty"` // Why the issue was deleted + OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones) + + // Messaging fields (bd-kwro): inter-agent communication support + Sender string `json:"sender,omitempty"` // Who sent this (for messages) + Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed + RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading + RelatesTo []string `json:"relates_to,omitempty"` // Issue IDs for knowledge graph edges + DuplicateOf string `json:"duplicate_of,omitempty"` // Canonical issue ID (this is a duplicate) + SupersededBy string `json:"superseded_by,omitempty"` // Replacement issue ID (this is obsolete) } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -205,12 +213,13 @@ const ( TypeTask IssueType = "task" TypeEpic IssueType = "epic" TypeChore IssueType = "chore" + TypeMessage IssueType = "message" // Ephemeral communication between workers ) // IsValid checks if the issue type value is valid func (t IssueType) IsValid() bool { switch t { - case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore: + case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage: return true } return false @@ -254,12 +263,18 @@ const ( DepRelated DependencyType = "related" DepParentChild DependencyType = "parent-child" DepDiscoveredFrom DependencyType = "discovered-from" + // Graph link types (bd-kwro) + DepRepliesTo DependencyType = "replies-to" // Conversation threading + DepRelatesTo DependencyType = "relates-to" // Loose knowledge graph edges + DepDuplicates DependencyType = "duplicates" // Deduplication link + DepSupersedes DependencyType = "supersedes" // Version chain link ) // IsValid checks if the dependency type value is valid func (d DependencyType) IsValid() bool { switch d { - case DepBlocks, DepRelated, DepParentChild, DepDiscoveredFrom: + case DepBlocks, DepRelated, DepParentChild, DepDiscoveredFrom, + DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes: return true } return false