From 774edc5be33f8ed56addf91b447dc793a2eed9be Mon Sep 17 00:00:00 2001 From: hy3560 Date: Fri, 25 Sep 2026 21:27:19 +0800 Subject: [PATCH 01/69] docs(benchmarks): fix measure-cli script path --- docs/benchmarks/performance/README.md | 2 +- docs/benchmarks/performance/doc_paths_test.go | 40 +++++++++++++++++++ docs/benchmarks/performance/measure-cli.sh | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 docs/benchmarks/performance/doc_paths_test.go diff --git a/docs/benchmarks/performance/README.md b/docs/benchmarks/performance/README.md index b6fc3b6316..c7e783c1b9 100644 --- a/docs/benchmarks/performance/README.md +++ b/docs/benchmarks/performance/README.md @@ -7,7 +7,7 @@ the on-disk and one-turn tables there came from other means, and `METHOD-REVIEW. records what is and is not documented about them. ```bash -WORKDIR=$PWD docs/benchmarks/measure-cli.sh codeaf /tmp/bench-home/codeaf ./bin/codeaf chat +WORKDIR=$PWD docs/benchmarks/performance/measure-cli.sh codeaf /tmp/bench-home/codeaf ./bin/codeaf chat ``` Run it once per CLI, on the same machine, in the same working directory, within the same diff --git a/docs/benchmarks/performance/doc_paths_test.go b/docs/benchmarks/performance/doc_paths_test.go new file mode 100644 index 0000000000..51f91d5a12 --- /dev/null +++ b/docs/benchmarks/performance/doc_paths_test.go @@ -0,0 +1,40 @@ +package performance_test + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "testing" +) + +var localScriptExample = regexp.MustCompile(`(?:docs|scripts)/[A-Za-z0-9_./-]+\.sh`) + +func TestDocumentedScriptPathsResolve(t *testing.T) { + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate test source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", "..", "..")) + + for _, document := range []string{"README.md", "measure-cli.sh"} { + t.Run(document, func(t *testing.T) { + path := filepath.Join(root, "docs", "benchmarks", "performance", document) + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + matches := localScriptExample.FindAllString(string(contents), -1) + if len(matches) == 0 { + t.Fatal("no local script examples found") + } + + for _, example := range matches { + scriptPath := filepath.Join(root, filepath.FromSlash(example)) + if _, err := os.Stat(scriptPath); err != nil { + t.Errorf("documented script %q does not resolve: %v", example, err) + } + } + }) + } +} diff --git a/docs/benchmarks/performance/measure-cli.sh b/docs/benchmarks/performance/measure-cli.sh index 3a26cb5688..16c6529f1f 100755 --- a/docs/benchmarks/performance/measure-cli.sh +++ b/docs/benchmarks/performance/measure-cli.sh @@ -5,7 +5,7 @@ # # From the root of a real repository: # -# WORKDIR=$PWD docs/benchmarks/measure-cli.sh mine /tmp/bench-home/mine bin/mine chat +# WORKDIR=$PWD docs/benchmarks/performance/measure-cli.sh mine /tmp/bench-home/mine bin/mine chat # # only labels the output, the tmux session and the run; nothing about the # CLI is inferred from it. is the profile the CLI runs under — an From 32b367f2c80477b8a2c1340f66ee79c2abb40ab9 Mon Sep 17 00:00:00 2001 From: hy3560 Date: Fri, 25 Sep 2026 21:39:35 +0800 Subject: [PATCH 02/69] docs(changelog): record measure-cli path correction --- docs/changes/unreleased/1498-measure-cli-path.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/changes/unreleased/1498-measure-cli-path.md diff --git a/docs/changes/unreleased/1498-measure-cli-path.md b/docs/changes/unreleased/1498-measure-cli-path.md new file mode 100644 index 0000000000..e63715cc20 --- /dev/null +++ b/docs/changes/unreleased/1498-measure-cli-path.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: benchmark examples use the script's current path +pr: 1498 +# Optional. Which part of the repository this touches, so a reader can skip it. +# One or more of: build, chat, docs, engine, remote, resident +surface: [docs] +# Optional, and the reason this file exists. Every statement that WAS true and is +# not any more, written whole: what it was, and what it is now. A model reading +# this has to be able to check its own memory against it, so "branch names" is +# useless and "the trunk was chat-v3-task and no longer exists; work goes to dev" +# is the whole point. Leave the list empty if nothing anybody believed changed. +invalidates: + - "The examples pointed to `docs/benchmarks/measure-cli.sh`, which does not exist; use `docs/benchmarks/performance/measure-cli.sh`." +--- From a2babe2e1cd8d2167ee432e9ac9a657648ea2a48 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 14:01:13 -0400 Subject: [PATCH 03/69] fix(tui3): bind Home task target before clearing its draft --- internal/manual/chat/home.md | 5 +++ internal/tui3/home.go | 9 ++++- .../tui3/home_task_project_regression_test.go | 39 +++++++++++++++++++ internal/tui3/homeslash.go | 5 ++- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 internal/tui3/home_task_project_regression_test.go diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index f8a214b474..8d5e67a275 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1345,6 +1345,11 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | | **`closes the conversation behind home`** | `/quit` `/exit` `/q` | Closes it and says `closed · `. When it was the last conversation this terminal was holding, codeaf leaves. | +**A task typed on home uses the project shown before you press enter.** Clearing the +command from the box does not retarget it to a newer conversation in another project. +An ordinary message and `/task ` open in that same displayed project, even when +another project's engine is still running. + **The fate is never the half that gets cut.** On a narrow window the command's own description gives way first, whole, and what `enter` will do stays on the row. diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 807355d59d..1a14c97772 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3654,7 +3654,14 @@ func (a *app) homeStartWithProject(text, place string) tea.Cmd { // ruling). It is spent on the way OUT rather than on the way in, so a door that // refused leaves the pin a person set exactly where they set it. func (a *app) homeOpenAtTarget() (tea.Cmd, bool) { - where := strings.TrimSpace(a.targetWhere()) + return a.homeOpenAt(a.targetWhere()) +} + +// homeOpenAt opens the target captured while the draft is still visible. +// Consuming a slash draft rebuilds Home's rows and can move its cursor to a +// different project; that new selection must not redirect the submitted work. +func (a *app) homeOpenAt(target string) (tea.Cmd, bool) { + where := strings.TrimSpace(target) if where != "" && where != strings.TrimSpace(a.workspace) { // THE TRAY GOES WITH THE PERSON, and carrying it means taking it OUT of // the conversation being stepped aside from before the aside is stowed — diff --git a/internal/tui3/home_task_project_regression_test.go b/internal/tui3/home_task_project_regression_test.go new file mode 100644 index 0000000000..bca9569a74 --- /dev/null +++ b/internal/tui3/home_task_project_regression_test.go @@ -0,0 +1,39 @@ +package tui3 + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestHomeTaskKeepsTheProjectShownBeforeSubmitting(t *testing.T) { + for _, text := range []string{"write a shopping report", "/task write a shopping report"} { + t.Run(strings.ReplaceAll(text, " ", "_"), func(t *testing.T) { + lab := newHomeLab(t) + projectA, projectB := lab.workspace("alpha"), lab.workspace("beta") + lab.session("-tmp-alpha", "aaaa000000000001", "other active project", projectA, time.Now()) + mine := lab.session("-tmp-beta", "bbbb000000000002", "current project", projectB, time.Now().Add(-time.Hour)) + a := lab.app(mine) + t.Cleanup(a.leaveEverything) + runCmd(a.openHome()) + typeHome(a, text) + before := a.targetWhere() + if before != projectB { + t.Fatalf("fixture target before submit=%q, want %q", before, projectB) + } + var asked string + a.start = func(workspace string) (Conversation, error) { + if workspace == "" { + workspace = projectB + } + asked = workspace + return Conversation{}, errors.New("captured target before opening") + } + runCmd(a.homeEnter()) + if asked != before { + t.Fatalf("submission opened %q after showing %q", asked, before) + } + }) + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 3da5b926bd..5e1a959548 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -296,6 +296,9 @@ func (a *app) homeSlash(line string) tea.Cmd { name, rest, _ := strings.Cut(strings.TrimPrefix(line, "/"), " ") rest = strings.TrimSpace(rest) word := canonicalCommand(strings.ToLower(name)) + // Bind the submission before clearing the draft rebuilds the list. The + // folder shown beside this draft is its destination, not the next row. + target := a.targetWhere() h.box.reset() h.build() switch homeFate(word, rest) { @@ -334,7 +337,7 @@ func (a *app) homeSlash(line string) tea.Cmd { // used to compact a conversation behind the screen, and `/files` opened a // shelf over one. Both are now about the conversation this line is // opening, which is the conversation the rule above the box named. - started, opened := a.homeOpenAtTarget() + started, opened := a.homeOpenAt(target) if !opened { return nil } From 3cbffa975a998165cb9c6e7e19dddd57eb0ed866 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 14:02:46 -0400 Subject: [PATCH 04/69] docs: record Home task target binding correction --- docs/changes/unreleased/1629-home-task-target.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/changes/unreleased/1629-home-task-target.md diff --git a/docs/changes/unreleased/1629-home-task-target.md b/docs/changes/unreleased/1629-home-task-target.md new file mode 100644 index 0000000000..ce91397ddd --- /dev/null +++ b/docs/changes/unreleased/1629-home-task-target.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: Home tasks keep the project displayed before submission +pr: 1629 +surface: [chat] +invalidates: + - "A task typed on Home could follow another project's newer conversation after the command cleared its draft. Slash commands now capture the displayed project before rebuilding Home, sharing the ordinary message opening path." +--- From f6cf48e651c06f25b23ad755edd0222e97e07205 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 23:09:23 -0400 Subject: [PATCH 05/69] 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 8e039e718889559600f385d198578e8dff5aea3a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:45:59 -0400 Subject: [PATCH 06/69] 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 d5d53037a6195b19f860b2103f6281dbcc9357bd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:48:01 -0400 Subject: [PATCH 07/69] 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 c2a895da6366b5f16063d1841f04b487e36b5748 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 11:03:24 -0400 Subject: [PATCH 08/69] 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) + } } } From 2bbbffbc794c62a6e591969f996c6f0250e4dc99 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 12:33:51 -0400 Subject: [PATCH 09/69] fix(standing): preserve approved one-time action in execution handoff --- internal/manual/chat/asking-from-home.md | 2 +- internal/manual/chat/keeping-an-eye.md | 20 ++++++-- internal/session/answers.go | 22 +++++++-- internal/session/answers_test.go | 18 +++++++ internal/session/standing_test.go | 63 +++++++++++++++++------- internal/session/tools_standing.go | 31 ++++++++++-- internal/tui3/standing.go | 12 ++--- internal/tui3/standing_test.go | 13 +++++ 8 files changed, 142 insertions(+), 39 deletions(-) diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index f7d4405577..50cee1db55 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -307,7 +307,7 @@ on home says `? waiting on you` for as long as it does. **The card stays after you answer it.** It does not disappear — it settles in place, greys out, and its bottom edge carries what was decided in the same words a card in a conversation -uses: `Set it up · Mondays at 9am · set up`, `Only now, don't repeat · done now, nothing kept`, +uses: `Set it up · Mondays at 9am · set up`, `Only now, don't repeat · approved once, not scheduled`, `Change… · you asked for something different`, `not set up`, `ended · nothing was set up`. The answers go, so `1`, `3`, `0` and `o` are ordinary characters again and can be typed into a follow-up. The only card that ever diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index fbccce3eec..761d8c4dab 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -939,10 +939,13 @@ piece of the ask still to do. `the card was left unanswered — nothing was set up`. This is the opposite of a task proposal, where silence starts the work: a task is bounded work somebody is watching, and a standing item spends money at times nobody chose. -- **A "do it once" answer sets nothing up.** It answers - `Do it now as an ordinary step and report what happened. The person chose not to repeat it. Do not set it up again unless they ask. Do not investigate codeaf.` - and codeaf does the thing in front of you instead. A **one-off reminder's card - does not offer that answer** — see "Why is there no once on my reminder card". +- **A "do it once" answer approves immediate work, not a schedule.** The settled + card says `approved once, not scheduled`. That is an approval receipt, not + proof that the work has finished. The approved action, workspace, watch probe, + acceptance and spending limits are handed back to the current conversation; + it uses its ordinary tools and permissions and reports the actual result or + a blocker. No standing item is saved. A **one-off reminder's card does not + offer that answer** — see "Why is there no once on my reminder card". - **It will not set a reminder for a moment that has already passed.** The stamp is refused with the current time in it, and codeaf is asked to work it out again from that. @@ -967,3 +970,12 @@ 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:`. + +## Why does a one-time job say run it then instead of remind me? + +A one-time card that will perform work says `wants to schedule work once`. +Its approval is `Run it then ·