| 1 | // Package trajectory appends a run's typed event stream to a JSONL file so a |
| 2 | // run's sequence, timing, and decisions can be replayed and analyzed offline. |
| 3 | // Records reuse the eventwire JSON contract and include content (prompts, tool |
| 4 | // arguments, reasoning) — the file is as sensitive as a session transcript. |
| 5 | package trajectory |
| 6 | |
| 7 | import ( |
| 8 | "bufio" |
| 9 | "encoding/json" |
| 10 | "os" |
| 11 | "sync" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/eventwire" |
| 16 | "reasonix/internal/evidence" |
| 17 | ) |
| 18 | |
| 19 | // SchemaVersion identifies the record layout; bump on breaking changes. |
| 20 | const SchemaVersion = 1 |
| 21 | |
| 22 | // Record is one observed occurrence. Exactly one payload field is set; Seq |
| 23 | // orders them and TS is the unix-millisecond observation time at the recorder. |
| 24 | type Record struct { |
| 25 | SchemaVersion int `json:"schema_version"` |
| 26 | Seq uint64 `json:"seq"` |
| 27 | TS int64 `json:"ts"` |
| 28 | Event *eventwire.Event `json:"event,omitempty"` |
| 29 | ReadinessAudit *ReadinessAudit `json:"readiness_audit,omitempty"` |
| 30 | AnchorSafetyAudit *AnchorSafetyAudit `json:"anchor_safety_audit,omitempty"` |
| 31 | ProtocolRecovery string `json:"protocol_recovery,omitempty"` |
| 32 | TurnCompletion bool `json:"turn_completion,omitempty"` |
| 33 | ContractShadow *ContractShadowAudit `json:"contract_shadow,omitempty"` |
| 34 | CompletionReport *CompletionReport `json:"completion_report,omitempty"` |
| 35 | OutcomeProgress *OutcomeProgress `json:"outcome_progress,omitempty"` |
| 36 | DelegationAdmission *DelegationAdmission `json:"delegation_admission,omitempty"` |
| 37 | MemoryRecall *MemoryRecall `json:"memory_recall,omitempty"` |
| 38 | SubagentLifecycle *SubagentLifecycle `json:"subagent_lifecycle,omitempty"` |
| 39 | } |
| 40 | |
| 41 | // SubagentLifecycle mirrors the content-free child lifecycle audit. |
| 42 | type SubagentLifecycle struct { |
| 43 | Phase string `json:"phase"` |
| 44 | Ref string `json:"ref,omitempty"` |
| 45 | ParentToolCallID string `json:"parent_tool_call_id,omitempty"` |
| 46 | Skill string `json:"skill,omitempty"` |
| 47 | Model string `json:"model,omitempty"` |
| 48 | Effort string `json:"effort,omitempty"` |
| 49 | Status string `json:"status,omitempty"` |
| 50 | ErrorCode string `json:"error_code,omitempty"` |
| 51 | Retryable bool `json:"retryable,omitempty"` |
| 52 | OutputBytes int `json:"output_bytes,omitempty"` |
| 53 | StartUnixMs int64 `json:"start_unix_ms,omitempty"` |
| 54 | EndUnixMs int64 `json:"end_unix_ms,omitempty"` |
| 55 | ValidatorMode string `json:"validator_mode,omitempty"` |
| 56 | ValidatorOutcome string `json:"validator_outcome,omitempty"` |
| 57 | ValidatorAttempt int `json:"validator_attempt,omitempty"` |
| 58 | ProviderRequestID string `json:"provider_request_id,omitempty"` |
| 59 | } |
| 60 | |
| 61 | // MemoryRecall mirrors event.MemoryRecallAudit with stable snake_case keys. |
| 62 | type MemoryRecall struct { |
| 63 | Hits []MemoryRecallHit `json:"hits,omitempty"` |
| 64 | UsedChars int `json:"used_chars,omitempty"` |
| 65 | Omitted int `json:"omitted,omitempty"` |
| 66 | Suppressed string `json:"suppressed,omitempty"` |
| 67 | ShadowHits []MemoryRecallHit `json:"shadow_hits,omitempty"` |
| 68 | } |
| 69 | |
| 70 | type AnchorSafetyAudit struct { |
| 71 | Mode string `json:"mode"` |
| 72 | TaskMode string `json:"task_mode"` |
| 73 | RangeLines int `json:"range_lines"` |
| 74 | ObservationAge int `json:"observation_age"` |
| 75 | LegacyAllowed bool `json:"legacy_allowed"` |
| 76 | ShadowAllowed bool `json:"shadow_allowed"` |
| 77 | Reason string `json:"reason"` |
| 78 | SameBatchReadRejected bool `json:"same_batch_read_rejected,omitempty"` |
| 79 | } |
| 80 | |
| 81 | // MemoryRecallHit is one recalled fact's content-free fingerprint. |
| 82 | type MemoryRecallHit struct { |
| 83 | ID string `json:"id"` |
| 84 | Revision int `json:"revision,omitempty"` |
| 85 | Scope string `json:"scope,omitempty"` |
| 86 | Type string `json:"type,omitempty"` |
| 87 | Freshness string `json:"freshness,omitempty"` |
| 88 | Score float64 `json:"score,omitempty"` |
| 89 | } |
| 90 | |
| 91 | // DelegationAdmission mirrors event.DelegationAdmissionAudit with stable keys. |
| 92 | type DelegationAdmission struct { |
| 93 | Tool string `json:"tool"` |
| 94 | Verdict string `json:"verdict"` |
| 95 | Reason string `json:"reason,omitempty"` |
| 96 | Intent string `json:"intent,omitempty"` |
| 97 | } |
| 98 | |
| 99 | // OutcomeProgress mirrors evidence.OutcomeSample with stable snake_case keys. |
| 100 | type OutcomeProgress struct { |
| 101 | Round int `json:"round"` |
| 102 | Exploration int `json:"exploration,omitempty"` |
| 103 | Verification int `json:"verification,omitempty"` |
| 104 | Objective int `json:"objective,omitempty"` |
| 105 | Regression int `json:"regression,omitempty"` |
| 106 | Churn int `json:"churn,omitempty"` |
| 107 | LegacyGain int `json:"legacy_gain,omitempty"` |
| 108 | Discriminating int `json:"discriminating,omitempty"` |
| 109 | DebtAge int `json:"debt_age,omitempty"` |
| 110 | BlindMutations int `json:"blind_mutations,omitempty"` |
| 111 | EBMEligible bool `json:"ebm_eligible,omitempty"` |
| 112 | EBMFired bool `json:"ebm_fired,omitempty"` |
| 113 | LocalExecSeen bool `json:"local_exec_seen,omitempty"` |
| 114 | GovernorEligible bool `json:"governor_eligible,omitempty"` |
| 115 | GovernorEngaged bool `json:"governor_engaged,omitempty"` |
| 116 | // Runway is a pointer so old records (nil: not observed) stay distinct from |
| 117 | // a new record whose counterfactual account genuinely reached zero. |
| 118 | Runway *int `json:"runway,omitempty"` |
| 119 | RunwayDry int `json:"runway_dry,omitempty"` |
| 120 | RunwayIdle int `json:"runway_idle,omitempty"` |
| 121 | RunwaySpent bool `json:"runway_spent,omitempty"` |
| 122 | } |
| 123 | |
| 124 | // ContractShadowAudit mirrors event.ContractShadowAudit with stable keys. |
| 125 | type ContractShadowAudit struct { |
| 126 | Intent string `json:"intent"` |
| 127 | Requirements int `json:"requirements,omitempty"` |
| 128 | RequirementsSatisfied int `json:"requirements_satisfied,omitempty"` |
| 129 | Checks int `json:"checks,omitempty"` |
| 130 | ChecksSatisfied int `json:"checks_satisfied,omitempty"` |
| 131 | Epoch uint64 `json:"epoch,omitempty"` |
| 132 | Verdict string `json:"verdict"` |
| 133 | Complete bool `json:"complete,omitempty"` |
| 134 | ReadyToFinalize bool `json:"ready_to_finalize,omitempty"` |
| 135 | } |
| 136 | |
| 137 | // CompletionReport mirrors event.CompletionReportAudit with stable keys. |
| 138 | type CompletionReport struct { |
| 139 | Verdict string `json:"verdict"` |
| 140 | Risk string `json:"risk,omitempty"` |
| 141 | Criteria int `json:"criteria,omitempty"` |
| 142 | CriteriaSatisfied int `json:"criteria_satisfied,omitempty"` |
| 143 | Changes int `json:"changes,omitempty"` |
| 144 | ChangesUnreviewed int `json:"changes_unreviewed,omitempty"` |
| 145 | Verifications int `json:"verifications,omitempty"` |
| 146 | VerificationsFailed int `json:"verifications_failed,omitempty"` |
| 147 | VerificationsStale int `json:"verifications_stale,omitempty"` |
| 148 | Gaps int `json:"gaps,omitempty"` |
| 149 | GapKinds []string `json:"gap_kinds,omitempty"` |
| 150 | ClaimsVerified int `json:"claims_verified,omitempty"` |
| 151 | ClaimsUnbacked int `json:"claims_unbacked,omitempty"` |
| 152 | } |
| 153 | |
| 154 | // ReadinessAudit mirrors evidence.ReadinessAudit with stable snake_case keys. |
| 155 | type ReadinessAudit struct { |
| 156 | Result string `json:"result"` |
| 157 | Recovered bool `json:"recovered,omitempty"` |
| 158 | MissingProjectChecks int `json:"missing_project_checks,omitempty"` |
| 159 | IncompleteTodos int `json:"incomplete_todos,omitempty"` |
| 160 | CommandMismatchMissing int `json:"command_mismatch_missing,omitempty"` |
| 161 | MissingAcceptanceCriteria int `json:"missing_acceptance_criteria,omitempty"` |
| 162 | MissingVerification int `json:"missing_verification,omitempty"` |
| 163 | MissingReview int `json:"missing_review,omitempty"` |
| 164 | MissingSignoff int `json:"missing_signoff,omitempty"` |
| 165 | MissingActionEvidence int `json:"missing_action_evidence,omitempty"` |
| 166 | MissingMutation int `json:"missing_mutation,omitempty"` |
| 167 | MissingCapabilities int `json:"missing_capabilities,omitempty"` |
| 168 | } |
| 169 | |
| 170 | // Recorder is an event.Sink decorator: every event (and optional-capability |
| 171 | // audit) is appended as one JSONL record, then forwarded to the inner sink. |
| 172 | // Recording failures never block forwarding — the first error is kept and |
| 173 | // returned by Close. |
| 174 | type Recorder struct { |
| 175 | inner event.Sink |
| 176 | clock func() time.Time |
| 177 | |
| 178 | mu sync.Mutex |
| 179 | file *os.File |
| 180 | buf *bufio.Writer |
| 181 | enc *json.Encoder |
| 182 | seq uint64 |
| 183 | err error |
| 184 | closed bool |
| 185 | } |
| 186 | |
| 187 | var _ event.OptionalSinkCapabilities = (*Recorder)(nil) |
| 188 | |
| 189 | // New opens (or truncates) path and returns a Recorder forwarding to inner. |
| 190 | // A nil clock means time.Now. |
| 191 | func New(inner event.Sink, path string, clock func() time.Time) (*Recorder, error) { |
| 192 | f, err := os.Create(path) |
| 193 | if err != nil { |
| 194 | return nil, err |
| 195 | } |
| 196 | if clock == nil { |
| 197 | clock = time.Now |
| 198 | } |
| 199 | buf := bufio.NewWriter(f) |
| 200 | return &Recorder{inner: inner, clock: clock, file: f, buf: buf, enc: json.NewEncoder(buf)}, nil |
| 201 | } |
| 202 | |
| 203 | func (r *Recorder) append(rec Record) { |
| 204 | r.mu.Lock() |
| 205 | defer r.mu.Unlock() |
| 206 | if r.closed || r.err != nil { |
| 207 | return |
| 208 | } |
| 209 | r.seq++ |
| 210 | rec.SchemaVersion = SchemaVersion |
| 211 | rec.Seq = r.seq |
| 212 | rec.TS = r.clock().UnixMilli() |
| 213 | if err := r.enc.Encode(rec); err != nil { |
| 214 | r.err = err |
| 215 | return |
| 216 | } |
| 217 | // Flush per record so a killed run still leaves every completed line. |
| 218 | if err := r.buf.Flush(); err != nil { |
| 219 | r.err = err |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func (r *Recorder) Emit(e event.Event) { |
| 224 | w := eventwire.ToWire(e) |
| 225 | r.append(Record{Event: &w}) |
| 226 | r.inner.Emit(e) |
| 227 | } |
| 228 | |
| 229 | // RecordDelegationAudit forwards without persisting: delegation receipts are |
| 230 | // aggregated by run metrics, and the trajectory schema stays unchanged. |
| 231 | func (r *Recorder) RecordDelegationAudit(a evidence.DelegationAudit) { |
| 232 | event.RecordDelegationAudit(r.inner, a) |
| 233 | } |
| 234 | |
| 235 | func (r *Recorder) RecordReadinessAudit(a evidence.ReadinessAudit) { |
| 236 | r.append(Record{ReadinessAudit: &ReadinessAudit{ |
| 237 | Result: string(a.Result), |
| 238 | Recovered: a.Recovered, |
| 239 | MissingProjectChecks: a.MissingProjectChecks, |
| 240 | IncompleteTodos: a.IncompleteTodos, |
| 241 | CommandMismatchMissing: a.CommandMismatchMissing, |
| 242 | MissingAcceptanceCriteria: a.MissingAcceptanceCriteria, |
| 243 | MissingVerification: a.MissingVerification, |
| 244 | MissingReview: a.MissingReview, |
| 245 | MissingSignoff: a.MissingSignoff, |
| 246 | MissingActionEvidence: a.MissingActionEvidence, |
| 247 | MissingMutation: a.MissingMutation, |
| 248 | MissingCapabilities: a.MissingCapabilities, |
| 249 | }}) |
| 250 | event.RecordReadinessAudit(r.inner, a) |
| 251 | } |
| 252 | |
| 253 | func (r *Recorder) RecordAnchorSafetyAudit(a event.AnchorSafetyAudit) { |
| 254 | r.append(Record{AnchorSafetyAudit: &AnchorSafetyAudit{ |
| 255 | Mode: a.Mode, TaskMode: a.TaskMode, RangeLines: a.RangeLines, |
| 256 | ObservationAge: a.ObservationAge, LegacyAllowed: a.LegacyAllowed, |
| 257 | ShadowAllowed: a.ShadowAllowed, Reason: a.Reason, |
| 258 | SameBatchReadRejected: a.SameBatchReadRejected, |
| 259 | }}) |
| 260 | event.RecordAnchorSafetyAudit(r.inner, a) |
| 261 | } |
| 262 | |
| 263 | func (r *Recorder) RecordContractShadow(a event.ContractShadowAudit) { |
| 264 | r.append(Record{ContractShadow: &ContractShadowAudit{ |
| 265 | Intent: a.Intent, |
| 266 | Requirements: a.Requirements, |
| 267 | RequirementsSatisfied: a.RequirementsSatisfied, |
| 268 | Checks: a.Checks, |
| 269 | ChecksSatisfied: a.ChecksSatisfied, |
| 270 | Epoch: a.Epoch, |
| 271 | Verdict: a.Verdict, |
| 272 | Complete: a.Complete, |
| 273 | ReadyToFinalize: a.ReadyToFinalize, |
| 274 | }}) |
| 275 | event.RecordContractShadow(r.inner, a) |
| 276 | } |
| 277 | |
| 278 | func (r *Recorder) RecordCompletionReport(a event.CompletionReportAudit) { |
| 279 | r.append(Record{CompletionReport: &CompletionReport{ |
| 280 | Verdict: a.Verdict, |
| 281 | Risk: a.Risk, |
| 282 | Criteria: a.Criteria, |
| 283 | CriteriaSatisfied: a.CriteriaSatisfied, |
| 284 | Changes: a.Changes, |
| 285 | ChangesUnreviewed: a.ChangesUnreviewed, |
| 286 | Verifications: a.Verifications, |
| 287 | VerificationsFailed: a.VerificationsFailed, |
| 288 | VerificationsStale: a.VerificationsStale, |
| 289 | Gaps: a.Gaps, |
| 290 | GapKinds: a.GapKinds, |
| 291 | ClaimsVerified: a.ClaimsVerified, |
| 292 | ClaimsUnbacked: a.ClaimsUnbacked, |
| 293 | }}) |
| 294 | event.RecordCompletionReport(r.inner, a) |
| 295 | } |
| 296 | |
| 297 | func (r *Recorder) RecordOutcomeProgress(sample evidence.OutcomeSample) { |
| 298 | runway := sample.Runway |
| 299 | r.append(Record{OutcomeProgress: &OutcomeProgress{ |
| 300 | Round: sample.Round, |
| 301 | Exploration: sample.Exploration, |
| 302 | Verification: sample.Verification, |
| 303 | Objective: sample.Objective, |
| 304 | Regression: sample.Regression, |
| 305 | Churn: sample.Churn, |
| 306 | LegacyGain: sample.LegacyGain, |
| 307 | Discriminating: sample.Discriminating, |
| 308 | DebtAge: sample.DebtAge, |
| 309 | BlindMutations: sample.BlindMutations, |
| 310 | EBMEligible: sample.EBMEligible, |
| 311 | EBMFired: sample.EBMFired, |
| 312 | LocalExecSeen: sample.LocalExecSeen, |
| 313 | GovernorEligible: sample.GovernorEligible, |
| 314 | GovernorEngaged: sample.GovernorEngaged, |
| 315 | Runway: &runway, |
| 316 | RunwayDry: sample.RunwayDry, |
| 317 | RunwayIdle: sample.RunwayIdle, |
| 318 | RunwaySpent: sample.RunwaySpent, |
| 319 | }}) |
| 320 | event.RecordOutcomeProgress(r.inner, sample) |
| 321 | } |
| 322 | |
| 323 | func (r *Recorder) RecordMemoryRecall(a event.MemoryRecallAudit) { |
| 324 | rec := &MemoryRecall{UsedChars: a.UsedChars, Omitted: a.Omitted, Suppressed: a.Suppressed} |
| 325 | for _, hit := range a.Hits { |
| 326 | rec.Hits = append(rec.Hits, MemoryRecallHit{ |
| 327 | ID: hit.ID, Revision: hit.Revision, Scope: hit.Scope, |
| 328 | Type: hit.Type, Freshness: hit.Freshness, Score: hit.Score, |
| 329 | }) |
| 330 | } |
| 331 | for _, hit := range a.Shadow { |
| 332 | rec.ShadowHits = append(rec.ShadowHits, MemoryRecallHit{ID: hit.ID, Score: hit.Score}) |
| 333 | } |
| 334 | r.append(Record{MemoryRecall: rec}) |
| 335 | event.RecordMemoryRecall(r.inner, a) |
| 336 | } |
| 337 | |
| 338 | func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) { |
| 339 | r.append(Record{DelegationAdmission: &DelegationAdmission{ |
| 340 | Tool: a.Tool, Verdict: a.Verdict, Reason: a.Reason, Intent: a.Intent, |
| 341 | }}) |
| 342 | event.RecordDelegationAdmission(r.inner, a) |
| 343 | } |
| 344 | |
| 345 | func (r *Recorder) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) { |
| 346 | r.append(Record{ProtocolRecovery: string(a.Kind)}) |
| 347 | event.RecordProtocolRecovery(r.inner, a) |
| 348 | } |
| 349 | |
| 350 | func (r *Recorder) RecordTurnCompletion() { |
| 351 | r.append(Record{TurnCompletion: true}) |
| 352 | event.RecordTurnCompletion(r.inner) |
| 353 | } |
| 354 | |
| 355 | func (r *Recorder) RecordWorkspaceMutation(m event.WorkspaceMutation) { |
| 356 | event.RecordWorkspaceMutation(r.inner, m) |
| 357 | } |
| 358 | |
| 359 | func (r *Recorder) RecordRunBudget(sample event.RunBudgetSample) { |
| 360 | event.RecordRunBudget(r.inner, sample) |
| 361 | } |
| 362 | |
| 363 | func (r *Recorder) RecordSubagentLifecycle(info event.SubagentLifecycleInfo) { |
| 364 | r.append(Record{SubagentLifecycle: &SubagentLifecycle{ |
| 365 | Phase: info.Phase, Ref: info.Ref, ParentToolCallID: info.ParentToolCallID, |
| 366 | Skill: info.Skill, Model: info.Model, Effort: info.Effort, Status: info.Status, |
| 367 | ErrorCode: info.ErrorCode, Retryable: info.Retryable, OutputBytes: info.OutputBytes, |
| 368 | StartUnixMs: info.StartUnixMs, EndUnixMs: info.EndUnixMs, ValidatorMode: info.ValidatorMode, |
| 369 | ValidatorOutcome: info.ValidatorOutcome, ValidatorAttempt: info.ValidatorAttempt, |
| 370 | ProviderRequestID: info.ProviderRequestID, |
| 371 | }}) |
| 372 | event.RecordSubagentLifecycle(r.inner, info) |
| 373 | } |
| 374 | |
| 375 | // Close flushes and closes the file, returning the first error seen. Events |
| 376 | // arriving after Close (late background jobs) are forwarded but not recorded. |
| 377 | func (r *Recorder) Close() error { |
| 378 | r.mu.Lock() |
| 379 | defer r.mu.Unlock() |
| 380 | if r.closed { |
| 381 | return r.err |
| 382 | } |
| 383 | r.closed = true |
| 384 | if err := r.buf.Flush(); err != nil && r.err == nil { |
| 385 | r.err = err |
| 386 | } |
| 387 | if err := r.file.Close(); err != nil && r.err == nil { |
| 388 | r.err = err |
| 389 | } |
| 390 | return r.err |
| 391 | } |
| 392 |