1595 lines
42 KiB
Go
1595 lines
42 KiB
Go
package evidence
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"unicode/utf8"
|
||
|
||
"mvdan.cc/sh/v3/syntax"
|
||
|
||
"reasonix/internal/provider"
|
||
"reasonix/internal/shellparse"
|
||
"reasonix/internal/shellsafe"
|
||
)
|
||
|
||
// TodoItem mirrors the todo_write item shape the host needs for step matching.
|
||
type TodoItem struct {
|
||
Content string `json:"content"`
|
||
Status string `json:"status"`
|
||
ActiveForm string `json:"activeForm,omitempty"`
|
||
Level int `json:"level,omitempty"`
|
||
}
|
||
|
||
// TodoStepMatch is the result of matching complete_step.step against the latest
|
||
// successful todo_write list in this turn.
|
||
type TodoStepMatch struct {
|
||
Found bool
|
||
Index int
|
||
Content string
|
||
Status string
|
||
ActiveForm string
|
||
}
|
||
|
||
// Receipt is the host-runtime record of one tool call. It stays in memory for
|
||
// the current agent turn and is not serialized into prompts or session state.
|
||
type Receipt struct {
|
||
ToolName string `json:"tool_name"`
|
||
Args json.RawMessage `json:"args,omitempty"`
|
||
Success bool `json:"success"`
|
||
Command string `json:"command,omitempty"`
|
||
Step string `json:"step,omitempty"`
|
||
StepProof bool `json:"step_proof,omitempty"`
|
||
TodoStep *TodoStepMatch `json:"todo_step,omitempty"`
|
||
Paths []string `json:"paths,omitempty"`
|
||
Read bool `json:"read,omitempty"`
|
||
Write bool `json:"write,omitempty"`
|
||
Mutation bool `json:"mutation,omitempty"`
|
||
Todos []TodoItem `json:"todos,omitempty"`
|
||
// OutputBytes is the host-observed length of the tool's (redacted, trimmed)
|
||
// output. Content-evidence checks require it to be non-zero so a command
|
||
// that printed nothing (head -n 0, >/dev/null) can never count as reading.
|
||
OutputBytes int `json:"output_bytes,omitempty"`
|
||
}
|
||
|
||
// Ledger stores the receipts available to complete_step for the current turn.
|
||
type Ledger struct {
|
||
mu sync.Mutex
|
||
receipts []Receipt
|
||
}
|
||
|
||
func NewLedger() *Ledger { return &Ledger{} }
|
||
|
||
// Reset clears receipts between user turns.
|
||
func (l *Ledger) Reset() {
|
||
if l == nil {
|
||
return
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
l.receipts = nil
|
||
}
|
||
|
||
// Record appends a receipt. Failed receipts are retained for auditability but
|
||
// are never accepted by the HasSuccessful* matchers.
|
||
func (l *Ledger) Record(r Receipt) {
|
||
if l == nil {
|
||
return
|
||
}
|
||
r.Command = strings.TrimSpace(r.Command)
|
||
r.Step = strings.TrimSpace(r.Step)
|
||
r.Paths = normalizePaths(r.Paths)
|
||
r.Todos = normalizeTodos(r.Todos)
|
||
if r.Args != nil {
|
||
cp := make(json.RawMessage, len(r.Args))
|
||
copy(cp, r.Args)
|
||
r.Args = cp
|
||
}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
if r.ToolName == "complete_step" && r.Step != "" && r.TodoStep == nil {
|
||
if match := latestTodoStep(r.Step, l.receipts); match.Found {
|
||
r.TodoStep = &match
|
||
}
|
||
}
|
||
l.receipts = append(l.receipts, r)
|
||
}
|
||
|
||
// Len returns the number of receipts recorded this turn, giving callers a
|
||
// stable index to pass to the *Since matchers.
|
||
func (l *Ledger) Len() int {
|
||
if l == nil {
|
||
return 0
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
return len(l.receipts)
|
||
}
|
||
|
||
// HasWriteOrCommandSince reports whether a successful write or command receipt
|
||
// was recorded at or after index — host-observable progress, as opposed to
|
||
// bookkeeping receipts (todo_write, complete_step, ask), which carry neither a
|
||
// write flag nor a command.
|
||
func (l *Ledger) HasWriteOrCommandSince(index int) bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
if index < 0 {
|
||
index = 0
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := index; i < len(l.receipts); i++ {
|
||
r := l.receipts[i]
|
||
if r.Success && (r.Mutation || r.Write || r.Command != "") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulCommand(command string) bool {
|
||
command = strings.TrimSpace(command)
|
||
if l == nil || command == "" {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if r.Success && r.ToolName == "bash" && CommandMatches(command, r.Command) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasFailedCommand reports whether the cited command ran this turn but exited
|
||
// non-zero — so callers can distinguish "ran and failed" from "never ran".
|
||
func (l *Ledger) HasFailedCommand(command string) bool {
|
||
command = strings.TrimSpace(command)
|
||
if l == nil || command == "" {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if !r.Success && r.ToolName == "bash" && CommandMatches(command, r.Command) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// SuccessfulCommands returns up to limit successful bash commands from this
|
||
// turn, most recent first, for self-correction hints in rejection errors.
|
||
func (l *Ledger) SuccessfulCommands(limit int) []string {
|
||
if l == nil || limit <= 0 {
|
||
return nil
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
var out []string
|
||
for i := len(l.receipts) - 1; i >= 0 && len(out) < limit; i-- {
|
||
r := l.receipts[i]
|
||
if r.Success && r.ToolName == "bash" && r.Command != "" {
|
||
out = append(out, r.Command)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TouchedPaths returns up to limit distinct paths from this turn's successful
|
||
// receipts, most recent first; writtenOnly restricts it to writer receipts.
|
||
func (l *Ledger) TouchedPaths(limit int, writtenOnly bool) []string {
|
||
if l == nil || limit <= 0 {
|
||
return nil
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
for i := len(l.receipts) - 1; i >= 0 && len(out) < limit; i-- {
|
||
r := l.receipts[i]
|
||
if !r.Success || (writtenOnly && !r.Write) || (!writtenOnly && !r.Read && !r.Write) {
|
||
continue
|
||
}
|
||
for _, p := range r.Paths {
|
||
if !seen[p] && len(out) < limit {
|
||
seen[p] = true
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// HasSuccessfulBashMentioningPaths reports whether every path appears in some
|
||
// successful bash command this turn — files created or edited through shell
|
||
// redirection (`seq … > file`) leave no reader/writer receipt, so the command
|
||
// text naming the path is the receipt.
|
||
func (l *Ledger) HasSuccessfulBashMentioningPaths(paths []string) bool {
|
||
wanted := normalizePaths(paths)
|
||
if l == nil || len(wanted) == 0 {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, p := range wanted {
|
||
needle := strings.ToLower(filepath.ToSlash(p))
|
||
found := false
|
||
for _, r := range l.receipts {
|
||
if !r.Success || r.ToolName != "bash" {
|
||
continue
|
||
}
|
||
command := strings.ToLower(strings.ReplaceAll(r.Command, `\`, `/`))
|
||
if strings.Contains(command, needle) {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulCommandAfter(command string, after int) bool {
|
||
command = strings.TrimSpace(command)
|
||
if l == nil || command == "" {
|
||
return false
|
||
}
|
||
start := after + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := start; i < len(l.receipts); i++ {
|
||
r := l.receipts[i]
|
||
if r.Success && r.ToolName == "bash" && CommandMatches(command, r.Command) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulCompleteStepAfter(after int) bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
start := after + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := start; i < len(l.receipts); i++ {
|
||
r := l.receipts[i]
|
||
if r.Success && r.ToolName == "complete_step" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulDeliverySignoffAfter reports whether a successful complete_step
|
||
// after the latest mutation cites a verification command that also succeeded
|
||
// after that mutation. complete_step already validates the cited command against
|
||
// host receipts; the additional ordering check prevents a pre-change test from
|
||
// signing off changed code in the delivery profile.
|
||
func (l *Ledger) HasSuccessfulDeliverySignoffAfter(after int) bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
start := after + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
receipts := append([]Receipt(nil), l.receipts...)
|
||
l.mu.Unlock()
|
||
for i := start; i < len(receipts); i++ {
|
||
r := receipts[i]
|
||
if !r.Success || r.ToolName != "complete_step" {
|
||
continue
|
||
}
|
||
if after >= 0 && !receiptsReviewChanges(receipts, start, i, after) {
|
||
continue
|
||
}
|
||
for _, command := range completeStepVerificationCommands(r.Args) {
|
||
if !bashCommandIsVerification(command) {
|
||
continue
|
||
}
|
||
for j := start; j < i; j++ {
|
||
candidate := receipts[j]
|
||
if candidate.Success && candidate.ToolName == "bash" && CommandMatches(command, candidate.Command) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulReviewAfter reports whether the changed result was inspected
|
||
// after the latest mutation. A read of a touched path is sufficient; git/diff
|
||
// inspection commands cover shell-driven or delegated mutations whose paths are
|
||
// not knowable to the host.
|
||
func (l *Ledger) HasSuccessfulReviewAfter(after int) bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
start := after + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
receipts := append([]Receipt(nil), l.receipts...)
|
||
l.mu.Unlock()
|
||
if after < 0 || after >= len(receipts) {
|
||
return false
|
||
}
|
||
return receiptsReviewChanges(receipts, start, len(receipts), after)
|
||
}
|
||
|
||
func receiptsReviewChanges(receipts []Receipt, start, end, mutationIndex int) bool {
|
||
if mutationIndex < 0 || mutationIndex >= len(receipts) {
|
||
return false
|
||
}
|
||
wanted := pathSet(receipts[mutationIndex].Paths)
|
||
for i := start; i < end && i < len(receipts); i++ {
|
||
r := receipts[i]
|
||
if !r.Success {
|
||
continue
|
||
}
|
||
if r.ToolName == "bash" && commandReviewsChanges(r.Command) {
|
||
return true
|
||
}
|
||
if r.ToolName == "bash" && len(wanted) > 0 && !bashMayMutate(r.Command) && commandMentionsPaths(r.Command, wanted) {
|
||
return true
|
||
}
|
||
if !r.Read {
|
||
continue
|
||
}
|
||
if len(wanted) == 0 {
|
||
return true
|
||
}
|
||
for _, p := range r.Paths {
|
||
if wanted[p] {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulTodoWrite() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if r.Success && r.ToolName == "todo_write" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulAcceptanceCriteria reports whether the current turn established
|
||
// a non-empty task list. Delivery mode uses that list as its host-observable
|
||
// acceptance contract before permitting state-changing work.
|
||
func (l *Ledger) HasSuccessfulAcceptanceCriteria() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if r.Success && r.ToolName == "todo_write" && len(r.Todos) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulTodoProgressReceipt reports whether any successful receipt in
|
||
// the turn reflects execution progress rather than read-only context gathering
|
||
// or a bare todo snapshot.
|
||
func (l *Ledger) HasSuccessfulTodoProgressReceipt() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if !r.Success || r.ToolName == "todo_write" || r.Read {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) IncompleteLatestTodos() ([]TodoStepMatch, bool) {
|
||
if l == nil {
|
||
return nil, false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := len(l.receipts) - 1; i >= 0; i-- {
|
||
r := l.receipts[i]
|
||
if !r.Success || r.ToolName != "todo_write" {
|
||
continue
|
||
}
|
||
return IncompleteTodos(r.Todos), true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// IncompleteTodos returns the items of a todo list that are not completed.
|
||
func IncompleteTodos(todos []TodoItem) []TodoStepMatch {
|
||
incomplete := make([]TodoStepMatch, 0)
|
||
for j, t := range todos {
|
||
status := todoStatus(t.Status)
|
||
if status == "completed" {
|
||
continue
|
||
}
|
||
incomplete = append(incomplete, TodoStepMatch{
|
||
Found: true,
|
||
Index: j + 1,
|
||
Content: t.Content,
|
||
Status: status,
|
||
ActiveForm: t.ActiveForm,
|
||
})
|
||
}
|
||
return incomplete
|
||
}
|
||
|
||
// MatchStep resolves a complete_step.step (number, title, or drift-tolerant
|
||
// variant) against a todo list, returning the matched item.
|
||
func MatchStep(step string, todos []TodoItem) (TodoStepMatch, bool) {
|
||
m := matchTodoStep(step, todos)
|
||
return m, m.Found
|
||
}
|
||
|
||
// HasAnySuccessfulReceipt reports whether any tool succeeded this turn — the
|
||
// signal that the turn did real work, not pure conversation.
|
||
func (l *Ledger) HasAnySuccessfulReceipt() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if r.Success {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulWorkReceipt excludes workflow bookkeeping and reports whether
|
||
// the assistant actually inspected, executed, or changed something this turn.
|
||
// Delivery mode uses it to reject text-only claims for technical tasks while
|
||
// still allowing ordinary conversation to finish without tools.
|
||
func (l *Ledger) HasSuccessfulWorkReceipt() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if !r.Success {
|
||
continue
|
||
}
|
||
switch r.ToolName {
|
||
case "ask", "todo_write", "complete_step":
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// HasSuccessfulVerificationCommand reports whether the turn ran at least one
|
||
// command classified as verification rather than inspection or mutation.
|
||
func (l *Ledger) HasSuccessfulVerificationCommand() bool {
|
||
if l == nil {
|
||
return false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if r.Success && r.ToolName == "bash" && bashCommandIsVerification(r.Command) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulWrite(paths []string) bool {
|
||
return l.hasSuccessfulPaths(paths, func(r Receipt) bool { return r.Write })
|
||
}
|
||
|
||
func (l *Ledger) HasSuccessfulReadOrWrite(paths []string) bool {
|
||
return l.hasSuccessfulPaths(paths, func(r Receipt) bool { return r.Read || r.Write })
|
||
}
|
||
|
||
func (l *Ledger) LatestSuccessfulWriteIndex(paths []string) (int, bool) {
|
||
wanted := pathSet(normalizePaths(paths))
|
||
if l == nil || len(wanted) == 0 {
|
||
return 0, false
|
||
}
|
||
latest := -1
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i, r := range l.receipts {
|
||
if !r.Success || !r.Write {
|
||
continue
|
||
}
|
||
for _, p := range r.Paths {
|
||
if wanted[p] {
|
||
latest = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return latest, latest >= 0
|
||
}
|
||
|
||
// HasSuccessfulAnchorRefreshReadAfter reports whether read_file refreshed a
|
||
// wanted path after the given receipt index. Windowed reads and grep/ls receipts
|
||
// are deliberately not enough for same-turn anchor edits: they may have observed
|
||
// a different region than the next old_string/delete_range anchor.
|
||
func (l *Ledger) HasSuccessfulAnchorRefreshReadAfter(paths []string, after int) bool {
|
||
wanted := pathSet(normalizePaths(paths))
|
||
if l == nil || len(wanted) == 0 {
|
||
return false
|
||
}
|
||
start := after + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := start; i < len(l.receipts); i++ {
|
||
r := l.receipts[i]
|
||
if !r.Success || !anchorRefreshRead(r) {
|
||
continue
|
||
}
|
||
for _, p := range r.Paths {
|
||
if wanted[p] {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func anchorRefreshRead(r Receipt) bool {
|
||
if r.ToolName != "read_file" || !r.Read {
|
||
return false
|
||
}
|
||
var fields map[string]json.RawMessage
|
||
if err := json.Unmarshal(r.Args, &fields); err != nil {
|
||
return false
|
||
}
|
||
if limit, ok := intField(fields, "limit"); ok && limit > 0 {
|
||
return false
|
||
}
|
||
if offset, ok := intField(fields, "offset"); ok && offset > 0 {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (l *Ledger) LatestSuccessfulWriterIndex() (int, bool) {
|
||
if l == nil {
|
||
return 0, false
|
||
}
|
||
latest := -1
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i, r := range l.receipts {
|
||
if r.Success && r.Write {
|
||
latest = i
|
||
}
|
||
}
|
||
return latest, latest >= 0
|
||
}
|
||
|
||
// LatestSuccessfulMutationIndex returns the most recent host-observed
|
||
// state-changing call. It includes known file writers, writer-capable delegated
|
||
// or external tools, and bash commands that are not demonstrably observational
|
||
// or verification-only.
|
||
func (l *Ledger) LatestSuccessfulMutationIndex() (int, bool) {
|
||
if l == nil {
|
||
return 0, false
|
||
}
|
||
latest := -1
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i, r := range l.receipts {
|
||
if r.Success && r.Mutation {
|
||
latest = i
|
||
}
|
||
}
|
||
return latest, latest >= 0
|
||
}
|
||
|
||
func (l *Ledger) MatchLatestTodoStep(step string) (TodoStepMatch, bool) {
|
||
step = strings.TrimSpace(step)
|
||
if l == nil || step == "" {
|
||
return TodoStepMatch{}, false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := len(l.receipts) - 1; i >= 0; i-- {
|
||
r := l.receipts[i]
|
||
if !r.Success || r.ToolName != "todo_write" {
|
||
continue
|
||
}
|
||
return matchTodoStep(step, r.Todos), true
|
||
}
|
||
return TodoStepMatch{}, false
|
||
}
|
||
|
||
// LatestTodos returns the todo list from this turn's latest successful todo_write.
|
||
func (l *Ledger) LatestTodos() ([]TodoItem, bool) {
|
||
if l == nil {
|
||
return nil, false
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for i := len(l.receipts) - 1; i >= 0; i-- {
|
||
r := l.receipts[i]
|
||
if r.Success && r.ToolName == "todo_write" {
|
||
return append([]TodoItem(nil), r.Todos...), true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// UnverifiedCompletedTodos reports current completed todos that transitioned
|
||
// from the latest prior successful todo_write receipt without a matching
|
||
// successful complete_step receipt earlier in the same turn. If this turn has no
|
||
// prior todo_write baseline, hasBaseline is false and callers should preserve
|
||
// the existing loose validation behavior.
|
||
func (l *Ledger) UnverifiedCompletedTodos(current []TodoItem) (missing []TodoStepMatch, hasBaseline bool) {
|
||
current = normalizeTodos(current)
|
||
if l == nil {
|
||
return nil, false
|
||
}
|
||
|
||
l.mu.Lock()
|
||
receipts := append([]Receipt(nil), l.receipts...)
|
||
l.mu.Unlock()
|
||
|
||
var previous []TodoItem
|
||
baseline := -1
|
||
for i := len(receipts) - 1; i >= 0; i-- {
|
||
r := receipts[i]
|
||
if !r.Success || r.ToolName != "todo_write" {
|
||
continue
|
||
}
|
||
previous = r.Todos
|
||
baseline = i
|
||
hasBaseline = true
|
||
break
|
||
}
|
||
if !hasBaseline {
|
||
return nil, false
|
||
}
|
||
|
||
for i, t := range current {
|
||
if todoStatus(t.Status) != "completed" {
|
||
continue
|
||
}
|
||
index := i + 1
|
||
if previousTodoCompleted(index, t, previous) {
|
||
continue
|
||
}
|
||
if hasSuccessfulCompleteStepForTodo(receipts, index, current) {
|
||
continue
|
||
}
|
||
if hasFailedCompleteStepRecoveryForTodo(receipts, baseline, index, current) {
|
||
continue
|
||
}
|
||
missing = append(missing, TodoStepMatch{
|
||
Found: true,
|
||
Index: index,
|
||
Content: t.Content,
|
||
Status: todoStatus(t.Status),
|
||
ActiveForm: t.ActiveForm,
|
||
})
|
||
}
|
||
return missing, true
|
||
}
|
||
|
||
func hasFailedCompleteStepRecoveryForTodo(receipts []Receipt, baseline int, index int, current []TodoItem) bool {
|
||
for i := baseline + 1; i < len(receipts); i++ {
|
||
r := receipts[i]
|
||
if r.Success || r.ToolName != "complete_step" || strings.TrimSpace(r.Step) == "" || !r.StepProof {
|
||
continue
|
||
}
|
||
if !hasSuccessfulProgressBeforeReceipt(receipts, baseline, i) {
|
||
continue
|
||
}
|
||
if r.TodoStep != nil && r.TodoStep.Found {
|
||
if index < 1 || index > len(current) {
|
||
continue
|
||
}
|
||
if sameTodoMatch(current[index-1], *r.TodoStep) {
|
||
return true
|
||
}
|
||
if !todoContentRelates(current[index-1], *r.TodoStep) {
|
||
continue
|
||
}
|
||
}
|
||
match := matchTodoStep(r.Step, current)
|
||
if match.Found && match.Index == index {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Recovery only trusts progress that happened before the failed sign-off.
|
||
// Later unrelated work must not retroactively authorize an earlier completion.
|
||
func hasSuccessfulProgressBeforeReceipt(receipts []Receipt, baseline int, before int) bool {
|
||
start := baseline + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
for i := start; i < before && i < len(receipts); i++ {
|
||
r := receipts[i]
|
||
if !r.Success || r.ToolName == "todo_write" || r.ToolName == "complete_step" || r.Read {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (l *Ledger) hasSuccessfulPaths(paths []string, accept func(Receipt) bool) bool {
|
||
wanted := pathSet(normalizePaths(paths))
|
||
if l == nil || len(wanted) == 0 {
|
||
return false
|
||
}
|
||
found := map[string]bool{}
|
||
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
for _, r := range l.receipts {
|
||
if !r.Success || !accept(r) {
|
||
continue
|
||
}
|
||
for _, p := range r.Paths {
|
||
if _, ok := wanted[p]; ok {
|
||
found[p] = true
|
||
}
|
||
}
|
||
}
|
||
return len(found) == len(wanted)
|
||
}
|
||
|
||
type contextKey struct{}
|
||
type sessionMessagesKey struct{}
|
||
|
||
func WithLedger(ctx context.Context, ledger *Ledger) context.Context {
|
||
if ledger == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, contextKey{}, ledger)
|
||
}
|
||
|
||
func FromContext(ctx context.Context) (*Ledger, bool) {
|
||
ledger, ok := ctx.Value(contextKey{}).(*Ledger)
|
||
return ledger, ok && ledger != nil
|
||
}
|
||
|
||
// WithSessionMessages attaches the full conversation history so verifyStepEvidence
|
||
// can fall back to scanning the transcript when the per-turn ledger misses a
|
||
// command (cross-turn references, non-bash tool calls, truncated command strings).
|
||
func WithSessionMessages(ctx context.Context, msgs []provider.Message) context.Context {
|
||
return context.WithValue(ctx, sessionMessagesKey{}, msgs)
|
||
}
|
||
|
||
// SessionMessagesFromContext retrieves the conversation history attached by
|
||
// WithSessionMessages.
|
||
func SessionMessagesFromContext(ctx context.Context) ([]provider.Message, bool) {
|
||
msgs, ok := ctx.Value(sessionMessagesKey{}).([]provider.Message)
|
||
return msgs, ok
|
||
}
|
||
|
||
// PathsProvenInSession reports whether every path is covered by a successful
|
||
// (non-errored) tool call somewhere in msgs — the cross-turn fallback for diff
|
||
// and files evidence, mirroring verifyCommandFromSession for the per-turn
|
||
// ledger's path receipts (which reset each turn). wantWrite restricts to writer
|
||
// tools (diff); false accepts a reader or writer (files).
|
||
func PathsProvenInSession(msgs []provider.Message, paths []string, wantWrite bool) bool {
|
||
wanted := pathSet(normalizePaths(paths))
|
||
if len(wanted) == 0 {
|
||
return false
|
||
}
|
||
failed := failedSessionCallIDs(msgs)
|
||
found := map[string]bool{}
|
||
for _, msg := range msgs {
|
||
for _, tc := range msg.ToolCalls {
|
||
if failed[tc.ID] {
|
||
continue
|
||
}
|
||
r := ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, false)
|
||
if wantWrite && !r.Write {
|
||
continue
|
||
}
|
||
if !wantWrite && !r.Read && !r.Write {
|
||
continue
|
||
}
|
||
for _, p := range normalizePaths(r.Paths) {
|
||
if _, ok := wanted[p]; ok {
|
||
found[p] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return len(found) == len(wanted)
|
||
}
|
||
|
||
func failedSessionCallIDs(msgs []provider.Message) map[string]bool {
|
||
failed := map[string]bool{}
|
||
for _, msg := range msgs {
|
||
if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(msg.Content, "error:") || strings.HasPrefix(msg.Content, "blocked:") {
|
||
failed[msg.ToolCallID] = true
|
||
}
|
||
}
|
||
return failed
|
||
}
|
||
|
||
func ReceiptFromToolCall(toolName string, args json.RawMessage, success bool, readOnly bool) Receipt {
|
||
r := Receipt{
|
||
ToolName: toolName,
|
||
Args: args,
|
||
Success: success,
|
||
Mutation: ToolCallMutates(toolName, args, readOnly),
|
||
}
|
||
|
||
var fields map[string]json.RawMessage
|
||
if err := json.Unmarshal(args, &fields); err == nil {
|
||
if toolName == "bash" {
|
||
r.Command = stringField(fields, "command")
|
||
}
|
||
if toolName == "complete_step" {
|
||
r.Step = completeStepIdentity(fields)
|
||
r.StepProof = completeStepHasProof(fields)
|
||
}
|
||
if toolName == "todo_write" {
|
||
r.Todos = todoItemsField(fields, "todos")
|
||
}
|
||
r.Paths = extractPaths(fields)
|
||
}
|
||
|
||
if isWriterTool(toolName) {
|
||
r.Write = true
|
||
} else if isReadReceipt(toolName, readOnly) {
|
||
r.Read = true
|
||
}
|
||
return r
|
||
}
|
||
|
||
// ToolCallMutates is the delivery profile's conservative state-change
|
||
// classifier. Trusted read-only tools never mutate. Meta tools that only
|
||
// delegate (task, run_skill, review, …) never mutate by themselves — real
|
||
// writes arrive via child evidence merge. Writer-capable tools do mutate,
|
||
// except for bash commands that the host can prove are inspection or
|
||
// verification commands.
|
||
func ToolCallMutates(toolName string, args json.RawMessage, readOnly bool) bool {
|
||
if readOnly {
|
||
return false
|
||
}
|
||
if IsNonMutationMetaTool(toolName) {
|
||
return false
|
||
}
|
||
switch toolName {
|
||
case "ask", "todo_write", "complete_step", "bash_output", "wait":
|
||
return false
|
||
case "bash":
|
||
var fields map[string]json.RawMessage
|
||
if err := json.Unmarshal(args, &fields); err != nil {
|
||
return true
|
||
}
|
||
return bashMayMutate(stringField(fields, "command"))
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
// ToolCallRequiresDeliveryCriteria reports whether a call begins execution
|
||
// work that needs an acceptance contract. Mutations always qualify; verification
|
||
// commands also qualify even though they are intentionally not mutations.
|
||
func ToolCallRequiresDeliveryCriteria(toolName string, args json.RawMessage, readOnly bool) bool {
|
||
if ToolCallMutates(toolName, args, readOnly) {
|
||
return true
|
||
}
|
||
if toolName != "bash" {
|
||
return false
|
||
}
|
||
var fields map[string]json.RawMessage
|
||
if err := json.Unmarshal(args, &fields); err != nil {
|
||
return true
|
||
}
|
||
return bashCommandIsVerification(stringField(fields, "command"))
|
||
}
|
||
|
||
func bashMayMutate(command string) bool {
|
||
command = strings.TrimSpace(command)
|
||
if command == "" {
|
||
return true
|
||
}
|
||
segments, _, ok := shellparse.SplitTopLevel(command)
|
||
if !ok || len(segments) == 0 {
|
||
return true
|
||
}
|
||
for _, segment := range segments {
|
||
normalized, safeRedirects := shellsafe.NormalizeBashSafeRedirectsForMatch(segment)
|
||
if !safeRedirects || shellsafe.ContainsShellSyntax(normalized) {
|
||
return true
|
||
}
|
||
fields, malformed := shellparse.StaticFields(normalized)
|
||
if malformed != "" || len(fields) == 0 {
|
||
return true
|
||
}
|
||
if bashSegmentIsVerification(fields) {
|
||
continue
|
||
}
|
||
base, sub, readOnly := shellsafe.CommandIsReadOnly(normalized)
|
||
if !readOnly || bashReadOnlyCommandWrites(base, sub, fields) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func bashCommandIsVerification(command string) bool {
|
||
command = strings.TrimSpace(command)
|
||
if command == "" {
|
||
return false
|
||
}
|
||
segments, _, ok := shellparse.SplitTopLevel(command)
|
||
if !ok || len(segments) == 0 {
|
||
return false
|
||
}
|
||
found := false
|
||
for _, segment := range segments {
|
||
normalized, safeRedirects := shellsafe.NormalizeBashSafeRedirectsForMatch(segment)
|
||
if !safeRedirects {
|
||
return false
|
||
}
|
||
fields, malformed := shellparse.StaticFields(normalized)
|
||
if malformed != "" || len(fields) == 0 {
|
||
return false
|
||
}
|
||
if bashSegmentIsVerification(fields) {
|
||
found = true
|
||
continue
|
||
}
|
||
if _, _, readOnly := shellsafe.CommandIsReadOnly(normalized); !readOnly {
|
||
return false
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
func bashSegmentIsVerification(fields []string) bool {
|
||
if len(fields) == 0 {
|
||
return false
|
||
}
|
||
base := strings.ToLower(filepath.Base(fields[0]))
|
||
args := fields[1:]
|
||
if hasCommandArg(args, "--fix", "--write", "-w", "--update", "-u") {
|
||
return false
|
||
}
|
||
switch base {
|
||
case "go":
|
||
if len(args) == 0 {
|
||
return false
|
||
}
|
||
if args[0] == "test" || args[0] == "vet" {
|
||
return true
|
||
}
|
||
return args[0] == "build" && !hasCommandArg(args, "-o")
|
||
case "git":
|
||
return len(args) > 1 && args[0] == "diff" && hasCommandArg(args[1:], "--check")
|
||
case "pytest", "py.test", "gotestsum", "staticcheck", "golangci-lint", "mypy", "tsc":
|
||
return true
|
||
case "npm", "pnpm", "yarn", "bun", "cargo":
|
||
if len(args) > 0 && hasCommandArg(args[:1], "test", "check", "lint", "clippy") {
|
||
return true
|
||
}
|
||
return len(args) > 1 && args[0] == "run" && hasCommandArg(args[1:2], "test", "check", "lint", "typecheck")
|
||
case "make", "just":
|
||
return len(args) > 0 && hasCommandArg(args[:1], "test", "check", "lint", "verify", "ci")
|
||
case "python", "python3":
|
||
return len(args) > 1 && args[0] == "-m" && hasCommandArg(args[1:2], "pytest", "unittest", "compileall")
|
||
case "dotnet":
|
||
return len(args) > 0 && args[0] == "test"
|
||
case "mvn", "mvnw", "gradle", "gradlew":
|
||
return len(args) > 0 && hasCommandArg(args, "test", "check", "verify")
|
||
}
|
||
return false
|
||
}
|
||
|
||
func bashReadOnlyCommandWrites(base, sub string, fields []string) bool {
|
||
args := fields[1:]
|
||
if sub != "" && len(args) > 0 {
|
||
args = args[1:]
|
||
}
|
||
switch base {
|
||
case "find":
|
||
return hasCommandArg(args, "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf")
|
||
case "sort":
|
||
for _, arg := range args {
|
||
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || strings.HasPrefix(arg, "-o") {
|
||
return true
|
||
}
|
||
}
|
||
case "git":
|
||
if sub == "diff" || sub == "show" || sub == "log" {
|
||
for _, arg := range args {
|
||
if arg == "--output" || strings.HasPrefix(arg, "--output=") {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
case "go":
|
||
return sub == "env" && hasCommandArg(args, "-w", "-u")
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasCommandArg(args []string, candidates ...string) bool {
|
||
for _, arg := range args {
|
||
for _, candidate := range candidates {
|
||
if strings.EqualFold(arg, candidate) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func completeStepVerificationCommands(args json.RawMessage) []string {
|
||
var p struct {
|
||
Evidence []struct {
|
||
Kind string `json:"kind"`
|
||
Command string `json:"command"`
|
||
} `json:"evidence"`
|
||
}
|
||
if err := json.Unmarshal(args, &p); err != nil {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, item := range p.Evidence {
|
||
if item.Kind == "verification" && strings.TrimSpace(item.Command) != "" {
|
||
out = append(out, strings.TrimSpace(item.Command))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// commandShowsContentForPath reports whether a bash command demonstrably
|
||
// printed the content of the (normalized, slash-lowered) claimed path: a
|
||
// content-printing program — cat/head/tail/diff/cmp or git diff/git show —
|
||
// whose statically parsed argv names the path exactly or by trailing path
|
||
// components. The receipt must contain exactly one simple statement; compound
|
||
// statements and pipelines are rejected because unrelated output can satisfy
|
||
// the aggregate OutputBytes receipt. Redirected, negated, background, or
|
||
// dynamically expanded commands and summary/quiet flags that suppress the
|
||
// patch body (--stat, --name-only, -q, …) are rejected too. Matching is
|
||
// per-argument and exact, so reading path.bak never satisfies path.
|
||
func commandShowsContentForPath(command, needle string) bool {
|
||
file, err := shellparse.ParseBash(command)
|
||
if err != nil || shellparse.HasHereDoc(file) || len(file.Stmts) != 1 {
|
||
return false
|
||
}
|
||
return contentStatementShowsPath(file.Stmts[0], needle)
|
||
}
|
||
|
||
func contentStatementShowsPath(stmt *syntax.Stmt, needle string) bool {
|
||
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess {
|
||
return false
|
||
}
|
||
if len(stmt.Redirs) > 0 {
|
||
// Any redirect can divert the content away from the transcript.
|
||
return false
|
||
}
|
||
switch cmd := stmt.Cmd.(type) {
|
||
case *syntax.BinaryCmd:
|
||
// Pipelines transform or swallow content; AND/OR lists can contribute
|
||
// unrelated bytes to the aggregate receipt. Neither proves file output.
|
||
return false
|
||
case *syntax.CallExpr:
|
||
argv := make([]string, 0, len(cmd.Args))
|
||
for _, w := range cmd.Args {
|
||
f, ok := shellparse.StaticWord(w)
|
||
if !ok {
|
||
return false
|
||
}
|
||
argv = append(argv, f)
|
||
}
|
||
return contentArgvShowsPath(argv, needle)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// contentSuppressingFlags turn a content command into a summary that never
|
||
// shows the patch body; their presence disqualifies the receipt as evidence.
|
||
var contentSuppressingFlags = map[string]bool{
|
||
"-q": true, "--quiet": true, "-s": true, "--silent": true,
|
||
"--brief": true, "--no-patch": true, "--name-only": true,
|
||
"--name-status": true, "--numstat": true, "--shortstat": true,
|
||
"--summary": true, "--check": true,
|
||
}
|
||
|
||
func contentArgvShowsPath(argv []string, needle string) bool {
|
||
if len(argv) == 0 {
|
||
return false
|
||
}
|
||
rest := argv[1:]
|
||
gitShow := false
|
||
switch strings.ToLower(filepath.Base(argv[0])) {
|
||
case "cat", "head", "tail", "diff", "cmp":
|
||
case "git":
|
||
if len(rest) == 0 {
|
||
return false
|
||
}
|
||
sub := strings.ToLower(rest[0])
|
||
if sub != "diff" && sub != "show" {
|
||
return false
|
||
}
|
||
gitShow = sub == "show"
|
||
rest = rest[1:]
|
||
default:
|
||
return false
|
||
}
|
||
named := false
|
||
for _, a := range rest {
|
||
lower := strings.ToLower(a)
|
||
if contentSuppressingFlags[lower] || strings.HasPrefix(lower, "--stat") || strings.HasPrefix(lower, "--dirstat") {
|
||
return false
|
||
}
|
||
if gitShow {
|
||
if argNamesGitRevisionPath(a, needle) {
|
||
named = true
|
||
}
|
||
} else if argNamesPath(a, needle) {
|
||
named = true
|
||
}
|
||
}
|
||
return named
|
||
}
|
||
|
||
// argNamesGitRevisionPath accepts only git show's REV:path form. The ordinary
|
||
// `git show REV -- path` form can print commit metadata with no file body while
|
||
// still producing a non-empty aggregate receipt.
|
||
func argNamesGitRevisionPath(arg, needle string) bool {
|
||
tok := strings.ToLower(filepath.ToSlash(normalizePath(arg)))
|
||
if tok == "" || strings.HasPrefix(tok, "-") {
|
||
return false
|
||
}
|
||
i := strings.Index(tok, ":")
|
||
if i <= 0 || i == len(tok)-1 {
|
||
return false
|
||
}
|
||
path := tok[i+1:]
|
||
return path == needle || strings.HasSuffix(path, "/"+needle)
|
||
}
|
||
|
||
// argNamesPath reports whether one static argv token names the claimed path:
|
||
// exact after normalization, a trailing-components match of a fuller token,
|
||
// or the path part of a git REV:path spec.
|
||
func argNamesPath(arg, needle string) bool {
|
||
tok := strings.ToLower(filepath.ToSlash(normalizePath(arg)))
|
||
if tok == "" || strings.HasPrefix(tok, "-") {
|
||
return false
|
||
}
|
||
if tok == needle || strings.HasSuffix(tok, "/"+needle) {
|
||
return true
|
||
}
|
||
if i := strings.Index(tok, ":"); i >= 0 {
|
||
rest := tok[i+1:]
|
||
if rest == needle || strings.HasSuffix(rest, "/"+needle) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func commandReviewsChanges(command string) bool {
|
||
segments, _, ok := shellparse.SplitTopLevel(command)
|
||
if !ok {
|
||
return false
|
||
}
|
||
for _, segment := range segments {
|
||
normalized, safe := shellsafe.NormalizeBashSafeRedirectsForMatch(segment)
|
||
if !safe {
|
||
continue
|
||
}
|
||
fields, malformed := shellparse.StaticFields(normalized)
|
||
if malformed != "" || len(fields) == 0 {
|
||
continue
|
||
}
|
||
base := strings.ToLower(filepath.Base(fields[0]))
|
||
if base == "diff" || base == "cmp" {
|
||
return true
|
||
}
|
||
if base == "git" && len(fields) > 1 {
|
||
sub := strings.ToLower(fields[1])
|
||
if sub == "diff" || sub == "status" || sub == "show" {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func commandMentionsPaths(command string, wanted map[string]bool) bool {
|
||
normalized := strings.ToLower(strings.ReplaceAll(command, `\`, "/"))
|
||
for path := range wanted {
|
||
if strings.Contains(normalized, strings.ToLower(filepath.ToSlash(path))) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isReadReceipt(name string, readOnly bool) bool {
|
||
switch name {
|
||
case "todo_write", "complete_step":
|
||
return false
|
||
default:
|
||
return isReaderTool(name) || readOnly
|
||
}
|
||
}
|
||
|
||
func isWriterTool(name string) bool {
|
||
switch name {
|
||
case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isReaderTool(name string) bool {
|
||
switch name {
|
||
case "read_file", "ls", "grep":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func extractPaths(fields map[string]json.RawMessage) []string {
|
||
var paths []string
|
||
for _, key := range []string{"path", "file_path", "notebook_path", "source_path", "destination_path"} {
|
||
if s := stringField(fields, key); s != "" {
|
||
paths = append(paths, s)
|
||
}
|
||
}
|
||
for _, key := range []string{"paths", "file_paths"} {
|
||
paths = append(paths, stringSliceField(fields, key)...)
|
||
}
|
||
return paths
|
||
}
|
||
|
||
func stringField(fields map[string]json.RawMessage, key string) string {
|
||
raw, ok := fields[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
var s string
|
||
if err := json.Unmarshal(raw, &s); err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func completeStepIdentity(fields map[string]json.RawMessage) string {
|
||
if n, ok := intField(fields, "step_index"); ok && n > 0 {
|
||
return strconv.Itoa(n)
|
||
}
|
||
return stringField(fields, "step")
|
||
}
|
||
|
||
func intField(fields map[string]json.RawMessage, key string) (int, bool) {
|
||
raw, ok := fields[key]
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
var n int
|
||
if err := json.Unmarshal(raw, &n); err != nil {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
func stringSliceField(fields map[string]json.RawMessage, key string) []string {
|
||
raw, ok := fields[key]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
var values []string
|
||
if err := json.Unmarshal(raw, &values); err != nil {
|
||
return nil
|
||
}
|
||
return values
|
||
}
|
||
|
||
func todoItemsField(fields map[string]json.RawMessage, key string) []TodoItem {
|
||
raw, ok := fields[key]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
var todos []TodoItem
|
||
if err := json.Unmarshal(raw, &todos); err != nil {
|
||
return nil
|
||
}
|
||
return normalizeTodos(todos)
|
||
}
|
||
|
||
// A failed complete_step can unlock todo recovery only when the payload had the
|
||
// same structural proof shape Execute expects before host verification runs.
|
||
func completeStepHasProof(fields map[string]json.RawMessage) bool {
|
||
if strings.TrimSpace(stringField(fields, "result")) == "" {
|
||
return false
|
||
}
|
||
raw, ok := fields["evidence"]
|
||
if !ok {
|
||
return false
|
||
}
|
||
var items []struct {
|
||
Kind string `json:"kind"`
|
||
Summary string `json:"summary"`
|
||
Command string `json:"command"`
|
||
Paths []string `json:"paths"`
|
||
}
|
||
if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
|
||
return false
|
||
}
|
||
for _, item := range items {
|
||
kind := strings.TrimSpace(item.Kind)
|
||
if kind == "" || strings.TrimSpace(item.Summary) == "" {
|
||
return false
|
||
}
|
||
switch kind {
|
||
case "verification":
|
||
if strings.TrimSpace(item.Command) == "" {
|
||
return false
|
||
}
|
||
case "diff", "files":
|
||
if len(normalizePaths(item.Paths)) == 0 {
|
||
return false
|
||
}
|
||
case "manual":
|
||
// Summary is enough for manual evidence.
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func normalizeTodos(todos []TodoItem) []TodoItem {
|
||
out := make([]TodoItem, 0, len(todos))
|
||
for _, t := range todos {
|
||
t.Content = strings.TrimSpace(t.Content)
|
||
t.Status = strings.TrimSpace(t.Status)
|
||
t.ActiveForm = strings.TrimSpace(t.ActiveForm)
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func todoStatus(status string) string {
|
||
status = strings.TrimSpace(status)
|
||
if status == "" {
|
||
return "pending"
|
||
}
|
||
return status
|
||
}
|
||
|
||
func previousTodoCompleted(index int, current TodoItem, previous []TodoItem) bool {
|
||
if index >= 1 && index <= len(previous) {
|
||
p := previous[index-1]
|
||
if todoStatus(p.Status) == "completed" && sameTodoIdentity(current, p) {
|
||
return true
|
||
}
|
||
}
|
||
for _, p := range previous {
|
||
if todoStatus(p.Status) == "completed" && sameTodoIdentity(current, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func sameTodoIdentity(a, b TodoItem) bool {
|
||
return sameStepText(a.Content, b.Content) || sameStepText(a.ActiveForm, b.ActiveForm)
|
||
}
|
||
|
||
func hasSuccessfulCompleteStepForTodo(receipts []Receipt, index int, current []TodoItem) bool {
|
||
for _, r := range receipts {
|
||
if !r.Success || r.ToolName != "complete_step" || strings.TrimSpace(r.Step) == "" {
|
||
continue
|
||
}
|
||
if r.TodoStep != nil && r.TodoStep.Found {
|
||
if index < 1 || index > len(current) {
|
||
continue
|
||
}
|
||
if sameTodoMatch(current[index-1], *r.TodoStep) {
|
||
return true
|
||
}
|
||
if !todoContentRelates(current[index-1], *r.TodoStep) {
|
||
continue
|
||
}
|
||
}
|
||
match := matchTodoStep(r.Step, current)
|
||
if match.Found && match.Index == index {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func latestTodoStep(step string, receipts []Receipt) TodoStepMatch {
|
||
for i := len(receipts) - 1; i >= 0; i-- {
|
||
r := receipts[i]
|
||
if !r.Success || r.ToolName != "todo_write" {
|
||
continue
|
||
}
|
||
return matchTodoStep(step, r.Todos)
|
||
}
|
||
return TodoStepMatch{}
|
||
}
|
||
|
||
func sameTodoMatch(todo TodoItem, match TodoStepMatch) bool {
|
||
return sameStepText(todo.Content, match.Content) || sameStepText(todo.ActiveForm, match.ActiveForm)
|
||
}
|
||
|
||
// todoContentRelates reports whether a todo item's preferred text has a
|
||
// recognisable semantic relationship (substring overlap) with the step match
|
||
// that was stored against a previous todo_write list. It returns true when
|
||
// the model has rephrased the same task, not swapped it for a different one.
|
||
func todoContentRelates(todo TodoItem, match TodoStepMatch) bool {
|
||
return textOverlaps(todo.Content, match.Content) ||
|
||
textOverlaps(todo.ActiveForm, match.ActiveForm)
|
||
}
|
||
|
||
func textOverlaps(a, b string) bool {
|
||
return stepTextContains(normalizeStepText(a), normalizeStepText(b))
|
||
}
|
||
|
||
func matchTodoStep(step string, todos []TodoItem) TodoStepMatch {
|
||
if n, ok := parseStepIndex(normalizeStepText(step)); ok && n >= 1 && n <= len(todos) {
|
||
t := todos[n-1]
|
||
return TodoStepMatch{Found: true, Index: n, Content: t.Content, Status: t.Status, ActiveForm: t.ActiveForm}
|
||
}
|
||
for i, t := range todos {
|
||
if sameStepText(step, t.Content) || sameStepText(step, t.ActiveForm) {
|
||
return TodoStepMatch{Found: true, Index: i + 1, Content: t.Content, Status: t.Status, ActiveForm: t.ActiveForm}
|
||
}
|
||
}
|
||
// Containment fallback for wording drift; an ambiguous citation (containing
|
||
// or contained by two different todos) stays unmatched rather than guessing.
|
||
norm := normalizeStepText(step)
|
||
found := -1
|
||
for i, t := range todos {
|
||
if stepTextContains(norm, normalizeStepText(t.Content)) || stepTextContains(norm, normalizeStepText(t.ActiveForm)) {
|
||
if found >= 0 && found != i {
|
||
return TodoStepMatch{}
|
||
}
|
||
found = i
|
||
}
|
||
}
|
||
if found >= 0 {
|
||
t := todos[found]
|
||
return TodoStepMatch{Found: true, Index: found + 1, Content: t.Content, Status: t.Status, ActiveForm: t.ActiveForm}
|
||
}
|
||
return TodoStepMatch{}
|
||
}
|
||
|
||
func parseStepIndex(step string) (int, bool) {
|
||
step = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(step), "."))
|
||
n, err := strconv.Atoi(step)
|
||
return n, err == nil
|
||
}
|
||
|
||
// normalizeStepText folds the drift models introduce when citing a todo:
|
||
// fullwidth ASCII forms → halfwidth (:"5 → :"5), all whitespace dropped,
|
||
// case-insensitive.
|
||
func normalizeStepText(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
if r >= 0xFF01 && r <= 0xFF5E {
|
||
r -= 0xFEE0
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return strings.ToLower(strings.Join(strings.Fields(b.String()), ""))
|
||
}
|
||
|
||
func sameStepText(a, b string) bool {
|
||
na, nb := normalizeStepText(a), normalizeStepText(b)
|
||
return na != "" && na == nb
|
||
}
|
||
|
||
// stepTextContains: substring match between normalized texts, but only when the
|
||
// shorter side is substantial enough (≥6 runes) to not match by accident.
|
||
func stepTextContains(a, b string) bool {
|
||
if a == "" || b == "" {
|
||
return false
|
||
}
|
||
short := a
|
||
if utf8.RuneCountInString(b) < utf8.RuneCountInString(a) {
|
||
short = b
|
||
}
|
||
if utf8.RuneCountInString(short) < 6 {
|
||
return false
|
||
}
|
||
return strings.Contains(a, b) || strings.Contains(b, a)
|
||
}
|
||
|
||
func pathSet(paths []string) map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, p := range paths {
|
||
if p != "" {
|
||
out[p] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizePaths(paths []string) []string {
|
||
out := make([]string, 0, len(paths))
|
||
for _, p := range paths {
|
||
p = normalizePath(p)
|
||
if p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizePath(p string) string {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
return ""
|
||
}
|
||
p = strings.ReplaceAll(p, `\`, `/`)
|
||
p = filepath.Clean(filepath.FromSlash(p))
|
||
if runtime.GOOS == "windows" {
|
||
p = strings.ToLower(p)
|
||
}
|
||
return p
|
||
}
|