Agent identity fields on Issue struct: - HookBead: reference to current work (0..1 cardinality) - RoleBead: reference to role definition bead - AgentState: self-reported state (idle|spawning|running|working|stuck|done|stopped|dead) - LastActivity: timestamp for heartbeat/timeout detection - RoleType: agent type (polecat|crew|witness|refinery|mayor|deacon) - Rig: rig name (empty for town-level agents) Also adds: - AgentState type with IsValid() method - Validation for agent state in Issue.Validate() - Agent fields included in content hash This enables the agent-as-bead architecture where agents are tracked as first-class beads with self-reported state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
865 lines
31 KiB
Go
865 lines
31 KiB
Go
// Package types defines core data structures for the bd issue tracker.
|
|
package types
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Issue represents a trackable work item
|
|
type Issue struct {
|
|
ID string `json:"id"`
|
|
ContentHash string `json:"-"` // Internal: SHA256 hash of canonical content (excludes ID, timestamps) - NOT exported to JSONL
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
Design string `json:"design,omitempty"`
|
|
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Status Status `json:"status,omitempty"`
|
|
Priority int `json:"priority"` // No omitempty: 0 is valid (P0/critical)
|
|
IssueType IssueType `json:"issue_type,omitempty"`
|
|
Assignee string `json:"assignee,omitempty"`
|
|
EstimatedMinutes *int `json:"estimated_minutes,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CreatedBy string `json:"created_by,omitempty"` // Who created this issue (GH#748)
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
|
CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing the issue
|
|
ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC"
|
|
CompactionLevel int `json:"compaction_level,omitempty"`
|
|
CompactedAt *time.Time `json:"compacted_at,omitempty"`
|
|
CompactedAtCommit *string `json:"compacted_at_commit,omitempty"` // Git commit hash when compacted
|
|
OriginalSize int `json:"original_size,omitempty"`
|
|
SourceRepo string `json:"-"` // Internal: Which repo owns this issue (multi-repo support) - NOT exported to JSONL
|
|
IDPrefix string `json:"-"` // Internal: Override prefix for ID generation (bd-hobo) - NOT exported to JSONL
|
|
Labels []string `json:"labels,omitempty"` // Populated only for export/import
|
|
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)
|
|
|
|
// Messaging fields (bd-kwro): inter-agent communication support
|
|
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
|
|
Ephemeral bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
|
|
// NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table
|
|
// per Decision 004 (Edge Schema Consolidation). Use dependency API instead.
|
|
|
|
// Pinned field (bd-7h5): persistent context markers
|
|
Pinned bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker, not a work item
|
|
|
|
// Template field (beads-1ra): template molecule support
|
|
IsTemplate bool `json:"is_template,omitempty"` // If true, issue is a read-only template molecule
|
|
|
|
// Bonding fields (bd-rnnr): compound molecule lineage
|
|
BondedFrom []BondRef `json:"bonded_from,omitempty"` // For compounds: constituent protos
|
|
|
|
// HOP fields (bd-7pwh): entity tracking for CV chains
|
|
Creator *EntityRef `json:"creator,omitempty"` // Who created this issue (human, agent, or org)
|
|
Validations []Validation `json:"validations,omitempty"` // Who validated/approved this work
|
|
|
|
// Gate fields (bd-udsi): async coordination primitives
|
|
AwaitType string `json:"await_type,omitempty"` // Condition type: gh:run, gh:pr, timer, human, mail
|
|
AwaitID string `json:"await_id,omitempty"` // Condition identifier (e.g., run ID, PR number)
|
|
Timeout time.Duration `json:"timeout,omitempty"` // Max wait time before escalation
|
|
Waiters []string `json:"waiters,omitempty"` // Mail addresses to notify when gate clears
|
|
|
|
// Source tracing fields (gt-8tmz.18): track where this issue came from during cooking
|
|
SourceFormula string `json:"source_formula,omitempty"` // Formula name where this step was defined
|
|
SourceLocation string `json:"source_location,omitempty"` // Path within source: "steps[0]", "advice[0].after"
|
|
|
|
// Agent identity fields (gt-v2gkv): agent-as-bead support
|
|
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook (0..1)
|
|
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (required for agents)
|
|
AgentState AgentState `json:"agent_state,omitempty"` // Agent-reported state (idle|running|stuck|stopped)
|
|
LastActivity *time.Time `json:"last_activity,omitempty"` // Updated on each agent action (for timeout detection)
|
|
RoleType string `json:"role_type,omitempty"` // Agent role: polecat|crew|witness|refinery|mayor|deacon
|
|
Rig string `json:"rig,omitempty"` // Rig name (empty for town-level agents like mayor/deacon)
|
|
}
|
|
|
|
// ComputeContentHash creates a deterministic hash of the issue's content.
|
|
// Uses all substantive fields (excluding ID, timestamps, and compaction metadata)
|
|
// to ensure that identical content produces identical hashes across all clones.
|
|
func (i *Issue) ComputeContentHash() string {
|
|
h := sha256.New()
|
|
|
|
// Hash all substantive fields in a stable order
|
|
h.Write([]byte(i.Title))
|
|
h.Write([]byte{0}) // separator
|
|
h.Write([]byte(i.Description))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Design))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.AcceptanceCriteria))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Notes))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Status))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(fmt.Sprintf("%d", i.Priority)))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.IssueType))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Assignee))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.CreatedBy))
|
|
h.Write([]byte{0})
|
|
|
|
if i.ExternalRef != nil {
|
|
h.Write([]byte(*i.ExternalRef))
|
|
}
|
|
h.Write([]byte{0})
|
|
if i.Pinned {
|
|
h.Write([]byte("pinned"))
|
|
}
|
|
h.Write([]byte{0})
|
|
if i.IsTemplate {
|
|
h.Write([]byte("template"))
|
|
}
|
|
h.Write([]byte{0})
|
|
// Hash bonded_from for compound molecules (bd-rnnr)
|
|
for _, br := range i.BondedFrom {
|
|
h.Write([]byte(br.ProtoID))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(br.BondType))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(br.BondPoint))
|
|
h.Write([]byte{0})
|
|
}
|
|
// Hash creator for HOP entity tracking (bd-m7ib)
|
|
if i.Creator != nil {
|
|
h.Write([]byte(i.Creator.Name))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Creator.Platform))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Creator.Org))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Creator.ID))
|
|
h.Write([]byte{0})
|
|
}
|
|
// Hash validations for HOP proof-of-stake (bd-du9h)
|
|
for _, v := range i.Validations {
|
|
if v.Validator != nil {
|
|
h.Write([]byte(v.Validator.Name))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(v.Validator.Platform))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(v.Validator.Org))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(v.Validator.ID))
|
|
h.Write([]byte{0})
|
|
}
|
|
h.Write([]byte(v.Outcome))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(v.Timestamp.Format(time.RFC3339)))
|
|
h.Write([]byte{0})
|
|
if v.Score != nil {
|
|
h.Write([]byte(fmt.Sprintf("%f", *v.Score)))
|
|
}
|
|
h.Write([]byte{0})
|
|
}
|
|
// Hash gate fields for async coordination (bd-udsi)
|
|
h.Write([]byte(i.AwaitType))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.AwaitID))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(fmt.Sprintf("%d", i.Timeout)))
|
|
h.Write([]byte{0})
|
|
for _, waiter := range i.Waiters {
|
|
h.Write([]byte(waiter))
|
|
h.Write([]byte{0})
|
|
}
|
|
// Hash agent identity fields (gt-v2gkv)
|
|
h.Write([]byte(i.HookBead))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.RoleBead))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.AgentState))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.RoleType))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(i.Rig))
|
|
h.Write([]byte{0})
|
|
// Note: LastActivity is a timestamp, excluded from hash like other timestamps
|
|
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
// DefaultTombstoneTTL is the default time-to-live for tombstones (30 days)
|
|
const DefaultTombstoneTTL = 30 * 24 * time.Hour
|
|
|
|
// MinTombstoneTTL is the minimum allowed TTL (7 days) to prevent data loss
|
|
const MinTombstoneTTL = 7 * 24 * time.Hour
|
|
|
|
// ClockSkewGrace is added to TTL to handle clock drift between machines
|
|
const ClockSkewGrace = 1 * time.Hour
|
|
|
|
// IsTombstone returns true if the issue has been soft-deleted (bd-vw8)
|
|
func (i *Issue) IsTombstone() bool {
|
|
return i.Status == StatusTombstone
|
|
}
|
|
|
|
// IsExpired returns true if the tombstone has exceeded its TTL.
|
|
// Non-tombstone issues always return false.
|
|
// ttl is the configured TTL duration:
|
|
// - If zero, DefaultTombstoneTTL (30 days) is used
|
|
// - If negative, the tombstone is immediately expired (for --hard mode)
|
|
// - If positive, ClockSkewGrace is added only for TTLs > 1 hour
|
|
func (i *Issue) IsExpired(ttl time.Duration) bool {
|
|
// Non-tombstones never expire
|
|
if !i.IsTombstone() {
|
|
return false
|
|
}
|
|
|
|
// Tombstones without DeletedAt are not expired (safety: shouldn't happen in valid data)
|
|
if i.DeletedAt == nil {
|
|
return false
|
|
}
|
|
|
|
// Negative TTL means "immediately expired" - for --hard mode (bd-4q8 fix)
|
|
if ttl < 0 {
|
|
return true
|
|
}
|
|
|
|
// Use default TTL if not specified
|
|
if ttl == 0 {
|
|
ttl = DefaultTombstoneTTL
|
|
}
|
|
|
|
// Only add clock skew grace period for normal TTLs (> 1 hour).
|
|
// For short TTLs (testing/development), skip grace period.
|
|
effectiveTTL := ttl
|
|
if ttl > ClockSkewGrace {
|
|
effectiveTTL = ttl + ClockSkewGrace
|
|
}
|
|
|
|
// Check if the tombstone has exceeded its TTL
|
|
expirationTime := i.DeletedAt.Add(effectiveTTL)
|
|
return time.Now().After(expirationTime)
|
|
}
|
|
|
|
// Validate checks if the issue has valid field values (built-in statuses only)
|
|
func (i *Issue) Validate() error {
|
|
return i.ValidateWithCustomStatuses(nil)
|
|
}
|
|
|
|
// ValidateWithCustomStatuses checks if the issue has valid field values,
|
|
// allowing custom statuses in addition to built-in ones.
|
|
func (i *Issue) ValidateWithCustomStatuses(customStatuses []string) error {
|
|
if len(i.Title) == 0 {
|
|
return fmt.Errorf("title is required")
|
|
}
|
|
if len(i.Title) > 500 {
|
|
return fmt.Errorf("title must be 500 characters or less (got %d)", len(i.Title))
|
|
}
|
|
if i.Priority < 0 || i.Priority > 4 {
|
|
return fmt.Errorf("priority must be between 0 and 4 (got %d)", i.Priority)
|
|
}
|
|
if !i.Status.IsValidWithCustom(customStatuses) {
|
|
return fmt.Errorf("invalid status: %s", i.Status)
|
|
}
|
|
if !i.IssueType.IsValid() {
|
|
return fmt.Errorf("invalid issue type: %s", i.IssueType)
|
|
}
|
|
if i.EstimatedMinutes != nil && *i.EstimatedMinutes < 0 {
|
|
return fmt.Errorf("estimated_minutes cannot be negative")
|
|
}
|
|
// Enforce closed_at invariant: closed_at should be set if and only if status is closed
|
|
// Exception: tombstones may retain closed_at from before deletion
|
|
if i.Status == StatusClosed && i.ClosedAt == nil {
|
|
return fmt.Errorf("closed issues must have closed_at timestamp")
|
|
}
|
|
if i.Status != StatusClosed && i.Status != StatusTombstone && i.ClosedAt != nil {
|
|
return fmt.Errorf("non-closed issues cannot have closed_at timestamp")
|
|
}
|
|
// Enforce tombstone invariants (bd-md2): deleted_at must be set for tombstones, and only for tombstones
|
|
if i.Status == StatusTombstone && i.DeletedAt == nil {
|
|
return fmt.Errorf("tombstone issues must have deleted_at timestamp")
|
|
}
|
|
if i.Status != StatusTombstone && i.DeletedAt != nil {
|
|
return fmt.Errorf("non-tombstone issues cannot have deleted_at timestamp")
|
|
}
|
|
// Validate agent state if set (gt-v2gkv)
|
|
if !i.AgentState.IsValid() {
|
|
return fmt.Errorf("invalid agent state: %s", i.AgentState)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDefaults applies default values for fields omitted during JSONL import.
|
|
// Call this after json.Unmarshal to ensure missing fields have proper defaults:
|
|
// - Status: defaults to StatusOpen if empty
|
|
// - Priority: defaults to 2 if zero (note: P0 issues must explicitly set priority=0)
|
|
// - IssueType: defaults to TypeTask if empty
|
|
//
|
|
// This enables smaller JSONL output by using omitempty on these fields.
|
|
func (i *Issue) SetDefaults() {
|
|
if i.Status == "" {
|
|
i.Status = StatusOpen
|
|
}
|
|
// Note: priority 0 (P0) is a valid value, so we can't distinguish between
|
|
// "explicitly set to 0" and "omitted". For JSONL compactness, we treat
|
|
// priority 0 in JSONL as P0, not as "use default". This is the expected
|
|
// behavior since P0 issues are explicitly marked.
|
|
// Priority default of 2 only applies to new issues via Create, not import.
|
|
if i.IssueType == "" {
|
|
i.IssueType = TypeTask
|
|
}
|
|
}
|
|
|
|
// Status represents the current state of an issue
|
|
type Status string
|
|
|
|
// Issue status constants
|
|
const (
|
|
StatusOpen Status = "open"
|
|
StatusInProgress Status = "in_progress"
|
|
StatusBlocked Status = "blocked"
|
|
StatusDeferred Status = "deferred" // Deliberately put on ice for later (bd-4jr)
|
|
StatusClosed Status = "closed"
|
|
StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8)
|
|
StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2)
|
|
)
|
|
|
|
// IsValid checks if the status value is valid (built-in statuses only)
|
|
func (s Status) IsValid() bool {
|
|
switch s {
|
|
case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsValidWithCustom checks if the status is valid, including custom statuses.
|
|
// Custom statuses are user-defined via bd config set status.custom "status1,status2,..."
|
|
func (s Status) IsValidWithCustom(customStatuses []string) bool {
|
|
// First check built-in statuses
|
|
if s.IsValid() {
|
|
return true
|
|
}
|
|
// Then check custom statuses
|
|
for _, custom := range customStatuses {
|
|
if string(s) == custom {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IssueType categorizes the kind of work
|
|
type IssueType string
|
|
|
|
// Issue type constants
|
|
const (
|
|
TypeBug IssueType = "bug"
|
|
TypeFeature IssueType = "feature"
|
|
TypeTask IssueType = "task"
|
|
TypeEpic IssueType = "epic"
|
|
TypeChore IssueType = "chore"
|
|
TypeMessage IssueType = "message" // Ephemeral communication between workers
|
|
TypeMergeRequest IssueType = "merge-request" // Merge queue entry for refinery processing
|
|
TypeMolecule IssueType = "molecule" // Template molecule for issue hierarchies (beads-1ra)
|
|
TypeGate IssueType = "gate" // Async coordination gate (bd-udsi)
|
|
TypeAgent IssueType = "agent" // Agent identity bead (gt-ikyo1)
|
|
TypeRole IssueType = "role" // Agent role definition (gt-gzp2y)
|
|
)
|
|
|
|
// IsValid checks if the issue type value is valid
|
|
func (t IssueType) IsValid() bool {
|
|
switch t {
|
|
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AgentState represents the self-reported state of an agent (gt-v2gkv)
|
|
type AgentState string
|
|
|
|
// Agent state constants
|
|
const (
|
|
StateIdle AgentState = "idle" // Agent is waiting for work
|
|
StateSpawning AgentState = "spawning" // Agent is starting up
|
|
StateRunning AgentState = "running" // Agent is executing (general)
|
|
StateWorking AgentState = "working" // Agent is actively working on a task
|
|
StateStuck AgentState = "stuck" // Agent is blocked and needs help
|
|
StateDone AgentState = "done" // Agent completed its current work
|
|
StateStopped AgentState = "stopped" // Agent has cleanly shut down
|
|
StateDead AgentState = "dead" // Agent died without clean shutdown (timeout detection)
|
|
)
|
|
|
|
// IsValid checks if the agent state value is valid
|
|
func (s AgentState) IsValid() bool {
|
|
switch s {
|
|
case StateIdle, StateSpawning, StateRunning, StateWorking, StateStuck, StateDone, StateStopped, StateDead, "":
|
|
return true // empty is valid (non-agent beads)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Dependency represents a relationship between issues
|
|
type Dependency struct {
|
|
IssueID string `json:"issue_id"`
|
|
DependsOnID string `json:"depends_on_id"`
|
|
Type DependencyType `json:"type"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
// Metadata contains type-specific edge data (JSON blob)
|
|
// Examples: similarity scores, approval details, skill proficiency
|
|
Metadata string `json:"metadata,omitempty"`
|
|
// ThreadID groups conversation edges for efficient thread queries
|
|
// For replies-to edges, this identifies the conversation root
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
}
|
|
|
|
// DependencyCounts holds counts for dependencies and dependents
|
|
type DependencyCounts struct {
|
|
DependencyCount int `json:"dependency_count"` // Number of issues this issue depends on
|
|
DependentCount int `json:"dependent_count"` // Number of issues that depend on this issue
|
|
}
|
|
|
|
// IssueWithDependencyMetadata extends Issue with dependency relationship type
|
|
// Note: We explicitly include all Issue fields to ensure proper JSON marshaling
|
|
type IssueWithDependencyMetadata struct {
|
|
Issue
|
|
DependencyType DependencyType `json:"dependency_type"`
|
|
}
|
|
|
|
// IssueWithCounts extends Issue with dependency relationship counts
|
|
type IssueWithCounts struct {
|
|
*Issue
|
|
DependencyCount int `json:"dependency_count"`
|
|
DependentCount int `json:"dependent_count"`
|
|
}
|
|
|
|
// DependencyType categorizes the relationship
|
|
type DependencyType string
|
|
|
|
// Dependency type constants
|
|
const (
|
|
// Workflow types (affect ready work calculation)
|
|
DepBlocks DependencyType = "blocks"
|
|
DepParentChild DependencyType = "parent-child"
|
|
DepConditionalBlocks DependencyType = "conditional-blocks" // B runs only if A fails (bd-kzda)
|
|
DepWaitsFor DependencyType = "waits-for" // Fanout gate: wait for dynamic children (bd-xo1o.2)
|
|
|
|
// Association types
|
|
DepRelated DependencyType = "related"
|
|
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
|
|
|
|
// Entity types (HOP foundation - Decision 004)
|
|
DepAuthoredBy DependencyType = "authored-by" // Creator relationship
|
|
DepAssignedTo DependencyType = "assigned-to" // Assignment relationship
|
|
DepApprovedBy DependencyType = "approved-by" // Approval relationship
|
|
)
|
|
|
|
// IsValid checks if the dependency type value is valid.
|
|
// Accepts any non-empty string up to 50 characters.
|
|
// Use IsWellKnown() to check if it's a built-in type.
|
|
func (d DependencyType) IsValid() bool {
|
|
return len(d) > 0 && len(d) <= 50
|
|
}
|
|
|
|
// IsWellKnown checks if the dependency type is a well-known constant.
|
|
// Returns false for custom/user-defined types (which are still valid).
|
|
func (d DependencyType) IsWellKnown() bool {
|
|
switch d {
|
|
case DepBlocks, DepParentChild, DepConditionalBlocks, DepWaitsFor, DepRelated, DepDiscoveredFrom,
|
|
DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes,
|
|
DepAuthoredBy, DepAssignedTo, DepApprovedBy:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AffectsReadyWork returns true if this dependency type blocks work.
|
|
// Only blocking types affect the ready work calculation.
|
|
func (d DependencyType) AffectsReadyWork() bool {
|
|
return d == DepBlocks || d == DepParentChild || d == DepConditionalBlocks || d == DepWaitsFor
|
|
}
|
|
|
|
// WaitsForMeta holds metadata for waits-for dependencies (fanout gates).
|
|
// Stored as JSON in the Dependency.Metadata field.
|
|
type WaitsForMeta struct {
|
|
// Gate type: "all-children" (wait for all), "any-children" (wait for first)
|
|
Gate string `json:"gate"`
|
|
// SpawnerID identifies which step/issue spawns the children to wait for.
|
|
// If empty, waits for all direct children of the depends_on_id issue.
|
|
SpawnerID string `json:"spawner_id,omitempty"`
|
|
}
|
|
|
|
// WaitsForGate constants
|
|
const (
|
|
WaitsForAllChildren = "all-children" // Wait for all dynamic children to complete
|
|
WaitsForAnyChildren = "any-children" // Proceed when first child completes (future)
|
|
)
|
|
|
|
// FailureCloseKeywords are keywords that indicate an issue was closed due to failure.
|
|
// Used by conditional-blocks dependencies to determine if the condition is met.
|
|
var FailureCloseKeywords = []string{
|
|
"failed",
|
|
"rejected",
|
|
"wontfix",
|
|
"won't fix",
|
|
"canceled",
|
|
"cancelled", //nolint:misspell // British spelling intentionally included
|
|
"abandoned",
|
|
"blocked",
|
|
"error",
|
|
"timeout",
|
|
"aborted",
|
|
}
|
|
|
|
// IsFailureClose returns true if the close reason indicates the issue failed.
|
|
// This is used by conditional-blocks dependencies: B runs only if A fails.
|
|
// A "failure" close reason contains one of the FailureCloseKeywords (case-insensitive).
|
|
func IsFailureClose(closeReason string) bool {
|
|
if closeReason == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(closeReason)
|
|
for _, keyword := range FailureCloseKeywords {
|
|
if strings.Contains(lower, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Label represents a tag on an issue
|
|
type Label struct {
|
|
IssueID string `json:"issue_id"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// Comment represents a comment on an issue
|
|
type Comment struct {
|
|
ID int64 `json:"id"`
|
|
IssueID string `json:"issue_id"`
|
|
Author string `json:"author"`
|
|
Text string `json:"text"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Event represents an audit trail entry
|
|
type Event struct {
|
|
ID int64 `json:"id"`
|
|
IssueID string `json:"issue_id"`
|
|
EventType EventType `json:"event_type"`
|
|
Actor string `json:"actor"`
|
|
OldValue *string `json:"old_value,omitempty"`
|
|
NewValue *string `json:"new_value,omitempty"`
|
|
Comment *string `json:"comment,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// EventType categorizes audit trail events
|
|
type EventType string
|
|
|
|
// Event type constants for audit trail
|
|
const (
|
|
EventCreated EventType = "created"
|
|
EventUpdated EventType = "updated"
|
|
EventStatusChanged EventType = "status_changed"
|
|
EventCommented EventType = "commented"
|
|
EventClosed EventType = "closed"
|
|
EventReopened EventType = "reopened"
|
|
EventDependencyAdded EventType = "dependency_added"
|
|
EventDependencyRemoved EventType = "dependency_removed"
|
|
EventLabelAdded EventType = "label_added"
|
|
EventLabelRemoved EventType = "label_removed"
|
|
EventCompacted EventType = "compacted"
|
|
)
|
|
|
|
// BlockedIssue extends Issue with blocking information
|
|
type BlockedIssue struct {
|
|
Issue
|
|
BlockedByCount int `json:"blocked_by_count"`
|
|
BlockedBy []string `json:"blocked_by"`
|
|
}
|
|
|
|
// TreeNode represents a node in a dependency tree
|
|
type TreeNode struct {
|
|
Issue
|
|
Depth int `json:"depth"`
|
|
ParentID string `json:"parent_id"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
// Statistics provides aggregate metrics
|
|
type Statistics struct {
|
|
TotalIssues int `json:"total_issues"`
|
|
OpenIssues int `json:"open_issues"`
|
|
InProgressIssues int `json:"in_progress_issues"`
|
|
ClosedIssues int `json:"closed_issues"`
|
|
BlockedIssues int `json:"blocked_issues"`
|
|
DeferredIssues int `json:"deferred_issues"` // Issues on ice (bd-4jr)
|
|
ReadyIssues int `json:"ready_issues"`
|
|
TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt)
|
|
PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2)
|
|
EpicsEligibleForClosure int `json:"epics_eligible_for_closure"`
|
|
AverageLeadTime float64 `json:"average_lead_time_hours"`
|
|
}
|
|
|
|
// IssueFilter is used to filter issue queries
|
|
type IssueFilter struct {
|
|
Status *Status
|
|
Priority *int
|
|
IssueType *IssueType
|
|
Assignee *string
|
|
Labels []string // AND semantics: issue must have ALL these labels
|
|
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
|
|
TitleSearch string
|
|
IDs []string // Filter by specific issue IDs
|
|
Limit int
|
|
|
|
// Pattern matching
|
|
TitleContains string
|
|
DescriptionContains string
|
|
NotesContains string
|
|
|
|
// Date ranges
|
|
CreatedAfter *time.Time
|
|
CreatedBefore *time.Time
|
|
UpdatedAfter *time.Time
|
|
UpdatedBefore *time.Time
|
|
ClosedAfter *time.Time
|
|
ClosedBefore *time.Time
|
|
|
|
// Empty/null checks
|
|
EmptyDescription bool
|
|
NoAssignee bool
|
|
NoLabels bool
|
|
|
|
// Numeric ranges
|
|
PriorityMin *int
|
|
PriorityMax *int
|
|
|
|
// Tombstone filtering (bd-1bu)
|
|
IncludeTombstones bool // If false (default), exclude tombstones from results
|
|
|
|
// Ephemeral filtering (bd-kwro.9)
|
|
Ephemeral *bool // Filter by ephemeral flag (nil = any, true = only ephemeral, false = only persistent)
|
|
|
|
// Pinned filtering (bd-7h5)
|
|
Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned)
|
|
|
|
// Template filtering (beads-1ra)
|
|
IsTemplate *bool // Filter by template flag (nil = any, true = only templates, false = exclude templates)
|
|
|
|
// Parent filtering (bd-yqhh): filter children by parent issue ID
|
|
ParentID *string // Filter by parent issue (via parent-child dependency)
|
|
}
|
|
|
|
// SortPolicy determines how ready work is ordered
|
|
type SortPolicy string
|
|
|
|
// Sort policy constants
|
|
const (
|
|
// SortPolicyHybrid prioritizes recent issues by priority, older by age
|
|
// Recent = created within 48 hours
|
|
// This is the default for backwards compatibility
|
|
SortPolicyHybrid SortPolicy = "hybrid"
|
|
|
|
// SortPolicyPriority always sorts by priority first, then creation date
|
|
// Use for autonomous execution, CI/CD, priority-driven workflows
|
|
SortPolicyPriority SortPolicy = "priority"
|
|
|
|
// SortPolicyOldest always sorts by creation date (oldest first)
|
|
// Use for backlog clearing, preventing issue starvation
|
|
SortPolicyOldest SortPolicy = "oldest"
|
|
)
|
|
|
|
// IsValid checks if the sort policy value is valid
|
|
func (s SortPolicy) IsValid() bool {
|
|
switch s {
|
|
case SortPolicyHybrid, SortPolicyPriority, SortPolicyOldest, "":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WorkFilter is used to filter ready work queries
|
|
type WorkFilter struct {
|
|
Status Status
|
|
Type string // Filter by issue type (task, bug, feature, epic, merge-request, etc.)
|
|
Priority *int
|
|
Assignee *string
|
|
Unassigned bool // Filter for issues with no assignee
|
|
Labels []string // AND semantics: issue must have ALL these labels
|
|
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
|
|
Limit int
|
|
SortPolicy SortPolicy
|
|
|
|
// Parent filtering: filter to descendants of a bead/epic (recursive)
|
|
ParentID *string // Show all descendants of this issue
|
|
}
|
|
|
|
// StaleFilter is used to filter stale issue queries
|
|
type StaleFilter struct {
|
|
Days int // Issues not updated in this many days
|
|
Status string // Filter by status (open|in_progress|blocked), empty = all non-closed
|
|
Limit int // Maximum issues to return
|
|
}
|
|
|
|
// EpicStatus represents an epic with its completion status
|
|
type EpicStatus struct {
|
|
Epic *Issue `json:"epic"`
|
|
TotalChildren int `json:"total_children"`
|
|
ClosedChildren int `json:"closed_children"`
|
|
EligibleForClose bool `json:"eligible_for_close"`
|
|
}
|
|
|
|
// BondRef tracks compound molecule lineage (bd-rnnr).
|
|
// When protos or molecules are bonded together, BondRefs record
|
|
// which sources were combined and how they were attached.
|
|
type BondRef struct {
|
|
ProtoID string `json:"proto_id"` // Source proto/molecule ID
|
|
BondType string `json:"bond_type"` // sequential, parallel, conditional
|
|
BondPoint string `json:"bond_point,omitempty"` // Attachment site (issue ID or empty for root)
|
|
}
|
|
|
|
// Bond type constants for compound molecules
|
|
const (
|
|
BondTypeSequential = "sequential" // B runs after A completes
|
|
BondTypeParallel = "parallel" // B runs alongside A
|
|
BondTypeConditional = "conditional" // B runs only if A fails
|
|
BondTypeRoot = "root" // Marks the primary/root component
|
|
)
|
|
|
|
// IsCompound returns true if this issue is a compound (bonded from multiple sources).
|
|
func (i *Issue) IsCompound() bool {
|
|
return len(i.BondedFrom) > 0
|
|
}
|
|
|
|
// GetConstituents returns the BondRefs for this compound's constituent protos.
|
|
// Returns nil for non-compound issues.
|
|
func (i *Issue) GetConstituents() []BondRef {
|
|
return i.BondedFrom
|
|
}
|
|
|
|
// EntityRef is a structured reference to an entity (human, agent, or org).
|
|
// This is the foundation for HOP entity tracking and CV chains.
|
|
// Can be rendered as a URI: entity://hop/<platform>/<org>/<id>
|
|
//
|
|
// Example usage:
|
|
//
|
|
// ref := &EntityRef{
|
|
// Name: "polecat/Nux",
|
|
// Platform: "gastown",
|
|
// Org: "steveyegge",
|
|
// ID: "polecat-nux",
|
|
// }
|
|
// uri := ref.URI() // "entity://hop/gastown/steveyegge/polecat-nux"
|
|
type EntityRef struct {
|
|
// Name is the human-readable identifier (e.g., "polecat/Nux", "mayor")
|
|
Name string `json:"name,omitempty"`
|
|
|
|
// Platform identifies the execution context (e.g., "gastown", "github")
|
|
Platform string `json:"platform,omitempty"`
|
|
|
|
// Org identifies the organization (e.g., "steveyegge", "anthropics")
|
|
Org string `json:"org,omitempty"`
|
|
|
|
// ID is the unique identifier within the platform/org (e.g., "polecat-nux")
|
|
ID string `json:"id,omitempty"`
|
|
}
|
|
|
|
// IsEmpty returns true if all fields are empty.
|
|
func (e *EntityRef) IsEmpty() bool {
|
|
if e == nil {
|
|
return true
|
|
}
|
|
return e.Name == "" && e.Platform == "" && e.Org == "" && e.ID == ""
|
|
}
|
|
|
|
// URI returns the entity as a HOP URI.
|
|
// Format: entity://hop/<platform>/<org>/<id>
|
|
// Returns empty string if Platform, Org, or ID is missing.
|
|
func (e *EntityRef) URI() string {
|
|
if e == nil || e.Platform == "" || e.Org == "" || e.ID == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("entity://hop/%s/%s/%s", e.Platform, e.Org, e.ID)
|
|
}
|
|
|
|
// String returns a human-readable representation.
|
|
// Prefers Name if set, otherwise returns URI or ID.
|
|
func (e *EntityRef) String() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Name != "" {
|
|
return e.Name
|
|
}
|
|
if uri := e.URI(); uri != "" {
|
|
return uri
|
|
}
|
|
return e.ID
|
|
}
|
|
|
|
// Validation records who validated/approved work completion.
|
|
// This is core to HOP's proof-of-stake concept - validators stake
|
|
// their reputation on approvals.
|
|
type Validation struct {
|
|
// Validator is who approved/rejected the work
|
|
Validator *EntityRef `json:"validator"`
|
|
|
|
// Outcome is the validation result: accepted, rejected, revision_requested
|
|
Outcome string `json:"outcome"`
|
|
|
|
// Timestamp is when the validation occurred
|
|
Timestamp time.Time `json:"timestamp"`
|
|
|
|
// Score is an optional quality score (0.0-1.0)
|
|
Score *float32 `json:"score,omitempty"`
|
|
}
|
|
|
|
// Validation outcome constants
|
|
const (
|
|
ValidationAccepted = "accepted"
|
|
ValidationRejected = "rejected"
|
|
ValidationRevisionRequested = "revision_requested"
|
|
)
|
|
|
|
// IsValidOutcome checks if the outcome is a known validation outcome.
|
|
func (v *Validation) IsValidOutcome() bool {
|
|
switch v.Outcome {
|
|
case ValidationAccepted, ValidationRejected, ValidationRevisionRequested:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseEntityURI parses a HOP entity URI into an EntityRef.
|
|
// Format: entity://hop/<platform>/<org>/<id>
|
|
// Returns nil and error if the URI is invalid.
|
|
func ParseEntityURI(uri string) (*EntityRef, error) {
|
|
const prefix = "entity://hop/"
|
|
if !strings.HasPrefix(uri, prefix) {
|
|
return nil, fmt.Errorf("invalid entity URI: must start with %q", prefix)
|
|
}
|
|
|
|
rest := uri[len(prefix):]
|
|
parts := strings.SplitN(rest, "/", 3)
|
|
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
|
return nil, fmt.Errorf("invalid entity URI: expected entity://hop/<platform>/<org>/<id>, got %q", uri)
|
|
}
|
|
|
|
return &EntityRef{
|
|
Platform: parts[0],
|
|
Org: parts[1],
|
|
ID: parts[2],
|
|
}, nil
|
|
}
|