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