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