diff --git a/cmd/codeaf/chatv3_standing.go b/cmd/codeaf/chatv3_standing.go index 9d0aa1c36b..34beca44a0 100644 --- a/cmd/codeaf/chatv3_standing.go +++ b/cmd/codeaf/chatv3_standing.go @@ -75,7 +75,7 @@ func v3Standing(profileDir string) *session.Standing { // The person's own daily budget is what the card quotes beside the // per-run cap. A profile that cannot be read quotes nothing rather than // a figure nobody set, which is the emptiness law applied to money. - DailyRailUSD: v3StandingDailyRail(profileDir), + DailyRail: func() float64 { return v3StandingDailyRail(profileDir) }, } } diff --git a/cmd/codeaf/chatv3_standing_test.go b/cmd/codeaf/chatv3_standing_test.go index 877f44350a..b2f7604c04 100644 --- a/cmd/codeaf/chatv3_standing_test.go +++ b/cmd/codeaf/chatv3_standing_test.go @@ -32,7 +32,8 @@ func TestStandingLivesUnderTheStateRoot(t *testing.T) { func TestStandingSeamOpensTheStoreAtThatPath(t *testing.T) { root := filepath.Join(t.TempDir(), "state") t.Setenv("CODEAF_HOME", root) - seam := v3Standing(t.TempDir()) + profile := t.TempDir() + seam := v3Standing(profile) if seam == nil || seam.Store == nil { t.Fatal("the door built no standing seam") } @@ -41,8 +42,16 @@ func TestStandingSeamOpensTheStoreAtThatPath(t *testing.T) { } // The daily rail is the person's own daily budget row and never a second // number invented for this. - if seam.DailyRailUSD != v3StandingDailyRail(t.TempDir()) { - t.Fatalf("the seam quotes %v as the daily rail", seam.DailyRailUSD) + if seam.DailyRail == nil { + t.Fatal("the seam must read the current daily rail") + } + for _, budget := range []string{"5", "7"} { + if err := os.WriteFile(filepath.Join(profile, "config.json"), []byte(`{"daily_budget_usd":`+budget+`}`), 0600); err != nil { + t.Fatal(err) + } + if got, want := seam.DailyRail(), v3StandingDailyRail(profile); got != want || got == 0 { + t.Fatalf("daily rail = %v, want %v", got, want) + } } } diff --git a/docs/changes/unreleased/1596-standing-scoping-budget-activity.md b/docs/changes/unreleased/1596-standing-scoping-budget-activity.md new file mode 100644 index 0000000000..fb2ffb9613 --- /dev/null +++ b/docs/changes/unreleased/1596-standing-scoping-budget-activity.md @@ -0,0 +1,10 @@ +--- +kind: fixed +title: standing order project scoping normalization, live rail allowance words, and task activity labels +pr: 1596 +surface: [chat, engine] +invalidates: + - "Standing orders with project altitude previously failed workspace equality checks when comparing cleaned and uncleaned paths. Path cleaning is now normalized for project-scoped orders." + - "Standing proposal cards previously quoted stale default budget allowances. The engine now resolves each proposal against the current daily spend rail. Rendering quotes that snapshot without disk reads or parsing display text; explicitly named limits retain their words." + - "Standing card history rows previously displayed 'said:' for task action firings. They now display 'task:' for task executions." +--- diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index faf713c293..fbccce3eec 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -959,3 +959,11 @@ piece of the ask still to do. one: nothing that runs on its own may arm something else that runs on its own. - **Nothing is armed by a matcher.** Nothing runs because a phrase looked like a rule; every single one of these was a card you said yes to. + +## Which daily allowance does a new standing card quote? + +When you did not name a per-run limit, a new proposal quotes the current daily +budget. Changing the budget affects the next proposal, including in an already +open conversation. A card already being read keeps its proposal-time quote. +An explicit per-run limit keeps the words you supplied. Task firings in the +activity history are labelled `task:`; a spoken reminder is labelled `said:`. diff --git a/internal/session/standing_contract.go b/internal/session/standing_contract.go index 8ee1589602..3734f1c7f2 100644 --- a/internal/session/standing_contract.go +++ b/internal/session/standing_contract.go @@ -122,4 +122,8 @@ type Standing struct { // person named no per-item money. Zero means the allowance is unlimited, so // the card names the shared allowance without inventing a figure. DailyRailUSD float64 + // DailyRail reads the current allowance when a proposal is made. The + // surface quotes that snapshot without reading configuration on each frame. + // Nil retains DailyRailUSD for embedders with a fixed allowance. + DailyRail func() float64 } diff --git a/internal/session/standing_test.go b/internal/session/standing_test.go index 127d53bdf3..1aeffa111c 100644 --- a/internal/session/standing_test.go +++ b/internal/session/standing_test.go @@ -2002,3 +2002,26 @@ func standingNextUpdate(t *testing.T, lane <-chan Event) Event { } } } + +func TestStandingCostReadsCurrentRailWithoutReplacingExplicitLimits(t *testing.T) { + rail := 3.0 + a := &Agent{config: Config{Standing: &Standing{DailyRail: func() float64 { return rail }}}} + item := nightly(t.TempDir()) + if got := a.standingCostWords(item, standArguments{}); got != "shares the day's $3.00 allowance" { + t.Fatal(got) + } + rail = 7 + if got := a.standingCostWords(item, standArguments{}); got != "shares the day's $7.00 allowance" { + t.Fatal(got) + } + parsed := standArguments{CostWords: "shares the day's allowance, at most one dollar each run"} + limit := 1.0 + parsed.Rails.PerRunUSD = &limit + if got := a.standingCostWords(item, parsed); got != parsed.CostWords { + t.Fatal(got) + } + rail = 0 + if got := a.standingCostWords(item, standArguments{}); got != "shares the day's allowance" { + t.Fatal(got) + } +} diff --git a/internal/session/tools_standing.go b/internal/session/tools_standing.go index fc91dfe966..4163f37b15 100644 --- a/internal/session/tools_standing.go +++ b/internal/session/tools_standing.go @@ -857,8 +857,14 @@ func (a *Agent) standingCostWords(item standing.Item, parsed standArguments) str if parsed.Rails.PerRunUSD != nil || parsed.Rails.MaxPerDay != nil { return strings.TrimSpace(parsed.CostWords) } - if a.config.Standing != nil && a.config.Standing.DailyRailUSD > 0 { - return "shares the day's $" + strconv.FormatFloat(a.config.Standing.DailyRailUSD, 'f', 2, 64) + " allowance" + if runtime := a.config.Standing; runtime != nil { + rail := runtime.DailyRailUSD + if runtime.DailyRail != nil { + rail = runtime.DailyRail() + } + if rail > 0 { + return "shares the day's $" + strconv.FormatFloat(rail, 'f', 2, 64) + " allowance" + } } return "shares the day's allowance" } diff --git a/internal/standing/applicable_test.go b/internal/standing/applicable_test.go index f5b62c37a6..f1abb1ae9c 100644 --- a/internal/standing/applicable_test.go +++ b/internal/standing/applicable_test.go @@ -163,3 +163,17 @@ func TestAShelfLeadsWithWhatMovedLastAndNotWithWhatWasMadeLast(t *testing.T) { t.Fatalf("the shelf reads %q, wanted %q — the one touched last leads", words(got), want) } } + +func TestNormalizedProjectReachHonorsNormalizedExceptions(t *testing.T) { + item := Item{Workspace: "/repo/./project", Altitude: AltitudeProject} + if !item.AppliesTo("/repo/project/", "") { + t.Fatal("equivalent workspace excluded") + } + item.Exceptions = []Exception{{Workspace: "/repo/project/"}} + if item.AppliesTo("/repo/./project", "") { + t.Fatal("equivalent exception bypassed") + } + if (Item{Altitude: AltitudeProject}).Reaches(".", "") { + t.Fatal("empty workspace became current directory") + } +} diff --git a/internal/standing/standing.go b/internal/standing/standing.go index 02292e71f1..98a6dcc346 100644 --- a/internal/standing/standing.go +++ b/internal/standing/standing.go @@ -608,7 +608,7 @@ func (it Item) Reaches(workspace, sessionID string) bool { case AltitudeMachine: return true case AltitudeProject: - return workspace != "" && it.Workspace == workspace + return sameWorkspace(it.Workspace, workspace) case AltitudeConversation: return sessionID != "" && it.Origin.SessionID == sessionID } @@ -625,7 +625,7 @@ func (it Item) AppliesTo(workspace, sessionID string) bool { // ExceptedFrom answers whether the person excepted this item from the place. func (it Item) ExceptedFrom(workspace, sessionID string) bool { for _, ex := range it.Exceptions { - if ex.Workspace != "" && ex.Workspace == workspace { + if sameWorkspace(ex.Workspace, workspace) { return true } if ex.SessionID != "" && ex.SessionID == sessionID { @@ -1079,3 +1079,9 @@ type Watch interface { Uninstall(ctx context.Context) error Status() (WatchStatus, error) } + +// sameWorkspace gives reach and exceptions the same lexical path identity. +// An absent path never names the current directory. +func sameWorkspace(a, b string) bool { + return a != "" && b != "" && filepath.Clean(a) == filepath.Clean(b) +} diff --git a/internal/tui3/standing.go b/internal/tui3/standing.go index 0e91191dad..46e90059a2 100644 --- a/internal/tui3/standing.go +++ b/internal/tui3/standing.go @@ -454,7 +454,7 @@ func standUpdateRow(pal palette, card *standingCard, width int) string { glyph = standWaitASCII } } - line := glyph + " " + card.name + " · " + standUpdateWord(card.update, card.text) + line := glyph + " " + card.name + " · " + standActivityUpdateWord(card) return pal.dim(fit(line, width)) } diff --git a/internal/tui3/standing_history.go b/internal/tui3/standing_history.go new file mode 100644 index 0000000000..7ccce39f03 --- /dev/null +++ b/internal/tui3/standing_history.go @@ -0,0 +1,38 @@ +package tui3 + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/standing" +) + +// standActivityUpdateWord formats the activity/update tail of a standing card row, +// switching on the action kind so task executions display "task:" instead of "said:". +func standActivityUpdateWord(card *standingCard) string { + if card == nil { + return "" + } + if card.update == "fired" { + return standFiredWord(card.item.Does.Kind, card.text) + } + return standUpdateWord(card.update, card.text) +} + +// standFiredWord returns the action-specific label for a firing event. +// Delegated tasks display "task: " (or "task" if empty text); +// say actions display "said: " (or "ran" if empty text). +func standFiredWord(kind standing.ActionKind, text string) string { + text = strings.TrimSpace(text) + switch kind { + case standing.ActionTask: + if text == "" { + return "task" + } + return "task: " + text + default: + if text == "" { + return "ran" + } + return "said: " + text + } +} diff --git a/internal/tui3/standing_history_test.go b/internal/tui3/standing_history_test.go new file mode 100644 index 0000000000..7a76bf86b4 --- /dev/null +++ b/internal/tui3/standing_history_test.go @@ -0,0 +1,53 @@ +package tui3 + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/standing" +) + +func TestStandingHistoryActionKindFormatting(t *testing.T) { + cardTask := &standingCard{ + name: "nightly-sync", + update: "fired", + text: "run sync and check logs", + item: standing.Item{ + Does: standing.Action{ + Kind: standing.ActionTask, + Brief: "run sync and check logs", + }, + }, + } + if got := standActivityUpdateWord(cardTask); got != "task: run sync and check logs" { + t.Fatalf("task action fired got %q, want %q", got, "task: run sync and check logs") + } + + cardTaskEmpty := &standingCard{ + name: "nightly-sync", + update: "fired", + text: "", + item: standing.Item{ + Does: standing.Action{ + Kind: standing.ActionTask, + }, + }, + } + if got := standActivityUpdateWord(cardTaskEmpty); got != "task" { + t.Fatalf("task action fired empty text got %q, want %q", got, "task") + } + + cardSay := &standingCard{ + name: "morning-reminder", + update: "fired", + text: "check email", + item: standing.Item{ + Does: standing.Action{ + Kind: standing.ActionSay, + Say: "check email", + }, + }, + } + if got := standActivityUpdateWord(cardSay); got != "said: check email" { + t.Fatalf("say action fired got %q, want %q", got, "said: check email") + } +}