From 51668184a279cdc04cafee2bca5f59ed4088ded9 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 23:09:23 -0400 Subject: [PATCH 1/4] fix(standing): project scoping path normalization, live rail allowance words, and task activity labels (#1555) --- .../1555-standing-scoping-budget-activity.md | 10 ++++ internal/standing/standing.go | 2 +- internal/tui3/standing.go | 4 +- internal/tui3/standing_history.go | 38 +++++++++++++ internal/tui3/standing_history_test.go | 53 +++++++++++++++++++ internal/tui3/standmark.go | 22 ++++++++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 docs/changes/unreleased/1555-standing-scoping-budget-activity.md create mode 100644 internal/tui3/standing_history.go create mode 100644 internal/tui3/standing_history_test.go diff --git a/docs/changes/unreleased/1555-standing-scoping-budget-activity.md b/docs/changes/unreleased/1555-standing-scoping-budget-activity.md new file mode 100644 index 0000000000..51af2e060b --- /dev/null +++ b/docs/changes/unreleased/1555-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: 1555 +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. They now resolve against the live daily spend rail." + - "Standing card history rows previously displayed 'said:' for task action firings. They now display 'task:' for task executions." +--- diff --git a/internal/standing/standing.go b/internal/standing/standing.go index 02292e71f1..89a5d8a113 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 workspace != "" && filepath.Clean(it.Workspace) == filepath.Clean(workspace) case AltitudeConversation: return sessionID != "" && it.Origin.SessionID == sessionID } diff --git a/internal/tui3/standing.go b/internal/tui3/standing.go index 0e91191dad..281ee00551 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)) } @@ -823,7 +823,7 @@ func (a *app) standBands(card *standingCard, width int) []string { } cost := "" if card.item.Spends() { - cost = card.cost + cost = a.standCardCostWords(card) if checked := standChecked(card.item.When.Kind); checked != "" { if cost == "" { cost = checked 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") + } +} diff --git a/internal/tui3/standmark.go b/internal/tui3/standmark.go index e0d8d9a910..be5f4be0b1 100644 --- a/internal/tui3/standmark.go +++ b/internal/tui3/standmark.go @@ -2,11 +2,13 @@ package tui3 import ( "context" + "strconv" "strings" "unicode" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/session" ) @@ -253,3 +255,23 @@ func (a *app) submitStandingShown(text, shown string) tea.Cmd { func standingStart(agent Agent, ctx context.Context, text string) func() (<-chan session.Event, error) { return func() (<-chan session.Event, error) { return agent.SubmitStanding(ctx, text) } } + +// standCardCostWords resolves the cost words for a standing card, reading the +// live daily rail budget from the profile rather than any stale default or cached +// figure when quoting the day's allowance. +func (a *app) standCardCostWords(card *standingCard) string { + if card == nil || !card.item.Spends() { + return "" + } + cost := card.cost + // If cost quotes the day's allowance (or shares the day's ...), resolve against live rail: + if strings.Contains(cost, "shares the day's") || strings.HasPrefix(cost, "shares the day") { + if rail, err := config.DailyBudgetUSDAt(a.profileDir); err == nil { + if rail > 0 { + return "shares the day's $" + strconv.FormatFloat(rail, 'f', 2, 64) + " allowance" + } + return "shares the day's allowance" + } + } + return cost +} From 1e87ad4a423287343fec5308620fe7e243f1406d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:45:59 -0400 Subject: [PATCH 2/4] fix(standing): resolve budget quotes in engine and honor normalized exclusions --- cmd/codeaf/chatv3_standing.go | 2 +- .../1555-standing-scoping-budget-activity.md | 4 ++-- internal/manual/chat/keeping-an-eye.md | 8 +++++++ internal/session/standing_contract.go | 4 ++++ internal/session/standing_test.go | 23 +++++++++++++++++++ internal/session/tools_standing.go | 10 ++++++-- internal/standing/applicable_test.go | 14 +++++++++++ internal/standing/standing.go | 10 ++++++-- internal/tui3/standing.go | 2 +- internal/tui3/standmark.go | 22 ------------------ 10 files changed, 69 insertions(+), 30 deletions(-) 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/docs/changes/unreleased/1555-standing-scoping-budget-activity.md b/docs/changes/unreleased/1555-standing-scoping-budget-activity.md index 51af2e060b..fb2ffb9613 100644 --- a/docs/changes/unreleased/1555-standing-scoping-budget-activity.md +++ b/docs/changes/unreleased/1555-standing-scoping-budget-activity.md @@ -1,10 +1,10 @@ --- kind: fixed title: standing order project scoping normalization, live rail allowance words, and task activity labels -pr: 1555 +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. They now resolve against the live daily spend rail." + - "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 89a5d8a113..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 != "" && filepath.Clean(it.Workspace) == filepath.Clean(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 281ee00551..46e90059a2 100644 --- a/internal/tui3/standing.go +++ b/internal/tui3/standing.go @@ -823,7 +823,7 @@ func (a *app) standBands(card *standingCard, width int) []string { } cost := "" if card.item.Spends() { - cost = a.standCardCostWords(card) + cost = card.cost if checked := standChecked(card.item.When.Kind); checked != "" { if cost == "" { cost = checked diff --git a/internal/tui3/standmark.go b/internal/tui3/standmark.go index be5f4be0b1..e0d8d9a910 100644 --- a/internal/tui3/standmark.go +++ b/internal/tui3/standmark.go @@ -2,13 +2,11 @@ package tui3 import ( "context" - "strconv" "strings" "unicode" tea "charm.land/bubbletea/v2" - "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/session" ) @@ -255,23 +253,3 @@ func (a *app) submitStandingShown(text, shown string) tea.Cmd { func standingStart(agent Agent, ctx context.Context, text string) func() (<-chan session.Event, error) { return func() (<-chan session.Event, error) { return agent.SubmitStanding(ctx, text) } } - -// standCardCostWords resolves the cost words for a standing card, reading the -// live daily rail budget from the profile rather than any stale default or cached -// figure when quoting the day's allowance. -func (a *app) standCardCostWords(card *standingCard) string { - if card == nil || !card.item.Spends() { - return "" - } - cost := card.cost - // If cost quotes the day's allowance (or shares the day's ...), resolve against live rail: - if strings.Contains(cost, "shares the day's") || strings.HasPrefix(cost, "shares the day") { - if rail, err := config.DailyBudgetUSDAt(a.profileDir); err == nil { - if rail > 0 { - return "shares the day's $" + strconv.FormatFloat(rail, 'f', 2, 64) + " allowance" - } - return "shares the day's allowance" - } - } - return cost -} From 180ace5f527be036c3aa1e5e4f6e6810938867a6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:48:01 -0400 Subject: [PATCH 3/4] docs: align change entry filename with pull request number --- ...udget-activity.md => 1596-standing-scoping-budget-activity.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{1555-standing-scoping-budget-activity.md => 1596-standing-scoping-budget-activity.md} (100%) diff --git a/docs/changes/unreleased/1555-standing-scoping-budget-activity.md b/docs/changes/unreleased/1596-standing-scoping-budget-activity.md similarity index 100% rename from docs/changes/unreleased/1555-standing-scoping-budget-activity.md rename to docs/changes/unreleased/1596-standing-scoping-budget-activity.md From a955db54ed5e4dc26287aec18c1fd6bc3ef31be7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 11:03:24 -0400 Subject: [PATCH 4/4] test(standing): verify live daily rail through launch seam --- cmd/codeaf/chatv3_standing_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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) + } } }