Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
}
}

Expand Down
15 changes: 12 additions & 3 deletions cmd/codeaf/chatv3_standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions docs/changes/unreleased/1596-standing-scoping-budget-activity.md
Original file line number Diff line number Diff line change
@@ -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."
---
8 changes: 8 additions & 0 deletions internal/manual/chat/keeping-an-eye.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`.
4 changes: 4 additions & 0 deletions internal/session/standing_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
23 changes: 23 additions & 0 deletions internal/session/standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 8 additions & 2 deletions internal/session/tools_standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
14 changes: 14 additions & 0 deletions internal/standing/applicable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
10 changes: 8 additions & 2 deletions internal/standing/standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion internal/tui3/standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
38 changes: 38 additions & 0 deletions internal/tui3/standing_history.go
Original file line number Diff line number Diff line change
@@ -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
}
}
53 changes: 53 additions & 0 deletions internal/tui3/standing_history_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading