返回 DeepSeek-Reasonix
evidence.go
根目录 / internal / evidence / evidence.go
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
2915 lines GO