ThreeDoors is a Go TUI application that reduces task management decision friction by showing only three tasks at a time. Built with Bubbletea (charmbracelet/bubbletea). See SOUL.md for project philosophy and design values.
- Language: Go 1.25.4+
- TUI Framework: Bubbletea + Lipgloss + Bubbles
- Data: YAML task files, JSONL session logs
- Build:
just build·just test·just lint·just fmt
cmd/threedoors/ # Entry point
internal/tasks/ # Task domain: models, providers, persistence, analytics
internal/tui/ # Bubbletea views and UI components
docs/ # Architecture, stories, PRD
scripts/ # Shell analysis scripts
Key interfaces: TaskProvider (internal/tasks/provider.go) — implement for new storage backends.
Git workflow: Gitflow. develop is the default branch. Branch from and
PR into develop. Alpha releases are cut automatically from develop.
Stable releases are cut from main via version tags (v*). Do not push
directly to main.
just fmt # gofumpt formatting (run before every commit)
just lint # golangci-lint — must pass with zero warnings
just test # go test ./... -v
go test -race ./... # Race detector — run before pushingWhen running multiclaude, the workspace window (tmux window 1) is the primary human interaction point — not the supervisor window.
- Use the workspace window for all human interaction with the system. It is explicitly exempted from daemon wake nudges and agent message injections, so your input will not be corrupted mid-keystroke.
- The supervisor window belongs to the supervisor Claude agent. Typing in it risks prompt injection conflicts with automated messages that arrive every ~2 minutes.
- Communicate with the supervisor via messaging:
multiclaude message send supervisor "your message here" - Observe agent activity read-only:
multiclaude agent attach <name> --read-only - Check system status from the workspace:
multiclaude status
Governance-critical files are protected by .github/CODEOWNERS with require_code_owner_review enabled in the branch ruleset. PRs touching these files require @skippy approval before merge. PRs touching only unprotected files merge with CI-only gates (existing behavior).
Protected files (require human review):
SOUL.md— project philosophyCLAUDE.md— agent instructions.claude/— agent rules, settingsROADMAP.md— scope controldocs/prd/epic-list.md— epic definitionsdocs/prd/epics-and-stories.md— story breakdowndocs/decisions/BOARD.md— architectural decisions.github/— CI/CD, CODEOWNERS itselfagents/— agent behavior definitions
Unprotected (AI agents can self-merge via merge-queue):
internal/,cmd/,pkg/— all application codedocs/stories/— workers must update story status freely- Test files, fixtures, scripts, build files
Rules for workers:
- Do NOT modify CODEOWNERS-protected files unless the story explicitly requires it
- merge-queue will skip PRs that touch protected files and label them
status.needs-human - If your story requires changes to protected files, the PR will need manual owner approval
A PreToolUse hook (scripts/hooks/git-safety.sh) mechanically enforces git safety rules via .claude/settings.json. This replaces prompt-level INC-002 guardrails with code-level enforcement that cannot be bypassed (Q-C-005).
Blocked commands (hook exits with code 2, tool call is rejected):
git fetch,git pull,git rebase,git merge— worktrees are managed by multiclaude; manual sync causes mid-rebase conflicts (INC-002)--no-gpg-sign/-c commit.gpgsign=false— all commits must be signedgit push origin main/master— use feature branches, never push directly to mainCo-Authored-Bytrailers — forbidden per project policy
Allowed: git add, git commit (signed), git push (feature branches), git status, git log, git diff, git branch, git checkout -b, git merge --abort, git stash, etc.
DO NOT conduct work without a story. Every implementation task must have a corresponding docs/stories/X.Y.story.md file before work begins. If work needs to get done, find or create the appropriate story first.
- Before implementing, check
docs/decisions/BOARD.mdfor relevant prior decisions, rejected options, and active research - Before implementing, verify the story file exists and read its acceptance criteria
- DO NOT check in code without first updating the story file, verifying that the ACs and tasks were met
- After implementation, update the story file status to
Done (PR #NNN).Donemeans all acceptance criteria are met in code — planning-only PRs (story creation, docs updates, research) do NOT qualify forDonestatus./plan-workcreates stories with statusNot Started; only/implement-storysetsDone. - If no story exists for needed work, create one (or ask the supervisor/PM to create one) before writing code
- Research, spikes, and documentation tasks are exempt — but should still reference a story when possible
Do NOT edit ROADMAP.md, docs/prd/epic-list.md, or docs/prd/epics-and-stories.md unless you are running /plan-work, or you are project-watchdog or supervisor.
- Update the story file status line:
Done (PR #NNN)— this is the ONLY doc update implementation workers make - Do NOT update planning docs — project-watchdog syncs them from story files
- project-watchdog initiates planning doc updates after story PRs merge, batching multiple updates into a single PR when possible (D-161)
/plan-workworkers create new epics/stories in all three planning docs as part of their pipeline- When creating a new epic or story, request the epic number from project-watchdog — do NOT self-assign
- ROADMAP.md ownership belongs to the PM role
- These three planning docs plus the story files form the source-of-truth chain — story files are authoritative for individual story status; planning docs must be kept consistent
When a party mode session, research spike, or architectural discussion produces a decision:
- Add an entry to
docs/decisions/BOARD.mdbefore the PR is submitted - Record both the adopted approach AND rejected alternatives with rationale
- If a prior decision is being overridden, update the original entry rather than creating a duplicate
Any PR modifying files in internal/tui/ or internal/cli/ MUST pass go test -race ./internal/tui/... ./internal/cli/... before submission. This is not optional — concurrency bugs in these packages have caused production panics.
Every commit message MUST reference the story being implemented:
feat: <description> (Story X.Y)fix: <description> (Story X.Y)docs: <description> (Story X.Y)
Commits for infrastructure work without a story should reference the issue number: fix: <description> (#NNN)
These rules prevent the most common AI-generated Go anti-patterns.
1. Use fmt.Fprintf — never WriteString + Sprintf
// WRONG — allocates intermediate string
s.WriteString(fmt.Sprintf("Task: %s", name))
// RIGHT — writes directly to the writer
fmt.Fprintf(&s, "Task: %s", name)2. Never nil-check before len
// WRONG — len handles nil slices/maps (returns 0)
if tasks != nil && len(tasks) > 0 { ... }
// RIGHT
if len(tasks) > 0 { ... }3. Always check error returns
// WRONG — silently ignoring error
data, _ := json.Marshal(task)
// RIGHT — handle or propagate every error
data, err := json.Marshal(task)
if err != nil {
return fmt.Errorf("marshal task %s: %w", task.ID, err)
}4. Wrap errors with context using %w
// WRONG — loses error chain
return fmt.Errorf("failed to save: %v", err)
// RIGHT — preserves chain for errors.Is/errors.As
return fmt.Errorf("save task %s: %w", id, err)5. Accept interfaces, return concrete types
// WRONG — returning interface hides implementation
func NewProvider() TaskProvider { ... }
// RIGHT — return the concrete type
func NewTextFileProvider(path string) *TextFileProvider { ... }6. context.Context is always the first parameter
// WRONG
func LoadTasks(path string, ctx context.Context) error
// RIGHT
func LoadTasks(ctx context.Context, path string) error7. Don't use interface{}/any without justification
- Prefer specific types or generics over
any - If
anyis needed, document why in a comment
8. Prefer value receivers unless mutation is needed
// Use pointer receiver only when:
// - The method mutates the receiver
// - The struct is large (>~64 bytes) and copying is expensive
// - Consistency: if one method needs pointer, all should use pointer9. No init() functions
- Pass dependencies explicitly via constructors
- Configuration belongs in
main()or factory functions
10. Timestamps always in UTC
// WRONG
time.Now()
// RIGHT
time.Now().UTC()- Every exported function that can fail returns
erroras last return value - Use
errors.Is()anderrors.As()for error inspection — never string matching - Define sentinel errors as package-level
varwith documentation:// ErrTaskNotFound is returned when a task ID doesn't exist in the pool. var ErrTaskNotFound = errors.New("task not found")
- No panics in user-facing code — Bubbletea
Update()andView()must never panic
- Table-driven tests for any function with >2 test cases:
func TestValidateStatus(t *testing.T) { tests := []struct { name string from Status to Status wantErr bool }{ {"todo to active", StatusTodo, StatusActive, false}, {"done to todo", StatusDone, StatusTodo, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateTransition(tt.from, tt.to) if (err != nil) != tt.wantErr { t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) } }) } }
- Use stdlib
testing— no testify. Uset.Fatal,t.Errorf,t.Helper() - Use
t.Helper()in test helper functions so failures report the caller's line - Use
t.Cleanup()instead ofdeferfor test resource cleanup - Test files live alongside source:
foo.go→foo_test.go - Test fixtures in
testdata/directories - Mark independent tests with
t.Parallel()where safe
- Package naming: lowercase, single word (
tasks,tui) — no underscores, no camelCase - File naming: lowercase snake_case (
task_pool.go,doors_view.go) - One primary type per file —
task.godefinesTask,task_pool.godefinesTaskPool - Import order: stdlib → external → internal (gofumpt enforces this)
- Keep packages small — split when a package exceeds ~10 files
- Provider pattern (
TaskProviderinterface) for storage backends — add new providers by implementing the interface - Factory functions (
NewTaskPool(),NewTextFileProvider()) — always use constructors, never raw struct literals for exported types - Atomic writes for all file persistence — write to
.tmp, sync, rename (seedocs/architecture/coding-standards.md) - Bubbletea pattern — all TUI output through
View()methods, neverfmt.Println
- Don't create unnecessary abstractions — three similar lines are better than a premature helper
- Don't add unused parameters "for future use" — YAGNI
- Don't shadow imports —
var errors = ...shadows theerrorspackage - Don't use
log.Fatal/os.Exitoutsidemain()— let errors propagate - Don't buffer channels without justification — unbuffered is the default for a reason
- Don't use
sync.Mutexwhenatomicsuffices for simple counters/flags - Don't create
utilsorhelperspackages — put functions where they're used - Don't add comments that restate the code — only comment the "why", not the "what"
- Don't use
strings.Builderthen callSprintfinto it — usefmt.Fprintfdirectly - Don't return
bool, erroras a substitute forerror— if the bool just means "did it succeed", the error alone suffices
- Formatter:
gofumpt(stricter thangofmt) — run viajust fmt - Linter:
golangci-lint run ./...— must pass with zero warnings - Vet:
go vet ./...— runs as part ofgolangci-lint - Never disable linter rules with
//nolintwithout a justifying comment
The bigger the interface, the weaker the abstraction.
Make the zero value useful.
A little copying is better than a little dependency.
Don't communicate by sharing memory; share memory by communicating.
Errors are values — program with them.
Don't just check errors, handle them gracefully.
- All user-visible output goes through Bubbletea
View()— neverfmt.Println - Use Lipgloss for styling — never ANSI escape codes directly
- Keep
Update()fast — no blocking I/O in the update loop - Use
tea.Cmdfor async operations (file I/O, timers)
Read charter.md before any substantive work. It contains:
- Current bedrock (what's committed)
- Current frontier (what's under exploration)
- Current graveyard (what's been ruled out)
- Read charter.md (orient)
- Identify the highest-value open question — or capture new ideas in _kos/ideas/
- Write an Exploration Brief in _kos/probes/
- Do the probe work
- Write a finding in _kos/findings/
- Harvest: update affected nodes, move files if confidence changed
- Update charter.md if bedrock changed
Cross-repo questions belong in the orchestrator's _kos/, not here.
Ideas live in _kos/ideas/ as markdown files. Generative, possibly contradictory, no commitment. When an idea crystallizes, extract into a frontier question + brief.
Nodes live in _kos/nodes/[confidence]/[id].yaml Schema follows kos schema v0.3. One node per file. Filename = node id.
Moving a file between confidence directories IS the promotion. Always accompany with a commit message explaining the evidence.
Before starting the next cycle, verify:
- Finding written and committed
- Charter updated if bedrock changed
- Frontier questions updated (closed, opened, or revised)
- Exploration briefs marked complete or carried forward