| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "slices" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "unicode/utf8" |
| 16 | |
| 17 | "mvdan.cc/sh/v3/syntax" |
| 18 | |
| 19 | "reasonix/internal/provider" |
| 20 | "reasonix/internal/shellparse" |
| 21 | "reasonix/internal/shellsafe" |
| 22 | ) |
| 23 | |
| 24 | // TodoItem mirrors the todo_write item shape the host needs for step matching. |
| 25 | // StepID is the item's stable identity: it survives a retitle and a reorder, so |
| 26 | // completion attribution never has to be inferred from wording or position. It |
| 27 | // is optional — a list written freehand has none, and matches by text instead. |
| 28 | type TodoItem struct { |
| 29 | Content string `json:"content"` |
| 30 | Status string `json:"status"` |
| 31 | ActiveForm string `json:"activeForm,omitempty"` |
| 32 | Level int `json:"level,omitempty"` |
| 33 | StepID string `json:"step_id,omitempty"` |
| 34 | } |
| 35 | |
| 36 | // ValidateSerialTodos validates only the public todo shape. Todo statuses are |
| 37 | // model reports rather than host proof, so completed items need not form a |
| 38 | // prefix and a non-empty list need not have a current item. |
| 39 | func ValidateSerialTodos(todos []TodoItem) error { |
| 40 | ipSeen := false |
| 41 | for i, todo := range todos { |
| 42 | if todo.Level < 0 || todo.Level > 1 { |
| 43 | return fmt.Errorf("todo %d %q has invalid level %d", i+1, todo.Content, todo.Level) |
| 44 | } |
| 45 | switch todoStatus(todo.Status) { |
| 46 | case "completed", "pending": |
| 47 | case "in_progress": |
| 48 | if ipSeen { |
| 49 | return fmt.Errorf("todo %d %q is a second in_progress item; serial task lists allow exactly one current item", i+1, todo.Content) |
| 50 | } |
| 51 | ipSeen = true |
| 52 | default: |
| 53 | return fmt.Errorf("todo %d %q has invalid status %q", i+1, todo.Content, todo.Status) |
| 54 | } |
| 55 | } |
| 56 | if len(todos) > 0 && todos[0].Level == 1 { |
| 57 | return fmt.Errorf("todo 1 %q is a level-1 sub-step with no phase above it; add a level-0 phase header or use level 0", todos[0].Content) |
| 58 | } |
| 59 | return nil |
| 60 | } |
| 61 | |
| 62 | // todoSegment is one serial unit of a task list: a level-0 phase header plus |
| 63 | // its level-1 sub-steps, or a single plain step. end is exclusive. |
| 64 | type todoSegment struct { |
| 65 | head int |
| 66 | end int |
| 67 | } |
| 68 | |
| 69 | // serialTodoSegments splits a task list into serial units. A level-0 item |
| 70 | // directly followed by level-1 items owns them as one phase segment; every |
| 71 | // other item — including a level-1 item with no preceding phase — is its own |
| 72 | // single-step segment. |
| 73 | func serialTodoSegments(todos []TodoItem) []todoSegment { |
| 74 | var segs []todoSegment |
| 75 | for i := 0; i < len(todos); { |
| 76 | end := i + 1 |
| 77 | if todos[i].Level == 0 { |
| 78 | for end < len(todos) && todos[end].Level == 1 { |
| 79 | end++ |
| 80 | } |
| 81 | } |
| 82 | segs = append(segs, todoSegment{head: i, end: end}) |
| 83 | i = end |
| 84 | } |
| 85 | return segs |
| 86 | } |
| 87 | |
| 88 | // NormalizeSerialTodos repairs legacy host state that predates |
| 89 | // ValidateSerialTodos. It preserves the leading run of fully completed |
| 90 | // segments and makes the first unfinished segment current: its completed |
| 91 | // sub-step prefix is kept and its first unfinished sub-step becomes the |
| 92 | // single in_progress item — or the phase itself when every sub-step is |
| 93 | // already completed. Every later segment returns to pending. |
| 94 | func NormalizeSerialTodos(todos []TodoItem) []TodoItem { |
| 95 | out := append([]TodoItem(nil), todos...) |
| 96 | unfinished := false |
| 97 | for _, seg := range serialTodoSegments(out) { |
| 98 | if !unfinished && serialSegmentCompleted(out, seg) { |
| 99 | continue |
| 100 | } |
| 101 | if unfinished { |
| 102 | for i := seg.head; i < seg.end; i++ { |
| 103 | out[i].Status = "pending" |
| 104 | } |
| 105 | continue |
| 106 | } |
| 107 | unfinished = true |
| 108 | if seg.end == seg.head+1 { |
| 109 | out[seg.head].Status = "in_progress" |
| 110 | continue |
| 111 | } |
| 112 | subUnfinished := false |
| 113 | for i := seg.head + 1; i < seg.end; i++ { |
| 114 | if !subUnfinished && todoStatus(out[i].Status) == "completed" { |
| 115 | continue |
| 116 | } |
| 117 | if !subUnfinished { |
| 118 | out[i].Status = "in_progress" |
| 119 | subUnfinished = true |
| 120 | continue |
| 121 | } |
| 122 | out[i].Status = "pending" |
| 123 | } |
| 124 | if subUnfinished { |
| 125 | out[seg.head].Status = "pending" |
| 126 | } else { |
| 127 | out[seg.head].Status = "in_progress" |
| 128 | } |
| 129 | } |
| 130 | return out |
| 131 | } |
| 132 | |
| 133 | func serialSegmentCompleted(todos []TodoItem, seg todoSegment) bool { |
| 134 | for i := seg.head; i < seg.end; i++ { |
| 135 | if todoStatus(todos[i].Status) != "completed" { |
| 136 | return false |
| 137 | } |
| 138 | } |
| 139 | return true |
| 140 | } |
| 141 | |
| 142 | // FirstUnfinishedSubStep reports whether todos[index] is a level-0 phase with |
| 143 | // level-1 sub-steps, and if so the 0-based index of its first sub-step that is |
| 144 | // not yet completed. ok is false when index is not a phase header; a phase |
| 145 | // whose sub-steps are all completed returns (-1, true). |
| 146 | func FirstUnfinishedSubStep(todos []TodoItem, index int) (int, bool) { |
| 147 | if index < 0 || index >= len(todos) || todos[index].Level != 0 { |
| 148 | return -1, false |
| 149 | } |
| 150 | if index+1 >= len(todos) || todos[index+1].Level != 1 { |
| 151 | return -1, false |
| 152 | } |
| 153 | for i := index + 1; i < len(todos) && todos[i].Level == 1; i++ { |
| 154 | if todoStatus(todos[i].Status) != "completed" { |
| 155 | return i, true |
| 156 | } |
| 157 | } |
| 158 | return -1, true |
| 159 | } |
| 160 | |
| 161 | // AdvanceSerialTodo completes the in_progress item at index (0-based) as a |
| 162 | // signed-off step and promotes the next serial item so exactly one item stays |
| 163 | // current. A phase with unfinished sub-steps does not complete. Completing a |
| 164 | // sub-step promotes its next pending sibling, or returns its phase to |
| 165 | // in_progress for sign-off once every sibling is completed. Completing a |
| 166 | // phase or plain step promotes the next pending unit — a phase's first |
| 167 | // pending sub-step (the phase itself stays pending until its sub-steps |
| 168 | // finish), or the plain step itself. A level-1 item with no phase above it |
| 169 | // advances as a standalone step. It reports whether the item was completed. |
| 170 | func AdvanceSerialTodo(todos []TodoItem, index int) bool { |
| 171 | if index < 0 || index >= len(todos) { |
| 172 | return false |
| 173 | } |
| 174 | if todoStatus(todos[index].Status) != "in_progress" { |
| 175 | return false |
| 176 | } |
| 177 | if unfinished, ok := FirstUnfinishedSubStep(todos, index); ok && unfinished >= 0 { |
| 178 | return false |
| 179 | } |
| 180 | todos[index].Status = "completed" |
| 181 | if todos[index].Level == 1 { |
| 182 | for i := index + 1; i < len(todos) && todos[i].Level == 1; i++ { |
| 183 | if todoStatus(todos[i].Status) == "pending" { |
| 184 | todos[i].Status = "in_progress" |
| 185 | return true |
| 186 | } |
| 187 | } |
| 188 | head := index - 1 |
| 189 | for head >= 0 && todos[head].Level == 1 { |
| 190 | head-- |
| 191 | } |
| 192 | if head >= 0 { |
| 193 | if todoStatus(todos[head].Status) != "completed" { |
| 194 | todos[head].Status = "in_progress" |
| 195 | } |
| 196 | return true |
| 197 | } |
| 198 | // No phase above: an orphan sub-step falls through and promotes the |
| 199 | // next pending unit like a plain step, so the list keeps one current |
| 200 | // item. |
| 201 | } |
| 202 | for i := range todos { |
| 203 | if todoStatus(todos[i].Status) == "in_progress" { |
| 204 | return true |
| 205 | } |
| 206 | } |
| 207 | for i := range todos { |
| 208 | if todoStatus(todos[i].Status) != "pending" { |
| 209 | continue |
| 210 | } |
| 211 | if sub, ok := FirstUnfinishedSubStep(todos, i); ok && sub >= 0 { |
| 212 | if todoStatus(todos[sub].Status) == "pending" { |
| 213 | todos[sub].Status = "in_progress" |
| 214 | } |
| 215 | return true |
| 216 | } |
| 217 | todos[i].Status = "in_progress" |
| 218 | return true |
| 219 | } |
| 220 | return true |
| 221 | } |
| 222 | |
| 223 | // TodoStepMatch is the result of matching a complete_step citation against the |
| 224 | // latest successful todo_write list in this turn. |
| 225 | type TodoStepMatch struct { |
| 226 | Found bool |
| 227 | Index int |
| 228 | Content string |
| 229 | Status string |
| 230 | ActiveForm string |
| 231 | StepID string |
| 232 | } |
| 233 | |
| 234 | // BackgroundLease identifies a background job whose evidence was provisionally |
| 235 | // merged into the current turn's ledger. The host commits these leases only |
| 236 | // after the turn passes its delivery gates, so a failed turn leaves the job's |
| 237 | // evidence collectable again. |
| 238 | type BackgroundLease struct { |
| 239 | Session string |
| 240 | JobID string |
| 241 | } |
| 242 | |
| 243 | // DeliveryCheckpoint is the compact, persistence-safe state carried across |
| 244 | // runs of one host-owned Goal. It intentionally stores no raw tool arguments or |
| 245 | // output. PendingMutation means a previously observed change still needs fresh |
| 246 | // verification, review, and sign-off before the Goal can finalize. |
| 247 | type DeliveryCheckpoint struct { |
| 248 | ScopeID string `json:"scopeID,omitempty"` |
| 249 | CriteriaEstablished bool `json:"criteriaEstablished,omitempty"` |
| 250 | WorkObserved bool `json:"workObserved,omitempty"` |
| 251 | MutationObserved bool `json:"mutationObserved,omitempty"` |
| 252 | PendingMutation bool `json:"pendingMutation,omitempty"` |
| 253 | } |
| 254 | |
| 255 | // Ledger stores bounded execution facts for the current turn. |
| 256 | type Ledger struct { |
| 257 | mu sync.Mutex |
| 258 | receipts []Receipt |
| 259 | nextSequence uint64 |
| 260 | backgroundLeases []BackgroundLease |
| 261 | } |
| 262 | |
| 263 | func NewLedger() *Ledger { return &Ledger{} } |
| 264 | |
| 265 | // Reset clears receipts and background leases between user turns. |
| 266 | func (l *Ledger) Reset() { |
| 267 | if l == nil { |
| 268 | return |
| 269 | } |
| 270 | l.mu.Lock() |
| 271 | defer l.mu.Unlock() |
| 272 | l.receipts = nil |
| 273 | l.nextSequence = 0 |
| 274 | l.backgroundLeases = nil |
| 275 | } |
| 276 | |
| 277 | // ResetBackgroundLeases starts a new run inside the same delivery scope. The |
| 278 | // durable receipts remain available, while per-run job leases must be collected |
| 279 | // and committed independently. |
| 280 | func (l *Ledger) ResetBackgroundLeases() { |
| 281 | if l == nil { |
| 282 | return |
| 283 | } |
| 284 | l.mu.Lock() |
| 285 | l.backgroundLeases = nil |
| 286 | l.mu.Unlock() |
| 287 | } |
| 288 | |
| 289 | // NoteBackgroundLease records that a background job's evidence was merged into |
| 290 | // this turn. It returns false when the job was already noted this turn so the |
| 291 | // caller can skip a duplicate merge — collection is idempotent within a turn, |
| 292 | // while a fresh turn (after Reset) leases again. |
| 293 | func (l *Ledger) NoteBackgroundLease(session, jobID string) bool { |
| 294 | if l == nil { |
| 295 | return false |
| 296 | } |
| 297 | l.mu.Lock() |
| 298 | defer l.mu.Unlock() |
| 299 | for _, lease := range l.backgroundLeases { |
| 300 | if lease.Session == session && lease.JobID == jobID { |
| 301 | return false |
| 302 | } |
| 303 | } |
| 304 | l.backgroundLeases = append(l.backgroundLeases, BackgroundLease{Session: session, JobID: jobID}) |
| 305 | return true |
| 306 | } |
| 307 | |
| 308 | // BackgroundLeases returns the background jobs merged into this turn, for the |
| 309 | // host to commit once the turn's delivery gates pass. |
| 310 | func (l *Ledger) BackgroundLeases() []BackgroundLease { |
| 311 | if l == nil { |
| 312 | return nil |
| 313 | } |
| 314 | l.mu.Lock() |
| 315 | defer l.mu.Unlock() |
| 316 | if len(l.backgroundLeases) == 0 { |
| 317 | return nil |
| 318 | } |
| 319 | out := make([]BackgroundLease, len(l.backgroundLeases)) |
| 320 | copy(out, l.backgroundLeases) |
| 321 | return out |
| 322 | } |
| 323 | |
| 324 | // Record appends a receipt and returns it as stored, including the host-issued |
| 325 | // ID a later citation resolves. Failed receipts are retained for auditability |
| 326 | // but are never accepted by the HasSuccessful* matchers. |
| 327 | func (l *Ledger) Record(r Receipt) Receipt { |
| 328 | if l == nil { |
| 329 | return r |
| 330 | } |
| 331 | r.Command = strings.TrimSpace(r.Command) |
| 332 | r.Step = strings.TrimSpace(r.Step) |
| 333 | r.Paths = normalizePaths(r.Paths) |
| 334 | r.Todos = normalizeTodos(r.Todos) |
| 335 | if r.Args != nil { |
| 336 | cp := make(json.RawMessage, len(r.Args)) |
| 337 | copy(cp, r.Args) |
| 338 | r.Args = cp |
| 339 | } |
| 340 | |
| 341 | l.mu.Lock() |
| 342 | defer l.mu.Unlock() |
| 343 | l.nextSequence++ |
| 344 | if r.ID == "" { |
| 345 | // A short content+position digest, not the provider's call ID: the model |
| 346 | // cites this across rounds, so it must be stable, cheap in tokens, and |
| 347 | // carry nothing about the host filesystem. |
| 348 | h := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%s\x00%s", r.ToolName, l.nextSequence, r.Command, strings.Join(r.Paths, "\x00"))) |
| 349 | r.ID = "r_" + hex.EncodeToString(h[:4]) |
| 350 | } |
| 351 | r.Sequence = l.nextSequence |
| 352 | if r.ToolName == "complete_step" && r.Step != "" && r.TodoStep == nil { |
| 353 | if match := latestTodoStep(r.Step, l.receipts); match.Found { |
| 354 | r.TodoStep = &match |
| 355 | } |
| 356 | } |
| 357 | l.receipts = append(l.receipts, r) |
| 358 | return r |
| 359 | } |
| 360 | |
| 361 | // Len returns the number of receipts recorded this turn, giving callers a |
| 362 | // stable index to pass to the *Since matchers. |
| 363 | func (l *Ledger) Len() int { |
| 364 | if l == nil { |
| 365 | return 0 |
| 366 | } |
| 367 | l.mu.Lock() |
| 368 | defer l.mu.Unlock() |
| 369 | return len(l.receipts) |
| 370 | } |
| 371 | |
| 372 | // ReceiptProgressSummary counts successful host-observable receipts by category |
| 373 | // for cross-turn progress signatures. Failed receipts and reads never count: |
| 374 | // repeated reads, failed bookkeeping, and reworded answers must not masquerade |
| 375 | // as progress. Categories are not mutually exclusive (a successful bash command |
| 376 | // that also writes counts in both), which is fine for a change detector. |
| 377 | type ReceiptProgressSummary struct { |
| 378 | Writes int // successful mutations/writes |
| 379 | Commands int // successful commands (bash receipts) |
| 380 | Todos int // successful todo_write receipts |
| 381 | Signoffs int // successful complete_step signoffs |
| 382 | Reviews int // successful review receipts |
| 383 | } |
| 384 | |
| 385 | // ReceiptProgressSummary returns the current ledger's progress counts. |
| 386 | func (l *Ledger) ReceiptProgressSummary() ReceiptProgressSummary { |
| 387 | if l == nil { |
| 388 | return ReceiptProgressSummary{} |
| 389 | } |
| 390 | l.mu.Lock() |
| 391 | defer l.mu.Unlock() |
| 392 | var out ReceiptProgressSummary |
| 393 | for _, r := range l.receipts { |
| 394 | if !r.Success { |
| 395 | continue |
| 396 | } |
| 397 | if r.Mutation || r.Write { |
| 398 | out.Writes++ |
| 399 | } |
| 400 | if r.Command != "" { |
| 401 | out.Commands++ |
| 402 | } |
| 403 | if r.ToolName == "todo_write" { |
| 404 | out.Todos++ |
| 405 | } |
| 406 | if r.ToolName == "complete_step" && r.StepProof { |
| 407 | out.Signoffs++ |
| 408 | } |
| 409 | if successfulForegroundReviewReceipt(r) || completedStructuredReviewReceipt(r, nil) { |
| 410 | out.Reviews++ |
| 411 | } |
| 412 | } |
| 413 | return out |
| 414 | } |
| 415 | |
| 416 | // HasWriteOrCommandSince reports whether a successful write or command receipt |
| 417 | // was recorded at or after index — host-observable progress, as opposed to |
| 418 | // bookkeeping receipts (todo_write, complete_step, ask), which carry neither a |
| 419 | // write flag nor a command. |
| 420 | func (l *Ledger) HasWriteOrCommandSince(index int) bool { |
| 421 | if l == nil { |
| 422 | return false |
| 423 | } |
| 424 | if index < 0 { |
| 425 | index = 0 |
| 426 | } |
| 427 | l.mu.Lock() |
| 428 | defer l.mu.Unlock() |
| 429 | for i := index; i < len(l.receipts); i++ { |
| 430 | r := l.receipts[i] |
| 431 | if r.Success && (r.Mutation || r.Write || r.Command != "") { |
| 432 | return true |
| 433 | } |
| 434 | } |
| 435 | return false |
| 436 | } |
| 437 | |
| 438 | // HasCompletedReview reports whether a review completed with evidence that is |
| 439 | // fresh for the latest mutation. Structured review_report receipts are the |
| 440 | // strongest proof and also cover collected background reviews. Foreground |
| 441 | // review/task adapters remain compatible, but after a mutation their child |
| 442 | // receipts must show that the changed result was actually inspected. |
| 443 | func (l *Ledger) HasCompletedReview() bool { |
| 444 | if l == nil { |
| 445 | return false |
| 446 | } |
| 447 | l.mu.Lock() |
| 448 | receipts := append([]Receipt(nil), l.receipts...) |
| 449 | l.mu.Unlock() |
| 450 | |
| 451 | mutation := -1 |
| 452 | for i, r := range receipts { |
| 453 | if r.Success && r.Mutation { |
| 454 | mutation = i |
| 455 | } |
| 456 | } |
| 457 | start := mutation + 1 |
| 458 | requiredPaths := []string(nil) |
| 459 | if mutation >= 0 { |
| 460 | requiredPaths = receipts[mutation].Paths |
| 461 | } |
| 462 | |
| 463 | for i := start; i < len(receipts); i++ { |
| 464 | r := receipts[i] |
| 465 | if completedStructuredReviewReceipt(r, requiredPaths) { |
| 466 | return true |
| 467 | } |
| 468 | if !successfulForegroundReviewReceipt(r) { |
| 469 | continue |
| 470 | } |
| 471 | if mutation < 0 || receiptsReviewChanges(receipts, start, i, mutation) { |
| 472 | return true |
| 473 | } |
| 474 | } |
| 475 | return false |
| 476 | } |
| 477 | |
| 478 | func successfulForegroundReviewReceipt(r Receipt) bool { |
| 479 | if !r.Success { |
| 480 | return false |
| 481 | } |
| 482 | if r.ToolName == "review" { |
| 483 | return true |
| 484 | } |
| 485 | if r.ToolName != "task" || r.Profile != "review" { |
| 486 | return false |
| 487 | } |
| 488 | var p struct { |
| 489 | RunInBackground bool `json:"run_in_background"` |
| 490 | } |
| 491 | return json.Unmarshal(r.Args, &p) == nil && !p.RunInBackground |
| 492 | } |
| 493 | |
| 494 | func completedStructuredReviewReceipt(r Receipt, requiredPaths []string) bool { |
| 495 | if !r.Success || r.ToolName != "review_report" { |
| 496 | return false |
| 497 | } |
| 498 | report, err := ParseReviewReport(r.Args) |
| 499 | return err == nil && report.Kind == ReviewKindReview && report.CoversPaths(requiredPaths) |
| 500 | } |
| 501 | |
| 502 | // TouchedPaths returns up to limit distinct paths from this turn's successful |
| 503 | // receipts, most recent first; writtenOnly restricts it to writer receipts. |
| 504 | func (l *Ledger) TouchedPaths(limit int, writtenOnly bool) []string { |
| 505 | if l == nil || limit <= 0 { |
| 506 | return nil |
| 507 | } |
| 508 | l.mu.Lock() |
| 509 | defer l.mu.Unlock() |
| 510 | seen := map[string]bool{} |
| 511 | var out []string |
| 512 | for i := len(l.receipts) - 1; i >= 0 && len(out) < limit; i-- { |
| 513 | r := l.receipts[i] |
| 514 | if !r.Success || (writtenOnly && !r.Write) || (!writtenOnly && !r.Read && !r.Write) { |
| 515 | continue |
| 516 | } |
| 517 | for _, p := range r.Paths { |
| 518 | if !seen[p] && len(out) < limit { |
| 519 | seen[p] = true |
| 520 | out = append(out, p) |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 | return out |
| 525 | } |
| 526 | |
| 527 | func (l *Ledger) HasSuccessfulCompleteStepAfter(after int) bool { |
| 528 | if l == nil { |
| 529 | return false |
| 530 | } |
| 531 | start := max(after+1, 0) |
| 532 | |
| 533 | l.mu.Lock() |
| 534 | defer l.mu.Unlock() |
| 535 | for i := start; i < len(l.receipts); i++ { |
| 536 | r := l.receipts[i] |
| 537 | if r.Success && r.ToolName == "complete_step" { |
| 538 | return true |
| 539 | } |
| 540 | } |
| 541 | return false |
| 542 | } |
| 543 | |
| 544 | // HasSuccessfulDeliverySignoffAfter reports whether a successful complete_step |
| 545 | // after the latest mutation cites a verification command that also succeeded |
| 546 | // after that mutation. complete_step already validates the cited command against |
| 547 | // host receipts; the additional ordering check prevents a pre-change test from |
| 548 | // signing off changed code in the delivery profile. |
| 549 | func (l *Ledger) HasSuccessfulDeliverySignoffAfter(after int) bool { |
| 550 | if l == nil { |
| 551 | return false |
| 552 | } |
| 553 | start := max(after+1, 0) |
| 554 | |
| 555 | l.mu.Lock() |
| 556 | receipts := append([]Receipt(nil), l.receipts...) |
| 557 | l.mu.Unlock() |
| 558 | for i := start; i < len(receipts); i++ { |
| 559 | r := receipts[i] |
| 560 | if !r.Success || r.ToolName != "complete_step" { |
| 561 | continue |
| 562 | } |
| 563 | if after >= 0 && !receiptsReviewChanges(receipts, start, i, after) { |
| 564 | continue |
| 565 | } |
| 566 | for _, command := range completeStepVerificationCommands(r.Args) { |
| 567 | if !bashCommandIsVerification(command) { |
| 568 | continue |
| 569 | } |
| 570 | for j := start; j < i; j++ { |
| 571 | candidate := receipts[j] |
| 572 | if candidate.Success && isShellToolName(candidate.ToolName) && CommandMatches(command, candidate.Command) { |
| 573 | return true |
| 574 | } |
| 575 | } |
| 576 | } |
| 577 | } |
| 578 | return false |
| 579 | } |
| 580 | |
| 581 | // HasSuccessfulReviewAfter reports whether the changed result was inspected |
| 582 | // after the latest mutation. A read of a touched path is sufficient; git/diff |
| 583 | // inspection commands cover shell-driven or delegated mutations whose paths are |
| 584 | // not knowable to the host. A negative index is the restored-checkpoint |
| 585 | // baseline: the mutation predates this ledger (controller rebuild or cold |
| 586 | // resume), so any successful review-shaped receipt counts. |
| 587 | func (l *Ledger) HasSuccessfulReviewAfter(after int) bool { |
| 588 | if l == nil { |
| 589 | return false |
| 590 | } |
| 591 | start := max(after+1, 0) |
| 592 | |
| 593 | l.mu.Lock() |
| 594 | receipts := append([]Receipt(nil), l.receipts...) |
| 595 | l.mu.Unlock() |
| 596 | if after >= len(receipts) { |
| 597 | return false |
| 598 | } |
| 599 | return receiptsReviewChanges(receipts, start, len(receipts), after) |
| 600 | } |
| 601 | |
| 602 | // HasHostReviewCoverageAfter reports whether host-observed content inspection |
| 603 | // after the latest mutation covers the production paths required by a Medium |
| 604 | // Delivery review. A plain, output-producing `git diff` covers the current |
| 605 | // change set; otherwise every required path needs a read receipt or a |
| 606 | // content-printing command that names it. Summary/status/check-only commands |
| 607 | // and model prose never satisfy this stronger alternative to review_report. |
| 608 | func (l *Ledger) HasHostReviewCoverageAfter(after int, requiredPaths []string) bool { |
| 609 | if l == nil { |
| 610 | return false |
| 611 | } |
| 612 | start := max(after+1, 0) |
| 613 | l.mu.Lock() |
| 614 | receipts := append([]Receipt(nil), l.receipts...) |
| 615 | l.mu.Unlock() |
| 616 | if after >= len(receipts) { |
| 617 | return false |
| 618 | } |
| 619 | for i := start; i < len(receipts); i++ { |
| 620 | r := receipts[i] |
| 621 | if r.Success && isShellToolName(r.ToolName) && r.OutputBytes > 0 && commandShowsWholeGitDiff(r.Command) { |
| 622 | return true |
| 623 | } |
| 624 | } |
| 625 | wanted := normalizePaths(requiredPaths) |
| 626 | if len(wanted) == 0 { |
| 627 | return false |
| 628 | } |
| 629 | for _, path := range wanted { |
| 630 | needle := strings.ToLower(filepath.ToSlash(path)) |
| 631 | covered := false |
| 632 | for i := start; i < len(receipts); i++ { |
| 633 | r := receipts[i] |
| 634 | if !r.Success { |
| 635 | continue |
| 636 | } |
| 637 | if r.Read { |
| 638 | for _, observed := range r.Paths { |
| 639 | candidate := strings.ToLower(filepath.ToSlash(normalizePath(observed))) |
| 640 | if candidate == needle || strings.HasSuffix(candidate, "/"+needle) { |
| 641 | covered = true |
| 642 | break |
| 643 | } |
| 644 | } |
| 645 | } |
| 646 | if !covered && isShellToolName(r.ToolName) && r.OutputBytes > 0 && commandShowsContentForPath(r.Command, needle) { |
| 647 | covered = true |
| 648 | } |
| 649 | if covered { |
| 650 | break |
| 651 | } |
| 652 | } |
| 653 | if !covered { |
| 654 | return false |
| 655 | } |
| 656 | } |
| 657 | return true |
| 658 | } |
| 659 | |
| 660 | func commandShowsWholeGitDiff(command string) bool { |
| 661 | file, err := shellparse.ParseBash(command) |
| 662 | if err != nil || shellparse.HasHereDoc(file) || len(file.Stmts) != 1 { |
| 663 | return false |
| 664 | } |
| 665 | stmt := file.Stmts[0] |
| 666 | if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || len(stmt.Redirs) > 0 { |
| 667 | return false |
| 668 | } |
| 669 | call, ok := stmt.Cmd.(*syntax.CallExpr) |
| 670 | if !ok || len(call.Assigns) > 0 || len(call.Args) != 2 { |
| 671 | return false |
| 672 | } |
| 673 | base, okBase := shellparse.StaticWord(call.Args[0]) |
| 674 | sub, okSub := shellparse.StaticWord(call.Args[1]) |
| 675 | return okBase && okSub && strings.EqualFold(filepath.Base(base), "git") && strings.EqualFold(sub, "diff") |
| 676 | } |
| 677 | |
| 678 | func receiptsReviewChanges(receipts []Receipt, start, end, mutationIndex int) bool { |
| 679 | if mutationIndex >= len(receipts) { |
| 680 | return false |
| 681 | } |
| 682 | // A negative mutationIndex is the restored-checkpoint baseline: the |
| 683 | // mutation's receipt is not in this ledger, so its touched paths are |
| 684 | // unknowable and any successful review-shaped receipt counts. |
| 685 | var wanted map[string]bool |
| 686 | if mutationIndex >= 0 { |
| 687 | wanted = pathSet(receipts[mutationIndex].Paths) |
| 688 | } |
| 689 | for i := start; i < end && i < len(receipts); i++ { |
| 690 | r := receipts[i] |
| 691 | if !r.Success { |
| 692 | continue |
| 693 | } |
| 694 | if isShellToolName(r.ToolName) && commandReviewsChanges(r.Command) { |
| 695 | return true |
| 696 | } |
| 697 | if isShellToolName(r.ToolName) && len(wanted) > 0 && !bashMayMutate(r.Command) && commandMentionsPaths(r.Command, wanted) { |
| 698 | return true |
| 699 | } |
| 700 | if !r.Read { |
| 701 | continue |
| 702 | } |
| 703 | if len(wanted) == 0 { |
| 704 | return true |
| 705 | } |
| 706 | for _, p := range r.Paths { |
| 707 | if wanted[p] { |
| 708 | return true |
| 709 | } |
| 710 | } |
| 711 | } |
| 712 | return false |
| 713 | } |
| 714 | |
| 715 | func (l *Ledger) HasSuccessfulTodoWrite() bool { |
| 716 | if l == nil { |
| 717 | return false |
| 718 | } |
| 719 | l.mu.Lock() |
| 720 | defer l.mu.Unlock() |
| 721 | for _, r := range l.receipts { |
| 722 | if r.Success && r.ToolName == "todo_write" { |
| 723 | return true |
| 724 | } |
| 725 | } |
| 726 | return false |
| 727 | } |
| 728 | |
| 729 | // HasSuccessfulAcceptanceCriteria reports whether the current turn established |
| 730 | // a non-empty task list. Delivery mode uses that list as its host-observable |
| 731 | // acceptance contract before permitting state-changing work. |
| 732 | func (l *Ledger) HasSuccessfulAcceptanceCriteria() bool { |
| 733 | if l == nil { |
| 734 | return false |
| 735 | } |
| 736 | l.mu.Lock() |
| 737 | defer l.mu.Unlock() |
| 738 | for _, r := range l.receipts { |
| 739 | if r.Success && r.ToolName == "todo_write" && len(r.Todos) > 0 { |
| 740 | return true |
| 741 | } |
| 742 | } |
| 743 | return false |
| 744 | } |
| 745 | |
| 746 | // HasSuccessfulTodoProgressReceipt reports whether any successful receipt in |
| 747 | // the turn reflects execution progress rather than read-only context gathering |
| 748 | // or a bare todo snapshot. |
| 749 | func (l *Ledger) HasSuccessfulTodoProgressReceipt() bool { |
| 750 | if l == nil { |
| 751 | return false |
| 752 | } |
| 753 | l.mu.Lock() |
| 754 | defer l.mu.Unlock() |
| 755 | for _, r := range l.receipts { |
| 756 | if !r.Success || r.ToolName == "todo_write" || r.Read { |
| 757 | continue |
| 758 | } |
| 759 | return true |
| 760 | } |
| 761 | return false |
| 762 | } |
| 763 | |
| 764 | func (l *Ledger) IncompleteLatestTodos() ([]TodoStepMatch, bool) { |
| 765 | if l == nil { |
| 766 | return nil, false |
| 767 | } |
| 768 | l.mu.Lock() |
| 769 | defer l.mu.Unlock() |
| 770 | for _, v := range slices.Backward(l.receipts) { |
| 771 | r := v |
| 772 | if !r.Success || r.ToolName != "todo_write" { |
| 773 | continue |
| 774 | } |
| 775 | return IncompleteTodos(r.Todos), true |
| 776 | } |
| 777 | return nil, false |
| 778 | } |
| 779 | |
| 780 | // IncompleteTodos returns the items of a todo list that are not completed. |
| 781 | func IncompleteTodos(todos []TodoItem) []TodoStepMatch { |
| 782 | incomplete := make([]TodoStepMatch, 0) |
| 783 | for j, t := range todos { |
| 784 | status := todoStatus(t.Status) |
| 785 | if status == "completed" { |
| 786 | continue |
| 787 | } |
| 788 | incomplete = append(incomplete, TodoStepMatch{ |
| 789 | Found: true, |
| 790 | Index: j + 1, |
| 791 | Content: t.Content, |
| 792 | Status: status, |
| 793 | ActiveForm: t.ActiveForm, |
| 794 | }) |
| 795 | } |
| 796 | return incomplete |
| 797 | } |
| 798 | |
| 799 | // MatchTodoIdentity resolves an existing todo against an updated list without |
| 800 | // interpreting numeric content as a 1-based step citation. |
| 801 | func MatchTodoIdentity(todo TodoItem, todos []TodoItem) (TodoStepMatch, bool) { |
| 802 | for i, candidate := range todos { |
| 803 | if sameTodoIdentity(todo, candidate) { |
| 804 | return todoMatchAt(i+1, candidate), true |
| 805 | } |
| 806 | } |
| 807 | found := -1 |
| 808 | for i, candidate := range todos { |
| 809 | match := TodoStepMatch{Content: candidate.Content, ActiveForm: candidate.ActiveForm} |
| 810 | if !todoContentRelates(todo, match) { |
| 811 | continue |
| 812 | } |
| 813 | if found >= 0 && found != i { |
| 814 | return TodoStepMatch{}, false |
| 815 | } |
| 816 | found = i |
| 817 | } |
| 818 | if found < 0 { |
| 819 | return TodoStepMatch{}, false |
| 820 | } |
| 821 | candidate := todos[found] |
| 822 | return todoMatchAt(found+1, candidate), true |
| 823 | } |
| 824 | |
| 825 | // PreservesCompletedTodoPositions reports whether every previously completed |
| 826 | // item remains completed at the same index in the replacement list. Completed |
| 827 | // sub-steps can sit behind a pending phase header, so this checks every item |
| 828 | // rather than assuming the literal list begins with completed statuses. |
| 829 | func PreservesCompletedTodoPositions(previous, next []TodoItem) bool { |
| 830 | for i, todo := range previous { |
| 831 | if todoStatus(todo.Status) != "completed" { |
| 832 | continue |
| 833 | } |
| 834 | if i >= len(next) || todoStatus(next[i].Status) != "completed" { |
| 835 | return false |
| 836 | } |
| 837 | match, found := MatchTodoIdentity(todo, next) |
| 838 | if !found || match.Index != i+1 { |
| 839 | return false |
| 840 | } |
| 841 | } |
| 842 | return true |
| 843 | } |
| 844 | |
| 845 | // HasAnySuccessfulReceipt reports whether any tool succeeded this turn — the |
| 846 | // signal that the turn did real work, not pure conversation. |
| 847 | func (l *Ledger) HasAnySuccessfulReceipt() bool { |
| 848 | if l == nil { |
| 849 | return false |
| 850 | } |
| 851 | l.mu.Lock() |
| 852 | defer l.mu.Unlock() |
| 853 | for _, r := range l.receipts { |
| 854 | if r.Success { |
| 855 | return true |
| 856 | } |
| 857 | } |
| 858 | return false |
| 859 | } |
| 860 | |
| 861 | // HasSuccessfulToolReceipt reports whether a named tool completed |
| 862 | // successfully in the current evidence scope. |
| 863 | func (l *Ledger) HasSuccessfulToolReceipt(name string) bool { |
| 864 | name = strings.TrimSpace(name) |
| 865 | if l == nil || name == "" { |
| 866 | return false |
| 867 | } |
| 868 | l.mu.Lock() |
| 869 | defer l.mu.Unlock() |
| 870 | for _, r := range l.receipts { |
| 871 | if r.Success && r.ToolName == name { |
| 872 | return true |
| 873 | } |
| 874 | } |
| 875 | return false |
| 876 | } |
| 877 | |
| 878 | // HasSuccessfulMutationOtherThan distinguishes a workflow-specific state |
| 879 | // change (for example durable memory) from unrelated workspace mutations that |
| 880 | // still need the full Delivery verification/review contract. |
| 881 | func (l *Ledger) HasSuccessfulMutationOtherThan(allowed ...string) bool { |
| 882 | if l == nil { |
| 883 | return false |
| 884 | } |
| 885 | allow := make(map[string]bool, len(allowed)) |
| 886 | for _, name := range allowed { |
| 887 | allow[strings.TrimSpace(name)] = true |
| 888 | } |
| 889 | l.mu.Lock() |
| 890 | defer l.mu.Unlock() |
| 891 | for _, r := range l.receipts { |
| 892 | if r.Success && r.Mutation && !allow[r.ToolName] { |
| 893 | return true |
| 894 | } |
| 895 | } |
| 896 | return false |
| 897 | } |
| 898 | |
| 899 | // HasSuccessfulWorkReceipt excludes workflow bookkeeping and reports whether |
| 900 | // the assistant actually inspected, executed, or changed something this turn. |
| 901 | // Delivery mode uses it to reject text-only claims for technical tasks while |
| 902 | // still allowing ordinary conversation to finish without tools. |
| 903 | func (l *Ledger) HasSuccessfulWorkReceipt() bool { |
| 904 | if l == nil { |
| 905 | return false |
| 906 | } |
| 907 | l.mu.Lock() |
| 908 | defer l.mu.Unlock() |
| 909 | for _, r := range l.receipts { |
| 910 | if !r.Success { |
| 911 | continue |
| 912 | } |
| 913 | switch r.ToolName { |
| 914 | case "ask", "todo_write", "complete_step": |
| 915 | continue |
| 916 | } |
| 917 | return true |
| 918 | } |
| 919 | return false |
| 920 | } |
| 921 | |
| 922 | // HasSuccessfulVerificationCommand reports whether the turn ran at least one |
| 923 | // command classified as verification rather than inspection or mutation. |
| 924 | func (l *Ledger) HasSuccessfulVerificationCommand() bool { |
| 925 | return l.HasSuccessfulVerificationCommandAfter(-1) |
| 926 | } |
| 927 | |
| 928 | // HasSuccessfulVerificationCommandAfter reports whether verification succeeded |
| 929 | // after the named receipt index. Mutations before the boundary do not satisfy a |
| 930 | // role setting's post-change verification floor. |
| 931 | func (l *Ledger) HasSuccessfulVerificationCommandAfter(after int) bool { |
| 932 | if l == nil { |
| 933 | return false |
| 934 | } |
| 935 | l.mu.Lock() |
| 936 | defer l.mu.Unlock() |
| 937 | for _, r := range l.receipts[max(after+1, 0):] { |
| 938 | if r.Success && isShellToolName(r.ToolName) && bashCommandIsVerification(r.Command) { |
| 939 | return true |
| 940 | } |
| 941 | } |
| 942 | return false |
| 943 | } |
| 944 | |
| 945 | func (l *Ledger) HasSuccessfulWrite(paths []string) bool { |
| 946 | return l.hasSuccessfulPaths(paths, func(r Receipt) bool { return r.Write }) |
| 947 | } |
| 948 | |
| 949 | func (l *Ledger) HasSuccessfulReadOrWrite(paths []string) bool { |
| 950 | return l.hasSuccessfulPaths(paths, func(r Receipt) bool { return r.Read || r.Write }) |
| 951 | } |
| 952 | |
| 953 | func (l *Ledger) LatestSuccessfulWriteIndex(paths []string) (int, bool) { |
| 954 | wanted := pathSet(normalizePaths(paths)) |
| 955 | if l == nil || len(wanted) == 0 { |
| 956 | return 0, false |
| 957 | } |
| 958 | latest := -1 |
| 959 | |
| 960 | l.mu.Lock() |
| 961 | defer l.mu.Unlock() |
| 962 | for i, r := range l.receipts { |
| 963 | if !r.Success || !r.Write { |
| 964 | continue |
| 965 | } |
| 966 | for _, p := range r.Paths { |
| 967 | if wanted[p] { |
| 968 | latest = i |
| 969 | break |
| 970 | } |
| 971 | } |
| 972 | } |
| 973 | return latest, latest >= 0 |
| 974 | } |
| 975 | |
| 976 | func (l *Ledger) LatestSuccessfulWriterIndex() (int, bool) { |
| 977 | if l == nil { |
| 978 | return 0, false |
| 979 | } |
| 980 | latest := -1 |
| 981 | |
| 982 | l.mu.Lock() |
| 983 | defer l.mu.Unlock() |
| 984 | for i, r := range l.receipts { |
| 985 | if r.Success && r.Write { |
| 986 | latest = i |
| 987 | } |
| 988 | } |
| 989 | return latest, latest >= 0 |
| 990 | } |
| 991 | |
| 992 | // LatestSuccessfulMutationIndex returns the most recent host-observed |
| 993 | // state-changing call. It includes known file writers, writer-capable delegated |
| 994 | // or external tools, and bash commands that are not demonstrably observational |
| 995 | // or verification-only. |
| 996 | func (l *Ledger) LatestSuccessfulMutationIndex() (int, bool) { |
| 997 | if l == nil { |
| 998 | return 0, false |
| 999 | } |
| 1000 | latest := -1 |
| 1001 | l.mu.Lock() |
| 1002 | defer l.mu.Unlock() |
| 1003 | for i, r := range l.receipts { |
| 1004 | if r.Success && r.Mutation { |
| 1005 | latest = i |
| 1006 | } |
| 1007 | } |
| 1008 | return latest, latest >= 0 |
| 1009 | } |
| 1010 | |
| 1011 | func (l *Ledger) MatchLatestTodoStep(step string) (TodoStepMatch, bool) { |
| 1012 | step = strings.TrimSpace(step) |
| 1013 | if l == nil || step == "" { |
| 1014 | return TodoStepMatch{}, false |
| 1015 | } |
| 1016 | l.mu.Lock() |
| 1017 | defer l.mu.Unlock() |
| 1018 | for _, v := range slices.Backward(l.receipts) { |
| 1019 | r := v |
| 1020 | if !r.Success || r.ToolName != "todo_write" { |
| 1021 | continue |
| 1022 | } |
| 1023 | return matchTodoStep(step, r.Todos), true |
| 1024 | } |
| 1025 | return TodoStepMatch{}, false |
| 1026 | } |
| 1027 | |
| 1028 | // LatestTodos returns the todo list from this turn's latest successful todo_write. |
| 1029 | func (l *Ledger) LatestTodos() ([]TodoItem, bool) { |
| 1030 | if l == nil { |
| 1031 | return nil, false |
| 1032 | } |
| 1033 | l.mu.Lock() |
| 1034 | defer l.mu.Unlock() |
| 1035 | for _, v := range slices.Backward(l.receipts) { |
| 1036 | r := v |
| 1037 | if r.Success && r.ToolName == "todo_write" { |
| 1038 | return append([]TodoItem(nil), r.Todos...), true |
| 1039 | } |
| 1040 | } |
| 1041 | return nil, false |
| 1042 | } |
| 1043 | |
| 1044 | // UnverifiedCompletedTodos reports current completed todos that transitioned |
| 1045 | // from the latest prior successful todo_write receipt without a matching |
| 1046 | // successful complete_step receipt earlier in the same turn. If this turn has no |
| 1047 | // prior todo_write baseline, hasBaseline is false and callers should preserve |
| 1048 | // the existing loose validation behavior. |
| 1049 | func (l *Ledger) UnverifiedCompletedTodos(current []TodoItem) (missing []TodoStepMatch, hasBaseline bool) { |
| 1050 | current = normalizeTodos(current) |
| 1051 | if l == nil { |
| 1052 | return nil, false |
| 1053 | } |
| 1054 | |
| 1055 | l.mu.Lock() |
| 1056 | receipts := append([]Receipt(nil), l.receipts...) |
| 1057 | l.mu.Unlock() |
| 1058 | |
| 1059 | var previous []TodoItem |
| 1060 | baseline := -1 |
| 1061 | for i, v := range slices.Backward(receipts) { |
| 1062 | r := v |
| 1063 | if !r.Success || r.ToolName != "todo_write" { |
| 1064 | continue |
| 1065 | } |
| 1066 | previous = r.Todos |
| 1067 | baseline = i |
| 1068 | hasBaseline = true |
| 1069 | break |
| 1070 | } |
| 1071 | if !hasBaseline { |
| 1072 | return nil, false |
| 1073 | } |
| 1074 | |
| 1075 | for i, t := range current { |
| 1076 | if todoStatus(t.Status) != "completed" { |
| 1077 | continue |
| 1078 | } |
| 1079 | index := i + 1 |
| 1080 | if previousTodoCompleted(index, t, previous) { |
| 1081 | continue |
| 1082 | } |
| 1083 | if hasSuccessfulCompleteStepForTodo(receipts, index, current) { |
| 1084 | continue |
| 1085 | } |
| 1086 | if hasFailedCompleteStepRecoveryForTodo(receipts, baseline, index, current) { |
| 1087 | continue |
| 1088 | } |
| 1089 | missing = append(missing, TodoStepMatch{ |
| 1090 | Found: true, |
| 1091 | Index: index, |
| 1092 | Content: t.Content, |
| 1093 | Status: todoStatus(t.Status), |
| 1094 | ActiveForm: t.ActiveForm, |
| 1095 | }) |
| 1096 | } |
| 1097 | return missing, true |
| 1098 | } |
| 1099 | |
| 1100 | func hasFailedCompleteStepRecoveryForTodo(receipts []Receipt, baseline int, index int, current []TodoItem) bool { |
| 1101 | for i := baseline + 1; i < len(receipts); i++ { |
| 1102 | r := receipts[i] |
| 1103 | if r.Success || r.ToolName != "complete_step" || strings.TrimSpace(r.Step) == "" || !r.StepProof { |
| 1104 | continue |
| 1105 | } |
| 1106 | if !hasSuccessfulProgressBeforeReceipt(receipts, baseline, i) { |
| 1107 | continue |
| 1108 | } |
| 1109 | if r.TodoStep != nil && r.TodoStep.Found { |
| 1110 | if index < 1 || index > len(current) { |
| 1111 | continue |
| 1112 | } |
| 1113 | if sameTodoMatch(current[index-1], *r.TodoStep) { |
| 1114 | return true |
| 1115 | } |
| 1116 | if !todoContentRelates(current[index-1], *r.TodoStep) { |
| 1117 | continue |
| 1118 | } |
| 1119 | } |
| 1120 | match := matchTodoStep(r.Step, current) |
| 1121 | if match.Found && match.Index == index { |
| 1122 | return true |
| 1123 | } |
| 1124 | } |
| 1125 | return false |
| 1126 | } |
| 1127 | |
| 1128 | // Recovery only trusts progress that happened before the failed sign-off. |
| 1129 | // Later unrelated work must not retroactively authorize an earlier completion. |
| 1130 | func hasSuccessfulProgressBeforeReceipt(receipts []Receipt, baseline int, before int) bool { |
| 1131 | start := max(baseline+1, 0) |
| 1132 | for i := start; i < before && i < len(receipts); i++ { |
| 1133 | r := receipts[i] |
| 1134 | if !r.Success || r.ToolName == "todo_write" || r.ToolName == "complete_step" || r.Read { |
| 1135 | continue |
| 1136 | } |
| 1137 | return true |
| 1138 | } |
| 1139 | return false |
| 1140 | } |
| 1141 | |
| 1142 | func (l *Ledger) hasSuccessfulPaths(paths []string, accept func(Receipt) bool) bool { |
| 1143 | wanted := pathSet(normalizePaths(paths)) |
| 1144 | if l == nil || len(wanted) == 0 { |
| 1145 | return false |
| 1146 | } |
| 1147 | found := map[string]bool{} |
| 1148 | |
| 1149 | l.mu.Lock() |
| 1150 | defer l.mu.Unlock() |
| 1151 | for _, r := range l.receipts { |
| 1152 | if !r.Success || !accept(r) { |
| 1153 | continue |
| 1154 | } |
| 1155 | for _, p := range r.Paths { |
| 1156 | if _, ok := wanted[p]; ok { |
| 1157 | found[p] = true |
| 1158 | } |
| 1159 | } |
| 1160 | } |
| 1161 | return len(found) == len(wanted) |
| 1162 | } |
| 1163 | |
| 1164 | type contextKey struct{} |
| 1165 | type closedLoopKey struct{} |
| 1166 | |
| 1167 | func WithLedger(ctx context.Context, ledger *Ledger) context.Context { |
| 1168 | if ledger == nil { |
| 1169 | return ctx |
| 1170 | } |
| 1171 | return context.WithValue(ctx, contextKey{}, ledger) |
| 1172 | } |
| 1173 | |
| 1174 | func FromContext(ctx context.Context) (*Ledger, bool) { |
| 1175 | ledger, ok := ctx.Value(contextKey{}).(*Ledger) |
| 1176 | return ledger, ok && ledger != nil |
| 1177 | } |
| 1178 | |
| 1179 | // WithClosedLoopExecution marks a tool call for closed-loop evidence checks. |
| 1180 | func WithClosedLoopExecution(ctx context.Context) context.Context { |
| 1181 | return context.WithValue(ctx, closedLoopKey{}, true) |
| 1182 | } |
| 1183 | |
| 1184 | // ClosedLoopExecutionFromContext reports whether closed-loop evidence is required. |
| 1185 | func ClosedLoopExecutionFromContext(ctx context.Context) bool { |
| 1186 | enabled, _ := ctx.Value(closedLoopKey{}).(bool) |
| 1187 | return enabled |
| 1188 | } |
| 1189 | |
| 1190 | // PathsProvenInSession reports whether every path is covered by a successful |
| 1191 | // (non-errored) tool call somewhere in msgs — the cross-turn fallback for diff |
| 1192 | // and files evidence, mirroring verifyCommandFromSession for the per-turn |
| 1193 | // ledger's path receipts (which reset each turn). wantWrite restricts to writer |
| 1194 | // tools (diff); false accepts a reader or writer (files). |
| 1195 | func PathsProvenInSession(msgs []provider.Message, paths []string, wantWrite bool) bool { |
| 1196 | wanted := pathSet(normalizePaths(paths)) |
| 1197 | if len(wanted) == 0 { |
| 1198 | return false |
| 1199 | } |
| 1200 | failed := failedSessionCallIDs(msgs) |
| 1201 | found := map[string]bool{} |
| 1202 | for _, msg := range msgs { |
| 1203 | for _, tc := range msg.ToolCalls { |
| 1204 | if failed[tc.ID] { |
| 1205 | continue |
| 1206 | } |
| 1207 | r := ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, false) |
| 1208 | if wantWrite && !r.Write { |
| 1209 | continue |
| 1210 | } |
| 1211 | if !wantWrite && !r.Read && !r.Write { |
| 1212 | continue |
| 1213 | } |
| 1214 | for _, p := range normalizePaths(r.Paths) { |
| 1215 | if _, ok := wanted[p]; ok { |
| 1216 | found[p] = true |
| 1217 | } |
| 1218 | } |
| 1219 | } |
| 1220 | } |
| 1221 | return len(found) == len(wanted) |
| 1222 | } |
| 1223 | |
| 1224 | func failedSessionCallIDs(msgs []provider.Message) map[string]bool { |
| 1225 | failed := map[string]bool{} |
| 1226 | for _, msg := range msgs { |
| 1227 | if msg.Role != provider.RoleTool || msg.ToolCallID == "" { |
| 1228 | continue |
| 1229 | } |
| 1230 | if strings.HasPrefix(msg.Content, "error:") || strings.HasPrefix(msg.Content, "blocked:") { |
| 1231 | failed[msg.ToolCallID] = true |
| 1232 | } |
| 1233 | } |
| 1234 | return failed |
| 1235 | } |
| 1236 | |
| 1237 | func ReceiptFromToolCall(toolName string, args json.RawMessage, success bool, readOnly bool) Receipt { |
| 1238 | effects := ClassifyToolCall(toolName, args, readOnly) |
| 1239 | r := Receipt{ |
| 1240 | ToolName: toolName, |
| 1241 | Args: args, |
| 1242 | Success: success, |
| 1243 | // Receipt.Mutation is delivery content debt. Repository-only state |
| 1244 | // transitions such as a pure commit remain guarded writers, but do not |
| 1245 | // force another content review pass by themselves. |
| 1246 | Mutation: effects.ContentMutation, |
| 1247 | } |
| 1248 | |
| 1249 | var fields map[string]json.RawMessage |
| 1250 | if err := json.Unmarshal(args, &fields); err == nil { |
| 1251 | if isShellToolName(toolName) { |
| 1252 | r.Command = stringField(fields, "command") |
| 1253 | } |
| 1254 | if toolName == "task" { |
| 1255 | r.Profile = stringField(fields, "profile") |
| 1256 | } |
| 1257 | if toolName == "complete_step" { |
| 1258 | r.Step = completeStepIdentity(fields) |
| 1259 | r.StepProof = completeStepHasProof(fields) |
| 1260 | } |
| 1261 | if toolName == "todo_write" { |
| 1262 | r.Todos = todoItemsField(fields, "todos") |
| 1263 | } |
| 1264 | r.Paths = extractPaths(fields) |
| 1265 | } |
| 1266 | |
| 1267 | if isWriterTool(toolName) { |
| 1268 | r.Write = true |
| 1269 | } else if isReadReceipt(toolName, readOnly) { |
| 1270 | r.Read = true |
| 1271 | } |
| 1272 | return r |
| 1273 | } |
| 1274 | |
| 1275 | // ToolCallPaths returns the bounded, structurally declared file paths in a |
| 1276 | // tool call. It intentionally does not attempt to parse shell scripts; callers |
| 1277 | // must treat bash and unknown targets as allPaths when invalidation is needed. |
| 1278 | func ToolCallPaths(args json.RawMessage) []string { |
| 1279 | var fields map[string]json.RawMessage |
| 1280 | if err := json.Unmarshal(args, &fields); err != nil { |
| 1281 | return nil |
| 1282 | } |
| 1283 | paths := extractPaths(fields) |
| 1284 | seen := make(map[string]struct{}, len(paths)) |
| 1285 | out := make([]string, 0, len(paths)) |
| 1286 | for _, path := range paths { |
| 1287 | path = strings.TrimSpace(path) |
| 1288 | if path == "" { |
| 1289 | continue |
| 1290 | } |
| 1291 | if _, ok := seen[path]; ok { |
| 1292 | continue |
| 1293 | } |
| 1294 | seen[path] = struct{}{} |
| 1295 | out = append(out, path) |
| 1296 | } |
| 1297 | return out |
| 1298 | } |
| 1299 | |
| 1300 | // ToolCallRequiresAcceptanceCriteria reports mutations and verification commands. |
| 1301 | func ToolCallRequiresAcceptanceCriteria(toolName string, args json.RawMessage, readOnly bool) bool { |
| 1302 | if ToolCallMutates(toolName, args, readOnly) { |
| 1303 | return true |
| 1304 | } |
| 1305 | if toolName != "bash" { |
| 1306 | return false |
| 1307 | } |
| 1308 | var fields map[string]json.RawMessage |
| 1309 | if err := json.Unmarshal(args, &fields); err != nil { |
| 1310 | return true |
| 1311 | } |
| 1312 | return bashCommandIsVerification(stringField(fields, "command")) |
| 1313 | } |
| 1314 | |
| 1315 | // BashToolCallUsesOpaqueInlineInterpreter reports whether a bash call executes |
| 1316 | // source supplied directly on an interpreter's command line. Delivery mode |
| 1317 | // cannot prove whether snippets such as node -e or python -c only inspect state |
| 1318 | // or also write files. Letting them run and then treating them as opaque |
| 1319 | // mutations invalidates otherwise valid review/verification receipts, while |
| 1320 | // treating them as read-only would create a delivery bypass. The agent blocks |
| 1321 | // this shape before execution and directs callers to auditable file tools, |
| 1322 | // script files, or conventional verifier commands instead. |
| 1323 | func BashToolCallUsesOpaqueInlineInterpreter(args json.RawMessage) bool { |
| 1324 | command, ok := bashCommandFromArgs(args) |
| 1325 | if !ok { |
| 1326 | return false |
| 1327 | } |
| 1328 | return bashCommandUsesOpaqueInlineInterpreter(command) |
| 1329 | } |
| 1330 | |
| 1331 | // BashCommandMayBeOpaqueMutation reports whether a sole opaque inline |
| 1332 | // interpreter call is allowed to run but cannot be proven read-only for |
| 1333 | // mutation-risk labeling. |
| 1334 | func BashCommandMayBeOpaqueMutation(args json.RawMessage) bool { |
| 1335 | return BashToolCallUsesOpaqueInlineInterpreter(args) |
| 1336 | } |
| 1337 | |
| 1338 | func bashCommandFromArgs(args json.RawMessage) (string, bool) { |
| 1339 | var fields map[string]json.RawMessage |
| 1340 | if err := json.Unmarshal(args, &fields); err != nil { |
| 1341 | return "", false |
| 1342 | } |
| 1343 | command := strings.TrimSpace(stringField(fields, "command")) |
| 1344 | return command, command != "" |
| 1345 | } |
| 1346 | |
| 1347 | func bashCommandUsesOpaqueInlineInterpreter(command string) bool { |
| 1348 | segments, _, ok := shellparse.SplitTopLevel(command) |
| 1349 | if !ok { |
| 1350 | return false |
| 1351 | } |
| 1352 | return slices.ContainsFunc(segments, bashSegmentUsesOpaqueInlineInterpreter) |
| 1353 | } |
| 1354 | |
| 1355 | func bashSegmentUsesOpaqueInlineInterpreter(segment string) bool { |
| 1356 | normalized, _ := shellsafe.NormalizeBashSafeRedirectsForMatch(segment) |
| 1357 | argv, malformed := shellparse.StaticFields(normalized) |
| 1358 | if malformed != "" || len(argv) == 0 { |
| 1359 | return false |
| 1360 | } |
| 1361 | base := strings.ToLower(filepath.Base(argv[0])) |
| 1362 | args := argv[1:] |
| 1363 | switch base { |
| 1364 | case "node", "bun": |
| 1365 | return hasCommandArg(args, "-e", "--eval", "-p", "--print") |
| 1366 | case "python", "python3", "ruby", "perl": |
| 1367 | return hasCommandArg(args, "-c", "-e") |
| 1368 | case "php": |
| 1369 | return hasCommandArg(args, "-r") |
| 1370 | case "deno": |
| 1371 | return len(args) > 0 && strings.EqualFold(args[0], "eval") |
| 1372 | } |
| 1373 | return false |
| 1374 | } |
| 1375 | |
| 1376 | func bashMayMutate(command string) bool { |
| 1377 | command = strings.TrimSpace(command) |
| 1378 | if command == "" { |
| 1379 | return true |
| 1380 | } |
| 1381 | segments, _, ok := shellparse.SplitTopLevel(command) |
| 1382 | if !ok || len(segments) == 0 { |
| 1383 | return true |
| 1384 | } |
| 1385 | for _, segment := range segments { |
| 1386 | normalized, _ := shellsafe.NormalizeBashSafeRedirectsForMatch(segment) |
| 1387 | if normalized == "" { |
| 1388 | normalized = segment |
| 1389 | } |
| 1390 | if fields, ok := bashStaticArgv(normalized); ok && bashSegmentIsVerification(fields) { |
| 1391 | continue |
| 1392 | } |
| 1393 | if shellsafe.ClassifyBash(segment).AnyMutation() { |
| 1394 | return true |
| 1395 | } |
| 1396 | } |
| 1397 | return false |
| 1398 | } |
| 1399 | |
| 1400 | func bashCommandIsVerification(command string) bool { |
| 1401 | command = strings.TrimSpace(command) |
| 1402 | if command == "" { |
| 1403 | return false |
| 1404 | } |
| 1405 | segments, _, ok := shellparse.SplitTopLevel(command) |
| 1406 | if !ok || len(segments) == 0 { |
| 1407 | return false |
| 1408 | } |
| 1409 | found := false |
| 1410 | for _, segment := range segments { |
| 1411 | normalized, safeRedirects := shellsafe.NormalizeBashSafeRedirectsForMatch(segment) |
| 1412 | if !safeRedirects { |
| 1413 | return false |
| 1414 | } |
| 1415 | fields, ok := bashStaticArgv(normalized) |
| 1416 | if !ok { |
| 1417 | return false |
| 1418 | } |
| 1419 | if bashSegmentIsVerification(fields) { |
| 1420 | found = true |
| 1421 | continue |
| 1422 | } |
| 1423 | if shellsafe.ClassifyBash(normalized).AnyMutation() { |
| 1424 | return false |
| 1425 | } |
| 1426 | } |
| 1427 | return found |
| 1428 | } |
| 1429 | |
| 1430 | func bashStaticArgv(command string) ([]string, bool) { |
| 1431 | if fields, _, ok := shellsafe.CommandArgv(command); ok { |
| 1432 | return fields, true |
| 1433 | } |
| 1434 | fields, malformed := shellparse.StaticFields(command) |
| 1435 | return fields, malformed == "" && len(fields) > 0 |
| 1436 | } |
| 1437 | |
| 1438 | // IsVerificationCommand reports whether command is a recognized verifier. |
| 1439 | func IsVerificationCommand(command string) bool { |
| 1440 | return bashCommandIsVerification(command) |
| 1441 | } |
| 1442 | |
| 1443 | type verificationCommandRecommendation struct { |
| 1444 | label string |
| 1445 | examples []string |
| 1446 | } |
| 1447 | |
| 1448 | // verificationCommandRecommendations is the single source for the concrete |
| 1449 | // model-readable examples and the family labels used to diagnose test failures. |
| 1450 | // It is intentionally a safe recommended subset rather than an exhaustive |
| 1451 | // rendering of bashSegmentIsVerification: accepted commands that may install |
| 1452 | // dependencies or create workspace outputs should not be suggested as the |
| 1453 | // first recovery action. |
| 1454 | func verificationCommandRecommendations() []verificationCommandRecommendation { |
| 1455 | return []verificationCommandRecommendation{ |
| 1456 | {label: "go test|vet", examples: []string{"go test ./...", "go vet ./..."}}, |
| 1457 | {label: "git diff --check", examples: []string{"git diff --check"}}, |
| 1458 | {label: "pytest/py.test", examples: []string{"pytest tests/", "py.test tests/"}}, |
| 1459 | {label: "gotestsum", examples: []string{"gotestsum"}}, |
| 1460 | {label: "staticcheck", examples: []string{"staticcheck ./..."}}, |
| 1461 | {label: "golangci-lint", examples: []string{"golangci-lint run"}}, |
| 1462 | {label: "tsc", examples: []string{"tsc --noEmit"}}, |
| 1463 | {label: "mypy (no report flag)", examples: []string{"mypy src/"}}, |
| 1464 | {label: "npm|pnpm|yarn|bun test|check|lint", examples: []string{"npm test", "pnpm check", "yarn lint", "bun test"}}, |
| 1465 | {label: "npm run test|check|lint|typecheck", examples: []string{"npm run typecheck"}}, |
| 1466 | {label: "cargo test|check|clippy", examples: []string{"cargo test", "cargo check", "cargo clippy"}}, |
| 1467 | {label: "node --check|--test", examples: []string{"node --check index.js", "node --test"}}, |
| 1468 | {label: "make|just test|check|lint|verify|ci", examples: []string{"make test", "just verify"}}, |
| 1469 | {label: "python -m pytest|unittest", examples: []string{"python -m pytest", "python -m unittest"}}, |
| 1470 | {label: "dotnet test", examples: []string{"dotnet test"}}, |
| 1471 | {label: "swift test", examples: []string{"swift test"}}, |
| 1472 | {label: "mvn|gradle test|check|verify", examples: []string{"mvn test", "gradle check"}}, |
| 1473 | } |
| 1474 | } |
| 1475 | |
| 1476 | // VerificationCommandSummary returns compact, model-readable recovery |
| 1477 | // guidance. It lists only recommended command families that the classifier |
| 1478 | // accepts, while omitting known self-installing and direct workspace-output |
| 1479 | // command forms from first-line guidance. |
| 1480 | func VerificationCommandSummary() string { |
| 1481 | recommendations := verificationCommandRecommendations() |
| 1482 | commands := make([]string, 0, len(recommendations)) |
| 1483 | for _, recommendation := range recommendations { |
| 1484 | commands = append(commands, recommendation.examples...) |
| 1485 | } |
| 1486 | return "recommended recognized verification commands: " + strings.Join(commands, ", ") + ". " + |
| 1487 | "Read-only inspection commands (grep/find/cat/wc/head/tail) are NOT verification; " + |
| 1488 | "inline interpreters (node -e, python -c) are blocked in delivery mode. " + |
| 1489 | "A read-only extraction pipeline ending in a recognized verifier " + |
| 1490 | "(e.g. tail -n +1 file | node --check -) is accepted." |
| 1491 | } |
| 1492 | |
| 1493 | func bashSegmentIsVerification(fields []string) bool { |
| 1494 | if len(fields) == 0 { |
| 1495 | return false |
| 1496 | } |
| 1497 | base := strings.ToLower(filepath.Base(fields[0])) |
| 1498 | args := fields[1:] |
| 1499 | if hasCommandArg(args, "--fix", "--write", "-w", "--update", "-u") { |
| 1500 | return false |
| 1501 | } |
| 1502 | if hasWriteOutputFlag(args) { |
| 1503 | return false |
| 1504 | } |
| 1505 | switch base { |
| 1506 | case "go": |
| 1507 | if len(args) == 0 { |
| 1508 | return false |
| 1509 | } |
| 1510 | if args[0] == "vet" { |
| 1511 | return true |
| 1512 | } |
| 1513 | if args[0] == "test" { |
| 1514 | return !slices.ContainsFunc(args[1:], goTestFlagWritesFile) |
| 1515 | } |
| 1516 | // A package pattern can expand to one main package, so even `go build |
| 1517 | // ./...` may write a workspace binary. Package expansion and inherited |
| 1518 | // GOFLAGS are unavailable to this static classifier; fail closed for all |
| 1519 | // build forms and keep test/vet as the recognized Go verifiers. |
| 1520 | return false |
| 1521 | case "git": |
| 1522 | return len(args) > 1 && args[0] == "diff" && hasCommandArg(args[1:], "--check") |
| 1523 | case "pytest", "py.test", "gotestsum", "staticcheck", "golangci-lint": |
| 1524 | return true |
| 1525 | case "tsc": |
| 1526 | return tscSegmentIsVerification(args) |
| 1527 | case "mypy": |
| 1528 | return !slices.ContainsFunc(args, mypyFlagWritesReport) |
| 1529 | case "npm", "pnpm", "yarn", "bun", "cargo": |
| 1530 | if len(args) > 0 && hasCommandArg(args[:1], "test", "check", "lint", "clippy") { |
| 1531 | return true |
| 1532 | } |
| 1533 | return len(args) > 1 && args[0] == "run" && hasCommandArg(args[1:2], "test", "check", "lint", "typecheck") |
| 1534 | case "npx": |
| 1535 | return npxSegmentIsVerification(args) |
| 1536 | case "node": |
| 1537 | return nodeSegmentIsVerification(args) |
| 1538 | case "make", "just": |
| 1539 | return len(args) > 0 && hasCommandArg(args[:1], "test", "check", "lint", "verify", "ci") |
| 1540 | case "python", "python3": |
| 1541 | return len(args) > 1 && args[0] == "-m" && hasCommandArg(args[1:2], "pytest", "unittest") |
| 1542 | case "dotnet": |
| 1543 | return len(args) > 0 && args[0] == "test" |
| 1544 | case "swift": |
| 1545 | // swift test runs the SwiftPM test suite; build artifacts stay under |
| 1546 | // the package's own .build directory (including --enable-code-coverage |
| 1547 | // reports). Other swift subcommands (build/run/package) can write |
| 1548 | // binaries or mutate the package, so only the test form is a |
| 1549 | // recognized verifier. Explicit report destinations, attachment dirs, |
| 1550 | // and scratch-dir redirects are rejected by writeOutputFlags. Note |
| 1551 | // that swift test may run Package.swift build plugins (arbitrary |
| 1552 | // code) — the same trust boundary as go test / cargo test. |
| 1553 | if len(args) == 0 || args[0] != "test" { |
| 1554 | return false |
| 1555 | } |
| 1556 | // Control modes that do not run the test suite (help, listing) must |
| 1557 | // not count as verification; mirror the tsc treatment of --help. |
| 1558 | for _, arg := range args[1:] { |
| 1559 | name := strings.TrimLeft(strings.ToLower(arg), "-") |
| 1560 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1561 | name = name[:i] |
| 1562 | } |
| 1563 | switch name { |
| 1564 | case "help", "h", "version", "list-tests", "l": |
| 1565 | return false |
| 1566 | } |
| 1567 | } |
| 1568 | return true |
| 1569 | case "mvn", "mvnw", "gradle", "gradlew": |
| 1570 | return len(args) > 0 && hasCommandArg(args, "test", "check", "verify") |
| 1571 | } |
| 1572 | return false |
| 1573 | } |
| 1574 | |
| 1575 | // tscSegmentIsVerification accepts only one-shot, explicit no-emit type checks. |
| 1576 | // Bare tsc commands may emit JavaScript, declarations, and source maps; control |
| 1577 | // modes may write config, skip checking, exit after printing metadata, or watch |
| 1578 | // indefinitely. Any explicit false value wins conservatively even if another |
| 1579 | // no-emit flag appears in the same command. |
| 1580 | func tscSegmentIsVerification(args []string) bool { |
| 1581 | noEmit := false |
| 1582 | for i, arg := range args { |
| 1583 | if tscFlagDisqualifiesVerification(arg) { |
| 1584 | return false |
| 1585 | } |
| 1586 | switch strings.ToLower(arg) { |
| 1587 | case "--noemit": |
| 1588 | if i+1 < len(args) && strings.EqualFold(args[i+1], "false") { |
| 1589 | return false |
| 1590 | } |
| 1591 | noEmit = true |
| 1592 | case "--noemit=true": |
| 1593 | noEmit = true |
| 1594 | case "--noemit=false": |
| 1595 | return false |
| 1596 | } |
| 1597 | } |
| 1598 | return noEmit |
| 1599 | } |
| 1600 | |
| 1601 | // tscFlagDisqualifiesVerification rejects modes that do not perform a bounded |
| 1602 | // type check and destinations that write independently of JavaScript/declaration |
| 1603 | // emit. Default incremental metadata remains conventional verifier cache; |
| 1604 | // explicit output destinations and control modes fail closed as mutations. |
| 1605 | func tscFlagDisqualifiesVerification(arg string) bool { |
| 1606 | name := strings.ToLower(arg) |
| 1607 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1608 | name = name[:i] |
| 1609 | } |
| 1610 | switch name { |
| 1611 | case "--tsbuildinfofile", "--generatetrace", "--generatecpuprofile", |
| 1612 | "--init", "--help", "-h", "-?", "--all", "--version", "-v", |
| 1613 | "--showconfig", "--listfilesonly", "--nocheck", "--watch", "-w", |
| 1614 | "--build", "-b", "--clean": |
| 1615 | return true |
| 1616 | default: |
| 1617 | return false |
| 1618 | } |
| 1619 | } |
| 1620 | |
| 1621 | // npxSegmentIsVerification unwraps only known test runners invoked directly, |
| 1622 | // with no npx control flags. Treating arbitrary npx packages as verification |
| 1623 | // would let package installation or an opaque executable masquerade as a |
| 1624 | // read-only check. Runner flags that update snapshots, write reports, or enable |
| 1625 | // coverage are rejected by the caller and the checks below. |
| 1626 | func npxSegmentIsVerification(args []string) bool { |
| 1627 | if len(args) == 0 || strings.HasPrefix(args[0], "-") { |
| 1628 | return false |
| 1629 | } |
| 1630 | runner, ok := npxRunnerName(args[0]) |
| 1631 | if !ok { |
| 1632 | return false |
| 1633 | } |
| 1634 | runnerArgs := args[1:] |
| 1635 | switch runner { |
| 1636 | case "vitest", "jest", "mocha", "ava", "eslint": |
| 1637 | // Known test/lint runners are verification unless an argument asks them |
| 1638 | // to update snapshots, collect coverage, or write a report. |
| 1639 | case "prettier": |
| 1640 | // Prettier without an explicit check mode formats to stdout and is not a |
| 1641 | // project verification receipt. Keep only its read-only check forms. |
| 1642 | if !hasCommandArg(runnerArgs, "--check", "-c", "--list-different") { |
| 1643 | return false |
| 1644 | } |
| 1645 | case "tsc": |
| 1646 | return tscSegmentIsVerification(runnerArgs) |
| 1647 | default: |
| 1648 | // Playwright/Cypress produce project reports, screenshots, or videos by |
| 1649 | // default; tsx/ts-node execute source. They remain mutations. |
| 1650 | return false |
| 1651 | } |
| 1652 | for _, arg := range runnerArgs { |
| 1653 | name := strings.ToLower(arg) |
| 1654 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1655 | name = name[:i] |
| 1656 | } |
| 1657 | switch name { |
| 1658 | case "--update", "-u", "--updatesnapshot", "--update-snapshots", |
| 1659 | "--output-file", "-o", "--cache-location": |
| 1660 | return false |
| 1661 | } |
| 1662 | if name == "--coverage" || strings.HasPrefix(name, "--coverage.") { |
| 1663 | return false |
| 1664 | } |
| 1665 | } |
| 1666 | return true |
| 1667 | } |
| 1668 | |
| 1669 | // npxRunnerName accepts only a bare package name with an optional ordinary |
| 1670 | // version or dist-tag suffix. Paths and package protocols such as |
| 1671 | // eslint@npm:other-package must not inherit a known runner's trust boundary. |
| 1672 | func npxRunnerName(spec string) (string, bool) { |
| 1673 | if spec == "" || strings.ContainsAny(spec, `/\`) { |
| 1674 | return "", false |
| 1675 | } |
| 1676 | name := strings.ToLower(spec) |
| 1677 | if strings.HasPrefix(name, "@") { |
| 1678 | return "", false |
| 1679 | } |
| 1680 | if i := strings.LastIndexByte(name, '@'); i >= 0 { |
| 1681 | if i == 0 || !plainNpxVersion(name[i+1:]) { |
| 1682 | return "", false |
| 1683 | } |
| 1684 | name = name[:i] |
| 1685 | } |
| 1686 | return name, true |
| 1687 | } |
| 1688 | |
| 1689 | func plainNpxVersion(version string) bool { |
| 1690 | if version == "" { |
| 1691 | return false |
| 1692 | } |
| 1693 | for _, r := range version { |
| 1694 | if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { |
| 1695 | continue |
| 1696 | } |
| 1697 | switch r { |
| 1698 | case '.', '-', '+', '_', '~', '^', '*': |
| 1699 | continue |
| 1700 | default: |
| 1701 | return false |
| 1702 | } |
| 1703 | } |
| 1704 | return true |
| 1705 | } |
| 1706 | |
| 1707 | func nodeSegmentIsVerification(args []string) bool { |
| 1708 | if len(args) == 0 { |
| 1709 | return false |
| 1710 | } |
| 1711 | // Node CLI flags are case-sensitive: -c/--check is the syntax-only mode, |
| 1712 | // while -C/--conditions executes the target with custom export conditions. |
| 1713 | switch args[0] { |
| 1714 | case "--check", "-c": |
| 1715 | // Syntax-check mode does not execute the target. Fail closed on any |
| 1716 | // additional option: preload/eval/import flags could execute code before |
| 1717 | // the check and turn a purported verifier into an opaque mutation. |
| 1718 | for _, arg := range args[1:] { |
| 1719 | if arg != "-" && strings.HasPrefix(arg, "-") { |
| 1720 | return false |
| 1721 | } |
| 1722 | } |
| 1723 | return true |
| 1724 | case "--test": |
| 1725 | // Match the repository's treatment of other conventional test runners, |
| 1726 | // but fail closed on test-runner and Node runtime flags that write files. |
| 1727 | return !slices.ContainsFunc(args[1:], nodeTestFlagWritesFile) |
| 1728 | default: |
| 1729 | return false |
| 1730 | } |
| 1731 | } |
| 1732 | |
| 1733 | func nodeTestFlagWritesFile(arg string) bool { |
| 1734 | name := strings.ToLower(arg) |
| 1735 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1736 | name = name[:i] |
| 1737 | } |
| 1738 | switch name { |
| 1739 | case "--cpu-prof", "--heap-prof", "--heapsnapshot-near-heap-limit", "--heapsnapshot-signal", |
| 1740 | "--localstorage-file", "--perf-basic-prof", "--perf-basic-prof-only-functions", "--perf-prof", |
| 1741 | "--prof", "--redirect-warnings", "--report-on-fatalerror", "--report-on-signal", |
| 1742 | "--report-uncaught-exception", "--test-reporter-destination", "--test-rerun-failures", |
| 1743 | "--test-update-snapshots", "--tls-keylog", "--trace-events-enabled": |
| 1744 | return true |
| 1745 | default: |
| 1746 | return false |
| 1747 | } |
| 1748 | } |
| 1749 | |
| 1750 | func hasCommandArg(args []string, candidates ...string) bool { |
| 1751 | for _, arg := range args { |
| 1752 | for _, candidate := range candidates { |
| 1753 | if strings.EqualFold(arg, candidate) { |
| 1754 | return true |
| 1755 | } |
| 1756 | } |
| 1757 | } |
| 1758 | return false |
| 1759 | } |
| 1760 | |
| 1761 | // writeOutputFlags are test-runner and linter flags that write snapshot, |
| 1762 | // report, or profile files. Snapshot flags rewrite checked-in fixtures (the |
| 1763 | // --update/-u class rejected above); the others write explicit output paths. |
| 1764 | // A runner invoked with one of them changes workspace state, so the segment |
| 1765 | // must not count as read-only verification. |
| 1766 | var writeOutputFlags = map[string]bool{ |
| 1767 | "snapshot-update": true, // pytest-snapshot / syrupy |
| 1768 | "updatesnapshot": true, // jest --updateSnapshot via npm/yarn wrappers |
| 1769 | "junitxml": true, // pytest |
| 1770 | "junit-xml": true, // pytest / mypy |
| 1771 | "junitfile": true, // gotestsum |
| 1772 | "jsonfile": true, // gotestsum |
| 1773 | "coverprofile": true, // go test |
| 1774 | "cpuprofile": true, // go test |
| 1775 | "memprofile": true, // go test |
| 1776 | "blockprofile": true, // go test |
| 1777 | "mutexprofile": true, // go test |
| 1778 | "testlogfile": true, // go test binary |
| 1779 | "gocoverdir": true, // go test binary |
| 1780 | "outputfile": true, // jest/vitest --outputFile (with --json) |
| 1781 | "report-log": true, // pytest-reportlog |
| 1782 | "xunit-output": true, // swift test --xunit-output writes a JUnit XML report |
| 1783 | "scratch-path": true, // swift test --scratch-path redirects the build dir |
| 1784 | "build-path": true, // swift test --build-path: legacy alias of --scratch-path |
| 1785 | "cache-path": true, // swift test --cache-path redirects the shared cache dir |
| 1786 | "event-stream-output-path": true, // swift test (Swift 6.x): swift-testing JSON output |
| 1787 | "experimental-event-stream-output": true, // swift test (Swift 6.x): experimental event-stream output |
| 1788 | "attachments-path": true, // swift test (Swift 6.x): Swift Testing attachments dir |
| 1789 | "experimental-attachments-path": true, // swift test (Swift 6.x): experimental attachments dir |
| 1790 | } |
| 1791 | |
| 1792 | func hasWriteOutputFlag(args []string) bool { |
| 1793 | for _, arg := range args { |
| 1794 | name := strings.TrimLeft(arg, "-") |
| 1795 | if len(name) == len(arg) || name == "" { |
| 1796 | continue // not a flag |
| 1797 | } |
| 1798 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1799 | name = name[:i] |
| 1800 | } |
| 1801 | // go test flags accept an optional test. prefix (-test.coverprofile) |
| 1802 | // that the go tool passes through to the test binary. |
| 1803 | name = strings.TrimPrefix(strings.ToLower(name), "test.") |
| 1804 | if writeOutputFlags[name] { |
| 1805 | return true |
| 1806 | } |
| 1807 | // Vitest exposes dotted per-reporter forms (--outputFile.json=path). |
| 1808 | if i := strings.IndexByte(name, '.'); i > 0 && writeOutputFlags[name[:i]] { |
| 1809 | return true |
| 1810 | } |
| 1811 | } |
| 1812 | return false |
| 1813 | } |
| 1814 | |
| 1815 | // mypyFlagWritesReport reports whether a mypy flag writes a report directory: |
| 1816 | // every mypy report option follows the --<type>-report DIR shape (txt, html, |
| 1817 | // xml, cobertura-xml, any-exprs, linecount, linecoverage, lineprecision), and |
| 1818 | // mypy has no read-only flag with that suffix. --junit-xml is covered by the |
| 1819 | // global write-output flags. |
| 1820 | func mypyFlagWritesReport(arg string) bool { |
| 1821 | name := strings.ToLower(arg) |
| 1822 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1823 | name = name[:i] |
| 1824 | } |
| 1825 | return strings.HasPrefix(name, "--") && strings.HasSuffix(name, "-report") |
| 1826 | } |
| 1827 | |
| 1828 | // goTestFlagWritesFile reports whether a go test flag writes a workspace |
| 1829 | // artifact: -c/-o emit the test binary, -trace and the profile flags write |
| 1830 | // profiles, and -artifacts/-testlogfile/-gocoverdir write test outputs. The |
| 1831 | // short and ambiguous names stay out of writeOutputFlags because the |
| 1832 | // dash-stripped global match would also hit node -c (a syntax-only check) |
| 1833 | // and pytest --trace (a read-only debugger flag). go test flags accept |
| 1834 | // single- and double-dash forms and an optional test. prefix that the go |
| 1835 | // tool passes through to the test binary. |
| 1836 | func goTestFlagWritesFile(arg string) bool { |
| 1837 | name := strings.ToLower(arg) |
| 1838 | if i := strings.IndexByte(name, '='); i >= 0 { |
| 1839 | name = name[:i] |
| 1840 | } |
| 1841 | trimmed := strings.TrimLeft(name, "-") |
| 1842 | if len(trimmed) == len(name) || trimmed == "" { |
| 1843 | return false // not a flag |
| 1844 | } |
| 1845 | trimmed = strings.TrimPrefix(trimmed, "test.") |
| 1846 | switch trimmed { |
| 1847 | case "c", "o", "trace", "artifacts", "testlogfile", "gocoverdir", |
| 1848 | "coverprofile", "cpuprofile", "memprofile", "blockprofile", "mutexprofile": |
| 1849 | return true |
| 1850 | default: |
| 1851 | return false |
| 1852 | } |
| 1853 | } |
| 1854 | |
| 1855 | func completeStepVerificationCommands(args json.RawMessage) []string { |
| 1856 | var p struct { |
| 1857 | Evidence []struct { |
| 1858 | Kind string `json:"kind"` |
| 1859 | Command string `json:"command"` |
| 1860 | } `json:"evidence"` |
| 1861 | } |
| 1862 | if err := json.Unmarshal(args, &p); err != nil { |
| 1863 | return nil |
| 1864 | } |
| 1865 | var out []string |
| 1866 | for _, item := range p.Evidence { |
| 1867 | if item.Kind == "verification" && strings.TrimSpace(item.Command) != "" { |
| 1868 | out = append(out, strings.TrimSpace(item.Command)) |
| 1869 | } |
| 1870 | } |
| 1871 | return out |
| 1872 | } |
| 1873 | |
| 1874 | // commandShowsContentForPath reports whether a bash command demonstrably |
| 1875 | // printed the content of the (normalized, slash-lowered) claimed path: a |
| 1876 | // content-printing program — cat/head/tail/diff/cmp or git diff/git show — |
| 1877 | // whose statically parsed argv names the path exactly or by trailing path |
| 1878 | // components. The receipt must contain exactly one simple statement; compound |
| 1879 | // statements and pipelines are rejected because unrelated output can satisfy |
| 1880 | // the aggregate OutputBytes receipt. Redirected, negated, background, or |
| 1881 | // dynamically expanded commands and summary/quiet flags that suppress the |
| 1882 | // patch body (--stat, --name-only, -q, …) are rejected too. Matching is |
| 1883 | // per-argument and exact, so reading path.bak never satisfies path. |
| 1884 | func commandShowsContentForPath(command, needle string) bool { |
| 1885 | file, err := shellparse.ParseBash(command) |
| 1886 | if err != nil || shellparse.HasHereDoc(file) || len(file.Stmts) != 1 { |
| 1887 | return false |
| 1888 | } |
| 1889 | return contentStatementShowsPath(file.Stmts[0], needle) |
| 1890 | } |
| 1891 | |
| 1892 | func contentStatementShowsPath(stmt *syntax.Stmt, needle string) bool { |
| 1893 | if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess { |
| 1894 | return false |
| 1895 | } |
| 1896 | if len(stmt.Redirs) > 0 { |
| 1897 | // Any redirect can divert the content away from the transcript. |
| 1898 | return false |
| 1899 | } |
| 1900 | switch cmd := stmt.Cmd.(type) { |
| 1901 | case *syntax.BinaryCmd: |
| 1902 | // Pipelines transform or swallow content; AND/OR lists can contribute |
| 1903 | // unrelated bytes to the aggregate receipt. Neither proves file output. |
| 1904 | return false |
| 1905 | case *syntax.CallExpr: |
| 1906 | argv := make([]string, 0, len(cmd.Args)) |
| 1907 | for _, w := range cmd.Args { |
| 1908 | f, ok := shellparse.StaticWord(w) |
| 1909 | if !ok { |
| 1910 | return false |
| 1911 | } |
| 1912 | argv = append(argv, f) |
| 1913 | } |
| 1914 | return contentArgvShowsPath(argv, needle) |
| 1915 | default: |
| 1916 | return false |
| 1917 | } |
| 1918 | } |
| 1919 | |
| 1920 | // contentSuppressingFlags turn a content command into a summary that never |
| 1921 | // shows the patch body; their presence disqualifies the receipt as evidence. |
| 1922 | var contentSuppressingFlags = map[string]bool{ |
| 1923 | "-q": true, "--quiet": true, "-s": true, "--silent": true, |
| 1924 | "--brief": true, "--no-patch": true, "--name-only": true, |
| 1925 | "--name-status": true, "--numstat": true, "--shortstat": true, |
| 1926 | "--summary": true, "--check": true, |
| 1927 | } |
| 1928 | |
| 1929 | func contentArgvShowsPath(argv []string, needle string) bool { |
| 1930 | if len(argv) == 0 { |
| 1931 | return false |
| 1932 | } |
| 1933 | rest := argv[1:] |
| 1934 | gitShow := false |
| 1935 | switch strings.ToLower(filepath.Base(argv[0])) { |
| 1936 | case "cat", "head", "tail", "diff", "cmp": |
| 1937 | case "git": |
| 1938 | if len(rest) == 0 { |
| 1939 | return false |
| 1940 | } |
| 1941 | sub := strings.ToLower(rest[0]) |
| 1942 | if sub != "diff" && sub != "show" { |
| 1943 | return false |
| 1944 | } |
| 1945 | gitShow = sub == "show" |
| 1946 | rest = rest[1:] |
| 1947 | default: |
| 1948 | return false |
| 1949 | } |
| 1950 | named := false |
| 1951 | for _, a := range rest { |
| 1952 | lower := strings.ToLower(a) |
| 1953 | if contentSuppressingFlags[lower] || strings.HasPrefix(lower, "--stat") || strings.HasPrefix(lower, "--dirstat") { |
| 1954 | return false |
| 1955 | } |
| 1956 | if gitShow { |
| 1957 | if argNamesGitRevisionPath(a, needle) { |
| 1958 | named = true |
| 1959 | } |
| 1960 | } else if argNamesPath(a, needle) { |
| 1961 | named = true |
| 1962 | } |
| 1963 | } |
| 1964 | return named |
| 1965 | } |
| 1966 | |
| 1967 | // argNamesGitRevisionPath accepts only git show's REV:path form. The ordinary |
| 1968 | // `git show REV -- path` form can print commit metadata with no file body while |
| 1969 | // still producing a non-empty aggregate receipt. |
| 1970 | func argNamesGitRevisionPath(arg, needle string) bool { |
| 1971 | tok := strings.ToLower(filepath.ToSlash(normalizePath(arg))) |
| 1972 | if tok == "" || strings.HasPrefix(tok, "-") { |
| 1973 | return false |
| 1974 | } |
| 1975 | i := strings.Index(tok, ":") |
| 1976 | if i <= 0 || i == len(tok)-1 { |
| 1977 | return false |
| 1978 | } |
| 1979 | path := tok[i+1:] |
| 1980 | return path == needle || strings.HasSuffix(path, "/"+needle) |
| 1981 | } |
| 1982 | |
| 1983 | // argNamesPath reports whether one static argv token names the claimed path: |
| 1984 | // exact after normalization, a trailing-components match of a fuller token, |
| 1985 | // or the path part of a git REV:path spec. |
| 1986 | func argNamesPath(arg, needle string) bool { |
| 1987 | tok := strings.ToLower(filepath.ToSlash(normalizePath(arg))) |
| 1988 | if tok == "" || strings.HasPrefix(tok, "-") { |
| 1989 | return false |
| 1990 | } |
| 1991 | if tok == needle || strings.HasSuffix(tok, "/"+needle) { |
| 1992 | return true |
| 1993 | } |
| 1994 | if _, after, ok := strings.Cut(tok, ":"); ok { |
| 1995 | rest := after |
| 1996 | if rest == needle || strings.HasSuffix(rest, "/"+needle) { |
| 1997 | return true |
| 1998 | } |
| 1999 | } |
| 2000 | return false |
| 2001 | } |
| 2002 | |
| 2003 | func commandReviewsChanges(command string) bool { |
| 2004 | segments, _, ok := shellparse.SplitTopLevel(command) |
| 2005 | if !ok { |
| 2006 | return false |
| 2007 | } |
| 2008 | for _, segment := range segments { |
| 2009 | normalized, safe := shellsafe.NormalizeBashSafeRedirectsForMatch(segment) |
| 2010 | if !safe { |
| 2011 | continue |
| 2012 | } |
| 2013 | fields, malformed := shellparse.StaticFields(normalized) |
| 2014 | if malformed != "" || len(fields) == 0 { |
| 2015 | continue |
| 2016 | } |
| 2017 | base := strings.ToLower(filepath.Base(fields[0])) |
| 2018 | if base == "diff" || base == "cmp" { |
| 2019 | return true |
| 2020 | } |
| 2021 | if base == "git" && len(fields) > 1 { |
| 2022 | sub := strings.ToLower(fields[1]) |
| 2023 | if sub == "diff" || sub == "status" || sub == "show" { |
| 2024 | return true |
| 2025 | } |
| 2026 | } |
| 2027 | } |
| 2028 | return false |
| 2029 | } |
| 2030 | |
| 2031 | func commandMentionsPaths(command string, wanted map[string]bool) bool { |
| 2032 | normalized := strings.ToLower(strings.ReplaceAll(command, `\`, "/")) |
| 2033 | for path := range wanted { |
| 2034 | if strings.Contains(normalized, strings.ToLower(filepath.ToSlash(path))) { |
| 2035 | return true |
| 2036 | } |
| 2037 | } |
| 2038 | return false |
| 2039 | } |
| 2040 | |
| 2041 | func isReadReceipt(name string, readOnly bool) bool { |
| 2042 | switch name { |
| 2043 | case "todo_write", "complete_step": |
| 2044 | return false |
| 2045 | default: |
| 2046 | return isReaderTool(name) || readOnly |
| 2047 | } |
| 2048 | } |
| 2049 | |
| 2050 | func isWriterTool(name string) bool { |
| 2051 | switch name { |
| 2052 | case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol": |
| 2053 | return true |
| 2054 | default: |
| 2055 | return false |
| 2056 | } |
| 2057 | } |
| 2058 | |
| 2059 | func isReaderTool(name string) bool { |
| 2060 | switch name { |
| 2061 | case "read_file", "ls", "grep": |
| 2062 | return true |
| 2063 | default: |
| 2064 | return false |
| 2065 | } |
| 2066 | } |
| 2067 | |
| 2068 | func extractPaths(fields map[string]json.RawMessage) []string { |
| 2069 | var paths []string |
| 2070 | for _, key := range []string{"path", "file_path", "notebook_path", "source_path", "destination_path"} { |
| 2071 | if s := stringField(fields, key); s != "" { |
| 2072 | paths = append(paths, s) |
| 2073 | } |
| 2074 | } |
| 2075 | for _, key := range []string{"paths", "file_paths"} { |
| 2076 | paths = append(paths, stringSliceField(fields, key)...) |
| 2077 | } |
| 2078 | return paths |
| 2079 | } |
| 2080 | |
| 2081 | func stringField(fields map[string]json.RawMessage, key string) string { |
| 2082 | raw, ok := fields[key] |
| 2083 | if !ok { |
| 2084 | return "" |
| 2085 | } |
| 2086 | var s string |
| 2087 | if err := json.Unmarshal(raw, &s); err != nil { |
| 2088 | return "" |
| 2089 | } |
| 2090 | return strings.TrimSpace(s) |
| 2091 | } |
| 2092 | |
| 2093 | // completeStepIdentity is the citation a receipt records, most stable first: a |
| 2094 | // step id survives a replan, an index survives a retitle, the title survives |
| 2095 | // neither. |
| 2096 | func completeStepIdentity(fields map[string]json.RawMessage) string { |
| 2097 | if id := stringField(fields, "step_id"); id != "" { |
| 2098 | return id |
| 2099 | } |
| 2100 | if n, ok := intField(fields, "step_index"); ok && n > 0 { |
| 2101 | return strconv.Itoa(n) |
| 2102 | } |
| 2103 | return stringField(fields, "step") |
| 2104 | } |
| 2105 | |
| 2106 | func intField(fields map[string]json.RawMessage, key string) (int, bool) { |
| 2107 | raw, ok := fields[key] |
| 2108 | if !ok { |
| 2109 | return 0, false |
| 2110 | } |
| 2111 | var n int |
| 2112 | if err := json.Unmarshal(raw, &n); err != nil { |
| 2113 | return 0, false |
| 2114 | } |
| 2115 | return n, true |
| 2116 | } |
| 2117 | |
| 2118 | func stringSliceField(fields map[string]json.RawMessage, key string) []string { |
| 2119 | raw, ok := fields[key] |
| 2120 | if !ok { |
| 2121 | return nil |
| 2122 | } |
| 2123 | var values []string |
| 2124 | if err := json.Unmarshal(raw, &values); err != nil { |
| 2125 | return nil |
| 2126 | } |
| 2127 | return values |
| 2128 | } |
| 2129 | |
| 2130 | func todoItemsField(fields map[string]json.RawMessage, key string) []TodoItem { |
| 2131 | raw, ok := fields[key] |
| 2132 | if !ok { |
| 2133 | return nil |
| 2134 | } |
| 2135 | var todos []TodoItem |
| 2136 | if err := json.Unmarshal(raw, &todos); err != nil { |
| 2137 | return nil |
| 2138 | } |
| 2139 | return normalizeTodos(todos) |
| 2140 | } |
| 2141 | |
| 2142 | // A failed complete_step can unlock todo recovery only when the payload had the |
| 2143 | // same structural proof shape Execute expects before host verification runs. |
| 2144 | func completeStepHasProof(fields map[string]json.RawMessage) bool { |
| 2145 | if strings.TrimSpace(stringField(fields, "result")) == "" { |
| 2146 | return false |
| 2147 | } |
| 2148 | raw, ok := fields["evidence"] |
| 2149 | if !ok { |
| 2150 | return false |
| 2151 | } |
| 2152 | var items []struct { |
| 2153 | Kind string `json:"kind"` |
| 2154 | Summary string `json:"summary"` |
| 2155 | Command string `json:"command"` |
| 2156 | Paths []string `json:"paths"` |
| 2157 | } |
| 2158 | if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 { |
| 2159 | return false |
| 2160 | } |
| 2161 | for _, item := range items { |
| 2162 | kind := strings.TrimSpace(item.Kind) |
| 2163 | if kind == "" || strings.TrimSpace(item.Summary) == "" { |
| 2164 | return false |
| 2165 | } |
| 2166 | switch kind { |
| 2167 | case "verification": |
| 2168 | if strings.TrimSpace(item.Command) == "" { |
| 2169 | return false |
| 2170 | } |
| 2171 | case "diff", "files": |
| 2172 | if len(normalizePaths(item.Paths)) == 0 { |
| 2173 | return false |
| 2174 | } |
| 2175 | case "manual": |
| 2176 | // Summary is enough for manual evidence. |
| 2177 | default: |
| 2178 | return false |
| 2179 | } |
| 2180 | } |
| 2181 | return true |
| 2182 | } |
| 2183 | |
| 2184 | func normalizeTodos(todos []TodoItem) []TodoItem { |
| 2185 | out := make([]TodoItem, 0, len(todos)) |
| 2186 | for _, t := range todos { |
| 2187 | t.Content = strings.TrimSpace(t.Content) |
| 2188 | t.Status = strings.TrimSpace(t.Status) |
| 2189 | t.ActiveForm = strings.TrimSpace(t.ActiveForm) |
| 2190 | out = append(out, t) |
| 2191 | } |
| 2192 | return out |
| 2193 | } |
| 2194 | |
| 2195 | func todoStatus(status string) string { |
| 2196 | status = strings.TrimSpace(status) |
| 2197 | if status == "" { |
| 2198 | return "pending" |
| 2199 | } |
| 2200 | return status |
| 2201 | } |
| 2202 | |
| 2203 | func previousTodoCompleted(index int, current TodoItem, previous []TodoItem) bool { |
| 2204 | if index >= 1 && index <= len(previous) { |
| 2205 | p := previous[index-1] |
| 2206 | if todoStatus(p.Status) == "completed" && sameTodoIdentity(current, p) { |
| 2207 | return true |
| 2208 | } |
| 2209 | } |
| 2210 | for _, p := range previous { |
| 2211 | if todoStatus(p.Status) == "completed" && sameTodoIdentity(current, p) { |
| 2212 | return true |
| 2213 | } |
| 2214 | } |
| 2215 | return false |
| 2216 | } |
| 2217 | |
| 2218 | func hasSuccessfulCompleteStepForTodo(receipts []Receipt, index int, current []TodoItem) bool { |
| 2219 | for _, r := range receipts { |
| 2220 | if !r.Success || r.ToolName != "complete_step" || strings.TrimSpace(r.Step) == "" { |
| 2221 | continue |
| 2222 | } |
| 2223 | if r.TodoStep != nil && r.TodoStep.Found { |
| 2224 | if index < 1 || index > len(current) { |
| 2225 | continue |
| 2226 | } |
| 2227 | if sameTodoMatch(current[index-1], *r.TodoStep) { |
| 2228 | return true |
| 2229 | } |
| 2230 | if !todoContentRelates(current[index-1], *r.TodoStep) { |
| 2231 | continue |
| 2232 | } |
| 2233 | } |
| 2234 | match := matchTodoStep(r.Step, current) |
| 2235 | if match.Found && match.Index == index { |
| 2236 | return true |
| 2237 | } |
| 2238 | } |
| 2239 | return false |
| 2240 | } |
| 2241 | |
| 2242 | func latestTodoStep(step string, receipts []Receipt) TodoStepMatch { |
| 2243 | for _, v := range slices.Backward(receipts) { |
| 2244 | r := v |
| 2245 | if !r.Success || r.ToolName != "todo_write" { |
| 2246 | continue |
| 2247 | } |
| 2248 | return matchTodoStep(step, r.Todos) |
| 2249 | } |
| 2250 | return TodoStepMatch{} |
| 2251 | } |
| 2252 | |
| 2253 | // matchTodoStep resolves a citation to a todo. A stable id wins outright; only |
| 2254 | // a list without ids falls back to position and wording, which a retitle or an |
| 2255 | // inserted step silently invalidates. |
| 2256 | func matchTodoStep(step string, todos []TodoItem) TodoStepMatch { |
| 2257 | if m, ok := MatchStepID(step, todos); ok { |
| 2258 | return m |
| 2259 | } |
| 2260 | if n, ok := parseStepIndex(normalizeStepText(step)); ok && n >= 1 && n <= len(todos) { |
| 2261 | t := todos[n-1] |
| 2262 | return todoMatchAt(n, t) |
| 2263 | } |
| 2264 | exact := -1 |
| 2265 | for i, t := range todos { |
| 2266 | if sameStepText(step, t.Content) || sameStepText(step, t.ActiveForm) { |
| 2267 | if exact >= 0 { |
| 2268 | return TodoStepMatch{} |
| 2269 | } |
| 2270 | exact = i |
| 2271 | } |
| 2272 | } |
| 2273 | if exact >= 0 { |
| 2274 | return todoMatchAt(exact+1, todos[exact]) |
| 2275 | } |
| 2276 | // Containment fallback for wording drift; an ambiguous citation (containing |
| 2277 | // or contained by two different todos) stays unmatched rather than guessing. |
| 2278 | norm := normalizeStepText(step) |
| 2279 | found := -1 |
| 2280 | for i, t := range todos { |
| 2281 | if stepTextContains(norm, normalizeStepText(t.Content)) || stepTextContains(norm, normalizeStepText(t.ActiveForm)) { |
| 2282 | if found >= 0 && found != i { |
| 2283 | return TodoStepMatch{} |
| 2284 | } |
| 2285 | found = i |
| 2286 | } |
| 2287 | } |
| 2288 | if found >= 0 { |
| 2289 | t := todos[found] |
| 2290 | return todoMatchAt(found+1, t) |
| 2291 | } |
| 2292 | return TodoStepMatch{} |
| 2293 | } |
| 2294 | |
| 2295 | func parseStepIndex(step string) (int, bool) { |
| 2296 | step = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(step), ".")) |
| 2297 | n, err := strconv.Atoi(step) |
| 2298 | return n, err == nil |
| 2299 | } |
| 2300 | |
| 2301 | // normalizeStepText folds the drift models introduce when citing a todo: |
| 2302 | // fullwidth ASCII forms → halfwidth (:"5 → :"5), all whitespace dropped, |
| 2303 | // case-insensitive. |
| 2304 | func normalizeStepText(s string) string { |
| 2305 | var b strings.Builder |
| 2306 | for _, r := range s { |
| 2307 | if r >= 0xFF01 && r <= 0xFF5E { |
| 2308 | r -= 0xFEE0 |
| 2309 | } |
| 2310 | b.WriteRune(r) |
| 2311 | } |
| 2312 | return strings.ToLower(strings.Join(strings.Fields(b.String()), "")) |
| 2313 | } |
| 2314 | |
| 2315 | func sameStepText(a, b string) bool { |
| 2316 | na, nb := normalizeStepText(a), normalizeStepText(b) |
| 2317 | return na != "" && na == nb |
| 2318 | } |
| 2319 | |
| 2320 | // stepTextContains: substring match between normalized texts, but only when the |
| 2321 | // shorter side is substantial enough (≥6 runes) to not match by accident. |
| 2322 | func stepTextContains(a, b string) bool { |
| 2323 | if a == "" || b == "" { |
| 2324 | return false |
| 2325 | } |
| 2326 | short := a |
| 2327 | if utf8.RuneCountInString(b) < utf8.RuneCountInString(a) { |
| 2328 | short = b |
| 2329 | } |
| 2330 | if utf8.RuneCountInString(short) < 6 { |
| 2331 | return false |
| 2332 | } |
| 2333 | return strings.Contains(a, b) || strings.Contains(b, a) |
| 2334 | } |
| 2335 | |
| 2336 | func pathSet(paths []string) map[string]bool { |
| 2337 | out := map[string]bool{} |
| 2338 | for _, p := range paths { |
| 2339 | if p != "" { |
| 2340 | out[p] = true |
| 2341 | } |
| 2342 | } |
| 2343 | return out |
| 2344 | } |
| 2345 | |
| 2346 | func normalizePaths(paths []string) []string { |
| 2347 | out := make([]string, 0, len(paths)) |
| 2348 | for _, p := range paths { |
| 2349 | p = normalizePath(p) |
| 2350 | if p != "" { |
| 2351 | out = append(out, p) |
| 2352 | } |
| 2353 | } |
| 2354 | return out |
| 2355 | } |
| 2356 | |
| 2357 | func normalizePath(p string) string { |
| 2358 | p = strings.TrimSpace(p) |
| 2359 | if p == "" { |
| 2360 | return "" |
| 2361 | } |
| 2362 | p = strings.ReplaceAll(p, `\`, `/`) |
| 2363 | p = filepath.Clean(filepath.FromSlash(p)) |
| 2364 | if runtime.GOOS == "windows" { |
| 2365 | p = strings.ToLower(p) |
| 2366 | } |
| 2367 | return p |
| 2368 | } |
| 2369 |