Files
boshu2__agentops/cli/cmd/ao/rpi_phased_context.go
T
Boden Fuller 2b338ee0c3 fix(cli+docs): tech debt sweep — UTF-8 truncation, CORS, stale refs (na-7r3)
Go fixes:
- Fix UTF-8 boundary slicing in narrative truncation (rune-safe truncateRunes helper)
- Add signal.Stop to rpi_serve signal handlers (prevent goroutine leak)
- Replace CORS wildcard with localhost-only validation (prevent cross-site reads)
- Surface SSE polling errors to client instead of silent continue
- Extract section name constants (HISTORY/INTEL/TASK) from magic strings
- Add intel_scope enum validation (none/topic/full)
- Sort resolveSkillPath glob matches for deterministic resolution
- Add random suffix to adhoc context IDs (prevent 1-second collision)
- Surface writePromptAuditTrail errors to stderr unconditionally
- Document RPI_RUN_ID env var in ao inject --help

Doc fixes:
- Replace stale /learn skill references with /retro --quick across 9 workflow docs
- Replace .agents/retros/ with .agents/council/ in post-mortem SKILL.md + references
- Reorder GOALS.md directives to sequential 1-8 numbering
- Rename post-mortem Phase 2 steps from 4.x to EX.x
- Update SKILL-TIERS.md: council + retro → council + knowledge lifecycle
- Sync all changes to skills-codex mirror

Epic: na-7r3 (6 issues, 2 waves)
2026-03-04 10:07:01 -05:00

678 lines
24 KiB
Go

package main
import (
"cmp"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
"time"
cliConfig "github.com/boshu2/agentops/cli/internal/config"
cliRPI "github.com/boshu2/agentops/cli/internal/rpi"
)
// phasedEngineOptions captures all configurable parameters for runPhasedEngine.
// This allows the loop and other callers to invoke the phased engine programmatically
// without depending on global cobra flag variables.
type phasedEngineOptions struct {
From string
FastPath bool
TestFirst bool
Interactive bool
MaxRetries int
PhaseTimeout time.Duration
StallTimeout time.Duration
StreamStartupTimeout time.Duration
NoWorktree bool
LiveStatus bool
SwarmFirst bool
AutoCleanStale bool
AutoCleanStaleAfter time.Duration
StallCheckInterval time.Duration
RuntimeMode string
RuntimeCommand string
AOCommand string
BDCommand string
TmuxCommand string
TmuxWorkers int
NoBudget bool
BudgetSpec string
RunID string // Pre-seeded run ID (serve mode); empty = auto-generate
NoDashboard bool
StdoutWriter io.Writer `json:"-"` // runtime-only; suppresses raw Claude output when dashboard active
OnSpawnCwdReady func(spawnCwd string) `json:"-"` // called after worktree resolved; serve mode uses this to update mux root
}
// defaultPhasedEngineOptions returns options matching the default cobra flag values.
func defaultPhasedEngineOptions() phasedEngineOptions {
return phasedEngineOptions{
From: "discovery",
TestFirst: true,
MaxRetries: 3,
PhaseTimeout: 90 * time.Minute,
StallTimeout: 10 * time.Minute,
StreamStartupTimeout: 45 * time.Second,
SwarmFirst: true,
AutoCleanStaleAfter: 24 * time.Hour,
StallCheckInterval: 30 * time.Second,
RuntimeMode: "auto",
RuntimeCommand: "claude",
AOCommand: "ao",
BDCommand: "bd",
TmuxCommand: "tmux",
TmuxWorkers: 1,
NoBudget: false,
BudgetSpec: "",
}
}
// Phase represents an RPI phase with its index and name.
type phase struct {
Num int
Name string
Step string // ratchet step name
}
var phases = []phase{
{1, "discovery", "research"},
{2, "implementation", "implement"},
{3, "validation", "validate"},
}
// phasedState persists orchestrator state between phase spawns.
type phasedState struct {
SchemaVersion int `json:"schema_version"`
Goal string `json:"goal"`
EpicID string `json:"epic_id,omitempty"`
Phase int `json:"phase"`
StartPhase int `json:"start_phase"`
Cycle int `json:"cycle"`
ParentEpic string `json:"parent_epic,omitempty"`
FastPath bool `json:"fast_path"`
TestFirst bool `json:"test_first"`
SwarmFirst bool `json:"swarm_first"`
Complexity ComplexityLevel `json:"complexity,omitempty"` // fast, standard, full
Verdicts map[string]string `json:"verdicts"`
Attempts map[string]int `json:"attempts"`
StartedAt string `json:"started_at"`
WorktreePath string `json:"worktree_path,omitempty"`
RunID string `json:"run_id,omitempty"`
OrchestratorPID int `json:"orchestrator_pid,omitempty"`
Backend string `json:"backend,omitempty"`
TerminalStatus string `json:"terminal_status,omitempty"` // interrupted, failed, stale, completed
TerminalReason string `json:"terminal_reason,omitempty"`
TerminatedAt string `json:"terminated_at,omitempty"`
Opts phasedEngineOptions `json:"opts"`
}
// retryContext holds context for retrying a failed gate.
type retryContext struct {
Attempt int
Findings []finding
Verdict string
}
// finding represents a structured finding from a council report.
type finding struct {
Description string `json:"description"`
Fix string `json:"fix"`
Ref string `json:"ref"`
}
// phaseSummaryInstruction is prepended to each phase prompt so Claude writes a rich summary.
// Placed first so it survives context compaction (early instructions persist longer).
const phaseSummaryInstruction = `PHASE SUMMARY CONTRACT: Before finishing this session, write a concise summary (max 500 tokens) to .agents/rpi/phase-{{.PhaseNum}}-summary.md covering key insights, tradeoffs considered, and risks for subsequent phases. This file is read by the next phase.
`
// contextDisciplineInstruction is prepended to every phase prompt to prevent compaction.
// CONTEXT DISCIPLINE: This constant exists so the CLI can enforce context-aware behavior.
const contextDisciplineInstruction = `CONTEXT DISCIPLINE: You are running inside ao rpi phased (phase {{.PhaseNum}} of 3). Each phase gets a FRESH context window. Stay disciplined:
- Do NOT accumulate large file contents in context. Read files with the Read tool JIT and extract only what you need.
- Do NOT explore broadly when narrow exploration suffices. Be surgical.
- Write findings, plans, and results to DISK (files in .agents/), not just in conversation.
- If you are delegating to workers or spawning agents, do NOT accumulate their full output. Read their result files from disk.
- If you notice context degradation (forgetting earlier instructions, repeating yourself, losing track of the goal), IMMEDIATELY write a handoff to .agents/rpi/phase-{{.PhaseNum}}-handoff.md with: (1) what you accomplished, (2) what remains, (3) key context. Then finish cleanly.
{{.ContextBudget}}
`
// phaseContextBudgets provides phase-specific context guidance.
var phaseContextBudgets = map[int]string{
1: "BUDGET: This session runs research + plan + pre-mortem. Research: limit to ~15 file reads, write findings to .agents/research/. Plan: write to .agents/plans/, focus on issue creation. Pre-mortem: invoke /council, read the verdict, done. If pre-mortem FAILs, re-plan and re-run pre-mortem within this session (max 3 attempts).",
2: "BUDGET (CRITICAL): Crank is the highest-risk phase for context. /crank spawns workers internally. Do NOT re-read worker output into your context. Trust /crank to manage its waves. Read only the completion status.",
3: "BUDGET: This session runs vibe + post-mortem. Vibe: invoke /council on recent changes, read the verdict. Post-mortem: invoke /council + /retro, read output files, write summary. Minimal context for both.",
}
// phasePrompts defines Go templates for each phase's Claude invocation.
// Phase 1 (discovery) chains research + plan + pre-mortem in a single session
// for prompt cache reuse. Phase 2 (implementation) gets a fresh context window.
// Phase 3 (validation) chains vibe + post-mortem with fresh eyes.
var phasePrompts = map[int]string{
// Discovery: research → plan → pre-mortem (all in one session)
1: `{{if .SwarmFirst}}SWARM-FIRST EXECUTION CONTRACT:
- Default to /swarm for each step in this phase (research, plan, pre-mortem) using a lead + worker team pattern.
- If /swarm runtime is unavailable, execute the direct commands below in this same session.
- Keep worker outputs on disk and consume thin summaries only.
{{end}}Run these skills IN SEQUENCE. Do not skip any step.
STEP 1 — Research:
{{if .SwarmFirst}}Prefer: execute this step via /swarm with research-focused workers.
Fallback direct command:
{{end}}/research "{{.Goal}}"{{if not .Interactive}} --auto{{end}}
STEP 2 — Plan:
After research completes, run:
{{if .SwarmFirst}}Prefer: execute this step via /swarm with planning/decomposition workers.
Fallback direct command:
{{end}}/plan "{{.Goal}}"{{if not .Interactive}} --auto{{end}}
STEP 3 — Pre-mortem:
After plan completes, run:
{{if .SwarmFirst}}Prefer: execute this step via /swarm (including council/critique workers when available).
Fallback direct command:
{{end}}/pre-mortem{{if .FastPath}} --quick{{end}}
If pre-mortem returns FAIL, re-run /plan with the findings and then /pre-mortem again. Max 3 total attempts. If still FAIL after 3 attempts, stop and report.
If pre-mortem returns PASS or WARN, proceed.`,
// Implementation: crank (single skill, fresh context)
2: `{{if .SwarmFirst}}SWARM-FIRST EXECUTION CONTRACT:
- Run implementation with swarm-managed waves by default (lead + worker teams).
- Prefer crank paths that delegate to /swarm for wave execution.
{{end}}{{if .PlanFileMode}}PLAN-FILE MODE: No beads epic exists. Use TaskList for issue tracking.
/crank {{.PlanFilePath}}{{if .TestFirst}} --test-first{{end}}{{else}}/crank {{.EpicID}}{{if .TestFirst}} --test-first{{end}}{{end}}`,
// Validation: vibe → post-mortem (both in one session, fresh eyes)
3: `{{if .SwarmFirst}}SWARM-FIRST EXECUTION CONTRACT:
- Use swarm/team execution for validation and retrospective steps where available.
- Keep validator and implementer contexts isolated; do not reuse implementation worker context.
{{end}}Run these skills IN SEQUENCE. Do not skip any step.
STEP 1 — Vibe:
{{if .SwarmFirst}}Prefer: execute vibe using /swarm-driven validation workers.
Fallback direct command:
{{end}}/vibe{{if .FastPath}} --quick{{end}} recent
If vibe returns FAIL, STOP and report the findings. Do NOT proceed to post-mortem.
If vibe returns PASS or WARN, proceed.
STEP 2 — Post-mortem:
{{if .SwarmFirst}}Prefer: execute post-mortem using /swarm-driven retro workers.
Fallback direct command:
{{end}}{{if .PlanFileMode}}/post-mortem --quick recent{{else}}/post-mortem{{if .FastPath}} --quick{{end}} {{.EpicID}}{{end}}`,
}
// retryContextDisciplineInstruction is appended to retry prompts to prevent
// re-doing work that already succeeded in prior phases or prior attempts.
const retryContextDisciplineInstruction = `Before retrying, summarize what was accomplished in prior phases and what specific issue caused the retry. Do not repeat work that already succeeded.`
// retryPhaseSummaryInstruction is appended to retry prompts so the model
// includes prior phase context when constructing the retry attempt.
const retryPhaseSummaryInstruction = `Include a brief summary of prior phase outcomes when constructing the retry context. This helps the model avoid re-doing completed work and focus on the specific failure.`
// retryPrompts defines templates for retry invocations with feedback context.
// Phase 1 retries are handled WITHIN the session (the prompt instructs Claude to retry).
// Phase 3 (validation) FAIL triggers a fresh phase 2 (implementation) session.
var retryPrompts = map[int]string{
// Vibe FAIL → re-crank with feedback (spawns fresh implementation session)
3: `{{if .PlanFileMode}}/crank {{.PlanFilePath}}{{if .TestFirst}} --test-first{{end}}{{else}}/crank {{.EpicID}}{{if .TestFirst}} --test-first{{end}}{{end}}` + "\n\n" +
`Vibe FAIL (attempt {{.RetryAttempt}}/{{.MaxRetries}}). Address these findings:` + "\n" +
`{{range .Findings}}FINDING: {{.Description}} | FIX: {{.Fix}} | REF: {{.Ref}}` + "\n" + `{{end}}`,
}
// resolveWorktreeModeFromConfig checks the agentops config for rpi.worktree_mode
// and returns the effective NoWorktree value.
func resolveWorktreeModeFromConfig(flagDefault bool) bool {
cfg, err := cliConfig.Load(nil)
if err != nil {
return flagDefault
}
switch cfg.RPI.WorktreeMode {
case "never":
return true
case "always":
return false
default: // "auto" or empty
return flagDefault
}
}
func normalizeRuntimeMode(mode string) string {
return cmp.Or(strings.ToLower(strings.TrimSpace(mode)), "auto")
}
func effectiveRuntimeCommand(command string) string {
return cmp.Or(strings.TrimSpace(command), "claude")
}
func effectiveAOCommand(command string) string {
return cmp.Or(strings.TrimSpace(command), "ao")
}
func effectiveBDCommand(command string) string {
return cmp.Or(strings.TrimSpace(command), "bd")
}
func effectiveTmuxCommand(command string) string {
return cmp.Or(strings.TrimSpace(command), "tmux")
}
func validateRuntimeMode(mode string) error {
switch normalizeRuntimeMode(mode) {
case "auto", "direct", "stream", "tmux":
return nil
default:
return fmt.Errorf("invalid runtime %q (valid: auto|direct|stream|tmux)", mode)
}
}
// parsePhaseBudgetSpec parses --budget=<phase:seconds,...> into per-phase durations.
// Phase aliases are accepted (for example: research, plan, pre-mortem, crank, vibe).
// Multiple entries that map to the same consolidated phase are summed.
func parsePhaseBudgetSpec(spec string) (map[int]time.Duration, error) {
budgets := make(map[int]time.Duration)
trimmed := strings.TrimSpace(spec)
if trimmed == "" {
return budgets, nil
}
entries := strings.Split(trimmed, ",")
for _, entry := range entries {
token := strings.TrimSpace(entry)
if token == "" {
return nil, fmt.Errorf("invalid budget spec %q (empty entry)", spec)
}
parts := strings.SplitN(token, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid budget entry %q (expected <phase>:<seconds>)", token)
}
phaseName := strings.TrimSpace(parts[0])
phaseNum := phaseNameToNum(phaseName)
if phaseNum == 0 {
return nil, fmt.Errorf("unknown budget phase %q (valid: discovery|implementation|validation and aliases)", phaseName)
}
secondsRaw := strings.TrimSpace(parts[1])
seconds, err := strconv.Atoi(secondsRaw)
if err != nil || seconds <= 0 {
return nil, fmt.Errorf("invalid budget seconds %q for phase %q (must be positive integer)", secondsRaw, phaseName)
}
budgets[phaseNum] += time.Duration(seconds) * time.Second
}
return budgets, nil
}
// defaultPhaseBudgetForComplexity returns the default budget for a consolidated phase.
// Implementation (phase 2) remains unbounded; crank has its own wave/backpressure limits.
func defaultPhaseBudgetForComplexity(complexity ComplexityLevel, phaseNum int) time.Duration {
switch phaseNum {
case 1: // discovery = research + plan + pre-mortem
switch complexity {
case ComplexityFast:
return 6 * time.Minute
case ComplexityFull:
return 25 * time.Minute
default:
return 13 * time.Minute
}
case 2: // implementation
return 0
case 3: // validation
switch complexity {
case ComplexityFast:
return 0
case ComplexityFull:
return 10 * time.Minute
default:
return 5 * time.Minute
}
default:
return 0
}
}
func budgetComplexityLevel(state *phasedState) ComplexityLevel {
if state == nil {
return ComplexityStandard
}
if state.FastPath {
return ComplexityFast
}
if state.Complexity == "" {
return ComplexityStandard
}
return state.Complexity
}
// resolvePhaseBudget returns the effective per-phase runtime budget.
// Returns hasBudget=false when no budget applies to the phase.
func resolvePhaseBudget(state *phasedState, phaseNum int) (budget time.Duration, hasBudget bool, err error) {
if state == nil {
return 0, false, nil
}
if state.Opts.NoBudget {
return 0, false, nil
}
overrides, err := parsePhaseBudgetSpec(state.Opts.BudgetSpec)
if err != nil {
return 0, false, err
}
if override, ok := overrides[phaseNum]; ok {
return override, true, nil
}
defaultBudget := defaultPhaseBudgetForComplexity(budgetComplexityLevel(state), phaseNum)
if defaultBudget <= 0 {
return 0, false, nil
}
return defaultBudget, true, nil
}
// renderPreambleInstructions renders the context-discipline and summary-contract
// instruction templates into the prompt builder. These are placed first so they
// survive context compaction.
func renderPreambleInstructions(prompt *strings.Builder, data any) {
disciplineTmpl, err := template.New("discipline").Parse(contextDisciplineInstruction)
if err == nil {
if execErr := disciplineTmpl.Execute(prompt, data); execErr != nil {
VerbosePrintf("Warning: could not render context discipline instruction: %v\n", execErr)
}
}
summaryTmpl, err := template.New("summary").Parse(phaseSummaryInstruction)
if err == nil {
if execErr := summaryTmpl.Execute(prompt, data); execErr != nil {
VerbosePrintf("Warning: could not render summary instruction: %v\n", execErr)
}
}
}
// buildPromptForPhase constructs the Claude invocation prompt for a phase.
func buildPromptForPhase(cwd string, phaseNum int, state *phasedState, _ *retryContext) (string, error) {
tmplStr, ok := phasePrompts[phaseNum]
if !ok {
return "", fmt.Errorf("no prompt template for phase %d", phaseNum)
}
tmpl, err := template.New("phase").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
data := struct {
Goal string
EpicID string
FastPath bool
TestFirst bool
SwarmFirst bool
Interactive bool
PhaseNum int
ContextBudget string
PlanFileMode bool
PlanFilePath string
}{
Goal: state.Goal,
EpicID: state.EpicID,
FastPath: state.FastPath,
TestFirst: state.TestFirst,
SwarmFirst: state.SwarmFirst,
Interactive: state.Opts.Interactive,
PhaseNum: phaseNum,
ContextBudget: phaseContextBudgets[phaseNum],
PlanFileMode: isPlanFileEpic(state.EpicID),
PlanFilePath: planFileFromEpic(state.EpicID),
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
var prompt strings.Builder
renderPreambleInstructions(&prompt, data)
// Cross-phase context for phases 2+ — prefer structured handoffs, fall back to raw summaries
if phaseNum >= 2 {
handoffs, _ := readAllHandoffs(cwd, phaseNum)
if len(handoffs) > 0 {
manifest := defaultPhaseManifests[phaseNum]
ctx := buildHandoffContext(handoffs, manifest)
prompt.WriteString(ctx)
prompt.WriteString("\n\n")
} else {
// Fallback: legacy summary-based context (for runs that predate structured handoffs)
ctx := buildPhaseContext(cwd, state, phaseNum)
if ctx != "" {
prompt.WriteString(ctx)
prompt.WriteString("\n\n")
}
}
}
prompt.WriteString(buf.String())
// Write provenance audit trail
if state.RunID != "" {
if err := writePromptAuditTrail(cwd, state.RunID, phaseNum, prompt.String()); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write prompt audit trail for phase %d: %v\n", phaseNum, err)
}
}
return prompt.String(), nil
}
// buildPhaseContext constructs a context block from goal, verdicts, and prior phase summaries.
func buildPhaseContext(cwd string, state *phasedState, phaseNum int) string {
var parts []string
// Always include the goal
if state.Goal != "" {
parts = append(parts, fmt.Sprintf("Goal: %s", state.Goal))
}
// Include prior verdicts
for key, verdict := range state.Verdicts {
parts = append(parts, fmt.Sprintf("%s verdict: %s", strings.ReplaceAll(key, "_", "-"), verdict))
}
// Include prior phase summaries (read from disk)
if cwd != "" {
summaries := readPhaseSummaries(cwd, phaseNum)
if summaries != "" {
parts = append(parts, summaries)
}
}
if len(parts) == 0 {
return ""
}
return "--- RPI Context (from prior phases) ---\n" + strings.Join(parts, "\n")
}
// readPhaseSummaries reads all phase summary files prior to the given phase.
func readPhaseSummaries(cwd string, currentPhase int) string {
var summaries []string
rpiDir := filepath.Join(cwd, ".agents", "rpi")
for i := 1; i < currentPhase; i++ {
path := filepath.Join(rpiDir, fmt.Sprintf("phase-%d-summary.md", i))
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content == "" {
continue
}
// Cap each summary to prevent context bloat
if len(content) > 2000 {
content = content[:2000] + "..."
}
phaseName := "unknown"
if i > 0 && i <= len(phases) {
phaseName = phases[i-1].Name
}
summaries = append(summaries, fmt.Sprintf("[Phase %d: %s]\n%s", i, phaseName, content))
}
if len(summaries) == 0 {
return ""
}
return strings.Join(summaries, "\n\n")
}
// buildRetryPrompt constructs a retry prompt with feedback context.
func buildRetryPrompt(cwd string, phaseNum int, state *phasedState, retryCtx *retryContext) (string, error) {
tmplStr, ok := retryPrompts[phaseNum]
if !ok {
// No retry template — fall back to normal prompt
return buildPromptForPhase(cwd, phaseNum, state, retryCtx)
}
tmpl, err := template.New("retry").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("parse retry template: %w", err)
}
data := struct {
Goal string
EpicID string
FastPath bool
TestFirst bool
RetryAttempt int
MaxRetries int
Findings []finding
PhaseNum int
ContextBudget string
PlanFileMode bool
PlanFilePath string
}{
Goal: state.Goal,
EpicID: state.EpicID,
FastPath: state.FastPath,
TestFirst: state.TestFirst,
RetryAttempt: retryCtx.Attempt,
MaxRetries: state.Opts.MaxRetries,
Findings: retryCtx.Findings,
PhaseNum: phaseNum,
ContextBudget: phaseContextBudgets[phaseNum],
PlanFileMode: isPlanFileEpic(state.EpicID),
PlanFilePath: planFileFromEpic(state.EpicID),
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute retry template: %w", err)
}
skillInvocation := buf.String()
// Build prompt: context discipline and summary contract first (survive compaction),
// then the retry skill invocation.
var prompt strings.Builder
// 1. Context discipline instruction (first — survives compaction)
disciplineTmpl, err := template.New("discipline").Parse(contextDisciplineInstruction)
if err == nil {
if err := disciplineTmpl.Execute(&prompt, data); err != nil {
VerbosePrintf("Warning: could not render context discipline instruction: %v\n", err)
}
}
// 2. Summary instruction
summaryTmpl, err := template.New("summary").Parse(phaseSummaryInstruction)
if err == nil {
if err := summaryTmpl.Execute(&prompt, data); err != nil {
VerbosePrintf("Warning: could not render summary instruction: %v\n", err)
}
}
// 3. Retry-specific context discipline (avoid repeating prior work)
prompt.WriteString("\n")
prompt.WriteString(retryContextDisciplineInstruction)
prompt.WriteString("\n\n")
// 4. Retry phase summary instruction (include prior phase outcomes)
prompt.WriteString(retryPhaseSummaryInstruction)
prompt.WriteString("\n\n")
// 5. Retry skill invocation (last — the actual command with findings)
prompt.WriteString(skillInvocation)
// Write provenance audit trail for retry
if state.RunID != "" {
if err := writePromptAuditTrail(cwd, state.RunID, phaseNum, prompt.String()); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write retry audit trail for phase %d: %v\n", phaseNum, err)
}
}
return prompt.String(), nil
}
// phaseNameToNum converts a phase name to a consolidated phase number (1-3).
func phaseNameToNum(name string) int {
normalized := strings.ToLower(strings.TrimSpace(name))
aliases := map[string]int{
// Canonical 3-phase names
"discovery": 1,
"implementation": 2,
"validation": 3,
// Backward-compatible aliases (old 6-phase names map to consolidated phases)
"research": 1,
"plan": 1,
"pre-mortem": 1,
"premortem": 1,
"pre_mortem": 1,
"crank": 2,
"implement": 2,
"vibe": 3,
"validate": 3,
"post-mortem": 3,
"postmortem": 3,
"post_mortem": 3,
}
return aliases[normalized]
}
// worktreeTimeout is the timeout for git worktree operations (matches Olympus DefaultTimeout).
const worktreeTimeout = 30 * time.Second
// generateRunID returns a 12-char lowercase hex string from crypto/rand.
func generateRunID() string {
return cliRPI.GenerateRunID()
}
// getCurrentBranch returns the current branch name, or error if detached HEAD.
func getCurrentBranch(repoRoot string) (string, error) {
return cliRPI.GetCurrentBranch(repoRoot, worktreeTimeout)
}
// createWorktree creates a sibling git worktree for isolated RPI execution.
// Path: ../<repo-basename>-rpi-<runID>/
func createWorktree(cwd string) (worktreePath, runID string, err error) {
return cliRPI.CreateWorktree(cwd, worktreeTimeout, VerbosePrintf)
}
// mergeWorktree merges the RPI worktree branch back into the original branch.
// Retries the pre-merge dirty check with backoff to handle the race where
// another parallel run is mid-merge (repo momentarily dirty).
func mergeWorktree(repoRoot, worktreePath, runID string) error {
return cliRPI.MergeWorktree(repoRoot, worktreePath, runID, worktreeTimeout, VerbosePrintf)
}
// removeWorktree removes a worktree directory and any legacy branch marker.
// Modeled on Olympus internal/git/worktree.go Remove().
func removeWorktree(repoRoot, worktreePath, runID string) error {
return cliRPI.RemoveWorktree(repoRoot, worktreePath, runID, worktreeTimeout)
}