mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
2b338ee0c3
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)
402 lines
13 KiB
Go
402 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// phaseHandoff is the structured artifact that carries context between phases.
|
|
// Written by the orchestrator after each phase completes.
|
|
// Read by buildPromptForPhase() when constructing the next phase's prompt.
|
|
type phaseHandoff struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
RunID string `json:"run_id"`
|
|
Phase int `json:"phase"`
|
|
PhaseName string `json:"phase_name"`
|
|
Status string `json:"status"` // completed, time_boxed, failed
|
|
|
|
// Context for next phase
|
|
Goal string `json:"goal"`
|
|
EpicID string `json:"epic_id,omitempty"`
|
|
Verdicts map[string]string `json:"verdicts"`
|
|
|
|
// What happened
|
|
ArtifactsProduced []string `json:"artifacts_produced"`
|
|
DecisionsMade []string `json:"decisions_made"`
|
|
OpenRisks []string `json:"open_risks"`
|
|
|
|
// Metrics
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
CostUSD float64 `json:"cost_usd,omitempty"`
|
|
ToolCalls int `json:"tool_calls"`
|
|
|
|
// Context quality signals
|
|
ContextDegradation bool `json:"context_degradation"`
|
|
AdHocHandoff string `json:"ad_hoc_handoff,omitempty"`
|
|
|
|
// Narrative (from Claude's phase-N-summary.md, capped)
|
|
Narrative string `json:"narrative,omitempty"`
|
|
|
|
// Timestamp
|
|
CompletedAt string `json:"completed_at"`
|
|
}
|
|
|
|
// writePhaseHandoff atomically writes a phase handoff to .agents/rpi/phase-N-handoff.json.
|
|
func writePhaseHandoff(cwd string, h *phaseHandoff) error {
|
|
dir := filepath.Join(cwd, ".agents", "rpi")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create rpi dir: %w", err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(h, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal handoff: %w", err)
|
|
}
|
|
|
|
target := filepath.Join(dir, fmt.Sprintf("phase-%d-handoff.json", h.Phase))
|
|
tmp := target + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return fmt.Errorf("write tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, target); err != nil {
|
|
// Fallback: direct write if rename fails (cross-device)
|
|
_ = os.Remove(tmp)
|
|
return os.WriteFile(target, data, 0o644)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readPhaseHandoff reads a single phase handoff. Falls back to reading
|
|
// phase-N-summary.md if the structured handoff doesn't exist (backward compat).
|
|
func readPhaseHandoff(cwd string, phaseNum int) (*phaseHandoff, error) {
|
|
dir := filepath.Join(cwd, ".agents", "rpi")
|
|
jsonPath := filepath.Join(dir, fmt.Sprintf("phase-%d-handoff.json", phaseNum))
|
|
|
|
data, err := os.ReadFile(jsonPath)
|
|
if err == nil {
|
|
var h phaseHandoff
|
|
if parseErr := json.Unmarshal(data, &h); parseErr != nil {
|
|
return nil, fmt.Errorf("parse handoff: %w", parseErr)
|
|
}
|
|
return &h, nil
|
|
}
|
|
|
|
// Fallback: construct minimal handoff from legacy summary
|
|
return readLegacySummaryAsHandoff(cwd, phaseNum)
|
|
}
|
|
|
|
// readLegacySummaryAsHandoff reads a phase-N-summary*.md file and wraps it
|
|
// in a minimal phaseHandoff struct for backward compatibility.
|
|
func readLegacySummaryAsHandoff(cwd string, phaseNum int) (*phaseHandoff, error) {
|
|
dir := filepath.Join(cwd, ".agents", "rpi")
|
|
// Find phase-N-summary files (may have date suffix)
|
|
pattern := filepath.Join(dir, fmt.Sprintf("phase-%d-summary*.md", phaseNum))
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return nil, fmt.Errorf("no handoff or summary found for phase %d", phaseNum)
|
|
}
|
|
|
|
// Use the most recent match
|
|
latest := matches[len(matches)-1]
|
|
content, err := os.ReadFile(latest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read summary: %w", err)
|
|
}
|
|
|
|
phaseNames := map[int]string{1: "discovery", 2: "implementation", 3: "validation"}
|
|
return &phaseHandoff{
|
|
SchemaVersion: 1,
|
|
Phase: phaseNum,
|
|
PhaseName: phaseNames[phaseNum],
|
|
Status: "completed",
|
|
Narrative: string(content),
|
|
}, nil
|
|
}
|
|
|
|
// readAllHandoffs reads handoffs for phases 1 through upToPhase-1.
|
|
// Missing phases are silently skipped.
|
|
func readAllHandoffs(cwd string, upToPhase int) ([]*phaseHandoff, error) {
|
|
var handoffs []*phaseHandoff
|
|
for i := 1; i < upToPhase; i++ {
|
|
h, err := readPhaseHandoff(cwd, i)
|
|
if err != nil {
|
|
continue // skip missing phases
|
|
}
|
|
handoffs = append(handoffs, h)
|
|
}
|
|
if len(handoffs) == 0 {
|
|
return nil, fmt.Errorf("no handoffs found for phases 1..%d", upToPhase-1)
|
|
}
|
|
return handoffs, nil
|
|
}
|
|
|
|
// fieldAllowed checks whether a field should be included in handoff context.
|
|
// Returns true if the manifest has no HandoffFields (backward compat) or the field is listed.
|
|
func fieldAllowed(m phaseManifest, field string) bool {
|
|
if len(m.HandoffFields) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range m.HandoffFields {
|
|
if f == field {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// formatVerdicts renders a sorted verdict line from a map.
|
|
// Returns empty string if verdicts is nil or empty.
|
|
func formatVerdicts(verdicts map[string]string) string {
|
|
if len(verdicts) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(verdicts))
|
|
for k := range verdicts {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s %s", k, verdicts[k]))
|
|
}
|
|
return fmt.Sprintf("Verdict: %s\n", strings.Join(parts, ", "))
|
|
}
|
|
|
|
// renderHandoffField renders a labeled field line.
|
|
// For string values: returns "Label: value\n" or "" if empty.
|
|
// For []string values: returns "Label: a, b, c\n" or "" if empty.
|
|
func renderHandoffField(label string, value interface{}) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s: %s\n", label, v)
|
|
case []string:
|
|
if len(v) == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s: %s\n", label, strings.Join(v, ", "))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildHandoffContext formats handoffs for prompt injection into the next phase.
|
|
// The manifest controls which fields are included and narrative truncation length.
|
|
func buildHandoffContext(handoffs []*phaseHandoff, manifest phaseManifest) string {
|
|
if len(handoffs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("--- RPI Context (structured handoffs from prior phases) ---\n")
|
|
|
|
// Use goal from latest handoff
|
|
if fieldAllowed(manifest, "goal") {
|
|
for i := len(handoffs) - 1; i >= 0; i-- {
|
|
if handoffs[i].Goal != "" {
|
|
sb.WriteString(fmt.Sprintf("Goal: %s\n\n", handoffs[i].Goal))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
narrativeCap := resolveNarrativeCap(manifest)
|
|
|
|
for _, h := range handoffs {
|
|
renderHandoffEntry(&sb, h, manifest, narrativeCap)
|
|
}
|
|
|
|
renderDegradationWarnings(&sb, handoffs)
|
|
|
|
// Apply token budget if specified in manifest
|
|
if manifest.MaxTokens > 0 {
|
|
result, budgetInfo := applyContextBudget(sb.String(), manifest.MaxTokens)
|
|
if budgetInfo.WasTruncated {
|
|
VerbosePrintf("Context budget applied: %d→%d tokens (-%d)\n",
|
|
budgetInfo.OriginalTokens, budgetInfo.BudgetTokens, budgetInfo.TruncatedTokens)
|
|
}
|
|
return result
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// resolveNarrativeCap returns the narrative character cap from manifest.
|
|
// NarrativeCap=0 means "omit narrative" when HandoffFields is set (least-privilege).
|
|
// When HandoffFields is empty (no manifest), default to 1000 for backward compat.
|
|
func resolveNarrativeCap(manifest phaseManifest) int {
|
|
if manifest.NarrativeCap > 0 {
|
|
return manifest.NarrativeCap
|
|
}
|
|
if len(manifest.HandoffFields) == 0 {
|
|
return 1000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// renderHandoffEntry writes a single phase handoff block to the builder.
|
|
func renderHandoffEntry(sb *strings.Builder, h *phaseHandoff, manifest phaseManifest, narrativeCap int) {
|
|
sb.WriteString(fmt.Sprintf("[Phase %d: %s — %s (source: phase-%d-handoff.json)", h.Phase, h.PhaseName, h.Status, h.Phase))
|
|
if h.DurationSeconds > 0 {
|
|
sb.WriteString(fmt.Sprintf(" in %.0fs", h.DurationSeconds))
|
|
}
|
|
sb.WriteString("]\n")
|
|
|
|
if fieldAllowed(manifest, "verdicts") {
|
|
sb.WriteString(formatVerdicts(h.Verdicts))
|
|
}
|
|
if fieldAllowed(manifest, "epic_id") {
|
|
sb.WriteString(renderHandoffField("Epic", h.EpicID))
|
|
}
|
|
if fieldAllowed(manifest, "artifacts_produced") {
|
|
sb.WriteString(renderHandoffField("Artifacts", h.ArtifactsProduced))
|
|
}
|
|
if fieldAllowed(manifest, "decisions_made") {
|
|
sb.WriteString(renderHandoffField("Decisions", h.DecisionsMade))
|
|
}
|
|
if fieldAllowed(manifest, "open_risks") {
|
|
sb.WriteString(renderHandoffField("Risks", h.OpenRisks))
|
|
}
|
|
|
|
if narrativeCap > 0 && h.Narrative != "" {
|
|
narrative := h.Narrative
|
|
if len(narrative) > narrativeCap {
|
|
narrative = truncateRunes(narrative, narrativeCap)
|
|
}
|
|
sb.WriteString(fmt.Sprintf("Narrative (from phase-%d-summary): %s\n", h.Phase, narrative))
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// renderDegradationWarnings writes context degradation warnings for handoffs with context loss.
|
|
func renderDegradationWarnings(sb *strings.Builder, handoffs []*phaseHandoff) {
|
|
for _, h := range handoffs {
|
|
if h.ContextDegradation {
|
|
sb.WriteString(fmt.Sprintf("⚠️ CONTEXT DEGRADATION: Phase %d handoff was missing — context may be incomplete\n\n", h.Phase-1))
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildPhaseHandoffFromState constructs a handoff from existing state + phase result + summary.
|
|
func buildPhaseHandoffFromState(state *phasedState, phaseNum int, cwd string) *phaseHandoff {
|
|
phaseNames := map[int]string{1: "discovery", 2: "implementation", 3: "validation"}
|
|
|
|
h := &phaseHandoff{
|
|
SchemaVersion: 1,
|
|
RunID: state.RunID,
|
|
Phase: phaseNum,
|
|
PhaseName: phaseNames[phaseNum],
|
|
Status: "completed",
|
|
Goal: state.Goal,
|
|
EpicID: state.EpicID,
|
|
Verdicts: make(map[string]string),
|
|
CompletedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
// Copy accumulated verdicts
|
|
for k, v := range state.Verdicts {
|
|
h.Verdicts[k] = v
|
|
}
|
|
|
|
// Read phase result for metrics if available
|
|
resultPath := filepath.Join(cwd, ".agents", "rpi", fmt.Sprintf("phase-%d-result.json", phaseNum))
|
|
if data, err := os.ReadFile(resultPath); err == nil {
|
|
var pr phaseResult
|
|
if json.Unmarshal(data, &pr) == nil {
|
|
h.DurationSeconds = pr.DurationSeconds
|
|
h.Status = pr.Status
|
|
if pr.Status == "" {
|
|
h.Status = "completed"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read narrative from summary file
|
|
summaryDir := filepath.Join(cwd, ".agents", "rpi")
|
|
pattern := filepath.Join(summaryDir, fmt.Sprintf("phase-%d-summary*.md", phaseNum))
|
|
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
|
|
if content, err := os.ReadFile(matches[len(matches)-1]); err == nil {
|
|
narrative := string(content)
|
|
if len(narrative) > 2000 {
|
|
narrative = truncateRunes(narrative, 2000)
|
|
}
|
|
h.Narrative = narrative
|
|
}
|
|
}
|
|
|
|
// Scan for artifacts produced during this phase
|
|
h.ArtifactsProduced = discoverPhaseArtifacts(cwd, phaseNum)
|
|
|
|
// Check if previous phase had a structured handoff (degradation = missing prior handoff)
|
|
if phaseNum > 1 && !handoffDetected(cwd, phaseNum-1) {
|
|
h.ContextDegradation = true
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
// discoverPhaseArtifacts finds key artifacts produced during a phase.
|
|
func discoverPhaseArtifacts(cwd string, phaseNum int) []string {
|
|
var artifacts []string
|
|
rpiDir := filepath.Join(cwd, ".agents", "rpi")
|
|
|
|
// Check for phase-specific artifacts
|
|
summaryPattern := filepath.Join(rpiDir, fmt.Sprintf("phase-%d-summary*.md", phaseNum))
|
|
if matches, _ := filepath.Glob(summaryPattern); len(matches) > 0 {
|
|
for _, m := range matches {
|
|
rel, _ := filepath.Rel(cwd, m)
|
|
if rel != "" {
|
|
artifacts = append(artifacts, rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase-specific artifact discovery
|
|
switch phaseNum {
|
|
case 1: // discovery — look for plans and council reports
|
|
planPattern := filepath.Join(cwd, ".agents", "plans", "*.md")
|
|
if matches, _ := filepath.Glob(planPattern); len(matches) > 0 {
|
|
rel, _ := filepath.Rel(cwd, matches[len(matches)-1])
|
|
if rel != "" {
|
|
artifacts = append(artifacts, rel)
|
|
}
|
|
}
|
|
councilPattern := filepath.Join(cwd, ".agents", "council", "*pre-mortem*.md")
|
|
if matches, _ := filepath.Glob(councilPattern); len(matches) > 0 {
|
|
rel, _ := filepath.Rel(cwd, matches[len(matches)-1])
|
|
if rel != "" {
|
|
artifacts = append(artifacts, rel)
|
|
}
|
|
}
|
|
case 2: // implementation — note crank artifacts
|
|
// Crank artifacts are tracked by beads, not file scanning
|
|
case 3: // validation — look for vibe/post-mortem reports
|
|
vibePattern := filepath.Join(cwd, ".agents", "council", "*vibe*.md")
|
|
if matches, _ := filepath.Glob(vibePattern); len(matches) > 0 {
|
|
rel, _ := filepath.Rel(cwd, matches[len(matches)-1])
|
|
if rel != "" {
|
|
artifacts = append(artifacts, rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
return artifacts
|
|
}
|
|
|
|
// truncateRunes truncates s to at most cap runes and appends "..." if truncated.
|
|
// Safe for multi-byte UTF-8 characters — avoids slicing mid-codepoint.
|
|
func truncateRunes(s string, cap int) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= cap {
|
|
return s
|
|
}
|
|
return string(runes[:cap]) + "..."
|
|
}
|