|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "errors" |
| 5 | + "fmt" |
| 6 | + "path/filepath" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/spf13/cobra" |
| 10 | + |
| 11 | + "github.com/luuuc/brain/internal/store" |
| 12 | + "github.com/luuuc/brain/internal/trust" |
| 13 | +) |
| 14 | + |
| 15 | +// trustLongHelp is appended to every trust command's Long description so |
| 16 | +// operators can see the exit-code contract and supported env vars without |
| 17 | +// grepping source. |
| 18 | +const trustLongHelp = ` |
| 19 | +Exit codes: |
| 20 | + 0 success |
| 21 | + 3 invalid input (missing flag, bad outcome, invalid urgency, ...) |
| 22 | + 4 trust state conflict (lock acquisition timeout) |
| 23 | +
|
| 24 | +Environment: |
| 25 | + BRAIN_TRUST_LOCK_TIMEOUT_MS override the advisory-lock acquisition |
| 26 | + timeout in milliseconds (default: 5000). |
| 27 | + Intended for tests and operator debugging; |
| 28 | + leave unset in production.` |
| 29 | + |
| 30 | +func trustCmd() *cobra.Command { |
| 31 | + var ( |
| 32 | + domain string |
| 33 | + urgency string |
| 34 | + verbose bool |
| 35 | + ) |
| 36 | + |
| 37 | + cmd := &cobra.Command{ |
| 38 | + Use: "trust", |
| 39 | + Short: "Check, record, and manage trust levels", |
| 40 | + Long: `Trust tracks per-domain autonomy levels for AI-produced work. |
| 41 | +
|
| 42 | +With --domain X, prints the current trust level and the recommendation |
| 43 | +(escalate / ship_notify / ship). Subcommands record outcomes, record |
| 44 | +overrides, repair corrupted state, or list all known domains.` + trustLongHelp, |
| 45 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 46 | + if domain == "" { |
| 47 | + return &ExitError{Code: 3, Err: fmt.Errorf("--domain is required (or pass a subcommand: record, override, list, repair)")} |
| 48 | + } |
| 49 | + teng := trustEngineFrom(cmd) |
| 50 | + |
| 51 | + opts := trust.CheckOptions{} |
| 52 | + if urgency != "" { |
| 53 | + if urgency != "hotfix" { |
| 54 | + return &ExitError{Code: 3, Err: fmt.Errorf("invalid --urgency %q (valid: hotfix)", urgency)} |
| 55 | + } |
| 56 | + opts.Hotfix = true |
| 57 | + } |
| 58 | + |
| 59 | + dec, err := teng.Check(cmd.Context(), domain, opts) |
| 60 | + if err != nil { |
| 61 | + return mapTrustErr(err) |
| 62 | + } |
| 63 | + |
| 64 | + printResult(cmd, trust.DecisionJSON(dec, verbose), func() string { return formatCheck(dec, verbose) }) |
| 65 | + return nil |
| 66 | + }, |
| 67 | + } |
| 68 | + cmd.Flags().StringVar(&domain, "domain", "", "trust domain to check") |
| 69 | + cmd.Flags().StringVar(&urgency, "urgency", "", "urgency override (hotfix)") |
| 70 | + cmd.Flags().BoolVar(&verbose, "verbose", false, "include history context") |
| 71 | + |
| 72 | + cmd.AddCommand(trustRecordCmd()) |
| 73 | + cmd.AddCommand(trustOverrideCmd()) |
| 74 | + cmd.AddCommand(trustListCmd()) |
| 75 | + cmd.AddCommand(trustRepairCmd()) |
| 76 | + return cmd |
| 77 | +} |
| 78 | + |
| 79 | +func trustRecordCmd() *cobra.Command { |
| 80 | + var ( |
| 81 | + domain string |
| 82 | + outcome string |
| 83 | + ref string |
| 84 | + reason string |
| 85 | + ) |
| 86 | + cmd := &cobra.Command{ |
| 87 | + Use: "record", |
| 88 | + Short: "Record an outcome (clean or failure) for a domain", |
| 89 | + Long: "Record an outcome (clean or failure) for a domain." + trustLongHelp, |
| 90 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 91 | + if domain == "" { |
| 92 | + return &ExitError{Code: 3, Err: fmt.Errorf("--domain is required")} |
| 93 | + } |
| 94 | + o := trust.Outcome(outcome) |
| 95 | + if !o.Valid() { |
| 96 | + return &ExitError{Code: 3, Err: fmt.Errorf("invalid --outcome %q (valid: clean, failure)", outcome)} |
| 97 | + } |
| 98 | + teng := trustEngineFrom(cmd) |
| 99 | + r, err := teng.Record(cmd.Context(), domain, o, trust.RecordOptions{Ref: ref, Reason: reason}) |
| 100 | + if err != nil { |
| 101 | + return mapTrustErr(err) |
| 102 | + } |
| 103 | + |
| 104 | + printResult(cmd, jsonRecord(r), func() string { return formatRecord(r) }) |
| 105 | + return nil |
| 106 | + }, |
| 107 | + } |
| 108 | + cmd.Flags().StringVar(&domain, "domain", "", "trust domain (required)") |
| 109 | + cmd.Flags().StringVar(&outcome, "outcome", "", "clean or failure (required)") |
| 110 | + cmd.Flags().StringVar(&ref, "ref", "", "optional reference (e.g. PR #42) for deduplication") |
| 111 | + cmd.Flags().StringVar(&reason, "reason", "", "optional reason (typically set on failures)") |
| 112 | + return cmd |
| 113 | +} |
| 114 | + |
| 115 | +func trustOverrideCmd() *cobra.Command { |
| 116 | + var ( |
| 117 | + domain string |
| 118 | + reason string |
| 119 | + ) |
| 120 | + cmd := &cobra.Command{ |
| 121 | + Use: "override", |
| 122 | + Short: "Record a human override for a domain", |
| 123 | + Long: "Record a human override for a domain. Writes a correction memory and appends an override event to the trust history." + trustLongHelp, |
| 124 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 125 | + if domain == "" { |
| 126 | + return &ExitError{Code: 3, Err: fmt.Errorf("--domain is required")} |
| 127 | + } |
| 128 | + if reason == "" { |
| 129 | + return &ExitError{Code: 3, Err: fmt.Errorf("--reason is required")} |
| 130 | + } |
| 131 | + teng := trustEngineFrom(cmd) |
| 132 | + dec, err := teng.Override(cmd.Context(), domain, reason) |
| 133 | + if err != nil { |
| 134 | + return mapTrustErr(err) |
| 135 | + } |
| 136 | + |
| 137 | + printResult(cmd, trust.DecisionJSON(dec, false), func() string { |
| 138 | + return fmt.Sprintf("Override recorded for %s\nLevel: %s\nRecommendation: %s\n", dec.Domain, dec.Level, dec.Recommendation) |
| 139 | + }) |
| 140 | + return nil |
| 141 | + }, |
| 142 | + } |
| 143 | + cmd.Flags().StringVar(&domain, "domain", "", "trust domain (required)") |
| 144 | + cmd.Flags().StringVar(&reason, "reason", "", "why the human overrode (required)") |
| 145 | + return cmd |
| 146 | +} |
| 147 | + |
| 148 | +func trustListCmd() *cobra.Command { |
| 149 | + cmd := &cobra.Command{ |
| 150 | + Use: "list", |
| 151 | + Short: "List every domain with its trust level", |
| 152 | + Long: "List every domain with its trust level." + trustLongHelp, |
| 153 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 154 | + teng := trustEngineFrom(cmd) |
| 155 | + list, err := teng.List(cmd.Context()) |
| 156 | + if err != nil { |
| 157 | + return mapTrustErr(err) |
| 158 | + } |
| 159 | + items := make([]map[string]any, 0, len(list)) |
| 160 | + for _, d := range list { |
| 161 | + items = append(items, trust.DecisionJSON(d, false)) |
| 162 | + } |
| 163 | + printResult(cmd, map[string]any{"domains": items, "count": len(list)}, func() string { |
| 164 | + var sb strings.Builder |
| 165 | + for _, d := range list { |
| 166 | + fmt.Fprintf(&sb, "%-14s %-10s clean_ships=%-4d", d.Domain, d.Level, d.CleanShips) |
| 167 | + if d.LastFailure != nil { |
| 168 | + fmt.Fprintf(&sb, " last_failure=%s", d.LastFailure.Format("2006-01-02")) |
| 169 | + } |
| 170 | + sb.WriteString("\n") |
| 171 | + } |
| 172 | + fmt.Fprintf(&sb, "\n%d domains\n", len(list)) |
| 173 | + return sb.String() |
| 174 | + }) |
| 175 | + return nil |
| 176 | + }, |
| 177 | + } |
| 178 | + return cmd |
| 179 | +} |
| 180 | + |
| 181 | +func trustRepairCmd() *cobra.Command { |
| 182 | + cmd := &cobra.Command{ |
| 183 | + Use: "repair", |
| 184 | + Short: "Repair corrupted trust.yml by restoring from the backup", |
| 185 | + Long: `Repair corrupted trust.yml by restoring from a crash-left sibling. |
| 186 | +
|
| 187 | +If trust.yml is valid, repair does nothing but sweeps stale .tmp files. |
| 188 | +If the live file is missing, repair promotes trust.yml.tmp (the latest |
| 189 | +write-in-progress, if present) or falls back to trust.yml.bak (the prior |
| 190 | +committed state). Schema-version mismatches block auto-promote — the |
| 191 | +operator must migrate by hand.` + trustLongHelp, |
| 192 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 193 | + dir, ok := cmd.Context().Value(brainDirKey).(string) |
| 194 | + if !ok || dir == "" { |
| 195 | + return fmt.Errorf("no .brain/ directory resolved") |
| 196 | + } |
| 197 | + source, err := trust.Repair(cmd.Context(), filepath.Join(dir, "trust")) |
| 198 | + if err != nil { |
| 199 | + return mapTrustErr(err) |
| 200 | + } |
| 201 | + |
| 202 | + // Report exactly what Repair did — operators at 3am need to |
| 203 | + // know whether state came from .tmp (mid-rename crash) or |
| 204 | + // .bak (ordinary corruption) so they can diagnose the root |
| 205 | + // cause, not just observe "it works now." |
| 206 | + var msg string |
| 207 | + switch source { |
| 208 | + case trust.RepairAlreadyValid: |
| 209 | + msg = "trust.yml is valid; no repair needed.\n" |
| 210 | + case trust.RepairFromTmp: |
| 211 | + msg = "Restored trust.yml from trust.yml.tmp (recovered from mid-write crash).\n" |
| 212 | + case trust.RepairFromBak: |
| 213 | + msg = "Restored trust.yml from trust.yml.bak (previous good state).\n" |
| 214 | + default: |
| 215 | + msg = fmt.Sprintf("Repair returned unexpected source %q\n", source) |
| 216 | + } |
| 217 | + payload := map[string]any{ |
| 218 | + "source": string(source), |
| 219 | + "already_valid": source == trust.RepairAlreadyValid, |
| 220 | + } |
| 221 | + printResult(cmd, payload, func() string { return msg }) |
| 222 | + return nil |
| 223 | + }, |
| 224 | + } |
| 225 | + return cmd |
| 226 | +} |
| 227 | + |
| 228 | +func formatCheck(d trust.Decision, verbose bool) string { |
| 229 | + var sb strings.Builder |
| 230 | + fmt.Fprintf(&sb, "Domain: %s\n", d.Domain) |
| 231 | + fmt.Fprintf(&sb, "Level: %s\n", d.Level) |
| 232 | + fmt.Fprintf(&sb, "Clean ships: %d\n", d.CleanShips) |
| 233 | + if d.LastFailure != nil { |
| 234 | + fmt.Fprintf(&sb, "Last failure: %s\n", d.LastFailure.Format("2006-01-02")) |
| 235 | + } |
| 236 | + if d.LastPromotion != nil { |
| 237 | + fmt.Fprintf(&sb, "Last promotion: %s\n", d.LastPromotion.Format("2006-01-02")) |
| 238 | + } |
| 239 | + if d.Hotfix { |
| 240 | + sb.WriteString("Urgency: hotfix\n") |
| 241 | + } |
| 242 | + fmt.Fprintf(&sb, "Recommendation: %s\n", d.Recommendation) |
| 243 | + if verbose && len(d.History) > 0 { |
| 244 | + sb.WriteString("History:\n") |
| 245 | + for _, e := range d.History { |
| 246 | + fmt.Fprintf(&sb, " %s %s", e.At.Format("2006-01-02"), e.Kind) |
| 247 | + if e.Outcome != "" { |
| 248 | + fmt.Fprintf(&sb, " outcome=%s", e.Outcome) |
| 249 | + } |
| 250 | + if e.From != "" || e.To != "" { |
| 251 | + fmt.Fprintf(&sb, " %s→%s", e.From, e.To) |
| 252 | + } |
| 253 | + if e.Ref != "" { |
| 254 | + fmt.Fprintf(&sb, " ref=%q", e.Ref) |
| 255 | + } |
| 256 | + if e.Reason != "" { |
| 257 | + fmt.Fprintf(&sb, " reason=%q", e.Reason) |
| 258 | + } |
| 259 | + sb.WriteString("\n") |
| 260 | + } |
| 261 | + } |
| 262 | + return sb.String() |
| 263 | +} |
| 264 | + |
| 265 | +func formatRecord(r trust.RecordResult) string { |
| 266 | + var sb strings.Builder |
| 267 | + if r.Deduplicated { |
| 268 | + sb.WriteString("Duplicate ref — outcome not recorded.\n") |
| 269 | + } |
| 270 | + fmt.Fprintf(&sb, "Domain: %s\n", r.Decision.Domain) |
| 271 | + fmt.Fprintf(&sb, "Level: %s\n", r.Decision.Level) |
| 272 | + fmt.Fprintf(&sb, "Clean ships: %d\n", r.Decision.CleanShips) |
| 273 | + if r.Promoted { |
| 274 | + fmt.Fprintf(&sb, "Promoted to %s.\n", r.Decision.Level) |
| 275 | + } |
| 276 | + if r.Demoted { |
| 277 | + sb.WriteString("Demoted to ask after failure.\n") |
| 278 | + } |
| 279 | + if r.LessonsTouched > 0 { |
| 280 | + fmt.Fprintf(&sb, "Lessons ticked: %d", r.LessonsTouched) |
| 281 | + if r.LessonsRetired > 0 { |
| 282 | + fmt.Fprintf(&sb, " (retired: %d)", r.LessonsRetired) |
| 283 | + } |
| 284 | + sb.WriteString("\n") |
| 285 | + } |
| 286 | + if r.EvictedRefs > 0 { |
| 287 | + fmt.Fprintf(&sb, "Seen-refs evicted (FIFO): %d\n", r.EvictedRefs) |
| 288 | + } |
| 289 | + fmt.Fprintf(&sb, "Recommendation: %s\n", r.Decision.Recommendation) |
| 290 | + return sb.String() |
| 291 | +} |
| 292 | + |
| 293 | +// mapTrustErr translates engine errors into CLI ExitErrors. Lock timeouts |
| 294 | +// surface as exit code 4 (conflict). |
| 295 | +func mapTrustErr(err error) error { |
| 296 | + if err == nil { |
| 297 | + return nil |
| 298 | + } |
| 299 | + if errors.Is(err, store.ErrConflict) { |
| 300 | + return &ExitError{Code: 4, Err: err} |
| 301 | + } |
| 302 | + return err |
| 303 | +} |
| 304 | + |
| 305 | +func jsonRecord(r trust.RecordResult) any { |
| 306 | + out := map[string]any{ |
| 307 | + "decision": trust.DecisionJSON(r.Decision, false), |
| 308 | + "promoted": r.Promoted, |
| 309 | + "demoted": r.Demoted, |
| 310 | + "deduplicated": r.Deduplicated, |
| 311 | + } |
| 312 | + if r.LessonsTouched > 0 { |
| 313 | + out["lessons_touched"] = r.LessonsTouched |
| 314 | + } |
| 315 | + if r.LessonsRetired > 0 { |
| 316 | + out["lessons_retired"] = r.LessonsRetired |
| 317 | + } |
| 318 | + if r.EvictedRefs > 0 { |
| 319 | + out["evicted_refs"] = r.EvictedRefs |
| 320 | + } |
| 321 | + return out |
| 322 | +} |
| 323 | + |
0 commit comments